Compare commits

..

3 Commits

Author SHA1 Message Date
Test User df09454e36 fix: apply prettier formatting 2026-04-21 21:03:58 +00:00
Test User 4b1ace57bc fix: apply ESLint autofix (single quotes, import sort) 2026-04-21 21:02:48 +00:00
Claude Opus 4.6 b76a3ab37c fix: add trailing newline for eol-last 2026-04-21 20:57:56 +00:00
23 changed files with 40 additions and 2336 deletions
-1
View File
@@ -1 +0,0 @@
github_sponsors: [privilegedescalation]
+3 -203
View File
@@ -2,210 +2,10 @@ name: CI
on:
push:
branches: ['**']
branches: [main]
pull_request:
branches: [main, dev, uat]
workflow_dispatch:
permissions:
contents: read
branches: [main]
jobs:
ci:
runs-on: ubuntu-latest
timeout-minutes: 10
container: node:22-slim
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Install Python
run: apt-get update && apt-get install -y --no-install-recommends python3 python3-yaml
- name: Validate artifacthub-pkg.yml
run: |
python3 - <<'EOF'
import sys, re
try:
import yaml
except ImportError:
print("::warning::PyYAML not available, skipping artifacthub-pkg.yml validation")
sys.exit(0)
try:
with open("artifacthub-pkg.yml") as f:
pkg = yaml.safe_load(f)
except FileNotFoundError:
print("::error::artifacthub-pkg.yml not found")
sys.exit(1)
except yaml.YAMLError as e:
print(f"::error::artifacthub-pkg.yml is invalid YAML: {e}")
sys.exit(1)
errors = []
for field in ["version", "name", "description", "homeURL"]:
if not pkg.get(field):
errors.append(f"Missing required field: {field}")
version = pkg.get("version", "")
if version and not re.match(r'^\d+\.\d+\.\d+$', str(version)):
errors.append(f"version '{version}' is not SemVer (expected X.Y.Z)")
annotations = pkg.get("annotations", {}) or {}
archive_url = annotations.get("headlamp/plugin/archive-url", "")
archive_checksum = annotations.get("headlamp/plugin/archive-checksum", "")
if not archive_url:
errors.append("Missing annotation: headlamp/plugin/archive-url")
if not archive_checksum:
errors.append("Missing annotation: headlamp/plugin/archive-checksum")
elif not re.match(r'^sha256:[0-9a-f]{64}$', str(archive_checksum)):
errors.append(f"archive-checksum has unexpected format: '{archive_checksum}' (expected sha256:<64 hex chars>)")
if errors:
for e in errors:
print(f"::error::{e}")
sys.exit(1)
print(f"artifacthub-pkg.yml valid: name={pkg['name']} version={pkg['version']}")
EOF
- name: Detect package manager
id: pkg-manager
run: |
if [ -f "pnpm-lock.yaml" ]; then
echo "manager=pnpm" >> $GITHUB_OUTPUT
PM=$(python3 -c "import json,sys; d=json.load(open('package.json')); print('true' if d.get('packageManager','').startswith('pnpm@') else 'false')" 2>/dev/null || echo "false")
echo "has_package_manager=$PM" >> $GITHUB_OUTPUT
else
echo "manager=npm" >> $GITHUB_OUTPUT
echo "has_package_manager=false" >> $GITHUB_OUTPUT
fi
- name: Setup Node
uses: actions/setup-node@v6
with:
node-version: '22'
cache: ${{ steps.pkg-manager.outputs.manager == 'npm' && 'npm' || '' }}
- name: Setup pnpm (via Corepack, reads version from packageManager field)
if: steps.pkg-manager.outputs.manager == 'pnpm' && steps.pkg-manager.outputs.has_package_manager == 'true'
run: |
npm install -g corepack
corepack enable pnpm
corepack install
- name: Setup pnpm (version latest)
if: steps.pkg-manager.outputs.manager == 'pnpm' && steps.pkg-manager.outputs.has_package_manager == 'false'
uses: pnpm/action-setup@v5
with:
run_install: false
version: latest
- name: Get pnpm store directory
id: pnpm-store
if: steps.pkg-manager.outputs.manager == 'pnpm'
run: echo "dir=$(pnpm store path --silent)" >> $GITHUB_OUTPUT
- name: Cache pnpm store
if: steps.pkg-manager.outputs.manager == 'pnpm'
uses: actions/cache@v5
with:
path: ${{ steps.pnpm-store.outputs.dir }}
key: ${{ runner.os }}-pnpm-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-
- name: Validate pnpm lockfile freshness
if: steps.pkg-manager.outputs.manager == 'pnpm'
run: |
if [ ! -f "pnpm-lock.yaml" ]; then
echo "No pnpm-lock.yaml found, skipping lockfile freshness check"
exit 0
fi
if ! grep -q 'overrides:' pnpm-lock.yaml 2>/dev/null; then
echo "No overrides section in pnpm-lock.yaml, skipping lockfile freshness check"
exit 0
fi
echo "Detected pnpm-lock.yaml with overrides section. Checking lockfile freshness..."
ERR_FILE=$(mktemp)
if pnpm install --frozen-lockfile 2>&1 | tee "$ERR_FILE"; then
echo "Lockfile is fresh."
else
if grep -q "CONFIG_MISMATCH\|EBADLOCKFILE\|ERR_PNPM_LOCKFILE" "$ERR_FILE"; then
echo ""
echo "::error::pnpm-lock.yaml is out of sync with package.json overrides."
echo "::error::Run 'pnpm install' to regenerate the lockfile and commit the updated pnpm-lock.yaml."
rm -f "$ERR_FILE"
exit 1
fi
rm -f "$ERR_FILE"
echo "::warning::Install failed with a different error. Will retry in the Install dependencies step."
fi
- name: Install dependencies
run: |
max_attempts=3
attempt=1
while [ $attempt -le $max_attempts ]; do
echo "Attempt $attempt of $max_attempts"
if [ "${{ steps.pkg-manager.outputs.manager }}" = "pnpm" ]; then
pnpm install --frozen-lockfile && break
else
npm ci && break
fi
if [ $attempt -lt $max_attempts ]; then
echo "::warning::Install step failed on attempt $attempt. Retrying in 5 seconds..."
sleep 5
fi
attempt=$((attempt + 1))
done
if [ $attempt -gt $max_attempts ]; then
echo "::error::Install step failed after $max_attempts attempts."
exit 1
fi
- name: Build plugin
run: npx @kinvolk/headlamp-plugin build
- name: Lint
run: |
if [ "${{ steps.pkg-manager.outputs.manager }}" = "pnpm" ]; then
pnpm run lint
else
npm run lint
fi
- name: Type-check
run: |
if [ "${{ steps.pkg-manager.outputs.manager }}" = "pnpm" ]; then
pnpm run tsc
else
npm run tsc
fi
- name: Format check
run: |
if [ "${{ steps.pkg-manager.outputs.manager }}" = "pnpm" ]; then
pnpm run format:check
else
npm run format:check
fi
- name: Run tests
run: |
if [ "${{ steps.pkg-manager.outputs.manager }}" = "pnpm" ]; then
pnpm test
else
npm test
fi
- name: Security audit
run: |
if [ "${{ steps.pkg-manager.outputs.manager }}" = "pnpm" ]; then
npx audit-ci --pnpm --audit-level=high --config ./audit-ci.jsonc
else
npx audit-ci --npm --audit-level=high --config ./audit-ci.jsonc
fi
uses: privilegedescalation/.github/.github/workflows/plugin-ci.yaml@main
-20
View File
@@ -1,20 +0,0 @@
name: Promotion Gate
# Calls the shared promotion gate workflow.
# dev PRs: no gate (engineer self-merges).
# uat PRs: QA approval required.
# main PRs: UAT approval required (uat→main promotions).
on:
pull_request_review:
types: [submitted, dismissed]
pull_request:
branches: [uat, main]
types: [opened, reopened, synchronize]
jobs:
promotion-gate:
uses: privilegedescalation/.github/.github/workflows/dual-approval-check.yaml@main
secrets: inherit
with:
pr_number: ${{ github.event.pull_request.number }}
-53
View File
@@ -1,53 +0,0 @@
{
"config": {
// Line length — not enforced for docs with code examples
"MD013": false,
// First line heading — files use YAML frontmatter, not headings
"MD041": false,
// Emphasis as heading — common pattern for Option 1/2/3 sections
"MD036": false,
// No duplicate heading — changelog files repeat section names intentionally
"MD024": false,
// Fenced code language — not always applicable for diagram blocks
"MD040": false,
// Table column style — table alignment is visual, not semantic
"MD060": false,
// Ordered list item prefix — number resets are intentional in documents
"MD029": false,
// No inline HTML — each elements are valid in valid Markdown
"MD033": false,
// List marker space — spacing after list markers varies by editor
"MD030": false,
// Blanks around headings — not always needed in compact docs
"MD022": false,
// Blanks around lists — not always needed in compact docs
"MD032": false,
// Blanks around fences — not always needed between adjacent blocks
"MD031": false,
// Multiple blanks — editor artifacts, not semantic
"MD012": false,
// Single title — files may have multiple H1 sections
"MD025": false,
// Trailing spaces — editor artifacts
"MD009": false,
// Bare URLs — URL shortening not always needed
"MD034": false,
// Single trailing newline — editor artifacts
"MD047": false,
// Trailing punctuation — heading punctuation is intentional
"MD026": false,
// Space in emphasis — double-asterisk bold spacing varies by renderer
"MD037": false,
// No hard tabs — some generated docs use tabs for indentation
"MD010": false,
// Code block style — generated docs may use inconsistent styles
"MD046": false,
// Comment style — generated docs have no comments
"MD048": false,
// Commands show output — shell examples intentionally show only commands
"MD014": false
},
"ignores": [
"docs/api-reference/generated/**"
]
}
-1
View File
@@ -1 +0,0 @@
docs/api-reference/generated/**
+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 (Application, ApplicationsList)
│ └── argocd.ts # ArgoCD API types, ApiProxy request helpers
└── components/
└── ApplicationsList.tsx # ArgoCD Applications List view with health/sync badges and filters
└── ArgoCDStubView.tsx # Placeholder Applications List view
```
## Code conventions
-1
View File
@@ -32,4 +32,3 @@ gh workflow run Release --field version=0.1.0
## License
Apache-2.0
+5 -5
View File
@@ -1,4 +1,4 @@
version: "0.1.3"
version: "0.1.0"
name: headlamp-argocd
displayName: ArgoCD Headlamp Plugin
createdAt: "2026-04-21T00:00:00Z"
@@ -26,10 +26,10 @@ maintainers:
provider:
name: privilegedescalation
annotations:
headlamp/plugin/archive-url: "https://github.com/privilegedescalation/headlamp-argocd-plugin/releases/download/v0.1.3/privilegedescalation-headlamp-argocd-plugin-0.1.3.tar.gz"
headlamp/plugin/archive-checksum: sha256:cf96084b79a76b341b5f08d4e17ccf77b5de20f4178061ddc5b5e8dfa81d2743
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/version-compat: ">=0.26"
headlamp/plugin/distro-compat: "in-cluster"
changes:
- kind: fixed
description: "Fix archive URL to point to GitHub v0.1.3 release and bump version"
- kind: added
description: "Initial v0.1.0 release"
+2 -2
View File
@@ -1,6 +1,6 @@
# Artifact Hub repository metadata
# https://artifacthub.io/docs/topics/repositories/#repository-metadata-file
repositoryID: "3648e8a8-54f7-474c-9977-00ec3b4ea1e1"
repositoryID: ""
owners:
- name: privilegedescalation
email: chris@farhood.org
email: chris@farhood.org
-20
View File
@@ -1,20 +0,0 @@
{
// Allowlist for inherited dev-dependency CVEs from @kinvolk/headlamp-plugin
// CTO decision (PRI-854): these high-severity vulns are dev/build-time only,
// trace to @kinvolk/headlamp-plugin transitive deps (Picomatch, Vite, lodash),
// and do NOT ship in production plugin artifacts.
"allowlist": [
{
"id": "GHSA-hhpm-516h-p3p6",
"reason": "Picomatch ReDoS: devDependency only, does not ship in production plugin bundle"
},
{
"id": "GHSA-36xf-7xpp-53w5",
"reason": "Vite arbitrary file read: devDependency only, does not ship in production plugin bundle"
},
{
"id": "GHSA-jf8v-p3pp-93qh",
"reason": "lodash code injection via _.template: devDependency only, does not ship in production plugin bundle"
}
]
}
+5 -4
View File
@@ -1,6 +1,6 @@
{
"name": "@privilegedescalation/headlamp-argocd-plugin",
"version": "0.1.2",
"version": "0.1.0",
"description": "Headlamp plugin for ArgoCD visibility — monitors ArgoCD Applications, Rollouts, and health status",
"repository": {
"type": "git",
@@ -33,8 +33,7 @@
"overrides": {
"tar": "^7.5.11",
"undici": "^7.24.3",
"flatted": "^3.4.2",
"elliptic": ">=6.6.1"
"flatted": "^3.4.2"
}
},
"devDependencies": {
@@ -53,7 +52,9 @@
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^5.3.0",
"tar": "^7.5.11",
"typescript": "~5.6.2",
"undici": "^7.24.3",
"vitest": "^3.0.5"
}
}
}
+8 -3
View File
@@ -8,7 +8,6 @@ overrides:
tar: ^7.5.11
undici: ^7.24.3
flatted: ^3.4.2
elliptic: '>=6.6.1'
importers:
@@ -59,9 +58,15 @@ importers:
react-router-dom:
specifier: ^5.3.0
version: 5.3.4(react@18.3.1)
tar:
specifier: ^7.5.11
version: 7.5.13
typescript:
specifier: ~5.6.2
version: 5.6.3
undici:
specifier: ^7.24.3
version: 7.25.0
vitest:
specifier: ^3.0.5
version: 3.2.4(@types/debug@4.1.13)(@types/node@20.19.39)(jsdom@24.1.3)(msw@2.4.9(typescript@5.6.3))(terser@5.46.1)(yaml@2.8.3)
@@ -6230,7 +6235,7 @@ snapshots:
jsdom: 24.1.3
jsonpath-plus: 10.4.0
lodash: 4.18.1
material-react-table: 2.13.3(0078ddeddc9e779fa84c03996c1db10e)
material-react-table: 2.13.3(330725fe5432f245d076f0c0dda1a7a7)
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))
@@ -9932,7 +9937,7 @@ snapshots:
'@types/minimatch': 3.0.5
minimatch: 3.1.5
material-react-table@2.13.3(0078ddeddc9e779fa84c03996c1db10e):
material-react-table@2.13.3(330725fe5432f245d076f0c0dda1a7a7):
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)
-4
View File
@@ -1,4 +0,0 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": ["github>privilegedescalation/.github:renovate-config"]
}
-300
View File
@@ -1,300 +0,0 @@
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
@@ -1,363 +0,0 @@
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
@@ -1,142 +0,0 @@
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
@@ -1,100 +0,0 @@
// --- 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
@@ -1,375 +0,0 @@
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
@@ -1,292 +0,0 @@
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
@@ -1,101 +0,0 @@
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
@@ -1,120 +0,0 @@
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
@@ -1,207 +0,0 @@
/**
* 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;
});
+13 -15
View File
@@ -7,9 +7,6 @@ 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 ---
@@ -39,6 +36,18 @@ 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({
@@ -66,18 +75,7 @@ registerRoute({
exact: true,
component: () => (
<ArgoCDErrorBoundary>
<ApplicationsList />
</ArgoCDErrorBoundary>
),
});
registerRoute({
path: "/argocd/applications/:name",
sidebar: "argocd-overview",
name: "argocd-application-detail",
component: () => (
<ArgoCDErrorBoundary>
<ApplicationDetail />
<ArgoCDStubView />
</ArgoCDErrorBoundary>
),
});