clean up settings and debug screens

This commit is contained in:
Paul Miller
2023-06-07 14:31:46 -05:00
parent 03f5ab667e
commit 3eba44f8c7
16 changed files with 369 additions and 276 deletions

View File

@@ -19,7 +19,7 @@ import chain from "~/assets/icons/chain-black.svg";
import copyIcon from "~/assets/icons/copy.svg";
import { ActivityAmount, HackActivityType } from "./ActivityItem";
import { CopyButton } from "./ShareCard";
import { CopyButton, TruncateMiddle } from "./ShareCard";
import { prettyPrintTime } from "~/utils/prettyPrintTime";
import { useMegaStore } from "~/state/megaStore";
import { tagToMutinyTag } from "~/utils/tags";
@@ -153,13 +153,18 @@ const KeyValue: ParentComponent<{ key: string }> = (props) => {
);
};
function MiniStringShower(props: { text: string }) {
const [copy, _copied] = useCopy({ copiedTimeout: 1000 });
export function MiniStringShower(props: { text: string }) {
const [copy, copied] = useCopy({ copiedTimeout: 1000 });
return (
<div class="w-full grid gap-1 grid-cols-[minmax(0,_1fr)_auto]">
<pre class="truncate text-neutral-300 font-light">{props.text}</pre>
<button class="w-[1rem]" onClick={() => copy(props.text)}>
<TruncateMiddle text={props.text} />
{/* <pre class="truncate text-neutral-300 font-light">{props.text}</pre> */}
<button
class="w-[1.5rem] p-1"
classList={{ "bg-m-green rounded": copied() }}
onClick={() => copy(props.text)}
>
<img src={copyIcon} alt="copy" class="w-4 h-4" />
</button>
</div>

View File

@@ -1,5 +1,5 @@
import { useMegaStore } from "~/state/megaStore";
import { Button, InnerCard, VStack } from "~/components/layout";
import { Button, InnerCard, NiceP, VStack } from "~/components/layout";
import { createSignal } from "solid-js";
import eify from "~/utils/eify";
import { showToast } from "./Toaster";
@@ -72,10 +72,23 @@ export function ImportExport() {
return (
<>
<InnerCard>
<InnerCard title="Export wallet state">
<NiceP>
You can export your entire Mutiny Wallet state to a file and
import it into a new browser. It usually works!
</NiceP>
<NiceP>
<strong class="font-semibold">Important caveats:</strong>{" "}
after exporting don't do any operations in the original
browser. If you do, you'll need to export again. After a
successful import, a best practice is to clear the state of
the original browser just to make sure you don't create
conflicts.
</NiceP>
<div />
<VStack>
<Button onClick={handleSave}>Save State As File</Button>
<Button onClick={uploadFile}>Upload Saved State</Button>
<Button onClick={uploadFile}>Import State From File</Button>
</VStack>
</InnerCard>
<ConfirmDialog

View File

@@ -1,23 +1,7 @@
import { useMegaStore } from "~/state/megaStore";
import {
Card,
Hr,
SmallHeader,
Button,
InnerCard,
VStack
} from "~/components/layout";
import PeerConnectModal from "~/components/PeerConnectModal";
import { Hr, Button, InnerCard, VStack } from "~/components/layout";
import NostrWalletConnectModal from "~/components/NostrWalletConnectModal";
import {
For,
Show,
Suspense,
createEffect,
createResource,
createSignal,
onCleanup
} from "solid-js";
import { For, Show, Suspense, createResource, createSignal } from "solid-js";
import { MutinyChannel, MutinyPeer } from "@mutinywallet/mutiny-wasm";
import { Collapsible, TextField } from "@kobalte/core";
import mempoolTxUrl from "~/utils/mempoolTxUrl";
@@ -27,6 +11,9 @@ import { showToast } from "~/components/Toaster";
import { ImportExport } from "~/components/ImportExport";
import { Network } from "~/logic/mutinyWalletSetup";
import { ExternalLink } from "./layout/ExternalLink";
import { Logs } from "./Logs";
import { Restart } from "./Restart";
import { MiniStringShower } from "./DetailsModal";
// TODO: hopefully I don't have to maintain this type forever but I don't know how to pass it around otherwise
type RefetchPeersType = (
@@ -90,23 +77,16 @@ function PeersList() {
const [peers, { refetch }] = createResource(getPeers);
createEffect(() => {
// refetch peers every 5 seconds
const interval = setTimeout(() => {
refetch();
}, 5000);
onCleanup(() => {
clearInterval(interval);
});
});
return (
<>
<SmallHeader>Peers</SmallHeader>
<InnerCard title="Peers">
{/* By wrapping this in a suspense I don't cause the page to jump to the top */}
<Suspense>
<VStack>
<For each={peers()} fallback={<code>No peers</code>}>
<For
each={peers.latest}
fallback={<code>No peers</code>}
>
{(peer) => <PeerItem peer={peer} />}
</For>
</VStack>
@@ -114,6 +94,7 @@ function PeersList() {
<Button layout="small" onClick={refetch}>
Refresh Peers
</Button>
</InnerCard>
<ConnectPeer refetchPeers={refetch} />
</>
);
@@ -155,10 +136,10 @@ function ConnectPeer(props: { refetchPeers: RefetchPeersType }) {
</TextField.Label>
<TextField.Input
class="w-full p-2 rounded-lg text-black"
placeholder="mutiny:028241..."
placeholder="028241..."
/>
<TextField.ErrorMessage class="text-red-500">
Expecting something like mutiny:abc123...
Expecting a value...
</TextField.ErrorMessage>
</TextField.Root>
<Button layout="small" type="submit">
@@ -249,21 +230,11 @@ function ChannelsList() {
const [channels, { refetch }] = createResource(getChannels);
createEffect(() => {
// refetch channels every 5 seconds
const interval = setTimeout(() => {
refetch();
}, 5000);
onCleanup(() => {
clearInterval(interval);
});
});
const network = state.mutiny_wallet?.get_network() as Network;
return (
<>
<SmallHeader>Channels</SmallHeader>
<InnerCard title="Channels">
{/* By wrapping this in a suspense I don't cause the page to jump to the top */}
<Suspense>
<For each={channels()} fallback={<code>No channels</code>}>
@@ -282,6 +253,7 @@ function ChannelsList() {
>
Refresh Channels
</Button>
</InnerCard>
<OpenChannel refetchChannels={refetch} />
</>
);
@@ -429,33 +401,33 @@ function LnUrlAuth() {
);
}
function ListTags() {
const [_state, actions] = useMegaStore();
function ListNodes() {
const [state, _] = useMegaStore();
const [tags] = createResource(actions.listTags);
const getNodeIds = async () => {
const nodes = await state.mutiny_wallet?.list_nodes();
return nodes as string[];
};
const [nodeIds] = createResource(getNodeIds);
return (
<Collapsible.Root>
<Collapsible.Trigger class="w-full">
<h2 class="truncate text-start text-lg font-mono bg-neutral-200 text-black rounded px-4 py-2">
{">"} Tags
</h2>
</Collapsible.Trigger>
<Collapsible.Content>
<VStack>
<pre class="overflow-x-auto whitespace-pre-wrap break-all">
{JSON.stringify(tags(), null, 2)}
</pre>
</VStack>
</Collapsible.Content>
</Collapsible.Root>
<InnerCard title="Nodes">
<Suspense>
<For each={nodeIds()} fallback={<code>No nodes</code>}>
{(nodeId) => <MiniStringShower text={nodeId} />}
</For>
</Suspense>
</InnerCard>
);
}
export default function KitchenSink() {
return (
<Card title="Kitchen Sink">
<PeerConnectModal />
<>
<Logs />
<Hr />
<ListNodes />
<Hr />
<NostrWalletConnectModal />
<Hr />
@@ -465,10 +437,10 @@ export default function KitchenSink() {
<Hr />
<LnUrlAuth />
<Hr />
<ListTags />
<Restart />
<Hr />
<ImportExport />
</Card>
<Hr />
</>
);
}

View File

@@ -0,0 +1,72 @@
import { Show, createResource } from "solid-js";
import { useMegaStore } from "~/state/megaStore";
import { Card, NiceP, SmallHeader, TinyText, VStack } from "./layout";
import { AmountSmall } from "./Amount";
function BalanceBar(props: { inbound: number; outbound: number }) {
return (
<VStack smallgap>
<div class="flex justify-between">
<SmallHeader>Outbound</SmallHeader>
<SmallHeader>Inbound</SmallHeader>
</div>
<div class="flex gap-1 w-full">
<div
class="bg-m-green p-2 rounded-l-xl min-w-fit"
style={{
"flex-grow": props.outbound || 1
}}
>
<AmountSmall amountSats={props.outbound} />
</div>
<div
class="bg-m-blue p-2 rounded-r-xl min-w-fit"
style={{
"flex-grow": props.inbound || 1
}}
>
<AmountSmall amountSats={props.inbound} />
</div>
</div>
</VStack>
);
}
export function LiquidityMonitor() {
const [state, _actions] = useMegaStore();
const [channelInfo] = createResource(async () => {
const channels = await state.mutiny_wallet?.list_channels();
let inbound = 0n;
for (const channel of channels) {
inbound =
inbound +
BigInt(channel.size) -
BigInt(channel.balance + channel.reserve);
}
return { inbound, channelCount: channels?.length };
});
return (
<Show when={channelInfo()?.channelCount}>
<Card>
<NiceP>
You have {channelInfo()?.channelCount} lightning{" "}
{channelInfo()?.channelCount === 1 ? "channel" : "channels"}
.
</NiceP>{" "}
<BalanceBar
inbound={Number(channelInfo()?.inbound) || 0}
outbound={Number(state.balance?.lightning) || 0}
/>
<TinyText>
Outbound is the amount of money you can spend on lightning.
Inbound is the amount you can receive without incurring a
lightning service fee.
</TinyText>
</Card>
</Show>
);
}

View File

@@ -1,4 +1,4 @@
import { Button, Card, NiceP, VStack } from "~/components/layout";
import { Button, InnerCard, NiceP, VStack } from "~/components/layout";
import { useMegaStore } from "~/state/megaStore";
import { downloadTextFile } from "~/utils/download";
@@ -11,11 +11,13 @@ export function Logs() {
}
return (
<Card>
<InnerCard title="Download debug logs">
<VStack>
<NiceP>Something screwy going on? Check out the logs!</NiceP>
<Button onClick={handleSave}>Download Logs</Button>
<Button intent="green" onClick={handleSave}>
Download Logs
</Button>
</VStack>
</Card>
</InnerCard>
);
}

View File

@@ -1,6 +1,6 @@
import { QRCodeSVG } from "solid-qr-code";
import { As, Dialog } from "@kobalte/core";
import { Button, Card } from "~/components/layout";
import { Button, Card, InnerCard, NiceP } from "~/components/layout";
import { useMegaStore } from "~/state/megaStore";
import { createResource, Show } from "solid-js";
@@ -37,6 +37,9 @@ export default function NostrWalletConnectModal() {
// TODO: a lot of this markup is probably reusable as a "Modal" component
return (
<InnerCard title="Nostr Wallet Connect">
<NiceP>Test out some nostr stuff.</NiceP>
<div />
<Dialog.Root>
<Dialog.Trigger asChild>
<As component={Button}>Show Nostr Wallet Connect URI</As>
@@ -75,5 +78,6 @@ export default function NostrWalletConnectModal() {
</div>
</Dialog.Portal>
</Dialog.Root>
</InnerCard>
);
}

View File

@@ -1,71 +0,0 @@
import { QRCodeSVG } from "solid-qr-code";
import { As, Dialog } from "@kobalte/core";
import { Button, Card } from "~/components/layout";
import { useMegaStore } from "~/state/megaStore";
import { Show, createResource } from "solid-js";
import { getExistingSettings } from "~/logic/mutinyWalletSetup";
import getHostname from "~/utils/getHostname";
const OVERLAY = "fixed inset-0 z-50 bg-black/50 backdrop-blur-sm";
const DIALOG_POSITIONER = "fixed inset-0 z-50 flex items-center justify-center";
const DIALOG_CONTENT =
"w-[80vw] max-w-[400px] p-4 bg-gray/50 backdrop-blur-md shadow-xl rounded-xl border border-white/10";
const SMALL_HEADER = "text-sm font-semibold uppercase";
export default function PeerConnectModal() {
const [state, _] = useMegaStore();
const getPeerConnectString = async () => {
if (state.mutiny_wallet) {
const { proxy } = getExistingSettings();
const nodes = await state.mutiny_wallet.list_nodes();
const firstNode = (nodes[0] as string) || "";
const hostName = getHostname(proxy || "");
const connectString = `mutiny:${firstNode}@${hostName}`;
return connectString;
} else {
return undefined;
}
};
const [peerConnectString] = createResource(getPeerConnectString);
// TODO: a lot of this markup is probably reusable as a "Modal" component
return (
<Dialog.Root>
<Dialog.Trigger asChild>
<As component={Button}>Show Peer Connect Info</As>
</Dialog.Trigger>
<Dialog.Portal>
<Dialog.Overlay class={OVERLAY} />
<div class={DIALOG_POSITIONER}>
<Dialog.Content class={DIALOG_CONTENT}>
<div class="flex justify-between mb-2">
<Dialog.Title class={SMALL_HEADER}>
Peer connect info
</Dialog.Title>
<Dialog.CloseButton class="dialog__close-button">
<code>X</code>
</Dialog.CloseButton>
</div>
<Dialog.Description class="flex flex-col gap-4">
<Show when={peerConnectString()}>
<div class="w-full bg-white rounded-xl">
<QRCodeSVG
value={peerConnectString() || ""}
class="w-full h-full p-8 max-h-[400px]"
/>
</div>
<Card>
<code class="break-all">
{peerConnectString() || ""}
</code>
</Card>
</Show>
</Dialog.Description>
</Dialog.Content>
</div>
</Dialog.Portal>
</Dialog.Root>
);
}

View File

@@ -1,21 +1,34 @@
import { Button, Card, NiceP, VStack } from "~/components/layout";
import { createSignal } from "solid-js";
import { Button, InnerCard, NiceP, VStack } from "~/components/layout";
import { useMegaStore } from "~/state/megaStore";
export function Restart() {
const [state, _] = useMegaStore();
const [hasStopped, setHasStopped] = createSignal(false);
async function handleStop() {
async function toggle() {
if (hasStopped()) {
await state.mutiny_wallet?.start();
setHasStopped(false);
} else {
await state.mutiny_wallet?.stop();
setHasStopped(true);
}
}
return (
<Card>
<InnerCard>
<VStack>
<NiceP>
Something *extra* screwy going on? Stop the nodes!
</NiceP>
<Button onClick={handleStop}>Stop</Button>
<Button
intent={hasStopped() ? "green" : "red"}
onClick={toggle}
>
{hasStopped() ? "Start" : "Stop"}
</Button>
</VStack>
</Card>
</InnerCard>
);
}

View File

@@ -1,10 +1,13 @@
import { For, Match, Switch, createMemo, createSignal } from "solid-js";
import { useCopy } from "~/utils/useCopy";
import copyIcon from "~/assets/icons/copy.svg";
export function SeedWords(props: {
words: string;
setHasSeen?: (hasSeen: boolean) => void;
}) {
const [shouldShow, setShouldShow] = createSignal(false);
const [copy, copied] = useCopy({ copiedTimeout: 1000 });
function toggleShow() {
setShouldShow(!shouldShow());
@@ -15,28 +18,61 @@ export function SeedWords(props: {
const splitWords = createMemo(() => props.words.split(" "));
function dangerouslyCopy() {
copy(props.words);
}
return (
<button
class="flex items-center gap-4 bg-m-red p-4 rounded-xl overflow-hidden"
onClick={toggleShow}
>
<div class="flex flex-col gap-4 bg-m-red p-4 rounded-xl overflow-hidden">
<Switch>
<Match when={!shouldShow()}>
<div class="cursor-pointer">
<div
class="cursor-pointer flex w-full justify-center"
onClick={toggleShow}
>
<code class="text-red">TAP TO REVEAL SEED WORDS</code>
</div>
</Match>
<Match when={shouldShow()}>
<ol class="cursor-pointer overflow-hidden grid grid-cols-2 w-full list-decimal list-inside">
<>
<div
class="cursor-pointer flex w-full justify-center"
onClick={toggleShow}
>
<code class="text-red">HIDE</code>
</div>
<ol class="overflow-hidden columns-2 w-full list-decimal list-inside">
<For each={splitWords()}>
{(word) => (
<li class="font-mono text-left">{word}</li>
<li class="font-mono text-left min-w-fit bg">
{word}
</li>
)}
</For>
</ol>
<div class="flex w-full justify-center">
<button
onClick={dangerouslyCopy}
class="bg-white/10 hover:bg-white/20 p-2 rounded-lg"
>
<div class="flex items-center gap-2">
<span>
{copied()
? "Copied!"
: "Dangerously Copy to Clipboard"}
</span>
<img
src={copyIcon}
alt="copy"
class="w-4 h-4"
/>
</div>
</button>
</div>
</>
</Match>
</Switch>
</button>
</div>
);
}

View File

@@ -4,14 +4,15 @@ import {
MutinyWalletSettingStrings,
getExistingSettings
} from "~/logic/mutinyWalletSetup";
import { Button, Card, SmallHeader } from "~/components/layout";
import { Button, Card, NiceP } from "~/components/layout";
import { showToast } from "./Toaster";
import eify from "~/utils/eify";
import { useMegaStore } from "~/state/megaStore";
import { ExternalLink } from "./layout/ExternalLink";
export function SettingsStringsEditor() {
const existingSettings = getExistingSettings();
const [_settingsForm, { Form, Field }] =
const [settingsForm, { Form, Field }] =
createForm<MutinyWalletSettingStrings>({
initialValues: existingSettings
});
@@ -19,8 +20,7 @@ export function SettingsStringsEditor() {
async function handleSubmit(values: MutinyWalletSettingStrings) {
try {
const existing = getExistingSettings();
const newSettings = { ...existing, ...values };
const newSettings = { ...existingSettings, ...values };
await actions.setupMutinyWallet(newSettings);
window.location.reload();
} catch (e) {
@@ -31,16 +31,15 @@ export function SettingsStringsEditor() {
}
return (
<Card>
<Card title="Servers">
<Form onSubmit={handleSubmit} class="flex flex-col gap-4">
<h2 class="text-2xl font-light">
<NiceP>
Don't trust us! Use your own servers to back Mutiny.
</h2>
<div class="flex flex-col gap-2">
<SmallHeader>Network</SmallHeader>
<pre>{existingSettings.network}</pre>
</div>
</NiceP>
<ExternalLink href="https://github.com/MutinyWallet/mutiny-web/wiki/Self-hosting">
Learn more about self-hosting
</ExternalLink>
<div />
<Field
name="proxy"
validate={[url("Should be a url starting with wss://")]}
@@ -51,6 +50,7 @@ export function SettingsStringsEditor() {
value={field.value}
error={field.error}
label="Websockets Proxy"
caption="How your lightning node communicates with the rest of the network."
/>
)}
</Field>
@@ -64,6 +64,7 @@ export function SettingsStringsEditor() {
value={field.value}
error={field.error}
label="Esplora"
caption="Block data for on-chain information."
/>
)}
</Field>
@@ -77,6 +78,7 @@ export function SettingsStringsEditor() {
value={field.value}
error={field.error}
label="RGS"
caption="Rapid Gossip Sync. Network data about the lightning network used for routing."
/>
)}
</Field>
@@ -90,10 +92,18 @@ export function SettingsStringsEditor() {
value={field.value}
error={field.error}
label="LSP"
caption="Lightning Service Provider. Automatically opens channels to you for inbound liquidity. Also wraps invoices for privacy."
/>
)}
</Field>
<Button type="submit">Save</Button>
<div />
<Button
type="submit"
disabled={!settingsForm.dirty}
intent="blue"
>
Save
</Button>
</Form>
</Card>
);

View File

@@ -34,9 +34,9 @@ export function ShareButton(props: { receiveString: string }) {
);
}
function TruncateMiddle(props: { text: string }) {
export function TruncateMiddle(props: { text: string }) {
return (
<div class="flex text-neutral-400 font-mono">
<div class="flex text-neutral-300 font-mono">
<span class="truncate">{props.text}</span>
<span class="pr-2">
{props.text.length > 8 ? props.text.slice(-8) : ""}

View File

@@ -1,11 +1,13 @@
import { TextField as KTextField } from "@kobalte/core";
import { type JSX, Show, splitProps } from "solid-js";
import { TinyText } from ".";
type TextFieldProps = {
name: string;
type?: "text" | "email" | "tel" | "password" | "url" | "date";
label?: string;
placeholder?: string;
caption?: string;
value: string | undefined;
error: string;
required?: boolean;
@@ -36,7 +38,7 @@ export function TextField(props: TextFieldProps) {
name={props.name}
value={props.value}
validationState={props.error ? "invalid" : "valid"}
isRequired={props.required}
required={props.required}
>
<Show when={props.label}>
<KTextField.Label class="text-sm uppercase font-semibold">
@@ -60,6 +62,9 @@ export function TextField(props: TextFieldProps) {
/>
</Show>
<KTextField.ErrorMessage>{props.error}</KTextField.ErrorMessage>
<Show when={props.caption}>
<TinyText>{props.caption}</TinyText>
</Show>
</KTextField.Root>
);
}

View File

@@ -146,9 +146,19 @@ export const LargeHeader: ParentComponent<{ action?: JSX.Element }> = (
);
};
export const VStack: ParentComponent<{ biggap?: boolean }> = (props) => {
export const VStack: ParentComponent<{
biggap?: boolean;
smallgap?: boolean;
}> = (props) => {
return (
<div class={`flex flex-col gap-${props.biggap ? "8" : "4"}`}>
<div
class="flex flex-col"
classList={{
"gap-2": props.smallgap,
"gap-8": props.biggap,
"gap-4": !props.biggap && !props.smallgap
}}
>
{props.children}
</div>
);
@@ -178,6 +188,10 @@ export const NiceP: ParentComponent = (props) => {
return <p class="text-xl font-light">{props.children}</p>;
};
export const TinyText: ParentComponent = (props) => {
return <p class="text-neutral-400 text-sm">{props.children}</p>;
};
export const TinyButton: ParentComponent<{
onClick: () => void;
tag?: MutinyTagItem;

View File

@@ -2,10 +2,10 @@ import { DeleteEverything } from "~/components/DeleteEverything";
import KitchenSink from "~/components/KitchenSink";
import NavBar from "~/components/NavBar";
import {
Card,
DefaultMain,
LargeHeader,
MutinyWalletGuard,
NiceP,
SafeArea,
SmallHeader,
VStack
@@ -18,14 +18,16 @@ export default function Admin() {
<SafeArea>
<DefaultMain>
<BackLink href="/settings" title="Settings" />
<LargeHeader>Admin</LargeHeader>
<LargeHeader>Secret Debug Tools</LargeHeader>
<VStack>
<Card>
<p>
If you know what you're doing you're in the
right place!
</p>
</Card>
<NiceP>
If you know what you're doing you're in the right
place.
</NiceP>
<NiceP>
These are internal tools we use to debug and test
the app. Please be careful!
</NiceP>
<KitchenSink />
<div class="rounded-xl p-4 flex flex-col gap-2 bg-m-red overflow-x-hidden">
<SmallHeader>Danger zone</SmallHeader>

View File

@@ -505,7 +505,7 @@ export default function Send() {
!destination() ||
sending() ||
amountSats() === 0n ||
insufficientFunds() ||
!!insufficientFunds() ||
!!error()
);
});

View File

@@ -1,18 +1,19 @@
import {
ButtonLink,
Card,
DefaultMain,
LargeHeader,
MutinyWalletGuard,
NiceP,
SafeArea,
VStack
} from "~/components/layout";
import { BackLink } from "~/components/layout/BackLink";
import { Logs } from "~/components/Logs";
import { Restart } from "~/components/Restart";
import NavBar from "~/components/NavBar";
import { SeedWords } from "~/components/SeedWords";
import { SettingsStringsEditor } from "~/components/SettingsStringsEditor";
import { useMegaStore } from "~/state/megaStore";
import { LiquidityMonitor } from "~/components/LiquidityMonitor";
export default function Settings() {
const [store, _actions] = useMegaStore();
@@ -24,20 +25,35 @@ export default function Settings() {
<BackLink />
<LargeHeader>Settings</LargeHeader>
<VStack biggap>
<LiquidityMonitor />
<Card title="Backup your seed words">
<VStack>
<p class="text-2xl font-light">
Write down these words or you'll die!
</p>
<NiceP>
These 12 words allow you to recover your
on-chain funds in case you lose your device
or clear your browser storage.
</NiceP>
<SeedWords
words={store.mutiny_wallet?.show_seed() || ""}
words={
store.mutiny_wallet?.show_seed() || ""
}
/>
</VStack>
</Card>
<SettingsStringsEditor />
<Logs />
<Restart />
<ButtonLink href="/admin">
"I know what I'm doing"
<Card title="If you know what you're doing">
<VStack>
<NiceP>
We have some not-very-pretty debug tools we
use to test the wallet. Use wisely!
</NiceP>
<div class="flex justify-center">
<ButtonLink href="/admin" layout="xs">
Secret Debug Tools
</ButtonLink>
</div>
</VStack>
</Card>
</VStack>
</DefaultMain>
<NavBar activeTab="settings" />