Manage scraper proxy from the Settings page

- New "Scraper Proxy" tab: enable toggle, host/port/username/password/
  country, Save, and a Test Connection button that reports the live exit
  IP + country through the proxy
- Backend proxy config now lives in system_config (DB authoritative when
  booking_proxy_enabled is set; BOOKING_PROXY_* env vars are the fallback)
- Dedicated /config/proxy GET/POST/test endpoints; password is write-only
  (never returned, blank keeps the stored value) and masked in /config/system
- Surface proxy status keys in the read-only System tab

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
jtricerolph 2026-07-05 20:36:19 +00:00
parent 11b995739c
commit 270d8293d1
3 changed files with 321 additions and 21 deletions

View file

@ -1,11 +1,12 @@
import { useState } from 'react'
import { useState, useEffect } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Save, RefreshCw, Database, Clock, BedDouble, ChevronUp, ChevronDown } from 'lucide-react'
import { Save, RefreshCw, Database, Clock, BedDouble, ChevronUp, ChevronDown, Network, ShieldCheck } from 'lucide-react'
import api from '../api'
const TABS = [
{ id: 'newbook', label: 'Newbook Sync' },
{ id: 'proxy', label: 'Scraper Proxy' },
{ id: 'system', label: 'System' },
]
@ -76,6 +77,10 @@ export default function Settings() {
/>
)}
{activeTab === 'proxy' && (
<ProxyTab />
)}
{activeTab === 'system' && (
<SystemTab config={config} isLoading={isLoading} />
)}
@ -83,6 +88,170 @@ export default function Settings() {
)
}
// ─── Scraper Proxy Tab ────────────────────────────────────────────────────────
interface ProxyConfigData {
enabled: boolean
host: string
port: string
username: string
country: string
password_set: boolean
}
interface ProxyTestResult {
ok: boolean
ip: string
country: string
city: string
org: string
}
function ProxyTab() {
const qc = useQueryClient()
const { data: cfg, isLoading } = useQuery<ProxyConfigData>({
queryKey: ['proxy-config'],
queryFn: () => api.get('/competitors/config/proxy').then(r => r.data),
})
const [enabled, setEnabled] = useState(false)
const [host, setHost] = useState('')
const [port, setPort] = useState('823')
const [username, setUsername] = useState('')
const [country, setCountry] = useState('gb')
const [password, setPassword] = useState('')
const [testResult, setTestResult] = useState<ProxyTestResult | null>(null)
const [testError, setTestError] = useState<string | null>(null)
// Seed form once the stored config arrives (password stays blank by design)
useEffect(() => {
if (cfg) {
setEnabled(cfg.enabled)
setHost(cfg.host)
setPort(cfg.port)
setUsername(cfg.username)
setCountry(cfg.country)
}
}, [cfg])
const save = useMutation({
mutationFn: () =>
api.post('/competitors/config/proxy', {
enabled, host, port, username, country,
// Only send a password when the user typed a new one
password: password || undefined,
}),
onSuccess: () => {
setPassword('')
qc.invalidateQueries({ queryKey: ['proxy-config'] })
qc.invalidateQueries({ queryKey: ['system-config'] })
},
})
const runTest = useMutation({
mutationFn: () => api.post('/competitors/config/proxy/test').then(r => r.data as ProxyTestResult),
onMutate: () => { setTestResult(null); setTestError(null) },
onSuccess: (data) => setTestResult(data),
onError: (err: any) => setTestError(err?.response?.data?.detail || 'Test failed'),
})
if (isLoading) {
return <div className="loading-state"><div className="spinner" /> Loading</div>
}
const labelStyle: React.CSSProperties = {
fontSize: 12, fontWeight: 600, color: 'var(--text-mid)', display: 'block', marginBottom: 6,
}
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 20, maxWidth: 600 }}>
<div className="card">
<div className="card-header">
<span style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<Network size={16} strokeWidth={1.75} />
Residential Proxy
</span>
<span className={`badge ${enabled ? 'badge-success' : 'badge-neutral'}`}>
{enabled ? 'Enabled' : 'Disabled'}
</span>
</div>
<div className="card-body" style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<p style={{ fontSize: 13, color: 'var(--text-mid)', margin: 0 }}>
Routes the Booking.com scraper through a residential proxy (e.g. DataImpulse).
One sticky IP is held per scrape session and rotated automatically if a page is
blocked. Leave disabled to scrape from this server's own IP.
</p>
<label style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 13 }}>
<input type="checkbox" checked={enabled} onChange={e => setEnabled(e.target.checked)} />
Enable proxy for scraping
</label>
<div style={{ display: 'grid', gridTemplateColumns: '2fr 1fr', gap: 12 }}>
<div>
<label style={labelStyle}>Proxy host</label>
<input type="text" placeholder="gw.dataimpulse.com" value={host}
onChange={e => setHost(e.target.value)} style={{ width: '100%' }} />
</div>
<div>
<label style={labelStyle}>Port</label>
<input type="text" placeholder="823" value={port}
onChange={e => setPort(e.target.value)} style={{ width: '100%' }} />
</div>
</div>
<div>
<label style={labelStyle}>Username / login</label>
<input type="text" placeholder="account login" value={username}
onChange={e => setUsername(e.target.value)} style={{ width: '100%' }} />
</div>
<div style={{ display: 'grid', gridTemplateColumns: '2fr 1fr', gap: 12 }}>
<div>
<label style={labelStyle}>Password</label>
<input type="password" placeholder={cfg?.password_set ? '•••••••• (unchanged)' : 'not set'}
value={password} onChange={e => setPassword(e.target.value)} style={{ width: '100%' }} />
</div>
<div>
<label style={labelStyle}>Country</label>
<input type="text" placeholder="gb" value={country}
onChange={e => setCountry(e.target.value.toLowerCase())} style={{ width: '100%' }} />
</div>
</div>
<div style={{ display: 'flex', gap: 12, alignItems: 'center' }}>
<button className="btn btn-primary" onClick={() => save.mutate()} disabled={save.isPending}>
<Save size={14} strokeWidth={1.75} />
{save.isPending ? 'Saving…' : 'Save'}
</button>
<button className="btn btn-outline" onClick={() => runTest.mutate()} disabled={runTest.isPending}>
<ShieldCheck size={14} strokeWidth={1.75} />
{runTest.isPending ? 'Testing…' : 'Test Connection'}
</button>
</div>
{save.isError && (
<p style={{ fontSize: 13, color: 'var(--danger)', margin: 0 }}>
{(save.error as any)?.response?.data?.detail || 'Save failed'}
</p>
)}
{testResult && (
<div style={{ fontSize: 13, color: 'var(--success)', background: 'var(--bg-subtle, #f0fdf4)',
padding: '10px 12px', borderRadius: 6, border: '1px solid var(--success)' }}>
Connected exit IP <strong>{testResult.ip}</strong> ({testResult.country}
{testResult.city ? `, ${testResult.city}` : ''}){testResult.org ? ` · ${testResult.org}` : ''}
</div>
)}
{testError && (
<p style={{ fontSize: 13, color: 'var(--danger)', margin: 0 }}>{testError}</p>
)}
</div>
</div>
</div>
)
}
// ─── Newbook Sync Tab ─────────────────────────────────────────────────────────
interface NewbookTabProps {
@ -321,6 +490,9 @@ function SystemTab({ config, isLoading }: { config: SystemConfig | undefined; is
'booking_scraper_paused',
'booking_scraper_backend',
'booking_scraper_daily_time',
'booking_proxy_enabled',
'booking_proxy_host',
'booking_proxy_country',
'sync_newbook_current_rates_enabled',
'sync_newbook_current_rates_time',
]