feat: surface connection issues in hub

This commit is contained in:
saunter 2026-05-12 18:35:24 +02:00
parent 162965300e
commit c78cc466ef
26 changed files with 469 additions and 27 deletions

View file

@ -505,6 +505,42 @@ func (api *api) GetApp(dbApp *db.App) (*App, error) {
return &response, nil
}
func (api *api) ListConnectionIssues(appId uint, limit uint64) ([]ConnectionIssue, error) {
if limit == 0 || limit > 20 {
limit = 10
}
dbIssues := []db.ConnectionIssue{}
err := api.db.
Where("app_id = ?", appId).
Order("created_at DESC").
Limit(int(limit)).
Find(&dbIssues).
Error
if err != nil {
logger.Logger.WithError(err).WithFields(logrus.Fields{
"app_id": appId,
}).Error("Failed to list connection issues")
return nil, err
}
issues := make([]ConnectionIssue, len(dbIssues))
for i, issue := range dbIssues {
issues[i] = ConnectionIssue{
ID: issue.ID,
AppId: issue.AppId,
RequestEventId: issue.RequestEventId,
Method: issue.Method,
Category: issue.Category,
ErrorCode: issue.ErrorCode,
ErrorMessage: issue.ErrorMessage,
CreatedAt: issue.CreatedAt,
}
}
return issues, nil
}
func (api *api) ListApps(limit uint64, offset uint64, filters ListAppsFilters, orderBy string) (*ListAppsResponse, error) {
// TODO: join dbApps and permissions
dbApps := []db.App{}

View file

@ -19,6 +19,7 @@ type API interface {
DeleteApp(app *db.App) error
GetApp(app *db.App) (*App, error)
ListApps(limit uint64, offset uint64, filters ListAppsFilters, orderBy string) (*ListAppsResponse, error)
ListConnectionIssues(appId uint, limit uint64) ([]ConnectionIssue, error)
CreateLightningAddress(ctx context.Context, createLightningAddressRequest *CreateLightningAddressRequest) error
DeleteLightningAddress(ctx context.Context, appId uint) error
ListChannels(ctx context.Context) ([]Channel, error)
@ -129,6 +130,17 @@ type ListAppsResponse struct {
TotalBalanceMsat *int64 `json:"totalBalanceMsat,omitempty"`
}
type ConnectionIssue struct {
ID uint `json:"id"`
AppId uint `json:"appId"`
RequestEventId uint `json:"requestEventId"`
Method string `json:"method"`
Category string `json:"category"`
ErrorCode string `json:"errorCode"`
ErrorMessage string `json:"errorMessage"`
CreatedAt time.Time `json:"createdAt"`
}
type UpdateAppRequest struct {
Name *string `json:"name"`
MaxAmount *uint64 `json:"maxAmount"` // deprecated

View file

@ -19,6 +19,7 @@ var expectedTables = []string{
"app_permissions",
"request_events",
"response_events",
"connection_issues",
"transactions",
"swaps",
"user_configs",
@ -151,6 +152,11 @@ func migrateDB(from, to *gorm.DB) error {
return fmt.Errorf("failed to migrate response_events: %w", err)
}
logger.Logger.Info("migrating connection_issues...")
if err := migrateTable[db.ConnectionIssue](from, tx); err != nil {
return fmt.Errorf("failed to migrate connection_issues: %w", err)
}
logger.Logger.Info("migrating transactions...")
if err := migrateTable[db.Transaction](from, tx); err != nil {
return fmt.Errorf("failed to migrate transactions: %w", err)
@ -270,6 +276,7 @@ func resetSequences(db *gorm.DB) error {
{"app_permissions", "app_permissions_2_id_seq"},
{"request_events", "request_events_id_seq"},
{"response_events", "response_events_id_seq"},
{"connection_issues", "connection_issues_id_seq"},
{"transactions", "transactions_id_seq"},
{"user_configs", "user_configs_id_seq"},
}

View file

@ -0,0 +1,38 @@
package migrations
import (
"text/template"
"github.com/go-gormigrate/gormigrate/v2"
"gorm.io/gorm"
)
const connectionIssuesMigration = `
CREATE TABLE connection_issues(
id {{ .AutoincrementPrimaryKey }},
app_id integer NOT NULL,
request_event_id integer NOT NULL,
method text,
category text NOT NULL,
error_code text,
error_message text,
created_at {{ .Timestamp }},
updated_at {{ .Timestamp }},
CONSTRAINT fk_connection_issues_app FOREIGN KEY (app_id) REFERENCES apps(id) ON DELETE CASCADE,
CONSTRAINT fk_connection_issues_request_event FOREIGN KEY (request_event_id) REFERENCES request_events(id) ON DELETE CASCADE
);
CREATE INDEX idx_connection_issues_app_id_created_at ON connection_issues(app_id, created_at);
CREATE INDEX idx_connection_issues_request_event_id ON connection_issues(request_event_id);
`
var connectionIssuesMigrationTmpl = template.Must(template.New("connectionIssuesMigration").Parse(connectionIssuesMigration))
var _202605121200_connection_issues = &gormigrate.Migration{
ID: "202605121200_connection_issues",
Migrate: func(tx *gorm.DB) error {
return exec(tx, connectionIssuesMigrationTmpl)
},
Rollback: func(tx *gorm.DB) error {
return nil
},
}

View file

@ -39,6 +39,7 @@ func Migrate(gormDB *gorm.DB) error {
_202508192137_forwards,
_202509031250_transactions_updated_at_index,
_202604081200_app_last_settled_transaction,
_202605121200_connection_issues,
})
return m.Migrate()

View file

@ -63,6 +63,20 @@ type ResponseEvent struct {
UpdatedAt time.Time
}
type ConnectionIssue struct {
ID uint
AppId uint `validate:"required"`
App App
RequestEventId uint `validate:"required"`
RequestEvent RequestEvent
Method string
Category string
ErrorCode string
ErrorMessage string
CreatedAt time.Time
UpdatedAt time.Time
}
type Transaction struct {
ID uint
AppId *uint

View file

@ -37,7 +37,7 @@ export function DisconnectPeerDialogContent({ peer }: Props) {
await reloadPeers();
} catch (e) {
console.error(e);
toast.error("Failed to disconnect peer", {
toast.error("Peer was not disconnected", {
description: "" + e,
});
}

View file

@ -38,11 +38,11 @@ export function InsufficientLightningBalanceAlert({
return (
<Alert className={className}>
<AlertTriangleIcon className="h-4 w-4" />
<AlertTitle>Maximum Spendable Balance Too Low</AlertTitle>
<AlertTitle>Not enough spendable balance</AlertTitle>
<AlertDescription>
<p>
Your payment will likely fail because your maximum spendable balance
in your lightning channels for the next payment is currently{" "}
This payment is above the wallet's current spendable balance. The most
you can send right now is{" "}
<FormattedBitcoinAmount amountMsat={maxSpendableMsat} />.
</p>
<div className="flex gap-2 mt-2 items-center justify-center">

View file

@ -10,9 +10,10 @@ export default function LowReceivingCapacityAlert() {
return (
<Alert variant="warning">
<AlertTriangleIcon className="h-4 w-4" />
<AlertTitle>Low receiving capacity</AlertTitle>
<AlertTitle>You need more receiving capacity</AlertTitle>
<AlertDescription className="inline">
You likely won't be able to receive payments until you{" "}
This wallet cannot receive larger payments right now. Add receiving
capacity,{" "}
<Link className="underline" to="/wallet/send">
spend
</Link>
@ -22,7 +23,7 @@ export default function LowReceivingCapacityAlert() {
</Link>
, or{" "}
<Link className="underline" to="/channels/incoming">
increase your receiving capacity.
open an incoming channel.
</Link>
</AlertDescription>
</Alert>

View file

@ -44,11 +44,11 @@ export function PaymentFailedAlert({
return (
<Alert>
<TriangleAlertIcon className="h-4 w-4" />
<AlertTitle>Payment Failed</AlertTitle>
<AlertTitle>Payment was not sent</AlertTitle>
<AlertDescription>
<p>
Try the payment again, read our payments guide, and optionally send
details about the failed payment to help improve Alby Hub.
Alby Hub could not complete this payment. No sats were sent unless you
see it in your transactions.
</p>
<div className="flex flex-wrap gap-2 mt-2">
<ExternalLinkButton

View file

@ -42,7 +42,7 @@ export function UnlinkAlbyAccount({
description: successMessage,
});
} catch (error) {
toast.error("Disconnect account failed", {
toast.error("Alby Account was not disconnected", {
description: (error as Error).message,
});
}

View file

@ -0,0 +1,92 @@
import { TriangleAlertIcon } from "lucide-react";
import { Link } from "react-router";
import {
Alert,
AlertAction,
AlertDescription,
AlertTitle,
} from "src/components/ui/alert";
import { Button } from "src/components/ui/button";
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "src/components/ui/card";
import { getConnectionIssueCopy } from "src/lib/connectionIssues";
import { ConnectionIssue } from "src/types";
export function ConnectionIssueAlert({
appName,
issue,
onViewDetails,
showTimestamp = true,
}: {
appName: string;
issue: ConnectionIssue;
onViewDetails: () => void;
showTimestamp?: boolean;
}) {
const copy = getConnectionIssueCopy(appName, issue, onViewDetails);
return (
<Alert variant="warning">
<TriangleAlertIcon />
<AlertTitle className="line-clamp-none">{copy.title}</AlertTitle>
<AlertDescription>
<p>{copy.description}</p>
<p className="font-mono text-xs break-all">
{issue.errorCode}: {issue.errorMessage}
</p>
{showTimestamp && (
<p className="text-xs">
{new Date(issue.createdAt).toLocaleString()}
</p>
)}
</AlertDescription>
<AlertAction>
{copy.href ? (
<Button asChild size="sm" variant="secondary">
<Link to={copy.href}>{copy.action}</Link>
</Button>
) : (
<Button size="sm" variant="secondary" onClick={copy.onClick}>
{copy.action}
</Button>
)}
</AlertAction>
</Alert>
);
}
export function ConnectionIssuesCard({
appName,
issues,
onViewDetails,
}: {
appName: string;
issues: ConnectionIssue[] | undefined;
onViewDetails: () => void;
}) {
if (!issues?.length) {
return null;
}
return (
<Card>
<CardHeader>
<CardTitle>Recent Connection Issues</CardTitle>
</CardHeader>
<CardContent className="grid gap-3">
{issues.map((issue) => (
<ConnectionIssueAlert
key={issue.id}
appName={appName}
issue={issue}
onViewDetails={onViewDetails}
/>
))}
</CardContent>
</Card>
);
}

View file

@ -1,6 +1,6 @@
import useSWR, { SWRConfiguration } from "swr";
import { App } from "src/types";
import { App, ConnectionIssue } from "src/types";
import { swrFetcher } from "src/utils/swr";
const pollConfiguration: SWRConfiguration = {
@ -14,3 +14,11 @@ export function useApp(id: number | undefined, poll = false) {
poll ? pollConfiguration : undefined
);
}
export function useConnectionIssues(appId: number | undefined) {
return useSWR<ConnectionIssue[]>(
!!appId && `/api/v2/apps/${appId}/issues?limit=5`,
swrFetcher,
pollConfiguration
);
}

View file

@ -0,0 +1,96 @@
import { ConnectionIssue } from "src/types";
export type ConnectionIssueCopy = {
title: string;
description: string;
action: string;
href?: string;
onClick?: () => void;
};
export function getMethodLabel(method: string) {
switch (method) {
case "pay_invoice":
case "multi_pay_invoice":
case "pay_keysend":
case "multi_pay_keysend":
return "send payments";
case "make_invoice":
return "create invoices";
case "lookup_invoice":
return "look up invoices";
case "list_transactions":
return "read transaction history";
case "get_balance":
return "read the balance";
case "get_info":
return "read wallet info";
case "sign_message":
return "sign messages";
default:
return "use this wallet feature";
}
}
export function getConnectionIssueCopy(
appName: string,
issue: ConnectionIssue,
onViewDetails: () => void
): ConnectionIssueCopy {
switch (issue.category) {
case "missing_permission":
return {
title: "App needs permission",
description: `This connection does not allow ${getMethodLabel(issue.method)} yet.`,
action: "Review connection",
href: `/apps/${issue.appId}?edit`,
};
case "unknown_method":
return {
title: "App requested an unknown feature",
description:
"Alby Hub does not recognize this app request. No wallet action was taken.",
action: "View details",
onClick: onViewDetails,
};
case "expired_connection":
return {
title: "Connection expired",
description:
"This app connection has expired. Review the connection to renew access.",
action: "Review connection",
href: `/apps/${issue.appId}?edit`,
};
case "budget_exceeded":
return {
title: "Connection budget reached",
description:
"This payment is above the connection's remaining budget. No payment was sent.",
action: "Review budget",
href: `/apps/${issue.appId}?edit`,
};
case "low_balance":
return {
title: "Not enough spendable balance",
description:
"This wallet does not have enough spendable sats for the app request.",
action: "View details",
onClick: onViewDetails,
};
case "payment_failed":
return {
title: "Payment was not sent",
description: `Alby Hub could not complete the payment requested by ${appName}. Check transactions if you are unsure.`,
action: "View details",
onClick: onViewDetails,
};
default:
return {
title: `${appName} request failed`,
description:
"Alby Hub could not complete this app request. No wallet action was taken unless you see it in your transactions.",
action: "View details",
onClick: onViewDetails,
};
}
}

View file

@ -65,7 +65,7 @@ export function MigrateNode() {
navigate("/create-node-migration-file-success");
} catch (error) {
handleRequestError("Failed to backup the node", error);
handleRequestError("Backup did not finish", error);
} finally {
setLoading(false);
}

View file

@ -29,6 +29,7 @@ import { AppLinksCard } from "src/components/connections/AppLinksCard";
import { AppTransactionList } from "src/components/connections/AppTransactionList";
import { AppUsage } from "src/components/connections/AppUsage";
import { ConnectionDetailsModal } from "src/components/connections/ConnectionDetailsModal";
import { ConnectionIssuesCard } from "src/components/connections/ConnectionIssuesCard";
import { DisconnectApp } from "src/components/connections/DisconnectApp";
import { getAppStoreApp } from "src/components/connections/SuggestedAppData";
import Loading from "src/components/Loading";
@ -64,7 +65,7 @@ import {
SUBWALLET_APPSTORE_APP_ID,
} from "src/constants";
import { useAlbyMe } from "src/hooks/useAlbyMe";
import { useApp } from "src/hooks/useApp";
import { useApp, useConnectionIssues } from "src/hooks/useApp";
import { useAppsForAppStoreApp } from "src/hooks/useApps";
import { useCapabilities } from "src/hooks/useCapabilities";
import { cn, getAppDisplayName } from "src/lib/utils";
@ -105,6 +106,7 @@ function AppInternal({ app, refetchApp, capabilities }: AppInternalProps) {
React.useState(false);
const [showDisconnectAppDialog, setShowDisconnectAppDialog] =
React.useState(false);
const { data: connectionIssues } = useConnectionIssues(app.id);
const { data: albyMe } = useAlbyMe();
@ -399,6 +401,11 @@ function AppInternal({ app, refetchApp, capabilities }: AppInternalProps) {
</div>
)}
<AppUsage app={app} />
<ConnectionIssuesCard
appName={appName}
issues={connectionIssues}
onViewDetails={() => setShowConnectionDetails(true)}
/>
</>
)}
{isEditingPermissions ? (

View file

@ -482,7 +482,7 @@ function PayBitcoinChannelOrderWithSpendableFunds({
});
} catch (error) {
console.error(error);
toast.error("Something went wrong", {
toast.error("Channel was not opened", {
description: "" + error,
});
}
@ -608,7 +608,7 @@ function PayLightningChannelOrder({ order }: { order: NewChannelOrder }) {
}
setLspOrderResponse(response);
} catch (error) {
toast.error("Something went wrong", {
toast.error("Channel order could not be created", {
description: "" + error,
});
}
@ -737,7 +737,7 @@ function PayLightningChannelOrder({ order }: { order: NewChannelOrder }) {
});
toast("Channel successfully requested");
} catch (e) {
toast.error("Failed to send: ", {
toast.error("Channel payment was not sent", {
description: "" + e,
});
console.error(e);

View file

@ -92,7 +92,10 @@ function NewChannelInternal({
React.useEffect(() => {
if (channelPeerSuggestionsError) {
toast.error("Failed to load channel suggestions");
toast.error("Channel suggestions could not be loaded", {
description:
"Alby Hub could not load receiving capacity options. You can still open a channel manually.",
});
navigate("/channels/outgoing");
}
}, [channelPeerSuggestionsError, navigate]);
@ -183,9 +186,9 @@ function NewChannelInternal({
}
if (!bestPartner) {
toast.error("No channel partner found", {
toast.error("No channel partner fits this request", {
description:
"No ideal channel partner found. Please choose from the advanced options to continue",
"Choose a different amount or use advanced options to continue.",
});
return;
}
@ -200,7 +203,7 @@ function NewChannelInternal({
useChannelOrderStore.getState().setOrder(nextOrder as NewChannelOrder);
navigate("/channels/order");
} catch (error) {
toast.error("Something went wrong", {
toast.error("Channel order could not be created", {
description: "" + error,
});
console.error(error);

View file

@ -193,7 +193,7 @@ function NewChannelInternal({
setShowConfirmModal(false);
navigate("/channels/order");
} catch (error) {
toast.error("Something went wrong", {
toast.error("Channel order could not be created", {
description: `${error}`,
});
setShowConfirmModal(false);

View file

@ -55,7 +55,7 @@ export default function ConnectPeer() {
setConnectionString("");
navigate("/peers");
} catch (e) {
toast.error("Failed to connect peer", {
toast.error("Peer is not connected", {
description: "" + e,
});
console.error(e);

View file

@ -34,9 +34,13 @@ export function AlbyAccount() {
</Card>
) : albyMeError ? (
<Card>
<CardContent className="flex items-center justify-center">
<p className="text-sm text-muted-foreground">
Failed to load Alby Account details
<CardContent className="flex flex-col gap-3 text-sm">
<h3 className="font-semibold">
Alby Account details could not be loaded
</h3>
<p className="text-muted-foreground">
Your Alby Account is still linked, but Alby Hub could not load
account details right now.
</p>
</CardContent>
</Card>

View file

@ -93,7 +93,7 @@ export function RestoreNode() {
setRestored(true);
} catch (error) {
handleRequestError("Failed to restore backup", error);
handleRequestError("Restore did not finish", error);
} finally {
setShowAlert(false);
setLoading(false);

View file

@ -141,6 +141,17 @@ export interface AppPermissions {
isolated: boolean;
}
export interface ConnectionIssue {
id: number;
appId: number;
requestEventId: number;
method: string;
category: string;
errorCode: string;
errorMessage: string;
createdAt: string;
}
export interface InfoResponse {
backendType: BackendType;
setupCompleted: boolean;

View file

@ -127,6 +127,7 @@ func (httpSvc *HttpService) RegisterSharedRoutes(e *echo.Echo) {
readOnlyApiGroup.GET("/apps", httpSvc.appsListHandler)
readOnlyApiGroup.GET("/apps/:pubkey", httpSvc.appsShowByPubkeyHandler)
readOnlyApiGroup.GET("/v2/apps/:id", httpSvc.appsShowHandler)
readOnlyApiGroup.GET("/v2/apps/:id/issues", httpSvc.appConnectionIssuesHandler)
readOnlyApiGroup.GET("/channels", httpSvc.channelsListHandler)
readOnlyApiGroup.GET("/channels/suggestions", httpSvc.channelPeerSuggestionsHandler)
readOnlyApiGroup.GET("/channel-offer", httpSvc.channelOfferHandler)
@ -1142,6 +1143,43 @@ func (httpSvc *HttpService) appsShowHandler(c echo.Context) error {
return c.JSON(http.StatusOK, response)
}
func (httpSvc *HttpService) appConnectionIssuesHandler(c echo.Context) error {
appIdStr := c.Param("id")
if appIdStr == "" {
return c.JSON(http.StatusBadRequest, ErrorResponse{
Message: "App ID is required",
})
}
appId, err := strconv.ParseUint(appIdStr, 10, 64)
if err != nil {
return c.JSON(http.StatusBadRequest, ErrorResponse{
Message: "Invalid App ID",
})
}
dbApp := httpSvc.appsSvc.GetAppById(uint(appId))
if dbApp == nil {
return c.JSON(http.StatusNotFound, ErrorResponse{
Message: "App not found",
})
}
limit := uint64(0)
if limitParam := c.QueryParam("limit"); limitParam != "" {
limit, _ = strconv.ParseUint(limitParam, 10, 64)
}
issues, err := httpSvc.api.ListConnectionIssues(uint(appId), limit)
if err != nil {
return c.JSON(http.StatusInternalServerError, ErrorResponse{
Message: err.Error(),
})
}
return c.JSON(http.StatusOK, issues)
}
func (httpSvc *HttpService) appsUpdateHandler(c echo.Context) error {
var requestData api.UpdateAppRequest
if err := c.Bind(&requestData); err != nil {

73
nip47/connection_issue.go Normal file
View file

@ -0,0 +1,73 @@
package nip47
import (
"github.com/getAlby/hub/constants"
"github.com/getAlby/hub/db"
"github.com/getAlby/hub/logger"
"github.com/getAlby/hub/nip47/models"
"github.com/sirupsen/logrus"
)
const (
connectionIssueMissingPermission = "missing_permission"
connectionIssueUnknownMethod = "unknown_method"
connectionIssueExpiredConnection = "expired_connection"
connectionIssueBudgetExceeded = "budget_exceeded"
connectionIssueLowBalance = "low_balance"
connectionIssuePaymentFailed = "payment_failed"
)
func categorizeConnectionIssue(method string, responseError *models.Error) (string, bool) {
if responseError.Code == constants.ERROR_RESTRICTED {
return connectionIssueMissingPermission, true
}
if responseError.Code == constants.ERROR_EXPIRED {
return connectionIssueExpiredConnection, true
}
if responseError.Code == constants.ERROR_QUOTA_EXCEEDED {
return connectionIssueBudgetExceeded, true
}
if responseError.Code == constants.ERROR_INSUFFICIENT_BALANCE {
return connectionIssueLowBalance, true
}
if responseError.Code == constants.ERROR_NOT_IMPLEMENTED {
return connectionIssueUnknownMethod, true
}
if method == models.PAY_INVOICE_METHOD ||
method == models.MULTI_PAY_INVOICE_METHOD ||
method == models.PAY_KEYSEND_METHOD ||
method == models.MULTI_PAY_KEYSEND_METHOD {
return connectionIssuePaymentFailed, true
}
return "", false
}
func (svc *nip47Service) recordConnectionIssue(app *db.App, requestEvent *db.RequestEvent, response *models.Response) {
if app == nil || response == nil || response.Error == nil {
return
}
category, ok := categorizeConnectionIssue(requestEvent.Method, response.Error)
if !ok {
return
}
issue := db.ConnectionIssue{
AppId: app.ID,
RequestEventId: requestEvent.ID,
Method: requestEvent.Method,
Category: category,
ErrorCode: response.Error.Code,
ErrorMessage: response.Error.Message,
}
err := svc.db.Create(&issue).Error
if err != nil {
logger.Logger.WithFields(logrus.Fields{
"appId": app.ID,
"requestEventId": requestEvent.ID,
"method": requestEvent.Method,
}).WithError(err).Error("Failed to record connection issue")
}
}

View file

@ -285,6 +285,7 @@ func (svc *nip47Service) HandleEvent(ctx context.Context, pool nostrmodels.Simpl
// TODO: update all previous occurrences of svc.publishResponseEvent to also use the channel
publishResponse := func(nip47Response *models.Response, tags nostr.Tags) {
var state string
svc.recordConnectionIssue(&app, &requestEvent, nip47Response)
resp, err := svc.CreateResponse(event, nip47Response, tags, nip47Cipher, appWalletPrivKey)
if err != nil {
logger.Logger.WithFields(logrus.Fields{