Skip to content

Commit ff9ef56

Browse files
rdmitrrolznz
andauthored
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 <roland.bewick@gmail.com>
1 parent fae02f3 commit ff9ef56

File tree

18 files changed

+253
-46
lines changed

18 files changed

+253
-46
lines changed

api/api.go

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1024,6 +1024,50 @@ func (api *api) GetLogOutput(ctx context.Context, logType string, getLogRequest
10241024
return &GetLogOutputResponse{Log: string(logData)}, nil
10251025
}
10261026

1027+
func (api *api) Health(ctx context.Context) (*HealthResponse, error) {
1028+
var alarms []HealthAlarm
1029+
1030+
albyInfo, err := api.albyOAuthSvc.GetInfo(ctx)
1031+
if err != nil {
1032+
return nil, err
1033+
}
1034+
if !albyInfo.Healthy {
1035+
alarms = append(alarms, NewHealthAlarm(HealthAlarmKindAlbyService, albyInfo.Incidents))
1036+
}
1037+
1038+
isNostrRelayReady := api.svc.IsRelayReady()
1039+
if !isNostrRelayReady {
1040+
alarms = append(alarms, NewHealthAlarm(HealthAlarmKindNostrRelayOffline, nil))
1041+
}
1042+
1043+
lnClient := api.svc.GetLNClient()
1044+
1045+
if lnClient != nil {
1046+
nodeStatus, err := lnClient.GetNodeStatus(ctx)
1047+
if err != nil {
1048+
return nil, err
1049+
}
1050+
if nodeStatus == nil || !nodeStatus.IsReady {
1051+
alarms = append(alarms, NewHealthAlarm(HealthAlarmKindNodeNotReady, nodeStatus))
1052+
}
1053+
1054+
channels, err := lnClient.ListChannels(ctx)
1055+
if err != nil {
1056+
return nil, err
1057+
}
1058+
1059+
offlineChannels := slices.DeleteFunc(channels, func(channel lnclient.Channel) bool {
1060+
return channel.Active
1061+
})
1062+
1063+
if len(offlineChannels) > 0 {
1064+
alarms = append(alarms, NewHealthAlarm(HealthAlarmKindChannelsOffline, nil))
1065+
}
1066+
}
1067+
1068+
return &HealthResponse{Alarms: alarms}, nil
1069+
}
1070+
10271071
func (api *api) parseExpiresAt(expiresAtString string) (*time.Time, error) {
10281072
var expiresAt *time.Time
10291073
if expiresAtString != "" {

api/models.go

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ type API interface {
5656
RestoreBackup(unlockPassword string, r io.Reader) error
5757
MigrateNodeStorage(ctx context.Context, to string) error
5858
GetWalletCapabilities(ctx context.Context) (*WalletCapabilitiesResponse, error)
59+
Health(ctx context.Context) (*HealthResponse, error)
5960
}
6061

6162
type App struct {
@@ -366,3 +367,28 @@ type Channel struct {
366367
type MigrateNodeStorageRequest struct {
367368
To string `json:"to"`
368369
}
370+
371+
type HealthAlarmKind string
372+
373+
const (
374+
HealthAlarmKindAlbyService HealthAlarmKind = "alby_service"
375+
HealthAlarmKindNodeNotReady = "node_not_ready"
376+
HealthAlarmKindChannelsOffline = "channels_offline"
377+
HealthAlarmKindNostrRelayOffline = "nostr_relay_offline"
378+
)
379+
380+
type HealthAlarm struct {
381+
Kind HealthAlarmKind `json:"kind"`
382+
RawDetails any `json:"rawDetails,omitempty"`
383+
}
384+
385+
func NewHealthAlarm(kind HealthAlarmKind, rawDetails any) HealthAlarm {
386+
return HealthAlarm{
387+
Kind: kind,
388+
RawDetails: rawDetails,
389+
}
390+
}
391+
392+
type HealthResponse struct {
393+
Alarms []HealthAlarm `json:"alarms,omitempty"`
394+
}

frontend/src/components/layouts/AppLayout.tsx

Lines changed: 65 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,12 +48,15 @@ import {
4848
} from "src/components/ui/tooltip";
4949
import { useAlbyMe } from "src/hooks/useAlbyMe";
5050

51+
import clsx from "clsx";
5152
import { useAlbyInfo } from "src/hooks/useAlbyInfo";
53+
import { useHealthCheck } from "src/hooks/useHealthCheck";
5254
import { useInfo } from "src/hooks/useInfo";
5355
import { useNotifyReceivedPayments } from "src/hooks/useNotifyReceivedPayments";
5456
import { useRemoveSuccessfulChannelOrder } from "src/hooks/useRemoveSuccessfulChannelOrder";
5557
import { deleteAuthToken } from "src/lib/auth";
5658
import { cn } from "src/lib/utils";
59+
import { HealthAlarm } from "src/types";
5760
import { isHttpMode } from "src/utils/isHttpMode";
5861
import { openLink } from "src/utils/openLink";
5962
import ExternalLink from "../ExternalLink";
@@ -230,6 +233,7 @@ export default function AppLayout() {
230233
<AlbyHubLogo className="text-foreground" />
231234
</Link>
232235
<AppVersion />
236+
<HealthIndicator />
233237
</div>
234238
<MainMenuContent />
235239
</nav>
@@ -285,9 +289,9 @@ export default function AppLayout() {
285289
<Link to="/">
286290
<AlbyHubLogo className="text-foreground" />
287291
</Link>
288-
{/* align shield with x icon */}
289-
<div className="mr-2">
292+
<div className="mr-1 flex gap-2 items-center justify-center">
290293
<AppVersion />
294+
<HealthIndicator />
291295
</div>
292296
</div>
293297
<MainMenuContent />
@@ -369,6 +373,65 @@ function AppVersion() {
369373
);
370374
}
371375

376+
function HealthIndicator() {
377+
const { data: health } = useHealthCheck();
378+
if (!health) {
379+
return null;
380+
}
381+
382+
const ok = !health.alarms?.length;
383+
384+
function getAlarmTitle(alarm: HealthAlarm) {
385+
// TODO: could show extra data from alarm.rawDetails
386+
// for some alarm types
387+
switch (alarm.kind) {
388+
case "alby_service":
389+
return "One or more Alby Services are offline";
390+
case "channels_offline":
391+
return "One or more channels are offline";
392+
case "node_not_ready":
393+
return "Node is not ready";
394+
case "nostr_relay_offline":
395+
return "Could not connect to relay";
396+
default:
397+
return "Unknown error";
398+
}
399+
}
400+
401+
return (
402+
<TooltipProvider>
403+
<Tooltip>
404+
<TooltipTrigger>
405+
<span className="text-xs flex items-center text-muted-foreground">
406+
<div
407+
className={clsx(
408+
"w-2 h-2 rounded-full",
409+
ok ? "bg-green-300" : "bg-destructive"
410+
)}
411+
/>
412+
</span>
413+
</TooltipTrigger>
414+
<TooltipContent>
415+
{ok ? (
416+
<p>Alby Hub is running</p>
417+
) : (
418+
<div>
419+
<p className="font-semibold">
420+
{health.alarms.length} issues were found
421+
</p>
422+
<ul className="mt-2 max-w-xs whitespace-pre-wrap list-disc list-inside">
423+
{health.alarms.map((alarm) => (
424+
<li key={alarm.kind}>{getAlarmTitle(alarm)}</li>
425+
))}
426+
</ul>
427+
</div>
428+
)}
429+
</TooltipContent>
430+
</Tooltip>
431+
</TooltipProvider>
432+
);
433+
}
434+
372435
const MenuItem = ({
373436
to,
374437
children,
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import useSWR, { SWRConfiguration } from "swr";
2+
3+
import { HealthResponse } from "src/types";
4+
import { swrFetcher } from "src/utils/swr";
5+
6+
const pollConfiguration: SWRConfiguration = {
7+
refreshInterval: 30000,
8+
};
9+
10+
export function useHealthCheck(poll = true) {
11+
return useSWR<HealthResponse>(
12+
"/api/health",
13+
swrFetcher,
14+
poll ? pollConfiguration : undefined
15+
);
16+
}

frontend/src/types.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,21 @@ export interface InfoResponse {
157157
autoUnlockPasswordEnabled: boolean;
158158
}
159159

160+
export type HealthAlarmKind =
161+
| "alby_service"
162+
| "node_not_ready"
163+
| "channels_offline"
164+
| "nostr_relay_offline";
165+
166+
export type HealthAlarm = {
167+
kind: HealthAlarmKind;
168+
rawDetails: unknown;
169+
};
170+
171+
export type HealthResponse = {
172+
alarms: HealthAlarm[];
173+
};
174+
160175
export type Network = "bitcoin" | "testnet" | "signet";
161176

162177
export type AppMetadata = { app_store_app_id?: string } & Record<

http/http_service.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,7 @@ func (httpSvc *HttpService) RegisterSharedRoutes(e *echo.Echo) {
153153
restrictedGroup.POST("/api/send-payment-probes", httpSvc.sendPaymentProbesHandler)
154154
restrictedGroup.POST("/api/send-spontaneous-payment-probes", httpSvc.sendSpontaneousPaymentProbesHandler)
155155
restrictedGroup.GET("/api/log/:type", httpSvc.getLogOutputHandler)
156+
restrictedGroup.GET("/api/health", httpSvc.healthHandler)
156157

157158
httpSvc.albyHttpSvc.RegisterSharedRoutes(restrictedGroup, e)
158159
}
@@ -1072,3 +1073,14 @@ func (httpSvc *HttpService) restoreBackupHandler(c echo.Context) error {
10721073

10731074
return c.NoContent(http.StatusNoContent)
10741075
}
1076+
1077+
func (httpSvc *HttpService) healthHandler(c echo.Context) error {
1078+
healthResponse, err := httpSvc.api.Health(c.Request().Context())
1079+
if err != nil {
1080+
return c.JSON(http.StatusInternalServerError, ErrorResponse{
1081+
Message: fmt.Sprintf("Failed to check node health: %v", err),
1082+
})
1083+
}
1084+
1085+
return c.JSON(http.StatusOK, healthResponse)
1086+
}

lnclient/breez/breez.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -426,7 +426,9 @@ func (bs *BreezService) GetLogOutput(ctx context.Context, maxLen int) ([]byte, e
426426
}
427427

428428
func (bs *BreezService) GetNodeStatus(ctx context.Context) (nodeStatus *lnclient.NodeStatus, err error) {
429-
return nil, nil
429+
return &lnclient.NodeStatus{
430+
IsReady: true,
431+
}, nil
430432
}
431433

432434
func (bs *BreezService) SignMessage(ctx context.Context, message string) (string, error) {

lnclient/cashu/cashu.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -258,7 +258,9 @@ func (cs *CashuService) GetNetworkGraph(ctx context.Context, nodeIds []string) (
258258
func (cs *CashuService) UpdateLastWalletSyncRequest() {}
259259

260260
func (cs *CashuService) GetNodeStatus(ctx context.Context) (nodeStatus *lnclient.NodeStatus, err error) {
261-
return nil, nil
261+
return &lnclient.NodeStatus{
262+
IsReady: true,
263+
}, nil
262264
}
263265

264266
func (cs *CashuService) SendPaymentProbes(ctx context.Context, invoice string) error {

lnclient/greenlight/greenlight.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -659,7 +659,9 @@ func (gs *GreenlightService) GetStorageDir() (string, error) {
659659
}
660660

661661
func (gs *GreenlightService) GetNodeStatus(ctx context.Context) (nodeStatus *lnclient.NodeStatus, err error) {
662-
return nil, nil
662+
return &lnclient.NodeStatus{
663+
IsReady: true,
664+
}, nil
663665
}
664666

665667
func (gs *GreenlightService) GetNetworkGraph(ctx context.Context, nodeIds []string) (lnclient.NetworkGraphResponse, error) {

lnclient/ldk/ldk.go

Lines changed: 33 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -255,35 +255,38 @@ func NewLDKService(ctx context.Context, cfg config.Config, eventPublisher events
255255
}).Info("LDK node synced successfully")
256256

257257
if ls.network == "bitcoin" {
258-
// try to connect to some peers to retrieve P2P gossip data. TODO: Remove once LDK can correctly do gossip with CLN and Eclair nodes
259-
// see https://github.com/lightningdevkit/rust-lightning/issues/3075
260-
peers := []string{
261-
"031b301307574bbe9b9ac7b79cbe1700e31e544513eae0b5d7497483083f99e581@45.79.192.236:9735", // Olympus
262-
"0364913d18a19c671bb36dd04d6ad5be0fe8f2894314c36a9db3f03c2d414907e1@192.243.215.102:9735", // LQwD
263-
"035e4ff418fc8b5554c5d9eea66396c227bd429a3251c8cbc711002ba215bfc226@170.75.163.209:9735", // WoS
264-
"02fcc5bfc48e83f06c04483a2985e1c390cb0f35058baa875ad2053858b8e80dbd@35.239.148.251:9735", // Blink
265-
"027100442c3b79f606f80f322d98d499eefcb060599efc5d4ecb00209c2cb54190@3.230.33.224:9735", // c=
266-
"038a9e56512ec98da2b5789761f7af8f280baf98a09282360cd6ff1381b5e889bf@64.23.162.51:9735", // Megalith LSP
267-
}
268-
logger.Logger.Info("Connecting to some peers to retrieve P2P gossip data")
269-
for _, peer := range peers {
270-
parts := strings.FieldsFunc(peer, func(r rune) bool { return r == '@' || r == ':' })
271-
port, err := strconv.ParseUint(parts[2], 10, 16)
272-
if err != nil {
273-
logger.Logger.WithError(err).Error("Failed to parse port number")
274-
continue
258+
go func() {
259+
// try to connect to some peers in the background to retrieve P2P gossip data.
260+
// TODO: Remove once LDK can correctly do gossip with CLN and Eclair nodes
261+
// see https://github.com/lightningdevkit/rust-lightning/issues/3075
262+
peers := []string{
263+
"031b301307574bbe9b9ac7b79cbe1700e31e544513eae0b5d7497483083f99e581@45.79.192.236:9735", // Olympus
264+
"0364913d18a19c671bb36dd04d6ad5be0fe8f2894314c36a9db3f03c2d414907e1@192.243.215.102:9735", // LQwD
265+
"035e4ff418fc8b5554c5d9eea66396c227bd429a3251c8cbc711002ba215bfc226@170.75.163.209:9735", // WoS
266+
"02fcc5bfc48e83f06c04483a2985e1c390cb0f35058baa875ad2053858b8e80dbd@35.239.148.251:9735", // Blink
267+
// "027100442c3b79f606f80f322d98d499eefcb060599efc5d4ecb00209c2cb54190@3.230.33.224:9735", // c=
268+
"038a9e56512ec98da2b5789761f7af8f280baf98a09282360cd6ff1381b5e889bf@64.23.162.51:9735", // Megalith LSP
275269
}
276-
err = ls.ConnectPeer(ctx, &lnclient.ConnectPeerRequest{
277-
Pubkey: parts[0],
278-
Address: parts[1],
279-
Port: uint16(port),
280-
})
281-
if err != nil {
282-
logger.Logger.WithFields(logrus.Fields{
283-
"peer": peer,
284-
}).WithError(err).Error("Failed to connect to peer")
270+
logger.Logger.Info("Connecting to some peers to retrieve P2P gossip data")
271+
for _, peer := range peers {
272+
parts := strings.FieldsFunc(peer, func(r rune) bool { return r == '@' || r == ':' })
273+
port, err := strconv.ParseUint(parts[2], 10, 16)
274+
if err != nil {
275+
logger.Logger.WithError(err).Error("Failed to parse port number")
276+
continue
277+
}
278+
err = ls.ConnectPeer(ctx, &lnclient.ConnectPeerRequest{
279+
Pubkey: parts[0],
280+
Address: parts[1],
281+
Port: uint16(port),
282+
})
283+
if err != nil {
284+
logger.Logger.WithFields(logrus.Fields{
285+
"peer": peer,
286+
}).WithError(err).Error("Failed to connect to peer")
287+
}
285288
}
286-
}
289+
}()
287290
}
288291

289292
// setup background sync
@@ -1644,8 +1647,10 @@ func deleteOldLDKLogs(ldkLogDir string) {
16441647
}
16451648

16461649
func (ls *LDKService) GetNodeStatus(ctx context.Context) (nodeStatus *lnclient.NodeStatus, err error) {
1650+
status := ls.node.Status()
16471651
return &lnclient.NodeStatus{
1648-
InternalNodeStatus: ls.node.Status(),
1652+
IsReady: status.IsRunning && status.IsListening,
1653+
InternalNodeStatus: status,
16491654
}, nil
16501655
}
16511656

0 commit comments

Comments
 (0)