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