Jelajahi Sumber

feat: add analytics dashboard ui

Codex 1 bulan lalu
induk
melakukan
4ca4f7c080
6 mengubah file dengan 1193 tambahan dan 7 penghapusan
  1. 294 4
      public/app.js
  2. 30 3
      public/index.html
  3. 680 0
      public/styles.css
  4. 139 0
      src/db.js
  5. 4 0
      src/server.js
  6. 46 0
      test/db.test.js

+ 294 - 4
public/app.js

@@ -6,14 +6,26 @@ const state = {
   apiTokens: [],
   apiTokens: [],
   domains: [],
   domains: [],
   events: [],
   events: [],
+  analytics: null,
   users: [],
   users: [],
   settings: null,
   settings: null,
   selectedId: null,
   selectedId: null,
-  view: 'domains',
+  view: 'dashboard',
+  dnsTestResults: {},
   busy: false
   busy: false
 };
 };
 
 
 const viewMeta = {
 const viewMeta = {
+  dashboard: {
+    eyebrow: 'Command Center',
+    title: '发信控制台',
+    subtitle: '总览域名健康、发信趋势和关键配置状态。'
+  },
+  analytics: {
+    eyebrow: 'Analytics',
+    title: '统计分析',
+    subtitle: '按时间、域名和状态分析发信表现。'
+  },
   domains: {
   domains: {
     eyebrow: 'Domains',
     eyebrow: 'Domains',
     title: '发信域名',
     title: '发信域名',
@@ -46,6 +58,8 @@ const els = {
   viewEyebrow: document.querySelector('#viewEyebrow'),
   viewEyebrow: document.querySelector('#viewEyebrow'),
   viewTitle: document.querySelector('#viewTitle'),
   viewTitle: document.querySelector('#viewTitle'),
   securityNotice: document.querySelector('#securityNotice'),
   securityNotice: document.querySelector('#securityNotice'),
+  dashboardPanel: document.querySelector('#dashboardPanel'),
+  analyticsPanel: document.querySelector('#analyticsPanel'),
   accountBox: document.querySelector('#accountBox'),
   accountBox: document.querySelector('#accountBox'),
   userRole: document.querySelector('#userRole'),
   userRole: document.querySelector('#userRole'),
   navButtons: document.querySelectorAll('.primary-nav [data-view]'),
   navButtons: document.querySelectorAll('.primary-nav [data-view]'),
@@ -74,6 +88,7 @@ const els = {
   apiTokenForm: document.querySelector('#apiTokenForm'),
   apiTokenForm: document.querySelector('#apiTokenForm'),
   apiTokenList: document.querySelector('#apiTokenList'),
   apiTokenList: document.querySelector('#apiTokenList'),
   apiTokenCount: document.querySelector('#apiTokenCount'),
   apiTokenCount: document.querySelector('#apiTokenCount'),
+  analyticsEventCount: document.querySelector('#analyticsEventCount'),
   defaultSenderHost: document.querySelector('#defaultSenderHost'),
   defaultSenderHost: document.querySelector('#defaultSenderHost'),
   defaultSendingIp: document.querySelector('#defaultSendingIp'),
   defaultSendingIp: document.querySelector('#defaultSendingIp'),
   defaultSpfExtra: document.querySelector('#defaultSpfExtra'),
   defaultSpfExtra: document.querySelector('#defaultSpfExtra'),
@@ -126,14 +141,16 @@ async function refreshAll() {
       api('/api/config'),
       api('/api/config'),
       api('/api/domains'),
       api('/api/domains'),
       api('/api/events'),
       api('/api/events'),
+      api('/api/analytics?days=30'),
       api('/api/smtp-credential'),
       api('/api/smtp-credential'),
       api('/api/dns-credentials'),
       api('/api/dns-credentials'),
       api('/api/api-tokens')
       api('/api/api-tokens')
     ];
     ];
-    const [config, domains, events, smtpCredential, dnsCredentials, apiTokens] = await Promise.all(baseCalls);
+    const [config, domains, events, analytics, smtpCredential, dnsCredentials, apiTokens] = await Promise.all(baseCalls);
     state.config = config;
     state.config = config;
     state.domains = domains.domains || [];
     state.domains = domains.domains || [];
     state.events = events.events || [];
     state.events = events.events || [];
+    state.analytics = analytics.analytics || null;
     state.smtpCredential = smtpCredential.credential || null;
     state.smtpCredential = smtpCredential.credential || null;
     state.dnsCredentials = dnsCredentials.credentials || [];
     state.dnsCredentials = dnsCredentials.credentials || [];
     state.apiTokens = apiTokens.tokens || [];
     state.apiTokens = apiTokens.tokens || [];
@@ -182,6 +199,8 @@ function renderDefaults() {
     </div>
     </div>
   `;
   `;
   renderDnsOptions();
   renderDnsOptions();
+  renderDashboard();
+  renderAnalyticsPanel();
   renderDomainReadiness();
   renderDomainReadiness();
   renderSmtpCredentialCopy();
   renderSmtpCredentialCopy();
   renderDnsCredentials();
   renderDnsCredentials();
@@ -195,7 +214,10 @@ function render() {
   els.domainCount.textContent = String(state.domains.length);
   els.domainCount.textContent = String(state.domains.length);
   els.dnsCredentialCount.textContent = String(state.dnsCredentials.length);
   els.dnsCredentialCount.textContent = String(state.dnsCredentials.length);
   els.apiTokenCount.textContent = String(state.apiTokens.length);
   els.apiTokenCount.textContent = String(state.apiTokens.length);
+  els.analyticsEventCount.textContent = String(state.analytics?.summary?.total || 0);
   renderViewMeta();
   renderViewMeta();
+  renderDashboard();
+  renderAnalyticsPanel();
   renderDomainList();
   renderDomainList();
   renderDetail();
   renderDetail();
 }
 }
@@ -230,6 +252,246 @@ function renderSecurityNotice() {
     : '';
     : '';
 }
 }
 
 
+function renderDashboard() {
+  if (!els.dashboardPanel) return;
+  const analytics = state.analytics || {};
+  const summary = analytics.summary || {};
+  const totalDomains = state.domains.length;
+  const verifiedDomains = state.domains.filter((domain) => domain.status?.verified).length;
+  const dnsReady = state.dnsCredentials.length > 0;
+  const smtpReady = Boolean(state.smtpCredential?.passwordSet);
+  const tokenReady = state.apiTokens.length > 0;
+  const domainScore = totalDomains ? (verifiedDomains / totalDomains) * 42 : 0;
+  const score = Math.min(100, Math.round(domainScore + (dnsReady ? 18 : 0) + (smtpReady ? 18 : 0) + (tokenReady ? 8 : 0) + ((summary.successRate || 0) * 0.14)));
+  const risks = dashboardRisks(summary, { totalDomains, verifiedDomains, dnsReady, smtpReady, tokenReady });
+  els.dashboardPanel.innerHTML = `
+    <section class="console-hero">
+      <div class="console-copy">
+        <span class="eyebrow">MailHub Command Center</span>
+        <h2>多租户发信运营后台</h2>
+        <p>${escapeHtml(state.config?.mailHostname || 'mail host')} · ${escapeHtml(state.config?.sendingIp || '未设置发信 IP')} · ${escapeHtml(state.me?.email || '')}</p>
+        <div class="hero-actions">
+          <button class="btn btn-primary" data-view="domains" type="button"><i class="ti ti-world-plus"></i> 添加域名</button>
+          <button class="btn btn-outline-secondary" data-view="analytics" type="button"><i class="ti ti-chart-histogram"></i> 查看分析</button>
+        </div>
+      </div>
+      <div class="health-orbit" style="--score: ${score}%">
+        <span>健康评分</span>
+        <strong>${score}</strong>
+        <small>${score >= 90 ? '运行良好' : score >= 70 ? '仍有优化项' : '需要配置'}</small>
+      </div>
+    </section>
+
+    <section class="ops-grid">
+      ${kpiTile('今日发送', summary.today || 0, `${summary.last7Days || 0} / 近 7 日`, 'ti-send', 'cyan')}
+      ${kpiTile('成功率', `${summary.successRate || 0}%`, `${summary.queued || 0} 成功 / ${summary.failed || 0} 失败`, 'ti-shield-check', 'green')}
+      ${kpiTile('域名健康', `${verifiedDomains}/${totalDomains}`, '已验证发信域名', 'ti-world-check', 'blue')}
+      ${kpiTile('收件人触达', summary.recipients || 0, '统计窗口内收件人数', 'ti-users', 'amber')}
+    </section>
+
+    <section class="dashboard-layout">
+      <div class="data-panel trend-panel">
+        <div class="panel-heading">
+          <div>
+            <span class="eyebrow">Traffic</span>
+            <h3>近 14 日发信趋势</h3>
+          </div>
+          <span class="badge bg-cyan-lt">${summary.total || 0} events</span>
+        </div>
+        ${renderDailyBars((analytics.byDay || []).slice(-14))}
+      </div>
+
+      <div class="data-panel">
+        <div class="panel-heading">
+          <div>
+            <span class="eyebrow">Readiness</span>
+            <h3>上线检查</h3>
+          </div>
+        </div>
+        <div class="readiness-list">
+          ${readinessItem('DNS API', dnsReady, dnsReady ? `${state.dnsCredentials.length} 组可用` : '需要添加 DNS API')}
+          ${readinessItem('SMTP 凭据', smtpReady, smtpReady ? state.smtpCredential.username : '需要设置平台凭据')}
+          ${readinessItem('发信域名', Boolean(totalDomains), totalDomains ? `${verifiedDomains} 个已验证` : '需要添加域名')}
+          ${readinessItem('API Token', tokenReady, tokenReady ? `${state.apiTokens.length} 个 Token` : '建议创建发送 Token')}
+        </div>
+      </div>
+
+      <div class="data-panel">
+        <div class="panel-heading">
+          <div>
+            <span class="eyebrow">Focus</span>
+            <h3>待处理事项</h3>
+          </div>
+        </div>
+        ${renderRiskList(risks)}
+      </div>
+    </section>
+  `;
+}
+
+function renderAnalyticsPanel() {
+  if (!els.analyticsPanel) return;
+  const analytics = state.analytics || {};
+  const summary = analytics.summary || {};
+  const statusTotal = Math.max(1, summary.total || 0);
+  const queuedRatio = Math.round(((summary.queued || 0) / statusTotal) * 100);
+  els.analyticsPanel.innerHTML = `
+    <section class="analysis-head">
+      <div>
+        <span class="eyebrow">Analytics Window</span>
+        <h2>${analytics.windowDays || 30} 天发信分析</h2>
+        <p>${escapeHtml(state.me?.username || '')} 的独立数据视图,按用户隔离统计。</p>
+      </div>
+      <div class="analysis-summary">
+        ${miniMetric('总发送', summary.total || 0)}
+        ${miniMetric('今日', summary.today || 0)}
+        ${miniMetric('成功率', `${summary.successRate || 0}%`)}
+      </div>
+    </section>
+
+    <section class="analysis-grid">
+      <div class="data-panel analysis-wide">
+        <div class="panel-heading">
+          <div><span class="eyebrow">Daily Volume</span><h3>每日发送量</h3></div>
+          <span class="badge bg-blue-lt">${summary.recipients || 0} recipients</span>
+        </div>
+        ${renderDailyBars(analytics.byDay || [])}
+      </div>
+
+      <div class="data-panel">
+        <div class="panel-heading">
+          <div><span class="eyebrow">Status</span><h3>状态分布</h3></div>
+        </div>
+        <div class="status-donut" style="--queued: ${queuedRatio}%">
+          <strong>${queuedRatio}%</strong>
+          <span>queued</span>
+        </div>
+        <div class="status-legend">
+          ${(analytics.byStatus || []).map((item) => `<div><span>${escapeHtml(statusLabel(item.status))}</span><strong>${escapeHtml(item.total)}</strong></div>`).join('') || '<p class="text-secondary">暂无发送数据</p>'}
+        </div>
+      </div>
+
+      <div class="data-panel">
+        <div class="panel-heading">
+          <div><span class="eyebrow">Domains</span><h3>域名排行</h3></div>
+        </div>
+        ${renderDomainLeaderboard(analytics.byDomain || [])}
+      </div>
+
+      <div class="data-panel">
+        <div class="panel-heading">
+          <div><span class="eyebrow">Hour Map</span><h3>24 小时热力</h3></div>
+        </div>
+        ${renderHourlyHeatmap(analytics.hourly || [])}
+      </div>
+
+      <div class="data-panel analysis-wide">
+        <div class="panel-heading">
+          <div><span class="eyebrow">Failures</span><h3>最近失败</h3></div>
+        </div>
+        ${renderFailureList(analytics.recentFailures || [])}
+      </div>
+    </section>
+  `;
+}
+
+function dashboardRisks(summary, flags) {
+  const risks = [];
+  if (!flags.smtpReady) risks.push({ tone: 'failed', title: 'SMTP 凭据未配置', detail: '客户端无法完成 SMTP Submission 认证。' });
+  if (!flags.dnsReady) risks.push({ tone: 'warn', title: 'DNS API 未配置', detail: '域名需要手动复制 DNS 记录。' });
+  if (flags.totalDomains && flags.verifiedDomains < flags.totalDomains) risks.push({ tone: 'warn', title: '存在未验证域名', detail: `${flags.totalDomains - flags.verifiedDomains} 个域名仍需检查 DNS。` });
+  if ((summary.failed || 0) > 0) risks.push({ tone: 'failed', title: '近期有发送失败', detail: `${summary.failed} 条失败记录,建议查看失败原因。` });
+  if (!flags.tokenReady) risks.push({ tone: 'idle', title: '未创建 API Token', detail: '生产系统建议使用独立 Token 接入。' });
+  return risks;
+}
+
+function renderRiskList(risks) {
+  if (!risks.length) return '<div class="empty-soft">暂无待处理事项</div>';
+  return `<div class="risk-list">${risks.map((risk) => `
+    <div class="risk-row ${risk.tone}">
+      ${badge({ className: risk.tone, label: risk.tone === 'failed' ? '重点' : risk.tone === 'warn' ? '注意' : '建议' })}
+      <div><strong>${escapeHtml(risk.title)}</strong><span>${escapeHtml(risk.detail)}</span></div>
+    </div>
+  `).join('')}</div>`;
+}
+
+function kpiTile(label, value, hint, icon, tone) {
+  return `
+    <article class="kpi-tile ${tone}">
+      <i class="ti ${icon}" aria-hidden="true"></i>
+      <span>${escapeHtml(label)}</span>
+      <strong>${escapeHtml(value)}</strong>
+      <small>${escapeHtml(hint || '')}</small>
+    </article>
+  `;
+}
+
+function miniMetric(label, value) {
+  return `<div><span>${escapeHtml(label)}</span><strong>${escapeHtml(value)}</strong></div>`;
+}
+
+function readinessItem(label, ready, detail) {
+  return `
+    <div class="readiness-item ${ready ? 'ok' : 'warn'}">
+      <i class="ti ${ready ? 'ti-circle-check' : 'ti-alert-triangle'}" aria-hidden="true"></i>
+      <div><strong>${escapeHtml(label)}</strong><span>${escapeHtml(detail)}</span></div>
+    </div>
+  `;
+}
+
+function renderDailyBars(days) {
+  if (!days.length || !days.some((day) => day.total)) return '<div class="empty-soft">暂无发送数据</div>';
+  const max = Math.max(...days.map((day) => day.total), 1);
+  return `
+    <div class="bar-chart">
+      ${days.map((day) => {
+        const totalHeight = Math.max(4, Math.round((day.total / max) * 100));
+        const queuedHeight = day.total ? Math.round((day.queued / day.total) * totalHeight) : 0;
+        const failedHeight = Math.max(0, totalHeight - queuedHeight);
+        return `
+          <div class="bar-column" title="${escapeAttr(day.day)} · ${escapeAttr(day.total)}">
+            <div class="bar-track">
+              <span class="bar-failed" style="height:${failedHeight}%"></span>
+              <span class="bar-queued" style="height:${queuedHeight}%"></span>
+            </div>
+            <small>${escapeHtml(day.day.slice(5))}</small>
+          </div>
+        `;
+      }).join('')}
+    </div>
+  `;
+}
+
+function renderDomainLeaderboard(rows) {
+  if (!rows.length) return '<div class="empty-soft">暂无域名发送数据</div>';
+  const max = Math.max(...rows.map((row) => row.total), 1);
+  return `<div class="leaderboard">${rows.map((row) => `
+    <div class="leader-row">
+      <div><strong>${escapeHtml(row.domain)}</strong><span>${escapeHtml(row.queued)} 成功 / ${escapeHtml(row.failed)} 失败</span></div>
+      <div class="leader-meter"><span style="width:${Math.round((row.total / max) * 100)}%"></span></div>
+      <b>${escapeHtml(row.total)}</b>
+    </div>
+  `).join('')}</div>`;
+}
+
+function renderHourlyHeatmap(rows) {
+  const max = Math.max(...rows.map((row) => row.total), 1);
+  return `<div class="hour-grid">${rows.map((row) => {
+    const level = row.total ? Math.max(1, Math.ceil((row.total / max) * 4)) : 0;
+    return `<span class="hour-cell level-${level}" title="${row.hour}:00 · ${row.total}">${String(row.hour).padStart(2, '0')}</span>`;
+  }).join('')}</div>`;
+}
+
+function renderFailureList(rows) {
+  if (!rows.length) return '<div class="empty-soft">暂无失败记录</div>';
+  return `<div class="failure-list">${rows.map((row) => `
+    <div class="failure-row">
+      <div><strong>${escapeHtml(row.subject || '(no subject)')}</strong><span>${escapeHtml(row.sender)} · ${escapeHtml(formatDate(row.createdAt))}</span></div>
+      <code>${escapeHtml(row.detail || '未返回错误详情')}</code>
+    </div>
+  `).join('')}</div>`;
+}
+
 function renderDomainReadiness() {
 function renderDomainReadiness() {
   const verified = state.domains.filter((domain) => domain.status?.verified).length;
   const verified = state.domains.filter((domain) => domain.status?.verified).length;
   const latestEvent = state.events[0];
   const latestEvent = state.events[0];
@@ -334,6 +596,7 @@ function renderDnsCredentials() {
             ${badge({ className: credential.credentialSet ? 'ok' : 'warn', label: credential.credentialSet ? '已保存' : '缺密钥' })}
             ${badge({ className: credential.credentialSet ? 'ok' : 'warn', label: credential.credentialSet ? '已保存' : '缺密钥' })}
           </div>
           </div>
           <span class="text-secondary">${providerLabel(credential.provider)} · ${escapeHtml(credential.zoneName || '未设置 Zone')} · TTL ${escapeHtml(credential.defaultTtl)}</span>
           <span class="text-secondary">${providerLabel(credential.provider)} · ${escapeHtml(credential.zoneName || '未设置 Zone')} · TTL ${escapeHtml(credential.defaultTtl)}</span>
+          ${renderDnsTestResult(credential.id)}
         </div>
         </div>
         <div class="d-flex flex-wrap gap-2 justify-content-end">
         <div class="d-flex flex-wrap gap-2 justify-content-end">
           <button class="btn btn-sm btn-outline-secondary" data-action="edit-dns" data-id="${credential.id}" type="button">编辑</button>
           <button class="btn btn-sm btn-outline-secondary" data-action="edit-dns" data-id="${credential.id}" type="button">编辑</button>
@@ -345,6 +608,17 @@ function renderDnsCredentials() {
   `).join('');
   `).join('');
 }
 }
 
 
+function renderDnsTestResult(id) {
+  const result = state.dnsTestResults[id];
+  if (!result) return '';
+  return `
+    <div class="dns-test-result ${result.ok ? 'ok' : 'failed'}">
+      <i class="ti ${result.ok ? 'ti-plug-connected' : 'ti-plug-connected-x'}" aria-hidden="true"></i>
+      <span>${escapeHtml(result.message)}</span>
+    </div>
+  `;
+}
+
 function renderApiTokens() {
 function renderApiTokens() {
   els.apiTokenCount.textContent = String(state.apiTokens.length);
   els.apiTokenCount.textContent = String(state.apiTokens.length);
   if (!state.apiTokens.length) {
   if (!state.apiTokens.length) {
@@ -960,8 +1234,12 @@ async function sendTest(form) {
   const data = Object.fromEntries(new FormData(form).entries());
   const data = Object.fromEntries(new FormData(form).entries());
   await mutate(async () => {
   await mutate(async () => {
     await api(`/api/domains/${id}/test-send`, { method: 'POST', body: JSON.stringify(data) });
     await api(`/api/domains/${id}/test-send`, { method: 'POST', body: JSON.stringify(data) });
-    const events = await api('/api/events');
+    const [events, analytics] = await Promise.all([
+      api('/api/events'),
+      api('/api/analytics?days=30')
+    ]);
     state.events = events.events || [];
     state.events = events.events || [];
+    state.analytics = analytics.analytics || state.analytics;
     renderDefaults();
     renderDefaults();
     render();
     render();
     toast('已提交到发信队列');
     toast('已提交到发信队列');
@@ -1007,7 +1285,12 @@ async function deleteSelected() {
 async function testDnsCredential(id) {
 async function testDnsCredential(id) {
   await mutate(async () => {
   await mutate(async () => {
     const result = await api(`/api/dns-credentials/${id}/test`, { method: 'POST' });
     const result = await api(`/api/dns-credentials/${id}/test`, { method: 'POST' });
-    toast(result.ok ? 'DNS API 连接成功' : result.error || 'DNS API 连接失败');
+    state.dnsTestResults[id] = {
+      ok: result.ok,
+      message: result.ok ? `连接成功:${result.detail || providerLabel(result.provider)}` : (result.error || 'DNS API 连接失败')
+    };
+    renderDnsCredentials();
+    toast(result.ok ? 'DNS API 连接成功' : result.error || 'DNS API 连接失败', result.ok ? 'info' : 'error');
   });
   });
 }
 }
 
 
@@ -1093,6 +1376,13 @@ function replaceDomain(domain) {
   state.domains = state.domains.map((item) => item.id === domain.id ? domain : item);
   state.domains = state.domains.map((item) => item.id === domain.id ? domain : item);
 }
 }
 
 
+function statusLabel(status) {
+  return {
+    queued: '成功入队',
+    failed: '发送失败'
+  }[status] || status || 'unknown';
+}
+
 function statusMeta(target) {
 function statusMeta(target) {
   if (!target || (!target.checkedAt && !target.status)) return { className: 'idle', label: '未检查' };
   if (!target || (!target.checkedAt && !target.status)) return { className: 'idle', label: '未检查' };
   if (target.verified || target.status === 'ok') return { className: 'ok', label: '通过' };
   if (target.verified || target.status === 'ok') return { className: 'ok', label: '通过' };

+ 30 - 3
public/index.html

@@ -5,6 +5,7 @@
     <meta name="viewport" content="width=device-width, initial-scale=1">
     <meta name="viewport" content="width=device-width, initial-scale=1">
     <title>MailHub</title>
     <title>MailHub</title>
     <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@tabler/core@1.0.0/dist/css/tabler.min.css">
     <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@tabler/core@1.0.0/dist/css/tabler.min.css">
+    <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@tabler/icons-webfont@3.31.0/dist/tabler-icons.min.css">
     <link rel="stylesheet" href="/styles.css">
     <link rel="stylesheet" href="/styles.css">
   </head>
   </head>
   <body>
   <body>
@@ -21,31 +22,49 @@
           <div class="collapse navbar-collapse show">
           <div class="collapse navbar-collapse show">
             <ul class="navbar-nav pt-lg-3 primary-nav" aria-label="主导航">
             <ul class="navbar-nav pt-lg-3 primary-nav" aria-label="主导航">
               <li class="nav-item">
               <li class="nav-item">
-                <button class="nav-link active" data-view="domains" type="button">
+                <button class="nav-link active" data-view="dashboard" type="button">
+                  <i class="ti ti-layout-dashboard nav-icon" aria-hidden="true"></i>
+                  <span class="nav-link-title">仪表盘</span>
+                </button>
+              </li>
+              <li class="nav-item">
+                <button class="nav-link" data-view="analytics" type="button">
+                  <i class="ti ti-chart-arcs nav-icon" aria-hidden="true"></i>
+                  <span class="nav-link-title">统计分析</span>
+                  <span class="badge bg-cyan-lt ms-auto" id="analyticsEventCount">0</span>
+                </button>
+              </li>
+              <li class="nav-item">
+                <button class="nav-link" data-view="domains" type="button">
+                  <i class="ti ti-world-www nav-icon" aria-hidden="true"></i>
                   <span class="nav-link-title">发信域名</span>
                   <span class="nav-link-title">发信域名</span>
                   <span class="badge bg-blue-lt ms-auto" id="domainCount">0</span>
                   <span class="badge bg-blue-lt ms-auto" id="domainCount">0</span>
                 </button>
                 </button>
               </li>
               </li>
               <li class="nav-item">
               <li class="nav-item">
                 <button class="nav-link" data-view="dns" type="button">
                 <button class="nav-link" data-view="dns" type="button">
+                  <i class="ti ti-cloud-cog nav-icon" aria-hidden="true"></i>
                   <span class="nav-link-title">DNS API</span>
                   <span class="nav-link-title">DNS API</span>
                   <span class="badge bg-azure-lt ms-auto" id="dnsCredentialCount">0</span>
                   <span class="badge bg-azure-lt ms-auto" id="dnsCredentialCount">0</span>
                 </button>
                 </button>
               </li>
               </li>
               <li class="nav-item">
               <li class="nav-item">
                 <button class="nav-link" data-view="smtp" type="button">
                 <button class="nav-link" data-view="smtp" type="button">
+                  <i class="ti ti-mail-cog nav-icon" aria-hidden="true"></i>
                   <span class="nav-link-title">SMTP 凭据</span>
                   <span class="nav-link-title">SMTP 凭据</span>
                   <span class="badge bg-secondary-lt ms-auto" id="smtpCredentialState">未配置</span>
                   <span class="badge bg-secondary-lt ms-auto" id="smtpCredentialState">未配置</span>
                 </button>
                 </button>
               </li>
               </li>
               <li class="nav-item">
               <li class="nav-item">
                 <button class="nav-link" data-view="tokens" type="button">
                 <button class="nav-link" data-view="tokens" type="button">
+                  <i class="ti ti-key nav-icon" aria-hidden="true"></i>
                   <span class="nav-link-title">API Token</span>
                   <span class="nav-link-title">API Token</span>
                   <span class="badge bg-secondary-lt ms-auto" id="apiTokenCount">0</span>
                   <span class="badge bg-secondary-lt ms-auto" id="apiTokenCount">0</span>
                 </button>
                 </button>
               </li>
               </li>
               <li class="nav-item hidden" id="adminNavButton">
               <li class="nav-item hidden" id="adminNavButton">
                 <button class="nav-link" data-view="admin" type="button">
                 <button class="nav-link" data-view="admin" type="button">
+                  <i class="ti ti-settings nav-icon" aria-hidden="true"></i>
                   <span class="nav-link-title">系统设置</span>
                   <span class="nav-link-title">系统设置</span>
                   <span class="badge bg-green-lt ms-auto">Admin</span>
                   <span class="badge bg-green-lt ms-auto">Admin</span>
                 </button>
                 </button>
@@ -90,7 +109,15 @@
           <div class="container-xl">
           <div class="container-xl">
             <div class="alert alert-danger hidden" id="securityNotice"></div>
             <div class="alert alert-danger hidden" id="securityNotice"></div>
 
 
-            <section class="view active" id="domainsView" data-view-panel="domains">
+            <section class="view active" id="dashboardView" data-view-panel="dashboard">
+              <div id="dashboardPanel"></div>
+            </section>
+
+            <section class="view" id="analyticsView" data-view-panel="analytics">
+              <div id="analyticsPanel"></div>
+            </section>
+
+            <section class="view" id="domainsView" data-view-panel="domains">
               <div class="row row-cards mb-3">
               <div class="row row-cards mb-3">
                 <div class="col-lg-5">
                 <div class="col-lg-5">
                   <form class="card compact-form" id="addDomainForm">
                   <form class="card compact-form" id="addDomainForm">
@@ -156,7 +183,7 @@
                 </div>
                 </div>
               </div>
               </div>
 
 
-              <section class="card main-panel" id="detailPanel">
+              <section class="detail-shell main-panel" id="detailPanel">
                 <div class="empty-state">
                 <div class="empty-state">
                   <h2>选择或添加一个域名</h2>
                   <h2>选择或添加一个域名</h2>
                   <p>DNS 引导、验证结果、DKIM 记录和测试发送会显示在这里。</p>
                   <p>DNS 引导、验证结果、DKIM 记录和测试发送会显示在这里。</p>

+ 680 - 0
public/styles.css

@@ -464,3 +464,683 @@ body.busy button {
     grid-column: auto;
     grid-column: auto;
   }
   }
 }
 }
+
+:root {
+  --mh-sidebar: #111827;
+  --mh-sidebar-soft: #1f2937;
+  --mh-bg: #f4f7fb;
+  --mh-panel: #ffffff;
+  --mh-panel-soft: #f8fbff;
+  --mh-line: #dbe4ef;
+  --mh-ink: #101827;
+  --mh-muted: #64748b;
+  --mh-blue: #2563eb;
+  --mh-cyan: #0891b2;
+  --mh-green: #16a34a;
+  --mh-yellow: #d97706;
+  --mh-red: #dc2626;
+  --mh-shadow: 0 18px 45px rgba(15, 23, 42, 0.08);
+}
+
+body {
+  background:
+    linear-gradient(rgba(37, 99, 235, 0.035) 1px, transparent 1px),
+    linear-gradient(90deg, rgba(8, 145, 178, 0.035) 1px, transparent 1px),
+    var(--mh-bg);
+  background-size: 28px 28px;
+  color: var(--mh-ink);
+  letter-spacing: 0;
+}
+
+.navbar-vertical {
+  background:
+    linear-gradient(180deg, rgba(8, 145, 178, 0.16), transparent 34%),
+    var(--mh-sidebar);
+  border-right: 1px solid rgba(148, 163, 184, 0.18);
+}
+
+.navbar-brand {
+  min-height: 4.2rem;
+}
+
+.brand-mark {
+  background: linear-gradient(135deg, #22d3ee, #34d399);
+  box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.2), 0 10px 24px rgba(8, 145, 178, 0.28);
+  color: #07111f;
+}
+
+.primary-nav {
+  gap: 0.25rem;
+}
+
+.primary-nav .nav-link {
+  align-items: center;
+  border: 1px solid transparent;
+  border-radius: 8px;
+  color: #cbd5e1;
+  display: flex;
+  gap: 0.65rem;
+  min-height: 42px;
+  padding: 0.58rem 0.72rem;
+}
+
+.primary-nav .nav-link:hover {
+  background: rgba(255, 255, 255, 0.07);
+  border-color: rgba(255, 255, 255, 0.1);
+  color: #ffffff;
+}
+
+.primary-nav .nav-link.active {
+  background: rgba(8, 145, 178, 0.2);
+  border-color: rgba(34, 211, 238, 0.35);
+  box-shadow: inset 3px 0 0 #22d3ee;
+  color: #ffffff;
+}
+
+.nav-icon {
+  color: #67e8f9;
+  flex: 0 0 auto;
+  font-size: 1.1rem;
+}
+
+.sidebar-block {
+  border-top-color: rgba(148, 163, 184, 0.2);
+}
+
+.page-wrapper {
+  background: transparent;
+}
+
+.topbar {
+  backdrop-filter: blur(18px);
+  background: rgba(244, 247, 251, 0.84);
+  border-bottom: 1px solid rgba(203, 213, 225, 0.72);
+  position: sticky;
+  top: 0;
+  z-index: 20;
+}
+
+.topbar .container-xl {
+  gap: 1rem;
+  min-height: 76px;
+}
+
+.page-title {
+  color: var(--mh-ink);
+  font-weight: 800;
+}
+
+.eyebrow,
+#viewEyebrow {
+  color: var(--mh-cyan);
+  font-size: 0.74rem;
+  font-weight: 800;
+  letter-spacing: 0;
+  text-transform: uppercase;
+}
+
+.card,
+.detail-shell,
+.data-panel,
+.kpi-tile {
+  border: 1px solid rgba(203, 213, 225, 0.9);
+  border-radius: 8px;
+  box-shadow: var(--mh-shadow);
+}
+
+.card {
+  background: var(--mh-panel);
+}
+
+.card-header {
+  background: linear-gradient(180deg, #ffffff, #f8fbff);
+  border-bottom-color: var(--mh-line);
+}
+
+.card-title,
+.data-panel h3 {
+  color: var(--mh-ink);
+  font-weight: 800;
+}
+
+.card-subtitle {
+  color: var(--mh-muted);
+}
+
+.btn {
+  align-items: center;
+  border-radius: 7px;
+  display: inline-flex;
+  gap: 0.4rem;
+  justify-content: center;
+}
+
+.btn-primary {
+  background: linear-gradient(135deg, #2563eb, #0891b2);
+  border-color: #1477c7;
+}
+
+.btn-outline-secondary {
+  background: #ffffff;
+}
+
+.form-control,
+.form-select {
+  border-color: #cbd5e1;
+  border-radius: 7px;
+}
+
+.form-control:focus,
+.form-select:focus {
+  border-color: var(--mh-cyan);
+  box-shadow: 0 0 0 0.22rem rgba(8, 145, 178, 0.16);
+}
+
+.detail-shell {
+  background: rgba(255, 255, 255, 0.92);
+  overflow: hidden;
+}
+
+.detail-shell > .card-header {
+  background:
+    linear-gradient(135deg, rgba(37, 99, 235, 0.1), rgba(8, 145, 178, 0.08)),
+    #ffffff;
+}
+
+.console-hero {
+  align-items: stretch;
+  background:
+    linear-gradient(135deg, rgba(17, 24, 39, 0.98), rgba(15, 23, 42, 0.94)),
+    linear-gradient(90deg, rgba(34, 211, 238, 0.14) 1px, transparent 1px);
+  border: 1px solid rgba(34, 211, 238, 0.25);
+  border-radius: 8px;
+  box-shadow: 0 22px 60px rgba(15, 23, 42, 0.24);
+  color: #ffffff;
+  display: grid;
+  gap: 1.25rem;
+  grid-template-columns: minmax(0, 1fr) minmax(190px, 240px);
+  margin-bottom: 1rem;
+  overflow: hidden;
+  padding: 1.35rem;
+}
+
+.console-copy {
+  display: grid;
+  gap: 0.65rem;
+  min-width: 0;
+}
+
+.console-copy h2,
+.analysis-head h2 {
+  font-size: clamp(1.65rem, 2.8vw, 2.45rem);
+  font-weight: 850;
+  letter-spacing: 0;
+  margin: 0;
+  overflow-wrap: anywhere;
+}
+
+.console-copy p,
+.analysis-head p {
+  color: #94a3b8;
+  margin: 0;
+  overflow-wrap: anywhere;
+}
+
+.hero-actions {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 0.7rem;
+  margin-top: 0.25rem;
+}
+
+.health-orbit {
+  align-content: center;
+  background:
+    radial-gradient(circle at center, #111827 56%, transparent 57%),
+    conic-gradient(#22d3ee var(--score), rgba(148, 163, 184, 0.22) 0);
+  border: 1px solid rgba(148, 163, 184, 0.2);
+  border-radius: 8px;
+  display: grid;
+  justify-items: center;
+  min-height: 190px;
+  padding: 1rem;
+  text-align: center;
+}
+
+.health-orbit span,
+.health-orbit small {
+  color: #94a3b8;
+}
+
+.health-orbit strong {
+  color: #ffffff;
+  font-size: 3.1rem;
+  line-height: 1;
+}
+
+.ops-grid {
+  display: grid;
+  gap: 1rem;
+  grid-template-columns: repeat(4, minmax(0, 1fr));
+  margin-bottom: 1rem;
+}
+
+.kpi-tile {
+  background: #ffffff;
+  display: grid;
+  gap: 0.35rem;
+  min-height: 142px;
+  padding: 1rem;
+  position: relative;
+}
+
+.kpi-tile i {
+  align-items: center;
+  border-radius: 8px;
+  display: inline-flex;
+  font-size: 1.35rem;
+  height: 2.3rem;
+  justify-content: center;
+  width: 2.3rem;
+}
+
+.kpi-tile span,
+.kpi-tile small,
+.panel-heading span,
+.readiness-item span,
+.risk-row span,
+.leader-row span,
+.failure-row span,
+.analysis-summary span {
+  color: var(--mh-muted);
+  overflow-wrap: anywhere;
+}
+
+.kpi-tile strong {
+  color: var(--mh-ink);
+  font-size: 2rem;
+  line-height: 1.05;
+  overflow-wrap: anywhere;
+}
+
+.kpi-tile.cyan i {
+  background: rgba(8, 145, 178, 0.12);
+  color: var(--mh-cyan);
+}
+
+.kpi-tile.green i {
+  background: rgba(22, 163, 74, 0.12);
+  color: var(--mh-green);
+}
+
+.kpi-tile.blue i {
+  background: rgba(37, 99, 235, 0.12);
+  color: var(--mh-blue);
+}
+
+.kpi-tile.amber i {
+  background: rgba(217, 119, 6, 0.12);
+  color: var(--mh-yellow);
+}
+
+.dashboard-layout,
+.analysis-grid {
+  display: grid;
+  gap: 1rem;
+  grid-template-columns: minmax(0, 1.25fr) minmax(280px, 0.75fr);
+}
+
+.analysis-grid {
+  grid-template-columns: repeat(3, minmax(0, 1fr));
+}
+
+.analysis-wide {
+  grid-column: span 2;
+}
+
+.data-panel {
+  background: rgba(255, 255, 255, 0.94);
+  display: grid;
+  gap: 1rem;
+  min-width: 0;
+  padding: 1rem;
+}
+
+.trend-panel {
+  grid-row: span 2;
+}
+
+.panel-heading {
+  align-items: flex-start;
+  display: flex;
+  gap: 1rem;
+  justify-content: space-between;
+}
+
+.panel-heading h3 {
+  font-size: 1rem;
+  margin: 0.1rem 0 0;
+}
+
+.bar-chart {
+  align-items: end;
+  display: grid;
+  gap: 0.45rem;
+  grid-template-columns: repeat(auto-fit, minmax(22px, 1fr));
+  min-height: 220px;
+}
+
+.bar-column {
+  align-items: center;
+  display: grid;
+  gap: 0.45rem;
+  min-width: 0;
+}
+
+.bar-track {
+  align-items: end;
+  background: #e8eef6;
+  border: 1px solid #d7e1ed;
+  border-radius: 7px;
+  display: flex;
+  flex-direction: column;
+  height: 168px;
+  justify-content: flex-end;
+  overflow: hidden;
+  width: 100%;
+}
+
+.bar-queued,
+.bar-failed {
+  display: block;
+  width: 100%;
+}
+
+.bar-queued {
+  background: linear-gradient(180deg, #22d3ee, #16a34a);
+}
+
+.bar-failed {
+  background: #f97373;
+}
+
+.bar-column small {
+  color: var(--mh-muted);
+  font-size: 0.68rem;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+  width: 100%;
+}
+
+.readiness-list,
+.risk-list,
+.leaderboard,
+.failure-list,
+.status-legend {
+  display: grid;
+  gap: 0.7rem;
+}
+
+.readiness-item,
+.risk-row {
+  align-items: flex-start;
+  background: #f8fbff;
+  border: 1px solid #dbe4ef;
+  border-radius: 8px;
+  display: grid;
+  gap: 0.7rem;
+  grid-template-columns: auto minmax(0, 1fr);
+  padding: 0.75rem;
+}
+
+.readiness-item i {
+  color: var(--mh-yellow);
+  font-size: 1.15rem;
+  margin-top: 0.1rem;
+}
+
+.readiness-item.ok i {
+  color: var(--mh-green);
+}
+
+.readiness-item strong,
+.risk-row strong {
+  display: block;
+  overflow-wrap: anywhere;
+}
+
+.risk-row {
+  grid-template-columns: auto minmax(0, 1fr);
+}
+
+.risk-row.failed {
+  border-color: rgba(220, 38, 38, 0.28);
+}
+
+.risk-row.warn {
+  border-color: rgba(217, 119, 6, 0.3);
+}
+
+.empty-soft {
+  align-content: center;
+  background: #f8fbff;
+  border: 1px dashed #cbd5e1;
+  border-radius: 8px;
+  color: var(--mh-muted);
+  display: grid;
+  min-height: 132px;
+  padding: 1rem;
+  text-align: center;
+}
+
+.analysis-head {
+  align-items: stretch;
+  background: linear-gradient(135deg, #ffffff, #edf7fb);
+  border: 1px solid rgba(203, 213, 225, 0.9);
+  border-radius: 8px;
+  box-shadow: var(--mh-shadow);
+  display: flex;
+  gap: 1rem;
+  justify-content: space-between;
+  margin-bottom: 1rem;
+  padding: 1.1rem;
+}
+
+.analysis-summary {
+  display: grid;
+  gap: 0.75rem;
+  grid-template-columns: repeat(3, minmax(92px, 1fr));
+}
+
+.analysis-summary div {
+  background: #ffffff;
+  border: 1px solid #dbe4ef;
+  border-radius: 8px;
+  display: grid;
+  gap: 0.2rem;
+  padding: 0.85rem;
+}
+
+.analysis-summary strong {
+  color: var(--mh-ink);
+  font-size: 1.45rem;
+}
+
+.status-donut {
+  align-content: center;
+  background:
+    radial-gradient(circle at center, #ffffff 56%, transparent 57%),
+    conic-gradient(#16a34a var(--queued), #ef4444 0);
+  border-radius: 8px;
+  display: grid;
+  justify-items: center;
+  min-height: 178px;
+}
+
+.status-donut strong {
+  color: var(--mh-ink);
+  font-size: 2rem;
+}
+
+.status-legend div {
+  align-items: center;
+  border-bottom: 1px solid #edf2f7;
+  display: flex;
+  justify-content: space-between;
+  padding-bottom: 0.45rem;
+}
+
+.leader-row {
+  align-items: center;
+  display: grid;
+  gap: 0.7rem;
+  grid-template-columns: minmax(0, 1fr) minmax(76px, 0.45fr) auto;
+}
+
+.leader-meter {
+  background: #e8eef6;
+  border-radius: 999px;
+  height: 0.5rem;
+  overflow: hidden;
+}
+
+.leader-meter span {
+  background: linear-gradient(90deg, #2563eb, #22d3ee);
+  display: block;
+  height: 100%;
+}
+
+.hour-grid {
+  display: grid;
+  gap: 0.4rem;
+  grid-template-columns: repeat(6, minmax(0, 1fr));
+}
+
+.hour-cell {
+  align-items: center;
+  background: #edf2f7;
+  border-radius: 7px;
+  color: #475569;
+  display: inline-flex;
+  font-size: 0.72rem;
+  justify-content: center;
+  min-height: 34px;
+}
+
+.hour-cell.level-1 {
+  background: #d7f6ff;
+}
+
+.hour-cell.level-2 {
+  background: #9ee7f6;
+  color: #0f172a;
+}
+
+.hour-cell.level-3 {
+  background: #38bdf8;
+  color: #082f49;
+}
+
+.hour-cell.level-4 {
+  background: #0891b2;
+  color: #ffffff;
+}
+
+.failure-row {
+  border: 1px solid #dbe4ef;
+  border-radius: 8px;
+  display: grid;
+  gap: 0.65rem;
+  grid-template-columns: minmax(0, 0.55fr) minmax(0, 1fr);
+  padding: 0.75rem;
+}
+
+.failure-row code {
+  margin: 0;
+}
+
+.dns-test-result {
+  align-items: center;
+  border-radius: 7px;
+  display: inline-flex;
+  font-size: 0.8rem;
+  gap: 0.35rem;
+  margin-top: 0.55rem;
+  max-width: 100%;
+  padding: 0.35rem 0.5rem;
+}
+
+.dns-test-result.ok {
+  background: rgba(22, 163, 74, 0.1);
+  color: #166534;
+}
+
+.dns-test-result.failed {
+  background: rgba(220, 38, 38, 0.1);
+  color: #991b1b;
+}
+
+.guide-hero,
+.metric-card.compact,
+.record-card,
+.empty-mini,
+.api-box pre,
+code,
+.inline-warnings li,
+.warning-list li,
+.mini-row,
+.app-toast,
+.domain-item {
+  border-radius: 8px;
+}
+
+@media (max-width: 1200px) {
+  .ops-grid,
+  .analysis-grid {
+    grid-template-columns: repeat(2, minmax(0, 1fr));
+  }
+
+  .dashboard-layout {
+    grid-template-columns: 1fr;
+  }
+}
+
+@media (max-width: 768px) {
+  .console-hero,
+  .analysis-head,
+  .ops-grid,
+  .analysis-grid,
+  .analysis-summary {
+    grid-template-columns: 1fr;
+  }
+
+  .analysis-head {
+    display: grid;
+  }
+
+  .analysis-wide {
+    grid-column: auto;
+  }
+
+  .console-hero {
+    padding: 1rem;
+  }
+
+  .health-orbit {
+    min-height: 150px;
+  }
+
+  .failure-row,
+  .leader-row {
+    grid-template-columns: 1fr;
+  }
+
+  .topbar {
+    position: static;
+  }
+
+  .topbar .container-xl {
+    align-items: stretch;
+  }
+}

+ 139 - 0
src/db.js

@@ -367,6 +367,118 @@ export function listSendEvents(userId, limit = 30) {
     }));
     }));
 }
 }
 
 
+export function getSendAnalytics(userId, { days = 30 } = {}) {
+  const windowDays = clampAnalyticsDays(days);
+  const since = new Date();
+  since.setUTCHours(0, 0, 0, 0);
+  since.setUTCDate(since.getUTCDate() - (windowDays - 1));
+  const rows = requireDb()
+    .prepare(`
+      SELECT e.*, d.domain
+      FROM send_events e
+      LEFT JOIN domains d ON d.id = e.domain_id
+      WHERE e.user_id = ? AND e.created_at >= ?
+      ORDER BY e.created_at ASC
+      LIMIT 5000
+    `)
+    .all(userId, since.toISOString());
+  const domains = listDomains(userId);
+  const dayBuckets = buildDayBuckets(windowDays);
+  const byStatus = {};
+  const byDomain = new Map();
+  const hourly = Array.from({ length: 24 }, (_, hour) => ({
+    hour,
+    total: 0,
+    queued: 0,
+    failed: 0
+  }));
+  let recipients = 0;
+  let queued = 0;
+  let failed = 0;
+  let today = 0;
+  let last7Days = 0;
+  const todayKey = new Date().toISOString().slice(0, 10);
+  const weekStart = new Date();
+  weekStart.setUTCHours(0, 0, 0, 0);
+  weekStart.setUTCDate(weekStart.getUTCDate() - 6);
+
+  for (const row of rows) {
+    const status = row.status || 'unknown';
+    const recipientList = safeJson(row.recipients, []);
+    const recipientCount = Array.isArray(recipientList) ? recipientList.length : 0;
+    const domainName = row.domain || (String(row.sender || '').split('@')[1] || 'unknown');
+    const createdAt = new Date(row.created_at);
+    const dayKey = row.created_at.slice(0, 10);
+    const hour = Number.isInteger(createdAt.getUTCHours()) ? createdAt.getUTCHours() : 0;
+    const isQueued = status === 'queued';
+
+    recipients += recipientCount;
+    queued += isQueued ? 1 : 0;
+    failed += isQueued ? 0 : 1;
+    today += dayKey === todayKey ? 1 : 0;
+    last7Days += createdAt >= weekStart ? 1 : 0;
+    byStatus[status] = (byStatus[status] || 0) + 1;
+
+    if (dayBuckets.has(dayKey)) {
+      const bucket = dayBuckets.get(dayKey);
+      bucket.total += 1;
+      bucket.queued += isQueued ? 1 : 0;
+      bucket.failed += isQueued ? 0 : 1;
+      bucket.recipients += recipientCount;
+    }
+
+    const domainBucket = byDomain.get(domainName) || {
+      domain: domainName,
+      total: 0,
+      queued: 0,
+      failed: 0,
+      recipients: 0
+    };
+    domainBucket.total += 1;
+    domainBucket.queued += isQueued ? 1 : 0;
+    domainBucket.failed += isQueued ? 0 : 1;
+    domainBucket.recipients += recipientCount;
+    byDomain.set(domainName, domainBucket);
+
+    hourly[hour].total += 1;
+    hourly[hour].queued += isQueued ? 1 : 0;
+    hourly[hour].failed += isQueued ? 0 : 1;
+  }
+
+  const recentFailures = [...rows]
+    .reverse()
+    .filter((row) => row.status !== 'queued')
+    .slice(0, 8)
+    .map((row) => ({
+      id: row.id,
+      domain: row.domain || '',
+      sender: row.sender,
+      subject: row.subject,
+      detail: row.detail,
+      createdAt: row.created_at
+    }));
+
+  return {
+    windowDays,
+    summary: {
+      total: rows.length,
+      queued,
+      failed,
+      recipients,
+      today,
+      last7Days,
+      successRate: rows.length ? Math.round((queued / rows.length) * 1000) / 10 : 0,
+      domains: domains.length,
+      verifiedDomains: domains.filter((domain) => domain.status?.verified).length
+    },
+    byDay: [...dayBuckets.values()],
+    byDomain: [...byDomain.values()].sort((a, b) => b.total - a.total).slice(0, 10),
+    byStatus: Object.entries(byStatus).map(([status, total]) => ({ status, total })),
+    hourly,
+    recentFailures
+  };
+}
+
 export function getSmtpCredential(userId, { includeHash = false, includePassword = false, includeSecret = false } = {}) {
 export function getSmtpCredential(userId, { includeHash = false, includePassword = false, includeSecret = false } = {}) {
   const row = requireDb()
   const row = requireDb()
     .prepare('SELECT * FROM smtp_credentials WHERE user_id = ?')
     .prepare('SELECT * FROM smtp_credentials WHERE user_id = ?')
@@ -726,6 +838,33 @@ function clampTtl(value) {
   return ttl;
   return ttl;
 }
 }
 
 
+function clampAnalyticsDays(value) {
+  const days = Number(value || 30);
+  if (!Number.isInteger(days) || days < 7) return 30;
+  if (days > 90) return 90;
+  return days;
+}
+
+function buildDayBuckets(days) {
+  const buckets = new Map();
+  const cursor = new Date();
+  cursor.setUTCHours(0, 0, 0, 0);
+  cursor.setUTCDate(cursor.getUTCDate() - (days - 1));
+  for (let index = 0; index < days; index += 1) {
+    const date = new Date(cursor);
+    date.setUTCDate(cursor.getUTCDate() + index);
+    const day = date.toISOString().slice(0, 10);
+    buckets.set(day, {
+      day,
+      total: 0,
+      queued: 0,
+      failed: 0,
+      recipients: 0
+    });
+  }
+  return buckets;
+}
+
 function safeJson(value, fallback) {
 function safeJson(value, fallback) {
   try {
   try {
     return JSON.parse(value);
     return JSON.parse(value);

+ 4 - 0
src/server.js

@@ -17,6 +17,7 @@ import {
   getDnsCredential,
   getDnsCredential,
   getDomain,
   getDomain,
   getDomainByName,
   getDomainByName,
+  getSendAnalytics,
   getSettings,
   getSettings,
   getSmtpCredential,
   getSmtpCredential,
   getUser,
   getUser,
@@ -203,6 +204,9 @@ async function handleApi(req, res, url, user) {
   if (method === 'GET' && pathname === '/api/events') {
   if (method === 'GET' && pathname === '/api/events') {
     return sendJson(res, 200, { events: listSendEvents(user.id) });
     return sendJson(res, 200, { events: listSendEvents(user.id) });
   }
   }
+  if (method === 'GET' && pathname === '/api/analytics') {
+    return sendJson(res, 200, { analytics: getSendAnalytics(user.id, { days: Number(url.searchParams.get('days') || 30) }) });
+  }
   if (method === 'GET' && pathname === '/api/smtp-credential') {
   if (method === 'GET' && pathname === '/api/smtp-credential') {
     return sendJson(res, 200, { credential: getSmtpCredential(user.id, { includePassword: true }) });
     return sendJson(res, 200, { credential: getSmtpCredential(user.id, { includePassword: true }) });
   }
   }

+ 46 - 0
test/db.test.js

@@ -10,6 +10,7 @@ import {
   createApiToken,
   createApiToken,
   createDomain,
   createDomain,
   createUser,
   createUser,
+  getSendAnalytics,
   getSmtpCredential,
   getSmtpCredential,
   initDatabase,
   initDatabase,
   listDomains,
   listDomains,
@@ -126,6 +127,51 @@ test('isolates domains, smtp credentials, and api tokens by user', () => {
   assert.equal(verifyApiToken(token.token).id, alice.id);
   assert.equal(verifyApiToken(token.token).id, alice.id);
 });
 });
 
 
+test('summarizes send analytics by user', () => {
+  initDatabase(tempDataDir(), 'test-secret');
+  const alice = createUser({ username: 'alice', email: 'alice@example.com', password: 'password123' });
+  const bob = createUser({ username: 'bob', email: 'bob@example.com', password: 'password123' });
+  const aliceDomain = createDomain(alice.id, domainFixture('alice.example'));
+  const bobDomain = createDomain(bob.id, domainFixture('bob.example'));
+
+  logSendEvent({
+    userId: alice.id,
+    domainId: aliceDomain.id,
+    sender: 'noreply@alice.example',
+    recipients: ['a@example.com', 'b@example.com'],
+    subject: 'Queued',
+    status: 'queued'
+  });
+  logSendEvent({
+    userId: alice.id,
+    domainId: aliceDomain.id,
+    sender: 'noreply@alice.example',
+    recipients: ['c@example.com'],
+    subject: 'Failed',
+    status: 'failed',
+    detail: 'relay rejected'
+  });
+  logSendEvent({
+    userId: bob.id,
+    domainId: bobDomain.id,
+    sender: 'noreply@bob.example',
+    recipients: ['x@example.com'],
+    subject: 'Hidden',
+    status: 'queued'
+  });
+
+  const analytics = getSendAnalytics(alice.id, { days: 7 });
+  assert.equal(analytics.summary.total, 2);
+  assert.equal(analytics.summary.queued, 1);
+  assert.equal(analytics.summary.failed, 1);
+  assert.equal(analytics.summary.recipients, 3);
+  assert.equal(analytics.summary.successRate, 50);
+  assert.equal(analytics.byDomain.length, 1);
+  assert.equal(analytics.byDomain[0].domain, 'alice.example');
+  assert.equal(analytics.recentFailures.length, 1);
+  assert.equal(analytics.recentFailures[0].detail, 'relay rejected');
+});
+
 function domainFixture(domain) {
 function domainFixture(domain) {
   return {
   return {
     domain,
     domain,