File size: 20,314 Bytes
a21c316 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 | import { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { request } from '../../utils/request';
import { showToast } from '../common/ToastContainer';
import { Plus, Network, Upload, RefreshCw, Link2, SlidersHorizontal, Trash2, Power } from 'lucide-react';
import { ProxyPoolConfig, ProxyEntry } from '../../types/config';
import ProxyList from './proxy/ProxyList';
import ProxyEditModal from './proxy/ProxyEditModal';
import BatchImportModal from './proxy/BatchImportModal';
import ProxyBindingManager from './proxy/ProxyBindingManager';
import { useAccountStore } from '../../stores/useAccountStore';
interface ProxyPoolSettingsProps {
config: ProxyPoolConfig;
onChange: (config: ProxyPoolConfig, silent?: boolean) => void;
}
export default function ProxyPoolSettings({ config, onChange }: ProxyPoolSettingsProps) {
const { t } = useTranslation();
const { accounts, fetchAccounts } = useAccountStore();
const [isAddModalOpen, setIsAddModalOpen] = useState(false);
const [isBatchImportOpen, setIsBatchImportOpen] = useState(false);
const [isBindingManagerOpen, setIsBindingManagerOpen] = useState(false);
const [isTesting, setIsTesting] = useState(false);
const [accountBindings, setAccountBindings] = useState<Record<string, string>>({});
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
// Fetch bindings and accounts on mount
useEffect(() => {
fetchBindings();
fetchAccounts();
}, []);
// Refresh bindings when manager closes
useEffect(() => {
if (!isBindingManagerOpen) {
fetchBindings();
}
}, [isBindingManagerOpen]);
// [FIX] Polling for proxy pool status
// Now only updates volatile status (is_healthy, latency) to avoid race condition regressions
useEffect(() => {
let interval: any;
if (config.enabled) { // Only poll if proxy pool is enabled
interval = setInterval(async () => {
try {
const liveConfig = await request<ProxyPoolConfig>('get_proxy_pool_config');
if (liveConfig && liveConfig.proxies) {
// Create a map for quick lookups
const liveMap = new Map(liveConfig.proxies.map(p => [p.id, p]));
// Check if any status actually changed
let hasChanges = false;
const updatedProxies = config.proxies.map(p => {
const live = liveMap.get(p.id);
if (live && (live.is_healthy !== p.is_healthy || live.latency !== p.latency || live.last_check_time !== p.last_check_time)) {
hasChanges = true;
return { ...p, is_healthy: live.is_healthy, latency: live.latency, last_check_time: live.last_check_time };
}
return p;
});
if (hasChanges) {
// Only update volatile status, DO NOT trigger heavy onChange which saves to disk
// This internal change will eventually be captured by next manual save or
// simply keep the UI fresh without risking rolling back user's structural changes (add/delete)
onChange({ ...config, proxies: updatedProxies }, true); // Pass 'true' as silent flag if onChange supports it, or use a separate state
}
}
} catch (e) {
// Ignore if service not running or other errors
console.error('Failed to poll proxy pool config:', e);
}
}, 5000); // Poll every 5s
}
return () => clearInterval(interval);
}, [config.enabled, config.proxies]); // Depend on config.enabled and config.proxies to re-evaluate polling
const fetchBindings = async () => {
try {
const bindings = await request<Record<string, string>>('get_all_account_bindings');
if (bindings) setAccountBindings(bindings);
} catch (e) {
console.error('Fetch bindings failed:', e);
}
};
const safeConfig: ProxyPoolConfig = {
enabled: config?.enabled ?? false,
proxies: config?.proxies ?? [],
health_check_interval: config?.health_check_interval ?? 300,
auto_failover: config?.auto_failover ?? true,
strategy: config?.strategy ?? 'priority',
};
const handleUpdateProxies = (proxies: ProxyEntry[]) => {
onChange({ ...safeConfig, proxies });
};
const handleAddProxy = (entry: ProxyEntry) => {
onChange({
...safeConfig,
proxies: [...safeConfig.proxies, entry]
});
};
const handleBatchImport = async (newProxies: ProxyEntry[]) => {
const updatedProxies = [...safeConfig.proxies, ...newProxies];
await onChange({
...safeConfig,
proxies: updatedProxies
});
// Auto-trigger test after import is fully committed
handleTestAll();
};
const handleBatchDelete = () => {
if (selectedIds.size === 0) return;
if (confirm(t('settings.proxy_pool.confirm_batch_delete', 'Are you sure you want to delete selected proxies?'))) {
const newProxies = safeConfig.proxies.filter(p => !selectedIds.has(p.id));
onChange({ ...safeConfig, proxies: newProxies });
setSelectedIds(new Set());
showToast(t('common.deleted', 'Deleted successfully'), 'success');
}
};
const handleBatchToggleEnabled = (enabled: boolean) => {
if (selectedIds.size === 0) return;
const newProxies = safeConfig.proxies.map(p =>
selectedIds.has(p.id) ? { ...p, enabled } : p
);
onChange({ ...safeConfig, proxies: newProxies });
showToast(t(enabled ? 'common.enabled' : 'common.disabled', enabled ? 'Enabled' : 'Disabled'), 'success');
};
const handleTestAll = async () => {
setIsTesting(true);
try {
const liveConfig = await request<ProxyPoolConfig>('check_proxy_health');
if (liveConfig && liveConfig.proxies) {
// [FIX] Use incremental merge to prevent race condition rollbacks
const liveMap = new Map(liveConfig.proxies.map(p => [p.id, p]));
const updatedProxies = config.proxies.map(p => {
const live = liveMap.get(p.id);
if (live) {
return {
...p,
is_healthy: live.is_healthy,
latency: live.latency,
last_check_time: live.last_check_time
};
}
return p;
});
// Update local UI state silently (syncing health stats only)
onChange({ ...config, proxies: updatedProxies }, true);
}
showToast(t('settings.proxy_pool.test_completed', 'Health check completed'), 'success');
} catch (error) {
console.error('Test all failed:', error);
showToast(t('settings.proxy_pool.test_failed', 'Health check failed'), 'error');
} finally {
setIsTesting(false);
}
};
return (
<div className="space-y-3">
{/* Consolidated Header & Toolbar */}
<div className="flex flex-wrap items-center justify-between gap-4 p-1.5 bg-gray-100/30 dark:bg-gray-800/20 border border-gray-200 dark:border-gray-800/70 rounded-2xl">
{/* Left: Component Identity & Feature Toggle */}
<div className="flex items-center gap-2 p-1.5 bg-white dark:bg-gray-900 border border-gray-200/50 dark:border-gray-800 shadow-sm rounded-xl">
<div className="flex items-center gap-3 pr-3 border-r border-gray-100 dark:border-gray-800">
<div className="p-1.5 bg-blue-600 text-white rounded-lg shadow-blue-500/20 shadow-lg">
<Network className="w-3.5 h-3.5" />
</div>
<h3 className="text-xs font-black text-gray-900 dark:text-white whitespace-nowrap uppercase tracking-wider">
{t('settings.proxy_pool.title', 'Proxy Pool')}
</h3>
<label className="relative inline-flex items-center cursor-pointer ml-1">
<input
type="checkbox"
checked={safeConfig.enabled}
onChange={e => onChange({ ...safeConfig, enabled: e.target.checked })}
className="sr-only peer"
/>
<div className="w-9 h-5 bg-gray-200 peer-focus:outline-none rounded-full peer dark:bg-gray-700 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-4 after:w-4 after:transition-all dark:border-gray-600 peer-checked:bg-blue-600"></div>
</label>
</div>
{/* Middle: Configuration Parameters (Strategy & Interval) */}
<div className="flex items-center gap-4 px-2">
<div className="flex items-center gap-2 group">
<SlidersHorizontal size={12} className="text-gray-400 group-hover:text-blue-500 transition-colors" />
<select
value={safeConfig.strategy}
onChange={e => onChange({ ...safeConfig, strategy: e.target.value as any })}
className="text-[10px] bg-transparent border-none p-0 pr-6 focus:ring-0 font-black uppercase tracking-tight text-gray-700 dark:text-gray-300 cursor-pointer hover:text-blue-600 dark:hover:text-blue-400 transition-colors"
>
<option value="priority">{t('settings.proxy_pool.strategy_priority', 'Priority')}</option>
<option value="round_robin">{t('settings.proxy_pool.strategy_round_robin', 'Round Robin')}</option>
<option value="random">{t('settings.proxy_pool.strategy_random', 'Random')}</option>
<option value="least_connections">{t('settings.proxy_pool.strategy_least_connections', 'Least Connections')}</option>
</select>
</div>
<div className="flex items-center gap-2 border-l border-gray-100 dark:border-gray-800 pl-4 group">
<RefreshCw size={12} className="text-gray-400 group-hover:text-emerald-500 transition-colors" />
<div className="flex items-center gap-1">
<input
type="number"
defaultValue={safeConfig.health_check_interval}
onBlur={e => {
const val = parseInt(e.target.value) || 60;
if (val !== safeConfig.health_check_interval) {
onChange({ ...safeConfig, health_check_interval: val });
}
}}
className="w-10 text-[10px] bg-transparent border-none p-0 focus:ring-0 font-black text-gray-700 dark:text-gray-300 text-right group-hover:text-emerald-600 transition-colors"
/>
<span className="text-[9px] text-gray-400 font-black uppercase tracking-tighter">{t('settings.proxy_pool.seconds', 'Sec')}</span>
</div>
</div>
</div>
</div>
{/* Right: Actions or Selection Toolbar */}
<div className="flex items-center gap-2">
{selectedIds.size > 0 ? (
<div className="flex items-center gap-1.5 bg-blue-50/50 dark:bg-blue-900/20 px-2.5 py-1.5 rounded-xl border border-blue-100 dark:border-blue-800/50 animate-in zoom-in-95 duration-200">
<span className="text-[10px] font-black text-blue-700 dark:text-blue-300 mr-2 uppercase tracking-tight">
{selectedIds.size} {t('common.selected', 'Selected')}
</span>
<div className="flex items-center gap-1">
<button
onClick={() => handleBatchToggleEnabled(true)}
className="p-1.5 text-blue-600 hover:bg-white dark:hover:bg-blue-800 rounded-lg transition-all shadow-sm hover:shadow active:scale-90"
title={t('common.enable', 'Enable')}
>
<Power size={14} />
</button>
<button
onClick={() => handleBatchToggleEnabled(false)}
className="p-1.5 text-gray-400 hover:bg-white dark:hover:bg-gray-800 rounded-lg transition-all shadow-sm hover:shadow active:scale-90"
title={t('common.disable', 'Disable')}
>
<Power size={14} className="opacity-50" />
</button>
<button
onClick={handleBatchDelete}
className="p-1.5 text-rose-500 hover:bg-white dark:hover:bg-rose-900/40 rounded-lg transition-all shadow-sm hover:shadow active:scale-90"
title={t('common.delete', 'Delete')}
>
<Trash2 size={14} />
</button>
</div>
<div className="w-px h-4 bg-blue-200 dark:bg-blue-800 mx-1"></div>
<button
onClick={() => setSelectedIds(new Set())}
className="text-[10px] font-black text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 uppercase px-2 py-1"
>
{t('common.cancel', 'Cancel')}
</button>
</div>
) : (
<>
<div className="flex items-center gap-1 p-1 bg-gray-50 dark:bg-gray-800/50 rounded-xl border border-gray-100 dark:border-gray-800/50">
<button
onClick={handleTestAll}
disabled={isTesting}
className={`p-2 text-gray-400 hover:text-emerald-500 hover:bg-white dark:hover:bg-gray-800 rounded-lg transition-all ${isTesting ? 'animate-spin opacity-50' : 'active:scale-90'}`}
title={t('settings.proxy_pool.test_all', 'Test All')}
>
<RefreshCw size={14} />
</button>
<button
onClick={() => setIsBindingManagerOpen(true)}
className="p-2 text-gray-400 hover:text-indigo-500 hover:bg-white dark:hover:bg-gray-800 rounded-lg transition-all active:scale-90"
title={t('settings.proxy_pool.binding_manager', 'Manage Bindings')}
>
<Link2 size={14} />
</button>
</div>
<div className="flex items-center gap-2 ml-1">
<button
onClick={() => setIsBatchImportOpen(true)}
className="flex items-center gap-1.5 px-3.5 py-2 text-[11px] font-black uppercase tracking-wider text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-white bg-gray-50 hover:bg-white dark:bg-gray-800/50 dark:hover:bg-gray-800 border border-gray-100 dark:border-gray-800/50 rounded-xl transition-all shadow-sm hover:shadow active:scale-95"
>
<Upload size={12} />
{t('settings.proxy_pool.batch_import', 'Import')}
</button>
<button
onClick={() => setIsAddModalOpen(true)}
className="flex items-center gap-1.5 px-4 py-2 text-[11px] font-black uppercase tracking-wider bg-gray-900 hover:bg-black dark:bg-white dark:hover:bg-gray-100 text-white dark:text-gray-900 rounded-xl transition-all shadow-lg hover:shadow-black/20 dark:hover:shadow-white/10 active:scale-95"
>
<Plus size={12} />
{t('settings.proxy_pool.add_proxy', 'Add')}
</button>
</div>
</>
)}
</div>
</div>
{/* Proxy List - Always visible, with status context */}
<div className="relative border border-gray-200 dark:border-gray-800 rounded-xl overflow-hidden bg-white dark:bg-gray-900 shadow-sm transition-all duration-300">
{!safeConfig.enabled && (
<div className="absolute inset-x-0 top-0 z-10 bg-amber-50/80 dark:bg-amber-900/10 backdrop-blur-[2px] px-3 py-1 flex items-center justify-center border-b border-amber-100/50 dark:border-amber-900/20">
<span className="text-[10px] font-black text-amber-600/80 dark:text-amber-500/80 uppercase tracking-[0.2em] pointer-events-none">
{t('settings.proxy_pool.inactive_notice', 'Proxy Pool Inactive')}
</span>
</div>
)}
<div className={!safeConfig.enabled ? 'pt-6 opacity-60' : ''}>
<ProxyList
proxies={safeConfig.proxies}
onUpdate={handleUpdateProxies}
accountBindings={accountBindings}
accounts={accounts}
selectedIds={selectedIds}
onSelectionChange={setSelectedIds}
isTesting={isTesting}
/>
</div>
</div>
{isAddModalOpen && (
<ProxyEditModal
isOpen={isAddModalOpen}
onClose={() => setIsAddModalOpen(false)}
onSave={handleAddProxy}
isEditing={false}
/>
)}
{isBatchImportOpen && (
<BatchImportModal
isOpen={isBatchImportOpen}
onClose={() => setIsBatchImportOpen(false)}
onImport={handleBatchImport}
/>
)}
{isBindingManagerOpen && (
<ProxyBindingManager
isOpen={isBindingManagerOpen}
onClose={() => setIsBindingManagerOpen(false)}
proxies={safeConfig.proxies}
/>
)}
</div>
);
}
|