Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7271d62077 | |||
| 87fc96e691 |
@@ -1,3 +0,0 @@
|
||||
module.exports = {
|
||||
extends: ['@headlamp-k8s/eslint-config'],
|
||||
};
|
||||
@@ -1,36 +0,0 @@
|
||||
name: AI Code Review
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
ai-review:
|
||||
name: AI Code Review
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: AI Review
|
||||
uses: Nikita-Filonov/ai-review@v0.56.0
|
||||
with:
|
||||
review-command: run
|
||||
env:
|
||||
LLM__PROVIDER: "OPENAI"
|
||||
LLM__META__MODEL: ${{ vars.AI_REVIEW_MODEL }}
|
||||
LLM__META__MAX_TOKENS: "15000"
|
||||
LLM__META__TEMPERATURE: "0.3"
|
||||
LLM__HTTP_CLIENT__API_URL: "https://api.openai.com/v1"
|
||||
LLM__HTTP_CLIENT__API_TOKEN: ${{ secrets.OPENAI_API_KEY }}
|
||||
VCS__PROVIDER: "GITEA"
|
||||
VCS__PIPELINE__OWNER: ${{ github.repository_owner }}
|
||||
VCS__PIPELINE__REPO: ${{ github.event.repository.name }}
|
||||
VCS__PIPELINE__PULL_NUMBER: ${{ github.event.pull_request.number }}
|
||||
VCS__HTTP_CLIENT__API_URL: ${{ github.server_url }}/api/v1
|
||||
VCS__HTTP_CLIENT__API_TOKEN: ${{ secrets.AI_REVIEW_GITEA_TOKEN }}
|
||||
@@ -1,30 +0,0 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
container: node:20
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Build
|
||||
run: npx @kinvolk/headlamp-plugin build
|
||||
|
||||
- name: Lint
|
||||
run: npx eslint --ext .ts,.tsx src/
|
||||
|
||||
- name: Type-check
|
||||
run: npx tsc --noEmit
|
||||
|
||||
- name: Format check
|
||||
run: npx prettier --check src/
|
||||
@@ -1,28 +0,0 @@
|
||||
name: E2E
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
e2e:
|
||||
runs-on: ubuntu-latest
|
||||
container: node:20
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Install Chromium
|
||||
run: npx playwright install --with-deps chromium
|
||||
|
||||
- name: Run E2E smoke tests
|
||||
env:
|
||||
HEADLAMP_URL: https://headlamp.animaniacs.farh.net
|
||||
AUTHENTIK_USERNAME: ${{ secrets.AUTHENTIK_USERNAME }}
|
||||
AUTHENTIK_PASSWORD: ${{ secrets.AUTHENTIK_PASSWORD }}
|
||||
run: npx playwright test
|
||||
+14
-145
@@ -12,162 +12,31 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Check if release is already finalized
|
||||
run: |
|
||||
VERSION=${GITHUB_REF_NAME#v}
|
||||
TARBALL_URL="https://github.com/cpfarhood/headlamp-polaris-plugin/releases/download/${GITHUB_REF_NAME}/headlamp-polaris-plugin-${VERSION}.tar.gz"
|
||||
HTTP_CODE=$(curl -sL -o /tmp/release.tar.gz -w "%{http_code}" "$TARBALL_URL" 2>/dev/null)
|
||||
if [ "$HTTP_CODE" = "200" ]; then
|
||||
ACTUAL="sha256:$(sha256sum /tmp/release.tar.gz | awk '{print $1}')"
|
||||
EXPECTED=$(grep 'archive-checksum' artifacthub-pkg.yml | awk '{print $2}')
|
||||
echo "Release tarball checksum: $ACTUAL"
|
||||
echo "Metadata checksum: $EXPECTED"
|
||||
if [ "$ACTUAL" = "$EXPECTED" ]; then
|
||||
echo "SKIP_BUILD=true" >> $GITHUB_ENV
|
||||
echo "Checksums match - release is finalized, nothing to do"
|
||||
fi
|
||||
else
|
||||
echo "No existing release (HTTP $HTTP_CODE) - will build"
|
||||
fi
|
||||
rm -f /tmp/release.tar.gz
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
[ "$SKIP_BUILD" = "true" ] && exit 0
|
||||
npm ci
|
||||
run: npm ci
|
||||
|
||||
- name: Build plugin
|
||||
run: |
|
||||
[ "$SKIP_BUILD" = "true" ] && exit 0
|
||||
npx @kinvolk/headlamp-plugin build
|
||||
run: npx @kinvolk/headlamp-plugin build
|
||||
|
||||
- name: Package tarball
|
||||
run: |
|
||||
[ "$SKIP_BUILD" = "true" ] && exit 0
|
||||
npx @kinvolk/headlamp-plugin package
|
||||
|
||||
- name: Compute tarball checksum
|
||||
run: |
|
||||
[ "$SKIP_BUILD" = "true" ] && exit 0
|
||||
TARBALL=$(ls *.tar.gz)
|
||||
CHECKSUM=$(sha256sum "$TARBALL" | awk '{print $1}')
|
||||
echo "TARBALL=$TARBALL" >> $GITHUB_ENV
|
||||
echo "CHECKSUM=$CHECKSUM" >> $GITHUB_ENV
|
||||
echo "Tarball: $TARBALL"
|
||||
echo "Checksum: sha256:$CHECKSUM"
|
||||
run: npx @kinvolk/headlamp-plugin package
|
||||
|
||||
- name: Install Docker CLI
|
||||
run: |
|
||||
[ "$SKIP_BUILD" = "true" ] && exit 0
|
||||
apt-get update && apt-get install -y docker.io
|
||||
run: apt-get update && apt-get install -y docker.io
|
||||
|
||||
- name: Build and push Docker image
|
||||
- name: Build Docker image
|
||||
run: docker build -t git.farh.net/${{ github.repository }}:${{ github.ref_name }} -t git.farh.net/${{ github.repository }}:latest .
|
||||
|
||||
- name: Push Docker image
|
||||
run: |
|
||||
[ "$SKIP_BUILD" = "true" ] && exit 0
|
||||
docker build -t git.farh.net/${{ github.repository }}:${{ github.ref_name }} -t git.farh.net/${{ github.repository }}:latest .
|
||||
echo "${{ secrets.REGISTRY_TOKEN }}" | docker login git.farh.net -u ${{ github.actor }} --password-stdin
|
||||
docker push git.farh.net/${{ github.repository }}:${{ github.ref_name }}
|
||||
docker push git.farh.net/${{ github.repository }}:latest
|
||||
|
||||
- name: Create Gitea release
|
||||
run: |
|
||||
[ "$SKIP_BUILD" = "true" ] && exit 0
|
||||
API_URL="${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}"
|
||||
# Create release (or get existing)
|
||||
RELEASE=$(curl -s -X POST \
|
||||
-H "Authorization: token ${{ github.token }}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${API_URL}/releases" \
|
||||
-d "{\"tag_name\":\"${GITHUB_REF_NAME}\",\"name\":\"${GITHUB_REF_NAME}\"}")
|
||||
RELEASE_ID=$(echo "$RELEASE" | node -e "process.stdin.resume();let d='';process.stdin.on('data',c=>d+=c);process.stdin.on('end',()=>console.log(JSON.parse(d).id))")
|
||||
if [ "$RELEASE_ID" = "undefined" ]; then
|
||||
RELEASE=$(curl -sf \
|
||||
-H "Authorization: token ${{ github.token }}" \
|
||||
"${API_URL}/releases/tags/${GITHUB_REF_NAME}")
|
||||
RELEASE_ID=$(echo "$RELEASE" | node -e "process.stdin.resume();let d='';process.stdin.on('data',c=>d+=c);process.stdin.on('end',()=>console.log(JSON.parse(d).id))")
|
||||
fi
|
||||
echo "Gitea Release ID: $RELEASE_ID"
|
||||
# Delete existing assets
|
||||
ASSETS=$(curl -sf \
|
||||
-H "Authorization: token ${{ github.token }}" \
|
||||
"${API_URL}/releases/${RELEASE_ID}/assets")
|
||||
echo "$ASSETS" | node -e "
|
||||
process.stdin.resume();let d='';
|
||||
process.stdin.on('data',c=>d+=c);
|
||||
process.stdin.on('end',()=>{
|
||||
JSON.parse(d).forEach(a=>console.log(a.id));
|
||||
})" | while read -r ASSET_ID; do
|
||||
curl -sf -X DELETE \
|
||||
-H "Authorization: token ${{ github.token }}" \
|
||||
"${API_URL}/releases/${RELEASE_ID}/assets/${ASSET_ID}"
|
||||
done
|
||||
# Upload tarball
|
||||
curl -sf -X POST \
|
||||
-H "Authorization: token ${{ github.token }}" \
|
||||
-F "attachment=@${TARBALL}" \
|
||||
"${API_URL}/releases/${RELEASE_ID}/assets?name=${TARBALL}"
|
||||
echo "Gitea release updated"
|
||||
|
||||
- name: Create GitHub release
|
||||
run: |
|
||||
[ "$SKIP_BUILD" = "true" ] && exit 0
|
||||
# GitHub API to create/update release
|
||||
GITHUB_API="https://api.github.com/repos/cpfarhood/headlamp-polaris-plugin"
|
||||
# Check if release exists
|
||||
RELEASE_DATA=$(curl -sf \
|
||||
-H "Authorization: token ${{ secrets.GH_TOKEN }}" \
|
||||
"${GITHUB_API}/releases/tags/${GITHUB_REF_NAME}" || echo "{}")
|
||||
RELEASE_ID=$(echo "$RELEASE_DATA" | node -e "process.stdin.resume();let d='';process.stdin.on('data',c=>d+=c);process.stdin.on('end',()=>console.log(JSON.parse(d).id||''))")
|
||||
|
||||
if [ -z "$RELEASE_ID" ]; then
|
||||
# Create new release
|
||||
RELEASE_DATA=$(curl -sf -X POST \
|
||||
-H "Authorization: token ${{ secrets.GH_TOKEN }}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${GITHUB_API}/releases" \
|
||||
-d "{\"tag_name\":\"${GITHUB_REF_NAME}\",\"name\":\"${GITHUB_REF_NAME}\",\"draft\":false,\"prerelease\":false}")
|
||||
RELEASE_ID=$(echo "$RELEASE_DATA" | node -e "process.stdin.resume();let d='';process.stdin.on('data',c=>d+=c);process.stdin.on('end',()=>console.log(JSON.parse(d).id))")
|
||||
fi
|
||||
|
||||
echo "GitHub Release ID: $RELEASE_ID"
|
||||
# Upload tarball to GitHub
|
||||
UPLOAD_URL=$(echo "$RELEASE_DATA" | node -e "process.stdin.resume();let d='';process.stdin.on('data',c=>d+=c);process.stdin.on('end',()=>{const r=JSON.parse(d);console.log(r.upload_url||'https://uploads.github.com/repos/cpfarhood/headlamp-polaris-plugin/releases/${RELEASE_ID}/assets')})" | sed 's/{.*}//')
|
||||
curl -sf -X POST \
|
||||
-H "Authorization: token ${{ secrets.GH_TOKEN }}" \
|
||||
-H "Content-Type: application/gzip" \
|
||||
--data-binary "@${TARBALL}" \
|
||||
"${UPLOAD_URL}?name=${TARBALL}"
|
||||
echo "GitHub release updated"
|
||||
|
||||
- name: Update metadata and align tag
|
||||
run: |
|
||||
[ "$SKIP_BUILD" = "true" ] && exit 0
|
||||
VERSION=${GITHUB_REF_NAME#v}
|
||||
git config user.name "gitea-actions[bot]"
|
||||
git config user.email "gitea-actions[bot]@git.farh.net"
|
||||
# Determine which Gitea branch to update based on version suffix
|
||||
if [[ "$VERSION" == *"-dev."* ]]; then
|
||||
GITEA_BRANCH="dev"
|
||||
else
|
||||
GITEA_BRANCH="main"
|
||||
fi
|
||||
git fetch origin ${GITEA_BRANCH}
|
||||
git checkout origin/${GITEA_BRANCH} -B temp-update
|
||||
sed -i "s|headlamp/plugin/archive-checksum:.*|headlamp/plugin/archive-checksum: sha256:${CHECKSUM}|" artifacthub-pkg.yml
|
||||
sed -i "s|headlamp/plugin/archive-url:.*|headlamp/plugin/archive-url: \"https://github.com/cpfarhood/headlamp-polaris-plugin/releases/download/${GITHUB_REF_NAME}/headlamp-polaris-plugin-${VERSION}.tar.gz\"|" artifacthub-pkg.yml
|
||||
sed -i "s|^version:.*|version: ${VERSION}|" artifacthub-pkg.yml
|
||||
git add artifacthub-pkg.yml
|
||||
git diff --cached --quiet || {
|
||||
git commit -m "ci: update artifact hub metadata for ${GITHUB_REF_NAME}"
|
||||
git push origin temp-update:${GITEA_BRANCH}
|
||||
}
|
||||
# Force-move tag to the commit with correct checksum.
|
||||
# This triggers a new CI run, but the guard step will detect
|
||||
# that the release checksum already matches and skip the build.
|
||||
git tag -f ${GITHUB_REF_NAME}
|
||||
git push -f origin ${GITHUB_REF_NAME}
|
||||
echo "Tag ${GITHUB_REF_NAME} aligned with updated metadata"
|
||||
echo "Note: GitHub sync handled by Gitea mirror configuration"
|
||||
- name: Create release
|
||||
uses: akkuman/gitea-release-action@v1
|
||||
with:
|
||||
files: |
|
||||
*.tar.gz
|
||||
token: ${{ github.token }}
|
||||
|
||||
@@ -1,102 +0,0 @@
|
||||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Check if release is already finalized
|
||||
run: |
|
||||
VERSION=${GITHUB_REF_NAME#v}
|
||||
TARBALL_URL="https://github.com/${{ github.repository }}/releases/download/${GITHUB_REF_NAME}/headlamp-polaris-plugin-${VERSION}.tar.gz"
|
||||
HTTP_CODE=$(curl -sL -o /tmp/release.tar.gz -w "%{http_code}" "$TARBALL_URL" 2>/dev/null)
|
||||
if [ "$HTTP_CODE" = "200" ]; then
|
||||
ACTUAL="sha256:$(sha256sum /tmp/release.tar.gz | awk '{print $1}')"
|
||||
EXPECTED=$(grep 'archive-checksum' artifacthub-pkg.yml | awk '{print $2}')
|
||||
echo "Release tarball checksum: $ACTUAL"
|
||||
echo "Metadata checksum: $EXPECTED"
|
||||
if [ "$ACTUAL" = "$EXPECTED" ]; then
|
||||
echo "SKIP_BUILD=true" >> $GITHUB_ENV
|
||||
echo "Checksums match - release is finalized, nothing to do"
|
||||
fi
|
||||
else
|
||||
echo "No existing release (HTTP $HTTP_CODE) - will build"
|
||||
fi
|
||||
rm -f /tmp/release.tar.gz
|
||||
|
||||
- name: Setup Node.js
|
||||
if: env.SKIP_BUILD != 'true'
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
|
||||
- name: Install dependencies
|
||||
if: env.SKIP_BUILD != 'true'
|
||||
run: npm ci
|
||||
|
||||
- name: Build plugin
|
||||
if: env.SKIP_BUILD != 'true'
|
||||
run: npx @kinvolk/headlamp-plugin build
|
||||
|
||||
- name: Package tarball
|
||||
if: env.SKIP_BUILD != 'true'
|
||||
run: npx @kinvolk/headlamp-plugin package
|
||||
|
||||
- name: Compute tarball checksum
|
||||
if: env.SKIP_BUILD != 'true'
|
||||
run: |
|
||||
TARBALL=$(ls *.tar.gz)
|
||||
CHECKSUM=$(sha256sum "$TARBALL" | awk '{print $1}')
|
||||
echo "TARBALL=$TARBALL" >> $GITHUB_ENV
|
||||
echo "CHECKSUM=$CHECKSUM" >> $GITHUB_ENV
|
||||
echo "Tarball: $TARBALL"
|
||||
echo "Checksum: sha256:$CHECKSUM"
|
||||
|
||||
- name: Create GitHub release and upload tarball
|
||||
if: env.SKIP_BUILD != 'true'
|
||||
uses: softprops/action-gh-release@v1
|
||||
with:
|
||||
files: ${{ env.TARBALL }}
|
||||
fail_on_unmatched_files: true
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Update metadata and align tag
|
||||
if: env.SKIP_BUILD != 'true'
|
||||
run: |
|
||||
VERSION=${GITHUB_REF_NAME#v}
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
|
||||
# Update metadata
|
||||
git fetch origin main
|
||||
git checkout origin/main -B temp-update
|
||||
sed -i "s|headlamp/plugin/archive-checksum:.*|headlamp/plugin/archive-checksum: sha256:${CHECKSUM}|" artifacthub-pkg.yml
|
||||
sed -i "s|headlamp/plugin/archive-url:.*|headlamp/plugin/archive-url: \"https://github.com/${{ github.repository }}/releases/download/${GITHUB_REF_NAME}/headlamp-polaris-plugin-${VERSION}.tar.gz\"|" artifacthub-pkg.yml
|
||||
sed -i "s|^version:.*|version: ${VERSION}|" artifacthub-pkg.yml
|
||||
git add artifacthub-pkg.yml
|
||||
|
||||
if ! git diff --cached --quiet; then
|
||||
git commit -m "ci: update artifact hub metadata for ${GITHUB_REF_NAME}"
|
||||
git push origin temp-update:main
|
||||
fi
|
||||
|
||||
# Force-move tag to the commit with correct checksum.
|
||||
# This triggers a new CI run, but the guard step will detect
|
||||
# that the release checksum already matches and skip the build.
|
||||
git tag -f ${GITHUB_REF_NAME}
|
||||
git push -f origin ${GITHUB_REF_NAME}
|
||||
echo "Tag ${GITHUB_REF_NAME} aligned with updated metadata"
|
||||
@@ -0,0 +1,37 @@
|
||||
name: GitHub Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Build plugin
|
||||
run: npx @kinvolk/headlamp-plugin build
|
||||
|
||||
- name: Package tarball
|
||||
run: npx @kinvolk/headlamp-plugin package
|
||||
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
files: "*.tar.gz"
|
||||
generate_release_notes: true
|
||||
@@ -2,7 +2,3 @@ node_modules/
|
||||
dist/
|
||||
.headlamp-plugin/
|
||||
.mcp.json
|
||||
*.tar.gz
|
||||
e2e/.auth/
|
||||
test-results/
|
||||
.playwright-mcp/
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
module.exports = require('@headlamp-k8s/eslint-config/prettier-config');
|
||||
+1
-2
@@ -6,5 +6,4 @@ COPY src/ src/
|
||||
RUN npx @kinvolk/headlamp-plugin build
|
||||
|
||||
FROM alpine:3.20
|
||||
COPY --from=build /app/dist/ /plugins/headlamp-polaris-plugin/
|
||||
COPY --from=build /app/package.json /plugins/headlamp-polaris-plugin/
|
||||
COPY --from=build /app/dist/ /plugins/polaris-headlamp-plugin/
|
||||
|
||||
@@ -1,290 +0,0 @@
|
||||
# Headlamp Polaris Plugin - Project Assessment
|
||||
|
||||
**Date:** 2026-02-11
|
||||
**Version:** v0.3.0
|
||||
**Status:** Active Development
|
||||
|
||||
## Executive Summary
|
||||
|
||||
This assessment identifies critical issues and improvement opportunities for the headlamp-polaris-plugin project. The plugin is currently non-functional in production due to Headlamp v0.39.0 compatibility issues, and has several TypeScript compilation errors that need immediate attention.
|
||||
|
||||
---
|
||||
|
||||
## 🔴 Critical Issues (Must Fix Immediately)
|
||||
|
||||
### 1. TypeScript Compilation Errors
|
||||
**Severity:** CRITICAL
|
||||
**Impact:** Build failures, type safety compromised
|
||||
|
||||
**Issues:**
|
||||
- `src/index.tsx:72` - `registerDetailsViewSection` expects 1 argument, got 2
|
||||
- `src/index.tsx:87` - `registerAppBarAction` expects 1 argument, got 2
|
||||
|
||||
**Recommendation:**
|
||||
Update Headlamp plugin API calls to match the current version. Check @kinvolk/headlamp-plugin version compatibility.
|
||||
|
||||
**Action Items:**
|
||||
- [ ] Review Headlamp plugin API documentation
|
||||
- [ ] Update `registerDetailsViewSection` and `registerAppBarAction` calls
|
||||
- [ ] Run `npm run tsc` to verify fixes
|
||||
- [ ] Update CI to fail on TypeScript errors
|
||||
|
||||
---
|
||||
|
||||
### 2. Production Plugin Loading Failure
|
||||
**Severity:** CRITICAL
|
||||
**Impact:** Plugin is completely non-functional in production
|
||||
|
||||
**Root Cause:**
|
||||
Headlamp v0.39.0 with default `watchPlugins: true` treats catalog-managed plugins as "development directory" plugins, preventing frontend JavaScript execution.
|
||||
|
||||
**Current Status:**
|
||||
- Deployment patched to install plugins to `/headlamp/static-plugins`
|
||||
- `watchPlugins: false` configured
|
||||
- Waiting for user to test if plugins now load
|
||||
|
||||
**Action Items:**
|
||||
- [ ] Confirm plugins load after recent deployment changes
|
||||
- [ ] Document the fix in deployment guide
|
||||
- [ ] Update MEMORY.md with final resolution
|
||||
- [ ] Consider downgrading Headlamp if issue persists
|
||||
|
||||
---
|
||||
|
||||
### 3. Test Failures
|
||||
**Severity:** HIGH
|
||||
**Impact:** CI failures, reduced confidence in changes
|
||||
|
||||
**Current Status:**
|
||||
- 1 test file failing (DashboardView)
|
||||
- 49 tests passing
|
||||
- Error related to `SimpleTable` component mock
|
||||
|
||||
**Action Items:**
|
||||
- [ ] Fix DashboardView test mocking
|
||||
- [ ] Ensure all tests pass before merging PRs
|
||||
- [ ] Add test for top issues feature
|
||||
- [ ] Increase test coverage to >80%
|
||||
|
||||
---
|
||||
|
||||
## 🟡 High Priority Improvements
|
||||
|
||||
### 4. Type Safety Enhancements
|
||||
**Severity:** HIGH
|
||||
**Impact:** Better developer experience, catch errors earlier
|
||||
|
||||
**Recommendations:**
|
||||
- Enable stricter TypeScript checks in `tsconfig.json`
|
||||
- Add type definitions for all Headlamp plugin APIs
|
||||
- Ensure no `any` types in production code
|
||||
- Add JSDoc comments for complex types
|
||||
|
||||
**Action Items:**
|
||||
- [ ] Audit codebase for `any` types
|
||||
- [ ] Enable `noImplicitAny` and `strictNullChecks`
|
||||
- [ ] Add type guards for API responses
|
||||
- [ ] Document complex type structures
|
||||
|
||||
---
|
||||
|
||||
### 5. Security Hardening
|
||||
**Severity:** HIGH
|
||||
**Impact:** Prevent vulnerabilities, protect user data
|
||||
|
||||
**Current Risks:**
|
||||
- Direct Kubernetes API access via service proxy
|
||||
- User input in exemption annotations (potential injection)
|
||||
- External URL configuration for Polaris dashboard
|
||||
|
||||
**Recommendations:**
|
||||
- Validate and sanitize all user inputs
|
||||
- Implement input validation for dashboard URL
|
||||
- Add CSRF protection for exemption management
|
||||
- Audit dependencies for known vulnerabilities
|
||||
|
||||
**Action Items:**
|
||||
- [ ] Add input validation utilities
|
||||
- [ ] Sanitize exemption annotation values
|
||||
- [ ] Validate URL format for dashboard configuration
|
||||
- [ ] Run `npm audit` and fix vulnerabilities
|
||||
- [ ] Add security testing to CI/CD
|
||||
|
||||
---
|
||||
|
||||
### 6. Error Handling & User Experience
|
||||
**Severity:** MEDIUM
|
||||
**Impact:** Better error messages, improved debugging
|
||||
|
||||
**Current Gaps:**
|
||||
- Generic error messages don't help users troubleshoot
|
||||
- No retry logic for transient API failures
|
||||
- Missing loading states in some components
|
||||
|
||||
**Recommendations:**
|
||||
- Provide specific, actionable error messages
|
||||
- Implement retry logic with exponential backoff
|
||||
- Add loading skeletons for all async operations
|
||||
- Show connection test results with specific failure reasons
|
||||
|
||||
**Action Items:**
|
||||
- [ ] Create error message constants with solutions
|
||||
- [ ] Add retry logic to API calls
|
||||
- [ ] Implement loading skeletons
|
||||
- [ ] Improve connection test error messages
|
||||
|
||||
---
|
||||
|
||||
## 🟢 Medium Priority Enhancements
|
||||
|
||||
### 7. Testing Coverage
|
||||
**Severity:** MEDIUM
|
||||
**Impact:** Confidence in changes, regression prevention
|
||||
|
||||
**Current Coverage:**
|
||||
- Unit tests: Good coverage for API utilities
|
||||
- Component tests: Some coverage, gaps exist
|
||||
- E2E tests: Minimal (Playwright configured but underutilized)
|
||||
|
||||
**Recommendations:**
|
||||
- Add E2E tests for critical user flows
|
||||
- Test error scenarios and edge cases
|
||||
- Add visual regression tests
|
||||
- Test RBAC permission denied scenarios
|
||||
|
||||
**Action Items:**
|
||||
- [ ] Write E2E test for complete audit workflow
|
||||
- [ ] Add tests for error states
|
||||
- [ ] Test exemption management flow
|
||||
- [ ] Add Playwright tests to CI
|
||||
|
||||
---
|
||||
|
||||
### 8. Performance Optimization
|
||||
**Severity:** MEDIUM
|
||||
**Impact:** Faster load times, better UX
|
||||
|
||||
**Opportunities:**
|
||||
- Memoize expensive calculations (score computation)
|
||||
- Lazy load namespace detail views
|
||||
- Debounce search/filter operations
|
||||
- Cache Polaris data with stale-while-revalidate
|
||||
|
||||
**Action Items:**
|
||||
- [ ] Add React.memo to pure components
|
||||
- [ ] Memoize score calculations
|
||||
- [ ] Implement data caching strategy
|
||||
- [ ] Profile component render times
|
||||
|
||||
---
|
||||
|
||||
### 9. Code Quality & Maintainability
|
||||
**Severity:** MEDIUM
|
||||
**Impact:** Easier maintenance, onboarding
|
||||
|
||||
**Recommendations:**
|
||||
- Extract magic strings to constants
|
||||
- Reduce component complexity
|
||||
- Add JSDoc comments for public APIs
|
||||
- Improve code organization
|
||||
|
||||
**Action Items:**
|
||||
- [ ] Create constants file for check IDs
|
||||
- [ ] Split large components (DashboardView, NamespaceDetailView)
|
||||
- [ ] Add comments for complex logic
|
||||
- [ ] Establish code review checklist
|
||||
|
||||
---
|
||||
|
||||
## 🔵 Low Priority / Future Enhancements
|
||||
|
||||
### 10. Documentation
|
||||
**Severity:** LOW
|
||||
**Impact:** Better onboarding, user adoption
|
||||
|
||||
**Gaps:**
|
||||
- No architecture documentation
|
||||
- Limited inline code comments
|
||||
- Missing troubleshooting guide
|
||||
- No contributor guidelines
|
||||
|
||||
**Action Items:**
|
||||
- [ ] Create architecture diagram
|
||||
- [ ] Document component hierarchy
|
||||
- [ ] Add troubleshooting section to README
|
||||
- [ ] Create CONTRIBUTING.md
|
||||
|
||||
---
|
||||
|
||||
### 11. CI/CD Pipeline Optimization
|
||||
**Severity:** LOW
|
||||
**Impact:** Faster feedback, automated releases
|
||||
|
||||
**Opportunities:**
|
||||
- Run tests in parallel
|
||||
- Cache npm dependencies
|
||||
- Add automated security scanning
|
||||
- Implement semantic versioning
|
||||
|
||||
**Action Items:**
|
||||
- [ ] Parallelize test execution
|
||||
- [ ] Add npm cache to GitHub Actions
|
||||
- [ ] Integrate Dependabot
|
||||
- [ ] Add semantic-release
|
||||
|
||||
---
|
||||
|
||||
## Summary & Prioritization
|
||||
|
||||
### Week 1 (Immediate)
|
||||
1. ✅ Fix TypeScript compilation errors
|
||||
2. ✅ Resolve production plugin loading issue
|
||||
3. ✅ Fix failing DashboardView test
|
||||
|
||||
### Week 2 (High Priority)
|
||||
4. Enhance type safety (strict mode)
|
||||
5. Implement security hardening
|
||||
6. Improve error handling and UX
|
||||
|
||||
### Week 3-4 (Medium Priority)
|
||||
7. Increase test coverage to >80%
|
||||
8. Optimize performance (memoization, caching)
|
||||
9. Refactor for maintainability
|
||||
|
||||
### Ongoing (Low Priority)
|
||||
10. Documentation improvements
|
||||
11. CI/CD optimizations
|
||||
|
||||
---
|
||||
|
||||
## Success Metrics
|
||||
|
||||
**Code Quality:**
|
||||
- ✅ Zero TypeScript errors
|
||||
- ✅ All tests passing
|
||||
- 🎯 Test coverage >80%
|
||||
- 🎯 No high/critical security vulnerabilities
|
||||
|
||||
**Production Readiness:**
|
||||
- ✅ Plugin loads successfully in Headlamp
|
||||
- ✅ All features functional
|
||||
- 🎯 Error rate <1%
|
||||
- 🎯 Average response time <500ms
|
||||
|
||||
**Developer Experience:**
|
||||
- ✅ Clear documentation
|
||||
- ✅ Easy local setup
|
||||
- 🎯 Fast CI/CD (<5 min)
|
||||
- 🎯 Automated releases
|
||||
|
||||
---
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. **Immediate:** Fix TypeScript errors and verify plugin loads
|
||||
2. **Short-term:** Complete Week 1-2 priorities
|
||||
3. **Long-term:** Address medium and low priority items
|
||||
4. **Continuous:** Monitor metrics and iterate
|
||||
|
||||
**Recommended First Action:**
|
||||
Fix the TypeScript compilation errors in `src/index.tsx` by updating the Headlamp plugin API calls.
|
||||
@@ -1,174 +1,34 @@
|
||||
# headlamp-polaris-plugin
|
||||
|
||||
[](https://artifacthub.io/packages/headlamp/polaris/headlamp-polaris-plugin)
|
||||
# polaris-headlamp-plugin
|
||||
|
||||
A [Headlamp](https://headlamp.dev/) plugin that surfaces [Fairwinds Polaris](https://polaris.docs.fairwinds.com/) audit results directly in the Headlamp UI.
|
||||
|
||||
## What It Does
|
||||
|
||||
Adds a **Polaris** top-level sidebar section to Headlamp with comprehensive security, reliability, and efficiency audit integration:
|
||||
Adds a **Polaris** sidebar entry to Headlamp that displays:
|
||||
|
||||
### Main Views
|
||||
- **Cluster Score** -- overall Polaris score as a percentage (color-coded green/amber/red)
|
||||
- **Check Summary** -- total, pass, warning, and danger counts across all workloads
|
||||
- **Cluster Info** -- node, pod, namespace, and controller counts
|
||||
|
||||
- **Overview Dashboard** -- cluster score with percentage gauge, check distribution charts, top 10 most common failing checks across the cluster, cluster statistics, and last audit time with manual refresh button
|
||||
- **Namespaces** -- table of all namespaces with per-namespace score and check counts; click a namespace to open a detailed side panel (1000px wide, theme-aware)
|
||||
- **Namespace Detail Panel** -- per-namespace score, check counts, resource-level audit results, external Polaris dashboard link, and exemption management
|
||||
Data is read from the `ConfigMap/polaris-dashboard` in the `polaris` namespace (key: `dashboard.json`), which is created by the standard Polaris Helm chart. The plugin is read-only -- it never writes to the cluster.
|
||||
|
||||
### Integrated Features
|
||||
Results are cached and refreshed on a user-configurable interval (1 / 5 / 10 / 30 minutes, default 5). The setting persists in the browser's localStorage.
|
||||
|
||||
- **App Bar Score Badge** -- cluster Polaris score displayed as a colored chip in the top navigation bar (green ≥80%, yellow ≥50%, red <50%); click to navigate to overview
|
||||
- **Inline Resource Audits** -- Polaris audit results automatically injected into detail views for Deployments, StatefulSets, DaemonSets, Jobs, and CronJobs; shows compact score, failing checks table, and link to full report
|
||||
- **Exemption Management** -- add or remove Polaris exemptions via annotation patches directly from the UI; supports per-check exemptions or exempt-all
|
||||
- **Configurable Dashboard URL** -- supports both Kubernetes service proxy URLs and full HTTP/HTTPS URLs for external Polaris deployments
|
||||
- **Connection Testing** -- test button in settings to verify Polaris dashboard connectivity and show version info
|
||||
|
||||
### Data & Refresh
|
||||
|
||||
Data is fetched from the Polaris dashboard API through the Kubernetes service proxy (`/api/v1/namespaces/polaris/services/polaris-dashboard:80/proxy/results.json`) or custom URLs. The plugin is primarily read-only; it only writes when explicitly applying exemption annotations.
|
||||
|
||||
Results are refreshed on a user-configurable interval (1 / 5 / 10 / 30 minutes, default 5). Settings are available in **Settings > Plugins > Polaris** and persist in browser localStorage.
|
||||
|
||||
Error states are handled explicitly with context-specific messages: RBAC denied (403), Polaris not installed (404/503), malformed JSON, network failures, and CORS issues.
|
||||
Error states are handled explicitly: RBAC denied (403), Polaris not installed (404), malformed JSON, and loading.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
| Requirement | Minimum version |
|
||||
|-------------|----------------|
|
||||
| Headlamp | v0.26+ |
|
||||
| Polaris (with dashboard enabled) | Any recent release |
|
||||
| Kubernetes | v1.24+ |
|
||||
|
||||
Polaris must be deployed in the `polaris` namespace with the dashboard component enabled (`dashboard.enabled: true` in the Helm chart, which is the default). The plugin reads from the `polaris-dashboard` ClusterIP service on port 80.
|
||||
|
||||
## Installing
|
||||
|
||||
### Option 1: Artifact Hub + Headlamp plugin manager (recommended)
|
||||
|
||||
The plugin is published on [Artifact Hub](https://artifacthub.io/packages/headlamp/polaris/headlamp-polaris-plugin). Configure Headlamp's `pluginsManager` in your Helm values to install it automatically:
|
||||
|
||||
```yaml
|
||||
pluginsManager:
|
||||
sources:
|
||||
- url: https://artifacthub.io/packages/headlamp/polaris/headlamp-polaris-plugin
|
||||
```
|
||||
|
||||
Headlamp will fetch and install the plugin on startup.
|
||||
|
||||
### Option 2: Docker init container
|
||||
|
||||
The plugin ships as a container image at `git.farh.net/farhoodliquor/headlamp-polaris-plugin`.
|
||||
|
||||
Add it as an init container in your Headlamp Helm values:
|
||||
|
||||
```yaml
|
||||
initContainers:
|
||||
- name: polaris-plugin
|
||||
image: git.farh.net/farhoodliquor/headlamp-polaris-plugin:latest
|
||||
command: ["sh", "-c", "cp -r /plugins/* /headlamp/plugins/"]
|
||||
volumeMounts:
|
||||
- name: plugins
|
||||
mountPath: /headlamp/plugins
|
||||
|
||||
volumes:
|
||||
- name: plugins
|
||||
emptyDir: {}
|
||||
|
||||
volumeMounts:
|
||||
- name: plugins
|
||||
mountPath: /headlamp/plugins
|
||||
```
|
||||
|
||||
### Option 3: Manual tarball install
|
||||
|
||||
Download the `.tar.gz` from the [GitHub releases page](https://github.com/cpfarhood/headlamp-polaris-plugin/releases) or the [Gitea releases page](https://git.farh.net/farhoodliquor/headlamp-polaris-plugin/releases), then extract into Headlamp's plugin directory:
|
||||
|
||||
```bash
|
||||
tar xzf headlamp-polaris-plugin-<version>.tar.gz -C /headlamp/plugins/
|
||||
```
|
||||
|
||||
### Option 4: Build from source
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run build
|
||||
npx @kinvolk/headlamp-plugin extract . /headlamp/plugins
|
||||
```
|
||||
|
||||
## Installing Dev/Preview Versions
|
||||
|
||||
Dev preview versions are **not currently available** through the Headlamp plugin manager. Stable versions can be installed from ArtifactHub via the plugin manager UI.
|
||||
|
||||
## RBAC / Security Setup
|
||||
|
||||
The plugin fetches audit data through the Kubernetes API server's **service proxy** sub-resource. The identity making the request (Headlamp's service account, or the user's own token in token-auth mode) must be granted:
|
||||
|
||||
| Verb | API Group | Resource | Resource Name | Namespace |
|
||||
|------|-----------|----------|---------------|-----------|
|
||||
| `get` | `""` (core) | `services/proxy` | `polaris-dashboard` | `polaris` |
|
||||
|
||||
### Minimal RBAC manifests
|
||||
|
||||
```yaml
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: Role
|
||||
metadata:
|
||||
name: polaris-proxy-reader
|
||||
namespace: polaris
|
||||
rules:
|
||||
- apiGroups: [""]
|
||||
resources: ["services/proxy"]
|
||||
resourceNames: ["polaris-dashboard"]
|
||||
verbs: ["get"]
|
||||
---
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: RoleBinding
|
||||
metadata:
|
||||
name: headlamp-polaris-proxy
|
||||
namespace: polaris
|
||||
subjects:
|
||||
- kind: ServiceAccount
|
||||
name: headlamp # adjust to match your Headlamp service account
|
||||
namespace: kube-system # adjust to match the namespace Headlamp runs in
|
||||
roleRef:
|
||||
kind: Role
|
||||
name: polaris-proxy-reader
|
||||
apiGroup: rbac.authorization.k8s.io
|
||||
```
|
||||
|
||||
Apply with `kubectl apply -f polaris-rbac.yaml`.
|
||||
|
||||
### Token-auth mode
|
||||
|
||||
When Headlamp is configured for user-supplied tokens (rather than a fixed service account), **each user** must have the RoleBinding above attached to their own identity. A 403 error in the plugin means the currently logged-in user lacks this binding.
|
||||
|
||||
### NetworkPolicy
|
||||
|
||||
If the `polaris` namespace enforces network policies, ensure ingress is allowed from the Kubernetes API server (which performs the proxy hop) to `polaris-dashboard` on port 80.
|
||||
|
||||
### Read-only access
|
||||
|
||||
The plugin only performs `GET` requests through the service proxy. No `create`, `update`, `delete`, or `patch` verbs are required. Do not grant broader access than `get` on `services/proxy`.
|
||||
|
||||
### Audit logging
|
||||
|
||||
Every proxied request is recorded in Kubernetes API audit logs as a `get` on `services/proxy` in the `polaris` namespace. If the auto-refresh interval generates more audit volume than desired, increase the refresh interval in the plugin settings or adjust your audit policy.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Symptom | Likely cause | Fix |
|
||||
|---------|-------------|-----|
|
||||
| **403 Access Denied** | Missing RBAC binding for `services/proxy` | Apply the Role + RoleBinding from the RBAC section above |
|
||||
| **404 or 503** | Polaris not installed, or dashboard disabled | Install Polaris with `dashboard.enabled: true` in the `polaris` namespace |
|
||||
| **No data** | Polaris running but no workloads scanned yet | Wait for the next Polaris audit cycle or restart the Polaris pod |
|
||||
| **Stale data** | Refresh interval too long | Lower the interval in **Settings > Plugins > Polaris** |
|
||||
- **Headlamp** >= v0.26 deployed in your cluster
|
||||
- **Polaris** installed via the [official Helm chart](https://github.com/FairwindsOps/polaris) with the dashboard component enabled
|
||||
- The Headlamp service account must have RBAC permission to `get` ConfigMaps in the `polaris` namespace
|
||||
|
||||
## Development
|
||||
|
||||
### Setup
|
||||
|
||||
```bash
|
||||
git clone https://github.com/cpfarhood/headlamp-polaris-plugin.git
|
||||
cd headlamp-polaris-plugin
|
||||
git clone https://git.farh.net/farhoodliquor/polaris-headlamp-plugin.git
|
||||
cd polaris-headlamp-plugin
|
||||
npm install
|
||||
```
|
||||
|
||||
@@ -184,48 +44,140 @@ This starts the Headlamp plugin dev server. Point a running Headlamp instance at
|
||||
|
||||
```bash
|
||||
npm run build # outputs dist/main.js
|
||||
npm run package # creates headlamp-polaris-plugin-<version>.tar.gz
|
||||
npm run package # creates polaris-headlamp-plugin-<version>.tar.gz
|
||||
```
|
||||
|
||||
### Type-check, lint, format, and test
|
||||
### Type-check
|
||||
|
||||
```bash
|
||||
npm run tsc # type-check without emitting
|
||||
npm run lint # eslint
|
||||
npm run format:check # prettier check
|
||||
npm test # vitest unit tests
|
||||
npm run tsc
|
||||
```
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
src/
|
||||
index.tsx -- Entry point. Registers sidebar entries and routes.
|
||||
index.tsx -- Entry point. Registers sidebar entry and route at /polaris.
|
||||
api/
|
||||
polaris.ts -- TypeScript types (AuditData schema), usePolarisData hook,
|
||||
countResults utilities, refresh interval settings.
|
||||
polaris.test.ts -- Unit tests for utility functions (vitest).
|
||||
PolarisDataContext.tsx -- React context provider; shared data fetch across views.
|
||||
polaris.ts -- TypeScript types matching the Polaris AuditData schema,
|
||||
usePolarisData() hook with caching, countResults() utility,
|
||||
and refresh interval settings (localStorage).
|
||||
components/
|
||||
DashboardView.tsx -- Overview page (score, check summary with skipped, cluster info).
|
||||
NamespacesListView.tsx -- Namespace list with scores and links to detail views.
|
||||
NamespaceDetailView.tsx -- Per-namespace drill-down with resource table.
|
||||
PolarisSettings.tsx -- Plugin settings page (refresh interval selector).
|
||||
vitest.config.mts -- Vitest configuration (jsdom environment).
|
||||
PolarisView.tsx -- Main page component. Score badge, check summary cards,
|
||||
cluster info, error states, refresh interval selector.
|
||||
```
|
||||
|
||||
## Deploying to Headlamp
|
||||
|
||||
### Option 1: Docker init container (recommended for Kubernetes)
|
||||
|
||||
The plugin ships as a container image at `git.farh.net/farhoodliquor/polaris-headlamp-plugin`.
|
||||
|
||||
Add it as an init container in your Headlamp Helm values:
|
||||
|
||||
```yaml
|
||||
initContainers:
|
||||
- name: polaris-plugin
|
||||
image: git.farh.net/farhoodliquor/polaris-headlamp-plugin:v0.0.1
|
||||
command: ["sh", "-c", "cp -r /plugins/* /headlamp/plugins/"]
|
||||
volumeMounts:
|
||||
- name: plugins
|
||||
mountPath: /headlamp/plugins
|
||||
|
||||
volumes:
|
||||
- name: plugins
|
||||
emptyDir: {}
|
||||
|
||||
volumeMounts:
|
||||
- name: plugins
|
||||
mountPath: /headlamp/plugins
|
||||
```
|
||||
|
||||
### Option 2: Manual tarball install
|
||||
|
||||
Download the `.tar.gz` from the [releases page](https://git.farh.net/farhoodliquor/polaris-headlamp-plugin/releases), then extract into Headlamp's plugin directory:
|
||||
|
||||
```bash
|
||||
tar xzf polaris-headlamp-plugin-0.0.1.tar.gz -C /headlamp/plugins/
|
||||
```
|
||||
|
||||
### Option 3: Build from source
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run build
|
||||
npx @kinvolk/headlamp-plugin extract . /headlamp/plugins
|
||||
```
|
||||
|
||||
## Releasing
|
||||
|
||||
Releases are automated via Gitea Actions. To cut a release:
|
||||
|
||||
```bash
|
||||
# Bump version in package.json, then:
|
||||
git add package.json package-lock.json
|
||||
git commit -m "chore: bump version to 0.0.2"
|
||||
git tag v0.0.2
|
||||
git push origin main v0.0.2
|
||||
```
|
||||
|
||||
The CI pipeline (`.gitea/workflows/release.yaml`) will:
|
||||
|
||||
1. Build the plugin in a `node:20` container
|
||||
2. Package a `.tar.gz` tarball
|
||||
3. Build and push a Docker image to `git.farh.net/farhoodliquor/polaris-headlamp-plugin:{tag}` and `:latest`
|
||||
4. Create a Gitea release with the tarball attached
|
||||
|
||||
### CI secrets
|
||||
|
||||
| Secret | Purpose |
|
||||
|---|---|
|
||||
| `REGISTRY_TOKEN` | Gitea personal access token with `package:write` scope, used to push Docker images to the container registry |
|
||||
|
||||
The release creation itself uses the built-in `github.token` -- no extra secret needed for that.
|
||||
|
||||
## RBAC
|
||||
|
||||
The plugin reads a single ConfigMap. Minimum RBAC required for the Headlamp service account:
|
||||
|
||||
```yaml
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: ClusterRole
|
||||
metadata:
|
||||
name: headlamp-polaris-reader
|
||||
rules:
|
||||
- apiGroups: [""]
|
||||
resources: ["configmaps"]
|
||||
resourceNames: ["polaris-dashboard"]
|
||||
verbs: ["get"]
|
||||
---
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: ClusterRoleBinding
|
||||
metadata:
|
||||
name: headlamp-polaris-reader
|
||||
roleRef:
|
||||
apiGroup: rbac.authorization.k8s.io
|
||||
kind: ClusterRole
|
||||
name: headlamp-polaris-reader
|
||||
subjects:
|
||||
- kind: ServiceAccount
|
||||
name: headlamp
|
||||
namespace: headlamp
|
||||
```
|
||||
|
||||
## Data Source
|
||||
|
||||
The plugin fetches live audit results from the Polaris dashboard HTTP API via the Kubernetes service proxy:
|
||||
The plugin reads from:
|
||||
|
||||
```
|
||||
GET /api/v1/namespaces/polaris/services/polaris-dashboard/proxy/results.json
|
||||
```
|
||||
- **ConfigMap**: `polaris-dashboard`
|
||||
- **Namespace**: `polaris`
|
||||
- **Key**: `dashboard.json`
|
||||
|
||||
This endpoint is served by the `polaris-dashboard` ClusterIP service, which is created by the Polaris Helm chart when `dashboard.enabled: true`. The JSON response matches Polaris's `AuditData` schema (`pkg/validator/output.go`):
|
||||
This ConfigMap is created automatically when Polaris is installed with the dashboard enabled. The JSON structure matches Polaris's `AuditData` schema (`pkg/validator/output.go`):
|
||||
|
||||
```
|
||||
AuditData
|
||||
Score -- cluster score (0-100)
|
||||
ClusterInfo -- nodes, pods, namespaces, controllers
|
||||
Results[] -- per-workload results
|
||||
Results{} -- top-level check results (ResultSet)
|
||||
@@ -235,66 +187,7 @@ AuditData
|
||||
Results{} -- container-level check results
|
||||
```
|
||||
|
||||
Each check in a `ResultSet` has `Success` (bool) and `Severity` (`"warning"`, `"danger"`, or `"ignore"`). Checks with `Severity: "ignore"` and `Success: false` are counted as skipped. The cluster score is computed client-side as `pass / total * 100`.
|
||||
|
||||
## Known Limitations
|
||||
|
||||
### Skipped Count and Annotation-Based Exemptions
|
||||
|
||||
The **Skipped** count shown in the plugin only reflects checks with `Severity: "ignore"` in the Polaris API response. It does **not** include annotation-based exemptions (e.g., `polaris.fairwinds.com/privilegeEscalationAllowed-exempt: "true"`).
|
||||
|
||||
**Why?** Polaris completely omits exempted checks from the `results.json` endpoint. The native Polaris dashboard UI computes the "skipped" count client-side by:
|
||||
1. Querying Kubernetes resources (Deployments, DaemonSets, StatefulSets, Pods) directly
|
||||
2. Parsing their annotations for `polaris.fairwinds.com/*-exempt` keys
|
||||
3. Counting how many checks were exempted
|
||||
|
||||
This plugin only has access to the processed audit results via the service proxy and does not query raw Kubernetes resources. To show accurate exemption counts, the plugin would need to:
|
||||
- Request cluster-wide read access to all workload types (requires additional RBAC grants beyond `services/proxy`)
|
||||
- Parse annotations on every workload in every namespace
|
||||
- Cross-reference with the Polaris check catalog to count exemptions
|
||||
|
||||
This is a significant architectural change and is not currently implemented. Hover over the "Skipped" count in the UI to see a tooltip explaining this limitation.
|
||||
|
||||
**Workaround:** Use the "View in Polaris Dashboard" link from any namespace detail view to see the full exemption count in the native dashboard.
|
||||
|
||||
## Releasing
|
||||
|
||||
Releases are automated via CI. To cut a release:
|
||||
|
||||
```bash
|
||||
# Bump version in package.json and artifacthub-pkg.yml (version + archive-url), then:
|
||||
git add package.json artifacthub-pkg.yml
|
||||
git commit -m "chore: bump version to X.Y.Z"
|
||||
git tag vX.Y.Z
|
||||
git push origin main vX.Y.Z
|
||||
```
|
||||
|
||||
This triggers the **Gitea Actions** release workflow (`.gitea/workflows/release.yaml`):
|
||||
1. Build the plugin in a `node:20` container
|
||||
2. Package a `.tar.gz` tarball
|
||||
3. Build and push a Docker image to `git.farh.net/farhoodliquor/headlamp-polaris-plugin:{tag}` and `:latest`
|
||||
4. Create a Gitea release with the tarball attached
|
||||
5. Create a GitHub release with the same tarball (for Artifact Hub)
|
||||
6. Update `artifacthub-pkg.yml` checksum on main and force-move the tag to match
|
||||
|
||||
A guard step prevents infinite loops: if the release tarball checksum already matches the metadata, the build is skipped.
|
||||
|
||||
### CI secrets
|
||||
|
||||
| Secret | Where | Purpose |
|
||||
|---|---|---|
|
||||
| `REGISTRY_TOKEN` | Gitea | Personal access token with `package:write` scope for Docker image push |
|
||||
| `GH_PAT` | Gitea | GitHub personal access token for creating GitHub releases |
|
||||
|
||||
The Gitea release uses the built-in `github.token`. The `archive-checksum` in `artifacthub-pkg.yml` is updated automatically by the release workflow.
|
||||
|
||||
## Links
|
||||
|
||||
- [Artifact Hub](https://artifacthub.io/packages/headlamp/polaris/headlamp-polaris-plugin)
|
||||
- [GitHub (mirror)](https://github.com/cpfarhood/headlamp-polaris-plugin)
|
||||
- [Gitea (source of truth)](https://git.farh.net/farhoodliquor/headlamp-polaris-plugin)
|
||||
- [Headlamp](https://headlamp.dev/)
|
||||
- [Fairwinds Polaris](https://polaris.docs.fairwinds.com/)
|
||||
Each check in a `ResultSet` has `Success` (bool) and `Severity` (`"warning"` or `"danger"`).
|
||||
|
||||
## License
|
||||
|
||||
|
||||
+8
-14
@@ -1,16 +1,10 @@
|
||||
version: 0.3.3
|
||||
name: headlamp-polaris-plugin
|
||||
version: 0.0.1
|
||||
name: polaris-headlamp-plugin
|
||||
displayName: Polaris
|
||||
createdAt: "2026-02-05T19:00:00Z"
|
||||
description: >-
|
||||
Surfaces Fairwinds Polaris audit results inside the Headlamp UI.
|
||||
Shows cluster score, check summary, and per-namespace drill-downs
|
||||
with per-resource pass/warning/danger breakdowns. Data is fetched
|
||||
read-only via the Kubernetes service proxy to the Polaris dashboard.
|
||||
Requires a Role granting `get` on `services/proxy` for the
|
||||
`polaris-dashboard` service in the `polaris` namespace.
|
||||
description: Surfaces Fairwinds Polaris audit results inside the Headlamp UI.
|
||||
license: MIT
|
||||
homeURL: "https://github.com/cpfarhood/headlamp-polaris-plugin"
|
||||
homeURL: "https://github.com/cpfarhood/polaris-headlamp-plugin"
|
||||
category: security
|
||||
keywords:
|
||||
- polaris
|
||||
@@ -21,14 +15,14 @@ keywords:
|
||||
- kubernetes
|
||||
links:
|
||||
- name: Source
|
||||
url: "https://github.com/cpfarhood/headlamp-polaris-plugin"
|
||||
url: "https://github.com/cpfarhood/polaris-headlamp-plugin"
|
||||
- name: Polaris
|
||||
url: "https://polaris.docs.fairwinds.com/"
|
||||
maintainers:
|
||||
- name: cpfarhood
|
||||
email: "chris@farhood.org"
|
||||
email: ""
|
||||
annotations:
|
||||
headlamp/plugin/archive-url: "https://github.com/cpfarhood/headlamp-polaris-plugin/releases/download/v0.3.3/headlamp-polaris-plugin-0.3.3.tar.gz"
|
||||
headlamp/plugin/archive-url: "https://github.com/cpfarhood/polaris-headlamp-plugin/releases/download/v0.0.1/polaris-headlamp-plugin-0.0.1.tar.gz"
|
||||
headlamp/plugin/version-compat: ">=0.26"
|
||||
headlamp/plugin/archive-checksum: sha256:b18a94682d991e4d8b5297ff58c5e56d86f93f0657a9485297c1b62a0504ff57
|
||||
headlamp/plugin/archive-checksum: sha256:456f09cf8b126816b80c723b6c6f300b2af0c2e1288ee67da13f435b0e35c04d
|
||||
headlamp/plugin/distro-compat: in-cluster
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
repositoryID: fc3397f6-a75a-4950-ab50-da75c08a8089
|
||||
repositoryID: polaris-headlamp-plugin
|
||||
owners:
|
||||
- name: cpfarhood
|
||||
email: "chris@farhood.org"
|
||||
email: ""
|
||||
|
||||
@@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||
|
||||
## Project Overview
|
||||
|
||||
Headlamp plugin that surfaces Fairwinds Polaris audit results inside the Headlamp UI. Queries the Polaris dashboard API via the Kubernetes service proxy (`/api/v1/namespaces/polaris/services/polaris-dashboard/proxy/results.json`). Target Headlamp ≥ v0.26.
|
||||
Headlamp plugin that surfaces Fairwinds Polaris audit results inside the Headlamp UI. Reads from `ConfigMap/polaris-dashboard` in the `polaris` namespace (key: `dashboard.json`). Target Headlamp ≥ v0.26.
|
||||
|
||||
## Build & Development Commands
|
||||
|
||||
@@ -23,79 +23,24 @@ npx tsc --noEmit
|
||||
|
||||
# Lint
|
||||
npx eslint src/
|
||||
|
||||
# Run tests
|
||||
npm test
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
src/
|
||||
├── index.tsx # Entry point: registers sidebar entries + routes
|
||||
├── index.tsx # Entry point: registerSidebarEntry + registerRoute for /polaris
|
||||
├── api/
|
||||
│ ├── polaris.ts # Types (AuditData schema), usePolarisData hook, countResults utilities, refresh settings
|
||||
│ ├── polaris.test.ts # Unit tests for utility functions (vitest)
|
||||
│ └── PolarisDataContext.tsx # React context provider for shared data fetch
|
||||
│ └── polaris.ts # Types (AuditData schema), usePolarisData hook, countResults utility, refresh settings
|
||||
└── components/
|
||||
├── DashboardView.tsx # Overview page (score, check summary with skipped count, cluster info)
|
||||
├── NamespacesListView.tsx # Namespace list with scores and links to detail views
|
||||
├── NamespaceDetailView.tsx # Per-namespace drill-down with resource table
|
||||
└── PolarisSettings.tsx # Plugin settings (refresh interval selector)
|
||||
└── PolarisView.tsx # Main page: score badge, check summary, cluster info, error states, refresh interval selector
|
||||
```
|
||||
|
||||
Top-level sidebar section at `/polaris` with sub-routes for namespaces list (`/polaris/namespaces`) and per-namespace views (`/polaris/ns/:namespace`). Data is fetched via `ApiProxy.request` to the Polaris dashboard service proxy and refreshed on a user-configurable interval (stored in localStorage under `polaris-plugin-refresh-interval`, default 5 minutes). Score is computed from result counts (pass/total). Skipped checks are always displayed in summaries.
|
||||
|
||||
**Sidebar limitation**: Headlamp's sidebar only supports 2-level nesting (parent → children). The `Collapse` component is driven by route-based selection, not click-to-toggle, so 3-level hierarchies don't expand properly. Namespace navigation is handled via the in-content table on the Namespaces page instead.
|
||||
|
||||
## Security / RBAC Requirements
|
||||
|
||||
The plugin reaches Polaris through the Kubernetes API server's service proxy sub-resource (`/api/v1/namespaces/polaris/services/polaris-dashboard/proxy/...`). The Headlamp service account (or the user's bearer token when Headlamp runs in token-auth mode) must be granted:
|
||||
|
||||
| Verb | API Group | Resource | Resource Name | Namespace |
|
||||
|------|-----------|----------|---------------|-----------|
|
||||
| `get` | `""` (core) | `services/proxy` | `polaris-dashboard` | `polaris` |
|
||||
|
||||
Minimal RBAC example:
|
||||
|
||||
```yaml
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: Role
|
||||
metadata:
|
||||
name: polaris-proxy-reader
|
||||
namespace: polaris
|
||||
rules:
|
||||
- apiGroups: [""]
|
||||
resources: ["services/proxy"]
|
||||
resourceNames: ["polaris-dashboard"]
|
||||
verbs: ["get"]
|
||||
---
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: RoleBinding
|
||||
metadata:
|
||||
name: headlamp-polaris-proxy
|
||||
namespace: polaris
|
||||
subjects:
|
||||
- kind: ServiceAccount
|
||||
name: headlamp # adjust to match your Headlamp SA
|
||||
namespace: kube-system
|
||||
roleRef:
|
||||
kind: Role
|
||||
name: polaris-proxy-reader
|
||||
apiGroup: rbac.authorization.k8s.io
|
||||
```
|
||||
|
||||
Additional considerations:
|
||||
|
||||
- **NetworkPolicy**: If the `polaris` namespace enforces network policies, allow ingress from the Headlamp pod (or the API server, since it performs the proxy hop) to `polaris-dashboard` on port 80.
|
||||
- **Polaris dashboard listen address**: The Polaris Helm chart exposes the dashboard on a ClusterIP service (`polaris-dashboard:80`). If the chart is installed with `dashboard.enabled: false`, the service will not be created, resulting in a 404 error for proxy requests.
|
||||
- **No write operations**: The plugin only performs `GET` requests through the proxy. No `create`, `update`, or `delete` verbs are required. Do not grant broader service proxy access than `get`.
|
||||
- **Token-auth mode**: When Headlamp is configured for user-supplied tokens (rather than a fixed service account), each user's own RBAC bindings must include the role above. A 403 from the plugin means the logged-in user lacks the binding.
|
||||
- **Audit logging**: Kubernetes API audit logs will record every proxied request as a `get` on `services/proxy` in the `polaris` namespace. Set an appropriate audit policy level if request volume from the auto-refresh interval is a concern.
|
||||
Single sidebar page at `/polaris`. Data is cached in React state and refreshed on a user-configurable interval (stored in localStorage under `polaris-plugin-refresh-interval`, default 5 minutes). The `usePolarisData` hook wraps `ConfigMap.useGet` with caching so stale data is shown while refreshing.
|
||||
|
||||
## Key Constraints
|
||||
|
||||
- **Data source**: Polaris dashboard API via K8s service proxy. Requires Polaris deployed in the `polaris` namespace with a `polaris-dashboard` service. No CRDs, no cluster write operations.
|
||||
- **Data source**: `ConfigMap/polaris-dashboard` in `polaris` namespace, key `dashboard.json`. No CRDs, no external API calls, no cluster write operations.
|
||||
- **UI components**: Use only Headlamp-provided components (`@kinvolk/headlamp-plugin/lib/CommonComponents`). Do not import raw MUI packages. No custom theming.
|
||||
- **Error handling**: Must handle 403 (RBAC denied), 404 (Polaris not installed), malformed JSON, and loading states with distinct visual states.
|
||||
- **TypeScript strictness**: No `any`, no implicit `unknown` casting, no dead code, no unused imports.
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
# Headlamp Plugin Loading Issue - Root Cause and Fix
|
||||
|
||||
## Problem
|
||||
Headlamp v0.39.0 was not loading plugins installed via the plugin manager. Plugins appeared in Settings → Plugins but:
|
||||
- No sidebar entries appeared
|
||||
- No plugin settings were available
|
||||
- Plugin JavaScript was not being executed in the browser
|
||||
|
||||
## Root Cause
|
||||
When `config.watchPlugins: true` (the default), Headlamp treats catalog-managed plugins in `/headlamp/plugins/` as "development directory" plugins. This causes:
|
||||
- Backend serves plugin metadata correctly
|
||||
- Backend logs show "Treating catalog-installed plugin in development directory as user plugin"
|
||||
- **Frontend does NOT execute the plugin JavaScript**
|
||||
- Plugin registrations (`registerSidebarEntry`, `registerRoute`, etc.) never happen
|
||||
|
||||
## Solution
|
||||
Set `config.watchPlugins: false` in the Headlamp HelmRelease values:
|
||||
|
||||
```yaml
|
||||
spec:
|
||||
values:
|
||||
config:
|
||||
watchPlugins: false
|
||||
pluginsManager:
|
||||
enabled: true
|
||||
configContent: |
|
||||
plugins:
|
||||
- name: polaris
|
||||
source: https://artifacthub.io/packages/headlamp/polaris/headlamp-polaris-plugin
|
||||
# ... other plugins
|
||||
```
|
||||
|
||||
## Why This Works
|
||||
With `watchPlugins: false`:
|
||||
- Headlamp no longer treats catalog-managed plugins as "development" plugins
|
||||
- Frontend properly loads and executes plugin JavaScript on startup
|
||||
- Plugin registrations happen correctly
|
||||
- All plugin features (sidebar, routes, settings, etc.) work as expected
|
||||
|
||||
## Testing
|
||||
After applying this fix:
|
||||
1. Verify plugins are installed: `kubectl logs -n kube-system <headlamp-pod> -c headlamp-plugin`
|
||||
2. Verify watchPlugins is false: `kubectl logs -n kube-system <headlamp-pod> -c headlamp | grep "Watch Plugins"`
|
||||
3. Hard refresh browser (Cmd+Shift+R / Ctrl+Shift+F5) to clear cached JavaScript
|
||||
4. Verify plugin sidebar entries appear
|
||||
5. Verify plugin functionality works
|
||||
|
||||
## Additional Notes
|
||||
- This appears to be a bug/limitation in Headlamp v0.39.0
|
||||
- The `watchPlugins` feature is intended for development scenarios where plugins are being actively modified
|
||||
- For production deployments with catalog-managed plugins, `watchPlugins: false` is the correct configuration
|
||||
- Once plugins are loaded, subsequent restarts or updates work correctly as long as `watchPlugins` remains false
|
||||
|
||||
## References
|
||||
- Headlamp Helm Chart: https://github.com/headlamp-k8s/headlamp/tree/main/charts/headlamp
|
||||
- Plugin Manager: https://github.com/headlamp-k8s/headlamp/tree/main/plugins/headlamp-plugin
|
||||
- Issue discovered: 2026-02-11
|
||||
- Fix applied: 2026-02-12
|
||||
@@ -1,83 +0,0 @@
|
||||
---
|
||||
# Custom Headlamp values for static plugin installation
|
||||
# This disables the plugin manager and uses an init container instead
|
||||
|
||||
# Disable the plugin manager sidecar
|
||||
pluginsManager:
|
||||
enabled: false
|
||||
|
||||
# Use an init container to install plugins to /headlamp/static-plugins
|
||||
initContainers:
|
||||
- name: install-plugins
|
||||
image: node:lts-alpine
|
||||
command:
|
||||
- /bin/sh
|
||||
- -c
|
||||
- |
|
||||
set -e
|
||||
echo "Installing plugins to /headlamp/static-plugins..."
|
||||
|
||||
# Create plugins directory
|
||||
mkdir -p /headlamp/static-plugins
|
||||
|
||||
# Set up npm cache
|
||||
export NPM_CONFIG_CACHE=/tmp/npm-cache
|
||||
export NPM_CONFIG_USERCONFIG=/tmp/npm-userconfig
|
||||
mkdir -p /tmp/npm-cache /tmp/npm-userconfig
|
||||
|
||||
# Install polaris plugin
|
||||
echo "Installing polaris plugin..."
|
||||
cd /headlamp/static-plugins
|
||||
npm pack headlamp-polaris-plugin@0.3.0
|
||||
tar -xzf headlamp-polaris-plugin-0.3.0.tgz
|
||||
mv package headlamp-polaris-plugin
|
||||
rm headlamp-polaris-plugin-0.3.0.tgz
|
||||
|
||||
# Install other plugins
|
||||
npx --yes @headlamp-k8s/plugin@latest install \
|
||||
--source https://artifacthub.io/packages/headlamp/headlamp-plugins/headlamp_flux \
|
||||
--folderName /headlamp/static-plugins
|
||||
|
||||
npx --yes @headlamp-k8s/plugin@latest install \
|
||||
--source https://artifacthub.io/packages/headlamp/headlamp-trivy/headlamp_trivy \
|
||||
--folderName /headlamp/static-plugins
|
||||
|
||||
npx --yes @headlamp-k8s/plugin@latest install \
|
||||
--source https://artifacthub.io/packages/headlamp/headlamp-plugins/headlamp_cert-manager \
|
||||
--folderName /headlamp/static-plugins
|
||||
|
||||
npx --yes @headlamp-k8s/plugin@latest install \
|
||||
--source https://artifacthub.io/packages/headlamp/headlamp-plugins/headlamp_ai_assistant \
|
||||
--folderName /headlamp/static-plugins
|
||||
|
||||
echo "All plugins installed successfully"
|
||||
ls -la /headlamp/static-plugins
|
||||
securityContext:
|
||||
runAsUser: 100
|
||||
runAsGroup: 101
|
||||
runAsNonRoot: true
|
||||
privileged: false
|
||||
resources:
|
||||
requests:
|
||||
cpu: 100m
|
||||
memory: 256Mi
|
||||
limits:
|
||||
memory: 512Mi
|
||||
volumeMounts:
|
||||
- name: static-plugins
|
||||
mountPath: /headlamp/static-plugins
|
||||
|
||||
# Configure headlamp to use static plugins
|
||||
config:
|
||||
pluginsDir: /headlamp/static-plugins
|
||||
|
||||
# Add volume for static plugins
|
||||
volumes:
|
||||
- name: static-plugins
|
||||
emptyDir: {}
|
||||
|
||||
# Add volume mount to main container
|
||||
volumeMounts:
|
||||
- name: static-plugins
|
||||
mountPath: /headlamp/static-plugins
|
||||
readOnly: true
|
||||
@@ -1,58 +0,0 @@
|
||||
# E2E Smoke Tests
|
||||
|
||||
Playwright-based smoke tests that validate the Polaris plugin against a live Headlamp deployment.
|
||||
|
||||
## CI
|
||||
|
||||
E2E tests run automatically in Gitea Actions on pushes to `main` and pull requests. The workflow (`.gitea/workflows/e2e.yaml`) uses Authentik OIDC for authentication via repo secrets.
|
||||
|
||||
### Required Gitea secrets
|
||||
|
||||
| Secret | Description |
|
||||
| -------------------- | -------------------------------------------------------------- |
|
||||
| `AUTHENTIK_USERNAME` | Authentik email or username for a CI user with Headlamp access |
|
||||
| `AUTHENTIK_PASSWORD` | Password for that user |
|
||||
|
||||
## Running Locally
|
||||
|
||||
### Option 1: OIDC via Authentik (same as CI)
|
||||
|
||||
```bash
|
||||
AUTHENTIK_USERNAME=you@example.com AUTHENTIK_PASSWORD=... npm run e2e
|
||||
```
|
||||
|
||||
The default base URL is `https://headlamp.animaniacs.farh.net`. Override with `HEADLAMP_URL` if needed.
|
||||
|
||||
### Option 2: K8s bearer token (port-forward)
|
||||
|
||||
```bash
|
||||
kubectl port-forward -n kube-system svc/headlamp 4466:80
|
||||
export HEADLAMP_TOKEN=$(kubectl create token headlamp -n kube-system)
|
||||
HEADLAMP_URL=http://localhost:4466 npm run e2e
|
||||
```
|
||||
|
||||
Or in headed mode (opens a browser window):
|
||||
|
||||
```bash
|
||||
HEADLAMP_URL=http://localhost:4466 npm run e2e:headed
|
||||
```
|
||||
|
||||
## Environment Variables
|
||||
|
||||
| Variable | Required | Default | Description |
|
||||
| -------------------- | -------- | -------------------------------------- | --------------------------------------- |
|
||||
| `HEADLAMP_URL` | No | `https://headlamp.animaniacs.farh.net` | Base URL of the Headlamp instance |
|
||||
| `AUTHENTIK_USERNAME` | OIDC | — | Authentik email/username |
|
||||
| `AUTHENTIK_PASSWORD` | OIDC | — | Authentik password |
|
||||
| `HEADLAMP_TOKEN` | Token | — | Kubernetes bearer token (fallback auth) |
|
||||
|
||||
Set either `AUTHENTIK_USERNAME` + `AUTHENTIK_PASSWORD` or `HEADLAMP_TOKEN`. OIDC takes priority if both are set.
|
||||
|
||||
## What the Tests Validate
|
||||
|
||||
- **Sidebar entry** — The Polaris sidebar item appears after login
|
||||
- **Overview page** — Cluster score and check distribution render correctly
|
||||
- **Namespaces page** — Table of namespaces loads with clickable links
|
||||
- **Namespace detail** — Clicking a namespace shows its score and resource table
|
||||
|
||||
These are smoke tests against real cluster data. They verify the plugin loads and renders without errors, not specific data values.
|
||||
@@ -1,67 +0,0 @@
|
||||
import { test as setup, expect, Page } from '@playwright/test';
|
||||
|
||||
const AUTH_STATE_PATH = 'e2e/.auth/state.json';
|
||||
|
||||
async function authenticateWithOIDC(page: Page, username: string, password: string): Promise<void> {
|
||||
// Navigate to login — Headlamp redirects / to /c/main/login
|
||||
await page.goto('/');
|
||||
await page.waitForURL('**/login');
|
||||
|
||||
// Click "Sign In" and capture the Authentik popup
|
||||
const popupPromise = page.waitForEvent('popup');
|
||||
await page.getByRole('button', { name: /sign in/i }).click();
|
||||
const popup = await popupPromise;
|
||||
|
||||
// Authentik step 1: fill username
|
||||
await popup.getByRole('textbox', { name: /email or username/i }).fill(username);
|
||||
await popup.getByRole('button', { name: /log in/i }).click();
|
||||
|
||||
// Authentik step 2: fill password
|
||||
await popup.getByRole('textbox', { name: /password/i }).fill(password);
|
||||
await popup.getByRole('button', { name: /continue|log in/i }).click();
|
||||
|
||||
// Wait for the popup to close (Authentik redirects back, Headlamp processes callback)
|
||||
await popup.waitForEvent('close', { timeout: 15_000 });
|
||||
|
||||
// Original page should now be authenticated — wait for sidebar
|
||||
await expect(page.getByRole('navigation', { name: 'Navigation' })).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
}
|
||||
|
||||
async function authenticateWithToken(page: Page, token: string): Promise<void> {
|
||||
// Navigate to login — Headlamp redirects / to /c/main/login
|
||||
await page.goto('/');
|
||||
await page.waitForURL('**/login');
|
||||
|
||||
// Click the token auth option
|
||||
await page.getByRole('button', { name: /use a token/i }).click();
|
||||
await page.waitForURL('**/token');
|
||||
|
||||
// Fill the "ID token" field and submit
|
||||
await page.getByRole('textbox', { name: /id token/i }).fill(token);
|
||||
await page.getByRole('button', { name: /authenticate/i }).click();
|
||||
|
||||
// Wait for the main UI to load
|
||||
await expect(page.getByRole('navigation', { name: 'Navigation' })).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
}
|
||||
|
||||
setup('authenticate with Headlamp', async ({ page }) => {
|
||||
const username = process.env.AUTHENTIK_USERNAME;
|
||||
const password = process.env.AUTHENTIK_PASSWORD;
|
||||
const token = process.env.HEADLAMP_TOKEN;
|
||||
|
||||
if (username && password) {
|
||||
await authenticateWithOIDC(page, username, password);
|
||||
} else if (token) {
|
||||
await authenticateWithToken(page, token);
|
||||
} else {
|
||||
throw new Error(
|
||||
'Set AUTHENTIK_USERNAME + AUTHENTIK_PASSWORD for OIDC auth, or HEADLAMP_TOKEN for token auth'
|
||||
);
|
||||
}
|
||||
|
||||
await page.context().storageState({ path: AUTH_STATE_PATH });
|
||||
});
|
||||
@@ -1,110 +0,0 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test.describe('Polaris plugin smoke tests', () => {
|
||||
test('sidebar contains Polaris entry', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
// The sidebar is the "Navigation" nav element (not "Appbar Tools")
|
||||
const sidebar = page.getByRole('navigation', { name: 'Navigation' });
|
||||
await expect(sidebar).toBeVisible({ timeout: 15_000 });
|
||||
await expect(sidebar.getByRole('button', { name: 'Polaris' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('overview page renders cluster score', async ({ page }) => {
|
||||
await page.goto('/c/main/polaris');
|
||||
|
||||
// SectionHeader renders a heading
|
||||
await expect(page.getByRole('heading', { name: 'Polaris \u2014 Overview' })).toBeVisible();
|
||||
|
||||
// "Cluster Score" section exists with a percentage
|
||||
await expect(page.getByText('Cluster Score')).toBeVisible();
|
||||
await expect(page.getByText(/%/)).toBeVisible();
|
||||
});
|
||||
|
||||
test('namespaces page renders table with namespace buttons', async ({ page }) => {
|
||||
await page.goto('/c/main/polaris/namespaces');
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Polaris \u2014 Namespaces' })).toBeVisible();
|
||||
|
||||
// Table should have at least one row with a namespace button
|
||||
const table = page.locator('table');
|
||||
await expect(table).toBeVisible();
|
||||
const rows = table.locator('tbody tr');
|
||||
await expect(rows.first()).toBeVisible();
|
||||
|
||||
// Each namespace row should contain a button (now buttons instead of links for drawer)
|
||||
const firstButton = rows.first().locator('button');
|
||||
await expect(firstButton).toBeVisible();
|
||||
});
|
||||
|
||||
test('namespace detail drawer opens from table button', async ({ page }) => {
|
||||
await page.goto('/c/main/polaris/namespaces');
|
||||
|
||||
// Click the first namespace button in the table
|
||||
const table = page.locator('table');
|
||||
await expect(table).toBeVisible();
|
||||
const firstButton = table.locator('tbody tr').first().locator('button');
|
||||
const namespaceName = await firstButton.textContent();
|
||||
await firstButton.click();
|
||||
|
||||
// Drawer should open and show the namespace name in the heading
|
||||
await expect(
|
||||
page.getByRole('heading', { name: `Polaris \u2014 ${namespaceName}` })
|
||||
).toBeVisible();
|
||||
|
||||
// "Namespace Score" section should be present in drawer
|
||||
await expect(page.getByText('Namespace Score')).toBeVisible();
|
||||
|
||||
// Resources table should exist in drawer
|
||||
await expect(page.getByText('Resources')).toBeVisible();
|
||||
|
||||
// URL hash should be updated with namespace name
|
||||
await expect(page).toHaveURL(/\/polaris\/namespaces#/);
|
||||
});
|
||||
|
||||
test('namespace detail drawer closes with Escape key', async ({ page }) => {
|
||||
await page.goto('/c/main/polaris/namespaces');
|
||||
|
||||
// Open the drawer by clicking a namespace button
|
||||
const table = page.locator('table');
|
||||
await expect(table).toBeVisible();
|
||||
const firstButton = table.locator('tbody tr').first().locator('button');
|
||||
const namespaceName = await firstButton.textContent();
|
||||
await firstButton.click();
|
||||
|
||||
// Verify drawer is open
|
||||
await expect(
|
||||
page.getByRole('heading', { name: `Polaris \u2014 ${namespaceName}` })
|
||||
).toBeVisible();
|
||||
|
||||
// Press Escape key
|
||||
await page.keyboard.press('Escape');
|
||||
|
||||
// Drawer should close (heading should not be visible anymore)
|
||||
await expect(
|
||||
page.getByRole('heading', { name: `Polaris \u2014 ${namespaceName}` })
|
||||
).not.toBeVisible();
|
||||
|
||||
// URL hash should be cleared
|
||||
await expect(page).toHaveURL(/\/polaris\/namespaces$/);
|
||||
});
|
||||
|
||||
test('namespace detail drawer opens from URL hash', async ({ page }) => {
|
||||
// Get a namespace name first
|
||||
await page.goto('/c/main/polaris/namespaces');
|
||||
const table = page.locator('table');
|
||||
await expect(table).toBeVisible();
|
||||
const firstButton = table.locator('tbody tr').first().locator('button');
|
||||
const namespaceName = await firstButton.textContent();
|
||||
|
||||
// Navigate directly to URL with hash
|
||||
await page.goto(`/c/main/polaris/namespaces#${namespaceName}`);
|
||||
|
||||
// Drawer should automatically open with the namespace details
|
||||
await expect(
|
||||
page.getByRole('heading', { name: `Polaris \u2014 ${namespaceName}` })
|
||||
).toBeVisible();
|
||||
|
||||
// "Namespace Score" section should be present
|
||||
await expect(page.getByText('Namespace Score')).toBeVisible();
|
||||
});
|
||||
});
|
||||
Generated
+5
-69
@@ -1,15 +1,14 @@
|
||||
{
|
||||
"name": "headlamp-polaris-plugin",
|
||||
"version": "0.2.0",
|
||||
"name": "polaris-headlamp-plugin",
|
||||
"version": "0.0.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "headlamp-polaris-plugin",
|
||||
"version": "0.2.0",
|
||||
"name": "polaris-headlamp-plugin",
|
||||
"version": "0.0.1",
|
||||
"devDependencies": {
|
||||
"@kinvolk/headlamp-plugin": "^0.13.0",
|
||||
"@playwright/test": "^1.58.2"
|
||||
"@kinvolk/headlamp-plugin": "^0.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@adobe/css-tools": {
|
||||
@@ -2470,22 +2469,6 @@
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/@playwright/test": {
|
||||
"version": "1.58.2",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz",
|
||||
"integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright": "1.58.2"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@popperjs/core": {
|
||||
"version": "2.11.8",
|
||||
"resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz",
|
||||
@@ -13571,53 +13554,6 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.58.2",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz",
|
||||
"integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.58.2"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.58.2",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz",
|
||||
"integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright/node_modules/fsevents": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/possible-typed-array-names": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
|
||||
|
||||
+4
-13
@@ -1,23 +1,14 @@
|
||||
{
|
||||
"name": "headlamp-polaris-plugin",
|
||||
"version": "0.3.3",
|
||||
"name": "polaris-headlamp-plugin",
|
||||
"version": "0.0.1",
|
||||
"description": "Headlamp plugin for Fairwinds Polaris audit results",
|
||||
"scripts": {
|
||||
"start": "headlamp-plugin start",
|
||||
"build": "headlamp-plugin build",
|
||||
"package": "headlamp-plugin package",
|
||||
"tsc": "tsc --noEmit",
|
||||
"lint": "eslint --ext .ts,.tsx src/",
|
||||
"lint:fix": "eslint --ext .ts,.tsx --fix src/",
|
||||
"format": "prettier --write src/",
|
||||
"format:check": "prettier --check src/",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"e2e": "playwright test",
|
||||
"e2e:headed": "playwright test --headed"
|
||||
"tsc": "tsc --noEmit"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@kinvolk/headlamp-plugin": "^0.13.0",
|
||||
"@playwright/test": "^1.58.2"
|
||||
"@kinvolk/headlamp-plugin": "^0.13.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './e2e',
|
||||
timeout: 30_000,
|
||||
expect: { timeout: 10_000 },
|
||||
fullyParallel: false,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 1 : 0,
|
||||
reporter: 'list',
|
||||
use: {
|
||||
baseURL: process.env.HEADLAMP_URL || 'https://headlamp.animaniacs.farh.net',
|
||||
trace: 'on-first-retry',
|
||||
},
|
||||
projects: [
|
||||
{ name: 'setup', testMatch: /auth\.setup\.ts/ },
|
||||
{
|
||||
name: 'chromium',
|
||||
use: {
|
||||
...devices['Desktop Chrome'],
|
||||
storageState: 'e2e/.auth/state.json',
|
||||
},
|
||||
dependencies: ['setup'],
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -1,48 +0,0 @@
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { makeAuditData, makeResult } from '../test-utils';
|
||||
|
||||
vi.mock('@kinvolk/headlamp-plugin/lib', () => ({
|
||||
ApiProxy: { request: vi.fn() },
|
||||
}));
|
||||
|
||||
// Mock usePolarisData so PolarisDataProvider doesn't make real API calls
|
||||
vi.mock('./polaris', async importOriginal => {
|
||||
const actual = await importOriginal<typeof import('./polaris')>();
|
||||
return {
|
||||
...actual,
|
||||
usePolarisData: vi.fn(() => ({
|
||||
data: makeAuditData([makeResult()]),
|
||||
loading: false,
|
||||
error: null,
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
import { PolarisDataProvider, usePolarisDataContext } from './PolarisDataContext';
|
||||
|
||||
describe('usePolarisDataContext', () => {
|
||||
it('throws when used outside PolarisDataProvider', () => {
|
||||
// Suppress console.error from React during expected error
|
||||
const spy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
expect(() => {
|
||||
renderHook(() => usePolarisDataContext());
|
||||
}).toThrow('usePolarisDataContext must be used within a PolarisDataProvider');
|
||||
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
it('returns context value when inside PolarisDataProvider', () => {
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<PolarisDataProvider>{children}</PolarisDataProvider>
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => usePolarisDataContext(), { wrapper });
|
||||
|
||||
expect(result.current.data).not.toBeNull();
|
||||
expect(result.current.loading).toBe(false);
|
||||
expect(result.current.error).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,37 +0,0 @@
|
||||
import React from 'react';
|
||||
import { AuditData, getRefreshInterval, usePolarisData } from './polaris';
|
||||
|
||||
interface PolarisDataContextValue {
|
||||
data: AuditData | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
refresh: () => void;
|
||||
}
|
||||
|
||||
const PolarisDataContext = React.createContext<PolarisDataContextValue | null>(null);
|
||||
|
||||
export function PolarisDataProvider(props: { children: React.ReactNode }) {
|
||||
const interval = getRefreshInterval();
|
||||
const state = usePolarisData(interval);
|
||||
|
||||
// Rename triggerRefresh to refresh for consistency
|
||||
const value = React.useMemo(
|
||||
() => ({
|
||||
data: state.data,
|
||||
loading: state.loading,
|
||||
error: state.error,
|
||||
refresh: state.triggerRefresh,
|
||||
}),
|
||||
[state]
|
||||
);
|
||||
|
||||
return <PolarisDataContext.Provider value={value}>{props.children}</PolarisDataContext.Provider>;
|
||||
}
|
||||
|
||||
export function usePolarisDataContext(): PolarisDataContextValue {
|
||||
const ctx = React.useContext(PolarisDataContext);
|
||||
if (ctx === null) {
|
||||
throw new Error('usePolarisDataContext must be used within a PolarisDataProvider');
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
@@ -1,238 +0,0 @@
|
||||
/**
|
||||
* Mapping of Polaris check IDs to human-readable names and descriptions
|
||||
* Sourced from Polaris documentation
|
||||
*/
|
||||
|
||||
export interface CheckInfo {
|
||||
name: string;
|
||||
description: string;
|
||||
category: 'Security' | 'Efficiency' | 'Reliability';
|
||||
defaultSeverity: 'danger' | 'warning' | 'ignore';
|
||||
}
|
||||
|
||||
export const CHECK_MAPPING: Record<string, CheckInfo> = {
|
||||
// Security checks
|
||||
hostIPCSet: {
|
||||
name: 'Host IPC',
|
||||
description: 'Host IPC should not be configured',
|
||||
category: 'Security',
|
||||
defaultSeverity: 'danger',
|
||||
},
|
||||
hostPIDSet: {
|
||||
name: 'Host PID',
|
||||
description: 'Host PID should not be configured',
|
||||
category: 'Security',
|
||||
defaultSeverity: 'danger',
|
||||
},
|
||||
hostNetworkSet: {
|
||||
name: 'Host Network',
|
||||
description: 'Host network should not be configured',
|
||||
category: 'Security',
|
||||
defaultSeverity: 'danger',
|
||||
},
|
||||
hostPortSet: {
|
||||
name: 'Host Port',
|
||||
description: 'Host port should not be configured',
|
||||
category: 'Security',
|
||||
defaultSeverity: 'warning',
|
||||
},
|
||||
runAsRootAllowed: {
|
||||
name: 'Run as Root',
|
||||
description: 'Should not be allowed to run as root',
|
||||
category: 'Security',
|
||||
defaultSeverity: 'danger',
|
||||
},
|
||||
runAsPrivileged: {
|
||||
name: 'Privileged Container',
|
||||
description: 'Should not run as privileged',
|
||||
category: 'Security',
|
||||
defaultSeverity: 'danger',
|
||||
},
|
||||
notReadOnlyRootFilesystem: {
|
||||
name: 'Read-Only Root Filesystem',
|
||||
description: 'Filesystem should be read-only',
|
||||
category: 'Security',
|
||||
defaultSeverity: 'warning',
|
||||
},
|
||||
privilegeEscalationAllowed: {
|
||||
name: 'Privilege Escalation',
|
||||
description: 'Privilege escalation should not be allowed',
|
||||
category: 'Security',
|
||||
defaultSeverity: 'danger',
|
||||
},
|
||||
dangerousCapabilities: {
|
||||
name: 'Dangerous Capabilities',
|
||||
description: 'Dangerous capabilities should not be allowed',
|
||||
category: 'Security',
|
||||
defaultSeverity: 'danger',
|
||||
},
|
||||
insecureCapabilities: {
|
||||
name: 'Insecure Capabilities',
|
||||
description: 'Insecure capabilities should not be allowed',
|
||||
category: 'Security',
|
||||
defaultSeverity: 'warning',
|
||||
},
|
||||
sensitiveContainerEnvVar: {
|
||||
name: 'Sensitive Environment Variables',
|
||||
description: 'Sensitive env vars detected',
|
||||
category: 'Security',
|
||||
defaultSeverity: 'danger',
|
||||
},
|
||||
sensitiveConfigmapContent: {
|
||||
name: 'Sensitive ConfigMap',
|
||||
description: 'Sensitive ConfigMap content detected',
|
||||
category: 'Security',
|
||||
defaultSeverity: 'danger',
|
||||
},
|
||||
automountServiceAccountToken: {
|
||||
name: 'Service Account Token Auto-mount',
|
||||
description: 'Service account token auto-mount',
|
||||
category: 'Security',
|
||||
defaultSeverity: 'warning',
|
||||
},
|
||||
tlsSettingsMissing: {
|
||||
name: 'TLS Settings',
|
||||
description: 'TLS settings missing',
|
||||
category: 'Security',
|
||||
defaultSeverity: 'warning',
|
||||
},
|
||||
missingNetworkPolicy: {
|
||||
name: 'Network Policy',
|
||||
description: 'Missing NetworkPolicy',
|
||||
category: 'Security',
|
||||
defaultSeverity: 'warning',
|
||||
},
|
||||
|
||||
// Reliability checks
|
||||
tagNotSpecified: {
|
||||
name: 'Image Tag',
|
||||
description: 'Image tag should be specified',
|
||||
category: 'Reliability',
|
||||
defaultSeverity: 'danger',
|
||||
},
|
||||
pullPolicyNotAlways: {
|
||||
name: 'Pull Policy',
|
||||
description: 'Pull policy should be Always',
|
||||
category: 'Reliability',
|
||||
defaultSeverity: 'warning',
|
||||
},
|
||||
readinessProbeMissing: {
|
||||
name: 'Readiness Probe',
|
||||
description: 'Readiness probe should be configured',
|
||||
category: 'Reliability',
|
||||
defaultSeverity: 'warning',
|
||||
},
|
||||
livenessProbeMissing: {
|
||||
name: 'Liveness Probe',
|
||||
description: 'Liveness probe should be configured',
|
||||
category: 'Reliability',
|
||||
defaultSeverity: 'warning',
|
||||
},
|
||||
deploymentMissingReplicas: {
|
||||
name: 'Deployment Replicas',
|
||||
description: 'Deployment should have multiple replicas',
|
||||
category: 'Reliability',
|
||||
defaultSeverity: 'warning',
|
||||
},
|
||||
priorityClassNotSet: {
|
||||
name: 'Priority Class',
|
||||
description: 'Priority class should be set',
|
||||
category: 'Reliability',
|
||||
defaultSeverity: 'warning',
|
||||
},
|
||||
metadataAndNameMismatched: {
|
||||
name: 'Metadata Mismatch',
|
||||
description: 'Metadata and name should match',
|
||||
category: 'Reliability',
|
||||
defaultSeverity: 'warning',
|
||||
},
|
||||
missingPodDisruptionBudget: {
|
||||
name: 'Pod Disruption Budget',
|
||||
description: 'PodDisruptionBudget should exist',
|
||||
category: 'Reliability',
|
||||
defaultSeverity: 'warning',
|
||||
},
|
||||
pdbDisruptionsIsZero: {
|
||||
name: 'PDB Disruptions',
|
||||
description: 'PDB maxUnavailable should not be zero',
|
||||
category: 'Reliability',
|
||||
defaultSeverity: 'warning',
|
||||
},
|
||||
|
||||
// Efficiency checks
|
||||
cpuRequestsMissing: {
|
||||
name: 'CPU Requests',
|
||||
description: 'CPU requests should be set',
|
||||
category: 'Efficiency',
|
||||
defaultSeverity: 'warning',
|
||||
},
|
||||
cpuLimitsMissing: {
|
||||
name: 'CPU Limits',
|
||||
description: 'CPU limits should be set',
|
||||
category: 'Efficiency',
|
||||
defaultSeverity: 'warning',
|
||||
},
|
||||
memoryRequestsMissing: {
|
||||
name: 'Memory Requests',
|
||||
description: 'Memory requests should be set',
|
||||
category: 'Efficiency',
|
||||
defaultSeverity: 'warning',
|
||||
},
|
||||
memoryLimitsMissing: {
|
||||
name: 'Memory Limits',
|
||||
description: 'Memory limits should be set',
|
||||
category: 'Efficiency',
|
||||
defaultSeverity: 'warning',
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Get human-readable name for a check ID
|
||||
*/
|
||||
export function getCheckName(checkId: string): string {
|
||||
return CHECK_MAPPING[checkId]?.name || checkId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get check description
|
||||
*/
|
||||
export function getCheckDescription(checkId: string): string {
|
||||
return CHECK_MAPPING[checkId]?.description || 'Unknown check';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get check category
|
||||
*/
|
||||
export function getCheckCategory(checkId: string): 'Security' | 'Efficiency' | 'Reliability' {
|
||||
return CHECK_MAPPING[checkId]?.category || 'Security';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get color for severity
|
||||
*/
|
||||
export function getSeverityColor(severity: string): string {
|
||||
switch (severity) {
|
||||
case 'danger':
|
||||
return '#f44336';
|
||||
case 'warning':
|
||||
return '#ff9800';
|
||||
case 'ignore':
|
||||
return '#9e9e9e';
|
||||
default:
|
||||
return '#9e9e9e';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get status for StatusLabel component
|
||||
*/
|
||||
export function getSeverityStatus(severity: string): 'error' | 'warning' | 'success' {
|
||||
switch (severity) {
|
||||
case 'danger':
|
||||
return 'error';
|
||||
case 'warning':
|
||||
return 'warning';
|
||||
default:
|
||||
return 'success';
|
||||
}
|
||||
}
|
||||
@@ -1,390 +0,0 @@
|
||||
import { act, renderHook, waitFor } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { makeAuditData, makeResult } from '../test-utils';
|
||||
|
||||
vi.mock('@kinvolk/headlamp-plugin/lib', () => ({
|
||||
ApiProxy: { request: vi.fn() },
|
||||
}));
|
||||
|
||||
import { ApiProxy } from '@kinvolk/headlamp-plugin/lib';
|
||||
import {
|
||||
computeScore,
|
||||
countResults,
|
||||
countResultsForItems,
|
||||
filterResultsByNamespace,
|
||||
getNamespaces,
|
||||
getRefreshInterval,
|
||||
Result,
|
||||
ResultCounts,
|
||||
setRefreshInterval,
|
||||
usePolarisData,
|
||||
} from './polaris';
|
||||
|
||||
// --- computeScore ---
|
||||
|
||||
describe('computeScore', () => {
|
||||
it('returns 0 when total is 0', () => {
|
||||
const counts: ResultCounts = { total: 0, pass: 0, warning: 0, danger: 0, skipped: 0 };
|
||||
expect(computeScore(counts)).toBe(0);
|
||||
});
|
||||
|
||||
it('returns 100 when all checks pass', () => {
|
||||
const counts: ResultCounts = { total: 10, pass: 10, warning: 0, danger: 0, skipped: 0 };
|
||||
expect(computeScore(counts)).toBe(100);
|
||||
});
|
||||
|
||||
it('rounds to nearest integer', () => {
|
||||
const counts: ResultCounts = { total: 3, pass: 1, warning: 1, danger: 1, skipped: 0 };
|
||||
expect(computeScore(counts)).toBe(33);
|
||||
});
|
||||
|
||||
it('includes skipped in total denominator', () => {
|
||||
const counts: ResultCounts = { total: 10, pass: 5, warning: 2, danger: 1, skipped: 2 };
|
||||
expect(computeScore(counts)).toBe(50);
|
||||
});
|
||||
});
|
||||
|
||||
// --- countResults / countResultsForItems ---
|
||||
|
||||
describe('countResults', () => {
|
||||
it('returns zero counts for empty results', () => {
|
||||
const data = makeAuditData([]);
|
||||
const counts = countResults(data);
|
||||
expect(counts).toEqual({ total: 0, pass: 0, warning: 0, danger: 0, skipped: 0 });
|
||||
});
|
||||
|
||||
it('counts top-level result set entries', () => {
|
||||
const result = makeResult({
|
||||
Results: {
|
||||
check1: {
|
||||
ID: 'check1',
|
||||
Message: 'ok',
|
||||
Details: [],
|
||||
Success: true,
|
||||
Severity: 'warning',
|
||||
Category: 'Security',
|
||||
},
|
||||
check2: {
|
||||
ID: 'check2',
|
||||
Message: 'bad',
|
||||
Details: [],
|
||||
Success: false,
|
||||
Severity: 'danger',
|
||||
Category: 'Security',
|
||||
},
|
||||
},
|
||||
});
|
||||
const counts = countResults(makeAuditData([result]));
|
||||
expect(counts.total).toBe(2);
|
||||
expect(counts.pass).toBe(1);
|
||||
expect(counts.danger).toBe(1);
|
||||
expect(counts.warning).toBe(0);
|
||||
expect(counts.skipped).toBe(0);
|
||||
});
|
||||
|
||||
it('counts skipped (severity=ignore, success=false) entries', () => {
|
||||
const result = makeResult({
|
||||
Results: {
|
||||
skipped1: {
|
||||
ID: 'skipped1',
|
||||
Message: 'skipped',
|
||||
Details: [],
|
||||
Success: false,
|
||||
Severity: 'ignore',
|
||||
Category: 'Security',
|
||||
},
|
||||
},
|
||||
});
|
||||
const counts = countResults(makeAuditData([result]));
|
||||
expect(counts.total).toBe(1);
|
||||
expect(counts.skipped).toBe(1);
|
||||
expect(counts.pass).toBe(0);
|
||||
});
|
||||
|
||||
it('counts PodResult and ContainerResults', () => {
|
||||
const result = makeResult({
|
||||
Results: {
|
||||
top: {
|
||||
ID: 'top',
|
||||
Message: 'ok',
|
||||
Details: [],
|
||||
Success: true,
|
||||
Severity: 'warning',
|
||||
Category: 'Reliability',
|
||||
},
|
||||
},
|
||||
PodResult: {
|
||||
Name: 'pod-1',
|
||||
Results: {
|
||||
podCheck: {
|
||||
ID: 'podCheck',
|
||||
Message: 'warn',
|
||||
Details: [],
|
||||
Success: false,
|
||||
Severity: 'warning',
|
||||
Category: 'Reliability',
|
||||
},
|
||||
},
|
||||
ContainerResults: [
|
||||
{
|
||||
Name: 'container-1',
|
||||
Results: {
|
||||
containerCheck: {
|
||||
ID: 'containerCheck',
|
||||
Message: 'danger',
|
||||
Details: [],
|
||||
Success: false,
|
||||
Severity: 'danger',
|
||||
Category: 'Security',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
const counts = countResults(makeAuditData([result]));
|
||||
expect(counts.total).toBe(3);
|
||||
expect(counts.pass).toBe(1);
|
||||
expect(counts.warning).toBe(1);
|
||||
expect(counts.danger).toBe(1);
|
||||
});
|
||||
|
||||
it('aggregates across multiple results', () => {
|
||||
const r1 = makeResult({
|
||||
Name: 'deploy-a',
|
||||
Results: {
|
||||
c1: {
|
||||
ID: 'c1',
|
||||
Message: '',
|
||||
Details: [],
|
||||
Success: true,
|
||||
Severity: 'warning',
|
||||
Category: 'X',
|
||||
},
|
||||
},
|
||||
});
|
||||
const r2 = makeResult({
|
||||
Name: 'deploy-b',
|
||||
Results: {
|
||||
c2: {
|
||||
ID: 'c2',
|
||||
Message: '',
|
||||
Details: [],
|
||||
Success: false,
|
||||
Severity: 'warning',
|
||||
Category: 'X',
|
||||
},
|
||||
},
|
||||
});
|
||||
const counts = countResults(makeAuditData([r1, r2]));
|
||||
expect(counts.total).toBe(2);
|
||||
expect(counts.pass).toBe(1);
|
||||
expect(counts.warning).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('countResultsForItems', () => {
|
||||
it('works on a subset of results', () => {
|
||||
const results: Result[] = [
|
||||
makeResult({
|
||||
Results: {
|
||||
a: {
|
||||
ID: 'a',
|
||||
Message: '',
|
||||
Details: [],
|
||||
Success: false,
|
||||
Severity: 'danger',
|
||||
Category: 'X',
|
||||
},
|
||||
},
|
||||
}),
|
||||
];
|
||||
const counts = countResultsForItems(results);
|
||||
expect(counts.danger).toBe(1);
|
||||
expect(counts.total).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
// --- getNamespaces ---
|
||||
|
||||
describe('getNamespaces', () => {
|
||||
it('returns empty array for no results', () => {
|
||||
expect(getNamespaces(makeAuditData([]))).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns sorted unique namespaces', () => {
|
||||
const data = makeAuditData([
|
||||
makeResult({ Namespace: 'beta' }),
|
||||
makeResult({ Namespace: 'alpha' }),
|
||||
makeResult({ Namespace: 'beta' }),
|
||||
makeResult({ Namespace: 'gamma' }),
|
||||
]);
|
||||
expect(getNamespaces(data)).toEqual(['alpha', 'beta', 'gamma']);
|
||||
});
|
||||
|
||||
it('excludes results with empty namespace (cluster-scoped resources)', () => {
|
||||
const data = makeAuditData([
|
||||
makeResult({ Namespace: '' }),
|
||||
makeResult({ Namespace: 'alpha' }),
|
||||
makeResult({ Namespace: '' }),
|
||||
]);
|
||||
expect(getNamespaces(data)).toEqual(['alpha']);
|
||||
});
|
||||
});
|
||||
|
||||
// --- filterResultsByNamespace ---
|
||||
|
||||
describe('filterResultsByNamespace', () => {
|
||||
it('returns only results matching the namespace', () => {
|
||||
const data = makeAuditData([
|
||||
makeResult({ Name: 'a', Namespace: 'ns1' }),
|
||||
makeResult({ Name: 'b', Namespace: 'ns2' }),
|
||||
makeResult({ Name: 'c', Namespace: 'ns1' }),
|
||||
]);
|
||||
const filtered = filterResultsByNamespace(data, 'ns1');
|
||||
expect(filtered).toHaveLength(2);
|
||||
expect(filtered.map(r => r.Name)).toEqual(['a', 'c']);
|
||||
});
|
||||
|
||||
it('returns empty array for non-existent namespace', () => {
|
||||
const data = makeAuditData([makeResult({ Namespace: 'ns1' })]);
|
||||
expect(filterResultsByNamespace(data, 'ns-missing')).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// --- getRefreshInterval / setRefreshInterval ---
|
||||
|
||||
describe('getRefreshInterval', () => {
|
||||
beforeEach(() => {
|
||||
window.localStorage.removeItem('polaris-plugin-refresh-interval');
|
||||
});
|
||||
|
||||
it('returns default (300) when nothing stored', () => {
|
||||
expect(getRefreshInterval()).toBe(300);
|
||||
});
|
||||
|
||||
it('returns stored value when valid', () => {
|
||||
localStorage.setItem('polaris-plugin-refresh-interval', '60');
|
||||
expect(getRefreshInterval()).toBe(60);
|
||||
});
|
||||
|
||||
it('returns default for non-numeric stored value', () => {
|
||||
localStorage.setItem('polaris-plugin-refresh-interval', 'abc');
|
||||
expect(getRefreshInterval()).toBe(300);
|
||||
});
|
||||
|
||||
it('returns default for zero stored value', () => {
|
||||
localStorage.setItem('polaris-plugin-refresh-interval', '0');
|
||||
expect(getRefreshInterval()).toBe(300);
|
||||
});
|
||||
|
||||
it('returns default for negative stored value', () => {
|
||||
localStorage.setItem('polaris-plugin-refresh-interval', '-10');
|
||||
expect(getRefreshInterval()).toBe(300);
|
||||
});
|
||||
});
|
||||
|
||||
describe('setRefreshInterval', () => {
|
||||
beforeEach(() => {
|
||||
window.localStorage.removeItem('polaris-plugin-refresh-interval');
|
||||
});
|
||||
|
||||
it('stores value that getRefreshInterval reads back', () => {
|
||||
setRefreshInterval(1800);
|
||||
expect(getRefreshInterval()).toBe(1800);
|
||||
});
|
||||
});
|
||||
|
||||
// --- usePolarisData ---
|
||||
|
||||
describe('usePolarisData', () => {
|
||||
const mockRequest = ApiProxy.request as ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
mockRequest.mockReset();
|
||||
});
|
||||
|
||||
it('returns data on successful fetch', async () => {
|
||||
const auditData = makeAuditData([makeResult()]);
|
||||
mockRequest.mockResolvedValue(auditData);
|
||||
|
||||
const { result } = renderHook(() => usePolarisData(300));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false);
|
||||
});
|
||||
|
||||
expect(result.current.data).toEqual(auditData);
|
||||
expect(result.current.error).toBeNull();
|
||||
});
|
||||
|
||||
it('returns RBAC error on 403', async () => {
|
||||
mockRequest.mockRejectedValue({ status: 403 });
|
||||
|
||||
const { result } = renderHook(() => usePolarisData(300));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false);
|
||||
});
|
||||
|
||||
expect(result.current.data).toBeNull();
|
||||
expect(result.current.error).toContain('403');
|
||||
expect(result.current.error).toContain('RBAC');
|
||||
});
|
||||
|
||||
it('returns not-installed error on 404', async () => {
|
||||
mockRequest.mockRejectedValue({ status: 404 });
|
||||
|
||||
const { result } = renderHook(() => usePolarisData(300));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false);
|
||||
});
|
||||
|
||||
expect(result.current.error).toContain('not reachable');
|
||||
});
|
||||
|
||||
it('returns not-installed error on 503', async () => {
|
||||
mockRequest.mockRejectedValue({ status: 503 });
|
||||
|
||||
const { result } = renderHook(() => usePolarisData(300));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false);
|
||||
});
|
||||
|
||||
expect(result.current.error).toContain('not reachable');
|
||||
});
|
||||
|
||||
it('returns generic error for other failures', async () => {
|
||||
mockRequest.mockRejectedValue(new Error('network down'));
|
||||
|
||||
const { result } = renderHook(() => usePolarisData(300));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false);
|
||||
});
|
||||
|
||||
expect(result.current.error).toContain('Failed to fetch');
|
||||
expect(result.current.error).toContain('network down');
|
||||
});
|
||||
|
||||
it('does not update state after unmount', async () => {
|
||||
let resolveFetch: (value: unknown) => void = () => {};
|
||||
mockRequest.mockReturnValue(
|
||||
new Promise(resolve => {
|
||||
resolveFetch = resolve;
|
||||
})
|
||||
);
|
||||
|
||||
const { result, unmount } = renderHook(() => usePolarisData(300));
|
||||
expect(result.current.loading).toBe(true);
|
||||
|
||||
unmount();
|
||||
|
||||
// Resolve after unmount — should not throw or update state
|
||||
await act(async () => {
|
||||
resolveFetch(makeAuditData([]));
|
||||
});
|
||||
});
|
||||
});
|
||||
+81
-149
@@ -1,4 +1,4 @@
|
||||
import { ApiProxy } from '@kinvolk/headlamp-plugin/lib';
|
||||
import { K8s } from '@kinvolk/headlamp-plugin/lib';
|
||||
import React from 'react';
|
||||
|
||||
// --- Polaris AuditData schema (matches pkg/validator/output.go) ---
|
||||
@@ -52,6 +52,7 @@ export interface AuditData {
|
||||
DisplayName: string;
|
||||
ClusterInfo: ClusterInfo;
|
||||
Results: Result[];
|
||||
Score: number;
|
||||
}
|
||||
|
||||
// --- Result counting ---
|
||||
@@ -61,7 +62,6 @@ export interface ResultCounts {
|
||||
pass: number;
|
||||
warning: number;
|
||||
danger: number;
|
||||
skipped: number;
|
||||
}
|
||||
|
||||
function countResultSet(rs: ResultSet, counts: ResultCounts): void {
|
||||
@@ -70,8 +70,6 @@ function countResultSet(rs: ResultSet, counts: ResultCounts): void {
|
||||
counts.total++;
|
||||
if (msg.Success) {
|
||||
counts.pass++;
|
||||
} else if (msg.Severity === 'ignore') {
|
||||
counts.skipped++;
|
||||
} else if (msg.Severity === 'warning') {
|
||||
counts.warning++;
|
||||
} else if (msg.Severity === 'danger') {
|
||||
@@ -80,9 +78,9 @@ function countResultSet(rs: ResultSet, counts: ResultCounts): void {
|
||||
}
|
||||
}
|
||||
|
||||
function countResultItems(results: Result[]): ResultCounts {
|
||||
const counts: ResultCounts = { total: 0, pass: 0, warning: 0, danger: 0, skipped: 0 };
|
||||
for (const result of results) {
|
||||
export function countResults(data: AuditData): ResultCounts {
|
||||
const counts: ResultCounts = { total: 0, pass: 0, warning: 0, danger: 0 };
|
||||
for (const result of data.Results) {
|
||||
countResultSet(result.Results, counts);
|
||||
if (result.PodResult) {
|
||||
countResultSet(result.PodResult.Results, counts);
|
||||
@@ -94,45 +92,13 @@ function countResultItems(results: Result[]): ResultCounts {
|
||||
return counts;
|
||||
}
|
||||
|
||||
export function countResults(data: AuditData): ResultCounts {
|
||||
return countResultItems(data.Results);
|
||||
}
|
||||
|
||||
export function countResultsForItems(results: Result[]): ResultCounts {
|
||||
return countResultItems(results);
|
||||
}
|
||||
|
||||
export function getNamespaces(data: AuditData): string[] {
|
||||
const namespaces = new Set<string>();
|
||||
for (const result of data.Results) {
|
||||
if (result.Namespace) {
|
||||
namespaces.add(result.Namespace);
|
||||
}
|
||||
}
|
||||
return Array.from(namespaces).sort();
|
||||
}
|
||||
|
||||
export function filterResultsByNamespace(data: AuditData, namespace: string): Result[] {
|
||||
return data.Results.filter(r => r.Namespace === namespace);
|
||||
}
|
||||
|
||||
// --- Settings ---
|
||||
|
||||
export const INTERVAL_OPTIONS = [
|
||||
{ label: '1 minute', value: 60 },
|
||||
{ label: '5 minutes', value: 300 },
|
||||
{ label: '10 minutes', value: 600 },
|
||||
{ label: '30 minutes', value: 1800 },
|
||||
];
|
||||
|
||||
const REFRESH_STORAGE_KEY = 'polaris-plugin-refresh-interval';
|
||||
const STORAGE_KEY = 'polaris-plugin-refresh-interval';
|
||||
const DEFAULT_INTERVAL_SECONDS = 300; // 5 minutes
|
||||
|
||||
const URL_STORAGE_KEY = 'polaris-plugin-dashboard-url';
|
||||
const DEFAULT_DASHBOARD_URL = '/api/v1/namespaces/polaris/services/polaris-dashboard:80/proxy/';
|
||||
|
||||
export function getRefreshInterval(): number {
|
||||
const stored = localStorage.getItem(REFRESH_STORAGE_KEY);
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
if (stored !== null) {
|
||||
const parsed = parseInt(stored, 10);
|
||||
if (!isNaN(parsed) && parsed > 0) {
|
||||
@@ -143,133 +109,99 @@ export function getRefreshInterval(): number {
|
||||
}
|
||||
|
||||
export function setRefreshInterval(seconds: number): void {
|
||||
localStorage.setItem(REFRESH_STORAGE_KEY, String(seconds));
|
||||
}
|
||||
|
||||
export function getDashboardUrl(): string {
|
||||
const stored = localStorage.getItem(URL_STORAGE_KEY);
|
||||
if (stored !== null && stored.trim() !== '') {
|
||||
return stored.trim();
|
||||
}
|
||||
return DEFAULT_DASHBOARD_URL;
|
||||
}
|
||||
|
||||
export function setDashboardUrl(url: string): void {
|
||||
localStorage.setItem(URL_STORAGE_KEY, url.trim());
|
||||
}
|
||||
|
||||
// --- Polaris dashboard proxy URL ---
|
||||
|
||||
export function getPolarisProxyUrl(): string {
|
||||
return getDashboardUrl();
|
||||
}
|
||||
|
||||
// --- Score computation ---
|
||||
|
||||
export function computeScore(counts: ResultCounts): number {
|
||||
if (counts.total === 0) return 0;
|
||||
return Math.round((counts.pass / counts.total) * 100);
|
||||
localStorage.setItem(STORAGE_KEY, String(seconds));
|
||||
}
|
||||
|
||||
// --- Data fetching hook ---
|
||||
|
||||
function getPolarisApiPath(): string {
|
||||
const baseUrl = getDashboardUrl();
|
||||
return baseUrl.endsWith('/') ? `${baseUrl}results.json` : `${baseUrl}/results.json`;
|
||||
}
|
||||
|
||||
function isFullUrl(url: string): boolean {
|
||||
return url.startsWith('http://') || url.startsWith('https://');
|
||||
}
|
||||
|
||||
interface PolarisDataState {
|
||||
data: AuditData | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
triggerRefresh: () => void;
|
||||
}
|
||||
|
||||
export function usePolarisData(refreshIntervalSeconds: number): PolarisDataState {
|
||||
const [data, setData] = React.useState<AuditData | null>(null);
|
||||
const [loading, setLoading] = React.useState(true);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
const [tick, setTick] = React.useState(0);
|
||||
|
||||
const triggerRefresh = React.useCallback(() => {
|
||||
setTick(t => t + 1);
|
||||
}, []);
|
||||
const [configMap, fetchError] = K8s.ResourceClasses.ConfigMap.useGet(
|
||||
'polaris-dashboard',
|
||||
'polaris'
|
||||
);
|
||||
const [cachedData, setCachedData] = React.useState<AuditData | null>(null);
|
||||
const [parseError, setParseError] = React.useState<string | null>(null);
|
||||
const [lastFetchTime, setLastFetchTime] = React.useState<number>(0);
|
||||
const [, setTick] = React.useState(0);
|
||||
|
||||
// Parse ConfigMap data when it arrives
|
||||
React.useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
async function fetchData() {
|
||||
try {
|
||||
const apiPath = getPolarisApiPath();
|
||||
let result: AuditData;
|
||||
|
||||
if (isFullUrl(apiPath)) {
|
||||
// Direct fetch for full URLs
|
||||
const response = await fetch(apiPath);
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
result = await response.json();
|
||||
} else {
|
||||
// Kubernetes proxy for relative URLs
|
||||
result = await ApiProxy.request(apiPath);
|
||||
}
|
||||
|
||||
if (!cancelled) {
|
||||
setData(result);
|
||||
setError(null);
|
||||
setLoading(false);
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
if (cancelled) return;
|
||||
const apiPath = getPolarisApiPath();
|
||||
const status = (err as { status?: number }).status;
|
||||
|
||||
if (isFullUrl(apiPath)) {
|
||||
// Full URL errors
|
||||
if (status === 403) {
|
||||
setError('Access denied (403). Check authentication and CORS configuration.');
|
||||
} else if (status === 404) {
|
||||
setError('Polaris dashboard not found (404). Verify the URL is correct.');
|
||||
} else {
|
||||
setError(`Failed to fetch from ${apiPath}: ${String(err)}`);
|
||||
}
|
||||
} else {
|
||||
// Kubernetes proxy errors
|
||||
if (status === 403) {
|
||||
setError(
|
||||
'Access denied (403). Check that your RBAC permissions allow proxying to the Polaris service.'
|
||||
);
|
||||
} else if (status === 404 || status === 503) {
|
||||
setError(
|
||||
'Polaris dashboard not reachable. Ensure Polaris is installed in the configured namespace.'
|
||||
);
|
||||
} else {
|
||||
setError(`Failed to fetch Polaris data: ${String(err)}`);
|
||||
}
|
||||
}
|
||||
setLoading(false);
|
||||
}
|
||||
if (!configMap) {
|
||||
return;
|
||||
}
|
||||
const dataMap = configMap.data as Record<string, string> | undefined;
|
||||
const raw = dataMap?.['dashboard.json'];
|
||||
if (!raw) {
|
||||
setParseError('ConfigMap exists but dashboard.json key is missing.');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const parsed: AuditData = JSON.parse(raw);
|
||||
setCachedData(parsed);
|
||||
setParseError(null);
|
||||
setLastFetchTime(Date.now());
|
||||
} catch {
|
||||
setParseError('Failed to parse dashboard.json: malformed JSON.');
|
||||
}
|
||||
}, [configMap]);
|
||||
|
||||
fetchData();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [tick]);
|
||||
|
||||
// Periodic refresh
|
||||
// Periodic refresh via re-render trigger
|
||||
React.useEffect(() => {
|
||||
if (refreshIntervalSeconds <= 0) return;
|
||||
if (refreshIntervalSeconds <= 0) {
|
||||
return;
|
||||
}
|
||||
const intervalId = window.setInterval(() => {
|
||||
setTick(t => t + 1);
|
||||
setTick((t) => t + 1);
|
||||
}, refreshIntervalSeconds * 1000);
|
||||
return () => window.clearInterval(intervalId);
|
||||
}, [refreshIntervalSeconds]);
|
||||
|
||||
return { data, loading, error, triggerRefresh };
|
||||
// Determine error state
|
||||
if (fetchError) {
|
||||
const status = (fetchError as { status?: number }).status;
|
||||
if (status === 403) {
|
||||
return {
|
||||
data: cachedData,
|
||||
loading: false,
|
||||
error:
|
||||
'Access denied (403). Check that your RBAC permissions allow reading ConfigMaps in the polaris namespace.',
|
||||
};
|
||||
}
|
||||
if (status === 404) {
|
||||
return {
|
||||
data: cachedData,
|
||||
loading: false,
|
||||
error:
|
||||
'Polaris dashboard ConfigMap not found (404). Ensure Polaris is installed in the polaris namespace.',
|
||||
};
|
||||
}
|
||||
return {
|
||||
data: cachedData,
|
||||
loading: false,
|
||||
error: `Failed to fetch Polaris data: ${String(fetchError)}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (parseError) {
|
||||
return { data: cachedData, loading: false, error: parseError };
|
||||
}
|
||||
|
||||
const isLoading = !configMap && !fetchError;
|
||||
|
||||
// Return cached data while loading if we have it
|
||||
if (isLoading && cachedData && lastFetchTime > 0) {
|
||||
return { data: cachedData, loading: false, error: null };
|
||||
}
|
||||
|
||||
return {
|
||||
data: cachedData,
|
||||
loading: isLoading,
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,81 +0,0 @@
|
||||
import { AuditData } from './polaris';
|
||||
import { getCheckName, getCheckCategory } from './checkMapping';
|
||||
|
||||
export interface TopIssue {
|
||||
checkId: string;
|
||||
checkName: string;
|
||||
category: 'Security' | 'Efficiency' | 'Reliability';
|
||||
severity: 'danger' | 'warning';
|
||||
count: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the most common failing checks across the cluster
|
||||
* Returns top 10 issues sorted by severity then count
|
||||
*/
|
||||
export function getTopIssues(data: AuditData): TopIssue[] {
|
||||
const issueCounts = new Map<string, { severity: 'danger' | 'warning'; count: number }>();
|
||||
|
||||
// Aggregate all failing checks
|
||||
for (const result of data.Results) {
|
||||
// Pod-level checks
|
||||
if (result.PodResult?.Results) {
|
||||
for (const [checkId, checkResult] of Object.entries(result.PodResult.Results)) {
|
||||
if (!checkResult.Success && checkResult.Severity !== 'ignore') {
|
||||
const existing = issueCounts.get(checkId);
|
||||
issueCounts.set(checkId, {
|
||||
severity: checkResult.Severity as 'danger' | 'warning',
|
||||
count: (existing?.count || 0) + 1,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Container-level checks
|
||||
if (result.PodResult?.ContainerResults) {
|
||||
for (const container of result.PodResult.ContainerResults) {
|
||||
for (const [checkId, checkResult] of Object.entries(container.Results)) {
|
||||
if (!checkResult.Success && checkResult.Severity !== 'ignore') {
|
||||
const existing = issueCounts.get(checkId);
|
||||
issueCounts.set(checkId, {
|
||||
severity: checkResult.Severity as 'danger' | 'warning',
|
||||
count: (existing?.count || 0) + 1,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Controller-level checks (if any)
|
||||
if (result.Results) {
|
||||
for (const [checkId, checkResult] of Object.entries(result.Results)) {
|
||||
if (!checkResult.Success && checkResult.Severity !== 'ignore') {
|
||||
const existing = issueCounts.get(checkId);
|
||||
issueCounts.set(checkId, {
|
||||
severity: checkResult.Severity as 'danger' | 'warning',
|
||||
count: (existing?.count || 0) + 1,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Convert to array and format
|
||||
const issues: TopIssue[] = Array.from(issueCounts.entries()).map(([checkId, data]) => ({
|
||||
checkId,
|
||||
checkName: getCheckName(checkId),
|
||||
category: getCheckCategory(checkId),
|
||||
severity: data.severity,
|
||||
count: data.count,
|
||||
}));
|
||||
|
||||
// Sort by severity (danger first) then by count (descending)
|
||||
issues.sort((a, b) => {
|
||||
if (a.severity === 'danger' && b.severity !== 'danger') return -1;
|
||||
if (a.severity !== 'danger' && b.severity === 'danger') return 1;
|
||||
return b.count - a.count;
|
||||
});
|
||||
|
||||
// Return top 10
|
||||
return issues.slice(0, 10);
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
import React from 'react';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import { usePolarisDataContext } from '../api/PolarisDataContext';
|
||||
import { computeScore, countResults } from '../api/polaris';
|
||||
|
||||
/**
|
||||
* App bar badge showing cluster Polaris score
|
||||
* Clicking navigates to the overview dashboard
|
||||
*/
|
||||
export default function AppBarScoreBadge() {
|
||||
const { data, loading } = usePolarisDataContext();
|
||||
const history = useHistory();
|
||||
|
||||
if (loading || !data) {
|
||||
return null; // Graceful degradation when Polaris unavailable
|
||||
}
|
||||
|
||||
const counts = countResults(data);
|
||||
const score = computeScore(counts);
|
||||
|
||||
// Color based on score
|
||||
const getColor = (score: number): string => {
|
||||
if (score >= 80) return '#4caf50'; // green
|
||||
if (score >= 50) return '#ff9800'; // orange
|
||||
return '#f44336'; // red
|
||||
};
|
||||
|
||||
const handleClick = () => {
|
||||
history.push('/polaris');
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={handleClick}
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
marginRight: '8px',
|
||||
padding: '4px 12px',
|
||||
borderRadius: '16px',
|
||||
border: 'none',
|
||||
backgroundColor: getColor(score),
|
||||
color: 'white',
|
||||
fontSize: '13px',
|
||||
fontWeight: 500,
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '4px',
|
||||
}}
|
||||
aria-label={`Polaris cluster score: ${score}%`}
|
||||
>
|
||||
<span>🛡️</span>
|
||||
<span>Polaris: {score}%</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -1,145 +0,0 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { makeAuditData, makeResult } from '../test-utils';
|
||||
|
||||
// Mock Headlamp lib
|
||||
vi.mock('@kinvolk/headlamp-plugin/lib', () => ({
|
||||
ApiProxy: { request: vi.fn() },
|
||||
}));
|
||||
|
||||
// Mock Headlamp CommonComponents as thin pass-throughs
|
||||
vi.mock('@kinvolk/headlamp-plugin/lib/CommonComponents', () => ({
|
||||
Loader: ({ title }: { title: string }) => <div data-testid="loader">{title}</div>,
|
||||
SectionBox: ({ title, children }: { title?: string; children?: React.ReactNode }) => (
|
||||
<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>
|
||||
),
|
||||
NameValueTable: ({ rows }: { rows: Array<{ name: string; value: React.ReactNode }> }) => (
|
||||
<table data-testid="name-value-table">
|
||||
<tbody>
|
||||
{rows.map(row => (
|
||||
<tr key={row.name}>
|
||||
<td>{row.name}</td>
|
||||
<td>{row.value}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
),
|
||||
SimpleTable: ({ data }: { data: Array<any> }) => (
|
||||
<table data-testid="simple-table">
|
||||
<tbody>
|
||||
{data.map((item, idx) => (
|
||||
<tr key={idx}>
|
||||
<td>{JSON.stringify(item)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
),
|
||||
PercentageCircle: ({ label }: { label: string }) => (
|
||||
<div data-testid="percentage-circle">{label}</div>
|
||||
),
|
||||
PercentageBar: () => <div data-testid="percentage-bar" />,
|
||||
}));
|
||||
|
||||
// Mock the context hook — we'll override per test via mockReturnValue
|
||||
const mockUsePolarisDataContext = vi.fn();
|
||||
vi.mock('../api/PolarisDataContext', () => ({
|
||||
usePolarisDataContext: () => mockUsePolarisDataContext(),
|
||||
}));
|
||||
|
||||
import DashboardView from './DashboardView';
|
||||
|
||||
describe('DashboardView', () => {
|
||||
it('renders loader when loading', () => {
|
||||
mockUsePolarisDataContext.mockReturnValue({
|
||||
data: null,
|
||||
loading: true,
|
||||
error: null,
|
||||
});
|
||||
|
||||
render(<DashboardView />);
|
||||
expect(screen.getByTestId('loader')).toHaveTextContent('Loading Polaris audit data');
|
||||
});
|
||||
|
||||
it('renders error message when error is set', () => {
|
||||
mockUsePolarisDataContext.mockReturnValue({
|
||||
data: null,
|
||||
loading: false,
|
||||
error: 'Access denied (403)',
|
||||
});
|
||||
|
||||
render(<DashboardView />);
|
||||
expect(screen.getByText('Access denied (403)')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders score, check distribution, and cluster info with data', () => {
|
||||
const data = makeAuditData([
|
||||
makeResult({
|
||||
Results: {
|
||||
c1: {
|
||||
ID: 'c1',
|
||||
Message: '',
|
||||
Details: [],
|
||||
Success: true,
|
||||
Severity: 'warning',
|
||||
Category: 'X',
|
||||
},
|
||||
c2: {
|
||||
ID: 'c2',
|
||||
Message: '',
|
||||
Details: [],
|
||||
Success: false,
|
||||
Severity: 'danger',
|
||||
Category: 'X',
|
||||
},
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
mockUsePolarisDataContext.mockReturnValue({
|
||||
data,
|
||||
loading: false,
|
||||
error: null,
|
||||
});
|
||||
|
||||
render(<DashboardView />);
|
||||
|
||||
// Score circle shows 50%
|
||||
expect(screen.getByTestId('percentage-circle')).toHaveTextContent('50%');
|
||||
|
||||
// Check distribution values
|
||||
expect(screen.getByText('Total Checks')).toBeInTheDocument();
|
||||
|
||||
// Cluster info section (title is in data-title attr of SectionBox)
|
||||
const sectionBoxes = screen.getAllByTestId('section-box');
|
||||
const clusterInfoBox = sectionBoxes.find(
|
||||
el => el.getAttribute('data-title') === 'Cluster Info'
|
||||
);
|
||||
expect(clusterInfoBox).toBeDefined();
|
||||
|
||||
// Cluster info values
|
||||
expect(screen.getByText('Nodes')).toBeInTheDocument();
|
||||
expect(screen.getByText('Pods')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders "No Data" when no data and no error', () => {
|
||||
mockUsePolarisDataContext.mockReturnValue({
|
||||
data: null,
|
||||
loading: false,
|
||||
error: null,
|
||||
});
|
||||
|
||||
render(<DashboardView />);
|
||||
expect(screen.getByText('No Polaris audit results found.')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,195 +0,0 @@
|
||||
import {
|
||||
Loader,
|
||||
NameValueTable,
|
||||
PercentageBar,
|
||||
PercentageCircle,
|
||||
SectionBox,
|
||||
SectionHeader,
|
||||
SimpleTable,
|
||||
StatusLabel,
|
||||
} from '@kinvolk/headlamp-plugin/lib/CommonComponents';
|
||||
import React from 'react';
|
||||
import { AuditData, computeScore, countResults, ResultCounts } from '../api/polaris';
|
||||
import { usePolarisDataContext } from '../api/PolarisDataContext';
|
||||
import { getTopIssues, TopIssue } from '../api/topIssues';
|
||||
import { getSeverityStatus } from '../api/checkMapping';
|
||||
|
||||
const COLORS = {
|
||||
pass: '#4caf50',
|
||||
warning: '#ff9800',
|
||||
danger: '#f44336',
|
||||
skipped: '#9e9e9e',
|
||||
};
|
||||
|
||||
function OverviewSection(props: { data: AuditData; counts: ResultCounts }) {
|
||||
const { counts } = props;
|
||||
const score = computeScore(counts);
|
||||
|
||||
const chartData = [
|
||||
{ name: 'Pass', value: counts.pass, fill: COLORS.pass },
|
||||
{ name: 'Warning', value: counts.warning, fill: COLORS.warning },
|
||||
{ name: 'Danger', value: counts.danger, fill: COLORS.danger },
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<SectionBox title="Cluster Score">
|
||||
<PercentageCircle data={chartData} total={counts.total} label={`${score}%`} />
|
||||
</SectionBox>
|
||||
<SectionBox title="Check Distribution">
|
||||
<PercentageBar data={chartData} total={counts.total} />
|
||||
<NameValueTable
|
||||
rows={[
|
||||
{ name: 'Total Checks', value: String(counts.total) },
|
||||
{
|
||||
name: 'Pass',
|
||||
value: <StatusLabel status="success">{counts.pass}</StatusLabel>,
|
||||
},
|
||||
{
|
||||
name: 'Warning',
|
||||
value: <StatusLabel status="warning">{counts.warning}</StatusLabel>,
|
||||
},
|
||||
{
|
||||
name: 'Danger',
|
||||
value: <StatusLabel status="error">{counts.danger}</StatusLabel>,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</SectionBox>
|
||||
<SectionBox title="Cluster Info">
|
||||
<NameValueTable
|
||||
rows={[
|
||||
{ name: 'Nodes', value: String(props.data.ClusterInfo.Nodes) },
|
||||
{ name: 'Pods', value: String(props.data.ClusterInfo.Pods) },
|
||||
{ name: 'Namespaces', value: String(props.data.ClusterInfo.Namespaces) },
|
||||
{ name: 'Controllers', value: String(props.data.ClusterInfo.Controllers) },
|
||||
]}
|
||||
/>
|
||||
</SectionBox>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function formatAuditTime(auditTime: string): string {
|
||||
const date = new Date(auditTime);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffMins = Math.floor(diffMs / 60000);
|
||||
|
||||
if (diffMins < 1) return 'just now';
|
||||
if (diffMins < 60) return `${diffMins} minute${diffMins > 1 ? 's' : ''} ago`;
|
||||
|
||||
const diffHours = Math.floor(diffMins / 60);
|
||||
if (diffHours < 24) return `${diffHours} hour${diffHours > 1 ? 's' : ''} ago`;
|
||||
|
||||
const diffDays = Math.floor(diffHours / 24);
|
||||
return `${diffDays} day${diffDays > 1 ? 's' : ''} ago`;
|
||||
}
|
||||
|
||||
export default function DashboardView() {
|
||||
const { data, loading, error, refresh } = usePolarisDataContext();
|
||||
|
||||
if (loading) {
|
||||
return <Loader title="Loading Polaris audit data..." />;
|
||||
}
|
||||
|
||||
const counts = data ? countResults(data) : null;
|
||||
const topIssues = data ? getTopIssues(data) : [];
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: '20px',
|
||||
}}
|
||||
>
|
||||
<SectionHeader title="Polaris — Overview" />
|
||||
{data && (
|
||||
<div style={{ display: 'flex', gap: '16px', alignItems: 'center' }}>
|
||||
<span style={{ fontSize: '14px', color: 'var(--mui-palette-text-secondary, #666)' }}>
|
||||
Last updated: {formatAuditTime(data.AuditTime)}
|
||||
</span>
|
||||
<button
|
||||
onClick={refresh}
|
||||
style={{
|
||||
padding: '6px 16px',
|
||||
backgroundColor: 'transparent',
|
||||
color: '#1976d2',
|
||||
border: '1px solid #1976d2',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '13px',
|
||||
fontWeight: 500,
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '4px',
|
||||
}}
|
||||
>
|
||||
<span>🔄</span>
|
||||
<span>Refresh</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<SectionBox title="Error">
|
||||
<NameValueTable
|
||||
rows={[
|
||||
{
|
||||
name: 'Status',
|
||||
value: <StatusLabel status="error">{error}</StatusLabel>,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</SectionBox>
|
||||
)}
|
||||
|
||||
{data && counts && (
|
||||
<>
|
||||
<OverviewSection data={data} counts={counts} />
|
||||
|
||||
{topIssues.length > 0 && (
|
||||
<SectionBox title="Top Issues">
|
||||
<SimpleTable
|
||||
columns={[
|
||||
{ label: 'Check', getter: (issue: TopIssue) => issue.checkName },
|
||||
{ label: 'Category', getter: (issue: TopIssue) => issue.category },
|
||||
{
|
||||
label: 'Severity',
|
||||
getter: (issue: TopIssue) => (
|
||||
<StatusLabel status={getSeverityStatus(issue.severity)}>
|
||||
{issue.severity}
|
||||
</StatusLabel>
|
||||
),
|
||||
},
|
||||
{
|
||||
label: 'Affected Workloads',
|
||||
getter: (issue: TopIssue) => String(issue.count),
|
||||
},
|
||||
]}
|
||||
data={topIssues}
|
||||
/>
|
||||
</SectionBox>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{!data && !error && (
|
||||
<SectionBox title="No Data">
|
||||
<NameValueTable
|
||||
rows={[
|
||||
{
|
||||
name: 'Status',
|
||||
value: 'No Polaris audit results found.',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</SectionBox>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,304 +0,0 @@
|
||||
import { NameValueTable, SectionBox, Dialog } from '@kinvolk/headlamp-plugin/lib/CommonComponents';
|
||||
import { ApiProxy } from '@kinvolk/headlamp-plugin/lib';
|
||||
import React from 'react';
|
||||
import { Result } from '../api/polaris';
|
||||
import { getCheckName } from '../api/checkMapping';
|
||||
|
||||
interface ExemptionManagerProps {
|
||||
workloadResult: Result;
|
||||
namespace: string;
|
||||
kind: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface CheckFailure {
|
||||
checkId: string;
|
||||
checkName: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Exemption management UI for adding/removing Polaris exemptions
|
||||
* Uses annotation patches on the workload resource
|
||||
*/
|
||||
export default function ExemptionManager({
|
||||
workloadResult,
|
||||
namespace,
|
||||
kind,
|
||||
name,
|
||||
}: ExemptionManagerProps) {
|
||||
const [dialogOpen, setDialogOpen] = React.useState(false);
|
||||
const [selectedChecks, setSelectedChecks] = React.useState<Set<string>>(new Set());
|
||||
const [exemptAll, setExemptAll] = React.useState(false);
|
||||
const [applying, setApplying] = React.useState(false);
|
||||
|
||||
// Extract current exemptions from workload metadata
|
||||
const getExemptions = (): string[] => {
|
||||
// This would need to fetch the actual workload from K8s API
|
||||
// For now, return empty array as placeholder
|
||||
return [];
|
||||
};
|
||||
|
||||
// Extract failing checks for this workload
|
||||
const getFailingChecks = (): CheckFailure[] => {
|
||||
const failures: CheckFailure[] = [];
|
||||
|
||||
// Pod-level checks
|
||||
if (workloadResult.PodResult?.Results) {
|
||||
for (const [checkId, checkResult] of Object.entries(workloadResult.PodResult.Results)) {
|
||||
if (!checkResult.Success && checkResult.Severity !== 'ignore') {
|
||||
failures.push({
|
||||
checkId,
|
||||
checkName: getCheckName(checkId),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Container checks
|
||||
if (workloadResult.PodResult?.ContainerResults) {
|
||||
for (const container of workloadResult.PodResult.ContainerResults) {
|
||||
for (const [checkId, checkResult] of Object.entries(container.Results)) {
|
||||
if (!checkResult.Success && checkResult.Severity !== 'ignore') {
|
||||
// Avoid duplicates
|
||||
if (!failures.some(f => f.checkId === checkId)) {
|
||||
failures.push({
|
||||
checkId,
|
||||
checkName: getCheckName(checkId),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return failures;
|
||||
};
|
||||
|
||||
const failingChecks = getFailingChecks();
|
||||
const currentExemptions = getExemptions();
|
||||
|
||||
const handleCheckToggle = (checkId: string) => {
|
||||
const newSelected = new Set(selectedChecks);
|
||||
if (newSelected.has(checkId)) {
|
||||
newSelected.delete(checkId);
|
||||
} else {
|
||||
newSelected.add(checkId);
|
||||
}
|
||||
setSelectedChecks(newSelected);
|
||||
};
|
||||
|
||||
const applyExemptions = async () => {
|
||||
setApplying(true);
|
||||
|
||||
try {
|
||||
// Construct the API path based on kind
|
||||
const apiGroup = getApiGroup(kind);
|
||||
const apiVersion = 'v1'; // This would need to be dynamic based on kind
|
||||
const plural = getPlural(kind);
|
||||
|
||||
const patchPath = apiGroup
|
||||
? `/apis/${apiGroup}/${apiVersion}/namespaces/${namespace}/${plural}/${name}`
|
||||
: `/api/v1/namespaces/${namespace}/${plural}/${name}`;
|
||||
|
||||
// Build annotations patch
|
||||
const annotations: Record<string, string> = {};
|
||||
|
||||
if (exemptAll) {
|
||||
annotations['polaris.fairwinds.com/exempt'] = 'true';
|
||||
} else {
|
||||
for (const checkId of selectedChecks) {
|
||||
annotations[`polaris.fairwinds.com/${checkId}-exempt`] = 'true';
|
||||
}
|
||||
}
|
||||
|
||||
const patch = {
|
||||
metadata: {
|
||||
annotations,
|
||||
},
|
||||
};
|
||||
|
||||
await ApiProxy.request(patchPath, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/strategic-merge-patch+json',
|
||||
},
|
||||
body: JSON.stringify(patch),
|
||||
});
|
||||
|
||||
setDialogOpen(false);
|
||||
setSelectedChecks(new Set());
|
||||
setExemptAll(false);
|
||||
|
||||
// Show success message (would need notistack integration)
|
||||
alert('Exemptions applied successfully');
|
||||
} catch (err) {
|
||||
alert(`Failed to apply exemptions: ${String(err)}`);
|
||||
} finally {
|
||||
setApplying(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<SectionBox title="Exemptions">
|
||||
{currentExemptions.length > 0 ? (
|
||||
<NameValueTable
|
||||
rows={currentExemptions.map(exemption => ({
|
||||
name: exemption,
|
||||
value: (
|
||||
<button
|
||||
style={{
|
||||
padding: '4px 12px',
|
||||
backgroundColor: '#f44336',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '12px',
|
||||
}}
|
||||
onClick={() => {
|
||||
// Remove exemption logic
|
||||
alert('Remove exemption: ' + exemption);
|
||||
}}
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
),
|
||||
}))}
|
||||
/>
|
||||
) : (
|
||||
<p>No exemptions configured</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={() => setDialogOpen(true)}
|
||||
disabled={failingChecks.length === 0}
|
||||
style={{
|
||||
marginTop: '8px',
|
||||
padding: '6px 16px',
|
||||
backgroundColor: failingChecks.length === 0 ? '#ccc' : 'transparent',
|
||||
color: failingChecks.length === 0 ? '#999' : '#1976d2',
|
||||
border: '1px solid',
|
||||
borderColor: failingChecks.length === 0 ? '#ccc' : '#1976d2',
|
||||
borderRadius: '4px',
|
||||
cursor: failingChecks.length === 0 ? 'not-allowed' : 'pointer',
|
||||
fontSize: '13px',
|
||||
}}
|
||||
>
|
||||
Add Exemption
|
||||
</button>
|
||||
</SectionBox>
|
||||
|
||||
<Dialog open={dialogOpen} onClose={() => setDialogOpen(false)} title="Add Exemptions">
|
||||
<div style={{ padding: '16px', minWidth: '400px' }}>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: '8px', cursor: 'pointer' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={exemptAll}
|
||||
onChange={e => setExemptAll(e.target.checked)}
|
||||
/>
|
||||
<span>Exempt from all checks</span>
|
||||
</label>
|
||||
|
||||
{!exemptAll && (
|
||||
<>
|
||||
<div style={{ marginTop: '16px', marginBottom: '8px', fontWeight: 600 }}>
|
||||
Select checks to exempt:
|
||||
</div>
|
||||
<div>
|
||||
{failingChecks.map(check => (
|
||||
<label
|
||||
key={check.checkId}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
marginBottom: '8px',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedChecks.has(check.checkId)}
|
||||
onChange={() => handleCheckToggle(check.checkId)}
|
||||
/>
|
||||
<span>{check.checkName}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div
|
||||
style={{ marginTop: '16px', display: 'flex', gap: '8px', justifyContent: 'flex-end' }}
|
||||
>
|
||||
<button
|
||||
onClick={() => setDialogOpen(false)}
|
||||
style={{
|
||||
padding: '6px 16px',
|
||||
backgroundColor: 'transparent',
|
||||
color: '#1976d2',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '13px',
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={applyExemptions}
|
||||
disabled={applying || (!exemptAll && selectedChecks.size === 0)}
|
||||
style={{
|
||||
padding: '6px 16px',
|
||||
backgroundColor:
|
||||
applying || (!exemptAll && selectedChecks.size === 0) ? '#ccc' : '#1976d2',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor:
|
||||
applying || (!exemptAll && selectedChecks.size === 0) ? 'not-allowed' : 'pointer',
|
||||
fontSize: '13px',
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
{applying ? 'Applying...' : 'Apply'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Helper functions to get API info based on kind
|
||||
function getApiGroup(kind: string): string | null {
|
||||
switch (kind) {
|
||||
case 'Deployment':
|
||||
case 'StatefulSet':
|
||||
case 'DaemonSet':
|
||||
return 'apps';
|
||||
case 'Job':
|
||||
case 'CronJob':
|
||||
return 'batch';
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function getPlural(kind: string): string {
|
||||
switch (kind) {
|
||||
case 'Deployment':
|
||||
return 'deployments';
|
||||
case 'StatefulSet':
|
||||
return 'statefulsets';
|
||||
case 'DaemonSet':
|
||||
return 'daemonsets';
|
||||
case 'Job':
|
||||
return 'jobs';
|
||||
case 'CronJob':
|
||||
return 'cronjobs';
|
||||
default:
|
||||
return kind.toLowerCase() + 's';
|
||||
}
|
||||
}
|
||||
@@ -1,177 +0,0 @@
|
||||
import {
|
||||
NameValueTable,
|
||||
SectionBox,
|
||||
StatusLabel,
|
||||
SimpleTable,
|
||||
} from '@kinvolk/headlamp-plugin/lib/CommonComponents';
|
||||
import { Link } from 'react-router-dom';
|
||||
import React from 'react';
|
||||
import { usePolarisDataContext } from '../api/PolarisDataContext';
|
||||
import { computeScore, countResultsForItems, ResultCounts } from '../api/polaris';
|
||||
import { getCheckName, getSeverityStatus } from '../api/checkMapping';
|
||||
import ExemptionManager from './ExemptionManager';
|
||||
|
||||
interface CheckFailure {
|
||||
checkId: string;
|
||||
checkName: string;
|
||||
severity: 'danger' | 'warning';
|
||||
message: string;
|
||||
}
|
||||
|
||||
interface InlineAuditSectionProps {
|
||||
resource: any; // KubeObject from Headlamp
|
||||
}
|
||||
|
||||
/**
|
||||
* Inline Polaris audit section for resource detail views
|
||||
* Shows a compact summary of Polaris findings for Deployments, StatefulSets, etc.
|
||||
*/
|
||||
export default function InlineAuditSection({ resource }: InlineAuditSectionProps) {
|
||||
const { data, loading } = usePolarisDataContext();
|
||||
|
||||
if (loading || !data) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check if this is a supported controller kind
|
||||
const supportedKinds = ['Deployment', 'StatefulSet', 'DaemonSet', 'Job', 'CronJob'];
|
||||
const kind = resource.kind;
|
||||
|
||||
if (!supportedKinds.includes(kind)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const name = resource.metadata?.name;
|
||||
const namespace = resource.metadata?.namespace;
|
||||
|
||||
if (!name || !namespace) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Find this workload in Polaris audit data
|
||||
const workloadResult = data.Results.find(
|
||||
r => r.Kind === kind && r.Name === name && r.Namespace === namespace
|
||||
);
|
||||
|
||||
if (!workloadResult) {
|
||||
return (
|
||||
<SectionBox title="Polaris Audit">
|
||||
<NameValueTable
|
||||
rows={[
|
||||
{
|
||||
name: 'Status',
|
||||
value: 'Polaris dashboard not detected — install Polaris to see audit results',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</SectionBox>
|
||||
);
|
||||
}
|
||||
|
||||
// Calculate score and counts
|
||||
const counts = countResultsForItems([workloadResult]);
|
||||
const score = computeScore(counts);
|
||||
|
||||
// Extract failing checks
|
||||
const failures: CheckFailure[] = [];
|
||||
|
||||
// Pod-level checks
|
||||
if (workloadResult.PodResult?.Results) {
|
||||
for (const [checkId, checkResult] of Object.entries(workloadResult.PodResult.Results)) {
|
||||
if (!checkResult.Success && checkResult.Severity !== 'ignore') {
|
||||
failures.push({
|
||||
checkId,
|
||||
checkName: getCheckName(checkId),
|
||||
severity: checkResult.Severity as 'danger' | 'warning',
|
||||
message: checkResult.Message,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Container checks
|
||||
if (workloadResult.PodResult?.ContainerResults) {
|
||||
for (const container of workloadResult.PodResult.ContainerResults) {
|
||||
for (const [checkId, checkResult] of Object.entries(container.Results)) {
|
||||
if (!checkResult.Success && checkResult.Severity !== 'ignore') {
|
||||
// Avoid duplicates
|
||||
if (!failures.some(f => f.checkId === checkId)) {
|
||||
failures.push({
|
||||
checkId,
|
||||
checkName: getCheckName(checkId),
|
||||
severity: checkResult.Severity as 'danger' | 'warning',
|
||||
message: checkResult.Message,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by severity
|
||||
failures.sort((a, b) => {
|
||||
if (a.severity === 'danger' && b.severity !== 'danger') return -1;
|
||||
if (a.severity !== 'danger' && b.severity === 'danger') return 1;
|
||||
return 0;
|
||||
});
|
||||
|
||||
return (
|
||||
<SectionBox title="Polaris Audit">
|
||||
<NameValueTable
|
||||
rows={[
|
||||
{
|
||||
name: 'Score',
|
||||
value: (
|
||||
<StatusLabel status={score >= 80 ? 'success' : score >= 50 ? 'warning' : 'error'}>
|
||||
{score}%
|
||||
</StatusLabel>
|
||||
),
|
||||
},
|
||||
{
|
||||
name: 'Summary',
|
||||
value: `${counts.pass} passing, ${counts.warning} warnings, ${counts.danger} dangers`,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
{failures.length > 0 && (
|
||||
<>
|
||||
<div style={{ marginTop: '16px', marginBottom: '8px', fontWeight: 600 }}>
|
||||
Failing Checks:
|
||||
</div>
|
||||
<SimpleTable
|
||||
columns={[
|
||||
{ label: 'Check', getter: (f: CheckFailure) => f.checkName },
|
||||
{
|
||||
label: 'Severity',
|
||||
getter: (f: CheckFailure) => (
|
||||
<StatusLabel status={getSeverityStatus(f.severity)}>{f.severity}</StatusLabel>
|
||||
),
|
||||
},
|
||||
{ label: 'Message', getter: (f: CheckFailure) => f.message },
|
||||
]}
|
||||
data={failures}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div style={{ marginTop: '16px' }}>
|
||||
<Link
|
||||
to={`/polaris/namespaces#${namespace}`}
|
||||
style={{ color: 'var(--link-color, #1976d2)' }}
|
||||
>
|
||||
View Full Report →
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: '16px' }}>
|
||||
<ExemptionManager
|
||||
workloadResult={workloadResult}
|
||||
namespace={namespace}
|
||||
kind={kind}
|
||||
name={name}
|
||||
/>
|
||||
</div>
|
||||
</SectionBox>
|
||||
);
|
||||
}
|
||||
@@ -1,200 +0,0 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { makeAuditData, makeResult } from '../test-utils';
|
||||
|
||||
// Mock Headlamp lib
|
||||
vi.mock('@kinvolk/headlamp-plugin/lib', () => ({
|
||||
ApiProxy: { request: vi.fn() },
|
||||
}));
|
||||
|
||||
// Mock react-router-dom useParams
|
||||
const mockNamespace = vi.fn(() => 'test-ns');
|
||||
vi.mock('react-router-dom', () => ({
|
||||
useParams: () => ({ namespace: mockNamespace() }),
|
||||
}));
|
||||
|
||||
// Mock Headlamp CommonComponents
|
||||
vi.mock('@kinvolk/headlamp-plugin/lib/CommonComponents', () => ({
|
||||
Loader: ({ title }: { title: string }) => <div data-testid="loader">{title}</div>,
|
||||
SectionBox: ({ title, children }: { title?: string; children?: React.ReactNode }) => (
|
||||
<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>
|
||||
),
|
||||
NameValueTable: ({ rows }: { rows: Array<{ name: string; value: React.ReactNode }> }) => (
|
||||
<table data-testid="name-value-table">
|
||||
<tbody>
|
||||
{rows.map(row => (
|
||||
<tr key={row.name}>
|
||||
<td>{row.name}</td>
|
||||
<td>{row.value}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
),
|
||||
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>
|
||||
),
|
||||
}));
|
||||
|
||||
const mockUsePolarisDataContext = vi.fn();
|
||||
vi.mock('../api/PolarisDataContext', () => ({
|
||||
usePolarisDataContext: () => mockUsePolarisDataContext(),
|
||||
}));
|
||||
|
||||
import NamespaceDetailView from './NamespaceDetailView';
|
||||
|
||||
describe('NamespaceDetailView', () => {
|
||||
it('renders loader when loading', () => {
|
||||
mockUsePolarisDataContext.mockReturnValue({
|
||||
data: null,
|
||||
loading: true,
|
||||
error: null,
|
||||
});
|
||||
|
||||
render(<NamespaceDetailView />);
|
||||
expect(screen.getByTestId('loader')).toHaveTextContent('Loading Polaris data for test-ns');
|
||||
});
|
||||
|
||||
it('renders error message when error is set', () => {
|
||||
mockUsePolarisDataContext.mockReturnValue({
|
||||
data: null,
|
||||
loading: false,
|
||||
error: 'Access denied (403)',
|
||||
});
|
||||
|
||||
render(<NamespaceDetailView />);
|
||||
expect(screen.getByText('Access denied (403)')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('section-header')).toHaveTextContent('Polaris — test-ns');
|
||||
});
|
||||
|
||||
it('renders "No Data" when no data and no error', () => {
|
||||
mockUsePolarisDataContext.mockReturnValue({
|
||||
data: null,
|
||||
loading: false,
|
||||
error: null,
|
||||
});
|
||||
|
||||
render(<NamespaceDetailView />);
|
||||
expect(screen.getByText('No Polaris audit results found.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders namespace score and resource table with data', () => {
|
||||
const data = makeAuditData([
|
||||
makeResult({
|
||||
Name: 'deploy-a',
|
||||
Namespace: 'test-ns',
|
||||
Kind: 'Deployment',
|
||||
Results: {
|
||||
c1: {
|
||||
ID: 'c1',
|
||||
Message: '',
|
||||
Details: [],
|
||||
Success: true,
|
||||
Severity: 'warning',
|
||||
Category: 'X',
|
||||
},
|
||||
c2: {
|
||||
ID: 'c2',
|
||||
Message: '',
|
||||
Details: [],
|
||||
Success: false,
|
||||
Severity: 'warning',
|
||||
Category: 'X',
|
||||
},
|
||||
},
|
||||
}),
|
||||
makeResult({
|
||||
Name: 'other',
|
||||
Namespace: 'other-ns',
|
||||
Kind: 'Deployment',
|
||||
Results: {
|
||||
c3: {
|
||||
ID: 'c3',
|
||||
Message: '',
|
||||
Details: [],
|
||||
Success: true,
|
||||
Severity: 'warning',
|
||||
Category: 'X',
|
||||
},
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
mockUsePolarisDataContext.mockReturnValue({
|
||||
data,
|
||||
loading: false,
|
||||
error: null,
|
||||
});
|
||||
|
||||
render(<NamespaceDetailView />);
|
||||
|
||||
// Header
|
||||
expect(screen.getByTestId('section-header')).toHaveTextContent('Polaris — test-ns');
|
||||
|
||||
// Score section: 50% (1 pass / 2 total)
|
||||
expect(screen.getByText('50%')).toBeInTheDocument();
|
||||
expect(screen.getByText('Total Checks')).toBeInTheDocument();
|
||||
|
||||
// Resource table shows only test-ns resources
|
||||
expect(screen.getByText('deploy-a')).toBeInTheDocument();
|
||||
expect(screen.queryByText('other')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders empty table message for namespace with no results', () => {
|
||||
const data = makeAuditData([
|
||||
makeResult({
|
||||
Name: 'deploy-a',
|
||||
Namespace: 'other-ns',
|
||||
Results: {},
|
||||
}),
|
||||
]);
|
||||
|
||||
mockUsePolarisDataContext.mockReturnValue({
|
||||
data,
|
||||
loading: false,
|
||||
error: null,
|
||||
});
|
||||
|
||||
render(<NamespaceDetailView />);
|
||||
expect(screen.getByTestId('simple-table-empty')).toHaveTextContent(
|
||||
'No resources found in namespace "test-ns"'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,163 +0,0 @@
|
||||
import {
|
||||
Loader,
|
||||
NameValueTable,
|
||||
SectionBox,
|
||||
SectionHeader,
|
||||
SimpleTable,
|
||||
StatusLabel,
|
||||
} from '@kinvolk/headlamp-plugin/lib/CommonComponents';
|
||||
import React from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import {
|
||||
computeScore,
|
||||
countResultsForItems,
|
||||
filterResultsByNamespace,
|
||||
getPolarisProxyUrl,
|
||||
Result,
|
||||
ResultCounts,
|
||||
} from '../api/polaris';
|
||||
import { usePolarisDataContext } from '../api/PolarisDataContext';
|
||||
|
||||
function scoreStatus(score: number): 'success' | 'warning' | 'error' {
|
||||
if (score >= 80) return 'success';
|
||||
if (score >= 50) return 'warning';
|
||||
return 'error';
|
||||
}
|
||||
|
||||
function resourceCounts(result: Result): ResultCounts {
|
||||
return countResultsForItems([result]);
|
||||
}
|
||||
|
||||
export default function NamespaceDetailView() {
|
||||
const { namespace } = useParams<{ namespace: string }>();
|
||||
const { data, loading, error } = usePolarisDataContext();
|
||||
|
||||
if (loading) {
|
||||
return <Loader title={`Loading Polaris data for ${namespace}...`} />;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<>
|
||||
<SectionHeader title={`Polaris — ${namespace}`} />
|
||||
<SectionBox title="Error">
|
||||
<NameValueTable
|
||||
rows={[
|
||||
{
|
||||
name: 'Status',
|
||||
value: <StatusLabel status="error">{error}</StatusLabel>,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</SectionBox>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return (
|
||||
<>
|
||||
<SectionHeader title={`Polaris — ${namespace}`} />
|
||||
<SectionBox title="No Data">
|
||||
<NameValueTable rows={[{ name: 'Status', value: 'No Polaris audit results found.' }]} />
|
||||
</SectionBox>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const results = filterResultsByNamespace(data, namespace);
|
||||
const counts = countResultsForItems(results);
|
||||
const score = computeScore(counts);
|
||||
const status = scoreStatus(score);
|
||||
|
||||
const countsPerResource = new Map<string, ResultCounts>();
|
||||
for (const r of results) {
|
||||
countsPerResource.set(`${r.Namespace}/${r.Kind}/${r.Name}`, resourceCounts(r));
|
||||
}
|
||||
|
||||
function getResourceCounts(row: Result): ResultCounts {
|
||||
return countsPerResource.get(`${row.Namespace}/${row.Kind}/${row.Name}`) ?? resourceCounts(row);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<SectionHeader title={`Polaris — ${namespace}`} />
|
||||
|
||||
<SectionBox title="External">
|
||||
<NameValueTable
|
||||
rows={[
|
||||
{
|
||||
name: 'Polaris Dashboard',
|
||||
value: (
|
||||
<a href={getPolarisProxyUrl()} target="_blank" rel="noopener noreferrer">
|
||||
View in Polaris Dashboard
|
||||
</a>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</SectionBox>
|
||||
|
||||
<SectionBox title="Namespace Score">
|
||||
<NameValueTable
|
||||
rows={[
|
||||
{
|
||||
name: 'Score',
|
||||
value: <StatusLabel status={status}>{score}%</StatusLabel>,
|
||||
},
|
||||
{ name: 'Total Checks', value: String(counts.total) },
|
||||
{
|
||||
name: 'Pass',
|
||||
value: <StatusLabel status="success">{counts.pass}</StatusLabel>,
|
||||
},
|
||||
{
|
||||
name: 'Warning',
|
||||
value: <StatusLabel status="warning">{counts.warning}</StatusLabel>,
|
||||
},
|
||||
{
|
||||
name: 'Danger',
|
||||
value: <StatusLabel status="error">{counts.danger}</StatusLabel>,
|
||||
},
|
||||
{
|
||||
name: 'Skipped',
|
||||
value: (
|
||||
<span title="Only counts checks with Severity=ignore. Annotation-based exemptions are not included.">
|
||||
{counts.skipped}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</SectionBox>
|
||||
|
||||
<SectionBox title="Resources">
|
||||
<SimpleTable
|
||||
columns={[
|
||||
{ label: 'Name', getter: (row: Result) => row.Name },
|
||||
{ label: 'Kind', getter: (row: Result) => row.Kind },
|
||||
{
|
||||
label: 'Pass',
|
||||
getter: (row: Result) => (
|
||||
<StatusLabel status="success">{getResourceCounts(row).pass}</StatusLabel>
|
||||
),
|
||||
},
|
||||
{
|
||||
label: 'Warning',
|
||||
getter: (row: Result) => (
|
||||
<StatusLabel status="warning">{getResourceCounts(row).warning}</StatusLabel>
|
||||
),
|
||||
},
|
||||
{
|
||||
label: 'Danger',
|
||||
getter: (row: Result) => (
|
||||
<StatusLabel status="error">{getResourceCounts(row).danger}</StatusLabel>
|
||||
),
|
||||
},
|
||||
]}
|
||||
data={results}
|
||||
emptyMessage={`No resources found in namespace "${namespace}".`}
|
||||
/>
|
||||
</SectionBox>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,292 +0,0 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import React from 'react';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { makeAuditData, makeResult } from '../test-utils';
|
||||
|
||||
// Mock Headlamp lib
|
||||
vi.mock('@kinvolk/headlamp-plugin/lib', () => ({
|
||||
ApiProxy: { request: vi.fn() },
|
||||
Router: {
|
||||
createRouteURL: (name: string, params: Record<string, string>) =>
|
||||
`/polaris/ns/${params.namespace}`,
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock Headlamp CommonComponents
|
||||
vi.mock('@kinvolk/headlamp-plugin/lib/CommonComponents', () => ({
|
||||
Loader: ({ title }: { title: string }) => <div data-testid="loader">{title}</div>,
|
||||
SectionBox: ({ title, children }: { title?: string; children?: React.ReactNode }) => (
|
||||
<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>
|
||||
),
|
||||
NameValueTable: ({ rows }: { rows: Array<{ name: string; value: React.ReactNode }> }) => (
|
||||
<table data-testid="name-value-table">
|
||||
<tbody>
|
||||
{rows.map(row => (
|
||||
<tr key={row.name}>
|
||||
<td>{row.name}</td>
|
||||
<td>{row.value}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
),
|
||||
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>
|
||||
),
|
||||
}));
|
||||
|
||||
const mockUsePolarisDataContext = vi.fn();
|
||||
vi.mock('../api/PolarisDataContext', () => ({
|
||||
usePolarisDataContext: () => mockUsePolarisDataContext(),
|
||||
}));
|
||||
|
||||
import NamespacesListView from './NamespacesListView';
|
||||
|
||||
function renderWithRouter(ui: React.ReactElement) {
|
||||
return render(<MemoryRouter>{ui}</MemoryRouter>);
|
||||
}
|
||||
|
||||
describe('NamespacesListView', () => {
|
||||
it('renders loader when loading', () => {
|
||||
mockUsePolarisDataContext.mockReturnValue({
|
||||
data: null,
|
||||
loading: true,
|
||||
error: null,
|
||||
});
|
||||
|
||||
renderWithRouter(<NamespacesListView />);
|
||||
expect(screen.getByTestId('loader')).toHaveTextContent('Loading Polaris audit data');
|
||||
});
|
||||
|
||||
it('renders error message when error is set', () => {
|
||||
mockUsePolarisDataContext.mockReturnValue({
|
||||
data: null,
|
||||
loading: false,
|
||||
error: 'Polaris dashboard not reachable',
|
||||
});
|
||||
|
||||
renderWithRouter(<NamespacesListView />);
|
||||
expect(screen.getByText('Polaris dashboard not reachable')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders "No Data" when no data and no error', () => {
|
||||
mockUsePolarisDataContext.mockReturnValue({
|
||||
data: null,
|
||||
loading: false,
|
||||
error: null,
|
||||
});
|
||||
|
||||
renderWithRouter(<NamespacesListView />);
|
||||
expect(screen.getByText('No Polaris audit results found.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders namespace rows with correct scores and buttons', () => {
|
||||
const data = makeAuditData([
|
||||
makeResult({
|
||||
Name: 'deploy-a',
|
||||
Namespace: 'alpha',
|
||||
Results: {
|
||||
c1: {
|
||||
ID: 'c1',
|
||||
Message: '',
|
||||
Details: [],
|
||||
Success: true,
|
||||
Severity: 'warning',
|
||||
Category: 'X',
|
||||
},
|
||||
},
|
||||
}),
|
||||
makeResult({
|
||||
Name: 'deploy-b',
|
||||
Namespace: 'beta',
|
||||
Results: {
|
||||
c2: {
|
||||
ID: 'c2',
|
||||
Message: '',
|
||||
Details: [],
|
||||
Success: false,
|
||||
Severity: 'danger',
|
||||
Category: 'X',
|
||||
},
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
mockUsePolarisDataContext.mockReturnValue({
|
||||
data,
|
||||
loading: false,
|
||||
error: null,
|
||||
});
|
||||
|
||||
renderWithRouter(<NamespacesListView />);
|
||||
|
||||
// Namespace buttons (now buttons instead of links for drawer)
|
||||
const alphaButton = screen.getByText('alpha');
|
||||
expect(alphaButton).toBeInTheDocument();
|
||||
expect(alphaButton.tagName).toBe('BUTTON');
|
||||
|
||||
const betaButton = screen.getByText('beta');
|
||||
expect(betaButton).toBeInTheDocument();
|
||||
expect(betaButton.tagName).toBe('BUTTON');
|
||||
});
|
||||
|
||||
it('uses correct scoreStatus: >=80 success, >=50 warning, <50 error', () => {
|
||||
// Create a namespace with 100% score (1 pass) and one with 0% (1 danger)
|
||||
const data = makeAuditData([
|
||||
makeResult({
|
||||
Name: 'perfect',
|
||||
Namespace: 'good-ns',
|
||||
Results: {
|
||||
c1: {
|
||||
ID: 'c1',
|
||||
Message: '',
|
||||
Details: [],
|
||||
Success: true,
|
||||
Severity: 'warning',
|
||||
Category: 'X',
|
||||
},
|
||||
},
|
||||
}),
|
||||
makeResult({
|
||||
Name: 'bad',
|
||||
Namespace: 'bad-ns',
|
||||
Results: {
|
||||
c2: {
|
||||
ID: 'c2',
|
||||
Message: '',
|
||||
Details: [],
|
||||
Success: false,
|
||||
Severity: 'danger',
|
||||
Category: 'X',
|
||||
},
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
mockUsePolarisDataContext.mockReturnValue({
|
||||
data,
|
||||
loading: false,
|
||||
error: null,
|
||||
});
|
||||
|
||||
renderWithRouter(<NamespacesListView />);
|
||||
|
||||
// Find score StatusLabels - good-ns has 100% (success), bad-ns has 0% (error)
|
||||
const statusLabels = screen.getAllByTestId('status-label');
|
||||
const scoreLabels = statusLabels.filter(el => el.textContent?.includes('%'));
|
||||
|
||||
const successScore = scoreLabels.find(el => el.textContent === '100%');
|
||||
expect(successScore).toHaveAttribute('data-status', 'success');
|
||||
|
||||
const errorScore = scoreLabels.find(el => el.textContent === '0%');
|
||||
expect(errorScore).toHaveAttribute('data-status', 'error');
|
||||
});
|
||||
|
||||
it('opens drawer when namespace button is clicked and URL hash is updated', async () => {
|
||||
const user = userEvent.setup();
|
||||
const data = makeAuditData([
|
||||
makeResult({
|
||||
Name: 'deploy-a',
|
||||
Namespace: 'alpha',
|
||||
Results: {
|
||||
c1: {
|
||||
ID: 'c1',
|
||||
Message: '',
|
||||
Details: [],
|
||||
Success: true,
|
||||
Severity: 'warning',
|
||||
Category: 'X',
|
||||
},
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
mockUsePolarisDataContext.mockReturnValue({
|
||||
data,
|
||||
loading: false,
|
||||
error: null,
|
||||
});
|
||||
|
||||
renderWithRouter(<NamespacesListView />);
|
||||
|
||||
// Click the namespace button
|
||||
const alphaButton = screen.getByText('alpha');
|
||||
await user.click(alphaButton);
|
||||
|
||||
// Drawer should open (check for the panel title)
|
||||
expect(screen.getByText(/Polaris — alpha/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('initializes drawer from URL hash', () => {
|
||||
const data = makeAuditData([
|
||||
makeResult({
|
||||
Name: 'deploy-a',
|
||||
Namespace: 'test-ns',
|
||||
Results: {
|
||||
c1: {
|
||||
ID: 'c1',
|
||||
Message: '',
|
||||
Details: [],
|
||||
Success: true,
|
||||
Severity: 'warning',
|
||||
Category: 'X',
|
||||
},
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
mockUsePolarisDataContext.mockReturnValue({
|
||||
data,
|
||||
loading: false,
|
||||
error: null,
|
||||
});
|
||||
|
||||
// Render with initial hash in URL
|
||||
render(
|
||||
<MemoryRouter initialEntries={['/polaris/namespaces#test-ns']}>
|
||||
<NamespacesListView />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
// Drawer should be open with the namespace from hash
|
||||
expect(screen.getByText(/Polaris — test-ns/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,385 +0,0 @@
|
||||
import {
|
||||
Loader,
|
||||
NameValueTable,
|
||||
SectionBox,
|
||||
SectionHeader,
|
||||
SimpleTable,
|
||||
StatusLabel,
|
||||
} from '@kinvolk/headlamp-plugin/lib/CommonComponents';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useHistory, useLocation } from 'react-router-dom';
|
||||
import {
|
||||
computeScore,
|
||||
countResultsForItems,
|
||||
filterResultsByNamespace,
|
||||
getNamespaces,
|
||||
getPolarisProxyUrl,
|
||||
Result,
|
||||
ResultCounts,
|
||||
} from '../api/polaris';
|
||||
import { usePolarisDataContext } from '../api/PolarisDataContext';
|
||||
|
||||
function scoreStatus(score: number): 'success' | 'warning' | 'error' {
|
||||
if (score >= 80) return 'success';
|
||||
if (score >= 50) return 'warning';
|
||||
return 'error';
|
||||
}
|
||||
|
||||
interface NamespaceRow {
|
||||
namespace: string;
|
||||
score: number;
|
||||
pass: number;
|
||||
warning: number;
|
||||
danger: number;
|
||||
skipped: number;
|
||||
}
|
||||
|
||||
function resourceCounts(result: Result): ResultCounts {
|
||||
return countResultsForItems([result]);
|
||||
}
|
||||
|
||||
interface NamespaceDetailPanelProps {
|
||||
namespace: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function NamespaceDetailPanel({ namespace, onClose }: NamespaceDetailPanelProps) {
|
||||
const { data, loading, error } = usePolarisDataContext();
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{ padding: '20px' }}>
|
||||
<Loader title={`Loading Polaris data for ${namespace}...`} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div style={{ padding: '20px' }}>
|
||||
<SectionBox title="Error">
|
||||
<NameValueTable
|
||||
rows={[
|
||||
{
|
||||
name: 'Status',
|
||||
value: <StatusLabel status="error">{error}</StatusLabel>,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</SectionBox>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return (
|
||||
<div style={{ padding: '20px' }}>
|
||||
<SectionBox title="No Data">
|
||||
<NameValueTable rows={[{ name: 'Status', value: 'No Polaris audit results found.' }]} />
|
||||
</SectionBox>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const results = filterResultsByNamespace(data, namespace);
|
||||
const counts = countResultsForItems(results);
|
||||
const score = computeScore(counts);
|
||||
const status = scoreStatus(score);
|
||||
|
||||
const countsPerResource = new Map<string, ResultCounts>();
|
||||
for (const r of results) {
|
||||
countsPerResource.set(`${r.Namespace}/${r.Kind}/${r.Name}`, resourceCounts(r));
|
||||
}
|
||||
|
||||
function getResourceCounts(row: Result): ResultCounts {
|
||||
return countsPerResource.get(`${row.Namespace}/${row.Kind}/${row.Name}`) ?? resourceCounts(row);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
right: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
width: '1000px',
|
||||
backgroundColor: 'var(--mui-palette-background-paper, var(--background-paper, #fff))',
|
||||
boxShadow: '-2px 0 8px rgba(0,0,0,0.15)',
|
||||
overflowY: 'auto',
|
||||
zIndex: 1200,
|
||||
padding: '20px',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
marginBottom: '20px',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<h2
|
||||
style={{ margin: 0, color: 'var(--mui-palette-text-primary, var(--text-primary, #000))' }}
|
||||
>
|
||||
Polaris — {namespace}
|
||||
</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
style={{
|
||||
border: 'none',
|
||||
background: 'transparent',
|
||||
fontSize: '24px',
|
||||
cursor: 'pointer',
|
||||
padding: '0 8px',
|
||||
color: 'var(--mui-palette-text-primary, var(--text-primary, #000))',
|
||||
}}
|
||||
aria-label="Close panel"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<SectionBox title="External">
|
||||
<NameValueTable
|
||||
rows={[
|
||||
{
|
||||
name: 'Polaris Dashboard',
|
||||
value: (
|
||||
<a href={getPolarisProxyUrl()} target="_blank" rel="noopener noreferrer">
|
||||
View in Polaris Dashboard
|
||||
</a>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</SectionBox>
|
||||
|
||||
<SectionBox title="Namespace Score">
|
||||
<NameValueTable
|
||||
rows={[
|
||||
{
|
||||
name: 'Score',
|
||||
value: <StatusLabel status={status}>{score}%</StatusLabel>,
|
||||
},
|
||||
{ name: 'Total Checks', value: String(counts.total) },
|
||||
{
|
||||
name: 'Pass',
|
||||
value: <StatusLabel status="success">{counts.pass}</StatusLabel>,
|
||||
},
|
||||
{
|
||||
name: 'Warning',
|
||||
value: <StatusLabel status="warning">{counts.warning}</StatusLabel>,
|
||||
},
|
||||
{
|
||||
name: 'Danger',
|
||||
value: <StatusLabel status="error">{counts.danger}</StatusLabel>,
|
||||
},
|
||||
{
|
||||
name: 'Skipped',
|
||||
value: (
|
||||
<span title="Only counts checks with Severity=ignore. Annotation-based exemptions are not included.">
|
||||
{counts.skipped}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</SectionBox>
|
||||
|
||||
<SectionBox title="Resources">
|
||||
<SimpleTable
|
||||
columns={[
|
||||
{ label: 'Name', getter: (row: Result) => row.Name },
|
||||
{ label: 'Kind', getter: (row: Result) => row.Kind },
|
||||
{
|
||||
label: 'Pass',
|
||||
getter: (row: Result) => (
|
||||
<StatusLabel status="success">{getResourceCounts(row).pass}</StatusLabel>
|
||||
),
|
||||
},
|
||||
{
|
||||
label: 'Warning',
|
||||
getter: (row: Result) => (
|
||||
<StatusLabel status="warning">{getResourceCounts(row).warning}</StatusLabel>
|
||||
),
|
||||
},
|
||||
{
|
||||
label: 'Danger',
|
||||
getter: (row: Result) => (
|
||||
<StatusLabel status="error">{getResourceCounts(row).danger}</StatusLabel>
|
||||
),
|
||||
},
|
||||
]}
|
||||
data={results}
|
||||
emptyMessage={`No resources found in namespace "${namespace}".`}
|
||||
/>
|
||||
</SectionBox>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function NamespacesListView() {
|
||||
const location = useLocation();
|
||||
const history = useHistory();
|
||||
const { data, loading, error } = usePolarisDataContext();
|
||||
|
||||
// Initialize from URL hash
|
||||
const [selectedNamespace, setSelectedNamespace] = useState<string | null>(
|
||||
location.hash.slice(1) || null
|
||||
);
|
||||
|
||||
// Sync drawer state when URL hash changes (browser back/forward)
|
||||
useEffect(() => {
|
||||
const hashNs = location.hash.slice(1);
|
||||
setSelectedNamespace(hashNs || null);
|
||||
}, [location.hash]);
|
||||
|
||||
const openNamespace = (ns: string) => {
|
||||
setSelectedNamespace(ns);
|
||||
history.push(`${location.pathname}#${ns}`);
|
||||
};
|
||||
|
||||
const closeNamespace = () => {
|
||||
setSelectedNamespace(null);
|
||||
history.push(location.pathname);
|
||||
};
|
||||
|
||||
// Handle keyboard navigation (Escape key closes drawer)
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape' && selectedNamespace) {
|
||||
closeNamespace();
|
||||
}
|
||||
};
|
||||
|
||||
if (selectedNamespace) {
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [selectedNamespace]);
|
||||
|
||||
if (loading) {
|
||||
return <Loader title="Loading Polaris audit data..." />;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<>
|
||||
<SectionHeader title="Polaris — Namespaces" />
|
||||
<SectionBox title="Error">
|
||||
<NameValueTable
|
||||
rows={[
|
||||
{
|
||||
name: 'Status',
|
||||
value: <StatusLabel status="error">{error}</StatusLabel>,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</SectionBox>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return (
|
||||
<>
|
||||
<SectionHeader title="Polaris — Namespaces" />
|
||||
<SectionBox title="No Data">
|
||||
<NameValueTable rows={[{ name: 'Status', value: 'No Polaris audit results found.' }]} />
|
||||
</SectionBox>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const namespaces = getNamespaces(data);
|
||||
const rows: NamespaceRow[] = namespaces.map(ns => {
|
||||
const results = filterResultsByNamespace(data, ns);
|
||||
const counts = countResultsForItems(results);
|
||||
const score = computeScore(counts);
|
||||
return {
|
||||
namespace: ns,
|
||||
score,
|
||||
pass: counts.pass,
|
||||
warning: counts.warning,
|
||||
danger: counts.danger,
|
||||
skipped: counts.skipped,
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<SectionHeader title="Polaris — Namespaces" />
|
||||
<SectionBox>
|
||||
<SimpleTable
|
||||
columns={[
|
||||
{
|
||||
label: 'Namespace',
|
||||
getter: (row: NamespaceRow) => (
|
||||
<button
|
||||
onClick={() => openNamespace(row.namespace)}
|
||||
style={{
|
||||
border: 'none',
|
||||
background: 'transparent',
|
||||
color: 'var(--link-color, #1976d2)',
|
||||
cursor: 'pointer',
|
||||
textDecoration: 'underline',
|
||||
padding: 0,
|
||||
font: 'inherit',
|
||||
}}
|
||||
>
|
||||
{row.namespace}
|
||||
</button>
|
||||
),
|
||||
},
|
||||
{
|
||||
label: 'Score',
|
||||
getter: (row: NamespaceRow) => (
|
||||
<StatusLabel status={scoreStatus(row.score)}>{row.score}%</StatusLabel>
|
||||
),
|
||||
},
|
||||
{
|
||||
label: 'Pass',
|
||||
getter: (row: NamespaceRow) => <StatusLabel status="success">{row.pass}</StatusLabel>,
|
||||
},
|
||||
{
|
||||
label: 'Warning',
|
||||
getter: (row: NamespaceRow) => (
|
||||
<StatusLabel status="warning">{row.warning}</StatusLabel>
|
||||
),
|
||||
},
|
||||
{
|
||||
label: 'Danger',
|
||||
getter: (row: NamespaceRow) => <StatusLabel status="error">{row.danger}</StatusLabel>,
|
||||
},
|
||||
{
|
||||
label: 'Skipped',
|
||||
getter: (row: NamespaceRow) => String(row.skipped),
|
||||
},
|
||||
]}
|
||||
data={rows}
|
||||
emptyMessage="No namespaces found in Polaris audit data."
|
||||
/>
|
||||
</SectionBox>
|
||||
|
||||
{selectedNamespace && (
|
||||
<>
|
||||
<div
|
||||
onClick={closeNamespace}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||
zIndex: 1100,
|
||||
}}
|
||||
aria-label="Close panel backdrop"
|
||||
/>
|
||||
<NamespaceDetailPanel namespace={selectedNamespace} onClose={closeNamespace} />
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import React from 'react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
// Mock Headlamp lib
|
||||
vi.mock('@kinvolk/headlamp-plugin/lib', () => ({
|
||||
ApiProxy: { request: vi.fn() },
|
||||
}));
|
||||
|
||||
// Mock Headlamp CommonComponents
|
||||
vi.mock('@kinvolk/headlamp-plugin/lib/CommonComponents', () => ({
|
||||
SectionBox: ({ title, children }: { title?: string; children?: React.ReactNode }) => (
|
||||
<div data-testid="section-box" data-title={title}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
NameValueTable: ({ rows }: { rows: Array<{ name: string; value: React.ReactNode }> }) => (
|
||||
<div data-testid="name-value-table">
|
||||
{rows.map(row => (
|
||||
<div key={row.name}>
|
||||
<span>{row.name}</span>
|
||||
<span>{row.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
import PolarisSettings from './PolarisSettings';
|
||||
|
||||
describe('PolarisSettings', () => {
|
||||
it('renders with interval from props.data', () => {
|
||||
render(<PolarisSettings data={{ refreshInterval: 60 }} />);
|
||||
|
||||
const select = screen.getByRole('combobox');
|
||||
expect(select).toHaveValue('60');
|
||||
});
|
||||
|
||||
it('falls back to getRefreshInterval when no prop data', () => {
|
||||
// Default is 300 (5 minutes)
|
||||
render(<PolarisSettings />);
|
||||
|
||||
const select = screen.getByRole('combobox');
|
||||
expect(select).toHaveValue('300');
|
||||
});
|
||||
|
||||
it('renders all interval options', () => {
|
||||
render(<PolarisSettings />);
|
||||
|
||||
const options = screen.getAllByRole('option');
|
||||
expect(options).toHaveLength(4);
|
||||
expect(options[0]).toHaveTextContent('1 minute');
|
||||
expect(options[1]).toHaveTextContent('5 minutes');
|
||||
expect(options[2]).toHaveTextContent('10 minutes');
|
||||
expect(options[3]).toHaveTextContent('30 minutes');
|
||||
});
|
||||
|
||||
it('calls setRefreshInterval and onDataChange when selection changes', async () => {
|
||||
const onDataChange = vi.fn();
|
||||
render(<PolarisSettings data={{ refreshInterval: 300 }} onDataChange={onDataChange} />);
|
||||
|
||||
const select = screen.getByRole('combobox');
|
||||
await userEvent.selectOptions(select, '1800');
|
||||
|
||||
// Check localStorage was updated
|
||||
expect(localStorage.getItem('polaris-plugin-refresh-interval')).toBe('1800');
|
||||
|
||||
// Check callback was called with merged data
|
||||
expect(onDataChange).toHaveBeenCalledWith({ refreshInterval: 1800 });
|
||||
});
|
||||
|
||||
it('works without onDataChange callback', async () => {
|
||||
render(<PolarisSettings data={{ refreshInterval: 300 }} />);
|
||||
|
||||
const select = screen.getByRole('combobox');
|
||||
// Should not throw even without onDataChange
|
||||
await userEvent.selectOptions(select, '60');
|
||||
|
||||
expect(localStorage.getItem('polaris-plugin-refresh-interval')).toBe('60');
|
||||
});
|
||||
});
|
||||
@@ -1,156 +0,0 @@
|
||||
import {
|
||||
NameValueTable,
|
||||
SectionBox,
|
||||
StatusLabel,
|
||||
} from '@kinvolk/headlamp-plugin/lib/CommonComponents';
|
||||
import { ApiProxy } from '@kinvolk/headlamp-plugin/lib';
|
||||
import React from 'react';
|
||||
import {
|
||||
getDashboardUrl,
|
||||
getRefreshInterval,
|
||||
INTERVAL_OPTIONS,
|
||||
setDashboardUrl,
|
||||
setRefreshInterval,
|
||||
AuditData,
|
||||
} from '../api/polaris';
|
||||
|
||||
interface PluginSettingsProps {
|
||||
data?: { [key: string]: string | number | boolean };
|
||||
onDataChange?: (data: { [key: string]: string | number | boolean }) => void;
|
||||
}
|
||||
|
||||
export default function PolarisSettings(props: PluginSettingsProps) {
|
||||
const { data, onDataChange } = props;
|
||||
const currentInterval = (data?.refreshInterval as number) ?? getRefreshInterval();
|
||||
const currentUrl = (data?.dashboardUrl as string) ?? getDashboardUrl();
|
||||
const [testing, setTesting] = React.useState(false);
|
||||
const [testResult, setTestResult] = React.useState<{ success: boolean; message: string } | null>(
|
||||
null
|
||||
);
|
||||
|
||||
function handleIntervalChange(e: React.ChangeEvent<HTMLSelectElement>) {
|
||||
const seconds = Number(e.target.value);
|
||||
setRefreshInterval(seconds);
|
||||
onDataChange?.({ ...data, refreshInterval: seconds });
|
||||
}
|
||||
|
||||
function handleUrlChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const url = e.target.value;
|
||||
setDashboardUrl(url);
|
||||
onDataChange?.({ ...data, dashboardUrl: url });
|
||||
}
|
||||
|
||||
async function testConnection() {
|
||||
setTesting(true);
|
||||
setTestResult(null);
|
||||
|
||||
try {
|
||||
const baseUrl = currentUrl;
|
||||
const apiPath = baseUrl.endsWith('/') ? `${baseUrl}results.json` : `${baseUrl}/results.json`;
|
||||
const isFullUrl = apiPath.startsWith('http://') || apiPath.startsWith('https://');
|
||||
|
||||
let result: AuditData;
|
||||
|
||||
if (isFullUrl) {
|
||||
const response = await fetch(apiPath);
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
result = await response.json();
|
||||
} else {
|
||||
result = await ApiProxy.request(apiPath);
|
||||
}
|
||||
|
||||
setTestResult({
|
||||
success: true,
|
||||
message: `Connected successfully! Version: ${
|
||||
result.PolarisOutputVersion
|
||||
}, Last audit: ${new Date(result.AuditTime).toLocaleString()}`,
|
||||
});
|
||||
} catch (err) {
|
||||
setTestResult({
|
||||
success: false,
|
||||
message: `Connection failed: ${String(err)}`,
|
||||
});
|
||||
} finally {
|
||||
setTesting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<SectionBox title="Polaris Settings">
|
||||
<NameValueTable
|
||||
rows={[
|
||||
{
|
||||
name: 'Refresh Interval',
|
||||
value: (
|
||||
<select value={currentInterval} onChange={handleIntervalChange}>
|
||||
{INTERVAL_OPTIONS.map(opt => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
),
|
||||
},
|
||||
{
|
||||
name: 'Dashboard URL',
|
||||
value: (
|
||||
<div>
|
||||
<input
|
||||
type="text"
|
||||
value={currentUrl}
|
||||
onChange={handleUrlChange}
|
||||
placeholder="/api/v1/namespaces/polaris/services/polaris-dashboard:80/proxy/"
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '4px 8px',
|
||||
border: '1px solid #ccc',
|
||||
borderRadius: '4px',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
/>
|
||||
<div style={{ fontSize: '12px', color: '#666', marginTop: '4px' }}>
|
||||
Examples:
|
||||
<br />• K8s proxy:{' '}
|
||||
<code>/api/v1/namespaces/polaris/services/polaris-dashboard:80/proxy/</code>
|
||||
<br />• Full URL: <code>https://my-polaris.example.com</code>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
name: 'Connection Test',
|
||||
value: (
|
||||
<div>
|
||||
<button
|
||||
onClick={testConnection}
|
||||
disabled={testing}
|
||||
style={{
|
||||
padding: '6px 16px',
|
||||
backgroundColor: testing ? '#ccc' : '#1976d2',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: testing ? 'not-allowed' : 'pointer',
|
||||
fontSize: '13px',
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
{testing ? 'Testing...' : 'Test Connection'}
|
||||
</button>
|
||||
{testResult && (
|
||||
<div style={{ marginTop: '8px' }}>
|
||||
<StatusLabel status={testResult.success ? 'success' : 'error'}>
|
||||
{testResult.message}
|
||||
</StatusLabel>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</SectionBox>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
import {
|
||||
Loader,
|
||||
SectionBox,
|
||||
SectionHeader,
|
||||
} from '@kinvolk/headlamp-plugin/lib/CommonComponents';
|
||||
import React from 'react';
|
||||
import {
|
||||
AuditData,
|
||||
countResults,
|
||||
getRefreshInterval,
|
||||
ResultCounts,
|
||||
setRefreshInterval,
|
||||
usePolarisData,
|
||||
} from '../api/polaris';
|
||||
|
||||
const INTERVAL_OPTIONS = [
|
||||
{ label: '1 minute', value: 60 },
|
||||
{ label: '5 minutes', value: 300 },
|
||||
{ label: '10 minutes', value: 600 },
|
||||
{ label: '30 minutes', value: 1800 },
|
||||
];
|
||||
|
||||
function RefreshSettings(props: {
|
||||
interval: number;
|
||||
onChange: (seconds: number) => void;
|
||||
}) {
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<label htmlFor="polaris-refresh-interval">Refresh interval:</label>
|
||||
<select
|
||||
id="polaris-refresh-interval"
|
||||
value={props.interval}
|
||||
onChange={(e) => props.onChange(Number(e.target.value))}
|
||||
>
|
||||
{INTERVAL_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatCard(props: { label: string; value: number; color?: string }) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
padding: '16px 24px',
|
||||
textAlign: 'center',
|
||||
minWidth: '120px',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '2rem',
|
||||
fontWeight: 'bold',
|
||||
color: props.color,
|
||||
}}
|
||||
>
|
||||
{props.value}
|
||||
</div>
|
||||
<div style={{ fontSize: '0.875rem', opacity: 0.8 }}>{props.label}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ScoreBadge(props: { score: number }) {
|
||||
const color = props.score >= 80 ? '#4caf50' : props.score >= 50 ? '#ff9800' : '#f44336';
|
||||
return (
|
||||
<div style={{ textAlign: 'center', marginBottom: '16px' }}>
|
||||
<div style={{ fontSize: '3rem', fontWeight: 'bold', color }}>
|
||||
{props.score}%
|
||||
</div>
|
||||
<div style={{ fontSize: '0.875rem', opacity: 0.8 }}>Cluster Score</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function OverviewSection(props: { data: AuditData; counts: ResultCounts }) {
|
||||
return (
|
||||
<>
|
||||
<SectionBox title="Score">
|
||||
<ScoreBadge score={props.data.Score} />
|
||||
</SectionBox>
|
||||
<SectionBox title="Check Summary">
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
flexWrap: 'wrap',
|
||||
gap: '16px',
|
||||
}}
|
||||
>
|
||||
<StatCard label="Total" value={props.counts.total} />
|
||||
<StatCard label="Pass" value={props.counts.pass} color="#4caf50" />
|
||||
<StatCard label="Warning" value={props.counts.warning} color="#ff9800" />
|
||||
<StatCard label="Danger" value={props.counts.danger} color="#f44336" />
|
||||
</div>
|
||||
</SectionBox>
|
||||
<SectionBox title="Cluster Info">
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
flexWrap: 'wrap',
|
||||
gap: '16px',
|
||||
}}
|
||||
>
|
||||
<StatCard label="Nodes" value={props.data.ClusterInfo.Nodes} />
|
||||
<StatCard label="Pods" value={props.data.ClusterInfo.Pods} />
|
||||
<StatCard label="Namespaces" value={props.data.ClusterInfo.Namespaces} />
|
||||
<StatCard label="Controllers" value={props.data.ClusterInfo.Controllers} />
|
||||
</div>
|
||||
</SectionBox>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function PolarisView() {
|
||||
const [interval, setInterval] = React.useState(getRefreshInterval);
|
||||
|
||||
function handleIntervalChange(seconds: number) {
|
||||
setInterval(seconds);
|
||||
setRefreshInterval(seconds);
|
||||
}
|
||||
|
||||
const { data, loading, error } = usePolarisData(interval);
|
||||
|
||||
if (loading) {
|
||||
return <Loader title="Loading Polaris audit data..." />;
|
||||
}
|
||||
|
||||
const counts = data ? countResults(data) : null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<SectionHeader title="Polaris" actions={[
|
||||
<RefreshSettings
|
||||
key="refresh"
|
||||
interval={interval}
|
||||
onChange={handleIntervalChange}
|
||||
/>,
|
||||
]} />
|
||||
|
||||
{error && (
|
||||
<SectionBox title="Error">
|
||||
<div style={{ padding: '16px', color: '#f44336' }}>{error}</div>
|
||||
</SectionBox>
|
||||
)}
|
||||
|
||||
{data && counts && <OverviewSection data={data} counts={counts} />}
|
||||
|
||||
{!data && !error && (
|
||||
<SectionBox title="No Data">
|
||||
<div style={{ padding: '16px' }}>
|
||||
No Polaris audit results found.
|
||||
</div>
|
||||
</SectionBox>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
+3
-72
@@ -1,19 +1,9 @@
|
||||
import {
|
||||
registerAppBarAction,
|
||||
registerDetailsViewSection,
|
||||
registerPluginSettings,
|
||||
registerRoute,
|
||||
registerSidebarEntry,
|
||||
} from '@kinvolk/headlamp-plugin/lib';
|
||||
import React from 'react';
|
||||
import { PolarisDataProvider } from './api/PolarisDataContext';
|
||||
import DashboardView from './components/DashboardView';
|
||||
import NamespacesListView from './components/NamespacesListView';
|
||||
import PolarisSettings from './components/PolarisSettings';
|
||||
import InlineAuditSection from './components/InlineAuditSection';
|
||||
import AppBarScoreBadge from './components/AppBarScoreBadge';
|
||||
|
||||
// --- Sidebar entries ---
|
||||
import PolarisView from './components/PolarisView';
|
||||
|
||||
registerSidebarEntry({
|
||||
parent: null,
|
||||
@@ -23,69 +13,10 @@ registerSidebarEntry({
|
||||
icon: 'mdi:shield-check',
|
||||
});
|
||||
|
||||
registerSidebarEntry({
|
||||
parent: 'polaris',
|
||||
name: 'polaris-overview',
|
||||
label: 'Overview',
|
||||
url: '/polaris',
|
||||
icon: 'mdi:view-dashboard',
|
||||
});
|
||||
|
||||
registerSidebarEntry({
|
||||
parent: 'polaris',
|
||||
name: 'polaris-namespaces',
|
||||
label: 'Namespaces',
|
||||
url: '/polaris/namespaces',
|
||||
icon: 'mdi:dns',
|
||||
});
|
||||
|
||||
// --- Routes ---
|
||||
|
||||
registerRoute({
|
||||
path: '/polaris',
|
||||
sidebar: 'polaris-overview',
|
||||
sidebar: 'polaris',
|
||||
name: 'polaris',
|
||||
exact: true,
|
||||
component: () => (
|
||||
<PolarisDataProvider>
|
||||
<DashboardView />
|
||||
</PolarisDataProvider>
|
||||
),
|
||||
component: () => <PolarisView />,
|
||||
});
|
||||
|
||||
registerRoute({
|
||||
path: '/polaris/namespaces',
|
||||
sidebar: 'polaris-namespaces',
|
||||
name: 'polaris-namespaces',
|
||||
exact: true,
|
||||
component: () => (
|
||||
<PolarisDataProvider>
|
||||
<NamespacesListView />
|
||||
</PolarisDataProvider>
|
||||
),
|
||||
});
|
||||
|
||||
// Register plugin settings
|
||||
registerPluginSettings('headlamp-polaris-plugin', PolarisSettings, true);
|
||||
|
||||
// Register details view section for supported controller types
|
||||
registerDetailsViewSection(({ resource }) => {
|
||||
const supportedKinds = ['Deployment', 'StatefulSet', 'DaemonSet', 'Job', 'CronJob'];
|
||||
|
||||
if (!supportedKinds.includes(resource?.kind)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<PolarisDataProvider>
|
||||
<InlineAuditSection resource={resource} />
|
||||
</PolarisDataProvider>
|
||||
);
|
||||
});
|
||||
|
||||
// Register app bar score badge
|
||||
registerAppBarAction(() => (
|
||||
<PolarisDataProvider>
|
||||
<AppBarScoreBadge />
|
||||
</PolarisDataProvider>
|
||||
));
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
import React from 'react';
|
||||
import { AuditData, Result } from './api/polaris';
|
||||
|
||||
// --- Fixtures ---
|
||||
|
||||
export function makeResult(overrides: Partial<Result> = {}): Result {
|
||||
return {
|
||||
Name: 'my-deploy',
|
||||
Namespace: 'default',
|
||||
Kind: 'Deployment',
|
||||
Results: {},
|
||||
CreatedTime: '2025-01-01T00:00:00Z',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
export function makeAuditData(results: Result[]): AuditData {
|
||||
return {
|
||||
PolarisOutputVersion: '1.0',
|
||||
AuditTime: '2025-01-01T00:00:00Z',
|
||||
SourceType: 'Cluster',
|
||||
SourceName: 'test',
|
||||
DisplayName: 'test',
|
||||
ClusterInfo: { Version: '1.28', Nodes: 3, Pods: 10, Namespaces: 2, Controllers: 5 },
|
||||
Results: results,
|
||||
};
|
||||
}
|
||||
|
||||
// --- Mock Polaris Context Provider ---
|
||||
|
||||
interface MockPolarisProviderProps {
|
||||
data?: AuditData | null;
|
||||
loading?: boolean;
|
||||
error?: string | null;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
// We dynamically import PolarisDataContext to inject mock values.
|
||||
// This avoids mocking the hook module — we supply real context with controlled values.
|
||||
const PolarisDataContext = React.createContext<{
|
||||
data: AuditData | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
} | null>(null);
|
||||
|
||||
export function MockPolarisProvider({
|
||||
data = null,
|
||||
loading = false,
|
||||
error = null,
|
||||
children,
|
||||
}: MockPolarisProviderProps) {
|
||||
return (
|
||||
<PolarisDataContext.Provider value={{ data, loading, error }}>
|
||||
{children}
|
||||
</PolarisDataContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
// The context reference used in test-utils must be the SAME object the components import.
|
||||
// We achieve this by having component tests mock `usePolarisDataContext` to read from our context.
|
||||
export { PolarisDataContext };
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"extends": "@kinvolk/headlamp-plugin/config/plugins-tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"types": ["vite/client", "vite-plugin-svgr/client", "vitest/globals", "lodash", "@testing-library/jest-dom"]
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'jsdom',
|
||||
setupFiles: ['./vitest.setup.ts'],
|
||||
exclude: ['e2e/**', 'node_modules/**'],
|
||||
},
|
||||
});
|
||||
@@ -1,43 +0,0 @@
|
||||
import '@testing-library/jest-dom';
|
||||
|
||||
// Node 22+ ships a minimal built-in `localStorage` global (property-bag only,
|
||||
// no getItem/setItem/removeItem/clear) that shadows jsdom's Web Storage
|
||||
// implementation. Provide a spec-compliant shim so code under test works.
|
||||
if (typeof localStorage !== 'undefined' && typeof localStorage.getItem !== 'function') {
|
||||
const store = new Map<string, string>();
|
||||
|
||||
const storage = {
|
||||
getItem(key: string): string | null {
|
||||
return store.get(key) ?? null;
|
||||
},
|
||||
setItem(key: string, value: string): void {
|
||||
store.set(key, String(value));
|
||||
},
|
||||
removeItem(key: string): void {
|
||||
store.delete(key);
|
||||
},
|
||||
clear(): void {
|
||||
store.clear();
|
||||
},
|
||||
get length(): number {
|
||||
return store.size;
|
||||
},
|
||||
key(index: number): string | null {
|
||||
return [...store.keys()][index] ?? null;
|
||||
},
|
||||
};
|
||||
|
||||
Object.defineProperty(globalThis, 'localStorage', {
|
||||
value: storage,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
Object.defineProperty(window, 'localStorage', {
|
||||
value: storage,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user