포털 인증·계정·사용자 그룹·대시보드·알림 센터·감사 화면·도움말·발송기·스케줄러 (§3·§4·§5·§24)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
0f11424224
commit
c90ac27596
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* portal/dashboard/api.mjs — 고객 대시보드(§5): 요약·주의 항목·최근 활동.
|
||||
*
|
||||
* 모든 수치는 현재 그룹(ctx.group) 기준으로 중심 표(sites·domains·mail_services·mailboxes·backup_options·orders·jobs)와 lib/quota 에서 계산한다.
|
||||
* 운영 사이트 수(§5.1): quota 'sites' 의 배정(사용 중)·예약(예약됨) 과 구매 총량. 초안·제작 중은 포함하지 않는다.
|
||||
* 주의 항목(§5.2)은 중요도 순(필수·critical 알림 → 결제·승인 대기 → 도메인·인증서 → 사용량 → 배포 실패 → 메일 → 백업 → 이전 승인 → 정지·보존 만료 → 장애)이며 각 항목은 해결 화면으로 연결한다.
|
||||
* 최근 활동은 감사 기록(audit_log)에서 가져오고 automatic 으로 사용자 작업과 자동 작업을 구분한다.
|
||||
*/
|
||||
import { requireGroup } from '../auth/guard.mjs';
|
||||
import { figures, warnLevel, summary as quotaSummary } from '../../lib/quota.mjs';
|
||||
import { listNotifications } from '../../lib/notify.mjs';
|
||||
import { listAudit } from '../../lib/audit.mjs';
|
||||
import { label } from '../../lib/status.mjs';
|
||||
import { t } from '../../lib/i18n.mjs';
|
||||
import { nowIso, parseJson } from '../../lib/db.mjs';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
const JOB_KIND_LABEL = { order: '주문', deploy: '배포', restore: '복원', relocation: '이전', backup: '백업', transfer: '소유권 이전', mail: '메일', domain: '도메인' };
|
||||
|
||||
export async function register(app, deps) {
|
||||
const { store } = deps;
|
||||
app.static('/ui/dashboard', join(HERE, 'public'));
|
||||
app.get('/', (ctx) => { if (!ctx.user) return ctx.redirect('/login'); if (!ctx.group) return ctx.html(deps.shell(ctx, { title: t('dashboard.title'), nav: 'dashboard', body: deps.statePanel('forbidden', t('error.groupRequired')) })); return ctx.html(deps.shell(ctx, { title: t('dashboard.title'), nav: 'dashboard', crumbs: [{ label: t('dashboard.title') }], actions: [{ label: '사이트 만들기', href: '/sites/new', kind: 'primary' }], scripts: ['/ui/dashboard/dashboard.js'], body: `${!ctx.user.email_verified_at ? `<div class="alert warning" data-alert="verify">${t('auth.verify.banner')} <a href="/account">확인 메일 다시 보내기</a></div>` : ''}<div id="dash">${deps.statePanel('loading')}</div>` })); });
|
||||
|
||||
app.get('/api/dashboard', (ctx) => {
|
||||
requireGroup(ctx); const gid = ctx.group.id; const now = nowIso();
|
||||
const count = (sql, ...p) => store.get(sql, ...p)?.n || 0;
|
||||
const sitesQ = figures(store, gid, 'sites');
|
||||
const live = count('SELECT COUNT(*) AS n FROM sites WHERE group_id=? AND lifecycle IN (\'live\',\'needs_domain\',\'paused\') AND deleted_at IS NULL', gid);
|
||||
const summaryCards = {
|
||||
sites: { live, purchased: sitesQ.purchased, inUse: sitesQ.allocated, reserved: sitesQ.reserved, unallocated: sitesQ.unallocated },
|
||||
drafts: count('SELECT COUNT(*) AS n FROM sites WHERE group_id=? AND lifecycle IN (\'draft\',\'building\',\'deploying\') AND deleted_at IS NULL', gid),
|
||||
health: { ok: count('SELECT COUNT(*) AS n FROM sites WHERE group_id=? AND lifecycle=\'live\' AND health=\'ok\'', gid), warn: count('SELECT COUNT(*) AS n FROM sites WHERE group_id=? AND lifecycle=\'live\' AND health IN (\'degraded\',\'stale\',\'unknown\')', gid), bad: count('SELECT COUNT(*) AS n FROM sites WHERE group_id=? AND lifecycle=\'live\' AND health IN (\'partial_outage\',\'down\')', gid) },
|
||||
deploy: { managed: count('SELECT COUNT(*) AS n FROM sites WHERE group_id=? AND deploy_mode=\'managed\' AND deleted_at IS NULL', gid), external: count('SELECT COUNT(*) AS n FROM sites WHERE group_id=? AND deploy_mode=\'external\' AND deleted_at IS NULL', gid) },
|
||||
domains: { total: count('SELECT COUNT(*) AS n FROM domains WHERE group_id=?', gid), action: count('SELECT COUNT(*) AS n FROM domains WHERE group_id=? AND (ownership_state<>\'verified\' OR dns_state<>\'ok\' OR cert_state IN (\'failed\',\'expiring\',\'expired\'))', gid) },
|
||||
mail: { services: count('SELECT COUNT(*) AS n FROM mail_services WHERE group_id=? AND deleted_at IS NULL', gid), mailboxes: count('SELECT COUNT(*) AS n FROM mailboxes WHERE group_id=? AND deleted_at IS NULL', gid) },
|
||||
backup: count('SELECT COUNT(*) AS n FROM backup_options WHERE group_id=? AND status=\'active\'', gid),
|
||||
jobs: Object.entries(deps.jobs.list({ groupId: gid, status: ['queued', 'running', 'awaiting_external', 'verifying', 'needs_user', 'awaiting_approval', 'awaiting_payment', 'reverting'], limit: 500 }).reduce((acc, j) => { const k = (j.kind || '').split('.')[0]; acc[k] = (acc[k] || 0) + 1; return acc; }, {})).map(([k, n]) => ({ kind: k, label: JOB_KIND_LABEL[k] || k, n })),
|
||||
cost: { estimated: store.get('SELECT COALESCE(SUM(total),0) AS n FROM orders WHERE group_id=? AND status IN (\'approved\') AND strftime(\'%Y-%m\', approved_at)=strftime(\'%Y-%m\', ?)', gid, now)?.n || 0, awaiting: store.get('SELECT COALESCE(SUM(total),0) AS n FROM orders WHERE group_id=? AND status IN (\'awaiting_payment\',\'awaiting_approval\')', gid)?.n || 0, currency: 'KRW', note: '승인된 주문 합계 기준 추정값입니다' },
|
||||
};
|
||||
// 주의 항목(중요도 순)
|
||||
const attention = [];
|
||||
for (const n of listNotifications(store, { groupId: gid, limit: 50 }).filter((x) => x.state === 'open' && (x.mandatory || x.severity === 'critical'))) attention.push({ severity: 'critical', text: n.title, href: n.link || '/notifications', kind: 'notification' });
|
||||
for (const o of store.all('SELECT id, order_no, status, total, due_at FROM orders WHERE group_id=? AND status IN (\'awaiting_payment\',\'awaiting_approval\') ORDER BY created_at DESC LIMIT 5', gid)) attention.push({ severity: 'warning', text: `주문 ${o.order_no} ${label('job', o.status)}${o.due_at ? ` (기한 ${o.due_at.slice(0, 10)})` : ''}`, href: `/billing/orders/${o.id}`, kind: 'order' });
|
||||
for (const d of store.all('SELECT id, name, ownership_state, dns_state, cert_state FROM domains WHERE group_id=? AND (ownership_state<>\'verified\' OR dns_state<>\'ok\' OR cert_state IN (\'failed\',\'expiring\',\'expired\')) LIMIT 5', gid)) attention.push({ severity: d.cert_state === 'failed' ? 'critical' : 'warning', text: `도메인 ${d.name}: ${d.ownership_state !== 'verified' ? '소유 확인 필요' : d.dns_state !== 'ok' ? 'DNS 확인 필요' : '인증서 ' + d.cert_state}`, href: `/domains/${d.id}`, kind: 'domain' });
|
||||
for (const q of quotaSummary(store, gid)) { if (q.purchased > 0) { const lvl = warnLevel(q.used, q.purchased); if (lvl >= 70) attention.push({ severity: lvl >= 95 ? 'critical' : 'warning', text: `${q.label} 사용량 ${lvl}% 구간${lvl >= 100 ? ' — 제한 적용 중' : ''}`, href: '/usage', kind: 'usage' }); } }
|
||||
for (const j of deps.jobs.list({ groupId: gid, status: ['failed', 'needs_user'], limit: 10 })) attention.push({ severity: j.status === 'failed' ? 'critical' : 'warning', text: `${j.label || j.kind}: ${label('job', j.status)}${j.user_message ? ' — ' + j.user_message : ''}`, href: j.subject_type === 'site' ? `/sites/${j.subject_id}` : '/notifications', kind: 'job' });
|
||||
for (const m of store.all('SELECT id, name, status FROM mail_services WHERE group_id=? AND status IN (\'failed\',\'needs_setup\',\'needs_user\') AND deleted_at IS NULL LIMIT 5', gid)) attention.push({ severity: 'warning', text: `메일 ${m.name}: ${label('job', m.status)}`, href: `/mail/${m.id}`, kind: 'mail' });
|
||||
for (const b of store.all('SELECT b.id, s.name, b.consecutive_failures FROM backup_options b JOIN sites s ON s.id=b.site_id WHERE b.group_id=? AND b.consecutive_failures>0 LIMIT 5', gid)) attention.push({ severity: b.consecutive_failures >= 3 ? 'critical' : 'warning', text: `사이트 ${b.name} 백업 ${b.consecutive_failures}회 연속 실패`, href: `/backup`, kind: 'backup' });
|
||||
for (const r of store.all('SELECT id, subject_id, status FROM relocations WHERE group_id=? AND status IN (\'awaiting_approval\',\'needs_user\') LIMIT 5', gid)) attention.push({ severity: 'warning', text: `서버 이전 ${label('job', r.status)}`, href: `/sites/${r.subject_id}`, kind: 'relocation' });
|
||||
for (const s of store.all('SELECT id, name, lifecycle, retention_until, delete_after FROM sites WHERE group_id=? AND lifecycle IN (\'paused\',\'cancel_scheduled\',\'readonly_retention\',\'pending_delete\') LIMIT 5', gid)) attention.push({ severity: s.lifecycle === 'pending_delete' ? 'critical' : 'warning', text: `사이트 ${s.name}: ${label('service', s.lifecycle)}${s.delete_after ? ` (삭제 예정 ${s.delete_after.slice(0, 10)})` : ''}`, href: `/sites/${s.id}`, kind: 'lifecycle' });
|
||||
for (const n of listNotifications(store, { groupId: gid, category: 'incident', limit: 5 }).filter((x) => x.state === 'open')) attention.push({ severity: 'warning', text: n.title, href: n.link || '/notifications', kind: 'incident' });
|
||||
const order = { critical: 0, warning: 1, info: 2 }; attention.sort((a, b) => order[a.severity] - order[b.severity]);
|
||||
const recent = listAudit(store, { groupId: gid, limit: 20 }).filter((a) => !/^auth\./.test(a.action)).map((a) => ({ at: a.at, action: a.action, actor: a.actor_label || a.actor_type, automatic: !!a.automatic || a.actor_type === 'system', outcome: a.outcome, subjectType: a.subject_type, subjectId: a.subject_id }));
|
||||
return { ok: true, generatedAt: now, summary: summaryCards, attention, recent, verified: !!ctx.user.email_verified_at };
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
/* portal/dashboard/public/dashboard.js — 대시보드 요약 카드·주의 항목·최근 활동 렌더(30초마다 갱신). */
|
||||
(() => {
|
||||
const el = document.getElementById('dash'); if (!el) return;
|
||||
const ACT = { 'deploy.start': '배포 시작', 'deploy.done': '배포 완료', 'order.created': '주문 제출', 'order.approve': '주문 승인', 'member.invited': '구성원 초대', 'member.joined': '구성원 참여', 'member.role': '역할 변경', 'group.settings': '그룹 설정 변경', 'permission.site': '사이트 권한 변경', 'ownership.group_transferred': '그룹 소유권 이전', 'member.suspended': '구성원 정지' };
|
||||
const fmtKrw = (n) => `${Number(n || 0).toLocaleString('ko-KR')}원`;
|
||||
async function load() {
|
||||
try {
|
||||
const d = await aw.api('GET', '/api/dashboard'); const s = d.summary;
|
||||
const card = (title, stat, sub) => `<div class="card"><h2>${aw.esc(title)}</h2><div class="stat">${stat}</div>${sub ? `<div class="sub">${sub}</div>` : ''}</div>`;
|
||||
el.innerHTML = `<div class="cards">
|
||||
${card('운영 사이트', `${s.sites.inUse} <span class="sub">/ 구매 ${s.sites.purchased}개</span>`, `사용 중 ${s.sites.inUse} · 예약됨 ${s.sites.reserved} · 미배정 ${s.sites.unallocated}`)}
|
||||
${card('초안·제작 중', s.drafts, '운영 사이트 수를 사용하지 않습니다')}
|
||||
${card('사이트 상태', `<span class="badge ok">● 정상 ${s.health.ok}</span> <span class="badge warning">! 경고 ${s.health.warn}</span> <span class="badge failed">✘ 장애 ${s.health.bad}</span>`)}
|
||||
${card('배포 방식', `관리형 ${s.deploy.managed} · 외부 ${s.deploy.external}`)}
|
||||
${card('도메인', `${s.domains.total}`, s.domains.action ? `<a href="/domains">조치 필요 ${s.domains.action}개</a>` : '조치 필요 없음')}
|
||||
${card('메일', `서비스 ${s.mail.services} · 메일함 ${s.mail.mailboxes}`)}
|
||||
${card('백업 옵션 사이트', s.backup)}
|
||||
${card('진행 중 작업', s.jobs.length ? s.jobs.map((j) => `${aw.esc(j.label)} ${j.n}`).join(' · ') : '0')}
|
||||
${card('이번 달 예상 비용', fmtKrw(s.cost.estimated), `결제 대기 ${fmtKrw(s.cost.awaiting)} · ${aw.esc(s.cost.note)}`)}
|
||||
</div>
|
||||
<div class="card"><h2>주의 항목</h2>${d.attention.length ? d.attention.map((a) => `<a class="alert ${a.severity}" href="${aw.esc(a.href)}">${a.severity === 'critical' ? '⚠' : '!'} ${aw.esc(a.text)}</a>`).join('') : '<div class="aw-state empty"><b>지금 조치할 항목이 없습니다</b></div>'}</div>
|
||||
<div class="card"><h2>최근 활동</h2>${d.recent.length ? `<table class="r"><thead><tr><th>시각</th><th>내용</th><th>주체</th><th>결과</th></tr></thead><tbody>${d.recent.map((r) => `<tr><td data-l="시각">${aw.esc(aw.fmt(r.at))}</td><td data-l="내용">${aw.esc(ACT[r.action] || r.action)}${r.subjectType ? ` <span class="sub">${aw.esc(r.subjectType)} ${aw.esc(r.subjectId || '')}</span>` : ''}</td><td data-l="주체">${r.automatic ? '<span class="badge">⚙ 자동</span>' : `<span class="badge">👤 ${aw.esc(r.actor)}</span>`}</td><td data-l="결과">${r.outcome === 'ok' ? '<span class="badge ok">✔ 성공</span>' : `<span class="badge failed">✘ ${aw.esc(r.outcome)}</span>`}</td></tr>`).join('')}</tbody></table>` : '<div class="aw-state empty"><b>최근 활동이 없습니다</b></div>'}</div>
|
||||
<p class="sub">마지막 갱신 ${aw.esc(aw.fmt(d.generatedAt))}</p>`;
|
||||
} catch (err) { aw.state(el, err.status === 403 ? 'forbidden' : err.code === 'service_unreachable' ? 'stale' : 'failed', err.message, { retry: load }); }
|
||||
}
|
||||
load(); aw.poll(load, 30000);
|
||||
})();
|
||||
Reference in New Issue
Block a user