From ff9ef56f43ffdba00372f71c7f0fe4c5088aa3d0 Mon Sep 17 00:00:00 2001 From: Roman D Date: Fri, 17 Jan 2025 11:29:20 +0300 Subject: [PATCH] feat: health check endpoint (#971) * chore: move Nostr relay readiness methods to service.go * feat: basic health checks implemented * chore: rename alby info variable * feat: add basic health check ui, remove unnecessary alarms * fix: health indicator layout on mobile --------- Co-authored-by: Roland Bewick --- api/api.go | 44 ++++++++++++ api/models.go | 26 +++++++ frontend/src/components/layouts/AppLayout.tsx | 67 ++++++++++++++++++- frontend/src/hooks/useHealthCheck.ts | 16 +++++ frontend/src/types.ts | 15 +++++ http/http_service.go | 12 ++++ lnclient/breez/breez.go | 4 +- lnclient/cashu/cashu.go | 4 +- lnclient/greenlight/greenlight.go | 4 +- lnclient/ldk/ldk.go | 61 +++++++++-------- lnclient/lnd/lnd.go | 3 +- lnclient/models.go | 1 + lnclient/phoenixd/phoenixd.go | 4 +- service/models.go | 4 +- service/service.go | 8 +++ service/start.go | 8 --- tests/mock_ln_client.go | 4 +- wails/wails_handlers.go | 14 +++- 18 files changed, 253 insertions(+), 46 deletions(-) create mode 100644 frontend/src/hooks/useHealthCheck.ts diff --git a/api/api.go b/api/api.go index 8116ef2a3..c81d0cb93 100644 --- a/api/api.go +++ b/api/api.go @@ -1024,6 +1024,50 @@ func (api *api) GetLogOutput(ctx context.Context, logType string, getLogRequest return &GetLogOutputResponse{Log: string(logData)}, nil } +func (api *api) Health(ctx context.Context) (*HealthResponse, error) { + var alarms []HealthAlarm + + albyInfo, err := api.albyOAuthSvc.GetInfo(ctx) + if err != nil { + return nil, err + } + if !albyInfo.Healthy { + alarms = append(alarms, NewHealthAlarm(HealthAlarmKindAlbyService, albyInfo.Incidents)) + } + + isNostrRelayReady := api.svc.IsRelayReady() + if !isNostrRelayReady { + alarms = append(alarms, NewHealthAlarm(HealthAlarmKindNostrRelayOffline, nil)) + } + + lnClient := api.svc.GetLNClient() + + if lnClient != nil { + nodeStatus, err := lnClient.GetNodeStatus(ctx) + if err != nil { + return nil, err + } + if nodeStatus == nil || !nodeStatus.IsReady { + alarms = append(alarms, NewHealthAlarm(HealthAlarmKindNodeNotReady, nodeStatus)) + } + + channels, err := lnClient.ListChannels(ctx) + if err != nil { + return nil, err + } + + offlineChannels := slices.DeleteFunc(channels, func(channel lnclient.Channel) bool { + return channel.Active + }) + + if len(offlineChannels) > 0 { + alarms = append(alarms, NewHealthAlarm(HealthAlarmKindChannelsOffline, nil)) + } + } + + return &HealthResponse{Alarms: alarms}, nil +} + func (api *api) parseExpiresAt(expiresAtString string) (*time.Time, error) { var expiresAt *time.Time if expiresAtString != "" { diff --git a/api/models.go b/api/models.go index 0408d2da9..73593f906 100644 --- a/api/models.go +++ b/api/models.go @@ -56,6 +56,7 @@ type API interface { RestoreBackup(unlockPassword string, r io.Reader) error MigrateNodeStorage(ctx context.Context, to string) error GetWalletCapabilities(ctx context.Context) (*WalletCapabilitiesResponse, error) + Health(ctx context.Context) (*HealthResponse, error) } type App struct { @@ -366,3 +367,28 @@ type Channel struct { type MigrateNodeStorageRequest struct { To string `json:"to"` } + +type HealthAlarmKind string + +const ( + HealthAlarmKindAlbyService HealthAlarmKind = "alby_service" + HealthAlarmKindNodeNotReady = "node_not_ready" + HealthAlarmKindChannelsOffline = "channels_offline" + HealthAlarmKindNostrRelayOffline = "nostr_relay_offline" +) + +type HealthAlarm struct { + Kind HealthAlarmKind `json:"kind"` + RawDetails any `json:"rawDetails,omitempty"` +} + +func NewHealthAlarm(kind HealthAlarmKind, rawDetails any) HealthAlarm { + return HealthAlarm{ + Kind: kind, + RawDetails: rawDetails, + } +} + +type HealthResponse struct { + Alarms []HealthAlarm `json:"alarms,omitempty"` +} diff --git a/frontend/src/components/layouts/AppLayout.tsx b/frontend/src/components/layouts/AppLayout.tsx index 2e91f57b9..b20089bce 100644 --- a/frontend/src/components/layouts/AppLayout.tsx +++ b/frontend/src/components/layouts/AppLayout.tsx @@ -48,12 +48,15 @@ import { } from "src/components/ui/tooltip"; import { useAlbyMe } from "src/hooks/useAlbyMe"; +import clsx from "clsx"; import { useAlbyInfo } from "src/hooks/useAlbyInfo"; +import { useHealthCheck } from "src/hooks/useHealthCheck"; import { useInfo } from "src/hooks/useInfo"; import { useNotifyReceivedPayments } from "src/hooks/useNotifyReceivedPayments"; import { useRemoveSuccessfulChannelOrder } from "src/hooks/useRemoveSuccessfulChannelOrder"; import { deleteAuthToken } from "src/lib/auth"; import { cn } from "src/lib/utils"; +import { HealthAlarm } from "src/types"; import { isHttpMode } from "src/utils/isHttpMode"; import { openLink } from "src/utils/openLink"; import ExternalLink from "../ExternalLink"; @@ -230,6 +233,7 @@ export default function AppLayout() { + @@ -285,9 +289,9 @@ export default function AppLayout() { - {/* align shield with x icon */} -
+
+
@@ -369,6 +373,65 @@ function AppVersion() { ); } +function HealthIndicator() { + const { data: health } = useHealthCheck(); + if (!health) { + return null; + } + + const ok = !health.alarms?.length; + + function getAlarmTitle(alarm: HealthAlarm) { + // TODO: could show extra data from alarm.rawDetails + // for some alarm types + switch (alarm.kind) { + case "alby_service": + return "One or more Alby Services are offline"; + case "channels_offline": + return "One or more channels are offline"; + case "node_not_ready": + return "Node is not ready"; + case "nostr_relay_offline": + return "Could not connect to relay"; + default: + return "Unknown error"; + } + } + + return ( + + + + +
+ + + + {ok ? ( +

Alby Hub is running

+ ) : ( +
+

+ {health.alarms.length} issues were found +

+
    + {health.alarms.map((alarm) => ( +
  • {getAlarmTitle(alarm)}
  • + ))} +
+
+ )} +
+ + + ); +} + const MenuItem = ({ to, children, diff --git a/frontend/src/hooks/useHealthCheck.ts b/frontend/src/hooks/useHealthCheck.ts new file mode 100644 index 000000000..52e1d7bc5 --- /dev/null +++ b/frontend/src/hooks/useHealthCheck.ts @@ -0,0 +1,16 @@ +import useSWR, { SWRConfiguration } from "swr"; + +import { HealthResponse } from "src/types"; +import { swrFetcher } from "src/utils/swr"; + +const pollConfiguration: SWRConfiguration = { + refreshInterval: 30000, +}; + +export function useHealthCheck(poll = true) { + return useSWR( + "/api/health", + swrFetcher, + poll ? pollConfiguration : undefined + ); +} diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 8ae29a196..5a103f36f 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -157,6 +157,21 @@ export interface InfoResponse { autoUnlockPasswordEnabled: boolean; } +export type HealthAlarmKind = + | "alby_service" + | "node_not_ready" + | "channels_offline" + | "nostr_relay_offline"; + +export type HealthAlarm = { + kind: HealthAlarmKind; + rawDetails: unknown; +}; + +export type HealthResponse = { + alarms: HealthAlarm[]; +}; + export type Network = "bitcoin" | "testnet" | "signet"; export type AppMetadata = { app_store_app_id?: string } & Record< diff --git a/http/http_service.go b/http/http_service.go index c312dfe12..7b72af8f4 100644 --- a/http/http_service.go +++ b/http/http_service.go @@ -153,6 +153,7 @@ func (httpSvc *HttpService) RegisterSharedRoutes(e *echo.Echo) { restrictedGroup.POST("/api/send-payment-probes", httpSvc.sendPaymentProbesHandler) restrictedGroup.POST("/api/send-spontaneous-payment-probes", httpSvc.sendSpontaneousPaymentProbesHandler) restrictedGroup.GET("/api/log/:type", httpSvc.getLogOutputHandler) + restrictedGroup.GET("/api/health", httpSvc.healthHandler) httpSvc.albyHttpSvc.RegisterSharedRoutes(restrictedGroup, e) } @@ -1072,3 +1073,14 @@ func (httpSvc *HttpService) restoreBackupHandler(c echo.Context) error { return c.NoContent(http.StatusNoContent) } + +func (httpSvc *HttpService) healthHandler(c echo.Context) error { + healthResponse, err := httpSvc.api.Health(c.Request().Context()) + if err != nil { + return c.JSON(http.StatusInternalServerError, ErrorResponse{ + Message: fmt.Sprintf("Failed to check node health: %v", err), + }) + } + + return c.JSON(http.StatusOK, healthResponse) +} diff --git a/lnclient/breez/breez.go b/lnclient/breez/breez.go index dfc37552c..37a04b5a2 100644 --- a/lnclient/breez/breez.go +++ b/lnclient/breez/breez.go @@ -426,7 +426,9 @@ func (bs *BreezService) GetLogOutput(ctx context.Context, maxLen int) ([]byte, e } func (bs *BreezService) GetNodeStatus(ctx context.Context) (nodeStatus *lnclient.NodeStatus, err error) { - return nil, nil + return &lnclient.NodeStatus{ + IsReady: true, + }, nil } func (bs *BreezService) SignMessage(ctx context.Context, message string) (string, error) { diff --git a/lnclient/cashu/cashu.go b/lnclient/cashu/cashu.go index 3b5107464..4febb000f 100644 --- a/lnclient/cashu/cashu.go +++ b/lnclient/cashu/cashu.go @@ -258,7 +258,9 @@ func (cs *CashuService) GetNetworkGraph(ctx context.Context, nodeIds []string) ( func (cs *CashuService) UpdateLastWalletSyncRequest() {} func (cs *CashuService) GetNodeStatus(ctx context.Context) (nodeStatus *lnclient.NodeStatus, err error) { - return nil, nil + return &lnclient.NodeStatus{ + IsReady: true, + }, nil } func (cs *CashuService) SendPaymentProbes(ctx context.Context, invoice string) error { diff --git a/lnclient/greenlight/greenlight.go b/lnclient/greenlight/greenlight.go index 303553f9b..5f76b6a0d 100644 --- a/lnclient/greenlight/greenlight.go +++ b/lnclient/greenlight/greenlight.go @@ -659,7 +659,9 @@ func (gs *GreenlightService) GetStorageDir() (string, error) { } func (gs *GreenlightService) GetNodeStatus(ctx context.Context) (nodeStatus *lnclient.NodeStatus, err error) { - return nil, nil + return &lnclient.NodeStatus{ + IsReady: true, + }, nil } func (gs *GreenlightService) GetNetworkGraph(ctx context.Context, nodeIds []string) (lnclient.NetworkGraphResponse, error) { diff --git a/lnclient/ldk/ldk.go b/lnclient/ldk/ldk.go index e12d2f335..438050bf2 100644 --- a/lnclient/ldk/ldk.go +++ b/lnclient/ldk/ldk.go @@ -255,35 +255,38 @@ func NewLDKService(ctx context.Context, cfg config.Config, eventPublisher events }).Info("LDK node synced successfully") if ls.network == "bitcoin" { - // try to connect to some peers to retrieve P2P gossip data. TODO: Remove once LDK can correctly do gossip with CLN and Eclair nodes - // see https://github.com/lightningdevkit/rust-lightning/issues/3075 - peers := []string{ - "031b301307574bbe9b9ac7b79cbe1700e31e544513eae0b5d7497483083f99e581@45.79.192.236:9735", // Olympus - "0364913d18a19c671bb36dd04d6ad5be0fe8f2894314c36a9db3f03c2d414907e1@192.243.215.102:9735", // LQwD - "035e4ff418fc8b5554c5d9eea66396c227bd429a3251c8cbc711002ba215bfc226@170.75.163.209:9735", // WoS - "02fcc5bfc48e83f06c04483a2985e1c390cb0f35058baa875ad2053858b8e80dbd@35.239.148.251:9735", // Blink - "027100442c3b79f606f80f322d98d499eefcb060599efc5d4ecb00209c2cb54190@3.230.33.224:9735", // c= - "038a9e56512ec98da2b5789761f7af8f280baf98a09282360cd6ff1381b5e889bf@64.23.162.51:9735", // Megalith LSP - } - logger.Logger.Info("Connecting to some peers to retrieve P2P gossip data") - for _, peer := range peers { - parts := strings.FieldsFunc(peer, func(r rune) bool { return r == '@' || r == ':' }) - port, err := strconv.ParseUint(parts[2], 10, 16) - if err != nil { - logger.Logger.WithError(err).Error("Failed to parse port number") - continue + go func() { + // try to connect to some peers in the background to retrieve P2P gossip data. + // TODO: Remove once LDK can correctly do gossip with CLN and Eclair nodes + // see https://github.com/lightningdevkit/rust-lightning/issues/3075 + peers := []string{ + "031b301307574bbe9b9ac7b79cbe1700e31e544513eae0b5d7497483083f99e581@45.79.192.236:9735", // Olympus + "0364913d18a19c671bb36dd04d6ad5be0fe8f2894314c36a9db3f03c2d414907e1@192.243.215.102:9735", // LQwD + "035e4ff418fc8b5554c5d9eea66396c227bd429a3251c8cbc711002ba215bfc226@170.75.163.209:9735", // WoS + "02fcc5bfc48e83f06c04483a2985e1c390cb0f35058baa875ad2053858b8e80dbd@35.239.148.251:9735", // Blink + // "027100442c3b79f606f80f322d98d499eefcb060599efc5d4ecb00209c2cb54190@3.230.33.224:9735", // c= + "038a9e56512ec98da2b5789761f7af8f280baf98a09282360cd6ff1381b5e889bf@64.23.162.51:9735", // Megalith LSP } - err = ls.ConnectPeer(ctx, &lnclient.ConnectPeerRequest{ - Pubkey: parts[0], - Address: parts[1], - Port: uint16(port), - }) - if err != nil { - logger.Logger.WithFields(logrus.Fields{ - "peer": peer, - }).WithError(err).Error("Failed to connect to peer") + logger.Logger.Info("Connecting to some peers to retrieve P2P gossip data") + for _, peer := range peers { + parts := strings.FieldsFunc(peer, func(r rune) bool { return r == '@' || r == ':' }) + port, err := strconv.ParseUint(parts[2], 10, 16) + if err != nil { + logger.Logger.WithError(err).Error("Failed to parse port number") + continue + } + err = ls.ConnectPeer(ctx, &lnclient.ConnectPeerRequest{ + Pubkey: parts[0], + Address: parts[1], + Port: uint16(port), + }) + if err != nil { + logger.Logger.WithFields(logrus.Fields{ + "peer": peer, + }).WithError(err).Error("Failed to connect to peer") + } } - } + }() } // setup background sync @@ -1644,8 +1647,10 @@ func deleteOldLDKLogs(ldkLogDir string) { } func (ls *LDKService) GetNodeStatus(ctx context.Context) (nodeStatus *lnclient.NodeStatus, err error) { + status := ls.node.Status() return &lnclient.NodeStatus{ - InternalNodeStatus: ls.node.Status(), + IsReady: status.IsRunning && status.IsListening, + InternalNodeStatus: status, }, nil } diff --git a/lnclient/lnd/lnd.go b/lnclient/lnd/lnd.go index 46d9bd472..4c630802c 100644 --- a/lnclient/lnd/lnd.go +++ b/lnclient/lnd/lnd.go @@ -1060,7 +1060,7 @@ func lndPaymentToTransaction(payment *lnrpc.Payment) (*lnclient.Transaction, err DescriptionHash: descriptionHash, ExpiresAt: expiresAt, SettledAt: settledAt, - //TODO: Metadata: (e.g. keysend), + // TODO: Metadata: (e.g. keysend), }, nil } @@ -1131,6 +1131,7 @@ func (svc *LNDService) GetNodeStatus(ctx context.Context) (nodeStatus *lnclient. } return &lnclient.NodeStatus{ + IsReady: true, // Assuming that, if GetNodeInfo() succeeds, the node is online and accessible. InternalNodeStatus: map[string]interface{}{ "info": info, "config": debugInfo.Config, diff --git a/lnclient/models.go b/lnclient/models.go index c5098866f..25f1cf0c5 100644 --- a/lnclient/models.go +++ b/lnclient/models.go @@ -100,6 +100,7 @@ type Channel struct { } type NodeStatus struct { + IsReady bool `json:"isReady"` InternalNodeStatus interface{} `json:"internalNodeStatus"` } diff --git a/lnclient/phoenixd/phoenixd.go b/lnclient/phoenixd/phoenixd.go index 21d8f5908..7f40cfe8c 100644 --- a/lnclient/phoenixd/phoenixd.go +++ b/lnclient/phoenixd/phoenixd.go @@ -507,7 +507,9 @@ func (svc *PhoenixService) GetLogOutput(ctx context.Context, maxLen int) ([]byte } func (svc *PhoenixService) GetNodeStatus(ctx context.Context) (nodeStatus *lnclient.NodeStatus, err error) { - return nil, nil + return &lnclient.NodeStatus{ + IsReady: true, + }, nil } func (svc *PhoenixService) GetStorageDir() (string, error) { diff --git a/service/models.go b/service/models.go index edb41bd7b..c7e112d65 100644 --- a/service/models.go +++ b/service/models.go @@ -1,13 +1,14 @@ package service import ( + "gorm.io/gorm" + "github.com/getAlby/hub/alby" "github.com/getAlby/hub/config" "github.com/getAlby/hub/events" "github.com/getAlby/hub/lnclient" "github.com/getAlby/hub/service/keys" "github.com/getAlby/hub/transactions" - "gorm.io/gorm" ) type Service interface { @@ -23,4 +24,5 @@ type Service interface { GetDB() *gorm.DB GetConfig() config.Config GetKeys() keys.Keys + IsRelayReady() bool } diff --git a/service/service.go b/service/service.go index f6bf0dce8..14475f400 100644 --- a/service/service.go +++ b/service/service.go @@ -250,3 +250,11 @@ func (svc *service) GetTransactionsService() transactions.TransactionsService { func (svc *service) GetKeys() keys.Keys { return svc.keys } + +func (svc *service) setRelayReady(ready bool) { + svc.isRelayReady.Store(ready) +} + +func (svc *service) IsRelayReady() bool { + return svc.isRelayReady.Load() +} diff --git a/service/start.go b/service/start.go index 12d97d971..63b15d552 100644 --- a/service/start.go +++ b/service/start.go @@ -430,11 +430,3 @@ func (svc *service) requestVssToken(ctx context.Context) (string, error) { } return vssToken, nil } - -func (svc *service) setRelayReady(ready bool) { - svc.isRelayReady.Store(ready) -} - -func (svc *service) IsRelayReady() bool { - return svc.isRelayReady.Load() -} diff --git a/tests/mock_ln_client.go b/tests/mock_ln_client.go index d876136b4..e6e30af6d 100644 --- a/tests/mock_ln_client.go +++ b/tests/mock_ln_client.go @@ -168,7 +168,9 @@ func (mln *MockLn) GetStorageDir() (string, error) { return "", nil } func (mln *MockLn) GetNodeStatus(ctx context.Context) (nodeStatus *lnclient.NodeStatus, err error) { - return nil, nil + return &lnclient.NodeStatus{ + IsReady: true, + }, nil } func (mln *MockLn) GetNetworkGraph(ctx context.Context, nodeIds []string) (lnclient.NetworkGraphResponse, error) { return nil, nil diff --git a/wails/wails_handlers.go b/wails/wails_handlers.go index e1d0053b6..5826ff0c9 100644 --- a/wails/wails_handlers.go +++ b/wails/wails_handlers.go @@ -11,10 +11,11 @@ import ( "github.com/sirupsen/logrus" + "github.com/wailsapp/wails/v2/pkg/runtime" + "github.com/getAlby/hub/alby" "github.com/getAlby/hub/api" "github.com/getAlby/hub/logger" - "github.com/wailsapp/wails/v2/pkg/runtime" ) type WailsRequestRouterResponse struct { @@ -909,6 +910,17 @@ func (app *WailsApp) WailsRequestRouter(route string, method string, body string return WailsRequestRouterResponse{Body: nil, Error: err.Error()} } return WailsRequestRouterResponse{Body: nil, Error: ""} + case "/api/health": + nodeHealth, err := app.api.Health(ctx) + if err != nil { + logger.Logger.WithFields(logrus.Fields{ + "route": route, + "method": method, + "body": body, + }).WithError(err).Error("Failed to check node health") + return WailsRequestRouterResponse{Body: nil, Error: err.Error()} + } + return WailsRequestRouterResponse{Body: *nodeHealth, Error: ""} } if strings.HasPrefix(route, "/api/log/") {