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: "",
+ },
+};