mirror of
https://github.com/joinmarket-webui/jam.git
synced 2026-08-18 13:09:10 +02:00
feat(sweep): show active scheduler states and schedule entry details (#1170)
This commit is contained in:
parent
eedc54c55d
commit
cc4abfb3b4
4 changed files with 235 additions and 10 deletions
|
|
@ -4,7 +4,7 @@ import { Button } from '@/components/ui/button'
|
|||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Spinner } from '../ui/spinner'
|
||||
import type { Schedule } from './scheduleUtils'
|
||||
import type { Schedule, ScheduleEntryState } from './scheduleUtils'
|
||||
import { toScheduleProgressSummary } from './scheduleUtils'
|
||||
|
||||
interface SweepScheduleProgressProps {
|
||||
|
|
@ -43,6 +43,30 @@ export const SweepScheduleProgress = ({ schedule, isStopping, onStop }: SweepSch
|
|||
'3': <span className="font-semibold" />,
|
||||
}
|
||||
|
||||
const formatWaitTime = (seconds: number): string => {
|
||||
const roundedSeconds = Math.max(0, Math.ceil(seconds))
|
||||
const minutes = Math.floor(roundedSeconds / 60)
|
||||
const remainingSeconds = roundedSeconds % 60
|
||||
|
||||
if (minutes === 0) {
|
||||
return t('scheduler.progress_wait_seconds', { seconds: remainingSeconds })
|
||||
}
|
||||
if (remainingSeconds === 0) {
|
||||
return t('scheduler.progress_wait_minutes', { minutes })
|
||||
}
|
||||
return t('scheduler.progress_wait_minutes_seconds', { minutes, seconds: remainingSeconds })
|
||||
}
|
||||
|
||||
const toScheduleEntryStateText = (state: ScheduleEntryState, txid?: string): string => {
|
||||
if (state === 'confirmed') {
|
||||
return t('scheduler.progress_entry_state_confirmed')
|
||||
}
|
||||
if (state === 'broadcasted') {
|
||||
return t('scheduler.progress_entry_state_waiting_confirmation', { txid: txid ?? '-' })
|
||||
}
|
||||
return t('scheduler.progress_entry_state_pending')
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="space-y-4">
|
||||
|
|
@ -78,19 +102,73 @@ export const SweepScheduleProgress = ({ schedule, isStopping, onStop }: SweepSch
|
|||
<Alert>
|
||||
<Spinner className="motion-reduce:hidden" />
|
||||
<AlertTitle>
|
||||
<Trans
|
||||
i18nKey="scheduler.progress_current_state"
|
||||
values={{
|
||||
current: progress.currentTransactionIndex + 1,
|
||||
total: progress.totalTransactions,
|
||||
}}
|
||||
components={highlightedComponents}
|
||||
/>
|
||||
{/* Keep a stable fallback state so brief polling gaps never leave this header empty. */}
|
||||
{progress.currentState?.type === 'waiting_before_next' ? (
|
||||
<Trans
|
||||
i18nKey="scheduler.progress_current_state_wait_before_next"
|
||||
values={{
|
||||
current: progress.currentState.currentTransaction,
|
||||
total: progress.currentState.totalTransactions,
|
||||
wait: formatWaitTime(progress.currentState.waitSeconds ?? 0),
|
||||
}}
|
||||
components={highlightedComponents}
|
||||
/>
|
||||
) : progress.currentState?.type === 'waiting_for_confirmation' ? (
|
||||
<Trans
|
||||
i18nKey="scheduler.progress_current_state_waiting_confirmation"
|
||||
values={{
|
||||
current: progress.currentState.currentTransaction,
|
||||
total: progress.currentState.totalTransactions,
|
||||
txid: progress.currentState.txid ?? '-',
|
||||
}}
|
||||
components={highlightedComponents}
|
||||
/>
|
||||
) : progress.currentState?.type === 'transaction_confirmed' ? (
|
||||
<Trans
|
||||
i18nKey="scheduler.progress_current_state_transaction_confirmed"
|
||||
values={{
|
||||
current: progress.currentState.currentTransaction,
|
||||
total: progress.currentState.totalTransactions,
|
||||
}}
|
||||
components={highlightedComponents}
|
||||
/>
|
||||
) : (
|
||||
<Trans
|
||||
i18nKey="scheduler.progress_current_state_creating_next"
|
||||
values={{
|
||||
current: progress.currentTransactionIndex + 1,
|
||||
total: progress.totalTransactions,
|
||||
}}
|
||||
components={highlightedComponents}
|
||||
/>
|
||||
)}
|
||||
</AlertTitle>
|
||||
<AlertDescription />
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className="space-y-2 rounded-lg border p-3">
|
||||
<div className="font-medium">{t('scheduler.progress_schedule_info_title')}</div>
|
||||
<div className="space-y-2">
|
||||
{progress.entries.map((entry) => (
|
||||
<div
|
||||
key={entry.index}
|
||||
className="bg-muted/30 flex flex-wrap items-center justify-between gap-x-3 gap-y-1 rounded-md px-2 py-1.5 text-xs"
|
||||
>
|
||||
<div className="font-medium">{t('scheduler.progress_entry_label', { index: entry.index + 1 })}</div>
|
||||
<div className="text-muted-foreground">{toScheduleEntryStateText(entry.state, entry.txid)}</div>
|
||||
<div className="text-muted-foreground">
|
||||
{entry.isLast
|
||||
? t('scheduler.progress_entry_wait_final')
|
||||
: t('scheduler.progress_entry_wait_before_next', {
|
||||
wait: formatWaitTime(entry.waitBeforeNextSeconds),
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button type="button" onClick={() => void onStop()} disabled={isStopping} size="lg" className="w-full">
|
||||
{isStopping ? (
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -28,6 +28,38 @@ describe('scheduleUtils', () => {
|
|||
expect(summary.isDone).toBe(false)
|
||||
expect(summary.steps).toHaveLength(3)
|
||||
expect(summary.steps[1].isActive).toBe(true)
|
||||
expect(summary.entries).toHaveLength(3)
|
||||
expect(summary.entries[0].state).toBe('confirmed')
|
||||
expect(summary.entries[0].waitBeforeNextSeconds).toBe(600)
|
||||
expect(summary.entries[2].isLast).toBe(true)
|
||||
})
|
||||
|
||||
it('derives current state while waiting for transaction confirmation', () => {
|
||||
const schedule: Schedule = [
|
||||
[0, 0, 8, 'INTERNAL', 10, 16, 1],
|
||||
[1, 0, 8, 'INTERNAL', 5, 16, '8'.repeat(64)],
|
||||
[2, 0, 8, 'bc1qdestination', 1, 16, 0],
|
||||
]
|
||||
|
||||
const summary = toScheduleProgressSummary(schedule)
|
||||
|
||||
expect(summary.currentState?.type).toBe('waiting_for_confirmation')
|
||||
expect(summary.currentState?.currentTransaction).toBe(2)
|
||||
expect(summary.entries[1].state).toBe('broadcasted')
|
||||
expect(summary.entries[1].txid).toBe('8'.repeat(64))
|
||||
})
|
||||
|
||||
it('derives current state while waiting before the next transaction', () => {
|
||||
const schedule: Schedule = [
|
||||
[0, 0, 8, 'INTERNAL', 2, 16, 1],
|
||||
[1, 0, 8, 'bc1qdestination', 0, 16, 0],
|
||||
]
|
||||
|
||||
const summary = toScheduleProgressSummary(schedule)
|
||||
|
||||
expect(summary.currentState?.type).toBe('waiting_before_next')
|
||||
expect(summary.currentState?.currentTransaction).toBe(2)
|
||||
expect(summary.currentState?.waitSeconds).toBe(120)
|
||||
})
|
||||
|
||||
it('falls back to frozen-utxo check when last schedule entry is stale', () => {
|
||||
|
|
|
|||
|
|
@ -32,6 +32,30 @@ export interface ScheduleProgressStep {
|
|||
isLast: boolean
|
||||
}
|
||||
|
||||
export type ScheduleEntryState = 'pending' | 'broadcasted' | 'confirmed'
|
||||
|
||||
export interface ScheduleProgressEntry {
|
||||
index: number
|
||||
waitBeforeNextSeconds: number
|
||||
state: ScheduleEntryState
|
||||
txid?: TxId
|
||||
isLast: boolean
|
||||
}
|
||||
|
||||
export type ScheduleCurrentStateType =
|
||||
| 'waiting_before_next'
|
||||
| 'creating_and_broadcasting'
|
||||
| 'waiting_for_confirmation'
|
||||
| 'transaction_confirmed'
|
||||
|
||||
export interface ScheduleCurrentState {
|
||||
type: ScheduleCurrentStateType
|
||||
currentTransaction: number
|
||||
totalTransactions: number
|
||||
waitSeconds?: number
|
||||
txid?: TxId
|
||||
}
|
||||
|
||||
export interface ScheduleProgressSummary {
|
||||
totalWaitSeconds: number
|
||||
totalTransactions: number
|
||||
|
|
@ -39,6 +63,8 @@ export interface ScheduleProgressSummary {
|
|||
currentTransactionIndex: number
|
||||
isDone: boolean
|
||||
steps: ScheduleProgressStep[]
|
||||
entries: ScheduleProgressEntry[]
|
||||
currentState?: ScheduleCurrentState
|
||||
}
|
||||
|
||||
const MIN_STEP_WIDTH_PERCENT = 8
|
||||
|
|
@ -55,6 +81,25 @@ const getScheduleEntryState = (entry: ScheduleEntry): string | number => {
|
|||
return entry[6] ?? 0
|
||||
}
|
||||
|
||||
const getScheduleEntryTxId = (entry: ScheduleEntry): TxId | undefined => {
|
||||
const state = getScheduleEntryState(entry)
|
||||
return typeof state === 'string' ? state : undefined
|
||||
}
|
||||
|
||||
// Scheduler state flag convention from backend:
|
||||
// 0 => not yet broadcast, txid string => broadcasted (unconfirmed), 1 => confirmed.
|
||||
const toScheduleEntryState = (entry: ScheduleEntry): ScheduleEntryState => {
|
||||
const state = getScheduleEntryState(entry)
|
||||
|
||||
if (state === 1) {
|
||||
return 'confirmed'
|
||||
}
|
||||
if (typeof state === 'string') {
|
||||
return 'broadcasted'
|
||||
}
|
||||
return 'pending'
|
||||
}
|
||||
|
||||
const isScheduleEntryConfirmed = (entry: ScheduleEntry): boolean => {
|
||||
return getScheduleEntryState(entry) === 1
|
||||
}
|
||||
|
|
@ -91,6 +136,7 @@ export const toScheduleProgressSummary = (schedule: Schedule): ScheduleProgressS
|
|||
currentTransactionIndex: 0,
|
||||
isDone: true,
|
||||
steps: [],
|
||||
entries: [],
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -106,6 +152,7 @@ export const toScheduleProgressSummary = (schedule: Schedule): ScheduleProgressS
|
|||
)
|
||||
|
||||
const isDone = completedTransactions >= schedule.length
|
||||
const currentTransactionIndex = Math.min(completedTransactions, schedule.length - 1)
|
||||
|
||||
const steps: ScheduleProgressStep[] = schedule.map((_entry, index) => {
|
||||
const widthPercent =
|
||||
|
|
@ -125,13 +172,67 @@ export const toScheduleProgressSummary = (schedule: Schedule): ScheduleProgressS
|
|||
}
|
||||
})
|
||||
|
||||
const entries: ScheduleProgressEntry[] = schedule.map((entry, index) => {
|
||||
return {
|
||||
index,
|
||||
waitBeforeNextSeconds: index >= schedule.length - 1 ? 0 : getScheduleEntryWaitMinutes(entry) * 60,
|
||||
state: toScheduleEntryState(entry),
|
||||
txid: getScheduleEntryTxId(entry),
|
||||
isLast: index === schedule.length - 1,
|
||||
}
|
||||
})
|
||||
|
||||
let currentState: ScheduleCurrentState | undefined
|
||||
if (!isDone) {
|
||||
const activeEntry = schedule[currentTransactionIndex]
|
||||
const activeEntryTxId = getScheduleEntryTxId(activeEntry)
|
||||
const activeEntryState = getScheduleEntryState(activeEntry)
|
||||
|
||||
// Infer the user-facing phase from the currently active entry and the previous wait slot.
|
||||
if (activeEntryTxId !== undefined) {
|
||||
currentState = {
|
||||
type: 'waiting_for_confirmation',
|
||||
currentTransaction: currentTransactionIndex + 1,
|
||||
totalTransactions: schedule.length,
|
||||
txid: activeEntryTxId,
|
||||
}
|
||||
} else if (activeEntryState === 1) {
|
||||
currentState = {
|
||||
type: 'transaction_confirmed',
|
||||
currentTransaction: currentTransactionIndex + 1,
|
||||
totalTransactions: schedule.length,
|
||||
}
|
||||
} else {
|
||||
const waitSeconds =
|
||||
currentTransactionIndex > 0
|
||||
? Math.ceil(getScheduleEntryWaitMinutes(schedule[currentTransactionIndex - 1]) * 60)
|
||||
: 0
|
||||
|
||||
currentState =
|
||||
waitSeconds > 0
|
||||
? {
|
||||
type: 'waiting_before_next',
|
||||
currentTransaction: currentTransactionIndex + 1,
|
||||
totalTransactions: schedule.length,
|
||||
waitSeconds,
|
||||
}
|
||||
: {
|
||||
type: 'creating_and_broadcasting',
|
||||
currentTransaction: currentTransactionIndex + 1,
|
||||
totalTransactions: schedule.length,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
totalWaitSeconds,
|
||||
totalTransactions: schedule.length,
|
||||
completedTransactions: Math.min(completedTransactions, schedule.length),
|
||||
currentTransactionIndex: Math.min(completedTransactions, schedule.length - 1),
|
||||
currentTransactionIndex,
|
||||
isDone,
|
||||
steps,
|
||||
entries,
|
||||
currentState,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -794,6 +794,20 @@
|
|||
"progress_tldr_hours": "Scheduled <1>{{ length }}</1> transactions over <3>{{ hours }}</3> hours.",
|
||||
"progress_description": "This estimate is the minimum waiting time. Additional delays due to network communication or transaction confirmation are not considered.",
|
||||
"progress_current_state": "Waiting for transaction <1>{{ current }}</1> of <3>{{ total }}</3> to process...",
|
||||
"progress_current_state_wait_before_next": "Waiting {{ wait }} before creating and broadcasting transaction <1>{{ current }}</1> of <3>{{ total }}</3>.",
|
||||
"progress_current_state_waiting_confirmation": "Waiting for transaction <1>{{ current }}</1> of <3>{{ total }}</3> ({{ txid }}) to be confirmed.",
|
||||
"progress_current_state_transaction_confirmed": "Transaction <1>{{ current }}</1> of <3>{{ total }}</3> confirmed.",
|
||||
"progress_current_state_creating_next": "Creating and broadcasting transaction <1>{{ current }}</1> of <3>{{ total }}</3>...",
|
||||
"progress_wait_seconds": "{{ seconds }}s",
|
||||
"progress_wait_minutes": "{{ minutes }}m",
|
||||
"progress_wait_minutes_seconds": "{{ minutes }}m {{ seconds }}s",
|
||||
"progress_schedule_info_title": "Schedule details",
|
||||
"progress_entry_label": "Transaction {{ index }}",
|
||||
"progress_entry_state_pending": "Pending",
|
||||
"progress_entry_state_waiting_confirmation": "Waiting for confirmation ({{ txid }})",
|
||||
"progress_entry_state_confirmed": "Confirmed",
|
||||
"progress_entry_wait_before_next": "Wait before next: {{ wait }}",
|
||||
"progress_entry_wait_final": "Final transaction",
|
||||
"progress_done": "All transactions completed successfully. The scheduler will stop soon.",
|
||||
"precondition": {
|
||||
"hint_missing_utxos": "To run the scheduler you need UTXOs with {{ minConfirmations }} or more confirmations. $t(scheduler.precondition.nested_hint_fund_wallet, {\"count\": {{ minConfirmations }} })",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue