Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
161 lines
22 KiB
JavaScript
161 lines
22 KiB
JavaScript
'use strict';
|
|
/* ============================================================================
|
|
* 사이트 정보 페이지 (v1.7.2, 요구 2) — 운영 화면의 오른쪽 캔버스 전체를 쓰는 그래픽 대시보드
|
|
*
|
|
* 1.4.14~1.6.40 의 "사이트 정보" 카드(가운데 작업 칸의 좁은 카드)와 "사이트 현황" 보기를 합쳐 오른쪽 캔버스(#mvStatus)에 크게 그린다.
|
|
* 구성(위→아래):
|
|
* ① 사이트 상태 타일: 상태 · 배포 · 운영 주소 · 도메인 · 버전 · 사용 캐시 · 문의 · 메일 · 켜진 모듈
|
|
* ② 접속 통계: 오늘/어제/최근 7일/이번 달/누적 타일 + 최근 30일 방문자·페이지뷰 막대 그래프(SVG)
|
|
* ③ 네트워크: 최근 30일 송신·수신 선 그래프(SVG, 따로 두 선) + 최근 7일 합계 막대(송신·수신·전체) + 월간 막대(직전 2개월 + 이번 달 누적, 달력 기준 1일~말일)
|
|
* ④ 저장 공간: 웹 데이터(사이트 파일·첨부) · DB 데이터(사이트 정보·문의·통계·버전 저장소) · 메일 데이터(메일 미설정이면 숨김). 허용량은 아직 없어 표시하지 않는다
|
|
* ⑤ 많이 본 페이지 · 유입 경로 (30일)
|
|
* 그래프는 외부 라이브러리 없이 SVG 를 직접 만든다 — 값마다 <title> 로 정확한 숫자를 보인다.
|
|
* 자료: app.js 의 statsData(GET /api/sites/<ID>/stats — stats.summary: series(30일), series7, months(3개월), disk{web,mail}, db), current(사이트), manage-ui 가 넘기는 문의·모듈.
|
|
* 전역 의존: $, T, TF, fmt, fmtBytes, krwFmt, uiLocale
|
|
* ========================================================================== */
|
|
(() => {
|
|
const NS = 'http://www.w3.org/2000/svg';
|
|
const el = (tag, cls, text) => { const e = document.createElement(tag); if (cls) e.className = cls; if (text !== undefined && text !== null) e.textContent = text; return e; };
|
|
const svg = (tag, attrs) => { const e = document.createElementNS(NS, tag); for (const [k, v] of Object.entries(attrs || {})) e.setAttribute(k, v); return e; };
|
|
const dtm = (s) => (s ? new Date(s).toLocaleString(uiLocale(), { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }) : '');
|
|
const COLOR = { out: '#2f6fed', in: '#15803d', total: '#64748b', pv: '#c7d2fe', v: '#2f6fed', web: '#2f6fed', db: '#7c3aed', mail: '#0e9f6e' };
|
|
const C = { bar: '#c7d2fe' };
|
|
|
|
/** 바이트 단위 눈금용: 최대값에 맞는 단위와 나누는 수 */
|
|
function unitFor(max) { if (max >= 1024 ** 3) return ['GB', 1024 ** 3]; if (max >= 1024 ** 2) return ['MB', 1024 ** 2]; if (max >= 1024) return ['KB', 1024]; return ['B', 1]; }
|
|
/** 눈금 값(0 포함 4~5개)을 보기 좋은 수로 */
|
|
function ticks(max, n = 4) { if (!(max > 0)) return [0]; const raw = max / n; const pow = Math.pow(10, Math.floor(Math.log10(raw))); const step = [1, 2, 2.5, 5, 10].map((m) => m * pow).find((s) => s >= raw) || raw; const out = []; for (let v = 0; v <= max + step * 0.001; v += step) out.push(v); if (out[out.length - 1] < max) out.push(out[out.length - 1] + step); return out; }
|
|
|
|
/**
|
|
* 선 그래프 (여러 선). series: [{ key, label, color, values: number[] }], labels: x 축 이름(일자). 바이트면 bytes=true 로 단위 눈금.
|
|
*/
|
|
function lineChart({ series, labels, bytes, height = 180, empty, width = 720 }) {
|
|
const W = width, H = height, L = 56, R = 12, Tp = 10, B = 26; const n = labels.length; const max = Math.max(0, ...series.flatMap((s) => s.values)); const tk = ticks(max); const top = tk[tk.length - 1] || 1;
|
|
const root = svg('svg', { viewBox: `0 0 ${W} ${H}`, class: 'si-chart', role: 'img' });
|
|
const [u, div] = bytes ? unitFor(top) : ['', 1];
|
|
const x = (i) => L + (n <= 1 ? 0 : (i * (W - L - R)) / (n - 1)); const y = (v) => Tp + (H - Tp - B) * (1 - v / top);
|
|
for (const t of tk) { root.appendChild(svg('line', { x1: L, x2: W - R, y1: y(t), y2: y(t), stroke: '#e5e7eb', 'stroke-width': 1 })); const tx = svg('text', { x: L - 6, y: y(t) + 4, 'text-anchor': 'end', class: 'si-tick' }); tx.textContent = bytes ? `${(t / div).toLocaleString(uiLocale(), { maximumFractionDigits: 1 })}${u}` : fmt(t); root.appendChild(tx); }
|
|
const every = n > 16 ? Math.ceil(n / 8) : 1;
|
|
// 마지막 날짜는 앞 라벨과 겹치지 않을 때만 보인다
|
|
let lastShown = -every; labels.forEach((lb, i) => { if (!(i % every === 0 || (i === n - 1 && i - lastShown >= Math.max(2, every / 2)))) return; lastShown = i; const tx = svg('text', { x: x(i), y: H - 8, 'text-anchor': 'middle', class: 'si-tick' }); tx.textContent = lb.slice(5); root.appendChild(tx); });
|
|
if (!(max > 0)) { const tx = svg('text', { x: (W + L) / 2, y: H / 2, 'text-anchor': 'middle', class: 'si-empty' }); tx.textContent = empty || T('아직 기록이 없습니다'); root.appendChild(tx); }
|
|
for (const s of series) {
|
|
const d = s.values.map((v, i) => `${i ? 'L' : 'M'}${x(i).toFixed(1)},${y(v).toFixed(1)}`).join(' ');
|
|
root.appendChild(svg('path', { d, fill: 'none', stroke: s.color, 'stroke-width': 2, 'stroke-linejoin': 'round', 'stroke-linecap': 'round' }));
|
|
s.values.forEach((v, i) => { const c = svg('circle', { cx: x(i), cy: y(v), r: 2.5, fill: s.color }); const t = svg('title'); t.textContent = `${labels[i]} · ${s.label}: ${bytes ? fmtBytes(v) : fmt(v)}`; c.appendChild(t); root.appendChild(c); });
|
|
}
|
|
return root;
|
|
}
|
|
/**
|
|
* 묶음 막대 그래프. groups: [{ label, sub?, bars: [{ key, label, color, value }] }]. bytes 면 단위 눈금.
|
|
*/
|
|
function barChart({ groups, bytes, height = 190, empty, width = 720 }) {
|
|
const W = width, H = height, L = 56, R = 12, Tp = 10, B = 34; const max = Math.max(0, ...groups.flatMap((g) => g.bars.map((b) => b.value))); const tk = ticks(max); const top = tk[tk.length - 1] || 1;
|
|
const root = svg('svg', { viewBox: `0 0 ${W} ${H}`, class: 'si-chart', role: 'img' }); const [u, div] = bytes ? unitFor(top) : ['', 1];
|
|
const y = (v) => Tp + (H - Tp - B) * (1 - v / top);
|
|
for (const t of tk) { root.appendChild(svg('line', { x1: L, x2: W - R, y1: y(t), y2: y(t), stroke: '#e5e7eb' })); const tx = svg('text', { x: L - 6, y: y(t) + 4, 'text-anchor': 'end', class: 'si-tick' }); tx.textContent = bytes ? `${(t / div).toLocaleString(uiLocale(), { maximumFractionDigits: 1 })}${u}` : fmt(t); root.appendChild(tx); }
|
|
const gw = (W - L - R) / Math.max(1, groups.length); const nb = Math.max(1, ...groups.map((g) => g.bars.length)); const bw = Math.min(42, (gw * 0.7) / nb);
|
|
groups.forEach((g, gi) => {
|
|
const gx = L + gi * gw + (gw - bw * g.bars.length) / 2;
|
|
g.bars.forEach((b, bi) => { const h = Math.max(0, (H - Tp - B) * (b.value / top)); const r = svg('rect', { x: gx + bi * bw + 2, y: y(b.value), width: Math.max(1, bw - 4), height: h, rx: 3, fill: b.color }); const t = svg('title'); t.textContent = `${g.label} · ${b.label}: ${bytes ? fmtBytes(b.value) : fmt(b.value)}`; r.appendChild(t); root.appendChild(r);
|
|
if (b.value > 0 && bw >= 28) { const tx = svg('text', { x: gx + bi * bw + bw / 2, y: y(b.value) - 3, 'text-anchor': 'middle', class: 'si-val' }); tx.textContent = bytes ? fmtBytes(b.value) : fmt(b.value); root.appendChild(tx); } });
|
|
const every = groups.length > 12 ? Math.ceil(groups.length / 8) : 1; // 묶음이 많으면(30일) 이름을 띄엄띄엄
|
|
if (gi % every === 0 || (gi === groups.length - 1 && gi % every >= Math.max(2, every / 2))) { const lx = svg('text', { x: L + gi * gw + gw / 2, y: H - 18, 'text-anchor': 'middle', class: 'si-xl' }); lx.textContent = g.label; root.appendChild(lx); }
|
|
if (g.sub) { const sx = svg('text', { x: L + gi * gw + gw / 2, y: H - 6, 'text-anchor': 'middle', class: 'si-tick' }); sx.textContent = g.sub; root.appendChild(sx); }
|
|
});
|
|
if (!(max > 0)) { const tx = svg('text', { x: (W + L) / 2, y: H / 2 - 10, 'text-anchor': 'middle', class: 'si-empty' }); tx.textContent = empty || T('아직 기록이 없습니다'); root.appendChild(tx); }
|
|
return root;
|
|
}
|
|
/** 범례 */
|
|
function legend(items) { const l = el('div', 'si-legend'); for (const it of items) { const s = el('span'); const sw = el('i'); sw.style.background = it.color; s.appendChild(sw); s.appendChild(document.createTextNode(it.label)); l.appendChild(s); } return l; }
|
|
/** 가로 누적 막대 (저장 공간) */
|
|
function stackBar(parts) { const total = parts.reduce((a, p) => a + (p.value || 0), 0); const bar = el('div', 'si-stack'); for (const p of parts) { const seg = el('div', 'si-seg'); seg.style.width = total ? `${((p.value || 0) / total) * 100}%` : '0'; seg.style.background = p.color; seg.title = `${p.label}: ${fmtBytes(p.value || 0)}`; bar.appendChild(seg); } return bar; }
|
|
|
|
const tile = (grid, k, v, d, cls) => { const t = el('div', 'tot' + (cls ? ' ' + cls : '')); t.appendChild(el('span', 'k', k)); const vv = el('span', 'v'); vv.textContent = v; t.appendChild(vv); if (d) t.appendChild(el('span', 'd', d)); grid.appendChild(t); return t; };
|
|
const section = (root, title, note) => { const h = el('section', 'si-sec'); const head = el('div', 'si-head'); head.appendChild(el('h3', null, title)); if (note) head.appendChild(el('span', 'muted small', note)); h.appendChild(head); root.appendChild(h); return h; };
|
|
|
|
/**
|
|
* 페이지 그리기.
|
|
* @param {HTMLElement} root #mvStatus
|
|
* @param {{site, stats, inquiries, modules}} ctx 사이트(current)·통계(statsData)·문의 목록 응답·모듈 상태
|
|
*/
|
|
function render(root, ctx) {
|
|
const { site, stats: st, inquiries, modules } = ctx; root.innerHTML = ''; if (!site) return;
|
|
root.appendChild(el('h2', 'si-title', `${site.title} — ${T('사이트 정보')}`));
|
|
// ① 상태 타일
|
|
{ const s = section(root, T('사이트 상태')); const g = el('div', 'stat-grid si-grid'); s.appendChild(g);
|
|
const p = site.plan; const stageTxt = site.mode === 'import' ? '가져옴' : site.mode === 'copy' ? '복사함' : (p && !['generated', 'published'].includes(p.stage) ? '생성 중' : (p && p.stage === 'published' ? '발행됨' : '생성 완료'));
|
|
tile(g, T('상태'), T(stageTxt), site.editLimit && site.editLimit.max ? TF('수정 {1}/{2}회', site.editLimit.used, site.editLimit.max) : '');
|
|
const ds = site.deployState || {}; const lastDep = (site.deployments || []).slice(-1)[0];
|
|
tile(g, T('배포'), lastDep ? (lastDep.ok ? T({ 'vhost-wait': '웹서버 설정 대기', 'dns-wait': 'DNS 설정 대기', 'cert-wait': '인증서 준비 중', 'check-failed': '접속 확인 실패', 'vhost-failed': '도메인 연결 실패' }[ds.phase] || '배포됨') : T('배포 실패')) : T('배포 전'), lastDep ? `${dtm(lastDep.at)}${ds.state === 'pending' && ds.lastChangeAt && ds.lastDeployAt && ds.lastChangeAt > ds.lastDeployAt ? ' · ' + T('변경 있음(재배포 필요)') : ''}` : (site.unpublishedAt ? T('발행 취소됨') : ''));
|
|
tile(g, T('운영 주소'), site.liveUrl ? T('연결됨') : T('미리보기만'), site.liveUrl || site.url);
|
|
tile(g, T('도메인'), (site.domains || []).length ? TF('{1}개', site.domains.length) : T('없음'), (site.domains || []).join(', '));
|
|
tile(g, T('버전'), TF('{1}개', (site.versions || []).length), site.versions && site.versions.length ? site.versions[site.versions.length - 1].message : '');
|
|
tile(g, T('사용 캐시'), krwFmt((site.totals || {}).chargeKrw || 0), TF('작업 {1}건', (site.commands || []).length));
|
|
const q = inquiries && inquiries.inquiries ? inquiries.inquiries : null; const unsent = q ? q.filter((x) => !x.sent).length : 0;
|
|
tile(g, T('문의 접수'), q ? TF('{1}건', q.length) : '-', q ? (unsent ? TF('미발송 {1}건', unsent) : (inquiries.contactEmail ? `${T('수신')} ${inquiries.contactEmail}` : T('수신 이메일 없음'))) : '');
|
|
const m = site.mail; tile(g, T('메일'), m && m.level !== 'off' ? m.label : T('꺼짐'), m && m.level !== 'off' ? m.domain : '');
|
|
const on = modules ? (modules.catalog || []).filter((c) => modules.enabled && modules.enabled[c.id]) : [];
|
|
tile(g, T('켜진 모듈'), modules ? TF('{1}개', on.length) : '-', on.map((c) => T(c.label)).join(', ')); }
|
|
if (!st) { root.appendChild(el('p', 'muted small', T('통계를 불러오는 중입니다…'))); return; }
|
|
const useLog = st.sources && st.sources.log; const pick = (a) => (useLog ? { v: a.visitors || 0, pv: a.pv || 0 } : { v: a.beaconVisitors || 0, pv: a.beaconPv || 0 });
|
|
const outOf = (a) => (useLog ? a.bytesOut || 0 : a.beaconBytesOut || 0); const inOf = (a) => (useLog ? a.bytesIn || 0 : 0);
|
|
// ② 접속 통계
|
|
{ const s = section(root, T('접속 통계'), st.updatedAt ? `(${T('집계')} ${dtm(st.updatedAt)})` : `(${T('아직 집계된 접속이 없습니다')})`); const g = el('div', 'stat-grid si-grid'); s.appendChild(g);
|
|
for (const [label, a] of [['오늘', st.today], ['어제', st.yesterday], ['최근 7일', st.last7], ['이번 달', st.month], ['누적', st.total]]) { const x = pick(a || {}); tile(g, T(label), `${fmt(x.v)}${T('명')}`, `${T('페이지뷰')} ${fmt(x.pv)}`); }
|
|
const series = st.series || []; const labels = series.map((x) => x.day);
|
|
s.appendChild(el('div', 'field-label', T('최근 30일 방문자 · 페이지뷰')));
|
|
s.appendChild(barChart({ groups: series.map((x) => ({ label: x.day.slice(5), bars: [{ key: 'v', label: T('방문자'), color: COLOR.v, value: pick(x).v }, { key: 'pv', label: T('페이지뷰'), color: COLOR.pv, value: pick(x).pv }] })), height: 200 }));
|
|
s.appendChild(legend([{ label: T('방문자'), color: COLOR.v }, { label: T('페이지뷰'), color: COLOR.pv }]));
|
|
s.appendChild(el('p', 'muted small', useLog ? T('방문자·페이지뷰·네트워크는 사이트가 올라간 서버의 접속 로그로 집계합니다(5분 주기, 검색엔진 봇 제외). 방문자는 하루 단위로 같은 기기·브라우저를 한 명으로 셉니다(IP 는 저장하지 않음).') : (st.sources && st.sources.beacon ? T('이 사이트는 서버 로그를 읽을 수 없어 페이지에 삽입된 통계 스크립트가 보고한 값(추정)으로 집계합니다. 수신량은 측정할 수 없습니다.') : T('배포 후 방문이 생기면 여기에 집계됩니다.'))));
|
|
void labels; }
|
|
// ③ 네트워크
|
|
{ const s = section(root, T('네트워크 사용량'), useLog ? T('서버 로그 측정값 (요청·응답 전체 바이트)') : T('브라우저 보고 기반 추정치 (송신만)'));
|
|
const series = st.series || []; const labels = series.map((x) => x.day);
|
|
s.appendChild(el('div', 'field-label', T('최근 30일 송신 · 수신 (일별)')));
|
|
s.appendChild(lineChart({ series: [{ key: 'out', label: T('송신'), color: COLOR.out, values: series.map(outOf) }, { key: 'in', label: T('수신'), color: COLOR.in, values: series.map(inOf) }], labels, bytes: true }));
|
|
s.appendChild(legend([{ label: T('송신(아웃바운드)'), color: COLOR.out }, { label: T('수신(인바운드)'), color: COLOR.in }]));
|
|
const two = el('div', 'si-two');
|
|
const w = el('div'); w.appendChild(el('div', 'field-label', T('최근 7일 합계'))); const l7 = st.last7 || {}; const o7 = outOf(l7), i7 = inOf(l7);
|
|
w.appendChild(barChart({ groups: [{ label: T('최근 7일'), sub: `${labels[Math.max(0, labels.length - 7)] || ''} ~ ${labels[labels.length - 1] || ''}`, bars: [{ key: 'out', label: T('송신'), color: COLOR.out, value: o7 }, { key: 'in', label: T('수신'), color: COLOR.in, value: i7 }, { key: 'all', label: T('전체'), color: COLOR.total, value: o7 + i7 }] }], bytes: true, height: 220, width: 400 })); two.appendChild(w);
|
|
const mo = el('div'); mo.appendChild(el('div', 'field-label', T('월간 합계 (1일~말일, 직전 2개월 + 이번 달 누적)')));
|
|
const months = st.months || [];
|
|
mo.appendChild(barChart({ groups: months.map((m) => { const o = outOf(m), i = inOf(m); return { label: m.month, sub: m.current ? T('이번 달 (누적 중)') : `${m.days}${T('일')}`, bars: [{ key: 'out', label: T('송신'), color: COLOR.out, value: o }, { key: 'in', label: T('수신'), color: COLOR.in, value: i }, { key: 'all', label: T('전체'), color: COLOR.total, value: o + i }] }; }), bytes: true, height: 220, width: 400 })); two.appendChild(mo);
|
|
s.appendChild(two); s.appendChild(legend([{ label: T('송신'), color: COLOR.out }, { label: T('수신'), color: COLOR.in }, { label: T('전체'), color: COLOR.total }]));
|
|
const tb = el('table', 'stat-table si-table'); const hr = el('tr'); for (const h of ['', '수신(인바운드)', '송신(아웃바운드)', '전체', '요청 수']) hr.appendChild(el('th', null, T(h))); tb.appendChild(hr);
|
|
const rows = [['최근 7일', st.last7], ...months.map((m) => [m.month + (m.current ? ' (' + T('누적 중') + ')' : ''), m]), ['누적', st.total]];
|
|
for (const [label, a] of rows) { const tr = el('tr'); const o = outOf(a || {}), i = inOf(a || {}); [T(label), useLog ? fmtBytes(i) : '-', fmtBytes(o) + (useLog ? '' : ' ' + T('(추정)')), useLog ? fmtBytes(o + i) : '-', useLog ? fmt((a || {}).req || 0) : fmt((a || {}).beaconPv || 0)].forEach((v) => tr.appendChild(el('td', null, v))); tb.appendChild(tr); }
|
|
const tw = el('div', 'table-wrap'); tw.appendChild(tb); s.appendChild(tw); }
|
|
// ④ 저장 공간
|
|
{ const s = section(root, T('디스크 사용량'), T('허용량은 아직 정해지지 않아 표시하지 않습니다')); const d = st.disk || {}; const db = st.db || {};
|
|
const parts = [{ label: T('웹 데이터'), value: d.web != null ? d.web : (d.total || 0), color: COLOR.web }, { label: T('DB 데이터'), value: db.total || 0, color: COLOR.db }];
|
|
if (d.mail != null) parts.push({ label: T('메일 데이터'), value: d.mail, color: COLOR.mail });
|
|
const total = parts.reduce((a, p) => a + p.value, 0);
|
|
s.appendChild(el('div', 'si-disk-total', TF('전체 {1}', fmtBytes(total)))); s.appendChild(stackBar(parts));
|
|
const g = el('div', 'stat-grid si-grid'); s.appendChild(g);
|
|
tile(g, T('웹 데이터'), fmtBytes(parts[0].value), `${T('사이트 파일')} ${fmtBytes(d.site || 0)} · ${T('첨부')} ${fmtBytes(d.uploads || 0)}`, 'si-web');
|
|
tile(g, T('DB 데이터'), fmtBytes(db.total || 0), `${T('사이트 정보')} ${fmtBytes(db.site || 0)} · ${T('문의')} ${fmtBytes(db.inquiries || 0)} · ${T('통계')} ${fmtBytes(db.stats || 0)} · ${T('버전 저장소')} ${fmtBytes(db.git || 0)}`, 'si-db');
|
|
// v1.7.3 (요구 2): 메일 서버(Arimail) 사용량 API 가 연결되면 "메일 데이터" 는 서버 메일함 용량(mailSource 'arimail'), 아니면 이 서비스의 메일함 파일 크기 + 미연동 이유
|
|
if (d.mail != null) { const mu = st.mailUsage; const srv = d.mailSource === 'arimail' && mu && mu.ok;
|
|
tile(g, T('메일 데이터'), fmtBytes(d.mail), srv ? `${TF('메일 서버 {1} 의 메일함 용량', (mu.server && (mu.server.nickname || mu.server.host)) || 'Arimail')} · ${TF('메일 {1}통', fmt(mu.messages.total))} · ${TF('계정 {1}개', fmt(mu.accounts.total))}${d.mailLocal ? ` · ${T('이 서비스 메일함 파일')} ${fmtBytes(d.mailLocal)}` : ''}` : (T('이 서비스에 저장된 메일함 파일') + (mu && !mu.ok && mu.message ? ` — ${T('메일 서버 사용량 미연동')}: ${mu.message}` : ` (${T('메일 서버 쪽 용량은 포함하지 않음')})`)), 'si-mail'); }
|
|
s.appendChild(legend(parts)); }
|
|
// ④-2 메일 사용량 (v1.7.3 요구 2): 메일 서버(Arimail 1.0.9 사용량 API) — 메일함 용량·메일 수·이달 발송/수신 타일 + 최근 30일 발송/수신 통수 막대. 미연동이면 구역 자체를 숨긴다
|
|
if (st.mailUsage && st.mailUsage.ok) { const mu = st.mailUsage; const s = section(root, T('메일 사용량'), `${mu.server ? (mu.server.nickname || mu.server.host) : ''} · ${mu.domain || ''}`);
|
|
const g = el('div', 'stat-grid si-grid'); s.appendChild(g); const tr = mu.traffic || {}; const mo = tr.month || {}; const dy = tr.day || {};
|
|
tile(g, T('메일함 용량'), fmtBytes(mu.storage.bytes), TF('계정 {1}개', fmt(mu.accounts.total)) + (mu.accounts.mine ? ` · ${TF('이 사이트 {1}개', mu.accounts.mine)} ${fmtBytes(mu.myBytes || 0)}` : ''), 'si-mail');
|
|
tile(g, T('메일 수'), fmt(mu.messages.total), `${T('받은 메일')} ${fmt(mu.messages.inbound)} · ${T('보낸 메일')} ${fmt(mu.messages.outbound)}${mu.messages.failed ? ` · ${T('실패')} ${fmt(mu.messages.failed)}` : ''}`);
|
|
tile(g, T('이달 발송'), fmt(mo.sent), `${fmtBytes(mo.sentBytes)} · ${T('오늘')} ${fmt(dy.sent)}`);
|
|
tile(g, T('이달 수신'), fmt(mo.received), `${fmtBytes(mo.receivedBytes)} · ${T('오늘')} ${fmt(dy.received)}`);
|
|
const daily = mu.daily || [];
|
|
s.appendChild(barChart({ groups: daily.map((x) => ({ label: String(x.date || '').slice(5), bars: [{ key: 'sent', label: T('발송'), color: COLOR.out, value: x.sent }, { key: 'recv', label: T('수신'), color: COLOR.in, value: x.received }] })), height: 170, empty: T('아직 메일 송수신 기록이 없습니다') }));
|
|
s.appendChild(legend([{ label: T('발송(통)'), color: COLOR.out }, { label: T('수신(통)'), color: COLOR.in }]));
|
|
s.appendChild(el('p', 'muted small', `${T('메일 서버 사용량 API 기준 (5분 캐시)')} · ${dtm(mu.at)}`)); }
|
|
// ⑤ 페이지 · 유입
|
|
{ const s = section(root, T('많이 본 페이지 · 유입 경로 (30일)')); const two = el('div', 'si-two'); s.appendChild(two);
|
|
const list = (title, items, empty) => { const w = el('div'); w.appendChild(el('div', 'field-label', title)); const ul = el('ul', 'git-list stat-list'); if (!(items || []).length) { const li = el('li', 'muted'); li.textContent = empty; ul.appendChild(li); } for (const it of items || []) { const li = el('li'); const b = el('span', 'bname', it.key); b.style.fontFamily = 'inherit'; li.appendChild(b); li.appendChild(el('span', 'st', fmt(it.n))); ul.appendChild(li); } w.appendChild(ul); return w; };
|
|
two.appendChild(list(T('많이 본 페이지'), st.topPages, T('아직 기록이 없습니다'))); two.appendChild(list(T('유입 경로'), st.topRefs, T('아직 기록이 없습니다'))); }
|
|
}
|
|
|
|
window.SiteInfoUI = { render, lineChart, barChart };
|
|
})();
|