码桶

发现社区成员的开源项目

main
DnsAnalytics.jsx10.5 KB
import React, { useState, useEffect, useCallback } from 'react';
import { RefreshCw, BarChart2, AlertTriangle, Search, Database } from 'lucide-react';
import TabSkeleton from './TabSkeleton';

const TIME_RANGES = [
    { value: -1440, label: '24h' },
    { value: -10080, label: '7d' },
    { value: -43200, label: '30d' },
];

const formatNumber = (n) => {
    if (n == null) return '0';
    if (n >= 1e9) return (n / 1e9).toFixed(1) + 'B';
    if (n >= 1e6) return (n / 1e6).toFixed(1) + 'M';
    if (n >= 1e3) return (n / 1e3).toFixed(1) + 'K';
    return n.toLocaleString();
};

const DnsAnalytics = ({ zone, getHeaders, authFetch, t, showToast }) => {
    const af = authFetch || fetch;
    const [data, setData] = useState(null);
    const [loading, setLoading] = useState(true);
    const [fetchError, setFetchError] = useState(null);
    const [timeRange, setTimeRange] = useState(-1440);

    const fetchAnalytics = useCallback(async () => {
        setLoading(true);
        setFetchError(null);
        try {
            const res = await af(`/api/zones/${zone.id}/dns-analytics?since=${timeRange}`, { headers: getHeaders() });
            const result = await res.json();
            if (result.success) {
                setData(result.data);
            } else {
                setFetchError(result.errors?.[0]?.message || result.error || t('errorOccurred'));
            }
        } catch (e) {
            setFetchError(t('errorOccurred'));
        }
        setLoading(false);
    }, [zone.id, timeRange]);

    useEffect(() => { fetchAnalytics(); }, [fetchAnalytics]);

    if (loading && !data && !fetchError) {
        return <TabSkeleton variant="analytics" />;
    }

    if (fetchError && !data) {
        return (
            <div style={{ display: 'flex', flexDirection: 'column', gap: '6px', padding: '0.75rem 1rem', borderRadius: '8px', background: 'rgba(239, 68, 68, 0.08)', border: '1px solid rgba(239, 68, 68, 0.3)', fontSize: '0.8125rem', color: 'var(--error, #ef4444)' }}>
                <div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}><AlertTriangle size={16} /><span>{t('loadSettingsError').replace('{error}', fetchError)}</span></div>
                <button className="btn btn-outline" onClick={fetchAnalytics} style={{ alignSelf: 'flex-start', padding: '4px 12px', fontSize: '0.75rem', marginTop: '4px' }}><RefreshCw size={12} /> {t('refresh') || 'Retry'}</button>
            </div>
        );
    }

    // Parse data - CF DNS analytics returns rows with dimensions and metrics
    const rows = data?.rows || [];
    const totals = data?.totals || {};

    // Aggregate by query type
    const queryTypes = {};
    const responseCodes = {};
    rows.forEach(row => {
        const dims = row.dimensions || [];
        const metrics = row.metrics || [];
        const qType = dims[0] || 'OTHER';
        const rCode = dims[1] || 'UNKNOWN';
        const count = metrics[0] || 0;
        queryTypes[qType] = (queryTypes[qType] || 0) + count;
        responseCodes[rCode] = (responseCodes[rCode] || 0) + count;
    });

    const totalQueries = totals[0] || Object.values(queryTypes).reduce((s, v) => s + v, 0);
    const nxdomainCount = responseCodes['NXDOMAIN'] || 0;

    const sortedTypes = Object.entries(queryTypes).sort((a, b) => b[1] - a[1]);
    const maxType = sortedTypes.length > 0 ? sortedTypes[0][1] : 1;

    const sortedCodes = Object.entries(responseCodes).sort((a, b) => b[1] - a[1]);
    const maxCode = sortedCodes.length > 0 ? sortedCodes[0][1] : 1;

    const codeColors = { 'NOERROR': '#22c55e', 'NXDOMAIN': '#f59e0b', 'SERVFAIL': '#ef4444', 'REFUSED': '#8b5cf6' };

    return (
        <div className="tab-enter" style={{ display: 'flex', flexDirection: 'column', gap: '1.5rem' }}>
            {/* Header */}
            <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: '0.5rem' }}>
                <div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
                    <BarChart2 size={20} color="var(--primary)" />
                    <h3 style={{ fontSize: '1rem', margin: 0 }}>{t('dnsAnTitle') || 'DNS Analytics'}</h3>
                </div>
                <div style={{ display: 'flex', gap: '0.35rem', alignItems: 'center' }}>
                    {TIME_RANGES.map(tr => (
                        <button key={tr.value} onClick={() => setTimeRange(tr.value)}
                            className={`btn ${timeRange === tr.value ? '' : 'btn-outline'}`}
                            style={{
                                padding: '0.3rem 0.75rem', fontSize: '0.75rem', fontWeight: 600, borderRadius: '6px',
                                ...(timeRange === tr.value ? { background: 'var(--primary)', color: 'white', border: 'none' } : {})
                            }}>
                            {tr.label}
                        </button>
                    ))}
                    <button className="btn btn-outline" onClick={fetchAnalytics} disabled={loading} style={{ padding: '4px 8px', height: 'auto', fontSize: '0.75rem', marginLeft: '4px' }} aria-label={t('refresh')}>
                        <RefreshCw size={12} className={loading ? 'spin' : ''} />
                    </button>
                </div>
            </div>

            {/* Summary Cards */}
            <div className="dns-analytics-summary" style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(200px, 1fr))', gap: '0.75rem' }}>
                <div className="glass-card" style={{ padding: '1rem', background: 'var(--subtle-bg)' }}>
                    <div style={{ display: 'flex', alignItems: 'center', gap: '6px', marginBottom: '0.5rem' }}>
                        <Search size={16} color="#3b82f6" />
                        <span style={{ fontSize: '0.75rem', color: 'var(--text-muted)', fontWeight: 500 }}>{t('dnsAnQueryCount') || 'Total Queries'}</span>
                    </div>
                    <div style={{ fontSize: '1.5rem', fontWeight: 700 }}>{formatNumber(totalQueries)}</div>
                </div>
                <div className="glass-card" style={{ padding: '1rem', background: 'var(--subtle-bg)' }}>
                    <div style={{ display: 'flex', alignItems: 'center', gap: '6px', marginBottom: '0.5rem' }}>
                        <Database size={16} color="#f59e0b" />
                        <span style={{ fontSize: '0.75rem', color: 'var(--text-muted)', fontWeight: 500 }}>{t('dnsAnNxdomain') || 'NXDOMAIN'}</span>
                    </div>
                    <div style={{ fontSize: '1.5rem', fontWeight: 700 }}>{formatNumber(nxdomainCount)}</div>
                    <div style={{ fontSize: '0.7rem', color: 'var(--text-muted)', marginTop: '4px' }}>
                        {totalQueries > 0 ? ((nxdomainCount / totalQueries) * 100).toFixed(1) : 0}% {t('dnsAnOfTotal') || 'of total'}
                    </div>
                </div>
            </div>

            {/* Query Type Breakdown */}
            {sortedTypes.length > 0 && (
                <div className="glass-card" style={{ padding: '1.25rem', background: 'var(--subtle-bg)' }}>
                    <h4 style={{ fontSize: '0.875rem', margin: '0 0 1rem 0' }}>{t('dnsAnQueryTypes') || 'Query Types'}</h4>
                    <div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
                        {sortedTypes.slice(0, 12).map(([type, count]) => (
                            <div key={type} style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
                                <span style={{ fontSize: '0.8125rem', fontWeight: 600, width: '50px', fontFamily: 'monospace' }}>{type}</span>
                                <div style={{ flex: 1, height: '20px', background: 'var(--border)', borderRadius: '4px', overflow: 'hidden' }}>
                                    <div style={{
                                        height: '100%', width: `${(count / maxType) * 100}%`,
                                        background: 'var(--primary)', borderRadius: '4px', opacity: 0.7,
                                        transition: 'width 0.5s ease', minWidth: count > 0 ? '2px' : 0
                                    }} />
                                </div>
                                <span style={{ fontSize: '0.75rem', fontWeight: 500, width: '60px', textAlign: 'right' }}>{formatNumber(count)}</span>
                            </div>
                        ))}
                    </div>
                </div>
            )}

            {/* Response Code Breakdown */}
            {sortedCodes.length > 0 && (
                <div className="glass-card" style={{ padding: '1.25rem', background: 'var(--subtle-bg)' }}>
                    <h4 style={{ fontSize: '0.875rem', margin: '0 0 1rem 0' }}>{t('dnsAnResponseCodes') || 'Response Codes'}</h4>
                    <div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
                        {sortedCodes.map(([code, count]) => (
                            <div key={code} style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
                                <span style={{ fontSize: '0.8125rem', fontWeight: 600, width: '80px', color: codeColors[code] || 'var(--text)' }}>{code}</span>
                                <div style={{ flex: 1, height: '20px', background: 'var(--border)', borderRadius: '4px', overflow: 'hidden' }}>
                                    <div style={{
                                        height: '100%', width: `${(count / maxCode) * 100}%`,
                                        background: codeColors[code] || 'var(--primary)', borderRadius: '4px',
                                        transition: 'width 0.5s ease', minWidth: count > 0 ? '2px' : 0
                                    }} />
                                </div>
                                <span style={{ fontSize: '0.75rem', fontWeight: 500, width: '60px', textAlign: 'right' }}>{formatNumber(count)}</span>
                            </div>
                        ))}
                    </div>
                </div>
            )}

            {/* Empty state */}
            {sortedTypes.length === 0 && sortedCodes.length === 0 && !loading && (
                <div className="glass-card" style={{ padding: '2rem', textAlign: 'center' }}>
                    <BarChart2 size={32} color="var(--text-muted)" style={{ marginBottom: '0.75rem', opacity: 0.5 }} />
                    <p style={{ fontSize: '0.875rem', color: 'var(--text-muted)' }}>{t('dnsAnNoData') || 'No DNS analytics data available for this time range.'}</p>
                </div>
            )}
        </div>
    );
};

export default DnsAnalytics;