mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-11 14:00:29 +00:00
feat(truenas): project aggregate disk temperatures
This commit is contained in:
@@ -233,6 +233,12 @@ That same monitoring boundary now also owns live TrueNAS disk temperatures.
|
||||
project those readings into the canonical physical-disk model and risk path
|
||||
instead of leaving temperature telemetry agent-only or adding a TrueNAS-local
|
||||
presentation shim.
|
||||
That same boundary now also owns recent aggregate TrueNAS disk temperature
|
||||
history. `internal/truenas/client.go` must ingest `disk.temperature_agg`, and
|
||||
`internal/truenas/provider.go` must project the returned min/avg/max readings
|
||||
onto the shared `physicalDisk.temperatureAggregate` contract so disk-health
|
||||
consumers can reuse one canonical metadata shape instead of inventing a
|
||||
TrueNAS-only history payload.
|
||||
That same boundary now also owns the canonical disk-history write path for
|
||||
API-backed disks. `internal/monitoring/monitor.go` must sync non-native
|
||||
physical-disk resources such as TrueNAS disks into the shared `disk`
|
||||
|
||||
@@ -164,6 +164,11 @@ populate canonical `physicalDisk.temperature` and reuse the shared
|
||||
physical-disk risk semantics, so infrastructure, storage, charts, and AI read
|
||||
the same disk-health contract instead of inventing a provider-local temperature
|
||||
surface.
|
||||
That same canonical disk contract now also owns recent aggregate temperature
|
||||
history. When a provider such as TrueNAS can supply `disk.temperature_agg`
|
||||
min/avg/max readings, it must project those onto
|
||||
`physicalDisk.temperatureAggregate` instead of introducing a provider-local
|
||||
history blob or a parallel disk-temperature presentation model.
|
||||
TrueNAS-managed applications now follow the same canonical workload rule. One
|
||||
TrueNAS app instance from `app.query` must project as one canonical
|
||||
`app-container` resource under `SourceTrueNAS`, reusing the shared workload and
|
||||
|
||||
+194
-11
@@ -29,6 +29,8 @@ const defaultRealtimeIntervalSeconds = 2
|
||||
|
||||
const defaultAppStatsIntervalSeconds = defaultRealtimeIntervalSeconds
|
||||
|
||||
const defaultDiskTemperatureAggregateWindowDays = 7
|
||||
|
||||
const defaultAppLogInitialWait = 2 * time.Second
|
||||
|
||||
const defaultAppLogIdleWait = 250 * time.Millisecond
|
||||
@@ -295,10 +297,15 @@ func (c *Client) GetDisks(ctx context.Context) ([]Disk, error) {
|
||||
if err := c.getJSON(ctx, http.MethodGet, "/disk", &response); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
temperatures, err := c.getDiskTemperaturesWithFallback(ctx, diskReportingIdentifiers(response))
|
||||
identifiers := diskReportingIdentifiers(response)
|
||||
temperatures, err := c.getDiskTemperaturesWithFallback(ctx, identifiers)
|
||||
if err != nil {
|
||||
temperatures = nil
|
||||
}
|
||||
aggregates, err := c.getDiskTemperatureAggregates(ctx, identifiers, defaultDiskTemperatureAggregateWindowDays)
|
||||
if err != nil {
|
||||
aggregates = nil
|
||||
}
|
||||
|
||||
disks := make([]Disk, 0, len(response))
|
||||
for _, item := range response {
|
||||
@@ -319,16 +326,17 @@ func (c *Client) GetDisks(ctx context.Context) ([]Disk, error) {
|
||||
}
|
||||
|
||||
disks = append(disks, Disk{
|
||||
ID: diskID,
|
||||
Name: strings.TrimSpace(item.Name),
|
||||
Pool: strings.TrimSpace(item.Pool),
|
||||
Status: strings.TrimSpace(item.Status),
|
||||
Model: strings.TrimSpace(item.Model),
|
||||
Serial: strings.TrimSpace(item.Serial),
|
||||
SizeBytes: item.Size,
|
||||
Temperature: temperatureForTrueNASDisk(temperatures, item),
|
||||
Transport: strings.ToLower(strings.TrimSpace(item.Bus)),
|
||||
Rotational: rotational,
|
||||
ID: diskID,
|
||||
Name: strings.TrimSpace(item.Name),
|
||||
Pool: strings.TrimSpace(item.Pool),
|
||||
Status: strings.TrimSpace(item.Status),
|
||||
Model: strings.TrimSpace(item.Model),
|
||||
Serial: strings.TrimSpace(item.Serial),
|
||||
SizeBytes: item.Size,
|
||||
Temperature: temperatureForTrueNASDisk(temperatures, item),
|
||||
TemperatureAggregate: temperatureAggregateForTrueNASDisk(aggregates, item),
|
||||
Transport: strings.ToLower(strings.TrimSpace(item.Bus)),
|
||||
Rotational: rotational,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -413,6 +421,25 @@ func (c *Client) getDiskTemperaturesFromReporting(ctx context.Context, identifie
|
||||
return rpc.getDiskTemperatures(ctx, identifiers)
|
||||
}
|
||||
|
||||
func (c *Client) getDiskTemperatureAggregates(ctx context.Context, identifiers []string, windowDays int) (map[string]DiskTemperatureAggregate, error) {
|
||||
identifiers = dedupeStrings(identifiers)
|
||||
if len(identifiers) == 0 {
|
||||
return nil, fmt.Errorf("truenas disk temperature aggregates require disk identifiers")
|
||||
}
|
||||
|
||||
conn, err := c.dialRPC(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() { _ = conn.Close() }()
|
||||
|
||||
rpc := &trueNASRPCClient{conn: conn, nextID: 1}
|
||||
if err := rpc.authenticate(ctx, c.config); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return rpc.getDiskTemperatureAggregates(ctx, identifiers, windowDays)
|
||||
}
|
||||
|
||||
// GetAlerts returns active and dismissed TrueNAS alerts.
|
||||
func (c *Client) GetAlerts(ctx context.Context) ([]Alert, error) {
|
||||
var response []alertResponse
|
||||
@@ -838,6 +865,26 @@ func temperatureForTrueNASDisk(temperatures map[string]int, item diskResponse) i
|
||||
return 0
|
||||
}
|
||||
|
||||
func temperatureAggregateForTrueNASDisk(aggregates map[string]DiskTemperatureAggregate, item diskResponse) DiskTemperatureAggregate {
|
||||
if len(aggregates) == 0 {
|
||||
return DiskTemperatureAggregate{}
|
||||
}
|
||||
keys := []string{
|
||||
strings.TrimSpace(item.Name),
|
||||
strings.TrimSpace(item.Identifier),
|
||||
strings.TrimSpace(item.Serial),
|
||||
}
|
||||
for _, key := range keys {
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
if aggregate, ok := aggregates[key]; ok {
|
||||
return aggregate
|
||||
}
|
||||
}
|
||||
return DiskTemperatureAggregate{}
|
||||
}
|
||||
|
||||
func parseDiskTemperatures(raw any) map[string]int {
|
||||
switch typed := raw.(type) {
|
||||
case nil:
|
||||
@@ -1312,6 +1359,25 @@ func (c *trueNASRPCClient) getDiskTemperatures(ctx context.Context, identifiers
|
||||
return parseReportingDiskTemperatures(response), nil
|
||||
}
|
||||
|
||||
func (c *trueNASRPCClient) getDiskTemperatureAggregates(ctx context.Context, identifiers []string, windowDays int) (map[string]DiskTemperatureAggregate, error) {
|
||||
if c == nil || c.conn == nil {
|
||||
return nil, fmt.Errorf("truenas rpc connection is nil")
|
||||
}
|
||||
identifiers = dedupeStrings(identifiers)
|
||||
if len(identifiers) == 0 {
|
||||
return nil, fmt.Errorf("truenas rpc disk temperature aggregate query requires at least one identifier")
|
||||
}
|
||||
if windowDays <= 0 {
|
||||
windowDays = defaultDiskTemperatureAggregateWindowDays
|
||||
}
|
||||
|
||||
var response any
|
||||
if err := c.call(ctx, "disk.temperature_agg", []any{identifiers, windowDays}, &response); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return parseDiskTemperatureAggregates(response, windowDays), nil
|
||||
}
|
||||
|
||||
func (c *trueNASRPCClient) getReportingData(ctx context.Context, graphs []map[string]any) ([]trueNASReportingGetDataResponse, error) {
|
||||
if c == nil || c.conn == nil {
|
||||
return nil, fmt.Errorf("truenas rpc connection is nil")
|
||||
@@ -1549,6 +1615,123 @@ func parseReportingDiskTemperatures(responses []trueNASReportingGetDataResponse)
|
||||
return temperatures
|
||||
}
|
||||
|
||||
func parseDiskTemperatureAggregates(raw any, defaultWindowDays int) map[string]DiskTemperatureAggregate {
|
||||
if defaultWindowDays <= 0 {
|
||||
defaultWindowDays = defaultDiskTemperatureAggregateWindowDays
|
||||
}
|
||||
|
||||
aggregates := make(map[string]DiskTemperatureAggregate)
|
||||
switch typed := raw.(type) {
|
||||
case map[string]any:
|
||||
for identifier, entry := range typed {
|
||||
aggregate, ok := parseDiskTemperatureAggregateEntry(entry, defaultWindowDays)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
aggregates[strings.TrimSpace(identifier)] = aggregate
|
||||
}
|
||||
case []any:
|
||||
for _, entry := range typed {
|
||||
record, ok := entry.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
identifier := readStringAny(record, "identifier", "name", "disk", "disk_name", "diskName")
|
||||
if identifier == "" {
|
||||
continue
|
||||
}
|
||||
aggregate, ok := parseDiskTemperatureAggregateEntry(record, defaultWindowDays)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
aggregates[identifier] = aggregate
|
||||
}
|
||||
}
|
||||
if len(aggregates) == 0 {
|
||||
return nil
|
||||
}
|
||||
return aggregates
|
||||
}
|
||||
|
||||
func parseDiskTemperatureAggregateEntry(raw any, defaultWindowDays int) (DiskTemperatureAggregate, bool) {
|
||||
record, ok := raw.(map[string]any)
|
||||
if !ok {
|
||||
return DiskTemperatureAggregate{}, false
|
||||
}
|
||||
|
||||
aggRecord := record
|
||||
for _, key := range []string{"aggregations", "aggregation", "stats", "temperature_agg", "temperatureAgg"} {
|
||||
if nested := readMapAny(record, key); len(nested) > 0 {
|
||||
aggRecord = nested
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
minimum, okMinimum := readFloatValueAny(aggRecord, "min", "minimum", "low")
|
||||
average, okAverage := readFloatValueAny(aggRecord, "avg", "average", "mean")
|
||||
maximum, okMaximum := readFloatValueAny(aggRecord, "max", "maximum", "high")
|
||||
if !okMinimum && !okAverage && !okMaximum {
|
||||
return DiskTemperatureAggregate{}, false
|
||||
}
|
||||
|
||||
windowDays := readIntValueAny(record, "days", "window_days", "windowDays")
|
||||
if windowDays <= 0 {
|
||||
windowDays = readIntValueAny(aggRecord, "days", "window_days", "windowDays")
|
||||
}
|
||||
if windowDays <= 0 {
|
||||
windowDays = defaultWindowDays
|
||||
}
|
||||
|
||||
return DiskTemperatureAggregate{
|
||||
WindowDays: windowDays,
|
||||
MinCelsius: minimum,
|
||||
AvgCelsius: average,
|
||||
MaxCelsius: maximum,
|
||||
}, true
|
||||
}
|
||||
|
||||
func readFloatValueAny(record map[string]any, keys ...string) (float64, bool) {
|
||||
for _, key := range keys {
|
||||
value, ok := record[key]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if parsed, ok := parseFloat64Any(value); ok {
|
||||
return parsed, true
|
||||
}
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
func readIntValueAny(record map[string]any, keys ...string) int {
|
||||
for _, key := range keys {
|
||||
value, ok := record[key]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
switch typed := value.(type) {
|
||||
case int:
|
||||
return typed
|
||||
case int64:
|
||||
return int(typed)
|
||||
case float64:
|
||||
return int(math.Round(typed))
|
||||
case json.Number:
|
||||
if integer, err := typed.Int64(); err == nil {
|
||||
return int(integer)
|
||||
}
|
||||
if floatValue, err := typed.Float64(); err == nil {
|
||||
return int(math.Round(floatValue))
|
||||
}
|
||||
case string:
|
||||
if parsed, err := strconv.Atoi(strings.TrimSpace(typed)); err == nil {
|
||||
return parsed
|
||||
}
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func extractReportingLegendFloatValues(raw any, legends []string) map[string]float64 {
|
||||
if raw == nil || len(legends) == 0 {
|
||||
return nil
|
||||
|
||||
+104
-16
@@ -627,6 +627,7 @@ func TestGetDiskTemperaturesFallsBackToReportingRPC(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestGetDisksFallsBackToReportingRPCWhenTemperatureEndpointUnavailable(t *testing.T) {
|
||||
connectionCount := 0
|
||||
server := newMockServerWithRPC(t, map[string]apiResponse{
|
||||
"/api/v2.0/disk": {
|
||||
body: `[{"identifier":"{disk-1}","name":"sda","serial":"SER-A","size":1000000,"model":"Seagate","type":"HDD","pool":"tank","bus":"SATA","rotationrate":7200,"status":"ONLINE"}]`,
|
||||
@@ -642,23 +643,41 @@ func TestGetDisksFallsBackToReportingRPCWhenTemperatureEndpointUnavailable(t *te
|
||||
}
|
||||
writeRPCResult(t, conn, authReq.ID, true)
|
||||
|
||||
temperatureReq := readRPCRequest(t, conn)
|
||||
if temperatureReq.Method != "reporting.get_data" {
|
||||
t.Fatalf("expected reporting.get_data, got %q", temperatureReq.Method)
|
||||
}
|
||||
writeRPCResult(t, conn, temperatureReq.ID, []map[string]any{{
|
||||
"name": "disktemp",
|
||||
"identifier": "sda",
|
||||
"legend": []string{"temperature"},
|
||||
"aggregations": map[string]any{
|
||||
"mean": map[string]any{
|
||||
"temperature": 43.2,
|
||||
connectionCount++
|
||||
request := readRPCRequest(t, conn)
|
||||
switch connectionCount {
|
||||
case 1:
|
||||
if request.Method != "reporting.get_data" {
|
||||
t.Fatalf("expected reporting.get_data, got %q", request.Method)
|
||||
}
|
||||
writeRPCResult(t, conn, request.ID, []map[string]any{{
|
||||
"name": "disktemp",
|
||||
"identifier": "sda",
|
||||
"legend": []string{"temperature"},
|
||||
"aggregations": map[string]any{
|
||||
"mean": map[string]any{
|
||||
"temperature": 43.2,
|
||||
},
|
||||
},
|
||||
},
|
||||
"data": []any{},
|
||||
"start": time.Now().Add(-5 * time.Minute).Unix(),
|
||||
"end": time.Now().Unix(),
|
||||
}})
|
||||
"data": []any{},
|
||||
"start": time.Now().Add(-5 * time.Minute).Unix(),
|
||||
"end": time.Now().Unix(),
|
||||
}})
|
||||
case 2:
|
||||
if request.Method != "disk.temperature_agg" {
|
||||
t.Fatalf("expected disk.temperature_agg, got %q", request.Method)
|
||||
}
|
||||
writeRPCResult(t, conn, request.ID, map[string]any{
|
||||
"sda": map[string]any{
|
||||
"min": 39.0,
|
||||
"avg": 41.6,
|
||||
"max": 45.0,
|
||||
"window_days": 7,
|
||||
},
|
||||
})
|
||||
default:
|
||||
t.Fatalf("unexpected extra websocket connection %d", connectionCount)
|
||||
}
|
||||
})
|
||||
t.Cleanup(server.Close)
|
||||
|
||||
@@ -673,6 +692,75 @@ func TestGetDisksFallsBackToReportingRPCWhenTemperatureEndpointUnavailable(t *te
|
||||
if got := disks[0].Temperature; got != 43 {
|
||||
t.Fatalf("expected reporting fallback temperature 43, got %+v", disks[0])
|
||||
}
|
||||
if got := disks[0].TemperatureAggregate.MaxCelsius; got != 45.0 {
|
||||
t.Fatalf("expected aggregate max 45.0, got %+v", disks[0].TemperatureAggregate)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetDisksIncludesDiskTemperatureAggregatesFromRPC(t *testing.T) {
|
||||
server := newMockServerWithRPC(t, map[string]apiResponse{
|
||||
"/api/v2.0/disk": {
|
||||
body: `[{"identifier":"{disk-1}","name":"sda","serial":"SER-A","size":1000000,"model":"Seagate","type":"HDD","pool":"tank","bus":"SATA","rotationrate":7200,"status":"ONLINE"}]`,
|
||||
},
|
||||
"/api/v2.0/disk/temperatures": {
|
||||
body: `{"sda":34}`,
|
||||
},
|
||||
}, nil, func(t *testing.T, conn *websocket.Conn) {
|
||||
authReq := readRPCRequest(t, conn)
|
||||
if authReq.Method != "auth.login_with_api_key" {
|
||||
t.Fatalf("expected api-key auth method, got %q", authReq.Method)
|
||||
}
|
||||
writeRPCResult(t, conn, authReq.ID, true)
|
||||
|
||||
aggregateReq := readRPCRequest(t, conn)
|
||||
if aggregateReq.Method != "disk.temperature_agg" {
|
||||
t.Fatalf("expected disk.temperature_agg, got %q", aggregateReq.Method)
|
||||
}
|
||||
params, ok := aggregateReq.Params.([]any)
|
||||
if !ok || len(params) != 2 {
|
||||
t.Fatalf("unexpected aggregate params: %#v", aggregateReq.Params)
|
||||
}
|
||||
identifiers, ok := params[0].([]any)
|
||||
if !ok || len(identifiers) != 1 {
|
||||
t.Fatalf("unexpected aggregate identifiers: %#v", params[0])
|
||||
}
|
||||
if got := strings.TrimSpace(fmt.Sprint(identifiers[0])); got != "sda" {
|
||||
t.Fatalf("expected sda identifier, got %q", got)
|
||||
}
|
||||
if got := int(readFloatAny(map[string]any{"value": params[1]}, "value")); got != defaultDiskTemperatureAggregateWindowDays {
|
||||
t.Fatalf("expected window %d, got %#v", defaultDiskTemperatureAggregateWindowDays, params[1])
|
||||
}
|
||||
writeRPCResult(t, conn, aggregateReq.ID, map[string]any{
|
||||
"sda": map[string]any{
|
||||
"min": 29.0,
|
||||
"avg": 32.8,
|
||||
"max": 38.0,
|
||||
"window_days": 7,
|
||||
},
|
||||
})
|
||||
})
|
||||
t.Cleanup(server.Close)
|
||||
|
||||
client := mustClientForServer(t, server.URL, ClientConfig{APIKey: "api-key"})
|
||||
disks, err := client.GetDisks(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("GetDisks() error = %v", err)
|
||||
}
|
||||
if len(disks) != 1 {
|
||||
t.Fatalf("expected 1 disk, got %d", len(disks))
|
||||
}
|
||||
if got := disks[0].TemperatureAggregate.WindowDays; got != 7 {
|
||||
t.Fatalf("expected aggregate window 7, got %+v", disks[0].TemperatureAggregate)
|
||||
}
|
||||
if got := disks[0].TemperatureAggregate.MinCelsius; got != 29.0 {
|
||||
t.Fatalf("expected aggregate min 29.0, got %+v", disks[0].TemperatureAggregate)
|
||||
}
|
||||
if got := disks[0].TemperatureAggregate.AvgCelsius; got != 32.8 {
|
||||
t.Fatalf("expected aggregate avg 32.8, got %+v", disks[0].TemperatureAggregate)
|
||||
}
|
||||
if got := disks[0].TemperatureAggregate.MaxCelsius; got != 38.0 {
|
||||
t.Fatalf("expected aggregate max 38.0, got %+v", disks[0].TemperatureAggregate)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientHandlesHTTPAndDecodeErrors(t *testing.T) {
|
||||
|
||||
@@ -108,8 +108,14 @@ func DefaultFixtures() FixtureSnapshot {
|
||||
Serial: "ZL0A1234",
|
||||
SizeBytes: 16 * 1024 * 1024 * 1024 * 1024,
|
||||
Temperature: 34,
|
||||
Transport: "sata",
|
||||
Rotational: true,
|
||||
TemperatureAggregate: DiskTemperatureAggregate{
|
||||
WindowDays: 7,
|
||||
MinCelsius: 29.0,
|
||||
AvgCelsius: 32.7,
|
||||
MaxCelsius: 38.0,
|
||||
},
|
||||
Transport: "sata",
|
||||
Rotational: true,
|
||||
},
|
||||
{
|
||||
ID: "disk-sdb",
|
||||
@@ -120,8 +126,14 @@ func DefaultFixtures() FixtureSnapshot {
|
||||
Serial: "ZL0A1235",
|
||||
SizeBytes: 16 * 1024 * 1024 * 1024 * 1024,
|
||||
Temperature: 36,
|
||||
Transport: "sata",
|
||||
Rotational: true,
|
||||
TemperatureAggregate: DiskTemperatureAggregate{
|
||||
WindowDays: 7,
|
||||
MinCelsius: 31.0,
|
||||
AvgCelsius: 34.5,
|
||||
MaxCelsius: 40.0,
|
||||
},
|
||||
Transport: "sata",
|
||||
Rotational: true,
|
||||
},
|
||||
{
|
||||
ID: "disk-nvme0n1",
|
||||
@@ -132,8 +144,14 @@ func DefaultFixtures() FixtureSnapshot {
|
||||
Serial: "S65ANX0R123456",
|
||||
SizeBytes: 2 * 1024 * 1024 * 1024 * 1024,
|
||||
Temperature: 48,
|
||||
Transport: "nvme",
|
||||
Rotational: false,
|
||||
TemperatureAggregate: DiskTemperatureAggregate{
|
||||
WindowDays: 7,
|
||||
MinCelsius: 41.0,
|
||||
AvgCelsius: 45.8,
|
||||
MaxCelsius: 52.0,
|
||||
},
|
||||
Transport: "nvme",
|
||||
Rotational: false,
|
||||
},
|
||||
{
|
||||
ID: "disk-sdc",
|
||||
@@ -144,8 +162,14 @@ func DefaultFixtures() FixtureSnapshot {
|
||||
Serial: "WD-WX12A3456",
|
||||
SizeBytes: 20 * 1024 * 1024 * 1024 * 1024,
|
||||
Temperature: 63,
|
||||
Transport: "sas",
|
||||
Rotational: true,
|
||||
TemperatureAggregate: DiskTemperatureAggregate{
|
||||
WindowDays: 7,
|
||||
MinCelsius: 52.0,
|
||||
AvgCelsius: 58.9,
|
||||
MaxCelsius: 66.0,
|
||||
},
|
||||
Transport: "sas",
|
||||
Rotational: true,
|
||||
},
|
||||
},
|
||||
Alerts: []Alert{
|
||||
|
||||
@@ -490,16 +490,17 @@ func (p *Provider) Records() []unifiedresources.IngestRecord {
|
||||
LastSeen: collectedAt,
|
||||
UpdatedAt: collectedAt,
|
||||
PhysicalDisk: &unifiedresources.PhysicalDiskMeta{
|
||||
DevPath: "/dev/" + disk.Name,
|
||||
Model: disk.Model,
|
||||
Serial: disk.Serial,
|
||||
DiskType: disk.Transport,
|
||||
SizeBytes: disk.SizeBytes,
|
||||
Health: healthFromDisk(disk),
|
||||
Temperature: disk.Temperature,
|
||||
Wearout: -1,
|
||||
RPM: rpmFromDisk(disk),
|
||||
Risk: unifiedresources.PhysicalDiskRiskFromAssessment(assessment),
|
||||
DevPath: "/dev/" + disk.Name,
|
||||
Model: disk.Model,
|
||||
Serial: disk.Serial,
|
||||
DiskType: disk.Transport,
|
||||
SizeBytes: disk.SizeBytes,
|
||||
Health: healthFromDisk(disk),
|
||||
Temperature: disk.Temperature,
|
||||
TemperatureAggregate: temperatureAggregateMetaFromTrueNASDisk(disk),
|
||||
Wearout: -1,
|
||||
RPM: rpmFromDisk(disk),
|
||||
Risk: unifiedresources.PhysicalDiskRiskFromAssessment(assessment),
|
||||
},
|
||||
Tags: []string{"truenas", "disk", disk.Transport},
|
||||
Incidents: incidents,
|
||||
@@ -1322,6 +1323,19 @@ func rpmFromDisk(disk Disk) int {
|
||||
return 0
|
||||
}
|
||||
|
||||
func temperatureAggregateMetaFromTrueNASDisk(disk Disk) *unifiedresources.TemperatureAggregateMeta {
|
||||
aggregate := disk.TemperatureAggregate
|
||||
if aggregate.WindowDays <= 0 && aggregate.MinCelsius <= 0 && aggregate.AvgCelsius <= 0 && aggregate.MaxCelsius <= 0 {
|
||||
return nil
|
||||
}
|
||||
return &unifiedresources.TemperatureAggregateMeta{
|
||||
WindowDays: aggregate.WindowDays,
|
||||
MinCelsius: aggregate.MinCelsius,
|
||||
AvgCelsius: aggregate.AvgCelsius,
|
||||
MaxCelsius: aggregate.MaxCelsius,
|
||||
}
|
||||
}
|
||||
|
||||
func parentPoolFromDataset(datasetName string) string {
|
||||
parts := strings.SplitN(strings.TrimSpace(datasetName), "/", 2)
|
||||
if len(parts) == 0 {
|
||||
|
||||
@@ -725,3 +725,36 @@ func TestRecordsElevateOnlineDiskWhenTemperatureCritical(t *testing.T) {
|
||||
t.Fatalf("expected hot disk critical risk, got %+v", diskRecord.Resource.PhysicalDisk.Risk)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordsProjectDiskTemperatureAggregatesIntoCanonicalMetadata(t *testing.T) {
|
||||
previous := IsFeatureEnabled()
|
||||
SetFeatureEnabled(true)
|
||||
t.Cleanup(func() {
|
||||
SetFeatureEnabled(previous)
|
||||
})
|
||||
|
||||
provider := NewProvider(DefaultFixtures())
|
||||
records := provider.Records()
|
||||
if len(records) == 0 {
|
||||
t.Fatal("expected fixture records from provider")
|
||||
}
|
||||
|
||||
for _, record := range records {
|
||||
if record.Resource.Type != unifiedresources.ResourceTypePhysicalDisk || record.Resource.Name != "sda" {
|
||||
continue
|
||||
}
|
||||
if record.Resource.PhysicalDisk == nil {
|
||||
t.Fatal("expected canonical physical-disk metadata")
|
||||
}
|
||||
aggregate := record.Resource.PhysicalDisk.TemperatureAggregate
|
||||
if aggregate == nil {
|
||||
t.Fatalf("expected temperature aggregate on canonical physical disk: %+v", record.Resource.PhysicalDisk)
|
||||
}
|
||||
if aggregate.WindowDays != 7 || aggregate.MinCelsius != 29.0 || aggregate.AvgCelsius != 32.7 || aggregate.MaxCelsius != 38.0 {
|
||||
t.Fatalf("unexpected canonical disk temperature aggregate: %+v", aggregate)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
t.Fatal("expected sda physical disk record")
|
||||
}
|
||||
|
||||
+20
-10
@@ -59,16 +59,26 @@ type Dataset struct {
|
||||
|
||||
// Disk mirrors a TrueNAS disk listing entry.
|
||||
type Disk struct {
|
||||
ID string
|
||||
Name string
|
||||
Pool string
|
||||
Status string
|
||||
Model string
|
||||
Serial string
|
||||
SizeBytes int64
|
||||
Temperature int
|
||||
Transport string
|
||||
Rotational bool
|
||||
ID string
|
||||
Name string
|
||||
Pool string
|
||||
Status string
|
||||
Model string
|
||||
Serial string
|
||||
SizeBytes int64
|
||||
Temperature int
|
||||
TemperatureAggregate DiskTemperatureAggregate
|
||||
Transport string
|
||||
Rotational bool
|
||||
}
|
||||
|
||||
// DiskTemperatureAggregate stores recent aggregate disk-temperature history
|
||||
// derived from the native TrueNAS disk.temperature_agg API.
|
||||
type DiskTemperatureAggregate struct {
|
||||
WindowDays int
|
||||
MinCelsius float64
|
||||
AvgCelsius float64
|
||||
MaxCelsius float64
|
||||
}
|
||||
|
||||
// Alert mirrors a TrueNAS alert listing entry.
|
||||
|
||||
@@ -125,6 +125,23 @@ func TestResourceRelationshipFieldsDefaultToNil(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPhysicalDiskTemperatureAggregateDefaultsToNil(t *testing.T) {
|
||||
meta := PhysicalDiskMeta{}
|
||||
if meta.TemperatureAggregate != nil {
|
||||
t.Fatalf("TemperatureAggregate should default to nil, got %+v", meta.TemperatureAggregate)
|
||||
}
|
||||
|
||||
meta.TemperatureAggregate = &TemperatureAggregateMeta{
|
||||
WindowDays: 7,
|
||||
MinCelsius: 29.0,
|
||||
AvgCelsius: 32.7,
|
||||
MaxCelsius: 38.0,
|
||||
}
|
||||
if meta.TemperatureAggregate.WindowDays != 7 || meta.TemperatureAggregate.MaxCelsius != 38.0 {
|
||||
t.Fatalf("unexpected temperature aggregate assignment: %+v", meta.TemperatureAggregate)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsUnsupportedLegacyResourceIDAlias(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
|
||||
@@ -289,11 +289,20 @@ func clonePhysicalDiskMeta(in *PhysicalDiskMeta) *PhysicalDiskMeta {
|
||||
return nil
|
||||
}
|
||||
out := *in
|
||||
out.TemperatureAggregate = cloneTemperatureAggregateMeta(in.TemperatureAggregate)
|
||||
out.SMART = cloneSMARTMeta(in.SMART)
|
||||
out.Risk = clonePhysicalDiskRisk(in.Risk)
|
||||
return &out
|
||||
}
|
||||
|
||||
func cloneTemperatureAggregateMeta(in *TemperatureAggregateMeta) *TemperatureAggregateMeta {
|
||||
if in == nil {
|
||||
return nil
|
||||
}
|
||||
out := *in
|
||||
return &out
|
||||
}
|
||||
|
||||
func cloneHostUnraidMeta(in *HostUnraidMeta) *HostUnraidMeta {
|
||||
if in == nil {
|
||||
return nil
|
||||
|
||||
@@ -1056,6 +1056,9 @@ func mergePhysicalDiskData(existing *PhysicalDiskMeta, incoming *PhysicalDiskMet
|
||||
if incoming.Temperature > 0 && (merged.Temperature == 0 || incoming.SMART != nil || merged.SMART == nil) {
|
||||
merged.Temperature = incoming.Temperature
|
||||
}
|
||||
if incoming.TemperatureAggregate != nil {
|
||||
merged.TemperatureAggregate = cloneTemperatureAggregateMeta(incoming.TemperatureAggregate)
|
||||
}
|
||||
if incoming.RPM > 0 {
|
||||
merged.RPM = incoming.RPM
|
||||
}
|
||||
|
||||
@@ -99,6 +99,61 @@ func TestResourceRegistry_ListByType_Empty(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestResourceRegistry_MergesPhysicalDiskTemperatureAggregate(t *testing.T) {
|
||||
rr := NewRegistry(nil)
|
||||
now := time.Date(2026, 3, 29, 12, 0, 0, 0, time.UTC)
|
||||
|
||||
rr.IngestRecords(SourceTrueNAS, []IngestRecord{
|
||||
{
|
||||
SourceID: "disk-sda",
|
||||
Resource: Resource{
|
||||
Type: ResourceTypePhysicalDisk,
|
||||
Name: "sda",
|
||||
Status: StatusOnline,
|
||||
LastSeen: now,
|
||||
PhysicalDisk: &PhysicalDiskMeta{
|
||||
DevPath: "/dev/sda",
|
||||
Model: "Seagate Exos X18",
|
||||
Serial: "SER-A",
|
||||
DiskType: "sata",
|
||||
SizeBytes: 1_000_000,
|
||||
Health: "PASSED",
|
||||
Temperature: 34,
|
||||
Wearout: -1,
|
||||
TemperatureAggregate: &TemperatureAggregateMeta{
|
||||
WindowDays: 7,
|
||||
MinCelsius: 29.0,
|
||||
AvgCelsius: 32.7,
|
||||
MaxCelsius: 38.0,
|
||||
},
|
||||
},
|
||||
},
|
||||
Identity: ResourceIdentity{MachineID: "SER-A"},
|
||||
},
|
||||
})
|
||||
|
||||
disks := rr.ListByType(ResourceTypePhysicalDisk)
|
||||
if len(disks) != 1 {
|
||||
t.Fatalf("expected 1 physical disk, got %d", len(disks))
|
||||
}
|
||||
aggregate := disks[0].PhysicalDisk.TemperatureAggregate
|
||||
if aggregate == nil {
|
||||
t.Fatalf("expected temperature aggregate on merged disk record: %+v", disks[0].PhysicalDisk)
|
||||
}
|
||||
if aggregate.WindowDays != 7 || aggregate.MinCelsius != 29.0 || aggregate.AvgCelsius != 32.7 || aggregate.MaxCelsius != 38.0 {
|
||||
t.Fatalf("unexpected merged temperature aggregate: %+v", aggregate)
|
||||
}
|
||||
|
||||
aggregate.MaxCelsius = 99.0
|
||||
got, ok := rr.Get(disks[0].ID)
|
||||
if !ok || got == nil || got.PhysicalDisk == nil || got.PhysicalDisk.TemperatureAggregate == nil {
|
||||
t.Fatalf("expected stored physical disk record, got %+v", got)
|
||||
}
|
||||
if got.PhysicalDisk.TemperatureAggregate.MaxCelsius != 38.0 {
|
||||
t.Fatalf("expected registry clone isolation for temperature aggregate, got %+v", got.PhysicalDisk.TemperatureAggregate)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResourceRegistryClonesCarryPolicyMetadata(t *testing.T) {
|
||||
rr := NewRegistry(nil)
|
||||
now := time.Date(2026, 3, 17, 12, 0, 0, 0, time.UTC)
|
||||
|
||||
@@ -315,22 +315,32 @@ type ResourceIncident struct {
|
||||
|
||||
// PhysicalDiskMeta contains physical disk-specific metadata.
|
||||
type PhysicalDiskMeta struct {
|
||||
DevPath string `json:"devPath"`
|
||||
Model string `json:"model,omitempty"`
|
||||
Serial string `json:"serial,omitempty"`
|
||||
WWN string `json:"wwn,omitempty"`
|
||||
DiskType string `json:"diskType"` // nvme, sata, sas
|
||||
SizeBytes int64 `json:"sizeBytes"`
|
||||
Health string `json:"health"` // PASSED, FAILED, UNKNOWN
|
||||
Wearout int `json:"wearout"` // 0-100, -1 unavailable
|
||||
Temperature int `json:"temperature"` // Celsius
|
||||
RPM int `json:"rpm"`
|
||||
Used string `json:"used,omitempty"`
|
||||
StorageRole string `json:"storageRole,omitempty"`
|
||||
StorageGroup string `json:"storageGroup,omitempty"`
|
||||
StorageState string `json:"storageState,omitempty"`
|
||||
SMART *SMARTMeta `json:"smart,omitempty"`
|
||||
Risk *PhysicalDiskRisk `json:"risk,omitempty"`
|
||||
DevPath string `json:"devPath"`
|
||||
Model string `json:"model,omitempty"`
|
||||
Serial string `json:"serial,omitempty"`
|
||||
WWN string `json:"wwn,omitempty"`
|
||||
DiskType string `json:"diskType"` // nvme, sata, sas
|
||||
SizeBytes int64 `json:"sizeBytes"`
|
||||
Health string `json:"health"` // PASSED, FAILED, UNKNOWN
|
||||
Wearout int `json:"wearout"` // 0-100, -1 unavailable
|
||||
Temperature int `json:"temperature"` // Celsius
|
||||
TemperatureAggregate *TemperatureAggregateMeta `json:"temperatureAggregate,omitempty"`
|
||||
RPM int `json:"rpm"`
|
||||
Used string `json:"used,omitempty"`
|
||||
StorageRole string `json:"storageRole,omitempty"`
|
||||
StorageGroup string `json:"storageGroup,omitempty"`
|
||||
StorageState string `json:"storageState,omitempty"`
|
||||
SMART *SMARTMeta `json:"smart,omitempty"`
|
||||
Risk *PhysicalDiskRisk `json:"risk,omitempty"`
|
||||
}
|
||||
|
||||
// TemperatureAggregateMeta stores recent aggregate temperature history for a
|
||||
// resource sensor where the provider can supply min/avg/max readings.
|
||||
type TemperatureAggregateMeta struct {
|
||||
WindowDays int `json:"windowDays,omitempty"`
|
||||
MinCelsius float64 `json:"minCelsius,omitempty"`
|
||||
AvgCelsius float64 `json:"avgCelsius,omitempty"`
|
||||
MaxCelsius float64 `json:"maxCelsius,omitempty"`
|
||||
}
|
||||
|
||||
type StorageRisk struct {
|
||||
|
||||
Reference in New Issue
Block a user