Initial web
This commit is contained in:
193
web/components/Dashboard.tsx
Normal file
193
web/components/Dashboard.tsx
Normal file
@@ -0,0 +1,193 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import GridLayout from 'react-grid-layout';
|
||||
import type { Layout, LayoutItem } from 'react-grid-layout';
|
||||
import 'react-grid-layout/css/styles.css';
|
||||
import 'react-resizable/css/styles.css';
|
||||
import { useApp } from '@/lib/context';
|
||||
import { fetchCharts, createChart, updateChart, deleteChart, fetchSignals, fetchSessions, fetchUps } from '@/lib/api';
|
||||
import type { ChartConfig, SignalRow, UpsRow, SessionBoundary, AlertConfig } from '@/lib/types';
|
||||
import ChartCard from './ChartCard';
|
||||
import ChartEditor from './ChartEditor';
|
||||
|
||||
const COLS = 6;
|
||||
const ROW_HEIGHT = 80;
|
||||
|
||||
interface Props {
|
||||
alerts: AlertConfig[];
|
||||
}
|
||||
|
||||
export default function Dashboard({ alerts }: Props) {
|
||||
const { timeRange, timeMode, getFromTo } = useApp();
|
||||
const [charts, setCharts] = useState<ChartConfig[]>([]);
|
||||
const [signalData, setSignalData] = useState<Map<string, SignalRow[]>>(new Map());
|
||||
const [upsData, setUpsData] = useState<Map<string, UpsRow[]>>(new Map());
|
||||
const [sessions, setSessions] = useState<SessionBoundary[]>([]);
|
||||
const [editingChart, setEditingChart] = useState<ChartConfig | null>(null);
|
||||
const [creatingChart, setCreatingChart] = useState(false);
|
||||
const [containerWidth, setContainerWidth] = useState(1200);
|
||||
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const chartsRef = useRef<ChartConfig[]>([]);
|
||||
const refreshingRef = useRef(false);
|
||||
const layoutSaveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
useEffect(() => { chartsRef.current = charts; }, [charts]);
|
||||
useEffect(() => { fetchCharts().then(setCharts); }, []);
|
||||
|
||||
const refreshData = useCallback(async () => {
|
||||
if (refreshingRef.current) return;
|
||||
const current = chartsRef.current;
|
||||
if (current.length === 0) return;
|
||||
|
||||
refreshingRef.current = true;
|
||||
try {
|
||||
const { from, to } = getFromTo();
|
||||
const signalCharts = current.filter(c => c.chart_type === 'signals');
|
||||
const upsCharts = current.filter(c => c.chart_type === 'ups');
|
||||
|
||||
if (signalCharts.length === 0 && upsCharts.length === 0) return;
|
||||
|
||||
const [newSessions, ...results] = await Promise.all([
|
||||
fetchSessions(from, to),
|
||||
...signalCharts.map(c => fetchSignals({
|
||||
combinator: c.filter_combinators ?? undefined,
|
||||
item: c.filter_items ?? undefined,
|
||||
exclude: c.filter_items_exclude ?? undefined,
|
||||
signal: c.signal_type,
|
||||
time_mode: timeMode,
|
||||
from, to,
|
||||
regex: c.filter_items_regex || undefined,
|
||||
...(c.order_by !== 'time' ? { order_by: c.order_by, limit: c.series_limit } : {}),
|
||||
})),
|
||||
...upsCharts.map(c => fetchUps({ combinator: c.filter_combinators?.[0], from, to })),
|
||||
]);
|
||||
|
||||
setSessions(newSessions as SessionBoundary[]);
|
||||
|
||||
const sigMap = new Map<string, SignalRow[]>();
|
||||
signalCharts.forEach((c, i) => sigMap.set(c.id, results[i] as SignalRow[]));
|
||||
setSignalData(sigMap);
|
||||
|
||||
const upsMap = new Map<string, UpsRow[]>();
|
||||
upsCharts.forEach((c, i) => upsMap.set(c.id, results[signalCharts.length + i] as UpsRow[]));
|
||||
setUpsData(upsMap);
|
||||
} finally {
|
||||
refreshingRef.current = false;
|
||||
}
|
||||
}, [getFromTo, timeMode]);
|
||||
|
||||
useEffect(() => {
|
||||
if (charts.length > 0) refreshData();
|
||||
}, [charts, timeRange, timeMode, refreshData]);
|
||||
|
||||
useEffect(() => {
|
||||
const id = setInterval(refreshData, 30_000);
|
||||
return () => clearInterval(id);
|
||||
}, [refreshData]);
|
||||
|
||||
useEffect(() => {
|
||||
const el = containerRef.current;
|
||||
if (!el) return;
|
||||
setContainerWidth(el.clientWidth);
|
||||
const ro = new ResizeObserver(() => setContainerWidth(el.clientWidth));
|
||||
ro.observe(el);
|
||||
return () => ro.disconnect();
|
||||
}, []);
|
||||
|
||||
async function handleCreate(draft: Omit<ChartConfig, 'id'>) {
|
||||
const created = await createChart(draft);
|
||||
setCharts(cs => [...cs, created]);
|
||||
setCreatingChart(false);
|
||||
}
|
||||
|
||||
async function handleUpdate(id: string, draft: Omit<ChartConfig, 'id'>) {
|
||||
const updated = await updateChart(id, draft);
|
||||
setCharts(cs => cs.map(c => c.id === id ? updated : c));
|
||||
setEditingChart(null);
|
||||
}
|
||||
|
||||
async function handleDelete(id: string) {
|
||||
await deleteChart(id);
|
||||
setCharts(cs => cs.filter(c => c.id !== id));
|
||||
}
|
||||
|
||||
function handleLayoutChange(layout: Layout) {
|
||||
const items = layout as readonly LayoutItem[];
|
||||
const changed = items.filter(item => {
|
||||
const chart = chartsRef.current.find(c => c.id === item.i);
|
||||
return chart && (
|
||||
chart.pos_x !== item.x || chart.pos_y !== item.y ||
|
||||
chart.width !== item.w || chart.height !== item.h
|
||||
);
|
||||
});
|
||||
if (changed.length === 0) return;
|
||||
|
||||
setCharts(cs => cs.map(c => {
|
||||
const l = changed.find(item => item.i === c.id);
|
||||
return l ? { ...c, pos_x: l.x, pos_y: l.y, width: l.w, height: l.h } : c;
|
||||
}));
|
||||
|
||||
if (layoutSaveTimer.current) clearTimeout(layoutSaveTimer.current);
|
||||
layoutSaveTimer.current = setTimeout(() => {
|
||||
changed.forEach(item =>
|
||||
updateChart(item.i, { pos_x: item.x, pos_y: item.y, width: item.w, height: item.h }),
|
||||
);
|
||||
}, 500);
|
||||
}
|
||||
|
||||
const layout: Layout = charts.map(c => ({
|
||||
i: c.id, x: c.pos_x, y: c.pos_y, w: c.width, h: c.height,
|
||||
minW: 1,
|
||||
minH: c.chart_type === 'divider' ? 1 : 2,
|
||||
}));
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="w-full">
|
||||
<GridLayout
|
||||
layout={layout}
|
||||
width={containerWidth}
|
||||
onLayoutChange={handleLayoutChange}
|
||||
gridConfig={{ cols: COLS, rowHeight: ROW_HEIGHT, margin: [8, 8] }}
|
||||
dragConfig={{ handle: '.drag-handle' }}
|
||||
>
|
||||
{charts.map(c => (
|
||||
<div key={c.id} className="drag-handle cursor-grab active:cursor-grabbing">
|
||||
<ChartCard
|
||||
config={c}
|
||||
rows={signalData.get(c.id) ?? []}
|
||||
upsRows={upsData.get(c.id) ?? []}
|
||||
sessions={sessions}
|
||||
alerts={alerts.filter(a =>
|
||||
!c.filter_combinators || !a.combinator ||
|
||||
c.filter_combinators.includes(a.combinator),
|
||||
)}
|
||||
timeMode={timeMode}
|
||||
onEdit={() => setEditingChart(c)}
|
||||
onDelete={() => handleDelete(c.id)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</GridLayout>
|
||||
|
||||
<button
|
||||
onClick={() => setCreatingChart(true)}
|
||||
className="fixed bottom-6 right-6 w-12 h-12 bg-indigo-600 hover:bg-indigo-500 text-white rounded-full text-2xl shadow-lg flex items-center justify-center z-30"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
|
||||
{creatingChart && (
|
||||
<ChartEditor onSave={handleCreate} onClose={() => setCreatingChart(false)} />
|
||||
)}
|
||||
{editingChart && (
|
||||
<ChartEditor
|
||||
initial={editingChart}
|
||||
onSave={draft => handleUpdate(editingChart.id, draft)}
|
||||
onClose={() => setEditingChart(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user