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: support multiple extensions per resource group/kind #9834

Merged
merged 5 commits into from
Jul 8, 2022
Merged
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
Next Next commit
feat: support multiple extensions per resource group/kind
Signed-off-by: Alexander Matyushentsev <AMatyushentsev@gmail.com>
  • Loading branch information
alexmt committed Jun 30, 2022
commit 3c9e8d205926e65bb6f856d7a19dd311b3d30736
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 ).
alexmt marked this conversation as resolved.
Show resolved Hide resolved

```
/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).
crenshaw-dev marked this conversation as resolved.
Show resolved Hide resolved

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">
crenshaw-dev marked this conversation as resolved.
Show resolved Hide resolved

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
@@ -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
36 changes: 33 additions & 3 deletions server/server.go
Original file line number Diff line number Diff line change
@@ -5,13 +5,15 @@ import (
"crypto/tls"
"fmt"
"io/fs"
"io/ioutil"
alexmt marked this conversation as resolved.
Show resolved Hide resolved
"math"
"net"
"net/http"
"net/url"
"os"
"os/exec"
"path"
"path/filepath"
"reflect"
"regexp"
go_runtime "runtime"
@@ -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) {
alexmt marked this conversation as resolved.
Show resolved Hide resolved
a.serveExtensions(extensionsSharedPath, writer)
})

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

var extensionsPattern = regexp.MustCompile(`^extension(.*).js$`)
alexmt marked this conversation as resolved.
Show resolved Hide resolved

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
alexmt marked this conversation as resolved.
Show resolved Hide resolved
}
if !info.IsDir() && extensionsPattern.MatchString(info.Name()) {
crenshaw-dev marked this conversation as resolved.
Show resolved Hide resolved
data, err := ioutil.ReadFile(filePath)
alexmt marked this conversation as resolved.
Show resolved Hide resolved
if err != nil {
return err
alexmt marked this conversation as resolved.
Show resolved Hide resolved
}
content = content + fmt.Sprintf(`
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we should either stream this out or add a cache. Total file of extensions will probably usually be small (<1MB), but an attacker could put memory pressure on the API server by making a bunch of requests to this endpoint.

Copy link
Collaborator Author

@alexmt alexmt Jul 7, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agree. Working on adding caching. Sorry, answered too soon. The only difference if this endpoint from http.FileServer is that it serves multiple files instead of one which is not a big difference.

We could introduce something like fsnotify but it known to cause problems in k8s (leaking file handlers). I don't think it worth it. So I would do nothing right now until we discover a real issue. WDYT?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

resolved accidentally, unresolved :)

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One downside to this vs. http.FileServer is that (I believe) that library streams the requested file from disk instead of loading it fully into memory. In the case of this new behavior, multiple files are loaded simultaneously into memory before being sent out.

If we can stream the concatenated files out instead of caching them, I think that would sufficiently limit memory load on the API server.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That is an awesome idea! Thank you! Working on it

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done!

// source: %s/%s
`, filePath, info.Name()) + string(data)
}
return nil
}); err != nil && !os.IsNotExist(err) {
alexmt marked this conversation as resolved.
Show resolved Hide resolved
log.Errorf("Failed to walk extensions directory: %v", err)
http.Error(w, "Internal error", http.StatusInternalServerError)
return
}
_, _ = fmt.Fprintln(w, content)
alexmt marked this conversation as resolved.
Show resolved Hide resolved
}

// registerDexHandlers will register dex HTTP handlers, creating the the OAuth client app
func (a *ArgoCDServer) registerDexHandlers(mux *http.ServeMux) {
if !a.settings.IsSSOConfigured() {
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';
@@ -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 [];
}
@@ -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;
@@ -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%'}}>
@@ -302,7 +286,7 @@ export const ResourceDetails = (props: ResourceDetailsProps) => {
data.liveState,
data.podState,
data.events,
error.state ? null : extension?.component,
extensions,
[
{
title: 'SUMMARY',
3 changes: 2 additions & 1 deletion ui/src/app/index.html
Original file line number Diff line number Diff line change
@@ -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>
@@ -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 {
@@ -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);