feat: replace SSE with Grafana-style client polling and rich date presets

This commit is contained in:
Cauê Faleiros
2026-05-07 14:52:45 -03:00
parent 3bb46cff1a
commit b986eafb98
6 changed files with 145 additions and 144 deletions

View File

@@ -61,32 +61,6 @@ const verifyToken = (req, res, next) => {
}); });
}; };
const sseClients = new Set();
// SSE Endpoint for real-time updates
app.get('/api/stream', (req, res) => {
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
res.setHeader('X-Accel-Buffering', 'no'); // Bypass Nginx Proxy Manager buffering
res.flushHeaders();
// Send initial connection event
res.write('data: {"connected": true}\n\n');
// Keep connection alive through Nginx/Cloudflare
const keepAlive = setInterval(() => {
res.write(':\n\n'); // SSE comment to prevent idle timeout
}, 15000);
sseClients.add(res);
req.on('close', () => {
clearInterval(keepAlive);
sseClients.delete(res);
});
});
// Login Endpoint // Login Endpoint
app.post('/api/login', (req, res) => { app.post('/api/login', (req, res) => {
const { email, password } = req.body; const { email, password } = req.body;
@@ -169,12 +143,6 @@ app.post('/api/data', authenticateAPIKey, async (req, res) => {
} }
await client.query('COMMIT'); await client.query('COMMIT');
// Broadcast update to all connected SSE clients
const broadcastMessage = `data: {"type": "update"}\n\n`;
for (const clientRes of sseClients) {
clientRes.write(broadcastMessage);
}
} catch (error) { } catch (error) {
await client.query('ROLLBACK'); await client.query('ROLLBACK');
console.error("Database insert error:", error); console.error("Database insert error:", error);

View File

@@ -1,93 +1,98 @@
import React, { useRef } from 'react'; import React, { useState } from 'react';
import { Calendar } from 'lucide-react'; import { Calendar, RefreshCw, ChevronDown } from 'lucide-react';
import type { DateRange } from '../types'; import type { DateRange } from '../types';
interface DateRangePickerProps { interface DateRangePickerProps {
dateRange: DateRange; dateRange: DateRange;
onChange: (range: DateRange) => void; onChange: (range: DateRange) => void;
refreshInterval: number;
setRefreshInterval: (interval: number) => void;
onManualRefresh: () => void;
} }
const DateRangePicker: React.FC<DateRangePickerProps> = ({ dateRange, onChange }) => { const PRESETS = [
const startRef = useRef<HTMLInputElement>(null); { label: 'Hoje', getRange: () => { const d = new Date(); d.setHours(0,0,0,0); return { start: d, end: new Date() }; } },
const endRef = useRef<HTMLInputElement>(null); { label: 'Ontem', getRange: () => { const d = new Date(); d.setDate(d.getDate() - 1); d.setHours(0,0,0,0); const end = new Date(d); end.setHours(23,59,59,999); return { start: d, end }; } },
{ label: 'Últimos 7 dias', getRange: () => { const end = new Date(); const start = new Date(); start.setDate(start.getDate() - 7); start.setHours(0,0,0,0); return { start, end }; } },
{ label: 'Últimos 30 dias', getRange: () => { const end = new Date(); const start = new Date(); start.setDate(start.getDate() - 30); start.setHours(0,0,0,0); return { start, end }; } },
{ label: 'Este Mês', getRange: () => { const end = new Date(); const start = new Date(end.getFullYear(), end.getMonth(), 1); return { start, end }; } },
{ label: 'Mês Passado', getRange: () => { const d = new Date(); const start = new Date(d.getFullYear(), d.getMonth() - 1, 1); const end = new Date(d.getFullYear(), d.getMonth(), 0, 23, 59, 59, 999); return { start, end }; } },
{ label: 'Últimos 90 dias', getRange: () => { const end = new Date(); const start = new Date(); start.setDate(start.getDate() - 90); start.setHours(0,0,0,0); return { start, end }; } },
{ label: 'Este Ano', getRange: () => { const end = new Date(); const start = new Date(end.getFullYear(), 0, 1); return { start, end }; } },
{ label: 'Todo o Período', getRange: () => { const end = new Date(); const start = new Date(2000, 0, 1); return { start, end }; } },
];
const formatDateForInput = (date: Date) => { const REFRESH_OPTIONS = [
const year = date.getFullYear(); { label: 'Desligado', value: 0 },
const month = String(date.getMonth() + 1).padStart(2, '0'); { label: '5s', value: 5000 },
const day = String(date.getDate()).padStart(2, '0'); { label: '10s', value: 10000 },
return `${year}-${month}-${day}`; { label: '30s', value: 30000 },
}; { label: '1m', value: 60000 },
{ label: '5m', value: 300000 },
];
const DateRangePicker: React.FC<DateRangePickerProps> = ({ dateRange, onChange, refreshInterval, setRefreshInterval, onManualRefresh }) => {
const [isPresetOpen, setIsPresetOpen] = useState(false);
const formatShortDate = (date: Date) => { const formatShortDate = (date: Date) => {
return date.toLocaleDateString('pt-BR', { day: '2-digit', month: '2-digit', year: '2-digit' }); return date.toLocaleDateString('pt-BR', { day: '2-digit', month: '2-digit', year: '2-digit' });
}; };
const parseLocalDate = (value: string) => {
if (!value) return null;
const [year, month, day] = value.split('-');
return new Date(parseInt(year), parseInt(month) - 1, parseInt(day));
};
const handleStartChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newStart = parseLocalDate(e.target.value);
if (newStart && !isNaN(newStart.getTime())) {
onChange({ ...dateRange, start: newStart });
}
};
const handleEndChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newEnd = parseLocalDate(e.target.value);
if (newEnd && !isNaN(newEnd.getTime())) {
newEnd.setHours(23, 59, 59, 999);
onChange({ ...dateRange, end: newEnd });
}
};
const openPicker = (ref: React.RefObject<HTMLInputElement | null>) => {
if (ref.current) {
try {
if ('showPicker' in HTMLInputElement.prototype) {
ref.current.showPicker();
} else {
ref.current.focus();
}
} catch (e) {
ref.current.focus();
}
}
};
return ( return (
<div className="flex items-center gap-2 bg-dark-card border border-dark-border px-3 py-2 rounded-xl shadow-sm hover:border-brand-primary transition-colors"> <div className="flex flex-wrap items-center gap-3">
{/* Date Presets Dropdown */}
<div className="relative">
<button
onClick={() => setIsPresetOpen(!isPresetOpen)}
className="flex items-center gap-2 bg-dark-card border border-dark-border px-4 py-2.5 rounded-xl shadow-sm hover:border-brand-primary transition-colors text-sm font-medium text-dark-text"
>
<Calendar size={16} className="text-dark-muted shrink-0" /> <Calendar size={16} className="text-dark-muted shrink-0" />
<div className="flex items-center gap-2 text-sm font-medium text-dark-text"> <span>{formatShortDate(dateRange.start)} - {formatShortDate(dateRange.end)}</span>
<div <ChevronDown size={14} className="text-dark-muted ml-1" />
className="relative cursor-pointer hover:text-brand-primary transition-colors" </button>
onClick={() => openPicker(startRef)}
{isPresetOpen && (
<>
<div className="fixed inset-0 z-10" onClick={() => setIsPresetOpen(false)}></div>
<div className="absolute right-0 mt-2 w-48 bg-dark-card border border-dark-border rounded-xl shadow-xl z-20 py-2 max-h-64 overflow-y-auto">
{PRESETS.map((preset) => (
<button
key={preset.label}
onClick={() => {
onChange(preset.getRange());
setIsPresetOpen(false);
}}
className="w-full text-left px-4 py-2 text-sm text-dark-muted hover:text-dark-text hover:bg-dark-input transition-colors"
> >
{formatShortDate(dateRange.start)} {preset.label}
<input </button>
ref={startRef} ))}
type="date"
value={formatDateForInput(dateRange.start)}
onChange={handleStartChange}
className="absolute opacity-0 w-0 h-0 overflow-hidden"
/>
</div> </div>
<span className="text-dark-muted font-normal text-xs">até</span> </>
<div )}
className="relative cursor-pointer hover:text-brand-primary transition-colors" </div>
onClick={() => openPicker(endRef)}
{/* Auto Refresh Dropdown */}
<div className="flex items-center bg-dark-card border border-dark-border rounded-xl shadow-sm overflow-hidden">
<button
onClick={onManualRefresh}
className="px-3 py-2.5 text-dark-muted hover:text-brand-primary hover:bg-dark-input transition-colors border-r border-dark-border"
title="Atualizar Agora"
> >
{formatShortDate(dateRange.end)} <RefreshCw size={16} />
<input </button>
ref={endRef} <select
type="date" value={refreshInterval}
value={formatDateForInput(dateRange.end)} onChange={(e) => setRefreshInterval(Number(e.target.value))}
onChange={handleEndChange} className="appearance-none bg-transparent text-sm font-medium text-dark-text pl-3 pr-8 py-2.5 focus:outline-none cursor-pointer relative"
className="absolute opacity-0 w-0 h-0 overflow-hidden" style={{ backgroundImage: `url("data:image/svg+xml;charset=UTF-8,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23a1a1aa' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3e%3cpolyline points='6 9 12 15 18 9'%3e%3c/polyline%3e%3c/svg%3e")`, backgroundRepeat: 'no-repeat', backgroundPosition: 'right 0.5rem center', backgroundSize: '1em' }}
/> >
</div> {REFRESH_OPTIONS.map(opt => (
<option key={opt.label} value={opt.value} className="bg-dark-card">
Auto: {opt.label}
</option>
))}
</select>
</div> </div>
</div> </div>
); );

View File

@@ -26,39 +26,29 @@ const Layout = () => {
const [ordersData, setOrdersData] = useState<OrderData[]>([]); const [ordersData, setOrdersData] = useState<OrderData[]>([]);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [refreshInterval, setRefreshInterval] = useState<number>(0);
useEffect(() => { const loadData = async (showLoading = false) => {
const loadInitial = async () => { if (showLoading) setIsLoading(true);
setIsLoading(true);
const data = await fetchData(); const data = await fetchData();
setOrdersData(data); setOrdersData(data);
setIsLoading(false); if (showLoading) setIsLoading(false);
}; };
loadInitial(); useEffect(() => {
loadData(true);
// Set up SSE for real-time updates
const API_URL = import.meta.env.VITE_API_URL || '/api';
const sse = new EventSource(`${API_URL}/stream`);
sse.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
if (data.type === 'update') {
fetchData().then(newData => {
setOrdersData(newData);
});
}
} catch (e) {
console.error("SSE parse error", e);
}
};
return () => {
sse.close();
};
}, []); }, []);
useEffect(() => {
if (refreshInterval === 0) return;
const intervalId = setInterval(() => {
loadData(false);
}, refreshInterval);
return () => clearInterval(intervalId);
}, [refreshInterval]);
useEffect(() => { useEffect(() => {
localStorage.setItem('nexstar_date_range', JSON.stringify({ localStorage.setItem('nexstar_date_range', JSON.stringify({
start: dateRange.start.toISOString(), start: dateRange.start.toISOString(),
@@ -149,7 +139,7 @@ const Layout = () => {
<Loader2 className="w-8 h-8 text-brand-primary animate-spin" /> <Loader2 className="w-8 h-8 text-brand-primary animate-spin" />
</div> </div>
) : ( ) : (
<Outlet context={{ dateRange, setDateRange, ordersData }} /> <Outlet context={{ dateRange, setDateRange, ordersData, refreshInterval, setRefreshInterval, loadData }} />
)} )}
</div> </div>
</main> </main>

View File

@@ -47,7 +47,14 @@ const CustomTooltip = ({ active, payload, label, isCurrency }: any) => {
}; };
const Dashboard = () => { const Dashboard = () => {
const { dateRange, setDateRange, ordersData } = useOutletContext<{ dateRange: DateRange, setDateRange: (range: DateRange) => void, ordersData: OrderData[] }>(); const { dateRange, setDateRange, ordersData, refreshInterval, setRefreshInterval, loadData } = useOutletContext<{
dateRange: DateRange,
setDateRange: (range: DateRange) => void,
ordersData: OrderData[],
refreshInterval: number,
setRefreshInterval: (interval: number) => void,
loadData: (showLoading?: boolean) => void
}>();
const filteredData = useMemo(() => { const filteredData = useMemo(() => {
const orders = ordersData; const orders = ordersData;
@@ -112,7 +119,13 @@ const Dashboard = () => {
<h1 className="text-2xl font-bold mb-2 text-dark-text">Visão Geral</h1> <h1 className="text-2xl font-bold mb-2 text-dark-text">Visão Geral</h1>
<p className="text-dark-muted font-medium">Resumo de vendas e performance dos produtos.</p> <p className="text-dark-muted font-medium">Resumo de vendas e performance dos produtos.</p>
</div> </div>
<DateRangePicker dateRange={dateRange} onChange={setDateRange} /> <DateRangePicker
dateRange={dateRange}
onChange={setDateRange}
refreshInterval={refreshInterval}
setRefreshInterval={setRefreshInterval}
onManualRefresh={() => loadData(true)}
/>
</div> </div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6"> <div className="grid grid-cols-1 md:grid-cols-3 gap-6">

View File

@@ -20,7 +20,14 @@ const CustomTooltip = ({ active, payload, label }: any) => {
const ProductDetails = () => { const ProductDetails = () => {
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
const { dateRange, setDateRange, ordersData } = useOutletContext<{ dateRange: DateRange, setDateRange: (range: DateRange) => void, ordersData: OrderData[] }>(); const { dateRange, setDateRange, ordersData, refreshInterval, setRefreshInterval, loadData } = useOutletContext<{
dateRange: DateRange,
setDateRange: (range: DateRange) => void,
ordersData: OrderData[],
refreshInterval: number,
setRefreshInterval: (interval: number) => void,
loadData: (showLoading?: boolean) => void
}>();
const { productInfo, chartData, totalSold, totalRevenue } = useMemo(() => { const { productInfo, chartData, totalSold, totalRevenue } = useMemo(() => {
const orders = ordersData.filter(order => order.ID_Produto === id); const orders = ordersData.filter(order => order.ID_Produto === id);
@@ -92,8 +99,13 @@ const ProductDetails = () => {
</div> </div>
</div> </div>
<DateRangePicker dateRange={dateRange} onChange={setDateRange} /> <DateRangePicker
</div> dateRange={dateRange}
onChange={setDateRange}
refreshInterval={refreshInterval}
setRefreshInterval={setRefreshInterval}
onManualRefresh={() => loadData(true)}
/> </div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6"> <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="bg-dark-card p-6 rounded-2xl border border-dark-border flex items-center justify-between shadow-sm"> <div className="bg-dark-card p-6 rounded-2xl border border-dark-border flex items-center justify-between shadow-sm">

View File

@@ -6,7 +6,14 @@ import type { OrderData, DateRange } from '../types';
import { parseOrderDate } from '../dataService'; import { parseOrderDate } from '../dataService';
const Products = () => { const Products = () => {
const { dateRange, setDateRange, ordersData } = useOutletContext<{ dateRange: DateRange, setDateRange: (range: DateRange) => void, ordersData: OrderData[] }>(); const { dateRange, setDateRange, ordersData, refreshInterval, setRefreshInterval, loadData } = useOutletContext<{
dateRange: DateRange,
setDateRange: (range: DateRange) => void,
ordersData: OrderData[],
refreshInterval: number,
setRefreshInterval: (interval: number) => void,
loadData: (showLoading?: boolean) => void
}>();
const [searchTerm, setSearchTerm] = useState(''); const [searchTerm, setSearchTerm] = useState('');
// Pagination state // Pagination state
@@ -67,7 +74,13 @@ const Products = () => {
</div> </div>
<div className="flex flex-col sm:flex-row gap-3"> <div className="flex flex-col sm:flex-row gap-3">
<DateRangePicker dateRange={dateRange} onChange={setDateRange} /> <DateRangePicker
dateRange={dateRange}
onChange={setDateRange}
refreshInterval={refreshInterval}
setRefreshInterval={setRefreshInterval}
onManualRefresh={() => loadData(true)}
/>
<div className="relative"> <div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-zinc-400 dark:text-dark-muted w-5 h-5" /> <Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-zinc-400 dark:text-dark-muted w-5 h-5" />