Add per-node health checks with status indicators and scheduled probes.

This commit is contained in:
2026-07-19 22:40:21 +02:00
parent 5307ba1a7b
commit aac9e82766
20 changed files with 1674 additions and 65 deletions
+3
View File
@@ -57,6 +57,9 @@ func main() {
if app.Executor != nil { if app.Executor != nil {
app.Executor.StopScheduler() app.Executor.StopScheduler()
} }
if app.HealthChecker != nil {
app.HealthChecker.StopScheduler()
}
shutdownContext, cancel := context.WithTimeout(context.Background(), 10*time.Second) shutdownContext, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel() defer cancel()
+17
View File
@@ -10,6 +10,7 @@ import (
var errUsersManageRequired = errors.New("permission users.manage required") var errUsersManageRequired = errors.New("permission users.manage required")
var errNodesReadRequired = errors.New("permission nodes.read required") var errNodesReadRequired = errors.New("permission nodes.read required")
var errNodesExecRequired = errors.New("permission nodes.exec required") var errNodesExecRequired = errors.New("permission nodes.exec required")
var errNodesUpdateRequired = errors.New("permission nodes.update required")
var errNodesDeleteRequired = errors.New("permission nodes.delete required") var errNodesDeleteRequired = errors.New("permission nodes.delete required")
var errActionsCreateRequired = errors.New("permission actions.create required") var errActionsCreateRequired = errors.New("permission actions.create required")
var errActionsUpdateRequired = errors.New("permission actions.update required") var errActionsUpdateRequired = errors.New("permission actions.update required")
@@ -135,6 +136,22 @@ func (app *App) authorizeNodesExec(request *http.Request) error {
return nil return nil
} }
func (app *App) authorizeNodesUpdate(request *http.Request) error {
user, ok := UserFromContext(request.Context())
if !ok {
return errors.New("authentication required")
}
payload, err := loadSettingsOrDefault(app.ConfigDir)
if err != nil {
return err
}
if !userHasPermission(user, payload.Groups, "nodes.update") {
return errNodesUpdateRequired
}
return nil
}
func (app *App) authorizeNodesDelete(request *http.Request) error { func (app *App) authorizeNodesDelete(request *http.Request) error {
user, ok := UserFromContext(request.Context()) user, ok := UserFromContext(request.Context())
if !ok { if !ok {
+159
View File
@@ -3,6 +3,7 @@ package api
import ( import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt"
"net" "net"
"net/http" "net/http"
"strings" "strings"
@@ -231,6 +232,164 @@ func (app *App) nodesTestSSHHandler(writer http.ResponseWriter, request *http.Re
}) })
} }
type patchNodeRequest struct {
HealthCheckIntervalSeconds *int `json:"health_check_interval_seconds"`
}
func (app *App) nodesPatchHandler(writer http.ResponseWriter, request *http.Request) {
if err := app.authorizeNodesUpdate(request); err != nil {
writeJSON(writer, http.StatusForbidden, apiErrorResponse{Error: err.Error()})
return
}
user, ok := UserFromContext(request.Context())
if !ok {
writeJSON(writer, http.StatusUnauthorized, apiErrorResponse{Error: "authentication required"})
return
}
nodeID := strings.TrimSpace(request.PathValue("id"))
if nodeID == "" {
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "missing node id"})
return
}
var payload patchNodeRequest
if err := json.NewDecoder(request.Body).Decode(&payload); err != nil {
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "invalid JSON body"})
return
}
if payload.HealthCheckIntervalSeconds == nil {
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{
Error: "health_check_interval_seconds is required",
})
return
}
if *payload.HealthCheckIntervalSeconds < 0 {
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{
Error: "health_check_interval_seconds must be >= 0",
})
return
}
settingsPayload, err := loadSettingsOrDefault(app.ConfigDir)
if err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
store, err := settings.LoadNodesOrEmpty(app.ConfigDir)
if err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
nodeIndex := -1
var node settings.Node
for index, candidate := range store.Nodes {
if candidate.ID != nodeID {
continue
}
node = candidate
nodeIndex = index
break
}
if nodeIndex < 0 {
writeJSON(writer, http.StatusNotFound, apiErrorResponse{Error: "node not found"})
return
}
if !userCanAccessNode(user, settingsPayload.Groups, node) {
writeJSON(writer, http.StatusForbidden, apiErrorResponse{Error: "permission denied"})
return
}
node.HealthCheckIntervalSeconds = *payload.HealthCheckIntervalSeconds
store.Nodes[nodeIndex] = node
if err := settings.SaveNodes(app.ConfigDir, store); err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
_ = appendNodeAudit(
app.ConfigDir,
settings.NodeAuditActionUpdate,
user.Username,
node,
fmt.Sprintf("health_check_interval_seconds=%d", node.HealthCheckIntervalSeconds),
)
writeJSON(writer, http.StatusOK, nodeResponse{Node: node})
}
func (app *App) nodesHealthCheckHandler(writer http.ResponseWriter, request *http.Request) {
if err := app.authorizeNodesExec(request); err != nil {
writeJSON(writer, http.StatusForbidden, apiErrorResponse{Error: err.Error()})
return
}
if err := app.requireKey(); err != nil {
writeJSON(writer, http.StatusServiceUnavailable, apiErrorResponse{Error: err.Error()})
return
}
if app.HealthChecker == nil {
writeJSON(writer, http.StatusServiceUnavailable, apiErrorResponse{Error: "health checker unavailable"})
return
}
user, ok := UserFromContext(request.Context())
if !ok {
writeJSON(writer, http.StatusUnauthorized, apiErrorResponse{Error: "authentication required"})
return
}
nodeID := strings.TrimSpace(request.PathValue("id"))
if nodeID == "" {
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "missing node id"})
return
}
settingsPayload, err := loadSettingsOrDefault(app.ConfigDir)
if err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
store, err := settings.LoadNodesOrEmpty(app.ConfigDir)
if err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
var node settings.Node
found := false
for _, candidate := range store.Nodes {
if candidate.ID != nodeID {
continue
}
node = candidate
found = true
break
}
if !found {
writeJSON(writer, http.StatusNotFound, apiErrorResponse{Error: "node not found"})
return
}
if !userCanAccessNode(user, settingsPayload.Groups, node) {
writeJSON(writer, http.StatusForbidden, apiErrorResponse{Error: "permission denied"})
return
}
updated, err := app.HealthChecker.CheckNodeByID(nodeID, user.Username)
if err != nil {
if strings.Contains(err.Error(), "already running") {
writeJSON(writer, http.StatusConflict, apiErrorResponse{Error: err.Error()})
return
}
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
writeJSON(writer, http.StatusOK, nodeResponse{Node: updated})
}
func (app *App) nodesCreateHandler(writer http.ResponseWriter, request *http.Request) { func (app *App) nodesCreateHandler(writer http.ResponseWriter, request *http.Request) {
if err := app.authorizeNodesExec(request); err != nil { if err := app.authorizeNodesExec(request); err != nil {
writeJSON(writer, http.StatusForbidden, apiErrorResponse{Error: err.Error()}) writeJSON(writer, http.StatusForbidden, apiErrorResponse{Error: err.Error()})
@@ -0,0 +1,211 @@
package api
import (
"bytes"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"testing"
"codeberg.org/SquidSE/ClusterCanvas/service/internal/auth"
)
func TestNodesHealthCheckPersistsStatus(t *testing.T) {
configDir := t.TempDir()
cookie := seedCompletedSetup(t, configDir)
router, app := NewRouterWithApp(configDir)
app.HealthChecker.PingFn = func(hostIP string) auth.PingResult {
return auth.PingResult{OK: true, Method: auth.PingMethodICMP}
}
app.HealthChecker.SSHFn = func(hostIP string, username string, privateKeyPEM string, passphrase string) error {
return nil
}
createBody := []byte(`{
"id":"bbbbbbbb-bbbb-4ccc-8ddd-eeeeeeeeeeee",
"kind":"container",
"name":"healthy-node",
"host_ip":"10.20.30.40",
"group_name":"Administrators",
"generate":{"algorithm":"ed25519"}
}`)
createRequest := withSession(
httptest.NewRequest(http.MethodPost, "/api/v1/nodes", bytes.NewReader(createBody)),
cookie,
)
createRecorder := httptest.NewRecorder()
router.ServeHTTP(createRecorder, createRequest)
if createRecorder.Code != http.StatusCreated {
t.Fatalf("create status = %d body=%s", createRecorder.Code, createRecorder.Body.String())
}
healthRequest := withSession(
httptest.NewRequest(
http.MethodPost,
"/api/v1/nodes/bbbbbbbb-bbbb-4ccc-8ddd-eeeeeeeeeeee/health-check",
nil,
),
cookie,
)
healthRecorder := httptest.NewRecorder()
router.ServeHTTP(healthRecorder, healthRequest)
if healthRecorder.Code != http.StatusOK {
t.Fatalf("health status = %d body=%s", healthRecorder.Code, healthRecorder.Body.String())
}
var response nodeResponse
if err := json.Unmarshal(healthRecorder.Body.Bytes(), &response); err != nil {
t.Fatalf("decode: %v", err)
}
if response.Node.HealthOK == nil || !*response.Node.HealthOK {
t.Fatalf("health_ok = %#v", response.Node.HealthOK)
}
if response.Node.HealthMessage == "" {
t.Fatal("expected health message")
}
}
func TestNodesHealthCheckRecordsFailure(t *testing.T) {
configDir := t.TempDir()
cookie := seedCompletedSetup(t, configDir)
router, app := NewRouterWithApp(configDir)
app.HealthChecker.PingFn = func(hostIP string) auth.PingResult {
return auth.PingResult{
OK: false,
Method: auth.PingMethodTCP,
Error: errors.New("unreachable"),
}
}
app.HealthChecker.SSHFn = func(hostIP string, username string, privateKeyPEM string, passphrase string) error {
return errors.New("ssh failed")
}
createBody := []byte(`{
"id":"cccccccc-bbbb-4ccc-8ddd-eeeeeeeeeeee",
"kind":"vm",
"name":"unhealthy-node",
"host_ip":"10.20.30.41",
"group_name":"Administrators",
"generate":{"algorithm":"ed25519"}
}`)
createRequest := withSession(
httptest.NewRequest(http.MethodPost, "/api/v1/nodes", bytes.NewReader(createBody)),
cookie,
)
createRecorder := httptest.NewRecorder()
router.ServeHTTP(createRecorder, createRequest)
if createRecorder.Code != http.StatusCreated {
t.Fatalf("create status = %d body=%s", createRecorder.Code, createRecorder.Body.String())
}
healthRequest := withSession(
httptest.NewRequest(
http.MethodPost,
"/api/v1/nodes/cccccccc-bbbb-4ccc-8ddd-eeeeeeeeeeee/health-check",
nil,
),
cookie,
)
healthRecorder := httptest.NewRecorder()
router.ServeHTTP(healthRecorder, healthRequest)
if healthRecorder.Code != http.StatusOK {
t.Fatalf("health status = %d body=%s", healthRecorder.Code, healthRecorder.Body.String())
}
var response nodeResponse
if err := json.Unmarshal(healthRecorder.Body.Bytes(), &response); err != nil {
t.Fatalf("decode: %v", err)
}
if response.Node.HealthOK == nil || *response.Node.HealthOK {
t.Fatalf("expected failure, got %#v", response.Node.HealthOK)
}
}
func TestNodesPatchHealthInterval(t *testing.T) {
configDir := t.TempDir()
cookie := seedCompletedSetup(t, configDir)
router := NewRouter(configDir)
createBody := []byte(`{
"id":"dddddddd-bbbb-4ccc-8ddd-eeeeeeeeeeee",
"kind":"docker",
"name":"interval-node",
"host_ip":"10.20.30.42",
"group_name":"Administrators",
"generate":{"algorithm":"ed25519"}
}`)
createRequest := withSession(
httptest.NewRequest(http.MethodPost, "/api/v1/nodes", bytes.NewReader(createBody)),
cookie,
)
createRecorder := httptest.NewRecorder()
router.ServeHTTP(createRecorder, createRequest)
if createRecorder.Code != http.StatusCreated {
t.Fatalf("create status = %d body=%s", createRecorder.Code, createRecorder.Body.String())
}
patchBody := []byte(`{"health_check_interval_seconds":120}`)
patchRequest := withSession(
httptest.NewRequest(
http.MethodPatch,
"/api/v1/nodes/dddddddd-bbbb-4ccc-8ddd-eeeeeeeeeeee",
bytes.NewReader(patchBody),
),
cookie,
)
patchRecorder := httptest.NewRecorder()
router.ServeHTTP(patchRecorder, patchRequest)
if patchRecorder.Code != http.StatusOK {
t.Fatalf("patch status = %d body=%s", patchRecorder.Code, patchRecorder.Body.String())
}
var response nodeResponse
if err := json.Unmarshal(patchRecorder.Body.Bytes(), &response); err != nil {
t.Fatalf("decode: %v", err)
}
if response.Node.HealthCheckIntervalSeconds != 120 {
t.Fatalf("interval = %d", response.Node.HealthCheckIntervalSeconds)
}
}
func TestNodesPatchHealthIntervalRejectsNegative(t *testing.T) {
configDir := t.TempDir()
cookie := seedCompletedSetup(t, configDir)
router := NewRouter(configDir)
createBody := []byte(`{
"id":"eeeeeeee-bbbb-4ccc-8ddd-eeeeeeeeeeee",
"kind":"container",
"name":"bad-interval",
"host_ip":"10.20.30.43",
"group_name":"Administrators",
"generate":{"algorithm":"ed25519"}
}`)
createRequest := withSession(
httptest.NewRequest(http.MethodPost, "/api/v1/nodes", bytes.NewReader(createBody)),
cookie,
)
createRecorder := httptest.NewRecorder()
router.ServeHTTP(createRecorder, createRequest)
if createRecorder.Code != http.StatusCreated {
t.Fatalf("create status = %d body=%s", createRecorder.Code, createRecorder.Body.String())
}
patchBody := []byte(`{"health_check_interval_seconds":-5}`)
patchRequest := withSession(
httptest.NewRequest(
http.MethodPatch,
"/api/v1/nodes/eeeeeeee-bbbb-4ccc-8ddd-eeeeeeeeeeee",
bytes.NewReader(patchBody),
),
cookie,
)
patchRecorder := httptest.NewRecorder()
router.ServeHTTP(patchRecorder, patchRequest)
if patchRecorder.Code != http.StatusBadRequest {
t.Fatalf("status = %d body=%s", patchRecorder.Code, patchRecorder.Body.String())
}
}
+5
View File
@@ -22,6 +22,9 @@ func NewRouterWithApp(configDir string) (http.Handler, *App) {
if app.Executor != nil { if app.Executor != nil {
app.Executor.StartScheduler() app.Executor.StartScheduler()
} }
if app.HealthChecker != nil {
app.HealthChecker.StartScheduler()
}
mux := http.NewServeMux() mux := http.NewServeMux()
mux.HandleFunc("GET /health", HealthHandler) mux.HandleFunc("GET /health", HealthHandler)
@@ -62,8 +65,10 @@ func NewRouterWithApp(configDir string) (http.Handler, *App) {
mux.HandleFunc("GET /api/v1/nodes", app.nodesListHandler) mux.HandleFunc("GET /api/v1/nodes", app.nodesListHandler)
mux.HandleFunc("POST /api/v1/nodes", app.nodesCreateHandler) mux.HandleFunc("POST /api/v1/nodes", app.nodesCreateHandler)
mux.HandleFunc("GET /api/v1/nodes/{id}", app.nodesGetHandler) mux.HandleFunc("GET /api/v1/nodes/{id}", app.nodesGetHandler)
mux.HandleFunc("PATCH /api/v1/nodes/{id}", app.nodesPatchHandler)
mux.HandleFunc("DELETE /api/v1/nodes/{id}", app.nodesDeleteHandler) mux.HandleFunc("DELETE /api/v1/nodes/{id}", app.nodesDeleteHandler)
mux.HandleFunc("POST /api/v1/nodes/{id}/test-ssh", app.nodesTestSSHHandler) mux.HandleFunc("POST /api/v1/nodes/{id}/test-ssh", app.nodesTestSSHHandler)
mux.HandleFunc("POST /api/v1/nodes/{id}/health-check", app.nodesHealthCheckHandler)
mux.HandleFunc("POST /api/v1/nodes/{id}/resolve-commands", app.nodesResolveCommandsHandler) mux.HandleFunc("POST /api/v1/nodes/{id}/resolve-commands", app.nodesResolveCommandsHandler)
mux.HandleFunc("GET /api/v1/nodes/{id}/action-groups", app.actionGroupsListHandler) mux.HandleFunc("GET /api/v1/nodes/{id}/action-groups", app.actionGroupsListHandler)
mux.HandleFunc("POST /api/v1/nodes/{id}/action-groups", app.actionGroupsCreateHandler) mux.HandleFunc("POST /api/v1/nodes/{id}/action-groups", app.actionGroupsCreateHandler)
+16 -12
View File
@@ -11,6 +11,7 @@ import (
"time" "time"
"codeberg.org/SquidSE/ClusterCanvas/service/internal/auth" "codeberg.org/SquidSE/ClusterCanvas/service/internal/auth"
"codeberg.org/SquidSE/ClusterCanvas/service/internal/health"
"codeberg.org/SquidSE/ClusterCanvas/service/internal/runner" "codeberg.org/SquidSE/ClusterCanvas/service/internal/runner"
"codeberg.org/SquidSE/ClusterCanvas/service/internal/settings" "codeberg.org/SquidSE/ClusterCanvas/service/internal/settings"
) )
@@ -77,10 +78,11 @@ type meResponse struct {
// App holds shared API dependencies. // App holds shared API dependencies.
type App struct { type App struct {
ConfigDir string ConfigDir string
Key []byte Key []byte
Sessions *auth.SessionManager Sessions *auth.SessionManager
Executor *runner.Executor Executor *runner.Executor
HealthChecker *health.Checker
pendingTOTPMu sync.Mutex pendingTOTPMu sync.Mutex
pendingTOTP map[string]string // username -> secret (setup only) pendingTOTP map[string]string // username -> secret (setup only)
@@ -91,18 +93,20 @@ func newApp(configDir string) (*App, error) {
if err != nil { if err != nil {
// Key is required for setup complete / auth; status still works without it. // Key is required for setup complete / auth; status still works without it.
app := &App{ app := &App{
ConfigDir: configDir, ConfigDir: configDir,
pendingTOTP: map[string]string{}, pendingTOTP: map[string]string{},
Executor: runner.NewExecutor(configDir, nil), Executor: runner.NewExecutor(configDir, nil),
HealthChecker: health.NewChecker(configDir, nil),
} }
return app, nil return app, nil
} }
app := &App{ app := &App{
ConfigDir: configDir, ConfigDir: configDir,
Key: key, Key: key,
Sessions: auth.NewSessionManager(configDir, key), Sessions: auth.NewSessionManager(configDir, key),
pendingTOTP: map[string]string{}, pendingTOTP: map[string]string{},
Executor: runner.NewExecutor(configDir, key), Executor: runner.NewExecutor(configDir, key),
HealthChecker: health.NewChecker(configDir, key),
} }
return app, nil return app, nil
} }
+89
View File
@@ -0,0 +1,89 @@
package auth
import (
"fmt"
"net"
"os/exec"
"strings"
"time"
)
const (
PingMethodICMP = "icmp"
PingMethodTCP = "tcp"
defaultPingCount = "1"
defaultPingWaitSec = "2"
defaultTCPTimeout = 3 * time.Second
)
// PingResult is the outcome of a reachability probe.
type PingResult struct {
OK bool
Method string
Error error
}
type pingCommandFunc func(hostIP string) error
type tcpDialFunc func(address string, timeout time.Duration) error
var (
runICMPPing = defaultRunICMPPing
dialTCP = defaultDialTCP
)
// PingHost probes host reachability: ICMP first, then TCP :22 when ICMP is
// unavailable or the host does not respond to ICMP.
func PingHost(hostIP string) PingResult {
hostIP = strings.TrimSpace(hostIP)
if hostIP == "" {
return PingResult{OK: false, Error: fmt.Errorf("host IP is required")}
}
icmpErr := runICMPPing(hostIP)
if icmpErr == nil {
return PingResult{OK: true, Method: PingMethodICMP}
}
address := net.JoinHostPort(hostIP, defaultSSHPort)
tcpErr := dialTCP(address, defaultTCPTimeout)
if tcpErr == nil {
return PingResult{OK: true, Method: PingMethodTCP}
}
return PingResult{
OK: false,
Method: PingMethodTCP,
Error: fmt.Errorf(
"icmp: %v; tcp %s: %w",
icmpErr,
address,
tcpErr,
),
}
}
func defaultRunICMPPing(hostIP string) error {
pingPath, err := exec.LookPath("ping")
if err != nil {
return fmt.Errorf("ping unavailable: %w", err)
}
cmd := exec.Command(pingPath, "-c", defaultPingCount, "-W", defaultPingWaitSec, hostIP)
output, runErr := cmd.CombinedOutput()
if runErr == nil {
return nil
}
combined := strings.TrimSpace(string(output) + " " + runErr.Error())
return fmt.Errorf("%s", strings.TrimSpace(combined))
}
func defaultDialTCP(address string, timeout time.Duration) error {
conn, err := net.DialTimeout("tcp", address, timeout)
if err != nil {
return err
}
_ = conn.Close()
return nil
}
+96
View File
@@ -0,0 +1,96 @@
package auth
import (
"errors"
"testing"
"time"
)
func TestPingHostUsesICMPWhenAvailable(t *testing.T) {
originalPing := runICMPPing
originalDial := dialTCP
t.Cleanup(func() {
runICMPPing = originalPing
dialTCP = originalDial
})
runICMPPing = func(hostIP string) error {
if hostIP != "10.0.0.1" {
t.Fatalf("host = %q", hostIP)
}
return nil
}
dialTCP = func(address string, timeout time.Duration) error {
t.Fatal("tcp should not be used when icmp succeeds")
return nil
}
result := PingHost("10.0.0.1")
if !result.OK {
t.Fatalf("expected ok, got %#v", result)
}
if result.Method != PingMethodICMP {
t.Fatalf("method = %q", result.Method)
}
}
func TestPingHostFallsBackToTCPWhenICMPFails(t *testing.T) {
originalPing := runICMPPing
originalDial := dialTCP
t.Cleanup(func() {
runICMPPing = originalPing
dialTCP = originalDial
})
runICMPPing = func(hostIP string) error {
return errors.New("ping unavailable: executable file not found")
}
dialTCP = func(address string, timeout time.Duration) error {
if address != "10.0.0.2:22" {
t.Fatalf("address = %q", address)
}
return nil
}
result := PingHost("10.0.0.2")
if !result.OK {
t.Fatalf("expected ok, got %#v", result)
}
if result.Method != PingMethodTCP {
t.Fatalf("method = %q", result.Method)
}
}
func TestPingHostFailsWhenBothFail(t *testing.T) {
originalPing := runICMPPing
originalDial := dialTCP
t.Cleanup(func() {
runICMPPing = originalPing
dialTCP = originalDial
})
runICMPPing = func(hostIP string) error {
return errors.New("100% packet loss")
}
dialTCP = func(address string, timeout time.Duration) error {
return errors.New("connection refused")
}
result := PingHost("10.0.0.3")
if result.OK {
t.Fatal("expected failure")
}
if result.Error == nil {
t.Fatal("expected error")
}
if result.Method != PingMethodTCP {
t.Fatalf("method = %q", result.Method)
}
}
func TestPingHostRequiresHostIP(t *testing.T) {
result := PingHost(" ")
if result.OK || result.Error == nil {
t.Fatalf("expected failure, got %#v", result)
}
}
+286
View File
@@ -0,0 +1,286 @@
package health
import (
"fmt"
"sync"
"time"
"codeberg.org/SquidSE/ClusterCanvas/service/internal/auth"
"codeberg.org/SquidSE/ClusterCanvas/service/internal/settings"
)
const schedulerTickInterval = 15 * time.Second
// Result is the outcome of a node health check.
type Result struct {
PingOK bool
SSHOK bool
OK bool
Message string
CheckedAt time.Time
}
// Checker runs and schedules per-node health checks.
type Checker struct {
ConfigDir string
Key []byte
mu sync.Mutex
running map[string]struct{}
stopCh chan struct{}
stopped chan struct{}
tickerOn bool
// Optional overrides for tests.
PingFn func(hostIP string) auth.PingResult
SSHFn func(hostIP string, username string, privateKeyPEM string, passphrase string) error
NowFn func() time.Time
}
// NewChecker creates a health checker for the given config directory and key.
func NewChecker(configDir string, key []byte) *Checker {
return &Checker{
ConfigDir: configDir,
Key: key,
running: map[string]struct{}{},
stopCh: make(chan struct{}),
stopped: make(chan struct{}),
}
}
// StartScheduler begins the background health-check ticker.
func (checker *Checker) StartScheduler() {
checker.mu.Lock()
if checker.tickerOn {
checker.mu.Unlock()
return
}
checker.tickerOn = true
checker.mu.Unlock()
go func() {
defer close(checker.stopped)
ticker := time.NewTicker(schedulerTickInterval)
defer ticker.Stop()
for {
select {
case <-checker.stopCh:
return
case <-ticker.C:
checker.tickDueChecks()
}
}
}()
}
// StopScheduler stops the background ticker and waits for it to exit.
func (checker *Checker) StopScheduler() {
checker.mu.Lock()
if !checker.tickerOn {
checker.mu.Unlock()
return
}
checker.tickerOn = false
checker.mu.Unlock()
close(checker.stopCh)
<-checker.stopped
}
func (checker *Checker) tryLockNode(nodeID string) bool {
checker.mu.Lock()
defer checker.mu.Unlock()
if _, ok := checker.running[nodeID]; ok {
return false
}
checker.running[nodeID] = struct{}{}
return true
}
func (checker *Checker) unlockNode(nodeID string) {
checker.mu.Lock()
defer checker.mu.Unlock()
delete(checker.running, nodeID)
}
func (checker *Checker) now() time.Time {
if checker.NowFn != nil {
return checker.NowFn()
}
return time.Now().UTC()
}
func (checker *Checker) tickDueChecks() {
if len(checker.Key) == 0 {
return
}
store, err := settings.LoadNodesOrEmpty(checker.ConfigDir)
if err != nil {
return
}
now := checker.now()
for _, node := range store.Nodes {
if !IsHealthCheckDue(node, now) {
continue
}
nodeID := node.ID
go func() {
_, _ = checker.CheckNodeByID(nodeID, "scheduler")
}()
}
}
// IsHealthCheckDue reports whether a node should be probed now.
func IsHealthCheckDue(node settings.Node, now time.Time) bool {
if node.HealthCheckIntervalSeconds <= 0 {
return false
}
if node.HealthLastCheckedAt == nil {
return true
}
elapsed := now.Sub(node.HealthLastCheckedAt.UTC())
return elapsed >= time.Duration(node.HealthCheckIntervalSeconds)*time.Second
}
// CheckNodeByID loads credentials, runs ping + SSH, and persists the result.
func (checker *Checker) CheckNodeByID(nodeID string, actor string) (settings.Node, error) {
if !checker.tryLockNode(nodeID) {
store, err := settings.LoadNodesOrEmpty(checker.ConfigDir)
if err != nil {
return settings.Node{}, err
}
for _, node := range store.Nodes {
if node.ID == nodeID {
return node, fmt.Errorf("health check already running for node")
}
}
return settings.Node{}, fmt.Errorf("node not found")
}
defer checker.unlockNode(nodeID)
if len(checker.Key) == 0 {
return settings.Node{}, fmt.Errorf("%s is not set", settings.ConfigKeyEnvVar)
}
store, err := settings.LoadNodesOrEmpty(checker.ConfigDir)
if err != nil {
return settings.Node{}, err
}
nodeIndex := -1
var node settings.Node
for index, candidate := range store.Nodes {
if candidate.ID != nodeID {
continue
}
node = candidate
nodeIndex = index
break
}
if nodeIndex < 0 {
return settings.Node{}, fmt.Errorf("node not found")
}
keyStore, err := settings.LoadNodeKeysOrEmpty(checker.ConfigDir, checker.Key)
if err != nil {
return settings.Node{}, err
}
var keyEntry settings.NodeKeyEntry
keyFound := false
for _, entry := range keyStore.Keys {
if entry.NodeID == node.ID {
keyEntry = entry
keyFound = true
break
}
}
if !keyFound {
return settings.Node{}, fmt.Errorf("private key not found for node")
}
result := checker.runChecks(node, keyEntry)
updated := applyHealthResult(node, result)
store.Nodes[nodeIndex] = updated
if err := settings.SaveNodes(checker.ConfigDir, store); err != nil {
return settings.Node{}, err
}
_ = appendHealthAudit(checker.ConfigDir, actor, updated, result.Message)
return updated, nil
}
func (checker *Checker) runChecks(node settings.Node, keyEntry settings.NodeKeyEntry) Result {
checkedAt := checker.now()
pingFn := checker.PingFn
if pingFn == nil {
pingFn = auth.PingHost
}
sshFn := checker.SSHFn
if sshFn == nil {
sshFn = auth.TestSSHConnection
}
pingResult := pingFn(node.HostIP)
sshErr := sshFn(node.HostIP, node.Username, keyEntry.PrivateKey, keyEntry.Passphrase)
sshOK := sshErr == nil
message := buildHealthMessage(pingResult, sshOK, sshErr)
return Result{
PingOK: pingResult.OK,
SSHOK: sshOK,
OK: pingResult.OK && sshOK,
Message: message,
CheckedAt: checkedAt,
}
}
func buildHealthMessage(pingResult auth.PingResult, sshOK bool, sshErr error) string {
var parts []string
if pingResult.OK {
parts = append(parts, fmt.Sprintf("ping ok (%s)", pingResult.Method))
} else if pingResult.Error != nil {
parts = append(parts, fmt.Sprintf("ping failed: %v", pingResult.Error))
} else {
parts = append(parts, "ping failed")
}
if sshOK {
parts = append(parts, "ssh ok")
} else if sshErr != nil {
parts = append(parts, fmt.Sprintf("ssh failed: %v", sshErr))
} else {
parts = append(parts, "ssh failed")
}
return fmt.Sprintf("%s; %s", parts[0], parts[1])
}
func applyHealthResult(node settings.Node, result Result) settings.Node {
checkedAt := result.CheckedAt
pingOK := result.PingOK
sshOK := result.SSHOK
healthOK := result.OK
node.HealthLastCheckedAt = &checkedAt
node.HealthPingOK = &pingOK
node.HealthSSHOK = &sshOK
node.HealthOK = &healthOK
node.HealthMessage = result.Message
return node
}
func appendHealthAudit(configDir string, actor string, node settings.Node, detail string) error {
eventID, err := auth.NewUUID()
if err != nil {
eventID = fmt.Sprintf("health-%d", time.Now().UnixNano())
}
return settings.AppendNodeAuditEvent(configDir, settings.NodeAuditEvent{
ID: eventID,
At: time.Now().UTC(),
Action: settings.NodeAuditActionHealthCheck,
Actor: actor,
NodeID: node.ID,
NodeName: node.Name,
NodeKind: node.Kind,
Detail: detail,
})
}
+164
View File
@@ -0,0 +1,164 @@
package health
import (
"errors"
"testing"
"time"
"codeberg.org/SquidSE/ClusterCanvas/service/internal/auth"
"codeberg.org/SquidSE/ClusterCanvas/service/internal/settings"
)
func TestIsHealthCheckDue(t *testing.T) {
now := time.Date(2026, 7, 19, 12, 0, 0, 0, time.UTC)
t.Run("disabled", func(t *testing.T) {
if IsHealthCheckDue(settings.Node{HealthCheckIntervalSeconds: 0}, now) {
t.Fatal("expected not due")
}
})
t.Run("never checked", func(t *testing.T) {
if !IsHealthCheckDue(settings.Node{HealthCheckIntervalSeconds: 60}, now) {
t.Fatal("expected due")
}
})
t.Run("interval elapsed", func(t *testing.T) {
checked := now.Add(-61 * time.Second)
node := settings.Node{
HealthCheckIntervalSeconds: 60,
HealthLastCheckedAt: &checked,
}
if !IsHealthCheckDue(node, now) {
t.Fatal("expected due")
}
})
t.Run("interval not elapsed", func(t *testing.T) {
checked := now.Add(-30 * time.Second)
node := settings.Node{
HealthCheckIntervalSeconds: 60,
HealthLastCheckedAt: &checked,
}
if IsHealthCheckDue(node, now) {
t.Fatal("expected not due")
}
})
}
func TestCheckNodeByIDPersistsResult(t *testing.T) {
dir := t.TempDir()
keyBytes := make([]byte, 32)
for index := range keyBytes {
keyBytes[index] = byte(index + 1)
}
nodeID := "11111111-2222-4333-8444-555555555555"
store := settings.NodeStore{
Nodes: []settings.Node{{
ID: nodeID,
Kind: settings.NodeKindContainer,
Name: "ct-1",
HostIP: "10.9.9.9",
Username: "clustercanvas",
GroupName: "Administrators",
KeyAlgo: "ed25519",
CreatedAt: time.Now().UTC(),
}},
}
if err := settings.SaveNodes(dir, store); err != nil {
t.Fatalf("SaveNodes: %v", err)
}
if err := settings.SaveNodeKeys(dir, settings.NodeKeyStore{
Keys: []settings.NodeKeyEntry{{
NodeID: nodeID,
PrivateKey: "dummy-key",
Algorithm: "ed25519",
}},
}, keyBytes); err != nil {
t.Fatalf("SaveNodeKeys: %v", err)
}
checker := NewChecker(dir, keyBytes)
checker.NowFn = func() time.Time {
return time.Date(2026, 7, 19, 15, 0, 0, 0, time.UTC)
}
checker.PingFn = func(hostIP string) auth.PingResult {
return auth.PingResult{OK: true, Method: auth.PingMethodTCP}
}
checker.SSHFn = func(hostIP string, username string, privateKeyPEM string, passphrase string) error {
return nil
}
updated, err := checker.CheckNodeByID(nodeID, "tester")
if err != nil {
t.Fatalf("CheckNodeByID: %v", err)
}
if updated.HealthOK == nil || !*updated.HealthOK {
t.Fatalf("health_ok = %#v", updated.HealthOK)
}
if updated.HealthPingOK == nil || !*updated.HealthPingOK {
t.Fatal("expected ping ok")
}
if updated.HealthSSHOK == nil || !*updated.HealthSSHOK {
t.Fatal("expected ssh ok")
}
if updated.HealthMessage == "" {
t.Fatal("expected message")
}
loaded, err := settings.LoadNodes(dir)
if err != nil {
t.Fatalf("LoadNodes: %v", err)
}
if loaded.Nodes[0].HealthOK == nil || !*loaded.Nodes[0].HealthOK {
t.Fatal("persisted health_ok missing")
}
}
func TestCheckNodeByIDRecordsFailure(t *testing.T) {
dir := t.TempDir()
keyBytes := make([]byte, 32)
for index := range keyBytes {
keyBytes[index] = byte(index + 3)
}
nodeID := "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee"
if err := settings.SaveNodes(dir, settings.NodeStore{
Nodes: []settings.Node{{
ID: nodeID,
Kind: settings.NodeKindVM,
Name: "vm-1",
HostIP: "10.8.8.8",
Username: "clustercanvas",
}},
}); err != nil {
t.Fatalf("SaveNodes: %v", err)
}
if err := settings.SaveNodeKeys(dir, settings.NodeKeyStore{
Keys: []settings.NodeKeyEntry{{
NodeID: nodeID,
PrivateKey: "dummy-key",
Algorithm: "ed25519",
}},
}, keyBytes); err != nil {
t.Fatalf("SaveNodeKeys: %v", err)
}
checker := NewChecker(dir, keyBytes)
checker.PingFn = func(hostIP string) auth.PingResult {
return auth.PingResult{OK: false, Method: auth.PingMethodTCP, Error: errors.New("unreachable")}
}
checker.SSHFn = func(hostIP string, username string, privateKeyPEM string, passphrase string) error {
return errors.New("ssh dial failed")
}
updated, err := checker.CheckNodeByID(nodeID, "tester")
if err != nil {
t.Fatalf("CheckNodeByID: %v", err)
}
if updated.HealthOK == nil || *updated.HealthOK {
t.Fatalf("expected health failure, got %#v", updated.HealthOK)
}
}
+13 -4
View File
@@ -105,6 +105,14 @@ type Node struct {
PublicKey string `json:"public_key"` PublicKey string `json:"public_key"`
KeyAlgo string `json:"key_algo"` KeyAlgo string `json:"key_algo"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
// Health check configuration and last result (persisted).
HealthCheckIntervalSeconds int `json:"health_check_interval_seconds"`
HealthLastCheckedAt *time.Time `json:"health_last_checked_at,omitempty"`
HealthPingOK *bool `json:"health_ping_ok,omitempty"`
HealthSSHOK *bool `json:"health_ssh_ok,omitempty"`
HealthOK *bool `json:"health_ok,omitempty"`
HealthMessage string `json:"health_message,omitempty"`
} }
// NodeStore is the plain JSON payload in nodes.json. // NodeStore is the plain JSON payload in nodes.json.
@@ -284,10 +292,11 @@ type NodeKeyStore struct {
type NodeAuditAction string type NodeAuditAction string
const ( const (
NodeAuditActionCreate NodeAuditAction = "create" NodeAuditActionCreate NodeAuditAction = "create"
NodeAuditActionDelete NodeAuditAction = "delete" NodeAuditActionDelete NodeAuditAction = "delete"
NodeAuditActionTestSSH NodeAuditAction = "test_ssh" NodeAuditActionTestSSH NodeAuditAction = "test_ssh"
NodeAuditActionUpdate NodeAuditAction = "update" NodeAuditActionHealthCheck NodeAuditAction = "health_check"
NodeAuditActionUpdate NodeAuditAction = "update"
) )
// NodeAuditEvent is one append-only record of a node mutation. // NodeAuditEvent is one append-only record of a node mutation.
+88 -1
View File
@@ -454,11 +454,98 @@
.node-list-heading-main { .node-list-heading-main {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
align-items: baseline; align-items: center;
gap: 0.5rem 1rem; gap: 0.5rem 1rem;
min-width: 0; min-width: 0;
} }
.node-health-dot {
display: inline-block;
width: 0.55rem;
height: 0.55rem;
border-radius: 50%;
flex-shrink: 0;
vertical-align: middle;
box-shadow: inset 0 0 0 1px color-mix(in srgb, currentColor 25%, transparent);
}
.node-health-dot-ok {
background: var(--ctp-green);
color: var(--ctp-green);
}
.node-health-dot-error {
background: var(--ctp-red);
color: var(--ctp-red);
}
.node-health-dot-unknown {
background: var(--ctp-overlay0);
color: var(--ctp-overlay0);
}
.node-healthcheck-tab {
display: flex;
flex-direction: column;
gap: 1rem;
}
.node-health-interval {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem 0.75rem;
margin: 0.55rem 0 0.35rem;
}
.node-health-interval-label {
display: inline-flex;
flex-wrap: wrap;
align-items: center;
gap: 0.4rem 0.5rem;
font-size: 0.85rem;
color: var(--ctp-subtext0);
}
.node-health-interval-input {
width: 5.5rem;
padding: 0.3rem 0.45rem;
border: 1px solid var(--ctp-surface1);
border-radius: 6px;
background: var(--ctp-surface0);
color: var(--ctp-text);
font: inherit;
}
.node-health-interval-unit {
padding: 0.3rem 0.45rem;
border: 1px solid var(--ctp-surface1);
border-radius: 6px;
background: var(--ctp-surface0);
color: var(--ctp-text);
font: inherit;
}
.node-health-interval-save {
padding: 0.3rem 0.7rem;
border: 1px solid var(--ctp-surface1);
border-radius: 6px;
background: var(--ctp-surface0);
color: var(--ctp-text);
font: inherit;
font-size: 0.85rem;
cursor: pointer;
}
.node-health-interval-save:hover:not(:disabled) {
border-color: var(--ctp-overlay0);
}
.node-health-interval-save:disabled {
opacity: 0.65;
cursor: not-allowed;
}
.node-edit { .node-edit {
flex-shrink: 0; flex-shrink: 0;
margin-left: auto; margin-left: auto;
+97 -45
View File
@@ -29,7 +29,7 @@ import {
patchAction, patchAction,
patchUser, patchUser,
reauth, reauth,
testNodeSSH, runNodeHealthCheck,
type AccessMode, type AccessMode,
type Action, type Action,
type ActionEnvVar, type ActionEnvVar,
@@ -76,6 +76,7 @@ import { LoginPage } from './LoginPage'
import { NodeDetailPanel } from './NodeDetailPanel' import { NodeDetailPanel } from './NodeDetailPanel'
import { import {
countAuthFailuresToday, countAuthFailuresToday,
countNodeHealthErrorsBySection,
countNodesBySection, countNodesBySection,
type NodeSectionCounts, type NodeSectionCounts,
} from './sidebarCounts' } from './sidebarCounts'
@@ -2407,6 +2408,26 @@ function resourceTabsFor(section: ResourceSectionId): ReadonlyArray<{
] ]
} }
function healthDotClass(healthOk: boolean | null | undefined): string {
if (healthOk === true) {
return 'node-health-dot node-health-dot-ok'
}
if (healthOk === false) {
return 'node-health-dot node-health-dot-error'
}
return 'node-health-dot node-health-dot-unknown'
}
function healthDotLabel(healthOk: boolean | null | undefined): string {
if (healthOk === true) {
return 'Healthy'
}
if (healthOk === false) {
return 'Unhealthy'
}
return 'Health unknown'
}
function ResourceOverviewTab({ function ResourceOverviewTab({
section, section,
canRead, canRead,
@@ -2421,10 +2442,7 @@ function ResourceOverviewTab({
const [nodes, setNodes] = useState<ReadonlyArray<Node>>([]) const [nodes, setNodes] = useState<ReadonlyArray<Node>>([])
const [isLoading, setIsLoading] = useState(true) const [isLoading, setIsLoading] = useState(true)
const [loadError, setLoadError] = useState<string | null>(null) const [loadError, setLoadError] = useState<string | null>(null)
const [testingNodeId, setTestingNodeId] = useState<string | null>(null) const [checkingNodeId, setCheckingNodeId] = useState<string | null>(null)
const [testResults, setTestResults] = useState(
{} as Record<string, { ok: boolean; message: string }>,
)
const [me, setMe] = useState<MeResponse | null>(null) const [me, setMe] = useState<MeResponse | null>(null)
const [pendingDeleteNodeId, setPendingDeleteNodeId] = useState<string | null>( const [pendingDeleteNodeId, setPendingDeleteNodeId] = useState<string | null>(
null, null,
@@ -2491,29 +2509,29 @@ function ResourceOverviewTab({
} }
}, [section, canRead]) }, [section, canRead])
async function handleTestSSH(nodeId: string) { async function handleHealthCheck(nodeId: string) {
setTestingNodeId(nodeId) setCheckingNodeId(nodeId)
setTestResults((previous) => {
const next = { ...previous }
delete next[nodeId]
return next
})
try { try {
const result = await testNodeSSH(nodeId) const response = await runNodeHealthCheck(nodeId)
setTestResults((previous) => ({ setNodes((previous) =>
...previous, previous.map((node) => (node.id === nodeId ? response.node : node)),
[nodeId]: { ok: result.ok, message: result.message }, )
}))
} catch (err) { } catch (err) {
setTestResults((previous) => ({ setNodes((previous) =>
...previous, previous.map((node) => {
[nodeId]: { if (node.id !== nodeId) {
ok: false, return node
message: err instanceof Error ? err.message : 'SSH test failed', }
}, return {
})) ...node,
health_ok: false,
health_message:
err instanceof Error ? err.message : 'Health check failed',
}
}),
)
} finally { } finally {
setTestingNodeId(null) setCheckingNodeId(null)
} }
} }
@@ -2571,11 +2589,16 @@ function ResourceOverviewTab({
) : null} ) : null}
<ul className="node-list"> <ul className="node-list">
{nodes.map((node) => { {nodes.map((node) => {
const testResult = testResults[node.id]
return ( return (
<li key={node.id} className="node-list-item"> <li key={node.id} className="node-list-item">
<div className="node-list-heading"> <div className="node-list-heading">
<div className="node-list-heading-main"> <div className="node-list-heading-main">
<span
className={healthDotClass(node.health_ok)}
title={healthDotLabel(node.health_ok)}
aria-label={healthDotLabel(node.health_ok)}
role="img"
/>
<strong>{node.name}</strong> <strong>{node.name}</strong>
<code className="node-id">{node.id}</code> <code className="node-id">{node.id}</code>
</div> </div>
@@ -2584,7 +2607,13 @@ function ResourceOverviewTab({
className="node-edit" className="node-edit"
onClick={() => { onClick={() => {
navigateTo( navigateTo(
pathFor(section, 'overview', 'overview', node.id, 'actions'), pathFor(
section,
'overview',
'overview',
node.id,
'healthcheck',
),
) )
}} }}
> >
@@ -2595,20 +2624,37 @@ function ResourceOverviewTab({
{node.host_ip} · {node.username} · group {node.group_name} ·{' '} {node.host_ip} · {node.username} · group {node.group_name} ·{' '}
{node.key_algo} {node.key_algo}
</p> </p>
{node.health_message ? (
<p
className={
node.health_ok
? 'node-test-result node-test-result-ok'
: node.health_ok === false
? 'node-test-result node-test-result-fail'
: 'node-test-result'
}
role="status"
>
{node.health_message}
{node.health_last_checked_at
? ` · ${new Date(node.health_last_checked_at).toLocaleString()}`
: ''}
</p>
) : null}
{canExec || canDelete ? ( {canExec || canDelete ? (
<div className="node-actions"> <div className="node-actions">
{canExec ? ( {canExec ? (
<button <button
type="button" type="button"
className="node-test-ssh" className="node-test-ssh"
disabled={testingNodeId === node.id} disabled={checkingNodeId === node.id}
onClick={() => { onClick={() => {
void handleTestSSH(node.id) void handleHealthCheck(node.id)
}} }}
> >
{testingNodeId === node.id {checkingNodeId === node.id
? 'Testing SSH…' ? 'Checking health…'
: 'Test SSH connection'} : 'Health check'}
</button> </button>
) : null} ) : null}
{canDelete ? ( {canDelete ? (
@@ -2622,19 +2668,6 @@ function ResourceOverviewTab({
Delete Delete
</button> </button>
) : null} ) : null}
{testResult ? (
<p
className={
testResult.ok
? 'node-test-result node-test-result-ok'
: 'node-test-result node-test-result-fail'
}
role="status"
>
{testResult.ok ? 'Succeeded: ' : 'Failed: '}
{testResult.message}
</p>
) : null}
</div> </div>
) : null} ) : null}
</li> </li>
@@ -3211,6 +3244,7 @@ function LogsPanel() {
{ id: 'update', label: 'update' }, { id: 'update', label: 'update' },
{ id: 'delete', label: 'delete' }, { id: 'delete', label: 'delete' },
{ id: 'test_ssh', label: 'test_ssh' }, { id: 'test_ssh', label: 'test_ssh' },
{ id: 'health_check', label: 'health_check' },
] ]
const authOutcomeFilters: ReadonlyArray<{ const authOutcomeFilters: ReadonlyArray<{
@@ -3812,6 +3846,8 @@ function App() {
const [canReadLogs, setCanReadLogs] = useState(false) const [canReadLogs, setCanReadLogs] = useState(false)
const [nodeSectionCounts, setNodeSectionCounts] = const [nodeSectionCounts, setNodeSectionCounts] =
useState<NodeSectionCounts | null>(null) useState<NodeSectionCounts | null>(null)
const [nodeHealthErrorCounts, setNodeHealthErrorCounts] =
useState<NodeSectionCounts | null>(null)
const [authFailuresToday, setAuthFailuresToday] = useState<number | null>( const [authFailuresToday, setAuthFailuresToday] = useState<number | null>(
null, null,
) )
@@ -3893,10 +3929,14 @@ function App() {
}) })
if (!isCancelled) { if (!isCancelled) {
setNodeSectionCounts(countNodesBySection(nodesResponse.nodes)) setNodeSectionCounts(countNodesBySection(nodesResponse.nodes))
setNodeHealthErrorCounts(
countNodeHealthErrorsBySection(nodesResponse.nodes),
)
} }
} catch { } catch {
if (!isCancelled) { if (!isCancelled) {
setNodeSectionCounts(null) setNodeSectionCounts(null)
setNodeHealthErrorCounts(null)
} }
} }
@@ -4099,6 +4139,10 @@ function App() {
isResourceSectionId(item.id) && nodeSectionCounts !== null isResourceSectionId(item.id) && nodeSectionCounts !== null
? nodeSectionCounts[item.id] ? nodeSectionCounts[item.id]
: null : null
const healthErrorCount =
isResourceSectionId(item.id) && nodeHealthErrorCounts !== null
? nodeHealthErrorCounts[item.id]
: null
return ( return (
<button <button
key={item.id} key={item.id}
@@ -4112,6 +4156,14 @@ function App() {
onClick={() => goToSection(item.id)} onClick={() => goToSection(item.id)}
> >
<span className="nav-item-label">{item.label}</span> <span className="nav-item-label">{item.label}</span>
{healthErrorCount !== null && healthErrorCount > 0 ? (
<span
className="nav-count-badge nav-count-badge-alert"
aria-label={`${healthErrorCount} unhealthy`}
>
{healthErrorCount}
</span>
) : null}
{sectionCount !== null ? ( {sectionCount !== null ? (
<span className="nav-count-badge" aria-hidden="true"> <span className="nav-count-badge" aria-hidden="true">
{sectionCount} {sectionCount}
+272 -1
View File
@@ -22,10 +22,12 @@ import {
fetchNode, fetchNode,
patchActionGroup, patchActionGroup,
patchActionGroupItem, patchActionGroupItem,
patchNode,
reorderActionGroupItems, reorderActionGroupItems,
resolveNodeCommands, resolveNodeCommands,
runActionGroup, runActionGroup,
runActionGroupItem, runActionGroupItem,
runNodeHealthCheck,
} from './api/client' } from './api/client'
import { import {
collectRelativeCommandNames, collectRelativeCommandNames,
@@ -45,6 +47,7 @@ const WEEKDAY_LABELS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'] as cons
const NODE_DETAIL_TABS: ReadonlyArray<{ id: NodeDetailTabId; label: string }> = [ const NODE_DETAIL_TABS: ReadonlyArray<{ id: NodeDetailTabId; label: string }> = [
{ id: 'overview', label: 'Overview' }, { id: 'overview', label: 'Overview' },
{ id: 'healthcheck', label: 'Healthcheck' },
{ id: 'actions', label: 'Actions' }, { id: 'actions', label: 'Actions' },
{ id: 'logs', label: 'Logs' }, { id: 'logs', label: 'Logs' },
] ]
@@ -325,6 +328,8 @@ export function NodeDetailPanel({
const canDelete = Boolean(me?.permissions?.includes('actions.delete')) const canDelete = Boolean(me?.permissions?.includes('actions.delete'))
const canRun = Boolean(me?.permissions?.includes('actions.execute')) const canRun = Boolean(me?.permissions?.includes('actions.execute'))
const canReadLogs = Boolean(me?.permissions?.includes('logs.read')) const canReadLogs = Boolean(me?.permissions?.includes('logs.read'))
const canExecNodes = Boolean(me?.permissions?.includes('nodes.exec'))
const canUpdateNodes = Boolean(me?.permissions?.includes('nodes.update'))
return ( return (
<div className="config-panel node-detail-panel"> <div className="config-panel node-detail-panel">
@@ -367,6 +372,14 @@ export function NodeDetailPanel({
{activeTab === 'overview' ? ( {activeTab === 'overview' ? (
<NodeDetailOverviewTab node={node} /> <NodeDetailOverviewTab node={node} />
) : null} ) : null}
{activeTab === 'healthcheck' ? (
<NodeHealthcheckTab
node={node}
canExec={canExecNodes}
canUpdate={canUpdateNodes}
onNodeUpdated={setNode}
/>
) : null}
{activeTab === 'actions' ? ( {activeTab === 'actions' ? (
<NodeActionsTab <NodeActionsTab
nodeId={node.id} nodeId={node.id}
@@ -385,12 +398,63 @@ export function NodeDetailPanel({
) )
} }
function healthDotClass(healthOk: boolean | null | undefined): string {
if (healthOk === true) {
return 'node-health-dot node-health-dot-ok'
}
if (healthOk === false) {
return 'node-health-dot node-health-dot-error'
}
return 'node-health-dot node-health-dot-unknown'
}
function healthDotLabel(healthOk: boolean | null | undefined): string {
if (healthOk === true) {
return 'Healthy'
}
if (healthOk === false) {
return 'Unhealthy'
}
return 'Health unknown'
}
function intervalToFormValues(intervalSeconds: number): {
value: number
unit: 'seconds' | 'minutes'
} {
if (intervalSeconds > 0 && intervalSeconds % 60 === 0) {
return { value: intervalSeconds / 60, unit: 'minutes' }
}
return { value: intervalSeconds, unit: 'seconds' }
}
function formValuesToIntervalSeconds(
value: number,
unit: 'seconds' | 'minutes',
): number {
if (!Number.isFinite(value) || value < 0) {
return 0
}
const rounded = Math.floor(value)
if (unit === 'minutes') {
return rounded * 60
}
return rounded
}
function NodeDetailOverviewTab({ node }: { node: Node }) { function NodeDetailOverviewTab({ node }: { node: Node }) {
return ( return (
<dl className="node-detail-overview"> <dl className="node-detail-overview">
<div> <div>
<dt>Name</dt> <dt>Name</dt>
<dd>{node.name}</dd> <dd>
<span
className={healthDotClass(node.health_ok)}
title={healthDotLabel(node.health_ok)}
aria-hidden="true"
/>{' '}
{node.name}
</dd>
</div> </div>
<div> <div>
<dt>Host IP</dt> <dt>Host IP</dt>
@@ -416,6 +480,213 @@ function NodeDetailOverviewTab({ node }: { node: Node }) {
) )
} }
function NodeHealthcheckTab({
node,
canExec,
canUpdate,
onNodeUpdated,
}: {
node: Node
canExec: boolean
canUpdate: boolean
onNodeUpdated: (node: Node) => void
}) {
const [intervalDraft, setIntervalDraft] = useState(() =>
intervalToFormValues(node.health_check_interval_seconds ?? 0),
)
const [isSavingInterval, setIsSavingInterval] = useState(false)
const [intervalError, setIntervalError] = useState<string | null>(null)
const [isChecking, setIsChecking] = useState(false)
const [checkError, setCheckError] = useState<string | null>(null)
useEffect(() => {
setIntervalDraft(
intervalToFormValues(node.health_check_interval_seconds ?? 0),
)
setIntervalError(null)
}, [node.id, node.health_check_interval_seconds])
async function handleSaveInterval() {
const seconds = formValuesToIntervalSeconds(
intervalDraft.value,
intervalDraft.unit,
)
setIsSavingInterval(true)
setIntervalError(null)
try {
const response = await patchNode(node.id, {
health_check_interval_seconds: seconds,
})
onNodeUpdated(response.node)
setIntervalDraft(
intervalToFormValues(response.node.health_check_interval_seconds ?? 0),
)
} catch (err) {
setIntervalError(
err instanceof Error ? err.message : 'Failed to save interval',
)
} finally {
setIsSavingInterval(false)
}
}
async function handleHealthCheck() {
setIsChecking(true)
setCheckError(null)
try {
const response = await runNodeHealthCheck(node.id)
onNodeUpdated(response.node)
} catch (err) {
setCheckError(
err instanceof Error ? err.message : 'Health check failed',
)
} finally {
setIsChecking(false)
}
}
return (
<div className="node-healthcheck-tab">
<dl className="node-detail-overview">
<div>
<dt>Status</dt>
<dd>
<span
className={healthDotClass(node.health_ok)}
title={healthDotLabel(node.health_ok)}
aria-hidden="true"
/>{' '}
{healthDotLabel(node.health_ok)}
</dd>
</div>
<div>
<dt>Last check</dt>
<dd>
{node.health_last_checked_at
? new Date(node.health_last_checked_at).toLocaleString()
: 'Never'}
</dd>
</div>
<div>
<dt>Result</dt>
<dd>
{node.health_message
? node.health_message
: 'Not checked yet'}
</dd>
</div>
<div>
<dt>Ping</dt>
<dd>
{node.health_ping_ok === true
? 'OK'
: node.health_ping_ok === false
? 'Failed'
: '—'}
</dd>
</div>
<div>
<dt>SSH</dt>
<dd>
{node.health_ssh_ok === true
? 'OK'
: node.health_ssh_ok === false
? 'Failed'
: '—'}
</dd>
</div>
</dl>
{canUpdate ? (
<div className="node-health-interval">
<label className="node-health-interval-label">
Health check every
<input
type="number"
min={0}
step={1}
className="node-health-interval-input"
value={intervalDraft.value}
disabled={isSavingInterval}
onChange={(event) => {
const nextValue = Number(event.target.value)
setIntervalDraft((previous) => ({
...previous,
value: Number.isFinite(nextValue) ? nextValue : 0,
}))
setIntervalError(null)
}}
/>
<select
className="node-health-interval-unit"
value={intervalDraft.unit}
disabled={isSavingInterval}
onChange={(event) => {
setIntervalDraft((previous) => ({
...previous,
unit: event.target.value as 'seconds' | 'minutes',
}))
setIntervalError(null)
}}
>
<option value="seconds">seconds</option>
<option value="minutes">minutes</option>
</select>
</label>
<button
type="button"
className="node-health-interval-save"
disabled={isSavingInterval}
onClick={() => {
void handleSaveInterval()
}}
>
{isSavingInterval ? 'Saving…' : 'Save interval'}
</button>
{intervalError ? (
<p className="config-error" role="alert">
{intervalError}
</p>
) : null}
<p className="config-hint">Use 0 to disable scheduled checks.</p>
</div>
) : (
<p className="config-hint">
Interval:{' '}
{(node.health_check_interval_seconds ?? 0) === 0
? 'Disabled'
: `Every ${node.health_check_interval_seconds} seconds`}
. You need <code>nodes.update</code> to change this.
</p>
)}
{canExec ? (
<div className="node-actions">
<button
type="button"
className="node-test-ssh"
disabled={isChecking}
onClick={() => {
void handleHealthCheck()
}}
>
{isChecking ? 'Checking health…' : 'Health check'}
</button>
{checkError ? (
<p className="config-error" role="alert">
{checkError}
</p>
) : null}
</div>
) : (
<p className="config-hint">
You need <code>nodes.exec</code> to run a health check.
</p>
)}
</div>
)
}
function NodeActionsTab({ function NodeActionsTab({
nodeId, nodeId,
nodeUsername, nodeUsername,
+57
View File
@@ -705,6 +705,63 @@ describe('nodes API', () => {
}), }),
) )
}) })
it('posts to the health-check endpoint', async () => {
const { runNodeHealthCheck } = await import('./client')
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
node: {
id: '11111111-2222-4333-8444-555555555555',
health_ok: true,
health_message: 'ping ok (icmp); ssh ok',
},
}),
})
const result = await runNodeHealthCheck(
'11111111-2222-4333-8444-555555555555',
fetchMock,
)
expect(result.node.health_ok).toBe(true)
expect(fetchMock).toHaveBeenCalledWith(
'http://localhost:8080/api/v1/nodes/11111111-2222-4333-8444-555555555555/health-check',
expect.objectContaining({
method: 'POST',
credentials: 'include',
}),
)
})
it('patches node health interval', async () => {
const { patchNode } = await import('./client')
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
node: {
id: '11111111-2222-4333-8444-555555555555',
health_check_interval_seconds: 300,
},
}),
})
const result = await patchNode(
'11111111-2222-4333-8444-555555555555',
{ health_check_interval_seconds: 300 },
fetchMock,
)
expect(result.node.health_check_interval_seconds).toBe(300)
expect(fetchMock).toHaveBeenCalledWith(
'http://localhost:8080/api/v1/nodes/11111111-2222-4333-8444-555555555555',
expect.objectContaining({
method: 'PATCH',
credentials: 'include',
body: JSON.stringify({ health_check_interval_seconds: 300 }),
}),
)
})
}) })
describe('actions API', () => { describe('actions API', () => {
+45 -1
View File
@@ -658,6 +658,12 @@ export type Node = {
public_key: string public_key: string
key_algo: string key_algo: string
created_at: string created_at: string
health_check_interval_seconds?: number
health_last_checked_at?: string | null
health_ping_ok?: boolean | null
health_ssh_ok?: boolean | null
health_ok?: boolean | null
health_message?: string
} }
export type NodesResponse = { export type NodesResponse = {
@@ -774,6 +780,44 @@ export async function testNodeSSH(
return (await response.json()) as NodeSSHTestResponse return (await response.json()) as NodeSSHTestResponse
} }
export type PatchNodeRequest = {
health_check_interval_seconds: number
}
export async function patchNode(
id: string,
payload: PatchNodeRequest,
fetchImpl: typeof fetch = fetch,
): Promise<NodeResponse> {
const response = await apiFetch(
`/api/v1/nodes/${encodeURIComponent(id)}`,
{
method: 'PATCH',
body: JSON.stringify(payload),
},
fetchImpl,
)
if (!response.ok) {
throw new Error(await readErrorMessage(response))
}
return (await response.json()) as NodeResponse
}
export async function runNodeHealthCheck(
id: string,
fetchImpl: typeof fetch = fetch,
): Promise<NodeResponse> {
const response = await apiFetch(
`/api/v1/nodes/${encodeURIComponent(id)}/health-check`,
{ method: 'POST' },
fetchImpl,
)
if (!response.ok) {
throw new Error(await readErrorMessage(response))
}
return (await response.json()) as NodeResponse
}
export type ResolveCommandsResponse = { export type ResolveCommandsResponse = {
paths: Record<string, string> paths: Record<string, string>
missing: ReadonlyArray<string> missing: ReadonlyArray<string>
@@ -799,7 +843,7 @@ export async function resolveNodeCommands(
return (await response.json()) as ResolveCommandsResponse return (await response.json()) as ResolveCommandsResponse
} }
export type NodeAuditAction = 'create' | 'delete' | 'test_ssh' | 'update' export type NodeAuditAction = 'create' | 'delete' | 'test_ssh' | 'health_check' | 'update'
export type NodeAuditEvent = { export type NodeAuditEvent = {
id: string id: string
+1
View File
@@ -59,6 +59,7 @@ describe('parseLocation', () => {
'/configuration/advanced', '/configuration/advanced',
'/containers/node-1', '/containers/node-1',
'/containers/node-1/actions', '/containers/node-1/actions',
'/containers/node-1/healthcheck',
'/vms/node-2/logs', '/vms/node-2/logs',
]) { ]) {
const location = parseLocation(path) const location = parseLocation(path)
+2 -1
View File
@@ -22,7 +22,7 @@ export type ConfigTabId =
export type ResourceTabId = 'overview' | 'security' | 'add' export type ResourceTabId = 'overview' | 'security' | 'add'
export type NodeDetailTabId = 'overview' | 'actions' | 'logs' export type NodeDetailTabId = 'overview' | 'healthcheck' | 'actions' | 'logs'
export type ResourceSectionId = 'containers' | 'vms' | 'docker' export type ResourceSectionId = 'containers' | 'vms' | 'docker'
@@ -54,6 +54,7 @@ const RESOURCE_TAB_IDS: ReadonlySet<string> = new Set([
const NODE_DETAIL_TAB_IDS: ReadonlySet<string> = new Set([ const NODE_DETAIL_TAB_IDS: ReadonlySet<string> = new Set([
'overview', 'overview',
'healthcheck',
'actions', 'actions',
'logs', 'logs',
]) ])
+36
View File
@@ -3,6 +3,7 @@ import { describe, expect, it } from 'vitest'
import type { AuthAuditEvent, Node } from './api/client' import type { AuthAuditEvent, Node } from './api/client'
import { import {
countAuthFailuresToday, countAuthFailuresToday,
countNodeHealthErrorsBySection,
countNodesBySection, countNodesBySection,
} from './sidebarCounts' } from './sidebarCounts'
@@ -37,6 +38,41 @@ describe('countNodesBySection', () => {
}) })
}) })
describe('countNodeHealthErrorsBySection', () => {
it('counts only nodes with health_ok false', () => {
expect(
countNodeHealthErrorsBySection([
{ kind: 'container', health_ok: false },
{ kind: 'container', health_ok: true },
{ kind: 'container', health_ok: null },
{ kind: 'container' },
{ kind: 'docker', health_ok: false },
{ kind: 'vm', health_ok: false },
{ kind: 'vm', health_ok: false },
{ kind: 'vm', health_ok: true },
]),
).toEqual({
containers: 1,
docker: 1,
vms: 2,
})
})
it('returns zeros when no unhealthy nodes', () => {
expect(
countNodeHealthErrorsBySection([
{ kind: 'container', health_ok: true },
{ kind: 'docker' },
{ kind: 'vm', health_ok: null },
]),
).toEqual({
containers: 0,
docker: 0,
vms: 0,
})
})
})
describe('countAuthFailuresToday', () => { describe('countAuthFailuresToday', () => {
const now = new Date(2026, 6, 18, 15, 30, 0) const now = new Date(2026, 6, 18, 15, 30, 0)
+17
View File
@@ -24,6 +24,23 @@ export function countNodesBySection(
return counts return counts
} }
export function countNodeHealthErrorsBySection(
nodes: ReadonlyArray<Pick<Node, 'kind' | 'health_ok'>>,
): NodeSectionCounts {
const counts: NodeSectionCounts = {
containers: 0,
docker: 0,
vms: 0,
}
for (const node of nodes) {
if (node.health_ok !== false) {
continue
}
counts[KIND_TO_SECTION[node.kind]] += 1
}
return counts
}
export function countAuthFailuresToday( export function countAuthFailuresToday(
events: ReadonlyArray<Pick<AuthAuditEvent, 'at' | 'action' | 'outcome'>>, events: ReadonlyArray<Pick<AuthAuditEvent, 'at' | 'action' | 'outcome'>>,
now: Date = new Date(), now: Date = new Date(),