feat: initial kube-vip Headlamp plugin

Headlamp plugin providing visibility into kube-vip virtual IP and load
balancer deployments. Features:

- Overview dashboard with deployment status, VIP mode, leader election
- Services page with LoadBalancer VIP assignments and detail panels
- Nodes page showing kube-vip pod status and leader designation
- Configuration page with DaemonSet config, IP pools, leases
- Service detail section injected into native Headlamp Service views

Read-only plugin — no cluster write operations. Uses standard K8s
resources (no CRDs): Services, Nodes, Pods, DaemonSets, Leases,
ConfigMaps with kube-vip.io/* annotations.

74 tests across 7 test files. All tsc/lint/format/test checks pass.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
DevContainer User
2026-03-04 00:23:08 +00:00
commit 3b9d007e8b
37 changed files with 22722 additions and 0 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.
+3
View File
@@ -0,0 +1,3 @@
{
"enabledMcpjsonServers": ["github", "kubernetes", "flux", "playwright"]
}
+3
View File
@@ -0,0 +1,3 @@
module.exports = {
extends: ['@headlamp-k8s/eslint-config'],
};
+40
View File
@@ -0,0 +1,40 @@
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
lint-and-test:
runs-on: local-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: Format check
run: npx prettier --check src/
- name: Run unit tests
run: npm test
+7
View File
@@ -0,0 +1,7 @@
node_modules/
dist/
.headlamp-plugin/
*.tar.gz
.env
.env.local
.eslintcache
+12
View File
@@ -0,0 +1,12 @@
{
"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 @@
{}
+1
View File
@@ -0,0 +1 @@
module.exports = require('@headlamp-k8s/eslint-config/prettier-config');
+93
View File
@@ -0,0 +1,93 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project
Headlamp plugin for kube-vip virtual IP and load balancer visibility. Read-only — monitors kube-vip DaemonSet/pods, LoadBalancer services, nodes, IP pools, and leader election. No cluster write operations.
- **Plugin name**: `kube-vip`
- **Target**: Headlamp >= v0.26
- **Data sources**: kube-vip DaemonSet/pods in `kube-system`, Services (type:LoadBalancer), Nodes, Leases, `kubevip` ConfigMap
- **Reference plugin**: `../headlamp-polaris-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 run lint:fix # ESLint with auto-fix
npm run format # Prettier write
npm run format:check # Prettier check
npm test # vitest run
npm run test:watch # vitest watch mode
```
All tests and `tsc` must pass before committing.
## Architecture
```
src/
├── index.tsx # Plugin entry: registerRoute, registerSidebarEntry, registerDetailsViewSection
├── test-helpers.tsx # Shared test utilities and fixtures
├── api/
│ ├── k8s.ts # Types + helpers (Services, Nodes, Pods, DaemonSets, Leases, ConfigMaps)
│ └── KubeVipDataContext.tsx # Shared React context provider
└── components/
├── OverviewPage.tsx # Dashboard: deployment status, cluster summary, VIP overview
├── ServicesPage.tsx # LoadBalancer services with VIP assignments and detail panel
├── NodesPage.tsx # Nodes with kube-vip pod status and leader election
├── ConfigPage.tsx # DaemonSet config, IP pools, leases, pod details
├── ServiceDetailSection.tsx # Injected into Headlamp Service detail view
└── __mocks__/
└── commonComponents.ts # Test mocks for headlamp CommonComponents
```
## Data flow
`KubeVipDataContext.tsx` uses **two fetching strategies**:
1. **Headlamp hooks** (`K8s.ResourceClasses.*.useList()`) — for Services and Nodes.
2. **`ApiProxy.request()`** — for kube-vip DaemonSet, pods (with label selector fallback for static pods), cloud-provider pods, Leases, and the `kubevip` ConfigMap.
kube-vip uses **no CRDs**. All state comes from standard Kubernetes resources and `kube-vip.io/*` annotations on Services.
## Key constants (src/api/k8s.ts)
- Namespace: `kube-system`
- DaemonSet name: `kube-vip-ds`
- Cloud provider name: `kube-vip-cloud-provider`
- ConfigMap name: `kubevip`
- Annotation prefix: `kube-vip.io/`
- Pod selector: `app.kubernetes.io/name=kube-vip-ds`
- Cloud provider selector: `app=kube-vip-cloud-provider`
- Metrics port: `2112`
## 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 (`KubeVipDataProvider`) wraps each route component in `index.tsx`
- Tests: vitest + @testing-library/react, mock with `vi.mock('@kinvolk/headlamp-plugin/lib', ...)`
- `vitest.setup.ts` provides a spec-compliant `localStorage` shim for Node 22+ compatibility
## Testing
Mock pattern for headlamp APIs:
```typescript
vi.mock('@kinvolk/headlamp-plugin/lib', () => ({
ApiProxy: { request: vi.fn().mockResolvedValue({ items: [] }) },
K8s: {
ResourceClasses: {
Service: { useList: vi.fn(() => [[], null]) },
Node: { useList: vi.fn(() => [[], null]) },
},
},
}));
```
+36
View File
@@ -0,0 +1,36 @@
# Contributing
Contributions are welcome! Please follow these guidelines.
## Development Setup
```bash
git clone https://github.com/privilegedescalation/headlamp-kube-vip-plugin.git
cd headlamp-kube-vip-plugin
npm install
npm start
```
## Before Submitting a PR
```bash
npm run tsc # TypeScript type check
npm run lint # ESLint
npm run format:check # Prettier
npm test # All tests must pass
```
## Code Style
- TypeScript strict mode (no `any`)
- Functional React components only
- All UI from `@kinvolk/headlamp-plugin/lib/CommonComponents`
- Tests with vitest + @testing-library/react
## Commit Messages
Use conventional commit format:
- `feat:` new features
- `fix:` bug fixes
- `chore:` maintenance
- `docs:` documentation
+190
View File
@@ -0,0 +1,190 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to the Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by the Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding any notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
Copyright 2025 privilegedescalation
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+102
View File
@@ -0,0 +1,102 @@
# headlamp-kube-vip-plugin
[![CI](https://github.com/privilegedescalation/headlamp-kube-vip-plugin/actions/workflows/ci.yml/badge.svg)](https://github.com/privilegedescalation/headlamp-kube-vip-plugin/actions/workflows/ci.yml)
[![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://opensource.org/licenses/Apache-2.0)
A [Headlamp](https://headlamp.dev/) plugin providing visibility into [kube-vip](https://kube-vip.io/) virtual IP and load balancer deployments.
## Features
- **Overview Dashboard** — Deployment status, VIP mode (ARP/BGP), leader election, cluster summary
- **Services** — LoadBalancer services with VIP assignments, kube-vip annotations, egress status
- **Nodes** — Cluster nodes with kube-vip pod status, leader designation, VIP labels
- **Configuration** — DaemonSet config, IP address pools, leader election leases
- **Service Detail Integration** — kube-vip details injected into native Headlamp Service detail views
## Installation
### Plugin Manager (Headlamp UI)
Search for `kube-vip` in the Headlamp Plugin Manager.
### Manual
```bash
# Download the latest release tarball
curl -LO https://github.com/privilegedescalation/headlamp-kube-vip-plugin/releases/latest/download/kube-vip-*.tar.gz
# Extract to Headlamp plugins directory
mkdir -p ~/.config/Headlamp/plugins
tar -xzf kube-vip-*.tar.gz -C ~/.config/Headlamp/plugins/
```
### From Source
```bash
git clone https://github.com/privilegedescalation/headlamp-kube-vip-plugin.git
cd headlamp-kube-vip-plugin
npm install
npm run build
```
## Requirements
- Headlamp >= v0.26
- kube-vip deployed in `kube-system` (DaemonSet or static pod)
- Optional: kube-vip-cloud-provider for IP pool management
## RBAC
This plugin is **read-only** and requires the following permissions:
| Resource | API Group | Verbs |
|----------|-----------|-------|
| services | v1 | list, get, watch |
| nodes | v1 | list, get, watch |
| pods | v1 | list, get, watch |
| daemonsets | apps/v1 | get |
| leases | coordination.k8s.io | list, get, watch |
| configmaps | v1 | get |
## Architecture
```
src/
├── index.tsx # Plugin entry point
├── api/
│ ├── k8s.ts # Types and helper functions
│ └── KubeVipDataContext.tsx # React context provider
└── components/
├── OverviewPage.tsx # Dashboard
├── ServicesPage.tsx # LoadBalancer services
├── NodesPage.tsx # Cluster nodes
├── ConfigPage.tsx # Configuration & IP pools
└── ServiceDetailSection.tsx # Injected into Service detail view
```
## Development
```bash
npm install
npm start # dev server
npm test # run tests
npm run tsc # type check
npm run lint # ESLint
```
## Troubleshooting
| Symptom | Cause | Fix |
|---------|-------|-----|
| "kube-vip Not Detected" | No kube-vip pods in kube-system | Install kube-vip per https://kube-vip.io/docs/installation/ |
| No IP pools shown | kubevip ConfigMap not found | Install kube-vip-cloud-provider |
| Services show "Pending" VIP | No IP pool configured or pool exhausted | Add IP ranges to kubevip ConfigMap |
| Leader shows "—" | No kube-vip leases found | Verify leader election is enabled (`vip_leaderelection=true`) |
## Contributing
See [CONTRIBUTING.md](CONTRIBUTING.md) for development guidelines.
## License
Apache License 2.0. See [LICENSE](LICENSE) for details.
+24
View File
@@ -0,0 +1,24 @@
# Security Policy
## Supported Versions
| Version | Supported |
|---------|-----------|
| latest | Yes |
## Plugin Scope
This plugin is **read-only**. It does not perform any write operations against the Kubernetes cluster. It reads:
- Services (type: LoadBalancer)
- Nodes
- Pods in `kube-system`
- DaemonSets in `kube-system`
- Leases in `kube-system`
- ConfigMaps in `kube-system`
All data is fetched through Headlamp's built-in API proxy, which respects the user's existing RBAC permissions.
## Reporting a Vulnerability
Please report security vulnerabilities by opening a private issue or emailing the maintainers directly.
+18451
View File
File diff suppressed because it is too large Load Diff
+30
View File
@@ -0,0 +1,30 @@
{
"name": "kube-vip",
"version": "0.1.0",
"description": "Headlamp plugin for kube-vip virtual IP and load balancer visibility",
"repository": {
"type": "git",
"url": "https://github.com/privilegedescalation/headlamp-kube-vip-plugin.git"
},
"bugs": {
"url": "https://github.com/privilegedescalation/headlamp-kube-vip-plugin/issues"
},
"homepage": "https://github.com/privilegedescalation/headlamp-kube-vip-plugin#readme",
"author": "privilegedescalation",
"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"
}
}
+51
View File
@@ -0,0 +1,51 @@
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: {
Service: {
useList: vi.fn(() => [[], null]),
},
Node: {
useList: vi.fn(() => [[], null]),
},
},
},
}));
import { KubeVipDataProvider, useKubeVipContext } from './KubeVipDataContext';
describe('useKubeVipContext', () => {
it('throws when used outside KubeVipDataProvider', () => {
const spy = vi.spyOn(console, 'error').mockImplementation(() => {});
expect(() => {
renderHook(() => useKubeVipContext());
}).toThrow('useKubeVipContext must be used within a KubeVipDataProvider');
spy.mockRestore();
});
it('returns context value when inside KubeVipDataProvider', async () => {
const wrapper = ({ children }: { children: React.ReactNode }) => (
<KubeVipDataProvider>{children}</KubeVipDataProvider>
);
const { result } = renderHook(() => useKubeVipContext(), { wrapper });
expect(result.current).toBeDefined();
expect(result.current.loadBalancerServices).toBeInstanceOf(Array);
expect(result.current.nodes).toBeInstanceOf(Array);
expect(result.current.kubeVipPods).toBeInstanceOf(Array);
expect(result.current.cloudProviderPods).toBeInstanceOf(Array);
expect(result.current.leases).toBeInstanceOf(Array);
expect(result.current.ipPools).toBeInstanceOf(Array);
expect(typeof result.current.refresh).toBe('function');
});
});
+290
View File
@@ -0,0 +1,290 @@
/**
* KubeVipDataContext — shared data provider for kube-vip Kubernetes resources.
*
* Fetches Services (LoadBalancer), Nodes, kube-vip DaemonSet/pods,
* Leases, and the kubevip ConfigMap. 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 {
DaemonSetStatus,
filterLoadBalancerServices,
IPPool,
isKubeList,
KUBE_VIP_CLOUD_PROVIDER_SELECTOR,
KUBE_VIP_CONFIGMAP_NAME,
KUBE_VIP_DAEMONSET_NAME,
KUBE_VIP_NAMESPACE,
KUBE_VIP_POD_SELECTOR,
KubeVipConfigMap,
KubeVipDaemonSet,
KubeVipLease,
KubeVipNode,
KubeVipPod,
KubeVipService,
parseIPPools,
} from './k8s';
// ---------------------------------------------------------------------------
// Context shape
// ---------------------------------------------------------------------------
export interface KubeVipContextValue {
// kube-vip deployment
kubeVipInstalled: boolean;
daemonSetStatus: DaemonSetStatus | null;
kubeVipPods: KubeVipPod[];
cloudProviderPods: KubeVipPod[];
// Services managed by kube-vip (type: LoadBalancer)
loadBalancerServices: KubeVipService[];
// Nodes
nodes: KubeVipNode[];
// Leader election
leases: KubeVipLease[];
// IP pool configuration
ipPools: IPPool[];
configMapData: Record<string, string>;
// kube-vip configuration (from DaemonSet env vars)
kubeVipConfig: Record<string, string>;
// Loading / error state
loading: boolean;
error: string | null;
// Manual refresh trigger
refresh: () => void;
}
// ---------------------------------------------------------------------------
// Context
// ---------------------------------------------------------------------------
const KubeVipContext = createContext<KubeVipContextValue | null>(null);
export function useKubeVipContext(): KubeVipContextValue {
const ctx = useContext(KubeVipContext);
if (!ctx) {
throw new Error('useKubeVipContext must be used within a KubeVipDataProvider');
}
return ctx;
}
// ---------------------------------------------------------------------------
// Provider
// ---------------------------------------------------------------------------
export function KubeVipDataProvider({ children }: { children: React.ReactNode }) {
// Async-fetched resources
const [kubeVipPods, setKubeVipPods] = useState<KubeVipPod[]>([]);
const [cloudProviderPods, setCloudProviderPods] = useState<KubeVipPod[]>([]);
const [daemonSetStatus, setDaemonSetStatus] = useState<DaemonSetStatus | null>(null);
const [leases, setLeases] = useState<KubeVipLease[]>([]);
const [configMapData, setConfigMapData] = useState<Record<string, string>>({});
const [kubeVipConfig, setKubeVipConfig] = useState<Record<string, string>>({});
const [asyncLoading, setAsyncLoading] = useState(true);
const [asyncError, setAsyncError] = useState<string | null>(null);
const [refreshKey, setRefreshKey] = useState(0);
// K8s resource hooks — Headlamp re-fetches on cluster changes automatically
const [allServices, svcError] = K8s.ResourceClasses.Service.useList({ namespace: '' });
const [allNodes, nodeError] = K8s.ResourceClasses.Node.useList();
const refresh = useCallback(() => {
setRefreshKey(k => k + 1);
}, []);
useEffect(() => {
let cancelled = false;
async function fetchAsync() {
setAsyncLoading(true);
setAsyncError(null);
try {
// kube-vip DaemonSet
try {
const ds = (await ApiProxy.request(
`/apis/apps/v1/namespaces/${KUBE_VIP_NAMESPACE}/daemonsets/${KUBE_VIP_DAEMONSET_NAME}`
)) as KubeVipDaemonSet;
if (!cancelled) {
setDaemonSetStatus(ds.status ?? null);
// Extract config from DaemonSet template env vars
const env = ds.spec?.template?.spec?.containers?.[0]?.env;
if (env) {
const config: Record<string, string> = {};
for (const e of env) {
if (e.value !== undefined) config[e.name] = e.value;
}
setKubeVipConfig(config);
}
}
} catch {
if (!cancelled) {
setDaemonSetStatus(null);
setKubeVipConfig({});
}
}
// kube-vip pods
try {
const podList = await ApiProxy.request(
`/api/v1/namespaces/${KUBE_VIP_NAMESPACE}/pods?labelSelector=${encodeURIComponent(
KUBE_VIP_POD_SELECTOR
)}`
);
if (!cancelled && isKubeList(podList)) {
setKubeVipPods(podList.items as KubeVipPod[]);
}
} catch {
// If label selector doesn't match, try listing all pods in kube-system
// and filtering by name prefix (for static pod deployments)
try {
const allPods = await ApiProxy.request(`/api/v1/namespaces/${KUBE_VIP_NAMESPACE}/pods`);
if (!cancelled && isKubeList(allPods)) {
const kvPods = (allPods.items as KubeVipPod[]).filter(p =>
p.metadata.name.startsWith('kube-vip')
);
setKubeVipPods(kvPods);
}
} catch {
if (!cancelled) setKubeVipPods([]);
}
}
// kube-vip-cloud-provider pods
try {
const cpList = await ApiProxy.request(
`/api/v1/namespaces/${KUBE_VIP_NAMESPACE}/pods?labelSelector=${encodeURIComponent(
KUBE_VIP_CLOUD_PROVIDER_SELECTOR
)}`
);
if (!cancelled && isKubeList(cpList)) {
setCloudProviderPods(cpList.items as KubeVipPod[]);
}
} catch {
if (!cancelled) setCloudProviderPods([]);
}
// Leases (kube-vip uses leases for leader election)
try {
const leaseList = await ApiProxy.request(
`/apis/coordination.k8s.io/v1/namespaces/${KUBE_VIP_NAMESPACE}/leases`
);
if (!cancelled && isKubeList(leaseList)) {
const kvLeases = (leaseList.items as KubeVipLease[]).filter(
l => l.metadata.name.startsWith('plndr-') || l.metadata.name.startsWith('kube-vip-')
);
setLeases(kvLeases);
}
} catch {
if (!cancelled) setLeases([]);
}
// kubevip ConfigMap (IP pool configuration)
try {
const cm = (await ApiProxy.request(
`/api/v1/namespaces/${KUBE_VIP_NAMESPACE}/configmaps/${KUBE_VIP_CONFIGMAP_NAME}`
)) as KubeVipConfigMap;
if (!cancelled) {
setConfigMapData(cm.data ?? {});
}
} catch {
if (!cancelled) setConfigMapData({});
}
} 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
// ---------------------------------------------------------------------------
const extractJsonData = (items: unknown[]): unknown[] =>
items.map(item =>
item && typeof item === 'object' && 'jsonData' in item
? (item as { jsonData: unknown }).jsonData
: item
);
const loadBalancerServices = useMemo(() => {
if (!allServices) return [];
return filterLoadBalancerServices(extractJsonData(allServices as unknown[]));
}, [allServices]);
const nodes = useMemo(() => {
if (!allNodes) return [];
return extractJsonData(allNodes as unknown[]) as KubeVipNode[];
}, [allNodes]);
const ipPools = useMemo(() => parseIPPools(configMapData), [configMapData]);
// ---------------------------------------------------------------------------
// Combined loading / error state
// ---------------------------------------------------------------------------
const loading = asyncLoading || !allServices || !allNodes;
const errors: string[] = [];
if (svcError) errors.push(String(svcError));
if (nodeError) errors.push(String(nodeError));
if (asyncError) errors.push(asyncError);
const error = errors.length > 0 ? errors.join('; ') : null;
const kubeVipInstalled = kubeVipPods.length > 0 || daemonSetStatus !== null;
// ---------------------------------------------------------------------------
// Memoized context value
// ---------------------------------------------------------------------------
const value = useMemo<KubeVipContextValue>(
() => ({
kubeVipInstalled,
daemonSetStatus,
kubeVipPods,
cloudProviderPods,
loadBalancerServices,
nodes,
leases,
ipPools,
configMapData,
kubeVipConfig,
loading,
error,
refresh,
}),
[
kubeVipInstalled,
daemonSetStatus,
kubeVipPods,
cloudProviderPods,
loadBalancerServices,
nodes,
leases,
ipPools,
configMapData,
kubeVipConfig,
loading,
error,
refresh,
]
);
return <KubeVipContext.Provider value={value}>{children}</KubeVipContext.Provider>;
}
+292
View File
@@ -0,0 +1,292 @@
import { describe, expect, it } from 'vitest';
import {
extractPodConfig,
filterLoadBalancerServices,
formatAge,
getNodeInternalIP,
getNodeVipLabel,
getPodImage,
getPodRestarts,
getServiceVIPs,
getVipHost,
isControlPlaneNode,
isEgressEnabled,
isKubeList,
isKubeVipService,
isLoadBalancerService,
isNodeReady,
isPodReady,
isServiceIgnored,
parseIPPools,
phaseToStatus,
} from './k8s';
describe('isKubeList', () => {
it('returns true for objects with items array', () => {
expect(isKubeList({ items: [] })).toBe(true);
expect(isKubeList({ items: [1, 2] })).toBe(true);
});
it('returns false for non-objects and missing items', () => {
expect(isKubeList(null)).toBe(false);
expect(isKubeList(undefined)).toBe(false);
expect(isKubeList('string')).toBe(false);
expect(isKubeList({ data: [] })).toBe(false);
});
});
describe('isLoadBalancerService', () => {
it('identifies LoadBalancer services', () => {
expect(isLoadBalancerService({ spec: { type: 'LoadBalancer' }, metadata: { name: 'x' } })).toBe(
true
);
expect(isLoadBalancerService({ spec: { type: 'ClusterIP' }, metadata: { name: 'x' } })).toBe(
false
);
expect(isLoadBalancerService(null)).toBe(false);
});
});
describe('isKubeVipService', () => {
it('returns true when kube-vip annotations are present', () => {
expect(
isKubeVipService({
metadata: { name: 'x', annotations: { 'kube-vip.io/loadbalancerIPs': '1.2.3.4' } },
spec: { type: 'LoadBalancer' },
})
).toBe(true);
});
it('returns false when no kube-vip annotations', () => {
expect(
isKubeVipService({
metadata: { name: 'x' },
spec: { type: 'LoadBalancer' },
})
).toBe(false);
});
});
describe('getServiceVIPs', () => {
it('returns IPs from annotation', () => {
const vips = getServiceVIPs({
metadata: { name: 'x', annotations: { 'kube-vip.io/loadbalancerIPs': '1.2.3.4,5.6.7.8' } },
spec: { type: 'LoadBalancer' },
});
expect(vips).toEqual(['1.2.3.4', '5.6.7.8']);
});
it('falls back to status.loadBalancer.ingress', () => {
const vips = getServiceVIPs({
metadata: { name: 'x' },
spec: { type: 'LoadBalancer' },
status: { loadBalancer: { ingress: [{ ip: '10.0.0.1' }] } },
});
expect(vips).toEqual(['10.0.0.1']);
});
it('falls back to spec.loadBalancerIP', () => {
const vips = getServiceVIPs({
metadata: { name: 'x' },
spec: { type: 'LoadBalancer', loadBalancerIP: '10.0.0.2' },
});
expect(vips).toEqual(['10.0.0.2']);
});
it('returns empty array when no VIP info', () => {
const vips = getServiceVIPs({
metadata: { name: 'x' },
spec: { type: 'LoadBalancer' },
});
expect(vips).toEqual([]);
});
});
describe('getVipHost', () => {
it('returns the vipHost annotation value', () => {
expect(
getVipHost({
metadata: { name: 'x', annotations: { 'kube-vip.io/vipHost': 'node-1' } },
spec: { type: 'LoadBalancer' },
})
).toBe('node-1');
});
it('returns undefined when not present', () => {
expect(
getVipHost({
metadata: { name: 'x' },
spec: { type: 'LoadBalancer' },
})
).toBeUndefined();
});
});
describe('isEgressEnabled / isServiceIgnored', () => {
it('detects egress enabled', () => {
expect(
isEgressEnabled({
metadata: { name: 'x', annotations: { 'kube-vip.io/egress': 'true' } },
spec: { type: 'LoadBalancer' },
})
).toBe(true);
});
it('detects ignored service', () => {
expect(
isServiceIgnored({
metadata: { name: 'x', annotations: { 'kube-vip.io/ignore': 'true' } },
spec: { type: 'LoadBalancer' },
})
).toBe(true);
});
});
describe('filterLoadBalancerServices', () => {
it('filters only LoadBalancer services', () => {
const items = [
{ spec: { type: 'LoadBalancer' }, metadata: { name: 'a' } },
{ spec: { type: 'ClusterIP' }, metadata: { name: 'b' } },
{ spec: { type: 'LoadBalancer' }, metadata: { name: 'c' } },
];
expect(filterLoadBalancerServices(items)).toHaveLength(2);
});
});
describe('Node helpers', () => {
const node = {
metadata: {
name: 'node-1',
labels: { 'node-role.kubernetes.io/control-plane': '' },
},
status: {
conditions: [{ type: 'Ready', status: 'True' }],
addresses: [{ type: 'InternalIP', address: '10.0.0.1' }],
},
};
it('isNodeReady returns true for Ready node', () => {
expect(isNodeReady(node)).toBe(true);
});
it('isControlPlaneNode returns true for control-plane labeled node', () => {
expect(isControlPlaneNode(node)).toBe(true);
});
it('getNodeInternalIP returns the InternalIP', () => {
expect(getNodeInternalIP(node)).toBe('10.0.0.1');
});
it('getNodeVipLabel returns undefined when no VIP label', () => {
expect(getNodeVipLabel(node)).toBeUndefined();
});
});
describe('Pod helpers', () => {
const pod = {
metadata: { name: 'kube-vip-ds-abc' },
spec: {
containers: [
{
name: 'kube-vip',
image: 'ghcr.io/kube-vip/kube-vip:v0.8.0',
env: [
{ name: 'address', value: '192.168.1.100' },
{ name: 'vip_arp', value: 'true' },
],
},
],
},
status: {
phase: 'Running',
conditions: [{ type: 'Ready', status: 'True' }],
containerStatuses: [
{
name: 'kube-vip',
ready: true,
restartCount: 2,
image: 'ghcr.io/kube-vip/kube-vip:v0.8.0',
},
],
},
};
it('isPodReady returns true for Ready pod', () => {
expect(isPodReady(pod)).toBe(true);
});
it('getPodRestarts sums container restarts', () => {
expect(getPodRestarts(pod)).toBe(2);
});
it('getPodImage returns container image', () => {
expect(getPodImage(pod)).toBe('ghcr.io/kube-vip/kube-vip:v0.8.0');
});
it('extractPodConfig extracts env vars', () => {
const config = extractPodConfig(pod);
expect(config).toEqual({ address: '192.168.1.100', vip_arp: 'true' });
});
});
describe('parseIPPools', () => {
it('parses range and cidr pools', () => {
const data = {
'range-global': '192.168.1.200-192.168.1.250',
'cidr-default': '10.0.0.0/24',
};
const pools = parseIPPools(data);
expect(pools).toHaveLength(2);
expect(pools[0]).toEqual({
name: 'range-global',
type: 'range',
value: '192.168.1.200-192.168.1.250',
scope: 'global',
});
expect(pools[1]).toEqual({
name: 'cidr-default',
type: 'cidr',
value: '10.0.0.0/24',
scope: 'global',
});
});
it('parses namespace-scoped pools', () => {
const data = {
'staging/range-pool1': '10.1.0.100-10.1.0.200',
};
const pools = parseIPPools(data);
expect(pools).toHaveLength(1);
expect(pools[0].scope).toBe('namespace');
expect(pools[0].namespace).toBe('staging');
});
it('returns empty for undefined data', () => {
expect(parseIPPools(undefined)).toEqual([]);
});
});
describe('formatAge', () => {
it('returns "unknown" for undefined', () => {
expect(formatAge(undefined)).toBe('unknown');
});
it('formats days', () => {
const twoDaysAgo = new Date(Date.now() - 2 * 24 * 60 * 60 * 1000).toISOString();
expect(formatAge(twoDaysAgo)).toBe('2d');
});
});
describe('phaseToStatus', () => {
it('maps Running to success', () => {
expect(phaseToStatus('Running')).toBe('success');
});
it('maps Pending to warning', () => {
expect(phaseToStatus('Pending')).toBe('warning');
});
it('maps unknown to error', () => {
expect(phaseToStatus('Failed')).toBe('error');
});
});
+406
View File
@@ -0,0 +1,406 @@
/**
* Kubernetes type definitions and helper functions for kube-vip resources.
*
* kube-vip uses no CRDs — all state is in standard Kubernetes resources
* (DaemonSets, Pods, Services, Nodes, Leases, ConfigMaps).
*/
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
export const KUBE_VIP_NAMESPACE = 'kube-system' as const;
export const KUBE_VIP_DAEMONSET_NAME = 'kube-vip-ds' as const;
export const KUBE_VIP_CLOUD_PROVIDER_NAME = 'kube-vip-cloud-provider' as const;
export const KUBE_VIP_CONFIGMAP_NAME = 'kubevip' as const;
export const KUBE_VIP_ANNOTATION_PREFIX = 'kube-vip.io/' as const;
export const KUBE_VIP_METRICS_PORT = 2112 as const;
/** Label selectors for kube-vip pods. */
export const KUBE_VIP_POD_SELECTOR = 'app.kubernetes.io/name=kube-vip-ds';
export const KUBE_VIP_CLOUD_PROVIDER_SELECTOR = 'app=kube-vip-cloud-provider';
// ---------------------------------------------------------------------------
// Annotation keys
// ---------------------------------------------------------------------------
export const ANNOTATION_LOADBALANCER_IPS = 'kube-vip.io/loadbalancerIPs';
export const ANNOTATION_IGNORE = 'kube-vip.io/ignore';
export const ANNOTATION_VIP_HOST = 'kube-vip.io/vipHost';
export const ANNOTATION_EGRESS = 'kube-vip.io/egress';
export const ANNOTATION_SERVICE_INTERFACE = 'kube-vip.io/serviceInterface';
export const ANNOTATION_HOSTNAME = 'kube-vip.io/loadbalancerHostname';
// ---------------------------------------------------------------------------
// 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;
}
// ---------------------------------------------------------------------------
// 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']);
}
// ---------------------------------------------------------------------------
// Service (LoadBalancer)
// ---------------------------------------------------------------------------
export interface ServicePort {
name?: string;
protocol?: string;
port: number;
targetPort?: number | string;
nodePort?: number;
}
export interface ServiceSpec {
type?: string;
clusterIP?: string;
externalTrafficPolicy?: string;
loadBalancerIP?: string;
ports?: ServicePort[];
selector?: Record<string, string>;
}
export interface ServiceStatus {
loadBalancer?: {
ingress?: Array<{ ip?: string; hostname?: string }>;
};
}
export interface KubeVipService extends KubeObject {
spec: ServiceSpec;
status?: ServiceStatus;
}
/** Returns true if a Service is of type LoadBalancer. */
export function isLoadBalancerService(svc: unknown): svc is KubeVipService {
if (!svc || typeof svc !== 'object') return false;
const obj = svc as Record<string, unknown>;
const spec = obj['spec'] as Record<string, unknown> | undefined;
return spec?.['type'] === 'LoadBalancer';
}
/** Returns true if a LoadBalancer service has a kube-vip annotation. */
export function isKubeVipService(svc: KubeVipService): boolean {
const annotations = svc.metadata.annotations ?? {};
return Object.keys(annotations).some(key => key.startsWith(KUBE_VIP_ANNOTATION_PREFIX));
}
/** Get the VIP address(es) from a service. */
export function getServiceVIPs(svc: KubeVipService): string[] {
// Check kube-vip annotation first
const annotatedIPs = svc.metadata.annotations?.[ANNOTATION_LOADBALANCER_IPS];
if (annotatedIPs) return annotatedIPs.split(',').map(ip => ip.trim());
// Fall back to status.loadBalancer.ingress
const ingress = svc.status?.loadBalancer?.ingress;
if (ingress && ingress.length > 0) {
return ingress.map(i => i.ip ?? i.hostname ?? '').filter(Boolean);
}
// Fall back to spec.loadBalancerIP
if (svc.spec.loadBalancerIP) return [svc.spec.loadBalancerIP];
return [];
}
/** Get the node currently hosting the VIP for this service. */
export function getVipHost(svc: KubeVipService): string | undefined {
return svc.metadata.annotations?.[ANNOTATION_VIP_HOST];
}
/** Check if egress is enabled on this service. */
export function isEgressEnabled(svc: KubeVipService): boolean {
return svc.metadata.annotations?.[ANNOTATION_EGRESS] === 'true';
}
/** Check if service is ignored by kube-vip. */
export function isServiceIgnored(svc: KubeVipService): boolean {
return svc.metadata.annotations?.[ANNOTATION_IGNORE] === 'true';
}
/** Filter LoadBalancer services from a list of unknown objects. */
export function filterLoadBalancerServices(items: unknown[]): KubeVipService[] {
return items.filter(isLoadBalancerService);
}
// ---------------------------------------------------------------------------
// Node
// ---------------------------------------------------------------------------
export interface NodeAddress {
type: string;
address: string;
}
export interface NodeCondition {
type: string;
status: string;
reason?: string;
message?: string;
lastTransitionTime?: string;
}
export interface NodeStatus {
conditions?: NodeCondition[];
addresses?: NodeAddress[];
nodeInfo?: {
kubeletVersion?: string;
osImage?: string;
containerRuntimeVersion?: string;
architecture?: string;
};
allocatable?: Record<string, string>;
capacity?: Record<string, string>;
}
export interface NodeSpec {
podCIDR?: string;
taints?: Array<{ key: string; effect: string; value?: string }>;
}
export interface KubeVipNode extends KubeObject {
spec?: NodeSpec;
status?: NodeStatus;
}
/** Check if a node is Ready. */
export function isNodeReady(node: KubeVipNode): boolean {
return node.status?.conditions?.some(c => c.type === 'Ready' && c.status === 'True') ?? false;
}
/** Get the InternalIP of a node. */
export function getNodeInternalIP(node: KubeVipNode): string {
return node.status?.addresses?.find(a => a.type === 'InternalIP')?.address ?? '—';
}
/** Check if a node is a control plane node. */
export function isControlPlaneNode(node: KubeVipNode): boolean {
const labels = node.metadata.labels ?? {};
return (
'node-role.kubernetes.io/control-plane' in labels || 'node-role.kubernetes.io/master' in labels
);
}
/** Get kube-vip VIP label from a node (if node labeling is enabled). */
export function getNodeVipLabel(node: KubeVipNode): string | undefined {
const labels = node.metadata.labels ?? {};
for (const [key, value] of Object.entries(labels)) {
if (key.startsWith('kube-vip.io/has-ip=')) return value;
if (key === 'kube-vip.io/has-ip') return value;
}
return undefined;
}
// ---------------------------------------------------------------------------
// 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[];
hostIP?: string;
podIP?: string;
}
export interface PodSpec {
nodeName?: string;
hostNetwork?: boolean;
containers?: Array<{
name: string;
image?: string;
env?: Array<{ name: string; value?: string }>;
args?: string[];
}>;
}
export interface KubeVipPod extends KubeObject {
spec?: PodSpec;
status?: PodStatus;
}
/** Check if a pod is Ready. */
export function isPodReady(pod: KubeVipPod): boolean {
return pod.status?.conditions?.some(c => c.type === 'Ready' && c.status === 'True') ?? false;
}
/** Get total restarts for a pod. */
export function getPodRestarts(pod: KubeVipPod): number {
return pod.status?.containerStatuses?.reduce((sum, c) => sum + c.restartCount, 0) ?? 0;
}
/** Get the container image for a pod. */
export function getPodImage(pod: KubeVipPod): string {
return pod.spec?.containers?.[0]?.image ?? pod.status?.containerStatuses?.[0]?.image ?? 'unknown';
}
/** Extract kube-vip configuration from pod environment variables. */
export function extractPodConfig(pod: KubeVipPod): Record<string, string> {
const config: Record<string, string> = {};
const env = pod.spec?.containers?.[0]?.env;
if (!env) return config;
for (const e of env) {
if (e.value !== undefined) {
config[e.name] = e.value;
}
}
return config;
}
// ---------------------------------------------------------------------------
// DaemonSet
// ---------------------------------------------------------------------------
export interface DaemonSetStatus {
currentNumberScheduled?: number;
desiredNumberScheduled?: number;
numberReady?: number;
numberAvailable?: number;
numberMisscheduled?: number;
updatedNumberScheduled?: number;
}
export interface DaemonSetSpec {
selector?: { matchLabels?: Record<string, string> };
template?: {
spec?: PodSpec;
};
}
export interface KubeVipDaemonSet extends KubeObject {
spec?: DaemonSetSpec;
status?: DaemonSetStatus;
}
// ---------------------------------------------------------------------------
// Lease (leader election)
// ---------------------------------------------------------------------------
export interface LeaseSpec {
holderIdentity?: string;
leaseDurationSeconds?: number;
acquireTime?: string;
renewTime?: string;
leaseTransitions?: number;
}
export interface KubeVipLease extends KubeObject {
spec?: LeaseSpec;
}
// ---------------------------------------------------------------------------
// ConfigMap (IP pool configuration for kube-vip-cloud-provider)
// ---------------------------------------------------------------------------
export interface KubeVipConfigMap extends KubeObject {
data?: Record<string, string>;
}
/** Parse IP pool ranges from the kubevip ConfigMap data. */
export function parseIPPools(data: Record<string, string> | undefined): IPPool[] {
if (!data) return [];
const pools: IPPool[] = [];
for (const [key, value] of Object.entries(data)) {
if (key.startsWith('range-') || key.startsWith('cidr-')) {
pools.push({
name: key,
type: key.startsWith('range-') ? 'range' : 'cidr',
value,
scope: 'global',
});
} else if (key.includes('/')) {
// Namespace-specific pool: "namespace/range-name" or "namespace/cidr-name"
const [ns, poolName] = key.split('/', 2);
const type = poolName.startsWith('range-')
? 'range'
: poolName.startsWith('cidr-')
? 'cidr'
: 'unknown';
pools.push({
name: poolName,
type,
value,
scope: 'namespace',
namespace: ns,
});
}
}
return pools;
}
export interface IPPool {
name: string;
type: 'range' | 'cidr' | 'unknown';
value: string;
scope: 'global' | 'namespace';
namespace?: string;
}
// ---------------------------------------------------------------------------
// 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`;
}
export function phaseToStatus(phase: string | undefined): 'success' | 'warning' | 'error' {
switch (phase) {
case 'Running':
case 'Active':
case 'Ready':
case 'Bound':
return 'success';
case 'Pending':
case 'Terminating':
return 'warning';
default:
return 'error';
}
}
+113
View File
@@ -0,0 +1,113 @@
import { render, screen } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
vi.mock(
'@kinvolk/headlamp-plugin/lib/CommonComponents',
async () => await import('./__mocks__/commonComponents')
);
vi.mock('../api/KubeVipDataContext');
import { useKubeVipContext } from '../api/KubeVipDataContext';
import {
defaultContext,
makeSampleDaemonSetStatus,
makeSampleLease,
makeSamplePod,
} from '../test-helpers';
import ConfigPage from './ConfigPage';
function mockContext(overrides?: Parameters<typeof defaultContext>[0]) {
vi.mocked(useKubeVipContext).mockReturnValue(defaultContext(overrides));
}
describe('ConfigPage', () => {
it('shows loader when loading', () => {
mockContext({ loading: true });
render(<ConfigPage />);
expect(screen.getByTestId('loader')).toHaveTextContent('Loading configuration...');
});
it('shows error state', () => {
mockContext({ error: 'config error' });
render(<ConfigPage />);
expect(screen.getByText('config error')).toBeInTheDocument();
});
it('shows not installed message when kube-vip is absent', () => {
mockContext({ kubeVipInstalled: false });
render(<ConfigPage />);
expect(screen.getByText(/not installed/)).toBeInTheDocument();
});
it('renders DaemonSet status when available', () => {
mockContext({
kubeVipInstalled: true,
daemonSetStatus: makeSampleDaemonSetStatus(),
});
render(<ConfigPage />);
expect(screen.getByText('DaemonSet Status')).toBeInTheDocument();
});
it('renders kube-vip configuration from env vars', () => {
mockContext({
kubeVipInstalled: true,
kubeVipConfig: {
address: '192.168.1.100',
vip_arp: 'true',
cp_enable: 'true',
svc_enable: 'false',
vip_interface: 'eth0',
},
});
render(<ConfigPage />);
expect(screen.getByText('kube-vip Configuration')).toBeInTheDocument();
expect(screen.getByText('192.168.1.100')).toBeInTheDocument();
expect(screen.getByText('eth0')).toBeInTheDocument();
});
it('renders IP pools table', () => {
mockContext({
kubeVipInstalled: true,
ipPools: [
{ name: 'range-global', type: 'range', value: '10.0.0.100-10.0.0.200', scope: 'global' },
],
configMapData: { 'range-global': '10.0.0.100-10.0.0.200' },
});
render(<ConfigPage />);
expect(screen.getByText('IP Address Pools')).toBeInTheDocument();
expect(screen.getByText('range-global')).toBeInTheDocument();
});
it('shows no IP pools message when empty', () => {
mockContext({
kubeVipInstalled: true,
ipPools: [],
configMapData: {},
});
render(<ConfigPage />);
expect(screen.getByText(/No kubevip ConfigMap found/)).toBeInTheDocument();
});
it('renders leader election leases', () => {
const lease = makeSampleLease();
mockContext({
kubeVipInstalled: true,
leases: [lease],
});
render(<ConfigPage />);
expect(screen.getByText('Leader Election Leases')).toBeInTheDocument();
expect(screen.getByText('plndr-cp-lock')).toBeInTheDocument();
});
it('renders kube-vip pods section', () => {
const pod = makeSamplePod();
mockContext({
kubeVipInstalled: true,
kubeVipPods: [pod],
});
render(<ConfigPage />);
expect(screen.getByText('kube-vip Pods')).toBeInTheDocument();
expect(screen.getByText('kube-vip-ds-abc12')).toBeInTheDocument();
});
});
+246
View File
@@ -0,0 +1,246 @@
/**
* ConfigPage — kube-vip configuration and IP pool management.
*
* Shows: kube-vip DaemonSet configuration (from env vars), IP pool
* assignments from the kubevip ConfigMap, and leader election leases.
*/
import {
Loader,
NameValueTable,
SectionBox,
SectionHeader,
SimpleTable,
StatusLabel,
} from '@kinvolk/headlamp-plugin/lib/CommonComponents';
import React from 'react';
import { formatAge, getPodImage, isPodReady, phaseToStatus } from '../api/k8s';
import { useKubeVipContext } from '../api/KubeVipDataContext';
/** Display-friendly names for kube-vip environment variables. */
const ENV_LABELS: Record<string, string> = {
address: 'VIP Address',
port: 'VIP Port',
vip_arp: 'ARP Mode',
bgp_enable: 'BGP Mode',
vip_interface: 'Interface',
vip_leaderelection: 'Leader Election',
vip_leaseduration: 'Lease Duration (s)',
vip_renewdeadline: 'Renew Deadline (s)',
vip_retryperiod: 'Retry Period (s)',
cp_enable: 'Control Plane HA',
svc_enable: 'Service LB',
svc_election: 'Per-Service Election',
lb_enable: 'IPVS Load Balancer',
lb_port: 'LB Port',
lb_fwdmethod: 'LB Forwarding Method',
vip_servicesinterface: 'Services Interface',
bgp_routerid: 'BGP Router ID',
bgp_as: 'BGP Local AS',
bgp_peeraddress: 'BGP Peer Address',
bgp_peeras: 'BGP Peer AS',
bgp_peers: 'BGP Peers',
prometheus_server: 'Prometheus Server',
vip_loglevel: 'Log Level',
enable_node_labeling: 'Node Labeling',
vip_subnet: 'VIP Subnet',
};
export default function ConfigPage() {
const {
kubeVipInstalled,
kubeVipConfig,
kubeVipPods,
cloudProviderPods,
daemonSetStatus,
ipPools,
configMapData,
leases,
loading,
error,
} = useKubeVipContext();
if (loading) {
return <Loader title="Loading configuration..." />;
}
if (error) {
return (
<SectionBox title="Error">
<NameValueTable
rows={[{ name: 'Status', value: <StatusLabel status="error">{error}</StatusLabel> }]}
/>
</SectionBox>
);
}
if (!kubeVipInstalled) {
return (
<>
<SectionHeader title="kube-vip — Configuration" />
<SectionBox>
<p>kube-vip is not installed. No configuration available.</p>
</SectionBox>
</>
);
}
// Build config rows from known env vars, in a defined order
const configKeys = Object.keys(ENV_LABELS);
const knownConfigRows = configKeys
.filter(key => kubeVipConfig[key] !== undefined)
.map(key => ({
name: ENV_LABELS[key],
value:
kubeVipConfig[key] === 'true' ? (
<StatusLabel status="success">Enabled</StatusLabel>
) : kubeVipConfig[key] === 'false' ? (
'Disabled'
) : (
kubeVipConfig[key]
),
}));
// Extra env vars not in our known list
const extraConfigRows = Object.entries(kubeVipConfig)
.filter(([key]) => !configKeys.includes(key))
.map(([key, value]) => ({ name: key, value }));
return (
<>
<SectionHeader title="kube-vip — Configuration" />
{/* DaemonSet status */}
{daemonSetStatus && (
<SectionBox title="DaemonSet Status">
<NameValueTable
rows={[
{ name: 'Desired', value: String(daemonSetStatus.desiredNumberScheduled ?? 0) },
{ name: 'Current', value: String(daemonSetStatus.currentNumberScheduled ?? 0) },
{ name: 'Ready', value: String(daemonSetStatus.numberReady ?? 0) },
{ name: 'Available', value: String(daemonSetStatus.numberAvailable ?? 0) },
{ name: 'Updated', value: String(daemonSetStatus.updatedNumberScheduled ?? 0) },
...(daemonSetStatus.numberMisscheduled
? [
{
name: 'Misscheduled',
value: (
<StatusLabel status="warning">
{daemonSetStatus.numberMisscheduled}
</StatusLabel>
),
},
]
: []),
]}
/>
</SectionBox>
)}
{/* kube-vip configuration from env vars */}
{knownConfigRows.length > 0 && (
<SectionBox title="kube-vip Configuration">
<NameValueTable rows={knownConfigRows} />
</SectionBox>
)}
{extraConfigRows.length > 0 && (
<SectionBox title="Additional Environment Variables">
<NameValueTable rows={extraConfigRows} />
</SectionBox>
)}
{/* IP Pools from ConfigMap */}
{ipPools.length > 0 && (
<SectionBox title="IP Address Pools">
<SimpleTable
columns={[
{ label: 'Name', getter: p => p.name },
{ label: 'Type', getter: p => p.type.toUpperCase() },
{ label: 'Value', getter: p => p.value },
{
label: 'Scope',
getter: p => (p.scope === 'namespace' ? p.namespace ?? '—' : 'Global'),
},
]}
data={ipPools}
/>
</SectionBox>
)}
{Object.keys(configMapData).length === 0 && ipPools.length === 0 && (
<SectionBox title="IP Address Pools">
<p>
No kubevip ConfigMap found. IP pools are not configured (kube-vip-cloud-provider may not
be installed).
</p>
</SectionBox>
)}
{/* Leader Election Leases */}
{leases.length > 0 && (
<SectionBox title="Leader Election Leases">
<SimpleTable
columns={[
{ label: 'Name', getter: l => l.metadata.name },
{ label: 'Holder', getter: l => l.spec?.holderIdentity ?? '—' },
{ label: 'Duration (s)', getter: l => String(l.spec?.leaseDurationSeconds ?? '—') },
{ label: 'Transitions', getter: l => String(l.spec?.leaseTransitions ?? 0) },
{ label: 'Last Renewed', getter: l => formatAge(l.spec?.renewTime) },
]}
data={leases}
/>
</SectionBox>
)}
{/* Pod details */}
{kubeVipPods.length > 0 && (
<SectionBox title="kube-vip Pods">
<SimpleTable
columns={[
{ label: 'Name', getter: p => p.metadata.name },
{ label: 'Node', getter: p => p.spec?.nodeName ?? '—' },
{
label: 'Status',
getter: p => (
<StatusLabel status={phaseToStatus(p.status?.phase)}>
{p.status?.phase ?? 'Unknown'}
</StatusLabel>
),
},
{
label: 'Ready',
getter: p => (isPodReady(p) ? 'Yes' : 'No'),
},
{ label: 'Image', getter: p => getPodImage(p) },
{ label: 'Age', getter: p => formatAge(p.metadata.creationTimestamp) },
]}
data={kubeVipPods}
/>
</SectionBox>
)}
{cloudProviderPods.length > 0 && (
<SectionBox title="Cloud Provider Pods">
<SimpleTable
columns={[
{ label: 'Name', getter: p => p.metadata.name },
{ label: 'Node', getter: p => p.spec?.nodeName ?? '—' },
{
label: 'Status',
getter: p => (
<StatusLabel status={phaseToStatus(p.status?.phase)}>
{p.status?.phase ?? 'Unknown'}
</StatusLabel>
),
},
{ label: 'Image', getter: p => getPodImage(p) },
{ label: 'Age', getter: p => formatAge(p.metadata.creationTimestamp) },
]}
data={cloudProviderPods}
/>
</SectionBox>
)}
</>
);
}
+76
View File
@@ -0,0 +1,76 @@
import { render, screen } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
vi.mock(
'@kinvolk/headlamp-plugin/lib/CommonComponents',
async () => await import('./__mocks__/commonComponents')
);
vi.mock('../api/KubeVipDataContext');
import { useKubeVipContext } from '../api/KubeVipDataContext';
import { defaultContext, makeSampleLease, makeSampleNode, makeSamplePod } from '../test-helpers';
import NodesPage from './NodesPage';
function mockContext(overrides?: Parameters<typeof defaultContext>[0]) {
vi.mocked(useKubeVipContext).mockReturnValue(defaultContext(overrides));
}
describe('NodesPage', () => {
it('shows loader when loading', () => {
mockContext({ loading: true });
render(<NodesPage />);
expect(screen.getByTestId('loader')).toHaveTextContent('Loading nodes...');
});
it('shows error state', () => {
mockContext({ error: 'nodes error' });
render(<NodesPage />);
expect(screen.getByText('nodes error')).toBeInTheDocument();
});
it('renders control plane nodes section', () => {
const node = makeSampleNode();
const pod = makeSamplePod();
mockContext({ nodes: [node], kubeVipPods: [pod] });
render(<NodesPage />);
expect(screen.getByText('Control Plane Nodes (1)')).toBeInTheDocument();
expect(screen.getByText('node-1')).toBeInTheDocument();
expect(screen.getByText('10.0.0.1')).toBeInTheDocument();
});
it('renders worker nodes section', () => {
const worker = makeSampleNode({
metadata: { name: 'worker-1', labels: { 'kubernetes.io/hostname': 'worker-1' } },
});
mockContext({ nodes: [worker] });
render(<NodesPage />);
expect(screen.getByText('Worker Nodes (1)')).toBeInTheDocument();
expect(screen.getByText('worker-1')).toBeInTheDocument();
});
it('shows leader status for nodes with matching lease', () => {
const node = makeSampleNode();
const lease = makeSampleLease();
const pod = makeSamplePod();
mockContext({ nodes: [node], leases: [lease], kubeVipPods: [pod] });
render(<NodesPage />);
// "Leader" appears as both a column header and a StatusLabel value
expect(screen.getAllByText('Leader').length).toBeGreaterThanOrEqual(2);
});
it('shows kube-vip pod status per node', () => {
const node = makeSampleNode();
const pod = makeSamplePod();
mockContext({ nodes: [node], kubeVipPods: [pod] });
render(<NodesPage />);
expect(screen.getByText('Running')).toBeInTheDocument();
});
it('shows kubelet version', () => {
const node = makeSampleNode();
mockContext({ nodes: [node] });
render(<NodesPage />);
expect(screen.getByText('v1.30.0')).toBeInTheDocument();
});
});
+145
View File
@@ -0,0 +1,145 @@
/**
* NodesPage — cluster nodes with kube-vip VIP assignments.
*
* Shows all nodes with their roles, readiness, kube-vip pod status,
* and any VIP labels applied by kube-vip.
*/
import {
Loader,
NameValueTable,
SectionBox,
SectionHeader,
SimpleTable,
StatusLabel,
} from '@kinvolk/headlamp-plugin/lib/CommonComponents';
import React from 'react';
import {
formatAge,
getNodeInternalIP,
getNodeVipLabel,
isControlPlaneNode,
isNodeReady,
} from '../api/k8s';
import { useKubeVipContext } from '../api/KubeVipDataContext';
export default function NodesPage() {
const { nodes, kubeVipPods, leases, loading, error } = useKubeVipContext();
if (loading) {
return <Loader title="Loading nodes..." />;
}
if (error) {
return (
<SectionBox title="Error">
<NameValueTable
rows={[{ name: 'Status', value: <StatusLabel status="error">{error}</StatusLabel> }]}
/>
</SectionBox>
);
}
// Build a map of node → kube-vip pod
const podByNode = new Map<string, (typeof kubeVipPods)[0]>();
for (const pod of kubeVipPods) {
if (pod.spec?.nodeName) {
podByNode.set(pod.spec.nodeName, pod);
}
}
// Determine leader from leases
const leaderIdentities = new Set<string>();
for (const lease of leases) {
if (lease.spec?.holderIdentity) {
leaderIdentities.add(lease.spec.holderIdentity);
}
}
const controlPlane = nodes.filter(isControlPlaneNode);
const workers = nodes.filter(n => !isControlPlaneNode(n));
return (
<>
<SectionHeader title="kube-vip — Nodes" />
{controlPlane.length > 0 && (
<SectionBox title={`Control Plane Nodes (${controlPlane.length})`}>
<SimpleTable
columns={[
{ label: 'Name', getter: n => n.metadata.name },
{ label: 'IP', getter: n => getNodeInternalIP(n) },
{
label: 'Ready',
getter: n => (
<StatusLabel status={isNodeReady(n) ? 'success' : 'error'}>
{isNodeReady(n) ? 'Ready' : 'NotReady'}
</StatusLabel>
),
},
{
label: 'kube-vip Pod',
getter: n => {
const pod = podByNode.get(n.metadata.name);
if (!pod) return '—';
return (
<StatusLabel status={pod.status?.phase === 'Running' ? 'success' : 'warning'}>
{pod.status?.phase ?? 'Unknown'}
</StatusLabel>
);
},
},
{
label: 'Leader',
getter: n =>
leaderIdentities.has(n.metadata.name) ? (
<StatusLabel status="success">Leader</StatusLabel>
) : (
'—'
),
},
{ label: 'VIP Label', getter: n => getNodeVipLabel(n) ?? '—' },
{ label: 'Kubelet', getter: n => n.status?.nodeInfo?.kubeletVersion ?? '—' },
{ label: 'Age', getter: n => formatAge(n.metadata.creationTimestamp) },
]}
data={controlPlane}
/>
</SectionBox>
)}
{workers.length > 0 && (
<SectionBox title={`Worker Nodes (${workers.length})`}>
<SimpleTable
columns={[
{ label: 'Name', getter: n => n.metadata.name },
{ label: 'IP', getter: n => getNodeInternalIP(n) },
{
label: 'Ready',
getter: n => (
<StatusLabel status={isNodeReady(n) ? 'success' : 'error'}>
{isNodeReady(n) ? 'Ready' : 'NotReady'}
</StatusLabel>
),
},
{
label: 'kube-vip Pod',
getter: n => {
const pod = podByNode.get(n.metadata.name);
if (!pod) return '—';
return (
<StatusLabel status={pod.status?.phase === 'Running' ? 'success' : 'warning'}>
{pod.status?.phase ?? 'Unknown'}
</StatusLabel>
);
},
},
{ label: 'Kubelet', getter: n => n.status?.nodeInfo?.kubeletVersion ?? '—' },
{ label: 'Age', getter: n => formatAge(n.metadata.creationTimestamp) },
]}
data={workers}
/>
</SectionBox>
)}
</>
);
}
+124
View File
@@ -0,0 +1,124 @@
import { render, screen } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
vi.mock(
'@kinvolk/headlamp-plugin/lib/CommonComponents',
async () => await import('./__mocks__/commonComponents')
);
vi.mock('../api/KubeVipDataContext');
import { useKubeVipContext } from '../api/KubeVipDataContext';
import {
defaultContext,
makeSampleLease,
makeSampleNode,
makeSamplePod,
makeSampleService,
} from '../test-helpers';
import OverviewPage from './OverviewPage';
function mockContext(overrides?: Parameters<typeof defaultContext>[0]) {
vi.mocked(useKubeVipContext).mockReturnValue(defaultContext(overrides));
}
describe('OverviewPage', () => {
it('shows loader when loading', () => {
mockContext({ loading: true });
render(<OverviewPage />);
expect(screen.getByTestId('loader')).toHaveTextContent('Loading kube-vip data...');
});
it('shows error state', () => {
mockContext({ error: 'api error' });
render(<OverviewPage />);
expect(screen.getByText('api error')).toBeInTheDocument();
});
it('shows "not detected" when kube-vip is not installed', () => {
mockContext({ kubeVipInstalled: false });
render(<OverviewPage />);
expect(screen.getByText('kube-vip Not Detected')).toBeInTheDocument();
});
it('renders deployment info when installed', () => {
const pod = makeSamplePod();
mockContext({
kubeVipInstalled: true,
kubeVipPods: [pod],
kubeVipConfig: {
vip_arp: 'true',
cp_enable: 'true',
svc_enable: 'true',
address: '192.168.1.100',
},
});
render(<OverviewPage />);
expect(screen.getByText('Deployment')).toBeInTheDocument();
expect(screen.getByText('ARP')).toBeInTheDocument();
expect(screen.getByText('192.168.1.100')).toBeInTheDocument();
});
it('renders LoadBalancer services table', () => {
const svc = makeSampleService();
mockContext({
kubeVipInstalled: true,
kubeVipPods: [makeSamplePod()],
loadBalancerServices: [svc],
});
render(<OverviewPage />);
expect(screen.getByText('my-service')).toBeInTheDocument();
expect(screen.getByText('192.168.1.200')).toBeInTheDocument();
});
it('renders IP pools when available', () => {
mockContext({
kubeVipInstalled: true,
kubeVipPods: [makeSamplePod()],
ipPools: [
{ name: 'range-global', type: 'range', value: '10.0.0.100-10.0.0.200', scope: 'global' },
],
});
render(<OverviewPage />);
expect(screen.getByText('range-global')).toBeInTheDocument();
expect(screen.getByText('10.0.0.100-10.0.0.200')).toBeInTheDocument();
});
it('shows cluster summary with node counts', () => {
const node = makeSampleNode();
const workerNode = makeSampleNode({
metadata: { name: 'worker-1', labels: { 'kubernetes.io/hostname': 'worker-1' } },
});
mockContext({
kubeVipInstalled: true,
kubeVipPods: [makeSamplePod()],
nodes: [node, workerNode],
});
render(<OverviewPage />);
expect(screen.getByText('Cluster Summary')).toBeInTheDocument();
});
it('shows leader from leases', () => {
const lease = makeSampleLease();
mockContext({
kubeVipInstalled: true,
kubeVipPods: [makeSamplePod()],
leases: [lease],
kubeVipConfig: { vip_arp: 'true', cp_enable: 'true', svc_enable: 'true' },
});
render(<OverviewPage />);
// "node-1" appears in both the Leader row and the pod table Node column;
// verify it appears at least twice (leader + pod row)
expect(screen.getAllByText('node-1').length).toBeGreaterThanOrEqual(2);
});
it('detects BGP mode', () => {
mockContext({
kubeVipInstalled: true,
kubeVipPods: [makeSamplePod()],
kubeVipConfig: { bgp_enable: 'true', cp_enable: 'true', svc_enable: 'true' },
});
render(<OverviewPage />);
expect(screen.getByText('BGP')).toBeInTheDocument();
});
});
+272
View File
@@ -0,0 +1,272 @@
/**
* OverviewPage — main dashboard for the kube-vip plugin.
*
* Shows: deployment status, VIP mode, leader election, service/node counts,
* IP pool summary, and pod health.
*/
import {
Loader,
NameValueTable,
SectionBox,
SectionHeader,
SimpleTable,
StatusLabel,
} from '@kinvolk/headlamp-plugin/lib/CommonComponents';
import React from 'react';
import {
formatAge,
getServiceVIPs,
isControlPlaneNode,
isEgressEnabled,
isKubeVipService,
isPodReady,
phaseToStatus,
} from '../api/k8s';
import { useKubeVipContext } from '../api/KubeVipDataContext';
export default function OverviewPage() {
const {
kubeVipInstalled,
daemonSetStatus,
kubeVipPods,
cloudProviderPods,
loadBalancerServices,
nodes,
leases,
ipPools,
kubeVipConfig,
loading,
error,
refresh,
} = useKubeVipContext();
if (loading) {
return <Loader title="Loading kube-vip data..." />;
}
const controlPlaneNodes = nodes.filter(isControlPlaneNode);
const readyPods = kubeVipPods.filter(isPodReady);
const kubeVipManaged = loadBalancerServices.filter(isKubeVipService);
const egressEnabled = loadBalancerServices.filter(isEgressEnabled);
// Detect mode from config
const mode =
kubeVipConfig['bgp_enable'] === 'true'
? 'BGP'
: kubeVipConfig['vip_arp'] === 'true'
? 'ARP'
: kubeVipPods.length > 0
? 'Unknown'
: '—';
const cpEnabled = kubeVipConfig['cp_enable'] === 'true';
const svcEnabled = kubeVipConfig['svc_enable'] === 'true';
const controlPlaneVIP = kubeVipConfig['address'] ?? '—';
// Find leader from leases
const cpLease = leases.find(
l => l.metadata.name.startsWith('plndr-cp-lock') || l.metadata.name === 'plndr-cp-lock'
);
const svcLease = leases.find(
l => l.metadata.name.startsWith('plndr-svcs-lock') || l.metadata.name === 'plndr-svcs-lock'
);
const leaderNode = cpLease?.spec?.holderIdentity ?? svcLease?.spec?.holderIdentity ?? '—';
return (
<>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '20px',
}}
>
<SectionHeader title="kube-vip — Overview" />
<button
onClick={refresh}
aria-label="Refresh kube-vip 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>
{!kubeVipInstalled && (
<SectionBox title="kube-vip Not Detected">
<NameValueTable
rows={[
{
name: 'Status',
value: (
<StatusLabel status="error">No kube-vip pods found in kube-system</StatusLabel>
),
},
{
name: 'Install',
value: 'See https://kube-vip.io/docs/installation/',
},
]}
/>
</SectionBox>
)}
{error && (
<SectionBox title="Error">
<NameValueTable
rows={[{ name: 'Status', value: <StatusLabel status="error">{error}</StatusLabel> }]}
/>
</SectionBox>
)}
{kubeVipInstalled && (
<>
<SectionBox title="Deployment">
<NameValueTable
rows={[
{
name: 'Status',
value: (
<StatusLabel status={readyPods.length > 0 ? 'success' : 'error'}>
{readyPods.length > 0 ? 'Running' : 'Unhealthy'}
</StatusLabel>
),
},
{ name: 'Mode', value: mode },
{ name: 'Control Plane HA', value: cpEnabled ? 'Enabled' : 'Disabled' },
{ name: 'Service LoadBalancer', value: svcEnabled ? 'Enabled' : 'Disabled' },
...(cpEnabled ? [{ name: 'Control Plane VIP', value: controlPlaneVIP }] : []),
{ name: 'Leader', value: leaderNode },
{
name: 'Pods',
value: `${readyPods.length}/${kubeVipPods.length} ready`,
},
...(daemonSetStatus
? [
{
name: 'DaemonSet',
value: `${daemonSetStatus.numberReady ?? 0}/${
daemonSetStatus.desiredNumberScheduled ?? 0
} ready`,
},
]
: []),
...(cloudProviderPods.length > 0
? [
{
name: 'Cloud Provider',
value: (
<StatusLabel
status={cloudProviderPods.some(isPodReady) ? 'success' : 'warning'}
>
{cloudProviderPods.length} pod(s)
</StatusLabel>
),
},
]
: []),
]}
/>
</SectionBox>
<SectionBox title="Cluster Summary">
<NameValueTable
rows={[
{ name: 'Total Nodes', value: String(nodes.length) },
{ name: 'Control Plane Nodes', value: String(controlPlaneNodes.length) },
{ name: 'LoadBalancer Services', value: String(loadBalancerServices.length) },
{ name: 'kube-vip Managed', value: String(kubeVipManaged.length) },
...(egressEnabled.length > 0
? [
{
name: 'Egress Enabled',
value: String(egressEnabled.length),
},
]
: []),
{ name: 'IP Pools', value: String(ipPools.length) },
{ name: 'Leader Election Leases', value: String(leases.length) },
]}
/>
</SectionBox>
{ipPools.length > 0 && (
<SectionBox title="IP Pools">
<SimpleTable
columns={[
{ label: 'Name', getter: p => p.name },
{ label: 'Type', getter: p => p.type.toUpperCase() },
{ label: 'Value', getter: p => p.value },
{
label: 'Scope',
getter: p => (p.scope === 'namespace' ? p.namespace ?? '—' : 'Global'),
},
]}
data={ipPools}
/>
</SectionBox>
)}
{kubeVipPods.length > 0 && (
<SectionBox title="kube-vip Pods">
<SimpleTable
columns={[
{ label: 'Name', getter: p => p.metadata.name },
{ label: 'Node', getter: p => p.spec?.nodeName ?? '—' },
{
label: 'Status',
getter: p => (
<StatusLabel status={phaseToStatus(p.status?.phase)}>
{p.status?.phase ?? 'Unknown'}
</StatusLabel>
),
},
{
label: 'Ready',
getter: p => (isPodReady(p) ? 'Yes' : 'No'),
},
{ label: 'Age', getter: p => formatAge(p.metadata.creationTimestamp) },
]}
data={kubeVipPods}
/>
</SectionBox>
)}
{loadBalancerServices.length > 0 && (
<SectionBox title="LoadBalancer Services">
<SimpleTable
columns={[
{ label: 'Name', getter: s => s.metadata.name },
{ label: 'Namespace', getter: s => s.metadata.namespace ?? '—' },
{ label: 'VIP', getter: s => getServiceVIPs(s).join(', ') || '—' },
{
label: 'Ports',
getter: s =>
s.spec.ports
?.map(
(p: { port: number; protocol?: string }) =>
`${p.port}/${p.protocol ?? 'TCP'}`
)
.join(', ') ?? '—',
},
{ label: 'Age', getter: s => formatAge(s.metadata.creationTimestamp) },
]}
data={loadBalancerServices}
/>
</SectionBox>
)}
</>
)}
</>
);
}
@@ -0,0 +1,133 @@
import { render, screen } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
vi.mock(
'@kinvolk/headlamp-plugin/lib/CommonComponents',
async () => await import('./__mocks__/commonComponents')
);
vi.mock('../api/KubeVipDataContext');
import { useKubeVipContext } from '../api/KubeVipDataContext';
import { defaultContext, makeSampleService } from '../test-helpers';
import ServiceDetailSection from './ServiceDetailSection';
function mockContext(overrides?: Parameters<typeof defaultContext>[0]) {
vi.mocked(useKubeVipContext).mockReturnValue(defaultContext(overrides));
}
describe('ServiceDetailSection', () => {
it('returns null when loading', () => {
mockContext({ loading: true });
const { container } = render(
<ServiceDetailSection
resource={{
metadata: { name: 'svc', namespace: 'default' },
spec: { type: 'LoadBalancer' },
}}
/>
);
expect(container.innerHTML).toBe('');
});
it('returns null for non-LoadBalancer services', () => {
mockContext();
const { container } = render(
<ServiceDetailSection
resource={{ metadata: { name: 'svc', namespace: 'default' }, spec: { type: 'ClusterIP' } }}
/>
);
expect(container.innerHTML).toBe('');
});
it('returns null when service is not in filtered list', () => {
mockContext({ loadBalancerServices: [] });
const { container } = render(
<ServiceDetailSection
resource={{
metadata: { name: 'unknown', namespace: 'default' },
spec: { type: 'LoadBalancer' },
}}
/>
);
expect(container.innerHTML).toBe('');
});
it('renders kube-vip details for matching LoadBalancer service', () => {
const svc = makeSampleService();
mockContext({ loadBalancerServices: [svc] });
render(
<ServiceDetailSection
resource={{
metadata: { name: 'my-service', namespace: 'default' },
spec: { type: 'LoadBalancer' },
}}
/>
);
expect(screen.getByText('kube-vip Details')).toBeInTheDocument();
expect(screen.getByText('192.168.1.200')).toBeInTheDocument();
});
it('shows VIP host when available', () => {
const svc = makeSampleService();
mockContext({ loadBalancerServices: [svc] });
render(
<ServiceDetailSection
resource={{
metadata: { name: 'my-service', namespace: 'default' },
spec: { type: 'LoadBalancer' },
}}
/>
);
// "node-1" appears in both the VIP Host row and the vipHost annotation
expect(screen.getAllByText('node-1').length).toBeGreaterThanOrEqual(1);
expect(screen.getByText('VIP Host Node')).toBeInTheDocument();
});
it('shows egress label when enabled', () => {
const svc = makeSampleService({
metadata: {
name: 'egress-svc',
namespace: 'default',
annotations: {
'kube-vip.io/loadbalancerIPs': '10.0.0.1',
'kube-vip.io/egress': 'true',
'kube-vip.io/vipHost': 'node-1',
},
},
});
mockContext({ loadBalancerServices: [svc] });
render(
<ServiceDetailSection
resource={{
metadata: { name: 'egress-svc', namespace: 'default' },
spec: { type: 'LoadBalancer' },
}}
/>
);
expect(screen.getByText('Egress')).toBeInTheDocument();
});
it('shows ignored warning when service is ignored', () => {
const svc = makeSampleService({
metadata: {
name: 'ignored-svc',
namespace: 'default',
annotations: {
'kube-vip.io/ignore': 'true',
'kube-vip.io/loadbalancerIPs': '10.0.0.1',
},
},
});
mockContext({ loadBalancerServices: [svc] });
render(
<ServiceDetailSection
resource={{
metadata: { name: 'ignored-svc', namespace: 'default' },
spec: { type: 'LoadBalancer' },
}}
/>
);
expect(screen.getByText(/ignoring this service/)).toBeInTheDocument();
});
});
+97
View File
@@ -0,0 +1,97 @@
/**
* ServiceDetailSection — injected into Headlamp's native Service detail view.
*
* Displays kube-vip-specific information for LoadBalancer services:
* VIP assignments, annotations, egress status.
*/
import {
NameValueTable,
SectionBox,
StatusLabel,
} from '@kinvolk/headlamp-plugin/lib/CommonComponents';
import React from 'react';
import {
ANNOTATION_LOADBALANCER_IPS,
getServiceVIPs,
getVipHost,
isEgressEnabled,
isServiceIgnored,
KUBE_VIP_ANNOTATION_PREFIX,
} from '../api/k8s';
import { useKubeVipContext } from '../api/KubeVipDataContext';
interface ServiceDetailSectionProps {
resource: { metadata: { name: string; namespace?: string }; spec?: { type?: string } };
}
export default function ServiceDetailSection({ resource }: ServiceDetailSectionProps) {
const { loadBalancerServices, loading } = useKubeVipContext();
if (loading) return null;
// Only show for LoadBalancer services
if (resource.spec?.type !== 'LoadBalancer') return null;
// Find the matching service in our filtered list
const svc = loadBalancerServices.find(
s =>
s.metadata.name === resource.metadata.name &&
s.metadata.namespace === resource.metadata.namespace
);
if (!svc) return null;
// Check if this service has any kube-vip annotations
const annotations = svc.metadata.annotations ?? {};
const hasKubeVipAnnotations = Object.keys(annotations).some(k =>
k.startsWith(KUBE_VIP_ANNOTATION_PREFIX)
);
// If no kube-vip annotations, still show VIP info from status
const vips = getServiceVIPs(svc);
if (!hasKubeVipAnnotations && vips.length === 0) return null;
const vipHost = getVipHost(svc);
const kubeVipAnnotations = Object.entries(annotations).filter(([key]) =>
key.startsWith(KUBE_VIP_ANNOTATION_PREFIX)
);
return (
<SectionBox title="kube-vip Details">
<NameValueTable
rows={[
{
name: 'VIP',
value: vips.length > 0 ? vips.join(', ') : 'Pending',
},
...(vipHost ? [{ name: 'VIP Host Node', value: vipHost }] : []),
...(isEgressEnabled(svc)
? [
{
name: 'Egress',
value: <StatusLabel status="success">Enabled</StatusLabel>,
},
]
: []),
...(isServiceIgnored(svc)
? [
{
name: 'Ignored',
value: (
<StatusLabel status="warning">kube-vip is ignoring this service</StatusLabel>
),
},
]
: []),
...kubeVipAnnotations
.filter(([key]) => key !== ANNOTATION_LOADBALANCER_IPS)
.map(([key, value]) => ({
name: key.replace(KUBE_VIP_ANNOTATION_PREFIX, ''),
value,
})),
]}
/>
</SectionBox>
);
}
+118
View File
@@ -0,0 +1,118 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock(
'@kinvolk/headlamp-plugin/lib/CommonComponents',
async () => await import('./__mocks__/commonComponents')
);
let mockHash = '';
const mockPush = vi.fn();
vi.mock('react-router-dom', () => ({
useLocation: () => ({ pathname: '/kube-vip/services', hash: mockHash }),
useHistory: () => ({ push: mockPush }),
}));
vi.mock('../api/KubeVipDataContext');
import { useKubeVipContext } from '../api/KubeVipDataContext';
import { defaultContext, makeSampleService } from '../test-helpers';
import ServicesPage from './ServicesPage';
function mockContext(overrides?: Parameters<typeof defaultContext>[0]) {
vi.mocked(useKubeVipContext).mockReturnValue(defaultContext(overrides));
}
describe('ServicesPage', () => {
beforeEach(() => {
mockPush.mockClear();
mockHash = '';
});
it('shows loader when loading', () => {
mockContext({ loading: true });
render(<ServicesPage />);
expect(screen.getByTestId('loader')).toHaveTextContent('Loading services...');
});
it('shows error state', () => {
mockContext({ error: 'fetch failed' });
render(<ServicesPage />);
expect(screen.getByText('fetch failed')).toBeInTheDocument();
});
it('shows empty message when no services', () => {
mockContext({ loadBalancerServices: [] });
render(<ServicesPage />);
expect(screen.getByText('No LoadBalancer services found.')).toBeInTheDocument();
});
it('renders services table', () => {
const svc = makeSampleService();
mockContext({ loadBalancerServices: [svc] });
render(<ServicesPage />);
expect(screen.getByText('my-service')).toBeInTheDocument();
expect(screen.getByText('192.168.1.200')).toBeInTheDocument();
});
it('opens detail panel when clicking service name', () => {
const svc = makeSampleService();
mockContext({ loadBalancerServices: [svc] });
render(<ServicesPage />);
fireEvent.click(screen.getByText('my-service'));
expect(mockPush).toHaveBeenCalledWith('/kube-vip/services#default/my-service');
});
it('renders detail panel when hash is set', () => {
mockHash = '#default/my-service';
const svc = makeSampleService();
mockContext({ loadBalancerServices: [svc] });
render(<ServicesPage />);
expect(screen.getByText('Service Details')).toBeInTheDocument();
});
it('closes panel via backdrop click', () => {
mockHash = '#default/my-service';
const svc = makeSampleService();
mockContext({ loadBalancerServices: [svc] });
render(<ServicesPage />);
fireEvent.click(screen.getByLabelText('Close panel backdrop'));
expect(mockPush).toHaveBeenCalledWith('/kube-vip/services');
});
it('closes panel on Escape key', () => {
mockHash = '#default/my-service';
const svc = makeSampleService();
mockContext({ loadBalancerServices: [svc] });
render(<ServicesPage />);
fireEvent.keyDown(window, { key: 'Escape' });
expect(mockPush).toHaveBeenCalledWith('/kube-vip/services');
});
it('shows kube-vip annotations in detail panel', () => {
mockHash = '#default/my-service';
const svc = makeSampleService();
mockContext({ loadBalancerServices: [svc] });
render(<ServicesPage />);
expect(screen.getByText('kube-vip Annotations')).toBeInTheDocument();
expect(screen.getByText('loadbalancerIPs')).toBeInTheDocument();
});
it('shows egress column for egress-enabled service', () => {
const svc = makeSampleService({
metadata: {
name: 'egress-svc',
namespace: 'default',
annotations: {
'kube-vip.io/loadbalancerIPs': '10.0.0.1',
'kube-vip.io/egress': 'true',
},
},
});
mockContext({ loadBalancerServices: [svc] });
render(<ServicesPage />);
// The "Yes" text appears in the Egress column
const cells = screen.getAllByRole('cell');
expect(cells.some(c => c.textContent === 'Yes')).toBe(true);
});
});
+265
View File
@@ -0,0 +1,265 @@
/**
* ServicesPage — LoadBalancer services managed by kube-vip.
*
* Shows all type:LoadBalancer services with VIP assignments, ports,
* kube-vip annotations, and egress status.
*/
import {
Loader,
NameValueTable,
SectionBox,
SectionHeader,
SimpleTable,
StatusLabel,
} from '@kinvolk/headlamp-plugin/lib/CommonComponents';
import React, { useEffect } from 'react';
import { useHistory, useLocation } from 'react-router-dom';
import {
formatAge,
getServiceVIPs,
getVipHost,
isEgressEnabled,
isKubeVipService,
isServiceIgnored,
KUBE_VIP_ANNOTATION_PREFIX,
KubeVipService,
} from '../api/k8s';
import { useKubeVipContext } from '../api/KubeVipDataContext';
export default function ServicesPage() {
const { loadBalancerServices, loading, error } = useKubeVipContext();
const location = useLocation();
const history = useHistory();
const selectedName = location.hash ? decodeURIComponent(location.hash.slice(1)) : null;
const selectedService = selectedName
? loadBalancerServices.find(s => `${s.metadata.namespace}/${s.metadata.name}` === selectedName)
: null;
const closePanel = () => history.push(location.pathname);
useEffect(() => {
if (!selectedName) return;
const handler = (e: KeyboardEvent) => {
if (e.key === 'Escape') closePanel();
};
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
});
if (loading) {
return <Loader title="Loading services..." />;
}
if (error) {
return (
<SectionBox title="Error">
<NameValueTable
rows={[{ name: 'Status', value: <StatusLabel status="error">{error}</StatusLabel> }]}
/>
</SectionBox>
);
}
if (loadBalancerServices.length === 0) {
return (
<>
<SectionHeader title="kube-vip — Services" />
<SectionBox>
<p>No LoadBalancer services found.</p>
</SectionBox>
</>
);
}
return (
<>
<SectionHeader title="kube-vip — Services" />
<SectionBox title={`LoadBalancer Services (${loadBalancerServices.length})`}>
<SimpleTable
columns={[
{
label: 'Name',
getter: s => (
<a
href="#"
onClick={e => {
e.preventDefault();
history.push(`${location.pathname}#${s.metadata.namespace}/${s.metadata.name}`);
}}
style={{ color: 'var(--mui-palette-primary-main, #1976d2)', cursor: 'pointer' }}
>
{s.metadata.name}
</a>
),
},
{ label: 'Namespace', getter: s => s.metadata.namespace ?? '—' },
{ label: 'VIP', getter: s => getServiceVIPs(s).join(', ') || 'Pending' },
{
label: 'Ports',
getter: s =>
s.spec.ports
?.map(
(p: { port: number; protocol?: string }) => `${p.port}/${p.protocol ?? 'TCP'}`
)
.join(', ') ?? '—',
},
{ label: 'VIP Host', getter: s => getVipHost(s) ?? '—' },
{
label: 'kube-vip',
getter: s => (
<StatusLabel status={isKubeVipService(s) ? 'success' : ''}>
{isKubeVipService(s) ? 'Yes' : '—'}
</StatusLabel>
),
},
{
label: 'Egress',
getter: s => (isEgressEnabled(s) ? 'Yes' : '—'),
},
{ label: 'Age', getter: s => formatAge(s.metadata.creationTimestamp) },
]}
data={loadBalancerServices}
/>
</SectionBox>
{/* Detail slide-in panel */}
{selectedService && (
<>
<div
onClick={closePanel}
aria-label="Close panel backdrop"
style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'rgba(0,0,0,0.3)',
zIndex: 1200,
}}
/>
<ServiceDetailPanel service={selectedService} onClose={closePanel} />
</>
)}
</>
);
}
function ServiceDetailPanel({
service,
onClose,
}: {
service: KubeVipService;
onClose: () => void;
}) {
const vips = getServiceVIPs(service);
const vipHost = getVipHost(service);
const annotations = service.metadata.annotations ?? {};
const kubeVipAnnotations = Object.entries(annotations).filter(([key]) =>
key.startsWith(KUBE_VIP_ANNOTATION_PREFIX)
);
return (
<div
style={{
position: 'fixed',
top: 0,
right: 0,
width: '480px',
height: '100vh',
backgroundColor: 'var(--mui-palette-background-paper, #fff)',
boxShadow: '-4px 0 12px rgba(0,0,0,0.15)',
zIndex: 1300,
overflowY: 'auto',
padding: '24px',
}}
>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '16px',
}}
>
<h2 style={{ margin: 0, fontSize: '18px' }}>Service Details</h2>
<button
onClick={onClose}
aria-label="Close panel"
style={{ background: 'none', border: 'none', fontSize: '20px', cursor: 'pointer' }}
>
&times;
</button>
</div>
<SectionBox title="General">
<NameValueTable
rows={[
{ name: 'Name', value: service.metadata.name },
{ name: 'Namespace', value: service.metadata.namespace ?? '—' },
{ name: 'Type', value: service.spec.type ?? '—' },
{ name: 'Cluster IP', value: service.spec.clusterIP ?? '—' },
{ name: 'VIP', value: vips.join(', ') || 'Pending' },
...(vipHost ? [{ name: 'VIP Host Node', value: vipHost }] : []),
{ name: 'External Traffic Policy', value: service.spec.externalTrafficPolicy ?? '—' },
{ name: 'Age', value: formatAge(service.metadata.creationTimestamp) },
]}
/>
</SectionBox>
{service.spec.ports && service.spec.ports.length > 0 && (
<SectionBox title="Ports">
<SimpleTable
columns={[
{ label: 'Name', getter: p => p.name ?? '—' },
{ label: 'Port', getter: p => String(p.port) },
{ label: 'Target', getter: p => String(p.targetPort ?? '—') },
{ label: 'Protocol', getter: p => p.protocol ?? 'TCP' },
...(service.spec.ports?.some((p: { nodePort?: number }) => p.nodePort)
? [
{
label: 'NodePort',
getter: (p: { nodePort?: number }) => String(p.nodePort ?? '—'),
},
]
: []),
]}
data={service.spec.ports}
/>
</SectionBox>
)}
{kubeVipAnnotations.length > 0 && (
<SectionBox title="kube-vip Annotations">
<NameValueTable
rows={kubeVipAnnotations.map(([key, value]) => ({
name: key.replace(KUBE_VIP_ANNOTATION_PREFIX, ''),
value,
}))}
/>
</SectionBox>
)}
{isServiceIgnored(service) && (
<SectionBox title="Notice">
<NameValueTable
rows={[
{
name: 'Ignored',
value: (
<StatusLabel status="warning">
This service has kube-vip.io/ignore=true kube-vip will not manage it
</StatusLabel>
),
},
]}
/>
</SectionBox>
)}
</div>
);
}
@@ -0,0 +1,95 @@
/**
* Lightweight mock implementations of @kinvolk/headlamp-plugin/lib/CommonComponents.
* Used via vi.mock('@kinvolk/headlamp-plugin/lib/CommonComponents', ...).
*
* Uses React.createElement instead of JSX since this file is .ts (not .tsx).
*/
import React from 'react';
type RC = React.ReactNode;
export const Loader = ({ title }: { title?: string }) =>
React.createElement('div', { 'data-testid': 'loader' }, title);
export const SectionBox = ({ title, children }: { title?: string; children?: RC }) =>
React.createElement(
'div',
{ 'data-testid': 'section-box', 'data-title': title },
title ? React.createElement('h3', null, title) : null,
children
);
export const SectionHeader = ({ title }: { title: string }) =>
React.createElement('h1', { 'data-testid': 'section-header' }, title);
export const SimpleTable = ({
columns,
data,
emptyMessage,
}: {
columns: Array<{ label: string; getter: (item: unknown) => RC }>;
data: unknown[];
emptyMessage?: string;
}) => {
if (data.length === 0 && emptyMessage) {
return React.createElement('div', { 'data-testid': 'empty-table' }, emptyMessage);
}
return React.createElement(
'table',
{ 'data-testid': 'simple-table' },
React.createElement(
'thead',
null,
React.createElement(
'tr',
null,
columns.map(col => React.createElement('th', { key: col.label }, col.label))
)
),
React.createElement(
'tbody',
null,
data.map((item, i) =>
React.createElement(
'tr',
{ key: i },
columns.map(col => React.createElement('td', { key: col.label }, col.getter(item)))
)
)
)
);
};
export const NameValueTable = ({ rows }: { rows: Array<{ name: string; value: RC }> }) =>
React.createElement(
'table',
{ 'data-testid': 'name-value-table' },
React.createElement(
'tbody',
null,
rows.map(row =>
React.createElement(
'tr',
{ key: row.name },
React.createElement('td', null, row.name),
React.createElement('td', null, row.value)
)
)
)
);
export const StatusLabel = ({ status, children }: { status: string; children?: RC }) =>
React.createElement('span', { 'data-testid': 'status-label', 'data-status': status }, children);
export const PercentageBar = ({
data,
}: {
data: Array<{ name: string; value: number }>;
total: number;
}) =>
React.createElement(
'div',
{ 'data-testid': 'percentage-bar' },
data.map(d => React.createElement('span', { key: d.name }, `${d.name}: ${d.value}`))
);
+128
View File
@@ -0,0 +1,128 @@
/**
* headlamp-kube-vip-plugin — entry point.
*
* Registers sidebar entries, routes, and detail view sections for
* kube-vip virtual IP and load balancer visibility in Headlamp.
*/
import {
registerDetailsViewSection,
registerRoute,
registerSidebarEntry,
} from '@kinvolk/headlamp-plugin/lib';
import React from 'react';
import { KubeVipDataProvider } from './api/KubeVipDataContext';
import ConfigPage from './components/ConfigPage';
import NodesPage from './components/NodesPage';
import OverviewPage from './components/OverviewPage';
import ServiceDetailSection from './components/ServiceDetailSection';
import ServicesPage from './components/ServicesPage';
// ---------------------------------------------------------------------------
// Sidebar entries
// ---------------------------------------------------------------------------
registerSidebarEntry({
parent: null,
name: 'kube-vip',
label: 'kube-vip',
url: '/kube-vip',
icon: 'mdi:ip-network',
});
registerSidebarEntry({
parent: 'kube-vip',
name: 'kube-vip-overview',
label: 'Overview',
url: '/kube-vip',
icon: 'mdi:view-dashboard',
});
registerSidebarEntry({
parent: 'kube-vip',
name: 'kube-vip-services',
label: 'Services',
url: '/kube-vip/services',
icon: 'mdi:lan',
});
registerSidebarEntry({
parent: 'kube-vip',
name: 'kube-vip-nodes',
label: 'Nodes',
url: '/kube-vip/nodes',
icon: 'mdi:server',
});
registerSidebarEntry({
parent: 'kube-vip',
name: 'kube-vip-config',
label: 'Configuration',
url: '/kube-vip/config',
icon: 'mdi:cog',
});
// ---------------------------------------------------------------------------
// Routes
// ---------------------------------------------------------------------------
registerRoute({
path: '/kube-vip',
sidebar: 'kube-vip-overview',
name: 'kube-vip-overview',
exact: true,
component: () => (
<KubeVipDataProvider>
<OverviewPage />
</KubeVipDataProvider>
),
});
registerRoute({
path: '/kube-vip/services',
sidebar: 'kube-vip-services',
name: 'kube-vip-services',
exact: true,
component: () => (
<KubeVipDataProvider>
<ServicesPage />
</KubeVipDataProvider>
),
});
registerRoute({
path: '/kube-vip/nodes',
sidebar: 'kube-vip-nodes',
name: 'kube-vip-nodes',
exact: true,
component: () => (
<KubeVipDataProvider>
<NodesPage />
</KubeVipDataProvider>
),
});
registerRoute({
path: '/kube-vip/config',
sidebar: 'kube-vip-config',
name: 'kube-vip-config',
exact: true,
component: () => (
<KubeVipDataProvider>
<ConfigPage />
</KubeVipDataProvider>
),
});
// ---------------------------------------------------------------------------
// Detail view section — Service pages (LoadBalancer type)
// ---------------------------------------------------------------------------
registerDetailsViewSection(({ resource }) => {
if (resource?.kind !== 'Service') return null;
return (
<KubeVipDataProvider>
<ServiceDetailSection resource={resource} />
</KubeVipDataProvider>
);
});
+164
View File
@@ -0,0 +1,164 @@
/**
* Shared test helpers: mock factories, fixtures, and context setup
* for component tests.
*/
import { vi } from 'vitest';
import type { KubeVipLease, KubeVipNode, KubeVipPod, KubeVipService } from './api/k8s';
import type { KubeVipContextValue } from './api/KubeVipDataContext';
// ---------------------------------------------------------------------------
// Default context value (everything empty / zeroed)
// ---------------------------------------------------------------------------
export function defaultContext(overrides?: Partial<KubeVipContextValue>): KubeVipContextValue {
return {
kubeVipInstalled: false,
daemonSetStatus: null,
kubeVipPods: [],
cloudProviderPods: [],
loadBalancerServices: [],
nodes: [],
leases: [],
ipPools: [],
configMapData: {},
kubeVipConfig: {},
loading: false,
error: null,
refresh: vi.fn(),
...overrides,
};
}
// ---------------------------------------------------------------------------
// Sample fixtures
// ---------------------------------------------------------------------------
export function makeSamplePod(overrides?: Partial<KubeVipPod>): KubeVipPod {
return {
metadata: {
name: 'kube-vip-ds-abc12',
namespace: 'kube-system',
creationTimestamp: '2025-01-01T00:00:00Z',
labels: { 'app.kubernetes.io/name': 'kube-vip-ds' },
},
spec: {
nodeName: 'node-1',
hostNetwork: true,
containers: [
{
name: 'kube-vip',
image: 'ghcr.io/kube-vip/kube-vip:v0.8.0',
env: [
{ name: 'address', value: '192.168.1.100' },
{ name: 'vip_arp', value: 'true' },
{ name: 'cp_enable', value: 'true' },
{ name: 'svc_enable', value: 'true' },
{ name: 'vip_interface', value: 'eth0' },
{ name: 'vip_leaderelection', value: 'true' },
],
},
],
},
status: {
phase: 'Running',
conditions: [{ type: 'Ready', status: 'True' }],
containerStatuses: [
{
name: 'kube-vip',
ready: true,
restartCount: 0,
image: 'ghcr.io/kube-vip/kube-vip:v0.8.0',
state: { running: { startedAt: '2025-01-01T00:00:00Z' } },
},
],
hostIP: '10.0.0.1',
podIP: '10.0.0.1',
},
...overrides,
};
}
export function makeSampleService(overrides?: Partial<KubeVipService>): KubeVipService {
return {
metadata: {
name: 'my-service',
namespace: 'default',
creationTimestamp: '2025-01-01T00:00:00Z',
annotations: {
'kube-vip.io/loadbalancerIPs': '192.168.1.200',
'kube-vip.io/vipHost': 'node-1',
},
},
spec: {
type: 'LoadBalancer',
clusterIP: '10.96.0.100',
externalTrafficPolicy: 'Cluster',
ports: [{ name: 'http', port: 80, targetPort: 8080, protocol: 'TCP' }],
},
status: {
loadBalancer: {
ingress: [{ ip: '192.168.1.200' }],
},
},
...overrides,
};
}
export function makeSampleNode(overrides?: Partial<KubeVipNode>): KubeVipNode {
return {
metadata: {
name: 'node-1',
creationTimestamp: '2025-01-01T00:00:00Z',
labels: {
'node-role.kubernetes.io/control-plane': '',
'kubernetes.io/hostname': 'node-1',
},
},
spec: {
podCIDR: '10.244.0.0/24',
},
status: {
conditions: [{ type: 'Ready', status: 'True' }],
addresses: [
{ type: 'InternalIP', address: '10.0.0.1' },
{ type: 'Hostname', address: 'node-1' },
],
nodeInfo: {
kubeletVersion: 'v1.30.0',
osImage: 'Ubuntu 22.04',
containerRuntimeVersion: 'containerd://1.7.0',
architecture: 'amd64',
},
},
...overrides,
};
}
export function makeSampleLease(overrides?: Partial<KubeVipLease>): KubeVipLease {
return {
metadata: {
name: 'plndr-cp-lock',
namespace: 'kube-system',
creationTimestamp: '2025-01-01T00:00:00Z',
},
spec: {
holderIdentity: 'node-1',
leaseDurationSeconds: 15,
renewTime: '2025-01-01T01:00:00Z',
leaseTransitions: 3,
},
...overrides,
};
}
export function makeSampleDaemonSetStatus(): KubeVipContextValue['daemonSetStatus'] {
return {
desiredNumberScheduled: 3,
currentNumberScheduled: 3,
numberReady: 3,
numberAvailable: 3,
updatedNumberScheduled: 3,
numberMisscheduled: 0,
};
}
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "@kinvolk/headlamp-plugin/config/plugins-tsconfig.json",
"compilerOptions": {
"module": "esnext",
"types": ["vite/client", "vite-plugin-svgr/client", "vitest/globals", "@testing-library/jest-dom"]
},
"include": ["src"]
}
+10
View File
@@ -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/**'],
},
});
+43
View File
@@ -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,
});
}
}