mirror of
https://github.com/argoproj/argo-cd.git
synced 2026-02-20 01:28:45 +01:00
feat(ui): appset list page and filters (#25837)
Signed-off-by: Peter Jiang <peterjiang823@gmail.com>
This commit is contained in:
@@ -32,6 +32,8 @@ type Routes = {[path: string]: {component: React.ComponentType<RouteComponentPro
|
||||
const routes: Routes = {
|
||||
'/login': {component: login.component as any, noLayout: true},
|
||||
'/applications': {component: applications.component},
|
||||
// TODO: Uncomment when ApplicationSet details page is fully implemented
|
||||
// '/applicationsets': {component: applications.component},
|
||||
'/settings': {component: settings.component},
|
||||
'/user-info': {component: userInfo.component},
|
||||
'/help': {component: help.component}
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
import {DropDownMenu, Tooltip} from 'argo-ui';
|
||||
import * as React from 'react';
|
||||
import Moment from 'react-moment';
|
||||
import {Cluster} from '../../../shared/components';
|
||||
import {ContextApis} from '../../../shared/context';
|
||||
import * as models from '../../../shared/models';
|
||||
import {ApplicationURLs} from '../application-urls';
|
||||
import * as AppUtils from '../utils';
|
||||
import {getAppDefaultSource, OperationState, getApplicationLinkURL, getManagedByURL} from '../utils';
|
||||
import {ApplicationsLabels} from './applications-labels';
|
||||
import {ApplicationsSource} from './applications-source';
|
||||
import {services} from '../../../shared/services';
|
||||
import {ViewPreferences} from '../../../shared/services';
|
||||
|
||||
export interface ApplicationTableRowProps {
|
||||
app: models.Application;
|
||||
selected: boolean;
|
||||
pref: ViewPreferences;
|
||||
ctx: ContextApis;
|
||||
syncApplication: (appName: string, appNamespace: string) => void;
|
||||
refreshApplication: (appName: string, appNamespace: string) => void;
|
||||
deleteApplication: (appName: string, appNamespace: string) => void;
|
||||
}
|
||||
|
||||
export const ApplicationTableRow = ({app, selected, pref, ctx, syncApplication, refreshApplication, deleteApplication}: ApplicationTableRowProps) => {
|
||||
const favList = pref.appList.favoritesAppList || [];
|
||||
const healthStatus = app.status.health.status;
|
||||
const linkInfo = getApplicationLinkURL(app, ctx.baseHref);
|
||||
const source = getAppDefaultSource(app);
|
||||
|
||||
const handleFavoriteToggle = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (favList?.includes(app.metadata.name)) {
|
||||
favList.splice(favList.indexOf(app.metadata.name), 1);
|
||||
} else {
|
||||
favList.push(app.metadata.name);
|
||||
}
|
||||
services.viewPreferences.updatePreferences({appList: {...pref.appList, favoritesAppList: favList}});
|
||||
};
|
||||
|
||||
const handleExternalLinkClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (linkInfo.isExternal) {
|
||||
window.open(linkInfo.url, '_blank', 'noopener,noreferrer');
|
||||
} else {
|
||||
ctx.navigation.goto(`/${AppUtils.getAppUrl(app)}`);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`argo-table-list__row applications-list__entry applications-list__entry--health-${healthStatus} ${selected ? 'applications-tiles__selected' : ''}`}>
|
||||
<div
|
||||
className={`row applications-list__table-row ${app.status.sourceHydrator?.currentOperation ? 'applications-table-row--with-hydrator' : ''}`}
|
||||
onClick={e => ctx.navigation.goto(`/${AppUtils.getAppUrl(app)}`, {}, {event: e})}>
|
||||
{/* First column: Favorite, URLs, Project, Name */}
|
||||
<div className='columns small-4'>
|
||||
<div className='row'>
|
||||
<div className='columns small-2'>
|
||||
<div>
|
||||
<Tooltip content={favList?.includes(app.metadata.name) ? 'Remove Favorite' : 'Add Favorite'}>
|
||||
<button onClick={handleFavoriteToggle}>
|
||||
<i
|
||||
className={favList?.includes(app.metadata.name) ? 'fas fa-star' : 'far fa-star'}
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
marginRight: '7px',
|
||||
color: favList?.includes(app.metadata.name) ? '#FFCE25' : '#8fa4b1'
|
||||
}}
|
||||
/>
|
||||
</button>
|
||||
</Tooltip>
|
||||
<ApplicationURLs urls={app.status.summary?.externalURLs} />
|
||||
</div>
|
||||
</div>
|
||||
<div className='show-for-xxlarge columns small-4'>Project:</div>
|
||||
<div className='columns small-12 xxlarge-6'>{app.spec.project}</div>
|
||||
</div>
|
||||
<div className='row'>
|
||||
<div className='columns small-2' />
|
||||
<div className='show-for-xxlarge columns small-4'>Name:</div>
|
||||
<div className='columns small-12 xxlarge-6'>
|
||||
<Tooltip
|
||||
content={
|
||||
<>
|
||||
{app.metadata.name}
|
||||
<br />
|
||||
<Moment fromNow={true} ago={true}>
|
||||
{app.metadata.creationTimestamp}
|
||||
</Moment>
|
||||
</>
|
||||
}>
|
||||
<span>{app.metadata.name}</span>
|
||||
</Tooltip>
|
||||
<button
|
||||
onClick={handleExternalLinkClick}
|
||||
style={{marginLeft: '0.5em'}}
|
||||
title={`Link: ${linkInfo.url}\nmanaged-by-url: ${getManagedByURL(app) || 'none'}`}>
|
||||
<i className='fa fa-external-link-alt' />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Second column: Source and Destination */}
|
||||
<div className='columns small-6'>
|
||||
<div className='row'>
|
||||
<div className='show-for-xxlarge columns small-2'>Source:</div>
|
||||
<div className='columns small-12 xxlarge-10 applications-table-source' style={{position: 'relative'}}>
|
||||
<div className='applications-table-source__link'>
|
||||
<ApplicationsSource source={source} />
|
||||
</div>
|
||||
<div className='applications-table-source__labels'>
|
||||
<ApplicationsLabels app={app} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className='row'>
|
||||
<div className='show-for-xxlarge columns small-2'>Destination:</div>
|
||||
<div className='columns small-12 xxlarge-10'>
|
||||
<Cluster server={app.spec.destination.server} name={app.spec.destination.name} />/{app.spec.destination.namespace}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Third column: Status and Actions */}
|
||||
<div className='columns small-2'>
|
||||
<AppUtils.HealthStatusIcon state={app.status.health} /> <span>{app.status.health.status}</span> <br />
|
||||
{app.status.sourceHydrator?.currentOperation && (
|
||||
<>
|
||||
<AppUtils.HydrateOperationPhaseIcon operationState={app.status.sourceHydrator.currentOperation} />{' '}
|
||||
<span>{app.status.sourceHydrator.currentOperation.phase}</span> <br />
|
||||
</>
|
||||
)}
|
||||
<AppUtils.ComparisonStatusIcon status={app.status.sync.status} />
|
||||
<span>{app.status.sync.status}</span> <OperationState app={app} quiet={true} />
|
||||
<DropDownMenu
|
||||
anchor={() => (
|
||||
<button className='argo-button argo-button--light argo-button--lg argo-button--short'>
|
||||
<i className='fa fa-ellipsis-v' />
|
||||
</button>
|
||||
)}
|
||||
items={[
|
||||
{
|
||||
title: 'Sync',
|
||||
iconClassName: 'fa fa-fw fa-sync',
|
||||
action: () => syncApplication(app.metadata.name, app.metadata.namespace)
|
||||
},
|
||||
{
|
||||
title: 'Refresh',
|
||||
iconClassName: 'fa fa-fw fa-redo',
|
||||
action: () => refreshApplication(app.metadata.name, app.metadata.namespace)
|
||||
},
|
||||
{
|
||||
title: 'Delete',
|
||||
iconClassName: 'fa fa-fw fa-times-circle',
|
||||
action: () => deleteApplication(app.metadata.name, app.metadata.namespace)
|
||||
}
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,267 @@
|
||||
import {Tooltip} from 'argo-ui';
|
||||
import * as classNames from 'classnames';
|
||||
import * as React from 'react';
|
||||
import {Cluster} from '../../../shared/components';
|
||||
import {ContextApis, AuthSettingsCtx} from '../../../shared/context';
|
||||
import * as models from '../../../shared/models';
|
||||
import {ApplicationURLs} from '../application-urls';
|
||||
import * as AppUtils from '../utils';
|
||||
import {getAppDefaultSource, OperationState, getApplicationLinkURL, getManagedByURL} from '../utils';
|
||||
import {services} from '../../../shared/services';
|
||||
import {ViewPreferences} from '../../../shared/services';
|
||||
|
||||
export interface ApplicationTileProps {
|
||||
app: models.Application;
|
||||
selected: boolean;
|
||||
pref: ViewPreferences;
|
||||
ctx: ContextApis;
|
||||
tileRef?: React.RefObject<HTMLDivElement>;
|
||||
syncApplication: (appName: string, appNamespace: string) => void;
|
||||
refreshApplication: (appName: string, appNamespace: string) => void;
|
||||
deleteApplication: (appName: string, appNamespace: string) => void;
|
||||
}
|
||||
|
||||
export const ApplicationTile = ({app, selected, pref, ctx, tileRef, syncApplication, refreshApplication, deleteApplication}: ApplicationTileProps) => {
|
||||
const useAuthSettingsCtx = React.useContext(AuthSettingsCtx);
|
||||
const favList = pref.appList.favoritesAppList || [];
|
||||
|
||||
const source = getAppDefaultSource(app);
|
||||
const isOci = source?.repoURL?.startsWith('oci://');
|
||||
const targetRevision = source ? source.targetRevision || 'HEAD' : 'Unknown';
|
||||
const linkInfo = getApplicationLinkURL(app, ctx.baseHref);
|
||||
const healthStatus = app.status.health.status;
|
||||
|
||||
const handleFavoriteToggle = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (favList?.includes(app.metadata.name)) {
|
||||
favList.splice(favList.indexOf(app.metadata.name), 1);
|
||||
} else {
|
||||
favList.push(app.metadata.name);
|
||||
}
|
||||
services.viewPreferences.updatePreferences({appList: {...pref.appList, favoritesAppList: favList}});
|
||||
};
|
||||
|
||||
const handleExternalLinkClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (linkInfo.isExternal) {
|
||||
window.open(linkInfo.url, '_blank', 'noopener,noreferrer');
|
||||
} else {
|
||||
ctx.navigation.goto(`/${AppUtils.getAppUrl(app)}`);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={tileRef}
|
||||
className={`argo-table-list__row applications-list__entry applications-list__entry--health-${healthStatus} ${selected ? 'applications-tiles__selected' : ''}`}>
|
||||
<div className='row applications-tiles__wrapper' onClick={e => ctx.navigation.goto(`/${AppUtils.getAppUrl(app)}`, {view: pref.appDetails.view}, {event: e})}>
|
||||
<div className={`columns small-12 applications-list__info qe-applications-list-${AppUtils.appInstanceName(app)} applications-tiles__item`}>
|
||||
{/* Header row with icon, title, and action buttons */}
|
||||
<div className='row'>
|
||||
<div className={app.status.summary?.externalURLs?.length > 0 ? 'columns small-10' : 'columns small-11'}>
|
||||
<i className={'icon argo-icon-' + (source?.chart != null ? 'helm' : isOci ? 'oci applications-tiles__item__small' : 'git')} />
|
||||
<Tooltip content={AppUtils.appInstanceName(app)}>
|
||||
<span className='applications-list__title'>{AppUtils.appQualifiedName(app, useAuthSettingsCtx?.appsInAnyNamespaceEnabled)}</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className={app.status.summary?.externalURLs?.length > 0 ? 'columns small-2' : 'columns small-1'}>
|
||||
<div className='applications-list__external-link'>
|
||||
<ApplicationURLs urls={app.status.summary?.externalURLs} />
|
||||
<button onClick={handleExternalLinkClick} title={getManagedByURL(app) ? `Managed by: ${getManagedByURL(app)}` : 'Open application'}>
|
||||
<i className='fa fa-external-link-alt' />
|
||||
</button>
|
||||
<button
|
||||
title={favList?.includes(app.metadata.name) ? 'Remove Favorite' : 'Add Favorite'}
|
||||
className='large-text-height'
|
||||
onClick={handleFavoriteToggle}>
|
||||
<i
|
||||
className={favList?.includes(app.metadata.name) ? 'fas fa-star fa-lg' : 'far fa-star fa-lg'}
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
margin: '-1px 0px 0px 7px',
|
||||
color: favList?.includes(app.metadata.name) ? '#FFCE25' : '#8fa4b1'
|
||||
}}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Project row */}
|
||||
<div className='row'>
|
||||
<div className='columns small-3' title='Project:'>
|
||||
Project:
|
||||
</div>
|
||||
<div className='columns small-9'>{app.spec.project}</div>
|
||||
</div>
|
||||
|
||||
{/* Labels row */}
|
||||
<div className='row'>
|
||||
<div className='columns small-3' title='Labels:'>
|
||||
Labels:
|
||||
</div>
|
||||
<div className='columns small-9'>
|
||||
<Tooltip
|
||||
zIndex={4}
|
||||
content={
|
||||
<div>
|
||||
{Object.keys(app.metadata.labels || {})
|
||||
.map(label => ({label, value: app.metadata.labels[label]}))
|
||||
.map(item => (
|
||||
<div key={item.label}>
|
||||
{item.label}={item.value}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
}>
|
||||
<span>
|
||||
{Object.keys(app.metadata.labels || {})
|
||||
.map(label => `${label}=${app.metadata.labels[label]}`)
|
||||
.join(', ')}
|
||||
</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status row */}
|
||||
<div className='row'>
|
||||
<div className='columns small-3' title='Status:'>
|
||||
Status:
|
||||
</div>
|
||||
<div className='columns small-9' qe-id='applications-tiles-health-status'>
|
||||
<AppUtils.HealthStatusIcon state={app.status.health} /> {app.status.health.status}
|
||||
|
||||
{app.status.sourceHydrator?.currentOperation && (
|
||||
<>
|
||||
<AppUtils.HydrateOperationPhaseIcon operationState={app.status.sourceHydrator.currentOperation} />{' '}
|
||||
{app.status.sourceHydrator.currentOperation.phase}
|
||||
|
||||
</>
|
||||
)}
|
||||
<AppUtils.ComparisonStatusIcon status={app.status.sync.status} /> {app.status.sync.status}
|
||||
|
||||
<OperationState app={app} quiet={true} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Repository row */}
|
||||
<div className='row'>
|
||||
<div className='columns small-3' title='Repository:'>
|
||||
Repository:
|
||||
</div>
|
||||
<div className='columns small-9'>
|
||||
<Tooltip content={source?.repoURL || ''} zIndex={4}>
|
||||
<span>{source?.repoURL}</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Target Revision row */}
|
||||
<div className='row'>
|
||||
<div className='columns small-3' title='Target Revision:'>
|
||||
Target Revision:
|
||||
</div>
|
||||
<div className='columns small-9'>{targetRevision}</div>
|
||||
</div>
|
||||
|
||||
{/* Path row (conditional) */}
|
||||
{source?.path && (
|
||||
<div className='row'>
|
||||
<div className='columns small-3' title='Path:'>
|
||||
Path:
|
||||
</div>
|
||||
<div className='columns small-9'>{source?.path}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Chart row (conditional) */}
|
||||
{source?.chart && (
|
||||
<div className='row'>
|
||||
<div className='columns small-3' title='Chart:'>
|
||||
Chart:
|
||||
</div>
|
||||
<div className='columns small-9'>{source?.chart}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Destination row */}
|
||||
<div className='row'>
|
||||
<div className='columns small-3' title='Destination:'>
|
||||
Destination:
|
||||
</div>
|
||||
<div className='columns small-9'>
|
||||
<Cluster server={app.spec.destination.server} name={app.spec.destination.name} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Namespace row */}
|
||||
<div className='row'>
|
||||
<div className='columns small-3' title='Namespace:'>
|
||||
Namespace:
|
||||
</div>
|
||||
<div className='columns small-9'>{app.spec.destination.namespace}</div>
|
||||
</div>
|
||||
|
||||
{/* Created At row */}
|
||||
<div className='row'>
|
||||
<div className='columns small-3' title='Age:'>
|
||||
Created At:
|
||||
</div>
|
||||
<div className='columns small-9'>{AppUtils.formatCreationTimestamp(app.metadata.creationTimestamp)}</div>
|
||||
</div>
|
||||
|
||||
{/* Last Sync row (conditional) */}
|
||||
{app.status.operationState && (
|
||||
<div className='row'>
|
||||
<div className='columns small-3' title='Last sync:'>
|
||||
Last Sync:
|
||||
</div>
|
||||
<div className='columns small-9'>{AppUtils.formatCreationTimestamp(app.status.operationState.finishedAt || app.status.operationState.startedAt)}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className='row applications-tiles__actions'>
|
||||
<div className='columns applications-list__entry--actions'>
|
||||
<a
|
||||
className='argo-button argo-button--base'
|
||||
qe-id='applications-tiles-button-sync'
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
syncApplication(app.metadata.name, app.metadata.namespace);
|
||||
}}>
|
||||
<i className='fa fa-sync' /> Sync
|
||||
</a>
|
||||
|
||||
<Tooltip className='custom-tooltip' content={'Refresh'}>
|
||||
<a
|
||||
className='argo-button argo-button--base'
|
||||
qe-id='applications-tiles-button-refresh'
|
||||
{...AppUtils.refreshLinkAttrs(app)}
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
refreshApplication(app.metadata.name, app.metadata.namespace);
|
||||
}}>
|
||||
<i className={classNames('fa fa-redo', {'status-icon--spin': AppUtils.isAppRefreshing(app)})} />{' '}
|
||||
<span className='show-for-xxlarge'>Refresh</span>
|
||||
</a>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip className='custom-tooltip' content={'Delete'}>
|
||||
<a
|
||||
className='argo-button argo-button--base'
|
||||
qe-id='applications-tiles-button-delete'
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
deleteApplication(app.metadata.name, app.metadata.namespace);
|
||||
}}>
|
||||
<i className='fa fa-times-circle' /> <span className='show-for-xxlarge'>Delete</span>
|
||||
</a>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -5,6 +5,7 @@ import {Context} from '../../../shared/context';
|
||||
import {
|
||||
Application,
|
||||
ApplicationDestination,
|
||||
ApplicationSet,
|
||||
Cluster,
|
||||
HealthStatusCode,
|
||||
HealthStatuses,
|
||||
@@ -14,10 +15,10 @@ import {
|
||||
SyncStatusCode,
|
||||
SyncStatuses
|
||||
} from '../../../shared/models';
|
||||
import {AppsListPreferences, services} from '../../../shared/services';
|
||||
import {AppsListPreferences, AppSetsListPreferences, services} from '../../../shared/services';
|
||||
import {Filter, FiltersGroup} from '../filter/filter';
|
||||
import {createMetadataSelector} from '../selectors';
|
||||
import {ComparisonStatusIcon, getAppDefaultSource, HealthStatusIcon, getOperationStateTitle} from '../utils';
|
||||
import {ComparisonStatusIcon, getAppDefaultSource, getAppSetHealthStatus, HealthStatusIcon, getOperationStateTitle} from '../utils';
|
||||
import {formatClusterQueryParam} from '../../../shared/utils';
|
||||
import {COLORS} from '../../../shared/components/colors';
|
||||
|
||||
@@ -26,18 +27,29 @@ export interface FilterResult {
|
||||
sync: boolean;
|
||||
autosync: boolean;
|
||||
health: boolean;
|
||||
namespaces: boolean;
|
||||
clusters: boolean;
|
||||
namespaces: boolean;
|
||||
operation: boolean;
|
||||
annotations: boolean;
|
||||
favourite: boolean;
|
||||
labels: boolean;
|
||||
}
|
||||
|
||||
export interface ApplicationSetFilterResult {
|
||||
health: boolean;
|
||||
favourite: boolean;
|
||||
labels: boolean;
|
||||
annotations: boolean;
|
||||
operation: boolean;
|
||||
}
|
||||
|
||||
export interface FilteredApp extends Application {
|
||||
isAppOfAppsPattern?: boolean;
|
||||
filterResult: FilterResult;
|
||||
}
|
||||
|
||||
export interface ApplicationSetFilteredApp extends ApplicationSet {
|
||||
filterResult: ApplicationSetFilterResult;
|
||||
}
|
||||
|
||||
export function getAutoSyncStatus(syncPolicy?: SyncPolicy) {
|
||||
if (!syncPolicy || !syncPolicy.automated || syncPolicy.automated.enabled === false) {
|
||||
return 'Disabled';
|
||||
@@ -45,7 +57,7 @@ export function getAutoSyncStatus(syncPolicy?: SyncPolicy) {
|
||||
return 'Enabled';
|
||||
}
|
||||
|
||||
export function getFilterResults(applications: Application[], pref: AppsListPreferences): FilteredApp[] {
|
||||
export function getAppFilterResults(applications: Application[], pref: AppsListPreferences): FilteredApp[] {
|
||||
const labelSelector = createMetadataSelector(pref.labelsFilter || []);
|
||||
const annotationSelector = createMetadataSelector(pref.annotationsFilter || []);
|
||||
|
||||
@@ -77,6 +89,19 @@ export function getFilterResults(applications: Application[], pref: AppsListPref
|
||||
}));
|
||||
}
|
||||
|
||||
export function getAppSetFilterResults(appSets: ApplicationSet[], pref: AppSetsListPreferences): ApplicationSetFilteredApp[] {
|
||||
const labelSelector = createMetadataSelector(pref.labelsFilter || []);
|
||||
|
||||
return appSets.map(appSet => ({
|
||||
...appSet,
|
||||
filterResult: {
|
||||
health: pref.healthFilter.length === 0 || pref.healthFilter.includes(getAppSetHealthStatus(appSet)),
|
||||
favourite: !pref.showFavorites || (pref.favoritesAppList && pref.favoritesAppList.includes(appSet.metadata.name)),
|
||||
labels: pref.labelsFilter.length === 0 || labelSelector(appSet.metadata.labels)
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
const optionsFrom = (options: string[], filter: string[]) => {
|
||||
return options
|
||||
.filter(s => filter.indexOf(s) === -1)
|
||||
@@ -85,7 +110,8 @@ const optionsFrom = (options: string[], filter: string[]) => {
|
||||
});
|
||||
};
|
||||
|
||||
interface AppFilterProps {
|
||||
// Props for Application filters
|
||||
export interface AppFilterProps {
|
||||
apps: FilteredApp[];
|
||||
pref: AppsListPreferences;
|
||||
onChange: (newPrefs: AppsListPreferences) => void;
|
||||
@@ -93,6 +119,15 @@ interface AppFilterProps {
|
||||
collapsed?: boolean;
|
||||
}
|
||||
|
||||
// Props for ApplicationSet filters
|
||||
export interface AppSetFilterProps {
|
||||
apps: ApplicationSetFilteredApp[];
|
||||
pref: AppSetsListPreferences;
|
||||
onChange: (newPrefs: AppSetsListPreferences) => void;
|
||||
children?: React.ReactNode;
|
||||
collapsed?: boolean;
|
||||
}
|
||||
|
||||
const getCounts = (apps: FilteredApp[], filterType: keyof FilterResult, filter: (app: Application) => string, init?: string[]) => {
|
||||
const map = new Map<string, number>();
|
||||
if (init) {
|
||||
@@ -105,6 +140,18 @@ const getCounts = (apps: FilteredApp[], filterType: keyof FilterResult, filter:
|
||||
return map;
|
||||
};
|
||||
|
||||
const getAppSetCounts = (apps: ApplicationSetFilteredApp[], filterType: keyof ApplicationSetFilterResult, filter: (app: ApplicationSet) => string, init?: string[]) => {
|
||||
const map = new Map<string, number>();
|
||||
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 ApplicationSetFilterResult) => key === filterType || app.filterResult[key])).forEach(app =>
|
||||
map.set(filter(app), (map.get(filter(app)) || 0) + 1)
|
||||
);
|
||||
return map;
|
||||
};
|
||||
|
||||
const getOptions = (apps: FilteredApp[], filterType: keyof FilterResult, filter: (app: Application) => string, keys: string[], getIcon?: (k: string) => React.ReactNode) => {
|
||||
const counts = getCounts(apps, filterType, filter, keys);
|
||||
return keys.map(k => {
|
||||
@@ -116,6 +163,23 @@ const getOptions = (apps: FilteredApp[], filterType: keyof FilterResult, filter:
|
||||
});
|
||||
};
|
||||
|
||||
const getAppSetOptions = (
|
||||
apps: ApplicationSetFilteredApp[],
|
||||
filterType: keyof ApplicationSetFilterResult,
|
||||
filter: (app: ApplicationSet) => string,
|
||||
keys: string[],
|
||||
getIcon?: (k: string) => React.ReactNode
|
||||
) => {
|
||||
const counts = getAppSetCounts(apps, filterType, filter, keys);
|
||||
return keys.map(k => {
|
||||
return {
|
||||
label: k,
|
||||
icon: getIcon && getIcon(k),
|
||||
count: counts.get(k)
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const SyncFilter = (props: AppFilterProps) => (
|
||||
<Filter
|
||||
label='SYNC STATUS'
|
||||
@@ -133,7 +197,7 @@ const SyncFilter = (props: AppFilterProps) => (
|
||||
/>
|
||||
);
|
||||
|
||||
const HealthFilter = (props: AppFilterProps) => (
|
||||
const AppHealthFilter = (props: AppFilterProps) => (
|
||||
<Filter
|
||||
label='HEALTH STATUS'
|
||||
selected={props.pref.healthFilter}
|
||||
@@ -150,7 +214,24 @@ const HealthFilter = (props: AppFilterProps) => (
|
||||
/>
|
||||
);
|
||||
|
||||
const LabelsFilter = (props: AppFilterProps) => {
|
||||
const AppSetHealthFilter = (props: AppSetFilterProps) => (
|
||||
<Filter
|
||||
label='HEALTH STATUS'
|
||||
selected={props.pref.healthFilter}
|
||||
setSelected={s => props.onChange({...props.pref, healthFilter: s})}
|
||||
options={getAppSetOptions(
|
||||
props.apps,
|
||||
'health',
|
||||
app => getAppSetHealthStatus(app),
|
||||
Object.keys(HealthStatuses),
|
||||
s => (
|
||||
<HealthStatusIcon state={{status: s as HealthStatusCode, message: ''}} noSpin={true} />
|
||||
)
|
||||
)}
|
||||
/>
|
||||
);
|
||||
|
||||
const LabelsFilter = (props: {apps: Array<{metadata: {labels?: {[key: string]: string}}}>; pref: {labelsFilter: string[]}; onChange: (labelsFilter: string[]) => void}) => {
|
||||
const labels = new Map<string, Set<string>>();
|
||||
props.apps
|
||||
.filter(app => app.metadata && app.metadata.labels)
|
||||
@@ -173,7 +254,7 @@ const LabelsFilter = (props: AppFilterProps) => {
|
||||
return {label: s};
|
||||
});
|
||||
|
||||
return <Filter label='LABELS' selected={props.pref.labelsFilter} setSelected={s => props.onChange({...props.pref, labelsFilter: s})} field={true} options={labelOptions} />;
|
||||
return <Filter label='LABELS' selected={props.pref.labelsFilter} setSelected={s => props.onChange(s)} field={true} options={labelOptions} />;
|
||||
};
|
||||
|
||||
const AnnotationsFilter = (props: AppFilterProps) => {
|
||||
@@ -278,11 +359,11 @@ const NamespaceFilter = (props: AppFilterProps) => {
|
||||
);
|
||||
};
|
||||
|
||||
const FavoriteFilter = (props: AppFilterProps) => {
|
||||
const FavoriteFilter = (props: {pref: {showFavorites?: boolean}; onChange: (showFavorites: boolean) => void}) => {
|
||||
const ctx = React.useContext(Context);
|
||||
const onChange = (val: boolean) => {
|
||||
ctx.navigation.goto('.', {showFavorites: val}, {replace: true});
|
||||
services.viewPreferences.updatePreferences({appList: {...props.pref, showFavorites: val}});
|
||||
props.onChange(val);
|
||||
};
|
||||
return (
|
||||
<div
|
||||
@@ -384,11 +465,11 @@ const OperationFilter = (props: AppFilterProps) => (
|
||||
export const ApplicationsFilter = (props: AppFilterProps) => {
|
||||
return (
|
||||
<FiltersGroup title='Application filters' content={props.children} collapsed={props.collapsed}>
|
||||
<FavoriteFilter {...props} />
|
||||
<FavoriteFilter pref={props.pref} onChange={val => services.viewPreferences.updatePreferences({appList: {...props.pref, showFavorites: val}})} />
|
||||
<SyncFilter {...props} />
|
||||
<HealthFilter {...props} />
|
||||
<AppHealthFilter {...props} />
|
||||
<OperationFilter {...props} />
|
||||
<LabelsFilter {...props} />
|
||||
<LabelsFilter apps={props.apps} pref={props.pref} onChange={labelsFilter => props.onChange({...props.pref, labelsFilter})} />
|
||||
<AnnotationsFilter {...props} />
|
||||
<ProjectFilter {...props} />
|
||||
<ClusterFilter {...props} />
|
||||
@@ -397,3 +478,13 @@ export const ApplicationsFilter = (props: AppFilterProps) => {
|
||||
</FiltersGroup>
|
||||
);
|
||||
};
|
||||
|
||||
export const AppSetsFilter = (props: AppSetFilterProps) => {
|
||||
return (
|
||||
<FiltersGroup title='ApplicationSet filters' content={props.children} collapsed={props.collapsed}>
|
||||
<FavoriteFilter pref={props.pref} onChange={val => services.viewPreferences.updatePreferences({appList: {...props.pref, showFavorites: val} as AppsListPreferences})} />
|
||||
<AppSetHealthFilter {...props} />
|
||||
<LabelsFilter apps={props.apps} pref={props.pref} onChange={labelsFilter => props.onChange({...props.pref, labelsFilter})} />
|
||||
</FiltersGroup>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -9,13 +9,13 @@ import {bufferTime, delay, filter, map, mergeMap, repeat, retryWhen} from 'rxjs/
|
||||
import {AddAuthToToolbar, ClusterCtx, DataLoader, EmptyState, Page, Paginate, Spinner} 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 {AppsListViewKey, AppsListPreferences, AppSetsListPreferences, 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, FilteredApp, getFilterResults} from './applications-filter';
|
||||
import {ApplicationsStatusBar} from './applications-status-bar';
|
||||
import {ApplicationsFilter, AppSetsFilter, FilteredApp, ApplicationSetFilteredApp, getAppFilterResults, getAppSetFilterResults} from './applications-filter';
|
||||
import {AppsStatusBar, AppSetsStatusBar} from './applications-status-bar';
|
||||
import {ApplicationsSummary} from './applications-summary';
|
||||
import {ApplicationsTable} from './applications-table';
|
||||
import {ApplicationTiles} from './applications-tiles';
|
||||
@@ -175,18 +175,36 @@ 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 => {
|
||||
function filterApplications(applications: models.Application[], pref: AppsListPreferences, search: string): {filteredApps: models.Application[]; filterResults: FilteredApp[]} {
|
||||
const processedApps = applications.map(app => {
|
||||
let isAppOfAppsPattern = false;
|
||||
for (const resource of app.status.resources) {
|
||||
if (resource.kind === 'Application') {
|
||||
isAppOfAppsPattern = true;
|
||||
break;
|
||||
if (app.status?.resources) {
|
||||
for (const resource of app.status.resources) {
|
||||
if (resource.kind === 'Application') {
|
||||
isAppOfAppsPattern = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return {...app, isAppOfAppsPattern};
|
||||
});
|
||||
const filterResults = getFilterResults(applications, pref);
|
||||
const filterResults = getAppFilterResults(processedApps, 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 filterApplicationSets(
|
||||
appSets: models.ApplicationSet[],
|
||||
pref: AppSetsListPreferences,
|
||||
search: string
|
||||
): {filteredApps: models.ApplicationSet[]; filterResults: ApplicationSetFilteredApp[]} {
|
||||
const filterResults = getAppSetFilterResults(appSets, pref);
|
||||
|
||||
return {
|
||||
filterResults,
|
||||
filteredApps: filterResults.filter(
|
||||
@@ -203,8 +221,8 @@ function tryJsonParse(input: string) {
|
||||
}
|
||||
}
|
||||
|
||||
const SearchBar = (props: {content: string; ctx: ContextApis; apps: models.Application[]}) => {
|
||||
const {content, ctx, apps} = {...props};
|
||||
const SearchBar = (props: {content: string; ctx: ContextApis; apps: models.AbstractApplication[]; isListOfApplications: boolean}) => {
|
||||
const {content, ctx, apps, isListOfApplications} = {...props};
|
||||
|
||||
const searchBar = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
@@ -263,7 +281,7 @@ const SearchBar = (props: {content: string; ctx: ContextApis; apps: models.Appli
|
||||
}}
|
||||
style={{fontSize: '14px'}}
|
||||
className='argo-field'
|
||||
placeholder='Search applications...'
|
||||
placeholder={isListOfApplications ? 'Search applications...' : 'Search application sets...'}
|
||||
/>
|
||||
<div className='keyboard-hint'>/</div>
|
||||
{content && (
|
||||
@@ -294,19 +312,20 @@ const SearchBar = (props: {content: string; ctx: ContextApis; apps: models.Appli
|
||||
};
|
||||
|
||||
interface ApplicationsToolbarProps {
|
||||
applications: models.Application[];
|
||||
applications: models.AbstractApplication[];
|
||||
pref: AppsListPreferences & {page: number; search: string};
|
||||
ctx: ContextApis;
|
||||
healthBarPrefs: HealthStatusBarPreferences;
|
||||
isListOfApplications: boolean;
|
||||
}
|
||||
|
||||
const ApplicationsToolbar: React.FC<ApplicationsToolbarProps> = ({applications, pref, ctx, healthBarPrefs}) => {
|
||||
const ApplicationsToolbar: React.FC<ApplicationsToolbarProps> = ({applications, pref, ctx, healthBarPrefs, isListOfApplications}) => {
|
||||
const {List, Summary, Tiles} = AppsListViewKey;
|
||||
const query = useQuery();
|
||||
|
||||
return (
|
||||
<React.Fragment key='app-list-tools'>
|
||||
<SearchBar content={query.get('search')} apps={applications} ctx={ctx} />
|
||||
<SearchBar content={query.get('search')} apps={applications} ctx={ctx} isListOfApplications={isListOfApplications} />
|
||||
<Tooltip content='Toggle Health Status Bar'>
|
||||
<button
|
||||
className={`applications-list__accordion argo-button argo-button--base${healthBarPrefs.showHealthStatusBar ? '-o' : ''}`}
|
||||
@@ -408,8 +427,7 @@ export const ApplicationsList = (props: RouteComponentProps<any> & {objectListKi
|
||||
const {List, Summary, Tiles} = AppsListViewKey;
|
||||
|
||||
const objectListKind = props.objectListKind;
|
||||
// isListOfApplications will be used when ApplicationSet routes are added
|
||||
// const isListOfApplications = objectListKind === 'application';
|
||||
const isListOfApplications = objectListKind === 'application';
|
||||
|
||||
function refreshApp(appName: string, appNamespace: string) {
|
||||
// app refreshing might be done too quickly so that UI might miss it due to event batching
|
||||
@@ -425,7 +443,7 @@ export const ApplicationsList = (props: RouteComponentProps<any> & {objectListKi
|
||||
services.applications.get(appName, appNamespace, objectListKind, 'normal');
|
||||
}
|
||||
|
||||
function onFilterPrefChanged(ctx: ContextApis, newPref: AppsListPreferences) {
|
||||
function onAppFilterPrefChanged(ctx: ContextApis, newPref: AppsListPreferences) {
|
||||
services.viewPreferences.updatePreferences({appList: newPref});
|
||||
ctx.navigation.goto(
|
||||
'.',
|
||||
@@ -444,14 +462,28 @@ export const ApplicationsList = (props: RouteComponentProps<any> & {objectListKi
|
||||
);
|
||||
}
|
||||
|
||||
function onAppSetFilterPrefChanged(ctx: ContextApis, newPref: AppSetsListPreferences) {
|
||||
// Use appList since ViewPreferences shares preferences between apps and appsets
|
||||
services.viewPreferences.updatePreferences({appList: newPref as AppsListPreferences});
|
||||
ctx.navigation.goto(
|
||||
'.',
|
||||
{
|
||||
health: newPref.healthFilter.join(','),
|
||||
labels: newPref.labelsFilter.map(encodeURIComponent).join(',')
|
||||
},
|
||||
{replace: true}
|
||||
);
|
||||
}
|
||||
|
||||
function getPageTitle(view: string) {
|
||||
const entityName = isListOfApplications ? 'Applications' : 'ApplicationSets';
|
||||
switch (view) {
|
||||
case List:
|
||||
return 'Applications List';
|
||||
return `${entityName} List`;
|
||||
case Tiles:
|
||||
return 'Applications Tiles';
|
||||
return `${entityName} Tiles`;
|
||||
case Summary:
|
||||
return 'Applications Summary';
|
||||
return `${entityName} Summary`;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
@@ -469,7 +501,14 @@ export const ApplicationsList = (props: RouteComponentProps<any> & {objectListKi
|
||||
key={pref.view}
|
||||
title={getPageTitle(pref.view)}
|
||||
useTitleOnly={true}
|
||||
toolbar={{breadcrumbs: [{title: 'Applications', path: '/applications'}]}}
|
||||
toolbar={{
|
||||
breadcrumbs: [
|
||||
{
|
||||
title: isListOfApplications ? 'Applications' : 'ApplicationSets',
|
||||
path: isListOfApplications ? '/applications' : '/applicationsets'
|
||||
}
|
||||
]
|
||||
}}
|
||||
hideAuth={true}>
|
||||
<DataLoader
|
||||
input={pref.projectsFilter?.join(',')}
|
||||
@@ -480,9 +519,8 @@ export const ApplicationsList = (props: RouteComponentProps<any> & {objectListKi
|
||||
<MockupList height={100} marginTop={30} />
|
||||
</div>
|
||||
)}>
|
||||
{(applications: models.Application[]) => {
|
||||
{(applications: models.AbstractApplication[]) => {
|
||||
const healthBarPrefs = pref.statusBarView || ({} as HealthStatusBarPreferences);
|
||||
const {filteredApps, filterResults} = filterApps(applications, pref, pref.search);
|
||||
const handleCreatePanelClose = async () => {
|
||||
const outsideDiv = document.querySelector('.sliding-panel__outside');
|
||||
const closeButton = document.querySelector('.sliding-panel__close');
|
||||
@@ -497,76 +535,290 @@ export const ApplicationsList = (props: RouteComponentProps<any> & {objectListKi
|
||||
ctx.navigation.goto('.', {new: null}, {replace: true});
|
||||
}
|
||||
};
|
||||
return (
|
||||
<React.Fragment>
|
||||
<FlexTopBar
|
||||
toolbar={{
|
||||
tools: <ApplicationsToolbar applications={applications} pref={pref} ctx={ctx} healthBarPrefs={healthBarPrefs} />,
|
||||
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})
|
||||
}
|
||||
]
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className='applications-list'>
|
||||
{applications.length === 0 && pref.projectsFilter?.length === 0 && (pref.labelsFilter || []).length === 0 ? (
|
||||
<EmptyState icon='argo-icon-application'>
|
||||
<h4>No applications available to you just yet</h4>
|
||||
<h5>Create new application to start managing resources in your cluster</h5>
|
||||
<button
|
||||
qe-id='applications-list-button-create-application'
|
||||
className='argo-button argo-button--base'
|
||||
onClick={() => ctx.navigation.goto('.', {new: JSON.stringify({})}, {replace: true})}>
|
||||
Create application
|
||||
</button>
|
||||
</EmptyState>
|
||||
) : (
|
||||
<>
|
||||
{ReactDOM.createPortal(
|
||||
<DataLoader load={() => services.viewPreferences.getPreferences()}>
|
||||
{allpref => (
|
||||
<ApplicationsFilter
|
||||
apps={filterResults}
|
||||
onChange={newPrefs => onFilterPrefChanged(ctx, newPrefs)}
|
||||
pref={pref}
|
||||
collapsed={allpref.hideSidebar}
|
||||
/>
|
||||
)}
|
||||
</DataLoader>,
|
||||
sidebarTarget?.current
|
||||
)}
|
||||
|
||||
{(pref.view === 'summary' && <ApplicationsSummary applications={filteredApps} />) || (
|
||||
if (isListOfApplications) {
|
||||
// Applications path - fully type-safe
|
||||
const apps = applications as models.Application[];
|
||||
const {filteredApps, filterResults} = filterApplications(apps, pref, pref.search);
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
<FlexTopBar
|
||||
toolbar={{
|
||||
tools: (
|
||||
<ApplicationsToolbar
|
||||
applications={applications}
|
||||
pref={pref}
|
||||
ctx={ctx}
|
||||
healthBarPrefs={healthBarPrefs}
|
||||
isListOfApplications={isListOfApplications}
|
||||
/>
|
||||
),
|
||||
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})
|
||||
}
|
||||
]
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className='applications-list'>
|
||||
{apps.length === 0 && pref.projectsFilter?.length === 0 && (pref.labelsFilter || []).length === 0 ? (
|
||||
<EmptyState icon='argo-icon-application'>
|
||||
<h4>No applications available to you just yet</h4>
|
||||
<h5>Create new application to start managing resources in your cluster</h5>
|
||||
<button
|
||||
qe-id='applications-list-button-create-application'
|
||||
className='argo-button argo-button--base'
|
||||
onClick={() => ctx.navigation.goto('.', {new: JSON.stringify({})}, {replace: true})}>
|
||||
Create application
|
||||
</button>
|
||||
</EmptyState>
|
||||
) : (
|
||||
<>
|
||||
{ReactDOM.createPortal(
|
||||
<DataLoader load={() => services.viewPreferences.getPreferences()}>
|
||||
{allpref => (
|
||||
<ApplicationsFilter
|
||||
apps={filterResults}
|
||||
onChange={newPrefs => onAppFilterPrefChanged(ctx, newPrefs)}
|
||||
pref={pref}
|
||||
collapsed={allpref.hideSidebar}
|
||||
/>
|
||||
)}
|
||||
</DataLoader>,
|
||||
sidebarTarget?.current
|
||||
)}
|
||||
|
||||
{(pref.view === 'summary' && <ApplicationsSummary applications={filteredApps} />) || (
|
||||
<Paginate
|
||||
header={filteredApps.length > 1 && <AppsStatusBar applications={filteredApps} />}
|
||||
showHeader={healthBarPrefs.showHealthStatusBar}
|
||||
preferencesKey='applications-list'
|
||||
page={pref.page}
|
||||
emptyState={() => (
|
||||
<EmptyState icon='fa fa-search'>
|
||||
<h4>No matching applications found</h4>
|
||||
<h5>
|
||||
Change filter criteria or
|
||||
<a
|
||||
onClick={() => {
|
||||
AppsListPreferences.clearFilters(pref);
|
||||
onAppFilterPrefChanged(ctx, pref);
|
||||
}}>
|
||||
clear filters
|
||||
</a>
|
||||
</h5>
|
||||
</EmptyState>
|
||||
)}
|
||||
sortOptions={[
|
||||
{
|
||||
title: 'Name',
|
||||
compare: (a, b) => a.metadata.name.localeCompare(b.metadata.name, undefined, {numeric: true})
|
||||
},
|
||||
{
|
||||
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' && (
|
||||
<ApplicationTiles
|
||||
applications={data}
|
||||
syncApplication={(appName, appNamespace) =>
|
||||
ctx.navigation.goto('.', {syncApp: appName, appNamespace}, {replace: true})
|
||||
}
|
||||
refreshApplication={refreshApp}
|
||||
deleteApplication={(appName, appNamespace) =>
|
||||
AppUtils.deleteApplication(appName, appNamespace, ctx)
|
||||
}
|
||||
/>
|
||||
)) || (
|
||||
<ApplicationsTable
|
||||
applications={data}
|
||||
syncApplication={(appName, appNamespace) =>
|
||||
ctx.navigation.goto('.', {syncApp: appName, appNamespace}, {replace: true})
|
||||
}
|
||||
refreshApplication={refreshApp}
|
||||
deleteApplication={(appName, appNamespace) =>
|
||||
AppUtils.deleteApplication(appName, appNamespace, ctx)
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
</Paginate>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<ApplicationsSyncPanel
|
||||
key='syncsPanel'
|
||||
show={syncAppsInput}
|
||||
hide={() => ctx.navigation.goto('.', {syncApps: null}, {replace: true})}
|
||||
apps={filteredApps}
|
||||
/>
|
||||
<ApplicationsRefreshPanel
|
||||
key='refreshPanel'
|
||||
show={refreshAppsInput}
|
||||
hide={() => ctx.navigation.goto('.', {refreshApps: null}, {replace: true})}
|
||||
apps={filteredApps}
|
||||
/>
|
||||
</div>
|
||||
<DataLoader
|
||||
load={() =>
|
||||
observableQuery$.pipe(
|
||||
mergeMap(params => {
|
||||
const syncApp = params.get('syncApp');
|
||||
const appNamespace = params.get('appNamespace');
|
||||
return (syncApp && from(services.applications.get(syncApp, appNamespace, objectListKind))) || from([null]);
|
||||
})
|
||||
)
|
||||
}>
|
||||
{app => (
|
||||
<ApplicationSyncPanel
|
||||
key='syncPanel'
|
||||
application={app}
|
||||
selectedResource={'all'}
|
||||
hide={() => ctx.navigation.goto('.', {syncApp: null}, {replace: true})}
|
||||
/>
|
||||
)}
|
||||
</DataLoader>
|
||||
<SlidingPanel
|
||||
isShown={!!appInput}
|
||||
onClose={() => handleCreatePanelClose()}
|
||||
header={
|
||||
<div>
|
||||
<button
|
||||
qe-id='applications-list-button-create'
|
||||
className='argo-button argo-button--base'
|
||||
disabled={isAppCreatePending}
|
||||
onClick={() => createApi && createApi.submitForm(null)}>
|
||||
<Spinner show={isAppCreatePending} style={{marginRight: '5px'}} />
|
||||
Create
|
||||
</button>{' '}
|
||||
<button
|
||||
qe-id='applications-list-button-cancel'
|
||||
onClick={() => ctx.navigation.goto('.', {new: null}, {replace: true})}
|
||||
className='argo-button argo-button--base-o'>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
}>
|
||||
{appInput && (
|
||||
<ApplicationCreatePanel
|
||||
getFormApi={api => {
|
||||
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: <ErrorNotification title='Unable to create application' e={e} />,
|
||||
type: NotificationType.Error
|
||||
});
|
||||
} finally {
|
||||
setAppCreatePending(false);
|
||||
}
|
||||
}}
|
||||
app={appInput}
|
||||
onAppChanged={app => ctx.navigation.goto('.', {new: JSON.stringify(app)}, {replace: true})}
|
||||
/>
|
||||
)}
|
||||
</SlidingPanel>
|
||||
</React.Fragment>
|
||||
);
|
||||
} else {
|
||||
// ApplicationSets path - fully type-safe
|
||||
const appSets = applications as models.ApplicationSet[];
|
||||
const appSetPref: AppSetsListPreferences = {
|
||||
labelsFilter: pref.labelsFilter,
|
||||
healthFilter: pref.healthFilter,
|
||||
showFavorites: pref.showFavorites,
|
||||
favoritesAppList: pref.favoritesAppList,
|
||||
view: pref.view,
|
||||
hideFilters: pref.hideFilters,
|
||||
statusBarView: pref.statusBarView,
|
||||
annotationsFilter: pref.annotationsFilter
|
||||
};
|
||||
const {filteredApps, filterResults} = filterApplicationSets(appSets, appSetPref, pref.search);
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
<FlexTopBar
|
||||
toolbar={{
|
||||
tools: (
|
||||
<ApplicationsToolbar
|
||||
applications={applications}
|
||||
pref={pref}
|
||||
ctx={ctx}
|
||||
healthBarPrefs={healthBarPrefs}
|
||||
isListOfApplications={isListOfApplications}
|
||||
/>
|
||||
),
|
||||
actionMenu: {
|
||||
items: [] // No action menu for ApplicationSets yet
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className='applications-list'>
|
||||
{appSets.length === 0 && (pref.labelsFilter || []).length === 0 ? (
|
||||
<EmptyState icon='argo-icon-application'>
|
||||
<h4>No ApplicationSets available to you just yet</h4>
|
||||
<h5>ApplicationSets will appear here once created</h5>
|
||||
</EmptyState>
|
||||
) : (
|
||||
<>
|
||||
{ReactDOM.createPortal(
|
||||
<DataLoader load={() => services.viewPreferences.getPreferences()}>
|
||||
{allpref => (
|
||||
<AppSetsFilter
|
||||
apps={filterResults}
|
||||
onChange={newPrefs => onAppSetFilterPrefChanged(ctx, newPrefs)}
|
||||
pref={appSetPref}
|
||||
collapsed={allpref.hideSidebar}
|
||||
/>
|
||||
)}
|
||||
</DataLoader>,
|
||||
sidebarTarget?.current
|
||||
)}
|
||||
|
||||
<Paginate
|
||||
header={filteredApps.length > 1 && <ApplicationsStatusBar applications={filteredApps} />}
|
||||
header={filteredApps.length > 1 && <AppSetsStatusBar appSets={filteredApps} />}
|
||||
showHeader={healthBarPrefs.showHealthStatusBar}
|
||||
preferencesKey='applications-list'
|
||||
page={pref.page}
|
||||
emptyState={() => (
|
||||
<EmptyState icon='fa fa-search'>
|
||||
<h4>No matching applications found</h4>
|
||||
<h4>No matching application sets found</h4>
|
||||
<h5>
|
||||
Change filter criteria or
|
||||
<a
|
||||
onClick={() => {
|
||||
AppsListPreferences.clearFilters(pref);
|
||||
onFilterPrefChanged(ctx, pref);
|
||||
AppSetsListPreferences.clearFilters(appSetPref);
|
||||
onAppSetFilterPrefChanged(ctx, appSetPref);
|
||||
}}>
|
||||
clear filters
|
||||
</a>
|
||||
@@ -581,11 +833,6 @@ export const ApplicationsList = (props: RouteComponentProps<any> & {objectListKi
|
||||
{
|
||||
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}
|
||||
@@ -594,110 +841,26 @@ export const ApplicationsList = (props: RouteComponentProps<any> & {objectListKi
|
||||
(pref.view === 'tiles' && (
|
||||
<ApplicationTiles
|
||||
applications={data}
|
||||
syncApplication={(appName, appNamespace) =>
|
||||
ctx.navigation.goto('.', {syncApp: appName, appNamespace}, {replace: true})
|
||||
}
|
||||
refreshApplication={refreshApp}
|
||||
deleteApplication={(appName, appNamespace) =>
|
||||
AppUtils.deleteApplication(appName, appNamespace, ctx)
|
||||
}
|
||||
syncApplication={() => {}}
|
||||
refreshApplication={() => {}}
|
||||
deleteApplication={() => {}}
|
||||
/>
|
||||
)) || (
|
||||
<ApplicationsTable
|
||||
applications={data}
|
||||
syncApplication={(appName, appNamespace) =>
|
||||
ctx.navigation.goto('.', {syncApp: appName, appNamespace}, {replace: true})
|
||||
}
|
||||
refreshApplication={refreshApp}
|
||||
deleteApplication={(appName, appNamespace) =>
|
||||
AppUtils.deleteApplication(appName, appNamespace, ctx)
|
||||
}
|
||||
syncApplication={() => {}}
|
||||
refreshApplication={() => {}}
|
||||
deleteApplication={() => {}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
</Paginate>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<ApplicationsSyncPanel
|
||||
key='syncsPanel'
|
||||
show={syncAppsInput}
|
||||
hide={() => ctx.navigation.goto('.', {syncApps: null}, {replace: true})}
|
||||
apps={filteredApps}
|
||||
/>
|
||||
<ApplicationsRefreshPanel
|
||||
key='refreshPanel'
|
||||
show={refreshAppsInput}
|
||||
hide={() => ctx.navigation.goto('.', {refreshApps: null}, {replace: true})}
|
||||
apps={filteredApps}
|
||||
/>
|
||||
</div>
|
||||
<DataLoader
|
||||
load={() =>
|
||||
observableQuery$.pipe(
|
||||
mergeMap(params => {
|
||||
const syncApp = params.get('syncApp');
|
||||
const appNamespace = params.get('appNamespace');
|
||||
return (syncApp && from(services.applications.get(syncApp, appNamespace, objectListKind))) || from([null]);
|
||||
})
|
||||
)
|
||||
}>
|
||||
{app => (
|
||||
<ApplicationSyncPanel
|
||||
key='syncPanel'
|
||||
application={app}
|
||||
selectedResource={'all'}
|
||||
hide={() => ctx.navigation.goto('.', {syncApp: null}, {replace: true})}
|
||||
/>
|
||||
)}
|
||||
</DataLoader>
|
||||
<SlidingPanel
|
||||
isShown={!!appInput}
|
||||
onClose={() => handleCreatePanelClose()} //Separate handling for outside click.
|
||||
header={
|
||||
<div>
|
||||
<button
|
||||
qe-id='applications-list-button-create'
|
||||
className='argo-button argo-button--base'
|
||||
disabled={isAppCreatePending}
|
||||
onClick={() => createApi && createApi.submitForm(null)}>
|
||||
<Spinner show={isAppCreatePending} style={{marginRight: '5px'}} />
|
||||
Create
|
||||
</button>{' '}
|
||||
<button
|
||||
qe-id='applications-list-button-cancel'
|
||||
onClick={() => ctx.navigation.goto('.', {new: null}, {replace: true})}
|
||||
className='argo-button argo-button--base-o'>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
}>
|
||||
{appInput && (
|
||||
<ApplicationCreatePanel
|
||||
getFormApi={api => {
|
||||
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: <ErrorNotification title='Unable to create application' e={e} />,
|
||||
type: NotificationType.Error
|
||||
});
|
||||
} finally {
|
||||
setAppCreatePending(false);
|
||||
}
|
||||
}}
|
||||
app={appInput}
|
||||
onAppChanged={app => ctx.navigation.goto('.', {new: JSON.stringify(app)}, {replace: true})}
|
||||
/>
|
||||
)}
|
||||
</SlidingPanel>
|
||||
</React.Fragment>
|
||||
);
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
}}
|
||||
</DataLoader>
|
||||
</Page>
|
||||
|
||||
@@ -3,15 +3,18 @@ import * as React from 'react';
|
||||
import {COLORS} from '../../../shared/components';
|
||||
import {Consumer} from '../../../shared/context';
|
||||
import * as models from '../../../shared/models';
|
||||
import {getAppSetHealthStatus} from '../utils';
|
||||
|
||||
import './applications-status-bar.scss';
|
||||
|
||||
export interface ApplicationsStatusBarProps {
|
||||
applications: models.Application[];
|
||||
interface Reading {
|
||||
name: string;
|
||||
value: number;
|
||||
color: string;
|
||||
}
|
||||
|
||||
export const ApplicationsStatusBar = ({applications}: ApplicationsStatusBarProps) => {
|
||||
const readings = [
|
||||
function getAppReadings(applications: models.Application[]): Reading[] {
|
||||
return [
|
||||
{
|
||||
name: 'Healthy',
|
||||
value: applications.filter(app => app.status.health.status === 'Healthy').length,
|
||||
@@ -43,11 +46,38 @@ export const ApplicationsStatusBar = ({applications}: ApplicationsStatusBarProps
|
||||
color: COLORS.health.unknown
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
function getAppSetReadings(appSets: models.ApplicationSet[]): Reading[] {
|
||||
return [
|
||||
{
|
||||
name: 'Healthy',
|
||||
value: appSets.filter(appSet => getAppSetHealthStatus(appSet) === 'Healthy').length,
|
||||
color: COLORS.health.healthy
|
||||
},
|
||||
{
|
||||
name: 'Progressing',
|
||||
value: appSets.filter(appSet => getAppSetHealthStatus(appSet) === 'Progressing').length,
|
||||
color: COLORS.health.progressing
|
||||
},
|
||||
{
|
||||
name: 'Degraded',
|
||||
value: appSets.filter(appSet => getAppSetHealthStatus(appSet) === 'Degraded').length,
|
||||
color: COLORS.health.degraded
|
||||
},
|
||||
{
|
||||
name: 'Unknown',
|
||||
value: appSets.filter(appSet => getAppSetHealthStatus(appSet) === 'Unknown').length,
|
||||
color: COLORS.health.unknown
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
function StatusBarRenderer({readings}: {readings: Reading[]}) {
|
||||
// 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 sortedReadings = [...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) => {
|
||||
const totalItems = sortedReadings.reduce((total, i) => {
|
||||
return total + i.value;
|
||||
}, 0);
|
||||
|
||||
@@ -57,9 +87,9 @@ export const ApplicationsStatusBar = ({applications}: ApplicationsStatusBarProps
|
||||
<>
|
||||
{totalItems > 1 && (
|
||||
<div className='status-bar'>
|
||||
{readings &&
|
||||
readings.length > 1 &&
|
||||
readings.map((item, i) => {
|
||||
{sortedReadings &&
|
||||
sortedReadings.length > 1 &&
|
||||
sortedReadings.map((item, i) => {
|
||||
if (item.value > 0) {
|
||||
return (
|
||||
<div className='status-bar__segment' style={{backgroundColor: item.color, width: (item.value / totalItems) * 100 + '%'}} key={i}>
|
||||
@@ -76,4 +106,35 @@ export const ApplicationsStatusBar = ({applications}: ApplicationsStatusBarProps
|
||||
)}
|
||||
</Consumer>
|
||||
);
|
||||
}
|
||||
|
||||
export interface AppsStatusBarProps {
|
||||
applications: models.Application[];
|
||||
}
|
||||
|
||||
export const AppsStatusBar = ({applications}: AppsStatusBarProps) => {
|
||||
if (!applications || applications.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return <StatusBarRenderer readings={getAppReadings(applications)} />;
|
||||
};
|
||||
|
||||
export interface AppSetsStatusBarProps {
|
||||
appSets: models.ApplicationSet[];
|
||||
}
|
||||
|
||||
export const AppSetsStatusBar = ({appSets}: AppSetsStatusBarProps) => {
|
||||
if (!appSets || appSets.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return <StatusBarRenderer readings={getAppSetReadings(appSets)} />;
|
||||
};
|
||||
|
||||
// Legacy wrapper for backwards compatibility (callers should migrate to AppsStatusBar or AppSetsStatusBar)
|
||||
export interface ApplicationsStatusBarProps {
|
||||
applications: models.Application[];
|
||||
}
|
||||
|
||||
export const ApplicationsStatusBar = ({applications}: ApplicationsStatusBarProps) => {
|
||||
return <AppsStatusBar applications={applications} />;
|
||||
};
|
||||
|
||||
@@ -1,20 +1,18 @@
|
||||
import {DataLoader, DropDownMenu, Tooltip} from 'argo-ui';
|
||||
import {DataLoader} 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, getApplicationLinkURL, getManagedByURL} from '../utils';
|
||||
import {ApplicationsLabels} from './applications-labels';
|
||||
import {ApplicationsSource} from './applications-source';
|
||||
import {isApp} from '../utils';
|
||||
import {services} from '../../../shared/services';
|
||||
import {ApplicationTableRow} from './application-table-row';
|
||||
import {AppSetTableRow} from './appset-table-row';
|
||||
|
||||
import './applications-table.scss';
|
||||
|
||||
export const ApplicationsTable = (props: {
|
||||
applications: models.Application[];
|
||||
applications: models.AbstractApplication[];
|
||||
syncApplication: (appName: string, appNamespace: string) => any;
|
||||
refreshApplication: (appName: string, appNamespace: string) => any;
|
||||
deleteApplication: (appName: string, appNamespace: string) => any;
|
||||
@@ -48,149 +46,26 @@ export const ApplicationsTable = (props: {
|
||||
<Consumer>
|
||||
{ctx => (
|
||||
<DataLoader load={() => services.viewPreferences.getPreferences()}>
|
||||
{pref => {
|
||||
const favList = pref.appList.favoritesAppList || [];
|
||||
return (
|
||||
<div className='applications-table argo-table-list argo-table-list--clickable'>
|
||||
{props.applications.map((app, i) => {
|
||||
return (
|
||||
<div
|
||||
key={AppUtils.appInstanceName(app)}
|
||||
className={`argo-table-list__row
|
||||
applications-list__entry applications-list__entry--health-${app.status.health.status} ${selectedApp === i ? 'applications-tiles__selected' : ''}`}>
|
||||
<div
|
||||
className={`row applications-list__table-row ${app.status.sourceHydrator?.currentOperation ? 'applications-table-row--with-hydrator' : ''}`}
|
||||
onClick={e => ctx.navigation.goto(`/${AppUtils.getAppUrl(app)}`, {}, {event: e})}>
|
||||
<div className='columns small-4'>
|
||||
<div className='row'>
|
||||
<div className=' columns small-2'>
|
||||
<div>
|
||||
<Tooltip content={favList?.includes(app.metadata.name) ? 'Remove Favorite' : 'Add Favorite'}>
|
||||
<button
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
favList?.includes(app.metadata.name)
|
||||
? favList.splice(favList.indexOf(app.metadata.name), 1)
|
||||
: favList.push(app.metadata.name);
|
||||
services.viewPreferences.updatePreferences({appList: {...pref.appList, favoritesAppList: favList}});
|
||||
}}>
|
||||
<i
|
||||
className={favList?.includes(app.metadata.name) ? 'fas fa-star' : 'far fa-star'}
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
marginRight: '7px',
|
||||
color: favList?.includes(app.metadata.name) ? '#FFCE25' : '#8fa4b1'
|
||||
}}
|
||||
/>
|
||||
</button>
|
||||
</Tooltip>
|
||||
<ApplicationURLs urls={app.status.summary.externalURLs} />
|
||||
</div>
|
||||
</div>
|
||||
<div className='show-for-xxlarge columns small-4'>Project:</div>
|
||||
<div className='columns small-12 xxlarge-6'>{app.spec.project}</div>
|
||||
</div>
|
||||
<div className='row'>
|
||||
<div className=' columns small-2' />
|
||||
<div className='show-for-xxlarge columns small-4'>Name:</div>
|
||||
<div className='columns small-12 xxlarge-6'>
|
||||
<Tooltip
|
||||
content={
|
||||
<>
|
||||
{app.metadata.name}
|
||||
<br />
|
||||
<Moment fromNow={true} ago={true}>
|
||||
{app.metadata.creationTimestamp}
|
||||
</Moment>
|
||||
</>
|
||||
}>
|
||||
<span>{app.metadata.name}</span>
|
||||
</Tooltip>
|
||||
{/* External link icon for managed-by-url */}
|
||||
{(() => {
|
||||
const linkInfo = getApplicationLinkURL(app, ctx.baseHref);
|
||||
return (
|
||||
<button
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
if (linkInfo.isExternal) {
|
||||
window.open(linkInfo.url, '_blank', 'noopener,noreferrer');
|
||||
} else {
|
||||
ctx.navigation.goto(`/${AppUtils.getAppUrl(app)}`);
|
||||
}
|
||||
}}
|
||||
style={{marginLeft: '0.5em'}}
|
||||
title={`Link: ${linkInfo.url}\nmanaged-by-url: ${getManagedByURL(app) || 'none'}`}>
|
||||
<i className='fa fa-external-link-alt' />
|
||||
</button>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='columns small-6'>
|
||||
<div className='row'>
|
||||
<div className='show-for-xxlarge columns small-2'>Source:</div>
|
||||
<div className='columns small-12 xxlarge-10 applications-table-source' style={{position: 'relative'}}>
|
||||
<div className='applications-table-source__link'>
|
||||
<ApplicationsSource source={getAppDefaultSource(app)} />
|
||||
</div>
|
||||
<div className='applications-table-source__labels'>
|
||||
<ApplicationsLabels app={app} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className='row'>
|
||||
<div className='show-for-xxlarge columns small-2'>Destination:</div>
|
||||
<div className='columns small-12 xxlarge-10'>
|
||||
<Cluster server={app.spec.destination.server} name={app.spec.destination.name} />/{app.spec.destination.namespace}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='columns small-2'>
|
||||
<AppUtils.HealthStatusIcon state={app.status.health} /> <span>{app.status.health.status}</span> <br />
|
||||
{app.status.sourceHydrator?.currentOperation && (
|
||||
<>
|
||||
<AppUtils.HydrateOperationPhaseIcon operationState={app.status.sourceHydrator.currentOperation} />{' '}
|
||||
<span>{app.status.sourceHydrator.currentOperation.phase}</span> <br />
|
||||
</>
|
||||
)}
|
||||
<AppUtils.ComparisonStatusIcon status={app.status.sync.status} />
|
||||
<span>{app.status.sync.status}</span> <OperationState app={app} quiet={true} />
|
||||
<DropDownMenu
|
||||
anchor={() => (
|
||||
<button className='argo-button argo-button--light argo-button--lg argo-button--short'>
|
||||
<i className='fa fa-ellipsis-v' />
|
||||
</button>
|
||||
)}
|
||||
items={[
|
||||
{
|
||||
title: 'Sync',
|
||||
iconClassName: 'fa fa-fw fa-sync',
|
||||
action: () => props.syncApplication(app.metadata.name, app.metadata.namespace)
|
||||
},
|
||||
{
|
||||
title: 'Refresh',
|
||||
iconClassName: 'fa fa-fw fa-redo',
|
||||
action: () => props.refreshApplication(app.metadata.name, app.metadata.namespace)
|
||||
},
|
||||
{
|
||||
title: 'Delete',
|
||||
iconClassName: 'fa fa-fw fa-times-circle',
|
||||
action: () => props.deleteApplication(app.metadata.name, app.metadata.namespace)
|
||||
}
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
{pref => (
|
||||
<div className='applications-table argo-table-list argo-table-list--clickable'>
|
||||
{props.applications.map((app, i) =>
|
||||
isApp(app) ? (
|
||||
<ApplicationTableRow
|
||||
key={AppUtils.appInstanceName(app)}
|
||||
app={app as models.Application}
|
||||
selected={selectedApp === i}
|
||||
pref={pref}
|
||||
ctx={ctx}
|
||||
syncApplication={props.syncApplication}
|
||||
refreshApplication={props.refreshApplication}
|
||||
deleteApplication={props.deleteApplication}
|
||||
/>
|
||||
) : (
|
||||
<AppSetTableRow key={AppUtils.appInstanceName(app)} appSet={app as models.ApplicationSet} selected={selectedApp === i} pref={pref} ctx={ctx} />
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</DataLoader>
|
||||
)}
|
||||
</Consumer>
|
||||
|
||||
@@ -1,19 +1,18 @@
|
||||
import {DataLoader, Tooltip} from 'argo-ui';
|
||||
import * as classNames from 'classnames';
|
||||
import {DataLoader} from 'argo-ui';
|
||||
import * as React from 'react';
|
||||
import {Key, KeybindingContext, NumKey, NumKeyToNumber, NumPadKey, useNav} from 'argo-ui/v2';
|
||||
import {Cluster} from '../../../shared/components';
|
||||
import {Consumer, Context, AuthSettingsCtx} from '../../../shared/context';
|
||||
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, getApplicationLinkURL, getManagedByURL} from '../utils';
|
||||
import {isApp} from '../utils';
|
||||
import {services} from '../../../shared/services';
|
||||
import {ApplicationTile} from './application-tile';
|
||||
import {AppSetTile} from './appset-tile';
|
||||
|
||||
import './applications-tiles.scss';
|
||||
|
||||
export interface ApplicationTilesProps {
|
||||
applications: models.Application[];
|
||||
applications: models.AbstractApplication[];
|
||||
syncApplication: (appName: string, appNamespace: string) => any;
|
||||
refreshApplication: (appName: string, appNamespace: string) => any;
|
||||
deleteApplication: (appName: string, appNamespace: string) => any;
|
||||
@@ -50,10 +49,9 @@ export const ApplicationTiles = ({applications, syncApplication, refreshApplicat
|
||||
const [selectedApp, navApp, reset] = useNav(applications.length);
|
||||
|
||||
const ctxh = React.useContext(Context);
|
||||
const appRef = {ref: React.useRef(null), set: false};
|
||||
const firstTileRef = React.useRef<HTMLDivElement>(null);
|
||||
const appContainerRef = React.useRef(null);
|
||||
const appsPerRow = useItemsPerContainer(appRef.ref, appContainerRef);
|
||||
const useAuthSettingsCtx = React.useContext(AuthSettingsCtx);
|
||||
const appsPerRow = useItemsPerContainer(firstTileRef, appContainerRef);
|
||||
|
||||
const {useKeybinding} = React.useContext(KeybindingContext);
|
||||
|
||||
@@ -98,269 +96,39 @@ export const ApplicationTiles = ({applications, syncApplication, refreshApplicat
|
||||
return navApp(NumKeyToNumber(n));
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<Consumer>
|
||||
{ctx => (
|
||||
<DataLoader load={() => services.viewPreferences.getPreferences()}>
|
||||
{pref => {
|
||||
const favList = pref.appList.favoritesAppList || [];
|
||||
return (
|
||||
<div className='applications-tiles argo-table-list argo-table-list--clickable' ref={appContainerRef}>
|
||||
{applications.map((app, i) => {
|
||||
const source = getAppDefaultSource(app);
|
||||
const isOci = source?.repoURL?.startsWith('oci://');
|
||||
const targetRevision = source ? source.targetRevision || 'HEAD' : 'Unknown';
|
||||
const linkInfo = getApplicationLinkURL(app, ctx.baseHref);
|
||||
return (
|
||||
<div
|
||||
key={AppUtils.appInstanceName(app)}
|
||||
ref={appRef.set ? null : appRef.ref}
|
||||
className={`argo-table-list__row applications-list__entry applications-list__entry--health-${app.status.health.status} ${
|
||||
selectedApp === i ? 'applications-tiles__selected' : ''
|
||||
}`}>
|
||||
<div
|
||||
className='row applications-tiles__wrapper'
|
||||
onClick={e => ctx.navigation.goto(`/${AppUtils.getAppUrl(app)}`, {view: pref.appDetails.view}, {event: e})}>
|
||||
<div
|
||||
className={`columns small-12 applications-list__info qe-applications-list-${AppUtils.appInstanceName(
|
||||
app
|
||||
)} applications-tiles__item`}>
|
||||
<div className='row '>
|
||||
<div className={app.status.summary.externalURLs?.length > 0 ? 'columns small-10' : 'columns small-11'}>
|
||||
<i
|
||||
className={
|
||||
'icon argo-icon-' + (source?.chart != null ? 'helm' : isOci ? 'oci applications-tiles__item__small' : 'git')
|
||||
}
|
||||
/>
|
||||
<Tooltip content={AppUtils.appInstanceName(app)}>
|
||||
<span className='applications-list__title'>
|
||||
{AppUtils.appQualifiedName(app, useAuthSettingsCtx?.appsInAnyNamespaceEnabled)}
|
||||
</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className={app.status.summary.externalURLs?.length > 0 ? 'columns small-2' : 'columns small-1'}>
|
||||
<div className='applications-list__external-link'>
|
||||
<ApplicationURLs urls={app.status.summary.externalURLs} />
|
||||
<button
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
if (linkInfo.isExternal) {
|
||||
window.open(linkInfo.url, '_blank', 'noopener,noreferrer');
|
||||
} else {
|
||||
ctx.navigation.goto(`/${AppUtils.getAppUrl(app)}`);
|
||||
}
|
||||
}}
|
||||
title={getManagedByURL(app) ? `Managed by: ${getManagedByURL(app)}` : 'Open application'}>
|
||||
<i className='fa fa-external-link-alt' />
|
||||
</button>
|
||||
<button
|
||||
title={favList?.includes(app.metadata.name) ? 'Remove Favorite' : 'Add Favorite'}
|
||||
className='large-text-height'
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
favList?.includes(app.metadata.name)
|
||||
? favList.splice(favList.indexOf(app.metadata.name), 1)
|
||||
: favList.push(app.metadata.name);
|
||||
services.viewPreferences.updatePreferences({appList: {...pref.appList, favoritesAppList: favList}});
|
||||
}}>
|
||||
<i
|
||||
className={favList?.includes(app.metadata.name) ? 'fas fa-star fa-lg' : 'far fa-star fa-lg'}
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
margin: '-1px 0px 0px 7px',
|
||||
color: favList?.includes(app.metadata.name) ? '#FFCE25' : '#8fa4b1'
|
||||
}}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className='row'>
|
||||
<div className='columns small-3' title='Project:'>
|
||||
Project:
|
||||
</div>
|
||||
<div className='columns small-9'>{app.spec.project}</div>
|
||||
</div>
|
||||
<div className='row'>
|
||||
<div className='columns small-3' title='Labels:'>
|
||||
Labels:
|
||||
</div>
|
||||
<div className='columns small-9'>
|
||||
<Tooltip
|
||||
zIndex={4}
|
||||
content={
|
||||
<div>
|
||||
{Object.keys(app.metadata.labels || {})
|
||||
.map(label => ({label, value: app.metadata.labels[label]}))
|
||||
.map(item => (
|
||||
<div key={item.label}>
|
||||
{item.label}={item.value}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
}>
|
||||
<span>
|
||||
{Object.keys(app.metadata.labels || {})
|
||||
.map(label => `${label}=${app.metadata.labels[label]}`)
|
||||
.join(', ')}
|
||||
</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
<div className='row'>
|
||||
<div className='columns small-3' title='Annotations:'>
|
||||
Annotations:
|
||||
</div>
|
||||
<div className='columns small-9'>
|
||||
<Tooltip
|
||||
zIndex={4}
|
||||
content={
|
||||
<div>
|
||||
{Object.keys(app.metadata.annotations || {})
|
||||
.map(annotation => ({label: annotation, value: app.metadata.annotations[annotation]}))
|
||||
.map(item => (
|
||||
<div key={item.label}>
|
||||
{item.label}={item.value}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
}>
|
||||
<span>
|
||||
{Object.keys(app.metadata.annotations || {})
|
||||
.map(annotation => `${annotation}=${app.metadata.annotations[annotation]}`)
|
||||
.join(', ')}
|
||||
</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
<div className='row'>
|
||||
<div className='columns small-3' title='Status:'>
|
||||
Status:
|
||||
</div>
|
||||
<div className='columns small-9' qe-id='applications-tiles-health-status'>
|
||||
<AppUtils.HealthStatusIcon state={app.status.health} /> {app.status.health.status}
|
||||
|
||||
{app.status.sourceHydrator?.currentOperation && (
|
||||
<>
|
||||
<AppUtils.HydrateOperationPhaseIcon operationState={app.status.sourceHydrator.currentOperation} />{' '}
|
||||
{app.status.sourceHydrator.currentOperation.phase}
|
||||
|
||||
</>
|
||||
)}
|
||||
<AppUtils.ComparisonStatusIcon status={app.status.sync.status} /> {app.status.sync.status}
|
||||
|
||||
<OperationState app={app} quiet={true} />
|
||||
</div>
|
||||
</div>
|
||||
<div className='row'>
|
||||
<div className='columns small-3' title='Repository:'>
|
||||
Repository:
|
||||
</div>
|
||||
<div className='columns small-9'>
|
||||
<Tooltip content={source?.repoURL || ''} zIndex={4}>
|
||||
<span>{source?.repoURL}</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
<div className='row'>
|
||||
<div className='columns small-3' title='Target Revision:'>
|
||||
Target Revision:
|
||||
</div>
|
||||
<div className='columns small-9'>{targetRevision}</div>
|
||||
</div>
|
||||
{source?.path && (
|
||||
<div className='row'>
|
||||
<div className='columns small-3' title='Path:'>
|
||||
Path:
|
||||
</div>
|
||||
<div className='columns small-9'>{source?.path}</div>
|
||||
</div>
|
||||
)}
|
||||
{source?.chart && (
|
||||
<div className='row'>
|
||||
<div className='columns small-3' title='Chart:'>
|
||||
Chart:
|
||||
</div>
|
||||
<div className='columns small-9'>{source?.chart}</div>
|
||||
</div>
|
||||
)}
|
||||
<div className='row'>
|
||||
<div className='columns small-3' title='Destination:'>
|
||||
Destination:
|
||||
</div>
|
||||
<div className='columns small-9'>
|
||||
<Cluster server={app.spec.destination.server} name={app.spec.destination.name} />
|
||||
</div>
|
||||
</div>
|
||||
<div className='row'>
|
||||
<div className='columns small-3' title='Namespace:'>
|
||||
Namespace:
|
||||
</div>
|
||||
<div className='columns small-9'>{app.spec.destination.namespace}</div>
|
||||
</div>
|
||||
<div className='row'>
|
||||
<div className='columns small-3' title='Age:'>
|
||||
Created At:
|
||||
</div>
|
||||
<div className='columns small-9'>{AppUtils.formatCreationTimestamp(app.metadata.creationTimestamp)}</div>
|
||||
</div>
|
||||
{app.status.operationState && (
|
||||
<div className='row'>
|
||||
<div className='columns small-3' title='Last sync:'>
|
||||
Last Sync:
|
||||
</div>
|
||||
<div className='columns small-9'>
|
||||
{AppUtils.formatCreationTimestamp(app.status.operationState.finishedAt || app.status.operationState.startedAt)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className='row applications-tiles__actions'>
|
||||
<div className='columns applications-list__entry--actions'>
|
||||
<a
|
||||
className='argo-button argo-button--base'
|
||||
qe-id='applications-tiles-button-sync'
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
syncApplication(app.metadata.name, app.metadata.namespace);
|
||||
}}>
|
||||
<i className='fa fa-sync' /> Sync
|
||||
</a>
|
||||
|
||||
<Tooltip className='custom-tooltip' content={'Refresh'}>
|
||||
<a
|
||||
className='argo-button argo-button--base'
|
||||
qe-id='applications-tiles-button-refresh'
|
||||
{...AppUtils.refreshLinkAttrs(app)}
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
refreshApplication(app.metadata.name, app.metadata.namespace);
|
||||
}}>
|
||||
<i className={classNames('fa fa-redo', {'status-icon--spin': AppUtils.isAppRefreshing(app)})} />{' '}
|
||||
<span className='show-for-xxlarge'>Refresh</span>
|
||||
</a>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip className='custom-tooltip' content={'Delete'}>
|
||||
<a
|
||||
className='argo-button argo-button--base'
|
||||
qe-id='applications-tiles-button-delete'
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
deleteApplication(app.metadata.name, app.metadata.namespace);
|
||||
}}>
|
||||
<i className='fa fa-times-circle' /> <span className='show-for-xxlarge'>Delete</span>
|
||||
</a>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
{pref => (
|
||||
<div className='applications-tiles argo-table-list argo-table-list--clickable' ref={appContainerRef}>
|
||||
{applications.map((app, i) =>
|
||||
isApp(app) ? (
|
||||
<ApplicationTile
|
||||
key={AppUtils.appInstanceName(app)}
|
||||
app={app as models.Application}
|
||||
selected={selectedApp === i}
|
||||
pref={pref}
|
||||
ctx={ctx}
|
||||
tileRef={i === 0 ? firstTileRef : undefined}
|
||||
syncApplication={syncApplication}
|
||||
refreshApplication={refreshApplication}
|
||||
deleteApplication={deleteApplication}
|
||||
/>
|
||||
) : (
|
||||
<AppSetTile
|
||||
key={AppUtils.appInstanceName(app)}
|
||||
appSet={app as models.ApplicationSet}
|
||||
selected={selectedApp === i}
|
||||
pref={pref}
|
||||
ctx={ctx}
|
||||
tileRef={i === 0 ? firstTileRef : undefined}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</DataLoader>
|
||||
)}
|
||||
</Consumer>
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import {Tooltip} from 'argo-ui';
|
||||
import * as React from 'react';
|
||||
import Moment from 'react-moment';
|
||||
import {ContextApis} from '../../../shared/context';
|
||||
import * as models from '../../../shared/models';
|
||||
import * as AppUtils from '../utils';
|
||||
import {getApplicationLinkURL, getManagedByURL, getAppSetHealthStatus} from '../utils';
|
||||
import {services} from '../../../shared/services';
|
||||
import {ViewPreferences} from '../../../shared/services';
|
||||
|
||||
export interface AppSetTableRowProps {
|
||||
appSet: models.ApplicationSet;
|
||||
selected: boolean;
|
||||
pref: ViewPreferences;
|
||||
ctx: ContextApis;
|
||||
}
|
||||
|
||||
export const AppSetTableRow = ({appSet, selected, pref, ctx}: AppSetTableRowProps) => {
|
||||
const favList = pref.appList.favoritesAppList || [];
|
||||
const healthStatus = getAppSetHealthStatus(appSet);
|
||||
const linkInfo = getApplicationLinkURL(appSet, ctx.baseHref);
|
||||
|
||||
const handleFavoriteToggle = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (favList?.includes(appSet.metadata.name)) {
|
||||
favList.splice(favList.indexOf(appSet.metadata.name), 1);
|
||||
} else {
|
||||
favList.push(appSet.metadata.name);
|
||||
}
|
||||
services.viewPreferences.updatePreferences({appList: {...pref.appList, favoritesAppList: favList}});
|
||||
};
|
||||
|
||||
const handleExternalLinkClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (linkInfo.isExternal) {
|
||||
window.open(linkInfo.url, '_blank', 'noopener,noreferrer');
|
||||
} else {
|
||||
ctx.navigation.goto(`/${AppUtils.getAppUrl(appSet)}`);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`argo-table-list__row applications-list__entry applications-list__entry--health-${healthStatus} ${selected ? 'applications-tiles__selected' : ''}`}>
|
||||
<div className='row applications-list__table-row' onClick={e => ctx.navigation.goto(`/${AppUtils.getAppUrl(appSet)}`, {}, {event: e})}>
|
||||
{/* First column: Favorite, Kind, Name */}
|
||||
<div className='columns small-4'>
|
||||
<div className='row'>
|
||||
<div className='columns small-2'>
|
||||
<div>
|
||||
<Tooltip content={favList?.includes(appSet.metadata.name) ? 'Remove Favorite' : 'Add Favorite'}>
|
||||
<button onClick={handleFavoriteToggle}>
|
||||
<i
|
||||
className={favList?.includes(appSet.metadata.name) ? 'fas fa-star' : 'far fa-star'}
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
marginRight: '7px',
|
||||
color: favList?.includes(appSet.metadata.name) ? '#FFCE25' : '#8fa4b1'
|
||||
}}
|
||||
/>
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
<div className='show-for-xxlarge columns small-4'>Kind:</div>
|
||||
<div className='columns small-12 xxlarge-6'>ApplicationSet</div>
|
||||
</div>
|
||||
<div className='row'>
|
||||
<div className='columns small-2' />
|
||||
<div className='show-for-xxlarge columns small-4'>Name:</div>
|
||||
<div className='columns small-12 xxlarge-6'>
|
||||
<Tooltip
|
||||
content={
|
||||
<>
|
||||
{appSet.metadata.name}
|
||||
<br />
|
||||
<Moment fromNow={true} ago={true}>
|
||||
{appSet.metadata.creationTimestamp}
|
||||
</Moment>
|
||||
</>
|
||||
}>
|
||||
<span>{appSet.metadata.name}</span>
|
||||
</Tooltip>
|
||||
<button
|
||||
onClick={handleExternalLinkClick}
|
||||
style={{marginLeft: '0.5em'}}
|
||||
title={`Link: ${linkInfo.url}\nmanaged-by-url: ${getManagedByURL(appSet) || 'none'}`}>
|
||||
<i className='fa fa-external-link-alt' />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status column (takes remaining space since no Source/Destination for AppSets) */}
|
||||
<div className='columns small-8'>
|
||||
<AppUtils.HealthStatusIcon state={{status: healthStatus, message: ''}} /> <span>{healthStatus}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,137 @@
|
||||
import {Tooltip} from 'argo-ui';
|
||||
import * as React from 'react';
|
||||
import {ContextApis, AuthSettingsCtx} from '../../../shared/context';
|
||||
import * as models from '../../../shared/models';
|
||||
import * as AppUtils from '../utils';
|
||||
import {getApplicationLinkURL, getManagedByURL, getAppSetHealthStatus} from '../utils';
|
||||
import {services} from '../../../shared/services';
|
||||
import {ViewPreferences} from '../../../shared/services';
|
||||
|
||||
export interface AppSetTileProps {
|
||||
appSet: models.ApplicationSet;
|
||||
selected: boolean;
|
||||
pref: ViewPreferences;
|
||||
ctx: ContextApis;
|
||||
tileRef?: React.RefObject<HTMLDivElement>;
|
||||
}
|
||||
|
||||
export const AppSetTile = ({appSet, selected, pref, ctx, tileRef}: AppSetTileProps) => {
|
||||
const useAuthSettingsCtx = React.useContext(AuthSettingsCtx);
|
||||
const favList = pref.appList.favoritesAppList || [];
|
||||
|
||||
const linkInfo = getApplicationLinkURL(appSet, ctx.baseHref);
|
||||
const healthStatus = getAppSetHealthStatus(appSet);
|
||||
|
||||
const handleFavoriteToggle = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (favList?.includes(appSet.metadata.name)) {
|
||||
favList.splice(favList.indexOf(appSet.metadata.name), 1);
|
||||
} else {
|
||||
favList.push(appSet.metadata.name);
|
||||
}
|
||||
services.viewPreferences.updatePreferences({appList: {...pref.appList, favoritesAppList: favList}});
|
||||
};
|
||||
|
||||
const handleExternalLinkClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (linkInfo.isExternal) {
|
||||
window.open(linkInfo.url, '_blank', 'noopener,noreferrer');
|
||||
} else {
|
||||
ctx.navigation.goto(`/${AppUtils.getAppUrl(appSet)}`);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={tileRef}
|
||||
className={`argo-table-list__row applications-list__entry applications-list__entry--health-${healthStatus} ${selected ? 'applications-tiles__selected' : ''}`}>
|
||||
<div className='row applications-tiles__wrapper' onClick={e => ctx.navigation.goto(`/${AppUtils.getAppUrl(appSet)}`, {view: pref.appDetails.view}, {event: e})}>
|
||||
<div className={`columns small-12 applications-list__info qe-applications-list-${AppUtils.appInstanceName(appSet)} applications-tiles__item`}>
|
||||
{/* Header row with icon, title, and action buttons */}
|
||||
<div className='row'>
|
||||
<div className='columns small-11'>
|
||||
<i className='icon argo-icon-git' />
|
||||
<Tooltip content={AppUtils.appInstanceName(appSet)}>
|
||||
<span className='applications-list__title'>{AppUtils.appQualifiedName(appSet, useAuthSettingsCtx?.appsInAnyNamespaceEnabled)}</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className='columns small-1'>
|
||||
<div className='applications-list__external-link'>
|
||||
<button onClick={handleExternalLinkClick} title={getManagedByURL(appSet) ? `Managed by: ${getManagedByURL(appSet)}` : 'Open application'}>
|
||||
<i className='fa fa-external-link-alt' />
|
||||
</button>
|
||||
<button
|
||||
title={favList?.includes(appSet.metadata.name) ? 'Remove Favorite' : 'Add Favorite'}
|
||||
className='large-text-height'
|
||||
onClick={handleFavoriteToggle}>
|
||||
<i
|
||||
className={favList?.includes(appSet.metadata.name) ? 'fas fa-star fa-lg' : 'far fa-star fa-lg'}
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
margin: '-1px 0px 0px 7px',
|
||||
color: favList?.includes(appSet.metadata.name) ? '#FFCE25' : '#8fa4b1'
|
||||
}}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Labels row */}
|
||||
<div className='row'>
|
||||
<div className='columns small-3' title='Labels:'>
|
||||
Labels:
|
||||
</div>
|
||||
<div className='columns small-9'>
|
||||
<Tooltip
|
||||
zIndex={4}
|
||||
content={
|
||||
<div>
|
||||
{Object.keys(appSet.metadata.labels || {})
|
||||
.map(label => ({label, value: appSet.metadata.labels[label]}))
|
||||
.map(item => (
|
||||
<div key={item.label}>
|
||||
{item.label}={item.value}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
}>
|
||||
<span>
|
||||
{Object.keys(appSet.metadata.labels || {})
|
||||
.map(label => `${label}=${appSet.metadata.labels[label]}`)
|
||||
.join(', ')}
|
||||
</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status row */}
|
||||
<div className='row'>
|
||||
<div className='columns small-3' title='Status:'>
|
||||
Status:
|
||||
</div>
|
||||
<div className='columns small-9' qe-id='applications-tiles-health-status'>
|
||||
<AppUtils.HealthStatusIcon state={{status: healthStatus, message: ''}} /> {healthStatus}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Applications count row */}
|
||||
<div className='row'>
|
||||
<div className='columns small-3' title='Applications:'>
|
||||
Applications:
|
||||
</div>
|
||||
<div className='columns small-9'>{appSet.status?.resourcesCount ?? appSet.status?.resources?.length ?? 0}</div>
|
||||
</div>
|
||||
|
||||
{/* Created At row */}
|
||||
<div className='row'>
|
||||
<div className='columns small-3' title='Age:'>
|
||||
Created At:
|
||||
</div>
|
||||
<div className='columns small-9'>{AppUtils.formatCreationTimestamp(appSet.metadata.creationTimestamp)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1722,6 +1722,35 @@ export function getRootPathByApp(abstractApp: appModels.AbstractApplication) {
|
||||
return isApp(abstractApp) ? '/applications' : '/applicationsets';
|
||||
}
|
||||
|
||||
// Get ApplicationSet health status from its conditions
|
||||
// Priority: ErrorOccurred=True → Degraded, RolloutProgressing=True → Progressing, ResourcesUpToDate=True → Healthy, else Unknown
|
||||
export function getAppSetHealthStatus(appSet: appModels.ApplicationSet): appModels.HealthStatusCode {
|
||||
const conditions = appSet.status?.conditions;
|
||||
if (!conditions || conditions.length === 0) {
|
||||
return 'Unknown';
|
||||
}
|
||||
|
||||
// Check for errors first (indicates degraded state)
|
||||
const errorCondition = conditions.find(c => c.type === 'ErrorOccurred' && c.status === 'True');
|
||||
if (errorCondition) {
|
||||
return 'Degraded';
|
||||
}
|
||||
|
||||
// Check if rollout is progressing
|
||||
const progressingCondition = conditions.find(c => c.type === 'RolloutProgressing' && c.status === 'True');
|
||||
if (progressingCondition) {
|
||||
return 'Progressing';
|
||||
}
|
||||
|
||||
// Check if resources are up to date (healthy state)
|
||||
const upToDateCondition = conditions.find(c => c.type === 'ResourcesUpToDate' && c.status === 'True');
|
||||
if (upToDateCondition) {
|
||||
return 'Healthy';
|
||||
}
|
||||
|
||||
return 'Unknown';
|
||||
}
|
||||
|
||||
export function appQualifiedName(app: appModels.AbstractApplication, nsEnabled: boolean): string {
|
||||
return (nsEnabled ? app.metadata.namespace + '/' : '') + app.metadata.name;
|
||||
}
|
||||
|
||||
@@ -1170,5 +1170,6 @@ export interface ApplicationSet extends AbstractApplication {
|
||||
targetRevisions?: string[];
|
||||
}>;
|
||||
resources?: ApplicationSetResource[];
|
||||
resourcesCount?: number;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -14,11 +14,18 @@ interface QueryOptions {
|
||||
appNamespace?: string;
|
||||
}
|
||||
|
||||
function optionsToSearch(options?: QueryOptions) {
|
||||
function optionsToSearch(options?: QueryOptions): {fields?: string; selector: string; appNamespace: string} {
|
||||
if (options) {
|
||||
return {fields: (options.exclude ? '-' : '') + options.fields.join(','), selector: options.selector || '', appNamespace: options.appNamespace || ''};
|
||||
const result: {fields?: string; selector: string; appNamespace: string} = {
|
||||
selector: options.selector || '',
|
||||
appNamespace: options.appNamespace || ''
|
||||
};
|
||||
if (options.fields) {
|
||||
result.fields = (options.exclude ? '-' : '') + options.fields.join(',');
|
||||
}
|
||||
return result;
|
||||
}
|
||||
return {};
|
||||
return {selector: '', appNamespace: ''};
|
||||
}
|
||||
|
||||
function getQuery(projects: string[], isListOfApplications: boolean, options?: QueryOptions): any {
|
||||
|
||||
Reference in New Issue
Block a user