diff --git a/docs/developer-guide/ui-extensions.md b/docs/developer-guide/ui-extensions.md new file mode 100644 index 0000000000000..fbb0334412504 --- /dev/null +++ b/docs/developer-guide/ui-extensions.md @@ -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) +``` + diff --git a/mkdocs.yml b/mkdocs.yml index edc1eb9437d26..47640bbbd489c 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -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 diff --git a/server/server.go b/server/server.go index 28485841bb333..4139d306377a9 100644 --- a/server/server.go +++ b/server/server.go @@ -4,6 +4,7 @@ import ( "context" "crypto/tls" "fmt" + goio "io" "io/fs" "math" "net" @@ -12,6 +13,7 @@ import ( "os" "os/exec" "path" + "path/filepath" "reflect" "regexp" go_runtime "runtime" @@ -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" @@ -861,11 +864,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()) @@ -876,6 +879,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() { diff --git a/ui/src/app/applications/components/resource-details/resource-details.tsx b/ui/src/app/applications/components/resource-details/resource-details.tsx index 3c1b4acf3c1c3..f6a4b34216489 100644 --- a/ui/src/app/applications/components/resource-details/resource-details.tsx +++ b/ui/src/app/applications/components/resource-details/resource-details.tsx @@ -1,5 +1,4 @@ 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'; @@ -7,7 +6,7 @@ import {ErrorBoundary} from '../../../shared/components/error-boundary/error-bou 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, - 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: ( - - - - ) + if (state) { + extensionTabs.forEach((tabExtensions, i) => { + tabs.push({ + title: tabExtensions.title, + key: `extension-${i}`, + content: ( + + + + ) + }); }); } 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 (
@@ -302,7 +286,7 @@ export const ResourceDetails = (props: ResourceDetailsProps) => { data.liveState, data.podState, data.events, - error.state ? null : extension?.component, + extensions, [ { title: 'SUMMARY', diff --git a/ui/src/app/index.html b/ui/src/app/index.html index 5b77a9d56971b..540c5d7a3a21b 100644 --- a/ui/src/app/index.html +++ b/ui/src/app/index.html @@ -9,6 +9,7 @@ + @@ -20,5 +21,5 @@
- + diff --git a/ui/src/app/shared/services/extensions-service.ts b/ui/src/app/shared/services/extensions-service.ts index 776f6f15a8456..08533040f91ab 100644 --- a/ui/src/app/shared/services/extensions-service.ts +++ b/ui/src/app/shared/services/extensions-service.ts @@ -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>(); +const extensions = { + resourceExtentions: new Array() +}; + +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; export interface Extension { - component: React.ComponentType; + component: ExtensionComponent; } export interface ExtensionComponentProps { @@ -14,27 +44,17 @@ export interface ExtensionComponentProps { } export class ExtensionsService { - public async loadResourceExtension(group: string, kind: string): Promise { - const key = `${group}/${kind}`; - const res = - cache.get(key) || - new Promise((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);