diff --git a/src/__tests__/ApplicationDetail.test.tsx b/src/__tests__/ApplicationDetail.test.tsx
new file mode 100644
index 0000000..c243480
--- /dev/null
+++ b/src/__tests__/ApplicationDetail.test.tsx
@@ -0,0 +1,300 @@
+import { ApiProxy } from "@kinvolk/headlamp-plugin/lib";
+import { render, screen } from "@testing-library/react";
+import React from "react";
+import { MemoryRouter, Route } from "react-router-dom";
+import { describe, expect, it, vi } from "vitest";
+import ApplicationDetail from "../components/ApplicationDetail";
+
+// --- Pure function unit tests ---
+
+function formatTimestamp(iso: string): string {
+ try {
+ const date = new Date(iso);
+ if (isNaN(date.getTime())) return "—";
+ return date.toLocaleString();
+ } catch {
+ return "—";
+ }
+}
+
+function formatRevision(revision: string): string {
+ if (!revision) return "—";
+ if (revision.length <= 8) return revision;
+ return revision.slice(0, 8);
+}
+
+describe("formatTimestamp", () => {
+ it("returns formatted date for valid ISO string", () => {
+ const result = formatTimestamp("2024-06-01T10:00:00Z");
+ expect(result).not.toBe("—");
+ });
+
+ it("returns em dash for empty string", () => {
+ expect(formatTimestamp("")).toBe("—");
+ });
+
+ it("returns em dash for invalid date", () => {
+ expect(formatTimestamp("not-a-date")).toBe("—");
+ });
+});
+
+describe("formatRevision", () => {
+ it("returns em dash for empty string", () => {
+ expect(formatRevision("")).toBe("—");
+ });
+
+ it("returns revision as-is if 8 chars or fewer", () => {
+ expect(formatRevision("abc")).toBe("abc");
+ expect(formatRevision("12345678")).toBe("12345678");
+ });
+
+ it("truncates revision to 8 chars if longer", () => {
+ expect(formatRevision("1234567890abcdef")).toBe("12345678");
+ });
+});
+
+// --- Component smoke tests ---
+
+// Mock Headlamp lib
+vi.mock("@kinvolk/headlamp-plugin/lib", () => ({
+ ApiProxy: { request: vi.fn() },
+}));
+
+// Mock CommonComponents
+vi.mock("@kinvolk/headlamp-plugin/lib/CommonComponents", () => ({
+ SectionBox: ({
+ children,
+ title,
+ }: {
+ children?: React.ReactNode;
+ title?: string;
+ }) => (
+
+ {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) => (
+ | {col.label} |
+ ))}
+
+
+
+ {data.map((row, i) => (
+
+ {columns.map((col) => (
+ | {col.getter(row)} |
+ ))}
+
+ ))}
+
+
+ ),
+}));
+
+function renderWithRouter(
+ ui: React.ReactElement,
+ initialEntry = "/argocd/applications/test-app"
+) {
+ return render(
+
+ {ui}
+
+ );
+}
+
+describe("ApplicationDetail component", () => {
+ it("renders loading state initially", async () => {
+ vi.mocked(ApiProxy.request).mockImplementation(
+ () => new Promise(() => {}) // never resolves — keeps loading
+ );
+
+ renderWithRouter();
+ expect(screen.getByTestId("application-detail-loading")).toHaveTextContent(
+ "Loading application details"
+ );
+ });
+
+ it("renders error state when API fails", async () => {
+ vi.mocked(ApiProxy.request).mockRejectedValue(
+ new Error("connection refused")
+ );
+
+ renderWithRouter();
+
+ await vi.waitFor(() => {
+ expect(
+ screen.getByTestId("application-detail-error")
+ ).toBeInTheDocument();
+ });
+
+ // fetchApplication catches the error and returns null, which sets "Application not found"
+ expect(screen.getByText(/Application not found/)).toBeInTheDocument();
+ });
+
+ it("renders application detail when API succeeds", async () => {
+ const mockApp = {
+ metadata: { name: "test-app", namespace: "argocd" },
+ spec: {
+ project: "default",
+ sourceRepoURL: "https://github.com/example/repo",
+ targetRevision: "v1.0.0",
+ },
+ status: {
+ health: { status: "Healthy" },
+ sync: { status: "Synced" },
+ resources: [
+ {
+ kind: "Deployment",
+ name: "example-app",
+ namespace: "default",
+ health: { status: "Healthy" },
+ status: "Synced",
+ },
+ ],
+ history: [
+ {
+ dexKey: "2024-06-01T10:00:00Z",
+ id: 1,
+ revision: "v1.0.0",
+ triggeredBy: "admin",
+ },
+ ],
+ },
+ };
+
+ vi.mocked(ApiProxy.request).mockImplementation((path: string) => {
+ if (path.includes("/events")) {
+ return Promise.resolve({ items: [] });
+ }
+ return Promise.resolve(mockApp);
+ });
+
+ renderWithRouter();
+
+ await vi.waitFor(() => {
+ expect(
+ screen.getByTestId("application-detail-header")
+ ).toBeInTheDocument();
+ });
+
+ expect(screen.getByText("ArgoCD — test-app")).toBeInTheDocument();
+ });
+
+ it("renders resource tree with resources", async () => {
+ const mockApp = {
+ metadata: { name: "test-app", namespace: "argocd" },
+ spec: {
+ project: "default",
+ sourceRepoURL: "https://github.com/example/repo",
+ targetRevision: "v1.0.0",
+ },
+ status: {
+ health: { status: "Healthy" },
+ sync: { status: "Synced" },
+ resources: [
+ {
+ kind: "Deployment",
+ name: "example-app",
+ namespace: "default",
+ health: { status: "Healthy" },
+ status: "Synced",
+ },
+ {
+ kind: "Service",
+ name: "example-svc",
+ namespace: "default",
+ health: { status: "Healthy" },
+ status: "Synced",
+ },
+ ],
+ history: [],
+ },
+ };
+
+ vi.mocked(ApiProxy.request).mockImplementation((path: string) => {
+ if (path.includes("/events")) {
+ return Promise.resolve({ items: [] });
+ }
+ return Promise.resolve(mockApp);
+ });
+
+ renderWithRouter();
+
+ await vi.waitFor(() => {
+ expect(screen.getByTestId("simple-table")).toBeInTheDocument();
+ });
+
+ // Resource tree should show 2 data rows (plus 1 header row)
+ const tables = screen.getAllByTestId("simple-table");
+ expect(tables.length).toBeGreaterThan(0);
+ });
+
+ it("renders sync history", async () => {
+ const mockApp = {
+ metadata: { name: "test-app", namespace: "argocd" },
+ spec: {
+ project: "default",
+ sourceRepoURL: "https://github.com/example/repo",
+ targetRevision: "v1.0.0",
+ },
+ status: {
+ health: { status: "Healthy" },
+ sync: { status: "Synced" },
+ resources: [],
+ history: [
+ {
+ dexKey: "2024-06-01T10:00:00Z",
+ id: 1,
+ revision: "v1.0.0",
+ triggeredBy: "admin",
+ },
+ ],
+ },
+ };
+
+ vi.mocked(ApiProxy.request).mockImplementation((path: string) => {
+ if (path.includes("/events")) {
+ return Promise.resolve({ items: [] });
+ }
+ return Promise.resolve(mockApp);
+ });
+
+ renderWithRouter();
+
+ await vi.waitFor(() => {
+ expect(screen.getByTestId("simple-table")).toBeInTheDocument();
+ });
+ });
+});
diff --git a/src/components/ApplicationDetail.tsx b/src/components/ApplicationDetail.tsx
new file mode 100644
index 0000000..7fa0ba0
--- /dev/null
+++ b/src/components/ApplicationDetail.tsx
@@ -0,0 +1,375 @@
+import { ApiProxy } from "@kinvolk/headlamp-plugin/lib";
+import {
+ SectionBox,
+ SectionHeader,
+ SimpleTable,
+ StatusLabel,
+} from "@kinvolk/headlamp-plugin/lib/CommonComponents";
+import React, { useEffect, useState } from "react";
+import { useParams } from "react-router-dom";
+import {
+ ArgoCDApplication,
+ ArgoCDHealthStatus,
+ ArgoCDSyncStatus,
+} from "../api/argocd";
+import { healthStatusToColor, syncStatusToColor } from "./ApplicationsList";
+
+// --- Types ---
+
+interface ResourceRow {
+ kind: string;
+ name: string;
+ namespace: string;
+ healthStatus: ArgoCDHealthStatus | "Unknown";
+ syncStatus: ArgoCDSyncStatus | "Unknown";
+}
+
+interface SyncHistoryRow {
+ revision: string;
+ deployedAt: string;
+ initiatedBy: string;
+ status: "Success" | "Failed" | "Unknown";
+}
+
+interface K8sEvent {
+ metadata: {
+ name: string;
+ namespace: string;
+ creationTimestamp: string;
+ };
+ type: string;
+ reason: string;
+ message: string;
+ involvedObject: {
+ name: string;
+ kind: string;
+ };
+ source: {
+ component: string;
+ };
+}
+
+// --- Helpers ---
+
+function healthStatusToLabel(status: ArgoCDHealthStatus | "Unknown"): string {
+ return status === "Unknown" ? "Unknown" : status;
+}
+
+function formatTimestamp(iso: string): string {
+ try {
+ const date = new Date(iso);
+ if (isNaN(date.getTime())) return "—";
+ return date.toLocaleString();
+ } catch {
+ return "—";
+ }
+}
+
+function formatRevision(revision: string): string {
+ if (!revision) return "—";
+ if (revision.length <= 8) return revision;
+ return revision.slice(0, 8);
+}
+
+// --- API ---
+
+const ARGOCD_API_PATH =
+ "/api/v1/namespaces/argocd/services/argocd-server/proxy/api/v1/applications";
+
+async function fetchApplication(
+ name: string
+): Promise {
+ try {
+ const response = (await ApiProxy.request(
+ `${ARGOCD_API_PATH}/${name}`
+ )) as ArgoCDApplication;
+ return response;
+ } catch {
+ return null;
+ }
+}
+
+async function fetchApplicationEvents(
+ namespace: string,
+ appName: string
+): Promise {
+ try {
+ const fieldSelector = encodeURIComponent(`involvedObject.name=${appName}`);
+ const response = (await ApiProxy.request(
+ `/api/v1/namespaces/${namespace}/events?fieldSelector=${fieldSelector}`
+ )) as { items: K8sEvent[] };
+ return response.items ?? [];
+ } catch {
+ return [];
+ }
+}
+
+// --- Component ---
+
+export default function ApplicationDetail() {
+ const { name } = useParams<{ name: string }>();
+ const [application, setApplication] = useState(
+ null
+ );
+ const [events, setEvents] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ if (!name) return;
+
+ let cancelled = false;
+ setLoading(true);
+ setError(null);
+
+ fetchApplication(name)
+ .then((app) => {
+ if (cancelled) return;
+ if (!app) {
+ setError("Application not found");
+ setLoading(false);
+ return;
+ }
+ setApplication(app);
+ setLoading(false);
+
+ // Fetch events in parallel
+ const namespace = app.metadata?.namespace ?? "argocd";
+ fetchApplicationEvents(namespace, name).then((evts) => {
+ if (!cancelled) setEvents(evts);
+ });
+ })
+ .catch((err: unknown) => {
+ if (cancelled) return;
+ const message = err instanceof Error ? err.message : String(err);
+ setError(message);
+ setLoading(false);
+ });
+
+ return () => {
+ cancelled = true;
+ };
+ }, [name]);
+
+ if (loading) {
+ return (
+ <>
+
+
+
+ Loading application details...
+
+
+ >
+ );
+ }
+
+ if (error || !application) {
+ return (
+ <>
+
+
+
+
+ {error ?? "Application not found"}
+
+
+
+ >
+ );
+ }
+
+ const healthStatus =
+ (application.status?.health?.status as ArgoCDHealthStatus) ?? "Unknown";
+ const syncStatus =
+ (application.status?.sync?.status as ArgoCDSyncStatus) ?? "Unknown";
+ const targetRevision = application.spec?.targetRevision ?? "—";
+ const repoURL = application.spec?.sourceRepoURL ?? "—";
+ const project = application.spec?.project ?? "—";
+ const namespace = application.metadata?.namespace ?? "—";
+
+ // Build resource rows
+ const resourceRows: ResourceRow[] = (application.status?.resources ?? []).map(
+ (res) => ({
+ kind: res.kind ?? "Unknown",
+ name: res.name ?? "unknown",
+ namespace: res.namespace ?? "—",
+ healthStatus: (res.health?.status as ArgoCDHealthStatus) ?? "Unknown",
+ syncStatus: (res.status as ArgoCDSyncStatus) ?? "Unknown",
+ })
+ );
+
+ // Build sync history rows (last 10)
+ const historyRows: SyncHistoryRow[] = (application.status?.history ?? [])
+ .slice(-10)
+ .map((entry) => ({
+ revision: formatRevision(entry.revision),
+ deployedAt: formatTimestamp(entry.dexKey),
+ initiatedBy: entry.triggeredBy ?? "automated",
+ status: entry.id !== undefined ? "Success" : "Unknown",
+ }));
+
+ const resourceColumns = [
+ {
+ label: "Kind",
+ getter: (row: ResourceRow) => row.kind,
+ },
+ {
+ label: "Name",
+ getter: (row: ResourceRow) => row.name,
+ },
+ {
+ label: "Namespace",
+ getter: (row: ResourceRow) => row.namespace,
+ },
+ {
+ label: "Health",
+ getter: (row: ResourceRow) => (
+
+ {healthStatusToLabel(row.healthStatus)}
+
+ ),
+ },
+ {
+ label: "Sync",
+ getter: (row: ResourceRow) => (
+
+ {row.syncStatus}
+
+ ),
+ },
+ ];
+
+ const historyColumns = [
+ {
+ label: "Revision",
+ getter: (row: SyncHistoryRow) => row.revision,
+ },
+ {
+ label: "Deployed At",
+ getter: (row: SyncHistoryRow) => row.deployedAt,
+ },
+ {
+ label: "Initiated By",
+ getter: (row: SyncHistoryRow) => row.initiatedBy,
+ },
+ {
+ label: "Status",
+ getter: (row: SyncHistoryRow) => (
+
+ {row.status}
+
+ ),
+ },
+ ];
+
+ return (
+ <>
+
+
+ {/* Header metadata */}
+
+
+ {healthStatusToLabel(healthStatus)}
+ {" "}
+
+ {syncStatus}
+
+
+
+
+ |
+ Project:
+ |
+ {project} |
+
+
+ |
+ Namespace:
+ |
+ {namespace} |
+
+
+ |
+ Target Revision:
+ |
+ {targetRevision} |
+
+
+ |
+ Repository:
+ |
+ {repoURL} |
+
+
+
+
+
+
+ {/* Resource Tree */}
+
+ {resourceRows.length === 0 ? (
+
+ No resources managed by this application.
+
+ ) : (
+
+ )}
+
+
+ {/* Sync History */}
+
+ {historyRows.length === 0 ? (
+ No sync history available.
+ ) : (
+
+ )}
+
+
+ {/* Events */}
+
+ {events.length === 0 ? (
+
+ No events found for this application.
+
+ ) : (
+ row.type,
+ },
+ {
+ label: "Reason",
+ getter: (row: K8sEvent) => row.reason,
+ },
+ {
+ label: "Message",
+ getter: (row: K8sEvent) => row.message,
+ },
+ {
+ label: "Source",
+ getter: (row: K8sEvent) => row.source?.component ?? "—",
+ },
+ {
+ label: "Age",
+ getter: (row: K8sEvent) =>
+ formatTimestamp(row.metadata?.creationTimestamp ?? ""),
+ },
+ ]}
+ data={events}
+ emptyMessage="No events found."
+ />
+ )}
+
+ >
+ );
+}
diff --git a/src/components/ApplicationsList.tsx b/src/components/ApplicationsList.tsx
index 9f76c17..5ec9a65 100644
--- a/src/components/ApplicationsList.tsx
+++ b/src/components/ApplicationsList.tsx
@@ -11,7 +11,7 @@ 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 { Link, useLocation } from "react-router-dom";
import { ArgoCDApplication, ArgoCDApplicationsList } from "../api/argocd";
// --- Types ---
@@ -165,7 +165,9 @@ export default function ApplicationsList() {
const columns = [
{
label: "App Name",
- getter: (row: ApplicationRow) => row.name,
+ getter: (row: ApplicationRow) => (
+ {row.name}
+ ),
},
{
label: "Namespace",
diff --git a/src/index.tsx b/src/index.tsx
index 2a50e06..4526b1b 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 ApplicationDetail from "./components/ApplicationDetail";
import ApplicationsList from "./components/ApplicationsList";
// --- Error boundary for plugin components ---
@@ -68,3 +69,14 @@ registerRoute({
),
});
+
+registerRoute({
+ path: "/argocd/applications/:name",
+ sidebar: "argocd-overview",
+ name: "argocd-application-detail",
+ component: () => (
+
+
+
+ ),
+});