Add per-node health checks with status indicators and scheduled probes.
This commit is contained in:
@@ -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()
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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
@@ -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}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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',
|
||||||
])
|
])
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
Reference in New Issue
Block a user