   375→	}
   376→
   377→	archiver.TriggerArchiving(job)
   378→}
   379→
   380→// processNodestateEvent extracts and processes node state data from the InfluxDB message.
   381→// Updates node states in the repository for all nodes in the payload.
   382→func (api *NatsAPI) processNodestateEvent(msg lp.CCMessage) {
   383→	v, ok := msg.GetEventValue()
   384→	if !ok {
   385→		cclog.Errorf("Nodestate event is missing event field with JSON payload")
   386→		return
   387→	}
   388→
   389→	var req UpdateNodeStatesRequest
   390→
   391→	dec := json.NewDecoder(strings.NewReader(v))
   392→	dec.DisallowUnknownFields()
   393→	if err := dec.Decode(&req); err != nil {
   394→		cclog.Errorf("NATS nodestate: parsing request failed: %v", err)
   395→		return
   396→	}
   397→
   398→	repo := repository.GetNodeRepository()
   399→	requestReceived := time.Now().Unix()
   400→
   401→	updates := make([]repository.NodeStateUpdate, 0, len(req.Nodes))
   402→	for _, node := range req.Nodes {
   403→		state := determineState(node.States)
   404→		nodeState := schema.NodeStateDB{
   405→			TimeStamp:       requestReceived,
   406→			NodeState:       state,
   407→			CpusAllocated:   node.CpusAllocated,
   408→			MemoryAllocated: node.MemoryAllocated,
   409→			GpusAllocated:   node.GpusAllocated,
   410→			HealthState:     schema.MonitoringStateFull,
   411→			JobsRunning:     node.JobsRunning,
   412→		}
   413→		updates = append(updates, repository.NodeStateUpdate{
   414→			Hostname:  node.Hostname,
   415→			Cluster:   req.Cluster,
   416→			NodeState: &nodeState,
   417→		})
   418→	}
   419→
   420→	if err := repo.BatchUpdateNodeStates(updates); err != nil {
   421→		cclog.Errorf("NATS nodestate: batch update for cluster %s failed: %v", req.Cluster, err)
   422→	}
   423→
   424→	cclog.Debugf("NATS nodestate: updated %d node states for cluster %s", len(req.Nodes), req.Cluster)
   425→}
   426→
   427→// handleNodeState processes node state update messages received via NATS using InfluxDB line protocol.
   428→// The message must be in line protocol format with measurement="nodestate" and include:
   429→//   - field "event" containing JSON payload (UpdateNodeStatesRequest)
   430→//
   431→// Example: nodestate event="{\"cluster\":\"testcluster\",\"nodes\":[...]}" 1234567890000000000
   432→func (api *NatsAPI) handleNodeState(subject string, data []byte) {
   433→	if len(data) == 0 {
   434→		cclog.Warnf("NATS %s: received empty message", subject)
   435→		return
   436→	}
   437→
   438→	d := influx.NewDecoderWithBytes(data)
   439→
   440→	for d.Next() {
   441→		m, err := receivers.DecodeInfluxMessage(d)
   442→		if err != nil {
   443→			cclog.Errorf("NATS %s: failed to decode InfluxDB line protocol message: %v", subject, err)
   444→			return
   445→		}
   446→
   447→		if !m.IsEvent() {
   448→			cclog.Warnf("NATS %s: received non-event message, skipping", subject)
   449→			continue
   450→		}
   451→
   452→		if m.Name() == "nodestate" {
   453→			api.processNodestateEvent(m)
   454→		} else {
   455→			cclog.Warnf("NATS %s: unexpected measurement name '%s', expected 'nodestate'", subject, m.Name())
   456→		}
   457→	}
   458→}
   459→

	}

	archiver.TriggerArchiving(job)
}

// processNodestateEvent extracts and processes node state data from the InfluxDB message.
// Updates node states in the repository for all nodes in the payload.
func (api *NatsAPI) processNodestateEvent(msg lp.CCMessage) {
	v, ok := msg.GetEventValue()
	if !ok {
		cclog.Errorf("Nodestate event is missing event field with JSON payload")
		return
	}

	var req UpdateNodeStatesRequest

	dec := json.NewDecoder(strings.NewReader(v))
	dec.DisallowUnknownFields()
	if err := dec.Decode(&req); err != nil {
		cclog.Errorf("NATS nodestate: parsing request failed: %v", err)
		return
	}

	repo := repository.GetNodeRepository()
	requestReceived := time.Now().Unix()

	updates := make([]repository.NodeStateUpdate, 0, len(req.Nodes))
	for _, node := range req.Nodes {
		state := determineState(node.States)
		nodeState := schema.NodeStateDB{
			TimeStamp:       requestReceived,
			NodeState:       state,
			CpusAllocated:   node.CpusAllocated,
			MemoryAllocated: node.MemoryAllocated,
			GpusAllocated:   node.GpusAllocated,
			HealthState:     schema.MonitoringStateFull,
			JobsRunning:     node.JobsRunning,
		}
		updates = append(updates, repository.NodeStateUpdate{
			Hostname:  node.Hostname,
			Cluster:   req.Cluster,
			NodeState: &nodeState,
		})
	}

	if err := repo.BatchUpdateNodeStates(updates); err != nil {
		cclog.Errorf("NATS nodestate: batch update for cluster %s failed: %v", req.Cluster, err)
	}

	cclog.Debugf("NATS nodestate: updated %d node states for cluster %s", len(req.Nodes), req.Cluster)
}

// handleNodeState processes node state update messages received via NATS using InfluxDB line protocol.
// The message must be in line protocol format with measurement="nodestate" and include:
//   - field "event" containing JSON payload (UpdateNodeStatesRequest)
//
// Example: nodestate event="{\"cluster\":\"testcluster\",\"nodes\":[...]}" 1234567890000000000
func (api *NatsAPI) handleNodeState(subject string, data []byte) {
	if len(data) == 0 {
		cclog.Warnf("NATS %s: received empty message", subject)
		return
	}

	d := influx.NewDecoderWithBytes(data)

	for d.Next() {
		m, err := receivers.DecodeInfluxMessage(d)
		if err != nil {
			cclog.Errorf("NATS %s: failed to decode InfluxDB line protocol message: %v", subject, err)
			return
		}

		if !m.IsEvent() {
			cclog.Warnf("NATS %s: received non-event message, skipping", subject)
			continue
		}

		if m.Name() == "nodestate" {
			api.processNodestateEvent(m)
		} else {
			cclog.Warnf("NATS %s: unexpected measurement name '%s', expected 'nodestate'", subject, m.Name())
		}
	}
}


     1→// Copyright (C) NHR@FAU, University Erlangen-Nuremberg.
     2→// All rights reserved. This file is part of cc-backend.
     3→// Use of this source code is governed by a MIT-style
     4→// license that can be found in the LICENSE file.
     5→
     6→package api
     7→
     8→import (
     9→	"fmt"
    10→	"maps"
    11→	"net/http"
    12→	"strings"
    13→	"time"
    14→
    15→	"github.com/ClusterCockpit/cc-backend/internal/metricdispatch"
    16→	"github.com/ClusterCockpit/cc-backend/internal/repository"
    17→	"github.com/ClusterCockpit/cc-backend/pkg/archive"
    18→	"github.com/ClusterCockpit/cc-backend/pkg/metricstore"
    19→	cclog "github.com/ClusterCockpit/cc-lib/v2/ccLogger"
    20→	"github.com/ClusterCockpit/cc-lib/v2/schema"
    21→)
    22→
    23→type UpdateNodeStatesRequest struct {
    24→	Nodes   []schema.NodePayload `json:"nodes"`
    25→	Cluster string               `json:"cluster" example:"fritz"`
    26→}
    27→
    28→// metricListToNames converts a map of metric configurations to a list of metric names
    29→func metricListToNames(metricList map[string]*schema.Metric) []string {
    30→	names := make([]string, 0, len(metricList))
    31→	for name := range metricList {
    32→		names = append(names, name)
    33→	}
    34→	return names
    35→}
    36→
    37→// this routine assumes that only one of them exists per node
    38→func determineState(states []string) schema.SchedulerState {
    39→	for _, state := range states {
    40→		switch strings.ToLower(state) {
    41→		case "allocated":
    42→			return schema.NodeStateAllocated
    43→		case "reserved":
    44→			return schema.NodeStateReserved
    45→		case "idle":
    46→			return schema.NodeStateIdle
    47→		case "down":
    48→			return schema.NodeStateDown
    49→		case "mixed":
    50→			return schema.NodeStateMixed
    51→		}
    52→	}
    53→
    54→	return schema.NodeStateUnknown
    55→}
    56→
    57→// updateNodeStates godoc
    58→// @summary     Deliver updated Slurm node states
    59→// @tags Nodestates
    60→// @description Returns a JSON-encoded list of users.
    61→// @description Required query-parameter defines if all users or only users with additional special roles are returned.
    62→// @produce     json
    63→// @param       request body UpdateNodeStatesRequest true "Request body containing nodes and their states"
    64→// @success     200     {object} api.DefaultAPIResponse "Success message"
    65→// @failure     400     {object} api.ErrorResponse      "Bad Request"
    66→// @failure     401     {object} api.ErrorResponse      "Unauthorized"
    67→// @failure     403     {object} api.ErrorResponse      "Forbidden"
    68→// @failure     500     {object} api.ErrorResponse      "Internal Server Error"
    69→// @security    ApiKeyAuth
    70→// @router      /api/nodestats/ [post]
    71→func (api *RestAPI) updateNodeStates(rw http.ResponseWriter, r *http.Request) {
    72→	// Parse request body
    73→	req := UpdateNodeStatesRequest{}
    74→	if err := decode(r.Body, &req); err != nil {
    75→		handleError(fmt.Errorf("parsing request body failed: %w", err),
    76→			http.StatusBadRequest, rw)
    77→		return
    78→	}
    79→	requestReceived := time.Now().Unix()
    80→	repo := repository.GetNodeRepository()
    81→
    82→	m := make(map[string][]string)
    83→	metricNames := make(map[string][]string)
    84→	healthResults := make(map[string]metricstore.HealthCheckResult)
    85→
    86→	startMs := time.Now()
    87→
    88→	// Step 1: Build nodeList and metricList per subcluster
    89→	for _, node := range req.Nodes {
    90→		if sc, err := archive.GetSubClusterByNode(req.Cluster, node.Hostname); err == nil {
    91→			m[sc] = append(m[sc], node.Hostname)
    92→		}
    93→	}
    94→
    95→	for sc := range m {
    96→		if sc != "" {
    97→			metricList := archive.GetMetricConfigSubCluster(req.Cluster, sc)
    98→			metricNames[sc] = metricListToNames(metricList)
    99→		}
   100→	}
   101→
   102→	// Step 2: Determine which metric store to query and perform health check
   103→	healthRepo, err := metricdispatch.GetHealthCheckRepo(req.Cluster)
   104→	if err != nil {
   105→		cclog.Warnf("updateNodeStates: no metric store for cluster %s, skipping health check: %v", req.Cluster, err)
   106→	} else {
   107→		for sc, nl := range m {
   108→			if sc != "" {
   109→				if results, err := healthRepo.HealthCheck(req.Cluster, nl, metricNames[sc]); err == nil {
   110→					maps.Copy(healthResults, results)
   111→				}
   112→			}
   113→		}
   114→	}
   115→
   116→	cclog.Debugf("Timer updateNodeStates, MemStore HealthCheck: %s", time.Since(startMs))
   117→	startDB := time.Now()
   118→
   119→	updates := make([]repository.NodeStateUpdate, 0, len(req.Nodes))
   120→	for _, node := range req.Nodes {
   121→		state := determineState(node.States)
   122→		healthState := schema.MonitoringStateFailed
   123→		var healthMetrics string
   124→		if result, ok := healthResults[node.Hostname]; ok {
   125→			healthState = result.State
   126→			healthMetrics = result.HealthMetrics
   127→		}
   128→		nodeState := schema.NodeStateDB{
   129→			TimeStamp:       requestReceived,
   130→			NodeState:       state,
   131→			CpusAllocated:   node.CpusAllocated,
   132→			MemoryAllocated: node.MemoryAllocated,
   133→			GpusAllocated:   node.GpusAllocated,
   134→			HealthState:     healthState,
   135→			HealthMetrics:   healthMetrics,
   136→			JobsRunning:     node.JobsRunning,
   137→		}
   138→		updates = append(updates, repository.NodeStateUpdate{
   139→			Hostname:  node.Hostname,
   140→			Cluster:   req.Cluster,
   141→			NodeState: &nodeState,
   142→		})
   143→	}
   144→
   145→	if err := repo.BatchUpdateNodeStates(updates); err != nil {
   146→		cclog.Errorf("updateNodeStates: batch update for cluster %s failed: %v", req.Cluster, err)
   147→	}
   148→
   149→	cclog.Debugf("Timer updateNodeStates, SQLite Inserts: %s", time.Since(startDB))
   150→}
   151→

// Copyright (C) NHR@FAU, University Erlangen-Nuremberg.
// All rights reserved. This file is part of cc-backend.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.

package api

import (
	"fmt"
	"maps"
	"net/http"
	"strings"
	"time"

	"github.com/ClusterCockpit/cc-backend/internal/metricdispatch"
	"github.com/ClusterCockpit/cc-backend/internal/repository"
	"github.com/ClusterCockpit/cc-backend/pkg/archive"
	"github.com/ClusterCockpit/cc-backend/pkg/metricstore"
	cclog "github.com/ClusterCockpit/cc-lib/v2/ccLogger"
	"github.com/ClusterCockpit/cc-lib/v2/schema"
)

type UpdateNodeStatesRequest struct {
	Nodes   []schema.NodePayload `json:"nodes"`
	Cluster string               `json:"cluster" example:"fritz"`
}

// metricListToNames converts a map of metric configurations to a list of metric names
func metricListToNames(metricList map[string]*schema.Metric) []string {
	names := make([]string, 0, len(metricList))
	for name := range metricList {
		names = append(names, name)
	}
	return names
}

// this routine assumes that only one of them exists per node
func determineState(states []string) schema.SchedulerState {
	for _, state := range states {
		switch strings.ToLower(state) {
		case "allocated":
			return schema.NodeStateAllocated
		case "reserved":
			return schema.NodeStateReserved
		case "idle":
			return schema.NodeStateIdle
		case "down":
			return schema.NodeStateDown
		case "mixed":
			return schema.NodeStateMixed
		}
	}

	return schema.NodeStateUnknown
}

// updateNodeStates godoc
// @summary     Deliver updated Slurm node states
// @tags Nodestates
// @description Returns a JSON-encoded list of users.
// @description Required query-parameter defines if all users or only users with additional special roles are returned.
// @produce     json
// @param       request body UpdateNodeStatesRequest true "Request body containing nodes and their states"
// @success     200     {object} api.DefaultAPIResponse "Success message"
// @failure     400     {object} api.ErrorResponse      "Bad Request"
// @failure     401     {object} api.ErrorResponse      "Unauthorized"
// @failure     403     {object} api.ErrorResponse      "Forbidden"
// @failure     500     {object} api.ErrorResponse      "Internal Server Error"
// @security    ApiKeyAuth
// @router      /api/nodestats/ [post]
func (api *RestAPI) updateNodeStates(rw http.ResponseWriter, r *http.Request) {
	// Parse request body
	req := UpdateNodeStatesRequest{}
	if err := decode(r.Body, &req); err != nil {
		handleError(fmt.Errorf("parsing request body failed: %w", err),
			http.StatusBadRequest, rw)
		return
	}
	requestReceived := time.Now().Unix()
	repo := repository.GetNodeRepository()

	m := make(map[string][]string)
	metricNames := make(map[string][]string)
	healthResults := make(map[string]metricstore.HealthCheckResult)

	startMs := time.Now()

	// Step 1: Build nodeList and metricList per subcluster
	for _, node := range req.Nodes {
		if sc, err := archive.GetSubClusterByNode(req.Cluster, node.Hostname); err == nil {
			m[sc] = append(m[sc], node.Hostname)
		}
	}

	for sc := range m {
		if sc != "" {
			metricList := archive.GetMetricConfigSubCluster(req.Cluster, sc)
			metricNames[sc] = metricListToNames(metricList)
		}
	}

	// Step 2: Determine which metric store to query and perform health check
	healthRepo, err := metricdispatch.GetHealthCheckRepo(req.Cluster)
	if err != nil {
		cclog.Warnf("updateNodeStates: no metric store for cluster %s, skipping health check: %v", req.Cluster, err)
	} else {
		for sc, nl := range m {
			if sc != "" {
				if results, err := healthRepo.HealthCheck(req.Cluster, nl, metricNames[sc]); err == nil {
					maps.Copy(healthResults, results)
				}
			}
		}
	}

	cclog.Debugf("Timer updateNodeStates, MemStore HealthCheck: %s", time.Since(startMs))
	startDB := time.Now()

	updates := make([]repository.NodeStateUpdate, 0, len(req.Nodes))
	for _, node := range req.Nodes {
		state := determineState(node.States)
		healthState := schema.MonitoringStateFailed
		var healthMetrics string
		if result, ok := healthResults[node.Hostname]; ok {
			healthState = result.State
			healthMetrics = result.HealthMetrics
		}
		nodeState := schema.NodeStateDB{
			TimeStamp:       requestReceived,
			NodeState:       state,
			CpusAllocated:   node.CpusAllocated,
			MemoryAllocated: node.MemoryAllocated,
			GpusAllocated:   node.GpusAllocated,
			HealthState:     healthState,
			HealthMetrics:   healthMetrics,
			JobsRunning:     node.JobsRunning,
		}
		updates = append(updates, repository.NodeStateUpdate{
			Hostname:  node.Hostname,
			Cluster:   req.Cluster,
			NodeState: &nodeState,
		})
	}

	if err := repo.BatchUpdateNodeStates(updates); err != nil {
		cclog.Errorf("updateNodeStates: batch update for cluster %s failed: %v", req.Cluster, err)
	}

	cclog.Debugf("Timer updateNodeStates, SQLite Inserts: %s", time.Since(startDB))
}


Let me check the NATS file imports to understand what's already available.


     1→// Copyright (C) NHR@FAU, University Erlangen-Nuremberg.
     2→// All rights reserved. This file is part of cc-backend.
     3→// Use of this source code is governed by a MIT-style
     4→// license that can be found in the LICENSE file.
     5→
     6→package api
     7→
     8→import (
     9→	"database/sql"
    10→	"encoding/json"
    11→	"strings"
    12→	"sync"
    13→	"time"
    14→
    15→	"github.com/ClusterCockpit/cc-backend/internal/archiver"
    16→	"github.com/ClusterCockpit/cc-backend/internal/config"
    17→	"github.com/ClusterCockpit/cc-backend/internal/importer"
    18→	"github.com/ClusterCockpit/cc-backend/internal/repository"
    19→	cclog "github.com/ClusterCockpit/cc-lib/v2/ccLogger"
    20→	lp "github.com/ClusterCockpit/cc-lib/v2/ccMessage"
    21→	"github.com/ClusterCockpit/cc-lib/v2/nats"
    22→	"github.com/ClusterCockpit/cc-lib/v2/receivers"
    23→	"github.com/ClusterCockpit/cc-lib/v2/schema"
    24→	influx "github.com/ClusterCockpit/cc-line-protocol/v2/lineprotocol"
    25→)
    26→
    27→// natsMessage wraps a raw NATS message with its subject for channel-based processing.
    28→type natsMessage struct {
    29→	subject string
    30→	data    []byte

// Copyright (C) NHR@FAU, University Erlangen-Nuremberg.
// All rights reserved. This file is part of cc-backend.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.

package api

import (
	"database/sql"
	"encoding/json"
	"strings"
	"sync"
	"time"

	"github.com/ClusterCockpit/cc-backend/internal/archiver"
	"github.com/ClusterCockpit/cc-backend/internal/config"
	"github.com/ClusterCockpit/cc-backend/internal/importer"
	"github.com/ClusterCockpit/cc-backend/internal/repository"
	cclog "github.com/ClusterCockpit/cc-lib/v2/ccLogger"
	lp "github.com/ClusterCockpit/cc-lib/v2/ccMessage"
	"github.com/ClusterCockpit/cc-lib/v2/nats"
	"github.com/ClusterCockpit/cc-lib/v2/receivers"
	"github.com/ClusterCockpit/cc-lib/v2/schema"
	influx "github.com/ClusterCockpit/cc-line-protocol/v2/lineprotocol"
)

// natsMessage wraps a raw NATS message with its subject for channel-based processing.
type natsMessage struct {
	subject string
	data    []byte


Now I have all the information needed. Let me write the plan.


Write

# Plan: Add Health Check to NATS Node State Handler

## Context

The NATS `processNodestateEvent` handler in `internal/api/nats.go` is missing the health check logic that the REST `updateNodeStates` handler in `internal/api/node.go` has. Currently, the NATS handler hardcodes `HealthState: schema.MonitoringStateFull` and leaves `HealthMetrics` empty, while the REST handler:

1. Groups nodes by subcluster
2. Fetches metric configurations per subcluster
3. Runs health checks against the metric store
4. Uses health check results per node (defaulting to `MonitoringStateFailed` if unavailable)

This means node state updates via NATS always report full health regardless of actual metric availability, producing inconsistent data compared to the REST path.

## Changes

### File: `internal/api/nats.go`

**1. Add missing imports:**
- `"github.com/ClusterCockpit/cc-backend/internal/metricdispatch"`
- `"github.com/ClusterCockpit/cc-backend/pkg/archive"`
- `"github.com/ClusterCockpit/cc-backend/pkg/metricstore"`

**2. Rewrite `processNodestateEvent` (lines 382-425)** to match the REST handler logic:

- After parsing the request, add the same health check flow from `updateNodeStates` (node.go lines 82-114):
  - Build `map[subcluster][]hostname` using `archive.GetSubClusterByNode()`
  - Build `metricNames` per subcluster using `archive.GetMetricConfigSubCluster()` + `metricListToNames()`
  - Get health check repo via `metricdispatch.GetHealthCheckRepo(req.Cluster)`
  - Run `healthRepo.HealthCheck()` per subcluster, merge results
- When building `NodeStateDB`, default `HealthState` to `MonitoringStateFailed` (not `MonitoringStateFull`) and use health check results when available — identical to REST handler (node.go lines 120-143)

### Reused existing functions (no new code needed):
- `determineState()` — already shared (`internal/api/node.go:38`)
- `metricListToNames()` — already shared (`internal/api/node.go:29`)
- `archive.GetSubClusterByNode()` — from `pkg/archive`
- `archive.GetMetricConfigSubCluster()` — from `pkg/archive`
- `metricdispatch.GetHealthCheckRepo()` — from `internal/metricdispatch`
- `repo.BatchUpdateNodeStates()` — already used

## Verification

1. `go build ./...` — ensure it compiles
2. `go test ./internal/api/...` — run existing NATS tests
3. Review that `TestNatsHandleNodeState` and `TestNatsHandleNodeStateEdgeCases` still pass (they don't assert on HealthState values, so should be fine)
4. Manual comparison: the node-building loop in NATS should now be identical to REST (node.go lines 119-143)


File created successfully at: /Users/jan/.claude/plans/magical-sauteeing-gosling.md

# Plan: Add Health Check to NATS Node State Handler

## Context

The NATS `processNodestateEvent` handler in `internal/api/nats.go` is missing the health check logic that the REST `updateNodeStates` handler in `internal/api/node.go` has. Currently, the NATS handler hardcodes `HealthState: schema.MonitoringStateFull` and leaves `HealthMetrics` empty, while the REST handler:

1. Groups nodes by subcluster
2. Fetches metric configurations per subcluster
3. Runs health checks against the metric store
4. Uses health check results per node (defaulting to `MonitoringStateFailed` if unavailable)

This means node state updates via NATS always report full health regardless of actual metric availability, producing inconsistent data compared to the REST path.

## Changes

### File: `internal/api/nats.go`

**1. Add missing imports:**
- `"github.com/ClusterCockpit/cc-backend/internal/metricdispatch"`
- `"github.com/ClusterCockpit/cc-backend/pkg/archive"`
- `"github.com/ClusterCockpit/cc-backend/pkg/metricstore"`

**2. Rewrite `processNodestateEvent` (lines 382-425)** to match the REST handler logic:

- After parsing the request, add the same health check flow from `updateNodeStates` (node.go lines 82-114):
  - Build `map[subcluster][]hostname` using `archive.GetSubClusterByNode()`
  - Build `metricNames` per subcluster using `archive.GetMetricConfigSubCluster()` + `metricListToNames()`
  - Get health check repo via `metricdispatch.GetHealthCheckRepo(req.Cluster)`
  - Run `healthRepo.HealthCheck()` per subcluster, merge results
- When building `NodeStateDB`, default `HealthState` to `MonitoringStateFailed` (not `MonitoringStateFull`) and use health check results when available — identical to REST handler (node.go lines 120-143)

### Reused existing functions (no new code needed):
- `determineState()` — already shared (`internal/api/node.go:38`)
- `metricListToNames()` — already shared (`internal/api/node.go:29`)
- `archive.GetSubClusterByNode()` — from `pkg/archive`
- `archive.GetMetricConfigSubCluster()` — from `pkg/archive`
- `metricdispatch.GetHealthCheckRepo()` — from `internal/metricdispatch`
- `repo.BatchUpdateNodeStates()` — already used

## Verification

1. `go build ./...` — ensure it compiles
2. `go test ./internal/api/...` — run existing NATS tests
3. Review that `TestNatsHandleNodeState` and `TestNatsHandleNodeStateEdgeCases` still pass (they don't assert on HealthState values, so should be fine)
4. Manual comparison: the node-building loop in NATS should now be identical to REST (node.go lines 119-143)


ToolSearch

Question: select:ExitPlanMode


Question: select:ExitPlanMode


Let me re-read the existing plan and the current state of the files to see if anything has changed.


     1→# Plan: Add Health Check to NATS Node State Handler
     2→
     3→## Context
     4→
     5→The NATS `processNodestateEvent` handler in `internal/api/nats.go` is missing the health check logic that the REST `updateNodeStates` handler in `internal/api/node.go` has. Currently, the NATS handler hardcodes `HealthState: schema.MonitoringStateFull` and leaves `HealthMetrics` empty, while the REST handler:
     6→
     7→1. Groups nodes by subcluster
     8→2. Fetches metric configurations per subcluster
     9→3. Runs health checks against the metric store
    10→4. Uses health check results per node (defaulting to `MonitoringStateFailed` if unavailable)
    11→
    12→This means node state updates via NATS always report full health regardless of actual metric availability, producing inconsistent data compared to the REST path.
    13→
    14→## Changes
    15→
    16→### File: `internal/api/nats.go`
    17→
    18→**1. Add missing imports:**
    19→- `"github.com/ClusterCockpit/cc-backend/internal/metricdispatch"`
    20→- `"github.com/ClusterCockpit/cc-backend/pkg/archive"`
    21→- `"github.com/ClusterCockpit/cc-backend/pkg/metricstore"`
    22→
    23→**2. Rewrite `processNodestateEvent` (lines 382-425)** to match the REST handler logic:
    24→
    25→- After parsing the request, add the same health check flow from `updateNodeStates` (node.go lines 82-114):
    26→  - Build `map[subcluster][]hostname` using `archive.GetSubClusterByNode()`
    27→  - Build `metricNames` per subcluster using `archive.GetMetricConfigSubCluster()` + `metricListToNames()`
    28→  - Get health check repo via `metricdispatch.GetHealthCheckRepo(req.Cluster)`
    29→  - Run `healthRepo.HealthCheck()` per subcluster, merge results
    30→- When building `NodeStateDB`, default `HealthState` to `MonitoringStateFailed` (not `MonitoringStateFull`) and use health check results when available — identical to REST handler (node.go lines 120-143)
    31→
    32→### Reused existing functions (no new code needed):
    33→- `determineState()` — already shared (`internal/api/node.go:38`)
    34→- `metricListToNames()` — already shared (`internal/api/node.go:29`)
    35→- `archive.GetSubClusterByNode()` — from `pkg/archive`
    36→- `archive.GetMetricConfigSubCluster()` — from `pkg/archive`
    37→- `metricdispatch.GetHealthCheckRepo()` — from `internal/metricdispatch`
    38→- `repo.BatchUpdateNodeStates()` — already used
    39→
    40→## Verification
    41→
    42→1. `go build ./...` — ensure it compiles
    43→2. `go test ./internal/api/...` — run existing NATS tests
    44→3. Review that `TestNatsHandleNodeState` and `TestNatsHandleNodeStateEdgeCases` still pass (they don't assert on HealthState values, so should be fine)
    45→4. Manual comparison: the node-building loop in NATS should now be identical to REST (node.go lines 119-143)
    46→


The existing plan is still the same task and the code hasn't changed. The plan looks correct and complete. Let me also add the `"maps"` import which is needed for `maps.Copy`, then finalize.


Edit

**1. Add missing imports:**
- `"github.com/ClusterCockpit/cc-backend/internal/metricdispatch"`
- `"github.com/ClusterCockpit/cc-backend/pkg/archive"`
- `"github.com/ClusterCockpit/cc-backend/pkg/metricstore"`

**1. Add missing imports:**
- `"maps"`
- `"github.com/ClusterCockpit/cc-backend/internal/metricdispatch"`
- `"github.com/ClusterCockpit/cc-backend/pkg/archive"`
- `"github.com/ClusterCockpit/cc-backend/pkg/metricstore"`


The file /Users/jan/.claude/plans/magical-sauteeing-gosling.md has been updated successfully.

**1. Add missing imports:**
- `"maps"`
- `"github.com/ClusterCockpit/cc-backend/internal/metricdispatch"`
- `"github.com/ClusterCockpit/cc-backend/pkg/archive"`
- `"github.com/ClusterCockpit/cc-backend/pkg/metricstore"`

# Plan: Add Health Check to NATS Node State Handler

## Context

The NATS `processNodestateEvent` handler in `internal/api/nats.go` is missing the health check logic that the REST `updateNodeStates` handler in `internal/api/node.go` has. Currently, the NATS handler hardcodes `HealthState: schema.MonitoringStateFull` and leaves `HealthMetrics` empty, while the REST handler:

1. Groups nodes by subcluster
2. Fetches metric configurations per subcluster
3. Runs health checks against the metric store
4. Uses health check results per node (defaulting to `MonitoringStateFailed` if unavailable)

This means node state updates via NATS always report full health regardless of actual metric availability, producing inconsistent data compared to the REST path.

## Changes

### File: `internal/api/nats.go`

**1. Add missing imports:**
- `"github.com/ClusterCockpit/cc-backend/internal/metricdispatch"`
- `"github.com/ClusterCockpit/cc-backend/pkg/archive"`
- `"github.com/ClusterCockpit/cc-backend/pkg/metricstore"`

**2. Rewrite `processNodestateEvent` (lines 382-425)** to match the REST handler logic:

- After parsing the request, add the same health check flow from `updateNodeStates` (node.go lines 82-114):
  - Build `map[subcluster][]hostname` using `archive.GetSubClusterByNode()`
  - Build `metricNames` per subcluster using `archive.GetMetricConfigSubCluster()` + `metricListToNames()`
  - Get health check repo via `metricdispatch.GetHealthCheckRepo(req.Cluster)`
  - Run `healthRepo.HealthCheck()` per subcluster, merge results
- When building `NodeStateDB`, default `HealthState` to `MonitoringStateFailed` (not `MonitoringStateFull`) and use health check results when available — identical to REST handler (node.go lines 120-143)

### Reused existing functions (no new code needed):
- `determineState()` — already shared (`internal/api/node.go:38`)
- `metricListToNames()` — already shared (`internal/api/node.go:29`)
- `archive.GetSubClusterByNode()` — from `pkg/archive`
- `archive.GetMetricConfigSubCluster()` — from `pkg/archive`
- `metricdispatch.GetHealthCheckRepo()` — from `internal/metricdispatch`
- `repo.BatchUpdateNodeStates()` — already used

## Verification

1. `go build ./...` — ensure it compiles
2. `go test ./internal/api/...` — run existing NATS tests
3. Review that `TestNatsHandleNodeState` and `TestNatsHandleNodeStateEdgeCases` still pass (they don't assert on HealthState values, so should be fine)
4. Manual comparison: the node-building loop in NATS should now be identical to REST (node.go lines 119-143)


ExitPlanMode

# Plan: Add Health Check to NATS Node State Handler

## Context

The NATS `processNodestateEvent` handler in `internal/api/nats.go` is missing the health check logic that the REST `updateNodeStates` handler in `internal/api/node.go` has. Currently, the NATS handler hardcodes `HealthState: schema.MonitoringStateFull` and leaves `HealthMetrics` empty, while the REST handler:

1. Groups nodes by subcluster
2. Fetches metric configurations per subcluster
3. Runs health checks against the metric store
4. Uses health check results per node (defaulting to `MonitoringStateFailed` if unavailable)

This means node state updates via NATS always report full health regardless of actual metric availability, producing inconsistent data compared to the REST path.

## Changes

### File: `internal/api/nats.go`

**1. Add missing imports:**
- `"maps"`
- `"github.com/ClusterCockpit/cc-backend/internal/metricdispatch"`
- `"github.com/ClusterCockpit/cc-backend/pkg/archive"`
- `"github.com/ClusterCockpit/cc-backend/pkg/metricstore"`

**2. Rewrite `processNodestateEvent` (lines 382-425)** to match the REST handler logic:

- After parsing the request, add the same health check flow from `updateNodeStates` (node.go lines 82-114):
  - Build `map[subcluster][]hostname` using `archive.GetSubClusterByNode()`
  - Build `metricNames` per subcluster using `archive.GetMetricConfigSubCluster()` + `metricListToNames()`
  - Get health check repo via `metricdispatch.GetHealthCheckRepo(req.Cluster)`
  - Run `healthRepo.HealthCheck()` per subcluster, merge results
- When building `NodeStateDB`, default `HealthState` to `MonitoringStateFailed` (not `MonitoringStateFull`) and use health check results when available — identical to REST handler (node.go lines 120-143)

### Reused existing functions (no new code needed):
- `determineState()` — already shared (`internal/api/node.go:38`)
- `metricListToNames()` — already shared (`internal/api/node.go:29`)
- `archive.GetSubClusterByNode()` — from `pkg/archive`
- `archive.GetMetricConfigSubCluster()` — from `pkg/archive`
- `metricdispatch.GetHealthCheckRepo()` — from `internal/metricdispatch`
- `repo.BatchUpdateNodeStates()` — already used

## Verification

1. `go build ./...` — ensure it compiles
2. `go test ./internal/api/...` — run existing NATS tests
3. Review that `TestNatsHandleNodeState` and `TestNatsHandleNodeStateEdgeCases` still pass (they don't assert on HealthState values, so should be fine)
4. Manual comparison: the node-building loop in NATS should now be identical to REST (node.go lines 119-143)


The user doesn't want to proceed with this tool use. The tool use was rejected (eg. if it was a file edit, the new_string was NOT written to the file). STOP what you are doing and wait for the user to tell you how to proceed.


[Request interrupted by user for tool use]