Compare commits

..

3 Commits

Author SHA1 Message Date
gitea-actions[bot] 6c9df7d40f ci: update artifact hub metadata for v0.2.0-dev.4 2026-02-09 17:27:39 +00:00
Chris Farhood c7a1f15fcf refactor: move to single-repo pattern for releases 2026-02-09 11:59:22 -05:00
Chris Farhood 261d636d71 fix: use dynamic repo URLs in metadata update step
The metadata update step was hardcoded to push to the stable repo,
causing dev releases to pollute the stable repo's main branch.

Changes:
- Use ${GITHUB_REPO} in archive-url instead of hardcoded stable repo
- Use ${GITHUB_REPO} in git remote instead of hardcoded stable repo
- Determine GITEA_BRANCH dynamically (dev/namespace-drawer for dev, main for stable)
- Push the correct Gitea branch to GitHub main branch
- Use temp branch to avoid conflicts

Now dev releases only touch the dev repo, and stable releases only
touch the stable repo.
2026-02-09 11:55:47 -05:00
24 changed files with 138 additions and 2172 deletions
+48 -25
View File
@@ -112,35 +112,53 @@ jobs:
echo "Gitea release updated"
- name: Create GitHub release
continue-on-error: true
run: |
[ "$SKIP_BUILD" = "true" ] && exit 0
# GitHub API to create/update release
GITHUB_API="https://api.github.com/repos/cpfarhood/headlamp-polaris-plugin"
# Check if release exists
RELEASE_DATA=$(curl -sf \
-H "Authorization: token ${{ secrets.GH_TOKEN }}" \
"${GITHUB_API}/releases/tags/${GITHUB_REF_NAME}" || echo "{}")
RELEASE_ID=$(echo "$RELEASE_DATA" | node -e "process.stdin.resume();let d='';process.stdin.on('data',c=>d+=c);process.stdin.on('end',()=>console.log(JSON.parse(d).id||''))")
if [ -z "$RELEASE_ID" ]; then
# Create new release
RELEASE_DATA=$(curl -sf -X POST \
-H "Authorization: token ${{ secrets.GH_TOKEN }}" \
-H "Content-Type: application/json" \
"${GITHUB_API}/releases" \
-d "{\"tag_name\":\"${GITHUB_REF_NAME}\",\"name\":\"${GITHUB_REF_NAME}\",\"draft\":false,\"prerelease\":false}")
RELEASE_ID=$(echo "$RELEASE_DATA" | node -e "process.stdin.resume();let d='';process.stdin.on('data',c=>d+=c);process.stdin.on('end',()=>console.log(JSON.parse(d).id))")
# Push tag to GitHub first so it exists before creating the release
git remote add github-release https://x-access-token:${{ secrets.GH_PAT }}@github.com/cpfarhood/headlamp-polaris-plugin.git 2>/dev/null || true
git push -f github-release ${GITHUB_REF_NAME} 2>/dev/null || true
GH_API="https://api.github.com/repos/cpfarhood/headlamp-polaris-plugin"
# Create release or fetch existing one
BODY=$(curl -s -X POST \
-H "Authorization: token ${{ secrets.GH_PAT }}" \
-H "Accept: application/vnd.github+json" \
"${GH_API}/releases" \
-d "{\"tag_name\":\"${GITHUB_REF_NAME}\",\"name\":\"${GITHUB_REF_NAME}\",\"generate_release_notes\":true}")
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))")
if [ "$RELEASE_ID" = "undefined" ]; then
echo "Release already exists, fetching it..."
BODY=$(curl -sf \
-H "Authorization: token ${{ secrets.GH_PAT }}" \
-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
echo "GitHub Release ID: $RELEASE_ID"
# Upload tarball to GitHub
UPLOAD_URL=$(echo "$RELEASE_DATA" | node -e "process.stdin.resume();let d='';process.stdin.on('data',c=>d+=c);process.stdin.on('end',()=>{const r=JSON.parse(d);console.log(r.upload_url||'https://uploads.github.com/repos/cpfarhood/headlamp-polaris-plugin/releases/${RELEASE_ID}/assets')})" | sed 's/{.*}//')
# Delete existing assets with the same name
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 \
-H "Authorization: token ${{ secrets.GH_TOKEN }}" \
-H "Authorization: token ${{ secrets.GH_PAT }}" \
-H "Content-Type: application/gzip" \
--data-binary "@${TARBALL}" \
"${UPLOAD_URL}?name=${TARBALL}"
echo "GitHub release updated"
"https://uploads.github.com/repos/cpfarhood/headlamp-polaris-plugin/releases/${RELEASE_ID}/assets?name=${TARBALL}" \
--data-binary "@${TARBALL}"
echo "GitHub release updated with same tarball"
- name: Update metadata and align tag
run: |
@@ -150,7 +168,7 @@ jobs:
git config user.email "gitea-actions[bot]@git.farh.net"
# Determine which Gitea branch to update based on version suffix
if [[ "$VERSION" == *"-dev."* ]]; then
GITEA_BRANCH="dev"
GITEA_BRANCH="dev/namespace-drawer"
else
GITEA_BRANCH="main"
fi
@@ -169,5 +187,10 @@ jobs:
# that the release checksum already matches and skip the build.
git tag -f ${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 "Note: GitHub sync handled by Gitea mirror configuration"
-102
View File
@@ -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"
-290
View File
@@ -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.
+8 -23
View File
@@ -6,29 +6,18 @@ A [Headlamp](https://headlamp.dev/) plugin that surfaces [Fairwinds Polaris](htt
## 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
- **Namespaces** -- table of all namespaces with per-namespace score and check counts; click a namespace to open a detailed side panel (1000px wide, theme-aware)
- **Namespace Detail Panel** -- per-namespace score, check counts, resource-level audit results, external Polaris dashboard link, and exemption management
Data is 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.
### 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
- **Inline Resource Audits** -- Polaris audit results automatically injected into detail views for Deployments, StatefulSets, DaemonSets, Jobs, and CronJobs; shows compact score, failing checks table, and link to full report
- **Exemption Management** -- add or remove Polaris exemptions via annotation patches directly from the UI; supports per-check exemptions or exempt-all
- **Configurable Dashboard URL** -- supports both Kubernetes service proxy URLs and full HTTP/HTTPS URLs for external Polaris deployments
- **Connection Testing** -- test button in settings to verify Polaris dashboard connectivity and show version info
### Data & Refresh
Data is fetched from the Polaris dashboard API through the Kubernetes service proxy (`/api/v1/namespaces/polaris/services/polaris-dashboard:80/proxy/results.json`) or custom URLs. The plugin is primarily read-only; it only writes when explicitly applying exemption annotations.
Results are refreshed on a user-configurable interval (1 / 5 / 10 / 30 minutes, default 5). Settings are available in **Settings > Plugins > Polaris** and persist in browser localStorage.
Error states are handled explicitly with context-specific messages: RBAC denied (403), Polaris not installed (404/503), malformed JSON, network failures, and CORS issues.
Error states are handled explicitly: RBAC denied (403), Polaris not installed (404/503), malformed JSON, and loading.
## Prerequisites
@@ -94,10 +83,6 @@ npm run build
npx @kinvolk/headlamp-plugin extract . /headlamp/plugins
```
## Installing Dev/Preview Versions
Dev preview versions are **not currently available** through the Headlamp plugin manager. Stable versions can be installed from ArtifactHub via the plugin manager UI.
## RBAC / Security Setup
The plugin fetches audit data through the Kubernetes API server's **service proxy** sub-resource. The identity making the request (Headlamp's service account, or the user's own token in token-auth mode) must be granted:
+3 -3
View File
@@ -1,4 +1,4 @@
version: 0.3.3
version: 0.2.0-dev.4
name: headlamp-polaris-plugin
displayName: Polaris
createdAt: "2026-02-05T19:00:00Z"
@@ -28,7 +28,7 @@ maintainers:
- name: cpfarhood
email: "chris@farhood.org"
annotations:
headlamp/plugin/archive-url: "https://github.com/cpfarhood/headlamp-polaris-plugin/releases/download/v0.3.3/headlamp-polaris-plugin-0.3.3.tar.gz"
headlamp/plugin/archive-url: "https://github.com/cpfarhood/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/archive-checksum: sha256:b18a94682d991e4d8b5297ff58c5e56d86f93f0657a9485297c1b62a0504ff57
headlamp/plugin/archive-checksum: sha256:70d46b8b478326794646bd90f9b4178c3010310509feecbe40305622954436a4
headlamp/plugin/distro-compat: in-cluster
-58
View File
@@ -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
View File
@@ -20,91 +20,42 @@ test.describe('Polaris plugin smoke tests', () => {
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 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');
await expect(table).toBeVisible();
const rows = table.locator('tbody tr');
await expect(rows.first()).toBeVisible();
// Each namespace row should contain a button (now buttons instead of links for drawer)
const firstButton = rows.first().locator('button');
await expect(firstButton).toBeVisible();
// Each namespace row should contain a link
const firstLink = rows.first().locator('a');
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');
// Click the first namespace button in the table
// Click the first namespace link in the table
const table = page.locator('table');
await expect(table).toBeVisible();
const firstButton = table.locator('tbody tr').first().locator('button');
const namespaceName = await firstButton.textContent();
await firstButton.click();
const firstLink = table.locator('tbody tr').first().locator('a');
const namespaceName = await firstLink.textContent();
await firstLink.click();
// Drawer should open and show the namespace name in the heading
await expect(
page.getByRole('heading', { name: `Polaris \u2014 ${namespaceName}` })
).toBeVisible();
// "Namespace Score" section should be present in drawer
await expect(page.getByText('Namespace Score')).toBeVisible();
// Resources table should exist in drawer
await expect(page.getByText('Resources')).toBeVisible();
// URL hash should be updated with namespace name
await expect(page).toHaveURL(/\/polaris\/namespaces#/);
});
test('namespace detail drawer closes with Escape key', async ({ page }) => {
await page.goto('/c/main/polaris/namespaces');
// Open the drawer by clicking a namespace button
const table = page.locator('table');
await expect(table).toBeVisible();
const firstButton = table.locator('tbody tr').first().locator('button');
const namespaceName = await firstButton.textContent();
await firstButton.click();
// Verify drawer is open
await expect(
page.getByRole('heading', { name: `Polaris \u2014 ${namespaceName}` })
).toBeVisible();
// Press Escape key
await page.keyboard.press('Escape');
// Drawer should close (heading should not be visible anymore)
await expect(
page.getByRole('heading', { name: `Polaris \u2014 ${namespaceName}` })
).not.toBeVisible();
// URL hash should be cleared
await expect(page).toHaveURL(/\/polaris\/namespaces$/);
});
test('namespace detail drawer opens from URL hash', async ({ page }) => {
// Get a namespace name first
await page.goto('/c/main/polaris/namespaces');
const table = page.locator('table');
await expect(table).toBeVisible();
const firstButton = table.locator('tbody tr').first().locator('button');
const namespaceName = await firstButton.textContent();
// Navigate directly to URL with hash
await page.goto(`/c/main/polaris/namespaces#${namespaceName}`);
// Drawer should automatically open with the namespace details
// 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
await expect(page.getByText('Namespace Score')).toBeVisible();
// Resources table should exist
await expect(page.getByText('Resources')).toBeVisible();
await expect(page.locator('table')).toBeVisible();
});
});
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "headlamp-polaris-plugin",
"version": "0.2.0",
"version": "0.1.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "headlamp-polaris-plugin",
"version": "0.2.0",
"version": "0.1.3",
"devDependencies": {
"@kinvolk/headlamp-plugin": "^0.13.0",
"@playwright/test": "^1.58.2"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "headlamp-polaris-plugin",
"version": "0.3.3",
"version": "0.1.6",
"description": "Headlamp plugin for Fairwinds Polaris audit results",
"scripts": {
"start": "headlamp-plugin start",
+1 -13
View File
@@ -5,7 +5,6 @@ interface PolarisDataContextValue {
data: AuditData | null;
loading: boolean;
error: string | null;
refresh: () => void;
}
const PolarisDataContext = React.createContext<PolarisDataContextValue | null>(null);
@@ -14,18 +13,7 @@ export function PolarisDataProvider(props: { children: React.ReactNode }) {
const interval = getRefreshInterval();
const state = usePolarisData(interval);
// Rename triggerRefresh to refresh for consistency
const value = React.useMemo(
() => ({
data: state.data,
loading: state.loading,
error: state.error,
refresh: state.triggerRefresh,
}),
[state]
);
return <PolarisDataContext.Provider value={value}>{props.children}</PolarisDataContext.Provider>;
return <PolarisDataContext.Provider value={state}>{props.children}</PolarisDataContext.Provider>;
}
export function usePolarisDataContext(): PolarisDataContextValue {
-238
View File
@@ -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
View File
@@ -125,14 +125,11 @@ export const INTERVAL_OPTIONS = [
{ label: '30 minutes', value: 1800 },
];
const REFRESH_STORAGE_KEY = 'polaris-plugin-refresh-interval';
const STORAGE_KEY = 'polaris-plugin-refresh-interval';
const DEFAULT_INTERVAL_SECONDS = 300; // 5 minutes
const URL_STORAGE_KEY = 'polaris-plugin-dashboard-url';
const DEFAULT_DASHBOARD_URL = '/api/v1/namespaces/polaris/services/polaris-dashboard:80/proxy/';
export function getRefreshInterval(): number {
const stored = localStorage.getItem(REFRESH_STORAGE_KEY);
const stored = localStorage.getItem(STORAGE_KEY);
if (stored !== null) {
const parsed = parseInt(stored, 10);
if (!isNaN(parsed) && parsed > 0) {
@@ -143,26 +140,13 @@ export function getRefreshInterval(): number {
}
export function setRefreshInterval(seconds: number): void {
localStorage.setItem(REFRESH_STORAGE_KEY, String(seconds));
}
export function getDashboardUrl(): string {
const stored = localStorage.getItem(URL_STORAGE_KEY);
if (stored !== null && stored.trim() !== '') {
return stored.trim();
}
return DEFAULT_DASHBOARD_URL;
}
export function setDashboardUrl(url: string): void {
localStorage.setItem(URL_STORAGE_KEY, url.trim());
localStorage.setItem(STORAGE_KEY, String(seconds));
}
// --- Polaris dashboard proxy URL ---
export function getPolarisProxyUrl(): string {
return getDashboardUrl();
}
export const POLARIS_DASHBOARD_PROXY =
'/api/v1/namespaces/polaris/services/polaris-dashboard:80/proxy/';
// --- Score computation ---
@@ -173,20 +157,13 @@ export function computeScore(counts: ResultCounts): number {
// --- Data fetching hook ---
function getPolarisApiPath(): string {
const baseUrl = getDashboardUrl();
return baseUrl.endsWith('/') ? `${baseUrl}results.json` : `${baseUrl}/results.json`;
}
function isFullUrl(url: string): boolean {
return url.startsWith('http://') || url.startsWith('https://');
}
const POLARIS_API_PATH =
'/api/v1/namespaces/polaris/services/polaris-dashboard:80/proxy/results.json';
interface PolarisDataState {
data: AuditData | null;
loading: boolean;
error: string | null;
triggerRefresh: () => void;
}
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 [tick, setTick] = React.useState(0);
const triggerRefresh = React.useCallback(() => {
setTick(t => t + 1);
}, []);
React.useEffect(() => {
let cancelled = false;
async function fetchData() {
try {
const apiPath = getPolarisApiPath();
let result: AuditData;
if (isFullUrl(apiPath)) {
// Direct fetch for full URLs
const response = await fetch(apiPath);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
result = await response.json();
} else {
// Kubernetes proxy for relative URLs
result = await ApiProxy.request(apiPath);
}
const result: AuditData = await ApiProxy.request(POLARIS_API_PATH);
if (!cancelled) {
setData(result);
setError(null);
@@ -226,31 +185,17 @@ export function usePolarisData(refreshIntervalSeconds: number): PolarisDataState
}
} catch (err: unknown) {
if (cancelled) return;
const apiPath = getPolarisApiPath();
const status = (err as { status?: number }).status;
if (isFullUrl(apiPath)) {
// Full URL errors
if (status === 403) {
setError('Access denied (403). Check authentication and CORS configuration.');
} else if (status === 404) {
setError('Polaris dashboard not found (404). Verify the URL is correct.');
} else {
setError(`Failed to fetch from ${apiPath}: ${String(err)}`);
}
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 polaris namespace.'
);
} else {
// Kubernetes proxy errors
if (status === 403) {
setError(
'Access denied (403). Check that your RBAC permissions allow proxying to the Polaris service.'
);
} else if (status === 404 || status === 503) {
setError(
'Polaris dashboard not reachable. Ensure Polaris is installed in the configured namespace.'
);
} else {
setError(`Failed to fetch Polaris data: ${String(err)}`);
}
setError(`Failed to fetch Polaris data: ${String(err)}`);
}
setLoading(false);
}
@@ -271,5 +216,5 @@ export function usePolarisData(refreshIntervalSeconds: number): PolarisDataState
return () => window.clearInterval(intervalId);
}, [refreshIntervalSeconds]);
return { data, loading, error, triggerRefresh };
return { data, loading, error };
}
-81
View File
@@ -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);
}
-55
View File
@@ -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>
);
}
-11
View File
@@ -34,17 +34,6 @@ vi.mock('@kinvolk/headlamp-plugin/lib/CommonComponents', () => ({
</tbody>
</table>
),
SimpleTable: ({ data }: { data: Array<any> }) => (
<table data-testid="simple-table">
<tbody>
{data.map((item, idx) => (
<tr key={idx}>
<td>{JSON.stringify(item)}</td>
</tr>
))}
</tbody>
</table>
),
PercentageCircle: ({ label }: { label: string }) => (
<div data-testid="percentage-circle">{label}</div>
),
+12 -86
View File
@@ -5,14 +5,11 @@ import {
PercentageCircle,
SectionBox,
SectionHeader,
SimpleTable,
StatusLabel,
} from '@kinvolk/headlamp-plugin/lib/CommonComponents';
import React from 'react';
import { AuditData, computeScore, countResults, ResultCounts } from '../api/polaris';
import { usePolarisDataContext } from '../api/PolarisDataContext';
import { getTopIssues, TopIssue } from '../api/topIssues';
import { getSeverityStatus } from '../api/checkMapping';
const COLORS = {
pass: '#4caf50',
@@ -29,6 +26,7 @@ function OverviewSection(props: { data: AuditData; counts: ResultCounts }) {
{ name: 'Pass', value: counts.pass, fill: COLORS.pass },
{ name: 'Warning', value: counts.warning, fill: COLORS.warning },
{ name: 'Danger', value: counts.danger, fill: COLORS.danger },
{ name: 'Skipped', value: counts.skipped, fill: COLORS.skipped },
];
return (
@@ -53,6 +51,14 @@ function OverviewSection(props: { data: AuditData; counts: ResultCounts }) {
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>
@@ -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() {
const { data, loading, error, refresh } = usePolarisDataContext();
const { data, loading, error } = usePolarisDataContext();
if (loading) {
return <Loader title="Loading Polaris audit data..." />;
}
const counts = data ? countResults(data) : null;
const topIssues = data ? getTopIssues(data) : [];
return (
<>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '20px',
}}
>
<SectionHeader title="Polaris — Overview" />
{data && (
<div style={{ display: 'flex', gap: '16px', alignItems: 'center' }}>
<span style={{ fontSize: '14px', color: 'var(--mui-palette-text-secondary, #666)' }}>
Last updated: {formatAuditTime(data.AuditTime)}
</span>
<button
onClick={refresh}
style={{
padding: '6px 16px',
backgroundColor: 'transparent',
color: '#1976d2',
border: '1px solid #1976d2',
borderRadius: '4px',
cursor: 'pointer',
fontSize: '13px',
fontWeight: 500,
display: 'inline-flex',
alignItems: 'center',
gap: '4px',
}}
>
<span>🔄</span>
<span>Refresh</span>
</button>
</div>
)}
</div>
<SectionHeader title="Polaris — Overview" />
{error && (
<SectionBox title="Error">
@@ -148,35 +102,7 @@ export default function DashboardView() {
</SectionBox>
)}
{data && counts && (
<>
<OverviewSection data={data} counts={counts} />
{topIssues.length > 0 && (
<SectionBox title="Top Issues">
<SimpleTable
columns={[
{ label: 'Check', getter: (issue: TopIssue) => issue.checkName },
{ label: 'Category', getter: (issue: TopIssue) => issue.category },
{
label: 'Severity',
getter: (issue: TopIssue) => (
<StatusLabel status={getSeverityStatus(issue.severity)}>
{issue.severity}
</StatusLabel>
),
},
{
label: 'Affected Workloads',
getter: (issue: TopIssue) => String(issue.count),
},
]}
data={topIssues}
/>
</SectionBox>
)}
</>
)}
{data && counts && <OverviewSection data={data} counts={counts} />}
{!data && !error && (
<SectionBox title="No Data">
-304
View File
@@ -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';
}
}
-177
View File
@@ -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>
);
}
+2 -2
View File
@@ -12,7 +12,7 @@ import {
computeScore,
countResultsForItems,
filterResultsByNamespace,
getPolarisProxyUrl,
POLARIS_DASHBOARD_PROXY,
Result,
ResultCounts,
} from '../api/polaris';
@@ -89,7 +89,7 @@ export default function NamespaceDetailView() {
{
name: 'Polaris Dashboard',
value: (
<a href={getPolarisProxyUrl()} target="_blank" rel="noopener noreferrer">
<a href={POLARIS_DASHBOARD_PROXY} target="_blank" rel="noopener noreferrer">
View in Polaris Dashboard
</a>
),
+6 -79
View File
@@ -1,5 +1,4 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React from 'react';
import { MemoryRouter } from 'react-router-dom';
import { describe, expect, it, vi } from 'vitest';
@@ -118,7 +117,7 @@ describe('NamespacesListView', () => {
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([
makeResult({
Name: 'deploy-a',
@@ -158,14 +157,12 @@ describe('NamespacesListView', () => {
renderWithRouter(<NamespacesListView />);
// Namespace buttons (now buttons instead of links for drawer)
const alphaButton = screen.getByText('alpha');
expect(alphaButton).toBeInTheDocument();
expect(alphaButton.tagName).toBe('BUTTON');
// Namespace links
const alphaLink = screen.getByText('alpha');
expect(alphaLink.closest('a')).toHaveAttribute('href', '/polaris/ns/alpha');
const betaButton = screen.getByText('beta');
expect(betaButton).toBeInTheDocument();
expect(betaButton.tagName).toBe('BUTTON');
const betaLink = screen.getByText('beta');
expect(betaLink.closest('a')).toHaveAttribute('href', '/polaris/ns/beta');
});
it('uses correct scoreStatus: >=80 success, >=50 warning, <50 error', () => {
@@ -219,74 +216,4 @@ describe('NamespacesListView', () => {
const errorScore = scoreLabels.find(el => el.textContent === '0%');
expect(errorScore).toHaveAttribute('data-status', 'error');
});
it('opens drawer when namespace button is clicked and URL hash is updated', async () => {
const user = userEvent.setup();
const data = makeAuditData([
makeResult({
Name: 'deploy-a',
Namespace: 'alpha',
Results: {
c1: {
ID: 'c1',
Message: '',
Details: [],
Success: true,
Severity: 'warning',
Category: 'X',
},
},
}),
]);
mockUsePolarisDataContext.mockReturnValue({
data,
loading: false,
error: null,
});
renderWithRouter(<NamespacesListView />);
// Click the namespace button
const alphaButton = screen.getByText('alpha');
await user.click(alphaButton);
// Drawer should open (check for the panel title)
expect(screen.getByText(/Polaris — alpha/)).toBeInTheDocument();
});
it('initializes drawer from URL hash', () => {
const data = makeAuditData([
makeResult({
Name: 'deploy-a',
Namespace: 'test-ns',
Results: {
c1: {
ID: 'c1',
Message: '',
Details: [],
Success: true,
Severity: 'warning',
Category: 'X',
},
},
}),
]);
mockUsePolarisDataContext.mockReturnValue({
data,
loading: false,
error: null,
});
// Render with initial hash in URL
render(
<MemoryRouter initialEntries={['/polaris/namespaces#test-ns']}>
<NamespacesListView />
</MemoryRouter>
);
// Drawer should be open with the namespace from hash
expect(screen.getByText(/Polaris — test-ns/)).toBeInTheDocument();
});
});
+8 -258
View File
@@ -1,3 +1,4 @@
import { Router } from '@kinvolk/headlamp-plugin/lib';
import {
Loader,
NameValueTable,
@@ -6,16 +7,13 @@ import {
SimpleTable,
StatusLabel,
} from '@kinvolk/headlamp-plugin/lib/CommonComponents';
import React, { useEffect, useState } from 'react';
import { useHistory, useLocation } from 'react-router-dom';
import React from 'react';
import { Link } from 'react-router-dom';
import {
computeScore,
countResultsForItems,
filterResultsByNamespace,
getNamespaces,
getPolarisProxyUrl,
Result,
ResultCounts,
} from '../api/polaris';
import { usePolarisDataContext } from '../api/PolarisDataContext';
@@ -34,231 +32,9 @@ interface NamespaceRow {
skipped: number;
}
function resourceCounts(result: Result): ResultCounts {
return countResultsForItems([result]);
}
interface NamespaceDetailPanelProps {
namespace: string;
onClose: () => void;
}
function NamespaceDetailPanel({ namespace, onClose }: NamespaceDetailPanelProps) {
const { data, loading, error } = usePolarisDataContext();
if (loading) {
return (
<div style={{ padding: '20px' }}>
<Loader title={`Loading Polaris data for ${namespace}...`} />
</div>
);
}
if (error) {
return (
<div style={{ padding: '20px' }}>
<SectionBox title="Error">
<NameValueTable
rows={[
{
name: 'Status',
value: <StatusLabel status="error">{error}</StatusLabel>,
},
]}
/>
</SectionBox>
</div>
);
}
if (!data) {
return (
<div style={{ padding: '20px' }}>
<SectionBox title="No Data">
<NameValueTable rows={[{ name: 'Status', value: 'No Polaris audit results found.' }]} />
</SectionBox>
</div>
);
}
const results = filterResultsByNamespace(data, namespace);
const counts = countResultsForItems(results);
const score = computeScore(counts);
const status = scoreStatus(score);
const countsPerResource = new Map<string, ResultCounts>();
for (const r of results) {
countsPerResource.set(`${r.Namespace}/${r.Kind}/${r.Name}`, resourceCounts(r));
}
function getResourceCounts(row: Result): ResultCounts {
return countsPerResource.get(`${row.Namespace}/${row.Kind}/${row.Name}`) ?? resourceCounts(row);
}
return (
<div
style={{
position: 'fixed',
right: 0,
top: 0,
bottom: 0,
width: '1000px',
backgroundColor: 'var(--mui-palette-background-paper, var(--background-paper, #fff))',
boxShadow: '-2px 0 8px rgba(0,0,0,0.15)',
overflowY: 'auto',
zIndex: 1200,
padding: '20px',
}}
>
<div
style={{
marginBottom: '20px',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
}}
>
<h2
style={{ margin: 0, color: 'var(--mui-palette-text-primary, var(--text-primary, #000))' }}
>
Polaris {namespace}
</h2>
<button
onClick={onClose}
style={{
border: 'none',
background: 'transparent',
fontSize: '24px',
cursor: 'pointer',
padding: '0 8px',
color: 'var(--mui-palette-text-primary, var(--text-primary, #000))',
}}
aria-label="Close panel"
>
×
</button>
</div>
<SectionBox title="External">
<NameValueTable
rows={[
{
name: 'Polaris Dashboard',
value: (
<a href={getPolarisProxyUrl()} target="_blank" rel="noopener noreferrer">
View in Polaris Dashboard
</a>
),
},
]}
/>
</SectionBox>
<SectionBox title="Namespace Score">
<NameValueTable
rows={[
{
name: 'Score',
value: <StatusLabel status={status}>{score}%</StatusLabel>,
},
{ name: 'Total Checks', value: String(counts.total) },
{
name: 'Pass',
value: <StatusLabel status="success">{counts.pass}</StatusLabel>,
},
{
name: 'Warning',
value: <StatusLabel status="warning">{counts.warning}</StatusLabel>,
},
{
name: 'Danger',
value: <StatusLabel status="error">{counts.danger}</StatusLabel>,
},
{
name: 'Skipped',
value: (
<span title="Only counts checks with Severity=ignore. Annotation-based exemptions are not included.">
{counts.skipped}
</span>
),
},
]}
/>
</SectionBox>
<SectionBox title="Resources">
<SimpleTable
columns={[
{ label: 'Name', getter: (row: Result) => row.Name },
{ label: 'Kind', getter: (row: Result) => row.Kind },
{
label: 'Pass',
getter: (row: Result) => (
<StatusLabel status="success">{getResourceCounts(row).pass}</StatusLabel>
),
},
{
label: 'Warning',
getter: (row: Result) => (
<StatusLabel status="warning">{getResourceCounts(row).warning}</StatusLabel>
),
},
{
label: 'Danger',
getter: (row: Result) => (
<StatusLabel status="error">{getResourceCounts(row).danger}</StatusLabel>
),
},
]}
data={results}
emptyMessage={`No resources found in namespace "${namespace}".`}
/>
</SectionBox>
</div>
);
}
export default function NamespacesListView() {
const location = useLocation();
const history = useHistory();
const { data, loading, error } = usePolarisDataContext();
// Initialize from URL hash
const [selectedNamespace, setSelectedNamespace] = useState<string | null>(
location.hash.slice(1) || null
);
// Sync drawer state when URL hash changes (browser back/forward)
useEffect(() => {
const hashNs = location.hash.slice(1);
setSelectedNamespace(hashNs || null);
}, [location.hash]);
const openNamespace = (ns: string) => {
setSelectedNamespace(ns);
history.push(`${location.pathname}#${ns}`);
};
const closeNamespace = () => {
setSelectedNamespace(null);
history.push(location.pathname);
};
// Handle keyboard navigation (Escape key closes drawer)
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape' && selectedNamespace) {
closeNamespace();
}
};
if (selectedNamespace) {
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedNamespace]);
if (loading) {
return <Loader title="Loading Polaris audit data..." />;
}
@@ -316,20 +92,13 @@ export default function NamespacesListView() {
{
label: 'Namespace',
getter: (row: NamespaceRow) => (
<button
onClick={() => openNamespace(row.namespace)}
style={{
border: 'none',
background: 'transparent',
color: 'var(--link-color, #1976d2)',
cursor: 'pointer',
textDecoration: 'underline',
padding: 0,
font: 'inherit',
}}
<Link
to={Router.createRouteURL('polaris-namespace', {
namespace: row.namespace,
})}
>
{row.namespace}
</button>
</Link>
),
},
{
@@ -361,25 +130,6 @@ export default function NamespacesListView() {
emptyMessage="No namespaces found in Polaris audit data."
/>
</SectionBox>
{selectedNamespace && (
<>
<div
onClick={closeNamespace}
style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'rgba(0, 0, 0, 0.5)',
zIndex: 1100,
}}
aria-label="Close panel backdrop"
/>
<NamespaceDetailPanel namespace={selectedNamespace} onClose={closeNamespace} />
</>
)}
</>
);
}
+4 -120
View File
@@ -1,18 +1,6 @@
import {
NameValueTable,
SectionBox,
StatusLabel,
} from '@kinvolk/headlamp-plugin/lib/CommonComponents';
import { ApiProxy } from '@kinvolk/headlamp-plugin/lib';
import { NameValueTable, SectionBox } from '@kinvolk/headlamp-plugin/lib/CommonComponents';
import React from 'react';
import {
getDashboardUrl,
getRefreshInterval,
INTERVAL_OPTIONS,
setDashboardUrl,
setRefreshInterval,
AuditData,
} from '../api/polaris';
import { getRefreshInterval, INTERVAL_OPTIONS, setRefreshInterval } from '../api/polaris';
interface PluginSettingsProps {
data?: { [key: string]: string | number | boolean };
@@ -22,61 +10,13 @@ interface PluginSettingsProps {
export default function PolarisSettings(props: PluginSettingsProps) {
const { data, onDataChange } = props;
const currentInterval = (data?.refreshInterval as number) ?? getRefreshInterval();
const currentUrl = (data?.dashboardUrl as string) ?? getDashboardUrl();
const [testing, setTesting] = React.useState(false);
const [testResult, setTestResult] = React.useState<{ success: boolean; message: string } | null>(
null
);
function handleIntervalChange(e: React.ChangeEvent<HTMLSelectElement>) {
function handleChange(e: React.ChangeEvent<HTMLSelectElement>) {
const seconds = Number(e.target.value);
setRefreshInterval(seconds);
onDataChange?.({ ...data, refreshInterval: seconds });
}
function handleUrlChange(e: React.ChangeEvent<HTMLInputElement>) {
const url = e.target.value;
setDashboardUrl(url);
onDataChange?.({ ...data, dashboardUrl: url });
}
async function testConnection() {
setTesting(true);
setTestResult(null);
try {
const baseUrl = currentUrl;
const apiPath = baseUrl.endsWith('/') ? `${baseUrl}results.json` : `${baseUrl}/results.json`;
const isFullUrl = apiPath.startsWith('http://') || apiPath.startsWith('https://');
let result: AuditData;
if (isFullUrl) {
const response = await fetch(apiPath);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
result = await response.json();
} else {
result = await ApiProxy.request(apiPath);
}
setTestResult({
success: true,
message: `Connected successfully! Version: ${
result.PolarisOutputVersion
}, Last audit: ${new Date(result.AuditTime).toLocaleString()}`,
});
} catch (err) {
setTestResult({
success: false,
message: `Connection failed: ${String(err)}`,
});
} finally {
setTesting(false);
}
}
return (
<SectionBox title="Polaris Settings">
<NameValueTable
@@ -84,7 +24,7 @@ export default function PolarisSettings(props: PluginSettingsProps) {
{
name: 'Refresh Interval',
value: (
<select value={currentInterval} onChange={handleIntervalChange}>
<select value={currentInterval} onChange={handleChange}>
{INTERVAL_OPTIONS.map(opt => (
<option key={opt.value} value={opt.value}>
{opt.label}
@@ -93,62 +33,6 @@ export default function PolarisSettings(props: PluginSettingsProps) {
</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>
+10 -24
View File
@@ -1,6 +1,4 @@
import {
registerAppBarAction,
registerDetailsViewSection,
registerPluginSettings,
registerRoute,
registerSidebarEntry,
@@ -8,10 +6,9 @@ import {
import React from 'react';
import { PolarisDataProvider } from './api/PolarisDataContext';
import DashboardView from './components/DashboardView';
import NamespaceDetailView from './components/NamespaceDetailView';
import NamespacesListView from './components/NamespacesListView';
import PolarisSettings from './components/PolarisSettings';
import InlineAuditSection from './components/InlineAuditSection';
import AppBarScoreBadge from './components/AppBarScoreBadge';
// --- Sidebar entries ---
@@ -65,27 +62,16 @@ registerRoute({
),
});
// Register plugin settings
registerPluginSettings('headlamp-polaris-plugin', PolarisSettings, true);
// Register details view section for supported controller types
registerDetailsViewSection(({ resource }) => {
const supportedKinds = ['Deployment', 'StatefulSet', 'DaemonSet', 'Job', 'CronJob'];
if (!supportedKinds.includes(resource?.kind)) {
return null;
}
return (
registerRoute({
path: '/polaris/ns/:namespace',
sidebar: 'polaris-namespaces',
name: 'polaris-namespace',
exact: true,
component: () => (
<PolarisDataProvider>
<InlineAuditSection resource={resource} />
<NamespaceDetailView />
</PolarisDataProvider>
);
),
});
// Register app bar score badge
registerAppBarAction(() => (
<PolarisDataProvider>
<AppBarScoreBadge />
</PolarisDataProvider>
));
registerPluginSettings('polaris', PolarisSettings, true);