feat(cloudflare): add custom targets and node failover (#43)

* feat(cloudflare): add custom targets and node failover

* fix(cloudflare): propagate batch sync errors

---------

Co-authored-by: OpenAI Codex <codex@openai.com>
This commit is contained in:
Ryan
2026-10-07 11:29:41 +08:00
committed by GitHub
parent e49e0ad2bf
commit fd0e9acc2f
28 changed files with 1144 additions and 155 deletions
@@ -23,6 +23,7 @@ import {
SelectValue,
} from '@/components/ui/select';
import { Switch } from '@/components/ui/switch';
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
import type {
CloudflareGroup,
CloudflareGroupPayload,
@@ -50,7 +51,15 @@ export function GroupDialog({
() => nodes.filter((node) => node.node_type === 'edge_node'),
[nodes],
);
const recordContentPlaceholders = {
CNAME: t('recordContentPlaceholder.CNAME'),
A: t('recordContentPlaceholder.A'),
AAAA: t('recordContentPlaceholder.AAAA'),
};
const [name, setName] = useState('');
const [targetMode, setTargetMode] = useState<'node' | 'custom'>('node');
const [recordType, setRecordType] = useState<'CNAME' | 'A' | 'AAAA'>('A');
const [recordContent, setRecordContent] = useState('');
const [primaryNodeID, setPrimaryNodeID] = useState('');
const [backupNodeID, setBackupNodeID] = useState('none');
const [defaultProxied, setDefaultProxied] = useState(true);
@@ -59,8 +68,19 @@ export function GroupDialog({
useEffect(() => {
if (!open) return;
setName(group?.name ?? '');
setPrimaryNodeID(group ? String(group.primary_node.id) : '');
setBackupNodeID(group?.backup_node ? String(group.backup_node.id) : 'none');
setTargetMode(group?.target_mode === 'custom' ? 'custom' : 'node');
setRecordType(group?.record_type ?? 'A');
setRecordContent(group?.record_content ?? '');
setPrimaryNodeID(
group?.target_mode !== 'custom' && group
? String(group.primary_node.id)
: '',
);
setBackupNodeID(
group?.target_mode !== 'custom' && group?.backup_node
? String(group.backup_node.id)
: 'none',
);
setDefaultProxied(group?.default_proxied ?? true);
setEnabled(group?.enabled ?? true);
}, [group, open]);
@@ -82,46 +102,113 @@ export function GroupDialog({
/>
</Field>
<Field>
<FieldLabel htmlFor='cf-primary-node'>{t('primary')}</FieldLabel>
<Select value={primaryNodeID} onValueChange={setPrimaryNodeID}>
<SelectTrigger id='cf-primary-node' className='w-full'>
<SelectValue placeholder={t('primaryPlaceholder')} />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{edgeNodes.map((node) => (
<SelectItem
key={node.id}
value={String(node.id)}
disabled={!node.ip}
>
{node.name} · {node.ip || t('noIp')}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</Field>
<Field>
<FieldLabel htmlFor='cf-backup-node'>{t('backup')}</FieldLabel>
<Select value={backupNodeID} onValueChange={setBackupNodeID}>
<SelectTrigger id='cf-backup-node' className='w-full'>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value='none'>{t('none')}</SelectItem>
{edgeNodes
.filter((node) => String(node.id) !== primaryNodeID)
.map((node) => (
<SelectItem key={node.id} value={String(node.id)}>
{node.name} · {node.ip || t('noIp')}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
<FieldLabel id='cf-target-mode-label'>{t('targetMode')}</FieldLabel>
<ToggleGroup
type='single'
variant='outline'
value={targetMode}
aria-labelledby='cf-target-mode-label'
className='grid w-full grid-cols-2'
onValueChange={(value) => {
if (value === 'node' || value === 'custom') {
setTargetMode(value);
}
}}
>
<ToggleGroupItem value='node' className='w-full'>
{t('bindNode')}
</ToggleGroupItem>
<ToggleGroupItem value='custom' className='w-full'>
{t('customTarget')}
</ToggleGroupItem>
</ToggleGroup>
</Field>
{targetMode === 'node' ? (
<>
<Field>
<FieldLabel htmlFor='cf-primary-node'>
{t('primary')}
</FieldLabel>
<Select value={primaryNodeID} onValueChange={setPrimaryNodeID}>
<SelectTrigger id='cf-primary-node' className='w-full'>
<SelectValue placeholder={t('primaryPlaceholder')} />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{edgeNodes.map((node) => (
<SelectItem
key={node.id}
value={String(node.id)}
disabled={!node.ip}
>
{node.name} · {node.ip || t('noIp')}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</Field>
<Field>
<FieldLabel htmlFor='cf-backup-node'>{t('backup')}</FieldLabel>
<Select value={backupNodeID} onValueChange={setBackupNodeID}>
<SelectTrigger id='cf-backup-node' className='w-full'>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value='none'>{t('none')}</SelectItem>
{edgeNodes
.filter((node) => String(node.id) !== primaryNodeID)
.map((node) => (
<SelectItem key={node.id} value={String(node.id)}>
{node.name} · {node.ip || t('noIp')}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</Field>
</>
) : (
<>
<Field>
<FieldLabel htmlFor='cf-record-type'>
{t('recordType')}
</FieldLabel>
<Select
value={recordType}
onValueChange={(value: 'CNAME' | 'A' | 'AAAA') =>
setRecordType(value)
}
>
<SelectTrigger id='cf-record-type' className='w-full'>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value='CNAME'>CNAME</SelectItem>
<SelectItem value='A'>A</SelectItem>
<SelectItem value='AAAA'>AAAA</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</Field>
<Field>
<FieldLabel htmlFor='cf-record-content'>
{t('recordContent')}
</FieldLabel>
<Input
id='cf-record-content'
value={recordContent}
placeholder={recordContentPlaceholders[recordType]}
onChange={(event) => setRecordContent(event.target.value)}
/>
<p className='text-sm text-muted-foreground'>
{t('recordContentHint')}
</p>
</Field>
</>
)}
<Field orientation='horizontal'>
<FieldLabel htmlFor='cf-default-proxied'>
{t('defaultProxied')}
@@ -146,13 +233,23 @@ export function GroupDialog({
{tCommon('cancel')}
</Button>
<Button
disabled={pending || !name.trim() || !primaryNodeID}
disabled={
pending ||
!name.trim() ||
(targetMode === 'node' ? !primaryNodeID : !recordContent.trim())
}
onClick={() =>
onSubmit({
name: name.trim(),
primary_node_id: Number(primaryNodeID),
target_mode: targetMode,
record_type: recordType,
record_content: recordContent.trim(),
primary_node_id:
targetMode === 'node' ? Number(primaryNodeID) : 0,
backup_node_id:
backupNodeID === 'none' ? null : Number(backupNodeID),
targetMode === 'node' && backupNodeID !== 'none'
? Number(backupNodeID)
: null,
default_proxied: defaultProxied,
enabled,
})
@@ -5,6 +5,7 @@ import {
ArrowLeft,
ArrowRightLeft,
Cloud,
CloudCog,
Loader2,
Plus,
RefreshCw,
@@ -239,6 +240,16 @@ export function CloudflareGroupDetailPageClient() {
},
onError: (error) => toast.error(getErrorMessage(error)),
});
const batchEnableProxyMutation = useMutation({
mutationFn: (memberIds: number[]) =>
CloudflareService.batchEnableProxy(groupID, memberIds),
onSuccess: async (_, memberIds) => {
toast.success(t('batchProxyEnabled', { count: memberIds.length }));
setSelectedMemberIDs(new Set());
await invalidate();
},
onError: (error) => toast.error(getErrorMessage(error)),
});
const handleSingleMove = (member: CloudflareMember) => {
setMovingMembers([member]);
@@ -324,23 +335,39 @@ export function CloudflareGroupDetailPageClient() {
<CardHeader>
<CardTitle className='text-base'>{t('currentPoint')}</CardTitle>
<CardDescription>
{t('activeNode', {
name: group.active_node.name,
ip: group.active_node.ip,
})}
{group.target_mode === 'custom'
? t('customTargetSummary', {
type: group.record_type,
content: group.record_content,
})
: t('activeNode', {
name: group.active_node.name,
ip: group.active_node.ip,
})}
</CardDescription>
</CardHeader>
<CardContent className='flex flex-wrap gap-2'>
<Badge variant={group.enabled ? 'default' : 'secondary'}>
{group.enabled ? t('syncEnabled') : t('syncDisabled')}
</Badge>
{group.target_mode === 'node' ? (
<>
<Badge variant='outline'>
{t('primaryNode', { name: group.primary_node.name })}
</Badge>
<Badge variant='outline'>
{t('backupNode', {
name: group.backup_node?.name ?? t('backupUnset'),
})}
</Badge>
</>
) : null}
<Badge variant='outline'>
{t('primaryNode', { name: group.primary_node.name })}
</Badge>
<Badge variant='outline'>
{t('backupNode', {
name: group.backup_node?.name ?? t('backupUnset'),
})}
{t(
group.target_mode === 'custom'
? 'customTargetMode'
: 'nodeTargetMode',
)}
</Badge>
</CardContent>
</Card>
@@ -356,6 +383,19 @@ export function CloudflareGroupDetailPageClient() {
<Badge variant='secondary' className='text-xs'>
{t('selectedCount', { count: selectedMembers.length })}
</Badge>
<Button
variant='outline'
size='sm'
onClick={() =>
batchEnableProxyMutation.mutate(
selectedMembers.map((member) => member.id),
)
}
disabled={batchEnableProxyMutation.isPending}
>
<CloudCog data-icon='inline-start' />
{t('batchEnableProxy')}
</Button>
<Button
variant='outline'
size='sm'
@@ -485,27 +525,30 @@ export function CloudflareGroupDetailPageClient() {
<div className='flex justify-end gap-2'>
<Button
variant='outline'
size='sm'
size='icon'
title={t('move')}
aria-label={t('move')}
onClick={() => handleSingleMove(member)}
>
<ArrowRightLeft data-icon='inline-start' />
{t('move')}
<ArrowRightLeft />
</Button>
<Button
variant='outline'
size='sm'
size='icon'
title={t('sync')}
aria-label={t('sync')}
onClick={() => syncMutation.mutate(member.id)}
>
<RefreshCw data-icon='inline-start' />
{t('sync')}
<RefreshCw />
</Button>
<Button
variant='destructive'
size='sm'
size='icon'
title={t('remove')}
aria-label={t('remove')}
onClick={() => removeMutation.mutate(member.id)}
>
<Trash2 data-icon='inline-start' />
{t('remove')}
<Trash2 />
</Button>
</div>
</TableCell>
+6 -1
View File
@@ -182,7 +182,12 @@ export default function CloudflarePage() {
<div>
<CardTitle className='text-base'>{group.name}</CardTitle>
<CardDescription>
{group.active_node.name} · {group.active_node.ip}
{group.target_mode === 'custom'
? t('customTargetSummary', {
type: group.record_type,
content: group.record_content,
})
: `${group.active_node.name} · ${group.active_node.ip}`}
</CardDescription>
</div>
<Badge variant={group.enabled ? 'default' : 'secondary'}>