码桶
发现社区成员的开源项目
DnsImportModal.jsx18.5 KB
import React, { useState, useRef } from 'react';
import { X, Upload, Search, RefreshCw } from 'lucide-react';
const VALID_TYPES = new Set(['A', 'AAAA', 'CNAME', 'MX', 'TXT', 'NS', 'SRV', 'CAA', 'PTR', 'SOA', 'SPF', 'TLSA', 'SSHFP', 'DS', 'NAPTR', 'HTTPS', 'SVCB', 'URI', 'LOC', 'CERT']);
const DnsImportModal = ({ zone, show, onClose, onImportComplete, auth, getHeaders, authFetch, t, showToast }) => {
const af = authFetch || fetch;
const [bulkImportJson, setBulkImportJson] = useState('');
const [bulkImportPreview, setBulkImportPreview] = useState(null);
const [bulkImportLoading, setBulkImportLoading] = useState(false);
const [bulkImportResult, setBulkImportResult] = useState(null);
const [detectedFormat, setDetectedFormat] = useState(null);
const jsonFileInputRef = useRef(null);
const resetState = () => {
setBulkImportJson('');
setBulkImportPreview(null);
setBulkImportLoading(false);
setBulkImportResult(null);
setDetectedFormat(null);
};
const handleClose = () => {
resetState();
onClose();
};
// Detect format from content
const detectFormat = (text) => {
const trimmed = text.trim();
if (!trimmed) return null;
// JSON: starts with [ or {
if (trimmed[0] === '[' || trimmed[0] === '{') return 'json';
// CSV: first line has comma-separated headers including common DNS fields
const firstLine = trimmed.split('\n')[0].toLowerCase();
if (firstLine.includes(',') && (firstLine.includes('type') || firstLine.includes('name') || firstLine.includes('content'))) return 'csv';
// BIND: contains lines with IN keyword
const lines = trimmed.split('\n').filter(l => l.trim() && !l.trim().startsWith(';') && !l.trim().startsWith('$'));
const bindPattern = /\bIN\s+(A|AAAA|CNAME|MX|TXT|NS|SRV|CAA|PTR|SOA)\b/i;
if (lines.some(l => bindPattern.test(l))) return 'bind';
return 'json'; // default fallback
};
// Parse JSON format
const parseJsonFormat = (text) => {
try {
const parsed = JSON.parse(text);
const records = parsed.records || parsed;
if (!Array.isArray(records)) return { error: t('invalidJson') };
if (records.length === 0) return { error: t('noRecordsFound') };
return { records };
} catch {
return { error: t('invalidJson') };
}
};
// Parse BIND zone file format
const parseBindFormat = (text) => {
const records = [];
const lines = text.split('\n');
let lastOwner = '@';
for (const rawLine of lines) {
const line = rawLine.replace(/;.*$/, '').trim();
if (!line || line.startsWith('$')) continue;
// Match patterns like: name [TTL] [CLASS] TYPE content
// or: [TTL] [CLASS] TYPE content (using last owner)
const bindRegex = /^(\S+)?\s+(?:(\d+)\s+)?(?:IN\s+)?(\S+)\s+(.+)$/i;
const match = line.match(bindRegex);
if (!match) continue;
let [, owner, ttlStr, type, content] = match;
type = type.toUpperCase();
if (!VALID_TYPES.has(type)) {
// Try without owner: TTL IN TYPE content
const altRegex = /^(?:(\d+)\s+)?IN\s+(\S+)\s+(.+)$/i;
const altMatch = line.match(altRegex);
if (altMatch) {
ttlStr = altMatch[1];
type = altMatch[2].toUpperCase();
content = altMatch[3];
owner = null;
} else {
continue;
}
}
if (owner) lastOwner = owner;
const name = (owner || lastOwner || '@').replace(/\.$/, '');
const ttl = ttlStr ? parseInt(ttlStr) : 1;
// Clean content - remove trailing dot, surrounding quotes for TXT
let cleanContent = content.trim().replace(/\.$/, '');
if (type === 'TXT' && cleanContent.startsWith('"') && cleanContent.endsWith('"')) {
cleanContent = cleanContent.slice(1, -1);
}
const record = { type, name, content: cleanContent, ttl };
// Handle MX priority
if (type === 'MX') {
const mxMatch = cleanContent.match(/^(\d+)\s+(.+)$/);
if (mxMatch) {
record.priority = parseInt(mxMatch[1]);
record.content = mxMatch[2].replace(/\.$/, '');
}
}
records.push(record);
}
if (records.length === 0) return { error: t('noRecordsFound') };
return { records };
};
// Parse CSV format
const parseCsvFormat = (text) => {
const lines = text.trim().split('\n');
if (lines.length < 2) return { error: t('noRecordsFound') };
// Parse header
const headerLine = lines[0].toLowerCase().trim();
const headers = headerLine.split(',').map(h => h.trim().replace(/^"|"$/g, ''));
const typeIdx = headers.indexOf('type');
const nameIdx = headers.indexOf('name');
const contentIdx = headers.indexOf('content');
const ttlIdx = headers.indexOf('ttl');
const proxiedIdx = headers.indexOf('proxied');
const priorityIdx = headers.indexOf('priority');
if (typeIdx === -1 || nameIdx === -1 || contentIdx === -1) {
return { error: t('importCsvMissingHeaders') };
}
const records = [];
for (let i = 1; i < lines.length; i++) {
const line = lines[i].trim();
if (!line) continue;
// Simple CSV parsing (handles quoted fields with commas)
const fields = [];
let current = '';
let inQuotes = false;
for (const ch of line) {
if (ch === '"') { inQuotes = !inQuotes; continue; }
if (ch === ',' && !inQuotes) { fields.push(current.trim()); current = ''; continue; }
current += ch;
}
fields.push(current.trim());
const type = (fields[typeIdx] || '').toUpperCase();
const name = fields[nameIdx] || '@';
const content = fields[contentIdx] || '';
if (!type || !content) continue;
const record = { type, name, content };
if (ttlIdx !== -1 && fields[ttlIdx]) {
const ttlVal = parseInt(fields[ttlIdx]);
record.ttl = isNaN(ttlVal) ? 1 : ttlVal;
}
if (proxiedIdx !== -1 && fields[proxiedIdx]) {
record.proxied = fields[proxiedIdx].toLowerCase() === 'true' || fields[proxiedIdx] === '1';
}
if (priorityIdx !== -1 && fields[priorityIdx]) {
const pri = parseInt(fields[priorityIdx]);
if (!isNaN(pri)) record.priority = pri;
}
records.push(record);
}
if (records.length === 0) return { error: t('noRecordsFound') };
return { records };
};
// Auto-detect and parse
const parseImportData = (text) => {
const format = detectFormat(text);
setDetectedFormat(format);
switch (format) {
case 'json': return parseJsonFormat(text);
case 'bind': return parseBindFormat(text);
case 'csv': return parseCsvFormat(text);
default: return parseJsonFormat(text);
}
};
const handleBulkImportPreview = () => {
const result = parseImportData(bulkImportJson);
if (result.error) {
showToast(result.error, 'error');
setBulkImportPreview(null);
return;
}
setBulkImportPreview(result.records);
setBulkImportResult(null);
};
const handleJsonFileUpload = (e) => {
const file = e.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (ev) => {
setBulkImportJson(ev.target.result);
setBulkImportPreview(null);
setBulkImportResult(null);
};
reader.readAsText(file);
e.target.value = '';
};
const handleBulkImportSubmit = async () => {
if (!bulkImportPreview || bulkImportPreview.length === 0) return;
setBulkImportLoading(true);
setBulkImportResult(null);
try {
const res = await af(`/api/zones/${zone.id}/dns_import`, {
method: 'POST',
headers: getHeaders(true),
body: JSON.stringify({ records: bulkImportPreview })
});
const data = await res.json();
if (res.ok && data.success) {
setBulkImportResult({ created: data.created, total: data.total, errors: data.errors || [] });
onImportComplete();
if (data.created > 0) {
showToast(t('importResultCreated').replace('{count}', data.created));
}
} else {
showToast(data.error || t('errorOccurred'), 'error');
}
} catch {
showToast(t('errorOccurred'), 'error');
}
setBulkImportLoading(false);
};
if (!show) return null;
return (
<div
className="modal-overlay"
onClick={(e) => { if (e.target === e.currentTarget) handleClose(); }}
>
<div className="glass-card fade-in modal-content" role="dialog" aria-label={t('bulkImport')} style={{ padding: '2rem', maxWidth: '600px', width: '90%', position: 'relative' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem' }}>
<h2 style={{ margin: 0 }}>{t('bulkImport')}</h2>
<button className="btn btn-outline" style={{ padding: '4px', border: 'none' }} onClick={handleClose} aria-label="Close">
<X size={20} />
</button>
</div>
<p style={{ fontSize: '0.8125rem', color: 'var(--text-muted)', marginBottom: '0.5rem' }}>{t('bulkImportDesc')}</p>
<p style={{ fontSize: '0.7rem', color: 'var(--text-muted)', marginBottom: '1rem' }}>{t('importFormatHint')}</p>
{detectedFormat && (
<div style={{ marginBottom: '0.75rem', display: 'flex', alignItems: 'center', gap: '6px' }}>
<span style={{ fontSize: '0.7rem', color: 'var(--text-muted)' }}>{t('importDetectedFormat')}:</span>
<span className="badge badge-blue" style={{ fontSize: '0.65rem', padding: '2px 6px' }}>
{detectedFormat.toUpperCase()}
</span>
</div>
)}
<div style={{ marginBottom: '1rem' }}>
<label style={{ fontSize: '0.8125rem', fontWeight: 600, display: 'block', marginBottom: '0.5rem' }}>{t('importPasteData')}</label>
<textarea
value={bulkImportJson}
onChange={(e) => { setBulkImportJson(e.target.value); setBulkImportPreview(null); setBulkImportResult(null); }}
placeholder={t('importPlaceholder')}
style={{
width: '100%',
minHeight: '120px',
padding: '0.75rem',
fontSize: '0.8125rem',
fontFamily: 'monospace',
border: '1px solid var(--border)',
borderRadius: '6px',
resize: 'vertical',
background: 'var(--card-bg, white)',
color: 'var(--text)'
}}
/>
</div>
<div style={{ display: 'flex', gap: '0.5rem', marginBottom: '1rem', flexWrap: 'wrap' }}>
<input
type="file"
ref={jsonFileInputRef}
style={{ display: 'none' }}
onChange={handleJsonFileUpload}
accept=".json,.csv,.txt,.zone"
/>
<button type="button" className="btn btn-outline" style={{ fontSize: '0.8125rem' }} onClick={() => jsonFileInputRef.current.click()}>
<Upload size={14} />
{t('importUploadFile')}
</button>
<button type="button" className="btn btn-primary" style={{ fontSize: '0.8125rem' }} onClick={handleBulkImportPreview} disabled={!bulkImportJson.trim()}>
<Search size={14} />
{t('previewRecords')}
</button>
</div>
{/* Preview Table */}
{bulkImportPreview && bulkImportPreview.length > 0 && (
<div style={{ marginBottom: '1rem' }}>
<div style={{ fontSize: '0.75rem', fontWeight: 600, color: 'var(--text-muted)', marginBottom: '0.5rem' }}>
{bulkImportPreview.length} {t('dnsRecords').toLowerCase()}
</div>
<div style={{ maxHeight: '200px', overflowY: 'auto', border: '1px solid var(--border)', borderRadius: '6px' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.75rem' }}>
<thead>
<tr style={{ borderBottom: '2px solid var(--border)', position: 'sticky', top: 0, background: 'var(--card-bg, white)' }}>
<th style={{ padding: '0.4rem 0.5rem', textAlign: 'left', color: 'var(--text-muted)', fontWeight: 600 }}>#</th>
<th style={{ padding: '0.4rem 0.5rem', textAlign: 'left', color: 'var(--text-muted)', fontWeight: 600 }}>{t('type')}</th>
<th style={{ padding: '0.4rem 0.5rem', textAlign: 'left', color: 'var(--text-muted)', fontWeight: 600 }}>{t('name')}</th>
<th style={{ padding: '0.4rem 0.5rem', textAlign: 'left', color: 'var(--text-muted)', fontWeight: 600 }}>{t('content')}</th>
<th style={{ padding: '0.4rem 0.5rem', textAlign: 'left', color: 'var(--text-muted)', fontWeight: 600 }}>TTL</th>
</tr>
</thead>
<tbody>
{bulkImportPreview.map((rec, idx) => (
<tr key={idx} style={{ borderBottom: '1px solid var(--border)' }}>
<td style={{ padding: '0.4rem 0.5rem', color: 'var(--text-muted)' }}>{idx + 1}</td>
<td style={{ padding: '0.4rem 0.5rem' }}>
<span className="badge badge-blue" style={{ fontSize: '0.6rem', padding: '1px 4px' }}>{rec.type}</span>
</td>
<td style={{ padding: '0.4rem 0.5rem', fontWeight: 500, maxWidth: '120px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{rec.name}</td>
<td style={{ padding: '0.4rem 0.5rem', color: 'var(--text-muted)', maxWidth: '160px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{rec.content}</td>
<td style={{ padding: '0.4rem 0.5rem', color: 'var(--text-muted)' }}>{rec.ttl || 'Auto'}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{/* Import Result */}
{bulkImportResult && (
<div style={{
padding: '0.75rem',
borderRadius: '6px',
marginBottom: '1rem',
background: bulkImportResult.errors.length > 0 ? 'var(--error-bg)' : 'var(--success-bg)',
border: `1px solid ${bulkImportResult.errors.length > 0 ? 'var(--diff-removed-border)' : 'var(--success-border)'}`
}}>
<div style={{ fontSize: '0.8125rem', fontWeight: 600, color: bulkImportResult.errors.length > 0 ? 'var(--error)' : 'var(--success-text)', marginBottom: bulkImportResult.errors.length > 0 ? '0.5rem' : 0 }}>
{t('importResultCreated').replace('{count}', bulkImportResult.created)}
{bulkImportResult.errors.length > 0 && (
<span style={{ marginLeft: '8px' }}>
| {t('importResultErrors').replace('{count}', bulkImportResult.errors.length)}
</span>
)}
</div>
{bulkImportResult.errors.length > 0 && (
<div style={{ maxHeight: '100px', overflowY: 'auto', fontSize: '0.75rem', color: 'var(--error)' }}>
{bulkImportResult.errors.map((err, idx) => (
<div key={idx} style={{ padding: '2px 0' }}>
#{err.index + 1}: {err.error}
</div>
))}
</div>
)}
</div>
)}
<div style={{ display: 'flex', gap: '0.75rem' }}>
<button type="button" className="btn btn-outline" style={{ flex: 1 }} onClick={handleClose}>{t('cancel')}</button>
<button
type="button"
className="btn btn-primary"
style={{ flex: 1 }}
onClick={handleBulkImportSubmit}
disabled={!bulkImportPreview || bulkImportPreview.length === 0 || bulkImportLoading}
>
{bulkImportLoading ? (
<><RefreshCw size={14} className="spin" /> {t('importing')}</>
) : (
<><Upload size={14} /> {t('importRecords')}</>
)}
</button>
</div>
</div>
</div>
);
};
export default DnsImportModal;