Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
60 lines
9.3 KiB
JavaScript
60 lines
9.3 KiB
JavaScript
/**
|
|
* 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 };
|
|
});
|
|
}
|