diff --git a/ui/src/app/app.tsx b/ui/src/app/app.tsx index e38e28d91a9db..0fbf38995c9a3 100644 --- a/ui/src/app/app.tsx +++ b/ui/src/app/app.tsx @@ -5,6 +5,7 @@ import * as React from 'react'; import {Helmet} from 'react-helmet'; import {Redirect, Route, RouteComponentProps, Router, Switch} from 'react-router'; import applications from './applications'; +import applicationsets from './applicationsets'; import help from './help'; import login from './login'; import settings from './settings'; @@ -30,6 +31,8 @@ type Routes = {[path: string]: {component: React.ComponentType { ); }; + +export const ApplicationSetsDetailsAppDropdown = (props: {appName: string}) => { + const [opened, setOpened] = React.useState(false); + const [appFilter, setAppFilter] = React.useState(''); + const ctx = React.useContext(Context); + return ( + ( + <> + {props.appName} + + )}> + {opened && ( +
    +
  • + setAppFilter(e.target.value)} + ref={el => + el && + setTimeout(() => { + if (el) { + el.focus(); + } + }, 100) + } + /> +
  • + services.applicationSets.list({fields: ['items.metadata.name']})}> + {apps => + apps.items + .filter(app => { + return appFilter.length === 0 || app.metadata.name.toLowerCase().includes(appFilter.toLowerCase()); + }) + .slice(0, 100) // take top 100 results after filtering to avoid performance issues + .map(app => ( +
  • ctx.navigation.goto(`/applicationSets/${app.metadata.name}`)}> + {app.metadata.name} {app.metadata.name === props.appName && ' (current)'} +
  • + )) + } +
    +
+ )} +
+ ); +}; + diff --git a/ui/src/app/applications/components/application-details/application-details.tsx b/ui/src/app/applications/components/application-details/application-details.tsx index 75eabc52b3caa..b6d7ca43abaf7 100644 --- a/ui/src/app/applications/components/application-details/application-details.tsx +++ b/ui/src/app/applications/components/application-details/application-details.tsx @@ -11,7 +11,7 @@ import {delay, filter, map, mergeMap, repeat, retryWhen} from 'rxjs/operators'; import {DataLoader, EmptyState, ErrorNotification, ObservableQuery, Page, Paginate, Revision, Timestamp} from '../../../shared/components'; import {AppContext, ContextApis} from '../../../shared/context'; import * as appModels from '../../../shared/models'; -import {AppDetailsPreferences, AppsDetailsViewKey, AppsDetailsViewType, services} from '../../../shared/services'; +import {AbstractAppDetailsPreferences, AppDetailsPreferences, AppsDetailsViewKey, AppsDetailsViewType, services} from '../../../shared/services'; import {ApplicationConditions} from '../application-conditions/application-conditions'; import {ApplicationDeploymentHistory} from '../application-deployment-history/application-deployment-history'; @@ -27,6 +27,7 @@ import {Filters, FiltersProps} from './application-resource-filter'; import {getAppDefaultSource, urlPattern, helpTip} from '../utils'; import {ChartDetails, ResourceStatus} from '../../../shared/models'; import {ApplicationsDetailsAppDropdown} from './application-details-app-dropdown'; +import {ApplicationSetsDetailsAppDropdown} from './application-details-app-dropdown'; import {useSidebarTarget} from '../../../sidebar/sidebar'; import './application-details.scss'; @@ -77,7 +78,7 @@ export class ApplicationDetails extends React.Component(null); + private appChanged = new BehaviorSubject(null); private appNamespace: string; constructor(props: RouteComponentProps<{appnamespace: string; name: string}>) { @@ -167,6 +168,11 @@ export class ApplicationDetails extends React.Component {q => ( @@ -176,7 +182,8 @@ export class ApplicationDetails extends React.Component combineLatest([this.loadAppInfo(name, this.appNamespace), services.viewPreferences.getPreferences(), q]).pipe( - map(items => { + map(items => + { const application = items[0].application; const pref = items[1].appDetails; const params = items[2]; @@ -213,7 +220,7 @@ export class ApplicationDetails extends React.Component - {({application, tree, pref}: {application: appModels.Application; tree: appModels.ApplicationTree; pref: AppDetailsPreferences}) => { + {({application, tree, pref}: {application: appModels.AbstractApplication; tree: appModels.ApplicationTree; pref: AppDetailsPreferences}) => { tree.nodes = tree.nodes || []; const treeFilter = this.getTreeFilter(pref.resourceFilter); const setFilter = (items: string[]) => { @@ -233,7 +240,10 @@ export class ApplicationDetails extends React.Component { const statusByKey = new Map(); - application.status.resources.forEach(res => statusByKey.set(AppUtils.nodeKey(res), res)); + if (!isApplicationSet) { + const appOrig = application as appModels.Application; + appOrig.status.resources.forEach(res => statusByKey.set(AppUtils.nodeKey(res), res)); + } const resources = new Map(); tree.nodes .map(node => ({...node, orphaned: false})) @@ -322,17 +332,19 @@ export class ApplicationDetails extends React.Component { - if (!((node.parentRefs || []).length === 0 || managedKeys.has(AppUtils.nodeKey(node)))) { - node.parentRefs.forEach(parent => { - const parentId = parent.uid; - if (collapsedNodesList.indexOf(parentId) < 0) { - collapsedNodesList.push(parentId); - } - }); - } - }); + if (!isApplicationSet) { + const managedKeys = new Set(application.status.resources.map(AppUtils.nodeKey)); + nodes.forEach(node => { + if (!((node.parentRefs || []).length === 0 || managedKeys.has(AppUtils.nodeKey(node)))) { + node.parentRefs.forEach(parent => { + const parentId = parent.uid; + if (collapsedNodesList.indexOf(parentId) < 0) { + collapsedNodesList.push(parentId); + } + }); + } + }); + } collapsedNodesList.push(application.kind + '-' + application.metadata.namespace + '-' + application.metadata.name); this.setState({collapsedNodes: collapsedNodesList}); } @@ -351,7 +363,7 @@ export class ApplicationDetails extends React.Component} ], actionMenu: {items: this.getApplicationActionMenu(application, true)}, @@ -366,6 +378,8 @@ export class ApplicationDetails extends React.Component + {!isApplicationSet && ( + <> + /> + )} {prop.actionLabel}; const hasMultipleSources = app.spec.sources && app.spec.sources.length > 0; + var isApplicationSet = this.props.match.path.substring(0,15) === "/applicationset"; + if (!isApplicationSet) { return [ { iconClassName: 'fa fa-info-circle', @@ -795,6 +812,20 @@ export class ApplicationDetails extends React.Component, + action: () => this.selectNode(fullName) + }, + { + iconClassName: 'fa fa-times-circle', + title: , + action: () => this.deleteApplication() + }, + ] + } } private filterTreeNode(node: ResourceTreeNode, filterInput: FilterInput): boolean { @@ -838,49 +869,96 @@ export class ApplicationDetails extends React.Component { - return from(services.applications.get(name, appNamespace)) - .pipe( - mergeMap(app => { - const fallbackTree = { - nodes: app.status.resources.map(res => ({...res, parentRefs: [], info: [], resourceVersion: '', uid: ''})), - orphanedNodes: [], - hosts: [] - } as appModels.ApplicationTree; - return combineLatest( - merge( - from([app]), - this.appChanged.pipe(filter(item => !!item)), - AppUtils.handlePageVisibility(() => - services.applications - .watch({name, appNamespace}) - .pipe( - map(watchEvent => { - if (watchEvent.type === 'DELETED') { - this.onAppDeleted(); - } - return watchEvent.application; - }) - ) - .pipe(repeat()) - .pipe(retryWhen(errors => errors.pipe(delay(500)))) - ) - ), - merge( - from([fallbackTree]), - services.applications.resourceTree(name, appNamespace).catch(() => fallbackTree), - AppUtils.handlePageVisibility(() => - services.applications - .watchResourceTree(name, appNamespace) - .pipe(repeat()) - .pipe(retryWhen(errors => errors.pipe(delay(500)))) + private loadAppInfo(name: string, appNamespace: string): Observable<{application: appModels.AbstractApplication; tree: appModels.ApplicationTree}> { + var isApplicationSet = this.props.match.path.substring(0,15) === "/applicationset"; + + if (!isApplicationSet) { + return from(services.applications.get(name, appNamespace)) + .pipe( + mergeMap(app => { + const fallbackTree = { + nodes: app.status.resources.map(res => ({...res, parentRefs: [], info: [], resourceVersion: '', uid: ''})), + orphanedNodes: [], + hosts: [] + } as appModels.ApplicationTree; + return combineLatest( + merge( + from([app]), + this.appChanged.pipe(filter(item => !!item)), + AppUtils.handlePageVisibility(() => + services.applications + .watch({name, appNamespace}) + .pipe( + map(watchEvent => { + if (watchEvent.type === 'DELETED') { + this.onAppDeleted(); + } + return watchEvent.application; + }) + ) + .pipe(repeat()) + .pipe(retryWhen(errors => errors.pipe(delay(500)))) + ) + ), + merge( + from([fallbackTree]), + services.applications.resourceTree(name, appNamespace).catch(() => fallbackTree), + AppUtils.handlePageVisibility(() => + services.applications + .watchResourceTree(name, appNamespace) + .pipe(repeat()) + .pipe(retryWhen(errors => errors.pipe(delay(500)))) + ) ) - ) - ); - }) - ) - .pipe(filter(([application, tree]) => !!application && !!tree)) - .pipe(map(([application, tree]) => ({application, tree}))); + ); + }) + ) + .pipe(filter(([application, tree]) => !!application && !!tree)) + .pipe(map(([application, tree]) => ({application, tree}))); + } else { + return from(services.applicationSets.get(name, appNamespace)) + .pipe( + mergeMap(app => { + const fallbackTree = { + // nodes: app.status.resources.map(res => ({...res, parentRefs: [], info: [], resourceVersion: '', uid: ''})), + orphanedNodes: [], + hosts: [] + } as appModels.ApplicationTree; + return combineLatest( + merge( + from([app]), + this.appChanged.pipe(filter(item => !!item)), + AppUtils.handlePageVisibility(() => + services.applicationSets + .watch({name, appNamespace}) + .pipe( + map(watchEvent => { + if (watchEvent.type === 'DELETED') { + this.onAppDeleted(); + } + return watchEvent.applicationSet; + }) + ) + .pipe(repeat()) + .pipe(retryWhen(errors => errors.pipe(delay(500)))) + ) + ), + merge( + from([fallbackTree]), + services.applicationSets.resourceTree(name, appNamespace).catch(() => fallbackTree), + AppUtils.handlePageVisibility(() => + services.applicationSets + .watchResourceTree(name, appNamespace) + .pipe(repeat()) + .pipe(retryWhen(errors => errors.pipe(delay(500)))) + ) + ) + ); + }) + ) + .pipe(filter(([application, tree]) => !!application && !!tree)) + .pipe(map(([application, tree]) => ({application, tree}))); + } } private onAppDeleted() { @@ -897,7 +975,7 @@ export class ApplicationDetails extends React.Component(); tree.nodes.concat(tree.orphanedNodes || []).forEach(node => nodeByKey.set(AppUtils.nodeKey(node), node)); nodeByKey.set(AppUtils.nodeKey({group: 'argoproj.io', kind: application.kind, name: application.metadata.name, namespace: application.metadata.namespace}), application); diff --git a/ui/src/app/applications/components/application-resource-tree/application-resource-tree.tsx b/ui/src/app/applications/components/application-resource-tree/application-resource-tree.tsx index b5426ff1de2bf..ae28868a98d94 100644 --- a/ui/src/app/applications/components/application-resource-tree/application-resource-tree.tsx +++ b/ui/src/app/applications/components/application-resource-tree/application-resource-tree.tsx @@ -47,7 +47,7 @@ export interface ResourceTreeNode extends models.ResourceNode { } export interface ApplicationResourceTreeProps { - app: models.Application; + app: models.AbstractApplication; tree: models.ApplicationTree; useNetworkingHierarchy: boolean; nodeFilter: (node: ResourceTreeNode) => boolean; @@ -748,7 +748,10 @@ function renderResourceNode(props: ApplicationResourceTreeProps, id: string, nod } const appNode = isAppNode(node); const rootNode = !node.root; - const extLinks: string[] = props.app.status.summary.externalURLs; + var extLinks: string[] = []; + if ('summary' in props.app.status) { + extLinks = props.app.status.summary.externalURLs; + } const childCount = nodesHavingChildren.get(node.uid); return (
graph.setGraph({nodesep: 25, rankdir: 'LR', marginy: 45, marginx: -100, ranksep: 80}); graph.setDefaultEdgeLabel(() => ({})); const overridesCount = getAppOverridesCount(props.app); + var status = ""; + var health = ""; + var isApplicationSet = true; + if ('sync' in props.app.status) { + status = props.app.status.sync.status; + isApplicationSet = false; + } + if ('health' in props.app.status) { + health = props.app.status.health; + } const appNode = { kind: props.app.kind, name: props.app.metadata.name, @@ -888,8 +901,8 @@ export const ApplicationResourceTree = (props: ApplicationResourceTreeProps) => group: 'argoproj.io', version: '', children: Array(), - status: props.app.status.sync.status, - health: props.app.status.health, + status: status, // props.app.status?.sync.status, + health: health, // props.app.status?.health, uid: props.app.kind + '-' + props.app.metadata.namespace + '-' + props.app.metadata.name, info: overridesCount > 0 @@ -903,7 +916,11 @@ export const ApplicationResourceTree = (props: ApplicationResourceTreeProps) => }; const statusByKey = new Map(); - props.app.status.resources.forEach(res => statusByKey.set(nodeKey(res), res)); + var resources : models.ResourceStatus [] = []; + if (!isApplicationSet) { + resources = props.app.status.resources; + resources.forEach(res => statusByKey.set(nodeKey(res), res)); + } const nodeByKey = new Map(); props.tree.nodes .map(node => ({...node, orphaned: false})) @@ -1073,7 +1090,12 @@ export const ApplicationResourceTree = (props: ApplicationResourceTreeProps) => } } else { // Tree view - const managedKeys = new Set(props.app.status.resources.map(nodeKey)); + var managedKeys: Set; + if (!isApplicationSet) { + managedKeys = new Set(props.app.status.resources.map(nodeKey)); + } else { + managedKeys = new Set(); + } const orphanedKeys = new Set(props.tree.orphanedNodes?.map(nodeKey)); const orphans: ResourceTreeNode[] = []; let allChildNodes: ResourceTreeNode[] = []; diff --git a/ui/src/app/applications/components/application-status-panel/application-status-panel.tsx b/ui/src/app/applications/components/application-status-panel/application-status-panel.tsx index 480e68622075d..a443e231c365f 100644 --- a/ui/src/app/applications/components/application-status-panel/application-status-panel.tsx +++ b/ui/src/app/applications/components/application-status-panel/application-status-panel.tsx @@ -5,14 +5,14 @@ import {Revision} from '../../../shared/components/revision'; import {Timestamp} from '../../../shared/components/timestamp'; import * as models from '../../../shared/models'; import {services} from '../../../shared/services'; -import {ApplicationSyncWindowStatusIcon, ComparisonStatusIcon, getAppDefaultSource, getAppOperationState} from '../utils'; +import {ApplicationSyncWindowStatusIcon, AppSetHealthStatusIcon, ComparisonStatusIcon, getAppDefaultSource, getAppOperationState, getAppSetConditionCategory} from '../utils'; import {getConditionCategory, HealthStatusIcon, OperationState, syncStatusMessage, helpTip} from '../utils'; import {RevisionMetadataPanel} from './revision-metadata-panel'; import './application-status-panel.scss'; interface Props { - application: models.Application; + application: models.AbstractApplication; showDiff?: () => any; showOperation?: () => any; showConditions?: () => any; @@ -46,165 +46,219 @@ const sectionHeader = (info: SectionInfo, hasMultipleSources: boolean, onClick?: }; export const ApplicationStatusPanel = ({application, showDiff, showOperation, showConditions, showMetadataInfo}: Props) => { + var isApplicationSet = true; + if ("resources" in application.status) { + isApplicationSet = false; + } const today = new Date(); - let daysSinceLastSynchronized = 0; - const history = application.status.history || []; - if (history.length > 0) { - const deployDate = new Date(history[history.length - 1].deployedAt); - daysSinceLastSynchronized = Math.round(Math.abs((today.getTime() - deployDate.getTime()) / (24 * 60 * 60 * 1000))); - } - const cntByCategory = (application.status.conditions || []).reduce( - (map, next) => map.set(getConditionCategory(next), (map.get(getConditionCategory(next)) || 0) + 1), - new Map() - ); - const appOperationState = getAppOperationState(application); - if (application.metadata.deletionTimestamp && !appOperationState) { - showOperation = null; - } - - const infos = cntByCategory.get('info'); - const warnings = cntByCategory.get('warning'); - const errors = cntByCategory.get('error'); - const source = getAppDefaultSource(application); - const hasMultipleSources = application.spec.sources && application.spec.sources.length > 0; - return ( -
-
-
{sectionLabel({title: 'APP HEALTH', helpContent: 'The health status of your app'})}
-
- -   - {application.status.health.status} -
- {application.status.health.message &&
{application.status.health.message}
} -
-
- - {sectionHeader( - { - title: 'SYNC STATUS', - helpContent: 'Whether or not the version of your app is up to date with your repo. You may wish to sync your app if it is out-of-sync.' - }, - hasMultipleSources, - () => showMetadataInfo(application.status.sync ? application.status.sync.revision : '') - )} - {appOperationState && ( -
-
- {application.status.sync.status === models.SyncStatuses.OutOfSync ? ( - showDiff && showDiff()}> - - - ) : ( - - )} -
-
{syncStatusMessage(application)}
-
- )} -
- {application.spec.syncPolicy?.automated ? 'Auto sync is enabled.' : 'Auto sync is not enabled.'} + console.log("============================ isAppSet " + isApplicationSet); + if (!isApplicationSet) { + const history = application.status.history || []; + if (history.length > 0) { + const deployDate = new Date(history[history.length - 1].deployedAt); + daysSinceLastSynchronized = Math.round(Math.abs((today.getTime() - deployDate.getTime()) / (24 * 60 * 60 * 1000))); + } + var appOrig = application as models.Application; + const cntByCategory = (appOrig.status.conditions || []).reduce( + (map, next) => map.set(getConditionCategory(next), (map.get(getConditionCategory(next)) || 0) + 1), + new Map() + ); + const appOperationState = getAppOperationState(application); + if (application.metadata.deletionTimestamp && !appOperationState) { + showOperation = null; + } + + const infos = cntByCategory.get('info'); + const warnings = cntByCategory.get('warning'); + const errors = cntByCategory.get('error'); + const source = getAppDefaultSource(application); + const hasMultipleSources = application.spec.sources && application.spec.sources.length > 0; + + return ( +
+
+
{sectionLabel({title: 'APP HEALTH', helpContent: 'The health status of your app'})}
+
+ +   + {application.status.health.status}
- {application.status && application.status.sync && application.status.sync.revision && !application.spec.source.chart && ( -
- -
- )} - -
- {appOperationState && ( + {application.status.health.message &&
{application.status.health.message}
} +
{sectionHeader( { - title: 'LAST SYNC', - helpContent: - 'Whether or not your last app sync was successful. It has been ' + - daysSinceLastSynchronized + - ' days since last sync. Click for the status of that sync.' + title: 'SYNC STATUS', + helpContent: 'Whether or not the version of your app is up to date with your repo. You may wish to sync your app if it is out-of-sync.' }, hasMultipleSources, - () => showMetadataInfo(appOperationState.syncResult ? appOperationState.syncResult.revision : '') + () => showMetadataInfo(application.status.sync ? application.status.sync.revision : '') )} -
- showOperation && showOperation()}> - {' '} - - {appOperationState.syncResult && appOperationState.syncResult.revision && ( -
- to + {appOperationState && ( +
+
+ {application.status.sync.status === models.SyncStatuses.OutOfSync ? ( + showDiff && showDiff()}> + + + ) : ( + + )}
- )} -
- +
{syncStatusMessage(application)}
+
+ )}
- {appOperationState.phase} + {application.spec.syncPolicy?.automated ? 'Auto sync is enabled.' : 'Auto sync is not enabled.'}
- {(appOperationState.syncResult && appOperationState.syncResult.revision && ( - - )) ||
{appOperationState.message}
} - -
- )} - {application.status.conditions && ( -
- {sectionLabel({title: 'APP CONDITIONS'})} -
showConditions && showConditions()}> - {infos && ( - - {infos} Info - - )} - {warnings && ( - - {warnings} Warning{warnings !== 1 && 's'} - - )} - {errors && ( - - {errors} Error{errors !== 1 && 's'} - + {application.status && application.status.sync && application.status.sync.revision && !application.spec.source.chart && ( +
+ +
)} -
+
- )} - { - return await services.applications.getApplicationSyncWindowState(app.metadata.name, app.metadata.namespace); - }}> - {(data: models.ApplicationSyncWindowState) => ( - - {data.assignedWindows && ( -
- {sectionLabel({ - title: 'SYNC WINDOWS', + {appOperationState && ( +
+ + {sectionHeader( + { + title: 'LAST SYNC', helpContent: - 'The aggregate state of sync windows for this app. ' + - 'Red: no syncs allowed. ' + - 'Yellow: manual syncs allowed. ' + - 'Green: all syncs allowed' - })} -
- -
+ 'Whether or not your last app sync was successful. It has been ' + + daysSinceLastSynchronized + + ' days since last sync. Click for the status of that sync.' + }, + hasMultipleSources, + () => showMetadataInfo(appOperationState.syncResult ? appOperationState.syncResult.revision : '') + )} +
+ showOperation && showOperation()}> + {' '} + + {appOperationState.syncResult && appOperationState.syncResult.revision && ( +
+ to +
+ )}
- )} -
+ +
+ {appOperationState.phase} +
+ {(appOperationState.syncResult && appOperationState.syncResult.revision && ( + + )) ||
{appOperationState.message}
} + +
)} - -
- ); + {application.status.conditions && ( +
+ {sectionLabel({title: 'APP CONDITIONS'})} +
showConditions && showConditions()}> + {infos && ( + + {infos} Info + + )} + {warnings && ( + + {warnings} Warning{warnings !== 1 && 's'} + + )} + {errors && ( + + {errors} Error{errors !== 1 && 's'} + + )} +
+
+ )} + { + return await services.applications.getApplicationSyncWindowState(app.metadata.name, app.metadata.namespace); + }}> + {(data: models.ApplicationSyncWindowState) => ( + + {data.assignedWindows && ( +
+ {sectionLabel({ + title: 'SYNC WINDOWS', + helpContent: + 'The aggregate state of sync windows for this app. ' + + 'Red: no syncs allowed. ' + + 'Yellow: manual syncs allowed. ' + + 'Green: all syncs allowed' + })} +
+ +
+
+ )} +
+ )} +
+
+ ); + } else { + var appSet = application as models.ApplicationSet; + const cntByCategory = (appSet.status.conditions || []).reduce( + (map, next) => map.set(getAppSetConditionCategory(next), (map.get(getAppSetConditionCategory(next)) || 0) + 1), + new Map() + ); + const infos = cntByCategory.get('info'); + const warnings = cntByCategory.get('warning'); + const errors = cntByCategory.get('error'); + + return ( +
+
+
{sectionLabel({title: 'APP HEALTH', helpContent: 'The health status of your app'})}
+
+ +   + {appSet.status.conditions ? appSet.status.conditions[0].status : 'Unknown'} +
+ {appSet.status.conditions ? (appSet.status.conditions[0].message &&
{appSet.status.conditions[0].message}
) : (
)} +
+ {appSet.status.conditions && ( +
+ {sectionLabel({title: 'APP CONDITIONS'})} +
showConditions && showConditions()}> + {infos && ( + + {infos} Info + + )} + {warnings && ( + + {warnings} Warning{warnings !== 1 && 's'} + + )} + {errors && ( + + {errors} Error{errors !== 1 && 's'} + + )} +
+
+ )} +
+ ); + } + }; diff --git a/ui/src/app/applications/components/application-summary/application-summary.tsx b/ui/src/app/applications/components/application-summary/application-summary.tsx index 9072f650f5026..34b3093ff77c6 100644 --- a/ui/src/app/applications/components/application-summary/application-summary.tsx +++ b/ui/src/app/applications/components/application-summary/application-summary.tsx @@ -30,6 +30,7 @@ import {EditAnnotations} from './edit-annotations'; import './application-summary.scss'; import {DeepLinks} from '../../../shared/components/deep-links'; +import { Input } from 'argo-ui/v2'; function swap(array: any[], a: number, b: number) { array = array.slice(); @@ -45,8 +46,16 @@ export interface ApplicationSummaryProps { export const ApplicationSummary = (props: ApplicationSummaryProps) => { const app = JSON.parse(JSON.stringify(props.app)) as models.Application; const source = getAppDefaultSource(app); - const isHelm = source.hasOwnProperty('chart'); - const initialState = app.spec.destination.server === undefined ? 'NAME' : 'URL'; + var isHelm = false; + if (source != null) { + isHelm = source.hasOwnProperty('chart'); + } + var initialState = 'NAME'; + var isApplicationSet = true; + if ('destination' in app.spec) { + isApplicationSet = false; + initialState = app.spec.destination.server === undefined ? 'NAME' : 'URL'; + } const [destFormat, setDestFormat] = React.useState(initialState); const [changeSync, setChangeSync] = React.useState(false); @@ -54,7 +63,7 @@ export const ApplicationSummary = (props: ApplicationSummaryProps) => { const updateApp = notificationSubscriptions.withNotificationSubscriptions(props.updateApp); const hasMultipleSources = app.spec.sources && app.spec.sources.length > 0; - + console.log("****** isApplicationSet " + isApplicationSet); const attributes = [ { title: 'PROJECT', @@ -88,6 +97,7 @@ export const ApplicationSummary = (props: ApplicationSummaryProps) => { view: false, // eventually the subscription input values will be merged in 'ANNOTATIONS', therefore 'ANNOATIONS' section is responsible to represent subscription values, edit: () => }, + (!isApplicationSet && { title: 'CLUSTER', view: , @@ -148,16 +158,18 @@ export const ApplicationSummary = (props: ApplicationSummaryProps) => { }} ) - }, + }), + (!isApplicationSet && { title: 'NAMESPACE', view: , edit: (formApi: FormApi) => - }, + }), { title: 'CREATED AT', view: formatCreationTimestamp(app.metadata.creationTimestamp) }, + (!isApplicationSet && { title: 'REPO URL', view: , @@ -167,10 +179,12 @@ export const ApplicationSummary = (props: ApplicationSummaryProps) => { ) : ( ) - }, + }), + // (!isApplicationSet && { ...(isHelm ? [ - { + (!isApplicationSet && + { title: 'CHART', view: ( @@ -221,9 +235,11 @@ export const ApplicationSummary = (props: ApplicationSummaryProps) => { )} ) - } + } + ) ] : [ + (!isApplicationSet && { title: 'TARGET REVISION', view: , @@ -233,7 +249,8 @@ export const ApplicationSummary = (props: ApplicationSummaryProps) => { ) : ( ) - }, + }), + (!isApplicationSet && { title: 'PATH', view: ( @@ -248,8 +265,10 @@ export const ApplicationSummary = (props: ApplicationSummaryProps) => { ) } + ) ]), - + // }), + (!isApplicationSet && { title: 'REVISION HISTORY LIMIT', view: app.spec.revisionHistoryLimit, @@ -267,7 +286,8 @@ export const ApplicationSummary = (props: ApplicationSummaryProps) => {
) - }, + }), + (!isApplicationSet && { title: 'SYNC OPTIONS', view: ( @@ -290,7 +310,8 @@ export const ApplicationSummary = (props: ApplicationSummaryProps) => {
) - }, + }), + (!isApplicationSet && { title: 'RETRY OPTIONS', view: , @@ -299,7 +320,8 @@ export const ApplicationSummary = (props: ApplicationSummaryProps) => {
) - }, + }), + (!isApplicationSet && { title: 'STATUS', view: ( @@ -307,7 +329,8 @@ export const ApplicationSummary = (props: ApplicationSummaryProps) => { {app.status.sync.status} {syncStatusMessage(app)} ) - }, + }), + (!isApplicationSet && { title: 'HEALTH', view: ( @@ -315,7 +338,8 @@ export const ApplicationSummary = (props: ApplicationSummaryProps) => { {app.status.health.status} ) - }, + }), + (!isApplicationSet && { title: 'LINKS', view: ( @@ -323,42 +347,43 @@ export const ApplicationSummary = (props: ApplicationSummaryProps) => { {(links: models.LinksResponse) => } ) - } + }) ]; - const urls = app.status.summary.externalURLs || []; - if (urls.length > 0) { - attributes.push({ - title: 'URLs', - view: ( - - {urls - .map(item => item.split('|')) - .map((parts, i) => ( - 1 ? parts[1] : parts[0]} target='__blank'> - {parts[0]}   - - ))} - - ) - }); - } + if (!isApplicationSet) { + const urls = app.status.summary.externalURLs || []; + if (urls.length > 0) { + attributes.push({ + title: 'URLs', + view: ( + + {urls + .map(item => item.split('|')) + .map((parts, i) => ( + 1 ? parts[1] : parts[0]} target='__blank'> + {parts[0]}   + + ))} + + ) + }); + } - if ((app.status.summary.images || []).length) { - attributes.push({ - title: 'IMAGES', - view: ( -
- {(app.status.summary.images || []).sort().map(image => ( - - {image} - - ))} -
- ) - }); + if ((app.status.summary.images || []).length) { + attributes.push({ + title: 'IMAGES', + view: ( +
+ {(app.status.summary.images || []).sort().map(image => ( + + {image} + + ))} +
+ ) + }); + } } - async function setAutoSync(ctx: ContextApis, confirmationTitle: string, confirmationText: string, prune: boolean, selfHeal: boolean) { const confirmed = await ctx.popup.confirm(confirmationTitle, confirmationText); if (confirmed) { @@ -479,8 +504,8 @@ export const ApplicationSummary = (props: ApplicationSummaryProps) => { save={updateApp} validate={input => ({ 'spec.project': !input.spec.project && 'Project name is required', - 'spec.destination.server': !input.spec.destination.server && input.spec.destination.hasOwnProperty('server') && 'Cluster server is required', - 'spec.destination.name': !input.spec.destination.name && input.spec.destination.hasOwnProperty('name') && 'Cluster name is required' + 'spec.destination.server': !isApplicationSet && !input.spec.destination.server && input.spec.destination.hasOwnProperty('server') && 'Cluster server is required', + 'spec.destination.name': !isApplicationSet && !input.spec.destination.name && input.spec.destination.hasOwnProperty('name') && 'Cluster name is required' })} values={app} title={app.metadata.name.toLocaleUpperCase()} diff --git a/ui/src/app/applications/components/applications-container.tsx b/ui/src/app/applications/components/applications-container.tsx index 756f7ea22f2d8..7e88b7418c78c 100644 --- a/ui/src/app/applications/components/applications-container.tsx +++ b/ui/src/app/applications/components/applications-container.tsx @@ -2,7 +2,8 @@ import * as React from 'react'; import {Route, RouteComponentProps, Switch} from 'react-router'; import {ApplicationDetails} from './application-details/application-details'; import {ApplicationFullscreenLogs} from './application-fullscreen-logs/application-fullscreen-logs'; -import {ApplicationsList} from './applications-list/applications-list'; +import {ApplicationSetsList, ApplicationsList} from './applications-list/applications-list'; +// import { ApplicationSetsList } from './applicationsets-list/applications-list'; export const ApplicationsContainer = (props: RouteComponentProps) => ( @@ -11,5 +12,9 @@ export const ApplicationsContainer = (props: RouteComponentProps) => ( + + + {/* */} + ); diff --git a/ui/src/app/applications/components/applications-list/applications-filter.tsx b/ui/src/app/applications/components/applications-list/applications-filter.tsx index af1da7a371d0f..37a3c43721392 100644 --- a/ui/src/app/applications/components/applications-list/applications-filter.tsx +++ b/ui/src/app/applications/components/applications-list/applications-filter.tsx @@ -2,7 +2,7 @@ import {useData, Checkbox} from 'argo-ui/v2'; import * as minimatch from 'minimatch'; import * as React from 'react'; import {Context} from '../../../shared/context'; -import {Application, ApplicationDestination, Cluster, HealthStatusCode, HealthStatuses, SyncPolicy, SyncStatusCode, SyncStatuses} from '../../../shared/models'; +import {AbstractApplication, Application, ApplicationDestination, Cluster, HealthStatusCode, HealthStatuses, SyncPolicy, SyncStatusCode, SyncStatuses} from '../../../shared/models'; import {AppsListPreferences, services} from '../../../shared/services'; import {Filter, FiltersGroup} from '../filter/filter'; import * as LabelSelector from '../label-selector'; @@ -30,7 +30,7 @@ function getAutoSyncStatus(syncPolicy?: SyncPolicy) { return 'Enabled'; } -export function getFilterResults(applications: Application[], pref: AppsListPreferences): FilteredApp[] { +export function getFilterResults(applications: AbstractApplication[], pref: AppsListPreferences): FilteredApp[] { return applications.map(app => ({ ...app, filterResult: { diff --git a/ui/src/app/applications/components/applications-list/applications-list.tsx b/ui/src/app/applications/components/applications-list/applications-list.tsx index d6ddfeb343e66..2e1f758cafe0d 100644 --- a/ui/src/app/applications/components/applications-list/applications-list.tsx +++ b/ui/src/app/applications/components/applications-list/applications-list.tsx @@ -48,10 +48,22 @@ const APP_FIELDS = [ 'status.summary', 'status.resources' ]; + +const APPSET_FIELDS = [ + 'metadata.name', + 'metadata.namespace', + 'metadata.annotations', + 'metadata.labels', + 'metadata.creationTimestamp', + 'metadata.deletionTimestamp', + 'spec', +]; + const APP_LIST_FIELDS = ['metadata.resourceVersion', ...APP_FIELDS.map(field => `items.${field}`)]; +const APPSET_LIST_FIELDS = ['metadata.resourceVersion', ...APPSET_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 { +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; @@ -92,6 +104,47 @@ function loadApplications(projects: string[], appNamespace: string): Observable< ); } +function loadApplicationSets(appSetNamespace: string): Observable { + return from(services.applicationSets.list({appSetNamespace, fields: APPSET_LIST_FIELDS})).pipe( + mergeMap(applicationsList => { + const applications = applicationsList.items; + return merge( + from([applications]), + services.applicationSets + .watch({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.appSetInstanceName(item) === AppUtils.appSetInstanceName(appChange.applicationSet)); + switch (appChange.type) { + case 'DELETED': + if (index > -1) { + applications.splice(index, 1); + } + break; + default: + if (index > -1) { + applications[index] = appChange.applicationSet; + } else { + applications.unshift(appChange.applicationSet); + } + break; + } + }); + return {applications, updated: appChanges.length > 0}; + }) + ) + .pipe(filter(item => item.updated)) + .pipe(map(item => item.applications)) + ); + }) + ); +} + const ViewPref = ({children}: {children: (pref: AppsListPreferences & {page: number; search: string}) => React.ReactNode}) => ( {q => ( @@ -160,17 +213,19 @@ const ViewPref = ({children}: {children: (pref: AppsListPreferences & {page: num ); -function filterApps(applications: models.Application[], pref: AppsListPreferences, search: string): {filteredApps: models.Application[]; filterResults: FilteredApp[]} { - applications = applications.map(app => { - let isAppOfAppsPattern = false; - for (const resource of app.status.resources) { - if (resource.kind === 'Application') { - isAppOfAppsPattern = true; - break; +function filterApps(applications: models.AbstractApplication[], pref: AppsListPreferences, search: string, isApplication: boolean): {filteredApps: models.AbstractApplication[]; filterResults: FilteredApp[]} { + if (isApplication) { + applications = applications.map(app => { + let isAppOfAppsPattern = false; + for (const resource of app.status.resources) { + if (resource.kind === 'Application') { + isAppOfAppsPattern = true; + break; + } } - } - return {...app, isAppOfAppsPattern}; - }); + return {...app, isAppOfAppsPattern}; + }); + } const filterResults = getFilterResults(applications, pref); return { filterResults, @@ -188,7 +243,7 @@ function tryJsonParse(input: string) { } } -const SearchBar = (props: {content: string; ctx: ContextApis; apps: models.Application[]}) => { +const SearchBar = (props: {content: string; ctx: ContextApis; apps: models.AbstractApplication[]}) => { const {content, ctx, apps} = {...props}; const searchBar = React.useRef(null); @@ -309,7 +364,12 @@ const FlexTopBar = (props: {toolbar: Toolbar | Observable}) => { ); }; -export const ApplicationsList = (props: RouteComponentProps<{}>) => { +export const ApplicationSetsList = (props: RouteComponentProps<{}>) => { + return ApplicationsList(props, false); +} + + +export const ApplicationsList = (props: RouteComponentProps<{}>, isApplication: boolean) => { const query = new URLSearchParams(props.location.search); const appInput = tryJsonParse(query.get('new')); const syncAppsInput = tryJsonParse(query.get('syncApps')); @@ -356,7 +416,7 @@ export const ApplicationsList = (props: RouteComponentProps<{}>) => { case List: return 'Applications List'; case Tiles: - return 'Applications Tiles'; + return isApplication ? 'Applications Tiles' : 'ApplicationSets Tiles'; case Summary: return 'Applications Summary'; } @@ -381,7 +441,7 @@ export const ApplicationsList = (props: RouteComponentProps<{}>) => { AppUtils.handlePageVisibility(() => loadApplications(pref.projectsFilter, query.get('appNamespace')))} + load={() => AppUtils.handlePageVisibility(() => isApplication ? loadApplications(pref.projectsFilter, query.get('appNamespace')) : loadApplicationSets(query.get('appSetNamespace')))} loadingRenderer={() => (
@@ -389,7 +449,7 @@ export const ApplicationsList = (props: RouteComponentProps<{}>) => { )}> {(applications: models.Application[]) => { const healthBarPrefs = pref.statusBarView || ({} as HealthStatusBarPreferences); - const {filteredApps, filterResults} = filterApps(applications, pref, pref.search); + const {filteredApps, filterResults} = filterApps(applications, pref, pref.search, isApplication); return ( ) => { )} )} + {isApplication && ( + <> ) => { hide={() => ctx.navigation.goto('.', {refreshApps: null}, {replace: true})} apps={filteredApps} /> + + )}
+ {isApplication && ( {q => ( ) => { )} + )} ctx.navigation.goto('.', {new: null}, {replace: true})} diff --git a/ui/src/app/applications/components/applicationsets-list/applications-filter.tsx b/ui/src/app/applications/components/applicationsets-list/applications-filter.tsx new file mode 100644 index 0000000000000..91e62d5bad39d --- /dev/null +++ b/ui/src/app/applications/components/applicationsets-list/applications-filter.tsx @@ -0,0 +1,295 @@ +import {useData, Checkbox} from 'argo-ui/v2'; +import * as minimatch from 'minimatch'; +import * as React from 'react'; +import {Context} from '../../../shared/context'; +import { ApplicationSetConditionStatuses, Application, ApplicationSet, ApplicationDestination, Cluster, HealthStatusCode, HealthStatuses, SyncPolicy, SyncStatusCode, SyncStatuses} from '../../../shared/models'; +import {AppsListPreferences, services} from '../../../shared/services'; +import {Filter, FiltersGroup} from '../filter/filter'; +import * as LabelSelector from '../label-selector'; +import {AppSetHealthStatusIcon, ComparisonStatusIcon, HealthStatusIcon} from '../utils'; + +export interface FilterResult { + // repos: boolean; + // sync: boolean; + // autosync: boolean; + health: boolean; + // namespaces: boolean; + // clusters: boolean; + favourite: boolean; + labels: boolean; +} + +export interface FilteredAppSet extends ApplicationSet { + filterResult: FilterResult; +} + +function getAutoSyncStatus(syncPolicy?: SyncPolicy) { + if (!syncPolicy || !syncPolicy.automated) { + return 'Disabled'; + } + return 'Enabled'; +} + +export function getFilterResults(applications: ApplicationSet[], pref: AppsListPreferences): FilteredAppSet[] { + return applications.map(app => ({ + ...app, + filterResult: { + // repos: pref.reposFilter.length === 0 || pref.reposFilter.includes(getAppDefaultSource(app).repoURL), + // sync: pref.syncFilter.length === 0 || pref.syncFilter.includes(app.status.sync.status), + // autosync: pref.autoSyncFilter.length === 0 || pref.autoSyncFilter.includes(getAutoSyncStatus(app.spec.syncPolicy)), + health: pref.healthFilter.length === 0 || pref.healthFilter.includes(app.status.conditions[0].status), + // namespaces: pref.namespacesFilter.length === 0 || pref.namespacesFilter.some(ns => app.spec.destination.namespace && minimatch(app.spec.destination.namespace, ns)), + favourite: !pref.showFavorites || (pref.favoritesAppList && pref.favoritesAppList.includes(app.metadata.name)), + /* 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.spec.destination.server || name === app.spec.destination.name; + } else { + const inputMatch = filterString.match('^http.*$'); + return (inputMatch && inputMatch[0] === app.spec.destination.server) || (app.spec.destination.name && minimatch(app.spec.destination.name, filterString)); + } + }),*/ + labels: pref.labelsFilter.length === 0 || pref.labelsFilter.every(selector => LabelSelector.match(selector, app.metadata.labels)) + } + })); +} + +const optionsFrom = (options: string[], filter: string[]) => { + return options + .filter(s => filter.indexOf(s) === -1) + .map(item => { + return {label: item}; + }); +}; + +interface AppFilterProps { + apps: FilteredAppSet[]; + pref: AppsListPreferences; + onChange: (newPrefs: AppsListPreferences) => void; + children?: React.ReactNode; + collapsed?: boolean; +} + +const getCounts = (apps: FilteredAppSet[], filterType: keyof FilterResult, filter: (app: ApplicationSet) => 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: FilteredAppSet[], filterType: keyof FilterResult, filter: (app: ApplicationSet) => 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.sync.status, + Object.keys(SyncStatuses), + s => ( + + ) + )} + /> +); +*/ + +const HealthFilter = (props: AppFilterProps) => ( + props.onChange({...props.pref, healthFilter: s})} + options={getOptions( + props.apps, + 'health', + app => app.status.conditions[0].status, + Object.keys(ApplicationSetConditionStatuses), + // s => ( + // + // ) + )} + /> +); + +const LabelsFilter = (props: AppFilterProps) => { + const labels = new Map>(); + props.apps + .filter(app => app.metadata && app.metadata.labels) + .forEach(app => + Object.keys(app.metadata.labels).forEach(label => { + let values = labels.get(label); + if (!values) { + values = new Set(); + labels.set(label, values); + } + values.add(app.metadata.labels[label]); + }) + ); + const suggestions = new Array(); + Array.from(labels.entries()).forEach(([label, values]) => { + suggestions.push(label); + values.forEach(val => suggestions.push(`${label}=${val}`)); + }); + const labelOptions = suggestions.map(s => { + return {label: s}; + }); + + return props.onChange({...props.pref, labelsFilter: s})} field={true} options={labelOptions} />; +}; + +/*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: ApplicationDestination, clusterList: Cluster[]): string => { + const cluster = (clusterList || []).find(target => target.name === dest.name || target.server === dest.server); + if (!cluster) { + return dest.server || dest.name; + } + 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.spec.destination, 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.spec.destination.namespace).filter(item => !!item))), props.pref.namespacesFilter); + return ( + props.onChange({...props.pref, namespacesFilter: s})} + field={true} + options={namespaceOptions} + /> + ); +}; +*/ +const FavoriteFilter = (props: AppFilterProps) => { + const ctx = React.useContext(Context); + const onChange = (val: boolean) => { + ctx.navigation.goto('.', {showFavorites: val}, {replace: true}); + services.viewPreferences.updatePreferences({appList: {...props.pref, showFavorites: val}}); + }; + return ( +
onChange(!props.pref.showFavorites)}> + +
+ +
+
Favorites Only
+
+ ); +}; + +/*function getAutoSyncOptions(apps: FilteredApp[]) { + const counts = getCounts(apps, 'autosync', app => getAutoSyncStatus(app.spec.syncPolicy), ['Enabled', 'Disabled']); + return [ + { + label: 'Enabled', + icon: , + count: counts.get('Enabled') + }, + { + label: 'Disabled', + icon: , + count: counts.get('Disabled') + } + ]; +} + +const AutoSyncFilter = (props: AppFilterProps) => ( + props.onChange({...props.pref, autoSyncFilter: s})} + options={getAutoSyncOptions(props.apps)} + collapsed={props.collapsed || false} + /> +); + +*/ + +export const ApplicationsFilter = (props: AppFilterProps) => { + return ( + + + {/* */} + + + {/* */} + {/* */} + {/* */} + {/* */} + + ); +}; diff --git a/ui/src/app/applications/components/applicationsets-list/applications-labels.scss b/ui/src/app/applications/components/applicationsets-list/applications-labels.scss new file mode 100644 index 0000000000000..a87074c5f77dd --- /dev/null +++ b/ui/src/app/applications/components/applicationsets-list/applications-labels.scss @@ -0,0 +1,23 @@ +@import 'node_modules/argo-ui/src/styles/config'; + +.application-labels, .application-labels-tooltip { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.application-labels { + .application-labels__item { + background-color: $argo-color-gray-4; + color: $argo-color-gray-8; + border-radius: 5px; + padding: 0 2px; + margin-right: 2px; + } +} + +.application-labels-tooltip { + display: flex; + flex-direction: column; + align-items: flex-start; +} \ No newline at end of file diff --git a/ui/src/app/applications/components/applicationsets-list/applications-labels.tsx b/ui/src/app/applications/components/applicationsets-list/applications-labels.tsx new file mode 100644 index 0000000000000..d88bae8b85ff6 --- /dev/null +++ b/ui/src/app/applications/components/applicationsets-list/applications-labels.tsx @@ -0,0 +1,36 @@ +import * as React from 'react'; +import {Tooltip} from 'argo-ui'; +import {Application} from '../../../shared/models'; +import {ApplicationSet} from '../../../shared/models'; +import {getAppDefaultSource} from '../utils'; + +import './applications-labels.scss'; + +export const ApplicationSetsLabels = ({app}: {app: ApplicationSet}) => { + const labels = ( + <> + {/* {getAppDefaultSource(app).targetRevision || 'HEAD'} */} + {Object.keys(app.metadata.labels || {}).map(label => ( + {`${label}=${app.metadata.labels[label]}`} + ))} + + ); + + return ( + {labels}}> +
{labels}
+
+ ); +}; diff --git a/ui/src/app/applications/components/applicationsets-list/applications-list.scss b/ui/src/app/applications/components/applicationsets-list/applications-list.scss new file mode 100644 index 0000000000000..dac4227e1abb2 --- /dev/null +++ b/ui/src/app/applications/components/applicationsets-list/applications-list.scss @@ -0,0 +1,230 @@ +@import 'node_modules/argo-ui/src/styles/config'; +@import 'node_modules/foundation-sites/scss/util/util'; +@import 'node_modules/argo-ui/src/styles/theme'; + +.applications-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; + } + + &--health-True { + 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-False { + 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 { + border: 1px solid $argo-color-gray-4; + @include themify($themes) { + background-color: themed('light-argo-gray-2'); + } + 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 { + 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; + } + } +} \ No newline at end of file diff --git a/ui/src/app/applications/components/applicationsets-list/applications-list.tsx b/ui/src/app/applications/components/applicationsets-list/applications-list.tsx new file mode 100644 index 0000000000000..799d9ac97c021 --- /dev/null +++ b/ui/src/app/applications/components/applicationsets-list/applications-list.tsx @@ -0,0 +1,678 @@ +// import {Autocomplete, ErrorNotification, MockupList, NotificationType, SlidingPanel, Toolbar, Tooltip} from 'argo-ui'; +import {Autocomplete, MockupList, SlidingPanel, Toolbar, Tooltip} from 'argo-ui'; +import * as classNames from 'classnames'; +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, Spinner} from '../../../shared/components'; +import {AddAuthToToolbar, ClusterCtx, DataLoader, EmptyState, ObservableQuery, Page, Paginate, Query} from '../../../shared/components'; +import {AuthSettingsCtx, Consumer, Context, ContextApis} from '../../../shared/context'; +import * as models from '../../../shared/models'; +import {AppsListViewKey, AppsListPreferences, AppsListViewType, HealthStatusBarPreferences, services} from '../../../shared/services'; +// import {ApplicationCreatePanel} from '../application-create-panel/application-create-panel'; +// import {ApplicationSyncPanel} from '../application-sync-panel/application-sync-panel'; +// import {ApplicationsSyncPanel} from '../applications-sync-panel/applications-sync-panel'; +import * as AppUtils from './utils'; +import {ApplicationsFilter, FilteredAppSet, getFilterResults} from './applications-filter'; +import {ApplicationsStatusBar} from './applications-status-bar'; +import {ApplicationSetsSummary} from './applications-summary'; +import {ApplicationSetsTable} from './applications-table'; +import {ApplicationSetTiles} from './applications-tiles'; +// import {ApplicationsRefreshPanel} from '../applications-refresh-panel/applications-refresh-panel'; +import {useSidebarTarget, useAppSetSidebarTarget} from '../../../sidebar/sidebar'; + +import './applications-list.scss'; +import './flex-top-bar.scss'; + +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 APPSET_FIELDS = [ + 'metadata.name', + 'metadata.namespace', + 'metadata.annotations', + 'metadata.labels', + 'metadata.creationTimestamp', + 'metadata.deletionTimestamp', + 'spec', +]; + +const APP_LIST_FIELDS = ['metadata.resourceVersion', ...APPSET_FIELDS.map(field => `items.${field}`)]; +const APPSET_LIST_FIELDS = ['metadata.resourceVersion', ...APPSET_FIELDS.map(field => `items.${field}`)]; +const APP_WATCH_FIELDS = ['result.type', ...APPSET_FIELDS.map(field => `result.application.${field}`)]; + +function loadApplicationSets(appSetNamespace: string): Observable { + return from(services.applicationSets.list({appSetNamespace, fields: APPSET_LIST_FIELDS})).pipe( + mergeMap(applicationsList => { + const applications = applicationsList.items; + return merge( + from([applications]), + services.applicationSets + .watch({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.appSetInstanceName(item) === AppUtils.appSetInstanceName(appChange.applicationSet)); + switch (appChange.type) { + case 'DELETED': + if (index > -1) { + applications.splice(index, 1); + } + break; + default: + if (index > -1) { + applications[index] = appChange.applicationSet; + } else { + applications.unshift(appChange.applicationSet); + } + break; + } + }); + return {applications, updated: appChanges.length > 0}; + }) + ) + .pipe(filter(item => item.updated)) + .pipe(map(item => item.applications)) + ); + }) + ); +} + +const ViewPref = ({children}: {children: (pref: AppsListPreferences & {page: number; search: string}) => React.ReactNode}) => ( + + {q => ( + + combineLatest([services.viewPreferences.getPreferences().pipe(map(item => item.appList)), q]).pipe( + map(items => { + const params = items[1]; + const viewPref: AppsListPreferences = {...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('autoSync') != null) { + viewPref.autoSyncFilter = params + .get('autoSync') + .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('showFavorites') != null) { + viewPref.showFavorites = params.get('showFavorites') === 'true'; + } + if (params.get('view') != null) { + viewPref.view = params.get('view') as AppsListViewType; + } + if (params.get('labels') != null) { + viewPref.labelsFilter = params + .get('labels') + .split(',') + .map(decodeURIComponent) + .filter(item => !!item); + } + return {...viewPref, page: parseInt(params.get('page') || '0', 10), search: params.get('search') || ''}; + }) + ) + }> + {pref => children(pref)} + + )} + +); + +function filterApps(applications: models.ApplicationSet[], pref: AppsListPreferences, search: string): {filteredApps: models.ApplicationSet[]; filterResults: FilteredAppSet[]} { + /* applications = applications.map(app => { + let isAppOfAppsPattern = false; + for (const resource of app.status.resources) { + if (resource.kind === 'Application') { + isAppOfAppsPattern = true; + break; + } + } + return {...app, isAppOfAppsPattern}; + }); + */ + const filterResults = getFilterResults(applications, pref); + return { + filterResults, + filteredApps: filterResults.filter( + app => (search === '' || app.metadata.name.includes(search) || app.metadata.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; apps: models.ApplicationSet[]}) => { + const {content, ctx, apps} = {...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); + const useAuthSettingsCtx = React.useContext(AuthSettingsCtx); + + 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(); + if (inputProps.onFocus) { + inputProps.onFocus(e); + } + }} + style={{fontSize: '14px'}} + className='argo-field' + placeholder='Search ApplicationSets...' + /> +
/
+ {content && ( + ctx.navigation.goto('.', {search: null}, {replace: true})} style={{cursor: 'pointer', marginLeft: '5px'}} /> + )} +
+ )} + wrapperProps={{className: 'applications-list__search-wrapper'}} + renderItem={item => ( + + {item.label} + + )} + onSelect={val => { + ctx.navigation.goto(`./${val}`); + }} + onChange={e => ctx.navigation.goto('.', {search: e.target.value}, {replace: true})} + value={content || ''} + items={apps.map(app => AppUtils.appSetQualifiedName(app, useAuthSettingsCtx?.appsInAnyNamespaceEnabled))} + /> + ); +}; + +const FlexTopBar = (props: {toolbar: Toolbar | Observable}) => { + const ctx = React.useContext(Context); + const loadToolbar = AddAuthToToolbar(props.toolbar, ctx); + return ( + +
+ loadToolbar}> + {toolbar => ( + +
+ {toolbar.actionMenu && ( + + {toolbar.actionMenu.items.map((item, i) => ( + + ))} + + )} +
+
{toolbar.tools}
+
+ )} +
+
+
+ + ); +}; + +export const ApplicationSetsList = (props: RouteComponentProps<{}>) => { + const query = new URLSearchParams(props.location.search); + const appInput = tryJsonParse(query.get('new')); + // const syncAppsInput = tryJsonParse(query.get('syncApps')); + // const refreshAppsInput = tryJsonParse(query.get('refreshApps')); + // const [createApi, setCreateApi] = React.useState(null); + const clusters = React.useMemo(() => services.clusters.list(), []); + // const [isAppCreatePending, setAppCreatePending] = React.useState(false); + const loaderRef = React.useRef(); + const {List, Summary, Tiles} = AppsListViewKey; + + {/* function refreshApp(appName: string, appNamespace: string) { + // app refreshing might be done too quickly so that UI might miss it due to event batching + // add refreshing annotation in the UI to improve user experience + if (loaderRef.current) { + const applications = loaderRef.current.getData() as models.Application[]; + const app = applications.find(item => item.metadata.name === appName && item.metadata.namespace === appNamespace); + if (app) { + AppUtils.setAppRefreshing(app); + loaderRef.current.setData(applications); + } + } + services.applications.get(appName, appNamespace, 'normal'); + } +*/} + + function onFilterPrefChanged(ctx: ContextApis, newPref: AppsListPreferences) { + services.viewPreferences.updatePreferences({appList: newPref}); + ctx.navigation.goto( + '.', + { + proj: newPref.projectsFilter.join(','), + sync: newPref.syncFilter.join(','), + autoSync: newPref.autoSyncFilter.join(','), + health: newPref.healthFilter.join(','), + namespace: newPref.namespacesFilter.join(','), + cluster: newPref.clustersFilter.join(','), + labels: newPref.labelsFilter.map(encodeURIComponent).join(',') + }, + {replace: true} + ); + } + + function getPageTitle(view: string) { + switch (view) { + case List: + return 'ApplicationSets List'; + case Tiles: + return 'ApplicationSets Tiles'; + case Summary: + return 'ApplicationSets Summary'; + } + return ''; + } + + const sidebarTarget = useSidebarTarget(); + + return ( + + + + {ctx => ( + + {pref => ( + + AppUtils.handlePageVisibility(() => loadApplicationSets(query.get('appSetNamespace')))} + loadingRenderer={() => ( +
+ +
+ )}> + {(applications: models.ApplicationSet[]) => { + const healthBarPrefs = pref.statusBarView || ({} as HealthStatusBarPreferences); + const {filteredApps, filterResults} = filterApps(applications, pref, pref.search); + return ( + + + {q => } + + + +
+ { + ctx.navigation.goto('.', {view: Tiles}); + services.viewPreferences.updatePreferences({appList: {...pref, view: Tiles}}); + }} + /> + { + ctx.navigation.goto('.', {view: List}); + services.viewPreferences.updatePreferences({appList: {...pref, view: List}}); + }} + /> + { + ctx.navigation.goto('.', {view: Summary}); + services.viewPreferences.updatePreferences({appList: {...pref, view: Summary}}); + }} + /> +
+
+ ), + /* actionMenu: { + items: [ + { + title: 'New App', + iconClassName: 'fa fa-plus', + qeId: 'applications-list-button-new-app', + action: () => ctx.navigation.goto('.', {new: '{}'}, {replace: true}) + }, + { + title: 'Sync Apps', + iconClassName: 'fa fa-sync', + action: () => ctx.navigation.goto('.', {syncApps: true}, {replace: true}) + }, + { + title: 'Refresh Apps', + iconClassName: 'fa fa-redo', + action: () => ctx.navigation.goto('.', {refreshApps: true}, {replace: true}) + } + ] + } + */ + }} + /> +
+ {applications.length === 0 && (pref.labelsFilter || []).length === 0 ? ( + +

No applications 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 + )} + + {(pref.view === 'summary' && ) || ( + 1 && } + showHeader={healthBarPrefs.showHealthStatusBar} + preferencesKey='applications-list' + page={pref.page} + emptyState={() => ( + +

No matching application sets found

+
+ Change filter criteria or  + { + AppsListPreferences.clearFilters(pref); + onFilterPrefChanged(ctx, pref); + }}> + clear filters + +
+
+ )} + sortOptions={[ + {title: 'Name', compare: (a, b) => a.metadata.name.localeCompare(b.metadata.name)}, + { + title: 'Created At', + compare: (b, a) => a.metadata.creationTimestamp.localeCompare(b.metadata.creationTimestamp) + }, + /* { + title: 'Synchronized', + compare: (b, a) => + a.status.operationState?.finishedAt?.localeCompare(b.status.operationState?.finishedAt) + } + */ + ]} + data={filteredApps} + onPageChange={page => ctx.navigation.goto('.', {page})}> + {data => + (pref.view === 'tiles' && ( + + // ctx.navigation.goto('.', {syncApp: appName, appNamespace}, {replace: true}) + // } + // refreshApplication={refreshApp} + deleteApplicationSet={(appName, appNamespace) => + AppUtils.deleteApplication(appName, appNamespace, ctx) + } + /> + )) || ( + + // ctx.navigation.goto('.', {syncApp: appName, appNamespace}, {replace: true}) + // } + + // refreshApplication={refreshApp} + deleteApplicationSet={(appName, appNamespace) => + AppUtils.deleteApplication(appName, appNamespace, ctx) + } + /> + ) + } +
+ )} + + )} + {/* ctx.navigation.goto('.', {syncApps: null}, {replace: true})} + apps={filteredApps} + /> + ctx.navigation.goto('.', {refreshApps: null}, {replace: true})} + apps={filteredApps} + />*/} +
+ {/* + {q => ( + + q.pipe( + mergeMap(params => { + const syncApp = params.get('syncApp'); + const appNamespace = params.get('appNamespace'); + return (syncApp && from(services.applicationSets.get(syncApp, appNamespace))) || from([null]); + }) + ) + }> + + {app => ( + ctx.navigation.goto('.', {syncApp: null}, {replace: true})} + /> + )} + + + )} + + */} + ctx.navigation.goto('.', {new: null}, {replace: true})} + header={ +
+ {/* {' '} */} + +
+ }> + {appInput && ( + {/* { + setCreateApi(api); + }} + createApp={async app => { + setAppCreatePending(true); + try { + await services.applications.create(app); + ctx.navigation.goto('.', {new: null}, {replace: true}); + } catch (e) { + ctx.notifications.show({ + content: , + type: NotificationType.Error + }); + } finally { + setAppCreatePending(false); + } + }} + app={appInput} + onAppChanged={app => ctx.navigation.goto('.', {new: JSON.stringify(app)}, {replace: true})} + /> */} + )} +
+ + ); + }} +
+
+ )} +
+ )} +
+
+
+ ); +}; diff --git a/ui/src/app/applications/components/applicationsets-list/applications-source.scss b/ui/src/app/applications/components/applicationsets-list/applications-source.scss new file mode 100644 index 0000000000000..d066fbc31c1b9 --- /dev/null +++ b/ui/src/app/applications/components/applicationsets-list/applications-source.scss @@ -0,0 +1,5 @@ +.application-source { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} \ No newline at end of file diff --git a/ui/src/app/applications/components/applicationsets-list/applications-source.tsx b/ui/src/app/applications/components/applicationsets-list/applications-source.tsx new file mode 100644 index 0000000000000..0a5fbe51f37c0 --- /dev/null +++ b/ui/src/app/applications/components/applicationsets-list/applications-source.tsx @@ -0,0 +1,14 @@ +import {Tooltip} from 'argo-ui'; +import * as React from 'react'; +import {ApplicationSource as ApplicationSourceType} from '../../../shared/models'; + +import './applications-source.scss'; + +export const ApplicationsSource = ({source}: {source: ApplicationSourceType}) => { + const sourceString = `${source.repoURL}/${source.path || source.chart}`; + return ( + +
{sourceString}
+
+ ); +}; diff --git a/ui/src/app/applications/components/applicationsets-list/applications-status-bar.scss b/ui/src/app/applications/components/applicationsets-list/applications-status-bar.scss new file mode 100644 index 0000000000000..ed94f335ff7a6 --- /dev/null +++ b/ui/src/app/applications/components/applicationsets-list/applications-status-bar.scss @@ -0,0 +1,32 @@ +@import 'node_modules/argo-ui/src/styles/config'; + +.status-bar { + $height: 16px; + $border-width: 2px; + margin: 0px; + width: 100%; + height: $height; + display: flex; + border-radius: 25px; + border: $border-width solid white; + + &__segment { + &__fill { + height: $height - (2 * $border-width); + } + } + + &__segment:first-child { + border-top-left-radius: 25px; + border-bottom-left-radius: 25px; + } + + &__segment:last-child { + border-top-right-radius: 25px; + border-bottom-right-radius: 25px; + } + + &__segment:not(:first-child) { + border-left: 3px solid white; + } +} diff --git a/ui/src/app/applications/components/applicationsets-list/applications-status-bar.tsx b/ui/src/app/applications/components/applicationsets-list/applications-status-bar.tsx new file mode 100644 index 0000000000000..522bd2faf91ee --- /dev/null +++ b/ui/src/app/applications/components/applicationsets-list/applications-status-bar.tsx @@ -0,0 +1,81 @@ +import {Tooltip} from 'argo-ui/v2'; +import * as React from 'react'; +import {COLORS} from '../../../shared/components'; +import {Consumer} from '../../../shared/context'; +import * as models from '../../../shared/models'; + +import './applications-status-bar.scss'; + +export interface ApplicationsStatusBarProps { + applications: models.ApplicationSet[]; +} + +export const ApplicationsStatusBar = ({applications}: ApplicationsStatusBarProps) => { + const readings = [ + { + name: 'Healthy', + value: applications.filter(app => app.status.conditions[0].status === 'True').length, + color: COLORS.health.healthy + }, + /* { + name: 'Progressing', + value: applications.filter(app => app.status.health.status === 'Progressing').length, + color: COLORS.health.progressing + }, + */ + { + name: 'Degraded', + value: applications.filter(app => app.status.conditions[0].status === 'False').length, + color: COLORS.health.degraded + }, + /*{ + name: 'Suspended', + value: applications.filter(app => app.status.health.status === 'Suspended').length, + color: COLORS.health.suspended + }, + { + name: 'Missing', + value: applications.filter(app => app.status.health.status === 'Missing').length, + color: COLORS.health.missing + }, + */ + { + name: 'Unknown', + value: applications.filter(app => app.status.conditions[0].status === 'Unknown').length, + color: COLORS.health.unknown + } + ]; + + // will sort readings by value greatest to lowest, then by name + readings.sort((a, b) => (a.value < b.value ? 1 : a.value === b.value ? (a.name > b.name ? 1 : -1) : -1)); + + const totalItems = readings.reduce((total, i) => { + return total + i.value; + }, 0); + + return ( + + {ctx => ( + <> + {totalItems > 1 && ( +
+ {readings && + readings.length > 1 && + readings.map((item, i) => { + if (item.value > 0) { + return ( +
+ +
+ +
+ ); + } + })} +
+ )} + + )} + + ); +}; diff --git a/ui/src/app/applications/components/applicationsets-list/applications-summary.tsx b/ui/src/app/applications/components/applicationsets-list/applications-summary.tsx new file mode 100644 index 0000000000000..3c15c53d3a694 --- /dev/null +++ b/ui/src/app/applications/components/applicationsets-list/applications-summary.tsx @@ -0,0 +1,124 @@ +import * as React from 'react'; +const PieChart = require('react-svg-piechart').default; + +import {COLORS} from '../../../shared/components'; +import * as models from '../../../shared/models'; +import {HealthStatusCode, ApplicationSetConditionType, SyncStatusCode, ApplicationSetStatus, ApplicationSetConditionStatus} from '../../../shared/models'; +import {AppSetHealthStatusIcon, ComparisonStatusIcon, HealthStatusIcon} from './utils'; + +const healthColors = new Map(); +healthColors.set('Unknown', COLORS.health.unknown); +/*healthColors.set('Progressing', COLORS.health.progressing); +healthColors.set('Suspended', COLORS.health.suspended); +*/ +healthColors.set('True', COLORS.health.healthy); +healthColors.set('False', COLORS.health.degraded); +// healthColors.set('Missing', COLORS.health.missing); + +const syncColors = new Map(); +syncColors.set('Unknown', COLORS.sync.unknown); +syncColors.set('Synced', COLORS.sync.synced); +syncColors.set('OutOfSync', COLORS.sync.out_of_sync); + +export const ApplicationSetsSummary = ({applications}: {applications: models.ApplicationSet[]}) => { + /* const sync = new Map(); + applications.forEach(app => sync.set(app.status.sync.status, (sync.get(app.status.sync.status) || 0) + 1)); + */ + const health = new Map(); + applications.forEach(app => health.set(app.status.conditions[0].status, (health.get(app.status.conditions[0].status) || 0) + 1)); + + const attributes = [ + { + title: 'APPLICATIONSETS', + value: applications.length + }, + /* { + title: 'SYNCED', + value: applications.filter(app => app.status.sync.status === 'Synced').length + }, + */ + { + title: 'HEALTHY', + value: applications.filter(app => app.status.conditions[0].status === 'True').length + }, + /* + { + title: 'CLUSTERS', + value: new Set(applications.map(app => app.spec.destination.server)).size + }, + + { + title: 'NAMESPACES', + value: new Set(applications.map(app => app.spec.destination.namespace)).size + } + */ + ]; + + const charts = [ + /* { + title: 'Sync', + data: Array.from(sync.keys()).map(key => ({title: key, value: sync.get(key), color: syncColors.get(key as models.SyncStatusCode)})), + legend: syncColors as Map + }, + */ + { + title: 'Health', + data: Array.from(health.keys()).map(key => ({title: key, value: health.get(key), color: healthColors.get(key as models.ApplicationSetConditionStatus)})), + legend: healthColors as Map + } + ]; + + return ( +
+
+
+
+

SUMMARY

+ {attributes.map(attr => ( +
+
{attr.title}
+
+ {attr.value} +
+
+ ))} +
+
+
+
+ {charts.map(chart => { + const getLegendValue = (key: string) => { + const index = chart.data.findIndex((data: {title: string}) => data.title === key); + return index > -1 ? chart.data[index].value : 0; + }; + return ( + +
+
+
+

{chart.title}

+ +
+
+
    + {Array.from(chart.legend.keys()).map(key => ( +
  • + {chart.title === 'Health' && } + {/* {chart.title === 'Health' && } */} + {chart.title === 'Sync' && } + {` ${key} (${getLegendValue(key)})`} +
  • + ))} +
+
+
+
+
+ ); + })} +
+
+
+
+ ); +}; diff --git a/ui/src/app/applications/components/applicationsets-list/applications-table.scss b/ui/src/app/applications/components/applicationsets-list/applications-table.scss new file mode 100644 index 0000000000000..ce2e723c2a014 --- /dev/null +++ b/ui/src/app/applications/components/applicationsets-list/applications-table.scss @@ -0,0 +1,31 @@ +.applications-table { + .argo-table-list__row { + line-height: 26px; + padding-top: 4px; + padding-bottom: 4px; + padding-left: 2em; + + .columns:last-child { + .argo-dropdown { + position: absolute; + right: 0; + top: 50%; + transform: translateY(-50%); + } + } + } + + .applications-table-source { + display: flex; + justify-content: space-between; + + .applications-table-source__link { + flex: 1; + min-width: 0; + } + + .applications-table-source__labels { + max-width: 40%; + } + } +} diff --git a/ui/src/app/applications/components/applicationsets-list/applications-table.tsx b/ui/src/app/applications/components/applicationsets-list/applications-table.tsx new file mode 100644 index 0000000000000..2e6aafc6dc8b5 --- /dev/null +++ b/ui/src/app/applications/components/applicationsets-list/applications-table.tsx @@ -0,0 +1,161 @@ +import {DataLoader, DropDownMenu, Tooltip} from 'argo-ui'; +import * as React from 'react'; +import Moment from 'react-moment'; +import {Key, KeybindingContext, useNav} from 'argo-ui/v2'; +import {Cluster} from '../../../shared/components'; +import {Consumer, Context} from '../../../shared/context'; +import * as models from '../../../shared/models'; +import {ApplicationURLs} from '../application-urls'; +import * as AppUtils from './utils'; +// import {getAppDefaultSource, OperationState} from '../utils'; +import {ApplicationSetsLabels} from './applications-labels'; +import {ApplicationsSource} from './applications-source'; +import {services} from '../../../shared/services'; +import './applications-table.scss'; + +export const ApplicationSetsTable = (props: { + applications: models.ApplicationSet[]; + // syncApplication: (appName: string, appNamespace: string) => any; + // refreshApplication: (appName: string, appNamespace: string) => any; + deleteApplicationSet: (appSetName: string, appSetNamespace: string) => any; +}) => { + const [selectedApp, navApp, reset] = useNav(props.applications.length); + const ctxh = React.useContext(Context); + + const {useKeybinding} = React.useContext(KeybindingContext); + + useKeybinding({keys: Key.DOWN, action: () => navApp(1)}); + useKeybinding({keys: Key.UP, action: () => navApp(-1)}); + useKeybinding({ + keys: Key.ESCAPE, + action: () => { + reset(); + return selectedApp > -1 ? true : false; + } + }); + useKeybinding({ + keys: Key.ENTER, + action: () => { + if (selectedApp > -1) { + ctxh.navigation.goto(`/applicationsets/${props.applications[selectedApp].metadata.name}`); + return true; + } + return false; + } + }); + + return ( + + {ctx => ( + services.viewPreferences.getPreferences()}> + {pref => { + const favList = pref.appList.favoritesAppList || []; + return ( +
+ {props.applications.map((appSet, i) => ( +
+
ctx.navigation.goto(`/applicationsets/${appSet.metadata.namespace}/${appSet.metadata.name}`, {}, {event: e})}> + onClick={e => ctx.navigation.goto(`/applicationsets/${appSet.metadata.name}`, {}, {event: e})}> +
+
+
+
+ + + + {/* */} +
+
+ {/*
Project:
*/} + {/*
{appSet.spec.project}
*/} +
+
+
+
Name:
+
+ + {appSet.metadata.name} +
+ + {appSet.metadata.creationTimestamp} + + + }> + {appSet.metadata.name} +
+
+
+
+ +
+
+
Source:
+
+ {/*
+ +
*/} +
+ +
+
+
+ {/*
+
Destination:
+
+ /{app.spec.destination.namespace} +
+
*/} +
+ +
+ {/* {app.status.health.status}
*/} + {appSet.status.conditions[0].status}
+ {/* */} + {/* {app.status.sync.status} */} + ( + + )} + items={[ + // {title: 'Sync', action: () => props.syncApplication(app.metadata.name, app.metadata.namespace)}, + // {title: 'Refresh', action: () => props.refreshApplication(app.metadata.name, app.metadata.namespace)}, + {title: 'Delete', action: () => props.deleteApplicationSet(appSet.metadata.name, appSet.metadata.namespace)} + ]} + /> +
+
+
+ ))} +
+ ); + }} + + )} + + ); +}; diff --git a/ui/src/app/applications/components/applicationsets-list/applications-tiles.scss b/ui/src/app/applications/components/applicationsets-list/applications-tiles.scss new file mode 100644 index 0000000000000..65514f82d0f93 --- /dev/null +++ b/ui/src/app/applications/components/applicationsets-list/applications-tiles.scss @@ -0,0 +1,11 @@ +@import 'node_modules/argo-ui/src/styles/config'; + +.applications-tiles { + .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/applications/components/applicationsets-list/applications-tiles.tsx b/ui/src/app/applications/components/applicationsets-list/applications-tiles.tsx new file mode 100644 index 0000000000000..f6a1f23f5942a --- /dev/null +++ b/ui/src/app/applications/components/applicationsets-list/applications-tiles.tsx @@ -0,0 +1,329 @@ +import {DataLoader, Tooltip} from 'argo-ui'; +import * as classNames from 'classnames'; +import * as React from 'react'; +import {Key, KeybindingContext, NumKey, NumKeyToNumber, NumPadKey, useNav} from 'argo-ui/v2'; +import {Cluster} from '../../../shared/components'; +import {Consumer, Context, AuthSettingsCtx} from '../../../shared/context'; +import * as models from '../../../shared/models'; +import {ApplicationURLs} from '../application-urls'; +import * as AppUtils from './utils'; +// import {getAppDefaultSource, OperationState} from '../utils'; +import {services} from '../../../shared/services'; + +import './applications-tiles.scss'; + +export interface ApplicationSetTilesProps { + applicationSets: models.ApplicationSet[]; + // syncApplication: (appName: string, appNamespace: string) => any; + // refreshApplication: (appName: string, appNamespace: string) => any; + deleteApplicationSet: (appSetName: string, appSetNamespace: string) => any; +} + +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 ApplicationSetTiles = ({applicationSets, deleteApplicationSet}: ApplicationSetTilesProps) => { + const [selectedAppSet, navApp, reset] = useNav(applicationSets.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 useAuthSettingsCtx = React.useContext(AuthSettingsCtx); + + 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 (selectedAppSet > -1) { + ctxh.navigation.goto(`/applicationsets/${applicationSets[selectedAppSet].metadata.name}`); + return true; + } + return false; + } + }); + + useKeybinding({ + keys: Key.ESCAPE, + action: () => { + if (selectedAppSet > -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 => { + const favList = pref.appList.favoritesAppList || []; + return ( +
+ {applicationSets.map((appSet, i) => { + // const source = getAppDefaultSource(app); + return ( +
+
+
+ ctx.navigation.goto( + // `/applicationsets/${appSet.metadata.namespace}/${appSet.metadata.name}`, + `/applicationsets/${appSet.metadata.name}`, + {view: pref.appDetails.view}, + {event: e} + ) + }> +
+
+ {/*
0 ? 'columns small-10' : 'columns small-11'}> */} +
+ {/* */} + + + + {AppUtils.appSetQualifiedName(appSet, useAuthSettingsCtx?.appsInAnyNamespaceEnabled)} + + +
+ {/*
0 ? 'columns small-2' : 'columns small-1'}> */} +
+
+ {/* */} + + + +
+
+
+ {/*
+
+ Project: +
+
{app.spec.project}
+
*/} +
+
+ Labels: +
+
+ + {Object.keys(appSet.metadata.labels || {}) + .map(label => ({label, value: appSet.metadata.labels[label]})) + .map(item => ( +
+ {item.label}={item.value} +
+ ))} +
+ }> + + {Object.keys(appSet.metadata.labels || {}) + .map(label => `${label}=${appSet.metadata.labels[label]}`) + .join(', ')} + + +
+
+
+
+ Status: +
+
+ {appSet.status.conditions[0].status} +   + {/* {app.status.sync.status} +   + */} + +
+
+ {/*
+
+ Repository: +
+
+ + {source.repoURL} + +
+
+
+
+ Target Revision: +
+
{source.targetRevision || 'HEAD'}
+
+ {source.path && ( +
+
+ Path: +
+
{source.path}
+
+ )} + {source.chart && ( +
+
+ Chart: +
+
{source.chart}
+
+ )} +
+
+ Destination: +
+
+ +
+
+
+
+ Namespace: +
+
{app.spec.destination.namespace}
+
+ */} +
+
+ Created At: +
+
{AppUtils.formatCreationTimestamp(appSet.metadata.creationTimestamp)}
+
+ {/* + {app.status.operationState && ( +
+
+ Last Sync: +
+
+ {AppUtils.formatCreationTimestamp(app.status.operationState.finishedAt || app.status.operationState.startedAt)} +
+
+ )} + */} + +
+
+
+
+ ); + })} +
+ ); + }} + + )} + + ); +}; diff --git a/ui/src/app/applications/components/applicationsets-list/flex-top-bar.scss b/ui/src/app/applications/components/applicationsets-list/flex-top-bar.scss new file mode 100644 index 0000000000000..e42253904580f --- /dev/null +++ b/ui/src/app/applications/components/applicationsets-list/flex-top-bar.scss @@ -0,0 +1,39 @@ +@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; + &__actions { + display: flex; + align-items: center; + height: 50px; + @include breakpoint(medium down) { + flex-basis: 100%; + justify-content: center; + } + button { + display: block; + } + } + &__tools { + display: flex; + flex-grow: 1; + 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/applications/components/applicationsets-list/utils.tsx b/ui/src/app/applications/components/applicationsets-list/utils.tsx new file mode 100644 index 0000000000000..49655350660d6 --- /dev/null +++ b/ui/src/app/applications/components/applicationsets-list/utils.tsx @@ -0,0 +1,1308 @@ +import {models, DataLoader, FormField, MenuItem, NotificationType, Tooltip} from 'argo-ui'; +import {ActionButton} from 'argo-ui/v2'; +import * as classNames from 'classnames'; +import * as React from 'react'; +import * as ReactForm from 'react-form'; +import {FormApi, Text} from 'react-form'; +import * as moment from 'moment'; +import {BehaviorSubject, combineLatest, concat, from, fromEvent, Observable, Observer, Subscription} from 'rxjs'; +import {debounceTime, map} from 'rxjs/operators'; +import {AppContext, Context, ContextApis} from '../../../shared/context'; +import {ResourceTreeNode} from '../application-resource-tree/application-resource-tree'; + +import {CheckboxField, COLORS, ErrorNotification, Revision} from '../../../shared/components'; +import * as appModels from '../../../shared/models'; +import {services} from '../../../shared/services'; + +require('../utils.scss'); + +export interface NodeId { + kind: string; + namespace: string; + name: string; + group: string; + createdAt?: models.Time; +} + +type ActionMenuItem = MenuItem & {disabled?: boolean; tooltip?: string}; + +export function nodeKey(node: NodeId) { + return [node.group, node.kind, node.namespace, node.name].join('/'); +} + +export function createdOrNodeKey(node: NodeId) { + return node?.createdAt || nodeKey(node); +} + +export function isSameNode(first: NodeId, second: NodeId) { + return nodeKey(first) === nodeKey(second); +} + +export function helpTip(text: string) { + return ( + + + {' '} + + + + ); +} +export async function deleteApplication(appName: string, appNamespace: string, apis: ContextApis): Promise { + let confirmed = false; + const propagationPolicies: {name: string; message: string}[] = [ + { + name: 'Foreground', + message: `Cascade delete the application's resources using foreground propagation policy` + }, + { + name: 'Background', + message: `Cascade delete the application's resources using background propagation policy` + }, + { + name: 'Non-cascading', + message: `Only delete the application, but do not cascade delete its resources` + } + ]; + await apis.popup.prompt( + 'Delete application', + api => ( +
+

+ Are you sure you want to delete the application {appName}? +

+
+ +
+

Select propagation policy for application deletion

+
+ {propagationPolicies.map(policy => { + return ( + + ); + })} +
+
+ ), + { + validate: vals => ({ + applicationName: vals.applicationName !== appName && 'Enter the application name to confirm the deletion' + }), + submit: async (vals, _, close) => { + try { + await services.applications.delete(appName, appNamespace, vals.propagationPolicy); + confirmed = true; + close(); + } catch (e) { + apis.notifications.show({ + content: , + type: NotificationType.Error + }); + } + } + }, + {name: 'argo-icon-warning', color: 'warning'}, + 'yellow', + {propagationPolicy: 'foreground'} + ); + return confirmed; +} + +export async function confirmSyncingAppOfApps(apps: appModels.Application[], apis: ContextApis, form: FormApi): Promise { + let confirmed = false; + const appNames: string[] = apps.map(app => app.metadata.name); + const appNameList = appNames.join(', '); + await apis.popup.prompt( + 'Warning: Synchronize App of Multiple Apps using replace?', + api => ( +
+

+ Are you sure you want to sync the application '{appNameList}' which contain(s) multiple apps with 'replace' option? This action will delete and recreate all + apps linked to '{appNameList}'. +

+
+ +
+
+ ), + { + validate: vals => ({ + applicationName: vals.applicationName !== appNameList && 'Enter the application name(s) to confirm syncing' + }), + submit: async (_vals, _, close) => { + try { + await form.submitForm(null); + confirmed = true; + close(); + } catch (e) { + apis.notifications.show({ + content: , + type: NotificationType.Error + }); + } + } + }, + {name: 'argo-icon-warning', color: 'warning'}, + 'yellow' + ); + return confirmed; +} + +const PropagationPolicyOption = ReactForm.FormField((props: {fieldApi: ReactForm.FieldApi; policy: string; message: string}) => { + const { + fieldApi: {setValue} + } = props; + return ( +
+ setValue(props.policy.toLowerCase())} + /> + +
+ ); +}); + +/* export const OperationPhaseIcon = ({app}: {app: appModels.Application}) => { + const operationState = getAppOperationState(app); + if (operationState === undefined) { + return ; + } + let className = ''; + let color = ''; + switch (operationState.phase) { + case appModels.OperationPhases.Succeeded: + className = 'fa fa-check-circle'; + color = COLORS.operation.success; + break; + case appModels.OperationPhases.Error: + className = 'fa fa-times-circle'; + color = COLORS.operation.error; + break; + case appModels.OperationPhases.Failed: + className = 'fa fa-times-circle'; + color = COLORS.operation.failed; + break; + default: + className = 'fa fa-circle-notch fa-spin'; + color = COLORS.operation.running; + break; + } + return ; +}; + +*/ +export const ComparisonStatusIcon = ({ + status, + resource, + label, + noSpin +}: { + status: appModels.SyncStatusCode; + resource?: {requiresPruning?: boolean}; + label?: boolean; + noSpin?: boolean; +}) => { + let className = 'fas fa-question-circle'; + let color = COLORS.sync.unknown; + let title: string = 'Unknown'; + + switch (status) { + case appModels.SyncStatuses.Synced: + className = 'fa fa-check-circle'; + color = COLORS.sync.synced; + title = 'Synced'; + break; + case appModels.SyncStatuses.OutOfSync: + const requiresPruning = resource && resource.requiresPruning; + className = requiresPruning ? 'fa fa-trash' : 'fa fa-arrow-alt-circle-up'; + title = 'OutOfSync'; + if (requiresPruning) { + title = `${title} (This resource is not present in the application's source. It will be deleted from Kubernetes if the prune option is enabled during sync.)`; + } + color = COLORS.sync.out_of_sync; + break; + case appModels.SyncStatuses.Unknown: + className = `fa fa-circle-notch ${noSpin ? '' : 'fa-spin'}`; + break; + } + return ( + + {label && title} + + ); +}; + +export function showDeploy(resource: string, revision: string, apis: ContextApis) { + apis.navigation.goto('.', {deploy: resource, revision}, {replace: true}); +} + +export function findChildPod(node: appModels.ResourceNode, tree: appModels.ApplicationTree): appModels.ResourceNode { + const key = nodeKey(node); + + const allNodes = tree.nodes.concat(tree.orphanedNodes || []); + const nodeByKey = new Map(); + allNodes.forEach(item => nodeByKey.set(nodeKey(item), item)); + + const pods = tree.nodes.concat(tree.orphanedNodes || []).filter(item => item.kind === 'Pod'); + return pods.find(pod => { + const items: Array = [pod]; + while (items.length > 0) { + const next = items.pop(); + const parentKeys = (next.parentRefs || []).map(nodeKey); + if (parentKeys.includes(key)) { + return true; + } + parentKeys.forEach(item => { + const parent = nodeByKey.get(item); + if (parent) { + items.push(parent); + } + }); + } + + return false; + }); +} + +export const deletePodAction = async (pod: appModels.Pod, appContext: AppContext, appName: string, appNamespace: string) => { + appContext.apis.popup.prompt( + 'Delete pod', + () => ( +
+

+ Are you sure you want to delete Pod {pod.name}? +

+
+ + + +
+
+ ), + { + submit: async (vals, _, close) => { + try { + await services.applications.deleteResource(appName, appNamespace, pod, !!vals.force, false); + close(); + } catch (e) { + appContext.apis.notifications.show({ + content: , + type: NotificationType.Error + }); + } + } + } + ); +}; + +export const deletePopup = async (ctx: ContextApis, resource: ResourceTreeNode, application: appModels.ApplicationSet, appChanged?: BehaviorSubject) => { + const isManaged = !!resource.status; + const deleteOptions = { + option: 'foreground' + }; + function handleStateChange(option: string) { + deleteOptions.option = option; + } + return ctx.popup.prompt( + 'Delete resource', + api => ( +
+

+ Are you sure you want to delete {resource.kind} {resource.name}? +

+ {isManaged ? ( +
+ +
+ ) : ( + '' + )} +
+ handleStateChange('foreground')} + defaultChecked={true} + style={{marginRight: '5px'}} + id='foreground-delete-radio' + /> + + handleStateChange('force')} style={{marginRight: '5px'}} id='force-delete-radio' /> + + handleStateChange('orphan')} style={{marginRight: '5px'}} id='cascade-delete-radio' /> + +
+
+ ), + { + validate: vals => + isManaged && { + resourceName: vals.resourceName !== resource.name && 'Enter the resource name to confirm the deletion' + }, + submit: async (vals, _, close) => { + const force = deleteOptions.option === 'force'; + const orphan = deleteOptions.option === 'orphan'; + try { + await services.applications.deleteResource(application.metadata.name, application.metadata.namespace, resource, !!force, !!orphan); + if (appChanged) { + appChanged.next(await services.applicationSets.get(application.metadata.name, application.metadata.namespace)); + } + close(); + } catch (e) { + ctx.notifications.show({ + content: , + type: NotificationType.Error + }); + } + } + }, + {name: 'argo-icon-warning', color: 'warning'}, + 'yellow' + ); +}; + +function getResourceActionsMenuItems(resource: ResourceTreeNode, metadata: models.ObjectMeta, apis: ContextApis): Promise { + return services.applications + .getResourceActions(metadata.name, metadata.namespace, resource) + .then(actions => { + return actions.map( + action => + ({ + title: action.name, + disabled: !!action.disabled, + action: async () => { + try { + const confirmed = await apis.popup.confirm(`Execute '${action.name}' action?`, `Are you sure you want to execute '${action.name}' action?`); + if (confirmed) { + await services.applications.runResourceAction(metadata.name, metadata.namespace, resource, action.name); + } + } catch (e) { + apis.notifications.show({ + content: , + type: NotificationType.Error + }); + } + } + } as MenuItem) + ); + }) + .catch(() => [] as MenuItem[]); +} + +function getActionItems( + resource: ResourceTreeNode, + application: appModels.ApplicationSet, + tree: appModels.ApplicationTree, + apis: ContextApis, + appChanged: BehaviorSubject, + isQuickStart: boolean +): Observable { + const isRoot = resource.root && nodeKey(resource.root) === nodeKey(resource); + const items: MenuItem[] = [ + ...((isRoot && [ + { + title: 'Sync', + iconClassName: 'fa fa-sync', + action: () => showDeploy(nodeKey(resource), null, apis) + } + ]) || + []), + { + title: 'Delete', + iconClassName: 'fa fa-times-circle', + action: async () => { + return deletePopup(apis, resource, application, appChanged); + } + } + ]; + if (!isQuickStart) { + items.unshift({ + title: 'Details', + iconClassName: 'fa fa-info-circle', + action: () => apis.navigation.goto('.', {node: nodeKey(resource)}) + }); + } + + if (findChildPod(resource, tree)) { + items.push({ + title: 'Logs', + iconClassName: 'fa fa-align-left', + action: () => apis.navigation.goto('.', {node: nodeKey(resource), tab: 'logs'}, {replace: true}) + }); + } + + if (isQuickStart) { + return from([items]); + } + + /* const execAction = services.authService + .settings() + .then(async settings => { + const execAllowed = await services.accounts.canI('exec', 'create', application.spec.project + '/' + application.metadata.name); + if (resource.kind === 'Pod' && settings.execEnabled && execAllowed) { + return [ + { + title: 'Exec', + iconClassName: 'fa fa-terminal', + action: async () => apis.navigation.goto('.', {node: nodeKey(resource), tab: 'exec'}, {replace: true}) + } as MenuItem + ]; + } + return [] as MenuItem[]; + }) + .catch(() => [] as MenuItem[]); +*/ + + const resourceActions = getResourceActionsMenuItems(resource, application.metadata, apis); + + const links = services.applications + .getResourceLinks(application.metadata.name, application.metadata.namespace, resource) + .then(data => { + return (data.items || []).map( + link => + ({ + title: link.title, + iconClassName: `fa ${link.iconClass ? link.iconClass : 'fa-external-link'}`, + action: () => window.open(link.url, '_blank'), + tooltip: link.description + } as MenuItem) + ); + }) + .catch(() => [] as MenuItem[]); + + return combineLatest( + from([items]), // this resolves immediately + concat([[] as MenuItem[]], resourceActions), // this resolves at first to [] and then whatever the API returns + // concat([[] as MenuItem[]], execAction), // this resolves at first to [] and then whatever the API returns + concat([[] as MenuItem[]], links) // this resolves at first to [] and then whatever the API returns + ).pipe(map(res => ([] as MenuItem[]).concat(...res))); +} + +export function renderResourceMenu( + resource: ResourceTreeNode, + application: appModels.ApplicationSet, + tree: appModels.ApplicationTree, + apis: ContextApis, + appChanged: BehaviorSubject, + getApplicationActionMenu: () => any +): React.ReactNode { + let menuItems: Observable; + + if (isAppNode(resource) && resource.name === application.metadata.name) { + menuItems = from([getApplicationActionMenu()]); + } else { + menuItems = getActionItems(resource, application, tree, apis, appChanged, false); + } + return ( + menuItems}> + {items => ( +
    + {items.map((item, i) => ( +
  • { + e.stopPropagation(); + if (!item.disabled) { + item.action(); + document.body.click(); + } + }}> + {item.tooltip ? ( + +
    + {item.iconClassName && } {item.title} +
    +
    + ) : ( + <> + {item.iconClassName && } {item.title} + + )} +
  • + ))} +
+ )} +
+ ); +} + +export function renderResourceActionMenu(resource: ResourceTreeNode, application: appModels.ApplicationSet, apis: ContextApis): React.ReactNode { + const menuItems = getResourceActionsMenuItems(resource, application.metadata, apis); + + return ( + menuItems}> + {items => ( +
    + {items.map((item, i) => ( +
  • { + e.stopPropagation(); + if (!item.disabled) { + item.action(); + document.body.click(); + } + }}> + {item.iconClassName && } {item.title} +
  • + ))} +
+ )} +
+ ); +} + +export function renderResourceButtons( + resource: ResourceTreeNode, + application: appModels.ApplicationSet, + tree: appModels.ApplicationTree, + apis: ContextApis, + appChanged: BehaviorSubject +): React.ReactNode { + let menuItems: Observable; + menuItems = getActionItems(resource, application, tree, apis, appChanged, true); + return ( + menuItems}> + {items => ( +
+ {items.map((item, i) => ( + { + e.stopPropagation(); + if (!item.disabled) { + item.action(); + document.body.click(); + } + }} + icon={item.iconClassName} + tooltip={ + item.title + .toString() + .charAt(0) + .toUpperCase() + item.title.toString().slice(1) + } + /> + ))} +
+ )} +
+ ); +} + +/* export function syncStatusMessage(app: appModels.Application) { + const source = getAppDefaultSource(app); + const rev = app.status.sync.revision || source.targetRevision || 'HEAD'; + let message = source.targetRevision || 'HEAD'; + + if (app.status.sync.revision) { + if (source.chart) { + message += ' (' + app.status.sync.revision + ')'; + } else if (app.status.sync.revision.length >= 7 && !app.status.sync.revision.startsWith(source.targetRevision)) { + message += ' (' + app.status.sync.revision.substr(0, 7) + ')'; + } + } + switch (app.status.sync.status) { + case appModels.SyncStatuses.Synced: + return ( + + to{' '} + + {message} + {' '} + + ); + case appModels.SyncStatuses.OutOfSync: + return ( + + from{' '} + + {message} + {' '} + + ); + default: + return {message}; + } +} +*/ +export const HealthStatusIcon = ({state, noSpin}: {state: appModels.HealthStatus; noSpin?: boolean}) => { + let color = COLORS.health.unknown; + let icon = 'fa-question-circle'; + + switch (state.status) { + case appModels.HealthStatuses.Healthy: + color = COLORS.health.healthy; + icon = 'fa-heart'; + break; + case appModels.HealthStatuses.Suspended: + color = COLORS.health.suspended; + icon = 'fa-pause-circle'; + break; + case appModels.HealthStatuses.Degraded: + color = COLORS.health.degraded; + icon = 'fa-heart-broken'; + break; + case appModels.HealthStatuses.Progressing: + color = COLORS.health.progressing; + icon = `fa fa-circle-notch ${noSpin ? '' : 'fa-spin'}`; + break; + case appModels.HealthStatuses.Missing: + color = COLORS.health.missing; + icon = 'fa-ghost'; + break; + } + let title: string = state.status; + if (state.message) { + title = `${state.status}: ${state.message}`; + } + return ; +}; + +export const AppSetHealthStatusIcon = ({state, noSpin}: {state: appModels.ApplicationSetStatus; noSpin?: boolean}) => { + let color = COLORS.health.unknown; + let icon = 'fa-question-circle'; + + switch (state.conditions[0].status) { + case appModels.ApplicationSetConditionStatuses.True: + color = COLORS.health.healthy; + icon = 'fa-heart'; + break; + /* case appModels.HealthStatuses.Suspended: + color = COLORS.health.suspended; + icon = 'fa-pause-circle'; + break; + */ + case appModels.ApplicationSetConditionStatuses.False: + color = COLORS.health.degraded; + icon = 'fa-heart-broken'; + break; + /* case appModels.HealthStatuses.Progressing: + color = COLORS.health.progressing; + icon = `fa fa-circle-notch ${noSpin ? '' : 'fa-spin'}`; + break; + */ + case appModels.ApplicationSetConditionStatuses.Unknown: + color = COLORS.health.missing; + icon = 'fa-ghost'; + break; + } + let title: string = state.conditions[0].message; + + if (state.conditions[0].message) { + title = `${state.conditions[0].status}: ${state.conditions[0].message}`; + } + + + // let title: string = "kuku" + return ; +}; + +export const PodHealthIcon = ({state}: {state: appModels.HealthStatus}) => { + let icon = 'fa-question-circle'; + + switch (state.status) { + case appModels.HealthStatuses.Healthy: + icon = 'fa-check'; + break; + case appModels.HealthStatuses.Suspended: + icon = 'fa-check'; + break; + case appModels.HealthStatuses.Degraded: + icon = 'fa-times'; + break; + case appModels.HealthStatuses.Progressing: + icon = 'fa fa-circle-notch fa-spin'; + break; + } + let title: string = state.status; + if (state.message) { + title = `${state.status}: ${state.message}`; + } + return ; +}; + +export const PodPhaseIcon = ({state}: {state: appModels.PodPhase}) => { + let className = ''; + switch (state) { + case appModels.PodPhase.PodSucceeded: + className = 'fa fa-check'; + break; + case appModels.PodPhase.PodRunning: + className = 'fa fa-circle-notch fa-spin'; + break; + case appModels.PodPhase.PodPending: + className = 'fa fa-circle-notch fa-spin'; + break; + case appModels.PodPhase.PodFailed: + className = 'fa fa-times'; + break; + default: + className = 'fa fa-question-circle'; + break; + } + return ; +}; + +export const ResourceResultIcon = ({resource}: {resource: appModels.ResourceResult}) => { + let color = COLORS.sync_result.unknown; + let icon = 'fas fa-question-circle'; + + if (!resource.hookType && resource.status) { + switch (resource.status) { + case appModels.ResultCodes.Synced: + color = COLORS.sync_result.synced; + icon = 'fa-heart'; + break; + case appModels.ResultCodes.Pruned: + color = COLORS.sync_result.pruned; + icon = 'fa-heart'; + break; + case appModels.ResultCodes.SyncFailed: + color = COLORS.sync_result.failed; + icon = 'fa-heart-broken'; + break; + case appModels.ResultCodes.PruneSkipped: + icon = 'fa-heart'; + break; + } + let title: string = resource.message; + if (resource.message) { + title = `${resource.status}: ${resource.message}`; + } + return ; + } + if (resource.hookType && resource.hookPhase) { + let className = ''; + switch (resource.hookPhase) { + case appModels.OperationPhases.Running: + color = COLORS.operation.running; + className = 'fa fa-circle-notch fa-spin'; + break; + case appModels.OperationPhases.Failed: + color = COLORS.operation.failed; + className = 'fa fa-heart-broken'; + break; + case appModels.OperationPhases.Error: + color = COLORS.operation.error; + className = 'fa fa-heart-broken'; + break; + case appModels.OperationPhases.Succeeded: + color = COLORS.operation.success; + className = 'fa fa-heart'; + break; + case appModels.OperationPhases.Terminating: + color = COLORS.operation.terminating; + className = 'fa fa-circle-notch fa-spin'; + break; + } + let title: string = resource.message; + if (resource.message) { + title = `${resource.hookPhase}: ${resource.message}`; + } + return ; + } + return null; +}; + +/*export const getAppOperationState = (app: appModels.Application): appModels.OperationState => { + if (app.operation) { + return { + phase: appModels.OperationPhases.Running, + message: (app.status && app.status.operationState && app.status.operationState.message) || 'waiting to start', + startedAt: new Date().toISOString(), + operation: { + sync: {} + } + } as appModels.OperationState; + } else if (app.metadata.deletionTimestamp) { + return { + phase: appModels.OperationPhases.Running, + startedAt: app.metadata.deletionTimestamp + } as appModels.OperationState; + } else { + return app.status.operationState; + } +}; +*/ + +export function getOperationType(application: appModels.ApplicationSet) { + // const operation = application.operation || (application.status && application.status.operationState && application.status.operationState.operation); + if (application.metadata.deletionTimestamp /*&& !application.operation */) { + return 'Delete'; + } + /* if (operation && operation.sync) { + return 'Sync'; + } + */ + return 'Unknown'; +} + +const getOperationStateTitle = (app: appModels.ApplicationSet) => { + // const appOperationState = getAppOperationState(app); + const operationType = getOperationType(app); + switch (operationType) { + case 'Delete': + return 'Deleting'; + /* case 'Sync': + switch (appOperationState.phase) { + case 'Running': + return 'Syncing'; + case 'Error': + return 'Sync error'; + case 'Failed': + return 'Sync failed'; + case 'Succeeded': + return 'Sync OK'; + case 'Terminating': + return 'Terminated'; + } + */ + } + return 'Unknown'; +}; + +/*export const OperationState = ({app, quiet}: {app: appModels.Application; quiet?: boolean}) => { + const appOperationState = getAppOperationState(app); + if (appOperationState === undefined) { + return ; + } + if (quiet && [appModels.OperationPhases.Running, appModels.OperationPhases.Failed, appModels.OperationPhases.Error].indexOf(appOperationState.phase) === -1) { + return ; + } + + return ( + + {getOperationStateTitle(app)} + + ); +}; +*/ + +export function getPodStateReason(pod: appModels.State): {message: string; reason: string; netContainerStatuses: any[]} { + let reason = pod.status.phase; + let message = ''; + if (pod.status.reason) { + reason = pod.status.reason; + } + + let initializing = false; + + let netContainerStatuses = pod.status.initContainerStatuses || []; + netContainerStatuses = netContainerStatuses.concat(pod.status.containerStatuses || []); + + for (const container of (pod.status.initContainerStatuses || []).slice().reverse()) { + if (container.state.terminated && container.state.terminated.exitCode === 0) { + continue; + } + + if (container.state.terminated) { + if (container.state.terminated.reason) { + reason = `Init:ExitCode:${container.state.terminated.exitCode}`; + } else { + reason = `Init:${container.state.terminated.reason}`; + message = container.state.terminated.message; + } + } else if (container.state.waiting && container.state.waiting.reason && container.state.waiting.reason !== 'PodInitializing') { + reason = `Init:${container.state.waiting.reason}`; + message = `Init:${container.state.waiting.message}`; + } else { + reason = `Init: ${(pod.spec.initContainers || []).length})`; + } + initializing = true; + break; + } + + if (!initializing) { + let hasRunning = false; + for (const container of pod.status.containerStatuses || []) { + if (container.state.waiting && container.state.waiting.reason) { + reason = container.state.waiting.reason; + message = container.state.waiting.message; + } else if (container.state.terminated && container.state.terminated.reason) { + reason = container.state.terminated.reason; + message = container.state.terminated.message; + } else if (container.state.terminated && !container.state.terminated.reason) { + if (container.state.terminated.signal !== 0) { + reason = `Signal:${container.state.terminated.signal}`; + message = ''; + } else { + reason = `ExitCode:${container.state.terminated.exitCode}`; + message = ''; + } + } else if (container.ready && container.state.running) { + hasRunning = true; + } + } + + // change pod status back to 'Running' if there is at least one container still reporting as 'Running' status + if (reason === 'Completed' && hasRunning) { + reason = 'Running'; + message = ''; + } + } + + if ((pod as any).metadata.deletionTimestamp && pod.status.reason === 'NodeLost') { + reason = 'Unknown'; + message = ''; + } else if ((pod as any).metadata.deletionTimestamp) { + reason = 'Terminating'; + message = ''; + } + + return {reason, message, netContainerStatuses}; +} + +export const getPodReadinessGatesState = (pod: appModels.State): {nonExistingConditions: string[]; failedConditions: string[]} => { + if (!pod.spec?.readinessGates?.length) { + return { + nonExistingConditions: [], + failedConditions: [] + }; + } + + const existingConditions = new Map(); + const podConditions = new Map(); + + const podStatusConditions = pod.status?.conditions || []; + + for (const condition of podStatusConditions) { + existingConditions.set(condition.type, true); + // priority order of conditions + // eg. if there are multiple conditions set with same name then the one which comes first is evaluated + if (podConditions.has(condition.type)) { + continue; + } + + if (condition.status === 'False') { + podConditions.set(condition.type, false); + } else if (condition.status === 'True') { + podConditions.set(condition.type, true); + } + } + + const nonExistingConditions: string[] = []; + const failedConditions: string[] = []; + + const readinessGates: appModels.ReadinessGate[] = pod.spec?.readinessGates || []; + + for (const readinessGate of readinessGates) { + if (!existingConditions.has(readinessGate.conditionType)) { + nonExistingConditions.push(readinessGate.conditionType); + } else if (podConditions.get(readinessGate.conditionType) === false) { + failedConditions.push(readinessGate.conditionType); + } + } + + return { + nonExistingConditions, + failedConditions + }; +}; + +export function getConditionCategory(condition: appModels.ApplicationSetCondition): 'error' | 'warning' | 'info' { + if (condition.type.endsWith('Error')) { + return 'error'; + } else if (condition.type.endsWith('Warning')) { + return 'warning'; + } else { + return 'info'; + } +} + +export function isAppNode(node: appModels.ResourceNode) { + return node.kind === 'ApplicationSet' && node.group === 'argoproj.io'; +} + +/*export function getAppOverridesCount(app: appModels.Application) { + const source = getAppDefaultSource(app); + if (source.kustomize && source.kustomize.images) { + return source.kustomize.images.length; + } + if (source.helm && source.helm.parameters) { + return source.helm.parameters.length; + } + return 0; +} +*/ + +// getAppDefaultSource gets the first app source from `sources` or, if that list is missing or empty, the `source` +// field. +/*export function getAppDefaultSource(app?: appModels.Application) { + if (!app) { + return null; + } + return app.spec.sources && app.spec.sources.length > 0 ? app.spec.sources[0] : app.spec.source; +} + +export function getAppSpecDefaultSource(spec: appModels.ApplicationSpec) { + return spec.sources && spec.sources.length > 0 ? spec.sources[0] : spec.source; +} + +export function isAppRefreshing(app: appModels.Application) { + return !!(app.metadata.annotations && app.metadata.annotations[appModels.AnnotationRefreshKey]); +} + +export function setAppRefreshing(app: appModels.Application) { + if (!app.metadata.annotations) { + app.metadata.annotations = {}; + } + if (!app.metadata.annotations[appModels.AnnotationRefreshKey]) { + app.metadata.annotations[appModels.AnnotationRefreshKey] = 'refreshing'; + } +} + +export function refreshLinkAttrs(app: appModels.Application) { + return {disabled: isAppRefreshing(app)}; +} + +export const SyncWindowStatusIcon = ({state, window}: {state: appModels.SyncWindowsState; window: appModels.SyncWindow}) => { + let className = ''; + let color = ''; + let current = ''; + + if (state.windows === undefined) { + current = 'Inactive'; + } else { + for (const w of state.windows) { + if (w.kind === window.kind && w.schedule === window.schedule && w.duration === window.duration && w.timeZone === window.timeZone) { + current = 'Active'; + break; + } else { + current = 'Inactive'; + } + } + } + + switch (current + ':' + window.kind) { + case 'Active:deny': + case 'Inactive:allow': + className = 'fa fa-stop-circle'; + if (window.manualSync) { + color = COLORS.sync_window.manual; + } else { + color = COLORS.sync_window.deny; + } + break; + case 'Active:allow': + case 'Inactive:deny': + className = 'fa fa-check-circle'; + color = COLORS.sync_window.allow; + break; + default: + className = 'fas fa-question-circle'; + color = COLORS.sync_window.unknown; + current = 'Unknown'; + break; + } + + return ( + + {current} + + ); +}; + +export const ApplicationSyncWindowStatusIcon = ({project, state}: {project: string; state: appModels.ApplicationSyncWindowState}) => { + let className = ''; + let color = ''; + let deny = false; + let allow = false; + let inactiveAllow = false; + if (state.assignedWindows !== undefined && state.assignedWindows.length > 0) { + if (state.activeWindows !== undefined && state.activeWindows.length > 0) { + for (const w of state.activeWindows) { + if (w.kind === 'deny') { + deny = true; + } else if (w.kind === 'allow') { + allow = true; + } + } + } + for (const a of state.assignedWindows) { + if (a.kind === 'allow') { + inactiveAllow = true; + } + } + } else { + allow = true; + } + + if (deny || (!deny && !allow && inactiveAllow)) { + className = 'fa fa-stop-circle'; + if (state.canSync) { + color = COLORS.sync_window.manual; + } else { + color = COLORS.sync_window.deny; + } + } else { + className = 'fa fa-check-circle'; + color = COLORS.sync_window.allow; + } + + const ctx = React.useContext(Context); + + return ( + + SyncWindow + + ); +}; + +*/ + +/** + * Automatically stops and restarts the given observable when page visibility changes. + */ +export function handlePageVisibility(src: () => Observable): Observable { + return new Observable((observer: Observer) => { + let subscription: Subscription; + const ensureUnsubscribed = () => { + if (subscription) { + subscription.unsubscribe(); + subscription = null; + } + }; + const start = () => { + ensureUnsubscribed(); + subscription = src().subscribe( + (item: T) => observer.next(item), + err => observer.error(err), + () => observer.complete() + ); + }; + + if (!document.hidden) { + start(); + } + + const visibilityChangeSubscription = fromEvent(document, 'visibilitychange') + // wait until user stop clicking back and forth to avoid restarting observable too often + .pipe(debounceTime(500)) + .subscribe(() => { + if (document.hidden && subscription) { + ensureUnsubscribed(); + } else if (!document.hidden && !subscription) { + start(); + } + }); + + return () => { + visibilityChangeSubscription.unsubscribe(); + ensureUnsubscribed(); + }; + }); +} + +export function parseApiVersion(apiVersion: string): {group: string; version: string} { + const parts = apiVersion.split('/'); + if (parts.length > 1) { + return {group: parts[0], version: parts[1]}; + } + return {version: parts[0], group: ''}; +} + +export function getContainerName(pod: any, containerIndex: number | null): string { + if (containerIndex == null && pod.metadata?.annotations?.['kubectl.kubernetes.io/default-container']) { + return pod.metadata?.annotations?.['kubectl.kubernetes.io/default-container']; + } + const containers = (pod.spec.containers || []).concat(pod.spec.initContainers || []); + const container = containers[containerIndex || 0]; + return container.name; +} + +export function isYoungerThanXMinutes(pod: any, x: number): boolean { + const createdAt = moment(pod.createdAt, 'YYYY-MM-DDTHH:mm:ssZ'); + const xMinutesAgo = moment().subtract(x, 'minutes'); + return createdAt.isAfter(xMinutesAgo); +} + +export const BASE_COLORS = [ + '#0DADEA', // blue + '#DE7EAE', // pink + '#FF9500', // orange + '#4B0082', // purple + '#F5d905', // yellow + '#964B00' // brown +]; + +export const urlPattern = new RegExp( + new RegExp( + // tslint:disable-next-line:max-line-length + /^(https?:\/\/(?:www\.|(?!www))[a-z0-9][a-z0-9-]+[a-z0-9]\.[^\s]{2,}|www\.[a-z0-9][a-z0-9-]+[a-z0-9]\.[^\s]{2,}|https?:\/\/(?:www\.|(?!www))[a-z0-9]+\.[^\s]{2,}|www\.[a-z0-9]+\.[^\s]{2,})$/, + 'gi' + ) +); + +export function appQualifiedName(app: appModels.Application, nsEnabled: boolean): string { + return (nsEnabled ? app.metadata.namespace + '/' : '') + app.metadata.name; +} + +export function appInstanceName(app: appModels.Application): string { + return app.metadata.namespace + '_' + app.metadata.name; +} + +export function appSetQualifiedName(appSet: appModels.ApplicationSet, nsEnabled: boolean): string { + return (nsEnabled ? appSet.metadata.namespace + '/' : '') + appSet.metadata.name; +} + +export function appSetInstanceName(appSet: appModels.ApplicationSet): string { + return appSet.metadata.namespace + '_' + appSet.metadata.name; +} + +export function formatCreationTimestamp(creationTimestamp: string) { + const createdAt = moment + .utc(creationTimestamp) + .local() + .format('MM/DD/YYYY HH:mm:ss'); + const fromNow = moment + .utc(creationTimestamp) + .local() + .fromNow(); + return ( + + {createdAt} + ({fromNow}) + + ); +} + +export const selectPostfix = (arr: string[], singular: string, plural: string) => (arr.length > 1 ? plural : singular); diff --git a/ui/src/app/applications/components/resource-details/resource-details.tsx b/ui/src/app/applications/components/resource-details/resource-details.tsx index 6477509370905..690247a3ce189 100644 --- a/ui/src/app/applications/components/resource-details/resource-details.tsx +++ b/ui/src/app/applications/components/resource-details/resource-details.tsx @@ -199,7 +199,7 @@ export const ResourceDetails = (props: ResourceDetailsProps) => { } ]; - if (application.status.sync.status !== SyncStatuses.Synced) { + if ('sync' in application.status && application.status.sync.status !== SyncStatuses.Synced) { tabs.push({ icon: 'fa fa-file-medical', title: 'DIFF', diff --git a/ui/src/app/applications/components/utils.tsx b/ui/src/app/applications/components/utils.tsx index 0df581ca0f291..969e71c21c20c 100644 --- a/ui/src/app/applications/components/utils.tsx +++ b/ui/src/app/applications/components/utils.tsx @@ -324,7 +324,7 @@ export const deletePodAction = async (pod: appModels.Pod, appContext: AppContext ); }; -export const deletePopup = async (ctx: ContextApis, resource: ResourceTreeNode, application: appModels.Application, appChanged?: BehaviorSubject) => { +export const deletePopup = async (ctx: ContextApis, resource: ResourceTreeNode, application: appModels.AbstractApplication, appChanged?: BehaviorSubject) => { const isManaged = !!resource.status; const deleteOptions = { option: 'foreground' @@ -429,7 +429,7 @@ function getActionItems( application: appModels.Application, tree: appModels.ApplicationTree, apis: ContextApis, - appChanged: BehaviorSubject, + appChanged: BehaviorSubject, isQuickStart: boolean ): Observable { const isRoot = resource.root && nodeKey(resource.root) === nodeKey(resource); @@ -517,7 +517,7 @@ export function renderResourceMenu( application: appModels.Application, tree: appModels.ApplicationTree, apis: ContextApis, - appChanged: BehaviorSubject, + appChanged: BehaviorSubject, getApplicationActionMenu: () => any ): React.ReactNode { let menuItems: Observable; @@ -593,7 +593,7 @@ export function renderResourceButtons( application: appModels.Application, tree: appModels.ApplicationTree, apis: ContextApis, - appChanged: BehaviorSubject + appChanged: BehaviorSubject ): React.ReactNode { let menuItems: Observable; menuItems = getActionItems(resource, application, tree, apis, appChanged, true); @@ -696,6 +696,35 @@ export const HealthStatusIcon = ({state, noSpin}: {state: appModels.HealthStatus return ; }; +/** from above */ +export const AppSetHealthStatusIcon = ({state, noSpin}: {state: appModels.ApplicationSetStatus; noSpin?: boolean}) => { + let color = COLORS.health.unknown; + let icon = 'fa-question-circle'; + + switch (state.conditions && state.conditions[0].status) { + case appModels.ApplicationSetConditionStatuses.True: + color = COLORS.health.healthy; + icon = 'fa-heart'; + break; + case appModels.ApplicationSetConditionStatuses.False: + color = COLORS.health.degraded; + icon = 'fa-heart-broken'; + break; + case appModels.ApplicationSetConditionStatuses.Unknown: + color = COLORS.health.missing; + icon = 'fa-ghost'; + break; + } + let title: string = state.conditions && state.conditions[0].message; + + if (state.conditions && state.conditions[0].message) { + title = `${state.conditions[0].status}: ${state.conditions[0].message}`; + } + + return ; +}; + + export const PodHealthIcon = ({state}: {state: appModels.HealthStatus}) => { let icon = 'fa-question-circle'; @@ -1005,11 +1034,29 @@ export function getConditionCategory(condition: appModels.ApplicationCondition): } } +export function getAppSetConditionCategory(condition: appModels.ApplicationSetCondition): 'error' | 'warning' | 'info' { + if (condition.type.endsWith('Error')) { + return 'error'; + } else if (condition.type.endsWith('Warning')) { + return 'warning'; + } else { + return 'info'; + } +} + + export function isAppNode(node: appModels.ResourceNode) { return node.kind === 'Application' && node.group === 'argoproj.io'; } -export function getAppOverridesCount(app: appModels.Application) { +export function getAppOverridesCount(app: appModels.AbstractApplication) { + var isApplicationSet = true; + if ("resource" in app.status) { + isApplicationSet = false; + } + if (isApplicationSet) { + return 0; + } const source = getAppDefaultSource(app); if (source.kustomize && source.kustomize.images) { return source.kustomize.images.length; @@ -1022,7 +1069,7 @@ export function getAppOverridesCount(app: appModels.Application) { // getAppDefaultSource gets the first app source from `sources` or, if that list is missing or empty, the `source` // field. -export function getAppDefaultSource(app?: appModels.Application) { +export function getAppDefaultSource(app?: appModels.AbstractApplication) { if (!app) { return null; } @@ -1234,6 +1281,14 @@ export function appInstanceName(app: appModels.Application): string { return app.metadata.namespace + '_' + app.metadata.name; } +export function appSetQualifiedName(appSet: appModels.ApplicationSet, nsEnabled: boolean): string { + return (nsEnabled ? appSet.metadata.namespace + '/' : '') + appSet.metadata.name; +} + +export function appSetInstanceName(appSet: appModels.ApplicationSet): string { + return appSet.metadata.namespace + '_' + appSet.metadata.name; +} + export function formatCreationTimestamp(creationTimestamp: string) { const createdAt = moment .utc(creationTimestamp) diff --git a/ui/src/app/settings/components/settings-container.tsx b/ui/src/app/settings/components/settings-container.tsx index 186cbb08c6e3e..85f9ffa7caa3e 100644 --- a/ui/src/app/settings/components/settings-container.tsx +++ b/ui/src/app/settings/components/settings-container.tsx @@ -12,10 +12,14 @@ import {ProjectsList} from './projects-list/projects-list'; import {ReposList} from './repos-list/repos-list'; import {SettingsOverview} from './settings-overview/settings-overview'; import {AppearanceList} from './appearance-list/appearance-list'; +// import { ApplicationSetsList } from '../../applications/components/applications-list/applications-list'; +import { ApplicationSetsList } from '../../applications/components/applicationsets-list/applications-list'; +// import {ApplicationSetsList} from './../../applicationsets/components/applications-list/applications-list'; export const SettingsContainer = (props: RouteComponentProps) => ( + diff --git a/ui/src/app/settings/components/settings-overview/settings-overview.tsx b/ui/src/app/settings/components/settings-overview/settings-overview.tsx index 102c2c28b1510..5ca2efd1b16a3 100644 --- a/ui/src/app/settings/components/settings-overview/settings-overview.tsx +++ b/ui/src/app/settings/components/settings-overview/settings-overview.tsx @@ -7,6 +7,11 @@ import {AppContext} from '../../../shared/context'; require('./settings-overview.scss'); const settings = [ + { + title: 'ApplicationSets', + description: 'Manage ApplicationSets', + path: './applicationsets' + }, { title: 'Repositories', description: 'Configure connected repositories', diff --git a/ui/src/app/shared/components/layout/layout.tsx b/ui/src/app/shared/components/layout/layout.tsx index dcf98dde565eb..744d3010b2779 100644 --- a/ui/src/app/shared/components/layout/layout.tsx +++ b/ui/src/app/shared/components/layout/layout.tsx @@ -1,6 +1,6 @@ import * as React from 'react'; -import {Sidebar} from '../../../sidebar/sidebar'; -import {ViewPreferences} from '../../services'; +import {Sidebar, AppSetSidebar} from '../../../sidebar/sidebar'; +import {ViewPreferences, ViewAppSetPreferences} from '../../services'; require('./layout.scss'); @@ -14,6 +14,14 @@ export interface LayoutProps { const getBGColor = (theme: string): string => (theme === 'light' ? '#dee6eb' : '#100f0f'); +export interface AppSetLayoutProps { + navItems: Array<{path: string; iconClassName: string; title: string}>; + onVersionClick?: () => void; + children?: React.ReactNode; + pref: ViewAppSetPreferences; + isExtension?: boolean; +} + export const Layout = (props: LayoutProps) => (
@@ -25,3 +33,14 @@ export const Layout = (props: LayoutProps) => (
); + +export const AppSetLayout = (props: AppSetLayoutProps) => ( +
+
+ +
+ {props.children} +
+
+
+); diff --git a/ui/src/app/shared/models.ts b/ui/src/app/shared/models.ts index ef0d47331c403..ff4c306a02ac9 100644 --- a/ui/src/app/shared/models.ts +++ b/ui/src/app/shared/models.ts @@ -16,6 +16,7 @@ interface ItemsList { } export interface ApplicationList extends ItemsList {} +export interface ApplicationSetList extends ItemsList {} export interface SyncOperationResource { group: string; @@ -132,16 +133,27 @@ export const AnnotationSyncWaveKey = 'argocd.argoproj.io/sync-wave'; export const AnnotationDefaultView = 'pref.argocd.argoproj.io/default-view'; export const AnnotationDefaultPodSort = 'pref.argocd.argoproj.io/default-pod-sort'; -export interface Application { +export interface AbstractApplication { apiVersion?: string; kind?: string; metadata: models.ObjectMeta; + + spec: any; + status: any; +} + +export interface Application extends AbstractApplication { spec: ApplicationSpec; status: ApplicationStatus; operation?: Operation; isAppOfAppsPattern?: boolean; } +export interface ApplicationSet extends AbstractApplication { + spec: ApplicationSetSpec; + status: ApplicationSetStatus; +} + export type WatchType = 'ADDED' | 'MODIFIED' | 'DELETED' | 'ERROR'; export interface ApplicationWatchEvent { @@ -149,6 +161,11 @@ export interface ApplicationWatchEvent { application: Application; } +export interface ApplicationSetWatchEvent { + type: WatchType; + applicationSet: ApplicationSet; +} + export interface ComponentParameter { component: string; name: string; @@ -299,6 +316,60 @@ export interface RevisionHistory { deployedAt: models.Time; } +export interface ApplicationSetSpec { + goTemplate: boolean; + // generators: ApplicationSetGenerator[]; + // template: ApplicationSetTemplate; + syncPolicy?: ApplicationSetSyncPolicy; + // strategy: ApplicationSetStrategy; + preservedFields: ApplicationPreservedFields; +} + +export interface ApplicationSetSyncPolicy{ + preserveResourcesOnDeletion: boolean; +} + +export interface ApplicationPreservedFields { + annotations: string[]; +} + +export interface ApplicationSetStatus { + conditions?: ApplicationSetCondition[]; + applicationStatus: ApplicationSetApplicationStatus[]; +} + +export interface ApplicationSetCondition { + type: ApplicationSetConditionType; + message: string; + status: ApplicationSetConditionStatus; + reason: string; +} + +export interface ApplicationSetApplicationStatus { + application: string; + message: string; + status: string; + step: string; +} + +export type ApplicationSetConditionType = 'ErrorOccurred' | 'ParametersGenerated' | 'ResourcesUpToDate' | 'RolloutProgressing'; + +export const ApplicationSetConditionTypes: {[key: string]: ApplicationSetConditionType} = { + ErrorOccurred: 'ErrorOccurred', + ParametersGenerated: 'ParametersGenerated', + ResourcesUpToDate: 'ResourcesUpToDate', + RolloutProgressing: 'RolloutProgressing', +}; + +export type ApplicationSetConditionStatus = 'True' | 'False' | 'Unknown'; + +export const ApplicationSetConditionStatuses: {[key: string]: ApplicationSetConditionStatus} = { + True: 'True', + False: 'False', + Unknown: 'Unknown', +}; + + export type SyncStatusCode = 'Unknown' | 'Synced' | 'OutOfSync'; export const SyncStatuses: {[key: string]: SyncStatusCode} = { diff --git a/ui/src/app/shared/services/applicationsets-service.ts b/ui/src/app/shared/services/applicationsets-service.ts new file mode 100644 index 0000000000000..6f72bbaf4b936 --- /dev/null +++ b/ui/src/app/shared/services/applicationsets-service.ts @@ -0,0 +1,204 @@ +import * as deepMerge from 'deepmerge'; +import {Observable} from 'rxjs'; +import {map, repeat, retry} from 'rxjs/operators'; + +import * as models from '../models'; +import {isValidURL} from '../utils'; +import requests from './requests'; + +interface QueryOptions { + fields: string[]; + exclude?: boolean; + selector?: string; + appSetNamespace?: string; +} + +function optionsToSearch(options?: QueryOptions) { + if (options) { + return {fields: (options.exclude ? '-' : '') + options.fields.join(','), selector: options.selector || '', appNamespace: options.appSetNamespace || ''}; + } + return {}; +} + +export class ApplicationSetsService { + public list(options?: QueryOptions): Promise { + return requests + .get('/applicationsets') + .query({...optionsToSearch(options)}) + .then(res => res.body as models.ApplicationSetList) + .then(list => { + list.items = (list.items || []).map(app => this.parseAppSetFields(app)); + return list; + }); + } + + public get(name: string, appNamespace: string, refresh?: 'normal' | 'hard'): Promise { + const query: {[key: string]: string} = {}; + if (refresh) { + query.refresh = refresh; + } + if (appNamespace) { + query.appNamespace = appNamespace; + } + return requests + .get(`/applicationsets/${name}`) + .query(query) + .then(res => this.parseAppSetFields(res.body)); + } + + + public resourceTree(name: string, appNamespace: string): Promise { + return requests + .get(`/applicationsets/${name}/resource-tree`) + .query({appNamespace}) + .then(res => res.body as models.ApplicationTree); + } + + public watchResourceTree(name: string, appNamespace: string): Observable { + return requests + .loadEventSource(`/stream/applicationsets/${name}/resource-tree?appNamespace=${appNamespace}`) + .pipe(map(data => JSON.parse(data).result as models.ApplicationTree)); + } + + public managedResources(name: string, appNamespace: string, options: {id?: models.ResourceID; fields?: string[]} = {}): Promise { + return requests + .get(`/applicationsets/${name}/managed-resources`) + .query(`appNamespace=${appNamespace.toString()}`) + .query({...options.id, fields: (options.fields || []).join(',')}) + .then(res => (res.body.items as any[]) || []) + .then(items => { + items.forEach(item => { + if (item.liveState) { + item.liveState = JSON.parse(item.liveState); + } + if (item.targetState) { + item.targetState = JSON.parse(item.targetState); + } + if (item.predictedLiveState) { + item.predictedLiveState = JSON.parse(item.predictedLiveState); + } + if (item.normalizedLiveState) { + item.normalizedLiveState = JSON.parse(item.normalizedLiveState); + } + }); + return items as models.ResourceDiff[]; + }); + } + + public getManifest(name: string, appNamespace: string, revision: string): Promise { + return requests + .get(`/applicationsets/${name}/manifests`) + .query({name, revision}) + .then(res => res.body as models.ManifestResponse); + } + + public updateSpec(appName: string, appNamespace: string, spec: models.ApplicationSpec): Promise { + return requests + .put(`/applicationsets/${appName}/spec`) + .send(spec) + .then(res => res.body as models.ApplicationSpec); + } + + public update(app: models.ApplicationSet, query: {validate?: boolean} = {}): Promise { + return requests + .put(`/applicationsets/${app.metadata.name}`) + .query(query) + .send(app) + .then(res => this.parseAppSetFields(res.body)); + } + + public create(app: models.Application): Promise { + // Namespace may be specified in the app name. We need to parse and + // handle it accordingly. + if (app.metadata.name.includes('/')) { + const nns = app.metadata.name.split('/', 2); + app.metadata.name = nns[1]; + app.metadata.namespace = nns[0]; + } + return requests + .post(`/applicationsets`) + .send(app) + .then(res => this.parseAppSetFields(res.body)); + } + + public delete(name: string, appNamespace: string, propagationPolicy: string): Promise { + let cascade = true; + if (propagationPolicy === 'non-cascading') { + propagationPolicy = ''; + cascade = false; + } + return requests + .delete(`/applicationsets/${name}`) + .query({ + cascade, + propagationPolicy, + appNamespace + }) + .send({}) + .then(() => true); + } + + public watch(query?: {name?: string; resourceVersion?: string; projects?: string[]; appNamespace?: string}, options?: QueryOptions): Observable { + const search = new URLSearchParams(); + if (query) { + if (query.name) { + search.set('name', query.name); + } + if (query.resourceVersion) { + search.set('resourceVersion', query.resourceVersion); + } + if (query.appNamespace) { + search.set('appNamespace', query.appNamespace); + } + } + if (options) { + const searchOptions = optionsToSearch(options); + search.set('fields', searchOptions.fields); + search.set('selector', searchOptions.selector); + search.set('appNamespace', searchOptions.appNamespace); + query?.projects?.forEach(project => search.append('projects', project)); + } + const searchStr = search.toString(); + const url = `/stream/applicationsets${(searchStr && '?' + searchStr) || ''}`; + return requests + .loadEventSource(url) + .pipe(repeat()) + .pipe(retry()) + .pipe(map(data => JSON.parse(data).result as models.ApplicationSetWatchEvent)) + .pipe( + map(watchEvent => { + watchEvent.applicationSet = this.parseAppSetFields(watchEvent.applicationSet); + return watchEvent; + }) + ); + } + + public getResource(name: string, appNamespace: string, resource: models.ResourceNode): Promise { + return requests + .get(`/applicationsets/${name}/resource`) + .query({ + name: resource.name, + appNamespace, + namespace: resource.namespace, + resourceName: resource.name, + version: resource.version, + kind: resource.kind, + group: resource.group || '' // The group query param must be present even if empty. + }) + .then(res => res.body as {manifest: string}) + .then(res => JSON.parse(res.manifest) as models.State); + } + + + private parseAppSetFields(data: any): models.ApplicationSet { + data = deepMerge( + { + apiVersion: 'argoproj.io/v1alpha1', + kind: 'ApplicationSet', + }, + data + ); + + return data as models.ApplicationSet; + } +} diff --git a/ui/src/app/shared/services/extensions-service.ts b/ui/src/app/shared/services/extensions-service.ts index 3975fb1aec018..fdf1fe902bb95 100644 --- a/ui/src/app/shared/services/extensions-service.ts +++ b/ui/src/app/shared/services/extensions-service.ts @@ -1,12 +1,14 @@ import * as React from 'react'; import * as minimatch from 'minimatch'; -import {Application, ApplicationTree, State} from '../models'; +import {Application, ApplicationSet, ApplicationTree, State} from '../models'; const extensions = { resourceExtentions: new Array(), + appSetResourceExtentions: new Array(), systemLevelExtensions: new Array(), - appViewExtensions: new Array() + appViewExtensions: new Array(), + appSetViewExtensions: new Array() }; function registerResourceExtension(component: ExtensionComponent, group: string, kind: string, tabTitle: string, opts?: {icon: string}) { @@ -21,6 +23,11 @@ function registerAppViewExtension(component: ExtensionComponent, title: string, extensions.appViewExtensions.push({component, title, icon}); } +function registerAppSetViewExtension(component: AppSetExtensionComponent, title: string, icon: string) { + extensions.appSetViewExtensions.push({component, title, icon}); +} + + let legacyInitialized = false; function initLegacyExtensions() { @@ -43,6 +50,14 @@ export interface ResourceTabExtension { icon?: string; } +export interface AppSetResourceTabExtension { + title: string; + group: string; + kind: string; + component: AppSetExtensionComponent; + icon?: string; +} + export interface SystemLevelExtension { title: string; component: SystemExtensionComponent; @@ -56,25 +71,47 @@ export interface AppViewExtension { icon?: string; } +export interface AppSetViewExtension { + component: AppSetViewExtensionComponent; + title: string; + icon?: string; +} export type ExtensionComponent = React.ComponentType; +export type AppSetExtensionComponent = React.ComponentType; export type SystemExtensionComponent = React.ComponentType; export type AppViewExtensionComponent = React.ComponentType; +export type AppSetViewExtensionComponent = React.ComponentType; export interface Extension { component: ExtensionComponent; } +export interface AppSetExtension { + component: AppSetExtensionComponent; +} + export interface ExtensionComponentProps { resource: State; tree: ApplicationTree; application: Application; } +export interface AppSetExtensionComponentProps { + resource: State; + tree: ApplicationTree; + application: ApplicationSet; +} + export interface AppViewComponentProps { application: Application; tree: ApplicationTree; } +export interface AppSetViewComponentProps { + application: ApplicationSet; + tree: ApplicationTree; +} + export class ExtensionsService { public getResourceTabs(group: string, kind: string): ResourceTabExtension[] { initLegacyExtensions(); @@ -82,6 +119,45 @@ export class ExtensionsService { return items.sort((a, b) => a.title.localeCompare(b.title)); } + public getAppSetResourceTabs(group: string, kind: string): AppSetResourceTabExtension[] { + initLegacyExtensions(); + const items = extensions.appSetResourceExtentions.filter(extension => minimatch(group, extension.group) && minimatch(kind, extension.kind)).slice(); + return items.sort((a, b) => a.title.localeCompare(b.title)); + } + + + public getSystemExtensions(): SystemLevelExtension[] { + return extensions.systemLevelExtensions.slice(); + } + + public getAppViewExtensions(): AppViewExtension[] { + return extensions.appViewExtensions.slice(); + } + + public getAppSetViewExtensions(): AppSetViewExtension[] { + return extensions.appSetViewExtensions.slice(); + } + +} + +((window: any) => { + // deprecated: kept for backwards compatibility + window.extensions = {resources: {}}; + window.extensionsAPI = { + registerResourceExtension, + registerSystemLevelExtension, + registerAppViewExtension + }; +})(window); + + +export class AppSetExtensionsService { + public getResourceTabs(group: string, kind: string): ResourceTabExtension[] { + initLegacyExtensions(); + const items = extensions.resourceExtentions.filter(extension => minimatch(group, extension.group) && minimatch(kind, extension.kind)).slice(); + return items.sort((a, b) => a.title.localeCompare(b.title)); + } + public getSystemExtensions(): SystemLevelExtension[] { return extensions.systemLevelExtensions.slice(); } diff --git a/ui/src/app/shared/services/index.ts b/ui/src/app/shared/services/index.ts index 8a3af94aee6cf..aca759a01e255 100644 --- a/ui/src/app/shared/services/index.ts +++ b/ui/src/app/shared/services/index.ts @@ -1,5 +1,6 @@ import {AccountsService} from './accounts-service'; import {ApplicationsService} from './applications-service'; +import {ApplicationSetsService} from './applicationsets-service'; import {AuthService} from './auth-service'; import {CertificatesService} from './cert-service'; import {ClustersService} from './clusters-service'; @@ -12,8 +13,10 @@ import {RepoCredsService} from './repocreds-service'; import {UserService} from './user-service'; import {VersionService} from './version-service'; import {ViewPreferencesService} from './view-preferences-service'; +// import {ViewAppSetPreferencesService} from './view-preferences-service'; export interface Services { applications: ApplicationsService; + applicationSets: ApplicationSetsService; users: UserService; authService: AuthService; certs: CertificatesService; @@ -22,6 +25,7 @@ export interface Services { clusters: ClustersService; projects: ProjectsService; viewPreferences: ViewPreferencesService; + // viewAppSetPreferences: ViewAppSetPreferencesService; version: VersionService; accounts: AccountsService; gpgkeys: GnuPGPublicKeyService; @@ -31,6 +35,7 @@ export interface Services { export const services: Services = { applications: new ApplicationsService(), + applicationSets: new ApplicationSetsService(), authService: new AuthService(), clusters: new ClustersService(), users: new UserService(), @@ -39,6 +44,7 @@ export const services: Services = { repocreds: new RepoCredsService(), projects: new ProjectsService(), viewPreferences: new ViewPreferencesService(), + // viewAppSetPreferences: new ViewAppSetPreferencesService(), version: new VersionService(), accounts: new AccountsService(), gpgkeys: new GnuPGPublicKeyService(), diff --git a/ui/src/app/shared/services/view-preferences-service.ts b/ui/src/app/shared/services/view-preferences-service.ts index 314170dba0404..9a56b34d7e2f6 100644 --- a/ui/src/app/shared/services/view-preferences-service.ts +++ b/ui/src/app/shared/services/view-preferences-service.ts @@ -12,24 +12,49 @@ export enum AppsDetailsViewKey { Pods = 'pods' } -export interface AppDetailsPreferences { +// export type AppSetsDetailsViewType = 'tree' | 'list' ; + +// export enum AppSetsDetailsViewKey { +// Tree = 'tree', +// List = 'list', +// } + +export interface AbstractAppDetailsPreferences { resourceFilter: string[]; - view: AppsDetailsViewType | string; + darkMode: boolean; + hideFilters: boolean; + groupNodes?: boolean; + zoom: number; + view: any; + resourceView: 'manifest' | 'diff' | 'desiredManifest'; inlineDiff: boolean; compactDiff: boolean; hideManagedFields?: boolean; orphanedResources: boolean; podView: PodViewPreferences; - darkMode: boolean; followLogs: boolean; - hideFilters: boolean; wrapLines: boolean; - groupNodes?: boolean; - zoom: number; podGroupCount: number; } +export interface AppDetailsPreferences extends AbstractAppDetailsPreferences { + view: AppsDetailsViewType | string; + // resourceView: 'manifest' | 'diff' | 'desiredManifest'; + // inlineDiff: boolean; + // compactDiff: boolean; + // hideManagedFields?: boolean; + // orphanedResources: boolean; + // podView: PodViewPreferences; + // followLogs: boolean; + // wrapLines: boolean; + // podGroupCount: number; +} + +// export interface AppSetDetailsPreferences extends AbstractAppDetailsPreferences { +// view: AppSetsDetailsViewType | string; +// } + export interface PodViewPreferences { sortMode: PodGroupType; hideUnschedulable: boolean; @@ -47,7 +72,27 @@ export enum AppsListViewKey { Tiles = 'tiles' } -export class AppsListPreferences { +export abstract class AbstractAppsListPreferences { + public static countEnabledFilters(pref: AbstractAppsListPreferences) {} + + public static clearFilters(pref: AppsListPreferences) {} + + public labelsFilter: string[]; + public projectsFilter: string[]; + public reposFilter: string[]; + public syncFilter: string[]; + public autoSyncFilter: string[]; + public healthFilter: string[]; + public namespacesFilter: string[]; + public clustersFilter: string[]; + public view: AppsListViewType; + public hideFilters: boolean; + public statusBarView: HealthStatusBarPreferences; + public showFavorites: boolean; + public favoritesAppList: string[]; +} + +export class AppsListPreferences extends AbstractAppsListPreferences { public static countEnabledFilters(pref: AppsListPreferences) { return [pref.clustersFilter, pref.healthFilter, pref.labelsFilter, pref.namespacesFilter, pref.projectsFilter, pref.reposFilter, pref.syncFilter].reduce( (count, filter) => { @@ -71,35 +116,51 @@ export class AppsListPreferences { pref.autoSyncFilter = []; pref.showFavorites = false; } - - public labelsFilter: string[]; - public projectsFilter: string[]; - public reposFilter: string[]; - public syncFilter: string[]; - public autoSyncFilter: string[]; - public healthFilter: string[]; - public namespacesFilter: string[]; - public clustersFilter: string[]; - public view: AppsListViewType; - public hideFilters: boolean; - public statusBarView: HealthStatusBarPreferences; - public showFavorites: boolean; - public favoritesAppList: string[]; } -export interface ViewPreferences { +// export class AppSetsListPreferences extends AbstractAppsListPreferences { +// public static countEnabledFilters(pref: AppSetsListPreferences) { +// return [pref.labelsFilter].reduce( +// (count, filter) => { +// if (filter && filter.length > 0) { +// return count + 1; +// } +// return count; +// }, +// 0 +// ); +// } + +// public static clearFilters(pref: AppSetsListPreferences) { +// pref.labelsFilter = []; +// pref.showFavorites = false; +// } +// } + +export interface AbstractViewPreferences { version: number; - appDetails: AppDetailsPreferences; - appList: AppsListPreferences; pageSizes: {[key: string]: number}; sortOptions?: {[key: string]: string}; hideBannerContent: string; hideSidebar: boolean; position: string; theme: string; + appDetails: AbstractAppDetailsPreferences; + appList: AbstractAppsListPreferences; +} + +export interface ViewPreferences extends AbstractViewPreferences { + appDetails: AppDetailsPreferences; + appList: AppsListPreferences; } +// export interface ViewAppSetPreferences extends AbstractViewPreferences { +// appDetails: AppSetDetailsPreferences; +// appList: AppSetsListPreferences; +// } + const VIEW_PREFERENCES_KEY = 'view_preferences'; +// const VIEW_APPSET_PREFERENCES_KEY = 'view_app_set_preferences'; const minVer = 5; @@ -148,8 +209,54 @@ const DEFAULT_PREFERENCES: ViewPreferences = { theme: 'light' }; -export class ViewPreferencesService { - private preferencesSubj: BehaviorSubject; + +// const DEFAULT_APPSET_PREFERENCES: ViewAppSetPreferences = { +// version: 1, +// appDetails: { +// view: 'tree', +// hideFilters: false, +// resourceFilter: [], +// inlineDiff: false, +// compactDiff: false, +// hideManagedFields: true, +// resourceView: 'manifest', +// orphanedResources: false, +// podView: { +// sortMode: 'node', +// hideUnschedulable: true +// }, +// darkMode: false, +// followLogs: false, +// wrapLines: false, +// zoom: 1.0, +// podGroupCount: 15.0 +// }, +// appList: { +// view: 'tiles' as AppsListViewType, +// labelsFilter: new Array(), +// projectsFilter: new Array(), +// namespacesFilter: new Array(), +// clustersFilter: new Array(), +// reposFilter: new Array(), +// syncFilter: new Array(), +// autoSyncFilter: new Array(), +// healthFilter: new Array(), +// hideFilters: false, +// showFavorites: false, +// favoritesAppList: new Array(), +// statusBarView: { +// showHealthStatusBar: true +// } +// }, +// pageSizes: {}, +// hideBannerContent: '', +// hideSidebar: false, +// position: '', +// theme: 'light' +// }; + +export abstract class AbstractViewPreferencesService { + protected preferencesSubj: BehaviorSubject; public init() { if (!this.preferencesSubj) { @@ -160,17 +267,26 @@ export class ViewPreferencesService { } } - public getPreferences(): Observable { + public getPreferences(): Observable { return this.preferencesSubj; } + public abstract updatePreferences(change: Partial): void; + + protected abstract loadPreferences(): AbstractViewPreferences; +} + + +export class ViewPreferencesService extends AbstractViewPreferencesService { + protected preferencesSubj: BehaviorSubject; + public updatePreferences(change: Partial) { const nextPref = Object.assign({}, this.preferencesSubj.getValue(), change, {version: minVer}); window.localStorage.setItem(VIEW_PREFERENCES_KEY, JSON.stringify(nextPref)); this.preferencesSubj.next(nextPref); } - private loadPreferences(): ViewPreferences { + protected loadPreferences(): AbstractViewPreferences { let preferences: ViewPreferences; const preferencesStr = window.localStorage.getItem(VIEW_PREFERENCES_KEY); if (preferencesStr) { @@ -188,3 +304,28 @@ export class ViewPreferencesService { return deepMerge(DEFAULT_PREFERENCES, preferences); } } + +// export class ViewAppSetPreferencesService extends AbstractViewPreferencesService { +// protected preferencesSubj: BehaviorSubject; + +// public updatePreferences(change: Partial) { +// } + +// protected loadPreferences(): AbstractViewPreferences { +// let preferences: ViewAppSetPreferences; +// const preferencesStr = window.localStorage.getItem(VIEW_APPSET_PREFERENCES_KEY); +// if (preferencesStr) { +// try { +// preferences = JSON.parse(preferencesStr); +// } catch (e) { +// preferences = DEFAULT_APPSET_PREFERENCES; +// } +// if (!preferences.version || preferences.version < minVer) { +// preferences = DEFAULT_APPSET_PREFERENCES; +// } +// } else { +// preferences = DEFAULT_APPSET_PREFERENCES; +// } +// return deepMerge(DEFAULT_APPSET_PREFERENCES, preferences); +// } +// } diff --git a/ui/src/app/sidebar/sidebar.tsx b/ui/src/app/sidebar/sidebar.tsx index c690565d01cb5..d4e7d4e488ed6 100644 --- a/ui/src/app/sidebar/sidebar.tsx +++ b/ui/src/app/sidebar/sidebar.tsx @@ -3,7 +3,7 @@ import {Boundary, Placement} from 'popper.js'; import {useData} from 'argo-ui/v2'; import * as React from 'react'; import {Context} from '../shared/context'; -import {services, ViewPreferences} from '../shared/services'; +import {services, ViewPreferences, ViewAppSetPreferences} from '../shared/services'; require('./sidebar.scss'); @@ -13,7 +13,15 @@ interface SidebarProps { pref: ViewPreferences; } +interface AppSetSidebarProps { + onVersionClick: () => void; + navItems: {path: string; iconClassName: string; title: string; tooltip?: string}[]; + pref: ViewAppSetPreferences; +} + + export const SIDEBAR_TOOLS_ID = 'sidebar-tools'; +export const APPSET_SIDEBAR_TOOLS_ID = 'appset-sidebar-tools'; export const useSidebarTarget = () => { const sidebarTarget = React.useRef(document.createElement('div')); @@ -29,6 +37,20 @@ export const useSidebarTarget = () => { return sidebarTarget; }; +export const useAppSetSidebarTarget = () => { + const sidebarTarget = React.useRef(document.createElement('div')); + + React.useEffect(() => { + const sidebar = document.getElementById(APPSET_SIDEBAR_TOOLS_ID); + sidebar.appendChild(sidebarTarget?.current); + return () => { + sidebarTarget.current?.remove(); + }; + }, []); + + return sidebarTarget; +}; + export const Sidebar = (props: SidebarProps) => { const context = React.useContext(Context); const [version, loading, error] = useData(() => services.version.version()); @@ -95,3 +117,70 @@ export const Sidebar = (props: SidebarProps) => {
); }; + +export const AppSetSidebar = (props: AppSetSidebarProps) => { + const context = React.useContext(Context); + const [version, loading, error] = useData(() => services.version.version()); + const locationPath = context.history.location.pathname; + + const tooltipProps = { + placement: 'right', + popperOptions: { + modifiers: { + preventOverflow: { + boundariesElement: 'window' + } + } + } + }; + + return ( +
+
+
+
services.viewAppSetPreferences.updatePreferences({...props.pref, hideSidebar: !props.pref.hideSidebar})} className='sidebar__collapse-button'> + +
+ {!props.pref.hideSidebar && ( +
+ Argo +
+ {loading ? 'Loading...' : error?.state ? 'Unknown' : version?.Version || 'Unknown'} +
+
+ )} + Argo{' '} +
+ + {(props.navItems || []).map(item => ( + +
context.history.push(item.path)}> + +
+ + {!props.pref.hideSidebar && item.title} +
+
+
+
+ ))} + + {props.pref.hideSidebar && ( + +
services.viewAppSetPreferences.updatePreferences({...props.pref, hideSidebar: !props.pref.hideSidebar})} + className='sidebar__nav-item sidebar__filter-button'> +
+ +
+
+
+ )} +
+
+
+ ); +};