diff --git a/api/api.go b/api/api.go
index 8116ef2a..c81d0cb9 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 0408d2da..73593f90 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 2e91f57b..b20089bc 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 00000000..52e1d7bc
--- /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 8ae29a19..5a103f36 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 c312dfe1..7b72af8f 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 dfc37552..37a04b5a 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 3b510746..4febb000 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 303553f9..5f76b6a0 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 e12d2f33..438050bf 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 46d9bd47..4c630802 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 c5098866..25f1cf0c 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 21d8f590..7f40cfe8 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 edb41bd7..c7e112d6 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 f6bf0dce..14475f40 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 12d97d97..63b15d55 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 d876136b..e6e30af6 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 e1d0053b..5826ff0c 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/") {