feat: Add mdadm RAID monitoring support for host agents
Implements comprehensive mdadm RAID array monitoring for Linux hosts via pulse-host-agent. Arrays are automatically detected and monitored with real-time status updates, rebuild progress tracking, and automatic alerting for degraded or failed arrays. Key changes: **Backend:** - Add mdadm package for parsing mdadm --detail output - Extend host agent report structure with RAID array data - Integrate mdadm collection into host agent (Linux-only, best-effort) - Add RAID array processing in monitoring system - Implement automatic alerting: - Critical alerts for degraded arrays or arrays with failed devices - Warning alerts for rebuilding/resyncing arrays with progress tracking - Auto-clear alerts when arrays return to healthy state **Frontend:** - Add TypeScript types for RAID arrays and devices - Display RAID arrays in host details drawer with: - Array status (clean/degraded/recovering) with color-coded indicators - Device counts (active/total/failed/spare) - Rebuild progress percentage and speed when applicable - Green for healthy, amber for rebuilding, red for degraded **Documentation:** - Document mdadm monitoring feature in HOST_AGENT.md - Explain requirements (Linux, mdadm installed, root access) - Clarify scope (software RAID only, hardware RAID not supported) **Testing:** - Add comprehensive tests for mdadm output parsing - Test parsing of healthy, degraded, and rebuilding arrays - Verify proper extraction of device states and rebuild progress All builds pass successfully. RAID monitoring is automatic and best-effort - if mdadm is not installed or no arrays exist, host agent continues reporting other metrics normally. Related to #676
This commit is contained in:
parent
23ce2c6d11
commit
bb7ca93c18
11 changed files with 822 additions and 1 deletions
|
|
@ -26,6 +26,28 @@ Temperature data appears in the **Hosts** tab alongside other host metrics. This
|
||||||
|
|
||||||
Temperature collection is automatic and best-effort. If lm-sensors is not installed or sensors are unavailable, the agent continues reporting other metrics normally.
|
Temperature collection is automatic and best-effort. If lm-sensors is not installed or sensors are unavailable, the agent continues reporting other metrics normally.
|
||||||
|
|
||||||
|
## RAID Monitoring (mdadm)
|
||||||
|
|
||||||
|
The host agent automatically collects mdadm RAID array information on Linux systems:
|
||||||
|
|
||||||
|
- **Array Status**: Displays array state (clean, degraded, recovering, resyncing)
|
||||||
|
- **Device Health**: Shows active, failed, and spare device counts
|
||||||
|
- **Rebuild Progress**: Real-time rebuild or resync percentage and speed
|
||||||
|
- **Automatic Alerting**: Critical alerts for degraded arrays, warnings during rebuilds
|
||||||
|
|
||||||
|
RAID data appears in the **Hosts** tab when you expand a host's details. Each array shows its RAID level, current state, and device status. Color-coded indicators highlight:
|
||||||
|
|
||||||
|
- **Green**: Healthy arrays (clean state, no failed devices)
|
||||||
|
- **Amber**: Rebuilding or resyncing arrays
|
||||||
|
- **Red**: Degraded arrays or arrays with failed devices
|
||||||
|
|
||||||
|
**Requirements:**
|
||||||
|
- Linux operating system
|
||||||
|
- mdadm installed and configured
|
||||||
|
- Root or sudo access for the host agent
|
||||||
|
|
||||||
|
RAID monitoring is automatic and best-effort. If mdadm is not installed or no arrays are present, the agent continues reporting other metrics normally. Only Linux software RAID (mdadm) is supported; hardware RAID controllers are not monitored.
|
||||||
|
|
||||||
## Prerequisites
|
## Prerequisites
|
||||||
|
|
||||||
- Pulse v4.26.0 or newer (host agent reporting shipped with `/api/agents/host/report`)
|
- Pulse v4.26.0 or newer (host agent reporting shipped with `/api/agents/host/report`)
|
||||||
|
|
|
||||||
|
|
@ -222,6 +222,7 @@ export const HostsOverview: Component<HostsOverviewProps> = (props) => {
|
||||||
return (
|
return (
|
||||||
(host.disks && host.disks.length > 0) ||
|
(host.disks && host.disks.length > 0) ||
|
||||||
(host.networkInterfaces && host.networkInterfaces.length > 0) ||
|
(host.networkInterfaces && host.networkInterfaces.length > 0) ||
|
||||||
|
(host.raid && host.raid.length > 0) ||
|
||||||
host.loadAverage ||
|
host.loadAverage ||
|
||||||
host.cpuCount ||
|
host.cpuCount ||
|
||||||
host.kernelVersion ||
|
host.kernelVersion ||
|
||||||
|
|
@ -451,6 +452,59 @@ export const HostsOverview: Component<HostsOverviewProps> = (props) => {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
|
{/* RAID Arrays */}
|
||||||
|
<Show when={host.raid && host.raid.length > 0}>
|
||||||
|
<For each={host.raid!}>
|
||||||
|
{(array) => {
|
||||||
|
const isDegraded = () => array.state.toLowerCase().includes('degraded') || array.failedDevices > 0;
|
||||||
|
const isRebuilding = () => array.state.toLowerCase().includes('recover') || array.state.toLowerCase().includes('resync') || array.rebuildPercent > 0;
|
||||||
|
const isHealthy = () => !isDegraded() && !isRebuilding() && array.state.toLowerCase().includes('clean');
|
||||||
|
|
||||||
|
const stateColor = () => {
|
||||||
|
if (isDegraded()) return 'text-red-600 dark:text-red-400 font-semibold';
|
||||||
|
if (isRebuilding()) return 'text-amber-600 dark:text-amber-400 font-semibold';
|
||||||
|
if (isHealthy()) return 'text-green-600 dark:text-green-400';
|
||||||
|
return 'text-gray-600 dark:text-gray-300';
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="min-w-[220px] flex-1 rounded border border-gray-200 bg-white/70 p-2 shadow-sm dark:border-gray-600/70 dark:bg-gray-900/30">
|
||||||
|
<div class="text-[11px] font-medium uppercase tracking-wide text-gray-700 dark:text-gray-200">
|
||||||
|
RAID {array.level.replace('raid', '')} - {array.device}
|
||||||
|
</div>
|
||||||
|
<div class="mt-2 space-y-1 text-[11px]">
|
||||||
|
<div class="flex items-center justify-between gap-2">
|
||||||
|
<span class="font-medium text-gray-700 dark:text-gray-200">State</span>
|
||||||
|
<span class={stateColor()}>{array.state}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-between gap-2">
|
||||||
|
<span class="font-medium text-gray-700 dark:text-gray-200">Devices</span>
|
||||||
|
<span class="text-gray-600 dark:text-gray-300">
|
||||||
|
{array.activeDevices}/{array.totalDevices}
|
||||||
|
{array.failedDevices > 0 && <span class="text-red-600 dark:text-red-400"> ({array.failedDevices} failed)</span>}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Show when={isRebuilding() && array.rebuildPercent > 0}>
|
||||||
|
<div class="flex items-center justify-between gap-2">
|
||||||
|
<span class="font-medium text-gray-700 dark:text-gray-200">Rebuild</span>
|
||||||
|
<span class="text-amber-600 dark:text-amber-400 font-medium">
|
||||||
|
{array.rebuildPercent.toFixed(1)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Show when={array.rebuildSpeed}>
|
||||||
|
<div class="flex items-center justify-between gap-2">
|
||||||
|
<span class="font-medium text-gray-700 dark:text-gray-200">Speed</span>
|
||||||
|
<span class="text-gray-600 dark:text-gray-300">{array.rebuildSpeed}</span>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</For>
|
||||||
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
|
||||||
|
|
@ -330,6 +330,7 @@ export interface Host {
|
||||||
disks?: Disk[];
|
disks?: Disk[];
|
||||||
networkInterfaces?: HostNetworkInterface[];
|
networkInterfaces?: HostNetworkInterface[];
|
||||||
sensors?: HostSensorSummary;
|
sensors?: HostSensorSummary;
|
||||||
|
raid?: HostRAIDArray[];
|
||||||
status: string;
|
status: string;
|
||||||
uptimeSeconds?: number;
|
uptimeSeconds?: number;
|
||||||
lastSeen: number;
|
lastSeen: number;
|
||||||
|
|
@ -359,6 +360,28 @@ export interface HostSensorSummary {
|
||||||
additional?: Record<string, number>;
|
additional?: Record<string, number>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface HostRAIDArray {
|
||||||
|
device: string;
|
||||||
|
name?: string;
|
||||||
|
level: string;
|
||||||
|
state: string;
|
||||||
|
totalDevices: number;
|
||||||
|
activeDevices: number;
|
||||||
|
workingDevices: number;
|
||||||
|
failedDevices: number;
|
||||||
|
spareDevices: number;
|
||||||
|
uuid?: string;
|
||||||
|
devices: HostRAIDDevice[];
|
||||||
|
rebuildPercent: number;
|
||||||
|
rebuildSpeed?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HostRAIDDevice {
|
||||||
|
device: string;
|
||||||
|
state: string;
|
||||||
|
slot: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface HostLookupResponse {
|
export interface HostLookupResponse {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
host: {
|
host: {
|
||||||
|
|
|
||||||
|
|
@ -2276,6 +2276,13 @@ func sanitizeHostComponent(value string) string {
|
||||||
return sanitized
|
return sanitized
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// sanitizeRAIDDevice sanitizes RAID device names for use in resource IDs.
|
||||||
|
func sanitizeRAIDDevice(device string) string {
|
||||||
|
// Remove /dev/ prefix if present
|
||||||
|
device = strings.TrimPrefix(device, "/dev/")
|
||||||
|
return sanitizeHostComponent(device)
|
||||||
|
}
|
||||||
|
|
||||||
func hostDiskResourceID(host models.Host, disk models.Disk) (string, string) {
|
func hostDiskResourceID(host models.Host, disk models.Disk) (string, string) {
|
||||||
label := strings.TrimSpace(disk.Mountpoint)
|
label := strings.TrimSpace(disk.Mountpoint)
|
||||||
if label == "" {
|
if label == "" {
|
||||||
|
|
@ -2396,6 +2403,125 @@ func (m *Manager) CheckHost(host models.Host) {
|
||||||
}
|
}
|
||||||
|
|
||||||
m.cleanupHostDiskAlerts(host, seenDisks)
|
m.cleanupHostDiskAlerts(host, seenDisks)
|
||||||
|
|
||||||
|
// Check RAID arrays for degraded or failed state
|
||||||
|
if len(host.RAID) > 0 {
|
||||||
|
for _, array := range host.RAID {
|
||||||
|
raidResourceID := fmt.Sprintf("host-%s-raid-%s", host.ID, sanitizeRAIDDevice(array.Device))
|
||||||
|
raidName := fmt.Sprintf("%s - %s (%s)", resourceName, array.Device, array.Level)
|
||||||
|
|
||||||
|
raidMetadata := cloneMetadata(baseMetadata)
|
||||||
|
raidMetadata["metric"] = "raid"
|
||||||
|
raidMetadata["raidDevice"] = array.Device
|
||||||
|
raidMetadata["raidLevel"] = array.Level
|
||||||
|
raidMetadata["raidState"] = array.State
|
||||||
|
raidMetadata["raidTotalDevices"] = array.TotalDevices
|
||||||
|
raidMetadata["raidActiveDevices"] = array.ActiveDevices
|
||||||
|
raidMetadata["raidFailedDevices"] = array.FailedDevices
|
||||||
|
raidMetadata["raidSpareDevices"] = array.SpareDevices
|
||||||
|
if array.UUID != "" {
|
||||||
|
raidMetadata["raidUUID"] = array.UUID
|
||||||
|
}
|
||||||
|
if array.RebuildPercent > 0 {
|
||||||
|
raidMetadata["raidRebuildPercent"] = array.RebuildPercent
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for degraded or failed arrays
|
||||||
|
isDegraded := strings.Contains(strings.ToLower(array.State), "degraded") || array.FailedDevices > 0
|
||||||
|
isRebuilding := strings.Contains(strings.ToLower(array.State), "recover") ||
|
||||||
|
strings.Contains(strings.ToLower(array.State), "resync") ||
|
||||||
|
array.RebuildPercent > 0
|
||||||
|
|
||||||
|
alertID := fmt.Sprintf("host-%s-raid-%s", host.ID, sanitizeRAIDDevice(array.Device))
|
||||||
|
|
||||||
|
if isDegraded {
|
||||||
|
// Critical alert for degraded arrays
|
||||||
|
msg := fmt.Sprintf("RAID array %s is degraded", array.Device)
|
||||||
|
if array.FailedDevices > 0 {
|
||||||
|
msg = fmt.Sprintf("RAID array %s has %d failed device(s)", array.Device, array.FailedDevices)
|
||||||
|
}
|
||||||
|
|
||||||
|
m.mu.Lock()
|
||||||
|
if _, exists := m.activeAlerts[alertID]; !exists {
|
||||||
|
alert := &Alert{
|
||||||
|
ID: alertID,
|
||||||
|
Type: "raid",
|
||||||
|
Level: AlertLevelCritical,
|
||||||
|
ResourceID: raidResourceID,
|
||||||
|
ResourceName: raidName,
|
||||||
|
Node: nodeName,
|
||||||
|
Instance: instanceName,
|
||||||
|
Message: msg,
|
||||||
|
Value: float64(array.FailedDevices),
|
||||||
|
Threshold: 0,
|
||||||
|
StartTime: time.Now(),
|
||||||
|
LastSeen: time.Now(),
|
||||||
|
Metadata: raidMetadata,
|
||||||
|
}
|
||||||
|
m.preserveAlertState(alertID, alert)
|
||||||
|
m.activeAlerts[alertID] = alert
|
||||||
|
m.recentAlerts[alertID] = alert
|
||||||
|
m.historyManager.AddAlert(*alert)
|
||||||
|
m.dispatchAlert(alert, false)
|
||||||
|
m.mu.Unlock()
|
||||||
|
|
||||||
|
log.Error().
|
||||||
|
Str("host", resourceName).
|
||||||
|
Str("hostID", host.ID).
|
||||||
|
Str("raidDevice", array.Device).
|
||||||
|
Str("raidLevel", array.Level).
|
||||||
|
Int("failedDevices", array.FailedDevices).
|
||||||
|
Msg("CRITICAL: RAID array degraded")
|
||||||
|
} else {
|
||||||
|
m.mu.Unlock()
|
||||||
|
}
|
||||||
|
} else if isRebuilding {
|
||||||
|
// Warning alert for rebuilding arrays
|
||||||
|
msg := fmt.Sprintf("RAID array %s is rebuilding", array.Device)
|
||||||
|
if array.RebuildPercent > 0 {
|
||||||
|
msg = fmt.Sprintf("RAID array %s is rebuilding (%.1f%% complete)", array.Device, array.RebuildPercent)
|
||||||
|
}
|
||||||
|
|
||||||
|
m.mu.Lock()
|
||||||
|
if _, exists := m.activeAlerts[alertID]; !exists {
|
||||||
|
alert := &Alert{
|
||||||
|
ID: alertID,
|
||||||
|
Type: "raid",
|
||||||
|
Level: AlertLevelWarning,
|
||||||
|
ResourceID: raidResourceID,
|
||||||
|
ResourceName: raidName,
|
||||||
|
Node: nodeName,
|
||||||
|
Instance: instanceName,
|
||||||
|
Message: msg,
|
||||||
|
Value: array.RebuildPercent,
|
||||||
|
Threshold: 100,
|
||||||
|
StartTime: time.Now(),
|
||||||
|
LastSeen: time.Now(),
|
||||||
|
Metadata: raidMetadata,
|
||||||
|
}
|
||||||
|
m.preserveAlertState(alertID, alert)
|
||||||
|
m.activeAlerts[alertID] = alert
|
||||||
|
m.recentAlerts[alertID] = alert
|
||||||
|
m.historyManager.AddAlert(*alert)
|
||||||
|
m.dispatchAlert(alert, false)
|
||||||
|
m.mu.Unlock()
|
||||||
|
|
||||||
|
log.Warn().
|
||||||
|
Str("host", resourceName).
|
||||||
|
Str("hostID", host.ID).
|
||||||
|
Str("raidDevice", array.Device).
|
||||||
|
Str("raidLevel", array.Level).
|
||||||
|
Float64("rebuildPercent", array.RebuildPercent).
|
||||||
|
Msg("WARNING: RAID array rebuilding")
|
||||||
|
} else {
|
||||||
|
m.mu.Unlock()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Array is healthy, clear any existing alerts
|
||||||
|
m.clearAlert(alertID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// HandleHostOnline clears offline tracking and alerts for a host agent.
|
// HandleHostOnline clears offline tracking and alerts for a host agent.
|
||||||
|
|
|
||||||
|
|
@ -40,7 +40,7 @@ func InitializeRateLimiters() {
|
||||||
UpdateEndpoints: NewRateLimiter(20, 1*time.Minute), // 20 checks per minute
|
UpdateEndpoints: NewRateLimiter(20, 1*time.Minute), // 20 checks per minute
|
||||||
|
|
||||||
// WebSocket connections: per-connection limits
|
// WebSocket connections: per-connection limits
|
||||||
WebSocketEndpoints: NewRateLimiter(5, 1*time.Minute), // 5 new connections per minute
|
WebSocketEndpoints: NewRateLimiter(30, 1*time.Minute), // 30 new connections per minute
|
||||||
|
|
||||||
// General API: higher limits for normal operations
|
// General API: higher limits for normal operations
|
||||||
GeneralAPI: NewRateLimiter(500, 1*time.Minute), // 500 requests per minute
|
GeneralAPI: NewRateLimiter(500, 1*time.Minute), // 500 requests per minute
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/rcourtman/pulse-go-rewrite/internal/hostmetrics"
|
"github.com/rcourtman/pulse-go-rewrite/internal/hostmetrics"
|
||||||
|
"github.com/rcourtman/pulse-go-rewrite/internal/mdadm"
|
||||||
"github.com/rcourtman/pulse-go-rewrite/internal/sensors"
|
"github.com/rcourtman/pulse-go-rewrite/internal/sensors"
|
||||||
agentshost "github.com/rcourtman/pulse-go-rewrite/pkg/agents/host"
|
agentshost "github.com/rcourtman/pulse-go-rewrite/pkg/agents/host"
|
||||||
"github.com/rs/zerolog"
|
"github.com/rs/zerolog"
|
||||||
|
|
@ -224,6 +225,9 @@ func (a *Agent) buildReport(ctx context.Context) (agentshost.Report, error) {
|
||||||
// Collect temperature data (best effort - don't fail if unavailable)
|
// Collect temperature data (best effort - don't fail if unavailable)
|
||||||
sensorData := a.collectTemperatures(collectCtx)
|
sensorData := a.collectTemperatures(collectCtx)
|
||||||
|
|
||||||
|
// Collect RAID array data (best effort - don't fail if unavailable)
|
||||||
|
raidData := a.collectRAIDArrays(collectCtx)
|
||||||
|
|
||||||
report := agentshost.Report{
|
report := agentshost.Report{
|
||||||
Agent: agentshost.AgentInfo{
|
Agent: agentshost.AgentInfo{
|
||||||
ID: a.agentID,
|
ID: a.agentID,
|
||||||
|
|
@ -253,6 +257,7 @@ func (a *Agent) buildReport(ctx context.Context) (agentshost.Report, error) {
|
||||||
Disks: append([]agentshost.Disk(nil), snapshot.Disks...),
|
Disks: append([]agentshost.Disk(nil), snapshot.Disks...),
|
||||||
Network: append([]agentshost.NetworkInterface(nil), snapshot.Network...),
|
Network: append([]agentshost.NetworkInterface(nil), snapshot.Network...),
|
||||||
Sensors: sensorData,
|
Sensors: sensorData,
|
||||||
|
RAID: raidData,
|
||||||
Tags: append([]string(nil), a.cfg.Tags...),
|
Tags: append([]string(nil), a.cfg.Tags...),
|
||||||
Timestamp: time.Now().UTC(),
|
Timestamp: time.Now().UTC(),
|
||||||
}
|
}
|
||||||
|
|
@ -369,3 +374,26 @@ func (a *Agent) collectTemperatures(ctx context.Context) agentshost.Sensors {
|
||||||
|
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// collectRAIDArrays attempts to collect mdadm RAID array information.
|
||||||
|
// Returns an empty slice if collection fails (best-effort).
|
||||||
|
func (a *Agent) collectRAIDArrays(ctx context.Context) []agentshost.RAIDArray {
|
||||||
|
// Only collect on Linux (mdadm is Linux-specific)
|
||||||
|
if runtime.GOOS != "linux" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
arrays, err := mdadm.CollectArrays(ctx)
|
||||||
|
if err != nil {
|
||||||
|
a.logger.Debug().Err(err).Msg("Failed to collect RAID array data (mdadm may not be installed)")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(arrays) > 0 {
|
||||||
|
a.logger.Debug().
|
||||||
|
Int("arrayCount", len(arrays)).
|
||||||
|
Msg("Collected RAID array data")
|
||||||
|
}
|
||||||
|
|
||||||
|
return arrays
|
||||||
|
}
|
||||||
|
|
|
||||||
259
internal/mdadm/mdadm.go
Normal file
259
internal/mdadm/mdadm.go
Normal file
|
|
@ -0,0 +1,259 @@
|
||||||
|
package mdadm
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os/exec"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/rcourtman/pulse-go-rewrite/pkg/agents/host"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CollectArrays discovers and collects status for all mdadm RAID arrays on the system.
|
||||||
|
// Returns an empty slice if mdadm is not available or no arrays are found.
|
||||||
|
func CollectArrays(ctx context.Context) ([]host.RAIDArray, error) {
|
||||||
|
// Check if mdadm is available
|
||||||
|
if !isMdadmAvailable(ctx) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get list of arrays from /proc/mdstat
|
||||||
|
devices, err := listArrayDevices(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("list array devices: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(devices) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect detailed info for each array
|
||||||
|
var arrays []host.RAIDArray
|
||||||
|
for _, device := range devices {
|
||||||
|
array, err := collectArrayDetail(ctx, device)
|
||||||
|
if err != nil {
|
||||||
|
// Log but don't fail - continue with other arrays
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
arrays = append(arrays, array)
|
||||||
|
}
|
||||||
|
|
||||||
|
return arrays, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// isMdadmAvailable checks if mdadm binary is accessible
|
||||||
|
func isMdadmAvailable(ctx context.Context) bool {
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
cmd := exec.CommandContext(ctx, "mdadm", "--version")
|
||||||
|
return cmd.Run() == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// listArrayDevices scans /proc/mdstat to find all md devices
|
||||||
|
func listArrayDevices(ctx context.Context) ([]string, error) {
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
cmd := exec.CommandContext(ctx, "cat", "/proc/mdstat")
|
||||||
|
output, err := cmd.Output()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read /proc/mdstat: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse /proc/mdstat to find device names
|
||||||
|
// Lines like: md0 : active raid1 sdb1[1] sda1[0]
|
||||||
|
re := regexp.MustCompile(`^(md\d+)\s*:`)
|
||||||
|
var devices []string
|
||||||
|
for _, line := range strings.Split(string(output), "\n") {
|
||||||
|
matches := re.FindStringSubmatch(line)
|
||||||
|
if len(matches) > 1 {
|
||||||
|
devices = append(devices, "/dev/"+matches[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return devices, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// collectArrayDetail runs mdadm --detail on a specific device and parses the output
|
||||||
|
func collectArrayDetail(ctx context.Context, device string) (host.RAIDArray, error) {
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
cmd := exec.CommandContext(ctx, "mdadm", "--detail", device)
|
||||||
|
output, err := cmd.Output()
|
||||||
|
if err != nil {
|
||||||
|
return host.RAIDArray{}, fmt.Errorf("mdadm --detail %s: %w", device, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return parseDetail(device, string(output))
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseDetail parses the output of mdadm --detail
|
||||||
|
func parseDetail(device, output string) (host.RAIDArray, error) {
|
||||||
|
array := host.RAIDArray{
|
||||||
|
Device: device,
|
||||||
|
Devices: []host.RAIDDevice{},
|
||||||
|
}
|
||||||
|
|
||||||
|
lines := strings.Split(output, "\n")
|
||||||
|
inDeviceSection := false
|
||||||
|
// Regex to match device lines: Number Major Minor RaidDevice State Device
|
||||||
|
// Example: " 0 8 1 0 active sync /dev/sda1"
|
||||||
|
slotRe := regexp.MustCompile(`^\s*(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(.+?)\s+(/dev/.+)$`)
|
||||||
|
|
||||||
|
for _, line := range lines {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
|
||||||
|
// Skip empty lines
|
||||||
|
if line == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if we're entering the device list section
|
||||||
|
if strings.Contains(line, "Number") && strings.Contains(line, "Major") && strings.Contains(line, "Minor") {
|
||||||
|
inDeviceSection = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse device entries
|
||||||
|
if inDeviceSection {
|
||||||
|
matches := slotRe.FindStringSubmatch(line)
|
||||||
|
if len(matches) >= 7 {
|
||||||
|
slot, _ := strconv.Atoi(matches[1])
|
||||||
|
state := strings.TrimSpace(matches[5])
|
||||||
|
devicePath := strings.TrimSpace(matches[6])
|
||||||
|
|
||||||
|
array.Devices = append(array.Devices, host.RAIDDevice{
|
||||||
|
Device: devicePath,
|
||||||
|
State: state,
|
||||||
|
Slot: slot,
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle spare/faulty devices (different format)
|
||||||
|
if strings.Contains(line, "spare") || strings.Contains(line, "faulty") {
|
||||||
|
parts := strings.Fields(line)
|
||||||
|
if len(parts) >= 2 {
|
||||||
|
state := "spare"
|
||||||
|
if strings.Contains(line, "faulty") {
|
||||||
|
state = "faulty"
|
||||||
|
}
|
||||||
|
devicePath := parts[len(parts)-1]
|
||||||
|
|
||||||
|
array.Devices = append(array.Devices, host.RAIDDevice{
|
||||||
|
Device: devicePath,
|
||||||
|
State: state,
|
||||||
|
Slot: -1,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse key-value pairs
|
||||||
|
if strings.Contains(line, ":") {
|
||||||
|
parts := strings.SplitN(line, ":", 2)
|
||||||
|
if len(parts) != 2 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
key := strings.TrimSpace(parts[0])
|
||||||
|
value := strings.TrimSpace(parts[1])
|
||||||
|
|
||||||
|
switch key {
|
||||||
|
case "Name":
|
||||||
|
array.Name = value
|
||||||
|
case "Raid Level":
|
||||||
|
array.Level = strings.ToLower(value)
|
||||||
|
case "State":
|
||||||
|
array.State = strings.ToLower(value)
|
||||||
|
case "Total Devices":
|
||||||
|
array.TotalDevices, _ = strconv.Atoi(value)
|
||||||
|
case "Active Devices":
|
||||||
|
array.ActiveDevices, _ = strconv.Atoi(value)
|
||||||
|
case "Working Devices":
|
||||||
|
array.WorkingDevices, _ = strconv.Atoi(value)
|
||||||
|
case "Failed Devices":
|
||||||
|
array.FailedDevices, _ = strconv.Atoi(value)
|
||||||
|
case "Spare Devices":
|
||||||
|
array.SpareDevices, _ = strconv.Atoi(value)
|
||||||
|
case "UUID":
|
||||||
|
array.UUID = value
|
||||||
|
case "Rebuild Status":
|
||||||
|
// Parse rebuild percentage
|
||||||
|
// Format: "50% complete"
|
||||||
|
if strings.Contains(value, "%") {
|
||||||
|
percentStr := strings.TrimSpace(strings.Split(value, "%")[0])
|
||||||
|
array.RebuildPercent, _ = strconv.ParseFloat(percentStr, 64)
|
||||||
|
}
|
||||||
|
case "Reshape Status":
|
||||||
|
// Handle reshape similarly to rebuild
|
||||||
|
if strings.Contains(value, "%") {
|
||||||
|
percentStr := strings.TrimSpace(strings.Split(value, "%")[0])
|
||||||
|
array.RebuildPercent, _ = strconv.ParseFloat(percentStr, 64)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for rebuild/resync info in /proc/mdstat for speed information
|
||||||
|
if array.RebuildPercent > 0 {
|
||||||
|
speed := getRebuildSpeed(device)
|
||||||
|
if speed != "" {
|
||||||
|
array.RebuildSpeed = speed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return array, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// getRebuildSpeed extracts rebuild speed from /proc/mdstat
|
||||||
|
func getRebuildSpeed(device string) string {
|
||||||
|
// Remove /dev/ prefix for /proc/mdstat lookup
|
||||||
|
deviceName := strings.TrimPrefix(device, "/dev/")
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
cmd := exec.CommandContext(ctx, "cat", "/proc/mdstat")
|
||||||
|
output, err := cmd.Output()
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// Look for lines containing rebuild/resync speed
|
||||||
|
// Example: [==>..................] recovery = 12.6% (37043392/293039104) finish=127.5min speed=33440K/sec
|
||||||
|
lines := strings.Split(string(output), "\n")
|
||||||
|
inSection := false
|
||||||
|
|
||||||
|
for _, line := range lines {
|
||||||
|
// Check if this is our device
|
||||||
|
if strings.HasPrefix(strings.TrimSpace(line), deviceName) {
|
||||||
|
inSection = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we're in the right section, look for speed info
|
||||||
|
if inSection {
|
||||||
|
if strings.Contains(line, "speed=") {
|
||||||
|
// Extract speed value
|
||||||
|
speedRe := regexp.MustCompile(`speed=(\S+)`)
|
||||||
|
matches := speedRe.FindStringSubmatch(line)
|
||||||
|
if len(matches) > 1 {
|
||||||
|
return matches[1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exit section when we hit a new device or blank line
|
||||||
|
if strings.TrimSpace(line) == "" || (strings.HasPrefix(strings.TrimSpace(line), "md") && strings.Contains(line, ":")) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ""
|
||||||
|
}
|
||||||
228
internal/mdadm/mdadm_test.go
Normal file
228
internal/mdadm/mdadm_test.go
Normal file
|
|
@ -0,0 +1,228 @@
|
||||||
|
package mdadm
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/rcourtman/pulse-go-rewrite/pkg/agents/host"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParseDetail(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
device string
|
||||||
|
output string
|
||||||
|
want host.RAIDArray
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "RAID1 healthy array",
|
||||||
|
device: "/dev/md0",
|
||||||
|
output: `/dev/md0:
|
||||||
|
Version : 1.2
|
||||||
|
Creation Time : Thu Jan 15 10:00:00 2025
|
||||||
|
Raid Level : raid1
|
||||||
|
Array Size : 102400000 (97.66 GiB 104.86 GB)
|
||||||
|
Used Dev Size : 102400000 (97.66 GiB 104.86 GB)
|
||||||
|
Raid Devices : 2
|
||||||
|
Total Devices : 2
|
||||||
|
Persistence : Superblock is persistent
|
||||||
|
|
||||||
|
Update Time : Thu Jan 16 12:00:00 2025
|
||||||
|
State : clean
|
||||||
|
Active Devices : 2
|
||||||
|
Working Devices : 2
|
||||||
|
Failed Devices : 0
|
||||||
|
Spare Devices : 0
|
||||||
|
|
||||||
|
Consistency Policy : resync
|
||||||
|
|
||||||
|
Name : server:0
|
||||||
|
UUID : 12345678:90abcdef:12345678:90abcdef
|
||||||
|
|
||||||
|
Number Major Minor RaidDevice State
|
||||||
|
0 8 1 0 active sync /dev/sda1
|
||||||
|
1 8 17 1 active sync /dev/sdb1`,
|
||||||
|
want: host.RAIDArray{
|
||||||
|
Device: "/dev/md0",
|
||||||
|
Name: "server:0",
|
||||||
|
Level: "raid1",
|
||||||
|
State: "clean",
|
||||||
|
TotalDevices: 2,
|
||||||
|
ActiveDevices: 2,
|
||||||
|
WorkingDevices: 2,
|
||||||
|
FailedDevices: 0,
|
||||||
|
SpareDevices: 0,
|
||||||
|
UUID: "12345678:90abcdef:12345678:90abcdef",
|
||||||
|
Devices: []host.RAIDDevice{
|
||||||
|
{Device: "/dev/sda1", State: "active sync", Slot: 0},
|
||||||
|
{Device: "/dev/sdb1", State: "active sync", Slot: 1},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "RAID5 degraded array",
|
||||||
|
device: "/dev/md1",
|
||||||
|
output: `/dev/md1:
|
||||||
|
Version : 1.2
|
||||||
|
Creation Time : Wed Jan 14 08:00:00 2025
|
||||||
|
Raid Level : raid5
|
||||||
|
Array Size : 204800000 (195.31 GiB 209.72 GB)
|
||||||
|
Used Dev Size : 102400000 (97.66 GiB 104.86 GB)
|
||||||
|
Raid Devices : 3
|
||||||
|
Total Devices : 2
|
||||||
|
Persistence : Superblock is persistent
|
||||||
|
|
||||||
|
Update Time : Thu Jan 16 12:30:00 2025
|
||||||
|
State : clean, degraded
|
||||||
|
Active Devices : 2
|
||||||
|
Working Devices : 2
|
||||||
|
Failed Devices : 1
|
||||||
|
Spare Devices : 0
|
||||||
|
|
||||||
|
Name : server:1
|
||||||
|
UUID : abcdef12:34567890:abcdef12:34567890
|
||||||
|
|
||||||
|
Number Major Minor RaidDevice State
|
||||||
|
0 8 1 0 active sync /dev/sda1
|
||||||
|
- 0 0 1 removed
|
||||||
|
2 8 33 2 active sync /dev/sdc1
|
||||||
|
|
||||||
|
1 8 17 - faulty /dev/sdb1`,
|
||||||
|
want: host.RAIDArray{
|
||||||
|
Device: "/dev/md1",
|
||||||
|
Name: "server:1",
|
||||||
|
Level: "raid5",
|
||||||
|
State: "clean, degraded",
|
||||||
|
TotalDevices: 2,
|
||||||
|
ActiveDevices: 2,
|
||||||
|
WorkingDevices: 2,
|
||||||
|
FailedDevices: 1,
|
||||||
|
SpareDevices: 0,
|
||||||
|
UUID: "abcdef12:34567890:abcdef12:34567890",
|
||||||
|
Devices: []host.RAIDDevice{
|
||||||
|
{Device: "/dev/sda1", State: "active sync", Slot: 0},
|
||||||
|
{Device: "/dev/sdc1", State: "active sync", Slot: 2},
|
||||||
|
{Device: "/dev/sdb1", State: "faulty", Slot: -1},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "RAID6 rebuilding",
|
||||||
|
device: "/dev/md2",
|
||||||
|
output: `/dev/md2:
|
||||||
|
Version : 1.2
|
||||||
|
Creation Time : Wed Jan 14 08:00:00 2025
|
||||||
|
Raid Level : raid6
|
||||||
|
Array Size : 409600000 (390.62 GiB 419.43 GB)
|
||||||
|
Used Dev Size : 102400000 (97.66 GiB 104.86 GB)
|
||||||
|
Raid Devices : 6
|
||||||
|
Total Devices : 6
|
||||||
|
Persistence : Superblock is persistent
|
||||||
|
|
||||||
|
Update Time : Thu Jan 16 13:00:00 2025
|
||||||
|
State : active, recovering
|
||||||
|
Active Devices : 5
|
||||||
|
Working Devices : 6
|
||||||
|
Failed Devices : 0
|
||||||
|
Spare Devices : 1
|
||||||
|
|
||||||
|
Rebuild Status : 42% complete
|
||||||
|
|
||||||
|
Name : server:2
|
||||||
|
UUID : fedcba09:87654321:fedcba09:87654321
|
||||||
|
|
||||||
|
Number Major Minor RaidDevice State
|
||||||
|
0 8 1 0 active sync /dev/sda1
|
||||||
|
1 8 17 1 active sync /dev/sdb1
|
||||||
|
2 8 33 2 active sync /dev/sdc1
|
||||||
|
3 8 49 3 active sync /dev/sdd1
|
||||||
|
6 8 81 4 spare rebuilding /dev/sdf1
|
||||||
|
5 8 65 5 active sync /dev/sde1`,
|
||||||
|
want: host.RAIDArray{
|
||||||
|
Device: "/dev/md2",
|
||||||
|
Name: "server:2",
|
||||||
|
Level: "raid6",
|
||||||
|
State: "active, recovering",
|
||||||
|
TotalDevices: 6,
|
||||||
|
ActiveDevices: 5,
|
||||||
|
WorkingDevices: 6,
|
||||||
|
FailedDevices: 0,
|
||||||
|
SpareDevices: 1,
|
||||||
|
UUID: "fedcba09:87654321:fedcba09:87654321",
|
||||||
|
RebuildPercent: 42.0,
|
||||||
|
Devices: []host.RAIDDevice{
|
||||||
|
{Device: "/dev/sda1", State: "active sync", Slot: 0},
|
||||||
|
{Device: "/dev/sdb1", State: "active sync", Slot: 1},
|
||||||
|
{Device: "/dev/sdc1", State: "active sync", Slot: 2},
|
||||||
|
{Device: "/dev/sdd1", State: "active sync", Slot: 3},
|
||||||
|
{Device: "/dev/sdf1", State: "spare rebuilding", Slot: 6},
|
||||||
|
{Device: "/dev/sde1", State: "active sync", Slot: 5},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got, err := parseDetail(tt.device, tt.output)
|
||||||
|
if (err != nil) != tt.wantErr {
|
||||||
|
t.Errorf("parseDetail() error = %v, wantErr %v", err, tt.wantErr)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compare fields
|
||||||
|
if got.Device != tt.want.Device {
|
||||||
|
t.Errorf("Device = %v, want %v", got.Device, tt.want.Device)
|
||||||
|
}
|
||||||
|
if got.Name != tt.want.Name {
|
||||||
|
t.Errorf("Name = %v, want %v", got.Name, tt.want.Name)
|
||||||
|
}
|
||||||
|
if got.Level != tt.want.Level {
|
||||||
|
t.Errorf("Level = %v, want %v", got.Level, tt.want.Level)
|
||||||
|
}
|
||||||
|
if got.State != tt.want.State {
|
||||||
|
t.Errorf("State = %v, want %v", got.State, tt.want.State)
|
||||||
|
}
|
||||||
|
if got.TotalDevices != tt.want.TotalDevices {
|
||||||
|
t.Errorf("TotalDevices = %v, want %v", got.TotalDevices, tt.want.TotalDevices)
|
||||||
|
}
|
||||||
|
if got.ActiveDevices != tt.want.ActiveDevices {
|
||||||
|
t.Errorf("ActiveDevices = %v, want %v", got.ActiveDevices, tt.want.ActiveDevices)
|
||||||
|
}
|
||||||
|
if got.WorkingDevices != tt.want.WorkingDevices {
|
||||||
|
t.Errorf("WorkingDevices = %v, want %v", got.WorkingDevices, tt.want.WorkingDevices)
|
||||||
|
}
|
||||||
|
if got.FailedDevices != tt.want.FailedDevices {
|
||||||
|
t.Errorf("FailedDevices = %v, want %v", got.FailedDevices, tt.want.FailedDevices)
|
||||||
|
}
|
||||||
|
if got.SpareDevices != tt.want.SpareDevices {
|
||||||
|
t.Errorf("SpareDevices = %v, want %v", got.SpareDevices, tt.want.SpareDevices)
|
||||||
|
}
|
||||||
|
if got.UUID != tt.want.UUID {
|
||||||
|
t.Errorf("UUID = %v, want %v", got.UUID, tt.want.UUID)
|
||||||
|
}
|
||||||
|
if got.RebuildPercent != tt.want.RebuildPercent {
|
||||||
|
t.Errorf("RebuildPercent = %v, want %v", got.RebuildPercent, tt.want.RebuildPercent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compare devices
|
||||||
|
if len(got.Devices) != len(tt.want.Devices) {
|
||||||
|
t.Errorf("Devices count = %v, want %v", len(got.Devices), len(tt.want.Devices))
|
||||||
|
}
|
||||||
|
for i := range got.Devices {
|
||||||
|
if i >= len(tt.want.Devices) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if got.Devices[i].Device != tt.want.Devices[i].Device {
|
||||||
|
t.Errorf("Device[%d].Device = %v, want %v", i, got.Devices[i].Device, tt.want.Devices[i].Device)
|
||||||
|
}
|
||||||
|
if got.Devices[i].State != tt.want.Devices[i].State {
|
||||||
|
t.Errorf("Device[%d].State = %v, want %v", i, got.Devices[i].State, tt.want.Devices[i].State)
|
||||||
|
}
|
||||||
|
if got.Devices[i].Slot != tt.want.Devices[i].Slot {
|
||||||
|
t.Errorf("Device[%d].Slot = %v, want %v", i, got.Devices[i].Slot, tt.want.Devices[i].Slot)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -163,6 +163,7 @@ type Host struct {
|
||||||
Disks []Disk `json:"disks,omitempty"`
|
Disks []Disk `json:"disks,omitempty"`
|
||||||
NetworkInterfaces []HostNetworkInterface `json:"networkInterfaces,omitempty"`
|
NetworkInterfaces []HostNetworkInterface `json:"networkInterfaces,omitempty"`
|
||||||
Sensors HostSensorSummary `json:"sensors,omitempty"`
|
Sensors HostSensorSummary `json:"sensors,omitempty"`
|
||||||
|
RAID []HostRAIDArray `json:"raid,omitempty"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
UptimeSeconds int64 `json:"uptimeSeconds,omitempty"`
|
UptimeSeconds int64 `json:"uptimeSeconds,omitempty"`
|
||||||
IntervalSeconds int `json:"intervalSeconds,omitempty"`
|
IntervalSeconds int `json:"intervalSeconds,omitempty"`
|
||||||
|
|
@ -192,6 +193,30 @@ type HostSensorSummary struct {
|
||||||
Additional map[string]float64 `json:"additional,omitempty"`
|
Additional map[string]float64 `json:"additional,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// HostRAIDArray represents an mdadm RAID array on a host.
|
||||||
|
type HostRAIDArray struct {
|
||||||
|
Device string `json:"device"`
|
||||||
|
Name string `json:"name,omitempty"`
|
||||||
|
Level string `json:"level"`
|
||||||
|
State string `json:"state"`
|
||||||
|
TotalDevices int `json:"totalDevices"`
|
||||||
|
ActiveDevices int `json:"activeDevices"`
|
||||||
|
WorkingDevices int `json:"workingDevices"`
|
||||||
|
FailedDevices int `json:"failedDevices"`
|
||||||
|
SpareDevices int `json:"spareDevices"`
|
||||||
|
UUID string `json:"uuid,omitempty"`
|
||||||
|
Devices []HostRAIDDevice `json:"devices"`
|
||||||
|
RebuildPercent float64 `json:"rebuildPercent"`
|
||||||
|
RebuildSpeed string `json:"rebuildSpeed,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// HostRAIDDevice represents a device in a RAID array.
|
||||||
|
type HostRAIDDevice struct {
|
||||||
|
Device string `json:"device"`
|
||||||
|
State string `json:"state"`
|
||||||
|
Slot int `json:"slot"`
|
||||||
|
}
|
||||||
|
|
||||||
// DockerHost represents a Docker host reporting metrics via the external agent.
|
// DockerHost represents a Docker host reporting metrics via the external agent.
|
||||||
type DockerHost struct {
|
type DockerHost struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
|
|
|
||||||
|
|
@ -1997,6 +1997,33 @@ func (m *Monitor) ApplyHostReport(report agentshost.Report, tokenRecord *config.
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
raid := make([]models.HostRAIDArray, 0, len(report.RAID))
|
||||||
|
for _, array := range report.RAID {
|
||||||
|
devices := make([]models.HostRAIDDevice, 0, len(array.Devices))
|
||||||
|
for _, dev := range array.Devices {
|
||||||
|
devices = append(devices, models.HostRAIDDevice{
|
||||||
|
Device: dev.Device,
|
||||||
|
State: dev.State,
|
||||||
|
Slot: dev.Slot,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
raid = append(raid, models.HostRAIDArray{
|
||||||
|
Device: array.Device,
|
||||||
|
Name: array.Name,
|
||||||
|
Level: array.Level,
|
||||||
|
State: array.State,
|
||||||
|
TotalDevices: array.TotalDevices,
|
||||||
|
ActiveDevices: array.ActiveDevices,
|
||||||
|
WorkingDevices: array.WorkingDevices,
|
||||||
|
FailedDevices: array.FailedDevices,
|
||||||
|
SpareDevices: array.SpareDevices,
|
||||||
|
UUID: array.UUID,
|
||||||
|
Devices: devices,
|
||||||
|
RebuildPercent: array.RebuildPercent,
|
||||||
|
RebuildSpeed: array.RebuildSpeed,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
host := models.Host{
|
host := models.Host{
|
||||||
ID: identifier,
|
ID: identifier,
|
||||||
Hostname: hostname,
|
Hostname: hostname,
|
||||||
|
|
@ -2017,6 +2044,7 @@ func (m *Monitor) ApplyHostReport(report agentshost.Report, tokenRecord *config.
|
||||||
FanRPM: cloneStringFloatMap(report.Sensors.FanRPM),
|
FanRPM: cloneStringFloatMap(report.Sensors.FanRPM),
|
||||||
Additional: cloneStringFloatMap(report.Sensors.Additional),
|
Additional: cloneStringFloatMap(report.Sensors.Additional),
|
||||||
},
|
},
|
||||||
|
RAID: raid,
|
||||||
Status: "online",
|
Status: "online",
|
||||||
UptimeSeconds: report.Host.UptimeSeconds,
|
UptimeSeconds: report.Host.UptimeSeconds,
|
||||||
IntervalSeconds: report.Agent.IntervalSeconds,
|
IntervalSeconds: report.Agent.IntervalSeconds,
|
||||||
|
|
@ -2034,6 +2062,9 @@ func (m *Monitor) ApplyHostReport(report agentshost.Report, tokenRecord *config.
|
||||||
if len(host.NetworkInterfaces) == 0 {
|
if len(host.NetworkInterfaces) == 0 {
|
||||||
host.NetworkInterfaces = nil
|
host.NetworkInterfaces = nil
|
||||||
}
|
}
|
||||||
|
if len(host.RAID) == 0 {
|
||||||
|
host.RAID = nil
|
||||||
|
}
|
||||||
|
|
||||||
if tokenRecord != nil {
|
if tokenRecord != nil {
|
||||||
host.TokenID = tokenRecord.ID
|
host.TokenID = tokenRecord.ID
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ type Report struct {
|
||||||
Disks []Disk `json:"disks,omitempty"`
|
Disks []Disk `json:"disks,omitempty"`
|
||||||
Network []NetworkInterface `json:"network,omitempty"`
|
Network []NetworkInterface `json:"network,omitempty"`
|
||||||
Sensors Sensors `json:"sensors,omitempty"`
|
Sensors Sensors `json:"sensors,omitempty"`
|
||||||
|
RAID []RAIDArray `json:"raid,omitempty"`
|
||||||
Tags []string `json:"tags,omitempty"`
|
Tags []string `json:"tags,omitempty"`
|
||||||
Timestamp time.Time `json:"timestamp"`
|
Timestamp time.Time `json:"timestamp"`
|
||||||
SequenceID string `json:"sequenceId,omitempty"`
|
SequenceID string `json:"sequenceId,omitempty"`
|
||||||
|
|
@ -84,3 +85,27 @@ type Sensors struct {
|
||||||
FanRPM map[string]float64 `json:"fanRpm,omitempty"`
|
FanRPM map[string]float64 `json:"fanRpm,omitempty"`
|
||||||
Additional map[string]float64 `json:"additional,omitempty"`
|
Additional map[string]float64 `json:"additional,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RAIDArray represents an mdadm RAID array.
|
||||||
|
type RAIDArray struct {
|
||||||
|
Device string `json:"device"` // e.g., /dev/md0
|
||||||
|
Name string `json:"name,omitempty"` // Array name if set
|
||||||
|
Level string `json:"level"` // RAID level: raid0, raid1, raid5, raid6, raid10
|
||||||
|
State string `json:"state"` // clean, active, degraded, recovering, resyncing, etc.
|
||||||
|
TotalDevices int `json:"totalDevices"` // Total number of devices in array
|
||||||
|
ActiveDevices int `json:"activeDevices"` // Number of active devices
|
||||||
|
WorkingDevices int `json:"workingDevices"` // Number of working devices
|
||||||
|
FailedDevices int `json:"failedDevices"` // Number of failed devices
|
||||||
|
SpareDevices int `json:"spareDevices"` // Number of spare devices
|
||||||
|
UUID string `json:"uuid,omitempty"` // Array UUID
|
||||||
|
Devices []RAIDDevice `json:"devices"` // Individual devices in array
|
||||||
|
RebuildPercent float64 `json:"rebuildPercent"` // Rebuild/resync progress (0-100)
|
||||||
|
RebuildSpeed string `json:"rebuildSpeed,omitempty"` // Rebuild speed (e.g., "50000K/sec")
|
||||||
|
}
|
||||||
|
|
||||||
|
// RAIDDevice represents a single device in a RAID array.
|
||||||
|
type RAIDDevice struct {
|
||||||
|
Device string `json:"device"` // e.g., /dev/sda1
|
||||||
|
State string `json:"state"` // active, spare, faulty, removed
|
||||||
|
Slot int `json:"slot"` // Position in array (-1 if not applicable)
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue