DebugTools.tsx raw
1 import { ClipboardPasteIcon, InfoIcon } from "lucide-react";
2 import React from "react";
3 import { toast } from "sonner";
4 import { ExecuteCustomNodeCommandDialogContent } from "src/components/ExecuteCustomNodeCommandDialogContent";
5 import ExternalLink from "src/components/ExternalLink";
6 import { ResetRoutingDataDialogContent } from "src/components/ResetRoutingDataDialogContent";
7 import SettingsHeader from "src/components/SettingsHeader";
8 import {
9 AlertDialog,
10 AlertDialogAction,
11 AlertDialogCancel,
12 AlertDialogContent,
13 AlertDialogDescription,
14 AlertDialogFooter,
15 AlertDialogHeader,
16 AlertDialogTitle,
17 AlertDialogTrigger,
18 } from "src/components/ui/alert-dialog";
19 import { Button } from "src/components/ui/button";
20 import { Input } from "src/components/ui/input";
21 import { Label } from "src/components/ui/label";
22 import { RadioGroup, RadioGroupItem } from "src/components/ui/radio-group";
23 import { Textarea } from "src/components/ui/textarea";
24 import {
25 Tooltip,
26 TooltipContent,
27 TooltipProvider,
28 TooltipTrigger,
29 } from "src/components/ui/tooltip";
30 import { useInfo } from "src/hooks/useInfo";
31
32 import { request } from "src/utils/request";
33
34 type Props = {
35 apiRequest: (
36 endpoint: string,
37 method: string,
38 requestBody?: object
39 ) => Promise<void>;
40 target?: string;
41 };
42
43 function RefundSwapDialogContent() {
44 const [swapId, setSwapId] = React.useState<string>("");
45 const [address, setAddress] = React.useState<string>("");
46 const [isInternal, setInternal] = React.useState<boolean>(true);
47
48 async function onConfirm() {
49 try {
50 const response = await request("/api/swaps/refund", {
51 method: "POST",
52 headers: {
53 "Content-Type": "application/json",
54 },
55 body: JSON.stringify({
56 swapId,
57 ...(address ? { address } : {}),
58 }),
59 });
60 console.info("Processed refund", response);
61 toast("Refund transaction broadcasted");
62 } catch (error) {
63 console.error(error);
64 toast.error("Failed to process refund", {
65 description: "" + error,
66 });
67 }
68 setSwapId("");
69 }
70
71 const handleSubmit = (e: React.FormEvent) => {
72 e.preventDefault();
73 onConfirm();
74 };
75
76 const paste = async () => {
77 const text = await navigator.clipboard.readText();
78 setAddress(text.trim());
79 };
80
81 return (
82 <AlertDialogContent>
83 <AlertDialogHeader>
84 <AlertDialogTitle className="capitalize">Refund Swap</AlertDialogTitle>
85 <AlertDialogDescription className="flex text-foreground flex-col gap-4">
86 <div className="flex flex-row gap-1 items-center text-muted-foreground">
87 Only On-chain {"->"} Lightning swaps need to be refunded
88 <ExternalLink to="https://guides.getalby.com/user-guide/alby-hub/faq/what-happens-if-lose-access-to-my-hub-while-a-swap-is-in-progress#swap-out-lightning-on-chain">
89 <InfoIcon className="h-4 w-4 shrink-0" />
90 </ExternalLink>
91 </div>
92 <form id="refund-swap-form" onSubmit={handleSubmit}>
93 <div className="flex flex-col gap-2">
94 <Label htmlFor="swapId">
95 <TooltipProvider>
96 <Tooltip>
97 <TooltipTrigger>
98 <div className="flex flex-row gap-1 items-center text-muted-foreground">
99 Swap Id
100 <InfoIcon className="h-4 w-4 shrink-0" />
101 </div>
102 </TooltipTrigger>
103 <TooltipContent>
104 <p>
105 To find the Swap ID, close this dialog and click on the
106 "List Swaps" button. Then you can look through and find
107 a swap that is in state "FAILED" and matches the amount
108 you tried to swap. The latest swaps are at the bottom of
109 the list.
110 </p>
111 <p className="mt-2">
112 When you have found the swap, copy the value of the id
113 field. The swap Id will look something like
114 uNHoD8QrAr9b.
115 </p>
116 </TooltipContent>
117 </Tooltip>
118 </TooltipProvider>
119 </Label>
120 <Input
121 id="swapId"
122 name="swapId"
123 type="text"
124 required
125 autoFocus
126 value={swapId}
127 onChange={(e) => {
128 setSwapId(e.target.value.trim());
129 }}
130 />
131 </div>
132 <div className="flex flex-col gap-4 mt-4">
133 <Label>Refund to</Label>
134 <RadioGroup
135 defaultValue="normal"
136 value={isInternal ? "internal" : "external"}
137 onValueChange={() => {
138 setAddress("");
139 setInternal(!isInternal);
140 }}
141 className="flex gap-4 flex-row"
142 >
143 <div className="flex items-start space-x-2 mb-2">
144 <RadioGroupItem
145 value="internal"
146 id="internal"
147 className="shrink-0"
148 />
149 <Label htmlFor="internal" className="cursor-pointer">
150 On-chain balance
151 </Label>
152 </div>
153 <div className="flex items-start space-x-2">
154 <RadioGroupItem
155 value="external"
156 id="external"
157 className="shrink-0"
158 />
159 <Label htmlFor="external" className="cursor-pointer">
160 External on-chain wallet
161 </Label>
162 </div>
163 </RadioGroup>
164 </div>
165 {!isInternal && (
166 <div className="grid gap-1.5 mt-4">
167 <Label>On-chain address</Label>
168 <div className="flex gap-2">
169 <Input
170 placeholder="bc1..."
171 value={address}
172 onChange={(e) => setAddress(e.target.value)}
173 required
174 />
175 <Button
176 type="button"
177 variant="outline"
178 className="px-2"
179 onClick={paste}
180 >
181 <ClipboardPasteIcon className="w-4 h-4" />
182 </Button>
183 </div>
184 </div>
185 )}
186 </form>
187 </AlertDialogDescription>
188 </AlertDialogHeader>
189 <AlertDialogFooter>
190 <AlertDialogCancel>Cancel</AlertDialogCancel>
191 <AlertDialogAction
192 disabled={!swapId || (!isInternal && !address)}
193 type="submit"
194 form="refund-swap-form"
195 >
196 Confirm
197 </AlertDialogAction>
198 </AlertDialogFooter>
199 </AlertDialogContent>
200 );
201 }
202
203 function GetLogsDialogContent({ apiRequest, target }: Props) {
204 const [maxLen, setMaxLen] = React.useState<string>("");
205
206 async function onConfirm() {
207 await apiRequest(`/api/log/${target}?maxLen=${maxLen}`, "GET");
208 setMaxLen("");
209 }
210
211 const handleSubmit = (e: React.FormEvent) => {
212 e.preventDefault();
213 onConfirm();
214 };
215
216 return (
217 <AlertDialogContent>
218 <AlertDialogHeader>
219 <AlertDialogTitle className="capitalize">
220 Get {target} Logs
221 </AlertDialogTitle>
222 <AlertDialogDescription className="text-start">
223 <form id="get-logs-form" onSubmit={handleSubmit}>
224 <Label htmlFor="maxLength" className="block mb-2">
225 Enter Max Length (in characters)
226 </Label>
227 <Input
228 id="maxLength"
229 name="maxLength"
230 type="number"
231 required
232 autoFocus
233 min={1}
234 value={maxLen}
235 onChange={(e) => {
236 setMaxLen(e.target.value.trim());
237 }}
238 />
239 </form>
240 </AlertDialogDescription>
241 </AlertDialogHeader>
242 <AlertDialogFooter>
243 <AlertDialogCancel>Cancel</AlertDialogCancel>
244 <AlertDialogAction
245 disabled={!parseInt(maxLen)}
246 type="submit"
247 form="get-logs-form"
248 >
249 Confirm
250 </AlertDialogAction>
251 </AlertDialogFooter>
252 </AlertDialogContent>
253 );
254 }
255
256 function GetNetworkGraphDialogContent({ apiRequest }: Props) {
257 const [nodeIds, setNodeIds] = React.useState<string>("");
258
259 async function onConfirm() {
260 await apiRequest(`/api/node/network-graph?nodeIds=${nodeIds}`, "GET");
261 setNodeIds("");
262 }
263
264 const handleSubmit = (e: React.FormEvent) => {
265 e.preventDefault();
266 onConfirm();
267 };
268
269 return (
270 <AlertDialogContent>
271 <AlertDialogHeader>
272 <AlertDialogTitle>Get Network Graph</AlertDialogTitle>
273 <AlertDialogDescription className="text-start">
274 <form id="get-network-graph-form" onSubmit={handleSubmit}>
275 <Label htmlFor="nodes" className="block mb-2">
276 Enter Node Pubkeys (separated by commas)
277 </Label>
278 <Input
279 id="nodes"
280 type="text"
281 placeholder="e.g. nodepubkey1,nodepubkey2,nodepubkey3"
282 value={nodeIds}
283 onChange={(e) => {
284 setNodeIds(e.target.value.trim());
285 }}
286 />
287 </form>
288 </AlertDialogDescription>
289 </AlertDialogHeader>
290 <AlertDialogFooter>
291 <AlertDialogCancel>Cancel</AlertDialogCancel>
292 <AlertDialogAction
293 disabled={!nodeIds}
294 type="submit"
295 form="get-network-graph-form"
296 >
297 Confirm
298 </AlertDialogAction>
299 </AlertDialogFooter>
300 </AlertDialogContent>
301 );
302 }
303
304 export default function DebugTools() {
305 const [apiResponse, setApiResponse] = React.useState<string>("");
306 const [dialog, setDialog] = React.useState<
307 | "refundSwap"
308 | "getAppLogs"
309 | "getNodeLogs"
310 | "getNetworkGraph"
311 | "resetRoutingData"
312 | "customNodeCommand"
313 >();
314
315 const { data: info, hasChannelManagement } = useInfo();
316
317 async function apiRequest(
318 endpoint: string,
319 method: string,
320 requestBody?: object
321 ) {
322 try {
323 const requestOptions: RequestInit = {
324 method: method,
325 headers: {
326 "Content-Type": "application/json",
327 },
328 };
329
330 if (requestBody) {
331 requestOptions.body = JSON.stringify(requestBody);
332 }
333
334 const data = await request(endpoint, requestOptions);
335
336 setApiResponse(
337 (data as { logs: string }).logs || JSON.stringify(data, null, 2)
338 );
339 } catch (error) {
340 setApiResponse(JSON.stringify(error, Object.getOwnPropertyNames(error)));
341 }
342 }
343
344 return (
345 <div>
346 <SettingsHeader
347 pageTitle="Debug Tools"
348 title="Debug Tools"
349 description="Extra tools for debugging purposes."
350 />
351 <div className="grid mt-6 gap-6 mb-8 lg:mb-8 md:grid-cols-2 xl:grid-cols-3">
352 <AlertDialog
353 onOpenChange={() => {
354 if (!open) {
355 setDialog(undefined);
356 }
357 }}
358 >
359 <Button
360 variant="outline"
361 onClick={() => apiRequest("/api/info", "GET")}
362 >
363 Get Info
364 </Button>
365 <Button
366 variant="outline"
367 onClick={() => apiRequest("/api/peers", "GET")}
368 >
369 List Peers
370 </Button>
371 <Button
372 variant="outline"
373 onClick={() => apiRequest("/api/channels", "GET")}
374 >
375 List Channels
376 </Button>
377 {hasChannelManagement && (
378 <>
379 <Button
380 variant={"outline"}
381 onClick={() => apiRequest("/api/swaps", "GET")}
382 >
383 List Swaps
384 </Button>
385 <AlertDialogTrigger asChild>
386 <Button
387 variant={"outline"}
388 onClick={() => setDialog("refundSwap")}
389 >
390 Refund Swap
391 </Button>
392 </AlertDialogTrigger>
393 <Button
394 variant={"outline"}
395 onClick={() => apiRequest("/api/swaps/mnemonic", "GET")}
396 >
397 Get Swap Mnemonic
398 </Button>
399 </>
400 )}
401 <AlertDialogTrigger asChild>
402 <Button variant="outline" onClick={() => setDialog("getAppLogs")}>
403 Get App Logs
404 </Button>
405 </AlertDialogTrigger>
406 <AlertDialogTrigger asChild>
407 <Button variant="outline" onClick={() => setDialog("getNodeLogs")}>
408 Get Node Logs
409 </Button>
410 </AlertDialogTrigger>
411 <Button
412 variant="outline"
413 onClick={() => {
414 apiRequest(`/api/node/status`, "GET");
415 }}
416 >
417 Get Node Status
418 </Button>
419 <Button
420 variant="outline"
421 onClick={() => {
422 apiRequest(`/api/balances`, "GET");
423 }}
424 >
425 Get Balances
426 </Button>
427 <AlertDialogTrigger asChild>
428 <Button
429 variant="outline"
430 onClick={() => setDialog("getNetworkGraph")}
431 >
432 Get Network Graph
433 </Button>
434 </AlertDialogTrigger>
435 {(info?.backendType === "LDK" || info?.backendType === "CASHU") && (
436 <AlertDialogTrigger asChild>
437 <Button
438 variant="outline"
439 onClick={() => setDialog("resetRoutingData")}
440 >
441 Clear Routing Data
442 </Button>
443 </AlertDialogTrigger>
444 )}
445 <Button
446 variant="outline"
447 onClick={() => {
448 apiRequest(`/api/commands`, "GET");
449 }}
450 >
451 Get Node Commands
452 </Button>
453 <AlertDialogTrigger asChild>
454 <Button
455 variant="outline"
456 onClick={() => {
457 apiRequest(`/api/commands`, "GET");
458 setDialog("customNodeCommand");
459 }}
460 >
461 Execute Node Command
462 </Button>
463 </AlertDialogTrigger>
464 {info?.backendType === "LDK" && (
465 <Button
466 variant="outline"
467 onClick={() => {
468 apiRequest(`/api/command`, "POST", {
469 command: "export_pathfinding_scores",
470 });
471 }}
472 >
473 Export Pathfinding Scores
474 </Button>
475 )}
476 {dialog === "refundSwap" && <RefundSwapDialogContent />}
477 {(dialog === "getAppLogs" || dialog === "getNodeLogs") && (
478 <GetLogsDialogContent
479 apiRequest={apiRequest}
480 target={dialog === "getAppLogs" ? "app" : "node"}
481 />
482 )}
483 {dialog === "getNetworkGraph" && (
484 <GetNetworkGraphDialogContent apiRequest={apiRequest} />
485 )}
486 {dialog === "resetRoutingData" && <ResetRoutingDataDialogContent />}
487 {dialog === "customNodeCommand" && (
488 <ExecuteCustomNodeCommandDialogContent
489 availableCommands={apiResponse}
490 setCommandResponse={setApiResponse}
491 />
492 )}
493 </AlertDialog>
494 </div>
495 {apiResponse && (
496 <Textarea
497 className="whitespace-pre-wrap break-anywhere font-mono"
498 rows={35}
499 value={`API Response: ${apiResponse}`}
500 />
501 )}
502 </div>
503 );
504 }
505