Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
228 changes: 217 additions & 11 deletions app/screens/BatchOperationsScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,12 +47,18 @@ const CANCEL_REASONS: Array<{ key: CancelReason['reason']; label: string }> = [

const STATE_COLORS: Record<BatchState, string> = {
pending: colors.textSecondary,
running: colors.warning,
processing: colors.warning,
completed: colors.success,
partial: colors.warning,
failed: colors.error,
rolled_back: colors.textSecondary,
};

const formatPercent = (fraction: number): string => `${Math.round(fraction * 100)}%`;

const formatDuration = (ms: number): string =>
ms >= 1000 ? `${(ms / 1000).toFixed(1)}s` : `${Math.round(ms)}ms`;

// ════════════════════════════════════════════════════════════════
// Component
// ════════════════════════════════════════════════════════════════
Expand All @@ -75,12 +81,19 @@ export const BatchOperationsScreen: React.FC = () => {
setDraft,
executeBatch,
retryFailed,
rollbackBatch,
canRollback,
exportResultJson,
exportResultCsv,
resetDraft,
clearResult,
loadHistory,
gasEstimate,
itemCount,
validateDraft,
activeConfig,
setOperationConfig,
resetOperationConfig,
analytics,
} = useBatchStore();

const [showHistory, setShowHistory] = useState(false);
Expand All @@ -100,14 +113,11 @@ export const BatchOperationsScreen: React.FC = () => {
loadHistory();
}, []);

const items =
draft.createInputs.length ||
draft.updateIds.length ||
draft.cancelIds.length ||
draft.chargeItems.length ||
0;

const canExecute = items > 0 && !isRunning;
const items = itemCount();
const config = activeConfig();
const sizeCheck = validateDraft();
const canExecute = sizeCheck.valid && !isRunning;
const rollbackAvailable = canRollback();

const onLoadCsv = useCallback(() => {
const csv = draft.csvContent;
Expand Down Expand Up @@ -139,6 +149,36 @@ export const BatchOperationsScreen: React.FC = () => {
await retryFailed();
}, [retryFailed]);

const onRollback = useCallback(() => {
Alert.alert(
'Roll back batch',
'Every committed item in this batch will be reversed. Continue?',
[
{ text: 'Cancel', style: 'cancel' },
{
text: 'Roll back',
style: 'destructive',
onPress: async () => {
const rollback = await rollbackBatch();
if (!rollback) {
Alert.alert(
'Rollback unavailable',
'This batch cannot be rolled back. Configure a rollback handler and check that the operation type allows it.',
);
return;
}
Alert.alert(
'Rollback complete',
`Reverted ${rollback.reverted} of ${rollback.attempted} item(s)${
rollback.failed > 0 ? `, ${rollback.failed} could not be reverted` : ''
}.`,
);
},
},
],
);
}, [rollbackBatch]);

const onExportJson = useCallback(() => {
const json = exportResultJson();
if (json) {
Expand Down Expand Up @@ -355,9 +395,132 @@ export const BatchOperationsScreen: React.FC = () => {
<Text style={styles.gasEstimate}>
Est. Gas: {gasEst.toLocaleString()} units | {draft.atomic ? 'Atomic' : 'Non-atomic'} mode
</Text>
{!sizeCheck.valid && items > 0 && (
<Text style={styles.validationText}>{sizeCheck.reason}</Text>
)}
</View>
);

const renderConfig = () => (
<View style={styles.section}>
<View style={styles.resultHeader}>
<Text style={styles.sectionTitle}>{draft.operationType.toUpperCase()} Configuration</Text>
<TouchableOpacity onPress={() => resetOperationConfig(draft.operationType)}>
<Text style={styles.historyLink}>Reset</Text>
</TouchableOpacity>
</View>
<Text style={styles.configHint}>
Applies to every {draft.operationType} batch. Limits mirror the on-chain configuration for
this operation type.
</Text>
<View style={styles.optionRow}>
<Text style={styles.optionLabel}>Max items per batch</Text>
<TextInput
style={styles.chunkInput}
value={String(config.maxItems)}
onChangeText={(v: string) =>
setOperationConfig(draft.operationType, {
maxItems: Math.max(1, parseInt(v, 10) || 1),
})
}
keyboardType="number-pad"
/>
</View>
<View style={styles.optionRow}>
<Text style={styles.optionLabel}>Atomic by default</Text>
<Switch
value={config.atomicDefault}
onValueChange={(atomicDefault: boolean) =>
setOperationConfig(draft.operationType, { atomicDefault })
}
trackColor={{ false: colors.border, true: colors.primary }}
/>
</View>
<View style={styles.optionRow}>
<Text style={styles.optionLabel}>Rollback allowed</Text>
<Switch
value={config.allowRollback}
onValueChange={(allowRollback: boolean) =>
setOperationConfig(draft.operationType, { allowRollback })
}
trackColor={{ false: colors.border, true: colors.primary }}
/>
</View>
<View style={styles.optionRow}>
<Text style={styles.optionLabel}>Max retries per item</Text>
<TextInput
style={styles.chunkInput}
value={String(config.maxRetries)}
onChangeText={(v: string) =>
setOperationConfig(draft.operationType, {
maxRetries: Math.max(0, parseInt(v, 10) || 0),
})
}
keyboardType="number-pad"
/>
</View>
<View style={styles.optionRow}>
<Text style={styles.optionLabel}>Skip already-applied items</Text>
<Switch
value={config.idempotent}
onValueChange={(idempotent: boolean) =>
setOperationConfig(draft.operationType, { idempotent })
}
trackColor={{ false: colors.border, true: colors.primary }}
/>
</View>
</View>
);

const renderAnalytics = () => {
const stats = analytics();
if (stats.overall.batches === 0) return null;
const active = stats.byOperationType[draft.operationType];
return (
<View style={styles.section}>
<Text style={styles.sectionTitle}>Batch Analytics</Text>
<View style={styles.statsRow}>
<View style={styles.statBox}>
<Text style={styles.statValue}>{stats.overall.batches}</Text>
<Text style={styles.statLabel}>Batches</Text>
</View>
<View style={styles.statBox}>
<Text style={[styles.statValue, styles.successText]}>
{formatPercent(stats.overall.batchSuccessRate)}
</Text>
<Text style={styles.statLabel}>Clean runs</Text>
</View>
<View style={styles.statBox}>
<Text style={[styles.statValue, styles.successText]}>
{formatPercent(stats.overall.itemSuccessRate)}
</Text>
<Text style={styles.statLabel}>Items OK</Text>
</View>
<View style={styles.statBox}>
<Text style={styles.statValue}>{formatDuration(stats.overall.avgDurationMs)}</Text>
<Text style={styles.statLabel}>Avg time</Text>
</View>
</View>
<Text style={styles.analyticsDetail}>
p95 {formatDuration(stats.overall.p95DurationMs)} ·{' '}
{stats.overall.avgItemDurationMs}ms per item · {stats.overall.throughputPerSecond}/s
throughput
</Text>
<Text style={styles.analyticsDetail}>
{stats.overall.failedItems} failed item(s) · {stats.overall.skippedItems} skipped ·{' '}
{stats.overall.rolledBack} rolled back
</Text>
{active.batches > 0 && (
<Text style={styles.analyticsDetail}>
{draft.operationType}: {active.batches} batch(es),{' '}
{formatPercent(active.itemSuccessRate)} item success, avg{' '}
{formatDuration(active.avgDurationMs)}
</Text>
)}
</View>
);
};

const renderActions = () => (
<View style={styles.section}>
<TouchableOpacity
Expand All @@ -382,6 +545,12 @@ export const BatchOperationsScreen: React.FC = () => {
disabled={!hasFailedItems || isRunning}>
<Text style={styles.secondaryButtonText}>Retry Failed</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.secondaryButton, !rollbackAvailable && styles.buttonDisabled]}
onPress={onRollback}
disabled={!rollbackAvailable || isRunning}>
<Text style={styles.secondaryButtonText}>Roll Back</Text>
</TouchableOpacity>
<TouchableOpacity style={styles.secondaryButton} onPress={resetDraft}>
<Text style={styles.secondaryButtonText}>Reset</Text>
</TouchableOpacity>
Expand Down Expand Up @@ -454,10 +623,29 @@ export const BatchOperationsScreen: React.FC = () => {

{currentResult.rolledBack && (
<View style={styles.rollbackBadge}>
<Text style={styles.rollbackText}>Rolled back (atomic failure)</Text>
<Text style={styles.rollbackText}>
{currentResult.state === 'rolled_back'
? 'Rolled back — every committed item was reversed'
: 'Rolled back (atomic failure)'}
</Text>
</View>
)}

{currentResult.rejectionReason && (
<View style={styles.rollbackBadge}>
<Text style={styles.rollbackText}>{currentResult.rejectionReason}</Text>
</View>
)}

{currentResult.durationMs !== undefined && (
<Text style={styles.analyticsDetail}>
Completed in {formatDuration(currentResult.durationMs)}
{currentResult.totalItems > 0
? ` · ${Math.round(currentResult.durationMs / currentResult.totalItems)}ms per item`
: ''}
</Text>
)}

<View style={styles.exportRow}>
<TouchableOpacity style={styles.exportButton} onPress={onExportJson}>
<Text style={styles.exportButtonText}>Export JSON</Text>
Expand Down Expand Up @@ -655,6 +843,7 @@ export const BatchOperationsScreen: React.FC = () => {
<Text style={styles.historySummary}>{item.summary}</Text>
<Text style={styles.historyTime}>
{new Date(item.timestamp).toLocaleString()}
{item.durationMs !== undefined ? ` · ${formatDuration(item.durationMs)}` : ''}
</Text>
</View>
)}
Expand All @@ -681,9 +870,11 @@ export const BatchOperationsScreen: React.FC = () => {
{renderUpdateParams()}
{renderCancelReasons()}
{renderOptions()}
{renderConfig()}
{renderActions()}
{renderProgress()}
{renderResults()}
{renderAnalytics()}

<View style={styles.bottomPad} />
</ScrollView>
Expand Down Expand Up @@ -853,6 +1044,21 @@ const styles = StyleSheet.create({
color: colors.textSecondary,
marginTop: spacing.sm,
},
validationText: {
...typography.caption,
color: colors.error,
marginTop: spacing.xs,
},
configHint: {
...typography.caption,
color: colors.textSecondary,
marginBottom: spacing.sm,
},
analyticsDetail: {
...typography.caption,
color: colors.textSecondary,
marginTop: spacing.xs,
},
primaryButton: {
backgroundColor: colors.primary,
paddingVertical: spacing.md,
Expand Down
Loading
Loading