Compare commits
57 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 604106c688 | |||
| 44a0016a4d | |||
| 03d7379e13 | |||
| 861dff6901 | |||
| 03b75a836b | |||
| 83a5342011 | |||
| 3daa1cbc14 | |||
| 9c03d912df | |||
| 00d4b224eb | |||
| c1248ec3c4 | |||
| 7ac5d0a494 | |||
| 59c1d4e844 | |||
| a507ba1d4a | |||
| d03fb81cd5 | |||
| d4d593cf74 | |||
| 2facb1b22b | |||
| 104a7fb2ba | |||
| b9e9484bf0 | |||
| 22d88cfca4 | |||
| 48dcb214b9 | |||
| c0681162e7 | |||
| 762056e46c | |||
| ab1f028fe0 | |||
| f2a2176eb6 | |||
| fe2e5d53e7 | |||
| 73939e66ad | |||
| 4378ad39f3 | |||
| 93bfb9e1bb | |||
| 2c26d49bf9 | |||
| 679be5dedc | |||
| a95f132413 | |||
| d3203b1890 | |||
| cd69cef2af | |||
| 0461ee8f23 | |||
| 14e323200c | |||
| a8e7dfca6d | |||
| 66903ca5e5 | |||
| f274203092 | |||
| 1273f94ae5 | |||
| 9d4b2e17aa | |||
| 82261a1c19 | |||
| 863889eca4 | |||
| 99bac773cc | |||
| 9fdb7c04cd | |||
| 0bd90ca317 | |||
| 975a31d1f3 | |||
| e54630410e | |||
| 088c74323b | |||
| d837987916 | |||
| 1b082a24db | |||
| 4544284df0 | |||
| 4838b22a02 | |||
| c67bcb1804 | |||
| c19bb2fa87 | |||
| 253d1277d9 | |||
| f69c91acf9 | |||
| 5659026959 |
@@ -112,50 +112,35 @@ 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
|
||||||
GH_API="https://api.github.com/repos/cpfarhood/headlamp-polaris-plugin"
|
# GitHub API to create/update release
|
||||||
# Create release or fetch existing one
|
GITHUB_API="https://api.github.com/repos/cpfarhood/headlamp-polaris-plugin"
|
||||||
BODY=$(curl -s -X POST \
|
# Check if release exists
|
||||||
-H "Authorization: token ${{ secrets.GH_PAT }}" \
|
RELEASE_DATA=$(curl -sf \
|
||||||
-H "Accept: application/vnd.github+json" \
|
-H "Authorization: token ${{ secrets.GH_TOKEN }}" \
|
||||||
"${GH_API}/releases" \
|
"${GITHUB_API}/releases/tags/${GITHUB_REF_NAME}" || echo "{}")
|
||||||
-d "{\"tag_name\":\"${GITHUB_REF_NAME}\",\"name\":\"${GITHUB_REF_NAME}\",\"generate_release_notes\":true}")
|
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||''))")
|
||||||
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
|
if [ -z "$RELEASE_ID" ]; then
|
||||||
echo "Release already exists, fetching it..."
|
# Create new release
|
||||||
BODY=$(curl -sf \
|
RELEASE_DATA=$(curl -sf -X POST \
|
||||||
-H "Authorization: token ${{ secrets.GH_PAT }}" \
|
-H "Authorization: token ${{ secrets.GH_TOKEN }}" \
|
||||||
-H "Accept: application/vnd.github+json" \
|
-H "Content-Type: application/json" \
|
||||||
"${GH_API}/releases/tags/${GITHUB_REF_NAME}")
|
"${GITHUB_API}/releases" \
|
||||||
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))")
|
-d "{\"tag_name\":\"${GITHUB_REF_NAME}\",\"name\":\"${GITHUB_REF_NAME}\",\"draft\":false,\"prerelease\":false}")
|
||||||
|
RELEASE_ID=$(echo "$RELEASE_DATA" | node -e "process.stdin.resume();let d='';process.stdin.on('data',c=>d+=c);process.stdin.on('end',()=>console.log(JSON.parse(d).id))")
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "GitHub Release ID: $RELEASE_ID"
|
echo "GitHub Release ID: $RELEASE_ID"
|
||||||
# Delete existing assets with the same name
|
# Upload tarball to GitHub
|
||||||
ASSETS=$(curl -sf \
|
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/{.*}//')
|
||||||
-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_PAT }}" \
|
-H "Authorization: token ${{ secrets.GH_TOKEN }}" \
|
||||||
-H "Content-Type: application/gzip" \
|
-H "Content-Type: application/gzip" \
|
||||||
"https://uploads.github.com/repos/cpfarhood/headlamp-polaris-plugin/releases/${RELEASE_ID}/assets?name=${TARBALL}" \
|
--data-binary "@${TARBALL}" \
|
||||||
--data-binary "@${TARBALL}"
|
"${UPLOAD_URL}?name=${TARBALL}"
|
||||||
echo "GitHub release updated with same tarball"
|
echo "GitHub release updated"
|
||||||
|
|
||||||
- name: Update metadata and align tag
|
- name: Update metadata and align tag
|
||||||
run: |
|
run: |
|
||||||
@@ -163,23 +148,26 @@ jobs:
|
|||||||
VERSION=${GITHUB_REF_NAME#v}
|
VERSION=${GITHUB_REF_NAME#v}
|
||||||
git config user.name "gitea-actions[bot]"
|
git config user.name "gitea-actions[bot]"
|
||||||
git config user.email "gitea-actions[bot]@git.farh.net"
|
git config user.email "gitea-actions[bot]@git.farh.net"
|
||||||
git fetch origin main
|
# Determine which Gitea branch to update based on version suffix
|
||||||
git checkout origin/main -B main
|
if [[ "$VERSION" == *"-dev."* ]]; then
|
||||||
|
GITEA_BRANCH="dev"
|
||||||
|
else
|
||||||
|
GITEA_BRANCH="main"
|
||||||
|
fi
|
||||||
|
git fetch origin ${GITEA_BRANCH}
|
||||||
|
git checkout origin/${GITEA_BRANCH} -B temp-update
|
||||||
sed -i "s|headlamp/plugin/archive-checksum:.*|headlamp/plugin/archive-checksum: sha256:${CHECKSUM}|" artifacthub-pkg.yml
|
sed -i "s|headlamp/plugin/archive-checksum:.*|headlamp/plugin/archive-checksum: sha256:${CHECKSUM}|" artifacthub-pkg.yml
|
||||||
sed -i "s|headlamp/plugin/archive-url:.*|headlamp/plugin/archive-url: \"https://github.com/cpfarhood/headlamp-polaris-plugin/releases/download/${GITHUB_REF_NAME}/headlamp-polaris-plugin-${VERSION}.tar.gz\"|" artifacthub-pkg.yml
|
sed -i "s|headlamp/plugin/archive-url:.*|headlamp/plugin/archive-url: \"https://github.com/cpfarhood/headlamp-polaris-plugin/releases/download/${GITHUB_REF_NAME}/headlamp-polaris-plugin-${VERSION}.tar.gz\"|" artifacthub-pkg.yml
|
||||||
sed -i "s|^version:.*|version: ${VERSION}|" artifacthub-pkg.yml
|
sed -i "s|^version:.*|version: ${VERSION}|" artifacthub-pkg.yml
|
||||||
git add artifacthub-pkg.yml
|
git add artifacthub-pkg.yml
|
||||||
git diff --cached --quiet || {
|
git diff --cached --quiet || {
|
||||||
git commit -m "ci: update artifact hub metadata for ${GITHUB_REF_NAME}"
|
git commit -m "ci: update artifact hub metadata for ${GITHUB_REF_NAME}"
|
||||||
git push origin main
|
git push origin temp-update:${GITEA_BRANCH}
|
||||||
}
|
}
|
||||||
# Force-move tag to the commit with correct checksum.
|
# Force-move tag to the commit with correct checksum.
|
||||||
# This triggers a new CI run, but the guard step will detect
|
# This triggers a new CI run, but the guard step will detect
|
||||||
# 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
|
|
||||||
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 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"
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
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"
|
||||||
@@ -83,6 +83,10 @@ 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:
|
||||||
@@ -222,6 +226,26 @@ AuditData
|
|||||||
|
|
||||||
Each check in a `ResultSet` has `Success` (bool) and `Severity` (`"warning"`, `"danger"`, or `"ignore"`). Checks with `Severity: "ignore"` and `Success: false` are counted as skipped. The cluster score is computed client-side as `pass / total * 100`.
|
Each check in a `ResultSet` has `Success` (bool) and `Severity` (`"warning"`, `"danger"`, or `"ignore"`). Checks with `Severity: "ignore"` and `Success: false` are counted as skipped. The cluster score is computed client-side as `pass / total * 100`.
|
||||||
|
|
||||||
|
## Known Limitations
|
||||||
|
|
||||||
|
### Skipped Count and Annotation-Based Exemptions
|
||||||
|
|
||||||
|
The **Skipped** count shown in the plugin only reflects checks with `Severity: "ignore"` in the Polaris API response. It does **not** include annotation-based exemptions (e.g., `polaris.fairwinds.com/privilegeEscalationAllowed-exempt: "true"`).
|
||||||
|
|
||||||
|
**Why?** Polaris completely omits exempted checks from the `results.json` endpoint. The native Polaris dashboard UI computes the "skipped" count client-side by:
|
||||||
|
1. Querying Kubernetes resources (Deployments, DaemonSets, StatefulSets, Pods) directly
|
||||||
|
2. Parsing their annotations for `polaris.fairwinds.com/*-exempt` keys
|
||||||
|
3. Counting how many checks were exempted
|
||||||
|
|
||||||
|
This plugin only has access to the processed audit results via the service proxy and does not query raw Kubernetes resources. To show accurate exemption counts, the plugin would need to:
|
||||||
|
- Request cluster-wide read access to all workload types (requires additional RBAC grants beyond `services/proxy`)
|
||||||
|
- Parse annotations on every workload in every namespace
|
||||||
|
- Cross-reference with the Polaris check catalog to count exemptions
|
||||||
|
|
||||||
|
This is a significant architectural change and is not currently implemented. Hover over the "Skipped" count in the UI to see a tooltip explaining this limitation.
|
||||||
|
|
||||||
|
**Workaround:** Use the "View in Polaris Dashboard" link from any namespace detail view to see the full exemption count in the native dashboard.
|
||||||
|
|
||||||
## Releasing
|
## Releasing
|
||||||
|
|
||||||
Releases are automated via CI. To cut a release:
|
Releases are automated via CI. To cut a release:
|
||||||
|
|||||||
+3
-3
@@ -1,4 +1,4 @@
|
|||||||
version: 0.1.5
|
version: 0.2.2
|
||||||
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.1.5/headlamp-polaris-plugin-0.1.5.tar.gz"
|
headlamp/plugin/archive-url: "https://github.com/cpfarhood/headlamp-polaris-plugin/releases/download/v0.2.2/headlamp-polaris-plugin-0.2.2.tar.gz"
|
||||||
headlamp/plugin/version-compat: ">=0.26"
|
headlamp/plugin/version-compat: ">=0.26"
|
||||||
headlamp/plugin/archive-checksum: sha256:7e56af04bcd9121e09eab092608c774b3aa2137d1e0338f1bb06149dd3f5c3ce
|
headlamp/plugin/archive-checksum: sha256:22400516eed9645e463873e7a5ab6c2dab8bfa8fca8a51bb51b0475079ca8c83
|
||||||
headlamp/plugin/distro-compat: in-cluster
|
headlamp/plugin/distro-compat: in-cluster
|
||||||
|
|||||||
+64
-15
@@ -20,42 +20,91 @@ test.describe('Polaris plugin smoke tests', () => {
|
|||||||
await expect(page.getByText(/%/)).toBeVisible();
|
await expect(page.getByText(/%/)).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('namespaces page renders table with links', async ({ page }) => {
|
test('namespaces page renders table with namespace buttons', 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 link
|
// Table should have at least one row with a namespace button
|
||||||
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 link
|
// Each namespace row should contain a button (now buttons instead of links for drawer)
|
||||||
const firstLink = rows.first().locator('a');
|
const firstButton = rows.first().locator('button');
|
||||||
await expect(firstLink).toBeVisible();
|
await expect(firstButton).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('namespace detail page renders from table link', async ({ page }) => {
|
test('namespace detail drawer opens from table button', async ({ page }) => {
|
||||||
await page.goto('/c/main/polaris/namespaces');
|
await page.goto('/c/main/polaris/namespaces');
|
||||||
|
|
||||||
// Click the first namespace link in the table
|
// Click the first namespace button in the table
|
||||||
const table = page.locator('table');
|
const table = page.locator('table');
|
||||||
await expect(table).toBeVisible();
|
await expect(table).toBeVisible();
|
||||||
const firstLink = table.locator('tbody tr').first().locator('a');
|
const firstButton = table.locator('tbody tr').first().locator('button');
|
||||||
const namespaceName = await firstLink.textContent();
|
const namespaceName = await firstButton.textContent();
|
||||||
await firstLink.click();
|
await firstButton.click();
|
||||||
|
|
||||||
// Detail page should show the namespace name in the heading
|
// Drawer should open and show the namespace name in the heading
|
||||||
|
await expect(
|
||||||
|
page.getByRole('heading', { name: `Polaris \u2014 ${namespaceName}` })
|
||||||
|
).toBeVisible();
|
||||||
|
|
||||||
|
// "Namespace Score" section should be present in drawer
|
||||||
|
await expect(page.getByText('Namespace Score')).toBeVisible();
|
||||||
|
|
||||||
|
// Resources table should exist in drawer
|
||||||
|
await expect(page.getByText('Resources')).toBeVisible();
|
||||||
|
|
||||||
|
// URL hash should be updated with namespace name
|
||||||
|
await expect(page).toHaveURL(/\/polaris\/namespaces#/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('namespace detail drawer closes with Escape key', async ({ page }) => {
|
||||||
|
await page.goto('/c/main/polaris/namespaces');
|
||||||
|
|
||||||
|
// Open the drawer by clicking a namespace button
|
||||||
|
const table = page.locator('table');
|
||||||
|
await expect(table).toBeVisible();
|
||||||
|
const firstButton = table.locator('tbody tr').first().locator('button');
|
||||||
|
const namespaceName = await firstButton.textContent();
|
||||||
|
await firstButton.click();
|
||||||
|
|
||||||
|
// Verify drawer is open
|
||||||
|
await expect(
|
||||||
|
page.getByRole('heading', { name: `Polaris \u2014 ${namespaceName}` })
|
||||||
|
).toBeVisible();
|
||||||
|
|
||||||
|
// Press Escape key
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
|
|
||||||
|
// Drawer should close (heading should not be visible anymore)
|
||||||
|
await expect(
|
||||||
|
page.getByRole('heading', { name: `Polaris \u2014 ${namespaceName}` })
|
||||||
|
).not.toBeVisible();
|
||||||
|
|
||||||
|
// URL hash should be cleared
|
||||||
|
await expect(page).toHaveURL(/\/polaris\/namespaces$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('namespace detail drawer opens from URL hash', async ({ page }) => {
|
||||||
|
// Get a namespace name first
|
||||||
|
await page.goto('/c/main/polaris/namespaces');
|
||||||
|
const table = page.locator('table');
|
||||||
|
await expect(table).toBeVisible();
|
||||||
|
const firstButton = table.locator('tbody tr').first().locator('button');
|
||||||
|
const namespaceName = await firstButton.textContent();
|
||||||
|
|
||||||
|
// Navigate directly to URL with hash
|
||||||
|
await page.goto(`/c/main/polaris/namespaces#${namespaceName}`);
|
||||||
|
|
||||||
|
// Drawer should automatically open with the namespace details
|
||||||
await expect(
|
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.1.3",
|
"version": "0.2.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "headlamp-polaris-plugin",
|
"name": "headlamp-polaris-plugin",
|
||||||
"version": "0.1.3",
|
"version": "0.2.0",
|
||||||
"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.1.5",
|
"version": "0.2.2",
|
||||||
"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",
|
||||||
|
|||||||
+26
-8
@@ -125,11 +125,14 @@ export const INTERVAL_OPTIONS = [
|
|||||||
{ label: '30 minutes', value: 1800 },
|
{ label: '30 minutes', value: 1800 },
|
||||||
];
|
];
|
||||||
|
|
||||||
const STORAGE_KEY = 'polaris-plugin-refresh-interval';
|
const REFRESH_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(STORAGE_KEY);
|
const stored = localStorage.getItem(REFRESH_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) {
|
||||||
@@ -140,13 +143,26 @@ export function getRefreshInterval(): number {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function setRefreshInterval(seconds: number): void {
|
export function setRefreshInterval(seconds: number): void {
|
||||||
localStorage.setItem(STORAGE_KEY, String(seconds));
|
localStorage.setItem(REFRESH_STORAGE_KEY, String(seconds));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDashboardUrl(): string {
|
||||||
|
const stored = localStorage.getItem(URL_STORAGE_KEY);
|
||||||
|
if (stored !== null && stored.trim() !== '') {
|
||||||
|
return stored.trim();
|
||||||
|
}
|
||||||
|
return DEFAULT_DASHBOARD_URL;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setDashboardUrl(url: string): void {
|
||||||
|
localStorage.setItem(URL_STORAGE_KEY, url.trim());
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Polaris dashboard proxy URL ---
|
// --- Polaris dashboard proxy URL ---
|
||||||
|
|
||||||
export const POLARIS_DASHBOARD_PROXY =
|
export function getPolarisProxyUrl(): string {
|
||||||
'/api/v1/namespaces/polaris/services/polaris-dashboard/proxy/';
|
return getDashboardUrl();
|
||||||
|
}
|
||||||
|
|
||||||
// --- Score computation ---
|
// --- Score computation ---
|
||||||
|
|
||||||
@@ -157,8 +173,10 @@ export function computeScore(counts: ResultCounts): number {
|
|||||||
|
|
||||||
// --- Data fetching hook ---
|
// --- Data fetching hook ---
|
||||||
|
|
||||||
const POLARIS_API_PATH =
|
function getPolarisApiPath(): string {
|
||||||
'/api/v1/namespaces/polaris/services/polaris-dashboard:80/proxy/results.json';
|
const baseUrl = getDashboardUrl();
|
||||||
|
return baseUrl.endsWith('/') ? `${baseUrl}results.json` : `${baseUrl}/results.json`;
|
||||||
|
}
|
||||||
|
|
||||||
interface PolarisDataState {
|
interface PolarisDataState {
|
||||||
data: AuditData | null;
|
data: AuditData | null;
|
||||||
@@ -177,7 +195,7 @@ export function usePolarisData(refreshIntervalSeconds: number): PolarisDataState
|
|||||||
|
|
||||||
async function fetchData() {
|
async function fetchData() {
|
||||||
try {
|
try {
|
||||||
const result: AuditData = await ApiProxy.request(POLARIS_API_PATH);
|
const result: AuditData = await ApiProxy.request(getPolarisApiPath());
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
setData(result);
|
setData(result);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|||||||
@@ -26,7 +26,6 @@ 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 (
|
||||||
@@ -51,7 +50,6 @@ 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: String(counts.skipped) },
|
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</SectionBox>
|
</SectionBox>
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
computeScore,
|
computeScore,
|
||||||
countResultsForItems,
|
countResultsForItems,
|
||||||
filterResultsByNamespace,
|
filterResultsByNamespace,
|
||||||
POLARIS_DASHBOARD_PROXY,
|
getPolarisProxyUrl,
|
||||||
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={POLARIS_DASHBOARD_PROXY} target="_blank" rel="noopener noreferrer">
|
<a href={getPolarisProxyUrl()} target="_blank" rel="noopener noreferrer">
|
||||||
View in Polaris Dashboard
|
View in Polaris Dashboard
|
||||||
</a>
|
</a>
|
||||||
),
|
),
|
||||||
@@ -120,7 +120,11 @@ export default function NamespaceDetailView() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Skipped',
|
name: 'Skipped',
|
||||||
value: String(counts.skipped),
|
value: (
|
||||||
|
<span title="Only counts checks with Severity=ignore. Annotation-based exemptions are not included.">
|
||||||
|
{counts.skipped}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
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';
|
||||||
@@ -117,7 +118,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 links', () => {
|
it('renders namespace rows with correct scores and buttons', () => {
|
||||||
const data = makeAuditData([
|
const data = makeAuditData([
|
||||||
makeResult({
|
makeResult({
|
||||||
Name: 'deploy-a',
|
Name: 'deploy-a',
|
||||||
@@ -157,12 +158,14 @@ describe('NamespacesListView', () => {
|
|||||||
|
|
||||||
renderWithRouter(<NamespacesListView />);
|
renderWithRouter(<NamespacesListView />);
|
||||||
|
|
||||||
// Namespace links
|
// Namespace buttons (now buttons instead of links for drawer)
|
||||||
const alphaLink = screen.getByText('alpha');
|
const alphaButton = screen.getByText('alpha');
|
||||||
expect(alphaLink.closest('a')).toHaveAttribute('href', '/polaris/ns/alpha');
|
expect(alphaButton).toBeInTheDocument();
|
||||||
|
expect(alphaButton.tagName).toBe('BUTTON');
|
||||||
|
|
||||||
const betaLink = screen.getByText('beta');
|
const betaButton = screen.getByText('beta');
|
||||||
expect(betaLink.closest('a')).toHaveAttribute('href', '/polaris/ns/beta');
|
expect(betaButton).toBeInTheDocument();
|
||||||
|
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', () => {
|
||||||
@@ -216,4 +219,74 @@ 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,4 +1,3 @@
|
|||||||
import { Router } from '@kinvolk/headlamp-plugin/lib';
|
|
||||||
import {
|
import {
|
||||||
Loader,
|
Loader,
|
||||||
NameValueTable,
|
NameValueTable,
|
||||||
@@ -7,13 +6,16 @@ import {
|
|||||||
SimpleTable,
|
SimpleTable,
|
||||||
StatusLabel,
|
StatusLabel,
|
||||||
} from '@kinvolk/headlamp-plugin/lib/CommonComponents';
|
} from '@kinvolk/headlamp-plugin/lib/CommonComponents';
|
||||||
import React from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { useHistory, useLocation } 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';
|
||||||
|
|
||||||
@@ -32,9 +34,226 @@ interface NamespaceRow {
|
|||||||
skipped: number;
|
skipped: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function NamespacesListView() {
|
function resourceCounts(result: Result): ResultCounts {
|
||||||
|
return countResultsForItems([result]);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NamespaceDetailPanelProps {
|
||||||
|
namespace: string;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function NamespaceDetailPanel({ namespace, onClose }: NamespaceDetailPanelProps) {
|
||||||
const { data, loading, error } = usePolarisDataContext();
|
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: '800px',
|
||||||
|
backgroundColor: '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 }}>Polaris — {namespace}</h2>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
style={{
|
||||||
|
border: 'none',
|
||||||
|
background: 'transparent',
|
||||||
|
fontSize: '24px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
padding: '0 8px',
|
||||||
|
}}
|
||||||
|
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) {
|
if (loading) {
|
||||||
return <Loader title="Loading Polaris audit data..." />;
|
return <Loader title="Loading Polaris audit data..." />;
|
||||||
}
|
}
|
||||||
@@ -92,13 +311,20 @@ export default function NamespacesListView() {
|
|||||||
{
|
{
|
||||||
label: 'Namespace',
|
label: 'Namespace',
|
||||||
getter: (row: NamespaceRow) => (
|
getter: (row: NamespaceRow) => (
|
||||||
<Link
|
<button
|
||||||
to={Router.createRouteURL('polaris-namespace', {
|
onClick={() => openNamespace(row.namespace)}
|
||||||
namespace: row.namespace,
|
style={{
|
||||||
})}
|
border: 'none',
|
||||||
|
background: 'transparent',
|
||||||
|
color: 'var(--link-color, #1976d2)',
|
||||||
|
cursor: 'pointer',
|
||||||
|
textDecoration: 'underline',
|
||||||
|
padding: 0,
|
||||||
|
font: 'inherit',
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{row.namespace}
|
{row.namespace}
|
||||||
</Link>
|
</button>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -130,6 +356,25 @@ 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,6 +1,6 @@
|
|||||||
import { NameValueTable, SectionBox } from '@kinvolk/headlamp-plugin/lib/CommonComponents';
|
import { NameValueTable, SectionBox } from '@kinvolk/headlamp-plugin/lib/CommonComponents';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { getRefreshInterval, INTERVAL_OPTIONS, setRefreshInterval } from '../api/polaris';
|
import { getDashboardUrl, getRefreshInterval, INTERVAL_OPTIONS, setDashboardUrl, setRefreshInterval } from '../api/polaris';
|
||||||
|
|
||||||
interface PluginSettingsProps {
|
interface PluginSettingsProps {
|
||||||
data?: { [key: string]: string | number | boolean };
|
data?: { [key: string]: string | number | boolean };
|
||||||
@@ -10,13 +10,20 @@ 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();
|
||||||
|
|
||||||
function handleChange(e: React.ChangeEvent<HTMLSelectElement>) {
|
function handleIntervalChange(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 });
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SectionBox title="Polaris Settings">
|
<SectionBox title="Polaris Settings">
|
||||||
<NameValueTable
|
<NameValueTable
|
||||||
@@ -24,7 +31,7 @@ export default function PolarisSettings(props: PluginSettingsProps) {
|
|||||||
{
|
{
|
||||||
name: 'Refresh Interval',
|
name: 'Refresh Interval',
|
||||||
value: (
|
value: (
|
||||||
<select value={currentInterval} onChange={handleChange}>
|
<select value={currentInterval} onChange={handleIntervalChange}>
|
||||||
{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}
|
||||||
@@ -33,6 +40,24 @@ export default function PolarisSettings(props: PluginSettingsProps) {
|
|||||||
</select>
|
</select>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'Dashboard URL',
|
||||||
|
value: (
|
||||||
|
<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',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</SectionBox>
|
</SectionBox>
|
||||||
|
|||||||
+1
-14
@@ -6,7 +6,6 @@ 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';
|
||||||
|
|
||||||
@@ -62,16 +61,4 @@ registerRoute({
|
|||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
registerRoute({
|
registerPluginSettings('polaris', PolarisSettings, true);
|
||||||
path: '/polaris/ns/:namespace',
|
|
||||||
sidebar: 'polaris-namespaces',
|
|
||||||
name: 'polaris-namespace',
|
|
||||||
exact: true,
|
|
||||||
component: () => (
|
|
||||||
<PolarisDataProvider>
|
|
||||||
<NamespaceDetailView />
|
|
||||||
</PolarisDataProvider>
|
|
||||||
),
|
|
||||||
});
|
|
||||||
|
|
||||||
registerPluginSettings('headlamp-polaris-plugin', PolarisSettings, true);
|
|
||||||
|
|||||||
Reference in New Issue
Block a user