From 04f149cdaa568dd2d26f52e48e5df14db3e4bcdb Mon Sep 17 00:00:00 2001 From: Test User Date: Tue, 21 Apr 2026 20:39:25 +0000 Subject: [PATCH] feat(ApplicationsList): implement ArgoCD Applications List view Implement the Applications List view for headlamp-argocd-plugin (PRI-189). - Add src/components/ApplicationsList.tsx with table of all ArgoCD Applications showing: app name, namespace, project, health status, sync status, target revision, and last synced time - Health/sync status badges using ArgoCD color conventions - Filter controls: health dropdown, sync dropdown, project dropdown - Friendly "ArgoCD not detected" error state when ArgoCD is unreachable - Add src/api/argocd.ts with ArgoCD API types (Application, ApplicationsList) - Add unit tests in src/__tests__/ApplicationsList.test.tsx: - Pure function tests for healthStatusToColor and syncStatusToColor - Filter logic unit tests - Component smoke tests (loading, error, data, empty states) - Replace stub view in src/index.tsx with ApplicationsList component Co-Authored-By: Paperclip --- src/__tests__/ApplicationsList.test.tsx | 363 ++++++++++++++++++++++++ src/api/argocd.ts | 100 +++++++ src/components/ApplicationsList.tsx | 290 +++++++++++++++++++ src/index.test.tsx | 2 +- src/index.tsx | 15 +- 5 files changed, 756 insertions(+), 14 deletions(-) create mode 100644 src/__tests__/ApplicationsList.test.tsx create mode 100644 src/api/argocd.ts create mode 100644 src/components/ApplicationsList.tsx diff --git a/src/__tests__/ApplicationsList.test.tsx b/src/__tests__/ApplicationsList.test.tsx new file mode 100644 index 0000000..8246e69 --- /dev/null +++ b/src/__tests__/ApplicationsList.test.tsx @@ -0,0 +1,363 @@ +import { ApiProxy } from "@kinvolk/headlamp-plugin/lib"; +import { render, screen } from "@testing-library/react"; +import React from "react"; +import { MemoryRouter } from "react-router-dom"; +import { describe, expect, it, vi } from "vitest"; +import { + ApplicationRow, + healthStatusToColor, + healthStatusToLabel, + syncStatusToColor, +} from "../components/ApplicationsList"; +import ApplicationsList from "../components/ApplicationsList"; + +// --- Pure function unit tests --- + +describe("healthStatusToColor", () => { + it("maps Healthy to success", () => { + expect(healthStatusToColor("Healthy")).toBe("success"); + }); + + it("maps Degraded to error", () => { + expect(healthStatusToColor("Degraded")).toBe("error"); + }); + + it("maps Progressing to warning", () => { + expect(healthStatusToColor("Progressing")).toBe("warning"); + }); + + it("maps Missing to default", () => { + expect(healthStatusToColor("Missing")).toBe("default"); + }); + + it("maps Unknown to default", () => { + expect(healthStatusToColor("Unknown")).toBe("default"); + }); +}); + +describe("syncStatusToColor", () => { + it("maps Synced to success", () => { + expect(syncStatusToColor("Synced")).toBe("success"); + }); + + it("maps OutOfSync to warning", () => { + expect(syncStatusToColor("OutOfSync")).toBe("warning"); + }); + + it("maps Unknown to default", () => { + expect(syncStatusToColor("Unknown")).toBe("default"); + }); +}); + +describe("healthStatusToLabel", () => { + it("returns the status string as-is", () => { + expect(healthStatusToLabel("Healthy")).toBe("Healthy"); + expect(healthStatusToLabel("Degraded")).toBe("Degraded"); + expect(healthStatusToLabel("Progressing")).toBe("Progressing"); + expect(healthStatusToLabel("Missing")).toBe("Missing"); + expect(healthStatusToLabel("Unknown")).toBe("Unknown"); + }); +}); + +// --- Filter logic unit tests --- + +const makeApp = (overrides: Partial = {}): ApplicationRow => ({ + name: "test-app", + namespace: "argocd", + project: "default", + healthStatus: "Healthy", + syncStatus: "Synced", + targetRevision: "HEAD", + lastSynced: "2024-01-01T00:00:00Z", + ...overrides, +}); + +const ALL_HEALTH = "All" as const; +const ALL_SYNC = "All" as const; + +function applyFilters( + apps: ApplicationRow[], + healthFilter: ApplicationRow["healthStatus"] | "All", + syncFilter: ApplicationRow["syncStatus"] | "All", + projectFilter: string +): ApplicationRow[] { + return apps.filter((app) => { + if (healthFilter !== ALL_HEALTH && app.healthStatus !== healthFilter) + return false; + if (syncFilter !== ALL_SYNC && app.syncStatus !== syncFilter) return false; + if (projectFilter !== "All" && app.project !== projectFilter) return false; + return true; + }); +} + +describe("ApplicationsList filter logic", () => { + const apps = [ + makeApp({ + name: "app-1", + healthStatus: "Healthy", + syncStatus: "Synced", + project: "proj-a", + }), + makeApp({ + name: "app-2", + healthStatus: "Healthy", + syncStatus: "OutOfSync", + project: "proj-a", + }), + makeApp({ + name: "app-3", + healthStatus: "Degraded", + syncStatus: "OutOfSync", + project: "proj-b", + }), + makeApp({ + name: "app-4", + healthStatus: "Progressing", + syncStatus: "Synced", + project: "proj-b", + }), + makeApp({ + name: "app-5", + healthStatus: "Unknown", + syncStatus: "Unknown", + project: "proj-c", + }), + ]; + + it("returns all apps when all filters are All", () => { + const result = applyFilters(apps, ALL_HEALTH, ALL_SYNC, "All"); + expect(result).toHaveLength(5); + }); + + it("filters by health status", () => { + const result = applyFilters(apps, "Healthy", ALL_SYNC, "All"); + expect(result).toHaveLength(2); + expect(result.map((a) => a.name)).toEqual(["app-1", "app-2"]); + }); + + it("filters by sync status", () => { + const result = applyFilters(apps, ALL_HEALTH, "OutOfSync", "All"); + expect(result).toHaveLength(2); + expect(result.map((a) => a.name)).toEqual(["app-2", "app-3"]); + }); + + it("filters by project", () => { + const result = applyFilters(apps, ALL_HEALTH, ALL_SYNC, "proj-a"); + expect(result).toHaveLength(2); + expect(result.map((a) => a.name)).toEqual(["app-1", "app-2"]); + }); + + it("combines multiple filters", () => { + const result = applyFilters(apps, "Healthy", "OutOfSync", "All"); + expect(result).toHaveLength(1); + expect(result[0].name).toBe("app-2"); + }); + + it("returns empty array when no apps match", () => { + const result = applyFilters(apps, "Degraded", "Synced", "All"); + expect(result).toHaveLength(0); + }); +}); + +// --- Component smoke test --- + +// Mock Headlamp lib +vi.mock("@kinvolk/headlamp-plugin/lib", () => ({ + ApiProxy: { request: vi.fn() }, +})); + +// Mock MUI +vi.mock("@mui/material/FormControl", () => ({ + default: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), +})); + +vi.mock("@mui/material/InputLabel", () => ({ + default: ({ children, id }: { children: React.ReactNode; id?: string }) => ( + + ), +})); + +vi.mock("@mui/material/Select", () => ({ + default: ({ + children, + value, + onChange, + label, + }: { + children: React.ReactNode; + value: string; + onChange: (e: { target: { value: string } }) => void; + label?: string; + }) => ( + + ), +})); + +vi.mock("@mui/material/MenuItem", () => ({ + default: ({ + children, + value, + }: { + children: React.ReactNode; + value: string; + }) => ( + + ), +})); + +vi.mock("@mui/material/Box", () => ({ + default: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), +})); + +// Mock CommonComponents +vi.mock("@kinvolk/headlamp-plugin/lib/CommonComponents", () => ({ + SectionBox: ({ children }: { children?: React.ReactNode }) => ( +
{children}
+ ), + SectionHeader: ({ title }: { title: string }) => ( +
{title}
+ ), + StatusLabel: ({ + status, + children, + }: { + status: string; + children?: React.ReactNode; + }) => ( + + {children} + + ), + SimpleTable: ({ + columns, + data, + emptyMessage, + }: { + columns: Array<{ + label: string; + getter: (row: unknown) => React.ReactNode; + }>; + data: unknown[]; + emptyMessage?: string; + }) => + data.length === 0 ? ( +
{emptyMessage}
+ ) : ( + + + + {columns.map((col) => ( + + ))} + + + + {data.map((row, i) => ( + + {columns.map((col) => ( + + ))} + + ))} + +
{col.label}
{col.getter(row)}
+ ), +})); + +function renderWithRouter(ui: React.ReactElement) { + return render({ui}); +} + +describe("ApplicationsList component", () => { + it("renders loading state initially", async () => { + vi.mocked(ApiProxy.request).mockImplementation( + () => new Promise(() => {}) // never resolves — keeps loading + ); + + renderWithRouter(); + expect(screen.getByTestId("applications-loading")).toHaveTextContent( + "Loading ArgoCD applications" + ); + }); + + it("renders error state when API fails", async () => { + vi.mocked(ApiProxy.request).mockRejectedValue( + new Error("connection refused") + ); + + renderWithRouter(); + + await vi.waitFor(() => { + expect(screen.getByTestId("applications-error")).toBeInTheDocument(); + }); + + expect(screen.getByText(/connection refused/)).toBeInTheDocument(); + }); + + it("renders table with applications when API succeeds", async () => { + const mockResponse = { + items: [ + { + metadata: { name: "app-1", namespace: "argocd" }, + spec: { project: "default", targetRevision: "v1.0.0" }, + status: { + health: { status: "Healthy" }, + sync: { status: "Synced" }, + history: [ + { dexKey: "2024-06-01T10:00:00Z", id: 0, revision: "v1.0.0" }, + ], + }, + }, + { + metadata: { name: "app-2", namespace: "argocd" }, + spec: { project: "default", targetRevision: "HEAD" }, + status: { + health: { status: "Degraded" }, + sync: { status: "OutOfSync" }, + history: [], + }, + }, + ], + }; + + vi.mocked(ApiProxy.request).mockResolvedValue(mockResponse); + + renderWithRouter(); + + await vi.waitFor(() => { + expect(screen.getByTestId("simple-table")).toBeInTheDocument(); + }); + + const rows = screen.getAllByRole("row"); + expect(rows.length).toBe(3); // 1 header + 2 data rows + }); + + it("renders empty message when no applications", async () => { + vi.mocked(ApiProxy.request).mockResolvedValue({ items: [] }); + + renderWithRouter(); + + await vi.waitFor(() => { + expect(screen.getByTestId("simple-table-empty")).toBeInTheDocument(); + }); + + expect( + screen.getByText("No ArgoCD applications found.") + ).toBeInTheDocument(); + }); +}); diff --git a/src/api/argocd.ts b/src/api/argocd.ts new file mode 100644 index 0000000..7f59ba3 --- /dev/null +++ b/src/api/argocd.ts @@ -0,0 +1,100 @@ +// --- ArgoCD API types --- + +/** + * Health status values returned by ArgoCD Application status. + */ +export type ArgoCDHealthStatus = + | "Healthy" + | "Degraded" + | "Progressing" + | "Missing" + | "Unknown"; + +/** + * Sync status values returned by ArgoCD Application status. + */ +export type ArgoCDSyncStatus = "Synced" | "OutOfSync" | "Unknown"; + +/** + * An ArgoCD Application resource. + * Matches the ArgoCD server API /api/v1/applications response shape. + */ +export interface ArgoCDApplication { + metadata: { + name: string; + namespace: string; + uid?: string; + labels?: Record; + annotations?: Record; + creationTimestamp?: string; + }; + spec: { + project: string; + sourceRepoURL?: string; + targetRevision?: string; + path?: string; + destination?: { + server?: string; + namespace?: string; + name?: string; + }; + sources?: Array<{ + repoURL?: string; + targetRevision?: string; + path?: string; + }>; + }; + status: { + health?: { + status: ArgoCDHealthStatus; + message?: string; + }; + sync?: { + status: ArgoCDSyncStatus; + comparedTo?: { + destination?: { + server?: string; + namespace?: string; + }; + source?: { + repoURL?: string; + path?: string; + targetRevision?: string; + }; + }; + }; + history?: Array<{ + dexKey: string; // ISO 8601 timestamp + id: number; + revision: string; + deployStartedAt?: string; + triggeredBy?: string; + }>; + resources?: Array<{ + kind: string; + namespace?: string; + name: string; + group?: string; + status?: string; + health?: { + status: ArgoCDHealthStatus; + }; + }>; + sourceType?: string; + summary?: { + externalURLs?: string[]; + images?: string[]; + }; + }; +} + +/** + * Response envelope for the ArgoCD Applications list API. + */ +export interface ArgoCDApplicationsList { + items: ArgoCDApplication[]; + metadata?: { + resourceVersion?: string; + remainingItemCount?: number; + }; +} diff --git a/src/components/ApplicationsList.tsx b/src/components/ApplicationsList.tsx new file mode 100644 index 0000000..9f76c17 --- /dev/null +++ b/src/components/ApplicationsList.tsx @@ -0,0 +1,290 @@ +import { ApiProxy } from "@kinvolk/headlamp-plugin/lib"; +import { + SectionBox, + SectionHeader, + SimpleTable, + StatusLabel, +} from "@kinvolk/headlamp-plugin/lib/CommonComponents"; +import Box from "@mui/material/Box"; +import FormControl from "@mui/material/FormControl"; +import InputLabel from "@mui/material/InputLabel"; +import MenuItem from "@mui/material/MenuItem"; +import Select from "@mui/material/Select"; +import React, { useEffect, useMemo, useState } from "react"; +import { useLocation } from "react-router-dom"; +import { ArgoCDApplication, ArgoCDApplicationsList } from "../api/argocd"; + +// --- Types --- + +export type HealthStatus = + | "Healthy" + | "Degraded" + | "Progressing" + | "Missing" + | "Unknown"; +export type SyncStatus = "Synced" | "OutOfSync" | "Unknown"; + +export interface ApplicationRow { + name: string; + namespace: string; + project: string; + healthStatus: HealthStatus; + syncStatus: SyncStatus; + targetRevision: string; + lastSynced: string | null; +} + +// --- Helpers --- + +export function healthStatusToLabel(status: HealthStatus): string { + return status; +} + +export function healthStatusToColor( + status: HealthStatus +): "success" | "warning" | "error" | "default" { + switch (status) { + case "Healthy": + return "success"; + case "Degraded": + return "error"; + case "Progressing": + return "warning"; + case "Missing": + case "Unknown": + return "default"; + } +} + +export function syncStatusToColor( + status: SyncStatus +): "success" | "warning" | "default" { + switch (status) { + case "Synced": + return "success"; + case "OutOfSync": + return "warning"; + case "Unknown": + return "default"; + } +} + +function formatLastSynced( + history: ArgoCDApplication["status"]["history"] +): string | null { + if (!history || history.length === 0) return null; + const last = history[history.length - 1]; + if (!last || !last.dexKey) return null; + const date = new Date(last.dexKey); + if (isNaN(date.getTime())) return null; + return date.toLocaleString(); +} + +// --- 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; +} + +// --- Component --- + +export default function ApplicationsList() { + const location = useLocation(); + const [applications, setApplications] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const [healthFilter, setHealthFilter] = useState("All"); + const [syncFilter, setSyncFilter] = useState("All"); + const [projectFilter, setProjectFilter] = useState("All"); + + // Initialize project filter from URL search param + useEffect(() => { + const params = new URLSearchParams(location.search); + const project = params.get("project"); + if (project) { + setProjectFilter(project); + } + }, [location.search]); + + useEffect(() => { + let cancelled = false; + setLoading(true); + setError(null); + + fetchApplications() + .then((data) => { + if (cancelled) return; + const rows: ApplicationRow[] = (data.items ?? []).map((app) => ({ + name: app.metadata?.name ?? "unknown", + namespace: app.metadata?.namespace ?? "unknown", + project: app.spec?.project ?? "unknown", + healthStatus: + (app.status?.health?.status as HealthStatus) ?? "Unknown", + syncStatus: (app.status?.sync?.status as SyncStatus) ?? "Unknown", + targetRevision: app.spec?.targetRevision ?? "", + lastSynced: formatLastSynced(app.status?.history), + })); + setApplications(rows); + setLoading(false); + }) + .catch((err: unknown) => { + if (cancelled) return; + const message = err instanceof Error ? err.message : String(err); + setError(message); + setLoading(false); + }); + + return () => { + cancelled = true; + }; + }, []); + + const projects = useMemo(() => { + const set = new Set(applications.map((app) => app.project)); + return Array.from(set).sort(); + }, [applications]); + + const filtered = useMemo(() => { + return applications.filter((app) => { + if (healthFilter !== "All" && app.healthStatus !== healthFilter) + return false; + if (syncFilter !== "All" && app.syncStatus !== syncFilter) return false; + if (projectFilter !== "All" && app.project !== projectFilter) + return false; + return true; + }); + }, [applications, healthFilter, syncFilter, projectFilter]); + + const columns = [ + { + label: "App Name", + getter: (row: ApplicationRow) => row.name, + }, + { + label: "Namespace", + getter: (row: ApplicationRow) => row.namespace, + }, + { + label: "Project", + getter: (row: ApplicationRow) => row.project, + }, + { + label: "Health", + getter: (row: ApplicationRow) => ( + + {healthStatusToLabel(row.healthStatus)} + + ), + }, + { + label: "Sync", + getter: (row: ApplicationRow) => ( + + {row.syncStatus} + + ), + }, + { + label: "Target Revision", + getter: (row: ApplicationRow) => row.targetRevision || "—", + }, + { + label: "Last Synced", + getter: (row: ApplicationRow) => row.lastSynced ?? "—", + }, + ]; + + return ( + <> + + + {/* Filters */} + + + Health + + + + + Sync + + + + + Project + + + + + {/* Table */} + {loading ? ( +
+ Loading ArgoCD applications... +
+ ) : error ? ( +
+ ArgoCD not detected +

+ Could not reach the ArgoCD server. Ensure ArgoCD is installed in + the argocd namespace and the server is reachable. +

+

+ Error: {error} +

+
+ ) : ( + + )} +
+ + ); +} diff --git a/src/index.test.tsx b/src/index.test.tsx index ac2894c..80ea20b 100644 --- a/src/index.test.tsx +++ b/src/index.test.tsx @@ -1,4 +1,4 @@ -import { describe, it, expect } from "vitest"; +import { describe, expect, it } from "vitest"; // Minimal smoke test — verify the test file itself is valid and can run. // Full plugin component tests will be added in subsequent tasks per PRI-189. diff --git a/src/index.tsx b/src/index.tsx index f6244f1..2a50e06 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -7,6 +7,7 @@ import { StatusLabel, } from "@kinvolk/headlamp-plugin/lib/CommonComponents"; import React from "react"; +import ApplicationsList from "./components/ApplicationsList"; // --- Error boundary for plugin components --- @@ -36,18 +37,6 @@ class ArgoCDErrorBoundary extends React.Component< } } -// --- Stub Applications List View --- - -function ArgoCDStubView() { - return ( - - - Plugin scaffold — features coming soon. - - - ); -} - // --- Sidebar entry --- registerSidebarEntry({ @@ -75,7 +64,7 @@ registerRoute({ exact: true, component: () => ( - + ), });