From a48fe8e471f8f886792ca4d4634ec1410274b610 Mon Sep 17 00:00:00 2001 From: Manabu Mccloskey Date: Thu, 29 Jun 2023 17:58:30 -0700 Subject: [PATCH] add workflow templates component --- plugins/argo-workflows/README.md | 3 + .../argo-workflows/src/api/ArgoWorkflows.ts | 79 ++++++++--- plugins/argo-workflows/src/api/index.ts | 15 +- .../src/components/Overview/Overview.tsx | 13 ++ .../src/components/Overview/index.ts | 1 + .../WorkflowOverview.test.tsx | 3 +- .../WorkflowOverview/WorkflowOverview.tsx | 31 ++-- .../WorkflowTemplateOverview.test.tsx | 132 ++++++++++++++++++ .../WorkflowTemplateOverview.tsx | 82 +++++++++++ .../argo-workflows/src/components/utils.ts | 38 +++++ plugins/argo-workflows/src/index.ts | 1 + plugins/argo-workflows/src/plugin.ts | 12 ++ .../testResponseWorkflowTemplates.ts | 92 ++++++++++++ 13 files changed, 446 insertions(+), 56 deletions(-) create mode 100644 plugins/argo-workflows/src/components/WorkflowTemplateOverview/WorkflowTemplateOverview.test.tsx create mode 100644 plugins/argo-workflows/src/components/WorkflowTemplateOverview/WorkflowTemplateOverview.tsx create mode 100644 plugins/argo-workflows/src/components/utils.ts create mode 100644 plugins/argo-workflows/src/test-data/testResponseWorkflowTemplates.ts diff --git a/plugins/argo-workflows/README.md b/plugins/argo-workflows/README.md index 58a67ea..77f8934 100644 --- a/plugins/argo-workflows/README.md +++ b/plugins/argo-workflows/README.md @@ -50,6 +50,9 @@ const overviewContent = ( + + + ... diff --git a/plugins/argo-workflows/src/api/ArgoWorkflows.ts b/plugins/argo-workflows/src/api/ArgoWorkflows.ts index caeaabe..9e70fcf 100644 --- a/plugins/argo-workflows/src/api/ArgoWorkflows.ts +++ b/plugins/argo-workflows/src/api/ArgoWorkflows.ts @@ -1,6 +1,9 @@ import { DiscoveryApi, FetchApi } from "@backstage/core-plugin-api"; import { KubernetesApi } from "@backstage/plugin-kubernetes"; -import { IoArgoprojWorkflowV1alpha1WorkflowList } from "./generated"; +import { + IoArgoprojWorkflowV1alpha1WorkflowList, + IoArgoprojWorkflowV1alpha1WorkflowTemplateList, +} from "./generated"; import { ArgoWorkflowsApi } from "./index"; const API_VERSION = "argoproj.io/v1alpha1"; @@ -27,7 +30,7 @@ export class ArgoWorkflows implements ArgoWorkflowsApi { async getWorkflowsFromK8s( clusterName: string | undefined, - namespace: string | undefined, + namespace: string, labels: string | undefined ): Promise { const ns = namespace !== undefined ? namespace : "default"; @@ -69,32 +72,37 @@ export class ArgoWorkflows implements ArgoWorkflowsApi { return this.getWorkflowsFromProxy(namespace, labels); } + async getWorkflowTemplates( + clusterName: string | undefined, + namespace: string, + labels: string | undefined + ): Promise { + if (clusterName) { + return Promise.reject("t"); + } + return this.getWorkflowTemplatesFromProxy(namespace, labels); + } + async getWorkflowsFromProxy( - namespace: string | undefined, + namespace: string, labels: string | undefined ): Promise { - const proxyUrl = await this.discoveryApi.getBaseUrl("proxy"); + const path = `/api/v1/workflows/${namespace}`; + const resp = await this.fetchFromPath(path, labels); + return await checkAndReturn( + resp + ); + } - const ns = namespace !== undefined ? namespace : "default"; - const url = `${proxyUrl}${DEFAULT_WORKFLOW_PROXY}/api/v1/workflows/${ns}`; + async getWorkflowTemplatesFromProxy( + namespace: string, + labels: string | undefined + ): Promise { + const path = `/api/v1/workflow-templates/${namespace}`; - const query = new URLSearchParams({ [API_TIMEOUT]: "30" }); - if (labels) { - query.set(API_LABEL_SELECTOR, labels); - } - const resp = await this.fetchApi.fetch(`${url}?${query.toString()}`, {}); - - if (!resp.ok) { - return Promise.reject( - `failed to fetch resources: ${resp.status}, ${ - resp.statusText - }, ${await resp.text()}` - ); - } - - // need validation - return Promise.resolve( - JSON.parse(await resp.text()) as IoArgoprojWorkflowV1alpha1WorkflowList + const resp = await this.fetchFromPath(path, labels); + return await checkAndReturn( + resp ); } @@ -105,4 +113,29 @@ export class ArgoWorkflows implements ArgoWorkflowsApi { } return Promise.reject("no clusters found in configuration"); } + + async fetchFromPath( + path: string, + labels: string | undefined + ): Promise { + const proxyUrl = await this.discoveryApi.getBaseUrl("proxy"); + const url = `${proxyUrl}${DEFAULT_WORKFLOW_PROXY}${path}`; + const query = new URLSearchParams({ [API_TIMEOUT]: "30" }); + if (labels) { + query.set(API_LABEL_SELECTOR, labels); + } + return this.fetchApi.fetch(`${url}?${query.toString()}`, {}); + } +} + +async function checkAndReturn(resp: Response): Promise { + if (!resp.ok) { + return Promise.reject( + `failed to fetch resources: ${resp.status}, ${ + resp.statusText + }, ${await resp.text()}` + ); + } + // need validation + return Promise.resolve(JSON.parse(await resp.text()) as T); } diff --git a/plugins/argo-workflows/src/api/index.ts b/plugins/argo-workflows/src/api/index.ts index b582c96..3fe1cfd 100644 --- a/plugins/argo-workflows/src/api/index.ts +++ b/plugins/argo-workflows/src/api/index.ts @@ -1,6 +1,9 @@ import { createApiRef } from "@backstage/core-plugin-api"; -import { IoArgoprojWorkflowV1alpha1WorkflowList } from "./generated/"; +import { + IoArgoprojWorkflowV1alpha1WorkflowList, + IoArgoprojWorkflowV1alpha1WorkflowTemplateList, +} from "./generated/"; export { ArgoWorkflows } from "./ArgoWorkflows"; @@ -8,18 +11,14 @@ export const argoWorkflowsApiRef = createApiRef({ id: "plugin.argoworkflows", }); export interface ArgoWorkflowsApi { - getWorkflowsFromK8s( - clusterName: string, - namespace: string | undefined, - labels: string | undefined - ): Promise; getWorkflows( clusterName: string | undefined, namespace: string | undefined, labels: string | undefined ): Promise; - getWorkflowsFromProxy( + getWorkflowTemplates( + clusterName: string | undefined, namespace: string, labels: string | undefined - ): Promise; + ): Promise; } diff --git a/plugins/argo-workflows/src/components/Overview/Overview.tsx b/plugins/argo-workflows/src/components/Overview/Overview.tsx index 927b0c1..b8209fb 100644 --- a/plugins/argo-workflows/src/components/Overview/Overview.tsx +++ b/plugins/argo-workflows/src/components/Overview/Overview.tsx @@ -12,6 +12,7 @@ import { Grid } from "@material-ui/core"; import { OverviewTable } from "../WorkflowOverview/WorkflowOverview"; import { useEntity } from "@backstage/plugin-catalog-react"; import { isArgoWorkflowsAvailable } from "../../plugin"; +import { WorkflowTemplateTable } from "../WorkflowTemplateOverview/WorkflowTemplateOverview"; export const ArgoWorkflowsOverviewPage = () => ( @@ -40,3 +41,15 @@ export const ArgoWorkflowsOverviewCard = () => { } return null; }; + +export const ArgoWorkflowsTemplatesOverviewCard = () => { + const { entity } = useEntity(); + if (isArgoWorkflowsAvailable(entity)) { + return ( + + + + ); + } + return null; +}; diff --git a/plugins/argo-workflows/src/components/Overview/index.ts b/plugins/argo-workflows/src/components/Overview/index.ts index 9d0bee4..e7f5a27 100644 --- a/plugins/argo-workflows/src/components/Overview/index.ts +++ b/plugins/argo-workflows/src/components/Overview/index.ts @@ -1,4 +1,5 @@ export { ArgoWorkflowsOverviewPage, ArgoWorkflowsOverviewCard, + ArgoWorkflowsTemplatesOverviewCard, } from "./Overview"; diff --git a/plugins/argo-workflows/src/components/WorkflowOverview/WorkflowOverview.test.tsx b/plugins/argo-workflows/src/components/WorkflowOverview/WorkflowOverview.test.tsx index 41fe9d9..06d459d 100644 --- a/plugins/argo-workflows/src/components/WorkflowOverview/WorkflowOverview.test.tsx +++ b/plugins/argo-workflows/src/components/WorkflowOverview/WorkflowOverview.test.tsx @@ -38,8 +38,7 @@ const mockKClient: jest.Mocked = { const noopFetchApi = new MockFetchApi({ baseImplementation: "none" }); const mockArgoWorkflows: jest.Mocked = { getWorkflows: jest.fn(), - getWorkflowsFromK8s: jest.fn(), - getWorkflowsFromProxy: jest.fn(), + getWorkflowTemplates: jest.fn(), }; const apis: [AnyApiRef, Partial][] = [ diff --git a/plugins/argo-workflows/src/components/WorkflowOverview/WorkflowOverview.tsx b/plugins/argo-workflows/src/components/WorkflowOverview/WorkflowOverview.tsx index d208cdd..4b78a84 100644 --- a/plugins/argo-workflows/src/components/WorkflowOverview/WorkflowOverview.tsx +++ b/plugins/argo-workflows/src/components/WorkflowOverview/WorkflowOverview.tsx @@ -6,14 +6,10 @@ import React from "react"; import Alert from "@material-ui/lab/Alert"; import { useEntity } from "@backstage/plugin-catalog-react"; import { IoArgoprojWorkflowV1alpha1WorkflowList } from "../../api/generated"; -import { - ARGO_WORKFLOWS_LABEL_SELECTOR_ANNOTATION, - CLUSTER_NAME_ANNOTATION, - K8S_LABEL_SELECTOR_ANNOTATION, - K8S_NAMESPACE_ANNOTATION, -} from "../../plugin"; +import { getAnnotationValues, trimBaseUrl } from "../utils"; type TableData = { + id: string; name: string; namespace: string; phase?: string; @@ -26,24 +22,11 @@ export const OverviewTable = () => { const { entity } = useEntity(); const apiClient = useApi(argoWorkflowsApiRef); const configApi = useApi(configApiRef); - let argoWorkflowsBaseUrl = configApi.getOptionalString( - "argoWorkflows.baseUrl" + const argoWorkflowsBaseUrl = trimBaseUrl( + configApi.getOptionalString("argoWorkflows.baseUrl") ); - if (argoWorkflowsBaseUrl && argoWorkflowsBaseUrl.endsWith("/")) { - argoWorkflowsBaseUrl = argoWorkflowsBaseUrl.substring( - 0, - argoWorkflowsBaseUrl.length - 1 - ); - } - const ln = entity.metadata.annotations?.[K8S_NAMESPACE_ANNOTATION]; - const ns = ln !== undefined ? ln : "default"; - const clusterName = entity.metadata.annotations?.[CLUSTER_NAME_ANNOTATION]; - const labelSelector = - entity.metadata?.annotations?.[ARGO_WORKFLOWS_LABEL_SELECTOR_ANNOTATION] !== - undefined - ? entity.metadata?.annotations?.[ARGO_WORKFLOWS_LABEL_SELECTOR_ANNOTATION] - : entity.metadata.annotations?.[K8S_LABEL_SELECTOR_ANNOTATION]; + const { ns, clusterName, labelSelector } = getAnnotationValues(entity); const columns: TableColumn[] = [ { @@ -99,11 +82,12 @@ export const OverviewTable = () => { if (loading) { return ; } else if (error) { - return {error.message}; + return {`${error}`}; } const data = value?.items?.map((val) => { return { + id: val.metadata.name, name: val.metadata.name, namespace: val.metadata.namespace, phase: val.status?.phase, @@ -117,6 +101,7 @@ export const OverviewTable = () => { return ( = { + getBaseUrl: jest.fn().mockImplementation((id) => { + return Promise.resolve(`https://backstage.io/${id}`); + }), +}; +const mockKClient: jest.Mocked = { + getObjectsByEntity: jest.fn(), + getClusters: jest.fn(), + getWorkloadsByEntity: jest.fn(), + getCustomObjectsByEntity: jest.fn(), + proxy: jest.fn(), +}; +const noopFetchApi = new MockFetchApi({ baseImplementation: "none" }); +const mockArgoWorkflows: jest.Mocked = { + getWorkflows: jest.fn(), + getWorkflowTemplates: jest.fn(), +}; + +const apis: [AnyApiRef, Partial][] = [ + [discoveryApiRef, mockDiscoveryApi], + [kubernetesApiRef, mockKClient], + [configApiRef, mockConfigApi], + [fetchApiRef, noopFetchApi], + [argoWorkflowsApiRef, mockArgoWorkflows], +]; + +const entity = { + metadata: { + namespace: "default", + annotations: { + "backstage.io/kubernetes-label-selector": "my=env", + "backstage.io/kubernetes-namespace": "default", + }, + name: "my-entity", + }, + apiVersion: "backstage.io/v1alpha1", + kind: "Component", +}; + +afterEach(() => { + jest.clearAllMocks(); +}); + +describe("WorkflowTemplateTable", () => { + it("should display workflows without link", async () => { + jest + .spyOn(mockArgoWorkflows, "getWorkflowTemplates") + .mockImplementation( + ( + _n, + _ns, + _l + ): Promise => { + return Promise.resolve( + simple as unknown as IoArgoprojWorkflowV1alpha1WorkflowTemplateList + ); + } + ); + jest.spyOn(mockConfigApi, "getOptionalString").mockImplementation((_) => { + return undefined; + }); + const r = await renderInTestApp( + + + + + + ); + const c = r.getByText(simple.items[0].metadata.name); + expect(c).not.toHaveAttribute("href"); + }); + + it("should display workflows wth link", async () => { + const spyWorkflows = jest + .spyOn(mockArgoWorkflows, "getWorkflowTemplates") + .mockImplementation( + ( + _n, + _ns, + _l + ): Promise => { + return Promise.resolve( + simple as unknown as IoArgoprojWorkflowV1alpha1WorkflowTemplateList + ); + } + ); + const spyConfigApi = jest + .spyOn(mockConfigApi, "getOptionalString") + .mockImplementation((_n) => { + return `https://backstage.io/`; + }); + const r = await renderInTestApp( + + + + + + ); + expect(spyWorkflows).toHaveBeenCalledWith(undefined, "default", "my=env"); + expect(spyConfigApi).toHaveBeenCalledWith("argoWorkflows.baseUrl"); + const c = r.getByText(simple.items[0].metadata.name); + expect(c).toHaveAttribute( + "href", + `${BASE_URL}/workflow-templates/default/${simple.items[0].metadata.name}` + ); + }); +}); diff --git a/plugins/argo-workflows/src/components/WorkflowTemplateOverview/WorkflowTemplateOverview.tsx b/plugins/argo-workflows/src/components/WorkflowTemplateOverview/WorkflowTemplateOverview.tsx new file mode 100644 index 0000000..b2bd088 --- /dev/null +++ b/plugins/argo-workflows/src/components/WorkflowTemplateOverview/WorkflowTemplateOverview.tsx @@ -0,0 +1,82 @@ +import { useEntity } from "@backstage/plugin-catalog-react"; +import { configApiRef, useApi } from "@backstage/core-plugin-api"; +import { argoWorkflowsApiRef } from "../../api"; +import { getAnnotationValues, trimBaseUrl } from "../utils"; +import { Link, Progress, Table, TableColumn } from "@backstage/core-components"; +import React from "react"; +import useAsync from "react-use/lib/useAsync"; +import Alert from "@material-ui/lab/Alert"; + +type TableData = { + id: string; + name: string; + namespace: string; + entrypoint: string | undefined; +}; + +export const WorkflowTemplateTable = () => { + const { entity } = useEntity(); + const apiClient = useApi(argoWorkflowsApiRef); + const configApi = useApi(configApiRef); + const argoWorkflowsBaseUrl = trimBaseUrl( + configApi.getOptionalString("argoWorkflows.baseUrl") + ); + const { ns, clusterName, labelSelector } = getAnnotationValues(entity); + + const columns: TableColumn[] = [ + { + title: "Name", + field: "name", + render: (data: any | TableData, _): any => { + if (data && argoWorkflowsBaseUrl) { + return ( + + {data.name} + + ); + } + return data.name; + }, + defaultSort: "desc", + }, + { title: "namespace", field: "namespace", type: "string" }, + { title: "entrypoint", field: "entrypoint", type: "string" }, + ]; + + const { value, loading, error } = useAsync(async () => { + return await apiClient.getWorkflowTemplates(clusterName, ns, labelSelector); + }); + + if (loading) { + return ; + } else if (error) { + return {`${error}`}; + } + const data = value?.items?.map((val) => { + return { + id: val.metadata.name, + name: val.metadata.name, + namespace: val.metadata.namespace, + entrypoint: val.spec.entrypoint, + } as TableData; + }); + if (data && data.length > 0) { + return ( +
+ ); + } + return ( + "No workflows found with provided labels" + ); +}; diff --git a/plugins/argo-workflows/src/components/utils.ts b/plugins/argo-workflows/src/components/utils.ts new file mode 100644 index 0000000..6b633b5 --- /dev/null +++ b/plugins/argo-workflows/src/components/utils.ts @@ -0,0 +1,38 @@ +import { Entity } from "@backstage/catalog-model"; +import { + ARGO_WORKFLOWS_LABEL_SELECTOR_ANNOTATION, + CLUSTER_NAME_ANNOTATION, + K8S_LABEL_SELECTOR_ANNOTATION, + K8S_NAMESPACE_ANNOTATION, +} from "../plugin"; + +export function trimBaseUrl(argoWorkflowsBaseUrl: string | undefined) { + if (argoWorkflowsBaseUrl && argoWorkflowsBaseUrl.endsWith("/")) { + return argoWorkflowsBaseUrl.substring(0, argoWorkflowsBaseUrl.length - 1); + } + return argoWorkflowsBaseUrl; +} + +export type getAnnotationValuesOutput = { + ns: string; + clusterName?: string; + labelSelector?: string; +}; + +export function getAnnotationValues(entity: Entity): getAnnotationValuesOutput { + const ns = + entity.metadata.annotations?.[K8S_NAMESPACE_ANNOTATION] !== undefined + ? entity.metadata.annotations?.[K8S_NAMESPACE_ANNOTATION] + : "default"; + const clusterName = entity.metadata.annotations?.[CLUSTER_NAME_ANNOTATION]; + const labelSelector = + entity.metadata?.annotations?.[ARGO_WORKFLOWS_LABEL_SELECTOR_ANNOTATION] !== + undefined + ? entity.metadata?.annotations?.[ARGO_WORKFLOWS_LABEL_SELECTOR_ANNOTATION] + : entity.metadata.annotations?.[K8S_LABEL_SELECTOR_ANNOTATION]; + return { + ns: ns, + clusterName: clusterName, + labelSelector: labelSelector, + }; +} diff --git a/plugins/argo-workflows/src/index.ts b/plugins/argo-workflows/src/index.ts index 1edc6d4..d9c32f1 100644 --- a/plugins/argo-workflows/src/index.ts +++ b/plugins/argo-workflows/src/index.ts @@ -2,5 +2,6 @@ export { argoWorkflowsPlugin, ArgoWorkflowsPage, EntityArgoWorkflowsOverviewCard, + EntityArgoWorkflowsTemplateOverviewCard, isArgoWorkflowsAvailable, } from "./plugin"; diff --git a/plugins/argo-workflows/src/plugin.ts b/plugins/argo-workflows/src/plugin.ts index 9ffba1f..e17e795 100644 --- a/plugins/argo-workflows/src/plugin.ts +++ b/plugins/argo-workflows/src/plugin.ts @@ -54,6 +54,18 @@ export const EntityArgoWorkflowsOverviewCard = argoWorkflowsPlugin.provide( }) ); +export const EntityArgoWorkflowsTemplateOverviewCard = + argoWorkflowsPlugin.provide( + createRoutableExtension({ + name: "ArgoWorkflowsTemplatesOverviewCard", + component: () => + import("./components/Overview").then( + (m) => m.ArgoWorkflowsTemplatesOverviewCard + ), + mountPoint: rootRouteRef, + }) + ); + export const isArgoWorkflowsAvailable = (entity: Entity) => Boolean( entity?.metadata.annotations?.[ARGO_WORKFLOWS_LABEL_SELECTOR_ANNOTATION] diff --git a/plugins/argo-workflows/src/test-data/testResponseWorkflowTemplates.ts b/plugins/argo-workflows/src/test-data/testResponseWorkflowTemplates.ts new file mode 100644 index 0000000..1871105 --- /dev/null +++ b/plugins/argo-workflows/src/test-data/testResponseWorkflowTemplates.ts @@ -0,0 +1,92 @@ +export const simple = { + apiVersion: "v1", + items: [ + { + apiVersion: "argoproj.io/v1alpha1", + kind: "WorkflowTemplate", + metadata: { + annotations: { + "kubectl.kubernetes.io/last-applied-configuration": + '{"apiVersion":"argoproj.io/v1alpha1","kind":"WorkflowTemplate","metadata":{"annotations":{},"name":"workflow-template-whalesay-template","namespace":"default"},"spec":{"entrypoint":"whalesay-template","templates":[{"inputs":{"parameters":[{"name":"message"}]},"name":"whalesay-template","steps":[[{"arguments":{"parameters":[{"name":"message","value":"{{inputs.parameters.message}}"}]},"name":"whalesay3","template":"whalesay-template-3"},{"name":"sleep","template":"sleep"}]]},{"container":{"args":["600"],"command":["sleep"],"image":"docker/whalesay"},"name":"sleep"},{"container":{"args":["{{inputs.parameters.message}}"],"command":["cowsay"],"image":"docker/whalesay"},"inputs":{"parameters":[{"name":"message"}]},"name":"whalesay-template-3"}],"ttlStrategy":{"secondsAfterCompletion":28800},"workflowMetadata":{"labels":{"env":"dev","my":"label"}}}}\n', + }, + creationTimestamp: "2023-06-26T22:20:33Z", + generation: 1, + name: "workflow-template-whalesay-template", + namespace: "default", + resourceVersion: "76502835", + uid: "c2ed5f0a-9093-4cb2-bac5-c9918bb3f8d7", + }, + spec: { + entrypoint: "whalesay-template", + templates: [ + { + inputs: { + parameters: [ + { + name: "message", + }, + ], + }, + name: "whalesay-template", + steps: [ + [ + { + arguments: { + parameters: [ + { + name: "message", + value: "{{inputs.parameters.message}}", + }, + ], + }, + name: "whalesay3", + template: "whalesay-template-3", + }, + { + name: "sleep", + template: "sleep", + }, + ], + ], + }, + { + container: { + args: ["600"], + command: ["sleep"], + image: "docker/whalesay", + }, + name: "sleep", + }, + { + container: { + args: ["{{inputs.parameters.message}}"], + command: ["cowsay"], + image: "docker/whalesay", + }, + inputs: { + parameters: [ + { + name: "message", + }, + ], + }, + name: "whalesay-template-3", + }, + ], + ttlStrategy: { + secondsAfterCompletion: 28800, + }, + workflowMetadata: { + labels: { + env: "dev", + my: "label", + }, + }, + }, + }, + ], + kind: "List", + metadata: { + resourceVersion: "", + }, +};