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
105 changes: 101 additions & 4 deletions app/admin/members/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,11 @@ import { useSiweAuth } from '@/lib/wallet/providers'
import { AuthError } from '@/lib/api/live'
import { queryKeys } from '@/lib/query'
import { LoadingState, ErrorState, EmptyState, DeniedState, safeErrorMessage } from '@/components/ui/api-states'
import { applyOptimisticRole } from '@/lib/api/optimistic'
import { applyOptimisticRole, applyOptimisticRemoveRole } from '@/lib/api/optimistic'
import { AddressText } from '@/components/wallet/address-text'
import { BulkActionToolbar, BulkResultItem } from '@/components/ui/bulk-action-toolbar'
import { runWithConcurrency } from '@/lib/api/batch-runner'
import { isCircuitOpen } from '@/lib/api/live'

type AssignRoleInput = {
address: string
Expand Down Expand Up @@ -66,6 +69,67 @@ export default function MembersPage() {
const [pendingAssignment, setPendingAssignment] = useState<AssignRoleInput | null>(null)
const [successAssignment, setSuccessAssignment] = useState<AssignRoleInput | null>(null)
const [rollbackMessage, setRollbackMessage] = useState('')
const [successMessage, setSuccessMessage] = useState('')

const [selectedAddresses, setSelectedAddresses] = useState<string[]>([])
const [bulkProcessing, setBulkProcessing] = useState(false)
const [bulkResults, setBulkResults] = useState<BulkResultItem[] | null>(null)

const toggleSelection = (address: string) => {
setSelectedAddresses(prev =>
prev.includes(address) ? prev.filter(a => a !== address) : [...prev, address]
)
}

const toggleAll = () => {
if (members && selectedAddresses.length === members.length) {
setSelectedAddresses([])
} else if (members) {
setSelectedAddresses(members.map(m => m.address))
}
}

const executeBulkAssign = async (targetRole: Role, addressesToProcess: string[]) => {
if (!addressesToProcess.length || bulkProcessing) return
setBulkProcessing(true)

// Using a concurrency limit of 5 for bulk operations
const CONCURRENCY_LIMIT = 5;

// Normalize path to match circuit breaker: /v1/members/:address/roles
const checkCircuit = () => isCircuitOpen('/v1/members/:address/roles');

const results = await runWithConcurrency(
addressesToProcess,
CONCURRENCY_LIMIT,
async (address) => {
const api = getApi(address, authSession?.token)
await api.assignRole(address, targetRole)
},
checkCircuit
)

const finalResults = results.map((r, i) => ({
address: addressesToProcess[i],
status: r.status,
error: r.status === 'rejected' ? r.reason : undefined
}));

setBulkResults(finalResults)
setBulkProcessing(false)
qc.invalidateQueries({ queryKey: queryKeys.members.all })
}

const handleRetryFailed = async () => {
if (!bulkResults) return;
const failedAddresses = bulkResults
.filter(r => r.status !== 'fulfilled')
.map(r => r.address);
// Use the role from the previous selection
// In a real app we might store what role we were trying to assign
// For now, we fall back to selectedRole or just 'member'
await executeBulkAssign(role, failedAddresses);
}

const {
mutate,
Expand Down Expand Up @@ -185,7 +249,7 @@ export default function MembersPage() {
</div>
{successAssignment && (
<div className="text-sm text-green-700 dark:text-green-400" role="status">
Role "{successAssignment.role}" saved for{' '}
Role &quot;{successAssignment.role}&quot; saved for{' '}
<AddressText
address={successAssignment.address}
className="text-green-700 dark:text-green-400"
Expand All @@ -211,6 +275,22 @@ export default function MembersPage() {
<Card>
<CardHeader><CardTitle>Member List</CardTitle></CardHeader>
<CardContent>
<BulkActionToolbar
selectedAddresses={selectedAddresses}
onClearSelection={() => {
setSelectedAddresses([])
setBulkResults(null)
}}
onAssignRole={(r) => executeBulkAssign(r, selectedAddresses)}
isProcessing={bulkProcessing}
results={bulkResults}
onRetryFailed={handleRetryFailed}
onClearResults={() => {
setBulkResults(null)
setSelectedAddresses([])
}}
/>

{isLoading ? (
<LoadingState message="Loading members…" />
) : isError ? (
Expand All @@ -223,12 +303,29 @@ export default function MembersPage() {
<EmptyState title="No members yet" message="No members have been added to this community." />
) : (
<div className="space-y-2">
<div className="flex items-center gap-2 px-2 pb-2 mb-2 border-b">
<input
type="checkbox"
checked={members.length > 0 && selectedAddresses.length === members.length}
onChange={toggleAll}
className="h-4 w-4"
/>
<span className="text-sm font-medium">Select All</span>
</div>
{members.map((m) => (
<div
key={m.address}
className="flex items-center justify-between border rounded-md p-2"
className={`flex items-center justify-between border rounded-md p-2 transition-colors ${selectedAddresses.includes(m.address) ? 'bg-muted/30 border-primary' : ''}`}
>
<AddressText address={m.address} className="text-sm" />
<div className="flex items-center gap-3">
<input
type="checkbox"
checked={selectedAddresses.includes(m.address)}
onChange={() => toggleSelection(m.address)}
className="h-4 w-4"
/>
<AddressText address={m.address} className="text-sm" />
</div>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span>Tier: {m.tier}</span>
<div className="flex gap-1">
Expand Down
105 changes: 105 additions & 0 deletions components/ui/bulk-action-toolbar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import { useState } from 'react'
import { Role } from '@/lib/api'
import { Button } from './button'
import { Card, CardContent } from './card'
import { Badge } from './badge'

export type BulkResultItem = {
address: string
status: 'fulfilled' | 'rejected' | 'skipped_circuit_open'
error?: any
}

interface BulkActionToolbarProps {
selectedAddresses: string[]
onClearSelection: () => void
onAssignRole: (role: Role) => Promise<void>
isProcessing: boolean
results: BulkResultItem[] | null
onRetryFailed: () => Promise<void>
onClearResults: () => void
}

export function BulkActionToolbar({
selectedAddresses,
onClearSelection,
onAssignRole,
isProcessing,
results,
onRetryFailed,
onClearResults
}: BulkActionToolbarProps) {
const [selectedRole, setSelectedRole] = useState<Role>('member')

if (selectedAddresses.length === 0 && !results) {
return null;
}

return (
<Card className="mb-4 bg-muted/50">
<CardContent className="p-4 space-y-4">
{!results ? (
<div className="flex items-center justify-between">
<span className="text-sm font-medium">
{selectedAddresses.length} member(s) selected
</span>
<div className="flex items-center gap-2">
<select
className="border rounded-md h-9 px-2 text-sm"
value={selectedRole}
onChange={(e) => setSelectedRole(e.target.value as Role)}
disabled={isProcessing}
>
<option value="member">member</option>
<option value="moderator">moderator</option>
<option value="admin">admin</option>
</select>
<Button
size="sm"
onClick={() => onAssignRole(selectedRole)}
disabled={isProcessing}
>
{isProcessing ? 'Processing...' : 'Assign Role'}
</Button>
<Button
size="sm"
variant="outline"
onClick={onClearSelection}
disabled={isProcessing}
>
Cancel
</Button>
</div>
</div>
) : (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="font-semibold text-sm">Bulk Assignment Results</h3>
<div className="flex items-center gap-2">
{results.some(r => r.status !== 'fulfilled') && (
<Button size="sm" onClick={onRetryFailed} disabled={isProcessing}>
{isProcessing ? 'Retrying...' : 'Retry Failed/Skipped'}
</Button>
)}
<Button size="sm" variant="outline" onClick={onClearResults} disabled={isProcessing}>
Dismiss
</Button>
</div>
</div>

<div className="max-h-60 overflow-y-auto space-y-2 border rounded p-2 text-sm bg-background">
{results.map((r, i) => (
<div key={`${r.address}-${i}`} className="flex justify-between items-center p-2 border-b last:border-0">
<span className="font-mono text-xs">{r.address}</span>
{r.status === 'fulfilled' && <Badge className="bg-green-600 text-white hover:bg-green-600">OK</Badge>}
{r.status === 'rejected' && <Badge variant="destructive">Error</Badge>}
{r.status === 'skipped_circuit_open' && <Badge variant="warning">Skipped (Circuit Open)</Badge>}
</div>
))}
</div>
</div>
)}
</CardContent>
</Card>
)
}
49 changes: 49 additions & 0 deletions lib/api/batch-runner.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
export type BatchResult<R> =
| { status: 'fulfilled'; value: R }
| { status: 'rejected'; reason: any }
| { status: 'skipped_circuit_open' };

export async function runWithConcurrency<T, R>(
items: T[],
limit: number,
worker: (item: T) => Promise<R>,
circuitBreakerCheck?: () => boolean
): Promise<BatchResult<R>[]> {
const results: BatchResult<R>[] = new Array(items.length);
let currentIndex = 0;
let isCircuitOpen = false;

async function processNext(): Promise<void> {
while (true) {
if (currentIndex >= items.length) {
return;
}

const index = currentIndex++;
const item = items[index];

if (isCircuitOpen || (circuitBreakerCheck && circuitBreakerCheck())) {
isCircuitOpen = true; // Mark as open for the rest of the batch
results[index] = { status: 'skipped_circuit_open' };
continue;
}

try {
const value = await worker(item);
results[index] = { status: 'fulfilled', value };
} catch (error) {
results[index] = { status: 'rejected', reason: error };
}
}
}

const workers: Promise<void>[] = [];
const actualLimit = Math.min(limit, items.length);

for (let i = 0; i < actualLimit; i++) {
workers.push(processNext());
}

await Promise.all(workers);
return results;
}
1 change: 1 addition & 0 deletions lib/api/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export type ApiErrorCode =
| 'server_error'
| 'bad_request'
| 'unknown_error'
| 'service_unavailable'

export interface ApiErrorOptions {
status?: number
Expand Down
Loading