mirror of
https://github.com/getAlby/hub.git
synced 2026-08-13 12:33:39 +02:00
feat: surface connection issues in hub
This commit is contained in:
parent
162965300e
commit
c78cc466ef
26 changed files with 469 additions and 27 deletions
36
api/api.go
36
api/api.go
|
|
@ -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{}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"},
|
||||
}
|
||||
|
|
|
|||
38
db/migrations/202605121200_connection_issues.go
Normal file
38
db/migrations/202605121200_connection_issues.go
Normal 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
|
||||
},
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
14
db/models.go
14
db/models.go
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
92
frontend/src/components/connections/ConnectionIssuesCard.tsx
Normal file
92
frontend/src/components/connections/ConnectionIssuesCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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
|
||||
);
|
||||
}
|
||||
|
|
|
|||
96
frontend/src/lib/connectionIssues.ts
Normal file
96
frontend/src/lib/connectionIssues.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 ? (
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
73
nip47/connection_issue.go
Normal 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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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{
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue