Dashboard.tsx 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295
  1. import { Area, Bar, Column, Pie } from '@ant-design/plots';
  2. import { Alert, Col, List, Row, Space, Table, Typography } from 'antd';
  3. import type { ColumnsType } from 'antd/es/table';
  4. import { EmptyState } from '../components/common/EmptyState';
  5. import { MetricCard } from '../components/common/MetricCard';
  6. import { PageHeader } from '../components/common/PageHeader';
  7. import { SectionCard } from '../components/common/SectionCard';
  8. import { StatusPill } from '../components/common/StatusPill';
  9. import {
  10. buildDashboardSummary,
  11. buildDomainRanking,
  12. buildHourlyHeatmap,
  13. buildStatusDistribution,
  14. buildTrendSeries
  15. } from '../frontend/analytics-model.js';
  16. import { buildDomainHealth } from '../frontend/domain-model.js';
  17. import { useI18n } from '../frontend/i18n/react';
  18. import { brandColors } from '../frontend/theme';
  19. import type { Analytics, Domain, RuntimeConfig, SendEvent, SmtpCredential } from '../frontend/types';
  20. interface DashboardProps {
  21. analytics: Analytics | null;
  22. domains: Domain[];
  23. events: SendEvent[];
  24. config: RuntimeConfig | null;
  25. smtpCredential: SmtpCredential | null;
  26. }
  27. export default function Dashboard({ analytics, domains, events, config, smtpCredential }: DashboardProps) {
  28. const { t } = useI18n();
  29. const summary = buildDashboardSummary({ analytics, domains, events, config, smtpCredential });
  30. const trendSeries = buildTrendSeries(analytics);
  31. const trendData = trendSeries.flatMap((item) => [
  32. { date: item.date, type: t('metrics.total'), value: item.total },
  33. { date: item.date, type: t('metrics.accepted'), value: item.accepted },
  34. { date: item.date, type: t('metrics.failed'), value: item.failed }
  35. ]);
  36. const statusData = buildStatusDistribution(analytics).map((item) => ({
  37. ...item,
  38. label: statusLabel(item.status, t)
  39. }));
  40. const rankingData = buildDomainRanking(analytics);
  41. const hourlyData = buildHourlyHeatmap(analytics);
  42. const lastSentLabel = summary.lastSentAt
  43. ? new Date(summary.lastSentAt).toLocaleString()
  44. : t('common.notFound');
  45. const columns: ColumnsType<SendEvent> = [
  46. { title: 'Time', dataIndex: 'createdAt', render: (value) => new Date(value).toLocaleString() },
  47. { title: 'Recipient', dataIndex: 'recipients', render: (value: string[]) => value.join(', ') },
  48. { title: 'Domain', dataIndex: 'domain' },
  49. { title: 'Subject', dataIndex: 'subject', ellipsis: true },
  50. {
  51. title: t('common.status'),
  52. dataIndex: 'status',
  53. render: (value) => (
  54. <StatusPill tone={statusTone(value)}>{statusLabel(value, t)}</StatusPill>
  55. )
  56. }
  57. ];
  58. return (
  59. <Space direction="vertical" size={20} className="full-width">
  60. {config?.usingDefaultAdminPassword ? (
  61. <Alert type="warning" showIcon message={t('dashboard.defaultPasswordWarning')} />
  62. ) : null}
  63. <PageHeader
  64. title={t('dashboard.title')}
  65. extra={
  66. <StatusPill tone={summary.smtpReady ? 'success' : 'warning'}>
  67. {t('dashboard.smtpStatus')}:{' '}
  68. {summary.smtpReady ? t('dashboard.smtpReady') : t('dashboard.smtpNotConfigured')}
  69. </StatusPill>
  70. }
  71. />
  72. <Row gutter={[16, 16]}>
  73. <Col xs={24} sm={12} lg={6}>
  74. <MetricCard label={t('dashboard.todaySent')} value={summary.today} />
  75. </Col>
  76. <Col xs={24} sm={12} lg={6}>
  77. <MetricCard
  78. label={t('dashboard.successRate')}
  79. value={`${summary.successRate}%`}
  80. hint={`${t('dashboard.bounceRate')} ${summary.bounceRate}% · ${t('dashboard.complaintRate')} ${summary.complaintRate}%`}
  81. />
  82. </Col>
  83. <Col xs={24} sm={12} lg={6}>
  84. <MetricCard label={t('dashboard.verifiedDomains')} value={summary.verifiedDomains} />
  85. </Col>
  86. <Col xs={24} sm={12} lg={6}>
  87. <MetricCard
  88. label={t('dashboard.dnsIssues')}
  89. value={summary.dnsIssues}
  90. tone={summary.dnsIssues > 0 ? 'warning' : 'default'}
  91. hint={summary.dnsIssues > 0 ? t('dashboard.dnsActionHint') : undefined}
  92. />
  93. </Col>
  94. </Row>
  95. <Row gutter={[16, 16]}>
  96. <Col xs={24} xl={15}>
  97. <SectionCard title={t('dashboard.trend')} className="chart-card">
  98. {trendData.length ? (
  99. <Area
  100. data={trendData}
  101. xField="date"
  102. yField="value"
  103. colorField="type"
  104. shapeField="smooth"
  105. height={316}
  106. axis={{ y: { title: false }, x: { title: false } }}
  107. scale={{
  108. color: {
  109. range: [brandColors.chartPrimary, brandColors.chartSuccess, brandColors.chartDanger]
  110. }
  111. }}
  112. tooltip={{ title: 'date' }}
  113. legend={{ color: { position: 'top' } }}
  114. />
  115. ) : (
  116. <EmptyState description={t('dashboard.noTrend')} />
  117. )}
  118. </SectionCard>
  119. </Col>
  120. <Col xs={24} xl={9}>
  121. <SectionCard title={t('dashboard.statusDistribution')} className="chart-card">
  122. {statusData.length ? (
  123. <Pie
  124. data={statusData}
  125. angleField="value"
  126. colorField="label"
  127. innerRadius={0.64}
  128. height={316}
  129. scale={{
  130. color: {
  131. range: [brandColors.chartSuccess, brandColors.chartDanger, brandColors.chartWarning]
  132. }
  133. }}
  134. label={{
  135. text: (datum: { label?: string; value?: number }) =>
  136. `${datum.label || ''}${datum.value != null ? ` ${datum.value}` : ''}`,
  137. position: 'outside'
  138. }}
  139. legend={{ color: { position: 'bottom' } }}
  140. tooltip={{
  141. title: (datum: { label?: string }) => datum.label || '',
  142. items: [
  143. (datum: { value?: number }) => ({
  144. name: t('metrics.total'),
  145. value: datum.value ?? 0
  146. })
  147. ]
  148. }}
  149. />
  150. ) : (
  151. <EmptyState description={t('dashboard.noTrend')} />
  152. )}
  153. </SectionCard>
  154. </Col>
  155. </Row>
  156. <Row gutter={[16, 16]}>
  157. <Col xs={24} xl={12}>
  158. <SectionCard title={t('dashboard.domainRanking')} className="chart-card">
  159. {rankingData.length ? (
  160. <Bar
  161. data={rankingData}
  162. xField="total"
  163. yField="domain"
  164. height={312}
  165. colorField="domain"
  166. scale={{ color: { range: [brandColors.chartPrimary] } }}
  167. label={{ text: 'total', position: 'right' }}
  168. axis={{ x: { title: false }, y: { title: false } }}
  169. legend={false}
  170. />
  171. ) : (
  172. <EmptyState description={t('dashboard.noDomains')} />
  173. )}
  174. </SectionCard>
  175. </Col>
  176. <Col xs={24} xl={12}>
  177. <SectionCard title={t('dashboard.hourlyHeatmap')} className="chart-card">
  178. {hourlyData.length ? (
  179. <Column
  180. data={hourlyData}
  181. xField="hour"
  182. yField="total"
  183. height={312}
  184. colorField="total"
  185. scale={{ color: { range: [brandColors.chartTrack, brandColors.chartPrimary] } }}
  186. axis={{ x: { title: false }, y: { title: false } }}
  187. tooltip={{ title: 'hour' }}
  188. legend={false}
  189. />
  190. ) : (
  191. <EmptyState description={t('dashboard.noTrend')} />
  192. )}
  193. </SectionCard>
  194. </Col>
  195. </Row>
  196. <Row gutter={[16, 16]}>
  197. <Col xs={24} xl={9}>
  198. <SectionCard title={t('dashboard.recentFailures')}>
  199. {analytics?.recentFailures?.length ? (
  200. <List
  201. dataSource={analytics.recentFailures}
  202. renderItem={(item) => (
  203. <List.Item>
  204. <List.Item.Meta
  205. title={<Typography.Text ellipsis>{item.subject || item.domain || '-'}</Typography.Text>}
  206. description={
  207. <Space direction="vertical" size={2}>
  208. <Typography.Text type="secondary">{new Date(item.createdAt).toLocaleString()}</Typography.Text>
  209. <Typography.Text type="danger" ellipsis>{item.detail}</Typography.Text>
  210. </Space>
  211. }
  212. />
  213. </List.Item>
  214. )}
  215. />
  216. ) : (
  217. <EmptyState description={t('dashboard.noFailures')} />
  218. )}
  219. </SectionCard>
  220. </Col>
  221. <Col xs={24} xl={15}>
  222. <SectionCard title={t('dashboard.domainHealth')}>
  223. <Space direction="vertical" className="full-width">
  224. {domains.slice(0, 6).map((domain) => {
  225. const health = buildDomainHealth(domain);
  226. return (
  227. <div className="health-row" key={domain.id}>
  228. <div>
  229. <Typography.Text strong>{domain.domain}</Typography.Text>
  230. <Typography.Text type="secondary">DNS {health.passed}/{health.total}</Typography.Text>
  231. </div>
  232. <StatusPill tone={domainHealthTone(health.status)}>
  233. {domainHealthLabel(health.status, t)}
  234. </StatusPill>
  235. </div>
  236. );
  237. })}
  238. {!domains.length ? <EmptyState description={t('dashboard.noDomains')} /> : null}
  239. </Space>
  240. </SectionCard>
  241. </Col>
  242. </Row>
  243. <SectionCard
  244. title={t('dashboard.recentLogs')}
  245. extra={
  246. <Typography.Text type="secondary">
  247. {t('dashboard.lastSentAt')}: {lastSentLabel}
  248. </Typography.Text>
  249. }
  250. >
  251. <Table rowKey="id" columns={columns} dataSource={events.slice(0, 8)} pagination={false} scroll={{ x: 900 }} />
  252. </SectionCard>
  253. </Space>
  254. );
  255. }
  256. function statusLabel(status: string, t: (key: string) => string) {
  257. if (status === 'queued') return t('logs.statusQueued');
  258. if (status === 'sent') return t('logs.statusSent');
  259. if (status === 'deferred') return t('logs.statusDeferred');
  260. if (status === 'bounced') return t('logs.statusBounced');
  261. if (status === 'failed') return t('logs.statusFailed');
  262. return status || t('dashboard.statusUnknown');
  263. }
  264. function statusTone(status: string): 'success' | 'warning' | 'error' | 'info' | 'neutral' {
  265. if (status === 'queued') return 'info';
  266. if (status === 'sent') return 'success';
  267. if (status === 'deferred') return 'warning';
  268. if (status === 'bounced' || status === 'failed') return 'error';
  269. return 'neutral';
  270. }
  271. function domainHealthLabel(status: string, t: (key: string) => string) {
  272. if (status === 'success') return t('domains.healthy');
  273. if (status === 'warning') return t('domains.waitingDns');
  274. return t('domains.needsAction');
  275. }
  276. function domainHealthTone(status: string): 'success' | 'warning' | 'error' {
  277. if (status === 'success') return 'success';
  278. if (status === 'warning') return 'warning';
  279. return 'error';
  280. }