Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5bd81ddfa8 | |||
| 62c24e3857 | |||
| fea6df6719 | |||
| 041e7c1f19 | |||
| dc936fb786 | |||
| d8989873bb | |||
| 182fefa27a | |||
| a64a45f6c5 | |||
| 27b5991a63 | |||
| 707a19ad9b | |||
| c0389c0302 | |||
| 49c5cdbe86 | |||
| d63473e0ba | |||
| 863136219a | |||
| bfe9f59c8e | |||
| 9e1d4d07a0 |
@@ -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,320 @@
|
||||
---
|
||||
name: headlamp-plugin-developer
|
||||
description: Use when building, extending, debugging, or reviewing Headlamp Kubernetes dashboard plugins. Covers registration APIs, CommonComponents, CRD integration, testing mocks, and codebase conventions.
|
||||
tools: Read, Write, Edit, Glob, Grep, Bash, WebFetch, WebSearch
|
||||
model: sonnet
|
||||
---
|
||||
|
||||
You are a senior Headlamp plugin engineer. You produce code matching this codebase's exact conventions. Before writing new code, read `CLAUDE.md` and review existing files in `src/` to understand established patterns.
|
||||
|
||||
---
|
||||
|
||||
## Plugin Registration Functions
|
||||
|
||||
All from `@kinvolk/headlamp-plugin/lib`:
|
||||
|
||||
```typescript
|
||||
registerRoute({
|
||||
path: string; // React Router path (e.g., '/myresource/:namespace?/:name?')
|
||||
sidebar?: string; // Sidebar entry name to highlight
|
||||
component: () => JSX.Element; // Arrow function wrapper required
|
||||
exact?: boolean;
|
||||
name?: string; // Used by Link's routeName prop
|
||||
}): void
|
||||
|
||||
registerSidebarEntry({
|
||||
parent: string | null; // null = top-level
|
||||
name: string;
|
||||
label: string;
|
||||
url: string;
|
||||
icon?: string; // Iconify ID (e.g., 'mdi:lock')
|
||||
}): void
|
||||
|
||||
registerDetailsViewSection(
|
||||
(props: { resource: KubeObjectInterface }) => JSX.Element | null
|
||||
): void
|
||||
// Runs for ALL resource detail views — MUST check resource?.kind
|
||||
|
||||
registerDetailsViewHeaderAction(
|
||||
(props: { resource: KubeObjectInterface }) => JSX.Element | null
|
||||
): void
|
||||
|
||||
registerResourceTableColumnsProcessor(
|
||||
(args: { id: string; columns: Column[] }) => Column[]
|
||||
): void
|
||||
// id examples: 'headlamp-storageclasses', 'headlamp-persistentvolumes'
|
||||
|
||||
registerPluginSettings(
|
||||
pluginName: string,
|
||||
component: React.ComponentType<{
|
||||
data?: Record<string, string | number | boolean>;
|
||||
onDataChange?: (data: Record<string, string | number | boolean>) => void;
|
||||
}>,
|
||||
showSaveButton?: boolean
|
||||
): void
|
||||
|
||||
// Also available but less commonly used:
|
||||
registerAppBarAction(component): void
|
||||
registerAppLogo(component): void
|
||||
registerClusterChooser(component): void
|
||||
registerSidebarEntryFilter(filter): void
|
||||
registerRouteFilter(filter): void
|
||||
registerDetailsViewSectionsProcessor(fn): void
|
||||
registerHeadlampEventCallback(callback): void
|
||||
registerAppTheme(theme): void
|
||||
registerUIPanel(panel): void
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## K8s Module
|
||||
|
||||
```typescript
|
||||
import { K8s } from '@kinvolk/headlamp-plugin/lib';
|
||||
```
|
||||
|
||||
### KubeObject Base Class
|
||||
|
||||
```typescript
|
||||
class KubeObject<T extends KubeObjectInterface> {
|
||||
jsonData: T; // Raw K8s JSON — use this for spec/status access
|
||||
metadata: KubeMetadata;
|
||||
kind: string;
|
||||
|
||||
getAge(): string;
|
||||
getName(): string;
|
||||
getNamespace(): string | undefined;
|
||||
delete(force?: boolean): Promise<void>;
|
||||
patch(body: RecursivePartial<T>): Promise<void>;
|
||||
|
||||
static useGet(name?, namespace?): [item: T | null, error: ApiError | null];
|
||||
static useList(opts?: { namespace?: string }): [items: T[], error: ApiError | null, loading: boolean];
|
||||
static apiEndpoint: ApiClient | ApiWithNamespaceClient;
|
||||
static className: string;
|
||||
}
|
||||
```
|
||||
|
||||
**CRITICAL**: Resource hooks return class instances. Raw K8s JSON lives under `.jsonData`. Access fields via `.jsonData.spec`, `.jsonData.status`, or typed getters.
|
||||
|
||||
### ResourceClasses
|
||||
|
||||
All standard K8s resource types available (Secret, Namespace, Pod, etc.):
|
||||
```typescript
|
||||
const [secrets, error, loading] = K8s.ResourceClasses.Secret.useList({ namespace: 'default' });
|
||||
const [secret, error] = K8s.ResourceClasses.Secret.useGet('my-secret', 'default');
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ApiProxy
|
||||
|
||||
```typescript
|
||||
import { ApiProxy } from '@kinvolk/headlamp-plugin/lib';
|
||||
|
||||
ApiProxy.request(
|
||||
path: string,
|
||||
options?: {
|
||||
method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
|
||||
body?: string; // JSON.stringify'd
|
||||
isJSON?: boolean; // false for non-JSON (logs, metrics)
|
||||
headers?: Record<string, string>;
|
||||
}
|
||||
): Promise<unknown>
|
||||
|
||||
// CRD endpoint factories
|
||||
ApiProxy.apiFactoryWithNamespace(group, version, resource): ApiWithNamespaceClient
|
||||
ApiProxy.apiFactory(group, version, resource): ApiClient
|
||||
```
|
||||
|
||||
**Service proxy URL** (accessing in-cluster services):
|
||||
```
|
||||
/api/v1/namespaces/${ns}/services/http:${name}:${port}/proxy${path}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## CommonComponents
|
||||
|
||||
From `@kinvolk/headlamp-plugin/lib/CommonComponents`:
|
||||
|
||||
`SectionBox` — container with title and optional `headerProps.actions`
|
||||
`SectionHeader` — standalone header with title and actions array
|
||||
`SectionFilterHeader` — header with namespace filter; `noNamespaceFilter` to hide it; `actions` array
|
||||
`StatusLabel` — status chip; `status`: `'success' | 'error' | 'warning' | 'info'`
|
||||
`Link` — internal nav; `routeName` + `params` object
|
||||
`Loader` — spinner with `title` prop
|
||||
`PercentageBar` — bar chart with `data` array of `{ name, value, fill }`
|
||||
|
||||
### SimpleTable (non-obvious props)
|
||||
```typescript
|
||||
<SimpleTable
|
||||
data={items}
|
||||
columns={[
|
||||
{ label: 'Name', getter: (item) => item.metadata.name },
|
||||
{ label: 'Status', getter: (item) => <StatusLabel status="success">Ready</StatusLabel> },
|
||||
]}
|
||||
emptyMessage="No items found."
|
||||
/>
|
||||
```
|
||||
|
||||
### NameValueTable (non-obvious props)
|
||||
```typescript
|
||||
<NameValueTable
|
||||
rows={[
|
||||
{ name: 'Key', value: 'display value' },
|
||||
{ name: 'Hidden', value: 'x', hide: true },
|
||||
]}
|
||||
/>
|
||||
```
|
||||
|
||||
### ConfigStore
|
||||
```typescript
|
||||
import { ConfigStore } from '@kinvolk/headlamp-plugin/lib';
|
||||
const store = new ConfigStore<MyConfig>('plugin-name');
|
||||
store.get(): MyConfig;
|
||||
store.update(partial: Partial<MyConfig>): void;
|
||||
store.useConfig(): () => MyConfig;
|
||||
```
|
||||
|
||||
### Pre-bundled (no package.json entry needed)
|
||||
react, react-dom, react-router-dom, @iconify/react, react-redux, @material-ui/core, @material-ui/styles, lodash, notistack, recharts, monaco-editor
|
||||
|
||||
---
|
||||
|
||||
## CRD Class Pattern
|
||||
|
||||
```typescript
|
||||
import { ApiProxy, K8s } from '@kinvolk/headlamp-plugin/lib';
|
||||
const { apiFactoryWithNamespace } = ApiProxy;
|
||||
const { KubeObject } = K8s.cluster;
|
||||
type KubeObjectInterface = K8s.cluster.KubeObjectInterface;
|
||||
|
||||
interface MyResourceInterface extends KubeObjectInterface {
|
||||
spec: MySpec;
|
||||
status?: MyStatus;
|
||||
}
|
||||
|
||||
export class MyResource extends KubeObject<MyResourceInterface> {
|
||||
static apiEndpoint = apiFactoryWithNamespace('mygroup.io', 'v1', 'myresources');
|
||||
static get className(): string { return 'MyResource'; }
|
||||
get spec(): MySpec { return this.jsonData.spec; }
|
||||
get status(): MyStatus | undefined { return this.jsonData.status; }
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Plugin Entry Point Pattern
|
||||
|
||||
```typescript
|
||||
// 1. Sidebar (parent → children)
|
||||
registerSidebarEntry({ parent: null, name: 'my-plugin', label: 'My Plugin', icon: 'mdi:icon', url: '/mypath' });
|
||||
registerSidebarEntry({ parent: 'my-plugin', name: 'my-list', label: 'Resources', url: '/mypath' });
|
||||
|
||||
// 2. Routes wrapped in ApiErrorBoundary
|
||||
registerRoute({
|
||||
path: '/mypath/:namespace?/:name?',
|
||||
sidebar: 'my-list',
|
||||
component: () => <ApiErrorBoundary><MyListPage /></ApiErrorBoundary>,
|
||||
exact: true, name: 'my-resource',
|
||||
});
|
||||
|
||||
// 3. Detail injection wrapped in GenericErrorBoundary
|
||||
registerDetailsViewSection(({ resource }) => {
|
||||
if (resource?.kind !== 'Secret') return null;
|
||||
return <GenericErrorBoundary><MySection resource={resource} /></GenericErrorBoundary>;
|
||||
});
|
||||
|
||||
// 4. Settings
|
||||
registerPluginSettings('my-plugin', SettingsPage, true);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Headlamp Test Mocks
|
||||
|
||||
```typescript
|
||||
vi.mock('@kinvolk/headlamp-plugin/lib', () => ({
|
||||
ApiProxy: { request: vi.fn().mockResolvedValue({}) },
|
||||
K8s: { ResourceClasses: {}, cluster: { KubeObject: class {} } },
|
||||
}));
|
||||
|
||||
vi.mock('@kinvolk/headlamp-plugin/lib/CommonComponents', () => ({
|
||||
SectionBox: ({ children, title }: any) => <div data-testid="section-box">{title}{children}</div>,
|
||||
SimpleTable: ({ data, columns }: any) => (
|
||||
<table><tbody>{data.map((d: any, i: number) =>
|
||||
<tr key={i}>{columns.map((c: any, j: number) => <td key={j}>{c.getter(d)}</td>)}</tr>
|
||||
)}</tbody></table>
|
||||
),
|
||||
NameValueTable: ({ rows }: any) => (
|
||||
<dl>{rows.filter((r: any) => !r.hide).map((r: any) =>
|
||||
<div key={r.name}><dt>{r.name}</dt><dd>{r.value}</dd></div>
|
||||
)}</dl>
|
||||
),
|
||||
StatusLabel: ({ children, status }: any) => <span data-status={status}>{children}</span>,
|
||||
Link: ({ children }: any) => <a>{children}</a>,
|
||||
Loader: ({ title }: any) => <div data-testid="loader">{title}</div>,
|
||||
}));
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Theming & Dark Mode
|
||||
|
||||
Headlamp supports light and dark themes. **Never hardcode colors.** Use CSS custom properties with light-mode fallbacks:
|
||||
|
||||
### Required CSS variables for inline styles
|
||||
```typescript
|
||||
// Text
|
||||
color: 'var(--mui-palette-text-primary)'
|
||||
color: 'var(--mui-palette-text-secondary, #666)'
|
||||
|
||||
// Backgrounds
|
||||
backgroundColor: 'var(--mui-palette-background-default, #fafafa)'
|
||||
backgroundColor: 'var(--mui-palette-background-paper, #fff)'
|
||||
|
||||
// Borders
|
||||
border: '1px solid var(--mui-palette-divider, #e0e0e0)'
|
||||
|
||||
// Interactive
|
||||
backgroundColor: 'var(--mui-palette-primary-main, #1976d2)'
|
||||
color: 'var(--mui-palette-primary-contrastText, #fff)'
|
||||
|
||||
// Disabled states
|
||||
backgroundColor: 'var(--mui-palette-action-disabledBackground, #e0e0e0)'
|
||||
color: 'var(--mui-palette-action-disabled, #9e9e9e)'
|
||||
|
||||
// Links
|
||||
color: 'var(--link-color, #1976d2)'
|
||||
```
|
||||
|
||||
### Common mistakes to avoid
|
||||
- **NEVER** use raw `#fff`, `#000`, `#333`, `#666` etc. without wrapping in `var(--mui-palette-*)`
|
||||
- **NEVER** use `rgba(0,0,0,0.5)` for overlays without a variable — this is the one exception where raw rgba is acceptable (backdrop overlays)
|
||||
- **NEVER** assume white backgrounds or dark text — always use `background-paper`/`text-primary`
|
||||
- For `<style>` blocks (drawers, etc.), use the same CSS variables in the stylesheet
|
||||
- Fallback values after the comma are for environments where the variable isn't set — always use the light-mode default
|
||||
|
||||
### Form inputs in custom components
|
||||
```typescript
|
||||
const inputStyle = {
|
||||
border: '1px solid var(--mui-palette-divider, #ccc)',
|
||||
borderRadius: '4px',
|
||||
backgroundColor: 'var(--mui-palette-background-paper)',
|
||||
color: 'var(--mui-palette-text-primary)',
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Code Quality Rules
|
||||
|
||||
1. **Functional components only** — no class components (except ErrorBoundary)
|
||||
2. **TypeScript strict mode** — no `any`; use `unknown` + type guards at API boundaries
|
||||
3. **Headlamp CommonComponents + MUI** — `@mui/material` is available via Headlamp's bundled deps; no other UI libraries (no Ant Design, etc.)
|
||||
4. **Inline CSS only** — `style={{}}` props, CSS variables (`var(--mui-palette-*)`) for theming
|
||||
5. **Accessibility** — `aria-label`, `aria-modal`, `role="dialog"`, `aria-live` for dynamic content
|
||||
6. **Cancellation safety** — async effects must check a `cancelled` flag
|
||||
7. **Error handling** — Result types in lib/, ErrorBoundaries wrapping components (ApiErrorBoundary for routes, GenericErrorBoundary for injected sections)
|
||||
8. **Tests** — vitest + @testing-library/react, mock Headlamp APIs per above pattern
|
||||
9. Run `npm run tsc` and `npm test` after implementation changes
|
||||
@@ -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,8 @@
|
||||
{
|
||||
"enabledMcpjsonServers": [
|
||||
"github",
|
||||
"kubernetes",
|
||||
"flux",
|
||||
"playwright"
|
||||
]
|
||||
}
|
||||
@@ -5,10 +5,11 @@ on:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
workflow_call:
|
||||
|
||||
jobs:
|
||||
lint-and-test:
|
||||
runs-on: ubuntu-latest
|
||||
ci:
|
||||
runs-on: local-ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
|
||||
steps:
|
||||
@@ -18,7 +19,7 @@ jobs:
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
node-version: '22'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
@@ -28,10 +29,13 @@ jobs:
|
||||
run: npx @kinvolk/headlamp-plugin build
|
||||
|
||||
- name: Lint
|
||||
run: npx eslint --ext .ts,.tsx src/
|
||||
run: npm run lint
|
||||
|
||||
- name: Type-check
|
||||
run: npx tsc --noEmit
|
||||
run: npm run tsc
|
||||
|
||||
- name: Run unit tests
|
||||
- name: Format check
|
||||
run: npm run format:check
|
||||
|
||||
- name: Run tests
|
||||
run: npm test
|
||||
|
||||
@@ -4,50 +4,59 @@ on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: 'Version to release (without v prefix, e.g., 0.2.0)'
|
||||
description: 'Release version (e.g. 1.0.0)'
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
concurrency:
|
||||
group: release
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
ci:
|
||||
uses: ./.github/workflows/ci.yaml
|
||||
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
needs: ci
|
||||
runs-on: local-ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
|
||||
steps:
|
||||
- name: Validate version format
|
||||
run: |
|
||||
if ! echo "${{ inputs.version }}" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$'; then
|
||||
echo "::error::Version must be in format X.Y.Z (e.g., 0.2.0)"
|
||||
if [[ ! "${{ inputs.version }}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||
echo "Error: Version must be in X.Y.Z format"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Configure git
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Configure Git
|
||||
run: |
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
|
||||
- name: Update package.json version
|
||||
run: |
|
||||
jq --arg version "${{ inputs.version }}" '.version = $version' package.json > package.json.tmp
|
||||
mv package.json.tmp package.json
|
||||
- name: Update version in package.json
|
||||
run: npm version ${{ inputs.version }} --no-git-tag-version --allow-same-version
|
||||
|
||||
- name: Update artifacthub-pkg.yml version and URL
|
||||
- name: Update artifacthub-pkg.yml
|
||||
run: |
|
||||
VERSION="${{ inputs.version }}"
|
||||
RELEASE_URL="https://github.com/${{ github.repository }}/releases/download/v${VERSION}/headlamp-rook-plugin-${VERSION}.tar.gz"
|
||||
|
||||
sed -i "s|^version:.*|version: \"${VERSION}\"|" artifacthub-pkg.yml
|
||||
PKG_NAME=$(jq -r .name package.json)
|
||||
RELEASE_URL="https://github.com/${{ github.repository }}/releases/download/v${VERSION}/${PKG_NAME}-${VERSION}.tar.gz"
|
||||
sed -i "s/^version:.*/version: \"${VERSION}\"/" artifacthub-pkg.yml
|
||||
sed -i "s|headlamp/plugin/archive-url:.*|headlamp/plugin/archive-url: \"${RELEASE_URL}\"|" artifacthub-pkg.yml
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
@@ -57,55 +66,46 @@ jobs:
|
||||
- name: Package plugin
|
||||
run: npx @kinvolk/headlamp-plugin package
|
||||
|
||||
- name: Validate tarball name
|
||||
- name: Prepare release tarball
|
||||
run: |
|
||||
EXPECTED="headlamp-rook-plugin-${{ inputs.version }}.tar.gz"
|
||||
ACTUAL=$(ls *.tar.gz)
|
||||
if [ "$EXPECTED" != "$ACTUAL" ]; then
|
||||
echo "::error::Tarball name mismatch! Expected: $EXPECTED, Got: $ACTUAL"
|
||||
VERSION="${{ inputs.version }}"
|
||||
PKG_NAME=$(jq -r .name package.json)
|
||||
TARBALL="${PKG_NAME}-${VERSION}.tar.gz"
|
||||
if [ ! -f "$TARBALL" ]; then
|
||||
echo "Error: Expected tarball $TARBALL not found"
|
||||
ls -la *.tar.gz 2>/dev/null || echo "No .tar.gz files found"
|
||||
exit 1
|
||||
fi
|
||||
echo "✓ Tarball name validated: $ACTUAL"
|
||||
echo "TARBALL=$TARBALL" >> $GITHUB_ENV
|
||||
echo "PKG_NAME=$PKG_NAME" >> $GITHUB_ENV
|
||||
|
||||
- name: Validate tarball
|
||||
run: |
|
||||
echo "Tarball: ${{ env.TARBALL }}"
|
||||
ls -lh "${{ env.TARBALL }}"
|
||||
tar -tzf "${{ env.TARBALL }}" | head -20
|
||||
tar -tzf "${{ env.TARBALL }}" | grep -q "main.js" || { echo "Error: main.js not found in tarball"; exit 1; }
|
||||
|
||||
- name: Compute checksum
|
||||
id: compute_checksum
|
||||
run: |
|
||||
TARBALL="headlamp-rook-plugin-${{ inputs.version }}.tar.gz"
|
||||
CHECKSUM=$(sha256sum "$TARBALL" | awk '{print $1}')
|
||||
echo "checksum=${CHECKSUM}" >> $GITHUB_OUTPUT
|
||||
echo "Checksum: sha256:${CHECKSUM}"
|
||||
CHECKSUM=$(sha256sum "${{ env.TARBALL }}" | awk '{print $1}')
|
||||
echo "CHECKSUM=$CHECKSUM" >> $GITHUB_ENV
|
||||
sed -i "s|headlamp/plugin/archive-checksum:.*|headlamp/plugin/archive-checksum: sha256:${CHECKSUM}|" artifacthub-pkg.yml
|
||||
|
||||
- name: Update checksum in metadata
|
||||
- name: Commit and tag
|
||||
run: |
|
||||
CHECKSUM="${{ steps.compute_checksum.outputs.checksum }}"
|
||||
sed -i "s|headlamp/plugin/archive-checksum:.*|headlamp/plugin/archive-checksum: \"sha256:${CHECKSUM}\"|" artifacthub-pkg.yml
|
||||
|
||||
- name: Commit version bump and metadata
|
||||
run: |
|
||||
git add package.json artifacthub-pkg.yml
|
||||
git commit -m "chore: release v${{ inputs.version }}"
|
||||
git push origin main
|
||||
|
||||
- name: Create and push tag
|
||||
run: |
|
||||
git tag "v${{ inputs.version }}"
|
||||
git push origin "v${{ inputs.version }}"
|
||||
VERSION="${{ inputs.version }}"
|
||||
git add package.json package-lock.json artifacthub-pkg.yml
|
||||
git commit -m "release: v${VERSION}"
|
||||
git tag "v${VERSION}"
|
||||
git push origin main --tags
|
||||
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
tag_name: "v${{ inputs.version }}"
|
||||
files: headlamp-rook-plugin-${{ inputs.version }}.tar.gz
|
||||
files: ${{ env.TARBALL }}
|
||||
fail_on_unmatched_files: true
|
||||
draft: false
|
||||
prerelease: false
|
||||
generate_release_notes: true
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Summary
|
||||
run: |
|
||||
echo "✓ Version bumped to ${{ inputs.version }}"
|
||||
echo "✓ Metadata updated with checksum sha256:${{ steps.compute_checksum.outputs.checksum }}"
|
||||
echo "✓ Tag v${{ inputs.version }} created"
|
||||
echo "✓ GitHub release published with tarball"
|
||||
|
||||
+4
-1
@@ -1,5 +1,8 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.headlamp-plugin/
|
||||
*.tar.gz
|
||||
.env
|
||||
.env.local
|
||||
.eslintcache
|
||||
.playwright-mcp/
|
||||
.mcp.json
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"github": {
|
||||
"type": "http",
|
||||
"url": "https://api.githubcopilot.com/mcp/",
|
||||
"headers": {
|
||||
"Authorization": "Bearer ${GITHUB_TOKEN}"
|
||||
}
|
||||
},
|
||||
"kubernetes": {
|
||||
"type": "sse",
|
||||
"url": "http://localhost:8080/sse"
|
||||
},
|
||||
"flux": {
|
||||
"type": "sse",
|
||||
"url": "http://localhost:8081/sse"
|
||||
},
|
||||
"playwright": {
|
||||
"type": "sse",
|
||||
"url": "http://localhost:8086/sse"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
module.exports = require('@headlamp-k8s/eslint-config/prettier-config');
|
||||
+50
-6
@@ -7,6 +7,47 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.2.6] - 2026-03-04
|
||||
|
||||
### Fixed
|
||||
|
||||
- **AppBarClusterBadge registration** — cluster health badge in the Headlamp top nav bar was implemented but never registered; now wired up via `registerAppBarAction`
|
||||
- **CSI pod label mismatch** — `CephPodDetailSection` now recognizes both legacy (`csi-rbdplugin-provisioner`) and Rook 1.12+ (`rook-ceph.rbd.csi.ceph.com-ctrlplugin`) CSI pod labels
|
||||
- **Duplicate `parseStorageToBytes`** — removed local copy from `OverviewPage`; imports shared implementation from `k8s.ts`
|
||||
- **ObjectStore endpoint type safety** — added `endpoints` field to `CephObjectStoreStatus` interface, eliminating unsafe double-cast
|
||||
- **Redundant guard** — removed duplicate `storageClasses.length > 0` condition in `OverviewPage`
|
||||
|
||||
### Added
|
||||
|
||||
- **Sidebar entries** for Storage Classes and Volumes pages — both are now navigable from the sidebar instead of only accessible via direct URL
|
||||
- **Drawer accessibility** — all detail panel drawers now include `role="dialog"`, `aria-modal`, `aria-labelledby`, and Escape key handling
|
||||
|
||||
### Changed
|
||||
|
||||
- **Theme-aware colors** — replaced hardcoded hex colors with CSS custom properties (`var(--mui-palette-*)`) in `AppBarClusterBadge`, `ClusterStatusCard`, and `OverviewPage` for dark/light theme compatibility
|
||||
- **API URL constants** — `RookCephDataContext` now uses `ROOK_CEPH_API_GROUP` and `ROOK_CEPH_API_VERSION` constants instead of string literals
|
||||
- **`extractJsonData` hoisted** — moved from inside the component render body to module-level function
|
||||
|
||||
### Removed
|
||||
|
||||
- **Dead code** — removed unused `extractPoolFromVolumeHandle` function from `k8s.ts`
|
||||
|
||||
## [0.2.2] - 2026-02-19
|
||||
|
||||
### Changed
|
||||
|
||||
- **Package name** — renamed from `headlamp-rook-plugin` to `rook` so the plugin displays correctly in Headlamp's Plugins list
|
||||
|
||||
## [0.2.1] - 2026-02-19
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Duplicate columns** — Protocol and Pool columns on mixed-driver clusters (rook-ceph + tns-csi) are now merged into a single shared column rather than duplicated; whichever plugin loads first owns the column and the second merges into it
|
||||
|
||||
### Changed
|
||||
|
||||
- **Sidebar label** — top-level navigation entry renamed from `Rook-Ceph` to `Rook`
|
||||
|
||||
## [0.2.0] - 2026-02-19
|
||||
|
||||
### Changed
|
||||
@@ -56,9 +97,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- TypeScript strict mode with zero `any` types
|
||||
- ESLint + Prettier code quality tooling
|
||||
|
||||
[Unreleased]: https://github.com/cpfarhood/headlamp-rook-plugin/compare/v0.2.0...HEAD
|
||||
[0.2.0]: https://github.com/cpfarhood/headlamp-rook-plugin/compare/v0.1.3...v0.2.0
|
||||
[0.1.3]: https://github.com/cpfarhood/headlamp-rook-plugin/compare/v0.1.2...v0.1.3
|
||||
[0.1.2]: https://github.com/cpfarhood/headlamp-rook-plugin/compare/v0.1.1...v0.1.2
|
||||
[0.1.1]: https://github.com/cpfarhood/headlamp-rook-plugin/compare/v0.1.0...v0.1.1
|
||||
[0.1.0]: https://github.com/cpfarhood/headlamp-rook-plugin/releases/tag/v0.1.0
|
||||
[Unreleased]: https://github.com/privilegedescalation/headlamp-rook-plugin/compare/v0.2.6...HEAD
|
||||
[0.2.6]: https://github.com/privilegedescalation/headlamp-rook-plugin/compare/v0.2.5...v0.2.6
|
||||
[0.2.2]: https://github.com/privilegedescalation/headlamp-rook-plugin/compare/v0.2.1...v0.2.2
|
||||
[0.2.1]: https://github.com/privilegedescalation/headlamp-rook-plugin/compare/v0.2.0...v0.2.1
|
||||
[0.2.0]: https://github.com/privilegedescalation/headlamp-rook-plugin/compare/v0.1.3...v0.2.0
|
||||
[0.1.3]: https://github.com/privilegedescalation/headlamp-rook-plugin/compare/v0.1.2...v0.1.3
|
||||
[0.1.2]: https://github.com/privilegedescalation/headlamp-rook-plugin/compare/v0.1.1...v0.1.2
|
||||
[0.1.1]: https://github.com/privilegedescalation/headlamp-rook-plugin/compare/v0.1.0...v0.1.1
|
||||
[0.1.0]: https://github.com/privilegedescalation/headlamp-rook-plugin/releases/tag/v0.1.0
|
||||
|
||||
@@ -6,7 +6,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||
|
||||
Headlamp plugin for Rook-Ceph cluster visibility.
|
||||
|
||||
- **Plugin name**: `headlamp-rook-plugin`
|
||||
- **Plugin name**: `rook`
|
||||
- **Rook-Ceph API group**: `ceph.rook.io/v1`
|
||||
- **Default namespace**: `rook-ceph`
|
||||
- **Reference plugin**: `../headlamp-tns-csi-plugin`
|
||||
@@ -33,7 +33,7 @@ All tests and `tsc` must pass before committing.
|
||||
|
||||
```
|
||||
src/
|
||||
├── index.tsx # Plugin entry: registerRoute, registerSidebarEntry, etc.
|
||||
├── index.tsx # Plugin entry: registerRoute, registerSidebarEntry, registerAppBarAction, etc.
|
||||
├── api/
|
||||
│ ├── k8s.ts # Types + filtering helpers (ceph.rook.io)
|
||||
│ └── RookCephDataContext.tsx # Shared React context provider
|
||||
@@ -43,8 +43,10 @@ src/
|
||||
├── StorageClassesPage.tsx
|
||||
├── VolumesPage.tsx
|
||||
├── PodsPage.tsx
|
||||
├── FilesystemsPage.tsx
|
||||
├── ObjectStoresPage.tsx
|
||||
├── ClusterStatusCard.tsx
|
||||
├── AppBarClusterBadge.tsx
|
||||
├── AppBarClusterBadge.tsx # Cluster health badge in Headlamp top nav bar
|
||||
├── PVCDetailSection.tsx # Injected into Headlamp PVC detail view
|
||||
├── PVDetailSection.tsx # Injected into Headlamp PV detail view
|
||||
├── CephPodDetailSection.tsx # Injected into Headlamp Pod detail view
|
||||
@@ -69,7 +71,9 @@ All pages consume data exclusively via `useRookCephContext()`. The provider is r
|
||||
- RBD provisioner: `rook-ceph.rbd.csi.ceph.com`
|
||||
- CephFS provisioner: `rook-ceph.cephfs.csi.ceph.com`
|
||||
- Custom namespace provisioners: any string ending in `.rbd.csi.ceph.com` or `.cephfs.csi.ceph.com`
|
||||
- Pod selectors: `app=rook-ceph-operator`, `app=rook-ceph-mon`, `app=rook-ceph-osd`, `app=rook-ceph-mgr`, `app=csi-rbdplugin-provisioner`, `app=csi-cephfsplugin-provisioner`
|
||||
- Pod selectors: `app=rook-ceph-operator`, `app=rook-ceph-mon`, `app=rook-ceph-osd`, `app=rook-ceph-mgr`, `app=rook-ceph-mds`, `app=rook-ceph-rgw`
|
||||
- CSI pod selectors (Rook 1.12+): `app=rook-ceph.rbd.csi.ceph.com-ctrlplugin`, `app=rook-ceph.cephfs.csi.ceph.com-ctrlplugin`
|
||||
- CSI pod selectors (legacy): `app=csi-rbdplugin-provisioner`, `app=csi-cephfsplugin-provisioner`, `app=csi-rbdplugin`, `app=csi-cephfsplugin`
|
||||
|
||||
## Code conventions
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
# Headlamp Rook Plugin
|
||||
|
||||
[](https://artifacthub.io/packages/headlamp/rook/headlamp-rook-plugin)
|
||||
[](https://github.com/cpfarhood/headlamp-rook-plugin/actions/workflows/ci.yaml)
|
||||
[](https://opensource.org/licenses/Apache-2.0)
|
||||
|
||||
@@ -47,7 +48,11 @@ Rook-Ceph must be deployed in the `rook-ceph` namespace with standard labels. Th
|
||||
|
||||
## Installing
|
||||
|
||||
### Option 1: Manual Plugin Install
|
||||
### Option 1: Headlamp Plugin Manager (Recommended)
|
||||
|
||||
Browse the Headlamp Plugin Manager (Settings → Plugins → Catalog) and install **headlamp-rook-plugin** directly.
|
||||
|
||||
### Option 2: Manual Plugin Install
|
||||
|
||||
Download the latest release tarball and place it in your Headlamp plugins directory:
|
||||
|
||||
@@ -60,10 +65,6 @@ curl -L https://github.com/cpfarhood/headlamp-rook-plugin/releases/latest/downlo
|
||||
tar -xzf headlamp-rook-plugin.tar.gz -C ~/.config/Headlamp/plugins/
|
||||
```
|
||||
|
||||
### Option 2: Headlamp In-App Plugin Manager
|
||||
|
||||
Browse the Headlamp Plugin Manager (Settings → Plugins) and install **headlamp-rook-plugin** directly.
|
||||
|
||||
## RBAC & Security Setup
|
||||
|
||||
The plugin reads Rook-Ceph CRDs and Kubernetes resources. Your Headlamp service account needs:
|
||||
@@ -107,6 +108,16 @@ subjects:
|
||||
namespace: headlamp
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Symptom | Likely Cause | Quick Fix |
|
||||
| ------- | ------------ | --------- |
|
||||
| **Plugin not in sidebar** | Plugin not installed or needs browser refresh | Hard refresh (Cmd+Shift+R / Ctrl+Shift+F5) |
|
||||
| **No CephCluster data** | CRDs not installed or RBAC insufficient | Verify `kubectl get cephclusters -n rook-ceph` works |
|
||||
| **Block Pools empty** | No CephBlockPool resources | Check `kubectl get cephblockpools -n rook-ceph` |
|
||||
| **App bar badge missing** | No CephCluster present | Verify rook-ceph is deployed with a CephCluster resource |
|
||||
| **StorageClass columns not showing** | Rook provisioner not matching | Verify SC provisioner ends in `.rbd.csi.ceph.com` or `.cephfs.csi.ceph.com` |
|
||||
|
||||
## Development
|
||||
|
||||
### Prerequisites
|
||||
|
||||
+7
-3
@@ -1,4 +1,4 @@
|
||||
version: "0.2.0"
|
||||
version: "0.2.6"
|
||||
name: headlamp-rook-plugin
|
||||
displayName: Rook Plugin
|
||||
createdAt: "2026-02-18T00:00:00Z"
|
||||
@@ -22,8 +22,12 @@ maintainers:
|
||||
email: privilegedescalation@users.noreply.github.com
|
||||
provider:
|
||||
name: privilegedescalation
|
||||
changes:
|
||||
- kind: changed
|
||||
description: "Package renamed to rook so the plugin displays correctly in Headlamp's Plugins list"
|
||||
|
||||
annotations:
|
||||
headlamp/plugin/archive-url: "https://github.com/privilegedescalation/headlamp-rook-plugin/releases/download/v0.2.0/headlamp-rook-plugin-0.2.0.tar.gz"
|
||||
headlamp/plugin/archive-checksum: "sha256:2ef2efd810a512c13914a9ce74af23f6ec8ab0937d30cac1593a42dce5e9fe17"
|
||||
headlamp/plugin/archive-url: "https://github.com/privilegedescalation/headlamp-rook-plugin/releases/download/v0.2.6/rook-0.2.6.tar.gz"
|
||||
headlamp/plugin/archive-checksum: sha256:b3707b65a3fc3e8b8a35975fe52633a6a1fa3c52cef493ef4e5ce979028b6f3e
|
||||
headlamp/plugin/distro-compat: ""
|
||||
headlamp/plugin/version-compat: ">=0.20"
|
||||
|
||||
Generated
+4
-4
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "headlamp-rook-plugin",
|
||||
"version": "0.1.0",
|
||||
"name": "rook",
|
||||
"version": "0.2.6",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "headlamp-rook-plugin",
|
||||
"version": "0.1.0",
|
||||
"name": "rook",
|
||||
"version": "0.2.6",
|
||||
"license": "Apache-2.0",
|
||||
"devDependencies": {
|
||||
"@kinvolk/headlamp-plugin": "^0.13.0"
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "headlamp-rook-plugin",
|
||||
"version": "0.2.0",
|
||||
"name": "rook",
|
||||
"version": "0.2.6",
|
||||
"description": "Headlamp plugin for Rook-Ceph cluster visibility and CSI driver monitoring",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||
"extends": ["config:recommended"]
|
||||
}
|
||||
@@ -17,6 +17,8 @@ import {
|
||||
filterRookCephPVCs,
|
||||
filterRookCephStorageClasses,
|
||||
isKubeList,
|
||||
ROOK_CEPH_API_GROUP,
|
||||
ROOK_CEPH_API_VERSION,
|
||||
ROOK_CEPH_NAMESPACE,
|
||||
ROOK_CSI_CEPHFS_SELECTOR,
|
||||
ROOK_CSI_RBD_SELECTOR,
|
||||
@@ -79,6 +81,19 @@ export function useRookCephContext(): RookCephContextValue {
|
||||
return ctx;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Unwrap Headlamp KubeObject class instances to their raw `.jsonData`. */
|
||||
function extractJsonData(items: unknown[]): unknown[] {
|
||||
return items.map(item =>
|
||||
item && typeof item === 'object' && 'jsonData' in item
|
||||
? (item as { jsonData: unknown }).jsonData
|
||||
: item
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Provider
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -118,7 +133,7 @@ export function RookCephDataProvider({ children }: { children: React.ReactNode }
|
||||
// CephCluster CRDs
|
||||
try {
|
||||
const clusterList = await ApiProxy.request(
|
||||
`/apis/ceph.rook.io/v1/namespaces/${ROOK_CEPH_NAMESPACE}/cephclusters`
|
||||
`/apis/${ROOK_CEPH_API_GROUP}/${ROOK_CEPH_API_VERSION}/namespaces/${ROOK_CEPH_NAMESPACE}/cephclusters`
|
||||
);
|
||||
if (!cancelled && isKubeList(clusterList)) {
|
||||
setCephClusters(clusterList.items as CephCluster[]);
|
||||
@@ -130,7 +145,7 @@ export function RookCephDataProvider({ children }: { children: React.ReactNode }
|
||||
// CephBlockPool CRDs
|
||||
try {
|
||||
const poolList = await ApiProxy.request(
|
||||
`/apis/ceph.rook.io/v1/namespaces/${ROOK_CEPH_NAMESPACE}/cephblockpools`
|
||||
`/apis/${ROOK_CEPH_API_GROUP}/${ROOK_CEPH_API_VERSION}/namespaces/${ROOK_CEPH_NAMESPACE}/cephblockpools`
|
||||
);
|
||||
if (!cancelled && isKubeList(poolList)) {
|
||||
setBlockPools(poolList.items as CephBlockPool[]);
|
||||
@@ -142,7 +157,7 @@ export function RookCephDataProvider({ children }: { children: React.ReactNode }
|
||||
// CephFilesystem CRDs
|
||||
try {
|
||||
const fsList = await ApiProxy.request(
|
||||
`/apis/ceph.rook.io/v1/namespaces/${ROOK_CEPH_NAMESPACE}/cephfilesystems`
|
||||
`/apis/${ROOK_CEPH_API_GROUP}/${ROOK_CEPH_API_VERSION}/namespaces/${ROOK_CEPH_NAMESPACE}/cephfilesystems`
|
||||
);
|
||||
if (!cancelled && isKubeList(fsList)) {
|
||||
setFilesystems(fsList.items as CephFilesystem[]);
|
||||
@@ -154,7 +169,7 @@ export function RookCephDataProvider({ children }: { children: React.ReactNode }
|
||||
// CephObjectStore CRDs
|
||||
try {
|
||||
const osList = await ApiProxy.request(
|
||||
`/apis/ceph.rook.io/v1/namespaces/${ROOK_CEPH_NAMESPACE}/cephobjectstores`
|
||||
`/apis/${ROOK_CEPH_API_GROUP}/${ROOK_CEPH_API_VERSION}/namespaces/${ROOK_CEPH_NAMESPACE}/cephobjectstores`
|
||||
);
|
||||
if (!cancelled && isKubeList(osList)) {
|
||||
setObjectStores(osList.items as CephObjectStore[]);
|
||||
@@ -166,7 +181,9 @@ export function RookCephDataProvider({ children }: { children: React.ReactNode }
|
||||
// Operator pods
|
||||
try {
|
||||
const opList = await ApiProxy.request(
|
||||
`/api/v1/namespaces/${ROOK_CEPH_NAMESPACE}/pods?labelSelector=${encodeURIComponent(ROOK_OPERATOR_SELECTOR)}`
|
||||
`/api/v1/namespaces/${ROOK_CEPH_NAMESPACE}/pods?labelSelector=${encodeURIComponent(
|
||||
ROOK_OPERATOR_SELECTOR
|
||||
)}`
|
||||
);
|
||||
if (!cancelled && isKubeList(opList)) setOperatorPods(opList.items as RookCephPod[]);
|
||||
} catch {
|
||||
@@ -176,7 +193,9 @@ export function RookCephDataProvider({ children }: { children: React.ReactNode }
|
||||
// MON pods
|
||||
try {
|
||||
const monList = await ApiProxy.request(
|
||||
`/api/v1/namespaces/${ROOK_CEPH_NAMESPACE}/pods?labelSelector=${encodeURIComponent(ROOK_MON_SELECTOR)}`
|
||||
`/api/v1/namespaces/${ROOK_CEPH_NAMESPACE}/pods?labelSelector=${encodeURIComponent(
|
||||
ROOK_MON_SELECTOR
|
||||
)}`
|
||||
);
|
||||
if (!cancelled && isKubeList(monList)) setMonPods(monList.items as RookCephPod[]);
|
||||
} catch {
|
||||
@@ -186,7 +205,9 @@ export function RookCephDataProvider({ children }: { children: React.ReactNode }
|
||||
// OSD pods
|
||||
try {
|
||||
const osdList = await ApiProxy.request(
|
||||
`/api/v1/namespaces/${ROOK_CEPH_NAMESPACE}/pods?labelSelector=${encodeURIComponent(ROOK_OSD_SELECTOR)}`
|
||||
`/api/v1/namespaces/${ROOK_CEPH_NAMESPACE}/pods?labelSelector=${encodeURIComponent(
|
||||
ROOK_OSD_SELECTOR
|
||||
)}`
|
||||
);
|
||||
if (!cancelled && isKubeList(osdList)) setOsdPods(osdList.items as RookCephPod[]);
|
||||
} catch {
|
||||
@@ -196,7 +217,9 @@ export function RookCephDataProvider({ children }: { children: React.ReactNode }
|
||||
// MGR pods
|
||||
try {
|
||||
const mgrList = await ApiProxy.request(
|
||||
`/api/v1/namespaces/${ROOK_CEPH_NAMESPACE}/pods?labelSelector=${encodeURIComponent(ROOK_MGR_SELECTOR)}`
|
||||
`/api/v1/namespaces/${ROOK_CEPH_NAMESPACE}/pods?labelSelector=${encodeURIComponent(
|
||||
ROOK_MGR_SELECTOR
|
||||
)}`
|
||||
);
|
||||
if (!cancelled && isKubeList(mgrList)) setMgrPods(mgrList.items as RookCephPod[]);
|
||||
} catch {
|
||||
@@ -206,9 +229,12 @@ export function RookCephDataProvider({ children }: { children: React.ReactNode }
|
||||
// CSI RBD provisioner pods
|
||||
try {
|
||||
const csiRbdList = await ApiProxy.request(
|
||||
`/api/v1/namespaces/${ROOK_CEPH_NAMESPACE}/pods?labelSelector=${encodeURIComponent(ROOK_CSI_RBD_SELECTOR)}`
|
||||
`/api/v1/namespaces/${ROOK_CEPH_NAMESPACE}/pods?labelSelector=${encodeURIComponent(
|
||||
ROOK_CSI_RBD_SELECTOR
|
||||
)}`
|
||||
);
|
||||
if (!cancelled && isKubeList(csiRbdList)) setCsiRbdPods(csiRbdList.items as RookCephPod[]);
|
||||
if (!cancelled && isKubeList(csiRbdList))
|
||||
setCsiRbdPods(csiRbdList.items as RookCephPod[]);
|
||||
} catch {
|
||||
if (!cancelled) setCsiRbdPods([]);
|
||||
}
|
||||
@@ -216,9 +242,12 @@ export function RookCephDataProvider({ children }: { children: React.ReactNode }
|
||||
// CSI CephFS provisioner pods
|
||||
try {
|
||||
const csiCephfsList = await ApiProxy.request(
|
||||
`/api/v1/namespaces/${ROOK_CEPH_NAMESPACE}/pods?labelSelector=${encodeURIComponent(ROOK_CSI_CEPHFS_SELECTOR)}`
|
||||
`/api/v1/namespaces/${ROOK_CEPH_NAMESPACE}/pods?labelSelector=${encodeURIComponent(
|
||||
ROOK_CSI_CEPHFS_SELECTOR
|
||||
)}`
|
||||
);
|
||||
if (!cancelled && isKubeList(csiCephfsList)) setCsiCephfsPods(csiCephfsList.items as RookCephPod[]);
|
||||
if (!cancelled && isKubeList(csiCephfsList))
|
||||
setCsiCephfsPods(csiCephfsList.items as RookCephPod[]);
|
||||
} catch {
|
||||
if (!cancelled) setCsiCephfsPods([]);
|
||||
}
|
||||
@@ -232,22 +261,16 @@ export function RookCephDataProvider({ children }: { children: React.ReactNode }
|
||||
}
|
||||
|
||||
void fetchAsync();
|
||||
return () => { cancelled = true; };
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [refreshKey]);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Derived / filtered values — memoized to avoid recomputation on every render
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Headlamp useList() returns KubeObject class instances that store raw
|
||||
// Kubernetes JSON under `.jsonData`. Extract it so our plain-object helpers
|
||||
// work correctly.
|
||||
const extractJsonData = (items: unknown[]): unknown[] =>
|
||||
items.map(item =>
|
||||
item && typeof item === 'object' && 'jsonData' in item
|
||||
? (item as { jsonData: unknown }).jsonData
|
||||
: item
|
||||
);
|
||||
// Uses module-level extractJsonData below
|
||||
|
||||
const storageClasses = useMemo(() => {
|
||||
if (!allStorageClasses) return [];
|
||||
|
||||
+31
-28
@@ -129,9 +129,12 @@ export interface CephCluster extends KubeObject {
|
||||
|
||||
export function healthToStatus(health: string | undefined): 'success' | 'warning' | 'error' {
|
||||
switch (health) {
|
||||
case 'HEALTH_OK': return 'success';
|
||||
case 'HEALTH_WARN': return 'warning';
|
||||
default: return 'error';
|
||||
case 'HEALTH_OK':
|
||||
return 'success';
|
||||
case 'HEALTH_WARN':
|
||||
return 'warning';
|
||||
default:
|
||||
return 'error';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -206,10 +209,16 @@ export interface CephObjectStoreSpec {
|
||||
gateway?: { port?: number; securePort?: number; instances?: number };
|
||||
}
|
||||
|
||||
export interface CephObjectStoreEndpoints {
|
||||
insecure?: string[];
|
||||
secure?: string[];
|
||||
}
|
||||
|
||||
export interface CephObjectStoreStatus {
|
||||
phase?: string;
|
||||
conditions?: CephClusterCondition[];
|
||||
info?: Record<string, string>;
|
||||
endpoints?: CephObjectStoreEndpoints;
|
||||
}
|
||||
|
||||
export interface CephObjectStore extends KubeObject {
|
||||
@@ -331,9 +340,7 @@ export function findBoundPv(
|
||||
): RookCephPersistentVolume | undefined {
|
||||
const ns = pvc.metadata.namespace ?? '';
|
||||
const name = pvc.metadata.name;
|
||||
return rookPvs.find(
|
||||
pv => pv.spec.claimRef?.namespace === ns && pv.spec.claimRef?.name === name
|
||||
);
|
||||
return rookPvs.find(pv => pv.spec.claimRef?.namespace === ns && pv.spec.claimRef?.name === name);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -368,15 +375,11 @@ export interface RookCephPod extends KubeObject {
|
||||
}
|
||||
|
||||
export function isPodReady(pod: RookCephPod): boolean {
|
||||
return (
|
||||
pod.status?.conditions?.some(c => c.type === 'Ready' && c.status === 'True') ?? false
|
||||
);
|
||||
return pod.status?.conditions?.some(c => c.type === 'Ready' && c.status === 'True') ?? false;
|
||||
}
|
||||
|
||||
export function getPodRestarts(pod: RookCephPod): number {
|
||||
return (
|
||||
pod.status?.containerStatuses?.reduce((sum, c) => sum + c.restartCount, 0) ?? 0
|
||||
);
|
||||
return pod.status?.containerStatuses?.reduce((sum, c) => sum + c.restartCount, 0) ?? 0;
|
||||
}
|
||||
|
||||
export function getPodImage(pod: RookCephPod): string {
|
||||
@@ -441,11 +444,16 @@ export function parseStorageToBytes(storage: string): number {
|
||||
const suffix = match[2] ?? '';
|
||||
const multipliers: Record<string, number> = {
|
||||
'': 1,
|
||||
K: 1e3, Ki: 1024,
|
||||
M: 1e6, Mi: 1024 ** 2,
|
||||
G: 1e9, Gi: 1024 ** 3,
|
||||
T: 1e12, Ti: 1024 ** 4,
|
||||
P: 1e15, Pi: 1024 ** 5,
|
||||
K: 1e3,
|
||||
Ki: 1024,
|
||||
M: 1e6,
|
||||
Mi: 1024 ** 2,
|
||||
G: 1e9,
|
||||
Gi: 1024 ** 3,
|
||||
T: 1e12,
|
||||
Ti: 1024 ** 4,
|
||||
P: 1e15,
|
||||
Pi: 1024 ** 5,
|
||||
};
|
||||
return value * (multipliers[suffix] ?? 1);
|
||||
}
|
||||
@@ -453,16 +461,11 @@ export function parseStorageToBytes(storage: string): number {
|
||||
/** Returns display label for storage type (rbd → Block, cephfs → Filesystem). */
|
||||
export function formatStorageType(type: 'rbd' | 'cephfs' | 'unknown'): string {
|
||||
switch (type) {
|
||||
case 'rbd': return 'Block (RBD)';
|
||||
case 'cephfs': return 'Filesystem (CephFS)';
|
||||
default: return 'Unknown';
|
||||
case 'rbd':
|
||||
return 'Block (RBD)';
|
||||
case 'cephfs':
|
||||
return 'Filesystem (CephFS)';
|
||||
default:
|
||||
return 'Unknown';
|
||||
}
|
||||
}
|
||||
|
||||
/** Extracts pool/subvolume group name from a Rook-Ceph PV volumeHandle. */
|
||||
export function extractPoolFromVolumeHandle(handle: string | undefined): string {
|
||||
if (!handle) return '—';
|
||||
// RBD format: "<csi-vol-id>-<pool>-..." — pool is in volumeAttributes
|
||||
// We rely on volumeAttributes.pool instead; this just provides a fallback.
|
||||
return handle;
|
||||
}
|
||||
|
||||
@@ -14,10 +14,14 @@ import { useRookCephContext } from '../api/RookCephDataContext';
|
||||
|
||||
function getHealthColor(health: string | undefined): string {
|
||||
switch (health) {
|
||||
case 'HEALTH_OK': return '#4caf50';
|
||||
case 'HEALTH_WARN': return '#ff9800';
|
||||
case 'HEALTH_ERR': return '#f44336';
|
||||
default: return '#9e9e9e';
|
||||
case 'HEALTH_OK':
|
||||
return 'var(--mui-palette-success-main, #4caf50)';
|
||||
case 'HEALTH_WARN':
|
||||
return 'var(--mui-palette-warning-main, #ff9800)';
|
||||
case 'HEALTH_ERR':
|
||||
return 'var(--mui-palette-error-main, #f44336)';
|
||||
default:
|
||||
return 'var(--mui-palette-action-disabled, #9e9e9e)';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -17,9 +17,18 @@ import { useRookCephContext } from '../api/RookCephDataContext';
|
||||
function BlockPoolDetail({ pool, onClose }: { pool: CephBlockPool; onClose: () => void }) {
|
||||
return (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="drawer-title-blockpool"
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
}}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: 0, right: 0, bottom: 0, width: '480px',
|
||||
top: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
width: '480px',
|
||||
backgroundColor: 'var(--mui-palette-background-paper, #fff)',
|
||||
boxShadow: '-4px 0 16px rgba(0,0,0,0.15)',
|
||||
zIndex: 1300,
|
||||
@@ -27,8 +36,15 @@ function BlockPoolDetail({ pool, onClose }: { pool: CephBlockPool; onClose: () =
|
||||
padding: '24px',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '16px' }}>
|
||||
<strong>{pool.metadata.name}</strong>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: '16px',
|
||||
}}
|
||||
>
|
||||
<strong id="drawer-title-blockpool">{pool.metadata.name}</strong>
|
||||
<button
|
||||
onClick={onClose}
|
||||
aria-label="Close"
|
||||
@@ -99,14 +115,18 @@ export default function BlockPoolsPage() {
|
||||
|
||||
{error && (
|
||||
<SectionBox title="Error">
|
||||
<NameValueTable rows={[{ name: 'Status', value: <StatusLabel status="error">{error}</StatusLabel> }]} />
|
||||
<NameValueTable
|
||||
rows={[{ name: 'Status', value: <StatusLabel status="error">{error}</StatusLabel> }]}
|
||||
/>
|
||||
</SectionBox>
|
||||
)}
|
||||
|
||||
{blockPools.length === 0 ? (
|
||||
<SectionBox title="No Block Pools">
|
||||
<NameValueTable
|
||||
rows={[{ name: 'Status', value: 'No CephBlockPool resources found in rook-ceph namespace.' }]}
|
||||
rows={[
|
||||
{ name: 'Status', value: 'No CephBlockPool resources found in rook-ceph namespace.' },
|
||||
]}
|
||||
/>
|
||||
</SectionBox>
|
||||
) : (
|
||||
@@ -118,7 +138,15 @@ export default function BlockPoolsPage() {
|
||||
getter: (p: CephBlockPool) => (
|
||||
<button
|
||||
onClick={() => setSelected(p)}
|
||||
style={{ border: 'none', background: 'transparent', color: 'var(--link-color, #1976d2)', cursor: 'pointer', textDecoration: 'underline', padding: 0, font: 'inherit' }}
|
||||
style={{
|
||||
border: 'none',
|
||||
background: 'transparent',
|
||||
color: 'var(--link-color, #1976d2)',
|
||||
cursor: 'pointer',
|
||||
textDecoration: 'underline',
|
||||
padding: 0,
|
||||
font: 'inherit',
|
||||
}}
|
||||
>
|
||||
{p.metadata.name}
|
||||
</button>
|
||||
@@ -132,10 +160,22 @@ export default function BlockPoolsPage() {
|
||||
</StatusLabel>
|
||||
),
|
||||
},
|
||||
{ label: 'Replicas', getter: (p: CephBlockPool) => String(p.spec?.replicated?.size ?? '—') },
|
||||
{ label: 'Failure Domain', getter: (p: CephBlockPool) => p.spec?.failureDomain ?? '—' },
|
||||
{ label: 'Mirroring', getter: (p: CephBlockPool) => p.spec?.mirroring?.enabled ? 'Enabled' : 'Disabled' },
|
||||
{ label: 'Age', getter: (p: CephBlockPool) => formatAge(p.metadata.creationTimestamp) },
|
||||
{
|
||||
label: 'Replicas',
|
||||
getter: (p: CephBlockPool) => String(p.spec?.replicated?.size ?? '—'),
|
||||
},
|
||||
{
|
||||
label: 'Failure Domain',
|
||||
getter: (p: CephBlockPool) => p.spec?.failureDomain ?? '—',
|
||||
},
|
||||
{
|
||||
label: 'Mirroring',
|
||||
getter: (p: CephBlockPool) => (p.spec?.mirroring?.enabled ? 'Enabled' : 'Disabled'),
|
||||
},
|
||||
{
|
||||
label: 'Age',
|
||||
getter: (p: CephBlockPool) => formatAge(p.metadata.creationTimestamp),
|
||||
},
|
||||
]}
|
||||
data={blockPools}
|
||||
/>
|
||||
@@ -145,7 +185,12 @@ export default function BlockPoolsPage() {
|
||||
{selected && (
|
||||
<>
|
||||
<div
|
||||
style={{ position: 'fixed', inset: 0, backgroundColor: 'rgba(0,0,0,0.3)', zIndex: 1299 }}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
backgroundColor: 'rgba(0,0,0,0.3)',
|
||||
zIndex: 1299,
|
||||
}}
|
||||
onClick={() => setSelected(null)}
|
||||
/>
|
||||
<BlockPoolDetail pool={selected} onClose={() => setSelected(null)} />
|
||||
|
||||
@@ -47,10 +47,14 @@ const ROOK_APP_LABELS = new Set([
|
||||
'rook-ceph-mgr',
|
||||
'rook-ceph-mds',
|
||||
'rook-ceph-rgw',
|
||||
// Legacy CSI labels (pre-Rook 1.12)
|
||||
'csi-rbdplugin-provisioner',
|
||||
'csi-cephfsplugin-provisioner',
|
||||
'csi-rbdplugin',
|
||||
'csi-cephfsplugin',
|
||||
// New CSI labels (Rook 1.12+)
|
||||
'rook-ceph.rbd.csi.ceph.com-ctrlplugin',
|
||||
'rook-ceph.cephfs.csi.ceph.com-ctrlplugin',
|
||||
]);
|
||||
|
||||
const ROLE_LABELS: Record<string, string> = {
|
||||
@@ -64,6 +68,8 @@ const ROLE_LABELS: Record<string, string> = {
|
||||
'csi-cephfsplugin-provisioner': 'CSI CephFS Provisioner',
|
||||
'csi-rbdplugin': 'CSI RBD Node Plugin',
|
||||
'csi-cephfsplugin': 'CSI CephFS Node Plugin',
|
||||
'rook-ceph.rbd.csi.ceph.com-ctrlplugin': 'CSI RBD Provisioner',
|
||||
'rook-ceph.cephfs.csi.ceph.com-ctrlplugin': 'CSI CephFS Provisioner',
|
||||
};
|
||||
|
||||
export default function CephPodDetailSection({ resource }: CephPodDetailSectionProps) {
|
||||
@@ -80,16 +86,17 @@ export default function CephPodDetailSection({ resource }: CephPodDetailSectionP
|
||||
const role = ROLE_LABELS[appLabel] ?? appLabel;
|
||||
const phase = raw.status?.phase ?? 'Unknown';
|
||||
const isReady =
|
||||
raw.status?.conditions?.some((c) => c.type === 'Ready' && c.status === 'True') ?? false;
|
||||
const restarts =
|
||||
raw.status?.containerStatuses?.reduce((s, c) => s + c.restartCount, 0) ?? 0;
|
||||
raw.status?.conditions?.some(c => c.type === 'Ready' && c.status === 'True') ?? false;
|
||||
const restarts = raw.status?.containerStatuses?.reduce((s, c) => s + c.restartCount, 0) ?? 0;
|
||||
|
||||
const containerRows = (raw.status?.containerStatuses ?? []).map((cs) => {
|
||||
const containerRows = (raw.status?.containerStatuses ?? []).map(cs => {
|
||||
let stateStr = 'Unknown';
|
||||
if (cs.state?.running) stateStr = 'Running';
|
||||
else if (cs.state?.waiting) stateStr = `Waiting: ${cs.state.waiting.reason ?? ''}`;
|
||||
else if (cs.state?.terminated)
|
||||
stateStr = `Terminated: ${cs.state.terminated.reason ?? ''} (exit ${cs.state.terminated.exitCode ?? ''})`;
|
||||
stateStr = `Terminated: ${cs.state.terminated.reason ?? ''} (exit ${
|
||||
cs.state.terminated.exitCode ?? ''
|
||||
})`;
|
||||
|
||||
return {
|
||||
name: cs.name,
|
||||
@@ -111,11 +118,7 @@ export default function CephPodDetailSection({ resource }: CephPodDetailSectionP
|
||||
},
|
||||
{
|
||||
name: 'Phase',
|
||||
value: (
|
||||
<StatusLabel status={isReady ? 'success' : 'error'}>
|
||||
{phase}
|
||||
</StatusLabel>
|
||||
),
|
||||
value: <StatusLabel status={isReady ? 'success' : 'error'}>{phase}</StatusLabel>,
|
||||
},
|
||||
{ name: 'Node', value: raw.spec?.nodeName ?? '—' },
|
||||
{ name: 'Restarts', value: String(restarts) },
|
||||
|
||||
@@ -11,7 +11,15 @@ import {
|
||||
} from '@kinvolk/headlamp-plugin/lib/CommonComponents';
|
||||
import React from 'react';
|
||||
import type { CephCluster, RookCephPod } from '../api/k8s';
|
||||
import { formatAge, formatBytes, getPodImage, getPodRestarts, healthToStatus, isPodReady, phaseToStatus } from '../api/k8s';
|
||||
import {
|
||||
formatAge,
|
||||
formatBytes,
|
||||
getPodImage,
|
||||
getPodRestarts,
|
||||
healthToStatus,
|
||||
isPodReady,
|
||||
phaseToStatus,
|
||||
} from '../api/k8s';
|
||||
|
||||
interface ClusterStatusCardProps {
|
||||
cephClusters: CephCluster[];
|
||||
@@ -26,17 +34,14 @@ interface ClusterStatusCardProps {
|
||||
function PodStatusBadge({ pod }: { pod: RookCephPod }) {
|
||||
const ready = isPodReady(pod);
|
||||
const phase = pod.status?.phase ?? 'Unknown';
|
||||
return (
|
||||
<StatusLabel status={ready ? 'success' : 'error'}>
|
||||
{phase}
|
||||
</StatusLabel>
|
||||
);
|
||||
return <StatusLabel status={ready ? 'success' : 'error'}>{phase}</StatusLabel>;
|
||||
}
|
||||
|
||||
function PodSummaryRow({ pods, label }: { pods: RookCephPod[]; label: string }) {
|
||||
const ready = pods.filter(isPodReady).length;
|
||||
const total = pods.length;
|
||||
const status = total === 0 ? 'error' : ready === total ? 'success' : ready > 0 ? 'warning' : 'error';
|
||||
const status =
|
||||
total === 0 ? 'error' : ready === total ? 'success' : ready > 0 ? 'warning' : 'error';
|
||||
return {
|
||||
name: label,
|
||||
value: (
|
||||
@@ -84,12 +89,12 @@ export default function ClusterStatusCard({
|
||||
{
|
||||
name: 'Phase',
|
||||
value: (
|
||||
<StatusLabel status={phaseToStatus(phase)}>
|
||||
{phase ?? 'Unknown'}
|
||||
</StatusLabel>
|
||||
<StatusLabel status={phaseToStatus(phase)}>{phase ?? 'Unknown'}</StatusLabel>
|
||||
),
|
||||
},
|
||||
...(cluster.status?.message ? [{ name: 'Message', value: cluster.status.message }] : []),
|
||||
...(cluster.status?.message
|
||||
? [{ name: 'Message', value: cluster.status.message }]
|
||||
: []),
|
||||
{ name: 'Ceph Version', value: version },
|
||||
{ name: 'Namespace', value: cluster.metadata.namespace ?? '—' },
|
||||
{ name: 'Age', value: formatAge(cluster.metadata.creationTimestamp) },
|
||||
@@ -102,8 +107,19 @@ export default function ClusterStatusCard({
|
||||
<div style={{ marginBottom: '12px' }}>
|
||||
<PercentageBar
|
||||
data={[
|
||||
{ name: 'Used', value: bytesUsed, fill: usedPct > 80 ? '#f44336' : '#1976d2' },
|
||||
{ name: 'Free', value: bytesAvail, fill: '#e0e0e0' },
|
||||
{
|
||||
name: 'Used',
|
||||
value: bytesUsed,
|
||||
fill:
|
||||
usedPct > 80
|
||||
? 'var(--mui-palette-error-main, #f44336)'
|
||||
: 'var(--mui-palette-primary-main, #1976d2)',
|
||||
},
|
||||
{
|
||||
name: 'Free',
|
||||
value: bytesAvail,
|
||||
fill: 'var(--mui-palette-action-disabledBackground, #e0e0e0)',
|
||||
},
|
||||
]}
|
||||
total={bytesTotal}
|
||||
/>
|
||||
@@ -142,7 +158,9 @@ export function PodDetailRows({ pods, label }: { pods: RookCephPod[]; label: str
|
||||
return (
|
||||
<SectionBox title={label}>
|
||||
<NameValueTable
|
||||
rows={[{ name: 'Status', value: <StatusLabel status="error">No pods found</StatusLabel> }]}
|
||||
rows={[
|
||||
{ name: 'Status', value: <StatusLabel status="error">No pods found</StatusLabel> },
|
||||
]}
|
||||
/>
|
||||
</SectionBox>
|
||||
);
|
||||
|
||||
@@ -17,9 +17,18 @@ import { useRookCephContext } from '../api/RookCephDataContext';
|
||||
function FilesystemDetail({ fs, onClose }: { fs: CephFilesystem; onClose: () => void }) {
|
||||
return (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="drawer-title-filesystem"
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
}}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: 0, right: 0, bottom: 0, width: '480px',
|
||||
top: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
width: '480px',
|
||||
backgroundColor: 'var(--mui-palette-background-paper, #fff)',
|
||||
boxShadow: '-4px 0 16px rgba(0,0,0,0.15)',
|
||||
zIndex: 1300,
|
||||
@@ -27,8 +36,15 @@ function FilesystemDetail({ fs, onClose }: { fs: CephFilesystem; onClose: () =>
|
||||
padding: '24px',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '16px' }}>
|
||||
<strong>{fs.metadata.name}</strong>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: '16px',
|
||||
}}
|
||||
>
|
||||
<strong id="drawer-title-filesystem">{fs.metadata.name}</strong>
|
||||
<button
|
||||
onClick={onClose}
|
||||
aria-label="Close"
|
||||
@@ -58,7 +74,10 @@ function FilesystemDetail({ fs, onClose }: { fs: CephFilesystem; onClose: () =>
|
||||
<NameValueTable
|
||||
rows={[
|
||||
{ name: 'Active Count', value: String(fs.spec?.metadataServer?.activeCount ?? '—') },
|
||||
{ name: 'Active Standby', value: String(fs.spec?.metadataServer?.activeStandby ?? '—') },
|
||||
{
|
||||
name: 'Active Standby',
|
||||
value: String(fs.spec?.metadataServer?.activeStandby ?? '—'),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</SectionBox>
|
||||
@@ -107,14 +126,21 @@ export default function FilesystemsPage() {
|
||||
|
||||
{error && (
|
||||
<SectionBox title="Error">
|
||||
<NameValueTable rows={[{ name: 'Status', value: <StatusLabel status="error">{error}</StatusLabel> }]} />
|
||||
<NameValueTable
|
||||
rows={[{ name: 'Status', value: <StatusLabel status="error">{error}</StatusLabel> }]}
|
||||
/>
|
||||
</SectionBox>
|
||||
)}
|
||||
|
||||
{filesystems.length === 0 ? (
|
||||
<SectionBox title="No Filesystems">
|
||||
<NameValueTable
|
||||
rows={[{ name: 'Status', value: 'No CephFilesystem resources found in rook-ceph namespace.' }]}
|
||||
rows={[
|
||||
{
|
||||
name: 'Status',
|
||||
value: 'No CephFilesystem resources found in rook-ceph namespace.',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</SectionBox>
|
||||
) : (
|
||||
@@ -126,7 +152,15 @@ export default function FilesystemsPage() {
|
||||
getter: (f: CephFilesystem) => (
|
||||
<button
|
||||
onClick={() => setSelected(f)}
|
||||
style={{ border: 'none', background: 'transparent', color: 'var(--link-color, #1976d2)', cursor: 'pointer', textDecoration: 'underline', padding: 0, font: 'inherit' }}
|
||||
style={{
|
||||
border: 'none',
|
||||
background: 'transparent',
|
||||
color: 'var(--link-color, #1976d2)',
|
||||
cursor: 'pointer',
|
||||
textDecoration: 'underline',
|
||||
padding: 0,
|
||||
font: 'inherit',
|
||||
}}
|
||||
>
|
||||
{f.metadata.name}
|
||||
</button>
|
||||
@@ -140,10 +174,22 @@ export default function FilesystemsPage() {
|
||||
</StatusLabel>
|
||||
),
|
||||
},
|
||||
{ label: 'Active MDS', getter: (f: CephFilesystem) => String(f.spec?.metadataServer?.activeCount ?? '—') },
|
||||
{ label: 'Active Standby', getter: (f: CephFilesystem) => String(f.spec?.metadataServer?.activeStandby ?? '—') },
|
||||
{ label: 'Data Pools', getter: (f: CephFilesystem) => String(f.spec?.dataPools?.length ?? 0) },
|
||||
{ label: 'Age', getter: (f: CephFilesystem) => formatAge(f.metadata.creationTimestamp) },
|
||||
{
|
||||
label: 'Active MDS',
|
||||
getter: (f: CephFilesystem) => String(f.spec?.metadataServer?.activeCount ?? '—'),
|
||||
},
|
||||
{
|
||||
label: 'Active Standby',
|
||||
getter: (f: CephFilesystem) => String(f.spec?.metadataServer?.activeStandby ?? '—'),
|
||||
},
|
||||
{
|
||||
label: 'Data Pools',
|
||||
getter: (f: CephFilesystem) => String(f.spec?.dataPools?.length ?? 0),
|
||||
},
|
||||
{
|
||||
label: 'Age',
|
||||
getter: (f: CephFilesystem) => formatAge(f.metadata.creationTimestamp),
|
||||
},
|
||||
]}
|
||||
data={filesystems}
|
||||
/>
|
||||
@@ -153,7 +199,12 @@ export default function FilesystemsPage() {
|
||||
{selected && (
|
||||
<>
|
||||
<div
|
||||
style={{ position: 'fixed', inset: 0, backgroundColor: 'rgba(0,0,0,0.3)', zIndex: 1299 }}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
backgroundColor: 'rgba(0,0,0,0.3)',
|
||||
zIndex: 1299,
|
||||
}}
|
||||
onClick={() => setSelected(null)}
|
||||
/>
|
||||
<FilesystemDetail fs={selected} onClose={() => setSelected(null)} />
|
||||
|
||||
@@ -15,15 +15,22 @@ import { CephObjectStore, formatAge, phaseToStatus } from '../api/k8s';
|
||||
import { useRookCephContext } from '../api/RookCephDataContext';
|
||||
|
||||
function ObjectStoreDetail({ store, onClose }: { store: CephObjectStore; onClose: () => void }) {
|
||||
const endpoints = (store.status as unknown as Record<string, unknown>)?.endpoints as
|
||||
| { insecure?: string[]; secure?: string[] }
|
||||
| undefined;
|
||||
const endpoints = store.status?.endpoints;
|
||||
|
||||
return (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="drawer-title-objectstore"
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
}}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: 0, right: 0, bottom: 0, width: '480px',
|
||||
top: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
width: '480px',
|
||||
backgroundColor: 'var(--mui-palette-background-paper, #fff)',
|
||||
boxShadow: '-4px 0 16px rgba(0,0,0,0.15)',
|
||||
zIndex: 1300,
|
||||
@@ -31,8 +38,15 @@ function ObjectStoreDetail({ store, onClose }: { store: CephObjectStore; onClose
|
||||
padding: '24px',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '16px' }}>
|
||||
<strong>{store.metadata.name}</strong>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: '16px',
|
||||
}}
|
||||
>
|
||||
<strong id="drawer-title-objectstore">{store.metadata.name}</strong>
|
||||
<button
|
||||
onClick={onClose}
|
||||
aria-label="Close"
|
||||
@@ -67,7 +81,7 @@ function ObjectStoreDetail({ store, onClose }: { store: CephObjectStore; onClose
|
||||
]}
|
||||
/>
|
||||
</SectionBox>
|
||||
{(endpoints?.insecure?.length || endpoints?.secure?.length) ? (
|
||||
{endpoints?.insecure?.length || endpoints?.secure?.length ? (
|
||||
<SectionBox title="Endpoints">
|
||||
<NameValueTable
|
||||
rows={[
|
||||
@@ -104,14 +118,21 @@ export default function ObjectStoresPage() {
|
||||
|
||||
{error && (
|
||||
<SectionBox title="Error">
|
||||
<NameValueTable rows={[{ name: 'Status', value: <StatusLabel status="error">{error}</StatusLabel> }]} />
|
||||
<NameValueTable
|
||||
rows={[{ name: 'Status', value: <StatusLabel status="error">{error}</StatusLabel> }]}
|
||||
/>
|
||||
</SectionBox>
|
||||
)}
|
||||
|
||||
{objectStores.length === 0 ? (
|
||||
<SectionBox title="No Object Stores">
|
||||
<NameValueTable
|
||||
rows={[{ name: 'Status', value: 'No CephObjectStore resources found in rook-ceph namespace.' }]}
|
||||
rows={[
|
||||
{
|
||||
name: 'Status',
|
||||
value: 'No CephObjectStore resources found in rook-ceph namespace.',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</SectionBox>
|
||||
) : (
|
||||
@@ -123,7 +144,15 @@ export default function ObjectStoresPage() {
|
||||
getter: (o: CephObjectStore) => (
|
||||
<button
|
||||
onClick={() => setSelected(o)}
|
||||
style={{ border: 'none', background: 'transparent', color: 'var(--link-color, #1976d2)', cursor: 'pointer', textDecoration: 'underline', padding: 0, font: 'inherit' }}
|
||||
style={{
|
||||
border: 'none',
|
||||
background: 'transparent',
|
||||
color: 'var(--link-color, #1976d2)',
|
||||
cursor: 'pointer',
|
||||
textDecoration: 'underline',
|
||||
padding: 0,
|
||||
font: 'inherit',
|
||||
}}
|
||||
>
|
||||
{o.metadata.name}
|
||||
</button>
|
||||
@@ -137,9 +166,18 @@ export default function ObjectStoresPage() {
|
||||
</StatusLabel>
|
||||
),
|
||||
},
|
||||
{ label: 'Gateway Port', getter: (o: CephObjectStore) => String(o.spec?.gateway?.port ?? '—') },
|
||||
{ label: 'Instances', getter: (o: CephObjectStore) => String(o.spec?.gateway?.instances ?? '—') },
|
||||
{ label: 'Age', getter: (o: CephObjectStore) => formatAge(o.metadata.creationTimestamp) },
|
||||
{
|
||||
label: 'Gateway Port',
|
||||
getter: (o: CephObjectStore) => String(o.spec?.gateway?.port ?? '—'),
|
||||
},
|
||||
{
|
||||
label: 'Instances',
|
||||
getter: (o: CephObjectStore) => String(o.spec?.gateway?.instances ?? '—'),
|
||||
},
|
||||
{
|
||||
label: 'Age',
|
||||
getter: (o: CephObjectStore) => formatAge(o.metadata.creationTimestamp),
|
||||
},
|
||||
]}
|
||||
data={objectStores}
|
||||
/>
|
||||
@@ -149,7 +187,12 @@ export default function ObjectStoresPage() {
|
||||
{selected && (
|
||||
<>
|
||||
<div
|
||||
style={{ position: 'fixed', inset: 0, backgroundColor: 'rgba(0,0,0,0.3)', zIndex: 1299 }}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
backgroundColor: 'rgba(0,0,0,0.3)',
|
||||
zIndex: 1299,
|
||||
}}
|
||||
onClick={() => setSelected(null)}
|
||||
/>
|
||||
<ObjectStoreDetail store={selected} onClose={() => setSelected(null)} />
|
||||
|
||||
@@ -15,7 +15,14 @@ import {
|
||||
StatusLabel,
|
||||
} from '@kinvolk/headlamp-plugin/lib/CommonComponents';
|
||||
import React from 'react';
|
||||
import { formatAge, formatBytes, healthToStatus, phaseToStatus, storageClassType } from '../api/k8s';
|
||||
import {
|
||||
formatAge,
|
||||
formatBytes,
|
||||
healthToStatus,
|
||||
parseStorageToBytes,
|
||||
phaseToStatus,
|
||||
storageClassType,
|
||||
} from '../api/k8s';
|
||||
import { useRookCephContext } from '../api/RookCephDataContext';
|
||||
import ClusterStatusCard from './ClusterStatusCard';
|
||||
|
||||
@@ -70,7 +77,14 @@ export default function OverviewPage() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '20px' }}>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: '20px',
|
||||
}}
|
||||
>
|
||||
<SectionHeader title="Rook-Ceph — Overview" />
|
||||
<button
|
||||
onClick={refresh}
|
||||
@@ -97,11 +111,16 @@ export default function OverviewPage() {
|
||||
rows={[
|
||||
{
|
||||
name: 'Status',
|
||||
value: <StatusLabel status="error">No CephCluster found in namespace rook-ceph</StatusLabel>,
|
||||
value: (
|
||||
<StatusLabel status="error">
|
||||
No CephCluster found in namespace rook-ceph
|
||||
</StatusLabel>
|
||||
),
|
||||
},
|
||||
{
|
||||
name: 'Install',
|
||||
value: 'helm install rook-ceph rook-release/rook-ceph -n rook-ceph --create-namespace',
|
||||
value:
|
||||
'helm install rook-ceph rook-release/rook-ceph -n rook-ceph --create-namespace',
|
||||
},
|
||||
{
|
||||
name: 'Docs',
|
||||
@@ -129,9 +148,7 @@ export default function OverviewPage() {
|
||||
{
|
||||
name: 'Health',
|
||||
value: (
|
||||
<StatusLabel status={healthToStatus(primaryHealth)}>
|
||||
{primaryHealth}
|
||||
</StatusLabel>
|
||||
<StatusLabel status={healthToStatus(primaryHealth)}>{primaryHealth}</StatusLabel>
|
||||
),
|
||||
},
|
||||
{
|
||||
@@ -146,27 +163,46 @@ export default function OverviewPage() {
|
||||
{/* Storage type distribution */}
|
||||
{storageClasses.length > 0 && (
|
||||
<SectionBox title="Storage Summary">
|
||||
{storageClasses.length > 0 && (
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
<div style={{ marginBottom: '8px', fontSize: '14px', color: 'var(--mui-palette-text-secondary)' }}>
|
||||
StorageClass Type Distribution
|
||||
</div>
|
||||
<PercentageBar
|
||||
data={[
|
||||
...(rbdClasses.length > 0
|
||||
? [{ name: 'Block (RBD)', value: rbdClasses.length, fill: '#1976d2' }]
|
||||
: []),
|
||||
...(cephfsClasses.length > 0
|
||||
? [{ name: 'Filesystem (CephFS)', value: cephfsClasses.length, fill: '#9c27b0' }]
|
||||
: []),
|
||||
]}
|
||||
total={storageClasses.length}
|
||||
/>
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
<div
|
||||
style={{
|
||||
marginBottom: '8px',
|
||||
fontSize: '14px',
|
||||
color: 'var(--mui-palette-text-secondary)',
|
||||
}}
|
||||
>
|
||||
StorageClass Type Distribution
|
||||
</div>
|
||||
)}
|
||||
<PercentageBar
|
||||
data={[
|
||||
...(rbdClasses.length > 0
|
||||
? [
|
||||
{
|
||||
name: 'Block (RBD)',
|
||||
value: rbdClasses.length,
|
||||
fill: 'var(--mui-palette-primary-main, #1976d2)',
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(cephfsClasses.length > 0
|
||||
? [
|
||||
{
|
||||
name: 'Filesystem (CephFS)',
|
||||
value: cephfsClasses.length,
|
||||
fill: 'var(--mui-palette-secondary-main, #9c27b0)',
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]}
|
||||
total={storageClasses.length}
|
||||
/>
|
||||
</div>
|
||||
<NameValueTable
|
||||
rows={[
|
||||
{ name: 'Storage Classes', value: `${storageClasses.length} (${rbdClasses.length} RBD, ${cephfsClasses.length} CephFS)` },
|
||||
{
|
||||
name: 'Storage Classes',
|
||||
value: `${storageClasses.length} (${rbdClasses.length} RBD, ${cephfsClasses.length} CephFS)`,
|
||||
},
|
||||
{ name: 'Block Pools', value: String(blockPools.length) },
|
||||
{ name: 'Filesystems', value: String(filesystems.length) },
|
||||
{ name: 'Object Stores', value: String(objectStores.length) },
|
||||
@@ -177,10 +213,20 @@ export default function OverviewPage() {
|
||||
value: <StatusLabel status="success">{pvcStatusCounts.Bound}</StatusLabel>,
|
||||
},
|
||||
...(pvcStatusCounts.Pending > 0
|
||||
? [{ name: 'PVCs (Pending)', value: <StatusLabel status="warning">{pvcStatusCounts.Pending}</StatusLabel> }]
|
||||
? [
|
||||
{
|
||||
name: 'PVCs (Pending)',
|
||||
value: <StatusLabel status="warning">{pvcStatusCounts.Pending}</StatusLabel>,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(pvcStatusCounts.Lost > 0
|
||||
? [{ name: 'PVCs (Lost)', value: <StatusLabel status="error">{pvcStatusCounts.Lost}</StatusLabel> }]
|
||||
? [
|
||||
{
|
||||
name: 'PVCs (Lost)',
|
||||
value: <StatusLabel status="error">{pvcStatusCounts.Lost}</StatusLabel>,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]}
|
||||
/>
|
||||
@@ -203,18 +249,18 @@ export default function OverviewPage() {
|
||||
<SectionBox title="Block Pools">
|
||||
<SimpleTable
|
||||
columns={[
|
||||
{ label: 'Name', getter: (p) => p.metadata.name },
|
||||
{ label: 'Name', getter: p => p.metadata.name },
|
||||
{
|
||||
label: 'Phase',
|
||||
getter: (p) => (
|
||||
getter: p => (
|
||||
<StatusLabel status={phaseToStatus(p.status?.phase)}>
|
||||
{p.status?.phase ?? 'Unknown'}
|
||||
</StatusLabel>
|
||||
),
|
||||
},
|
||||
{ label: 'Replicas', getter: (p) => String(p.spec?.replicated?.size ?? '—') },
|
||||
{ label: 'Failure Domain', getter: (p) => p.spec?.failureDomain ?? '—' },
|
||||
{ label: 'Age', getter: (p) => formatAge(p.metadata.creationTimestamp) },
|
||||
{ label: 'Replicas', getter: p => String(p.spec?.replicated?.size ?? '—') },
|
||||
{ label: 'Failure Domain', getter: p => p.spec?.failureDomain ?? '—' },
|
||||
{ label: 'Age', getter: p => formatAge(p.metadata.creationTimestamp) },
|
||||
]}
|
||||
data={blockPools}
|
||||
/>
|
||||
@@ -226,17 +272,20 @@ export default function OverviewPage() {
|
||||
<SectionBox title="Filesystems">
|
||||
<SimpleTable
|
||||
columns={[
|
||||
{ label: 'Name', getter: (f) => f.metadata.name },
|
||||
{ label: 'Name', getter: f => f.metadata.name },
|
||||
{
|
||||
label: 'Phase',
|
||||
getter: (f) => (
|
||||
getter: f => (
|
||||
<StatusLabel status={phaseToStatus(f.status?.phase)}>
|
||||
{f.status?.phase ?? 'Unknown'}
|
||||
</StatusLabel>
|
||||
),
|
||||
},
|
||||
{ label: 'Active MDS', getter: (f) => String(f.spec?.metadataServer?.activeCount ?? '—') },
|
||||
{ label: 'Age', getter: (f) => formatAge(f.metadata.creationTimestamp) },
|
||||
{
|
||||
label: 'Active MDS',
|
||||
getter: f => String(f.spec?.metadataServer?.activeCount ?? '—'),
|
||||
},
|
||||
{ label: 'Age', getter: f => formatAge(f.metadata.creationTimestamp) },
|
||||
]}
|
||||
data={filesystems}
|
||||
/>
|
||||
@@ -248,18 +297,18 @@ export default function OverviewPage() {
|
||||
<SectionBox title="Object Stores">
|
||||
<SimpleTable
|
||||
columns={[
|
||||
{ label: 'Name', getter: (o) => o.metadata.name },
|
||||
{ label: 'Name', getter: o => o.metadata.name },
|
||||
{
|
||||
label: 'Phase',
|
||||
getter: (o) => (
|
||||
getter: o => (
|
||||
<StatusLabel status={phaseToStatus(o.status?.phase)}>
|
||||
{o.status?.phase ?? 'Unknown'}
|
||||
</StatusLabel>
|
||||
),
|
||||
},
|
||||
{ label: 'Gateway Port', getter: (o) => String(o.spec?.gateway?.port ?? '—') },
|
||||
{ label: 'Instances', getter: (o) => String(o.spec?.gateway?.instances ?? '—') },
|
||||
{ label: 'Age', getter: (o) => formatAge(o.metadata.creationTimestamp) },
|
||||
{ label: 'Gateway Port', getter: o => String(o.spec?.gateway?.port ?? '—') },
|
||||
{ label: 'Instances', getter: o => String(o.spec?.gateway?.instances ?? '—') },
|
||||
{ label: 'Age', getter: o => formatAge(o.metadata.creationTimestamp) },
|
||||
]}
|
||||
data={objectStores}
|
||||
/>
|
||||
@@ -271,17 +320,17 @@ export default function OverviewPage() {
|
||||
<SectionBox title="Attention: Non-Bound PVCs">
|
||||
<SimpleTable
|
||||
columns={[
|
||||
{ label: 'Name', getter: (pvc) => pvc.metadata.name },
|
||||
{ label: 'Namespace', getter: (pvc) => pvc.metadata.namespace ?? '—' },
|
||||
{ label: 'Name', getter: pvc => pvc.metadata.name },
|
||||
{ label: 'Namespace', getter: pvc => pvc.metadata.namespace ?? '—' },
|
||||
{
|
||||
label: 'Status',
|
||||
getter: (pvc) => (
|
||||
getter: pvc => (
|
||||
<StatusLabel status={phaseToStatus(pvc.status?.phase)}>
|
||||
{pvc.status?.phase ?? 'Unknown'}
|
||||
</StatusLabel>
|
||||
),
|
||||
},
|
||||
{ label: 'Age', getter: (pvc) => formatAge(pvc.metadata.creationTimestamp) },
|
||||
{ label: 'Age', getter: pvc => formatAge(pvc.metadata.creationTimestamp) },
|
||||
]}
|
||||
data={nonBoundPvcs}
|
||||
/>
|
||||
@@ -290,19 +339,3 @@ export default function OverviewPage() {
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function parseStorageToBytes(storage: string): number {
|
||||
const match = /^(\d+(?:\.\d+)?)\s*(Ki|Mi|Gi|Ti|Pi|K|M|G|T|P)?$/.exec(storage.trim());
|
||||
if (!match) return 0;
|
||||
const value = parseFloat(match[1]);
|
||||
const suffix = match[2] ?? '';
|
||||
const multipliers: Record<string, number> = {
|
||||
'': 1,
|
||||
K: 1e3, Ki: 1024,
|
||||
M: 1e6, Mi: 1024 ** 2,
|
||||
G: 1e9, Gi: 1024 ** 3,
|
||||
T: 1e12, Ti: 1024 ** 4,
|
||||
P: 1e15, Pi: 1024 ** 5,
|
||||
};
|
||||
return value * (multipliers[suffix] ?? 1);
|
||||
}
|
||||
|
||||
@@ -5,10 +5,7 @@
|
||||
* Uses registerDetailsViewSection in index.tsx.
|
||||
*/
|
||||
|
||||
import {
|
||||
NameValueTable,
|
||||
SectionBox,
|
||||
} from '@kinvolk/headlamp-plugin/lib/CommonComponents';
|
||||
import { NameValueTable, SectionBox } from '@kinvolk/headlamp-plugin/lib/CommonComponents';
|
||||
import React from 'react';
|
||||
import { findBoundPv, formatStorageType } from '../api/k8s';
|
||||
import { useRookCephContext } from '../api/RookCephDataContext';
|
||||
@@ -40,7 +37,11 @@ export default function PVCDetailSection({ resource }: PVCDetailSectionProps) {
|
||||
|
||||
// Determine storage type from driver name
|
||||
const driver = boundPv.spec.csi?.driver ?? '';
|
||||
const type = driver.includes('.rbd.') ? 'rbd' : driver.includes('.cephfs.') ? 'cephfs' : 'unknown';
|
||||
const type = driver.includes('.rbd.')
|
||||
? 'rbd'
|
||||
: driver.includes('.cephfs.')
|
||||
? 'cephfs'
|
||||
: 'unknown';
|
||||
|
||||
return (
|
||||
<SectionBox title="Rook-Ceph Storage Details">
|
||||
|
||||
@@ -4,17 +4,17 @@
|
||||
* Shown only when the PV uses a Rook-Ceph CSI driver.
|
||||
*/
|
||||
|
||||
import {
|
||||
NameValueTable,
|
||||
SectionBox,
|
||||
} from '@kinvolk/headlamp-plugin/lib/CommonComponents';
|
||||
import { NameValueTable, SectionBox } from '@kinvolk/headlamp-plugin/lib/CommonComponents';
|
||||
import React from 'react';
|
||||
import { formatStorageType, isRookCephPersistentVolume } from '../api/k8s';
|
||||
|
||||
interface PVDetailSectionProps {
|
||||
resource: {
|
||||
metadata?: { name?: string };
|
||||
spec?: { csi?: { driver?: string; volumeHandle?: string; volumeAttributes?: Record<string, string> }; storageClassName?: string };
|
||||
spec?: {
|
||||
csi?: { driver?: string; volumeHandle?: string; volumeAttributes?: Record<string, string> };
|
||||
storageClassName?: string;
|
||||
};
|
||||
jsonData?: unknown;
|
||||
};
|
||||
}
|
||||
@@ -34,7 +34,11 @@ export default function PVDetailSection({ resource }: PVDetailSectionProps) {
|
||||
}
|
||||
|
||||
const attrs = spec?.csi?.volumeAttributes ?? {};
|
||||
const type = driver.includes('.rbd.') ? 'rbd' : driver.includes('.cephfs.') ? 'cephfs' : 'unknown';
|
||||
const type = driver.includes('.rbd.')
|
||||
? 'rbd'
|
||||
: driver.includes('.cephfs.')
|
||||
? 'cephfs'
|
||||
: 'unknown';
|
||||
|
||||
return (
|
||||
<SectionBox title="Rook-Ceph Volume Details">
|
||||
|
||||
+37
-32
@@ -20,18 +20,18 @@ function PodTable({ pods, title }: { pods: RookCephPod[]; title: string }) {
|
||||
<SectionBox title={`${title} (${pods.length})`}>
|
||||
<SimpleTable
|
||||
columns={[
|
||||
{ label: 'Name', getter: (p) => p.metadata.name },
|
||||
{ label: 'Name', getter: p => p.metadata.name },
|
||||
{
|
||||
label: 'Status',
|
||||
getter: (p) => (
|
||||
getter: p => (
|
||||
<StatusLabel status={isPodReady(p) ? 'success' : 'error'}>
|
||||
{p.status?.phase ?? 'Unknown'}
|
||||
</StatusLabel>
|
||||
),
|
||||
},
|
||||
{ label: 'Node', getter: (p) => p.spec?.nodeName ?? '—' },
|
||||
{ label: 'Restarts', getter: (p) => String(getPodRestarts(p)) },
|
||||
{ label: 'Age', getter: (p) => formatAge(p.metadata.creationTimestamp) },
|
||||
{ label: 'Node', getter: p => p.spec?.nodeName ?? '—' },
|
||||
{ label: 'Restarts', getter: p => String(getPodRestarts(p)) },
|
||||
{ label: 'Age', getter: p => formatAge(p.metadata.creationTimestamp) },
|
||||
]}
|
||||
data={pods}
|
||||
/>
|
||||
@@ -45,27 +45,27 @@ function OsdTable({ pods }: { pods: RookCephPod[] }) {
|
||||
<SectionBox title={`OSDs (${pods.length})`}>
|
||||
<SimpleTable
|
||||
columns={[
|
||||
{ label: 'OSD ID', getter: (p) => p.metadata.labels?.['osd'] ?? p.metadata.name },
|
||||
{ label: 'OSD ID', getter: p => p.metadata.labels?.['osd'] ?? p.metadata.name },
|
||||
{
|
||||
label: 'Status',
|
||||
getter: (p) => {
|
||||
const st = isPodReady(p) ? 'success' : p.status?.phase === 'Pending' ? 'warning' : 'error';
|
||||
return (
|
||||
<StatusLabel status={st}>
|
||||
{p.status?.phase ?? 'Unknown'}
|
||||
</StatusLabel>
|
||||
);
|
||||
getter: p => {
|
||||
const st = isPodReady(p)
|
||||
? 'success'
|
||||
: p.status?.phase === 'Pending'
|
||||
? 'warning'
|
||||
: 'error';
|
||||
return <StatusLabel status={st}>{p.status?.phase ?? 'Unknown'}</StatusLabel>;
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Node',
|
||||
getter: (p) => p.spec?.nodeName ?? p.metadata.labels?.['topology-location-host'] ?? '—',
|
||||
getter: p => p.spec?.nodeName ?? p.metadata.labels?.['topology-location-host'] ?? '—',
|
||||
},
|
||||
{ label: 'Device Class', getter: (p) => p.metadata.labels?.['device-class'] ?? '—' },
|
||||
{ label: 'Store', getter: (p) => p.metadata.labels?.['osd-store'] ?? '—' },
|
||||
{ label: 'Failure Domain', getter: (p) => p.metadata.labels?.['failure-domain'] ?? '—' },
|
||||
{ label: 'Restarts', getter: (p) => String(getPodRestarts(p)) },
|
||||
{ label: 'Age', getter: (p) => formatAge(p.metadata.creationTimestamp) },
|
||||
{ label: 'Device Class', getter: p => p.metadata.labels?.['device-class'] ?? '—' },
|
||||
{ label: 'Store', getter: p => p.metadata.labels?.['osd-store'] ?? '—' },
|
||||
{ label: 'Failure Domain', getter: p => p.metadata.labels?.['failure-domain'] ?? '—' },
|
||||
{ label: 'Restarts', getter: p => String(getPodRestarts(p)) },
|
||||
{ label: 'Age', getter: p => formatAge(p.metadata.creationTimestamp) },
|
||||
]}
|
||||
data={pods}
|
||||
/>
|
||||
@@ -74,20 +74,19 @@ function OsdTable({ pods }: { pods: RookCephPod[] }) {
|
||||
}
|
||||
|
||||
export default function PodsPage() {
|
||||
const {
|
||||
operatorPods,
|
||||
monPods,
|
||||
osdPods,
|
||||
mgrPods,
|
||||
csiRbdPods,
|
||||
csiCephfsPods,
|
||||
loading,
|
||||
error,
|
||||
} = useRookCephContext();
|
||||
const { operatorPods, monPods, osdPods, mgrPods, csiRbdPods, csiCephfsPods, loading, error } =
|
||||
useRookCephContext();
|
||||
|
||||
if (loading) return <Loader title="Loading Rook-Ceph pods..." />;
|
||||
|
||||
const allPods = [...operatorPods, ...monPods, ...osdPods, ...mgrPods, ...csiRbdPods, ...csiCephfsPods];
|
||||
const allPods = [
|
||||
...operatorPods,
|
||||
...monPods,
|
||||
...osdPods,
|
||||
...mgrPods,
|
||||
...csiRbdPods,
|
||||
...csiCephfsPods,
|
||||
];
|
||||
const totalReady = allPods.filter(isPodReady).length;
|
||||
|
||||
return (
|
||||
@@ -96,7 +95,9 @@ export default function PodsPage() {
|
||||
|
||||
{error && (
|
||||
<SectionBox title="Error">
|
||||
<NameValueTable rows={[{ name: 'Status', value: <StatusLabel status="error">{error}</StatusLabel> }]} />
|
||||
<NameValueTable
|
||||
rows={[{ name: 'Status', value: <StatusLabel status="error">{error}</StatusLabel> }]}
|
||||
/>
|
||||
</SectionBox>
|
||||
)}
|
||||
|
||||
@@ -106,7 +107,11 @@ export default function PodsPage() {
|
||||
{
|
||||
name: 'Overall Health',
|
||||
value: (
|
||||
<StatusLabel status={totalReady === allPods.length && allPods.length > 0 ? 'success' : 'warning'}>
|
||||
<StatusLabel
|
||||
status={
|
||||
totalReady === allPods.length && allPods.length > 0 ? 'success' : 'warning'
|
||||
}
|
||||
>
|
||||
{totalReady}/{allPods.length} pods ready
|
||||
</StatusLabel>
|
||||
),
|
||||
|
||||
@@ -14,13 +14,30 @@ import React, { useState } from 'react';
|
||||
import { formatAge, formatStorageType, RookCephStorageClass, storageClassType } from '../api/k8s';
|
||||
import { useRookCephContext } from '../api/RookCephDataContext';
|
||||
|
||||
function StorageClassDetail({ sc, pvCount, onClose }: { sc: RookCephStorageClass; pvCount: number; onClose: () => void }) {
|
||||
function StorageClassDetail({
|
||||
sc,
|
||||
pvCount,
|
||||
onClose,
|
||||
}: {
|
||||
sc: RookCephStorageClass;
|
||||
pvCount: number;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const type = storageClassType(sc);
|
||||
return (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="drawer-title-storageclass"
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
}}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: 0, right: 0, bottom: 0, width: '480px',
|
||||
top: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
width: '480px',
|
||||
backgroundColor: 'var(--mui-palette-background-paper, #fff)',
|
||||
boxShadow: '-4px 0 16px rgba(0,0,0,0.15)',
|
||||
zIndex: 1300,
|
||||
@@ -28,8 +45,15 @@ function StorageClassDetail({ sc, pvCount, onClose }: { sc: RookCephStorageClass
|
||||
padding: '24px',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '16px' }}>
|
||||
<strong>{sc.metadata.name}</strong>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: '16px',
|
||||
}}
|
||||
>
|
||||
<strong id="drawer-title-storageclass">{sc.metadata.name}</strong>
|
||||
<button
|
||||
onClick={onClose}
|
||||
aria-label="Close"
|
||||
@@ -46,7 +70,10 @@ function StorageClassDetail({ sc, pvCount, onClose }: { sc: RookCephStorageClass
|
||||
{ name: 'Type', value: formatStorageType(type) },
|
||||
{ name: 'Reclaim Policy', value: sc.reclaimPolicy ?? '—' },
|
||||
{ name: 'Volume Binding Mode', value: sc.volumeBindingMode ?? '—' },
|
||||
{ name: 'Volume Expansion', value: sc.allowVolumeExpansion ? 'Allowed' : 'Not allowed' },
|
||||
{
|
||||
name: 'Volume Expansion',
|
||||
value: sc.allowVolumeExpansion ? 'Allowed' : 'Not allowed',
|
||||
},
|
||||
{ name: 'Age', value: formatAge(sc.metadata.creationTimestamp) },
|
||||
{ name: 'Bound PVs', value: String(pvCount) },
|
||||
]}
|
||||
@@ -81,14 +108,22 @@ export default function StorageClassesPage() {
|
||||
|
||||
{error && (
|
||||
<SectionBox title="Error">
|
||||
<NameValueTable rows={[{ name: 'Status', value: <StatusLabel status="error">{error}</StatusLabel> }]} />
|
||||
<NameValueTable
|
||||
rows={[{ name: 'Status', value: <StatusLabel status="error">{error}</StatusLabel> }]}
|
||||
/>
|
||||
</SectionBox>
|
||||
)}
|
||||
|
||||
{storageClasses.length === 0 ? (
|
||||
<SectionBox title="No Storage Classes">
|
||||
<NameValueTable
|
||||
rows={[{ name: 'Status', value: 'No Rook-Ceph StorageClasses found. Ensure CephBlockPool and CephFilesystem resources exist.' }]}
|
||||
rows={[
|
||||
{
|
||||
name: 'Status',
|
||||
value:
|
||||
'No Rook-Ceph StorageClasses found. Ensure CephBlockPool and CephFilesystem resources exist.',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</SectionBox>
|
||||
) : (
|
||||
@@ -100,7 +135,15 @@ export default function StorageClassesPage() {
|
||||
getter: (sc: RookCephStorageClass) => (
|
||||
<button
|
||||
onClick={() => setSelected(sc)}
|
||||
style={{ border: 'none', background: 'transparent', color: 'var(--link-color, #1976d2)', cursor: 'pointer', textDecoration: 'underline', padding: 0, font: 'inherit' }}
|
||||
style={{
|
||||
border: 'none',
|
||||
background: 'transparent',
|
||||
color: 'var(--link-color, #1976d2)',
|
||||
cursor: 'pointer',
|
||||
textDecoration: 'underline',
|
||||
padding: 0,
|
||||
font: 'inherit',
|
||||
}}
|
||||
>
|
||||
{sc.metadata.name}
|
||||
</button>
|
||||
@@ -115,11 +158,24 @@ export default function StorageClassesPage() {
|
||||
),
|
||||
},
|
||||
{ label: 'Provisioner', getter: (sc: RookCephStorageClass) => sc.provisioner },
|
||||
{ label: 'Pool', getter: (sc: RookCephStorageClass) => sc.parameters?.['pool'] ?? '—' },
|
||||
{
|
||||
label: 'Pool',
|
||||
getter: (sc: RookCephStorageClass) => sc.parameters?.['pool'] ?? '—',
|
||||
},
|
||||
{ label: 'Reclaim', getter: (sc: RookCephStorageClass) => sc.reclaimPolicy ?? '—' },
|
||||
{ label: 'Expansion', getter: (sc: RookCephStorageClass) => sc.allowVolumeExpansion ? 'Yes' : 'No' },
|
||||
{ label: 'PVs', getter: (sc: RookCephStorageClass) => String(pvCountByClass.get(sc.metadata.name) ?? 0) },
|
||||
{ label: 'Age', getter: (sc: RookCephStorageClass) => formatAge(sc.metadata.creationTimestamp) },
|
||||
{
|
||||
label: 'Expansion',
|
||||
getter: (sc: RookCephStorageClass) => (sc.allowVolumeExpansion ? 'Yes' : 'No'),
|
||||
},
|
||||
{
|
||||
label: 'PVs',
|
||||
getter: (sc: RookCephStorageClass) =>
|
||||
String(pvCountByClass.get(sc.metadata.name) ?? 0),
|
||||
},
|
||||
{
|
||||
label: 'Age',
|
||||
getter: (sc: RookCephStorageClass) => formatAge(sc.metadata.creationTimestamp),
|
||||
},
|
||||
]}
|
||||
data={storageClasses}
|
||||
/>
|
||||
@@ -129,7 +185,12 @@ export default function StorageClassesPage() {
|
||||
{selected && (
|
||||
<>
|
||||
<div
|
||||
style={{ position: 'fixed', inset: 0, backgroundColor: 'rgba(0,0,0,0.3)', zIndex: 1299 }}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
backgroundColor: 'rgba(0,0,0,0.3)',
|
||||
zIndex: 1299,
|
||||
}}
|
||||
onClick={() => setSelected(null)}
|
||||
/>
|
||||
<StorageClassDetail
|
||||
|
||||
@@ -18,9 +18,18 @@ function PVDetail({ pv, onClose }: { pv: RookCephPersistentVolume; onClose: () =
|
||||
const attrs = pv.spec.csi?.volumeAttributes ?? {};
|
||||
return (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="drawer-title-pv"
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
}}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: 0, right: 0, bottom: 0, width: '520px',
|
||||
top: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
width: '520px',
|
||||
backgroundColor: 'var(--mui-palette-background-paper, #fff)',
|
||||
boxShadow: '-4px 0 16px rgba(0,0,0,0.15)',
|
||||
zIndex: 1300,
|
||||
@@ -28,8 +37,15 @@ function PVDetail({ pv, onClose }: { pv: RookCephPersistentVolume; onClose: () =
|
||||
padding: '24px',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '16px' }}>
|
||||
<strong>{pv.metadata.name}</strong>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: '16px',
|
||||
}}
|
||||
>
|
||||
<strong id="drawer-title-pv">{pv.metadata.name}</strong>
|
||||
<button
|
||||
onClick={onClose}
|
||||
aria-label="Close"
|
||||
@@ -89,7 +105,9 @@ export default function VolumesPage() {
|
||||
|
||||
{error && (
|
||||
<SectionBox title="Error">
|
||||
<NameValueTable rows={[{ name: 'Status', value: <StatusLabel status="error">{error}</StatusLabel> }]} />
|
||||
<NameValueTable
|
||||
rows={[{ name: 'Status', value: <StatusLabel status="error">{error}</StatusLabel> }]}
|
||||
/>
|
||||
</SectionBox>
|
||||
)}
|
||||
|
||||
@@ -108,14 +126,28 @@ export default function VolumesPage() {
|
||||
getter: (pv: RookCephPersistentVolume) => (
|
||||
<button
|
||||
onClick={() => setSelected(pv)}
|
||||
style={{ border: 'none', background: 'transparent', color: 'var(--link-color, #1976d2)', cursor: 'pointer', textDecoration: 'underline', padding: 0, font: 'inherit' }}
|
||||
style={{
|
||||
border: 'none',
|
||||
background: 'transparent',
|
||||
color: 'var(--link-color, #1976d2)',
|
||||
cursor: 'pointer',
|
||||
textDecoration: 'underline',
|
||||
padding: 0,
|
||||
font: 'inherit',
|
||||
}}
|
||||
>
|
||||
{pv.metadata.name}
|
||||
</button>
|
||||
),
|
||||
},
|
||||
{ label: 'Capacity', getter: (pv: RookCephPersistentVolume) => pv.spec.capacity?.storage ?? '—' },
|
||||
{ label: 'Access Modes', getter: (pv: RookCephPersistentVolume) => formatAccessModes(pv.spec.accessModes) },
|
||||
{
|
||||
label: 'Capacity',
|
||||
getter: (pv: RookCephPersistentVolume) => pv.spec.capacity?.storage ?? '—',
|
||||
},
|
||||
{
|
||||
label: 'Access Modes',
|
||||
getter: (pv: RookCephPersistentVolume) => formatAccessModes(pv.spec.accessModes),
|
||||
},
|
||||
{
|
||||
label: 'Phase',
|
||||
getter: (pv: RookCephPersistentVolume) => (
|
||||
@@ -124,10 +156,25 @@ export default function VolumesPage() {
|
||||
</StatusLabel>
|
||||
),
|
||||
},
|
||||
{ label: 'Reclaim', getter: (pv: RookCephPersistentVolume) => pv.spec.persistentVolumeReclaimPolicy ?? '—' },
|
||||
{ label: 'Pool', getter: (pv: RookCephPersistentVolume) => pv.spec.csi?.volumeAttributes?.['pool'] ?? '—' },
|
||||
{ label: 'Claim', getter: (pv: RookCephPersistentVolume) => pv.spec.claimRef ? `${pv.spec.claimRef.namespace}/${pv.spec.claimRef.name}` : '—' },
|
||||
{ label: 'Age', getter: (pv: RookCephPersistentVolume) => formatAge(pv.metadata.creationTimestamp) },
|
||||
{
|
||||
label: 'Reclaim',
|
||||
getter: (pv: RookCephPersistentVolume) =>
|
||||
pv.spec.persistentVolumeReclaimPolicy ?? '—',
|
||||
},
|
||||
{
|
||||
label: 'Pool',
|
||||
getter: (pv: RookCephPersistentVolume) =>
|
||||
pv.spec.csi?.volumeAttributes?.['pool'] ?? '—',
|
||||
},
|
||||
{
|
||||
label: 'Claim',
|
||||
getter: (pv: RookCephPersistentVolume) =>
|
||||
pv.spec.claimRef ? `${pv.spec.claimRef.namespace}/${pv.spec.claimRef.name}` : '—',
|
||||
},
|
||||
{
|
||||
label: 'Age',
|
||||
getter: (pv: RookCephPersistentVolume) => formatAge(pv.metadata.creationTimestamp),
|
||||
},
|
||||
]}
|
||||
data={persistentVolumes}
|
||||
/>
|
||||
@@ -137,7 +184,12 @@ export default function VolumesPage() {
|
||||
{selected && (
|
||||
<>
|
||||
<div
|
||||
style={{ position: 'fixed', inset: 0, backgroundColor: 'rgba(0,0,0,0.3)', zIndex: 1299 }}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
backgroundColor: 'rgba(0,0,0,0.3)',
|
||||
zIndex: 1299,
|
||||
}}
|
||||
onClick={() => setSelected(null)}
|
||||
/>
|
||||
<PVDetail pv={selected} onClose={() => setSelected(null)} />
|
||||
|
||||
@@ -64,7 +64,7 @@ export function buildStorageClassColumns() {
|
||||
},
|
||||
{
|
||||
label: 'Pool',
|
||||
getValue: (item: unknown) => getField(item, 'parameters', 'pool') as string | null ?? null,
|
||||
getValue: (item: unknown) => (getField(item, 'parameters', 'pool') as string | null) ?? null,
|
||||
render: (item: unknown) => {
|
||||
if (!isRookRow(item)) return <span>—</span>;
|
||||
const pool = getField(item, 'parameters', 'pool') as string | undefined;
|
||||
@@ -73,12 +73,17 @@ export function buildStorageClassColumns() {
|
||||
},
|
||||
{
|
||||
label: 'Cluster',
|
||||
getValue: (item: unknown) => getField(item, 'parameters', 'clusterID') as string | null ?? null,
|
||||
getValue: (item: unknown) =>
|
||||
(getField(item, 'parameters', 'clusterID') as string | null) ?? null,
|
||||
render: (item: unknown) => {
|
||||
if (!isRookRow(item)) return <span>—</span>;
|
||||
const clusterID = getField(item, 'parameters', 'clusterID') as string | undefined;
|
||||
if (!clusterID) return <span>—</span>;
|
||||
return <span title={clusterID}>{clusterID.length > 16 ? `${clusterID.slice(0, 16)}…` : clusterID}</span>;
|
||||
return (
|
||||
<span title={clusterID}>
|
||||
{clusterID.length > 16 ? `${clusterID.slice(0, 16)}…` : clusterID}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -101,10 +106,13 @@ export function buildPVColumns() {
|
||||
},
|
||||
{
|
||||
label: 'Pool',
|
||||
getValue: (item: unknown) => getField(item, 'spec', 'csi', 'volumeAttributes', 'pool') as string | null ?? null,
|
||||
getValue: (item: unknown) =>
|
||||
(getField(item, 'spec', 'csi', 'volumeAttributes', 'pool') as string | null) ?? null,
|
||||
render: (item: unknown) => {
|
||||
if (!isRookPvRow(item)) return <span>—</span>;
|
||||
const pool = getField(item, 'spec', 'csi', 'volumeAttributes', 'pool') as string | undefined;
|
||||
const pool = getField(item, 'spec', 'csi', 'volumeAttributes', 'pool') as
|
||||
| string
|
||||
| undefined;
|
||||
return <span>{pool ?? '—'}</span>;
|
||||
},
|
||||
},
|
||||
|
||||
+72
-8
@@ -6,6 +6,7 @@
|
||||
*/
|
||||
|
||||
import {
|
||||
registerAppBarAction,
|
||||
registerDetailsViewSection,
|
||||
registerResourceTableColumnsProcessor,
|
||||
registerRoute,
|
||||
@@ -13,10 +14,14 @@ import {
|
||||
} from '@kinvolk/headlamp-plugin/lib';
|
||||
import React from 'react';
|
||||
import { RookCephDataProvider } from './api/RookCephDataContext';
|
||||
import AppBarClusterBadge from './components/AppBarClusterBadge';
|
||||
import BlockPoolsPage from './components/BlockPoolsPage';
|
||||
import CephPodDetailSection from './components/CephPodDetailSection';
|
||||
import FilesystemsPage from './components/FilesystemsPage';
|
||||
import { buildPVColumns, buildStorageClassColumns } from './components/integrations/StorageClassColumns';
|
||||
import {
|
||||
buildPVColumns,
|
||||
buildStorageClassColumns,
|
||||
} from './components/integrations/StorageClassColumns';
|
||||
import ObjectStoresPage from './components/ObjectStoresPage';
|
||||
import OverviewPage from './components/OverviewPage';
|
||||
import PodsPage from './components/PodsPage';
|
||||
@@ -32,7 +37,7 @@ import VolumesPage from './components/VolumesPage';
|
||||
registerSidebarEntry({
|
||||
parent: null,
|
||||
name: 'rook-ceph',
|
||||
label: 'Rook-Ceph',
|
||||
label: 'Rook',
|
||||
url: '/rook-ceph',
|
||||
icon: 'mdi:database-cog',
|
||||
});
|
||||
@@ -69,6 +74,22 @@ registerSidebarEntry({
|
||||
icon: 'mdi:bucket',
|
||||
});
|
||||
|
||||
registerSidebarEntry({
|
||||
parent: 'rook-ceph',
|
||||
name: 'rook-ceph-storage-classes',
|
||||
label: 'Storage Classes',
|
||||
url: '/rook-ceph/storage-classes',
|
||||
icon: 'mdi:database-settings',
|
||||
});
|
||||
|
||||
registerSidebarEntry({
|
||||
parent: 'rook-ceph',
|
||||
name: 'rook-ceph-volumes',
|
||||
label: 'Volumes',
|
||||
url: '/rook-ceph/volumes',
|
||||
icon: 'mdi:harddisk',
|
||||
});
|
||||
|
||||
registerSidebarEntry({
|
||||
parent: 'rook-ceph',
|
||||
name: 'rook-ceph-pods',
|
||||
@@ -77,6 +98,16 @@ registerSidebarEntry({
|
||||
icon: 'mdi:cube-outline',
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// App bar action — cluster health badge
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
registerAppBarAction(() => (
|
||||
<RookCephDataProvider>
|
||||
<AppBarClusterBadge />
|
||||
</RookCephDataProvider>
|
||||
));
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Routes
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -129,10 +160,9 @@ registerRoute({
|
||||
),
|
||||
});
|
||||
|
||||
// Storage Classes and Volumes pages accessible via direct URL
|
||||
registerRoute({
|
||||
path: '/rook-ceph/storage-classes',
|
||||
sidebar: 'rook-ceph-overview',
|
||||
sidebar: 'rook-ceph-storage-classes',
|
||||
name: 'rook-ceph-storage-classes',
|
||||
exact: true,
|
||||
component: () => (
|
||||
@@ -144,7 +174,7 @@ registerRoute({
|
||||
|
||||
registerRoute({
|
||||
path: '/rook-ceph/volumes',
|
||||
sidebar: 'rook-ceph-overview',
|
||||
sidebar: 'rook-ceph-volumes',
|
||||
name: 'rook-ceph-volumes',
|
||||
exact: true,
|
||||
component: () => (
|
||||
@@ -202,13 +232,47 @@ registerDetailsViewSection(({ resource }) => {
|
||||
// Table column processors — native StorageClass and PV tables
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Merges incoming columns into existing ones by label.
|
||||
// If a column with the same label already exists, the incoming getValue/render
|
||||
// takes priority and falls back to the existing one (for mixed-driver tables).
|
||||
function mergeColumns<T>(
|
||||
existing: T[],
|
||||
incoming: Array<{
|
||||
label: string;
|
||||
getValue: (r: unknown) => unknown;
|
||||
render: (r: unknown) => React.ReactNode;
|
||||
}>
|
||||
): T[] {
|
||||
type ObjCol = {
|
||||
label: string;
|
||||
getValue: (r: unknown) => unknown;
|
||||
render: (r: unknown) => React.ReactNode;
|
||||
};
|
||||
const isObjCol = (c: unknown): c is ObjCol => typeof c === 'object' && c !== null && 'label' in c;
|
||||
const result = [...existing];
|
||||
const toAppend: typeof incoming = [];
|
||||
for (const col of incoming) {
|
||||
const idx = result.findIndex(c => isObjCol(c) && (c as ObjCol).label === col.label);
|
||||
if (idx !== -1) {
|
||||
const prev = result[idx] as ObjCol;
|
||||
result[idx] = {
|
||||
label: col.label,
|
||||
getValue: (r: unknown) => col.getValue(r) ?? prev.getValue(r),
|
||||
render: (r: unknown) => (col.getValue(r) !== null ? col.render(r) : prev.render(r)),
|
||||
} as unknown as T;
|
||||
} else {
|
||||
toAppend.push(col);
|
||||
}
|
||||
}
|
||||
return [...result, ...(toAppend as unknown as T[])];
|
||||
}
|
||||
|
||||
registerResourceTableColumnsProcessor(({ id, columns }) => {
|
||||
if (id === 'headlamp-storageclasses') {
|
||||
return [...columns, ...buildStorageClassColumns()];
|
||||
return mergeColumns(columns, buildStorageClassColumns());
|
||||
}
|
||||
if (id === 'headlamp-persistentvolumes') {
|
||||
return [...columns, ...buildPVColumns()];
|
||||
return mergeColumns(columns, buildPVColumns());
|
||||
}
|
||||
return columns;
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user