feat(sweep): show active scheduler states and schedule entry details (#1170)

This commit is contained in:
Parth 2026-03-25 14:22:21 +05:30 committed by theborakompanioni
parent eedc54c55d
commit cc4abfb3b4
No known key found for this signature in database
GPG key ID: E8070AF0053AAC0D
4 changed files with 235 additions and 10 deletions

View file

@ -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 ? (
<>

View file

@ -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', () => {

View file

@ -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,
}
}

View file

@ -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 }} })",