improve UI

This commit is contained in:
Victor Oliva
2026-05-05 13:42:35 +02:00
parent 23345e7e35
commit 8e60fb775f
8 changed files with 467 additions and 141 deletions

View File

@@ -18,7 +18,9 @@ export const TokenAmount: FC<{
const formattedValue = (
Number(children) /
10 ** properties.tokenDecimals
).toLocaleString(undefined)
).toLocaleString(undefined, {
maximumSignificantDigits: 3,
})
return (
<span

View File

@@ -92,9 +92,12 @@ export const useSyncHashParam = <T extends any[]>(
useLayoutEffect(
() =>
setHashParams({
[key]: getFn(...dependencies),
}),
setHashParams(
{
[key]: getFn(...dependencies),
},
location,
),
// eslint-disable-next-line react-hooks/exhaustive-deps
dependencies,
)
@@ -116,6 +119,6 @@ export const useHashParamState = <T extends string | null>(
return [
getHashParams(location).get(key) ?? init?.() ?? (null as any),
(value: string | null) => setHashParams({ [key]: value }),
(value: string | null) => setHashParams({ [key]: value }, location),
] as const
}

View File

@@ -130,15 +130,19 @@ export const EventDisplay: FC<{
)
}
export const Sender: React.FC<{
export const senderToAddress = (
sender: Enum<{ Id: SS58String }> | SS58String | HexString,
) =>
typeof sender === "string"
? sender
: "type" in sender && sender.type === "Id"
? sender.value
: null
const Sender: React.FC<{
sender: Enum<{ Id: SS58String }> | SS58String | HexString
}> = ({ sender }) => {
const value: string | null =
typeof sender === "string"
? sender
: "type" in sender && sender.type === "Id"
? sender.value
: null
const value = senderToAddress(sender)
return (
value && (
<div className="flex gap-2 items-center py-2">

View File

@@ -1,5 +1,6 @@
import { client$ } from "@/state/chains/chain.state"
import { useStateObservable, withDefault } from "@react-rxjs/core"
import { jsonSerialize } from "polkadot-api/utils"
import { FC, useContext } from "react"
import { map, switchMap } from "rxjs"
import { BlockContext } from "./blockContext"
@@ -17,9 +18,31 @@ export const MortalityAnalyzer: FC<{
const finalizedNumber = useStateObservable(finalizedNumber$)
const selectedBlockNumber = selectedBlock?.number ?? finalizedNumber
if (mortality.type === "Immortal") return <div>Immortal</div>
if (mortality.type === "Immortal") {
return (
<div className="rounded-lg border border-foreground/10 bg-background/60 px-3 py-2">
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-foreground/50">
Lifetime
</div>
<div className="mt-1 text-sm font-medium text-foreground">Immortal</div>
</div>
)
}
const parseResult = /^Mortal(\d+)$/.exec(mortality.type)
if (parseResult === null) return null
if (parseResult === null) {
return (
<div className="rounded-lg border border-foreground/10 bg-background/60 px-3 py-2">
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-foreground/50">
Lifetime
</div>
<div className="mt-1 font-mono text-sm text-foreground">
{mortality.type} {mortality.value}
</div>
</div>
)
}
const first = BigInt(parseResult[1])
const second = BigInt(mortality.value)
// from polkadot-sdk primitives runtime generic era fn decode
@@ -34,13 +57,126 @@ export const MortalityAnalyzer: FC<{
period +
phase
: null
const deathBlock = birthBlock == null ? null : birthBlock + period
const blocksRemaining =
birthBlock == null || deathBlock == null || selectedBlockNumber == null
? null
: deathBlock - selectedBlockNumber
const contextProgress =
birthBlock == null || deathBlock == null || selectedBlockNumber == null
? null
: getTimelineProgress(birthBlock, deathBlock, selectedBlockNumber)
return (
<div>
period={period} phase={phase}{" "}
{birthBlock != null
? `currentBlock=${selectedBlockNumber?.toLocaleString()} from=${birthBlock.toLocaleString()} to=${(birthBlock + period).toLocaleString()}`
: null}
<div className="space-y-3 rounded-lg border border-foreground/10 bg-background/60 px-3 py-3">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-foreground/50">
Lifetime
</div>
</div>
</div>
<div className="grid gap-2 sm:grid-cols-2 xl:grid-cols-4">
<Metric label="Period" value={period.toLocaleString()} />
<Metric label="Phase" value={phase.toLocaleString()} />
</div>
<div className="rounded-md border border-foreground/10 bg-foreground/5 px-3 py-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-foreground/50">
Validity Window
</div>
<div className="text-xs text-muted-foreground">
{blocksRemaining == null
? null
: `${blocksRemaining.toLocaleString()} blocks remaining`}
</div>
</div>
<div className="mt-4">
<div className="relative h-3 rounded-full bg-emerald-500/50">
{contextProgress != null ? (
<div
className="absolute top-1/2 -translate-y-2.5"
style={{ left: `${contextProgress}%` }}
>
<div className="h-5 w-0.5 bg-foreground/60" />
<div
className="text-xs whitespace-nowrap font-semibold text-foreground/60"
style={{
transform: `translateX(${contextProgress < 10 ? "-1%" : contextProgress > 90 ? "-95%" : "-50%"})`,
}}
>
{selectedBlockNumber?.toLocaleString()}
</div>
</div>
) : null}
</div>
<div className="mt-5 grid grid-cols-[1fr_1fr] items-start gap-2 text-xs text-muted-foreground">
<div>
<div className="font-semibold uppercase tracking-[0.14em] text-foreground/50">
Start
</div>
<div className="mt-1 font-mono text-sm text-foreground">
{formatOptional(birthBlock)}
</div>
</div>
<div className="text-right">
<div className="font-semibold uppercase tracking-[0.14em] text-foreground/50">
End
</div>
<div className="mt-1 font-mono text-sm text-foreground">
{formatOptional(deathBlock)}
</div>
</div>
</div>
</div>
</div>
</div>
)
}
const Metric: FC<{ label: string; value: string }> = ({ label, value }) => (
<div className="rounded-md border border-foreground/10 bg-foreground/5 px-3 py-2">
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-foreground/50">
{label}
</div>
<div className="mt-1 break-all font-mono text-sm text-foreground">
{value}
</div>
</div>
)
const formatOptional = (value: number | null) =>
value == null ? "N/A" : value.toLocaleString()
const getTimelineProgress = (
start: number,
end: number,
value: number,
): number => {
if (end <= start) return 0
const raw = ((value - start) / (end - start)) * 100
return Math.max(0, Math.min(100, raw))
}
export const InlineMortality: FC<{
mortality: { type: string; value: number }
}> = ({ mortality }) => {
if (mortality.type === "Immortal") return "Immortal"
const parseResult = /^Mortal(\d+)$/.exec(mortality.type)
if (parseResult === null) return JSON.stringify(mortality, jsonSerialize)
const first = BigInt(parseResult[1])
const second = BigInt(mortality.value)
// from polkadot-sdk primitives runtime generic era fn decode
const encoded = first + (second << 8n)
const period = Number(2n << (encoded % (1n << 4n)))
const factor = period >> 12 || 1
const phase = Number(encoded >> 4n) * factor
return JSON.stringify({ period, phase }) + " " + JSON.stringify(mortality)
}

View File

@@ -1,73 +1,108 @@
import { ExpandBtn } from "@/components/Expand"
import { JsonDisplay } from "@/components/JsonDisplay"
import { Dot } from "lucide-react"
import { toHex } from "polkadot-api/utils"
import { ComponentType, FC, useState } from "react"
import { MortalityAnalyzer } from "./MortalityAnalyzer"
import { jsonSerialize } from "polkadot-api/utils"
import {
ComponentType,
FC,
PropsWithChildren,
ReactNode,
useState,
} from "react"
import { InlineMortality, MortalityAnalyzer } from "./MortalityAnalyzer"
export const SignedExtensions: FC<{ extra: Record<string, unknown> }> = ({
extra,
}) => (
export const SignedExtensions: FC<{
extra: Record<string, unknown>
title?: boolean
}> = ({ extra, title = true }) => (
<div className="space-y-2">
<h3>Signed extensions</h3>
<ul className="space-y-2">
{Object.entries(extra).map(([key, value]) => {
const KnownExtension = knownSignedExtensions[key]
return KnownExtension ? (
<KnownExtension key={key} id={key} value={value} />
) : (
<SignedExtension key={key} id={key} value={value} />
)
})}
{title ? (
<h3 className="text-sm font-semibold">Signed extensions</h3>
) : null}
<ul className="space-y-3">
{Object.entries(extra).map(([key, value]) => (
<SignedExtension key={key} id={key} value={value} />
))}
</ul>
</div>
)
const SignedExtension: FC<{ id: string; value: unknown }> = ({ id, value }) => {
const [expanded, setExpanded] = useState(false)
const inlineJson = JSON.stringify(value, (_, v) =>
typeof v === "bigint" ? String(v) : v instanceof Uint8Array ? toHex(v) : v,
)
if (!inlineJson || inlineJson.length < 40) {
return (
<li className="flex items-center flex-wrap gap-1">
<div className="flex gap-2 items-center">
<Dot size={16} />
{id}
</div>
{inlineJson ? (
<div className="whitespace-nowrap">
- <span className="font-mono text-sm">{inlineJson}</span>
</div>
) : null}
</li>
)
const knownExtension = knownSignedExtensions[id]
if (knownExtension) {
const { expanded: ExpandedView, inline: InlineView } = knownExtension
return ExpandedView ? (
<ExpandableLine
id={id}
inlineContent={InlineView ? <InlineView id={id} value={value} /> : null}
>
<ExpandedView id={id} value={value} />
</ExpandableLine>
) : InlineView ? (
<InlineLine
id={id}
inlineContent={InlineView ? <InlineView id={id} value={value} /> : null}
/>
) : null
}
return (
<li className="space-y-2">
<div className="flex gap-2 items-center">
<ExpandBtn expanded={expanded} onClick={() => setExpanded((e) => !e)} />
{id}
</div>
{expanded && <JsonDisplay src={value} />}
</li>
const inlineJson = JSON.stringify(value, jsonSerialize)
return !inlineJson || inlineJson.length < 40 ? (
<InlineLine id={id} inlineContent={inlineJson} />
) : (
<ExpandableLine id={id} inlineContent={null}>
<JsonDisplay src={value} />
</ExpandableLine>
)
}
export const knownSignedExtensions: Record<
string,
ComponentType<{ id: string; value: unknown }>
{
inline?: ComponentType<{ id: string; value: unknown }>
expanded?: ComponentType<{ id: string; value: unknown }>
}
> = {
CheckMortality: ({ id, value }) => {
return (
<li className="space-y-2">
<div className="flex gap-2 items-center">
<Dot size={16} />
{id}
</div>
<MortalityAnalyzer mortality={value as any} />
</li>
)
CheckMortality: {
expanded: ({ value }) => <MortalityAnalyzer mortality={value as any} />,
inline: ({ value }) => <InlineMortality mortality={value as any} />,
},
}
const ExpandableLine: FC<
PropsWithChildren<{ id: string; inlineContent?: ReactNode }>
> = ({ id, inlineContent, children }) => {
const [expanded, setExpanded] = useState(false)
return (
<li className="space-y-2 rounded-lg border border-foreground/10 bg-foreground/5 px-3 py-2">
<div className="flex gap-2 items-center">
<ExpandBtn expanded={expanded} onClick={() => setExpanded((e) => !e)} />
{id}
{inlineContent ? (
<div className="max-w-full whitespace-nowrap font-mono text-sm">
- {inlineContent}
</div>
) : null}
</div>
{expanded && children}
</li>
)
}
const InlineLine: FC<{ id: string; inlineContent?: ReactNode }> = ({
id,
inlineContent,
}) => (
<li className="flex flex-wrap items-center gap-2 rounded-lg border border-foreground/10 bg-foreground/5 px-3 py-2">
<div className="flex gap-2 items-center">
<Dot size={16} />
{id}
</div>
{inlineContent ? (
<div className="max-w-full whitespace-nowrap font-mono text-sm">
- {inlineContent}
</div>
) : null}
</li>
)

View File

@@ -12,27 +12,41 @@ export const ExtrinsicAnalyzer: FC = () => {
)
return (
<div className="p-2 space-y-2">
<h2 className="text-lg font-bold">Analyze Extrinsic</h2>
<div className="flex items-center gap-1 flex-wrap">
<div>
<label>
Block
<div className="space-y-4 p-3 md:p-4">
<header className="space-y-2">
<h2 className="text-2xl font-semibold tracking-tight">
Analyze Extrinsic
</h2>
<p className="max-w-3xl text-sm text-muted-foreground">
Decode a SCALE-encoded extrinsic against the selected block metadata,
inspect its signer and extensions, and review the exact call payload
and fee priority data.
</p>
</header>
<section className="rounded-xl border border-foreground/10 bg-card p-4 shadow-sm">
<div className="flex gap-4 flex-col lg:flex-row">
<div className="space-y-2">
<div className="text-xs font-semibold uppercase tracking-[0.2em] text-foreground/60">
Reference Block
</div>
<BlockPicker />
</label>
</div>
<div className="grow">
<label>
Extrinsic
</div>
<div className="space-y-2 w-full">
<div className="text-xs font-semibold uppercase tracking-[0.2em] text-foreground/60">
Extrinsic Bytes (hex)
</div>
<TextInputField
className="w-full"
className="w-full rounded-md bg-input"
value={extrinsicHex}
onChange={setExtrinsicHex}
placeholder="Extrinsic Hex"
placeholder="0x..."
/>
</label>
</div>
</div>
</div>
</section>
<Subscribe source$={extrinsicDecoder$} fallback={null}>
{extrinsicHex ? <ExtrinsicDecoder extrinsic={extrinsicHex} /> : null}
</Subscribe>

View File

@@ -1,15 +1,16 @@
import { CopyBinary } from "@/codec-components/ViewCodec/CopyBinary"
import { AccountIdDisplay } from "@/components/AccountIdDisplay"
import { JsonDisplay } from "@/components/JsonDisplay"
import { blockInfoState$ } from "@/pages/Explorer/block.state"
import { BlockContext } from "@/pages/Explorer/Detail/blockContext"
import { SignedExtensions } from "@/pages/Explorer/Detail/SignedExtensions"
import { DecodedExtrinsic, getExtrinsicDecoder } from "@polkadot-api/tx-utils"
import { getExtrinsicDecoder } from "@polkadot-api/tx-utils"
import { useStateObservable, withDefault } from "@react-rxjs/core"
import { HexString, TxCallData } from "polkadot-api"
import { toHex } from "polkadot-api/utils"
import { FC, useMemo } from "react"
import { FC, ReactNode, useMemo } from "react"
import { map, merge, switchMap } from "rxjs"
import { Sender } from "../../Explorer/Detail/Extrinsic"
import { senderToAddress } from "../../Explorer/Detail/Extrinsic"
import { AnalyzePriority, analyzePriority$ } from "./Priority"
import { selectedBlock$, selectedBlockHex$ } from "./selectedBlock"
@@ -43,24 +44,66 @@ export const ExtrinsicDecoder: FC<{
}, [extrinsicDecoder, extrinsic])
if (decodeResult.type === "error") {
return <div>Can't decode: {decodeResult.value.message}</div>
return (
<div className="rounded-xl border border-red-500/30 bg-red-500/5 p-4 text-sm text-red-700 dark:text-red-300">
<div className="text-xs font-semibold uppercase tracking-[0.2em]">
Decode Error
</div>
<div className="mt-2">
Can&apos;t decode: {decodeResult.value.message}
</div>
</div>
)
}
const decoded = decodeResult.value
const signerAddress =
decoded.type === "signed" ? senderToAddress(decoded.address) : null
return (
<BlockContext value={block}>
<div>
<h2 className="capitalize text-xl font-bold">
{decoded.type} Transaction v{decoded.version}
</h2>
<div className="space-y-4">
<SectionCard className="space-y-4">
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div className="space-y-2">
<div className="text-xs font-semibold uppercase tracking-[0.2em] text-foreground/60">
Decoded Extrinsic
</div>
<h2 className="text-2xl font-semibold tracking-tight capitalize">
{decoded.type} Transaction v{decoded.version}
</h2>
<p className="text-sm text-muted-foreground">
{decoded.call.type}.{decoded.call.value.type}
</p>
</div>
{signerAddress ? (
<div className="rounded-lg border border-foreground/10 bg-foreground/5 px-3 py-2">
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-foreground/50">
Signer
</div>
<div className="mt-1">
<AccountIdDisplay value={signerAddress} />
</div>
</div>
) : null}
</div>
</SectionCard>
{decoded.type === "signed" ? (
<SignedInfo extrinsic={extrinsic} decoded={decoded} />
<SectionCard title="Signed Extensions">
<SignedExtensions extra={decoded.extra} title={false} />
</SectionCard>
) : decoded.type === "general" ? (
<div>TODO</div>
) : (
<SectionCard>
<div className="text-sm text-muted-foreground">
General transaction analysis is not implemented yet.
</div>
</SectionCard>
) : null}
<SectionCard title="Priority Analysis">
<AnalyzePriority extrinsic={extrinsic} />
)}
</SectionCard>
<CallData
call={decoded.call as TxCallData}
callData={decoded.callData}
@@ -70,40 +113,60 @@ export const ExtrinsicDecoder: FC<{
)
}
const SignedInfo: FC<{
extrinsic: HexString
decoded: DecodedExtrinsic & { type: "signed" }
}> = ({ extrinsic, decoded }) => {
const txPayment =
decoded.extra.ChargeAssetTxPayment ?? decoded.extra.ChargeTxPayment
return (
<div className="space-y-2 mb-4">
<Sender sender={decoded.address} />
<SignedExtensions extra={decoded.extra} />
<AnalyzePriority extrinsic={extrinsic} txPayment={txPayment ?? {}} />
</div>
)
}
const CallData: FC<{ call: TxCallData; callData: Uint8Array }> = ({
call,
callData,
}) => (
<div>
<div className="flex gap-2 items-baseline">
<div className="text-lg">
{call.type}.{call.value.type}
</div>
<div className="flex gap-1 items-center overflow-hidden">
<div className="shrink overflow-hidden text-ellipsis text-muted-foreground">
{toHex(callData)}
<SectionCard title="Call Payload">
<div className="space-y-4">
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div>
<div className="text-lg font-semibold tracking-tight">
{call.type}.{call.value.type}
</div>
<p className="text-sm text-muted-foreground">
Decoded arguments for the call embedded in this extrinsic.
</p>
</div>
<CopyBinary value={callData} />
<div className="min-w-0 rounded-lg border border-foreground/10 bg-foreground/5 px-3 py-2 lg:max-w-xl">
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-foreground/50">
Call Data
</div>
<div className="mt-1 flex items-start gap-2">
<div className="shrink overflow-auto font-mono text-xs text-muted-foreground">
{toHex(callData)}
</div>
<CopyBinary value={callData} />
</div>
</div>
</div>
<div className="overflow-auto rounded-xl border border-foreground/10 bg-background/60 p-3">
<JsonDisplay src={call.value.value} />
</div>
</div>
<JsonDisplay src={call.value.value} />
</div>
</SectionCard>
)
export const extrinsicDecoder$ = merge(extDecoder$, analyzePriority$)
const SectionCard: FC<{
children: ReactNode
title?: string
className?: string
}> = ({ children, title, className }) => (
<section
className={[
"rounded-xl border border-foreground/10 bg-card p-4 shadow-sm",
className,
]
.filter(Boolean)
.join(" ")}
>
{title ? (
<div className="mb-3 text-xs font-semibold uppercase tracking-[0.2em] text-foreground/60">
{title}
</div>
) : null}
{children}
</section>
)

View File

@@ -2,9 +2,10 @@ import { client$ } from "@/state/chains/chain.state"
import { polkadot_people } from "@polkadot-api/descriptors"
import { state, useStateObservable } from "@react-rxjs/core"
import { Binary, HexString } from "polkadot-api"
import { FC, useEffect, useState } from "react"
import { FC, ReactNode, useEffect, useState } from "react"
import { combineLatest, firstValueFrom, switchMap } from "rxjs"
import { selectedBlockHex$ } from "./selectedBlock"
import { TokenAmount } from "@/components/TokenAmount"
const maxBlockSize$ = state(
combineLatest([selectedBlockHex$, client$]).pipe(
@@ -83,24 +84,82 @@ export const AnalyzePriority: FC<{
? maxTxPerBlockWeight
: maxTxPerBlockLength
const priority = (tip + 1n) * maxTxPerBlock
const limitingFactor =
maxTxPerBlockWeight < maxTxPerBlockLength ? "Weight" : "Length"
return { priority, maxTxPerBlockLength, maxTxPerBlockWeight }
return {
priority,
maxTxPerBlockLength,
maxTxPerBlockWeight,
maxTxPerBlock,
limitingFactor,
}
})()
return (
<div>
<b>Priority:</b> tip={tip.toLocaleString()} fee=
{queryInfo.partial_fee.toLocaleString()} class={queryInfo.class.type}{" "}
weight=
{queryInfo.weight.proof_size.toLocaleString() +
"/" +
queryInfo.weight.ref_time.toLocaleString()}{" "}
<div className="space-y-4">
{priority ? (
<>
txPerBlockLength={priority.maxTxPerBlockLength.toLocaleString()}{" "}
txPerBlockWeight={priority.maxTxPerBlockWeight.toLocaleString()}{" "}
priority={priority.priority.toLocaleString()}
</>
<div className="flex flex-col gap-2 border-b border-foreground/10 pb-4 lg:flex-row lg:items-end lg:justify-between">
<div>
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-foreground/50">
Computed Priority
</div>
<div className="mt-1 font-mono text-3xl font-semibold tracking-tight text-foreground sm:text-4xl">
{priority.priority.toLocaleString()}
</div>
</div>
</div>
) : null}
<div className="grid gap-4 lg:grid-cols-[minmax(0,1.1fr)_minmax(18rem,0.9fr)]">
<div className="rounded-lg border border-foreground/10 bg-foreground/5 p-3">
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-foreground/50">
Inputs
</div>
<div className="mt-3 space-y-2">
<DetailRow label="Tip" value={<TokenAmount>{tip}</TokenAmount>} />
<DetailRow
label="Partial Fee"
value={<TokenAmount>{queryInfo.partial_fee}</TokenAmount>}
/>
<DetailRow label="Class" value={queryInfo.class.type} />
<DetailRow label="Encoded Length" value={length.toLocaleString()} />
<DetailRow
label="Weight"
value={`${(Number(queryInfo.weight.proof_size) / 1024).toLocaleString(undefined, { maximumSignificantDigits: 3 })} KB / ${(Number(queryInfo.weight.ref_time) / 1_000_000).toLocaleString(undefined, { maximumSignificantDigits: 3 })} ms`}
/>
</div>
</div>
<div className="rounded-lg border border-foreground/10 bg-foreground/5 p-3">
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-foreground/50">
Capacity
</div>
<div className="mt-3 space-y-2">
<DetailRow
label="Tx/Block by Length"
value={
priority ? priority.maxTxPerBlockLength.toLocaleString() : "N/A"
}
/>
<DetailRow
label="Tx/Block by Weight"
value={
priority ? priority.maxTxPerBlockWeight.toLocaleString() : "N/A"
}
/>
<DetailRow
label="Limited By"
value={priority ? priority.limitingFactor : "N/A"}
/>
</div>
</div>
</div>
{!priority ? (
<div className="rounded-lg border border-foreground/10 bg-background/60 p-3 text-xs text-muted-foreground">
Waiting for block limits to finish the priority calculation.
</div>
) : null}
</div>
)
@@ -114,3 +173,13 @@ const divWeight = (a: Weight, b: Weight) => {
const proof_size = b.proof_size === 0n ? 1n : a.proof_size / b.proof_size
return ref_time < proof_size ? ref_time : proof_size
}
const DetailRow: FC<{ label: string; value: ReactNode }> = ({
label,
value,
}) => (
<div className="flex items-baseline justify-between gap-4 border-b border-foreground/8 pb-2 last:border-b-0 last:pb-0">
<div className="text-sm text-muted-foreground">{label}</div>
<div className="text-right font-mono text-sm text-foreground">{value}</div>
</div>
)