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 all commits
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
71 changes: 52 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,65 @@ 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);
```

## System Level Extensions

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 system level extension should be registered using the `extensionsAPI.registerSystemLevelExtension` method:

```typescript
registerSystemLevelExtension(component: ExtensionComponent, title: string, options: {icon?: string})
```

Below is an example of a simple system level extension:

```typescript
((window) => {
const component = () => {
return React.createElement(
"div",
{ style: { padding: "10px" } },
"Hello World"
);
};
window.extensionsAPI.registerSystemLevelExtension(
component,
"Test Ext",
"/hello",
"fa-flask"
);
})(window);
```

## 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.
58 changes: 50 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,31 @@ export class App extends React.Component<{}, {popupProps: PopupProps; showVersio
link.type = 'text/css';
document.head.appendChild(link);
}

const systemExtensions = services.extensions.getSystemExtensions();
const extendedNavItems = this.navItems;
const extendedRoutes = this.routes;
for (const extension of systemExtensions) {
extendedNavItems.push({
title: extension.title,
path: extension.path,
iconClassName: `fa ${extension.icon}`
});
const component = () => (
<>
<Helmet>
<title>{extension.title} - Argo CD</title>
</Helmet>
<extension.component />
</>
);
extendedRoutes[extension.path] = {
component: component as React.ComponentType<React.ComponentProps<any>>,
extension: true
};
}

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

public render() {
Expand Down Expand Up @@ -176,8 +214,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 +228,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 +244,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>
);
22 changes: 20 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>(),
systemLevelExtensions: new Array<SystemLevelExtension>()
};

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 registerSystemLevelExtension(component: ExtensionComponent, title: string, path: string, icon: string) {
extensions.systemLevelExtensions.push({component, title, icon, path});
}

let legacyInitialized = false;

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

export interface SystemLevelExtension {
title: string;
component: SystemExtensionComponent;
icon?: string;
path?: string;
}

export type ExtensionComponent = React.ComponentType<ExtensionComponentProps>;
export type SystemExtensionComponent = React.ComponentType;

export interface Extension {
component: ExtensionComponent;
Expand All @@ -51,12 +64,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 getSystemExtensions(): SystemLevelExtension[] {
return extensions.systemLevelExtensions.slice();
}
}

((window: any) => {
// deprecated: kept for backwards compatibility
window.extensions = {resources: {}};
window.extensionsAPI = {
registerResourceExtension
registerResourceExtension,
registerSystemLevelExtension
};
})(window);
Loading