diff --git a/src/__tests__/PageInjections.test.tsx b/src/__tests__/PageInjections.test.tsx new file mode 100644 index 0000000..c28136d --- /dev/null +++ b/src/__tests__/PageInjections.test.tsx @@ -0,0 +1,142 @@ +import { describe, expect, it } from "vitest"; +import type { ArgoCDApplication } from "../api/argocd"; + +// --- Matching helpers (copied for unit testing) --- + +function appsForNamespace( + apps: ArgoCDApplication[], + namespace: string +): ArgoCDApplication[] { + return apps.filter((app) => app.spec?.destination?.namespace === namespace); +} + +function appsForDeployment( + apps: ArgoCDApplication[], + deploymentName: string +): ArgoCDApplication[] { + return apps.filter((app) => + (app.status?.resources ?? []).some( + (res) => res.kind === "Deployment" && res.name === deploymentName + ) + ); +} + +// --- Fixture factory --- + +function makeApp( + overrides: Partial = {} +): ArgoCDApplication { + return { + metadata: { name: "test-app", namespace: "argocd" }, + spec: { project: "default" }, + status: {}, + ...overrides, + } as ArgoCDApplication; +} + +// --- appsForNamespace tests --- + +describe("appsForNamespace", () => { + it("returns apps whose destination.namespace matches", () => { + const apps = [ + makeApp({ + metadata: { name: "app-a", namespace: "argocd" }, + spec: { project: "default", destination: { namespace: "web" } }, + }), + makeApp({ + metadata: { name: "app-b", namespace: "argocd" }, + spec: { project: "default", destination: { namespace: "data" } }, + }), + ]; + expect(appsForNamespace(apps, "web").map((a) => a.metadata.name)).toEqual([ + "app-a", + ]); + }); + + it("returns empty array when no match", () => { + const apps = [ + makeApp({ + metadata: { name: "app-a", namespace: "argocd" }, + spec: { project: "default", destination: { namespace: "web" } }, + }), + ]; + expect(appsForNamespace(apps, "data")).toEqual([]); + }); + + it("returns empty array for empty app list", () => { + expect(appsForNamespace([], "web")).toEqual([]); + }); + + it("returns empty array when destination is undefined", () => { + const apps = [ + makeApp({ + metadata: { name: "app-a", namespace: "argocd" }, + spec: { project: "default" }, + }), + ]; + expect(appsForNamespace(apps, "web")).toEqual([]); + }); +}); + +// --- appsForDeployment tests --- + +describe("appsForDeployment", () => { + it("returns apps that manage the deployment via status.resources", () => { + const apps = [ + makeApp({ + metadata: { name: "app-a", namespace: "argocd" }, + status: { + resources: [{ kind: "Deployment", name: "nginx", namespace: "web" }], + }, + }), + makeApp({ + metadata: { name: "app-b", namespace: "argocd" }, + status: { + resources: [{ kind: "Service", name: "nginx", namespace: "web" }], + }, + }), + ]; + expect( + appsForDeployment(apps, "nginx").map((a) => a.metadata.name) + ).toEqual(["app-a"]); + }); + + it("returns empty array when no deployment resource matches", () => { + const apps = [ + makeApp({ + metadata: { name: "app-a", namespace: "argocd" }, + status: { + resources: [{ kind: "Service", name: "nginx", namespace: "web" }], + }, + }), + ]; + expect(appsForDeployment(apps, "nginx")).toEqual([]); + }); + + it("returns empty array for empty app list", () => { + expect(appsForDeployment([], "nginx")).toEqual([]); + }); + + it("returns empty array when resources is undefined", () => { + const apps = [ + makeApp({ metadata: { name: "app-a", namespace: "argocd" }, status: {} }), + ]; + expect(appsForDeployment(apps, "nginx")).toEqual([]); + }); + + it("returns multiple apps that manage the same deployment", () => { + const apps = [ + makeApp({ + metadata: { name: "app-a", namespace: "argocd" }, + status: { resources: [{ kind: "Deployment", name: "nginx" }] }, + }), + makeApp({ + metadata: { name: "app-b", namespace: "argocd" }, + status: { resources: [{ kind: "Deployment", name: "nginx" }] }, + }), + ]; + expect( + appsForDeployment(apps, "nginx").map((a) => a.metadata.name) + ).toEqual(["app-a", "app-b"]); + }); +}); diff --git a/src/components/DeploymentArgoBadge.tsx b/src/components/DeploymentArgoBadge.tsx new file mode 100644 index 0000000..878d6b7 --- /dev/null +++ b/src/components/DeploymentArgoBadge.tsx @@ -0,0 +1,101 @@ +import { ApiProxy } from "@kinvolk/headlamp-plugin/lib"; +import { + Link, + StatusLabel, +} from "@kinvolk/headlamp-plugin/lib/CommonComponents"; +import React, { useEffect, useState } from "react"; +import { ArgoCDApplication, ArgoCDApplicationsList } from "../api/argocd"; +import { syncStatusToColor } from "./ApplicationsList"; + +// --- API --- + +const ARGOCD_API_PATH = + "/api/v1/namespaces/argocd/services/argocd-server/proxy/api/v1/applications"; + +async function fetchApplications(): Promise { + const response = (await ApiProxy.request( + ARGOCD_API_PATH + )) as ArgoCDApplicationsList; + return response; +} + +// --- Matching helper --- + +/** + * Returns ArgoCD applications that manage the given Deployment by matching + * kind=Deployment and name in Application.status.resources[]. + */ +export function appsForDeployment( + apps: ArgoCDApplication[], + deploymentName: string +): ArgoCDApplication[] { + return apps.filter((app) => + (app.status?.resources ?? []).some( + (res) => res.kind === "Deployment" && res.name === deploymentName + ) + ); +} + +// --- Component --- + +interface DeploymentArgoBadgeProps { + deploymentName: string; +} + +export default function DeploymentArgoBadge({ + deploymentName, +}: DeploymentArgoBadgeProps) { + const [apps, setApps] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + let cancelled = false; + fetchApplications() + .then((data) => { + if (cancelled) return; + const matched = appsForDeployment(data.items ?? [], deploymentName); + setApps(matched); + setLoading(false); + }) + .catch((err: unknown) => { + if (cancelled) return; + setError(err instanceof Error ? err.message : String(err)); + setLoading(false); + }); + return () => { + cancelled = true; + }; + }, [deploymentName]); + + if (loading || error || !apps || apps.length === 0) { + return null; // Show nothing when no matching application + } + + const app = apps[0]; // Show first matching app + const lastSynced = app.status?.history?.length + ? app.status.history[app.status.history.length - 1]?.dexKey + : null; + const lastSyncedStr = lastSynced + ? new Date(lastSynced).toLocaleString() + : "—"; + + return ( + +   + + ArgoCD: {app.metadata.name} + +   + + {app.status?.sync?.status ?? "Unknown"} + +   + + Last sync: {lastSyncedStr} + + + ); +} diff --git a/src/components/NamespaceArgoSection.tsx b/src/components/NamespaceArgoSection.tsx new file mode 100644 index 0000000..8679729 --- /dev/null +++ b/src/components/NamespaceArgoSection.tsx @@ -0,0 +1,120 @@ +import { ApiProxy } from "@kinvolk/headlamp-plugin/lib"; +import { + Link, + SectionBox, + StatusLabel, +} from "@kinvolk/headlamp-plugin/lib/CommonComponents"; +import React, { useEffect, useState } from "react"; +import { ArgoCDApplication, ArgoCDApplicationsList } from "../api/argocd"; +import { + healthStatusToColor, + healthStatusToLabel, + syncStatusToColor, +} from "./ApplicationsList"; + +// --- API --- + +const ARGOCD_API_PATH = + "/api/v1/namespaces/argocd/services/argocd-server/proxy/api/v1/applications"; + +async function fetchApplications(): Promise { + const response = (await ApiProxy.request( + ARGOCD_API_PATH + )) as ArgoCDApplicationsList; + return response; +} + +// --- Matching helper --- + +/** + * Returns ArgoCD applications whose spec.destination.namespace matches + * the given namespace name. + */ +export function appsForNamespace( + apps: ArgoCDApplication[], + namespace: string +): ArgoCDApplication[] { + return apps.filter((app) => app.spec?.destination?.namespace === namespace); +} + +// --- Component --- + +interface NamespaceArgoSectionProps { + namespaceName: string; +} + +export default function NamespaceArgoSection({ + namespaceName, +}: NamespaceArgoSectionProps) { + const [apps, setApps] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + let cancelled = false; + fetchApplications() + .then((data) => { + if (cancelled) return; + const matched = appsForNamespace(data.items ?? [], namespaceName); + setApps(matched); + setLoading(false); + }) + .catch((err: unknown) => { + if (cancelled) return; + setError(err instanceof Error ? err.message : String(err)); + setLoading(false); + }); + return () => { + cancelled = true; + }; + }, [namespaceName]); + + if (loading) { + return ( + + Loading... + + ); + } + + if (error || !apps) { + return ( + + ArgoCD unreachable + + ); + } + + if (apps.length === 0) { + return null; // Show nothing when no matching application + } + + return ( + + {apps.length} application(s) +
    + {apps.map((app) => ( +
  • + + {app.metadata.name} + +   + + {healthStatusToLabel(app.status?.health?.status ?? "Unknown")} + +   + + {app.status?.sync?.status ?? "Unknown"} + +
  • + ))} +
+
+ ); +} diff --git a/src/components/PageInjections.tsx b/src/components/PageInjections.tsx new file mode 100644 index 0000000..bbd8bdb --- /dev/null +++ b/src/components/PageInjections.tsx @@ -0,0 +1,207 @@ +/** + * Page injection registrations for ArgoCD plugin. + * Registers detail view sections on Namespace and Deployment pages. + */ +import { ApiProxy } from "@kinvolk/headlamp-plugin/lib"; +import { KubeObject } from "@kinvolk/headlamp-plugin/lib/lib/k8s/KubeObject"; +import { registerDetailsViewSection } from "@kinvolk/headlamp-plugin/lib"; +import { + SectionBox, + StatusLabel, +} from "@kinvolk/headlamp-plugin/lib/CommonComponents"; +import { Link } from "react-router-dom"; +import React, { useEffect, useState } from "react"; +import { ArgoCDApplication, ArgoCDApplicationsList } from "../api/argocd"; +import { + healthStatusToColor, + healthStatusToLabel, + syncStatusToColor, +} from "./ApplicationsList"; + +// --- API --- + +const ARGOCD_API_PATH = + "/api/v1/namespaces/argocd/services/argocd-server/proxy/api/v1/applications"; + +async function fetchApplications(): Promise { + const response = (await ApiProxy.request( + ARGOCD_API_PATH + )) as ArgoCDApplicationsList; + return response; +} + +// --- Namespace section --- + +function NamespaceArgoSection({ resource }: { resource: KubeObject }) { + const namespaceName = resource.metadata.name; + const [apps, setApps] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + let cancelled = false; + fetchApplications() + .then((data) => { + if (cancelled) return; + const matched = (data.items ?? []).filter( + (app) => app.spec?.destination?.namespace === namespaceName + ); + setApps(matched); + setLoading(false); + }) + .catch((err: unknown) => { + if (cancelled) return; + setError(err instanceof Error ? err.message : String(err)); + setLoading(false); + }); + return () => { + cancelled = true; + }; + }, [namespaceName]); + + if (loading) { + return ( + + Loading... + + ); + } + + if (error || !apps) { + return ( + + ArgoCD unreachable + + ); + } + + if (apps.length === 0) { + return null; + } + + return ( + + {apps.length} application(s) +
    + {apps.map((app) => ( +
  • + + {app.metadata.name} + +   + + {healthStatusToLabel( + (app.status?.health?.status as + | "Healthy" + | "Degraded" + | "Progressing" + | "Missing" + | "Unknown") ?? "Unknown" + )} + +   + + {app.status?.sync?.status ?? "Unknown"} + +
  • + ))} +
+
+ ); +} + +// --- Deployment badge --- + +function DeploymentArgoBadge({ resource }: { resource: KubeObject }) { + const deploymentName = resource.metadata.name; + const [apps, setApps] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + let cancelled = false; + fetchApplications() + .then((data) => { + if (cancelled) return; + const matched = (data.items ?? []).filter((app) => + (app.status?.resources ?? []).some( + (res) => res.kind === "Deployment" && res.name === deploymentName + ) + ); + setApps(matched); + setLoading(false); + }) + .catch((err: unknown) => { + if (cancelled) return; + setError(err instanceof Error ? err.message : String(err)); + setLoading(false); + }); + return () => { + cancelled = true; + }; + }, [deploymentName]); + + if (loading || error || !apps || apps.length === 0) { + return null; + } + + const app = apps[0]; + const lastSynced = app.status?.history?.length + ? app.status.history[app.status.history.length - 1]?.dexKey + : null; + const lastSyncedStr = lastSynced + ? new Date(lastSynced).toLocaleString() + : "—"; + + return ( + +   + + ArgoCD: {app.metadata.name} + +   + + {app.status?.sync?.status ?? "Unknown"} + +   + + Last sync: {lastSyncedStr} + + + ); +} + +// --- Registration --- + +registerDetailsViewSection(({ resource }: { resource: KubeObject }) => { + if (resource.kind === "Namespace") { + return ; + } + + if (resource.kind === "Deployment") { + return ; + } + + return null; +}); diff --git a/src/index.tsx b/src/index.tsx index 4526b1b..fdaf211 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -9,6 +9,7 @@ import { import React from "react"; import ApplicationDetail from "./components/ApplicationDetail"; import ApplicationsList from "./components/ApplicationsList"; +import "./components/PageInjections"; // side-effect: registers detail view sections // --- Error boundary for plugin components ---