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:
@@ -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
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"enabledMcpjsonServers": ["github", "kubernetes", "flux", "playwright"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
module.exports = {
|
||||||
|
extends: ['@headlamp-k8s/eslint-config'],
|
||||||
|
};
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
.headlamp-plugin/
|
||||||
|
*.tar.gz
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.eslintcache
|
||||||
@@ -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" }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
module.exports = require('@headlamp-k8s/eslint-config/prettier-config');
|
||||||
@@ -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]) },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
```
|
||||||
@@ -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
|
||||||
@@ -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.
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
# headlamp-kube-vip-plugin
|
||||||
|
|
||||||
|
[](https://github.com/privilegedescalation/headlamp-kube-vip-plugin/actions/workflows/ci.yml)
|
||||||
|
[](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
@@ -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.
|
||||||
Generated
+18451
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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>;
|
||||||
|
}
|
||||||
@@ -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
@@ -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';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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' }}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</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
@@ -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>
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { defineConfig } from 'vitest/config';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
globals: true,
|
||||||
|
environment: 'jsdom',
|
||||||
|
setupFiles: ['./vitest.setup.ts'],
|
||||||
|
exclude: ['e2e/**', 'node_modules/**'],
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import '@testing-library/jest-dom';
|
||||||
|
|
||||||
|
// Node 22+ ships a minimal built-in `localStorage` global (property-bag only,
|
||||||
|
// no getItem/setItem/removeItem/clear) that shadows jsdom's Web Storage
|
||||||
|
// implementation. Provide a spec-compliant shim so code under test works.
|
||||||
|
if (typeof localStorage !== 'undefined' && typeof localStorage.getItem !== 'function') {
|
||||||
|
const store = new Map<string, string>();
|
||||||
|
|
||||||
|
const storage = {
|
||||||
|
getItem(key: string): string | null {
|
||||||
|
return store.get(key) ?? null;
|
||||||
|
},
|
||||||
|
setItem(key: string, value: string): void {
|
||||||
|
store.set(key, String(value));
|
||||||
|
},
|
||||||
|
removeItem(key: string): void {
|
||||||
|
store.delete(key);
|
||||||
|
},
|
||||||
|
clear(): void {
|
||||||
|
store.clear();
|
||||||
|
},
|
||||||
|
get length(): number {
|
||||||
|
return store.size;
|
||||||
|
},
|
||||||
|
key(index: number): string | null {
|
||||||
|
return [...store.keys()][index] ?? null;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
Object.defineProperty(globalThis, 'localStorage', {
|
||||||
|
value: storage,
|
||||||
|
writable: true,
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
Object.defineProperty(window, 'localStorage', {
|
||||||
|
value: storage,
|
||||||
|
writable: true,
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user