2f173dc3e5
CI / backend (push) Successful in 1m33s
CI / frontend (push) Successful in 33s
CI / format (push) Successful in 2m2s
CI / db-tests (push) Successful in 1m45s
Deploy Staging / deploy (push) Successful in 38s
Security / secrets (push) Successful in 6s
Security / dependencies (push) Successful in 1m17s
Security / sast (push) Successful in 56s
CI / backend (pull_request) Successful in 1m9s
CI / frontend (pull_request) Successful in 21s
CI / format (pull_request) Successful in 1m4s
CI / db-tests (pull_request) Successful in 1m27s
Security / secrets (pull_request) Successful in 6s
Security / dependencies (pull_request) Successful in 1m21s
Security / sast (pull_request) Successful in 1m8s
156 lines
5.8 KiB
React
156 lines
5.8 KiB
React
import { useCallback, useEffect, useMemo, useState } from 'react';
|
|
import GridLayout from 'react-grid-layout';
|
|
import 'react-grid-layout/css/styles.css';
|
|
import 'react-resizable/css/styles.css';
|
|
import { AnalyticsApi, LayoutApi, ExportApi, SyncApi } from '../api/client.js';
|
|
import SyncSplash from '../components/SyncSplash.jsx';
|
|
import {
|
|
HealthWidget, StatCard, TopSendersWidget, VolumeWidget,
|
|
CategoryBreakdownWidget, AttachmentsWidget, StorageWidget
|
|
} from '../components/widgets.jsx';
|
|
import { Skeleton } from '../components/ui/skeleton.jsx';
|
|
|
|
// Default grid geometry; overridden by the user's saved layout.
|
|
const DEFAULT_LAYOUT = [
|
|
{ i: 'inbox-health', x: 0, y: 0, w: 3, h: 5 },
|
|
{ i: 'total-emails', x: 3, y: 0, w: 2, h: 2 },
|
|
{ i: 'unread-emails', x: 5, y: 0, w: 2, h: 2 },
|
|
{ i: 'storage', x: 7, y: 0, w: 2, h: 2 },
|
|
{ i: 'top-senders', x: 3, y: 2, w: 3, h: 5 },
|
|
{ i: 'volume', x: 6, y: 2, w: 6, h: 4 },
|
|
{ i: 'category-breakdown', x: 0, y: 5, w: 6, h: 5 },
|
|
{ i: 'attachments', x: 6, y: 6, w: 4, h: 4 },
|
|
];
|
|
|
|
const ALL_WIDGETS = DEFAULT_LAYOUT.map((l) => l.i);
|
|
|
|
function WidgetSkeleton() {
|
|
return (
|
|
<div className="widget">
|
|
<Skeleton className="mb-3 h-4 w-1/3" />
|
|
<Skeleton className="mb-2 h-3 w-full" />
|
|
<Skeleton className="mb-2 h-3 w-5/6" />
|
|
<Skeleton className="h-3 w-2/3" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default function Dashboard() {
|
|
const [data, setData] = useState(null);
|
|
const [layout, setLayout] = useState(DEFAULT_LAYOUT);
|
|
const [hidden, setHidden] = useState([]);
|
|
const [syncing, setSyncing] = useState(false);
|
|
|
|
const loadData = useCallback(() => {
|
|
AnalyticsApi.dashboard().then(setData).catch(() => {});
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
LayoutApi.get().then((saved) => {
|
|
if (saved?.length) {
|
|
const savedLayout = saved.map((w) => ({ i: w.widgetKey, x: w.x, y: w.y, w: w.w, h: w.h }));
|
|
const savedKeys = new Set(savedLayout.map((l) => l.i));
|
|
// Append any newer default widgets the saved layout doesn't know about yet.
|
|
const merged = [...savedLayout, ...DEFAULT_LAYOUT.filter((d) => !savedKeys.has(d.i))];
|
|
setLayout(merged);
|
|
setHidden(saved.filter((w) => !w.visible).map((w) => w.widgetKey));
|
|
}
|
|
}).catch(() => {});
|
|
|
|
// Decide whether to show the loading splash: if a sync is running, watch it;
|
|
// if the inbox has never been synced, kick one off automatically.
|
|
SyncApi.status().then((s) => {
|
|
if (s.isRunning) {
|
|
setSyncing(true);
|
|
} else if (!s.lastSuccessfulSyncUtc) {
|
|
SyncApi.incremental().then(() => setSyncing(true)).catch(loadData);
|
|
} else {
|
|
loadData();
|
|
}
|
|
}).catch(loadData);
|
|
}, [loadData]);
|
|
|
|
// Show the splash when a sync is triggered from the header "Sync now" button.
|
|
useEffect(() => {
|
|
const onStart = () => setSyncing(true);
|
|
window.addEventListener('inboxintel:sync-started', onStart);
|
|
return () => window.removeEventListener('inboxintel:sync-started', onStart);
|
|
}, []);
|
|
|
|
const onSyncDone = useCallback(() => {
|
|
setSyncing(false);
|
|
loadData();
|
|
}, [loadData]);
|
|
|
|
const persist = (nextLayout, nextHidden) => {
|
|
const dto = nextLayout.map((l, idx) => ({
|
|
widgetKey: l.i, x: l.x, y: l.y, w: l.w, h: l.h,
|
|
visible: !nextHidden.includes(l.i), sortOrder: idx, settingsJson: null
|
|
}));
|
|
LayoutApi.save(dto).catch(() => {});
|
|
};
|
|
|
|
const onLayoutChange = (l) => { setLayout(l); persist(l, hidden); };
|
|
const toggle = (key) => {
|
|
const next = hidden.includes(key) ? hidden.filter((h) => h !== key) : [...hidden, key];
|
|
setHidden(next); persist(layout, next);
|
|
};
|
|
|
|
const visibleLayout = useMemo(() => layout.filter((l) => !hidden.includes(l.i)), [layout, hidden]);
|
|
|
|
const render = (key) => {
|
|
// category-breakdown self-fetches, so it renders regardless of dashboard load state.
|
|
if (key === 'category-breakdown') return <CategoryBreakdownWidget />;
|
|
// While the dashboard payload loads, show a skeleton placeholder per widget.
|
|
if (!data) return <WidgetSkeleton />;
|
|
switch (key) {
|
|
case 'inbox-health': return <HealthWidget health={data.health} />;
|
|
case 'total-emails': return <StatCard label="Total Emails" value={(data.totalEmails ?? 0).toLocaleString()} to="/app/folder/allmail" />;
|
|
case 'unread-emails': return <StatCard label="Unread" value={(data.unreadEmails ?? 0).toLocaleString()} to="/app/folder/unread" />;
|
|
case 'storage': return <StorageWidget bytes={data.storageEstimateBytes} />;
|
|
case 'top-senders': return <TopSendersWidget senders={data.topSenders} />;
|
|
case 'volume': return <VolumeWidget volume={data.volumeOverTime} />;
|
|
case 'attachments': return <AttachmentsWidget attachments={data.attachmentBreakdown} />;
|
|
default: return null;
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="dashboard">
|
|
{syncing && <SyncSplash onDone={onSyncDone} />}
|
|
<div className="toolbar">
|
|
<div className="widget-toggles">
|
|
{ALL_WIDGETS.map((k) => (
|
|
<label key={k}>
|
|
<input type="checkbox" checked={!hidden.includes(k)} onChange={() => toggle(k)} /> {k}
|
|
</label>
|
|
))}
|
|
</div>
|
|
<div className="spacer" />
|
|
<a className="btn" href={ExportApi.reportUrl('pdf')}>Export PDF</a>
|
|
<a className="btn" href={ExportApi.reportUrl('csv')}>CSV</a>
|
|
<a className="btn" href={ExportApi.reportUrl('json')}>JSON</a>
|
|
</div>
|
|
|
|
<GridLayout
|
|
className="layout"
|
|
layout={visibleLayout}
|
|
cols={12}
|
|
rowHeight={60}
|
|
width={1200}
|
|
isResizable
|
|
isDraggable
|
|
resizeHandles={['se']}
|
|
compactType="vertical"
|
|
margin={[12, 12]}
|
|
onLayoutChange={onLayoutChange}
|
|
draggableHandle=".widget h3, .widget .stat-label"
|
|
>
|
|
{visibleLayout.map((l) => (
|
|
<div key={l.i} className="grid-item">{render(l.i)}</div>
|
|
))}
|
|
</GridLayout>
|
|
</div>
|
|
);
|
|
}
|