Compare commits

..

9 Commits

Author SHA1 Message Date
github-actions[bot] c648b43493 release: v0.1.2 2026-05-04 06:38:54 +00:00
privilegedescalation-engineer[bot] 730f7cbe54 fix: override lodash >=4.18.0 to patch code injection vulnerability (#7)
* fix: override lodash >=4.18.0 to patch code injection vulnerability

GHSA-r5fr-rjxr-66jc is a code injection vulnerability in lodash
below 4.18.0. The vulnerable transitive dependency comes through
@kinvolk/headlamp-plugin.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* Regenerate lockfile for lodash override

Co-Authored-By: Paperclip <noreply@paperclip.ing>

---------

Co-authored-by: Gandalf the Greybeard <gandalf@privilegedescalation.dev>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: Chris Farhood <chris@farhood.org>
Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-05-04 03:24:00 +00:00
privilegedescalation-ceo[bot] 59c176621f chore: add FUNDING.yml for GitHub Sponsors
Co-authored-by: Gandalf the Greybeard <gandalf@privilegedescalation.dev>
2026-04-22 18:52:47 +00:00
privilegedescalation-engineer[bot] e87b065821 feat: scaffold headlamp-argocd-plugin with standard plugin structure
Squash merge of PR #1. CI  | QA (Regina)  | CTO (Nancy) . Merged by CEO (Countess von Containerheim).
2026-04-22 13:41:13 +00:00
privilegedescalation-ceo[bot] 9d664fda45 feat(page-injections): ArgoCD section on Namespace and Deployment detail pages
Merging after full approval chain: CI , QA (Regina) , CTO (Nancy) . Injects ArgoCD status into Headlamp native Namespace and Deployment detail pages.
2026-04-22 09:35:26 +00:00
Test User bcbed693b1 feat(page-injections): inject ArgoCD info into Namespace and Deployment detail views
- Register detail view sections for Namespace and Deployment resource kinds
- NamespaceArgoSection: shows ArgoCD apps whose spec.destination.namespace matches
- DeploymentArgoBadge: shows ArgoCD app managing the deployment (via status.resources)
- 9 unit tests for matching logic (appsForNamespace, appsForDeployment)
- All checks pass: pnpm tsc, pnpm test (40/40), pnpm lint (0 errors)

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-21 20:53:51 +00:00
Test User 8009f616bc feat(ApplicationDetail): implement ArgoCD Application Detail view
- New component: src/components/ApplicationDetail.tsx
  - Route: /argocd/applications/:name
  - Header: app name, health/sync badges, project, namespace, target revision, repo URL
  - Resource Tree: table of Application.status.resources[] with kind, name, namespace, health, sync
  - Sync History: table of Application.status.history[] (last 10) with revision, deployedAt, initiatedBy
  - Events: K8s events via fieldSelector=involvedObject.name={appName}
- Updated src/components/ApplicationsList.tsx: App Name column links to detail view
- Updated src/index.tsx: added ApplicationDetail route
- Unit tests: 11 tests covering pure functions and component smoke tests

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-21 20:46:46 +00:00
Test User 908df705c0 docs: update CLAUDE.md architecture to reflect ApplicationsList
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-21 20:40:41 +00:00
Test User 04f149cdaa 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 <noreply@paperclip.ing>
2026-04-21 20:39:25 +00:00
15 changed files with 2030 additions and 24 deletions
+1
View File
@@ -0,0 +1 @@
github_sponsors: [privilegedescalation]
+4 -4
View File
@@ -32,12 +32,12 @@ All tests and `pnpm tsc` must pass before committing.
```
src/
├── index.tsx # Plugin entry: registerRoute, registerSidebarEntry; ArgoCDErrorBoundary
├── test-utils.tsx # Shared test fixtures
├── index.tsx # Plugin entry: registerRoute, registerSidebarEntry; ArgoCDErrorBoundary
├── test-utils.tsx # Shared test fixtures
├── api/
│ └── argocd.ts # ArgoCD API types, ApiProxy request helpers
│ └── argocd.ts # ArgoCD API types (Application, ApplicationsList)
└── components/
└── ArgoCDStubView.tsx # Placeholder Applications List view
└── ApplicationsList.tsx # ArgoCD Applications List view with health/sync badges and filters
```
## Code conventions
+3 -3
View File
@@ -1,4 +1,4 @@
version: "0.1.0"
version: "0.1.2"
name: headlamp-argocd
displayName: ArgoCD Headlamp Plugin
createdAt: "2026-04-21T00:00:00Z"
@@ -26,8 +26,8 @@ maintainers:
provider:
name: privilegedescalation
annotations:
headlamp/plugin/archive-url: "https://github.com/privilegedescalation/headlamp-argocd-plugin/releases/download/v0.1.0/headlamp-argocd-0.1.0.tar.gz"
headlamp/plugin/archive-checksum: "sha256:1f4df43f79b795bdf4f70e1e3aa5bacadf689ea5584fdadf92fb677faab21c2c"
headlamp/plugin/archive-url: "https://github.com/privilegedescalation/headlamp-argocd-plugin/releases/download/v0.1.2/privilegedescalation-headlamp-argocd-plugin-0.1.2.tar.gz"
headlamp/plugin/archive-checksum: sha256:e71f84913eed1fd7e2d074912e3bfa668c4b1fefcbb069731a4e4277a998ca28
headlamp/plugin/version-compat: ">=0.26"
headlamp/plugin/distro-compat: "in-cluster"
changes:
+5 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@privilegedescalation/headlamp-argocd-plugin",
"version": "0.1.0",
"version": "0.1.2",
"description": "Headlamp plugin for ArgoCD visibility — monitors ArgoCD Applications, Rollouts, and health status",
"repository": {
"type": "git",
@@ -56,5 +56,8 @@
"typescript": "~5.6.2",
"undici": "^7.24.3",
"vitest": "^3.0.5"
},
"overrides": {
"lodash": ">=4.18.0"
}
}
}
+2 -2
View File
@@ -6235,7 +6235,7 @@ snapshots:
jsdom: 24.1.3
jsonpath-plus: 10.4.0
lodash: 4.18.1
material-react-table: 2.13.3(330725fe5432f245d076f0c0dda1a7a7)
material-react-table: 2.13.3(0078ddeddc9e779fa84c03996c1db10e)
monaco-editor: 0.52.2
msw: 2.4.9(typescript@5.6.2)
msw-storybook-addon: 2.0.3(msw@2.4.9(typescript@5.6.3))
@@ -9937,7 +9937,7 @@ snapshots:
'@types/minimatch': 3.0.5
minimatch: 3.1.5
material-react-table@2.13.3(330725fe5432f245d076f0c0dda1a7a7):
material-react-table@2.13.3(0078ddeddc9e779fa84c03996c1db10e):
dependencies:
'@emotion/react': 11.14.0(@types/react@18.3.28)(react@18.3.1)
'@emotion/styled': 11.14.1(@emotion/react@11.14.0(@types/react@19.2.14)(react@18.3.1))(@types/react@18.3.28)(react@18.3.1)
+300
View File
@@ -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;
}) => (
<div data-testid="section-box" data-title={title}>
{children}
</div>
),
SectionHeader: ({ title }: { title: string }) => (
<div data-testid="section-header">{title}</div>
),
StatusLabel: ({
status,
children,
}: {
status: string;
children?: React.ReactNode;
}) => (
<span data-testid="status-label" data-status={status}>
{children}
</span>
),
SimpleTable: ({
columns,
data,
emptyMessage,
}: {
columns: Array<{
label: string;
getter: (row: unknown) => React.ReactNode;
}>;
data: unknown[];
emptyMessage?: string;
}) =>
data.length === 0 ? (
<div data-testid="simple-table-empty">{emptyMessage}</div>
) : (
<table data-testid="simple-table">
<thead>
<tr>
{columns.map((col) => (
<th key={col.label}>{col.label}</th>
))}
</tr>
</thead>
<tbody>
{data.map((row, i) => (
<tr key={i}>
{columns.map((col) => (
<td key={col.label}>{col.getter(row)}</td>
))}
</tr>
))}
</tbody>
</table>
),
}));
function renderWithRouter(
ui: React.ReactElement,
initialEntry = "/argocd/applications/test-app"
) {
return render(
<MemoryRouter initialEntries={[initialEntry]}>
<Route path="/argocd/applications/:name">{ui}</Route>
</MemoryRouter>
);
}
describe("ApplicationDetail component", () => {
it("renders loading state initially", async () => {
vi.mocked(ApiProxy.request).mockImplementation(
() => new Promise(() => {}) // never resolves — keeps loading
);
renderWithRouter(<ApplicationDetail />);
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(<ApplicationDetail />);
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(<ApplicationDetail />);
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(<ApplicationDetail />);
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(<ApplicationDetail />);
await vi.waitFor(() => {
expect(screen.getByTestId("simple-table")).toBeInTheDocument();
});
});
});
+363
View File
@@ -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> = {}): 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 }) => (
<div data-testid="form-control">{children}</div>
),
}));
vi.mock("@mui/material/InputLabel", () => ({
default: ({ children, id }: { children: React.ReactNode; id?: string }) => (
<label data-testid="input-label" id={id}>
{children}
</label>
),
}));
vi.mock("@mui/material/Select", () => ({
default: ({
children,
value,
onChange,
label,
}: {
children: React.ReactNode;
value: string;
onChange: (e: { target: { value: string } }) => void;
label?: string;
}) => (
<select
data-testid="select"
value={value}
onChange={(e) => onChange({ target: { value: e.target.value } })}
aria-label={label}
>
{children}
</select>
),
}));
vi.mock("@mui/material/MenuItem", () => ({
default: ({
children,
value,
}: {
children: React.ReactNode;
value: string;
}) => (
<option data-testid="menu-item" value={value}>
{children}
</option>
),
}));
vi.mock("@mui/material/Box", () => ({
default: ({ children }: { children: React.ReactNode }) => (
<div data-testid="box">{children}</div>
),
}));
// Mock CommonComponents
vi.mock("@kinvolk/headlamp-plugin/lib/CommonComponents", () => ({
SectionBox: ({ children }: { children?: React.ReactNode }) => (
<div data-testid="section-box">{children}</div>
),
SectionHeader: ({ title }: { title: string }) => (
<div data-testid="section-header">{title}</div>
),
StatusLabel: ({
status,
children,
}: {
status: string;
children?: React.ReactNode;
}) => (
<span data-testid="status-label" data-status={status}>
{children}
</span>
),
SimpleTable: ({
columns,
data,
emptyMessage,
}: {
columns: Array<{
label: string;
getter: (row: unknown) => React.ReactNode;
}>;
data: unknown[];
emptyMessage?: string;
}) =>
data.length === 0 ? (
<div data-testid="simple-table-empty">{emptyMessage}</div>
) : (
<table data-testid="simple-table">
<thead>
<tr>
{columns.map((col) => (
<th key={col.label}>{col.label}</th>
))}
</tr>
</thead>
<tbody>
{data.map((row, i) => (
<tr key={i}>
{columns.map((col) => (
<td key={col.label}>{col.getter(row)}</td>
))}
</tr>
))}
</tbody>
</table>
),
}));
function renderWithRouter(ui: React.ReactElement) {
return render(<MemoryRouter>{ui}</MemoryRouter>);
}
describe("ApplicationsList component", () => {
it("renders loading state initially", async () => {
vi.mocked(ApiProxy.request).mockImplementation(
() => new Promise(() => {}) // never resolves — keeps loading
);
renderWithRouter(<ApplicationsList />);
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(<ApplicationsList />);
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(<ApplicationsList />);
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(<ApplicationsList />);
await vi.waitFor(() => {
expect(screen.getByTestId("simple-table-empty")).toBeInTheDocument();
});
expect(
screen.getByText("No ArgoCD applications found.")
).toBeInTheDocument();
});
});
+142
View File
@@ -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> = {}
): 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"]);
});
});
+100
View File
@@ -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<string, string>;
annotations?: Record<string, string>;
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;
};
}
+375
View File
@@ -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<ArgoCDApplication | null> {
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<K8sEvent[]> {
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<ArgoCDApplication | null>(
null
);
const [events, setEvents] = useState<K8sEvent[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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 (
<>
<SectionHeader title="Application Detail" />
<SectionBox>
<div data-testid="application-detail-loading">
Loading application details...
</div>
</SectionBox>
</>
);
}
if (error || !application) {
return (
<>
<SectionHeader title="Application Detail" />
<SectionBox>
<div data-testid="application-detail-error">
<StatusLabel status="error">
{error ?? "Application not found"}
</StatusLabel>
</div>
</SectionBox>
</>
);
}
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) => (
<StatusLabel status={healthStatusToColor(row.healthStatus)}>
{healthStatusToLabel(row.healthStatus)}
</StatusLabel>
),
},
{
label: "Sync",
getter: (row: ResourceRow) => (
<StatusLabel status={syncStatusToColor(row.syncStatus)}>
{row.syncStatus}
</StatusLabel>
),
},
];
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) => (
<StatusLabel status={row.status === "Success" ? "success" : "warning"}>
{row.status}
</StatusLabel>
),
},
];
return (
<>
<SectionHeader title={`ArgoCD — ${name}`} />
<SectionBox>
{/* Header metadata */}
<div data-testid="application-detail-header">
<StatusLabel status={healthStatusToColor(healthStatus)}>
{healthStatusToLabel(healthStatus)}
</StatusLabel>{" "}
<StatusLabel status={syncStatusToColor(syncStatus)}>
{syncStatus}
</StatusLabel>
<table>
<tbody>
<tr>
<td>
<strong>Project:</strong>
</td>
<td>{project}</td>
</tr>
<tr>
<td>
<strong>Namespace:</strong>
</td>
<td>{namespace}</td>
</tr>
<tr>
<td>
<strong>Target Revision:</strong>
</td>
<td>{targetRevision}</td>
</tr>
<tr>
<td>
<strong>Repository:</strong>
</td>
<td>{repoURL}</td>
</tr>
</tbody>
</table>
</div>
</SectionBox>
{/* Resource Tree */}
<SectionBox title="Resource Tree">
{resourceRows.length === 0 ? (
<div data-testid="resource-tree-empty">
No resources managed by this application.
</div>
) : (
<SimpleTable
columns={resourceColumns}
data={resourceRows}
emptyMessage="No resources found."
/>
)}
</SectionBox>
{/* Sync History */}
<SectionBox title="Sync History">
{historyRows.length === 0 ? (
<div data-testid="sync-history-empty">No sync history available.</div>
) : (
<SimpleTable
columns={historyColumns}
data={historyRows}
emptyMessage="No sync history found."
/>
)}
</SectionBox>
{/* Events */}
<SectionBox title="Events">
{events.length === 0 ? (
<div data-testid="events-empty">
No events found for this application.
</div>
) : (
<SimpleTable
columns={[
{
label: "Type",
getter: (row: K8sEvent) => 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."
/>
)}
</SectionBox>
</>
);
}
+292
View File
@@ -0,0 +1,292 @@
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 { Link, 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<ArgoCDApplicationsList> {
const response = (await ApiProxy.request(
ARGOCD_API_PATH
)) as ArgoCDApplicationsList;
return response;
}
// --- Component ---
export default function ApplicationsList() {
const location = useLocation();
const [applications, setApplications] = useState<ApplicationRow[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [healthFilter, setHealthFilter] = useState<HealthStatus | "All">("All");
const [syncFilter, setSyncFilter] = useState<SyncStatus | "All">("All");
const [projectFilter, setProjectFilter] = useState<string>("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) => (
<Link to={`/argocd/applications/${row.name}`}>{row.name}</Link>
),
},
{
label: "Namespace",
getter: (row: ApplicationRow) => row.namespace,
},
{
label: "Project",
getter: (row: ApplicationRow) => row.project,
},
{
label: "Health",
getter: (row: ApplicationRow) => (
<StatusLabel status={healthStatusToColor(row.healthStatus)}>
{healthStatusToLabel(row.healthStatus)}
</StatusLabel>
),
},
{
label: "Sync",
getter: (row: ApplicationRow) => (
<StatusLabel status={syncStatusToColor(row.syncStatus)}>
{row.syncStatus}
</StatusLabel>
),
},
{
label: "Target Revision",
getter: (row: ApplicationRow) => row.targetRevision || "—",
},
{
label: "Last Synced",
getter: (row: ApplicationRow) => row.lastSynced ?? "—",
},
];
return (
<>
<SectionHeader title="ArgoCD — Applications" />
<SectionBox>
{/* Filters */}
<Box sx={{ display: "flex", gap: 2, mb: 2, flexWrap: "wrap" }}>
<FormControl size="small" sx={{ minWidth: 150 }}>
<InputLabel id="health-filter-label">Health</InputLabel>
<Select
labelId="health-filter-label"
label="Health"
value={healthFilter}
onChange={(e) =>
setHealthFilter(e.target.value as HealthStatus | "All")
}
>
<MenuItem value="All">All</MenuItem>
<MenuItem value="Healthy">Healthy</MenuItem>
<MenuItem value="Degraded">Degraded</MenuItem>
<MenuItem value="Progressing">Progressing</MenuItem>
<MenuItem value="Missing">Missing</MenuItem>
<MenuItem value="Unknown">Unknown</MenuItem>
</Select>
</FormControl>
<FormControl size="small" sx={{ minWidth: 150 }}>
<InputLabel id="sync-filter-label">Sync</InputLabel>
<Select
labelId="sync-filter-label"
label="Sync"
value={syncFilter}
onChange={(e) =>
setSyncFilter(e.target.value as SyncStatus | "All")
}
>
<MenuItem value="All">All</MenuItem>
<MenuItem value="Synced">Synced</MenuItem>
<MenuItem value="OutOfSync">OutOfSync</MenuItem>
<MenuItem value="Unknown">Unknown</MenuItem>
</Select>
</FormControl>
<FormControl size="small" sx={{ minWidth: 150 }}>
<InputLabel id="project-filter-label">Project</InputLabel>
<Select
labelId="project-filter-label"
label="Project"
value={projectFilter}
onChange={(e) => setProjectFilter(e.target.value)}
>
<MenuItem value="All">All</MenuItem>
{projects.map((p) => (
<MenuItem key={p} value={p}>
{p}
</MenuItem>
))}
</Select>
</FormControl>
</Box>
{/* Table */}
{loading ? (
<div data-testid="applications-loading">
Loading ArgoCD applications...
</div>
) : error ? (
<div data-testid="applications-error">
<StatusLabel status="error">ArgoCD not detected</StatusLabel>
<p>
Could not reach the ArgoCD server. Ensure ArgoCD is installed in
the <code>argocd</code> namespace and the server is reachable.
</p>
<p>
<strong>Error:</strong> {error}
</p>
</div>
) : (
<SimpleTable
columns={columns}
data={filtered}
emptyMessage="No ArgoCD applications found."
/>
)}
</SectionBox>
</>
);
}
+101
View File
@@ -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<ArgoCDApplicationsList> {
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<ArgoCDApplication[] | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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 (
<span>
&nbsp;
<Link to={`/argocd/applications/${app.metadata.name}`}>
ArgoCD: {app.metadata.name}
</Link>
&nbsp;
<StatusLabel
status={syncStatusToColor(app.status?.sync?.status ?? "Unknown")}
>
{app.status?.sync?.status ?? "Unknown"}
</StatusLabel>
&nbsp;
<span style={{ fontSize: "0.85em", opacity: 0.8 }}>
Last sync: {lastSyncedStr}
</span>
</span>
);
}
+120
View File
@@ -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<ArgoCDApplicationsList> {
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<ArgoCDApplication[] | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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 (
<SectionBox title="ArgoCD">
<StatusLabel status="warning">Loading...</StatusLabel>
</SectionBox>
);
}
if (error || !apps) {
return (
<SectionBox title="ArgoCD">
<StatusLabel status="error">ArgoCD unreachable</StatusLabel>
</SectionBox>
);
}
if (apps.length === 0) {
return null; // Show nothing when no matching application
}
return (
<SectionBox title="ArgoCD">
<StatusLabel status="success">{apps.length} application(s)</StatusLabel>
<ul style={{ paddingLeft: 20, margin: "8px 0" }}>
{apps.map((app) => (
<li key={app.metadata.name} style={{ marginBottom: 8 }}>
<Link to={`/argocd/applications/${app.metadata.name}`}>
{app.metadata.name}
</Link>
&nbsp;
<StatusLabel
status={healthStatusToColor(
app.status?.health?.status ?? "Unknown"
)}
>
{healthStatusToLabel(app.status?.health?.status ?? "Unknown")}
</StatusLabel>
&nbsp;
<StatusLabel
status={syncStatusToColor(app.status?.sync?.status ?? "Unknown")}
>
{app.status?.sync?.status ?? "Unknown"}
</StatusLabel>
</li>
))}
</ul>
</SectionBox>
);
}
+207
View File
@@ -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<ArgoCDApplicationsList> {
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<ArgoCDApplication[] | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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 (
<SectionBox title="ArgoCD">
<StatusLabel status="warning">Loading...</StatusLabel>
</SectionBox>
);
}
if (error || !apps) {
return (
<SectionBox title="ArgoCD">
<StatusLabel status="error">ArgoCD unreachable</StatusLabel>
</SectionBox>
);
}
if (apps.length === 0) {
return null;
}
return (
<SectionBox title="ArgoCD">
<StatusLabel status="success">{apps.length} application(s)</StatusLabel>
<ul style={{ paddingLeft: 20, margin: "8px 0" }}>
{apps.map((app) => (
<li key={app.metadata.name} style={{ marginBottom: 8 }}>
<Link to={`/argocd/applications/${app.metadata.name}`}>
{app.metadata.name}
</Link>
&nbsp;
<StatusLabel
status={healthStatusToColor(
(app.status?.health?.status as
| "Healthy"
| "Degraded"
| "Progressing"
| "Missing"
| "Unknown") ?? "Unknown"
)}
>
{healthStatusToLabel(
(app.status?.health?.status as
| "Healthy"
| "Degraded"
| "Progressing"
| "Missing"
| "Unknown") ?? "Unknown"
)}
</StatusLabel>
&nbsp;
<StatusLabel
status={syncStatusToColor(
(app.status?.sync?.status as
| "Synced"
| "OutOfSync"
| "Unknown") ?? "Unknown"
)}
>
{app.status?.sync?.status ?? "Unknown"}
</StatusLabel>
</li>
))}
</ul>
</SectionBox>
);
}
// --- Deployment badge ---
function DeploymentArgoBadge({ resource }: { resource: KubeObject }) {
const deploymentName = resource.metadata.name;
const [apps, setApps] = useState<ArgoCDApplication[] | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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 (
<span>
&nbsp;
<Link to={`/argocd/applications/${app.metadata.name}`}>
ArgoCD: {app.metadata.name}
</Link>
&nbsp;
<StatusLabel
status={syncStatusToColor(
(app.status?.sync?.status as "Synced" | "OutOfSync" | "Unknown") ??
"Unknown"
)}
>
{app.status?.sync?.status ?? "Unknown"}
</StatusLabel>
&nbsp;
<span style={{ fontSize: "0.85em", opacity: 0.8 }}>
Last sync: {lastSyncedStr}
</span>
</span>
);
}
// --- Registration ---
registerDetailsViewSection(({ resource }: { resource: KubeObject }) => {
if (resource.kind === "Namespace") {
return <NamespaceArgoSection resource={resource} />;
}
if (resource.kind === "Deployment") {
return <DeploymentArgoBadge resource={resource} />;
}
return null;
});
+15 -13
View File
@@ -7,6 +7,9 @@ import {
StatusLabel,
} from "@kinvolk/headlamp-plugin/lib/CommonComponents";
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 ---
@@ -36,18 +39,6 @@ class ArgoCDErrorBoundary extends React.Component<
}
}
// --- Stub Applications List View ---
function ArgoCDStubView() {
return (
<SectionBox title="ArgoCD Applications">
<StatusLabel status="info">
Plugin scaffold features coming soon.
</StatusLabel>
</SectionBox>
);
}
// --- Sidebar entry ---
registerSidebarEntry({
@@ -75,7 +66,18 @@ registerRoute({
exact: true,
component: () => (
<ArgoCDErrorBoundary>
<ArgoCDStubView />
<ApplicationsList />
</ArgoCDErrorBoundary>
),
});
registerRoute({
path: "/argocd/applications/:name",
sidebar: "argocd-overview",
name: "argocd-application-detail",
component: () => (
<ArgoCDErrorBoundary>
<ApplicationDetail />
</ArgoCDErrorBoundary>
),
});