Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: system level extensions #10758

Merged
merged 7 commits into from
Oct 3, 2022
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 32 additions & 19 deletions docs/developer-guide/ui-extensions.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,9 @@ The extension should provide a React component that is responsible for rendering
Instead extension should use the `react` global variable. You can leverage `externals` setting if you are using webpack:

```js
externals: {
react: 'React'
}
externals: {
react: "React";
}
```

## Resource Tab Extensions
Expand All @@ -33,32 +33,45 @@ The resource tab extension should be registered using the `extensionsAPI.registe
registerResourceExtension(component: ExtensionComponent, group: string, kind: string, tabTitle: string)
```

* `component: ExtensionComponent` is a React component that receives the following properties:
- `component: ExtensionComponent` is a React component that receives the following properties:

* application: Application - Argo CD Application resource;
* resource: State - the kubernetes resource object;
* tree: ApplicationTree - includes list of all resources that comprise the application;
- application: Application - Argo CD Application resource;
- resource: State - the kubernetes resource object;
- tree: ApplicationTree - includes list of all resources that comprise the application;

See properties interfaces in [models.ts](https://github.com/argoproj/argo-cd/blob/master/ui/src/app/shared/models.ts)
See properties interfaces in [models.ts](https://github.com/argoproj/argo-cd/blob/master/ui/src/app/shared/models.ts)

* `group: string` - the glob expression that matches the group of the resource; note: use globstar (`**`) to match all groups including empty string;
* `kind: string` - the glob expression that matches the kind of the resource;
* `tabTitle: string` - the extension tab title.
* `opts: Object` - additional options:
* `icon: string` - the class name the represents the icon from the [https://fontawesome.com/](https://fontawesome.com/) library (e.g. 'fa-calendar-alt');
- `group: string` - the glob expression that matches the group of the resource; note: use globstar (`**`) to match all groups including empty string;
- `kind: string` - the glob expression that matches the kind of the resource;
- `tabTitle: string` - the extension tab title.
- `opts: Object` - additional options:
- `icon: string` - the class name the represents the icon from the [https://fontawesome.com/](https://fontawesome.com/) library (e.g. 'fa-calendar-alt');

Below is an example of a resource tab extension:

```javascript
((window) => {
const component = () => {
return React.createElement( 'div', {}, 'Hello World' );
};
window.extensionsAPI.registerResourceExtension(component, '*', '*', 'Nice extension');
})(window)
const component = () => {
return React.createElement("div", {}, "Hello World");
};
window.extensionsAPI.registerResourceExtension(
component,
"*",
"*",
"Nice extension"
);
})(window);
```

## Sidebar Extensions
rbreeze marked this conversation as resolved.
Show resolved Hide resolved

Argo CD allows you to add new items to the sidebar that will be displayed as a new page with a custom component when clicked. The sidebar extension should be registered using the `extensionsAPI.registerSidebarExtension` method:

```typescript
registerSidebarExtension(component: ExtensionComponent, title: string, options: {icon?: string})
```
rbreeze marked this conversation as resolved.
Show resolved Hide resolved

## Application Tab Extensions

Since the Argo CD Application is a Kubernetes resource, application tabs can be the same as any other resource tab.
Make sure to use 'argoproj.io'/'Application' as group/kind and an extension will be used to render the application-level tab.
Make sure to use 'argoproj.io'/'Application' as group/kind and an extension will be used to render the application-level tab.
47 changes: 39 additions & 8 deletions ui/src/app/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,15 +23,24 @@ const base = bases.length > 0 ? bases[0].getAttribute('href') || '/' : '/';
export const history = createBrowserHistory({basename: base});
requests.setBaseHRef(base);

const routes: {[path: string]: {component: React.ComponentType<RouteComponentProps<any>>; noLayout?: boolean}} = {
type Routes = {[path: string]: {component: React.ComponentType<RouteComponentProps<any>>; noLayout?: boolean; extension?: boolean}};

const routes: Routes = {
'/login': {component: login.component as any, noLayout: true},
'/applications': {component: applications.component},
'/settings': {component: settings.component},
'/user-info': {component: userInfo.component},
'/help': {component: help.component}
};

const navItems = [
interface NavItem {
title: string;
tooltip?: string;
path: string;
iconClassName: string;
}

const navItems: NavItem[] = [
{
title: 'Applications',
tooltip: 'Manage your applications, and diagnose health problems.',
Expand Down Expand Up @@ -97,7 +106,7 @@ requests.onError.subscribe(async err => {
}
});

export class App extends React.Component<{}, {popupProps: PopupProps; showVersionPanel: boolean; error: Error}> {
export class App extends React.Component<{}, {popupProps: PopupProps; showVersionPanel: boolean; error: Error; navItems: NavItem[]; routes: Routes; extensionsLoaded: boolean}> {
public static childContextTypes = {
history: PropTypes.object,
apis: PropTypes.object
Expand All @@ -110,13 +119,17 @@ export class App extends React.Component<{}, {popupProps: PopupProps; showVersio
private popupManager: PopupManager;
private notificationsManager: NotificationsManager;
private navigationManager: NavigationManager;
private navItems: NavItem[];
private routes: Routes;

constructor(props: {}) {
super(props);
this.state = {popupProps: null, error: null, showVersionPanel: false};
this.state = {popupProps: null, error: null, showVersionPanel: false, navItems: [], routes: null, extensionsLoaded: false};
this.popupManager = new PopupManager();
this.notificationsManager = new NotificationsManager();
this.navigationManager = new NavigationManager(history);
this.navItems = navItems;
this.routes = routes;
}

public async componentDidMount() {
Expand Down Expand Up @@ -144,6 +157,20 @@ export class App extends React.Component<{}, {popupProps: PopupProps; showVersio
link.type = 'text/css';
document.head.appendChild(link);
}

const sidebarExtensions = services.extensions.getSidebarExtensions();
const extendedNavItems = this.navItems;
const extendedRoutes = this.routes;
for (const extension of sidebarExtensions) {
extendedNavItems.push({
title: extension.title,
path: extension.path,
iconClassName: `fa ${extension.icon}`
});
extendedRoutes[extension.path] = {component: extension.component as React.ComponentType<React.ComponentProps<any>>, extension: true};
rbreeze marked this conversation as resolved.
Show resolved Hide resolved
}

this.setState({...this.state, navItems: extendedNavItems, routes: extendedRoutes, extensionsLoaded: true});
}

public render() {
Expand Down Expand Up @@ -176,8 +203,8 @@ export class App extends React.Component<{}, {popupProps: PopupProps; showVersio
<Router history={history}>
<Switch>
<Redirect exact={true} path='/' to='/applications' />
{Object.keys(routes).map(path => {
const route = routes[path];
{Object.keys(this.routes).map(path => {
const route = this.routes[path];
return (
<Route
key={path}
Expand All @@ -190,7 +217,11 @@ export class App extends React.Component<{}, {popupProps: PopupProps; showVersio
) : (
<DataLoader load={() => services.viewPreferences.getPreferences()}>
{pref => (
<Layout onVersionClick={() => this.setState({showVersionPanel: true})} navItems={navItems} theme={pref.theme}>
<Layout
onVersionClick={() => this.setState({showVersionPanel: true})}
navItems={this.navItems}
pref={pref}
isExtension={route.extension}>
<Banner>
<route.component {...routeProps} />
</Banner>
Expand All @@ -202,7 +233,7 @@ export class App extends React.Component<{}, {popupProps: PopupProps; showVersio
/>
);
})}
<Redirect path='*' to='/' />
{this.state.extensionsLoaded && <Redirect path='*' to='/' />}
</Switch>
</Router>
</Provider>
Expand Down
15 changes: 15 additions & 0 deletions ui/src/app/shared/components/layout/layout.scss
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
@import 'node_modules/argo-ui/src/styles/config';
@import 'node_modules/argo-ui/src/styles/theme';
@import '../../config.scss';

.theme-light,
.theme-dark {
Expand Down Expand Up @@ -35,4 +36,18 @@
}
}
}

&__content {
width: 100%;
}

&--extension {
.cd-layout__content--sb-expanded {
padding-left: $sidebar-width;
}

.cd-layout__content--sb-collapsed {
padding-left: $collapsed-sidebar-width;
}
}
}
12 changes: 7 additions & 5 deletions ui/src/app/shared/components/layout/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,20 +1,22 @@
import * as React from 'react';
import {Sidebar} from '../../../sidebar/sidebar';
import {ViewPreferences} from '../../services';

require('./layout.scss');

export interface LayoutProps {
navItems: Array<{path: string; iconClassName: string; title: string}>;
onVersionClick?: () => void;
theme?: string;
children?: React.ReactNode;
pref: ViewPreferences;
isExtension?: boolean;
}

export const Layout = (props: LayoutProps) => (
<div className={props.theme ? 'theme-' + props.theme : 'theme-light'}>
<div className='cd-layout'>
<Sidebar onVersionClick={props.onVersionClick} navItems={props.navItems} />
{props.children}
<div className={props.pref.theme ? 'theme-' + props.pref.theme : 'theme-light'}>
<div className={`cd-layout ${props.isExtension ? 'cd-layout--extension' : ''}`}>
<Sidebar onVersionClick={props.onVersionClick} navItems={props.navItems} pref={props.pref} />
<div className={`cd-layout__content ${props.pref.hideSidebar ? 'cd-layout__content--sb-collapsed' : 'cd-layout__content--sb-expanded'}`}>{props.children}</div>
</div>
</div>
);
21 changes: 19 additions & 2 deletions ui/src/app/shared/services/extensions-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,18 @@ import * as minimatch from 'minimatch';
import {Application, ApplicationTree, State} from '../models';

const extensions = {
resourceExtentions: new Array<ResourceTabExtension>()
resourceExtentions: new Array<ResourceTabExtension>(),
sidebarExtensions: new Array<SidebarExtension>()
};

function registerResourceExtension(component: ExtensionComponent, group: string, kind: string, tabTitle: string, opts?: {icon: string}) {
extensions.resourceExtentions.push({component, group, kind, title: tabTitle, icon: opts?.icon});
}

function registerSidebarExtension(component: ExtensionComponent, title: string, path: string, icon: string) {
extensions.sidebarExtensions.push({component, title, icon, path});
}

let legacyInitialized = false;

function initLegacyExtensions() {
Expand All @@ -33,6 +38,13 @@ export interface ResourceTabExtension {
icon?: string;
}

export interface SidebarExtension {
title: string;
component: ExtensionComponent;
icon?: string;
path?: string;
}

export type ExtensionComponent = React.ComponentType<ExtensionComponentProps>;

export interface Extension {
Expand All @@ -51,12 +63,17 @@ export class ExtensionsService {
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 getSidebarExtensions(): SidebarExtension[] {
return extensions.sidebarExtensions.slice();
}
}

((window: any) => {
// deprecated: kept for backwards compatibility
window.extensions = {resources: {}};
window.extensionsAPI = {
registerResourceExtension
registerResourceExtension,
registerSidebarExtension
};
})(window);
77 changes: 37 additions & 40 deletions ui/src/app/sidebar/sidebar.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
import {DataLoader, Tooltip} from 'argo-ui';
import {Tooltip} from 'argo-ui';
import {useData} from 'argo-ui/v2';
import * as React from 'react';
import {Context} from '../shared/context';
import {services} from '../shared/services';
import {services, ViewPreferences} from '../shared/services';

require('./sidebar.scss');

interface SidebarProps {
onVersionClick: () => void;
navItems: {path: string; iconClassName: string; title: string; tooltip?: string}[];
pref: ViewPreferences;
}

export const SIDEBAR_TOOLS_ID = 'sidebar-tools';
Expand All @@ -33,45 +34,41 @@ export const Sidebar = (props: SidebarProps) => {
const locationPath = context.history.location.pathname;

return (
<DataLoader load={() => services.viewPreferences.getPreferences()}>
{pref => (
<div className={`sidebar ${pref.hideSidebar ? 'sidebar--collapsed' : ''}`}>
<div className='sidebar__logo'>
<img src='assets/images/logo.png' alt='Argo' /> {!pref.hideSidebar && 'Argo CD'}
</div>
<div className='sidebar__version' onClick={props.onVersionClick}>
{loading ? 'Loading...' : error?.state ? 'Unknown' : version?.Version || 'Unknown'}
</div>
{(props.navItems || []).map(item => (
<Tooltip
content={item?.tooltip || item.title}
placement='right'
popperOptions={{
modifiers: {
preventOverflow: {
boundariesElement: 'window'
}
}
}}>
<div
key={item.title}
className={`sidebar__nav-item ${locationPath === item.path || locationPath.startsWith(`${item.path}/`) ? 'sidebar__nav-item--active' : ''}`}
onClick={() => context.history.push(item.path)}>
<React.Fragment>
<div>
<i className={item?.iconClassName || ''} />
{!pref.hideSidebar && item.title}
</div>
</React.Fragment>
<div className={`sidebar ${props.pref.hideSidebar ? 'sidebar--collapsed' : ''}`}>
<div className='sidebar__logo'>
<img src='assets/images/logo.png' alt='Argo' /> {!props.pref.hideSidebar && 'Argo CD'}
</div>
<div className='sidebar__version' onClick={props.onVersionClick}>
{loading ? 'Loading...' : error?.state ? 'Unknown' : version?.Version || 'Unknown'}
</div>
{(props.navItems || []).map(item => (
<Tooltip
rbreeze marked this conversation as resolved.
Show resolved Hide resolved
content={item?.tooltip || item.title}
placement='right'
popperOptions={{
modifiers: {
preventOverflow: {
boundariesElement: 'window'
}
}
}}>
<div
key={item.title}
className={`sidebar__nav-item ${locationPath === item.path || locationPath.startsWith(`${item.path}/`) ? 'sidebar__nav-item--active' : ''}`}
onClick={() => context.history.push(item.path)}>
<React.Fragment>
<div>
<i className={item?.iconClassName || ''} />
{!props.pref.hideSidebar && item.title}
</div>
</Tooltip>
))}
<div onClick={() => services.viewPreferences.updatePreferences({...pref, hideSidebar: !pref.hideSidebar})} className='sidebar__collapse-button'>
<i className={`fas fa-arrow-${pref.hideSidebar ? 'right' : 'left'}`} />
</React.Fragment>
</div>
<div id={SIDEBAR_TOOLS_ID} />
</div>
)}
</DataLoader>
</Tooltip>
))}
<div onClick={() => services.viewPreferences.updatePreferences({...props.pref, hideSidebar: !props.pref.hideSidebar})} className='sidebar__collapse-button'>
<i className={`fas fa-arrow-${props.pref.hideSidebar ? 'right' : 'left'}`} />
</div>
<div id={SIDEBAR_TOOLS_ID} />
</div>
);
};