Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6c9df7d40f | |||
| c7a1f15fcf | |||
| 261d636d71 |
@@ -112,35 +112,53 @@ jobs:
|
|||||||
echo "Gitea release updated"
|
echo "Gitea release updated"
|
||||||
|
|
||||||
- name: Create GitHub release
|
- name: Create GitHub release
|
||||||
|
continue-on-error: true
|
||||||
run: |
|
run: |
|
||||||
[ "$SKIP_BUILD" = "true" ] && exit 0
|
[ "$SKIP_BUILD" = "true" ] && exit 0
|
||||||
# GitHub API to create/update release
|
# Push tag to GitHub first so it exists before creating the release
|
||||||
GITHUB_API="https://api.github.com/repos/cpfarhood/headlamp-polaris-plugin"
|
git remote add github-release https://x-access-token:${{ secrets.GH_PAT }}@github.com/cpfarhood/headlamp-polaris-plugin.git 2>/dev/null || true
|
||||||
# Check if release exists
|
git push -f github-release ${GITHUB_REF_NAME} 2>/dev/null || true
|
||||||
RELEASE_DATA=$(curl -sf \
|
GH_API="https://api.github.com/repos/cpfarhood/headlamp-polaris-plugin"
|
||||||
-H "Authorization: token ${{ secrets.GH_TOKEN }}" \
|
# Create release or fetch existing one
|
||||||
"${GITHUB_API}/releases/tags/${GITHUB_REF_NAME}" || echo "{}")
|
BODY=$(curl -s -X POST \
|
||||||
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||''))")
|
-H "Authorization: token ${{ secrets.GH_PAT }}" \
|
||||||
|
-H "Accept: application/vnd.github+json" \
|
||||||
if [ -z "$RELEASE_ID" ]; then
|
"${GH_API}/releases" \
|
||||||
# Create new release
|
-d "{\"tag_name\":\"${GITHUB_REF_NAME}\",\"name\":\"${GITHUB_REF_NAME}\",\"generate_release_notes\":true}")
|
||||||
RELEASE_DATA=$(curl -sf -X POST \
|
RELEASE_ID=$(echo "$BODY" | node -e "process.stdin.resume();let d='';process.stdin.on('data',c=>d+=c);process.stdin.on('end',()=>console.log(JSON.parse(d).id))")
|
||||||
-H "Authorization: token ${{ secrets.GH_TOKEN }}" \
|
if [ "$RELEASE_ID" = "undefined" ]; then
|
||||||
-H "Content-Type: application/json" \
|
echo "Release already exists, fetching it..."
|
||||||
"${GITHUB_API}/releases" \
|
BODY=$(curl -sf \
|
||||||
-d "{\"tag_name\":\"${GITHUB_REF_NAME}\",\"name\":\"${GITHUB_REF_NAME}\",\"draft\":false,\"prerelease\":false}")
|
-H "Authorization: token ${{ secrets.GH_PAT }}" \
|
||||||
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))")
|
-H "Accept: application/vnd.github+json" \
|
||||||
|
"${GH_API}/releases/tags/${GITHUB_REF_NAME}")
|
||||||
|
RELEASE_ID=$(echo "$BODY" | 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
|
fi
|
||||||
|
|
||||||
echo "GitHub Release ID: $RELEASE_ID"
|
echo "GitHub Release ID: $RELEASE_ID"
|
||||||
# Upload tarball to GitHub
|
# Delete existing assets with the same name
|
||||||
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/{.*}//')
|
ASSETS=$(curl -sf \
|
||||||
|
-H "Authorization: token ${{ secrets.GH_PAT }}" \
|
||||||
|
-H "Accept: application/vnd.github+json" \
|
||||||
|
"${GH_API}/releases/${RELEASE_ID}/assets")
|
||||||
|
echo "$ASSETS" | node -e "
|
||||||
|
process.stdin.resume();let d='';
|
||||||
|
process.stdin.on('data',c=>d+=c);
|
||||||
|
process.stdin.on('end',()=>{
|
||||||
|
const assets=JSON.parse(d);
|
||||||
|
assets.filter(a=>a.name==='${TARBALL}').forEach(a=>console.log(a.id));
|
||||||
|
})" | while read -r ASSET_ID; do
|
||||||
|
echo "Deleting existing asset $ASSET_ID..."
|
||||||
|
curl -sf -X DELETE \
|
||||||
|
-H "Authorization: token ${{ secrets.GH_PAT }}" \
|
||||||
|
"${GH_API}/releases/assets/${ASSET_ID}"
|
||||||
|
done
|
||||||
|
# Upload tarball
|
||||||
curl -sf -X POST \
|
curl -sf -X POST \
|
||||||
-H "Authorization: token ${{ secrets.GH_TOKEN }}" \
|
-H "Authorization: token ${{ secrets.GH_PAT }}" \
|
||||||
-H "Content-Type: application/gzip" \
|
-H "Content-Type: application/gzip" \
|
||||||
--data-binary "@${TARBALL}" \
|
"https://uploads.github.com/repos/cpfarhood/headlamp-polaris-plugin/releases/${RELEASE_ID}/assets?name=${TARBALL}" \
|
||||||
"${UPLOAD_URL}?name=${TARBALL}"
|
--data-binary "@${TARBALL}"
|
||||||
echo "GitHub release updated"
|
echo "GitHub release updated with same tarball"
|
||||||
|
|
||||||
- name: Update metadata and align tag
|
- name: Update metadata and align tag
|
||||||
run: |
|
run: |
|
||||||
@@ -150,7 +168,7 @@ jobs:
|
|||||||
git config user.email "gitea-actions[bot]@git.farh.net"
|
git config user.email "gitea-actions[bot]@git.farh.net"
|
||||||
# Determine which Gitea branch to update based on version suffix
|
# Determine which Gitea branch to update based on version suffix
|
||||||
if [[ "$VERSION" == *"-dev."* ]]; then
|
if [[ "$VERSION" == *"-dev."* ]]; then
|
||||||
GITEA_BRANCH="dev"
|
GITEA_BRANCH="dev/namespace-drawer"
|
||||||
else
|
else
|
||||||
GITEA_BRANCH="main"
|
GITEA_BRANCH="main"
|
||||||
fi
|
fi
|
||||||
@@ -169,5 +187,10 @@ jobs:
|
|||||||
# that the release checksum already matches and skip the build.
|
# that the release checksum already matches and skip the build.
|
||||||
git tag -f ${GITHUB_REF_NAME}
|
git tag -f ${GITHUB_REF_NAME}
|
||||||
git push -f origin ${GITHUB_REF_NAME}
|
git push -f origin ${GITHUB_REF_NAME}
|
||||||
|
# Also push to GitHub directly to avoid waiting for mirror sync
|
||||||
|
# Single repo pattern: both stable and dev releases go to same GitHub repo
|
||||||
|
# ArtifactHub will differentiate based on prerelease flag in metadata
|
||||||
|
git remote add github https://x-access-token:${{ secrets.GH_PAT }}@github.com/cpfarhood/headlamp-polaris-plugin.git 2>/dev/null || true
|
||||||
|
git push github temp-update:main 2>/dev/null || true
|
||||||
|
git push -f github ${GITHUB_REF_NAME} 2>/dev/null || true
|
||||||
echo "Tag ${GITHUB_REF_NAME} aligned with updated metadata"
|
echo "Tag ${GITHUB_REF_NAME} aligned with updated metadata"
|
||||||
echo "Note: GitHub sync handled by Gitea mirror configuration"
|
|
||||||
|
|||||||
@@ -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"
|
|
||||||
@@ -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.
|
|
||||||
@@ -6,29 +6,18 @@ A [Headlamp](https://headlamp.dev/) plugin that surfaces [Fairwinds Polaris](htt
|
|||||||
|
|
||||||
## What It Does
|
## What It Does
|
||||||
|
|
||||||
Adds a **Polaris** top-level sidebar section to Headlamp with comprehensive security, reliability, and efficiency audit integration:
|
Adds a **Polaris** top-level sidebar section to Headlamp with the following views:
|
||||||
|
|
||||||
### Main Views
|
- **Overview** -- cluster score as a percentage (color-coded green/amber/red), check summary (pass/warning/danger/skipped counts), and cluster info (nodes, pods, namespaces, controllers)
|
||||||
|
- **Namespaces** -- table of all namespaces with per-namespace score, pass/warning/danger/skipped counts; click a namespace to drill down
|
||||||
|
- **Namespace detail** -- per-namespace score, check counts, and a resource table showing pass/warning/danger per workload
|
||||||
|
- **External link** -- quick jump to the native Polaris dashboard via the Kubernetes service proxy (from namespace detail view)
|
||||||
|
|
||||||
- **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
|
Data is fetched from the Polaris dashboard API through the Kubernetes service proxy (`/api/v1/namespaces/polaris/services/polaris-dashboard/proxy/results.json`). The plugin is read-only -- it never writes to the cluster.
|
||||||
- **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
|
|
||||||
|
|
||||||
### Integrated Features
|
Results are refreshed on a user-configurable interval (1 / 5 / 10 / 30 minutes, default 5). The setting is available in **Settings > Plugins > Polaris** and 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
|
Error states are handled explicitly: RBAC denied (403), Polaris not installed (404/503), malformed JSON, and loading.
|
||||||
- **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.
|
|
||||||
|
|
||||||
## Prerequisites
|
## Prerequisites
|
||||||
|
|
||||||
@@ -94,10 +83,6 @@ npm run build
|
|||||||
npx @kinvolk/headlamp-plugin extract . /headlamp/plugins
|
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
|
## 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:
|
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:
|
||||||
|
|||||||
+3
-3
@@ -1,4 +1,4 @@
|
|||||||
version: 0.3.2
|
version: 0.2.0-dev.4
|
||||||
name: headlamp-polaris-plugin
|
name: headlamp-polaris-plugin
|
||||||
displayName: Polaris
|
displayName: Polaris
|
||||||
createdAt: "2026-02-05T19:00:00Z"
|
createdAt: "2026-02-05T19:00:00Z"
|
||||||
@@ -28,7 +28,7 @@ maintainers:
|
|||||||
- name: cpfarhood
|
- name: cpfarhood
|
||||||
email: "chris@farhood.org"
|
email: "chris@farhood.org"
|
||||||
annotations:
|
annotations:
|
||||||
headlamp/plugin/archive-url: "https://github.com/cpfarhood/headlamp-polaris-plugin/releases/download/v0.3.2/headlamp-polaris-plugin-0.3.2.tar.gz"
|
headlamp/plugin/archive-url: "https://github.com/cpfarhood/headlamp-polaris-plugin/releases/download/v0.2.0-dev.4/headlamp-polaris-plugin-0.2.0-dev.4.tar.gz"
|
||||||
headlamp/plugin/version-compat: ">=0.26"
|
headlamp/plugin/version-compat: ">=0.26"
|
||||||
headlamp/plugin/archive-checksum: sha256:66d51513a6bf73b6f67af10d2dc55dabea7340d551faf3d59a9cd34b232ca868
|
headlamp/plugin/archive-checksum: sha256:70d46b8b478326794646bd90f9b4178c3010310509feecbe40305622954436a4
|
||||||
headlamp/plugin/distro-compat: in-cluster
|
headlamp/plugin/distro-compat: in-cluster
|
||||||
|
|||||||
@@ -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
|
|
||||||
+15
-64
@@ -20,91 +20,42 @@ test.describe('Polaris plugin smoke tests', () => {
|
|||||||
await expect(page.getByText(/%/)).toBeVisible();
|
await expect(page.getByText(/%/)).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('namespaces page renders table with namespace buttons', async ({ page }) => {
|
test('namespaces page renders table with links', async ({ page }) => {
|
||||||
await page.goto('/c/main/polaris/namespaces');
|
await page.goto('/c/main/polaris/namespaces');
|
||||||
|
|
||||||
await expect(page.getByRole('heading', { name: 'Polaris \u2014 Namespaces' })).toBeVisible();
|
await expect(page.getByRole('heading', { name: 'Polaris \u2014 Namespaces' })).toBeVisible();
|
||||||
|
|
||||||
// Table should have at least one row with a namespace button
|
// Table should have at least one row with a namespace link
|
||||||
const table = page.locator('table');
|
const table = page.locator('table');
|
||||||
await expect(table).toBeVisible();
|
await expect(table).toBeVisible();
|
||||||
const rows = table.locator('tbody tr');
|
const rows = table.locator('tbody tr');
|
||||||
await expect(rows.first()).toBeVisible();
|
await expect(rows.first()).toBeVisible();
|
||||||
|
|
||||||
// Each namespace row should contain a button (now buttons instead of links for drawer)
|
// Each namespace row should contain a link
|
||||||
const firstButton = rows.first().locator('button');
|
const firstLink = rows.first().locator('a');
|
||||||
await expect(firstButton).toBeVisible();
|
await expect(firstLink).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('namespace detail drawer opens from table button', async ({ page }) => {
|
test('namespace detail page renders from table link', async ({ page }) => {
|
||||||
await page.goto('/c/main/polaris/namespaces');
|
await page.goto('/c/main/polaris/namespaces');
|
||||||
|
|
||||||
// Click the first namespace button in the table
|
// Click the first namespace link in the table
|
||||||
const table = page.locator('table');
|
const table = page.locator('table');
|
||||||
await expect(table).toBeVisible();
|
await expect(table).toBeVisible();
|
||||||
const firstButton = table.locator('tbody tr').first().locator('button');
|
const firstLink = table.locator('tbody tr').first().locator('a');
|
||||||
const namespaceName = await firstButton.textContent();
|
const namespaceName = await firstLink.textContent();
|
||||||
await firstButton.click();
|
await firstLink.click();
|
||||||
|
|
||||||
// Drawer should open and show the namespace name in the heading
|
// Detail page should 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(
|
await expect(
|
||||||
page.getByRole('heading', { name: `Polaris \u2014 ${namespaceName}` })
|
page.getByRole('heading', { name: `Polaris \u2014 ${namespaceName}` })
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
|
|
||||||
// "Namespace Score" section should be present
|
// "Namespace Score" section should be present
|
||||||
await expect(page.getByText('Namespace Score')).toBeVisible();
|
await expect(page.getByText('Namespace Score')).toBeVisible();
|
||||||
|
|
||||||
|
// Resources table should exist
|
||||||
|
await expect(page.getByText('Resources')).toBeVisible();
|
||||||
|
await expect(page.locator('table')).toBeVisible();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "headlamp-polaris-plugin",
|
"name": "headlamp-polaris-plugin",
|
||||||
"version": "0.2.0",
|
"version": "0.1.3",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "headlamp-polaris-plugin",
|
"name": "headlamp-polaris-plugin",
|
||||||
"version": "0.2.0",
|
"version": "0.1.3",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@kinvolk/headlamp-plugin": "^0.13.0",
|
"@kinvolk/headlamp-plugin": "^0.13.0",
|
||||||
"@playwright/test": "^1.58.2"
|
"@playwright/test": "^1.58.2"
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "headlamp-polaris-plugin",
|
"name": "headlamp-polaris-plugin",
|
||||||
"version": "0.3.2",
|
"version": "0.1.6",
|
||||||
"description": "Headlamp plugin for Fairwinds Polaris audit results",
|
"description": "Headlamp plugin for Fairwinds Polaris audit results",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "headlamp-plugin start",
|
"start": "headlamp-plugin start",
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ interface PolarisDataContextValue {
|
|||||||
data: AuditData | null;
|
data: AuditData | null;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
refresh: () => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const PolarisDataContext = React.createContext<PolarisDataContextValue | null>(null);
|
const PolarisDataContext = React.createContext<PolarisDataContextValue | null>(null);
|
||||||
@@ -14,18 +13,7 @@ export function PolarisDataProvider(props: { children: React.ReactNode }) {
|
|||||||
const interval = getRefreshInterval();
|
const interval = getRefreshInterval();
|
||||||
const state = usePolarisData(interval);
|
const state = usePolarisData(interval);
|
||||||
|
|
||||||
// Rename triggerRefresh to refresh for consistency
|
return <PolarisDataContext.Provider value={state}>{props.children}</PolarisDataContext.Provider>;
|
||||||
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 {
|
export function usePolarisDataContext(): PolarisDataContextValue {
|
||||||
|
|||||||
@@ -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';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+18
-73
@@ -125,14 +125,11 @@ export const INTERVAL_OPTIONS = [
|
|||||||
{ label: '30 minutes', value: 1800 },
|
{ 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 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 {
|
export function getRefreshInterval(): number {
|
||||||
const stored = localStorage.getItem(REFRESH_STORAGE_KEY);
|
const stored = localStorage.getItem(STORAGE_KEY);
|
||||||
if (stored !== null) {
|
if (stored !== null) {
|
||||||
const parsed = parseInt(stored, 10);
|
const parsed = parseInt(stored, 10);
|
||||||
if (!isNaN(parsed) && parsed > 0) {
|
if (!isNaN(parsed) && parsed > 0) {
|
||||||
@@ -143,26 +140,13 @@ export function getRefreshInterval(): number {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function setRefreshInterval(seconds: number): void {
|
export function setRefreshInterval(seconds: number): void {
|
||||||
localStorage.setItem(REFRESH_STORAGE_KEY, String(seconds));
|
localStorage.setItem(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 ---
|
// --- Polaris dashboard proxy URL ---
|
||||||
|
|
||||||
export function getPolarisProxyUrl(): string {
|
export const POLARIS_DASHBOARD_PROXY =
|
||||||
return getDashboardUrl();
|
'/api/v1/namespaces/polaris/services/polaris-dashboard:80/proxy/';
|
||||||
}
|
|
||||||
|
|
||||||
// --- Score computation ---
|
// --- Score computation ---
|
||||||
|
|
||||||
@@ -173,20 +157,13 @@ export function computeScore(counts: ResultCounts): number {
|
|||||||
|
|
||||||
// --- Data fetching hook ---
|
// --- Data fetching hook ---
|
||||||
|
|
||||||
function getPolarisApiPath(): string {
|
const POLARIS_API_PATH =
|
||||||
const baseUrl = getDashboardUrl();
|
'/api/v1/namespaces/polaris/services/polaris-dashboard:80/proxy/results.json';
|
||||||
return baseUrl.endsWith('/') ? `${baseUrl}results.json` : `${baseUrl}/results.json`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isFullUrl(url: string): boolean {
|
|
||||||
return url.startsWith('http://') || url.startsWith('https://');
|
|
||||||
}
|
|
||||||
|
|
||||||
interface PolarisDataState {
|
interface PolarisDataState {
|
||||||
data: AuditData | null;
|
data: AuditData | null;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
triggerRefresh: () => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function usePolarisData(refreshIntervalSeconds: number): PolarisDataState {
|
export function usePolarisData(refreshIntervalSeconds: number): PolarisDataState {
|
||||||
@@ -195,30 +172,12 @@ export function usePolarisData(refreshIntervalSeconds: number): PolarisDataState
|
|||||||
const [error, setError] = React.useState<string | null>(null);
|
const [error, setError] = React.useState<string | null>(null);
|
||||||
const [tick, setTick] = React.useState(0);
|
const [tick, setTick] = React.useState(0);
|
||||||
|
|
||||||
const triggerRefresh = React.useCallback(() => {
|
|
||||||
setTick(t => t + 1);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
|
||||||
async function fetchData() {
|
async function fetchData() {
|
||||||
try {
|
try {
|
||||||
const apiPath = getPolarisApiPath();
|
const result: AuditData = await ApiProxy.request(POLARIS_API_PATH);
|
||||||
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) {
|
if (!cancelled) {
|
||||||
setData(result);
|
setData(result);
|
||||||
setError(null);
|
setError(null);
|
||||||
@@ -226,31 +185,17 @@ export function usePolarisData(refreshIntervalSeconds: number): PolarisDataState
|
|||||||
}
|
}
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
const apiPath = getPolarisApiPath();
|
|
||||||
const status = (err as { status?: number }).status;
|
const status = (err as { status?: number }).status;
|
||||||
|
if (status === 403) {
|
||||||
if (isFullUrl(apiPath)) {
|
setError(
|
||||||
// Full URL errors
|
'Access denied (403). Check that your RBAC permissions allow proxying to the Polaris service.'
|
||||||
if (status === 403) {
|
);
|
||||||
setError('Access denied (403). Check authentication and CORS configuration.');
|
} else if (status === 404 || status === 503) {
|
||||||
} else if (status === 404) {
|
setError(
|
||||||
setError('Polaris dashboard not found (404). Verify the URL is correct.');
|
'Polaris dashboard not reachable. Ensure Polaris is installed in the polaris namespace.'
|
||||||
} else {
|
);
|
||||||
setError(`Failed to fetch from ${apiPath}: ${String(err)}`);
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
// Kubernetes proxy errors
|
setError(`Failed to fetch Polaris data: ${String(err)}`);
|
||||||
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);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -271,5 +216,5 @@ export function usePolarisData(refreshIntervalSeconds: number): PolarisDataState
|
|||||||
return () => window.clearInterval(intervalId);
|
return () => window.clearInterval(intervalId);
|
||||||
}, [refreshIntervalSeconds]);
|
}, [refreshIntervalSeconds]);
|
||||||
|
|
||||||
return { data, loading, error, triggerRefresh };
|
return { data, loading, error };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -34,17 +34,6 @@ vi.mock('@kinvolk/headlamp-plugin/lib/CommonComponents', () => ({
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</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 }) => (
|
PercentageCircle: ({ label }: { label: string }) => (
|
||||||
<div data-testid="percentage-circle">{label}</div>
|
<div data-testid="percentage-circle">{label}</div>
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -5,14 +5,11 @@ import {
|
|||||||
PercentageCircle,
|
PercentageCircle,
|
||||||
SectionBox,
|
SectionBox,
|
||||||
SectionHeader,
|
SectionHeader,
|
||||||
SimpleTable,
|
|
||||||
StatusLabel,
|
StatusLabel,
|
||||||
} from '@kinvolk/headlamp-plugin/lib/CommonComponents';
|
} from '@kinvolk/headlamp-plugin/lib/CommonComponents';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { AuditData, computeScore, countResults, ResultCounts } from '../api/polaris';
|
import { AuditData, computeScore, countResults, ResultCounts } from '../api/polaris';
|
||||||
import { usePolarisDataContext } from '../api/PolarisDataContext';
|
import { usePolarisDataContext } from '../api/PolarisDataContext';
|
||||||
import { getTopIssues, TopIssue } from '../api/topIssues';
|
|
||||||
import { getSeverityStatus } from '../api/checkMapping';
|
|
||||||
|
|
||||||
const COLORS = {
|
const COLORS = {
|
||||||
pass: '#4caf50',
|
pass: '#4caf50',
|
||||||
@@ -29,6 +26,7 @@ function OverviewSection(props: { data: AuditData; counts: ResultCounts }) {
|
|||||||
{ name: 'Pass', value: counts.pass, fill: COLORS.pass },
|
{ name: 'Pass', value: counts.pass, fill: COLORS.pass },
|
||||||
{ name: 'Warning', value: counts.warning, fill: COLORS.warning },
|
{ name: 'Warning', value: counts.warning, fill: COLORS.warning },
|
||||||
{ name: 'Danger', value: counts.danger, fill: COLORS.danger },
|
{ name: 'Danger', value: counts.danger, fill: COLORS.danger },
|
||||||
|
{ name: 'Skipped', value: counts.skipped, fill: COLORS.skipped },
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -53,6 +51,14 @@ function OverviewSection(props: { data: AuditData; counts: ResultCounts }) {
|
|||||||
name: 'Danger',
|
name: 'Danger',
|
||||||
value: <StatusLabel status="error">{counts.danger}</StatusLabel>,
|
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>
|
||||||
@@ -70,70 +76,18 @@ function OverviewSection(props: { data: AuditData; counts: ResultCounts }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
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() {
|
export default function DashboardView() {
|
||||||
const { data, loading, error, refresh } = usePolarisDataContext();
|
const { data, loading, error } = usePolarisDataContext();
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return <Loader title="Loading Polaris audit data..." />;
|
return <Loader title="Loading Polaris audit data..." />;
|
||||||
}
|
}
|
||||||
|
|
||||||
const counts = data ? countResults(data) : null;
|
const counts = data ? countResults(data) : null;
|
||||||
const topIssues = data ? getTopIssues(data) : [];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div
|
<SectionHeader title="Polaris — Overview" />
|
||||||
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 && (
|
{error && (
|
||||||
<SectionBox title="Error">
|
<SectionBox title="Error">
|
||||||
@@ -148,35 +102,7 @@ export default function DashboardView() {
|
|||||||
</SectionBox>
|
</SectionBox>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{data && counts && (
|
{data && counts && <OverviewSection data={data} counts={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 && (
|
{!data && !error && (
|
||||||
<SectionBox title="No Data">
|
<SectionBox title="No Data">
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
computeScore,
|
computeScore,
|
||||||
countResultsForItems,
|
countResultsForItems,
|
||||||
filterResultsByNamespace,
|
filterResultsByNamespace,
|
||||||
getPolarisProxyUrl,
|
POLARIS_DASHBOARD_PROXY,
|
||||||
Result,
|
Result,
|
||||||
ResultCounts,
|
ResultCounts,
|
||||||
} from '../api/polaris';
|
} from '../api/polaris';
|
||||||
@@ -89,7 +89,7 @@ export default function NamespaceDetailView() {
|
|||||||
{
|
{
|
||||||
name: 'Polaris Dashboard',
|
name: 'Polaris Dashboard',
|
||||||
value: (
|
value: (
|
||||||
<a href={getPolarisProxyUrl()} target="_blank" rel="noopener noreferrer">
|
<a href={POLARIS_DASHBOARD_PROXY} target="_blank" rel="noopener noreferrer">
|
||||||
View in Polaris Dashboard
|
View in Polaris Dashboard
|
||||||
</a>
|
</a>
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { render, screen } from '@testing-library/react';
|
import { render, screen } from '@testing-library/react';
|
||||||
import userEvent from '@testing-library/user-event';
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { MemoryRouter } from 'react-router-dom';
|
import { MemoryRouter } from 'react-router-dom';
|
||||||
import { describe, expect, it, vi } from 'vitest';
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
@@ -118,7 +117,7 @@ describe('NamespacesListView', () => {
|
|||||||
expect(screen.getByText('No Polaris audit results found.')).toBeInTheDocument();
|
expect(screen.getByText('No Polaris audit results found.')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders namespace rows with correct scores and buttons', () => {
|
it('renders namespace rows with correct scores and links', () => {
|
||||||
const data = makeAuditData([
|
const data = makeAuditData([
|
||||||
makeResult({
|
makeResult({
|
||||||
Name: 'deploy-a',
|
Name: 'deploy-a',
|
||||||
@@ -158,14 +157,12 @@ describe('NamespacesListView', () => {
|
|||||||
|
|
||||||
renderWithRouter(<NamespacesListView />);
|
renderWithRouter(<NamespacesListView />);
|
||||||
|
|
||||||
// Namespace buttons (now buttons instead of links for drawer)
|
// Namespace links
|
||||||
const alphaButton = screen.getByText('alpha');
|
const alphaLink = screen.getByText('alpha');
|
||||||
expect(alphaButton).toBeInTheDocument();
|
expect(alphaLink.closest('a')).toHaveAttribute('href', '/polaris/ns/alpha');
|
||||||
expect(alphaButton.tagName).toBe('BUTTON');
|
|
||||||
|
|
||||||
const betaButton = screen.getByText('beta');
|
const betaLink = screen.getByText('beta');
|
||||||
expect(betaButton).toBeInTheDocument();
|
expect(betaLink.closest('a')).toHaveAttribute('href', '/polaris/ns/beta');
|
||||||
expect(betaButton.tagName).toBe('BUTTON');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('uses correct scoreStatus: >=80 success, >=50 warning, <50 error', () => {
|
it('uses correct scoreStatus: >=80 success, >=50 warning, <50 error', () => {
|
||||||
@@ -219,74 +216,4 @@ describe('NamespacesListView', () => {
|
|||||||
const errorScore = scoreLabels.find(el => el.textContent === '0%');
|
const errorScore = scoreLabels.find(el => el.textContent === '0%');
|
||||||
expect(errorScore).toHaveAttribute('data-status', 'error');
|
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,3 +1,4 @@
|
|||||||
|
import { Router } from '@kinvolk/headlamp-plugin/lib';
|
||||||
import {
|
import {
|
||||||
Loader,
|
Loader,
|
||||||
NameValueTable,
|
NameValueTable,
|
||||||
@@ -6,16 +7,13 @@ import {
|
|||||||
SimpleTable,
|
SimpleTable,
|
||||||
StatusLabel,
|
StatusLabel,
|
||||||
} from '@kinvolk/headlamp-plugin/lib/CommonComponents';
|
} from '@kinvolk/headlamp-plugin/lib/CommonComponents';
|
||||||
import React, { useEffect, useState } from 'react';
|
import React from 'react';
|
||||||
import { useHistory, useLocation } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
computeScore,
|
computeScore,
|
||||||
countResultsForItems,
|
countResultsForItems,
|
||||||
filterResultsByNamespace,
|
filterResultsByNamespace,
|
||||||
getNamespaces,
|
getNamespaces,
|
||||||
getPolarisProxyUrl,
|
|
||||||
Result,
|
|
||||||
ResultCounts,
|
|
||||||
} from '../api/polaris';
|
} from '../api/polaris';
|
||||||
import { usePolarisDataContext } from '../api/PolarisDataContext';
|
import { usePolarisDataContext } from '../api/PolarisDataContext';
|
||||||
|
|
||||||
@@ -34,231 +32,9 @@ interface NamespaceRow {
|
|||||||
skipped: 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() {
|
export default function NamespacesListView() {
|
||||||
const location = useLocation();
|
|
||||||
const history = useHistory();
|
|
||||||
const { data, loading, error } = usePolarisDataContext();
|
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) {
|
if (loading) {
|
||||||
return <Loader title="Loading Polaris audit data..." />;
|
return <Loader title="Loading Polaris audit data..." />;
|
||||||
}
|
}
|
||||||
@@ -316,20 +92,13 @@ export default function NamespacesListView() {
|
|||||||
{
|
{
|
||||||
label: 'Namespace',
|
label: 'Namespace',
|
||||||
getter: (row: NamespaceRow) => (
|
getter: (row: NamespaceRow) => (
|
||||||
<button
|
<Link
|
||||||
onClick={() => openNamespace(row.namespace)}
|
to={Router.createRouteURL('polaris-namespace', {
|
||||||
style={{
|
namespace: row.namespace,
|
||||||
border: 'none',
|
})}
|
||||||
background: 'transparent',
|
|
||||||
color: 'var(--link-color, #1976d2)',
|
|
||||||
cursor: 'pointer',
|
|
||||||
textDecoration: 'underline',
|
|
||||||
padding: 0,
|
|
||||||
font: 'inherit',
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{row.namespace}
|
{row.namespace}
|
||||||
</button>
|
</Link>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -361,25 +130,6 @@ export default function NamespacesListView() {
|
|||||||
emptyMessage="No namespaces found in Polaris audit data."
|
emptyMessage="No namespaces found in Polaris audit data."
|
||||||
/>
|
/>
|
||||||
</SectionBox>
|
</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,18 +1,6 @@
|
|||||||
import {
|
import { NameValueTable, SectionBox } from '@kinvolk/headlamp-plugin/lib/CommonComponents';
|
||||||
NameValueTable,
|
|
||||||
SectionBox,
|
|
||||||
StatusLabel,
|
|
||||||
} from '@kinvolk/headlamp-plugin/lib/CommonComponents';
|
|
||||||
import { ApiProxy } from '@kinvolk/headlamp-plugin/lib';
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import {
|
import { getRefreshInterval, INTERVAL_OPTIONS, setRefreshInterval } from '../api/polaris';
|
||||||
getDashboardUrl,
|
|
||||||
getRefreshInterval,
|
|
||||||
INTERVAL_OPTIONS,
|
|
||||||
setDashboardUrl,
|
|
||||||
setRefreshInterval,
|
|
||||||
AuditData,
|
|
||||||
} from '../api/polaris';
|
|
||||||
|
|
||||||
interface PluginSettingsProps {
|
interface PluginSettingsProps {
|
||||||
data?: { [key: string]: string | number | boolean };
|
data?: { [key: string]: string | number | boolean };
|
||||||
@@ -22,61 +10,13 @@ interface PluginSettingsProps {
|
|||||||
export default function PolarisSettings(props: PluginSettingsProps) {
|
export default function PolarisSettings(props: PluginSettingsProps) {
|
||||||
const { data, onDataChange } = props;
|
const { data, onDataChange } = props;
|
||||||
const currentInterval = (data?.refreshInterval as number) ?? getRefreshInterval();
|
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>) {
|
function handleChange(e: React.ChangeEvent<HTMLSelectElement>) {
|
||||||
const seconds = Number(e.target.value);
|
const seconds = Number(e.target.value);
|
||||||
setRefreshInterval(seconds);
|
setRefreshInterval(seconds);
|
||||||
onDataChange?.({ ...data, refreshInterval: 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 (
|
return (
|
||||||
<SectionBox title="Polaris Settings">
|
<SectionBox title="Polaris Settings">
|
||||||
<NameValueTable
|
<NameValueTable
|
||||||
@@ -84,7 +24,7 @@ export default function PolarisSettings(props: PluginSettingsProps) {
|
|||||||
{
|
{
|
||||||
name: 'Refresh Interval',
|
name: 'Refresh Interval',
|
||||||
value: (
|
value: (
|
||||||
<select value={currentInterval} onChange={handleIntervalChange}>
|
<select value={currentInterval} onChange={handleChange}>
|
||||||
{INTERVAL_OPTIONS.map(opt => (
|
{INTERVAL_OPTIONS.map(opt => (
|
||||||
<option key={opt.value} value={opt.value}>
|
<option key={opt.value} value={opt.value}>
|
||||||
{opt.label}
|
{opt.label}
|
||||||
@@ -93,62 +33,6 @@ export default function PolarisSettings(props: PluginSettingsProps) {
|
|||||||
</select>
|
</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>
|
</SectionBox>
|
||||||
|
|||||||
+10
-24
@@ -1,6 +1,4 @@
|
|||||||
import {
|
import {
|
||||||
registerAppBarAction,
|
|
||||||
registerDetailsViewSection,
|
|
||||||
registerPluginSettings,
|
registerPluginSettings,
|
||||||
registerRoute,
|
registerRoute,
|
||||||
registerSidebarEntry,
|
registerSidebarEntry,
|
||||||
@@ -8,10 +6,9 @@ import {
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { PolarisDataProvider } from './api/PolarisDataContext';
|
import { PolarisDataProvider } from './api/PolarisDataContext';
|
||||||
import DashboardView from './components/DashboardView';
|
import DashboardView from './components/DashboardView';
|
||||||
|
import NamespaceDetailView from './components/NamespaceDetailView';
|
||||||
import NamespacesListView from './components/NamespacesListView';
|
import NamespacesListView from './components/NamespacesListView';
|
||||||
import PolarisSettings from './components/PolarisSettings';
|
import PolarisSettings from './components/PolarisSettings';
|
||||||
import InlineAuditSection from './components/InlineAuditSection';
|
|
||||||
import AppBarScoreBadge from './components/AppBarScoreBadge';
|
|
||||||
|
|
||||||
// --- Sidebar entries ---
|
// --- Sidebar entries ---
|
||||||
|
|
||||||
@@ -65,27 +62,16 @@ registerRoute({
|
|||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Register plugin settings
|
registerRoute({
|
||||||
registerPluginSettings('polaris', PolarisSettings);
|
path: '/polaris/ns/:namespace',
|
||||||
|
sidebar: 'polaris-namespaces',
|
||||||
// Register details view section for supported controller types
|
name: 'polaris-namespace',
|
||||||
registerDetailsViewSection(({ resource }) => {
|
exact: true,
|
||||||
const supportedKinds = ['Deployment', 'StatefulSet', 'DaemonSet', 'Job', 'CronJob'];
|
component: () => (
|
||||||
|
|
||||||
if (!supportedKinds.includes(resource?.kind)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<PolarisDataProvider>
|
<PolarisDataProvider>
|
||||||
<InlineAuditSection resource={resource} />
|
<NamespaceDetailView />
|
||||||
</PolarisDataProvider>
|
</PolarisDataProvider>
|
||||||
);
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Register app bar score badge
|
registerPluginSettings('polaris', PolarisSettings, true);
|
||||||
registerAppBarAction(() => (
|
|
||||||
<PolarisDataProvider>
|
|
||||||
<AppBarScoreBadge />
|
|
||||||
</PolarisDataProvider>
|
|
||||||
));
|
|
||||||
|
|||||||
Reference in New Issue
Block a user