Skip to content

Commit

Permalink
feat: support multiple extensions per resource group/kind
Browse files Browse the repository at this point in the history
Signed-off-by: Alexander Matyushentsev <AMatyushentsev@gmail.com>
  • Loading branch information
alexmt committed Jun 30, 2022
1 parent 7630d43 commit 3c9e8d2
Show file tree
Hide file tree
Showing 6 changed files with 156 additions and 59 deletions.
61 changes: 61 additions & 0 deletions docs/developer-guide/ui-extensions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
# UI Extensions

Argo CD web user interface can be extended with additional UI elements. Extensions should be delivered as a javascript file
in the `argocd-server` Pods that are placed in the `/tmp/extensions` directory and starts with `extension` prefix ( matches to `^extension(.*).js$` regex ).

```
/tmp/extensions
├── example1
│   └── extension-1.js
└── example2
└── extension-2.js
```

Extensions are loaded during initial page rendering and should register themselves using API exposed in the `extensionsAPI` global variable. (See
corresponding extention type details for additional information).

The extension should provide a React component that is responsible for rendering the UI element. Extension should not bundle the React library.
Instead extension should use the `react` global variable. You can leverage `externals` setting if you are using webpack:

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

## Resource Tab Extensions

Resource Tab extensions is an extension that provides an additional tab for the resource sliding panel at the Argo CD Application details page.

<img width="568" alt="image" src="https://user-images.githubusercontent.com/426437/176794114-cb3707a4-1d65-4468-91d7-4fd54e9e9d42.png">

The resource tab extension should be registered using the `extensionsAPI.registerResourceExtension` method:

```typescript
registerResourceExtension(component: ExtensionComponent, group: string, kind: string, tabTitle: string)
```



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

* 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)

* `group: string` - the glob expression that matches the group of the resource;
* `kind: string` - the glob expression that matches the kind of the resource;
* `tabTitle: string` - the extension tab title.

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)
```

1 change: 1 addition & 0 deletions mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,7 @@ nav:
- developer-guide/releasing.md
- developer-guide/site.md
- developer-guide/static-code-analysis.md
- developer-guide/ui-extensions.md
- developer-guide/faq.md
- faq.md
- security_considerations.md
Expand Down
36 changes: 33 additions & 3 deletions server/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,15 @@ import (
"crypto/tls"
"fmt"
"io/fs"
"io/ioutil"
"math"
"net"
"net/http"
"net/url"
"os"
"os/exec"
"path"
"path/filepath"
"reflect"
"regexp"
go_runtime "runtime"
Expand Down Expand Up @@ -861,11 +863,11 @@ func (a *ArgoCDServer) newHTTPServer(ctx context.Context, port int, grpcWebHandl
registerDownloadHandlers(mux, "/download")

// Serve extensions
var extensionsApiPath = "/extensions/"
var extensionsSharedPath = "/tmp/extensions/"

extHandler := http.StripPrefix(extensionsApiPath, http.FileServer(http.Dir(extensionsSharedPath)))
mux.HandleFunc(extensionsApiPath, extHandler.ServeHTTP)
mux.HandleFunc("/extensions.js", func(writer http.ResponseWriter, _ *http.Request) {
a.serveExtensions(extensionsSharedPath, writer)
})

// Serve UI static assets
var assetsHandler http.Handler = http.HandlerFunc(a.newStaticAssetsHandler())
Expand All @@ -876,6 +878,34 @@ func (a *ArgoCDServer) newHTTPServer(ctx context.Context, port int, grpcWebHandl
return &httpS
}

var extensionsPattern = regexp.MustCompile(`^extension(.*).js$`)

func (a *ArgoCDServer) serveExtensions(extensionsSharedPath string, w http.ResponseWriter) {
w.Header().Set("Content-Type", "application/javascript")

content := ""
if err := filepath.Walk(extensionsSharedPath, func(filePath string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if !info.IsDir() && extensionsPattern.MatchString(info.Name()) {
data, err := ioutil.ReadFile(filePath)
if err != nil {
return err
}
content = content + fmt.Sprintf(`
// source: %s/%s
`, filePath, info.Name()) + string(data)
}
return nil
}); err != nil && !os.IsNotExist(err) {
log.Errorf("Failed to walk extensions directory: %v", err)
http.Error(w, "Internal error", http.StatusInternalServerError)
return
}
_, _ = fmt.Fprintln(w, content)
}

// registerDexHandlers will register dex HTTP handlers, creating the the OAuth client app
func (a *ArgoCDServer) registerDexHandlers(mux *http.ServeMux) {
if !a.settings.IsSSOConfigured() {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,13 +1,12 @@
import {DataLoader, Tab, Tabs} from 'argo-ui';
import {useData} from 'argo-ui/v2';
import * as React from 'react';
import {EventsList, YamlEditor} from '../../../shared/components';
import * as models from '../../../shared/models';
import {ErrorBoundary} from '../../../shared/components/error-boundary/error-boundary';
import {Context} from '../../../shared/context';
import {Application, ApplicationTree, AppSourceType, Event, RepoAppDetails, ResourceNode, State, SyncStatuses} from '../../../shared/models';
import {services} from '../../../shared/services';
import {ExtensionComponentProps} from '../../../shared/services/extensions-service';
import {ResourceTabExtension} from '../../../shared/services/extensions-service';
import {NodeInfo, SelectNode} from '../application-details/application-details';
import {ApplicationNodeInfo} from '../application-node-info/application-node-info';
import {ApplicationParameters} from '../application-parameters/application-parameters';
Expand Down Expand Up @@ -43,15 +42,7 @@ export const ResourceDetails = (props: ResourceDetailsProps) => {
const page = parseInt(new URLSearchParams(appContext.history.location.search).get('page'), 10) || 0;
const untilTimes = (new URLSearchParams(appContext.history.location.search).get('untilTimes') || '').split(',') || [];

const getResourceTabs = (
node: ResourceNode,
state: State,
podState: State,
events: Event[],
ExtensionComponent: React.ComponentType<ExtensionComponentProps>,
tabs: Tab[],
execEnabled: boolean
) => {
const getResourceTabs = (node: ResourceNode, state: State, podState: State, events: Event[], extensionTabs: ResourceTabExtension[], tabs: Tab[], execEnabled: boolean) => {
if (!node || node === undefined) {
return [];
}
Expand Down Expand Up @@ -124,15 +115,17 @@ export const ResourceDetails = (props: ResourceDetailsProps) => {
]);
}
}
if (ExtensionComponent && state) {
tabs.push({
title: 'More',
key: 'extension',
content: (
<ErrorBoundary message={`Something went wrong with Extension for ${state.kind}`}>
<ExtensionComponent tree={tree} resource={state} />
</ErrorBoundary>
)
if (state) {
extensionTabs.forEach((tabExtensions, i) => {
tabs.push({
title: tabExtensions.title,
key: `extension-${i}`,
content: (
<ErrorBoundary message={`Something went wrong with Extension for ${state.kind}`}>
<tabExtensions.component tree={tree} resource={state} />
</ErrorBoundary>
)
});
});
}
return tabs;
Expand Down Expand Up @@ -212,16 +205,7 @@ export const ResourceDetails = (props: ResourceDetailsProps) => {
return tabs;
};

const [extension, , error] = useData(
async () => {
if (selectedNode?.kind && selectedNode?.group) {
return await services.extensions.loadResourceExtension(selectedNode?.group || '', selectedNode?.kind || '');
}
},
null,
null,
[selectedNode]
);
const extensions = selectedNode?.kind && selectedNode?.group ? services.extensions.getResourceTabs(selectedNode?.group, selectedNode?.kind) : [];

return (
<div style={{width: '100%', height: '100%'}}>
Expand Down Expand Up @@ -302,7 +286,7 @@ export const ResourceDetails = (props: ResourceDetailsProps) => {
data.liveState,
data.podState,
data.events,
error.state ? null : extension?.component,
extensions,
[
{
title: 'SUMMARY',
Expand Down
3 changes: 2 additions & 1 deletion ui/src/app/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
<link rel='icon' type='image/png' href='assets/favicon/favicon-32x32.png' sizes='32x32'/>
<link rel='icon' type='image/png' href='assets/favicon/favicon-16x16.png' sizes='16x16'/>
<link href="assets/fonts.css" rel="stylesheet">

</head>

<body>
Expand All @@ -20,5 +21,5 @@
</noscript>
<div id="app"></div>
</body>

<script defer src="extensions.js"></script>
</html>
68 changes: 44 additions & 24 deletions ui/src/app/shared/services/extensions-service.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,41 @@
import * as React from 'react';
import * as minimatch from 'minimatch';

import {ApplicationTree, State} from '../models';

const extensions: {resources: {[key: string]: Extension}} = {resources: {}};
const cache = new Map<string, Promise<Extension>>();
const extensions = {
resourceExtentions: new Array<ResourceTabExtension>()
};

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

let legacyInitialized = false;

function initLegacyExtensions() {
if (legacyInitialized) {
return;
}
legacyInitialized = true;
const resources = (window as any).extensions.resources;
Object.keys(resources).forEach(key => {
const [group, kind] = key.split('/');
registerResourceExtension(resources[key].component, group, kind, 'More');
});
}

export interface ResourceTabExtension {
title: string;
group: string;
kind: string;
component: ExtensionComponent;
}

export type ExtensionComponent = React.ComponentType<ExtensionComponentProps>;

export interface Extension {
component: React.ComponentType<ExtensionComponentProps>;
component: ExtensionComponent;
}

export interface ExtensionComponentProps {
Expand All @@ -14,27 +44,17 @@ export interface ExtensionComponentProps {
}

export class ExtensionsService {
public async loadResourceExtension(group: string, kind: string): Promise<Extension> {
const key = `${group}/${kind}`;
const res =
cache.get(key) ||
new Promise<Extension>((resolve, reject) => {
const script = document.createElement('script');
script.src = `extensions/resources/${group}/${kind}/ui/extensions.js`;
script.onload = () => {
const ext = extensions.resources[key];
if (!ext) {
reject(`Failed to load extension for ${group}/${kind}`);
} else {
resolve(ext);
}
};
script.onerror = reject;
document.body.appendChild(script);
});
cache.set(key, res);
return res;
public getResourceTabs(group: string, kind: string): ResourceTabExtension[] {
initLegacyExtensions();
const items = extensions.resourceExtentions.filter(extension => minimatch(group, extension.group) && minimatch(kind, extension.kind)).slice();
return items.sort((a, b) => a.title.localeCompare(b.title));
}
}

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

0 comments on commit 3c9e8d2

Please sign in to comment.