feat: initial release of headlamp-rook-ceph-plugin v0.1.0
Headlamp plugin for Rook-Ceph cluster visibility. Pages: - Overview dashboard: CephCluster health, capacity bar, resource counts (block pools, filesystems, object stores, PVs, PVCs), daemon pod health summary, non-Bound PVC alerts - Block Pools: CephBlockPool table with replication, failure domain, mirroring; slide-in detail panel - Pods: all Rook-Ceph daemon pods grouped by role with ready/total counts Native Headlamp integrations: - StorageClass table: Rook Type, Pool, Cluster ID columns - PV table: Rook Type, Pool columns - PVC detail injection: driver, type, pool, volume handle - PV detail injection: CSI volume attributes - Pod detail injection: Ceph daemon role badge - App bar badge: cluster health (HEALTH_OK/WARN/ERR), color-coded API / architecture: - src/api/k8s.ts: types + filters for ceph.rook.io/v1 CRDs; handles both default rook-ceph.* and custom-namespace provisioner strings - src/api/RookCephDataContext.tsx: shared context provider; fetches CephCluster, CephBlockPool, CephFilesystem, CephObjectStore CRDs plus daemon pods via label selectors - 37 unit tests (vitest + @testing-library/react) - TypeScript strict mode, zero any types - CI + release GitHub Actions workflows Generated with [Claude Code](https://claude.ai/code) via [Happy](https://happy.engineering) Co-Authored-By: Claude <noreply@anthropic.com> Co-Authored-By: Happy <yesreply@happy.engineering>
This commit is contained in:
@@ -0,0 +1,37 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
pull_request:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
lint-and-test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 10
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '20'
|
||||||
|
cache: 'npm'
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Build plugin
|
||||||
|
run: npx @kinvolk/headlamp-plugin build
|
||||||
|
|
||||||
|
- name: Lint
|
||||||
|
run: npx eslint --ext .ts,.tsx src/
|
||||||
|
|
||||||
|
- name: Type-check
|
||||||
|
run: npx tsc --noEmit
|
||||||
|
|
||||||
|
- name: Run unit tests
|
||||||
|
run: npm test
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
name: Release
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
version:
|
||||||
|
description: 'Version to release (without v prefix, e.g., 0.2.0)'
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
release:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
steps:
|
||||||
|
- name: Validate version format
|
||||||
|
run: |
|
||||||
|
if ! echo "${{ inputs.version }}" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$'; then
|
||||||
|
echo "::error::Version must be in format X.Y.Z (e.g., 0.2.0)"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Configure git
|
||||||
|
run: |
|
||||||
|
git config user.name "github-actions[bot]"
|
||||||
|
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||||
|
|
||||||
|
- name: Update package.json version
|
||||||
|
run: |
|
||||||
|
jq --arg version "${{ inputs.version }}" '.version = $version' package.json > package.json.tmp
|
||||||
|
mv package.json.tmp package.json
|
||||||
|
|
||||||
|
- name: Update artifacthub-pkg.yml version and URL
|
||||||
|
run: |
|
||||||
|
VERSION="${{ inputs.version }}"
|
||||||
|
RELEASE_URL="https://github.com/${{ github.repository }}/releases/download/v${VERSION}/headlamp-rook-ceph-plugin-${VERSION}.tar.gz"
|
||||||
|
|
||||||
|
sed -i "s|^version:.*|version: \"${VERSION}\"|" artifacthub-pkg.yml
|
||||||
|
sed -i "s|headlamp/plugin/archive-url:.*|headlamp/plugin/archive-url: \"${RELEASE_URL}\"|" artifacthub-pkg.yml
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '20'
|
||||||
|
cache: 'npm'
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Build plugin
|
||||||
|
run: npx @kinvolk/headlamp-plugin build
|
||||||
|
|
||||||
|
- name: Package plugin
|
||||||
|
run: npx @kinvolk/headlamp-plugin package
|
||||||
|
|
||||||
|
- name: Validate tarball name
|
||||||
|
run: |
|
||||||
|
EXPECTED="headlamp-rook-ceph-plugin-${{ inputs.version }}.tar.gz"
|
||||||
|
ACTUAL=$(ls *.tar.gz)
|
||||||
|
if [ "$EXPECTED" != "$ACTUAL" ]; then
|
||||||
|
echo "::error::Tarball name mismatch! Expected: $EXPECTED, Got: $ACTUAL"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "✓ Tarball name validated: $ACTUAL"
|
||||||
|
|
||||||
|
- name: Compute checksum
|
||||||
|
id: compute_checksum
|
||||||
|
run: |
|
||||||
|
TARBALL="headlamp-rook-ceph-plugin-${{ inputs.version }}.tar.gz"
|
||||||
|
CHECKSUM=$(sha256sum "$TARBALL" | awk '{print $1}')
|
||||||
|
echo "checksum=${CHECKSUM}" >> $GITHUB_OUTPUT
|
||||||
|
echo "Checksum: sha256:${CHECKSUM}"
|
||||||
|
|
||||||
|
- name: Update checksum in metadata
|
||||||
|
run: |
|
||||||
|
CHECKSUM="${{ steps.compute_checksum.outputs.checksum }}"
|
||||||
|
sed -i "s|headlamp/plugin/archive-checksum:.*|headlamp/plugin/archive-checksum: \"sha256:${CHECKSUM}\"|" artifacthub-pkg.yml
|
||||||
|
|
||||||
|
- name: Commit version bump and metadata
|
||||||
|
run: |
|
||||||
|
git add package.json artifacthub-pkg.yml
|
||||||
|
git commit -m "chore: release v${{ inputs.version }}"
|
||||||
|
git push origin main
|
||||||
|
|
||||||
|
- name: Create and push tag
|
||||||
|
run: |
|
||||||
|
git tag "v${{ inputs.version }}"
|
||||||
|
git push origin "v${{ inputs.version }}"
|
||||||
|
|
||||||
|
- name: Create GitHub Release
|
||||||
|
uses: softprops/action-gh-release@v2
|
||||||
|
with:
|
||||||
|
tag_name: "v${{ inputs.version }}"
|
||||||
|
files: headlamp-rook-ceph-plugin-${{ inputs.version }}.tar.gz
|
||||||
|
fail_on_unmatched_files: true
|
||||||
|
draft: false
|
||||||
|
prerelease: false
|
||||||
|
generate_release_notes: true
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Summary
|
||||||
|
run: |
|
||||||
|
echo "✓ Version bumped to ${{ inputs.version }}"
|
||||||
|
echo "✓ Metadata updated with checksum sha256:${{ steps.compute_checksum.outputs.checksum }}"
|
||||||
|
echo "✓ Tag v${{ inputs.version }} created"
|
||||||
|
echo "✓ GitHub release published with tarball"
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
*.tar.gz
|
||||||
|
.playwright-mcp/
|
||||||
|
.mcp.json
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
All notable changes to the Headlamp Rook-Ceph Plugin will be documented in this file.
|
||||||
|
|
||||||
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||||
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [0.1.0] - 2026-02-18
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Overview Dashboard** — CephCluster health (HEALTH_OK/WARN/ERR), cluster capacity PercentageBar, storage resource counts (block pools, filesystems, object stores, PVs, PVCs), daemon pod health summary, non-Bound PVC alert table
|
||||||
|
- **Block Pools page** — CephBlockPool table with phase, replication, failure domain, mirroring; slide-in detail panel with erasure coding and status info
|
||||||
|
- **Storage Classes page** — Rook-Ceph StorageClass table with type (Block/Filesystem), pool, provisioner, reclaim policy, expansion; slide-in detail panel with parameters
|
||||||
|
- **Volumes page** — Rook-Ceph PV table with capacity, access modes, reclaim, pool, claim; slide-in detail panel with full CSI volume attributes
|
||||||
|
- **Pods page** — all Rook-Ceph daemon pods grouped by role with ready/total counts and restart tracking
|
||||||
|
- **StorageClass column injection** — adds Rook Type, Pool, and Cluster ID columns to native Headlamp StorageClass table
|
||||||
|
- **PV column injection** — adds Rook Type and Pool columns to native Headlamp PV table
|
||||||
|
- **PVC Detail Injection** — Rook-Ceph section automatically injected into Headlamp's PVC detail view showing driver, type, pool, volume handle, and PV name
|
||||||
|
- **PV Detail Injection** — Rook-Ceph section injected into PV detail view with full CSI volume attributes
|
||||||
|
- **Pod Detail Injection** — Ceph daemon role badge (Operator, MON, OSD, MGR, etc.) injected into matching Pod detail pages
|
||||||
|
- **App Bar Badge** — cluster health badge in top nav bar, color-coded green/orange/red; hidden when no CephCluster present
|
||||||
|
- **RookCephDataContext** — shared React context provider for all plugin pages; fetches CephCluster, CephBlockPool, CephFilesystem, CephObjectStore CRDs plus daemon pods
|
||||||
|
- **Multi-provisioner support** — handles both default `rook-ceph.*` and custom-namespace provisioner strings
|
||||||
|
|
||||||
|
### Infrastructure
|
||||||
|
|
||||||
|
- GitHub repository with CI (lint + type-check + test) and release workflows
|
||||||
|
- Unit tests with Vitest + @testing-library/react
|
||||||
|
- TypeScript strict mode with zero `any` types
|
||||||
|
- ESLint + Prettier code quality tooling
|
||||||
|
|
||||||
|
[Unreleased]: https://github.com/cpfarhood/headlamp-rook-ceph-plugin/compare/v0.1.0...HEAD
|
||||||
|
[0.1.0]: https://github.com/cpfarhood/headlamp-rook-ceph-plugin/releases/tag/v0.1.0
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
# headlamp-rook-ceph-plugin
|
||||||
|
|
||||||
|
Headlamp plugin for Rook-Ceph cluster visibility.
|
||||||
|
|
||||||
|
## Project
|
||||||
|
|
||||||
|
- **Plugin name**: `headlamp-rook-ceph-plugin`
|
||||||
|
- **Rook-Ceph API group**: `ceph.rook.io/v1`
|
||||||
|
- **Default namespace**: `rook-ceph`
|
||||||
|
- **RBD provisioner**: `rook-ceph.rbd.csi.ceph.com`
|
||||||
|
- **CephFS provisioner**: `rook-ceph.cephfs.csi.ceph.com`
|
||||||
|
- **Reference plugin**: `../headlamp-tns-csi-plugin`
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm start # dev server with hot reload
|
||||||
|
npm run build # production build
|
||||||
|
npm run package # package for headlamp
|
||||||
|
npm run tsc # TypeScript type check (no emit)
|
||||||
|
npm run lint # ESLint
|
||||||
|
npm test # vitest run
|
||||||
|
npm run test:watch # vitest watch mode
|
||||||
|
```
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── index.tsx # Plugin entry: registerRoute, registerSidebarEntry, etc.
|
||||||
|
├── api/
|
||||||
|
│ ├── k8s.ts # Types + filtering helpers (ceph.rook.io)
|
||||||
|
│ └── RookCephDataContext.tsx # Shared React context provider
|
||||||
|
└── components/
|
||||||
|
├── OverviewPage.tsx
|
||||||
|
├── BlockPoolsPage.tsx
|
||||||
|
├── StorageClassesPage.tsx
|
||||||
|
├── VolumesPage.tsx
|
||||||
|
├── PodsPage.tsx
|
||||||
|
├── ClusterStatusCard.tsx
|
||||||
|
├── AppBarClusterBadge.tsx
|
||||||
|
├── PVCDetailSection.tsx # Injected into Headlamp PVC detail view
|
||||||
|
├── PVDetailSection.tsx # Injected into Headlamp PV detail view
|
||||||
|
├── CephPodDetailSection.tsx # Injected into Headlamp Pod detail view
|
||||||
|
└── integrations/
|
||||||
|
└── StorageClassColumns.tsx # Column processors for SC + PV tables
|
||||||
|
```
|
||||||
|
|
||||||
|
## Key constants (src/api/k8s.ts)
|
||||||
|
|
||||||
|
- Namespace: `rook-ceph`
|
||||||
|
- API group: `ceph.rook.io/v1`
|
||||||
|
- RBD provisioner: `rook-ceph.rbd.csi.ceph.com`
|
||||||
|
- CephFS provisioner: `rook-ceph.cephfs.csi.ceph.com`
|
||||||
|
- Custom namespace provisioners: any string ending in `.rbd.csi.ceph.com` or `.cephfs.csi.ceph.com`
|
||||||
|
- Pod selectors: `app=rook-ceph-operator`, `app=rook-ceph-mon`, `app=rook-ceph-osd`, `app=rook-ceph-mgr`, `app=csi-rbdplugin-provisioner`, `app=csi-cephfsplugin-provisioner`
|
||||||
|
|
||||||
|
## Code conventions
|
||||||
|
|
||||||
|
- Functional React components only — no class components
|
||||||
|
- All imports from `@kinvolk/headlamp-plugin/lib` and `@kinvolk/headlamp-plugin/lib/CommonComponents`
|
||||||
|
- No additional UI libraries (no MUI direct imports, no Ant Design, etc.)
|
||||||
|
- TypeScript strict mode — no `any`, use `unknown` + type guards at API boundaries
|
||||||
|
- Context provider (`RookCephDataProvider`) wraps each route component in `index.tsx`
|
||||||
|
- Tests: vitest + @testing-library/react, mock with `vi.mock('@kinvolk/headlamp-plugin/lib', ...)`
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
All tests must pass before committing:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm test # tests across test files
|
||||||
|
npm run tsc # must exit 0
|
||||||
|
```
|
||||||
|
|
||||||
|
Mock pattern for headlamp APIs:
|
||||||
|
```typescript
|
||||||
|
vi.mock('@kinvolk/headlamp-plugin/lib', () => ({
|
||||||
|
ApiProxy: { request: vi.fn().mockResolvedValue({ items: [] }) },
|
||||||
|
K8s: {
|
||||||
|
ResourceClasses: {
|
||||||
|
StorageClass: { useList: vi.fn(() => [[], null]) },
|
||||||
|
PersistentVolume: { useList: vi.fn(() => [[], null]) },
|
||||||
|
PersistentVolumeClaim: { useList: vi.fn(() => [[], null]) },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
```
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
# Contributing
|
||||||
|
|
||||||
|
Contributions are welcome! Please open an issue before submitting large PRs.
|
||||||
|
|
||||||
|
## Development Setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/cpfarhood/headlamp-rook-ceph-plugin.git
|
||||||
|
cd headlamp-rook-ceph-plugin
|
||||||
|
npm install
|
||||||
|
npm start # hot-reload dev server
|
||||||
|
```
|
||||||
|
|
||||||
|
## Before Submitting
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run tsc # must exit 0
|
||||||
|
npm run lint # must exit 0
|
||||||
|
npm test # all tests must pass
|
||||||
|
npm run build # must succeed
|
||||||
|
```
|
||||||
|
|
||||||
|
## Coding Conventions
|
||||||
|
|
||||||
|
- Functional React components only — no class components
|
||||||
|
- TypeScript strict mode: no `any`, use `unknown` + type guards at API boundaries
|
||||||
|
- All imports from `@kinvolk/headlamp-plugin/lib` and `@kinvolk/headlamp-plugin/lib/CommonComponents`
|
||||||
|
- No additional UI libraries (no direct MUI imports, no Ant Design, etc.)
|
||||||
|
- Context provider (`RookCephDataProvider`) wraps each route component in `index.tsx`
|
||||||
|
- Tests: vitest + @testing-library/react, mock with `vi.mock('@kinvolk/headlamp-plugin/lib', ...)`
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
By contributing, you agree your contributions will be licensed under Apache-2.0.
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
# Headlamp Rook-Ceph Plugin
|
||||||
|
|
||||||
|
[](https://github.com/cpfarhood/headlamp-rook-ceph-plugin/actions/workflows/ci.yaml)
|
||||||
|
[](https://opensource.org/licenses/Apache-2.0)
|
||||||
|
|
||||||
|
A [Headlamp](https://headlamp.dev/) plugin that surfaces [Rook-Ceph](https://rook.io/) cluster health, storage resources, and CSI driver status directly in the Headlamp UI.
|
||||||
|
|
||||||
|
**[Installation](#installing) | [Features](#what-it-does) | [Security](#rbac--security-setup) | [Development](#development)**
|
||||||
|
|
||||||
|
## What It Does
|
||||||
|
|
||||||
|
Adds a **Rook-Ceph** top-level sidebar section to Headlamp with full cluster observability:
|
||||||
|
|
||||||
|
### Main Views
|
||||||
|
|
||||||
|
- **Overview Dashboard** — CephCluster health (HEALTH_OK/WARN/ERR), cluster capacity bar, storage resource counts (block pools, filesystems, object stores, PVs), daemon pod health summary, non-Bound PVC alerts
|
||||||
|
- **Block Pools** — table of CephBlockPool resources with phase, replication factor, failure domain, and mirroring status; click a row for a slide-in detail panel
|
||||||
|
- **Pods** — all Rook-Ceph daemon pods grouped by role (Operator, MON, MGR, OSD, CSI RBD, CSI CephFS) with ready/total counts and restart counts
|
||||||
|
|
||||||
|
### Integrated Features (Native Headlamp Views)
|
||||||
|
|
||||||
|
- **StorageClass columns** — adds Rook Type (Block/Filesystem), Pool, and Cluster ID columns to the native `/storage-classes` table for Rook-Ceph StorageClasses; `—` for non-Rook rows
|
||||||
|
- **PV columns** — adds Rook Type and Pool columns to the native `/persistent-volumes` table
|
||||||
|
- **PVC Detail Injection** — Rook-Ceph section automatically injected into Headlamp's PVC detail view (driver, type, pool, volume handle, PV name)
|
||||||
|
- **PV Detail Injection** — Rook-Ceph section injected into PV detail view with full CSI volume attributes
|
||||||
|
- **Pod Detail Injection** — Ceph daemon role badge (Operator, MON, OSD, MGR, etc.) injected into matching Pod detail pages
|
||||||
|
- **App Bar Badge** — cluster health badge (`rook-ceph: HEALTH_OK`) in top nav bar, color-coded; hidden when no CephCluster is present
|
||||||
|
|
||||||
|
### Data Sources
|
||||||
|
|
||||||
|
- **CephCluster, CephBlockPool, CephFilesystem, CephObjectStore** CRDs fetched via `ApiProxy.request` from `ceph.rook.io/v1`
|
||||||
|
- **StorageClasses, PVs, PVCs** fetched via Headlamp's `K8s.ResourceClasses` hooks (live watch)
|
||||||
|
- **Daemon pods** (operator, mon, osd, mgr, csi-rbdplugin-provisioner, csi-cephfsplugin-provisioner) fetched via label selector
|
||||||
|
|
||||||
|
The plugin is **read-only** — no write operations.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
| Requirement | Minimum version |
|
||||||
|
|----------------|-----------------|
|
||||||
|
| Headlamp | v0.20+ |
|
||||||
|
| Rook | v1.12+ |
|
||||||
|
| Ceph | v17 (Quincy)+ |
|
||||||
|
| Kubernetes | v1.24+ |
|
||||||
|
|
||||||
|
Rook-Ceph must be deployed in the `rook-ceph` namespace with standard labels. The CephCluster, CephBlockPool, CephFilesystem, and CephObjectStore CRDs (`ceph.rook.io/v1`) must be installed.
|
||||||
|
|
||||||
|
## Installing
|
||||||
|
|
||||||
|
### Option 1: Manual Plugin Install
|
||||||
|
|
||||||
|
Download the latest release tarball and place it in your Headlamp plugins directory:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Download the latest release
|
||||||
|
curl -L https://github.com/cpfarhood/headlamp-rook-ceph-plugin/releases/latest/download/headlamp-rook-ceph-plugin-<version>.tar.gz \
|
||||||
|
-o headlamp-rook-ceph-plugin.tar.gz
|
||||||
|
|
||||||
|
# Extract to Headlamp plugins directory
|
||||||
|
tar -xzf headlamp-rook-ceph-plugin.tar.gz -C ~/.config/Headlamp/plugins/
|
||||||
|
```
|
||||||
|
|
||||||
|
### Option 2: Headlamp In-App Plugin Manager
|
||||||
|
|
||||||
|
Browse the Headlamp Plugin Manager (Settings → Plugins) and install **headlamp-rook-ceph-plugin** directly.
|
||||||
|
|
||||||
|
## RBAC & Security Setup
|
||||||
|
|
||||||
|
The plugin reads Rook-Ceph CRDs and Kubernetes resources. Your Headlamp service account needs:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
apiVersion: rbac.authorization.k8s.io/v1
|
||||||
|
kind: ClusterRole
|
||||||
|
metadata:
|
||||||
|
name: headlamp-rook-ceph-reader
|
||||||
|
rules:
|
||||||
|
# Rook-Ceph CRDs
|
||||||
|
- apiGroups: ["ceph.rook.io"]
|
||||||
|
resources:
|
||||||
|
- cephclusters
|
||||||
|
- cephblockpools
|
||||||
|
- cephfilesystems
|
||||||
|
- cephobjectstores
|
||||||
|
verbs: ["get", "list", "watch"]
|
||||||
|
# Native K8s resources
|
||||||
|
- apiGroups: [""]
|
||||||
|
resources:
|
||||||
|
- pods
|
||||||
|
- persistentvolumes
|
||||||
|
- persistentvolumeclaims
|
||||||
|
verbs: ["get", "list", "watch"]
|
||||||
|
- apiGroups: ["storage.k8s.io"]
|
||||||
|
resources: ["storageclasses"]
|
||||||
|
verbs: ["get", "list", "watch"]
|
||||||
|
---
|
||||||
|
apiVersion: rbac.authorization.k8s.io/v1
|
||||||
|
kind: ClusterRoleBinding
|
||||||
|
metadata:
|
||||||
|
name: headlamp-rook-ceph-reader
|
||||||
|
roleRef:
|
||||||
|
apiGroup: rbac.authorization.k8s.io
|
||||||
|
kind: ClusterRole
|
||||||
|
name: headlamp-rook-ceph-reader
|
||||||
|
subjects:
|
||||||
|
- kind: ServiceAccount
|
||||||
|
name: headlamp
|
||||||
|
namespace: headlamp
|
||||||
|
```
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
- Node.js 20+
|
||||||
|
- npm
|
||||||
|
|
||||||
|
### Setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/cpfarhood/headlamp-rook-ceph-plugin.git
|
||||||
|
cd headlamp-rook-ceph-plugin
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
### Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm start # dev server with hot reload
|
||||||
|
npm run build # production build
|
||||||
|
npm run package # package for headlamp
|
||||||
|
npm run tsc # TypeScript type check (no emit)
|
||||||
|
npm run lint # ESLint
|
||||||
|
npm test # vitest run
|
||||||
|
npm run test:watch # vitest watch mode
|
||||||
|
```
|
||||||
|
|
||||||
|
### Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── index.tsx # Plugin entry: registerRoute, registerSidebarEntry, etc.
|
||||||
|
├── api/
|
||||||
|
│ ├── k8s.ts # Types + filtering helpers (ceph.rook.io/v1)
|
||||||
|
│ └── RookCephDataContext.tsx # Shared React context provider
|
||||||
|
└── components/
|
||||||
|
├── OverviewPage.tsx # Dashboard: health, capacity, resource counts
|
||||||
|
├── BlockPoolsPage.tsx # CephBlockPool table + detail panel
|
||||||
|
├── StorageClassesPage.tsx # Rook-Ceph StorageClass table
|
||||||
|
├── VolumesPage.tsx # Rook-Ceph PV table + detail panel
|
||||||
|
├── PodsPage.tsx # Daemon pods grouped by role
|
||||||
|
├── ClusterStatusCard.tsx # Reusable cluster health + capacity card
|
||||||
|
├── AppBarClusterBadge.tsx # App bar health badge
|
||||||
|
├── PVCDetailSection.tsx # Injected into Headlamp PVC detail view
|
||||||
|
├── PVDetailSection.tsx # Injected into Headlamp PV detail view
|
||||||
|
├── CephPodDetailSection.tsx # Injected into Headlamp Pod detail view
|
||||||
|
└── integrations/
|
||||||
|
└── StorageClassColumns.tsx # Column processors for SC + PV tables
|
||||||
|
```
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm test # runs all unit tests
|
||||||
|
npm run tsc # must exit 0
|
||||||
|
```
|
||||||
|
|
||||||
|
Mock pattern for headlamp APIs:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
vi.mock('@kinvolk/headlamp-plugin/lib', () => ({
|
||||||
|
ApiProxy: { request: vi.fn().mockResolvedValue({ items: [] }) },
|
||||||
|
K8s: {
|
||||||
|
ResourceClasses: {
|
||||||
|
StorageClass: { useList: vi.fn(() => [[], null]) },
|
||||||
|
PersistentVolume: { useList: vi.fn(() => [[], null]) },
|
||||||
|
PersistentVolumeClaim: { useList: vi.fn(() => [[], null]) },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
```
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
Apache-2.0 — see [LICENSE](LICENSE) for details.
|
||||||
+15
@@ -0,0 +1,15 @@
|
|||||||
|
# Security Policy
|
||||||
|
|
||||||
|
## Supported Versions
|
||||||
|
|
||||||
|
| Version | Supported |
|
||||||
|
|---------|-----------|
|
||||||
|
| Latest | ✓ |
|
||||||
|
|
||||||
|
## Reporting a Vulnerability
|
||||||
|
|
||||||
|
Please report security vulnerabilities by opening a [GitHub Security Advisory](https://github.com/cpfarhood/headlamp-rook-ceph-plugin/security/advisories/new) rather than a public issue.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
This plugin is **read-only** — it does not modify any Kubernetes or Ceph resources. The only network requests made are to the Kubernetes API server via Headlamp's `ApiProxy` (which handles auth) and to Headlamp's own hooks.
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
version: "0.1.0"
|
||||||
|
name: headlamp-rook-ceph-plugin
|
||||||
|
displayName: Rook-Ceph Plugin
|
||||||
|
createdAt: "2026-02-18T00:00:00Z"
|
||||||
|
description: Headlamp plugin for Rook-Ceph cluster visibility — CephCluster health, pool status, CSI driver monitoring, and native Headlamp StorageClass/PV integrations.
|
||||||
|
logoPath: ""
|
||||||
|
digest: ""
|
||||||
|
license: Apache-2.0
|
||||||
|
homeURL: https://github.com/cpfarhood/headlamp-rook-ceph-plugin
|
||||||
|
keywords:
|
||||||
|
- rook
|
||||||
|
- ceph
|
||||||
|
- storage
|
||||||
|
- csi
|
||||||
|
- headlamp
|
||||||
|
- kubernetes
|
||||||
|
links:
|
||||||
|
- name: source
|
||||||
|
url: https://github.com/cpfarhood/headlamp-rook-ceph-plugin
|
||||||
|
maintainers:
|
||||||
|
- name: cpfarhood
|
||||||
|
email: cpfarhood@users.noreply.github.com
|
||||||
|
provider:
|
||||||
|
name: cpfarhood
|
||||||
|
annotations:
|
||||||
|
headlamp/plugin/archive-url: "https://github.com/cpfarhood/headlamp-rook-ceph-plugin/releases/download/v0.1.0/headlamp-rook-ceph-plugin-0.1.0.tar.gz"
|
||||||
|
headlamp/plugin/archive-checksum: "sha256:"
|
||||||
|
headlamp/plugin/distro-compat: ""
|
||||||
|
headlamp/plugin/version-compat: ">=0.20"
|
||||||
Generated
+18188
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,30 @@
|
|||||||
|
{
|
||||||
|
"name": "headlamp-rook-ceph-plugin",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "Headlamp plugin for Rook-Ceph cluster visibility and CSI driver monitoring",
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/cpfarhood/headlamp-rook-ceph-plugin.git"
|
||||||
|
},
|
||||||
|
"bugs": {
|
||||||
|
"url": "https://github.com/cpfarhood/headlamp-rook-ceph-plugin/issues"
|
||||||
|
},
|
||||||
|
"homepage": "https://github.com/cpfarhood/headlamp-rook-ceph-plugin#readme",
|
||||||
|
"author": "cpfarhood",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"scripts": {
|
||||||
|
"start": "headlamp-plugin start",
|
||||||
|
"build": "headlamp-plugin build",
|
||||||
|
"package": "headlamp-plugin package",
|
||||||
|
"tsc": "tsc --noEmit",
|
||||||
|
"lint": "eslint --ext .ts,.tsx src/",
|
||||||
|
"lint:fix": "eslint --ext .ts,.tsx --fix src/",
|
||||||
|
"format": "prettier --write src/",
|
||||||
|
"format:check": "prettier --check src/",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@kinvolk/headlamp-plugin": "^0.13.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { renderHook } from '@testing-library/react';
|
||||||
|
import React from 'react';
|
||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
// Mock headlamp plugin APIs before importing the module under test
|
||||||
|
vi.mock('@kinvolk/headlamp-plugin/lib', () => ({
|
||||||
|
ApiProxy: {
|
||||||
|
request: vi.fn().mockResolvedValue({ items: [] }),
|
||||||
|
},
|
||||||
|
K8s: {
|
||||||
|
ResourceClasses: {
|
||||||
|
StorageClass: {
|
||||||
|
useList: vi.fn(() => [[], null]),
|
||||||
|
},
|
||||||
|
PersistentVolume: {
|
||||||
|
useList: vi.fn(() => [[], null]),
|
||||||
|
},
|
||||||
|
PersistentVolumeClaim: {
|
||||||
|
useList: vi.fn(() => [[], null]),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { RookCephDataProvider, useRookCephContext } from './RookCephDataContext';
|
||||||
|
|
||||||
|
describe('useRookCephContext', () => {
|
||||||
|
it('throws when used outside RookCephDataProvider', () => {
|
||||||
|
const spy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||||
|
|
||||||
|
expect(() => {
|
||||||
|
renderHook(() => useRookCephContext());
|
||||||
|
}).toThrow('useRookCephContext must be used within a RookCephDataProvider');
|
||||||
|
|
||||||
|
spy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns context value when inside RookCephDataProvider', async () => {
|
||||||
|
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||||
|
<RookCephDataProvider>{children}</RookCephDataProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useRookCephContext(), { wrapper });
|
||||||
|
|
||||||
|
expect(result.current).toBeDefined();
|
||||||
|
expect(result.current.storageClasses).toBeInstanceOf(Array);
|
||||||
|
expect(result.current.persistentVolumes).toBeInstanceOf(Array);
|
||||||
|
expect(result.current.persistentVolumeClaims).toBeInstanceOf(Array);
|
||||||
|
expect(result.current.cephClusters).toBeInstanceOf(Array);
|
||||||
|
expect(result.current.blockPools).toBeInstanceOf(Array);
|
||||||
|
expect(result.current.filesystems).toBeInstanceOf(Array);
|
||||||
|
expect(result.current.objectStores).toBeInstanceOf(Array);
|
||||||
|
expect(result.current.operatorPods).toBeInstanceOf(Array);
|
||||||
|
expect(result.current.monPods).toBeInstanceOf(Array);
|
||||||
|
expect(result.current.osdPods).toBeInstanceOf(Array);
|
||||||
|
expect(result.current.mgrPods).toBeInstanceOf(Array);
|
||||||
|
expect(typeof result.current.refresh).toBe('function');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,331 @@
|
|||||||
|
/**
|
||||||
|
* RookCephDataContext — shared data provider for Rook-Ceph Kubernetes resources.
|
||||||
|
*
|
||||||
|
* Fetches CephCluster, CephBlockPool, CephFilesystem, CephObjectStore CRDs
|
||||||
|
* plus StorageClasses, PVs, PVCs, and Rook-Ceph pods via Headlamp hooks and
|
||||||
|
* ApiProxy. Provides filtered data to all child pages via React context.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { ApiProxy, K8s } from '@kinvolk/headlamp-plugin/lib';
|
||||||
|
import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
|
||||||
|
import {
|
||||||
|
CephBlockPool,
|
||||||
|
CephCluster,
|
||||||
|
CephFilesystem,
|
||||||
|
CephObjectStore,
|
||||||
|
filterRookCephPersistentVolumes,
|
||||||
|
filterRookCephPVCs,
|
||||||
|
filterRookCephStorageClasses,
|
||||||
|
isKubeList,
|
||||||
|
ROOK_CEPH_NAMESPACE,
|
||||||
|
RookCephPersistentVolume,
|
||||||
|
RookCephPVC,
|
||||||
|
RookCephPod,
|
||||||
|
RookCephStorageClass,
|
||||||
|
ROOK_CSI_CEPHFS_SELECTOR,
|
||||||
|
ROOK_CSI_RBD_SELECTOR,
|
||||||
|
ROOK_MGR_SELECTOR,
|
||||||
|
ROOK_MON_SELECTOR,
|
||||||
|
ROOK_OSD_SELECTOR,
|
||||||
|
ROOK_OPERATOR_SELECTOR,
|
||||||
|
} from './k8s';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Context shape
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface RookCephContextValue {
|
||||||
|
// Cluster presence
|
||||||
|
cephClusters: CephCluster[];
|
||||||
|
clusterInstalled: boolean;
|
||||||
|
|
||||||
|
// Core CRD resources
|
||||||
|
blockPools: CephBlockPool[];
|
||||||
|
filesystems: CephFilesystem[];
|
||||||
|
objectStores: CephObjectStore[];
|
||||||
|
|
||||||
|
// Core K8s resources (filtered to Rook-Ceph only)
|
||||||
|
storageClasses: RookCephStorageClass[];
|
||||||
|
persistentVolumes: RookCephPersistentVolume[];
|
||||||
|
persistentVolumeClaims: RookCephPVC[];
|
||||||
|
|
||||||
|
// Operator / daemon pods
|
||||||
|
operatorPods: RookCephPod[];
|
||||||
|
monPods: RookCephPod[];
|
||||||
|
osdPods: RookCephPod[];
|
||||||
|
mgrPods: RookCephPod[];
|
||||||
|
csiRbdPods: RookCephPod[];
|
||||||
|
csiCephfsPods: RookCephPod[];
|
||||||
|
|
||||||
|
// Loading / error state
|
||||||
|
loading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
|
||||||
|
// Manual refresh trigger
|
||||||
|
refresh: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Context
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const RookCephContext = createContext<RookCephContextValue | null>(null);
|
||||||
|
|
||||||
|
export function useRookCephContext(): RookCephContextValue {
|
||||||
|
const ctx = useContext(RookCephContext);
|
||||||
|
if (!ctx) {
|
||||||
|
throw new Error('useRookCephContext must be used within a RookCephDataProvider');
|
||||||
|
}
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Provider
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export function RookCephDataProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
// K8s resource hooks — Headlamp re-fetches on cluster changes automatically
|
||||||
|
const [allStorageClasses, scError] = K8s.ResourceClasses.StorageClass.useList();
|
||||||
|
const [allPvs, pvError] = K8s.ResourceClasses.PersistentVolume.useList();
|
||||||
|
const [allPvcs, pvcError] = K8s.ResourceClasses.PersistentVolumeClaim.useList({ namespace: '' });
|
||||||
|
|
||||||
|
// Async-fetched resources (CRDs, pods)
|
||||||
|
const [cephClusters, setCephClusters] = useState<CephCluster[]>([]);
|
||||||
|
const [blockPools, setBlockPools] = useState<CephBlockPool[]>([]);
|
||||||
|
const [filesystems, setFilesystems] = useState<CephFilesystem[]>([]);
|
||||||
|
const [objectStores, setObjectStores] = useState<CephObjectStore[]>([]);
|
||||||
|
const [operatorPods, setOperatorPods] = useState<RookCephPod[]>([]);
|
||||||
|
const [monPods, setMonPods] = useState<RookCephPod[]>([]);
|
||||||
|
const [osdPods, setOsdPods] = useState<RookCephPod[]>([]);
|
||||||
|
const [mgrPods, setMgrPods] = useState<RookCephPod[]>([]);
|
||||||
|
const [csiRbdPods, setCsiRbdPods] = useState<RookCephPod[]>([]);
|
||||||
|
const [csiCephfsPods, setCsiCephfsPods] = useState<RookCephPod[]>([]);
|
||||||
|
const [asyncLoading, setAsyncLoading] = useState(true);
|
||||||
|
const [asyncError, setAsyncError] = useState<string | null>(null);
|
||||||
|
const [refreshKey, setRefreshKey] = useState(0);
|
||||||
|
|
||||||
|
const refresh = useCallback(() => {
|
||||||
|
setRefreshKey(k => k + 1);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
async function fetchAsync() {
|
||||||
|
setAsyncLoading(true);
|
||||||
|
setAsyncError(null);
|
||||||
|
try {
|
||||||
|
// CephCluster CRDs
|
||||||
|
try {
|
||||||
|
const clusterList = await ApiProxy.request(
|
||||||
|
`/apis/ceph.rook.io/v1/namespaces/${ROOK_CEPH_NAMESPACE}/cephclusters`
|
||||||
|
);
|
||||||
|
if (!cancelled && isKubeList(clusterList)) {
|
||||||
|
setCephClusters(clusterList.items as CephCluster[]);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
if (!cancelled) setCephClusters([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// CephBlockPool CRDs
|
||||||
|
try {
|
||||||
|
const poolList = await ApiProxy.request(
|
||||||
|
`/apis/ceph.rook.io/v1/namespaces/${ROOK_CEPH_NAMESPACE}/cephblockpools`
|
||||||
|
);
|
||||||
|
if (!cancelled && isKubeList(poolList)) {
|
||||||
|
setBlockPools(poolList.items as CephBlockPool[]);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
if (!cancelled) setBlockPools([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// CephFilesystem CRDs
|
||||||
|
try {
|
||||||
|
const fsList = await ApiProxy.request(
|
||||||
|
`/apis/ceph.rook.io/v1/namespaces/${ROOK_CEPH_NAMESPACE}/cephfilesystems`
|
||||||
|
);
|
||||||
|
if (!cancelled && isKubeList(fsList)) {
|
||||||
|
setFilesystems(fsList.items as CephFilesystem[]);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
if (!cancelled) setFilesystems([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// CephObjectStore CRDs
|
||||||
|
try {
|
||||||
|
const osList = await ApiProxy.request(
|
||||||
|
`/apis/ceph.rook.io/v1/namespaces/${ROOK_CEPH_NAMESPACE}/cephobjectstores`
|
||||||
|
);
|
||||||
|
if (!cancelled && isKubeList(osList)) {
|
||||||
|
setObjectStores(osList.items as CephObjectStore[]);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
if (!cancelled) setObjectStores([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Operator pods
|
||||||
|
try {
|
||||||
|
const opList = await ApiProxy.request(
|
||||||
|
`/api/v1/namespaces/${ROOK_CEPH_NAMESPACE}/pods?labelSelector=${encodeURIComponent(ROOK_OPERATOR_SELECTOR)}`
|
||||||
|
);
|
||||||
|
if (!cancelled && isKubeList(opList)) setOperatorPods(opList.items as RookCephPod[]);
|
||||||
|
} catch {
|
||||||
|
if (!cancelled) setOperatorPods([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// MON pods
|
||||||
|
try {
|
||||||
|
const monList = await ApiProxy.request(
|
||||||
|
`/api/v1/namespaces/${ROOK_CEPH_NAMESPACE}/pods?labelSelector=${encodeURIComponent(ROOK_MON_SELECTOR)}`
|
||||||
|
);
|
||||||
|
if (!cancelled && isKubeList(monList)) setMonPods(monList.items as RookCephPod[]);
|
||||||
|
} catch {
|
||||||
|
if (!cancelled) setMonPods([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// OSD pods
|
||||||
|
try {
|
||||||
|
const osdList = await ApiProxy.request(
|
||||||
|
`/api/v1/namespaces/${ROOK_CEPH_NAMESPACE}/pods?labelSelector=${encodeURIComponent(ROOK_OSD_SELECTOR)}`
|
||||||
|
);
|
||||||
|
if (!cancelled && isKubeList(osdList)) setOsdPods(osdList.items as RookCephPod[]);
|
||||||
|
} catch {
|
||||||
|
if (!cancelled) setOsdPods([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// MGR pods
|
||||||
|
try {
|
||||||
|
const mgrList = await ApiProxy.request(
|
||||||
|
`/api/v1/namespaces/${ROOK_CEPH_NAMESPACE}/pods?labelSelector=${encodeURIComponent(ROOK_MGR_SELECTOR)}`
|
||||||
|
);
|
||||||
|
if (!cancelled && isKubeList(mgrList)) setMgrPods(mgrList.items as RookCephPod[]);
|
||||||
|
} catch {
|
||||||
|
if (!cancelled) setMgrPods([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// CSI RBD provisioner pods
|
||||||
|
try {
|
||||||
|
const csiRbdList = await ApiProxy.request(
|
||||||
|
`/api/v1/namespaces/${ROOK_CEPH_NAMESPACE}/pods?labelSelector=${encodeURIComponent(ROOK_CSI_RBD_SELECTOR)}`
|
||||||
|
);
|
||||||
|
if (!cancelled && isKubeList(csiRbdList)) setCsiRbdPods(csiRbdList.items as RookCephPod[]);
|
||||||
|
} catch {
|
||||||
|
if (!cancelled) setCsiRbdPods([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// CSI CephFS provisioner pods
|
||||||
|
try {
|
||||||
|
const csiCephfsList = await ApiProxy.request(
|
||||||
|
`/api/v1/namespaces/${ROOK_CEPH_NAMESPACE}/pods?labelSelector=${encodeURIComponent(ROOK_CSI_CEPHFS_SELECTOR)}`
|
||||||
|
);
|
||||||
|
if (!cancelled && isKubeList(csiCephfsList)) setCsiCephfsPods(csiCephfsList.items as RookCephPod[]);
|
||||||
|
} catch {
|
||||||
|
if (!cancelled) setCsiCephfsPods([]);
|
||||||
|
}
|
||||||
|
} catch (err: unknown) {
|
||||||
|
if (!cancelled) {
|
||||||
|
setAsyncError(err instanceof Error ? err.message : String(err));
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) setAsyncLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void fetchAsync();
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, [refreshKey]);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Derived / filtered values — memoized to avoid recomputation on every render
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// Headlamp useList() returns KubeObject class instances that store raw
|
||||||
|
// Kubernetes JSON under `.jsonData`. Extract it so our plain-object helpers
|
||||||
|
// work correctly.
|
||||||
|
const extractJsonData = (items: unknown[]): unknown[] =>
|
||||||
|
items.map(item =>
|
||||||
|
item && typeof item === 'object' && 'jsonData' in item
|
||||||
|
? (item as { jsonData: unknown }).jsonData
|
||||||
|
: item
|
||||||
|
);
|
||||||
|
|
||||||
|
const storageClasses = useMemo(() => {
|
||||||
|
if (!allStorageClasses) return [];
|
||||||
|
return filterRookCephStorageClasses(extractJsonData(allStorageClasses as unknown[]));
|
||||||
|
}, [allStorageClasses]);
|
||||||
|
|
||||||
|
const persistentVolumes = useMemo(() => {
|
||||||
|
if (!allPvs) return [];
|
||||||
|
return filterRookCephPersistentVolumes(extractJsonData(allPvs as unknown[]));
|
||||||
|
}, [allPvs]);
|
||||||
|
|
||||||
|
const persistentVolumeClaims = useMemo(() => {
|
||||||
|
if (!allPvcs || persistentVolumes.length === 0) return [];
|
||||||
|
return filterRookCephPVCs(
|
||||||
|
extractJsonData(allPvcs as unknown[]) as RookCephPVC[],
|
||||||
|
persistentVolumes
|
||||||
|
);
|
||||||
|
}, [allPvcs, persistentVolumes]);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Combined loading / error state
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const loading = asyncLoading || !allStorageClasses || !allPvs || !allPvcs;
|
||||||
|
|
||||||
|
const errors: string[] = [];
|
||||||
|
if (scError) errors.push(String(scError));
|
||||||
|
if (pvError) errors.push(String(pvError));
|
||||||
|
if (pvcError) errors.push(String(pvcError));
|
||||||
|
if (asyncError) errors.push(asyncError);
|
||||||
|
const error = errors.length > 0 ? errors.join('; ') : null;
|
||||||
|
|
||||||
|
const clusterInstalled = cephClusters.length > 0;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Memoized context value
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const value = useMemo<RookCephContextValue>(
|
||||||
|
() => ({
|
||||||
|
cephClusters,
|
||||||
|
clusterInstalled,
|
||||||
|
blockPools,
|
||||||
|
filesystems,
|
||||||
|
objectStores,
|
||||||
|
storageClasses,
|
||||||
|
persistentVolumes,
|
||||||
|
persistentVolumeClaims,
|
||||||
|
operatorPods,
|
||||||
|
monPods,
|
||||||
|
osdPods,
|
||||||
|
mgrPods,
|
||||||
|
csiRbdPods,
|
||||||
|
csiCephfsPods,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
refresh,
|
||||||
|
}),
|
||||||
|
[
|
||||||
|
cephClusters,
|
||||||
|
clusterInstalled,
|
||||||
|
blockPools,
|
||||||
|
filesystems,
|
||||||
|
objectStores,
|
||||||
|
storageClasses,
|
||||||
|
persistentVolumes,
|
||||||
|
persistentVolumeClaims,
|
||||||
|
operatorPods,
|
||||||
|
monPods,
|
||||||
|
osdPods,
|
||||||
|
mgrPods,
|
||||||
|
csiRbdPods,
|
||||||
|
csiCephfsPods,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
refresh,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
return <RookCephContext.Provider value={value}>{children}</RookCephContext.Provider>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,287 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import {
|
||||||
|
filterRookCephPersistentVolumes,
|
||||||
|
filterRookCephStorageClasses,
|
||||||
|
formatAge,
|
||||||
|
formatAccessModes,
|
||||||
|
formatBytes,
|
||||||
|
formatStorageType,
|
||||||
|
healthToStatus,
|
||||||
|
isKubeList,
|
||||||
|
isPodReady,
|
||||||
|
isRookCephPersistentVolume,
|
||||||
|
isRookCephProvisioner,
|
||||||
|
isRookCephStorageClass,
|
||||||
|
parseStorageToBytes,
|
||||||
|
phaseToStatus,
|
||||||
|
ROOK_CEPH_CEPHFS_PROVISIONER,
|
||||||
|
ROOK_CEPH_RBD_PROVISIONER,
|
||||||
|
storageClassType,
|
||||||
|
filterRookCephPVCs,
|
||||||
|
findBoundPv,
|
||||||
|
getPodRestarts,
|
||||||
|
} from './k8s';
|
||||||
|
|
||||||
|
describe('isRookCephProvisioner', () => {
|
||||||
|
it('recognises default namespace RBD provisioner', () => {
|
||||||
|
expect(isRookCephProvisioner(ROOK_CEPH_RBD_PROVISIONER)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('recognises default namespace CephFS provisioner', () => {
|
||||||
|
expect(isRookCephProvisioner(ROOK_CEPH_CEPHFS_PROVISIONER)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('recognises custom namespace provisioners', () => {
|
||||||
|
expect(isRookCephProvisioner('my-namespace.rbd.csi.ceph.com')).toBe(true);
|
||||||
|
expect(isRookCephProvisioner('my-namespace.cephfs.csi.ceph.com')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects non-rook provisioners', () => {
|
||||||
|
expect(isRookCephProvisioner('tns.csi.io')).toBe(false);
|
||||||
|
expect(isRookCephProvisioner('ebs.csi.aws.com')).toBe(false);
|
||||||
|
expect(isRookCephProvisioner('')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isRookCephStorageClass', () => {
|
||||||
|
it('accepts a Rook-Ceph SC', () => {
|
||||||
|
const sc = { metadata: { name: 'rook-ceph-block' }, provisioner: ROOK_CEPH_RBD_PROVISIONER };
|
||||||
|
expect(isRookCephStorageClass(sc)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a non-Rook SC', () => {
|
||||||
|
const sc = { metadata: { name: 'other' }, provisioner: 'tns.csi.io' };
|
||||||
|
expect(isRookCephStorageClass(sc)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects null / non-object', () => {
|
||||||
|
expect(isRookCephStorageClass(null)).toBe(false);
|
||||||
|
expect(isRookCephStorageClass('string')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('filterRookCephStorageClasses', () => {
|
||||||
|
it('filters to Rook-Ceph only', () => {
|
||||||
|
const items = [
|
||||||
|
{ metadata: { name: 'rook-block' }, provisioner: ROOK_CEPH_RBD_PROVISIONER },
|
||||||
|
{ metadata: { name: 'other' }, provisioner: 'tns.csi.io' },
|
||||||
|
{ metadata: { name: 'rook-cephfs' }, provisioner: ROOK_CEPH_CEPHFS_PROVISIONER },
|
||||||
|
];
|
||||||
|
const result = filterRookCephStorageClasses(items);
|
||||||
|
expect(result).toHaveLength(2);
|
||||||
|
expect(result.map(s => s.metadata.name)).toEqual(['rook-block', 'rook-cephfs']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('storageClassType', () => {
|
||||||
|
it('returns rbd for RBD provisioner', () => {
|
||||||
|
const sc = { metadata: { name: 'x' }, provisioner: ROOK_CEPH_RBD_PROVISIONER };
|
||||||
|
expect(storageClassType(sc)).toBe('rbd');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns cephfs for CephFS provisioner', () => {
|
||||||
|
const sc = { metadata: { name: 'x' }, provisioner: ROOK_CEPH_CEPHFS_PROVISIONER };
|
||||||
|
expect(storageClassType(sc)).toBe('cephfs');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isRookCephPersistentVolume', () => {
|
||||||
|
it('accepts a Rook-Ceph PV', () => {
|
||||||
|
const pv = {
|
||||||
|
metadata: { name: 'pvc-123' },
|
||||||
|
spec: { csi: { driver: ROOK_CEPH_RBD_PROVISIONER } },
|
||||||
|
};
|
||||||
|
expect(isRookCephPersistentVolume(pv)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a non-Rook PV', () => {
|
||||||
|
const pv = { metadata: { name: 'other' }, spec: { csi: { driver: 'tns.csi.io' } } };
|
||||||
|
expect(isRookCephPersistentVolume(pv)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects PVs with no spec.csi', () => {
|
||||||
|
expect(isRookCephPersistentVolume({ metadata: { name: 'x' }, spec: {} })).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('filterRookCephPersistentVolumes', () => {
|
||||||
|
it('returns only Rook-Ceph PVs', () => {
|
||||||
|
const items = [
|
||||||
|
{ metadata: { name: 'pv-a' }, spec: { csi: { driver: ROOK_CEPH_RBD_PROVISIONER } } },
|
||||||
|
{ metadata: { name: 'pv-b' }, spec: { csi: { driver: 'tns.csi.io' } } },
|
||||||
|
];
|
||||||
|
expect(filterRookCephPersistentVolumes(items)).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('filterRookCephPVCs', () => {
|
||||||
|
it('returns PVCs bound to Rook-Ceph PVs', () => {
|
||||||
|
const pvs = [
|
||||||
|
{
|
||||||
|
metadata: { name: 'pv-1' },
|
||||||
|
spec: {
|
||||||
|
csi: { driver: ROOK_CEPH_RBD_PROVISIONER },
|
||||||
|
claimRef: { name: 'my-pvc', namespace: 'default' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const pvcs = [
|
||||||
|
{ metadata: { name: 'my-pvc', namespace: 'default' }, spec: {} },
|
||||||
|
{ metadata: { name: 'other-pvc', namespace: 'default' }, spec: {} },
|
||||||
|
];
|
||||||
|
const result = filterRookCephPVCs(pvcs as never, pvs as never);
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result[0].metadata.name).toBe('my-pvc');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('findBoundPv', () => {
|
||||||
|
it('finds the matching PV', () => {
|
||||||
|
const pv = {
|
||||||
|
metadata: { name: 'pv-1' },
|
||||||
|
spec: {
|
||||||
|
csi: { driver: ROOK_CEPH_RBD_PROVISIONER },
|
||||||
|
claimRef: { name: 'my-pvc', namespace: 'default' },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const pvc = { metadata: { name: 'my-pvc', namespace: 'default' }, spec: {} };
|
||||||
|
const result = findBoundPv(pvc as never, [pv] as never);
|
||||||
|
expect(result?.metadata.name).toBe('pv-1');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('healthToStatus', () => {
|
||||||
|
it('maps health strings correctly', () => {
|
||||||
|
expect(healthToStatus('HEALTH_OK')).toBe('success');
|
||||||
|
expect(healthToStatus('HEALTH_WARN')).toBe('warning');
|
||||||
|
expect(healthToStatus('HEALTH_ERR')).toBe('error');
|
||||||
|
expect(healthToStatus(undefined)).toBe('error');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('phaseToStatus', () => {
|
||||||
|
it('maps phase strings correctly', () => {
|
||||||
|
expect(phaseToStatus('Ready')).toBe('success');
|
||||||
|
expect(phaseToStatus('Bound')).toBe('success');
|
||||||
|
expect(phaseToStatus('Progressing')).toBe('warning');
|
||||||
|
expect(phaseToStatus('Pending')).toBe('warning');
|
||||||
|
expect(phaseToStatus('Failed')).toBe('error');
|
||||||
|
expect(phaseToStatus(undefined)).toBe('error');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isPodReady', () => {
|
||||||
|
it('returns true when Ready condition is True', () => {
|
||||||
|
const pod = {
|
||||||
|
metadata: { name: 'p' },
|
||||||
|
status: { conditions: [{ type: 'Ready', status: 'True' }] },
|
||||||
|
};
|
||||||
|
expect(isPodReady(pod as never)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false when Ready condition is False', () => {
|
||||||
|
const pod = {
|
||||||
|
metadata: { name: 'p' },
|
||||||
|
status: { conditions: [{ type: 'Ready', status: 'False' }] },
|
||||||
|
};
|
||||||
|
expect(isPodReady(pod as never)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getPodRestarts', () => {
|
||||||
|
it('sums restart counts across containers', () => {
|
||||||
|
const pod = {
|
||||||
|
metadata: { name: 'p' },
|
||||||
|
status: {
|
||||||
|
containerStatuses: [
|
||||||
|
{ name: 'c1', ready: true, restartCount: 2 },
|
||||||
|
{ name: 'c2', ready: true, restartCount: 3 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
expect(getPodRestarts(pod as never)).toBe(5);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('formatAge', () => {
|
||||||
|
it('returns unknown for undefined', () => {
|
||||||
|
expect(formatAge(undefined)).toBe('unknown');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('formats seconds', () => {
|
||||||
|
const ts = new Date(Date.now() - 30_000).toISOString();
|
||||||
|
expect(formatAge(ts)).toBe('30s');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('formats minutes', () => {
|
||||||
|
const ts = new Date(Date.now() - 5 * 60_000).toISOString();
|
||||||
|
expect(formatAge(ts)).toBe('5m');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('formats hours', () => {
|
||||||
|
const ts = new Date(Date.now() - 3 * 3600_000).toISOString();
|
||||||
|
expect(formatAge(ts)).toBe('3h');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('formats days', () => {
|
||||||
|
const ts = new Date(Date.now() - 2 * 86400_000).toISOString();
|
||||||
|
expect(formatAge(ts)).toBe('2d');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('formatAccessModes', () => {
|
||||||
|
it('abbreviates access modes', () => {
|
||||||
|
expect(formatAccessModes(['ReadWriteOnce'])).toBe('RWO');
|
||||||
|
expect(formatAccessModes(['ReadWriteMany', 'ReadOnlyMany'])).toBe('RWX, ROX');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns — for empty', () => {
|
||||||
|
expect(formatAccessModes([])).toBe('—');
|
||||||
|
expect(formatAccessModes(undefined)).toBe('—');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('formatBytes', () => {
|
||||||
|
it('formats various byte sizes', () => {
|
||||||
|
expect(formatBytes(0)).toBe('0 B');
|
||||||
|
expect(formatBytes(1024)).toBe('1.0 KiB');
|
||||||
|
expect(formatBytes(1024 ** 2)).toBe('1.0 MiB');
|
||||||
|
expect(formatBytes(1024 ** 3)).toBe('1.0 GiB');
|
||||||
|
expect(formatBytes(1024 ** 4)).toBe('1.0 TiB');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('parseStorageToBytes', () => {
|
||||||
|
it('parses Gi suffix', () => {
|
||||||
|
expect(parseStorageToBytes('10Gi')).toBe(10 * 1024 ** 3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses Mi suffix', () => {
|
||||||
|
expect(parseStorageToBytes('512Mi')).toBe(512 * 1024 ** 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 0 for invalid', () => {
|
||||||
|
expect(parseStorageToBytes('invalid')).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('formatStorageType', () => {
|
||||||
|
it('formats storage types', () => {
|
||||||
|
expect(formatStorageType('rbd')).toBe('Block (RBD)');
|
||||||
|
expect(formatStorageType('cephfs')).toBe('Filesystem (CephFS)');
|
||||||
|
expect(formatStorageType('unknown')).toBe('Unknown');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isKubeList', () => {
|
||||||
|
it('accepts objects with items array', () => {
|
||||||
|
expect(isKubeList({ items: [] })).toBe(true);
|
||||||
|
expect(isKubeList({ items: [1, 2] })).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects non-list shapes', () => {
|
||||||
|
expect(isKubeList(null)).toBe(false);
|
||||||
|
expect(isKubeList({})).toBe(false);
|
||||||
|
expect(isKubeList({ items: 'not-array' })).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
+468
@@ -0,0 +1,468 @@
|
|||||||
|
/**
|
||||||
|
* Kubernetes type definitions and helper functions for Rook-Ceph resources.
|
||||||
|
*
|
||||||
|
* All K8s resource types are typed at the fields we actually use.
|
||||||
|
* External data from the API is validated at the boundary before use.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Provisioner constants (namespace-prefixed — default namespace: rook-ceph)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export const ROOK_CEPH_NAMESPACE = 'rook-ceph' as const;
|
||||||
|
export const ROOK_CEPH_API_GROUP = 'ceph.rook.io' as const;
|
||||||
|
export const ROOK_CEPH_API_VERSION = 'v1' as const;
|
||||||
|
|
||||||
|
/** RBD (block) provisioner — prefix matches operator namespace */
|
||||||
|
export const ROOK_CEPH_RBD_PROVISIONER = `${ROOK_CEPH_NAMESPACE}.rbd.csi.ceph.com` as const;
|
||||||
|
/** CephFS provisioner — prefix matches operator namespace */
|
||||||
|
export const ROOK_CEPH_CEPHFS_PROVISIONER = `${ROOK_CEPH_NAMESPACE}.cephfs.csi.ceph.com` as const;
|
||||||
|
|
||||||
|
/** Returns true if the provisioner string is a known Rook-Ceph provisioner. */
|
||||||
|
export function isRookCephProvisioner(provisioner: string): boolean {
|
||||||
|
return (
|
||||||
|
provisioner === ROOK_CEPH_RBD_PROVISIONER ||
|
||||||
|
provisioner === ROOK_CEPH_CEPHFS_PROVISIONER ||
|
||||||
|
// Handle non-default namespaces: ends with .rbd.csi.ceph.com or .cephfs.csi.ceph.com
|
||||||
|
provisioner.endsWith('.rbd.csi.ceph.com') ||
|
||||||
|
provisioner.endsWith('.cephfs.csi.ceph.com')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Pod label selectors
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export const ROOK_OPERATOR_SELECTOR = 'app=rook-ceph-operator';
|
||||||
|
export const ROOK_MON_SELECTOR = 'app=rook-ceph-mon';
|
||||||
|
export const ROOK_OSD_SELECTOR = 'app=rook-ceph-osd';
|
||||||
|
export const ROOK_MGR_SELECTOR = 'app=rook-ceph-mgr';
|
||||||
|
export const ROOK_MDS_SELECTOR = 'app=rook-ceph-mds';
|
||||||
|
export const ROOK_RGW_SELECTOR = 'app=rook-ceph-rgw';
|
||||||
|
export const ROOK_CSI_RBD_SELECTOR = 'app=csi-rbdplugin-provisioner';
|
||||||
|
export const ROOK_CSI_CEPHFS_SELECTOR = 'app=csi-cephfsplugin-provisioner';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Generic Kubernetes object base shapes
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface KubeObjectMeta {
|
||||||
|
name: string;
|
||||||
|
namespace?: string;
|
||||||
|
creationTimestamp?: string;
|
||||||
|
labels?: Record<string, string>;
|
||||||
|
annotations?: Record<string, string>;
|
||||||
|
uid?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface KubeObject {
|
||||||
|
apiVersion?: string;
|
||||||
|
kind?: string;
|
||||||
|
metadata: KubeObjectMeta;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// CephCluster
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface CephClusterStatusCeph {
|
||||||
|
health?: 'HEALTH_OK' | 'HEALTH_WARN' | 'HEALTH_ERR' | string;
|
||||||
|
lastChecked?: string;
|
||||||
|
capacity?: {
|
||||||
|
bytesAvailable?: number;
|
||||||
|
bytesTotal?: number;
|
||||||
|
bytesUsed?: number;
|
||||||
|
lastUpdated?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CephClusterStatusStorage {
|
||||||
|
deviceClasses?: Array<{ name: string }>;
|
||||||
|
osd?: { storeType?: Record<string, number> };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CephClusterStatusVersion {
|
||||||
|
image?: string;
|
||||||
|
version?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CephClusterCondition {
|
||||||
|
type: string;
|
||||||
|
status: string;
|
||||||
|
reason?: string;
|
||||||
|
message?: string;
|
||||||
|
lastTransitionTime?: string;
|
||||||
|
lastHeartbeatTime?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CephClusterStatus {
|
||||||
|
phase?: 'Ready' | 'Progressing' | 'Failed' | string;
|
||||||
|
state?: 'Created' | 'Updating' | 'Deleting' | string;
|
||||||
|
message?: string;
|
||||||
|
ceph?: CephClusterStatusCeph;
|
||||||
|
storage?: CephClusterStatusStorage;
|
||||||
|
version?: CephClusterStatusVersion;
|
||||||
|
conditions?: CephClusterCondition[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CephClusterSpec {
|
||||||
|
cephVersion?: { image?: string; allowUnsupported?: boolean };
|
||||||
|
dataDirHostPath?: string;
|
||||||
|
mon?: { count?: number; allowMultiplePerNode?: boolean };
|
||||||
|
mgr?: { count?: number };
|
||||||
|
dashboard?: { enabled?: boolean; ssl?: boolean };
|
||||||
|
monitoring?: { enabled?: boolean };
|
||||||
|
storage?: {
|
||||||
|
useAllNodes?: boolean;
|
||||||
|
useAllDevices?: boolean;
|
||||||
|
deviceFilter?: string;
|
||||||
|
nodes?: unknown[];
|
||||||
|
};
|
||||||
|
network?: { hostNetwork?: boolean };
|
||||||
|
resources?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CephCluster extends KubeObject {
|
||||||
|
spec?: CephClusterSpec;
|
||||||
|
status?: CephClusterStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function healthToStatus(health: string | undefined): 'success' | 'warning' | 'error' {
|
||||||
|
switch (health) {
|
||||||
|
case 'HEALTH_OK': return 'success';
|
||||||
|
case 'HEALTH_WARN': return 'warning';
|
||||||
|
default: return 'error';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function phaseToStatus(phase: string | undefined): 'success' | 'warning' | 'error' {
|
||||||
|
switch (phase) {
|
||||||
|
case 'Ready':
|
||||||
|
case 'Bound':
|
||||||
|
case 'Available':
|
||||||
|
case 'Running':
|
||||||
|
case 'Succeeded':
|
||||||
|
return 'success';
|
||||||
|
case 'Progressing':
|
||||||
|
case 'Pending':
|
||||||
|
case 'Released':
|
||||||
|
return 'warning';
|
||||||
|
default:
|
||||||
|
return 'error';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// CephBlockPool
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface CephBlockPoolSpec {
|
||||||
|
failureDomain?: string;
|
||||||
|
replicated?: { size?: number; requireSafeReplicaSize?: boolean };
|
||||||
|
erasureCoded?: { codingChunks?: number; dataChunks?: number };
|
||||||
|
parameters?: Record<string, string>;
|
||||||
|
mirroring?: { enabled?: boolean };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CephBlockPoolStatus {
|
||||||
|
phase?: string;
|
||||||
|
info?: Record<string, string>;
|
||||||
|
conditions?: CephClusterCondition[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CephBlockPool extends KubeObject {
|
||||||
|
spec?: CephBlockPoolSpec;
|
||||||
|
status?: CephBlockPoolStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// CephFilesystem
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface CephFilesystemSpec {
|
||||||
|
metadataPool?: { replicated?: { size?: number } };
|
||||||
|
dataPools?: Array<{ name?: string; replicated?: { size?: number } }>;
|
||||||
|
metadataServer?: { activeCount?: number; activeStandby?: boolean };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CephFilesystemStatus {
|
||||||
|
phase?: string;
|
||||||
|
conditions?: CephClusterCondition[];
|
||||||
|
info?: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CephFilesystem extends KubeObject {
|
||||||
|
spec?: CephFilesystemSpec;
|
||||||
|
status?: CephFilesystemStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// CephObjectStore
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface CephObjectStoreSpec {
|
||||||
|
metadataPool?: { replicated?: { size?: number } };
|
||||||
|
dataPool?: { replicated?: { size?: number } };
|
||||||
|
gateway?: { port?: number; securePort?: number; instances?: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CephObjectStoreStatus {
|
||||||
|
phase?: string;
|
||||||
|
conditions?: CephClusterCondition[];
|
||||||
|
info?: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CephObjectStore extends KubeObject {
|
||||||
|
spec?: CephObjectStoreSpec;
|
||||||
|
status?: CephObjectStoreStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// StorageClass (Rook-Ceph provisioned)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface RookCephStorageClass extends KubeObject {
|
||||||
|
provisioner: string;
|
||||||
|
reclaimPolicy?: string;
|
||||||
|
volumeBindingMode?: string;
|
||||||
|
allowVolumeExpansion?: boolean;
|
||||||
|
parameters?: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isRookCephStorageClass(sc: unknown): sc is RookCephStorageClass {
|
||||||
|
if (!sc || typeof sc !== 'object') return false;
|
||||||
|
const obj = sc as Record<string, unknown>;
|
||||||
|
const provisioner = obj['provisioner'];
|
||||||
|
return typeof provisioner === 'string' && isRookCephProvisioner(provisioner);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function filterRookCephStorageClasses(items: unknown[]): RookCephStorageClass[] {
|
||||||
|
return items.filter(isRookCephStorageClass);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns 'rbd' or 'cephfs' based on provisioner string, or 'unknown'. */
|
||||||
|
export function storageClassType(sc: RookCephStorageClass): 'rbd' | 'cephfs' | 'unknown' {
|
||||||
|
if (sc.provisioner.includes('.rbd.')) return 'rbd';
|
||||||
|
if (sc.provisioner.includes('.cephfs.')) return 'cephfs';
|
||||||
|
return 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// PersistentVolume (Rook-Ceph provisioned)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface RookCsiSpec {
|
||||||
|
driver: string;
|
||||||
|
volumeHandle?: string;
|
||||||
|
volumeAttributes?: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ClaimRef {
|
||||||
|
name: string;
|
||||||
|
namespace: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PersistentVolumeSpec {
|
||||||
|
csi?: RookCsiSpec;
|
||||||
|
capacity?: { storage?: string };
|
||||||
|
accessModes?: string[];
|
||||||
|
persistentVolumeReclaimPolicy?: string;
|
||||||
|
storageClassName?: string;
|
||||||
|
claimRef?: ClaimRef;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RookCephPersistentVolume extends KubeObject {
|
||||||
|
spec: PersistentVolumeSpec;
|
||||||
|
status?: { phase?: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isRookCephPersistentVolume(pv: unknown): pv is RookCephPersistentVolume {
|
||||||
|
if (!pv || typeof pv !== 'object') return false;
|
||||||
|
const obj = pv as Record<string, unknown>;
|
||||||
|
const spec = obj['spec'] as Record<string, unknown> | undefined;
|
||||||
|
if (!spec) return false;
|
||||||
|
const csi = spec['csi'] as Record<string, unknown> | undefined;
|
||||||
|
const driver = csi?.['driver'];
|
||||||
|
return typeof driver === 'string' && isRookCephProvisioner(driver);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function filterRookCephPersistentVolumes(items: unknown[]): RookCephPersistentVolume[] {
|
||||||
|
return items.filter(isRookCephPersistentVolume);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// PersistentVolumeClaim (Rook-Ceph)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface PVCSpec {
|
||||||
|
storageClassName?: string;
|
||||||
|
accessModes?: string[];
|
||||||
|
resources?: { requests?: { storage?: string } };
|
||||||
|
volumeName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RookCephPVC extends KubeObject {
|
||||||
|
spec: PVCSpec;
|
||||||
|
status?: {
|
||||||
|
phase?: string;
|
||||||
|
capacity?: { storage?: string };
|
||||||
|
accessModes?: string[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function filterRookCephPVCs(
|
||||||
|
pvcs: RookCephPVC[],
|
||||||
|
rookPvs: RookCephPersistentVolume[]
|
||||||
|
): RookCephPVC[] {
|
||||||
|
const boundSet = new Set<string>();
|
||||||
|
for (const pv of rookPvs) {
|
||||||
|
const ref = pv.spec.claimRef;
|
||||||
|
if (ref) boundSet.add(`${ref.namespace}/${ref.name}`);
|
||||||
|
}
|
||||||
|
return pvcs.filter(pvc => {
|
||||||
|
const ns = pvc.metadata.namespace ?? '';
|
||||||
|
return boundSet.has(`${ns}/${pvc.metadata.name}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findBoundPv(
|
||||||
|
pvc: RookCephPVC,
|
||||||
|
rookPvs: RookCephPersistentVolume[]
|
||||||
|
): RookCephPersistentVolume | undefined {
|
||||||
|
const ns = pvc.metadata.namespace ?? '';
|
||||||
|
const name = pvc.metadata.name;
|
||||||
|
return rookPvs.find(
|
||||||
|
pv => pv.spec.claimRef?.namespace === ns && pv.spec.claimRef?.name === name
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Pod
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface ContainerStatus {
|
||||||
|
name: string;
|
||||||
|
ready: boolean;
|
||||||
|
restartCount: number;
|
||||||
|
image?: string;
|
||||||
|
state?: {
|
||||||
|
running?: { startedAt?: string };
|
||||||
|
waiting?: { reason?: string; message?: string };
|
||||||
|
terminated?: { exitCode?: number; reason?: string };
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PodStatus {
|
||||||
|
phase?: string;
|
||||||
|
conditions?: Array<{ type: string; status: string }>;
|
||||||
|
containerStatuses?: ContainerStatus[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PodSpec {
|
||||||
|
nodeName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RookCephPod extends KubeObject {
|
||||||
|
spec?: PodSpec;
|
||||||
|
status?: PodStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isPodReady(pod: RookCephPod): boolean {
|
||||||
|
return (
|
||||||
|
pod.status?.conditions?.some(c => c.type === 'Ready' && c.status === 'True') ?? false
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPodRestarts(pod: RookCephPod): number {
|
||||||
|
return (
|
||||||
|
pod.status?.containerStatuses?.reduce((sum, c) => sum + c.restartCount, 0) ?? 0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPodImage(pod: RookCephPod): string {
|
||||||
|
return pod.status?.containerStatuses?.[0]?.image ?? 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// K8s API list response envelope
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface KubeList<T> {
|
||||||
|
items: T[];
|
||||||
|
metadata?: { resourceVersion?: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isKubeList(value: unknown): value is KubeList<unknown> {
|
||||||
|
if (!value || typeof value !== 'object') return false;
|
||||||
|
return Array.isArray((value as Record<string, unknown>)['items']);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Utilities
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export function formatAge(timestamp: string | undefined): string {
|
||||||
|
if (!timestamp) return 'unknown';
|
||||||
|
const diffMs = Date.now() - new Date(timestamp).getTime();
|
||||||
|
const secs = Math.floor(diffMs / 1000);
|
||||||
|
if (secs < 60) return `${secs}s`;
|
||||||
|
const mins = Math.floor(secs / 60);
|
||||||
|
if (mins < 60) return `${mins}m`;
|
||||||
|
const hours = Math.floor(mins / 60);
|
||||||
|
if (hours < 24) return `${hours}h`;
|
||||||
|
const days = Math.floor(hours / 24);
|
||||||
|
return `${days}d`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ACCESS_MODE_ABBREV: Record<string, string> = {
|
||||||
|
ReadWriteOnce: 'RWO',
|
||||||
|
ReadWriteMany: 'RWX',
|
||||||
|
ReadOnlyMany: 'ROX',
|
||||||
|
ReadWriteOncePod: 'RWOP',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function formatAccessModes(modes: string[] | undefined): string {
|
||||||
|
if (!modes || modes.length === 0) return '—';
|
||||||
|
return modes.map(m => ACCESS_MODE_ABBREV[m] ?? m).join(', ');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatBytes(bytes: number): string {
|
||||||
|
if (bytes >= 1024 ** 4) return `${(bytes / 1024 ** 4).toFixed(1)} TiB`;
|
||||||
|
if (bytes >= 1024 ** 3) return `${(bytes / 1024 ** 3).toFixed(1)} GiB`;
|
||||||
|
if (bytes >= 1024 ** 2) return `${(bytes / 1024 ** 2).toFixed(1)} MiB`;
|
||||||
|
if (bytes >= 1024) return `${(bytes / 1024).toFixed(1)} KiB`;
|
||||||
|
return `${bytes} B`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseStorageToBytes(storage: string): number {
|
||||||
|
const match = /^(\d+(?:\.\d+)?)\s*(Ki|Mi|Gi|Ti|Pi|K|M|G|T|P)?$/.exec(storage.trim());
|
||||||
|
if (!match) return 0;
|
||||||
|
const value = parseFloat(match[1]);
|
||||||
|
const suffix = match[2] ?? '';
|
||||||
|
const multipliers: Record<string, number> = {
|
||||||
|
'': 1,
|
||||||
|
K: 1e3, Ki: 1024,
|
||||||
|
M: 1e6, Mi: 1024 ** 2,
|
||||||
|
G: 1e9, Gi: 1024 ** 3,
|
||||||
|
T: 1e12, Ti: 1024 ** 4,
|
||||||
|
P: 1e15, Pi: 1024 ** 5,
|
||||||
|
};
|
||||||
|
return value * (multipliers[suffix] ?? 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns display label for storage type (rbd → Block, cephfs → Filesystem). */
|
||||||
|
export function formatStorageType(type: 'rbd' | 'cephfs' | 'unknown'): string {
|
||||||
|
switch (type) {
|
||||||
|
case 'rbd': return 'Block (RBD)';
|
||||||
|
case 'cephfs': return 'Filesystem (CephFS)';
|
||||||
|
default: return 'Unknown';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Extracts pool/subvolume group name from a Rook-Ceph PV volumeHandle. */
|
||||||
|
export function extractPoolFromVolumeHandle(handle: string | undefined): string {
|
||||||
|
if (!handle) return '—';
|
||||||
|
// RBD format: "<csi-vol-id>-<pool>-..." — pool is in volumeAttributes
|
||||||
|
// We rely on volumeAttributes.pool instead; this just provides a fallback.
|
||||||
|
return handle;
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
/**
|
||||||
|
* AppBarClusterBadge — registerAppBarAction cluster health badge.
|
||||||
|
*
|
||||||
|
* Displays "rook-ceph: HEALTH_OK" in the Headlamp top nav bar.
|
||||||
|
* Color-coded: green=HEALTH_OK, orange=HEALTH_WARN, red=HEALTH_ERR.
|
||||||
|
* Returns null if no CephCluster found (no clutter on unmanaged clusters).
|
||||||
|
*
|
||||||
|
* Wrapped in RookCephDataProvider at registration time (index.tsx).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { useHistory } from 'react-router-dom';
|
||||||
|
import { useRookCephContext } from '../api/RookCephDataContext';
|
||||||
|
|
||||||
|
function getHealthColor(health: string | undefined): string {
|
||||||
|
switch (health) {
|
||||||
|
case 'HEALTH_OK': return '#4caf50';
|
||||||
|
case 'HEALTH_WARN': return '#ff9800';
|
||||||
|
case 'HEALTH_ERR': return '#f44336';
|
||||||
|
default: return '#9e9e9e';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AppBarClusterBadge() {
|
||||||
|
const { cephClusters, loading } = useRookCephContext();
|
||||||
|
const history = useHistory();
|
||||||
|
|
||||||
|
if (loading || cephClusters.length === 0) return null;
|
||||||
|
|
||||||
|
const primary = cephClusters[0];
|
||||||
|
const health = primary?.status?.ceph?.health;
|
||||||
|
|
||||||
|
const color = getHealthColor(health);
|
||||||
|
const label = health ?? 'Unknown';
|
||||||
|
const ariaLabel = `Rook-Ceph cluster health: ${label}`;
|
||||||
|
|
||||||
|
const handleClick = () => {
|
||||||
|
history.push('/rook-ceph');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={handleClick}
|
||||||
|
style={{
|
||||||
|
cursor: 'pointer',
|
||||||
|
marginRight: '8px',
|
||||||
|
padding: '4px 12px',
|
||||||
|
borderRadius: '16px',
|
||||||
|
border: 'none',
|
||||||
|
backgroundColor: color,
|
||||||
|
color: 'white',
|
||||||
|
fontSize: '13px',
|
||||||
|
fontWeight: 500,
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '4px',
|
||||||
|
}}
|
||||||
|
aria-label={ariaLabel}
|
||||||
|
title={ariaLabel}
|
||||||
|
>
|
||||||
|
<span>rook-ceph: {label}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
/**
|
||||||
|
* BlockPoolsPage — lists CephBlockPool resources.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
Loader,
|
||||||
|
NameValueTable,
|
||||||
|
SectionBox,
|
||||||
|
SectionHeader,
|
||||||
|
SimpleTable,
|
||||||
|
StatusLabel,
|
||||||
|
} from '@kinvolk/headlamp-plugin/lib/CommonComponents';
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { useRookCephContext } from '../api/RookCephDataContext';
|
||||||
|
import { CephBlockPool, formatAge, phaseToStatus } from '../api/k8s';
|
||||||
|
|
||||||
|
function BlockPoolDetail({ pool, onClose }: { pool: CephBlockPool; onClose: () => void }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
top: 0, right: 0, bottom: 0, width: '480px',
|
||||||
|
backgroundColor: 'var(--mui-palette-background-paper, #fff)',
|
||||||
|
boxShadow: '-4px 0 16px rgba(0,0,0,0.15)',
|
||||||
|
zIndex: 1300,
|
||||||
|
overflowY: 'auto',
|
||||||
|
padding: '24px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '16px' }}>
|
||||||
|
<strong>{pool.metadata.name}</strong>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
aria-label="Close"
|
||||||
|
style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: '18px' }}
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<SectionBox title="Block Pool Details">
|
||||||
|
<NameValueTable
|
||||||
|
rows={[
|
||||||
|
{ name: 'Name', value: pool.metadata.name },
|
||||||
|
{ name: 'Namespace', value: pool.metadata.namespace ?? '—' },
|
||||||
|
{
|
||||||
|
name: 'Phase',
|
||||||
|
value: (
|
||||||
|
<StatusLabel status={phaseToStatus(pool.status?.phase)}>
|
||||||
|
{pool.status?.phase ?? 'Unknown'}
|
||||||
|
</StatusLabel>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{ name: 'Age', value: formatAge(pool.metadata.creationTimestamp) },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</SectionBox>
|
||||||
|
<SectionBox title="Replication">
|
||||||
|
<NameValueTable
|
||||||
|
rows={[
|
||||||
|
{ name: 'Replicas', value: String(pool.spec?.replicated?.size ?? '—') },
|
||||||
|
{
|
||||||
|
name: 'Require Safe Replica Size',
|
||||||
|
value: String(pool.spec?.replicated?.requireSafeReplicaSize ?? '—'),
|
||||||
|
},
|
||||||
|
{ name: 'Failure Domain', value: pool.spec?.failureDomain ?? '—' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</SectionBox>
|
||||||
|
{pool.spec?.erasureCoded && (
|
||||||
|
<SectionBox title="Erasure Coding">
|
||||||
|
<NameValueTable
|
||||||
|
rows={[
|
||||||
|
{ name: 'Data Chunks', value: String(pool.spec.erasureCoded.dataChunks ?? '—') },
|
||||||
|
{ name: 'Coding Chunks', value: String(pool.spec.erasureCoded.codingChunks ?? '—') },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</SectionBox>
|
||||||
|
)}
|
||||||
|
{pool.status?.info && Object.keys(pool.status.info).length > 0 && (
|
||||||
|
<SectionBox title="Status Info">
|
||||||
|
<NameValueTable
|
||||||
|
rows={Object.entries(pool.status.info).map(([k, v]) => ({ name: k, value: v }))}
|
||||||
|
/>
|
||||||
|
</SectionBox>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function BlockPoolsPage() {
|
||||||
|
const { blockPools, loading, error } = useRookCephContext();
|
||||||
|
const [selected, setSelected] = useState<CephBlockPool | null>(null);
|
||||||
|
|
||||||
|
if (loading) return <Loader title="Loading block pools..." />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<SectionHeader title="Block Pools" />
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<SectionBox title="Error">
|
||||||
|
<NameValueTable rows={[{ name: 'Status', value: <StatusLabel status="error">{error}</StatusLabel> }]} />
|
||||||
|
</SectionBox>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{blockPools.length === 0 ? (
|
||||||
|
<SectionBox title="No Block Pools">
|
||||||
|
<NameValueTable
|
||||||
|
rows={[{ name: 'Status', value: 'No CephBlockPool resources found in rook-ceph namespace.' }]}
|
||||||
|
/>
|
||||||
|
</SectionBox>
|
||||||
|
) : (
|
||||||
|
<SectionBox title={`Block Pools (${blockPools.length})`}>
|
||||||
|
<SimpleTable
|
||||||
|
columns={[
|
||||||
|
{
|
||||||
|
label: 'Name',
|
||||||
|
getter: (p: CephBlockPool) => (
|
||||||
|
<button
|
||||||
|
onClick={() => setSelected(p)}
|
||||||
|
style={{ border: 'none', background: 'transparent', color: 'var(--link-color, #1976d2)', cursor: 'pointer', textDecoration: 'underline', padding: 0, font: 'inherit' }}
|
||||||
|
>
|
||||||
|
{p.metadata.name}
|
||||||
|
</button>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Phase',
|
||||||
|
getter: (p: CephBlockPool) => (
|
||||||
|
<StatusLabel status={phaseToStatus(p.status?.phase)}>
|
||||||
|
{p.status?.phase ?? 'Unknown'}
|
||||||
|
</StatusLabel>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{ label: 'Replicas', getter: (p: CephBlockPool) => String(p.spec?.replicated?.size ?? '—') },
|
||||||
|
{ label: 'Failure Domain', getter: (p: CephBlockPool) => p.spec?.failureDomain ?? '—' },
|
||||||
|
{ label: 'Mirroring', getter: (p: CephBlockPool) => p.spec?.mirroring?.enabled ? 'Enabled' : 'Disabled' },
|
||||||
|
{ label: 'Age', getter: (p: CephBlockPool) => formatAge(p.metadata.creationTimestamp) },
|
||||||
|
]}
|
||||||
|
data={blockPools}
|
||||||
|
/>
|
||||||
|
</SectionBox>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{selected && (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
style={{ position: 'fixed', inset: 0, backgroundColor: 'rgba(0,0,0,0.3)', zIndex: 1299 }}
|
||||||
|
onClick={() => setSelected(null)}
|
||||||
|
/>
|
||||||
|
<BlockPoolDetail pool={selected} onClose={() => setSelected(null)} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
/**
|
||||||
|
* CephPodDetailSection — injected into Headlamp's Pod detail view.
|
||||||
|
*
|
||||||
|
* Shown only for Rook-Ceph daemon pods (operator, mon, osd, mgr, csi).
|
||||||
|
* Guards on rook-ceph label presence.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
NameValueTable,
|
||||||
|
SectionBox,
|
||||||
|
StatusLabel,
|
||||||
|
} from '@kinvolk/headlamp-plugin/lib/CommonComponents';
|
||||||
|
import React from 'react';
|
||||||
|
import { formatAge, getPodRestarts } from '../api/k8s';
|
||||||
|
|
||||||
|
interface CephPodDetailSectionProps {
|
||||||
|
resource: {
|
||||||
|
metadata?: {
|
||||||
|
name?: string;
|
||||||
|
namespace?: string;
|
||||||
|
labels?: Record<string, string>;
|
||||||
|
creationTimestamp?: string;
|
||||||
|
};
|
||||||
|
spec?: { nodeName?: string; containers?: Array<{ name: string; image?: string }> };
|
||||||
|
status?: {
|
||||||
|
phase?: string;
|
||||||
|
conditions?: Array<{ type: string; status: string }>;
|
||||||
|
containerStatuses?: Array<{
|
||||||
|
name: string;
|
||||||
|
ready: boolean;
|
||||||
|
restartCount: number;
|
||||||
|
state?: {
|
||||||
|
running?: { startedAt?: string };
|
||||||
|
waiting?: { reason?: string };
|
||||||
|
terminated?: { reason?: string; exitCode?: number };
|
||||||
|
};
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
jsonData?: unknown;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const ROOK_APP_LABELS = new Set([
|
||||||
|
'rook-ceph-operator',
|
||||||
|
'rook-ceph-mon',
|
||||||
|
'rook-ceph-osd',
|
||||||
|
'rook-ceph-mgr',
|
||||||
|
'rook-ceph-mds',
|
||||||
|
'rook-ceph-rgw',
|
||||||
|
'csi-rbdplugin-provisioner',
|
||||||
|
'csi-cephfsplugin-provisioner',
|
||||||
|
'csi-rbdplugin',
|
||||||
|
'csi-cephfsplugin',
|
||||||
|
]);
|
||||||
|
|
||||||
|
const ROLE_LABELS: Record<string, string> = {
|
||||||
|
'rook-ceph-operator': 'Operator',
|
||||||
|
'rook-ceph-mon': 'Monitor (MON)',
|
||||||
|
'rook-ceph-osd': 'OSD',
|
||||||
|
'rook-ceph-mgr': 'Manager (MGR)',
|
||||||
|
'rook-ceph-mds': 'MDS (CephFS)',
|
||||||
|
'rook-ceph-rgw': 'RGW (Object Gateway)',
|
||||||
|
'csi-rbdplugin-provisioner': 'CSI RBD Provisioner',
|
||||||
|
'csi-cephfsplugin-provisioner': 'CSI CephFS Provisioner',
|
||||||
|
'csi-rbdplugin': 'CSI RBD Node Plugin',
|
||||||
|
'csi-cephfsplugin': 'CSI CephFS Node Plugin',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function CephPodDetailSection({ resource }: CephPodDetailSectionProps) {
|
||||||
|
const raw =
|
||||||
|
resource.jsonData && typeof resource.jsonData === 'object'
|
||||||
|
? (resource.jsonData as typeof resource)
|
||||||
|
: resource;
|
||||||
|
|
||||||
|
const labels = raw.metadata?.labels ?? {};
|
||||||
|
const appLabel = labels['app'] ?? '';
|
||||||
|
|
||||||
|
if (!ROOK_APP_LABELS.has(appLabel)) return null;
|
||||||
|
|
||||||
|
const role = ROLE_LABELS[appLabel] ?? appLabel;
|
||||||
|
const phase = raw.status?.phase ?? 'Unknown';
|
||||||
|
const isReady =
|
||||||
|
raw.status?.conditions?.some((c) => c.type === 'Ready' && c.status === 'True') ?? false;
|
||||||
|
const restarts =
|
||||||
|
raw.status?.containerStatuses?.reduce((s, c) => s + c.restartCount, 0) ?? 0;
|
||||||
|
|
||||||
|
const containerRows = (raw.status?.containerStatuses ?? []).map((cs) => {
|
||||||
|
let stateStr = 'Unknown';
|
||||||
|
if (cs.state?.running) stateStr = 'Running';
|
||||||
|
else if (cs.state?.waiting) stateStr = `Waiting: ${cs.state.waiting.reason ?? ''}`;
|
||||||
|
else if (cs.state?.terminated)
|
||||||
|
stateStr = `Terminated: ${cs.state.terminated.reason ?? ''} (exit ${cs.state.terminated.exitCode ?? ''})`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: cs.name,
|
||||||
|
value: (
|
||||||
|
<StatusLabel status={cs.ready ? 'success' : 'warning'}>
|
||||||
|
{stateStr} | Restarts: {cs.restartCount}
|
||||||
|
</StatusLabel>
|
||||||
|
),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SectionBox title="Rook-Ceph Daemon Info">
|
||||||
|
<NameValueTable
|
||||||
|
rows={[
|
||||||
|
{
|
||||||
|
name: 'Role',
|
||||||
|
value: <StatusLabel status="success">{role}</StatusLabel>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Phase',
|
||||||
|
value: (
|
||||||
|
<StatusLabel status={isReady ? 'success' : 'error'}>
|
||||||
|
{phase}
|
||||||
|
</StatusLabel>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{ name: 'Node', value: raw.spec?.nodeName ?? '—' },
|
||||||
|
{ name: 'Restarts', value: String(restarts) },
|
||||||
|
{ name: 'Age', value: formatAge(raw.metadata?.creationTimestamp) },
|
||||||
|
...containerRows,
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</SectionBox>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
/**
|
||||||
|
* ClusterStatusCard — reusable component showing Rook-Ceph cluster health.
|
||||||
|
* Displays CephCluster health, phase, capacity, version, and daemon pod counts.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
NameValueTable,
|
||||||
|
PercentageBar,
|
||||||
|
SectionBox,
|
||||||
|
StatusLabel,
|
||||||
|
} from '@kinvolk/headlamp-plugin/lib/CommonComponents';
|
||||||
|
import React from 'react';
|
||||||
|
import type { CephCluster, RookCephPod } from '../api/k8s';
|
||||||
|
import { formatAge, formatBytes, getPodImage, getPodRestarts, healthToStatus, isPodReady, phaseToStatus } from '../api/k8s';
|
||||||
|
|
||||||
|
interface ClusterStatusCardProps {
|
||||||
|
cephClusters: CephCluster[];
|
||||||
|
operatorPods: RookCephPod[];
|
||||||
|
monPods: RookCephPod[];
|
||||||
|
osdPods: RookCephPod[];
|
||||||
|
mgrPods: RookCephPod[];
|
||||||
|
csiRbdPods: RookCephPod[];
|
||||||
|
csiCephfsPods: RookCephPod[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function PodStatusBadge({ pod }: { pod: RookCephPod }) {
|
||||||
|
const ready = isPodReady(pod);
|
||||||
|
const phase = pod.status?.phase ?? 'Unknown';
|
||||||
|
return (
|
||||||
|
<StatusLabel status={ready ? 'success' : 'error'}>
|
||||||
|
{phase}
|
||||||
|
</StatusLabel>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PodSummaryRow({ pods, label }: { pods: RookCephPod[]; label: string }) {
|
||||||
|
const ready = pods.filter(isPodReady).length;
|
||||||
|
const total = pods.length;
|
||||||
|
const status = total === 0 ? 'error' : ready === total ? 'success' : ready > 0 ? 'warning' : 'error';
|
||||||
|
return {
|
||||||
|
name: label,
|
||||||
|
value: (
|
||||||
|
<StatusLabel status={status}>
|
||||||
|
{total === 0 ? 'None found' : `${ready}/${total} ready`}
|
||||||
|
</StatusLabel>
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ClusterStatusCard({
|
||||||
|
cephClusters,
|
||||||
|
operatorPods,
|
||||||
|
monPods,
|
||||||
|
osdPods,
|
||||||
|
mgrPods,
|
||||||
|
csiRbdPods,
|
||||||
|
csiCephfsPods,
|
||||||
|
}: ClusterStatusCardProps) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{cephClusters.map(cluster => {
|
||||||
|
const health = cluster.status?.ceph?.health;
|
||||||
|
const phase = cluster.status?.phase;
|
||||||
|
const capacity = cluster.status?.ceph?.capacity;
|
||||||
|
const version = cluster.status?.version?.version ?? '—';
|
||||||
|
const bytesTotal = capacity?.bytesTotal ?? 0;
|
||||||
|
const bytesUsed = capacity?.bytesUsed ?? 0;
|
||||||
|
const bytesAvail = capacity?.bytesAvailable ?? 0;
|
||||||
|
const usedPct = bytesTotal > 0 ? Math.round((bytesUsed / bytesTotal) * 100) : 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<React.Fragment key={cluster.metadata.name}>
|
||||||
|
<SectionBox title={`CephCluster: ${cluster.metadata.name}`}>
|
||||||
|
<NameValueTable
|
||||||
|
rows={[
|
||||||
|
{
|
||||||
|
name: 'Health',
|
||||||
|
value: (
|
||||||
|
<StatusLabel status={healthToStatus(health)}>
|
||||||
|
{health ?? 'Unknown'}
|
||||||
|
</StatusLabel>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Phase',
|
||||||
|
value: (
|
||||||
|
<StatusLabel status={phaseToStatus(phase)}>
|
||||||
|
{phase ?? 'Unknown'}
|
||||||
|
</StatusLabel>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
...(cluster.status?.message ? [{ name: 'Message', value: cluster.status.message }] : []),
|
||||||
|
{ name: 'Ceph Version', value: version },
|
||||||
|
{ name: 'Namespace', value: cluster.metadata.namespace ?? '—' },
|
||||||
|
{ name: 'Age', value: formatAge(cluster.metadata.creationTimestamp) },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</SectionBox>
|
||||||
|
|
||||||
|
{bytesTotal > 0 && (
|
||||||
|
<SectionBox title="Cluster Capacity">
|
||||||
|
<div style={{ marginBottom: '12px' }}>
|
||||||
|
<PercentageBar
|
||||||
|
data={[
|
||||||
|
{ name: 'Used', value: bytesUsed, fill: usedPct > 80 ? '#f44336' : '#1976d2' },
|
||||||
|
{ name: 'Free', value: bytesAvail, fill: '#e0e0e0' },
|
||||||
|
]}
|
||||||
|
total={bytesTotal}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<NameValueTable
|
||||||
|
rows={[
|
||||||
|
{ name: 'Total', value: formatBytes(bytesTotal) },
|
||||||
|
{ name: 'Used', value: `${formatBytes(bytesUsed)} (${usedPct}%)` },
|
||||||
|
{ name: 'Available', value: formatBytes(bytesAvail) },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</SectionBox>
|
||||||
|
)}
|
||||||
|
</React.Fragment>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
<SectionBox title="Daemon Health">
|
||||||
|
<NameValueTable
|
||||||
|
rows={[
|
||||||
|
PodSummaryRow({ pods: operatorPods, label: 'Operator' }),
|
||||||
|
PodSummaryRow({ pods: monPods, label: 'Monitors (MON)' }),
|
||||||
|
PodSummaryRow({ pods: osdPods, label: 'OSDs' }),
|
||||||
|
PodSummaryRow({ pods: mgrPods, label: 'Managers (MGR)' }),
|
||||||
|
PodSummaryRow({ pods: csiRbdPods, label: 'CSI RBD Provisioner' }),
|
||||||
|
PodSummaryRow({ pods: csiCephfsPods, label: 'CSI CephFS Provisioner' }),
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</SectionBox>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PodDetailRows({ pods, label }: { pods: RookCephPod[]; label: string }) {
|
||||||
|
if (pods.length === 0) {
|
||||||
|
return (
|
||||||
|
<SectionBox title={label}>
|
||||||
|
<NameValueTable
|
||||||
|
rows={[{ name: 'Status', value: <StatusLabel status="error">No pods found</StatusLabel> }]}
|
||||||
|
/>
|
||||||
|
</SectionBox>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SectionBox title={`${label} (${pods.length})`}>
|
||||||
|
{pods.map(pod => (
|
||||||
|
<NameValueTable
|
||||||
|
key={pod.metadata.name}
|
||||||
|
rows={[
|
||||||
|
{ name: 'Pod', value: pod.metadata.name },
|
||||||
|
{ name: 'Node', value: pod.spec?.nodeName ?? '—' },
|
||||||
|
{ name: 'Status', value: <PodStatusBadge pod={pod} /> },
|
||||||
|
{ name: 'Restarts', value: String(getPodRestarts(pod)) },
|
||||||
|
{ name: 'Image', value: getPodImage(pod) },
|
||||||
|
{ name: 'Age', value: formatAge(pod.metadata.creationTimestamp) },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</SectionBox>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,308 @@
|
|||||||
|
/**
|
||||||
|
* OverviewPage — main dashboard for the Rook-Ceph plugin.
|
||||||
|
*
|
||||||
|
* Shows: cluster health, capacity overview, storage resource counts,
|
||||||
|
* daemon pod summary, and non-Bound PVC alerts.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
Loader,
|
||||||
|
NameValueTable,
|
||||||
|
PercentageBar,
|
||||||
|
SectionBox,
|
||||||
|
SectionHeader,
|
||||||
|
SimpleTable,
|
||||||
|
StatusLabel,
|
||||||
|
} from '@kinvolk/headlamp-plugin/lib/CommonComponents';
|
||||||
|
import React from 'react';
|
||||||
|
import { useRookCephContext } from '../api/RookCephDataContext';
|
||||||
|
import { formatAge, formatBytes, healthToStatus, phaseToStatus, storageClassType } from '../api/k8s';
|
||||||
|
import ClusterStatusCard from './ClusterStatusCard';
|
||||||
|
|
||||||
|
export default function OverviewPage() {
|
||||||
|
const {
|
||||||
|
cephClusters,
|
||||||
|
clusterInstalled,
|
||||||
|
blockPools,
|
||||||
|
filesystems,
|
||||||
|
objectStores,
|
||||||
|
storageClasses,
|
||||||
|
persistentVolumes,
|
||||||
|
persistentVolumeClaims,
|
||||||
|
operatorPods,
|
||||||
|
monPods,
|
||||||
|
osdPods,
|
||||||
|
mgrPods,
|
||||||
|
csiRbdPods,
|
||||||
|
csiCephfsPods,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
refresh,
|
||||||
|
} = useRookCephContext();
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <Loader title="Loading Rook-Ceph data..." />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Storage summary
|
||||||
|
const rbdClasses = storageClasses.filter(sc => storageClassType(sc) === 'rbd');
|
||||||
|
const cephfsClasses = storageClasses.filter(sc => storageClassType(sc) === 'cephfs');
|
||||||
|
|
||||||
|
const totalCapacityBytes = persistentVolumes.reduce((sum, pv) => {
|
||||||
|
const cap = pv.spec.capacity?.storage ?? '0';
|
||||||
|
return sum + parseStorageToBytes(cap);
|
||||||
|
}, 0);
|
||||||
|
|
||||||
|
const pvcStatusCounts = { Bound: 0, Pending: 0, Lost: 0, Other: 0 };
|
||||||
|
for (const pvc of persistentVolumeClaims) {
|
||||||
|
const phase = pvc.status?.phase ?? 'Other';
|
||||||
|
if (phase === 'Bound') pvcStatusCounts.Bound++;
|
||||||
|
else if (phase === 'Pending') pvcStatusCounts.Pending++;
|
||||||
|
else if (phase === 'Lost') pvcStatusCounts.Lost++;
|
||||||
|
else pvcStatusCounts.Other++;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nonBoundPvcs = persistentVolumeClaims.filter(pvc => pvc.status?.phase !== 'Bound');
|
||||||
|
|
||||||
|
// Primary cluster health (first cluster)
|
||||||
|
const primaryCluster = cephClusters[0];
|
||||||
|
const primaryHealth = primaryCluster?.status?.ceph?.health;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '20px' }}>
|
||||||
|
<SectionHeader title="Rook-Ceph — Overview" />
|
||||||
|
<button
|
||||||
|
onClick={refresh}
|
||||||
|
aria-label="Refresh Rook-Ceph data"
|
||||||
|
style={{
|
||||||
|
padding: '6px 16px',
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
color: 'var(--mui-palette-primary-main, #1976d2)',
|
||||||
|
border: '1px solid var(--mui-palette-primary-main, #1976d2)',
|
||||||
|
borderRadius: '4px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '13px',
|
||||||
|
fontWeight: 500,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Refresh
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Cluster not detected */}
|
||||||
|
{!clusterInstalled && !loading && (
|
||||||
|
<SectionBox title="Rook-Ceph Not Detected">
|
||||||
|
<NameValueTable
|
||||||
|
rows={[
|
||||||
|
{
|
||||||
|
name: 'Status',
|
||||||
|
value: <StatusLabel status="error">No CephCluster found in namespace rook-ceph</StatusLabel>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Install',
|
||||||
|
value: 'helm install rook-ceph rook-release/rook-ceph -n rook-ceph --create-namespace',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Docs',
|
||||||
|
value: 'https://rook.io/docs/rook/latest/Getting-Started/quickstart/',
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</SectionBox>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Error state */}
|
||||||
|
{error && (
|
||||||
|
<SectionBox title="Error">
|
||||||
|
<NameValueTable
|
||||||
|
rows={[{ name: 'Status', value: <StatusLabel status="error">{error}</StatusLabel> }]}
|
||||||
|
/>
|
||||||
|
</SectionBox>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Quick health summary banner when cluster is installed */}
|
||||||
|
{clusterInstalled && primaryHealth && (
|
||||||
|
<SectionBox title="Cluster Health">
|
||||||
|
<NameValueTable
|
||||||
|
rows={[
|
||||||
|
{
|
||||||
|
name: 'Health',
|
||||||
|
value: (
|
||||||
|
<StatusLabel status={healthToStatus(primaryHealth)}>
|
||||||
|
{primaryHealth}
|
||||||
|
</StatusLabel>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Clusters',
|
||||||
|
value: String(cephClusters.length),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</SectionBox>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Storage type distribution */}
|
||||||
|
{storageClasses.length > 0 && (
|
||||||
|
<SectionBox title="Storage Summary">
|
||||||
|
{storageClasses.length > 0 && (
|
||||||
|
<div style={{ marginBottom: '16px' }}>
|
||||||
|
<div style={{ marginBottom: '8px', fontSize: '14px', color: 'var(--mui-palette-text-secondary)' }}>
|
||||||
|
StorageClass Type Distribution
|
||||||
|
</div>
|
||||||
|
<PercentageBar
|
||||||
|
data={[
|
||||||
|
...(rbdClasses.length > 0
|
||||||
|
? [{ name: 'Block (RBD)', value: rbdClasses.length, fill: '#1976d2' }]
|
||||||
|
: []),
|
||||||
|
...(cephfsClasses.length > 0
|
||||||
|
? [{ name: 'Filesystem (CephFS)', value: cephfsClasses.length, fill: '#9c27b0' }]
|
||||||
|
: []),
|
||||||
|
]}
|
||||||
|
total={storageClasses.length}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<NameValueTable
|
||||||
|
rows={[
|
||||||
|
{ name: 'Storage Classes', value: `${storageClasses.length} (${rbdClasses.length} RBD, ${cephfsClasses.length} CephFS)` },
|
||||||
|
{ name: 'Block Pools', value: String(blockPools.length) },
|
||||||
|
{ name: 'Filesystems', value: String(filesystems.length) },
|
||||||
|
{ name: 'Object Stores', value: String(objectStores.length) },
|
||||||
|
{ name: 'Persistent Volumes', value: String(persistentVolumes.length) },
|
||||||
|
{ name: 'Total PV Capacity', value: formatBytes(totalCapacityBytes) },
|
||||||
|
{
|
||||||
|
name: 'PVCs (Bound)',
|
||||||
|
value: <StatusLabel status="success">{pvcStatusCounts.Bound}</StatusLabel>,
|
||||||
|
},
|
||||||
|
...(pvcStatusCounts.Pending > 0
|
||||||
|
? [{ name: 'PVCs (Pending)', value: <StatusLabel status="warning">{pvcStatusCounts.Pending}</StatusLabel> }]
|
||||||
|
: []),
|
||||||
|
...(pvcStatusCounts.Lost > 0
|
||||||
|
? [{ name: 'PVCs (Lost)', value: <StatusLabel status="error">{pvcStatusCounts.Lost}</StatusLabel> }]
|
||||||
|
: []),
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</SectionBox>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Cluster status + capacity + daemon health */}
|
||||||
|
<ClusterStatusCard
|
||||||
|
cephClusters={cephClusters}
|
||||||
|
operatorPods={operatorPods}
|
||||||
|
monPods={monPods}
|
||||||
|
osdPods={osdPods}
|
||||||
|
mgrPods={mgrPods}
|
||||||
|
csiRbdPods={csiRbdPods}
|
||||||
|
csiCephfsPods={csiCephfsPods}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Block pools table */}
|
||||||
|
{blockPools.length > 0 && (
|
||||||
|
<SectionBox title="Block Pools">
|
||||||
|
<SimpleTable
|
||||||
|
columns={[
|
||||||
|
{ label: 'Name', getter: (p) => p.metadata.name },
|
||||||
|
{
|
||||||
|
label: 'Phase',
|
||||||
|
getter: (p) => (
|
||||||
|
<StatusLabel status={phaseToStatus(p.status?.phase)}>
|
||||||
|
{p.status?.phase ?? 'Unknown'}
|
||||||
|
</StatusLabel>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{ label: 'Replicas', getter: (p) => String(p.spec?.replicated?.size ?? '—') },
|
||||||
|
{ label: 'Failure Domain', getter: (p) => p.spec?.failureDomain ?? '—' },
|
||||||
|
{ label: 'Age', getter: (p) => formatAge(p.metadata.creationTimestamp) },
|
||||||
|
]}
|
||||||
|
data={blockPools}
|
||||||
|
/>
|
||||||
|
</SectionBox>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Filesystems table */}
|
||||||
|
{filesystems.length > 0 && (
|
||||||
|
<SectionBox title="Filesystems">
|
||||||
|
<SimpleTable
|
||||||
|
columns={[
|
||||||
|
{ label: 'Name', getter: (f) => f.metadata.name },
|
||||||
|
{
|
||||||
|
label: 'Phase',
|
||||||
|
getter: (f) => (
|
||||||
|
<StatusLabel status={phaseToStatus(f.status?.phase)}>
|
||||||
|
{f.status?.phase ?? 'Unknown'}
|
||||||
|
</StatusLabel>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{ label: 'Active MDS', getter: (f) => String(f.spec?.metadataServer?.activeCount ?? '—') },
|
||||||
|
{ label: 'Age', getter: (f) => formatAge(f.metadata.creationTimestamp) },
|
||||||
|
]}
|
||||||
|
data={filesystems}
|
||||||
|
/>
|
||||||
|
</SectionBox>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Object stores table */}
|
||||||
|
{objectStores.length > 0 && (
|
||||||
|
<SectionBox title="Object Stores">
|
||||||
|
<SimpleTable
|
||||||
|
columns={[
|
||||||
|
{ label: 'Name', getter: (o) => o.metadata.name },
|
||||||
|
{
|
||||||
|
label: 'Phase',
|
||||||
|
getter: (o) => (
|
||||||
|
<StatusLabel status={phaseToStatus(o.status?.phase)}>
|
||||||
|
{o.status?.phase ?? 'Unknown'}
|
||||||
|
</StatusLabel>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{ label: 'Gateway Port', getter: (o) => String(o.spec?.gateway?.port ?? '—') },
|
||||||
|
{ label: 'Instances', getter: (o) => String(o.spec?.gateway?.instances ?? '—') },
|
||||||
|
{ label: 'Age', getter: (o) => formatAge(o.metadata.creationTimestamp) },
|
||||||
|
]}
|
||||||
|
data={objectStores}
|
||||||
|
/>
|
||||||
|
</SectionBox>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Non-bound PVCs warning */}
|
||||||
|
{nonBoundPvcs.length > 0 && (
|
||||||
|
<SectionBox title="Attention: Non-Bound PVCs">
|
||||||
|
<SimpleTable
|
||||||
|
columns={[
|
||||||
|
{ label: 'Name', getter: (pvc) => pvc.metadata.name },
|
||||||
|
{ label: 'Namespace', getter: (pvc) => pvc.metadata.namespace ?? '—' },
|
||||||
|
{
|
||||||
|
label: 'Status',
|
||||||
|
getter: (pvc) => (
|
||||||
|
<StatusLabel status={phaseToStatus(pvc.status?.phase)}>
|
||||||
|
{pvc.status?.phase ?? 'Unknown'}
|
||||||
|
</StatusLabel>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{ label: 'Age', getter: (pvc) => formatAge(pvc.metadata.creationTimestamp) },
|
||||||
|
]}
|
||||||
|
data={nonBoundPvcs}
|
||||||
|
/>
|
||||||
|
</SectionBox>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseStorageToBytes(storage: string): number {
|
||||||
|
const match = /^(\d+(?:\.\d+)?)\s*(Ki|Mi|Gi|Ti|Pi|K|M|G|T|P)?$/.exec(storage.trim());
|
||||||
|
if (!match) return 0;
|
||||||
|
const value = parseFloat(match[1]);
|
||||||
|
const suffix = match[2] ?? '';
|
||||||
|
const multipliers: Record<string, number> = {
|
||||||
|
'': 1,
|
||||||
|
K: 1e3, Ki: 1024,
|
||||||
|
M: 1e6, Mi: 1024 ** 2,
|
||||||
|
G: 1e9, Gi: 1024 ** 3,
|
||||||
|
T: 1e12, Ti: 1024 ** 4,
|
||||||
|
P: 1e15, Pi: 1024 ** 5,
|
||||||
|
};
|
||||||
|
return value * (multipliers[suffix] ?? 1);
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
/**
|
||||||
|
* PVCDetailSection — injected into Headlamp's PVC detail view.
|
||||||
|
*
|
||||||
|
* Shown only when the bound PV uses a Rook-Ceph CSI driver.
|
||||||
|
* Uses registerDetailsViewSection in index.tsx.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
NameValueTable,
|
||||||
|
SectionBox,
|
||||||
|
} from '@kinvolk/headlamp-plugin/lib/CommonComponents';
|
||||||
|
import React from 'react';
|
||||||
|
import { useRookCephContext } from '../api/RookCephDataContext';
|
||||||
|
import { findBoundPv, formatStorageType, storageClassType } from '../api/k8s';
|
||||||
|
|
||||||
|
interface PVCDetailSectionProps {
|
||||||
|
resource: {
|
||||||
|
metadata?: { name?: string; namespace?: string };
|
||||||
|
spec?: { volumeName?: string; storageClassName?: string };
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PVCDetailSection({ resource }: PVCDetailSectionProps) {
|
||||||
|
const { persistentVolumes, persistentVolumeClaims, loading } = useRookCephContext();
|
||||||
|
|
||||||
|
if (loading) return null;
|
||||||
|
|
||||||
|
const pvcName = resource.metadata?.name;
|
||||||
|
const pvcNamespace = resource.metadata?.namespace;
|
||||||
|
const matchedPvc = persistentVolumeClaims.find(
|
||||||
|
pvc => pvc.metadata.name === pvcName && pvc.metadata.namespace === pvcNamespace
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!matchedPvc) return null;
|
||||||
|
|
||||||
|
const boundPv = findBoundPv(matchedPvc, persistentVolumes);
|
||||||
|
if (!boundPv) return null;
|
||||||
|
|
||||||
|
const attrs = boundPv.spec.csi?.volumeAttributes ?? {};
|
||||||
|
|
||||||
|
// Determine storage type from driver name
|
||||||
|
const driver = boundPv.spec.csi?.driver ?? '';
|
||||||
|
const type = driver.includes('.rbd.') ? 'rbd' : driver.includes('.cephfs.') ? 'cephfs' : 'unknown';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SectionBox title="Rook-Ceph Storage Details">
|
||||||
|
<NameValueTable
|
||||||
|
rows={[
|
||||||
|
{ name: 'Driver', value: driver || '—' },
|
||||||
|
{ name: 'Type', value: formatStorageType(type) },
|
||||||
|
{ name: 'Pool', value: attrs['pool'] ?? '—' },
|
||||||
|
{ name: 'Storage Class', value: boundPv.spec.storageClassName ?? '—' },
|
||||||
|
{ name: 'Volume Handle', value: boundPv.spec.csi?.volumeHandle ?? '—' },
|
||||||
|
{ name: 'PV Name', value: boundPv.metadata.name },
|
||||||
|
...Object.entries(attrs)
|
||||||
|
.filter(([k]) => k !== 'pool')
|
||||||
|
.map(([k, v]) => ({ name: k, value: v ?? '—' })),
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</SectionBox>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
/**
|
||||||
|
* PVDetailSection — injected into Headlamp's PV detail view.
|
||||||
|
*
|
||||||
|
* Shown only when the PV uses a Rook-Ceph CSI driver.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
NameValueTable,
|
||||||
|
SectionBox,
|
||||||
|
} from '@kinvolk/headlamp-plugin/lib/CommonComponents';
|
||||||
|
import React from 'react';
|
||||||
|
import { formatStorageType, isRookCephPersistentVolume } from '../api/k8s';
|
||||||
|
|
||||||
|
interface PVDetailSectionProps {
|
||||||
|
resource: {
|
||||||
|
metadata?: { name?: string };
|
||||||
|
spec?: { csi?: { driver?: string; volumeHandle?: string; volumeAttributes?: Record<string, string> }; storageClassName?: string };
|
||||||
|
jsonData?: unknown;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PVDetailSection({ resource }: PVDetailSectionProps) {
|
||||||
|
// Accept both KubeObject instances (jsonData) and plain objects
|
||||||
|
const raw =
|
||||||
|
resource.jsonData && typeof resource.jsonData === 'object'
|
||||||
|
? (resource.jsonData as typeof resource)
|
||||||
|
: resource;
|
||||||
|
|
||||||
|
const spec = raw.spec;
|
||||||
|
const driver = spec?.csi?.driver ?? '';
|
||||||
|
|
||||||
|
if (!isRookCephPersistentVolume({ metadata: raw.metadata ?? { name: '' }, spec: spec ?? {} })) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const attrs = spec?.csi?.volumeAttributes ?? {};
|
||||||
|
const type = driver.includes('.rbd.') ? 'rbd' : driver.includes('.cephfs.') ? 'cephfs' : 'unknown';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SectionBox title="Rook-Ceph Volume Details">
|
||||||
|
<NameValueTable
|
||||||
|
rows={[
|
||||||
|
{ name: 'Driver', value: driver || '—' },
|
||||||
|
{ name: 'Type', value: formatStorageType(type) },
|
||||||
|
{ name: 'Volume Handle', value: spec?.csi?.volumeHandle ?? '—' },
|
||||||
|
{ name: 'Pool', value: attrs['pool'] ?? '—' },
|
||||||
|
{ name: 'Storage Class', value: spec?.storageClassName ?? '—' },
|
||||||
|
...Object.entries(attrs)
|
||||||
|
.filter(([k]) => k !== 'pool')
|
||||||
|
.map(([k, v]) => ({ name: k, value: v ?? '—' })),
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</SectionBox>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
/**
|
||||||
|
* PodsPage — lists all Rook-Ceph daemon pods grouped by role.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
Loader,
|
||||||
|
NameValueTable,
|
||||||
|
SectionBox,
|
||||||
|
SectionHeader,
|
||||||
|
SimpleTable,
|
||||||
|
StatusLabel,
|
||||||
|
} from '@kinvolk/headlamp-plugin/lib/CommonComponents';
|
||||||
|
import React from 'react';
|
||||||
|
import { useRookCephContext } from '../api/RookCephDataContext';
|
||||||
|
import { formatAge, getPodRestarts, isPodReady, RookCephPod } from '../api/k8s';
|
||||||
|
|
||||||
|
function PodTable({ pods, title }: { pods: RookCephPod[]; title: string }) {
|
||||||
|
if (pods.length === 0) return null;
|
||||||
|
return (
|
||||||
|
<SectionBox title={`${title} (${pods.length})`}>
|
||||||
|
<SimpleTable
|
||||||
|
columns={[
|
||||||
|
{ label: 'Name', getter: (p) => p.metadata.name },
|
||||||
|
{
|
||||||
|
label: 'Status',
|
||||||
|
getter: (p) => (
|
||||||
|
<StatusLabel status={isPodReady(p) ? 'success' : 'error'}>
|
||||||
|
{p.status?.phase ?? 'Unknown'}
|
||||||
|
</StatusLabel>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{ label: 'Node', getter: (p) => p.spec?.nodeName ?? '—' },
|
||||||
|
{ label: 'Restarts', getter: (p) => String(getPodRestarts(p)) },
|
||||||
|
{ label: 'Age', getter: (p) => formatAge(p.metadata.creationTimestamp) },
|
||||||
|
]}
|
||||||
|
data={pods}
|
||||||
|
/>
|
||||||
|
</SectionBox>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PodsPage() {
|
||||||
|
const {
|
||||||
|
operatorPods,
|
||||||
|
monPods,
|
||||||
|
osdPods,
|
||||||
|
mgrPods,
|
||||||
|
csiRbdPods,
|
||||||
|
csiCephfsPods,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
} = useRookCephContext();
|
||||||
|
|
||||||
|
if (loading) return <Loader title="Loading Rook-Ceph pods..." />;
|
||||||
|
|
||||||
|
const allPods = [...operatorPods, ...monPods, ...osdPods, ...mgrPods, ...csiRbdPods, ...csiCephfsPods];
|
||||||
|
const totalReady = allPods.filter(isPodReady).length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<SectionHeader title="Rook-Ceph Pods" />
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<SectionBox title="Error">
|
||||||
|
<NameValueTable rows={[{ name: 'Status', value: <StatusLabel status="error">{error}</StatusLabel> }]} />
|
||||||
|
</SectionBox>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<SectionBox title="Summary">
|
||||||
|
<NameValueTable
|
||||||
|
rows={[
|
||||||
|
{
|
||||||
|
name: 'Overall Health',
|
||||||
|
value: (
|
||||||
|
<StatusLabel status={totalReady === allPods.length && allPods.length > 0 ? 'success' : 'warning'}>
|
||||||
|
{totalReady}/{allPods.length} pods ready
|
||||||
|
</StatusLabel>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</SectionBox>
|
||||||
|
|
||||||
|
<PodTable pods={operatorPods} title="Operator" />
|
||||||
|
<PodTable pods={monPods} title="Monitors (MON)" />
|
||||||
|
<PodTable pods={mgrPods} title="Managers (MGR)" />
|
||||||
|
<PodTable pods={osdPods} title="OSDs" />
|
||||||
|
<PodTable pods={csiRbdPods} title="CSI RBD Provisioner" />
|
||||||
|
<PodTable pods={csiCephfsPods} title="CSI CephFS Provisioner" />
|
||||||
|
|
||||||
|
{allPods.length === 0 && (
|
||||||
|
<SectionBox title="No Pods">
|
||||||
|
<NameValueTable
|
||||||
|
rows={[{ name: 'Status', value: 'No Rook-Ceph pods found in rook-ceph namespace.' }]}
|
||||||
|
/>
|
||||||
|
</SectionBox>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
/**
|
||||||
|
* StorageClassesPage — lists Rook-Ceph StorageClasses.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
Loader,
|
||||||
|
NameValueTable,
|
||||||
|
SectionBox,
|
||||||
|
SectionHeader,
|
||||||
|
SimpleTable,
|
||||||
|
StatusLabel,
|
||||||
|
} from '@kinvolk/headlamp-plugin/lib/CommonComponents';
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { useRookCephContext } from '../api/RookCephDataContext';
|
||||||
|
import { formatAge, formatStorageType, RookCephStorageClass, storageClassType } from '../api/k8s';
|
||||||
|
|
||||||
|
function StorageClassDetail({ sc, pvCount, onClose }: { sc: RookCephStorageClass; pvCount: number; onClose: () => void }) {
|
||||||
|
const type = storageClassType(sc);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
top: 0, right: 0, bottom: 0, width: '480px',
|
||||||
|
backgroundColor: 'var(--mui-palette-background-paper, #fff)',
|
||||||
|
boxShadow: '-4px 0 16px rgba(0,0,0,0.15)',
|
||||||
|
zIndex: 1300,
|
||||||
|
overflowY: 'auto',
|
||||||
|
padding: '24px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '16px' }}>
|
||||||
|
<strong>{sc.metadata.name}</strong>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
aria-label="Close"
|
||||||
|
style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: '18px' }}
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<SectionBox title="StorageClass Details">
|
||||||
|
<NameValueTable
|
||||||
|
rows={[
|
||||||
|
{ name: 'Name', value: sc.metadata.name },
|
||||||
|
{ name: 'Provisioner', value: sc.provisioner },
|
||||||
|
{ name: 'Type', value: formatStorageType(type) },
|
||||||
|
{ name: 'Reclaim Policy', value: sc.reclaimPolicy ?? '—' },
|
||||||
|
{ name: 'Volume Binding Mode', value: sc.volumeBindingMode ?? '—' },
|
||||||
|
{ name: 'Volume Expansion', value: sc.allowVolumeExpansion ? 'Allowed' : 'Not allowed' },
|
||||||
|
{ name: 'Age', value: formatAge(sc.metadata.creationTimestamp) },
|
||||||
|
{ name: 'Bound PVs', value: String(pvCount) },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</SectionBox>
|
||||||
|
{sc.parameters && Object.keys(sc.parameters).length > 0 && (
|
||||||
|
<SectionBox title="Parameters">
|
||||||
|
<NameValueTable
|
||||||
|
rows={Object.entries(sc.parameters).map(([k, v]) => ({ name: k, value: v ?? '—' }))}
|
||||||
|
/>
|
||||||
|
</SectionBox>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function StorageClassesPage() {
|
||||||
|
const { storageClasses, persistentVolumes, loading, error } = useRookCephContext();
|
||||||
|
const [selected, setSelected] = useState<RookCephStorageClass | null>(null);
|
||||||
|
|
||||||
|
if (loading) return <Loader title="Loading Rook-Ceph storage classes..." />;
|
||||||
|
|
||||||
|
const pvCountByClass = new Map<string, number>();
|
||||||
|
for (const pv of persistentVolumes) {
|
||||||
|
const sc = pv.spec.storageClassName ?? '';
|
||||||
|
pvCountByClass.set(sc, (pvCountByClass.get(sc) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<SectionHeader title="Rook-Ceph Storage Classes" />
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<SectionBox title="Error">
|
||||||
|
<NameValueTable rows={[{ name: 'Status', value: <StatusLabel status="error">{error}</StatusLabel> }]} />
|
||||||
|
</SectionBox>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{storageClasses.length === 0 ? (
|
||||||
|
<SectionBox title="No Storage Classes">
|
||||||
|
<NameValueTable
|
||||||
|
rows={[{ name: 'Status', value: 'No Rook-Ceph StorageClasses found. Ensure CephBlockPool and CephFilesystem resources exist.' }]}
|
||||||
|
/>
|
||||||
|
</SectionBox>
|
||||||
|
) : (
|
||||||
|
<SectionBox title={`Storage Classes (${storageClasses.length})`}>
|
||||||
|
<SimpleTable
|
||||||
|
columns={[
|
||||||
|
{
|
||||||
|
label: 'Name',
|
||||||
|
getter: (sc: RookCephStorageClass) => (
|
||||||
|
<button
|
||||||
|
onClick={() => setSelected(sc)}
|
||||||
|
style={{ border: 'none', background: 'transparent', color: 'var(--link-color, #1976d2)', cursor: 'pointer', textDecoration: 'underline', padding: 0, font: 'inherit' }}
|
||||||
|
>
|
||||||
|
{sc.metadata.name}
|
||||||
|
</button>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Type',
|
||||||
|
getter: (sc: RookCephStorageClass) => (
|
||||||
|
<StatusLabel status="success">
|
||||||
|
{formatStorageType(storageClassType(sc))}
|
||||||
|
</StatusLabel>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{ label: 'Provisioner', getter: (sc: RookCephStorageClass) => sc.provisioner },
|
||||||
|
{ label: 'Pool', getter: (sc: RookCephStorageClass) => sc.parameters?.['pool'] ?? '—' },
|
||||||
|
{ label: 'Reclaim', getter: (sc: RookCephStorageClass) => sc.reclaimPolicy ?? '—' },
|
||||||
|
{ label: 'Expansion', getter: (sc: RookCephStorageClass) => sc.allowVolumeExpansion ? 'Yes' : 'No' },
|
||||||
|
{ label: 'PVs', getter: (sc: RookCephStorageClass) => String(pvCountByClass.get(sc.metadata.name) ?? 0) },
|
||||||
|
{ label: 'Age', getter: (sc: RookCephStorageClass) => formatAge(sc.metadata.creationTimestamp) },
|
||||||
|
]}
|
||||||
|
data={storageClasses}
|
||||||
|
/>
|
||||||
|
</SectionBox>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{selected && (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
style={{ position: 'fixed', inset: 0, backgroundColor: 'rgba(0,0,0,0.3)', zIndex: 1299 }}
|
||||||
|
onClick={() => setSelected(null)}
|
||||||
|
/>
|
||||||
|
<StorageClassDetail
|
||||||
|
sc={selected}
|
||||||
|
pvCount={pvCountByClass.get(selected.metadata.name) ?? 0}
|
||||||
|
onClose={() => setSelected(null)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
/**
|
||||||
|
* VolumesPage — lists Rook-Ceph PersistentVolumes.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
Loader,
|
||||||
|
NameValueTable,
|
||||||
|
SectionBox,
|
||||||
|
SectionHeader,
|
||||||
|
SimpleTable,
|
||||||
|
StatusLabel,
|
||||||
|
} from '@kinvolk/headlamp-plugin/lib/CommonComponents';
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { useRookCephContext } from '../api/RookCephDataContext';
|
||||||
|
import { formatAccessModes, formatAge, phaseToStatus, RookCephPersistentVolume } from '../api/k8s';
|
||||||
|
|
||||||
|
function PVDetail({ pv, onClose }: { pv: RookCephPersistentVolume; onClose: () => void }) {
|
||||||
|
const attrs = pv.spec.csi?.volumeAttributes ?? {};
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
top: 0, right: 0, bottom: 0, width: '520px',
|
||||||
|
backgroundColor: 'var(--mui-palette-background-paper, #fff)',
|
||||||
|
boxShadow: '-4px 0 16px rgba(0,0,0,0.15)',
|
||||||
|
zIndex: 1300,
|
||||||
|
overflowY: 'auto',
|
||||||
|
padding: '24px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '16px' }}>
|
||||||
|
<strong>{pv.metadata.name}</strong>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
aria-label="Close"
|
||||||
|
style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: '18px' }}
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<SectionBox title="PersistentVolume">
|
||||||
|
<NameValueTable
|
||||||
|
rows={[
|
||||||
|
{ name: 'Name', value: pv.metadata.name },
|
||||||
|
{ name: 'Capacity', value: pv.spec.capacity?.storage ?? '—' },
|
||||||
|
{ name: 'Access Modes', value: formatAccessModes(pv.spec.accessModes) },
|
||||||
|
{ name: 'Reclaim Policy', value: pv.spec.persistentVolumeReclaimPolicy ?? '—' },
|
||||||
|
{ name: 'Storage Class', value: pv.spec.storageClassName ?? '—' },
|
||||||
|
{
|
||||||
|
name: 'Phase',
|
||||||
|
value: (
|
||||||
|
<StatusLabel status={phaseToStatus(pv.status?.phase)}>
|
||||||
|
{pv.status?.phase ?? 'Unknown'}
|
||||||
|
</StatusLabel>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Claim',
|
||||||
|
value: pv.spec.claimRef
|
||||||
|
? `${pv.spec.claimRef.namespace}/${pv.spec.claimRef.name}`
|
||||||
|
: '—',
|
||||||
|
},
|
||||||
|
{ name: 'Age', value: formatAge(pv.metadata.creationTimestamp) },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</SectionBox>
|
||||||
|
<SectionBox title="CSI Volume Attributes">
|
||||||
|
<NameValueTable
|
||||||
|
rows={[
|
||||||
|
{ name: 'Driver', value: pv.spec.csi?.driver ?? '—' },
|
||||||
|
{ name: 'Volume Handle', value: pv.spec.csi?.volumeHandle ?? '—' },
|
||||||
|
...Object.entries(attrs).map(([k, v]) => ({ name: k, value: v ?? '—' })),
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</SectionBox>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function VolumesPage() {
|
||||||
|
const { persistentVolumes, loading, error } = useRookCephContext();
|
||||||
|
const [selected, setSelected] = useState<RookCephPersistentVolume | null>(null);
|
||||||
|
|
||||||
|
if (loading) return <Loader title="Loading Rook-Ceph volumes..." />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<SectionHeader title="Rook-Ceph Persistent Volumes" />
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<SectionBox title="Error">
|
||||||
|
<NameValueTable rows={[{ name: 'Status', value: <StatusLabel status="error">{error}</StatusLabel> }]} />
|
||||||
|
</SectionBox>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{persistentVolumes.length === 0 ? (
|
||||||
|
<SectionBox title="No Volumes">
|
||||||
|
<NameValueTable
|
||||||
|
rows={[{ name: 'Status', value: 'No Rook-Ceph PersistentVolumes found.' }]}
|
||||||
|
/>
|
||||||
|
</SectionBox>
|
||||||
|
) : (
|
||||||
|
<SectionBox title={`Persistent Volumes (${persistentVolumes.length})`}>
|
||||||
|
<SimpleTable
|
||||||
|
columns={[
|
||||||
|
{
|
||||||
|
label: 'Name',
|
||||||
|
getter: (pv: RookCephPersistentVolume) => (
|
||||||
|
<button
|
||||||
|
onClick={() => setSelected(pv)}
|
||||||
|
style={{ border: 'none', background: 'transparent', color: 'var(--link-color, #1976d2)', cursor: 'pointer', textDecoration: 'underline', padding: 0, font: 'inherit' }}
|
||||||
|
>
|
||||||
|
{pv.metadata.name}
|
||||||
|
</button>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{ label: 'Capacity', getter: (pv: RookCephPersistentVolume) => pv.spec.capacity?.storage ?? '—' },
|
||||||
|
{ label: 'Access Modes', getter: (pv: RookCephPersistentVolume) => formatAccessModes(pv.spec.accessModes) },
|
||||||
|
{
|
||||||
|
label: 'Phase',
|
||||||
|
getter: (pv: RookCephPersistentVolume) => (
|
||||||
|
<StatusLabel status={phaseToStatus(pv.status?.phase)}>
|
||||||
|
{pv.status?.phase ?? 'Unknown'}
|
||||||
|
</StatusLabel>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{ label: 'Reclaim', getter: (pv: RookCephPersistentVolume) => pv.spec.persistentVolumeReclaimPolicy ?? '—' },
|
||||||
|
{ label: 'Pool', getter: (pv: RookCephPersistentVolume) => pv.spec.csi?.volumeAttributes?.['pool'] ?? '—' },
|
||||||
|
{ label: 'Claim', getter: (pv: RookCephPersistentVolume) => pv.spec.claimRef ? `${pv.spec.claimRef.namespace}/${pv.spec.claimRef.name}` : '—' },
|
||||||
|
{ label: 'Age', getter: (pv: RookCephPersistentVolume) => formatAge(pv.metadata.creationTimestamp) },
|
||||||
|
]}
|
||||||
|
data={persistentVolumes}
|
||||||
|
/>
|
||||||
|
</SectionBox>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{selected && (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
style={{ position: 'fixed', inset: 0, backgroundColor: 'rgba(0,0,0,0.3)', zIndex: 1299 }}
|
||||||
|
onClick={() => setSelected(null)}
|
||||||
|
/>
|
||||||
|
<PVDetail pv={selected} onClose={() => setSelected(null)} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
/**
|
||||||
|
* StorageClassColumns — registerResourceTableColumnsProcessor integration.
|
||||||
|
*
|
||||||
|
* Adds Rook-Ceph-specific columns to the native Headlamp StorageClass table
|
||||||
|
* ('headlamp-storageclasses') and PV table ('headlamp-persistentvolumes').
|
||||||
|
* Non-Rook-Ceph rows show '—'.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { isRookCephProvisioner, formatStorageType } from '../../api/k8s';
|
||||||
|
|
||||||
|
/** Safely read a nested field from either a KubeObject instance or plain object. */
|
||||||
|
function getField(item: unknown, ...path: string[]): unknown {
|
||||||
|
if (!item || typeof item !== 'object') return undefined;
|
||||||
|
const obj = item as Record<string, unknown>;
|
||||||
|
// KubeObject instances store raw JSON in .jsonData
|
||||||
|
const raw =
|
||||||
|
'jsonData' in obj && obj['jsonData'] && typeof obj['jsonData'] === 'object'
|
||||||
|
? (obj['jsonData'] as Record<string, unknown>)
|
||||||
|
: obj;
|
||||||
|
let cur: unknown = raw;
|
||||||
|
for (const key of path) {
|
||||||
|
if (!cur || typeof cur !== 'object') return undefined;
|
||||||
|
cur = (cur as Record<string, unknown>)[key];
|
||||||
|
}
|
||||||
|
return cur;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRookRow(item: unknown): boolean {
|
||||||
|
const provisioner = getField(item, 'provisioner') as string | undefined;
|
||||||
|
return typeof provisioner === 'string' && isRookCephProvisioner(provisioner);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRookPvRow(item: unknown): boolean {
|
||||||
|
const driver = getField(item, 'spec', 'csi', 'driver') as string | undefined;
|
||||||
|
return typeof driver === 'string' && isRookCephProvisioner(driver);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildStorageClassColumns() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
label: 'Rook Type',
|
||||||
|
getValue: (item: unknown) => {
|
||||||
|
if (!isRookRow(item)) return null;
|
||||||
|
const provisioner = getField(item, 'provisioner') as string | undefined;
|
||||||
|
if (!provisioner) return null;
|
||||||
|
const type = provisioner.includes('.rbd.') ? 'rbd' : provisioner.includes('.cephfs.') ? 'cephfs' : 'unknown';
|
||||||
|
return formatStorageType(type as 'rbd' | 'cephfs' | 'unknown');
|
||||||
|
},
|
||||||
|
render: (item: unknown) => {
|
||||||
|
if (!isRookRow(item)) return <span>—</span>;
|
||||||
|
const provisioner = getField(item, 'provisioner') as string | undefined;
|
||||||
|
if (!provisioner) return <span>—</span>;
|
||||||
|
const type = provisioner.includes('.rbd.') ? 'rbd' : provisioner.includes('.cephfs.') ? 'cephfs' : 'unknown';
|
||||||
|
return <span style={{ color: '#1976d2', fontWeight: 500 }}>{formatStorageType(type as 'rbd' | 'cephfs' | 'unknown')}</span>;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Pool',
|
||||||
|
getValue: (item: unknown) => getField(item, 'parameters', 'pool') as string | null ?? null,
|
||||||
|
render: (item: unknown) => {
|
||||||
|
if (!isRookRow(item)) return <span>—</span>;
|
||||||
|
const pool = getField(item, 'parameters', 'pool') as string | undefined;
|
||||||
|
return <span>{pool ?? '—'}</span>;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Cluster ID',
|
||||||
|
getValue: (item: unknown) => getField(item, 'parameters', 'clusterID') as string | null ?? null,
|
||||||
|
render: (item: unknown) => {
|
||||||
|
if (!isRookRow(item)) return <span>—</span>;
|
||||||
|
const clusterID = getField(item, 'parameters', 'clusterID') as string | undefined;
|
||||||
|
if (!clusterID) return <span>—</span>;
|
||||||
|
// Truncate long cluster IDs
|
||||||
|
return <span title={clusterID}>{clusterID.length > 16 ? `${clusterID.slice(0, 16)}…` : clusterID}</span>;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildPVColumns() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
label: 'Rook Type',
|
||||||
|
getValue: (item: unknown) => {
|
||||||
|
if (!isRookPvRow(item)) return null;
|
||||||
|
const driver = getField(item, 'spec', 'csi', 'driver') as string | undefined;
|
||||||
|
if (!driver) return null;
|
||||||
|
const type = driver.includes('.rbd.') ? 'rbd' : driver.includes('.cephfs.') ? 'cephfs' : 'unknown';
|
||||||
|
return formatStorageType(type as 'rbd' | 'cephfs' | 'unknown');
|
||||||
|
},
|
||||||
|
render: (item: unknown) => {
|
||||||
|
if (!isRookPvRow(item)) return <span>—</span>;
|
||||||
|
const driver = getField(item, 'spec', 'csi', 'driver') as string | undefined;
|
||||||
|
if (!driver) return <span>—</span>;
|
||||||
|
const type = driver.includes('.rbd.') ? 'rbd' : driver.includes('.cephfs.') ? 'cephfs' : 'unknown';
|
||||||
|
return <span style={{ color: '#1976d2', fontWeight: 500 }}>{formatStorageType(type as 'rbd' | 'cephfs' | 'unknown')}</span>;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Pool',
|
||||||
|
getValue: (item: unknown) => getField(item, 'spec', 'csi', 'volumeAttributes', 'pool') as string | null ?? null,
|
||||||
|
render: (item: unknown) => {
|
||||||
|
if (!isRookPvRow(item)) return <span>—</span>;
|
||||||
|
const pool = getField(item, 'spec', 'csi', 'volumeAttributes', 'pool') as string | undefined;
|
||||||
|
return <span>{pool ?? '—'}</span>;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
+183
@@ -0,0 +1,183 @@
|
|||||||
|
/**
|
||||||
|
* headlamp-rook-ceph-plugin — entry point.
|
||||||
|
*
|
||||||
|
* Registers sidebar entries, routes, detail view sections, table column
|
||||||
|
* processors, and app bar action for Rook-Ceph visibility in Headlamp.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
registerAppBarAction,
|
||||||
|
registerDetailsViewSection,
|
||||||
|
registerResourceTableColumnsProcessor,
|
||||||
|
registerRoute,
|
||||||
|
registerSidebarEntry,
|
||||||
|
} from '@kinvolk/headlamp-plugin/lib';
|
||||||
|
import React from 'react';
|
||||||
|
import { RookCephDataProvider } from './api/RookCephDataContext';
|
||||||
|
import AppBarClusterBadge from './components/AppBarClusterBadge';
|
||||||
|
import BlockPoolsPage from './components/BlockPoolsPage';
|
||||||
|
import CephPodDetailSection from './components/CephPodDetailSection';
|
||||||
|
import { buildPVColumns, buildStorageClassColumns } from './components/integrations/StorageClassColumns';
|
||||||
|
import OverviewPage from './components/OverviewPage';
|
||||||
|
import PodsPage from './components/PodsPage';
|
||||||
|
import PVCDetailSection from './components/PVCDetailSection';
|
||||||
|
import PVDetailSection from './components/PVDetailSection';
|
||||||
|
import StorageClassesPage from './components/StorageClassesPage';
|
||||||
|
import VolumesPage from './components/VolumesPage';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Sidebar entries
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
registerSidebarEntry({
|
||||||
|
parent: null,
|
||||||
|
name: 'rook-ceph',
|
||||||
|
label: 'Rook-Ceph',
|
||||||
|
url: '/rook-ceph',
|
||||||
|
icon: 'mdi:database-cog',
|
||||||
|
});
|
||||||
|
|
||||||
|
registerSidebarEntry({
|
||||||
|
parent: 'rook-ceph',
|
||||||
|
name: 'rook-ceph-overview',
|
||||||
|
label: 'Overview',
|
||||||
|
url: '/rook-ceph',
|
||||||
|
icon: 'mdi:view-dashboard',
|
||||||
|
});
|
||||||
|
|
||||||
|
registerSidebarEntry({
|
||||||
|
parent: 'rook-ceph',
|
||||||
|
name: 'rook-ceph-blockpools',
|
||||||
|
label: 'Block Pools',
|
||||||
|
url: '/rook-ceph/block-pools',
|
||||||
|
icon: 'mdi:database',
|
||||||
|
});
|
||||||
|
|
||||||
|
registerSidebarEntry({
|
||||||
|
parent: 'rook-ceph',
|
||||||
|
name: 'rook-ceph-pods',
|
||||||
|
label: 'Pods',
|
||||||
|
url: '/rook-ceph/pods',
|
||||||
|
icon: 'mdi:cube-outline',
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Routes
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
registerRoute({
|
||||||
|
path: '/rook-ceph',
|
||||||
|
sidebar: 'rook-ceph-overview',
|
||||||
|
name: 'rook-ceph-overview',
|
||||||
|
exact: true,
|
||||||
|
component: () => (
|
||||||
|
<RookCephDataProvider>
|
||||||
|
<OverviewPage />
|
||||||
|
</RookCephDataProvider>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
registerRoute({
|
||||||
|
path: '/rook-ceph/block-pools',
|
||||||
|
sidebar: 'rook-ceph-blockpools',
|
||||||
|
name: 'rook-ceph-blockpools',
|
||||||
|
exact: true,
|
||||||
|
component: () => (
|
||||||
|
<RookCephDataProvider>
|
||||||
|
<BlockPoolsPage />
|
||||||
|
</RookCephDataProvider>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Storage Classes and Volumes pages accessible via direct URL
|
||||||
|
registerRoute({
|
||||||
|
path: '/rook-ceph/storage-classes',
|
||||||
|
sidebar: 'rook-ceph-overview',
|
||||||
|
name: 'rook-ceph-storage-classes',
|
||||||
|
exact: true,
|
||||||
|
component: () => (
|
||||||
|
<RookCephDataProvider>
|
||||||
|
<StorageClassesPage />
|
||||||
|
</RookCephDataProvider>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
registerRoute({
|
||||||
|
path: '/rook-ceph/volumes',
|
||||||
|
sidebar: 'rook-ceph-overview',
|
||||||
|
name: 'rook-ceph-volumes',
|
||||||
|
exact: true,
|
||||||
|
component: () => (
|
||||||
|
<RookCephDataProvider>
|
||||||
|
<VolumesPage />
|
||||||
|
</RookCephDataProvider>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
registerRoute({
|
||||||
|
path: '/rook-ceph/pods',
|
||||||
|
sidebar: 'rook-ceph-pods',
|
||||||
|
name: 'rook-ceph-pods',
|
||||||
|
exact: true,
|
||||||
|
component: () => (
|
||||||
|
<RookCephDataProvider>
|
||||||
|
<PodsPage />
|
||||||
|
</RookCephDataProvider>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Detail view section — PVC pages
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
registerDetailsViewSection(({ resource }) => {
|
||||||
|
if (resource?.kind !== 'PersistentVolumeClaim') return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<RookCephDataProvider>
|
||||||
|
<PVCDetailSection resource={resource} />
|
||||||
|
</RookCephDataProvider>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Detail view section — PV pages
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
registerDetailsViewSection(({ resource }) => {
|
||||||
|
if (resource?.kind !== 'PersistentVolume') return null;
|
||||||
|
return <PVDetailSection resource={resource} />;
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Detail view section — Pod pages (Rook-Ceph daemon pods only)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
registerDetailsViewSection(({ resource }) => {
|
||||||
|
if (resource?.kind !== 'Pod') return null;
|
||||||
|
return <CephPodDetailSection resource={resource} />;
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Table column processors — native StorageClass and PV tables
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
registerResourceTableColumnsProcessor(({ id, columns }) => {
|
||||||
|
if (id === 'headlamp-storageclasses') {
|
||||||
|
return [...columns, ...buildStorageClassColumns()];
|
||||||
|
}
|
||||||
|
if (id === 'headlamp-persistentvolumes') {
|
||||||
|
return [...columns, ...buildPVColumns()];
|
||||||
|
}
|
||||||
|
return columns;
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// App bar action — cluster health badge
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
registerAppBarAction(() => (
|
||||||
|
<RookCephDataProvider>
|
||||||
|
<AppBarClusterBadge />
|
||||||
|
</RookCephDataProvider>
|
||||||
|
));
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"extends": "@kinvolk/headlamp-plugin/config/plugins-tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"types": ["vite/client", "vite-plugin-svgr/client", "vitest/globals", "@testing-library/jest-dom"]
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { defineConfig } from 'vitest/config';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
globals: true,
|
||||||
|
environment: 'jsdom',
|
||||||
|
setupFiles: ['./vitest.setup.ts'],
|
||||||
|
exclude: ['e2e/**', 'node_modules/**'],
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import '@testing-library/jest-dom';
|
||||||
|
|
||||||
|
// Node 22+ ships a minimal built-in `localStorage` global (property-bag only,
|
||||||
|
// no getItem/setItem/removeItem/clear) that shadows jsdom's Web Storage
|
||||||
|
// implementation. Provide a spec-compliant shim so code under test works.
|
||||||
|
if (typeof localStorage !== 'undefined' && typeof localStorage.getItem !== 'function') {
|
||||||
|
const store = new Map<string, string>();
|
||||||
|
|
||||||
|
const storage = {
|
||||||
|
getItem(key: string): string | null {
|
||||||
|
return store.get(key) ?? null;
|
||||||
|
},
|
||||||
|
setItem(key: string, value: string): void {
|
||||||
|
store.set(key, String(value));
|
||||||
|
},
|
||||||
|
removeItem(key: string): void {
|
||||||
|
store.delete(key);
|
||||||
|
},
|
||||||
|
clear(): void {
|
||||||
|
store.clear();
|
||||||
|
},
|
||||||
|
get length(): number {
|
||||||
|
return store.size;
|
||||||
|
},
|
||||||
|
key(index: number): string | null {
|
||||||
|
return [...store.keys()][index] ?? null;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
Object.defineProperty(globalThis, 'localStorage', {
|
||||||
|
value: storage,
|
||||||
|
writable: true,
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
Object.defineProperty(window, 'localStorage', {
|
||||||
|
value: storage,
|
||||||
|
writable: true,
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user