Skip to content

Commit

Permalink
feat: support multiple extensions per resource group/kind (#9834)
Browse files Browse the repository at this point in the history
* feat: support multiple extensions per resource group/kind

Signed-off-by: Alexander Matyushentsev <AMatyushentsev@gmail.com>

* apply reviewers suggestions

Signed-off-by: Alexander Matyushentsev <AMatyushentsev@gmail.com>

* apply reviewer notes: stream extension files one by one

Signed-off-by: Alexander Matyushentsev <AMatyushentsev@gmail.com>

* wrap errors

Signed-off-by: Alexander Matyushentsev <AMatyushentsev@gmail.com>

* skip symlinks

Signed-off-by: Alexander Matyushentsev <AMatyushentsev@gmail.com>
  • Loading branch information
alexmt authored Jul 8, 2022
1 parent 335ffa8 commit 58d4068
Show file tree
Hide file tree
Showing 6 changed files with 169 additions and 59 deletions.
59 changes: 59 additions & 0 deletions docs/developer-guide/ui-extensions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
# 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 extension 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.

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
51 changes: 48 additions & 3 deletions server/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"
"crypto/tls"
"fmt"
goio "io"
"io/fs"
"math"
"net"
Expand All @@ -12,6 +13,7 @@ import (
"os"
"os/exec"
"path"
"path/filepath"
"reflect"
"regexp"
go_runtime "runtime"
Expand Down Expand Up @@ -98,6 +100,7 @@ import (
"github.com/argoproj/argo-cd/v2/util/healthz"
httputil "github.com/argoproj/argo-cd/v2/util/http"
"github.com/argoproj/argo-cd/v2/util/io"
"github.com/argoproj/argo-cd/v2/util/io/files"
jwtutil "github.com/argoproj/argo-cd/v2/util/jwt"
kubeutil "github.com/argoproj/argo-cd/v2/util/kube"
"github.com/argoproj/argo-cd/v2/util/oidc"
Expand Down Expand Up @@ -864,11 +867,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 @@ -879,6 +882,48 @@ 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")

err := filepath.Walk(extensionsSharedPath, func(filePath string, info os.FileInfo, err error) error {
if err != nil {
return fmt.Errorf("failed to iterate files in '%s': %w", extensionsSharedPath, err)
}
if !files.IsSymlink(info) && !info.IsDir() && extensionsPattern.MatchString(info.Name()) {
processFile := func() error {
if _, err = w.Write([]byte(fmt.Sprintf("// source: %s/%s \n", filePath, info.Name()))); err != nil {
return fmt.Errorf("failed to write to response: %w", err)
}

f, err := os.Open(filePath)
if err != nil {
return fmt.Errorf("failed to open file '%s': %w", filePath, err)
}
defer io.Close(f)

if _, err := goio.Copy(w, f); err != nil {
return fmt.Errorf("failed to copy file '%s': %w", filePath, err)
}

return nil
}

if processFile() != nil {
return fmt.Errorf("failed to serve extension file '%s': %w", filePath, processFile())
}
}
return nil
})

if err != nil && !os.IsNotExist(err) {
log.Errorf("Failed to walk extensions directory: %v", err)
http.Error(w, "Internal error", http.StatusInternalServerError)
return
}
}

// 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 58d4068

Please sign in to comment.