码桶

发现社区成员的开源项目

Analytics.jsx11.7 KB
import React, { useState, useEffect, useCallback } from 'react';
import { RefreshCw, BarChart2, AlertTriangle, Globe, Shield, Eye, Activity } 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 formatBytes = (bytes) => {
    if (bytes == null) return '0 B';
    if (bytes >= 1e12) return (bytes / 1e12).toFixed(2) + ' TB';
    if (bytes >= 1e9) return (bytes / 1e9).toFixed(2) + ' GB';
    if (bytes >= 1e6) return (bytes / 1e6).toFixed(2) + ' MB';
    if (bytes >= 1e3) return (bytes / 1e3).toFixed(2) + ' KB';
    return bytes + ' B';
};

const Analytics = ({ 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}/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>
        );
    }

    const totals = data?.totals || {};
    const requests = totals.requests || {};
    const bandwidth = totals.bandwidth || {};
    const threats = totals.threats || {};
    const uniques = totals.uniques || {};
    const pageviews = totals.pageviews || {};

    const httpStatus = requests.http_status || {};
    const statusGroups = {
        '2xx': Object.entries(httpStatus).filter(([k]) => k.startsWith('2')).reduce((s, [, v]) => s + v, 0),
        '3xx': Object.entries(httpStatus).filter(([k]) => k.startsWith('3')).reduce((s, [, v]) => s + v, 0),
        '4xx': Object.entries(httpStatus).filter(([k]) => k.startsWith('4')).reduce((s, [, v]) => s + v, 0),
        '5xx': Object.entries(httpStatus).filter(([k]) => k.startsWith('5')).reduce((s, [, v]) => s + v, 0),
    };
    const maxStatus = Math.max(...Object.values(statusGroups), 1);

    const topCountries = Object.entries(threats.country || requests.country || {})
        .sort((a, b) => b[1] - a[1])
        .slice(0, 8);
    const maxCountry = topCountries.length > 0 ? topCountries[0][1] : 1;

    const statusColors = { '2xx': '#22c55e', '3xx': '#3b82f6', '4xx': '#f59e0b', '5xx': '#ef4444' };

    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('anTitle')}</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' }}>
                        <RefreshCw size={12} className={loading ? 'spin' : ''} />
                    </button>
                </div>
            </div>

            {/* Summary Cards */}
            <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(200px, 1fr))', gap: '0.75rem' }}>
                {/* Total Requests */}
                <div className="glass-card" style={{ padding: '1rem', background: 'var(--subtle-bg)' }}>
                    <div style={{ display: 'flex', alignItems: 'center', gap: '6px', marginBottom: '0.5rem' }}>
                        <Activity size={16} color="#3b82f6" />
                        <span style={{ fontSize: '0.75rem', color: 'var(--text-muted)', fontWeight: 500 }}>{t('anTotalRequests')}</span>
                    </div>
                    <div style={{ fontSize: '1.5rem', fontWeight: 700 }}>{formatNumber(requests.all)}</div>
                    <div style={{ fontSize: '0.7rem', color: 'var(--text-muted)', marginTop: '4px' }}>
                        {t('anCachedRequests')}: {formatNumber(requests.cached)} ({requests.all > 0 ? ((requests.cached / requests.all) * 100).toFixed(1) : 0}%)
                    </div>
                </div>

                {/* Bandwidth */}
                <div className="glass-card" style={{ padding: '1rem', background: 'var(--subtle-bg)' }}>
                    <div style={{ display: 'flex', alignItems: 'center', gap: '6px', marginBottom: '0.5rem' }}>
                        <Globe size={16} color="#8b5cf6" />
                        <span style={{ fontSize: '0.75rem', color: 'var(--text-muted)', fontWeight: 500 }}>{t('anBandwidth')}</span>
                    </div>
                    <div style={{ fontSize: '1.5rem', fontWeight: 700 }}>{formatBytes(bandwidth.all)}</div>
                    <div style={{ fontSize: '0.7rem', color: 'var(--text-muted)', marginTop: '4px' }}>
                        {t('anCachedBandwidth')}: {formatBytes(bandwidth.cached)} ({bandwidth.all > 0 ? ((bandwidth.cached / bandwidth.all) * 100).toFixed(1) : 0}%)
                    </div>
                </div>

                {/* Threats */}
                <div className="glass-card" style={{ padding: '1rem', background: 'var(--subtle-bg)' }}>
                    <div style={{ display: 'flex', alignItems: 'center', gap: '6px', marginBottom: '0.5rem' }}>
                        <Shield size={16} color="#ef4444" />
                        <span style={{ fontSize: '0.75rem', color: 'var(--text-muted)', fontWeight: 500 }}>{t('anThreats')}</span>
                    </div>
                    <div style={{ fontSize: '1.5rem', fontWeight: 700 }}>{formatNumber(threats.all)}</div>
                </div>

                {/* Unique Visitors */}
                <div className="glass-card" style={{ padding: '1rem', background: 'var(--subtle-bg)' }}>
                    <div style={{ display: 'flex', alignItems: 'center', gap: '6px', marginBottom: '0.5rem' }}>
                        <Eye size={16} color="#22c55e" />
                        <span style={{ fontSize: '0.75rem', color: 'var(--text-muted)', fontWeight: 500 }}>{t('anVisitors')}</span>
                    </div>
                    <div style={{ fontSize: '1.5rem', fontWeight: 700 }}>{formatNumber(uniques.all)}</div>
                    {pageviews.all > 0 && <div style={{ fontSize: '0.7rem', color: 'var(--text-muted)', marginTop: '4px' }}>Pageviews: {formatNumber(pageviews.all)}</div>}
                </div>
            </div>

            {/* HTTP Status Breakdown */}
            <div className="glass-card" style={{ padding: '1.25rem', background: 'var(--subtle-bg)' }}>
                <h4 style={{ fontSize: '0.875rem', margin: '0 0 1rem 0' }}>{t('anHttpStatus')}</h4>
                <div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
                    {Object.entries(statusGroups).map(([group, count]) => (
                        <div key={group} style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
                            <span style={{ fontSize: '0.8125rem', fontWeight: 600, width: '32px', color: statusColors[group] }}>{group}</span>
                            <div style={{ flex: 1, height: '20px', background: 'var(--border)', borderRadius: '4px', overflow: 'hidden' }}>
                                <div style={{
                                    height: '100%', width: `${(count / maxStatus) * 100}%`,
                                    background: statusColors[group], 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>

            {/* Top Countries */}
            {topCountries.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('anCountries')}</h4>
                    <div style={{ display: 'flex', flexDirection: 'column', gap: '0.35rem' }}>
                        {topCountries.map(([country, count]) => (
                            <div key={country} style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
                                <span style={{ fontSize: '0.8125rem', fontWeight: 500, width: '28px' }}>{country}</span>
                                <div style={{ flex: 1, height: '16px', background: 'var(--border)', borderRadius: '4px', overflow: 'hidden' }}>
                                    <div style={{
                                        height: '100%', width: `${(count / maxCountry) * 100}%`,
                                        background: 'var(--primary)', borderRadius: '4px', opacity: 0.7,
                                        transition: 'width 0.5s ease', minWidth: '2px'
                                    }} />
                                </div>
                                <span style={{ fontSize: '0.7rem', color: 'var(--text-muted)', width: '60px', textAlign: 'right' }}>{formatNumber(count)}</span>
                            </div>
                        ))}
                    </div>
                </div>
            )}
        </div>
    );
};

export default Analytics;