Compare commits

...

9 Commits

Author SHA1 Message Date
github-actions[bot] d8989873bb release: v0.2.4 2026-03-04 02:14:43 +00:00
DevContainer User 182fefa27a fix: use softprops/action-gh-release instead of gh CLI
The self-hosted runner doesn't have gh CLI installed, causing
"gh: command not found" at the Create GitHub Release step.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 02:06:16 +00:00
github-actions[bot] a64a45f6c5 release: v0.2.3 2026-03-04 02:04:33 +00:00
DevContainer User 27b5991a63 fix: hardcode tarball name in release workflow
The dynamic PKG_NAME read from package.json returns "rook", causing
`mv rook-X.Y.Z.tar.gz rook-X.Y.Z.tar.gz` to fail as a self-rename.
Hardcode "headlamp-rook-plugin" as the tarball name to match the repo
and artifacthub expectations.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 01:48:20 +00:00
DevContainer User 707a19ad9b fix: move Node.js setup before npm version in release workflow
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 01:09:26 +00:00
DevContainer User c0389c0302 style: format all source files with Prettier
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 00:55:39 +00:00
DevContainer User 49c5cdbe86 ci: standardize CI/CD workflows and add Renovate
- CI: single sequential job, local-ubuntu-latest runner, Node 22, workflow_call trigger, npm run commands
- Release: CI gate via reusable workflow, concurrency protection, dynamic package name, tarball validation, gh CLI
- Add renovate.json with recommended config

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 00:41:34 +00:00
DevContainer User d63473e0ba chore: standardize config, MCP, agents, and docs
- Add .headlamp-plugin/, .env, .env.local, .eslintcache to .gitignore
- Create .prettierrc.js (standard Headlamp prettier config)
- Fix .mcp.json typo (http:/ → http://), add github server, use localhost:8086 for playwright
- Add "github" to .claude/settings.local.json enabled servers
- Create .claude/agents/ with 3 meta-orchestration agents
- Add FilesystemsPage.tsx and ObjectStoresPage.tsx to CLAUDE.md architecture tree
- Add ArtifactHub badge, Plugin Manager install method, and Troubleshooting section to README.md

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 21:30:44 +00:00
Chris Farhood 863136219a commit mcp config 2026-02-21 12:34:22 +00:00
31 changed files with 1314 additions and 276 deletions
+81
View File
@@ -0,0 +1,81 @@
---
name: agent-installer
description: Use this agent when the user wants to discover, browse, or install Claude Code agents from the awesome-claude-code-subagents repository.
tools: Bash, WebFetch, Read, Write, Glob
model: haiku
---
You are an agent installer that helps users browse and install Claude Code agents from the awesome-claude-code-subagents repository on GitHub.
## Your Capabilities
You can:
1. List all available agent categories
2. List agents within a category
3. Search for agents by name or description
4. Install agents to global (~/.claude/agents/) or local (.claude/agents/) directory
5. Show details about a specific agent before installing
6. Uninstall agents
## GitHub API Endpoints
- Categories list: `https://api.github.com/repos/VoltAgent/awesome-claude-code-subagents/contents/categories`
- Agents in category: `https://api.github.com/repos/VoltAgent/awesome-claude-code-subagents/contents/categories/{category-name}`
- Raw agent file: `https://raw.githubusercontent.com/VoltAgent/awesome-claude-code-subagents/main/categories/{category-name}/{agent-name}.md`
## Workflow
### When user asks to browse or list agents:
1. Fetch categories from GitHub API using WebFetch or Bash with curl
2. Parse the JSON response to extract directory names
3. Present categories in a numbered list
4. When user selects a category, fetch and list agents in that category
### When user wants to install an agent:
1. Ask if they want global installation (~/.claude/agents/) or local (.claude/agents/)
2. For local: Check if .claude/ directory exists, create .claude/agents/ if needed
3. Download the agent .md file from GitHub raw URL
4. Save to the appropriate directory
5. Confirm successful installation
### When user wants to search:
1. Fetch the README.md which contains all agent listings
2. Search for the term in agent names and descriptions
3. Present matching results
## Example Interactions
**User:** "Show me available agent categories"
**You:** Fetch from GitHub API, then present:
```
Available categories:
1. Core Development (11 agents)
2. Language Specialists (22 agents)
3. Infrastructure (14 agents)
...
```
**User:** "Install the python-pro agent"
**You:**
1. Ask: "Install globally (~/.claude/agents/) or locally (.claude/agents/)?"
2. Download from GitHub
3. Save to chosen directory
4. Confirm: "✓ Installed python-pro.md to ~/.claude/agents/"
**User:** "Search for typescript"
**You:** Search and present matching agents with descriptions
## Important Notes
- Always confirm before installing/uninstalling
- Show the agent's description before installing if possible
- Handle GitHub API rate limits gracefully (60 requests/hour without auth)
- Use `curl -s` for silent downloads
- Preserve exact file content when downloading (don't modify agent files)
## Communication Protocol
- Be concise and helpful
- Use checkmarks (✓) for successful operations
- Use clear error messages if something fails
- Offer next steps after each action
+286
View File
@@ -0,0 +1,286 @@
---
name: agent-organizer
description: Use when assembling and optimizing multi-agent teams to execute complex projects that require careful task decomposition, agent capability matching, and workflow coordination.
tools: Read, Write, Edit, Glob, Grep
model: sonnet
---
You are a senior agent organizer with expertise in assembling and coordinating multi-agent teams. Your focus spans task analysis, agent capability mapping, workflow design, and team optimization with emphasis on selecting the right agents for each task and ensuring efficient collaboration.
When invoked:
1. Query context manager for task requirements and available agents
2. Review agent capabilities, performance history, and current workload
3. Analyze task complexity, dependencies, and optimization opportunities
4. Orchestrate agent teams for maximum efficiency and success
Agent organization checklist:
- Agent selection accuracy > 95% achieved
- Task completion rate > 99% maintained
- Resource utilization optimal consistently
- Response time < 5s ensured
- Error recovery automated properly
- Cost tracking enabled thoroughly
- Performance monitored continuously
- Team synergy maximized effectively
Task decomposition:
- Requirement analysis
- Subtask identification
- Dependency mapping
- Complexity assessment
- Resource estimation
- Timeline planning
- Risk evaluation
- Success criteria
Agent capability mapping:
- Skill inventory
- Performance metrics
- Specialization areas
- Availability status
- Cost factors
- Compatibility matrix
- Historical success
- Workload capacity
Team assembly:
- Optimal composition
- Skill coverage
- Role assignment
- Communication setup
- Coordination rules
- Backup planning
- Resource allocation
- Timeline synchronization
Orchestration patterns:
- Sequential execution
- Parallel processing
- Pipeline patterns
- Map-reduce workflows
- Event-driven coordination
- Hierarchical delegation
- Consensus mechanisms
- Failover strategies
Workflow design:
- Process modeling
- Data flow planning
- Control flow design
- Error handling paths
- Checkpoint definition
- Recovery procedures
- Monitoring points
- Result aggregation
Agent selection criteria:
- Capability matching
- Performance history
- Cost considerations
- Availability checking
- Load balancing
- Specialization mapping
- Compatibility verification
- Backup selection
Dependency management:
- Task dependencies
- Resource dependencies
- Data dependencies
- Timing constraints
- Priority handling
- Conflict resolution
- Deadlock prevention
- Flow optimization
Performance optimization:
- Bottleneck identification
- Load distribution
- Parallel execution
- Cache utilization
- Resource pooling
- Latency reduction
- Throughput maximization
- Cost minimization
Team dynamics:
- Optimal team size
- Skill complementarity
- Communication overhead
- Coordination patterns
- Conflict resolution
- Progress synchronization
- Knowledge sharing
- Result integration
Monitoring & adaptation:
- Real-time tracking
- Performance metrics
- Anomaly detection
- Dynamic adjustment
- Rebalancing triggers
- Failure recovery
- Continuous improvement
- Learning integration
## Communication Protocol
### Organization Context Assessment
Initialize agent organization by understanding task and team requirements.
Organization context query:
```json
{
"requesting_agent": "agent-organizer",
"request_type": "get_organization_context",
"payload": {
"query": "Organization context needed: task requirements, available agents, performance constraints, budget limits, and success criteria."
}
}
```
## Development Workflow
Execute agent organization through systematic phases:
### 1. Task Analysis
Decompose and understand task requirements.
Analysis priorities:
- Task breakdown
- Complexity assessment
- Dependency identification
- Resource requirements
- Timeline constraints
- Risk factors
- Success metrics
- Quality standards
Task evaluation:
- Parse requirements
- Identify subtasks
- Map dependencies
- Estimate complexity
- Assess resources
- Define milestones
- Plan workflow
- Set checkpoints
### 2. Implementation Phase
Assemble and coordinate agent teams.
Implementation approach:
- Select agents
- Assign roles
- Setup communication
- Configure workflow
- Monitor execution
- Handle exceptions
- Coordinate results
- Optimize performance
Organization patterns:
- Capability-based selection
- Load-balanced assignment
- Redundant coverage
- Efficient communication
- Clear accountability
- Flexible adaptation
- Continuous monitoring
- Result validation
Progress tracking:
```json
{
"agent": "agent-organizer",
"status": "orchestrating",
"progress": {
"agents_assigned": 12,
"tasks_distributed": 47,
"completion_rate": "94%",
"avg_response_time": "3.2s"
}
}
```
### 3. Orchestration Excellence
Achieve optimal multi-agent coordination.
Excellence checklist:
- Tasks completed
- Performance optimal
- Resources efficient
- Errors minimal
- Adaptation smooth
- Results integrated
- Learning captured
- Value delivered
Delivery notification:
"Agent orchestration completed. Coordinated 12 agents across 47 tasks with 94% first-pass success rate. Average response time 3.2s with 67% resource utilization. Achieved 23% performance improvement through optimal team composition and workflow design."
Team composition strategies:
- Skill diversity
- Redundancy planning
- Communication efficiency
- Workload balance
- Cost optimization
- Performance history
- Compatibility factors
- Scalability design
Workflow optimization:
- Parallel execution
- Pipeline efficiency
- Resource sharing
- Cache utilization
- Checkpoint optimization
- Recovery planning
- Monitoring integration
- Result synthesis
Dynamic adaptation:
- Performance monitoring
- Bottleneck detection
- Agent reallocation
- Workflow adjustment
- Failure recovery
- Load rebalancing
- Priority shifting
- Resource scaling
Coordination excellence:
- Clear communication
- Efficient handoffs
- Synchronized execution
- Conflict prevention
- Progress tracking
- Result validation
- Knowledge transfer
- Continuous improvement
Learning & improvement:
- Performance analysis
- Pattern recognition
- Best practice extraction
- Failure analysis
- Optimization opportunities
- Team effectiveness
- Workflow refinement
- Knowledge base update
Integration with other agents:
- Collaborate with context-manager on information sharing
- Support multi-agent-coordinator on execution
- Work with task-distributor on load balancing
- Guide workflow-orchestrator on process design
- Help performance-monitor on metrics
- Assist error-coordinator on recovery
- Partner with knowledge-synthesizer on learning
- Coordinate with all agents on task execution
Always prioritize optimal agent selection, efficient coordination, and continuous improvement while orchestrating multi-agent teams that deliver exceptional results through synergistic collaboration.
+286
View File
@@ -0,0 +1,286 @@
---
name: multi-agent-coordinator
description: Use when coordinating multiple concurrent agents that need to communicate, share state, synchronize work, and handle distributed failures across a system.
tools: Read, Write, Edit, Glob, Grep
model: opus
---
You are a senior multi-agent coordinator with expertise in orchestrating complex distributed workflows. Your focus spans inter-agent communication, task dependency management, parallel execution control, and fault tolerance with emphasis on ensuring efficient, reliable coordination across large agent teams.
When invoked:
1. Query context manager for workflow requirements and agent states
2. Review communication patterns, dependencies, and resource constraints
3. Analyze coordination bottlenecks, deadlock risks, and optimization opportunities
4. Implement robust multi-agent coordination strategies
Multi-agent coordination checklist:
- Coordination overhead < 5% maintained
- Deadlock prevention 100% ensured
- Message delivery guaranteed thoroughly
- Scalability to 100+ agents verified
- Fault tolerance built-in properly
- Monitoring comprehensive continuously
- Recovery automated effectively
- Performance optimal consistently
Workflow orchestration:
- Process design
- Flow control
- State management
- Checkpoint handling
- Rollback procedures
- Compensation logic
- Event coordination
- Result aggregation
Inter-agent communication:
- Protocol design
- Message routing
- Channel management
- Broadcast strategies
- Request-reply patterns
- Event streaming
- Queue management
- Backpressure handling
Dependency management:
- Dependency graphs
- Topological sorting
- Circular detection
- Resource locking
- Priority scheduling
- Constraint solving
- Deadlock prevention
- Race condition handling
Coordination patterns:
- Master-worker
- Peer-to-peer
- Hierarchical
- Publish-subscribe
- Request-reply
- Pipeline
- Scatter-gather
- Consensus-based
Parallel execution:
- Task partitioning
- Work distribution
- Load balancing
- Synchronization points
- Barrier coordination
- Fork-join patterns
- Map-reduce workflows
- Result merging
Communication mechanisms:
- Message passing
- Shared memory
- Event streams
- RPC calls
- WebSocket connections
- REST APIs
- GraphQL subscriptions
- Queue systems
Resource coordination:
- Resource allocation
- Lock management
- Semaphore control
- Quota enforcement
- Priority handling
- Fair scheduling
- Starvation prevention
- Efficiency optimization
Fault tolerance:
- Failure detection
- Timeout handling
- Retry mechanisms
- Circuit breakers
- Fallback strategies
- State recovery
- Checkpoint restoration
- Graceful degradation
Workflow management:
- DAG execution
- State machines
- Saga patterns
- Compensation logic
- Checkpoint/restart
- Dynamic workflows
- Conditional branching
- Loop handling
Performance optimization:
- Bottleneck analysis
- Pipeline optimization
- Batch processing
- Caching strategies
- Connection pooling
- Message compression
- Latency reduction
- Throughput maximization
## Communication Protocol
### Coordination Context Assessment
Initialize multi-agent coordination by understanding workflow needs.
Coordination context query:
```json
{
"requesting_agent": "multi-agent-coordinator",
"request_type": "get_coordination_context",
"payload": {
"query": "Coordination context needed: workflow complexity, agent count, communication patterns, performance requirements, and fault tolerance needs."
}
}
```
## Development Workflow
Execute multi-agent coordination through systematic phases:
### 1. Workflow Analysis
Design efficient coordination strategies.
Analysis priorities:
- Workflow mapping
- Agent capabilities
- Communication needs
- Dependency analysis
- Resource requirements
- Performance targets
- Risk assessment
- Optimization opportunities
Workflow evaluation:
- Map processes
- Identify dependencies
- Analyze communication
- Assess parallelism
- Plan synchronization
- Design recovery
- Document patterns
- Validate approach
### 2. Implementation Phase
Orchestrate complex multi-agent workflows.
Implementation approach:
- Setup communication
- Configure workflows
- Manage dependencies
- Control execution
- Monitor progress
- Handle failures
- Coordinate results
- Optimize performance
Coordination patterns:
- Efficient messaging
- Clear dependencies
- Parallel execution
- Fault tolerance
- Resource efficiency
- Progress tracking
- Result validation
- Continuous optimization
Progress tracking:
```json
{
"agent": "multi-agent-coordinator",
"status": "coordinating",
"progress": {
"active_agents": 87,
"messages_processed": "234K/min",
"workflow_completion": "94%",
"coordination_efficiency": "96%"
}
}
```
### 3. Coordination Excellence
Achieve seamless multi-agent collaboration.
Excellence checklist:
- Workflows smooth
- Communication efficient
- Dependencies resolved
- Failures handled
- Performance optimal
- Scaling proven
- Monitoring active
- Value delivered
Delivery notification:
"Multi-agent coordination completed. Orchestrated 87 agents processing 234K messages/minute with 94% workflow completion rate. Achieved 96% coordination efficiency with zero deadlocks and 99.9% message delivery guarantee."
Communication optimization:
- Protocol efficiency
- Message batching
- Compression strategies
- Route optimization
- Connection pooling
- Async patterns
- Event streaming
- Queue management
Dependency resolution:
- Graph algorithms
- Priority scheduling
- Resource allocation
- Lock optimization
- Conflict resolution
- Parallel planning
- Critical path analysis
- Bottleneck removal
Fault handling:
- Failure detection
- Isolation strategies
- Recovery procedures
- State restoration
- Compensation execution
- Retry policies
- Timeout management
- Graceful degradation
Scalability patterns:
- Horizontal scaling
- Vertical partitioning
- Load distribution
- Connection management
- Resource pooling
- Batch optimization
- Pipeline design
- Cluster coordination
Performance tuning:
- Latency analysis
- Throughput optimization
- Resource utilization
- Cache effectiveness
- Network efficiency
- CPU optimization
- Memory management
- I/O optimization
Integration with other agents:
- Collaborate with agent-organizer on team assembly
- Support context-manager on state synchronization
- Work with workflow-orchestrator on process execution
- Guide task-distributor on work allocation
- Help performance-monitor on metrics collection
- Assist error-coordinator on failure handling
- Partner with knowledge-synthesizer on patterns
- Coordinate with all agents on communication
Always prioritize efficiency, reliability, and scalability while coordinating multi-agent systems that deliver exceptional performance through seamless collaboration.
+8
View File
@@ -0,0 +1,8 @@
{
"enabledMcpjsonServers": [
"github",
"kubernetes",
"flux",
"playwright"
]
}
+10 -6
View File
@@ -5,10 +5,11 @@ on:
branches: [main] branches: [main]
pull_request: pull_request:
branches: [main] branches: [main]
workflow_call:
jobs: jobs:
lint-and-test: ci:
runs-on: ubuntu-latest runs-on: local-ubuntu-latest
timeout-minutes: 10 timeout-minutes: 10
steps: steps:
@@ -18,7 +19,7 @@ jobs:
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: '20' node-version: '22'
cache: 'npm' cache: 'npm'
- name: Install dependencies - name: Install dependencies
@@ -28,10 +29,13 @@ jobs:
run: npx @kinvolk/headlamp-plugin build run: npx @kinvolk/headlamp-plugin build
- name: Lint - name: Lint
run: npx eslint --ext .ts,.tsx src/ run: npm run lint
- name: Type-check - name: Type-check
run: npx tsc --noEmit run: npm run tsc
- name: Run unit tests - name: Format check
run: npm run format:check
- name: Run tests
run: npm test run: npm test
+52 -56
View File
@@ -4,50 +4,58 @@ on:
workflow_dispatch: workflow_dispatch:
inputs: inputs:
version: version:
description: 'Version to release (without v prefix, e.g., 0.2.0)' description: 'Release version (e.g. 1.0.0)'
required: true required: true
type: string type: string
permissions:
contents: write
concurrency:
group: release
cancel-in-progress: false
jobs: jobs:
ci:
uses: ./.github/workflows/ci.yaml
release: release:
runs-on: ubuntu-latest needs: ci
permissions: runs-on: local-ubuntu-latest
contents: write timeout-minutes: 10
steps: steps:
- name: Validate version format - name: Validate version format
run: | run: |
if ! echo "${{ inputs.version }}" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$'; then if [[ ! "${{ inputs.version }}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "::error::Version must be in format X.Y.Z (e.g., 0.2.0)" echo "Error: Version must be in X.Y.Z format"
exit 1 exit 1
fi fi
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Configure git - name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'npm'
- name: Configure Git
run: | run: |
git config user.name "github-actions[bot]" git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com" git config user.email "github-actions[bot]@users.noreply.github.com"
- name: Update package.json version - name: Update version in package.json
run: | run: npm version ${{ inputs.version }} --no-git-tag-version
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 - name: Update artifacthub-pkg.yml
run: | run: |
VERSION="${{ inputs.version }}" VERSION="${{ inputs.version }}"
RELEASE_URL="https://github.com/${{ github.repository }}/releases/download/v${VERSION}/headlamp-rook-plugin-${VERSION}.tar.gz" RELEASE_URL="https://github.com/${{ github.repository }}/releases/download/v${VERSION}/headlamp-rook-plugin-${VERSION}.tar.gz"
sed -i "s/^version:.*/version: \"${VERSION}\"/" artifacthub-pkg.yml
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 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 - name: Install dependencies
run: npm ci run: npm ci
@@ -57,55 +65,43 @@ jobs:
- name: Package plugin - name: Package plugin
run: npx @kinvolk/headlamp-plugin package run: npx @kinvolk/headlamp-plugin package
- name: Validate tarball name - name: Prepare release tarball
run: | run: |
EXPECTED="headlamp-rook-plugin-${{ inputs.version }}.tar.gz" VERSION="${{ inputs.version }}"
ACTUAL=$(ls *.tar.gz) TARBALL="headlamp-rook-plugin-${VERSION}.tar.gz"
if [ "$EXPECTED" != "$ACTUAL" ]; then GENERATED=$(ls *.tar.gz)
echo "::error::Tarball name mismatch! Expected: $EXPECTED, Got: $ACTUAL" if [ "$GENERATED" != "$TARBALL" ]; then
exit 1 mv "$GENERATED" "$TARBALL"
fi fi
echo "✓ Tarball name validated: $ACTUAL" echo "TARBALL=$TARBALL" >> $GITHUB_ENV
- name: Validate tarball
run: |
echo "Tarball: ${{ env.TARBALL }}"
ls -lh "${{ env.TARBALL }}"
tar -tzf "${{ env.TARBALL }}" | head -20
tar -tzf "${{ env.TARBALL }}" | grep -q "main.js" || { echo "Error: main.js not found in tarball"; exit 1; }
- name: Compute checksum - name: Compute checksum
id: compute_checksum
run: | run: |
TARBALL="headlamp-rook-plugin-${{ inputs.version }}.tar.gz" CHECKSUM=$(sha256sum "${{ env.TARBALL }}" | awk '{print $1}')
CHECKSUM=$(sha256sum "$TARBALL" | awk '{print $1}') echo "CHECKSUM=$CHECKSUM" >> $GITHUB_ENV
echo "checksum=${CHECKSUM}" >> $GITHUB_OUTPUT sed -i "s|headlamp/plugin/archive-checksum:.*|headlamp/plugin/archive-checksum: sha256:${CHECKSUM}|" artifacthub-pkg.yml
echo "Checksum: sha256:${CHECKSUM}"
- name: Update checksum in metadata - name: Commit and tag
run: | run: |
CHECKSUM="${{ steps.compute_checksum.outputs.checksum }}" VERSION="${{ inputs.version }}"
sed -i "s|headlamp/plugin/archive-checksum:.*|headlamp/plugin/archive-checksum: \"sha256:${CHECKSUM}\"|" artifacthub-pkg.yml git add package.json package-lock.json artifacthub-pkg.yml
git commit -m "release: v${VERSION}"
- name: Commit version bump and metadata git tag "v${VERSION}"
run: | git push origin main --tags
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 - name: Create GitHub Release
uses: softprops/action-gh-release@v2 uses: softprops/action-gh-release@v2
with: with:
tag_name: "v${{ inputs.version }}" tag_name: "v${{ inputs.version }}"
files: headlamp-rook-plugin-${{ inputs.version }}.tar.gz files: ${{ env.TARBALL }}
fail_on_unmatched_files: true fail_on_unmatched_files: true
draft: false
prerelease: false
generate_release_notes: true generate_release_notes: true
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 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"
+4 -1
View File
@@ -1,5 +1,8 @@
node_modules/ node_modules/
dist/ dist/
.headlamp-plugin/
*.tar.gz *.tar.gz
.env
.env.local
.eslintcache
.playwright-mcp/ .playwright-mcp/
.mcp.json
+23
View File
@@ -0,0 +1,23 @@
{
"mcpServers": {
"github": {
"type": "http",
"url": "https://api.githubcopilot.com/mcp/",
"headers": {
"Authorization": "Bearer ${GITHUB_TOKEN}"
}
},
"kubernetes": {
"type": "sse",
"url": "http://localhost:8080/sse"
},
"flux": {
"type": "sse",
"url": "http://localhost:8081/sse"
},
"playwright": {
"type": "sse",
"url": "http://localhost:8086/sse"
}
}
}
+1
View File
@@ -0,0 +1 @@
module.exports = require('@headlamp-k8s/eslint-config/prettier-config');
+2
View File
@@ -43,6 +43,8 @@ src/
├── StorageClassesPage.tsx ├── StorageClassesPage.tsx
├── VolumesPage.tsx ├── VolumesPage.tsx
├── PodsPage.tsx ├── PodsPage.tsx
├── FilesystemsPage.tsx
├── ObjectStoresPage.tsx
├── ClusterStatusCard.tsx ├── ClusterStatusCard.tsx
├── AppBarClusterBadge.tsx ├── AppBarClusterBadge.tsx
├── PVCDetailSection.tsx # Injected into Headlamp PVC detail view ├── PVCDetailSection.tsx # Injected into Headlamp PVC detail view
+16 -5
View File
@@ -1,5 +1,6 @@
# Headlamp Rook Plugin # Headlamp Rook Plugin
[![Artifact Hub](https://img.shields.io/endpoint?url=https://artifacthub.io/badge/package/headlamp/rook/headlamp-rook-plugin)](https://artifacthub.io/packages/headlamp/rook/headlamp-rook-plugin)
[![CI](https://github.com/cpfarhood/headlamp-rook-plugin/actions/workflows/ci.yaml/badge.svg)](https://github.com/cpfarhood/headlamp-rook-plugin/actions/workflows/ci.yaml) [![CI](https://github.com/cpfarhood/headlamp-rook-plugin/actions/workflows/ci.yaml/badge.svg)](https://github.com/cpfarhood/headlamp-rook-plugin/actions/workflows/ci.yaml)
[![License: Apache-2.0](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](https://opensource.org/licenses/Apache-2.0) [![License: Apache-2.0](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](https://opensource.org/licenses/Apache-2.0)
@@ -47,7 +48,11 @@ Rook-Ceph must be deployed in the `rook-ceph` namespace with standard labels. Th
## Installing ## Installing
### Option 1: Manual Plugin Install ### Option 1: Headlamp Plugin Manager (Recommended)
Browse the Headlamp Plugin Manager (Settings → Plugins → Catalog) and install **headlamp-rook-plugin** directly.
### Option 2: Manual Plugin Install
Download the latest release tarball and place it in your Headlamp plugins directory: Download the latest release tarball and place it in your Headlamp plugins directory:
@@ -60,10 +65,6 @@ curl -L https://github.com/cpfarhood/headlamp-rook-plugin/releases/latest/downlo
tar -xzf headlamp-rook-plugin.tar.gz -C ~/.config/Headlamp/plugins/ tar -xzf headlamp-rook-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-plugin** directly.
## RBAC & Security Setup ## RBAC & Security Setup
The plugin reads Rook-Ceph CRDs and Kubernetes resources. Your Headlamp service account needs: The plugin reads Rook-Ceph CRDs and Kubernetes resources. Your Headlamp service account needs:
@@ -107,6 +108,16 @@ subjects:
namespace: headlamp namespace: headlamp
``` ```
## Troubleshooting
| Symptom | Likely Cause | Quick Fix |
| ------- | ------------ | --------- |
| **Plugin not in sidebar** | Plugin not installed or needs browser refresh | Hard refresh (Cmd+Shift+R / Ctrl+Shift+F5) |
| **No CephCluster data** | CRDs not installed or RBAC insufficient | Verify `kubectl get cephclusters -n rook-ceph` works |
| **Block Pools empty** | No CephBlockPool resources | Check `kubectl get cephblockpools -n rook-ceph` |
| **App bar badge missing** | No CephCluster present | Verify rook-ceph is deployed with a CephCluster resource |
| **StorageClass columns not showing** | Rook provisioner not matching | Verify SC provisioner ends in `.rbd.csi.ceph.com` or `.cephfs.csi.ceph.com` |
## Development ## Development
### Prerequisites ### Prerequisites
+3 -3
View File
@@ -1,4 +1,4 @@
version: "0.2.2" version: "0.2.4"
name: headlamp-rook-plugin name: headlamp-rook-plugin
displayName: Rook Plugin displayName: Rook Plugin
createdAt: "2026-02-18T00:00:00Z" createdAt: "2026-02-18T00:00:00Z"
@@ -27,7 +27,7 @@ changes:
description: "Package renamed to rook so the plugin displays correctly in Headlamp's Plugins list" description: "Package renamed to rook so the plugin displays correctly in Headlamp's Plugins list"
annotations: annotations:
headlamp/plugin/archive-url: "https://github.com/privilegedescalation/headlamp-rook-plugin/releases/download/v0.2.2/rook-0.2.2.tar.gz" headlamp/plugin/archive-url: "https://github.com/privilegedescalation/headlamp-rook-plugin/releases/download/v0.2.4/headlamp-rook-plugin-0.2.4.tar.gz"
headlamp/plugin/archive-checksum: "sha256:0c0d39f275f206cd0fe66a17f98c9bf0b9a5d74122bad770c7d4051e59f85675" headlamp/plugin/archive-checksum: sha256:0dd88eecd784bc70557bb4c7ce5eede50fe83944990bc881bbb17313588c79f2
headlamp/plugin/distro-compat: "" headlamp/plugin/distro-compat: ""
headlamp/plugin/version-compat: ">=0.20" headlamp/plugin/version-compat: ">=0.20"
+4 -4
View File
@@ -1,12 +1,12 @@
{ {
"name": "headlamp-rook-plugin", "name": "rook",
"version": "0.1.0", "version": "0.2.4",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "headlamp-rook-plugin", "name": "rook",
"version": "0.1.0", "version": "0.2.4",
"license": "Apache-2.0", "license": "Apache-2.0",
"devDependencies": { "devDependencies": {
"@kinvolk/headlamp-plugin": "^0.13.0" "@kinvolk/headlamp-plugin": "^0.13.0"
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "rook", "name": "rook",
"version": "0.2.2", "version": "0.2.4",
"description": "Headlamp plugin for Rook-Ceph cluster visibility and CSI driver monitoring", "description": "Headlamp plugin for Rook-Ceph cluster visibility and CSI driver monitoring",
"repository": { "repository": {
"type": "git", "type": "git",
+4
View File
@@ -0,0 +1,4 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": ["config:recommended"]
}
+25 -9
View File
@@ -166,7 +166,9 @@ export function RookCephDataProvider({ children }: { children: React.ReactNode }
// Operator pods // Operator pods
try { try {
const opList = await ApiProxy.request( const opList = await ApiProxy.request(
`/api/v1/namespaces/${ROOK_CEPH_NAMESPACE}/pods?labelSelector=${encodeURIComponent(ROOK_OPERATOR_SELECTOR)}` `/api/v1/namespaces/${ROOK_CEPH_NAMESPACE}/pods?labelSelector=${encodeURIComponent(
ROOK_OPERATOR_SELECTOR
)}`
); );
if (!cancelled && isKubeList(opList)) setOperatorPods(opList.items as RookCephPod[]); if (!cancelled && isKubeList(opList)) setOperatorPods(opList.items as RookCephPod[]);
} catch { } catch {
@@ -176,7 +178,9 @@ export function RookCephDataProvider({ children }: { children: React.ReactNode }
// MON pods // MON pods
try { try {
const monList = await ApiProxy.request( const monList = await ApiProxy.request(
`/api/v1/namespaces/${ROOK_CEPH_NAMESPACE}/pods?labelSelector=${encodeURIComponent(ROOK_MON_SELECTOR)}` `/api/v1/namespaces/${ROOK_CEPH_NAMESPACE}/pods?labelSelector=${encodeURIComponent(
ROOK_MON_SELECTOR
)}`
); );
if (!cancelled && isKubeList(monList)) setMonPods(monList.items as RookCephPod[]); if (!cancelled && isKubeList(monList)) setMonPods(monList.items as RookCephPod[]);
} catch { } catch {
@@ -186,7 +190,9 @@ export function RookCephDataProvider({ children }: { children: React.ReactNode }
// OSD pods // OSD pods
try { try {
const osdList = await ApiProxy.request( const osdList = await ApiProxy.request(
`/api/v1/namespaces/${ROOK_CEPH_NAMESPACE}/pods?labelSelector=${encodeURIComponent(ROOK_OSD_SELECTOR)}` `/api/v1/namespaces/${ROOK_CEPH_NAMESPACE}/pods?labelSelector=${encodeURIComponent(
ROOK_OSD_SELECTOR
)}`
); );
if (!cancelled && isKubeList(osdList)) setOsdPods(osdList.items as RookCephPod[]); if (!cancelled && isKubeList(osdList)) setOsdPods(osdList.items as RookCephPod[]);
} catch { } catch {
@@ -196,7 +202,9 @@ export function RookCephDataProvider({ children }: { children: React.ReactNode }
// MGR pods // MGR pods
try { try {
const mgrList = await ApiProxy.request( const mgrList = await ApiProxy.request(
`/api/v1/namespaces/${ROOK_CEPH_NAMESPACE}/pods?labelSelector=${encodeURIComponent(ROOK_MGR_SELECTOR)}` `/api/v1/namespaces/${ROOK_CEPH_NAMESPACE}/pods?labelSelector=${encodeURIComponent(
ROOK_MGR_SELECTOR
)}`
); );
if (!cancelled && isKubeList(mgrList)) setMgrPods(mgrList.items as RookCephPod[]); if (!cancelled && isKubeList(mgrList)) setMgrPods(mgrList.items as RookCephPod[]);
} catch { } catch {
@@ -206,9 +214,12 @@ export function RookCephDataProvider({ children }: { children: React.ReactNode }
// CSI RBD provisioner pods // CSI RBD provisioner pods
try { try {
const csiRbdList = await ApiProxy.request( const csiRbdList = await ApiProxy.request(
`/api/v1/namespaces/${ROOK_CEPH_NAMESPACE}/pods?labelSelector=${encodeURIComponent(ROOK_CSI_RBD_SELECTOR)}` `/api/v1/namespaces/${ROOK_CEPH_NAMESPACE}/pods?labelSelector=${encodeURIComponent(
ROOK_CSI_RBD_SELECTOR
)}`
); );
if (!cancelled && isKubeList(csiRbdList)) setCsiRbdPods(csiRbdList.items as RookCephPod[]); if (!cancelled && isKubeList(csiRbdList))
setCsiRbdPods(csiRbdList.items as RookCephPod[]);
} catch { } catch {
if (!cancelled) setCsiRbdPods([]); if (!cancelled) setCsiRbdPods([]);
} }
@@ -216,9 +227,12 @@ export function RookCephDataProvider({ children }: { children: React.ReactNode }
// CSI CephFS provisioner pods // CSI CephFS provisioner pods
try { try {
const csiCephfsList = await ApiProxy.request( const csiCephfsList = await ApiProxy.request(
`/api/v1/namespaces/${ROOK_CEPH_NAMESPACE}/pods?labelSelector=${encodeURIComponent(ROOK_CSI_CEPHFS_SELECTOR)}` `/api/v1/namespaces/${ROOK_CEPH_NAMESPACE}/pods?labelSelector=${encodeURIComponent(
ROOK_CSI_CEPHFS_SELECTOR
)}`
); );
if (!cancelled && isKubeList(csiCephfsList)) setCsiCephfsPods(csiCephfsList.items as RookCephPod[]); if (!cancelled && isKubeList(csiCephfsList))
setCsiCephfsPods(csiCephfsList.items as RookCephPod[]);
} catch { } catch {
if (!cancelled) setCsiCephfsPods([]); if (!cancelled) setCsiCephfsPods([]);
} }
@@ -232,7 +246,9 @@ export function RookCephDataProvider({ children }: { children: React.ReactNode }
} }
void fetchAsync(); void fetchAsync();
return () => { cancelled = true; }; return () => {
cancelled = true;
};
}, [refreshKey]); }, [refreshKey]);
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
+25 -20
View File
@@ -129,9 +129,12 @@ export interface CephCluster extends KubeObject {
export function healthToStatus(health: string | undefined): 'success' | 'warning' | 'error' { export function healthToStatus(health: string | undefined): 'success' | 'warning' | 'error' {
switch (health) { switch (health) {
case 'HEALTH_OK': return 'success'; case 'HEALTH_OK':
case 'HEALTH_WARN': return 'warning'; return 'success';
default: return 'error'; case 'HEALTH_WARN':
return 'warning';
default:
return 'error';
} }
} }
@@ -331,9 +334,7 @@ export function findBoundPv(
): RookCephPersistentVolume | undefined { ): RookCephPersistentVolume | undefined {
const ns = pvc.metadata.namespace ?? ''; const ns = pvc.metadata.namespace ?? '';
const name = pvc.metadata.name; const name = pvc.metadata.name;
return rookPvs.find( return rookPvs.find(pv => pv.spec.claimRef?.namespace === ns && pv.spec.claimRef?.name === name);
pv => pv.spec.claimRef?.namespace === ns && pv.spec.claimRef?.name === name
);
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -368,15 +369,11 @@ export interface RookCephPod extends KubeObject {
} }
export function isPodReady(pod: RookCephPod): boolean { export function isPodReady(pod: RookCephPod): boolean {
return ( return pod.status?.conditions?.some(c => c.type === 'Ready' && c.status === 'True') ?? false;
pod.status?.conditions?.some(c => c.type === 'Ready' && c.status === 'True') ?? false
);
} }
export function getPodRestarts(pod: RookCephPod): number { export function getPodRestarts(pod: RookCephPod): number {
return ( return pod.status?.containerStatuses?.reduce((sum, c) => sum + c.restartCount, 0) ?? 0;
pod.status?.containerStatuses?.reduce((sum, c) => sum + c.restartCount, 0) ?? 0
);
} }
export function getPodImage(pod: RookCephPod): string { export function getPodImage(pod: RookCephPod): string {
@@ -441,11 +438,16 @@ export function parseStorageToBytes(storage: string): number {
const suffix = match[2] ?? ''; const suffix = match[2] ?? '';
const multipliers: Record<string, number> = { const multipliers: Record<string, number> = {
'': 1, '': 1,
K: 1e3, Ki: 1024, K: 1e3,
M: 1e6, Mi: 1024 ** 2, Ki: 1024,
G: 1e9, Gi: 1024 ** 3, M: 1e6,
T: 1e12, Ti: 1024 ** 4, Mi: 1024 ** 2,
P: 1e15, Pi: 1024 ** 5, G: 1e9,
Gi: 1024 ** 3,
T: 1e12,
Ti: 1024 ** 4,
P: 1e15,
Pi: 1024 ** 5,
}; };
return value * (multipliers[suffix] ?? 1); return value * (multipliers[suffix] ?? 1);
} }
@@ -453,9 +455,12 @@ export function parseStorageToBytes(storage: string): number {
/** Returns display label for storage type (rbd → Block, cephfs → Filesystem). */ /** Returns display label for storage type (rbd → Block, cephfs → Filesystem). */
export function formatStorageType(type: 'rbd' | 'cephfs' | 'unknown'): string { export function formatStorageType(type: 'rbd' | 'cephfs' | 'unknown'): string {
switch (type) { switch (type) {
case 'rbd': return 'Block (RBD)'; case 'rbd':
case 'cephfs': return 'Filesystem (CephFS)'; return 'Block (RBD)';
default: return 'Unknown'; case 'cephfs':
return 'Filesystem (CephFS)';
default:
return 'Unknown';
} }
} }
+8 -4
View File
@@ -14,10 +14,14 @@ import { useRookCephContext } from '../api/RookCephDataContext';
function getHealthColor(health: string | undefined): string { function getHealthColor(health: string | undefined): string {
switch (health) { switch (health) {
case 'HEALTH_OK': return '#4caf50'; case 'HEALTH_OK':
case 'HEALTH_WARN': return '#ff9800'; return '#4caf50';
case 'HEALTH_ERR': return '#f44336'; case 'HEALTH_WARN':
default: return '#9e9e9e'; return '#ff9800';
case 'HEALTH_ERR':
return '#f44336';
default:
return '#9e9e9e';
} }
} }
+49 -10
View File
@@ -19,7 +19,10 @@ function BlockPoolDetail({ pool, onClose }: { pool: CephBlockPool; onClose: () =
<div <div
style={{ style={{
position: 'fixed', position: 'fixed',
top: 0, right: 0, bottom: 0, width: '480px', top: 0,
right: 0,
bottom: 0,
width: '480px',
backgroundColor: 'var(--mui-palette-background-paper, #fff)', backgroundColor: 'var(--mui-palette-background-paper, #fff)',
boxShadow: '-4px 0 16px rgba(0,0,0,0.15)', boxShadow: '-4px 0 16px rgba(0,0,0,0.15)',
zIndex: 1300, zIndex: 1300,
@@ -27,7 +30,14 @@ function BlockPoolDetail({ pool, onClose }: { pool: CephBlockPool; onClose: () =
padding: '24px', padding: '24px',
}} }}
> >
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '16px' }}> <div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '16px',
}}
>
<strong>{pool.metadata.name}</strong> <strong>{pool.metadata.name}</strong>
<button <button
onClick={onClose} onClick={onClose}
@@ -99,14 +109,18 @@ export default function BlockPoolsPage() {
{error && ( {error && (
<SectionBox title="Error"> <SectionBox title="Error">
<NameValueTable rows={[{ name: 'Status', value: <StatusLabel status="error">{error}</StatusLabel> }]} /> <NameValueTable
rows={[{ name: 'Status', value: <StatusLabel status="error">{error}</StatusLabel> }]}
/>
</SectionBox> </SectionBox>
)} )}
{blockPools.length === 0 ? ( {blockPools.length === 0 ? (
<SectionBox title="No Block Pools"> <SectionBox title="No Block Pools">
<NameValueTable <NameValueTable
rows={[{ name: 'Status', value: 'No CephBlockPool resources found in rook-ceph namespace.' }]} rows={[
{ name: 'Status', value: 'No CephBlockPool resources found in rook-ceph namespace.' },
]}
/> />
</SectionBox> </SectionBox>
) : ( ) : (
@@ -118,7 +132,15 @@ export default function BlockPoolsPage() {
getter: (p: CephBlockPool) => ( getter: (p: CephBlockPool) => (
<button <button
onClick={() => setSelected(p)} onClick={() => setSelected(p)}
style={{ border: 'none', background: 'transparent', color: 'var(--link-color, #1976d2)', cursor: 'pointer', textDecoration: 'underline', padding: 0, font: 'inherit' }} style={{
border: 'none',
background: 'transparent',
color: 'var(--link-color, #1976d2)',
cursor: 'pointer',
textDecoration: 'underline',
padding: 0,
font: 'inherit',
}}
> >
{p.metadata.name} {p.metadata.name}
</button> </button>
@@ -132,10 +154,22 @@ export default function BlockPoolsPage() {
</StatusLabel> </StatusLabel>
), ),
}, },
{ label: 'Replicas', getter: (p: CephBlockPool) => String(p.spec?.replicated?.size ?? '—') }, {
{ label: 'Failure Domain', getter: (p: CephBlockPool) => p.spec?.failureDomain ?? '—' }, label: 'Replicas',
{ label: 'Mirroring', getter: (p: CephBlockPool) => p.spec?.mirroring?.enabled ? 'Enabled' : 'Disabled' }, getter: (p: CephBlockPool) => String(p.spec?.replicated?.size ?? '—'),
{ label: 'Age', getter: (p: CephBlockPool) => formatAge(p.metadata.creationTimestamp) }, },
{
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} data={blockPools}
/> />
@@ -145,7 +179,12 @@ export default function BlockPoolsPage() {
{selected && ( {selected && (
<> <>
<div <div
style={{ position: 'fixed', inset: 0, backgroundColor: 'rgba(0,0,0,0.3)', zIndex: 1299 }} style={{
position: 'fixed',
inset: 0,
backgroundColor: 'rgba(0,0,0,0.3)',
zIndex: 1299,
}}
onClick={() => setSelected(null)} onClick={() => setSelected(null)}
/> />
<BlockPoolDetail pool={selected} onClose={() => setSelected(null)} /> <BlockPoolDetail pool={selected} onClose={() => setSelected(null)} />
+7 -10
View File
@@ -80,16 +80,17 @@ export default function CephPodDetailSection({ resource }: CephPodDetailSectionP
const role = ROLE_LABELS[appLabel] ?? appLabel; const role = ROLE_LABELS[appLabel] ?? appLabel;
const phase = raw.status?.phase ?? 'Unknown'; const phase = raw.status?.phase ?? 'Unknown';
const isReady = const isReady =
raw.status?.conditions?.some((c) => c.type === 'Ready' && c.status === 'True') ?? false; raw.status?.conditions?.some(c => c.type === 'Ready' && c.status === 'True') ?? false;
const restarts = const restarts = raw.status?.containerStatuses?.reduce((s, c) => s + c.restartCount, 0) ?? 0;
raw.status?.containerStatuses?.reduce((s, c) => s + c.restartCount, 0) ?? 0;
const containerRows = (raw.status?.containerStatuses ?? []).map((cs) => { const containerRows = (raw.status?.containerStatuses ?? []).map(cs => {
let stateStr = 'Unknown'; let stateStr = 'Unknown';
if (cs.state?.running) stateStr = 'Running'; if (cs.state?.running) stateStr = 'Running';
else if (cs.state?.waiting) stateStr = `Waiting: ${cs.state.waiting.reason ?? ''}`; else if (cs.state?.waiting) stateStr = `Waiting: ${cs.state.waiting.reason ?? ''}`;
else if (cs.state?.terminated) else if (cs.state?.terminated)
stateStr = `Terminated: ${cs.state.terminated.reason ?? ''} (exit ${cs.state.terminated.exitCode ?? ''})`; stateStr = `Terminated: ${cs.state.terminated.reason ?? ''} (exit ${
cs.state.terminated.exitCode ?? ''
})`;
return { return {
name: cs.name, name: cs.name,
@@ -111,11 +112,7 @@ export default function CephPodDetailSection({ resource }: CephPodDetailSectionP
}, },
{ {
name: 'Phase', name: 'Phase',
value: ( value: <StatusLabel status={isReady ? 'success' : 'error'}>{phase}</StatusLabel>,
<StatusLabel status={isReady ? 'success' : 'error'}>
{phase}
</StatusLabel>
),
}, },
{ name: 'Node', value: raw.spec?.nodeName ?? '—' }, { name: 'Node', value: raw.spec?.nodeName ?? '—' },
{ name: 'Restarts', value: String(restarts) }, { name: 'Restarts', value: String(restarts) },
+24 -13
View File
@@ -11,7 +11,15 @@ import {
} from '@kinvolk/headlamp-plugin/lib/CommonComponents'; } from '@kinvolk/headlamp-plugin/lib/CommonComponents';
import React from 'react'; import React from 'react';
import type { CephCluster, RookCephPod } from '../api/k8s'; import type { CephCluster, RookCephPod } from '../api/k8s';
import { formatAge, formatBytes, getPodImage, getPodRestarts, healthToStatus, isPodReady, phaseToStatus } from '../api/k8s'; import {
formatAge,
formatBytes,
getPodImage,
getPodRestarts,
healthToStatus,
isPodReady,
phaseToStatus,
} from '../api/k8s';
interface ClusterStatusCardProps { interface ClusterStatusCardProps {
cephClusters: CephCluster[]; cephClusters: CephCluster[];
@@ -26,17 +34,14 @@ interface ClusterStatusCardProps {
function PodStatusBadge({ pod }: { pod: RookCephPod }) { function PodStatusBadge({ pod }: { pod: RookCephPod }) {
const ready = isPodReady(pod); const ready = isPodReady(pod);
const phase = pod.status?.phase ?? 'Unknown'; const phase = pod.status?.phase ?? 'Unknown';
return ( return <StatusLabel status={ready ? 'success' : 'error'}>{phase}</StatusLabel>;
<StatusLabel status={ready ? 'success' : 'error'}>
{phase}
</StatusLabel>
);
} }
function PodSummaryRow({ pods, label }: { pods: RookCephPod[]; label: string }) { function PodSummaryRow({ pods, label }: { pods: RookCephPod[]; label: string }) {
const ready = pods.filter(isPodReady).length; const ready = pods.filter(isPodReady).length;
const total = pods.length; const total = pods.length;
const status = total === 0 ? 'error' : ready === total ? 'success' : ready > 0 ? 'warning' : 'error'; const status =
total === 0 ? 'error' : ready === total ? 'success' : ready > 0 ? 'warning' : 'error';
return { return {
name: label, name: label,
value: ( value: (
@@ -84,12 +89,12 @@ export default function ClusterStatusCard({
{ {
name: 'Phase', name: 'Phase',
value: ( value: (
<StatusLabel status={phaseToStatus(phase)}> <StatusLabel status={phaseToStatus(phase)}>{phase ?? 'Unknown'}</StatusLabel>
{phase ?? 'Unknown'}
</StatusLabel>
), ),
}, },
...(cluster.status?.message ? [{ name: 'Message', value: cluster.status.message }] : []), ...(cluster.status?.message
? [{ name: 'Message', value: cluster.status.message }]
: []),
{ name: 'Ceph Version', value: version }, { name: 'Ceph Version', value: version },
{ name: 'Namespace', value: cluster.metadata.namespace ?? '—' }, { name: 'Namespace', value: cluster.metadata.namespace ?? '—' },
{ name: 'Age', value: formatAge(cluster.metadata.creationTimestamp) }, { name: 'Age', value: formatAge(cluster.metadata.creationTimestamp) },
@@ -102,7 +107,11 @@ export default function ClusterStatusCard({
<div style={{ marginBottom: '12px' }}> <div style={{ marginBottom: '12px' }}>
<PercentageBar <PercentageBar
data={[ data={[
{ name: 'Used', value: bytesUsed, fill: usedPct > 80 ? '#f44336' : '#1976d2' }, {
name: 'Used',
value: bytesUsed,
fill: usedPct > 80 ? '#f44336' : '#1976d2',
},
{ name: 'Free', value: bytesAvail, fill: '#e0e0e0' }, { name: 'Free', value: bytesAvail, fill: '#e0e0e0' },
]} ]}
total={bytesTotal} total={bytesTotal}
@@ -142,7 +151,9 @@ export function PodDetailRows({ pods, label }: { pods: RookCephPod[]; label: str
return ( return (
<SectionBox title={label}> <SectionBox title={label}>
<NameValueTable <NameValueTable
rows={[{ name: 'Status', value: <StatusLabel status="error">No pods found</StatusLabel> }]} rows={[
{ name: 'Status', value: <StatusLabel status="error">No pods found</StatusLabel> },
]}
/> />
</SectionBox> </SectionBox>
); );
+56 -11
View File
@@ -19,7 +19,10 @@ function FilesystemDetail({ fs, onClose }: { fs: CephFilesystem; onClose: () =>
<div <div
style={{ style={{
position: 'fixed', position: 'fixed',
top: 0, right: 0, bottom: 0, width: '480px', top: 0,
right: 0,
bottom: 0,
width: '480px',
backgroundColor: 'var(--mui-palette-background-paper, #fff)', backgroundColor: 'var(--mui-palette-background-paper, #fff)',
boxShadow: '-4px 0 16px rgba(0,0,0,0.15)', boxShadow: '-4px 0 16px rgba(0,0,0,0.15)',
zIndex: 1300, zIndex: 1300,
@@ -27,7 +30,14 @@ function FilesystemDetail({ fs, onClose }: { fs: CephFilesystem; onClose: () =>
padding: '24px', padding: '24px',
}} }}
> >
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '16px' }}> <div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '16px',
}}
>
<strong>{fs.metadata.name}</strong> <strong>{fs.metadata.name}</strong>
<button <button
onClick={onClose} onClick={onClose}
@@ -58,7 +68,10 @@ function FilesystemDetail({ fs, onClose }: { fs: CephFilesystem; onClose: () =>
<NameValueTable <NameValueTable
rows={[ rows={[
{ name: 'Active Count', value: String(fs.spec?.metadataServer?.activeCount ?? '—') }, { name: 'Active Count', value: String(fs.spec?.metadataServer?.activeCount ?? '—') },
{ name: 'Active Standby', value: String(fs.spec?.metadataServer?.activeStandby ?? '—') }, {
name: 'Active Standby',
value: String(fs.spec?.metadataServer?.activeStandby ?? '—'),
},
]} ]}
/> />
</SectionBox> </SectionBox>
@@ -107,14 +120,21 @@ export default function FilesystemsPage() {
{error && ( {error && (
<SectionBox title="Error"> <SectionBox title="Error">
<NameValueTable rows={[{ name: 'Status', value: <StatusLabel status="error">{error}</StatusLabel> }]} /> <NameValueTable
rows={[{ name: 'Status', value: <StatusLabel status="error">{error}</StatusLabel> }]}
/>
</SectionBox> </SectionBox>
)} )}
{filesystems.length === 0 ? ( {filesystems.length === 0 ? (
<SectionBox title="No Filesystems"> <SectionBox title="No Filesystems">
<NameValueTable <NameValueTable
rows={[{ name: 'Status', value: 'No CephFilesystem resources found in rook-ceph namespace.' }]} rows={[
{
name: 'Status',
value: 'No CephFilesystem resources found in rook-ceph namespace.',
},
]}
/> />
</SectionBox> </SectionBox>
) : ( ) : (
@@ -126,7 +146,15 @@ export default function FilesystemsPage() {
getter: (f: CephFilesystem) => ( getter: (f: CephFilesystem) => (
<button <button
onClick={() => setSelected(f)} onClick={() => setSelected(f)}
style={{ border: 'none', background: 'transparent', color: 'var(--link-color, #1976d2)', cursor: 'pointer', textDecoration: 'underline', padding: 0, font: 'inherit' }} style={{
border: 'none',
background: 'transparent',
color: 'var(--link-color, #1976d2)',
cursor: 'pointer',
textDecoration: 'underline',
padding: 0,
font: 'inherit',
}}
> >
{f.metadata.name} {f.metadata.name}
</button> </button>
@@ -140,10 +168,22 @@ export default function FilesystemsPage() {
</StatusLabel> </StatusLabel>
), ),
}, },
{ label: 'Active MDS', getter: (f: CephFilesystem) => String(f.spec?.metadataServer?.activeCount ?? '—') }, {
{ label: 'Active Standby', getter: (f: CephFilesystem) => String(f.spec?.metadataServer?.activeStandby ?? '—') }, label: 'Active MDS',
{ label: 'Data Pools', getter: (f: CephFilesystem) => String(f.spec?.dataPools?.length ?? 0) }, getter: (f: CephFilesystem) => String(f.spec?.metadataServer?.activeCount ?? '—'),
{ label: 'Age', getter: (f: CephFilesystem) => formatAge(f.metadata.creationTimestamp) }, },
{
label: 'Active Standby',
getter: (f: CephFilesystem) => String(f.spec?.metadataServer?.activeStandby ?? '—'),
},
{
label: 'Data Pools',
getter: (f: CephFilesystem) => String(f.spec?.dataPools?.length ?? 0),
},
{
label: 'Age',
getter: (f: CephFilesystem) => formatAge(f.metadata.creationTimestamp),
},
]} ]}
data={filesystems} data={filesystems}
/> />
@@ -153,7 +193,12 @@ export default function FilesystemsPage() {
{selected && ( {selected && (
<> <>
<div <div
style={{ position: 'fixed', inset: 0, backgroundColor: 'rgba(0,0,0,0.3)', zIndex: 1299 }} style={{
position: 'fixed',
inset: 0,
backgroundColor: 'rgba(0,0,0,0.3)',
zIndex: 1299,
}}
onClick={() => setSelected(null)} onClick={() => setSelected(null)}
/> />
<FilesystemDetail fs={selected} onClose={() => setSelected(null)} /> <FilesystemDetail fs={selected} onClose={() => setSelected(null)} />
+49 -10
View File
@@ -23,7 +23,10 @@ function ObjectStoreDetail({ store, onClose }: { store: CephObjectStore; onClose
<div <div
style={{ style={{
position: 'fixed', position: 'fixed',
top: 0, right: 0, bottom: 0, width: '480px', top: 0,
right: 0,
bottom: 0,
width: '480px',
backgroundColor: 'var(--mui-palette-background-paper, #fff)', backgroundColor: 'var(--mui-palette-background-paper, #fff)',
boxShadow: '-4px 0 16px rgba(0,0,0,0.15)', boxShadow: '-4px 0 16px rgba(0,0,0,0.15)',
zIndex: 1300, zIndex: 1300,
@@ -31,7 +34,14 @@ function ObjectStoreDetail({ store, onClose }: { store: CephObjectStore; onClose
padding: '24px', padding: '24px',
}} }}
> >
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '16px' }}> <div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '16px',
}}
>
<strong>{store.metadata.name}</strong> <strong>{store.metadata.name}</strong>
<button <button
onClick={onClose} onClick={onClose}
@@ -67,7 +77,7 @@ function ObjectStoreDetail({ store, onClose }: { store: CephObjectStore; onClose
]} ]}
/> />
</SectionBox> </SectionBox>
{(endpoints?.insecure?.length || endpoints?.secure?.length) ? ( {endpoints?.insecure?.length || endpoints?.secure?.length ? (
<SectionBox title="Endpoints"> <SectionBox title="Endpoints">
<NameValueTable <NameValueTable
rows={[ rows={[
@@ -104,14 +114,21 @@ export default function ObjectStoresPage() {
{error && ( {error && (
<SectionBox title="Error"> <SectionBox title="Error">
<NameValueTable rows={[{ name: 'Status', value: <StatusLabel status="error">{error}</StatusLabel> }]} /> <NameValueTable
rows={[{ name: 'Status', value: <StatusLabel status="error">{error}</StatusLabel> }]}
/>
</SectionBox> </SectionBox>
)} )}
{objectStores.length === 0 ? ( {objectStores.length === 0 ? (
<SectionBox title="No Object Stores"> <SectionBox title="No Object Stores">
<NameValueTable <NameValueTable
rows={[{ name: 'Status', value: 'No CephObjectStore resources found in rook-ceph namespace.' }]} rows={[
{
name: 'Status',
value: 'No CephObjectStore resources found in rook-ceph namespace.',
},
]}
/> />
</SectionBox> </SectionBox>
) : ( ) : (
@@ -123,7 +140,15 @@ export default function ObjectStoresPage() {
getter: (o: CephObjectStore) => ( getter: (o: CephObjectStore) => (
<button <button
onClick={() => setSelected(o)} onClick={() => setSelected(o)}
style={{ border: 'none', background: 'transparent', color: 'var(--link-color, #1976d2)', cursor: 'pointer', textDecoration: 'underline', padding: 0, font: 'inherit' }} style={{
border: 'none',
background: 'transparent',
color: 'var(--link-color, #1976d2)',
cursor: 'pointer',
textDecoration: 'underline',
padding: 0,
font: 'inherit',
}}
> >
{o.metadata.name} {o.metadata.name}
</button> </button>
@@ -137,9 +162,18 @@ export default function ObjectStoresPage() {
</StatusLabel> </StatusLabel>
), ),
}, },
{ label: 'Gateway Port', getter: (o: CephObjectStore) => String(o.spec?.gateway?.port ?? '—') }, {
{ label: 'Instances', getter: (o: CephObjectStore) => String(o.spec?.gateway?.instances ?? '—') }, label: 'Gateway Port',
{ label: 'Age', getter: (o: CephObjectStore) => formatAge(o.metadata.creationTimestamp) }, getter: (o: CephObjectStore) => String(o.spec?.gateway?.port ?? '—'),
},
{
label: 'Instances',
getter: (o: CephObjectStore) => String(o.spec?.gateway?.instances ?? '—'),
},
{
label: 'Age',
getter: (o: CephObjectStore) => formatAge(o.metadata.creationTimestamp),
},
]} ]}
data={objectStores} data={objectStores}
/> />
@@ -149,7 +183,12 @@ export default function ObjectStoresPage() {
{selected && ( {selected && (
<> <>
<div <div
style={{ position: 'fixed', inset: 0, backgroundColor: 'rgba(0,0,0,0.3)', zIndex: 1299 }} style={{
position: 'fixed',
inset: 0,
backgroundColor: 'rgba(0,0,0,0.3)',
zIndex: 1299,
}}
onClick={() => setSelected(null)} onClick={() => setSelected(null)}
/> />
<ObjectStoreDetail store={selected} onClose={() => setSelected(null)} /> <ObjectStoreDetail store={selected} onClose={() => setSelected(null)} />
+84 -35
View File
@@ -15,7 +15,13 @@ import {
StatusLabel, StatusLabel,
} from '@kinvolk/headlamp-plugin/lib/CommonComponents'; } from '@kinvolk/headlamp-plugin/lib/CommonComponents';
import React from 'react'; import React from 'react';
import { formatAge, formatBytes, healthToStatus, phaseToStatus, storageClassType } from '../api/k8s'; import {
formatAge,
formatBytes,
healthToStatus,
phaseToStatus,
storageClassType,
} from '../api/k8s';
import { useRookCephContext } from '../api/RookCephDataContext'; import { useRookCephContext } from '../api/RookCephDataContext';
import ClusterStatusCard from './ClusterStatusCard'; import ClusterStatusCard from './ClusterStatusCard';
@@ -70,7 +76,14 @@ export default function OverviewPage() {
return ( return (
<> <>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '20px' }}> <div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '20px',
}}
>
<SectionHeader title="Rook-Ceph — Overview" /> <SectionHeader title="Rook-Ceph — Overview" />
<button <button
onClick={refresh} onClick={refresh}
@@ -97,11 +110,16 @@ export default function OverviewPage() {
rows={[ rows={[
{ {
name: 'Status', name: 'Status',
value: <StatusLabel status="error">No CephCluster found in namespace rook-ceph</StatusLabel>, value: (
<StatusLabel status="error">
No CephCluster found in namespace rook-ceph
</StatusLabel>
),
}, },
{ {
name: 'Install', name: 'Install',
value: 'helm install rook-ceph rook-release/rook-ceph -n rook-ceph --create-namespace', value:
'helm install rook-ceph rook-release/rook-ceph -n rook-ceph --create-namespace',
}, },
{ {
name: 'Docs', name: 'Docs',
@@ -129,9 +147,7 @@ export default function OverviewPage() {
{ {
name: 'Health', name: 'Health',
value: ( value: (
<StatusLabel status={healthToStatus(primaryHealth)}> <StatusLabel status={healthToStatus(primaryHealth)}>{primaryHealth}</StatusLabel>
{primaryHealth}
</StatusLabel>
), ),
}, },
{ {
@@ -148,7 +164,13 @@ export default function OverviewPage() {
<SectionBox title="Storage Summary"> <SectionBox title="Storage Summary">
{storageClasses.length > 0 && ( {storageClasses.length > 0 && (
<div style={{ marginBottom: '16px' }}> <div style={{ marginBottom: '16px' }}>
<div style={{ marginBottom: '8px', fontSize: '14px', color: 'var(--mui-palette-text-secondary)' }}> <div
style={{
marginBottom: '8px',
fontSize: '14px',
color: 'var(--mui-palette-text-secondary)',
}}
>
StorageClass Type Distribution StorageClass Type Distribution
</div> </div>
<PercentageBar <PercentageBar
@@ -157,7 +179,13 @@ export default function OverviewPage() {
? [{ name: 'Block (RBD)', value: rbdClasses.length, fill: '#1976d2' }] ? [{ name: 'Block (RBD)', value: rbdClasses.length, fill: '#1976d2' }]
: []), : []),
...(cephfsClasses.length > 0 ...(cephfsClasses.length > 0
? [{ name: 'Filesystem (CephFS)', value: cephfsClasses.length, fill: '#9c27b0' }] ? [
{
name: 'Filesystem (CephFS)',
value: cephfsClasses.length,
fill: '#9c27b0',
},
]
: []), : []),
]} ]}
total={storageClasses.length} total={storageClasses.length}
@@ -166,7 +194,10 @@ export default function OverviewPage() {
)} )}
<NameValueTable <NameValueTable
rows={[ rows={[
{ name: 'Storage Classes', value: `${storageClasses.length} (${rbdClasses.length} RBD, ${cephfsClasses.length} CephFS)` }, {
name: 'Storage Classes',
value: `${storageClasses.length} (${rbdClasses.length} RBD, ${cephfsClasses.length} CephFS)`,
},
{ name: 'Block Pools', value: String(blockPools.length) }, { name: 'Block Pools', value: String(blockPools.length) },
{ name: 'Filesystems', value: String(filesystems.length) }, { name: 'Filesystems', value: String(filesystems.length) },
{ name: 'Object Stores', value: String(objectStores.length) }, { name: 'Object Stores', value: String(objectStores.length) },
@@ -177,10 +208,20 @@ export default function OverviewPage() {
value: <StatusLabel status="success">{pvcStatusCounts.Bound}</StatusLabel>, value: <StatusLabel status="success">{pvcStatusCounts.Bound}</StatusLabel>,
}, },
...(pvcStatusCounts.Pending > 0 ...(pvcStatusCounts.Pending > 0
? [{ name: 'PVCs (Pending)', value: <StatusLabel status="warning">{pvcStatusCounts.Pending}</StatusLabel> }] ? [
{
name: 'PVCs (Pending)',
value: <StatusLabel status="warning">{pvcStatusCounts.Pending}</StatusLabel>,
},
]
: []), : []),
...(pvcStatusCounts.Lost > 0 ...(pvcStatusCounts.Lost > 0
? [{ name: 'PVCs (Lost)', value: <StatusLabel status="error">{pvcStatusCounts.Lost}</StatusLabel> }] ? [
{
name: 'PVCs (Lost)',
value: <StatusLabel status="error">{pvcStatusCounts.Lost}</StatusLabel>,
},
]
: []), : []),
]} ]}
/> />
@@ -203,18 +244,18 @@ export default function OverviewPage() {
<SectionBox title="Block Pools"> <SectionBox title="Block Pools">
<SimpleTable <SimpleTable
columns={[ columns={[
{ label: 'Name', getter: (p) => p.metadata.name }, { label: 'Name', getter: p => p.metadata.name },
{ {
label: 'Phase', label: 'Phase',
getter: (p) => ( getter: p => (
<StatusLabel status={phaseToStatus(p.status?.phase)}> <StatusLabel status={phaseToStatus(p.status?.phase)}>
{p.status?.phase ?? 'Unknown'} {p.status?.phase ?? 'Unknown'}
</StatusLabel> </StatusLabel>
), ),
}, },
{ label: 'Replicas', getter: (p) => String(p.spec?.replicated?.size ?? '—') }, { label: 'Replicas', getter: p => String(p.spec?.replicated?.size ?? '—') },
{ label: 'Failure Domain', getter: (p) => p.spec?.failureDomain ?? '—' }, { label: 'Failure Domain', getter: p => p.spec?.failureDomain ?? '—' },
{ label: 'Age', getter: (p) => formatAge(p.metadata.creationTimestamp) }, { label: 'Age', getter: p => formatAge(p.metadata.creationTimestamp) },
]} ]}
data={blockPools} data={blockPools}
/> />
@@ -226,17 +267,20 @@ export default function OverviewPage() {
<SectionBox title="Filesystems"> <SectionBox title="Filesystems">
<SimpleTable <SimpleTable
columns={[ columns={[
{ label: 'Name', getter: (f) => f.metadata.name }, { label: 'Name', getter: f => f.metadata.name },
{ {
label: 'Phase', label: 'Phase',
getter: (f) => ( getter: f => (
<StatusLabel status={phaseToStatus(f.status?.phase)}> <StatusLabel status={phaseToStatus(f.status?.phase)}>
{f.status?.phase ?? 'Unknown'} {f.status?.phase ?? 'Unknown'}
</StatusLabel> </StatusLabel>
), ),
}, },
{ label: 'Active MDS', getter: (f) => String(f.spec?.metadataServer?.activeCount ?? '—') }, {
{ label: 'Age', getter: (f) => formatAge(f.metadata.creationTimestamp) }, label: 'Active MDS',
getter: f => String(f.spec?.metadataServer?.activeCount ?? '—'),
},
{ label: 'Age', getter: f => formatAge(f.metadata.creationTimestamp) },
]} ]}
data={filesystems} data={filesystems}
/> />
@@ -248,18 +292,18 @@ export default function OverviewPage() {
<SectionBox title="Object Stores"> <SectionBox title="Object Stores">
<SimpleTable <SimpleTable
columns={[ columns={[
{ label: 'Name', getter: (o) => o.metadata.name }, { label: 'Name', getter: o => o.metadata.name },
{ {
label: 'Phase', label: 'Phase',
getter: (o) => ( getter: o => (
<StatusLabel status={phaseToStatus(o.status?.phase)}> <StatusLabel status={phaseToStatus(o.status?.phase)}>
{o.status?.phase ?? 'Unknown'} {o.status?.phase ?? 'Unknown'}
</StatusLabel> </StatusLabel>
), ),
}, },
{ label: 'Gateway Port', getter: (o) => String(o.spec?.gateway?.port ?? '—') }, { label: 'Gateway Port', getter: o => String(o.spec?.gateway?.port ?? '—') },
{ label: 'Instances', getter: (o) => String(o.spec?.gateway?.instances ?? '—') }, { label: 'Instances', getter: o => String(o.spec?.gateway?.instances ?? '—') },
{ label: 'Age', getter: (o) => formatAge(o.metadata.creationTimestamp) }, { label: 'Age', getter: o => formatAge(o.metadata.creationTimestamp) },
]} ]}
data={objectStores} data={objectStores}
/> />
@@ -271,17 +315,17 @@ export default function OverviewPage() {
<SectionBox title="Attention: Non-Bound PVCs"> <SectionBox title="Attention: Non-Bound PVCs">
<SimpleTable <SimpleTable
columns={[ columns={[
{ label: 'Name', getter: (pvc) => pvc.metadata.name }, { label: 'Name', getter: pvc => pvc.metadata.name },
{ label: 'Namespace', getter: (pvc) => pvc.metadata.namespace ?? '—' }, { label: 'Namespace', getter: pvc => pvc.metadata.namespace ?? '—' },
{ {
label: 'Status', label: 'Status',
getter: (pvc) => ( getter: pvc => (
<StatusLabel status={phaseToStatus(pvc.status?.phase)}> <StatusLabel status={phaseToStatus(pvc.status?.phase)}>
{pvc.status?.phase ?? 'Unknown'} {pvc.status?.phase ?? 'Unknown'}
</StatusLabel> </StatusLabel>
), ),
}, },
{ label: 'Age', getter: (pvc) => formatAge(pvc.metadata.creationTimestamp) }, { label: 'Age', getter: pvc => formatAge(pvc.metadata.creationTimestamp) },
]} ]}
data={nonBoundPvcs} data={nonBoundPvcs}
/> />
@@ -298,11 +342,16 @@ function parseStorageToBytes(storage: string): number {
const suffix = match[2] ?? ''; const suffix = match[2] ?? '';
const multipliers: Record<string, number> = { const multipliers: Record<string, number> = {
'': 1, '': 1,
K: 1e3, Ki: 1024, K: 1e3,
M: 1e6, Mi: 1024 ** 2, Ki: 1024,
G: 1e9, Gi: 1024 ** 3, M: 1e6,
T: 1e12, Ti: 1024 ** 4, Mi: 1024 ** 2,
P: 1e15, Pi: 1024 ** 5, G: 1e9,
Gi: 1024 ** 3,
T: 1e12,
Ti: 1024 ** 4,
P: 1e15,
Pi: 1024 ** 5,
}; };
return value * (multipliers[suffix] ?? 1); return value * (multipliers[suffix] ?? 1);
} }
+6 -5
View File
@@ -5,10 +5,7 @@
* Uses registerDetailsViewSection in index.tsx. * Uses registerDetailsViewSection in index.tsx.
*/ */
import { import { NameValueTable, SectionBox } from '@kinvolk/headlamp-plugin/lib/CommonComponents';
NameValueTable,
SectionBox,
} from '@kinvolk/headlamp-plugin/lib/CommonComponents';
import React from 'react'; import React from 'react';
import { findBoundPv, formatStorageType } from '../api/k8s'; import { findBoundPv, formatStorageType } from '../api/k8s';
import { useRookCephContext } from '../api/RookCephDataContext'; import { useRookCephContext } from '../api/RookCephDataContext';
@@ -40,7 +37,11 @@ export default function PVCDetailSection({ resource }: PVCDetailSectionProps) {
// Determine storage type from driver name // Determine storage type from driver name
const driver = boundPv.spec.csi?.driver ?? ''; const driver = boundPv.spec.csi?.driver ?? '';
const type = driver.includes('.rbd.') ? 'rbd' : driver.includes('.cephfs.') ? 'cephfs' : 'unknown'; const type = driver.includes('.rbd.')
? 'rbd'
: driver.includes('.cephfs.')
? 'cephfs'
: 'unknown';
return ( return (
<SectionBox title="Rook-Ceph Storage Details"> <SectionBox title="Rook-Ceph Storage Details">
+10 -6
View File
@@ -4,17 +4,17 @@
* Shown only when the PV uses a Rook-Ceph CSI driver. * Shown only when the PV uses a Rook-Ceph CSI driver.
*/ */
import { import { NameValueTable, SectionBox } from '@kinvolk/headlamp-plugin/lib/CommonComponents';
NameValueTable,
SectionBox,
} from '@kinvolk/headlamp-plugin/lib/CommonComponents';
import React from 'react'; import React from 'react';
import { formatStorageType, isRookCephPersistentVolume } from '../api/k8s'; import { formatStorageType, isRookCephPersistentVolume } from '../api/k8s';
interface PVDetailSectionProps { interface PVDetailSectionProps {
resource: { resource: {
metadata?: { name?: string }; metadata?: { name?: string };
spec?: { csi?: { driver?: string; volumeHandle?: string; volumeAttributes?: Record<string, string> }; storageClassName?: string }; spec?: {
csi?: { driver?: string; volumeHandle?: string; volumeAttributes?: Record<string, string> };
storageClassName?: string;
};
jsonData?: unknown; jsonData?: unknown;
}; };
} }
@@ -34,7 +34,11 @@ export default function PVDetailSection({ resource }: PVDetailSectionProps) {
} }
const attrs = spec?.csi?.volumeAttributes ?? {}; const attrs = spec?.csi?.volumeAttributes ?? {};
const type = driver.includes('.rbd.') ? 'rbd' : driver.includes('.cephfs.') ? 'cephfs' : 'unknown'; const type = driver.includes('.rbd.')
? 'rbd'
: driver.includes('.cephfs.')
? 'cephfs'
: 'unknown';
return ( return (
<SectionBox title="Rook-Ceph Volume Details"> <SectionBox title="Rook-Ceph Volume Details">
+37 -32
View File
@@ -20,18 +20,18 @@ function PodTable({ pods, title }: { pods: RookCephPod[]; title: string }) {
<SectionBox title={`${title} (${pods.length})`}> <SectionBox title={`${title} (${pods.length})`}>
<SimpleTable <SimpleTable
columns={[ columns={[
{ label: 'Name', getter: (p) => p.metadata.name }, { label: 'Name', getter: p => p.metadata.name },
{ {
label: 'Status', label: 'Status',
getter: (p) => ( getter: p => (
<StatusLabel status={isPodReady(p) ? 'success' : 'error'}> <StatusLabel status={isPodReady(p) ? 'success' : 'error'}>
{p.status?.phase ?? 'Unknown'} {p.status?.phase ?? 'Unknown'}
</StatusLabel> </StatusLabel>
), ),
}, },
{ label: 'Node', getter: (p) => p.spec?.nodeName ?? '—' }, { label: 'Node', getter: p => p.spec?.nodeName ?? '—' },
{ label: 'Restarts', getter: (p) => String(getPodRestarts(p)) }, { label: 'Restarts', getter: p => String(getPodRestarts(p)) },
{ label: 'Age', getter: (p) => formatAge(p.metadata.creationTimestamp) }, { label: 'Age', getter: p => formatAge(p.metadata.creationTimestamp) },
]} ]}
data={pods} data={pods}
/> />
@@ -45,27 +45,27 @@ function OsdTable({ pods }: { pods: RookCephPod[] }) {
<SectionBox title={`OSDs (${pods.length})`}> <SectionBox title={`OSDs (${pods.length})`}>
<SimpleTable <SimpleTable
columns={[ columns={[
{ label: 'OSD ID', getter: (p) => p.metadata.labels?.['osd'] ?? p.metadata.name }, { label: 'OSD ID', getter: p => p.metadata.labels?.['osd'] ?? p.metadata.name },
{ {
label: 'Status', label: 'Status',
getter: (p) => { getter: p => {
const st = isPodReady(p) ? 'success' : p.status?.phase === 'Pending' ? 'warning' : 'error'; const st = isPodReady(p)
return ( ? 'success'
<StatusLabel status={st}> : p.status?.phase === 'Pending'
{p.status?.phase ?? 'Unknown'} ? 'warning'
</StatusLabel> : 'error';
); return <StatusLabel status={st}>{p.status?.phase ?? 'Unknown'}</StatusLabel>;
}, },
}, },
{ {
label: 'Node', label: 'Node',
getter: (p) => p.spec?.nodeName ?? p.metadata.labels?.['topology-location-host'] ?? '—', getter: p => p.spec?.nodeName ?? p.metadata.labels?.['topology-location-host'] ?? '—',
}, },
{ label: 'Device Class', getter: (p) => p.metadata.labels?.['device-class'] ?? '—' }, { label: 'Device Class', getter: p => p.metadata.labels?.['device-class'] ?? '—' },
{ label: 'Store', getter: (p) => p.metadata.labels?.['osd-store'] ?? '—' }, { label: 'Store', getter: p => p.metadata.labels?.['osd-store'] ?? '—' },
{ label: 'Failure Domain', getter: (p) => p.metadata.labels?.['failure-domain'] ?? '—' }, { label: 'Failure Domain', getter: p => p.metadata.labels?.['failure-domain'] ?? '—' },
{ label: 'Restarts', getter: (p) => String(getPodRestarts(p)) }, { label: 'Restarts', getter: p => String(getPodRestarts(p)) },
{ label: 'Age', getter: (p) => formatAge(p.metadata.creationTimestamp) }, { label: 'Age', getter: p => formatAge(p.metadata.creationTimestamp) },
]} ]}
data={pods} data={pods}
/> />
@@ -74,20 +74,19 @@ function OsdTable({ pods }: { pods: RookCephPod[] }) {
} }
export default function PodsPage() { export default function PodsPage() {
const { const { operatorPods, monPods, osdPods, mgrPods, csiRbdPods, csiCephfsPods, loading, error } =
operatorPods, useRookCephContext();
monPods,
osdPods,
mgrPods,
csiRbdPods,
csiCephfsPods,
loading,
error,
} = useRookCephContext();
if (loading) return <Loader title="Loading Rook-Ceph pods..." />; if (loading) return <Loader title="Loading Rook-Ceph pods..." />;
const allPods = [...operatorPods, ...monPods, ...osdPods, ...mgrPods, ...csiRbdPods, ...csiCephfsPods]; const allPods = [
...operatorPods,
...monPods,
...osdPods,
...mgrPods,
...csiRbdPods,
...csiCephfsPods,
];
const totalReady = allPods.filter(isPodReady).length; const totalReady = allPods.filter(isPodReady).length;
return ( return (
@@ -96,7 +95,9 @@ export default function PodsPage() {
{error && ( {error && (
<SectionBox title="Error"> <SectionBox title="Error">
<NameValueTable rows={[{ name: 'Status', value: <StatusLabel status="error">{error}</StatusLabel> }]} /> <NameValueTable
rows={[{ name: 'Status', value: <StatusLabel status="error">{error}</StatusLabel> }]}
/>
</SectionBox> </SectionBox>
)} )}
@@ -106,7 +107,11 @@ export default function PodsPage() {
{ {
name: 'Overall Health', name: 'Overall Health',
value: ( value: (
<StatusLabel status={totalReady === allPods.length && allPods.length > 0 ? 'success' : 'warning'}> <StatusLabel
status={
totalReady === allPods.length && allPods.length > 0 ? 'success' : 'warning'
}
>
{totalReady}/{allPods.length} pods ready {totalReady}/{allPods.length} pods ready
</StatusLabel> </StatusLabel>
), ),
+67 -12
View File
@@ -14,13 +14,24 @@ import React, { useState } from 'react';
import { formatAge, formatStorageType, RookCephStorageClass, storageClassType } from '../api/k8s'; import { formatAge, formatStorageType, RookCephStorageClass, storageClassType } from '../api/k8s';
import { useRookCephContext } from '../api/RookCephDataContext'; import { useRookCephContext } from '../api/RookCephDataContext';
function StorageClassDetail({ sc, pvCount, onClose }: { sc: RookCephStorageClass; pvCount: number; onClose: () => void }) { function StorageClassDetail({
sc,
pvCount,
onClose,
}: {
sc: RookCephStorageClass;
pvCount: number;
onClose: () => void;
}) {
const type = storageClassType(sc); const type = storageClassType(sc);
return ( return (
<div <div
style={{ style={{
position: 'fixed', position: 'fixed',
top: 0, right: 0, bottom: 0, width: '480px', top: 0,
right: 0,
bottom: 0,
width: '480px',
backgroundColor: 'var(--mui-palette-background-paper, #fff)', backgroundColor: 'var(--mui-palette-background-paper, #fff)',
boxShadow: '-4px 0 16px rgba(0,0,0,0.15)', boxShadow: '-4px 0 16px rgba(0,0,0,0.15)',
zIndex: 1300, zIndex: 1300,
@@ -28,7 +39,14 @@ function StorageClassDetail({ sc, pvCount, onClose }: { sc: RookCephStorageClass
padding: '24px', padding: '24px',
}} }}
> >
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '16px' }}> <div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '16px',
}}
>
<strong>{sc.metadata.name}</strong> <strong>{sc.metadata.name}</strong>
<button <button
onClick={onClose} onClick={onClose}
@@ -46,7 +64,10 @@ function StorageClassDetail({ sc, pvCount, onClose }: { sc: RookCephStorageClass
{ name: 'Type', value: formatStorageType(type) }, { name: 'Type', value: formatStorageType(type) },
{ name: 'Reclaim Policy', value: sc.reclaimPolicy ?? '—' }, { name: 'Reclaim Policy', value: sc.reclaimPolicy ?? '—' },
{ name: 'Volume Binding Mode', value: sc.volumeBindingMode ?? '—' }, { name: 'Volume Binding Mode', value: sc.volumeBindingMode ?? '—' },
{ name: 'Volume Expansion', value: sc.allowVolumeExpansion ? 'Allowed' : 'Not allowed' }, {
name: 'Volume Expansion',
value: sc.allowVolumeExpansion ? 'Allowed' : 'Not allowed',
},
{ name: 'Age', value: formatAge(sc.metadata.creationTimestamp) }, { name: 'Age', value: formatAge(sc.metadata.creationTimestamp) },
{ name: 'Bound PVs', value: String(pvCount) }, { name: 'Bound PVs', value: String(pvCount) },
]} ]}
@@ -81,14 +102,22 @@ export default function StorageClassesPage() {
{error && ( {error && (
<SectionBox title="Error"> <SectionBox title="Error">
<NameValueTable rows={[{ name: 'Status', value: <StatusLabel status="error">{error}</StatusLabel> }]} /> <NameValueTable
rows={[{ name: 'Status', value: <StatusLabel status="error">{error}</StatusLabel> }]}
/>
</SectionBox> </SectionBox>
)} )}
{storageClasses.length === 0 ? ( {storageClasses.length === 0 ? (
<SectionBox title="No Storage Classes"> <SectionBox title="No Storage Classes">
<NameValueTable <NameValueTable
rows={[{ name: 'Status', value: 'No Rook-Ceph StorageClasses found. Ensure CephBlockPool and CephFilesystem resources exist.' }]} rows={[
{
name: 'Status',
value:
'No Rook-Ceph StorageClasses found. Ensure CephBlockPool and CephFilesystem resources exist.',
},
]}
/> />
</SectionBox> </SectionBox>
) : ( ) : (
@@ -100,7 +129,15 @@ export default function StorageClassesPage() {
getter: (sc: RookCephStorageClass) => ( getter: (sc: RookCephStorageClass) => (
<button <button
onClick={() => setSelected(sc)} onClick={() => setSelected(sc)}
style={{ border: 'none', background: 'transparent', color: 'var(--link-color, #1976d2)', cursor: 'pointer', textDecoration: 'underline', padding: 0, font: 'inherit' }} style={{
border: 'none',
background: 'transparent',
color: 'var(--link-color, #1976d2)',
cursor: 'pointer',
textDecoration: 'underline',
padding: 0,
font: 'inherit',
}}
> >
{sc.metadata.name} {sc.metadata.name}
</button> </button>
@@ -115,11 +152,24 @@ export default function StorageClassesPage() {
), ),
}, },
{ label: 'Provisioner', getter: (sc: RookCephStorageClass) => sc.provisioner }, { label: 'Provisioner', getter: (sc: RookCephStorageClass) => sc.provisioner },
{ label: 'Pool', getter: (sc: RookCephStorageClass) => sc.parameters?.['pool'] ?? '—' }, {
label: 'Pool',
getter: (sc: RookCephStorageClass) => sc.parameters?.['pool'] ?? '—',
},
{ label: 'Reclaim', getter: (sc: RookCephStorageClass) => sc.reclaimPolicy ?? '—' }, { 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: 'Expansion',
{ label: 'Age', getter: (sc: RookCephStorageClass) => formatAge(sc.metadata.creationTimestamp) }, 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} data={storageClasses}
/> />
@@ -129,7 +179,12 @@ export default function StorageClassesPage() {
{selected && ( {selected && (
<> <>
<div <div
style={{ position: 'fixed', inset: 0, backgroundColor: 'rgba(0,0,0,0.3)', zIndex: 1299 }} style={{
position: 'fixed',
inset: 0,
backgroundColor: 'rgba(0,0,0,0.3)',
zIndex: 1299,
}}
onClick={() => setSelected(null)} onClick={() => setSelected(null)}
/> />
<StorageClassDetail <StorageClassDetail
+57 -11
View File
@@ -20,7 +20,10 @@ function PVDetail({ pv, onClose }: { pv: RookCephPersistentVolume; onClose: () =
<div <div
style={{ style={{
position: 'fixed', position: 'fixed',
top: 0, right: 0, bottom: 0, width: '520px', top: 0,
right: 0,
bottom: 0,
width: '520px',
backgroundColor: 'var(--mui-palette-background-paper, #fff)', backgroundColor: 'var(--mui-palette-background-paper, #fff)',
boxShadow: '-4px 0 16px rgba(0,0,0,0.15)', boxShadow: '-4px 0 16px rgba(0,0,0,0.15)',
zIndex: 1300, zIndex: 1300,
@@ -28,7 +31,14 @@ function PVDetail({ pv, onClose }: { pv: RookCephPersistentVolume; onClose: () =
padding: '24px', padding: '24px',
}} }}
> >
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '16px' }}> <div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '16px',
}}
>
<strong>{pv.metadata.name}</strong> <strong>{pv.metadata.name}</strong>
<button <button
onClick={onClose} onClick={onClose}
@@ -89,7 +99,9 @@ export default function VolumesPage() {
{error && ( {error && (
<SectionBox title="Error"> <SectionBox title="Error">
<NameValueTable rows={[{ name: 'Status', value: <StatusLabel status="error">{error}</StatusLabel> }]} /> <NameValueTable
rows={[{ name: 'Status', value: <StatusLabel status="error">{error}</StatusLabel> }]}
/>
</SectionBox> </SectionBox>
)} )}
@@ -108,14 +120,28 @@ export default function VolumesPage() {
getter: (pv: RookCephPersistentVolume) => ( getter: (pv: RookCephPersistentVolume) => (
<button <button
onClick={() => setSelected(pv)} onClick={() => setSelected(pv)}
style={{ border: 'none', background: 'transparent', color: 'var(--link-color, #1976d2)', cursor: 'pointer', textDecoration: 'underline', padding: 0, font: 'inherit' }} style={{
border: 'none',
background: 'transparent',
color: 'var(--link-color, #1976d2)',
cursor: 'pointer',
textDecoration: 'underline',
padding: 0,
font: 'inherit',
}}
> >
{pv.metadata.name} {pv.metadata.name}
</button> </button>
), ),
}, },
{ label: 'Capacity', getter: (pv: RookCephPersistentVolume) => pv.spec.capacity?.storage ?? '—' }, {
{ label: 'Access Modes', getter: (pv: RookCephPersistentVolume) => formatAccessModes(pv.spec.accessModes) }, label: 'Capacity',
getter: (pv: RookCephPersistentVolume) => pv.spec.capacity?.storage ?? '—',
},
{
label: 'Access Modes',
getter: (pv: RookCephPersistentVolume) => formatAccessModes(pv.spec.accessModes),
},
{ {
label: 'Phase', label: 'Phase',
getter: (pv: RookCephPersistentVolume) => ( getter: (pv: RookCephPersistentVolume) => (
@@ -124,10 +150,25 @@ export default function VolumesPage() {
</StatusLabel> </StatusLabel>
), ),
}, },
{ label: 'Reclaim', getter: (pv: RookCephPersistentVolume) => pv.spec.persistentVolumeReclaimPolicy ?? '—' }, {
{ label: 'Pool', getter: (pv: RookCephPersistentVolume) => pv.spec.csi?.volumeAttributes?.['pool'] ?? '—' }, label: 'Reclaim',
{ label: 'Claim', getter: (pv: RookCephPersistentVolume) => pv.spec.claimRef ? `${pv.spec.claimRef.namespace}/${pv.spec.claimRef.name}` : '—' }, getter: (pv: RookCephPersistentVolume) =>
{ label: 'Age', getter: (pv: RookCephPersistentVolume) => formatAge(pv.metadata.creationTimestamp) }, 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} data={persistentVolumes}
/> />
@@ -137,7 +178,12 @@ export default function VolumesPage() {
{selected && ( {selected && (
<> <>
<div <div
style={{ position: 'fixed', inset: 0, backgroundColor: 'rgba(0,0,0,0.3)', zIndex: 1299 }} style={{
position: 'fixed',
inset: 0,
backgroundColor: 'rgba(0,0,0,0.3)',
zIndex: 1299,
}}
onClick={() => setSelected(null)} onClick={() => setSelected(null)}
/> />
<PVDetail pv={selected} onClose={() => setSelected(null)} /> <PVDetail pv={selected} onClose={() => setSelected(null)} />
@@ -64,7 +64,7 @@ export function buildStorageClassColumns() {
}, },
{ {
label: 'Pool', label: 'Pool',
getValue: (item: unknown) => getField(item, 'parameters', 'pool') as string | null ?? null, getValue: (item: unknown) => (getField(item, 'parameters', 'pool') as string | null) ?? null,
render: (item: unknown) => { render: (item: unknown) => {
if (!isRookRow(item)) return <span></span>; if (!isRookRow(item)) return <span></span>;
const pool = getField(item, 'parameters', 'pool') as string | undefined; const pool = getField(item, 'parameters', 'pool') as string | undefined;
@@ -73,12 +73,17 @@ export function buildStorageClassColumns() {
}, },
{ {
label: 'Cluster', label: 'Cluster',
getValue: (item: unknown) => getField(item, 'parameters', 'clusterID') as string | null ?? null, getValue: (item: unknown) =>
(getField(item, 'parameters', 'clusterID') as string | null) ?? null,
render: (item: unknown) => { render: (item: unknown) => {
if (!isRookRow(item)) return <span></span>; if (!isRookRow(item)) return <span></span>;
const clusterID = getField(item, 'parameters', 'clusterID') as string | undefined; const clusterID = getField(item, 'parameters', 'clusterID') as string | undefined;
if (!clusterID) return <span></span>; if (!clusterID) return <span></span>;
return <span title={clusterID}>{clusterID.length > 16 ? `${clusterID.slice(0, 16)}` : clusterID}</span>; return (
<span title={clusterID}>
{clusterID.length > 16 ? `${clusterID.slice(0, 16)}` : clusterID}
</span>
);
}, },
}, },
]; ];
@@ -101,10 +106,13 @@ export function buildPVColumns() {
}, },
{ {
label: 'Pool', label: 'Pool',
getValue: (item: unknown) => getField(item, 'spec', 'csi', 'volumeAttributes', 'pool') as string | null ?? null, getValue: (item: unknown) =>
(getField(item, 'spec', 'csi', 'volumeAttributes', 'pool') as string | null) ?? null,
render: (item: unknown) => { render: (item: unknown) => {
if (!isRookPvRow(item)) return <span></span>; if (!isRookPvRow(item)) return <span></span>;
const pool = getField(item, 'spec', 'csi', 'volumeAttributes', 'pool') as string | undefined; const pool = getField(item, 'spec', 'csi', 'volumeAttributes', 'pool') as
| string
| undefined;
return <span>{pool ?? '—'}</span>; return <span>{pool ?? '—'}</span>;
}, },
}, },
+16 -7
View File
@@ -16,7 +16,10 @@ import { RookCephDataProvider } from './api/RookCephDataContext';
import BlockPoolsPage from './components/BlockPoolsPage'; import BlockPoolsPage from './components/BlockPoolsPage';
import CephPodDetailSection from './components/CephPodDetailSection'; import CephPodDetailSection from './components/CephPodDetailSection';
import FilesystemsPage from './components/FilesystemsPage'; import FilesystemsPage from './components/FilesystemsPage';
import { buildPVColumns, buildStorageClassColumns } from './components/integrations/StorageClassColumns'; import {
buildPVColumns,
buildStorageClassColumns,
} from './components/integrations/StorageClassColumns';
import ObjectStoresPage from './components/ObjectStoresPage'; import ObjectStoresPage from './components/ObjectStoresPage';
import OverviewPage from './components/OverviewPage'; import OverviewPage from './components/OverviewPage';
import PodsPage from './components/PodsPage'; import PodsPage from './components/PodsPage';
@@ -207,11 +210,18 @@ registerDetailsViewSection(({ resource }) => {
// takes priority and falls back to the existing one (for mixed-driver tables). // takes priority and falls back to the existing one (for mixed-driver tables).
function mergeColumns<T>( function mergeColumns<T>(
existing: T[], existing: T[],
incoming: Array<{ label: string; getValue: (r: unknown) => unknown; render: (r: unknown) => React.ReactNode }> incoming: Array<{
label: string;
getValue: (r: unknown) => unknown;
render: (r: unknown) => React.ReactNode;
}>
): T[] { ): T[] {
type ObjCol = { label: string; getValue: (r: unknown) => unknown; render: (r: unknown) => React.ReactNode }; type ObjCol = {
const isObjCol = (c: unknown): c is ObjCol => label: string;
typeof c === 'object' && c !== null && 'label' in c; getValue: (r: unknown) => unknown;
render: (r: unknown) => React.ReactNode;
};
const isObjCol = (c: unknown): c is ObjCol => typeof c === 'object' && c !== null && 'label' in c;
const result = [...existing]; const result = [...existing];
const toAppend: typeof incoming = []; const toAppend: typeof incoming = [];
for (const col of incoming) { for (const col of incoming) {
@@ -221,7 +231,7 @@ function mergeColumns<T>(
result[idx] = { result[idx] = {
label: col.label, label: col.label,
getValue: (r: unknown) => col.getValue(r) ?? prev.getValue(r), getValue: (r: unknown) => col.getValue(r) ?? prev.getValue(r),
render: (r: unknown) => col.getValue(r) !== null ? col.render(r) : prev.render(r), render: (r: unknown) => (col.getValue(r) !== null ? col.render(r) : prev.render(r)),
} as unknown as T; } as unknown as T;
} else { } else {
toAppend.push(col); toAppend.push(col);
@@ -239,4 +249,3 @@ registerResourceTableColumnsProcessor(({ id, columns }) => {
} }
return columns; return columns;
}); });