feat: add account keys

This commit is contained in:
Alexander Medvedev 2026-09-06 16:59:09 +02:00
parent 7091dc152f
commit d623dea1e1
5 changed files with 679 additions and 19 deletions

View File

@ -30,6 +30,7 @@ const DeveloperTermsPage = React.lazy(() => import('./pages/legal/DeveloperTerms
const LegalNoticePage = React.lazy(() => import('./pages/legal/LegalNoticePage')); const LegalNoticePage = React.lazy(() => import('./pages/legal/LegalNoticePage'));
const AdminPanel = React.lazy(() => import('./pages/admin/AdminPanel')); const AdminPanel = React.lazy(() => import('./pages/admin/AdminPanel'));
const PageLoader = () => ( const PageLoader = () => (
<div style={{ minHeight: '50vh', display: 'flex', alignItems: 'center', justifyContent: 'center', opacity: 0.6, fontSize: '0.9rem' }}> <div style={{ minHeight: '50vh', display: 'flex', alignItems: 'center', justifyContent: 'center', opacity: 0.6, fontSize: '0.9rem' }}>
Loading... Loading...
@ -184,6 +185,7 @@ const App = () => (
<Route path="settings" element={<ProtectedRoute><ProfilePage /></ProtectedRoute>} /> <Route path="settings" element={<ProtectedRoute><ProfilePage /></ProtectedRoute>} />
</Route> </Route>
{/* Developer Studio layout (Developer Studio navbar replaces original navbar when in dev menu) */} {/* Developer Studio layout (Developer Studio navbar replaces original navbar when in dev menu) */}
<Route path="/dashboard" element={<ProtectedRoute><DashboardLayout /></ProtectedRoute>}> <Route path="/dashboard" element={<ProtectedRoute><DashboardLayout /></ProtectedRoute>}>
<Route index element={<Navigate to="/dashboard/plugins" replace />} /> <Route index element={<Navigate to="/dashboard/plugins" replace />} />
@ -251,6 +253,7 @@ const DeveloperStudioNavbar = ({ user }: any) => {
)} )}
</div> </div>
<div className="nav-actions" style={{ marginLeft: 'auto', display: 'flex', alignItems: 'center', gap: '1.25rem' }}> <div className="nav-actions" style={{ marginLeft: 'auto', display: 'flex', alignItems: 'center', gap: '1.25rem' }}>
<Link <Link
to="/" to="/"
@ -331,6 +334,7 @@ const Navbar = ({ user }: any) => {
{user && (user.role === 'admin' || user.role === 'moderator') && <NavLink to="/staff" onClick={() => setIsMenuOpen(false)}>Staff</NavLink>} {user && (user.role === 'admin' || user.role === 'moderator') && <NavLink to="/staff" onClick={() => setIsMenuOpen(false)}>Staff</NavLink>}
</div> </div>
{/* ── Center Search Bar with Suggestions ── */} {/* ── Center Search Bar with Suggestions ── */}
<div className="nav-search-center"> <div className="nav-search-center">
<NavSearch onNavigate={() => setIsMenuOpen(false)} /> <NavSearch onNavigate={() => setIsMenuOpen(false)} />
@ -428,6 +432,7 @@ const Footer = () => (
© 2026 PumpkinMC © 2026 PumpkinMC
</p> </p>
<span style={{ opacity: 0.2, fontSize: '11px' }}>·</span> <span style={{ opacity: 0.2, fontSize: '11px' }}>·</span>
<Link <Link

View File

@ -17,7 +17,7 @@ import {
AlertCircle, Eye, EyeOff, ChevronRight, Bell, AlertCircle, Eye, EyeOff, ChevronRight, Bell,
Smartphone, Key, Trash2, Code, Sparkles, Building2, Smartphone, Key, Trash2, Code, Sparkles, Building2,
ShieldCheck, Download, Sliders, Package, Laptop, Tablet, ShieldCheck, Download, Sliders, Package, Laptop, Tablet,
Fingerprint, Plus, ShieldAlert, RefreshCw Fingerprint, Plus, ShieldAlert, RefreshCw, Copy, Check, Terminal
} from 'lucide-react'; } from 'lucide-react';
interface LibraryEntry { interface LibraryEntry {
@ -31,11 +31,12 @@ interface LibraryEntry {
is_preorder?: boolean; is_preorder?: boolean;
} }
type Tab = 'account' | 'security' | 'developer' | 'notifications' | 'library' | 'privacy' | 'danger'; type Tab = 'account' | 'security' | 'api_keys' | 'developer' | 'notifications' | 'library' | 'privacy' | 'danger';
const NAV: { key: Tab; label: string; icon: React.FC<{ size?: number }> ; danger?: boolean }[] = [ const NAV: { key: Tab; label: string; icon: React.FC<{ size?: number }> ; danger?: boolean }[] = [
{ key: 'account', label: 'Account', icon: User }, { key: 'account', label: 'Account', icon: User },
{ key: 'security', label: 'Security', icon: Shield }, { key: 'security', label: 'Security', icon: Shield },
{ key: 'api_keys', label: 'API Keys', icon: Key },
{ key: 'developer', label: 'Developer Profile', icon: Code }, { key: 'developer', label: 'Developer Profile', icon: Code },
{ key: 'notifications', label: 'Notifications', icon: Bell }, { key: 'notifications', label: 'Notifications', icon: Bell },
{ key: 'library', label: 'Library', icon: BookOpen }, { key: 'library', label: 'Library', icon: BookOpen },
@ -324,11 +325,89 @@ const ProfilePage = () => {
const [verify2faError, setVerify2faError] = useState(''); const [verify2faError, setVerify2faError] = useState('');
const [verify2faLoading, setVerify2faLoading] = useState(false); const [verify2faLoading, setVerify2faLoading] = useState(false);
// API Keys / Personal Access Tokens
interface ApiKey {
id: number;
name: string;
keyPrefix: string;
scopes: string[];
lastUsedAt: string | null;
expiresAt: string | null;
createdAt: string;
isRevoked: boolean;
}
const [apiKeys, setApiKeys] = useState<ApiKey[]>([]);
const [apiKeysLoading, setApiKeysLoading] = useState(false);
const [newKeyName, setNewKeyName] = useState('');
const [newKeyExpiresIn, setNewKeyExpiresIn] = useState(90);
const [createdRawKey, setCreatedRawKey] = useState<string | null>(null);
const [creatingKey, setCreatingKey] = useState(false);
const [revokingKeyId, setRevokingKeyId] = useState<number | null>(null);
const [copiedKey, setCopiedKey] = useState(false);
const fetchApiKeys = async () => {
setApiKeysLoading(true);
try {
const res = await api.get('/user/api-keys');
setApiKeys(res.data);
} catch (err) {
console.error('Failed to load API keys:', err);
} finally {
setApiKeysLoading(false);
}
};
const handleCreateApiKey = async (e: React.FormEvent) => {
e.preventDefault();
if (!newKeyName.trim()) return;
setCreatingKey(true);
try {
const res = await api.post('/user/api-keys', {
name: newKeyName.trim(),
expiresInDays: newKeyExpiresIn > 0 ? newKeyExpiresIn : null,
scopes: ['plugins:download'],
});
setCreatedRawKey(res.data.rawKey);
setNewKeyName('');
setNewKeyExpiresIn(90);
await fetchApiKeys();
showToast('API Key created successfully!');
} catch (err: any) {
showToast(err.response?.data?.error || 'Failed to create API key', 'error');
} finally {
setCreatingKey(false);
}
};
const handleRevokeApiKey = async (id: number) => {
if (!window.confirm('Are you sure you want to revoke this API key? External tools and servers using it will immediately lose access.')) return;
setRevokingKeyId(id);
try {
await api.delete(`/user/api-keys/${id}`);
setApiKeys(prev => prev.filter(k => k.id !== id));
showToast('API Key revoked successfully.');
} catch (err: any) {
showToast(err.response?.data?.error || 'Failed to revoke API key', 'error');
} finally {
setRevokingKeyId(null);
}
};
const handleCopyRawKey = () => {
if (!createdRawKey) return;
navigator.clipboard.writeText(createdRawKey);
setCopiedKey(true);
setTimeout(() => setCopiedKey(false), 2500);
};
useEffect(() => { useEffect(() => {
if (activeTab === 'security') { if (activeTab === 'security') {
fetchSessions(); fetchSessions();
fetchPasskeys(); fetchPasskeys();
fetchRecoveryStatus(); fetchRecoveryStatus();
} else if (activeTab === 'api_keys') {
fetchApiKeys();
} }
}, [activeTab]); }, [activeTab]);
@ -1242,6 +1321,200 @@ const ProfilePage = () => {
</div> </div>
)} )}
{/* ══ PERSONAL ACCESS TOKENS (API KEYS) ══════════════════════════ */}
{activeTab === 'api_keys' && (
<div className="profile-section">
<div className="settings-section-header">
<h2 className="section-title" style={{ marginBottom: 0 }}>
<span>API Keys</span> & Automation Tokens
</h2>
<p className="settings-section-sub">
Personal access tokens allow external tools, CI/CD pipelines, and Minecraft servers to securely call the Pumpkin Market REST API without exposing your account password.
</p>
</div>
{/* Newly Generated Raw Key Modal / Highlight Box */}
{createdRawKey && (
<div
style={{
background: 'rgba(34, 197, 94, 0.1)',
border: '1px solid rgba(34, 197, 94, 0.3)',
borderRadius: 12,
padding: '1.25rem',
marginBottom: '1.5rem',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', color: '#4ade80', fontWeight: 600, marginBottom: '6px' }}>
<CheckCircle size={18} />
<span>Personal Access Token Created Successfully!</span>
</div>
<p style={{ fontSize: '0.85rem', color: '#cbd5e1', margin: '0 0 12px 0' }}>
<strong>Make sure to copy your token now.</strong> For your security, you will not be able to view this token again after leaving this page.
</p>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '10px',
background: 'rgba(0, 0, 0, 0.4)',
padding: '10px 14px',
borderRadius: 8,
border: '1px solid rgba(255, 255, 255, 0.1)',
}}
>
<code style={{ fontFamily: 'monospace', color: '#f1f5f9', fontSize: '0.9rem', wordBreak: 'break-all', flex: 1 }}>
{createdRawKey}
</code>
<button
type="button"
className="settings-btn settings-btn-primary"
style={{ display: 'inline-flex', alignItems: 'center', gap: '6px', whiteSpace: 'nowrap' }}
onClick={handleCopyRawKey}
>
{copiedKey ? <Check size={14} /> : <Copy size={14} />}
<span>{copiedKey ? 'Copied!' : 'Copy Token'}</span>
</button>
<button
type="button"
className="settings-btn settings-btn-secondary"
onClick={() => setCreatedRawKey(null)}
>
Dismiss
</button>
</div>
</div>
)}
{/* Generate New API Key Card */}
<SettingsCard
title="Generate New API Key"
icon={Key}
description="Create a dedicated token for automated plugin downloads, server setup scripts, or backup bots."
>
<form onSubmit={handleCreateApiKey}>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(240px, 1fr))', gap: '1rem', marginBottom: '1.25rem' }}>
<Field label="Key Name / Purpose" hint="e.g., 'SMP Server 1', 'Pterodactyl Daemon', 'CI Automation'">
<input
type="text"
className="settings-input"
placeholder="My Production Server"
value={newKeyName}
onChange={e => setNewKeyName(e.target.value)}
maxLength={64}
required
/>
</Field>
<Field label="Expiration" hint="Tokens will automatically stop working after expiry">
<select
className="settings-input"
value={newKeyExpiresIn}
onChange={e => setNewKeyExpiresIn(Number(e.target.value))}
>
<option value={30}>30 Days</option>
<option value={90}>90 Days (Recommended)</option>
<option value={180}>180 Days</option>
<option value={365}>1 Year</option>
<option value={0}>No Expiration</option>
</select>
</Field>
</div>
<button
type="submit"
className="settings-btn settings-btn-primary"
disabled={creatingKey || !newKeyName.trim()}
style={{ display: 'inline-flex', alignItems: 'center', gap: '6px' }}
>
<Plus size={16} />
{creatingKey ? 'Generating…' : 'Generate Token'}
</button>
</form>
</SettingsCard>
{/* Active API Keys List */}
<SettingsCard
title="Active Tokens"
icon={Key}
description="Tokens currently authorized to authenticate as your account."
>
{apiKeysLoading ? (
<p className="settings-hint" style={{ textAlign: 'center', padding: '1.5rem 0' }}>Loading your API keys…</p>
) : apiKeys.length === 0 ? (
<div style={{ textAlign: 'center', padding: '2rem 1rem', color: '#94a3b8' }}>
<Key size={32} style={{ opacity: 0.3, marginBottom: '8px' }} />
<p style={{ margin: 0, fontWeight: 500 }}>No API keys generated yet.</p>
<p style={{ fontSize: '0.82rem', color: '#64748b', marginTop: '4px' }}>
Generate your first token above to start automating plugin downloads with curl or scripts.
</p>
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.85rem' }}>
{apiKeys.map(k => (
<div
key={k.id}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: '1rem',
padding: '1rem 1.15rem',
background: 'rgba(255, 255, 255, 0.02)',
border: '1px solid var(--mp-border, rgba(255, 255, 255, 0.08))',
borderRadius: 10,
}}
>
<div>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '4px' }}>
<span style={{ fontWeight: 600, fontSize: '0.92rem', color: 'var(--mp-text, #fff)' }}>
{k.name}
</span>
<code style={{ fontSize: '0.78rem', background: 'rgba(255,255,255,0.06)', padding: '2px 6px', borderRadius: 4, color: '#38bdf8' }}>
{k.keyPrefix}
</code>
</div>
<div style={{ fontSize: '0.78rem', color: 'var(--mp-muted, #94a3b8)', display: 'flex', gap: '12px', flexWrap: 'wrap' }}>
<span>Created: {new Date(k.createdAt).toLocaleDateString()}</span>
<span>Expires: {k.expiresAt ? new Date(k.expiresAt).toLocaleDateString() : 'Never'}</span>
<span>Last used: {k.lastUsedAt ? new Date(k.lastUsedAt).toLocaleDateString() : 'Never'}</span>
</div>
</div>
<button
type="button"
className="settings-btn settings-btn-danger-outline"
style={{ padding: '0.45rem 0.85rem', fontSize: '0.8rem', display: 'inline-flex', alignItems: 'center', gap: '5px' }}
onClick={() => handleRevokeApiKey(k.id)}
disabled={revokingKeyId === k.id}
>
<Trash2 size={13} />
{revokingKeyId === k.id ? 'Revoking…' : 'Revoke'}
</button>
</div>
))}
</div>
)}
</SettingsCard>
{/* Usage Guide Box */}
<SettingsCard
title="How to Use Your Token"
icon={Terminal}
description="Using your token with cURL or automation scripts."
>
<p style={{ fontSize: '0.85rem', color: '#94a3b8', margin: '0 0 10px 0' }}>
Pass your token in the <code>Authorization: Bearer</code> header to authenticate directly with the REST API:
</p>
<div style={{ background: 'rgba(0, 0, 0, 0.3)', padding: '12px 16px', borderRadius: 8, border: '1px solid rgba(255,255,255,0.06)' }}>
<code style={{ fontSize: '0.82rem', color: '#f1f5f9', display: 'block', lineHeight: 1.6 }}>
curl -H "Authorization: Bearer pmk_your_token_here" \<br />
&nbsp;&nbsp;https://market.pumpkinmc.org/api/v1/rest/plugins/1/download -o my_plugin.wasm
</code>
</div>
</SettingsCard>
</div>
)}
{/* ══ DEVELOPER PROFILE ════════════════════════════════════════════ */} {/* ══ DEVELOPER PROFILE ════════════════════════════════════════════ */}
{activeTab === 'developer' && ( {activeTab === 'developer' && (
<div className="profile-section"> <div className="profile-section">

View File

@ -1,16 +1,20 @@
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import { ComposableMap, Geographies, Geography } from "@vnedyalk0v/react19-simple-maps"; import { ComposableMap, Geographies, Geography } from "@vnedyalk0v/react19-simple-maps";
import { scaleLinear } from "d3-scale"; import { scaleLinear } from "d3-scale";
import { Server, Users, Globe, Activity } from 'lucide-react';
import { useAnalytics } from './useAnalytics'; import { useAnalytics } from './useAnalytics';
const GEO_URL = "https://cdn.jsdelivr.net/npm/world-atlas@2/countries-110m.json"; const GEO_URL = "https://cdn.jsdelivr.net/npm/world-atlas@2/countries-110m.json";
const DashboardAudience = () => { const DashboardAudience = () => {
const { mapData, loading } = useAnalytics(); const { mapData, telemetry, loading } = useAnalytics();
const [mapFilter, setMapFilter] = useState<'visitors' | 'free_downloads' | 'buyers'>('visitors'); const [mapFilter, setMapFilter] = useState<'servers' | 'players' | 'visitors' | 'free_downloads'>('servers');
const [hoveredInfo, setHoveredInfo] = useState<{ name: string; servers: number; players: number; visitors: number } | null>(null);
// Memoize scales to prevent recalculation on every render // Memoize scales to prevent recalculation on every render
const colorScales = useMemo(() => ({ const colorScales = useMemo(() => ({
servers: scaleLinear<string>().domain([0, 20]).range(["#1a1a2e", "#10b981"]),
players: scaleLinear<string>().domain([0, 100]).range(["#1a1a2e", "#06b6d4"]),
visitors: scaleLinear<string>().domain([0, 50]).range(["#1a1a2e", "#8884d8"]), visitors: scaleLinear<string>().domain([0, 50]).range(["#1a1a2e", "#8884d8"]),
free_downloads: scaleLinear<string>().domain([0, 50]).range(["#1a1a2e", "#82ca9d"]), free_downloads: scaleLinear<string>().domain([0, 50]).range(["#1a1a2e", "#82ca9d"]),
buyers: scaleLinear<string>().domain([0, 50]).range(["#1a1a2e", "#FF7518"]), buyers: scaleLinear<string>().domain([0, 50]).range(["#1a1a2e", "#FF7518"]),
@ -20,52 +24,152 @@ const DashboardAudience = () => {
return ( return (
<div className="loader-container" style={{ padding: '4rem', textAlign: 'center' }}> <div className="loader-container" style={{ padding: '4rem', textAlign: 'center' }}>
<div className="pumpkin-spinner"></div> <div className="pumpkin-spinner"></div>
<p>Analyzing Global Distribution...</p> <p>Analyzing Global Telemetry & Distribution...</p>
</div> </div>
); );
} }
return ( return (
<div className="analytics-container"> <div className="analytics-container">
{/* ── Top Telemetry Audience KPIs ── */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: '1rem', marginBottom: '1.5rem' }}>
<div style={{ background: 'var(--dash-surface, #13131f)', border: '1px solid var(--dash-border, rgba(255,255,255,0.08))', borderRadius: '10px', padding: '1rem' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '6px', color: '#10b981', fontSize: '0.78rem', fontWeight: 600, textTransform: 'uppercase' }}>
<Server size={14} />
Live Game Servers (24h)
</div>
<div style={{ fontSize: '1.75rem', fontWeight: 800, color: 'var(--text, #fff)', marginTop: '4px' }}>
{telemetry?.activeServers24h?.toLocaleString() ?? 0}
</div>
<div style={{ fontSize: '0.75rem', color: 'var(--text-muted, #94a3b8)', marginTop: '2px' }}>
{telemetry?.activeServers7d ?? 0} active past 7 days
</div>
</div>
<div style={{ background: 'var(--dash-surface, #13131f)', border: '1px solid var(--dash-border, rgba(255,255,255,0.08))', borderRadius: '10px', padding: '1rem' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '6px', color: '#06b6d4', fontSize: '0.78rem', fontWeight: 600, textTransform: 'uppercase' }}>
<Users size={14} />
Online Player Reach
</div>
<div style={{ fontSize: '1.75rem', fontWeight: 800, color: 'var(--text, #fff)', marginTop: '4px' }}>
{telemetry?.totalPlayersOnline?.toLocaleString() ?? 0}
</div>
<div style={{ fontSize: '0.75rem', color: 'var(--text-muted, #94a3b8)', marginTop: '2px' }}>
Concurrent players on servers running your plugins
</div>
</div>
<div style={{ background: 'var(--dash-surface, #13131f)', border: '1px solid var(--dash-border, rgba(255,255,255,0.08))', borderRadius: '10px', padding: '1rem' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '6px', color: '#f59e0b', fontSize: '0.78rem', fontWeight: 600, textTransform: 'uppercase' }}>
<Activity size={14} />
Peak 24h Players
</div>
<div style={{ fontSize: '1.75rem', fontWeight: 800, color: 'var(--text, #fff)', marginTop: '4px' }}>
{telemetry?.peakPlayers24h?.toLocaleString() ?? 0}
</div>
<div style={{ fontSize: '0.75rem', color: 'var(--text-muted, #94a3b8)', marginTop: '2px' }}>
Max player concurrency
</div>
</div>
<div style={{ background: 'var(--dash-surface, #13131f)', border: '1px solid var(--dash-border, rgba(255,255,255,0.08))', borderRadius: '10px', padding: '1rem' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '6px', color: '#a855f7', fontSize: '0.78rem', fontWeight: 600, textTransform: 'uppercase' }}>
<Globe size={14} />
Server Locations
</div>
<div style={{ fontSize: '1.75rem', fontWeight: 800, color: 'var(--text, #fff)', marginTop: '4px' }}>
{telemetry?.countries?.length ?? mapData.length}
</div>
<div style={{ fontSize: '0.75rem', color: 'var(--text-muted, #94a3b8)', marginTop: '2px' }}>
Countries with deployed servers
</div>
</div>
</div>
{/* ── Global Interactive Map ── */}
<div className="charts-grid"> <div className="charts-grid">
<div className="chart-card" style={{ gridColumn: '1 / -1' }}> <div className="chart-card" style={{ gridColumn: '1 / -1' }}>
<div className="card-header-flex" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1.5rem' }}> <div className="card-header-flex" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1.5rem', flexWrap: 'wrap', gap: '0.75rem' }}>
<div> <div>
<h3 className="section-title" style={{ margin: 0 }}>Geographic <span>Distribution</span></h3> <h3 className="section-title" style={{ margin: 0 }}>Plugin <span>Audience & Server Geography</span></h3>
<p style={{ fontSize: '0.85rem', color: 'var(--text-muted)' }}>Visualizing user activity across the globe</p> <p style={{ fontSize: '0.85rem', color: 'var(--text-muted)', margin: '4px 0 0 0' }}>
Visualizing active Minecraft servers and player reach reported via telemetry
</p>
</div> </div>
<div className="map-filters" style={{ display: 'flex', gap: '8px' }}> <div className="map-filters" style={{ display: 'flex', gap: '8px' }}>
{(['visitors', 'free_downloads', 'buyers'] as const).map(type => ( {(['servers', 'players', 'visitors', 'free_downloads'] as const).map(type => (
<button <button
key={type} key={type}
className={`btn-sm ${mapFilter === type ? 'active' : ''}`} className={`btn-sm ${mapFilter === type ? 'active' : ''}`}
onClick={() => setMapFilter(type)} onClick={() => setMapFilter(type)}
style={{
padding: '6px 12px',
borderRadius: '6px',
border: '1px solid var(--dash-border, rgba(255,255,255,0.1))',
background: mapFilter === type ? 'var(--primary, #f97316)' : 'rgba(255,255,255,0.04)',
color: mapFilter === type ? '#fff' : 'var(--text-muted, #94a3b8)',
fontWeight: 600,
fontSize: '0.78rem',
cursor: 'pointer',
textTransform: 'capitalize'
}}
> >
{type.replace('_', ' ')} {type === 'servers' ? 'Live Servers' : type === 'players' ? 'Online Players' : type.replace('_', ' ')}
</button> </button>
))} ))}
</div> </div>
</div> </div>
<div className="map-wrapper" style={{ background: '#0f0f1a', borderRadius: '12px', padding: '20px', border: '1px solid var(--border)' }}> <div className="map-wrapper" style={{ background: '#0f0f1a', borderRadius: '12px', padding: '20px', border: '1px solid var(--border)', position: 'relative' }}>
{hoveredInfo && (
<div style={{
position: 'absolute',
top: '16px',
right: '16px',
background: 'rgba(20, 20, 35, 0.95)',
border: '1px solid rgba(255, 255, 255, 0.12)',
borderRadius: '8px',
padding: '8px 12px',
fontSize: '0.82rem',
color: '#fff',
pointerEvents: 'none',
zIndex: 10,
boxShadow: '0 4px 12px rgba(0,0,0,0.4)'
}}>
<div style={{ fontWeight: 700, marginBottom: '4px' }}>{hoveredInfo.name}</div>
<div style={{ color: '#10b981' }}>🖥️ {hoveredInfo.servers} active servers</div>
<div style={{ color: '#06b6d4' }}>👥 {hoveredInfo.players} online players</div>
<div style={{ color: '#94a3b8', fontSize: '0.75rem' }}>🌐 {hoveredInfo.visitors} web views</div>
</div>
)}
<ComposableMap projectionConfig={{ rotate: [-10, 0, 0] as any, scale: 147 }} width={800} height={400}> <ComposableMap projectionConfig={{ rotate: [-10, 0, 0] as any, scale: 147 }} width={800} height={400}>
<Geographies geography={GEO_URL}> <Geographies geography={GEO_URL}>
{({ geographies }) => {({ geographies }) =>
geographies.map((geo) => { geographies.map((geo) => {
// Try different property keys used by various GeoJSON versions
const countryCode = geo.properties.ISO_A2 || geo.properties.iso_a2 || geo.properties.IS_A2; const countryCode = geo.properties.ISO_A2 || geo.properties.iso_a2 || geo.properties.IS_A2;
const d = mapData.find((s: any) => s.country === countryCode); const d: any = mapData.find((s: any) => s.country === countryCode);
const val = d ? (d[mapFilter === 'free_downloads' ? 'freeDownloads' : mapFilter] || 0) : 0;
return ( return (
<Geography <Geography
key={geo.rsmKey} key={geo.rsmKey}
geography={geo} geography={geo}
fill={d ? colorScales[mapFilter](d[mapFilter] || 0) : "#1a1a2e"} fill={val > 0 ? colorScales[mapFilter](val) : "#1a1a2e"}
stroke="#2a2a40" stroke="#2a2a40"
strokeWidth={0.5} strokeWidth={0.5}
onMouseEnter={() => {
setHoveredInfo({
name: d?.countryName || geo.properties.name || countryCode || 'Unknown',
servers: d?.servers || 0,
players: d?.players || 0,
visitors: d?.visitors || 0,
});
}}
onMouseLeave={() => setHoveredInfo(null)}
style={{ style={{
default: { outline: "none" }, default: { outline: "none" },
hover: { fill: "var(--primary)", outline: "none", cursor: 'pointer' }, hover: { fill: "#f97316", outline: "none", cursor: 'pointer' },
pressed: { outline: "none" } pressed: { outline: "none" }
}} }}
/> />
@ -77,10 +181,51 @@ const DashboardAudience = () => {
{mapData.length === 0 && ( {mapData.length === 0 && (
<div style={{ textAlign: 'center', padding: '1rem', color: 'var(--text-muted)', fontSize: '0.9rem' }}> <div style={{ textAlign: 'center', padding: '1rem', color: 'var(--text-muted)', fontSize: '0.9rem' }}>
No geographic data captured yet. (Note: Localhost/VPN IPs may appear as 'Unknown') No server telemetry or geographic data captured yet.
</div> </div>
)} )}
</div> </div>
{/* Table of Top Geographic Locations */}
{mapData.length > 0 && (
<div style={{ marginTop: '1.5rem', overflowX: 'auto' }}>
<h4 style={{ margin: '0 0 10px 0', fontSize: '0.92rem', color: 'var(--text, #fff)' }}>
Country Telemetry Breakdown
</h4>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.82rem', textAlign: 'left' }}>
<thead>
<tr style={{ borderBottom: '1px solid rgba(255,255,255,0.08)', color: 'var(--text-muted, #94a3b8)' }}>
<th style={{ padding: '8px 12px' }}>Country</th>
<th style={{ padding: '8px 12px' }}>Game Servers</th>
<th style={{ padding: '8px 12px' }}>Online Players</th>
<th style={{ padding: '8px 12px' }}>Web Visitors</th>
<th style={{ padding: '8px 12px' }}>Downloads</th>
</tr>
</thead>
<tbody>
{mapData.slice(0, 10).map((row, idx) => (
<tr key={idx} style={{ borderBottom: '1px solid rgba(255,255,255,0.04)' }}>
<td style={{ padding: '8px 12px', fontWeight: 600, color: 'var(--text, #fff)' }}>
{row.countryName} ({row.country})
</td>
<td style={{ padding: '8px 12px', color: '#10b981', fontWeight: 700 }}>
{row.servers}
</td>
<td style={{ padding: '8px 12px', color: '#06b6d4' }}>
{row.players}
</td>
<td style={{ padding: '8px 12px', color: 'var(--text-muted, #94a3b8)' }}>
{row.visitors}
</td>
<td style={{ padding: '8px 12px', color: 'var(--text-muted, #94a3b8)' }}>
{row.freeDownloads}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div> </div>
</div> </div>
</div> </div>

View File

@ -5,7 +5,8 @@ import {
} from 'recharts'; } from 'recharts';
import { import {
TrendingUp, TrendingDown, Minus, DollarSign, TrendingUp, TrendingDown, Minus, DollarSign,
Download, MousePointer2, Star, Globe Download, MousePointer2, Star, Globe,
Server, Users, Radio, Cpu, Layers, Activity
} from 'lucide-react'; } from 'lucide-react';
import { useAnalytics } from '../useAnalytics'; import { useAnalytics } from '../useAnalytics';
@ -87,7 +88,7 @@ const KpiCard = ({ title, value, comparison, icon: Icon, chartData, dataKey }: a
}; };
export const PluginAnalyticsTab: React.FC<Props> = ({ pluginId, pluginName }) => { export const PluginAnalyticsTab: React.FC<Props> = ({ pluginId, pluginName }) => {
const { processedData, totals, ratingSummary, referrers, timeframe, setTimeframe, loading } = useAnalytics(pluginId); const { processedData, totals, ratingSummary, referrers, telemetry, timeframe, setTimeframe, loading } = useAnalytics(pluginId);
const timeframes = ['Today', '7 Days', '1 Month', '1 Year', 'Lifetime']; const timeframes = ['Today', '7 Days', '1 Month', '1 Year', 'Lifetime'];
if (loading) { if (loading) {
@ -164,6 +165,202 @@ export const PluginAnalyticsTab: React.FC<Props> = ({ pluginId, pluginName }) =>
</div> </div>
</div> </div>
{/* ── Live Game Server Telemetry (Pumpkin & Vine) ── */}
<div className="mp-card" style={{ marginTop: '1.25rem', padding: '1.25rem' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1.25rem', flexWrap: 'wrap', gap: '0.75rem' }}>
<div>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<Server size={18} color="#10b981" />
<h3 style={{ margin: 0, fontSize: '1.1rem', fontWeight: 700, color: 'var(--mp-text-1, #f8fafc)' }}>
Live Server Telemetry & Installations
</h3>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: '5px', padding: '2px 8px', borderRadius: '12px', background: 'rgba(16, 185, 129, 0.12)', color: '#10b981', fontSize: '0.72rem', fontWeight: 600 }}>
<span style={{ width: '6px', height: '6px', borderRadius: '50%', background: '#10b981', boxShadow: '0 0 6px #10b981' }} />
Real-Time Heartbeats
</span>
</div>
<p style={{ margin: '4px 0 0 0', fontSize: '0.8rem', color: 'var(--mp-text-3, #94a3b8)' }}>
Direct metrics reported by Minecraft production servers running <strong>{pluginName}</strong> on Pumpkin & Vine.
</p>
</div>
</div>
{/* 4 Telemetry Metric Badges */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: '1rem', marginBottom: '1.25rem' }}>
<div style={{ background: 'rgba(255,255,255,0.02)', border: '1px solid rgba(255,255,255,0.06)', borderRadius: '10px', padding: '14px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '6px', color: '#10b981', fontSize: '0.78rem', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.04em' }}>
<Server size={14} />
Active Servers (24h)
</div>
<div style={{ fontSize: '1.75rem', fontWeight: 800, color: 'var(--mp-text-1, #fff)', marginTop: '4px', lineHeight: 1.1 }}>
{telemetry?.activeServers24h?.toLocaleString() ?? 0}
</div>
<div style={{ fontSize: '0.75rem', color: 'var(--mp-text-3, #94a3b8)', marginTop: '4px' }}>
{telemetry?.activeServers7d ?? 0} weekly active servers (7d)
</div>
</div>
<div style={{ background: 'rgba(255,255,255,0.02)', border: '1px solid rgba(255,255,255,0.06)', borderRadius: '10px', padding: '14px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '6px', color: '#06b6d4', fontSize: '0.78rem', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.04em' }}>
<Users size={14} />
Total Player Reach
</div>
<div style={{ fontSize: '1.75rem', fontWeight: 800, color: 'var(--mp-text-1, #fff)', marginTop: '4px', lineHeight: 1.1 }}>
{telemetry?.totalPlayersOnline?.toLocaleString() ?? 0}
</div>
<div style={{ fontSize: '0.75rem', color: 'var(--mp-text-3, #94a3b8)', marginTop: '4px' }}>
Concurrent players on active servers
</div>
</div>
<div style={{ background: 'rgba(255,255,255,0.02)', border: '1px solid rgba(255,255,255,0.06)', borderRadius: '10px', padding: '14px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '6px', color: '#f59e0b', fontSize: '0.78rem', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.04em' }}>
<Activity size={14} />
Peak 24h Players
</div>
<div style={{ fontSize: '1.75rem', fontWeight: 800, color: 'var(--mp-text-1, #fff)', marginTop: '4px', lineHeight: 1.1 }}>
{telemetry?.peakPlayers24h?.toLocaleString() ?? 0}
</div>
<div style={{ fontSize: '0.75rem', color: 'var(--mp-text-3, #94a3b8)', marginTop: '4px' }}>
Highest server player concurrency
</div>
</div>
<div style={{ background: 'rgba(255,255,255,0.02)', border: '1px solid rgba(255,255,255,0.06)', borderRadius: '10px', padding: '14px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '6px', color: '#a855f7', fontSize: '0.78rem', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.04em' }}>
<Radio size={14} />
Primary Platform
</div>
<div style={{ fontSize: '1.5rem', fontWeight: 800, color: 'var(--mp-text-1, #fff)', marginTop: '4px', lineHeight: 1.1, textTransform: 'capitalize' }}>
{telemetry?.serverTypes?.[0]?.label || 'Pumpkin'}
</div>
<div style={{ fontSize: '0.75rem', color: 'var(--mp-text-3, #94a3b8)', marginTop: '4px' }}>
{telemetry?.serverTypes?.[0]?.percentage ? `${telemetry.serverTypes[0].percentage}% of instances` : 'Awaiting heartbeats'}
</div>
</div>
</div>
{/* Breakdown sub-grid: Installed Versions, Runtime Environment, Active Server Geography */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(280px, 1fr))', gap: '1.25rem' }}>
{/* Installed Version Adoption */}
<div style={{ background: 'rgba(255,255,255,0.015)', border: '1px solid rgba(255,255,255,0.05)', borderRadius: '8px', padding: '16px' }}>
<div style={{ fontSize: '0.85rem', fontWeight: 700, color: 'var(--mp-text-1, #fff)', marginBottom: '12px', display: 'flex', alignItems: 'center', gap: '6px' }}>
<Layers size={16} color="#10b981" />
Installed Version Adoption
</div>
{telemetry?.versions && telemetry.versions.length > 0 ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px' }}>
{telemetry.versions.map((ver, idx) => (
<div key={idx} style={{ fontSize: '0.8rem' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '4px' }}>
<span style={{ fontWeight: 600, color: 'var(--mp-text-1, #fff)', fontFamily: 'monospace' }}>
v{ver.label.replace(/^v/, '')}
</span>
<span style={{ color: 'var(--mp-text-3, #94a3b8)', fontSize: '0.75rem' }}>
{ver.count} {ver.count === 1 ? 'server' : 'servers'} ({ver.percentage}%)
</span>
</div>
<div style={{ height: '6px', width: '100%', background: 'rgba(255,255,255,0.05)', borderRadius: '3px', overflow: 'hidden' }}>
<div style={{ height: '100%', width: `${Math.min(ver.percentage, 100)}%`, background: idx === 0 ? '#10b981' : '#3b82f6', borderRadius: '3px' }} />
</div>
</div>
))}
</div>
) : (
<div style={{ fontSize: '0.8rem', color: 'var(--mp-text-3, #94a3b8)', padding: '1.5rem 0', textAlign: 'center' }}>
Awaiting version heartbeats from active servers.
</div>
)}
</div>
{/* Runtime Environment: Engine, Minecraft Version & OS */}
<div style={{ background: 'rgba(255,255,255,0.015)', border: '1px solid rgba(255,255,255,0.05)', borderRadius: '8px', padding: '16px' }}>
<div style={{ fontSize: '0.85rem', fontWeight: 700, color: 'var(--mp-text-1, #fff)', marginBottom: '12px', display: 'flex', alignItems: 'center', gap: '6px' }}>
<Cpu size={16} color="#06b6d4" />
Server Runtime Environment
</div>
{/* Platform engines */}
{telemetry?.serverTypes && telemetry.serverTypes.length > 0 && (
<div style={{ marginBottom: '12px' }}>
<div style={{ fontSize: '0.7rem', color: 'var(--mp-text-3, #94a3b8)', textTransform: 'uppercase', marginBottom: '4px', fontWeight: 600 }}>
Server Software
</div>
<div style={{ display: 'flex', gap: '6px', flexWrap: 'wrap' }}>
{telemetry.serverTypes.map((st, i) => (
<span key={i} style={{ fontSize: '0.75rem', padding: '3px 8px', borderRadius: '4px', background: 'rgba(255,255,255,0.04)', border: '1px solid rgba(255,255,255,0.08)', color: 'var(--mp-text-1, #fff)' }}>
<strong>{st.label}</strong>: {st.count} ({st.percentage}%)
</span>
))}
</div>
</div>
)}
{/* Minecraft versions */}
<div style={{ marginBottom: '12px' }}>
<div style={{ fontSize: '0.7rem', color: 'var(--mp-text-3, #94a3b8)', textTransform: 'uppercase', marginBottom: '4px', fontWeight: 600 }}>
Minecraft Versions
</div>
{telemetry?.minecraftVersions && telemetry.minecraftVersions.length > 0 ? (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '6px' }}>
{telemetry.minecraftVersions.slice(0, 4).map((mc, i) => (
<span key={i} style={{ fontSize: '0.75rem', padding: '2px 8px', borderRadius: '4px', background: 'rgba(6, 182, 212, 0.08)', border: '1px solid rgba(6, 182, 212, 0.2)', color: '#06b6d4' }}>
MC {mc.label} ({mc.percentage}%)
</span>
))}
</div>
) : (
<span style={{ fontSize: '0.75rem', color: 'var(--mp-text-3, #94a3b8)' }}>MC 1.21.x</span>
)}
</div>
{/* Operating System */}
<div>
<div style={{ fontSize: '0.7rem', color: 'var(--mp-text-3, #94a3b8)', textTransform: 'uppercase', marginBottom: '4px', fontWeight: 600 }}>
Server Host OS
</div>
{telemetry?.operatingSystems && telemetry.operatingSystems.length > 0 ? (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '6px' }}>
{telemetry.operatingSystems.slice(0, 3).map((os, i) => (
<span key={i} style={{ fontSize: '0.75rem', padding: '2px 8px', borderRadius: '4px', background: 'rgba(255,255,255,0.04)', color: 'var(--mp-text-2, #cbd5e1)' }}>
{os.label} ({os.percentage}%)
</span>
))}
</div>
) : (
<span style={{ fontSize: '0.75rem', color: 'var(--mp-text-3, #94a3b8)' }}>Linux / Bare Metal</span>
)}
</div>
</div>
{/* Active Server Geography */}
<div style={{ background: 'rgba(255,255,255,0.015)', border: '1px solid rgba(255,255,255,0.05)', borderRadius: '8px', padding: '16px' }}>
<div style={{ fontSize: '0.85rem', fontWeight: 700, color: 'var(--mp-text-1, #fff)', marginBottom: '12px', display: 'flex', alignItems: 'center', gap: '6px' }}>
<Globe size={16} color="#f59e0b" />
Active Server Locations
</div>
{telemetry?.countries && telemetry.countries.length > 0 ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px', maxHeight: '180px', overflowY: 'auto' }}>
{telemetry.countries.slice(0, 6).map((c, i) => (
<div key={i} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', fontSize: '0.78rem', padding: '3px 0', borderBottom: i < 5 ? '1px solid rgba(255,255,255,0.03)' : 'none' }}>
<span style={{ color: 'var(--mp-text-1, #fff)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', maxWidth: '150px' }} title={c.countryName}>
{c.countryName}
</span>
<span style={{ color: 'var(--mp-text-3, #94a3b8)', fontSize: '0.75rem' }}>
<strong style={{ color: '#f59e0b' }}>{c.servers}</strong> {c.servers === 1 ? 'server' : 'servers'} • {c.players} players
</span>
</div>
))}
</div>
) : (
<div style={{ fontSize: '0.8rem', color: 'var(--mp-text-3, #94a3b8)', padding: '1.5rem 0', textAlign: 'center' }}>
Global server distribution will appear here as instances connect.
</div>
)}
</div>
</div>
</div>
{/* Grid: Charts & Rating Breakdown */} {/* Grid: Charts & Rating Breakdown */}
<div style={{ display: 'grid', gridTemplateColumns: '2fr 1fr', gap: '1.25rem', marginTop: '1.25rem' }}> <div style={{ display: 'grid', gridTemplateColumns: '2fr 1fr', gap: '1.25rem', marginTop: '1.25rem' }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: '1.25rem' }}> <div style={{ display: 'flex', flexDirection: 'column', gap: '1.25rem' }}>

View File

@ -1,11 +1,48 @@
import { useState, useEffect, useMemo, useCallback } from 'react'; import { useState, useEffect, useMemo, useCallback } from 'react';
import api from '../../api'; import api from '../../api';
export interface TelemetryDistributionItem {
label: string;
count: number;
percentage: number;
}
export interface TelemetryGeoItem {
country: string;
countryName: string;
servers: number;
players: number;
percentage: number;
}
export interface PluginTelemetry {
activeServers24h: number;
activeServers7d: number;
totalPlayersOnline: number;
peakPlayers24h: number;
versions: TelemetryDistributionItem[];
serverTypes: TelemetryDistributionItem[];
minecraftVersions: TelemetryDistributionItem[];
operatingSystems: TelemetryDistributionItem[];
countries: TelemetryGeoItem[];
}
export interface DevMapItem {
country: string;
countryName: string;
servers: number;
players: number;
visitors: number;
freeDownloads: number;
buyers: number;
}
export const useAnalytics = (pluginId?: number) => { export const useAnalytics = (pluginId?: number) => {
const [timeSeries, setTimeSeries] = useState<any[]>([]); const [timeSeries, setTimeSeries] = useState<any[]>([]);
const [ratingSummary, setRatingSummary] = useState<any>(null); const [ratingSummary, setRatingSummary] = useState<any>(null);
const [referrers, setReferrers] = useState<any[]>([]); const [referrers, setReferrers] = useState<any[]>([]);
const [mapData, setMapData] = useState<any[]>([]); const [mapData, setMapData] = useState<DevMapItem[]>([]);
const [telemetry, setTelemetry] = useState<PluginTelemetry | null>(null);
const [breakdowns, setBreakdowns] = useState<any[]>([]); const [breakdowns, setBreakdowns] = useState<any[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [timeframe, setTimeframe] = useState<string>('Lifetime'); const [timeframe, setTimeframe] = useState<string>('Lifetime');
@ -23,6 +60,7 @@ export const useAnalytics = (pluginId?: number) => {
setRatingSummary(res.data.ratingSummary || null); setRatingSummary(res.data.ratingSummary || null);
setReferrers(Array.isArray(res.data.referrers) ? res.data.referrers : []); setReferrers(Array.isArray(res.data.referrers) ? res.data.referrers : []);
setMapData(Array.isArray(res.data.mapData) ? res.data.mapData : []); setMapData(Array.isArray(res.data.mapData) ? res.data.mapData : []);
setTelemetry(res.data.telemetry || null);
setBreakdowns(Array.isArray(res.data.breakdowns) ? res.data.breakdowns : []); setBreakdowns(Array.isArray(res.data.breakdowns) ? res.data.breakdowns : []);
} catch (error) { } catch (error) {
console.error("Analytics Fetch Error:", error); console.error("Analytics Fetch Error:", error);
@ -30,6 +68,7 @@ export const useAnalytics = (pluginId?: number) => {
setRatingSummary(null); setRatingSummary(null);
setReferrers([]); setReferrers([]);
setMapData([]); setMapData([]);
setTelemetry(null);
setBreakdowns([]); setBreakdowns([]);
} finally { } finally {
setLoading(false); setLoading(false);
@ -88,6 +127,7 @@ export const useAnalytics = (pluginId?: number) => {
ratingSummary, ratingSummary,
referrers, referrers,
mapData, mapData,
telemetry,
breakdowns, breakdowns,
processedData, processedData,
totals, totals,