'use strict'; // ============================================================================ // 관리자 "로그 보기" (v1.3.2, 1.3.1 까지의 "오류 로그" 를 확장) // // 모든 사이트의 작업 기록(commands)·작업 상세 로그(commands[].log)·배포 기록(deployments)을 한 목록으로 모아 // 종류·기간·회사(회원)·사이트·검색어로 걸러 보여 준다. 사용자에게는 보이지 않는 상세 원인(debug 로그)도 포함한다. // // 종류(type) // error : 실패한 작업, 실패한 배포, 오류·실패 문구가 든 상세 로그(도구 오류, 엔진 준비 실패, 시간 초과 등) // warn : 경고 로그(warn 레벨) // cancelled : 중단된 작업(사용자 중단, 캐시 소진으로 멈춤) // deploy : 성공한 배포·발행 취소 // done : 완료된 작업 // debug : 그 밖의 상세 로그 // ============================================================================ const TYPES = { error: '오류', warn: '경고', cancelled: '중단', deploy: '배포', done: '완료', debug: '상세' }; // 상세 로그 문장이 오류인지 판단하는 표현 (debug·warn 레벨 안에서) const ERROR_RE = /오류|실패|error|fail|denied|거부|ENOENT|EACCES|timeout|시간 초과|exception|unauthorized|forbidden/i; /** * 한 사이트의 기록을 로그 항목으로 펼친다. * @param {object} site store.load() 결과 * @param {Map} owners 회원 아이디 → 이름(회사명) */ function entriesOf(site, owners) { const base = { siteId: site.id, siteTitle: site.title, ownerId: site.ownerId || '', ownerName: owners.get(site.ownerId) || site.ownerName || '' }; const out = []; for (const c of site.commands || []) { const cmd = { ...base, no: c.no, kind: c.kind }; const at = c.finishedAt || c.startedAt; if (c.status === 'error') out.push({ ...cmd, at, type: 'error', text: c.error || '작업 실패' }); else if (c.status === 'cancelled' || c.status === 'paused') out.push({ ...cmd, at, type: 'cancelled', text: c.error || (c.status === 'paused' ? '캐시 소진으로 일시 중지' : '작업 중단') }); else if (c.status === 'done') out.push({ ...cmd, at, type: 'done', text: `완료 — ${String(c.instruction || '').slice(0, 160)}${c.chargeKrw ? ` (캐시 ${require('./credits').fmt(c.chargeKrw)})` : ''}` }); for (const l of c.log || []) { if (l.level === 'assistant' || l.level === 'tool' || !l.text) continue; // AI 의 말·도구 호출 원문은 제외(사이트 상세에서 봄) const isErr = (l.level === 'debug' || l.level === 'warn' || l.level === 'error') && ERROR_RE.test(l.text); const type = isErr || l.level === 'error' ? 'error' : l.level === 'warn' ? 'warn' : l.level === 'debug' ? 'debug' : null; if (type) out.push({ ...cmd, at: l.ts ? new Date(l.ts).toISOString() : at, type, text: String(l.text).slice(0, 2000) }); } } for (const d of site.deployments || []) { out.push({ ...base, no: null, kind: d.kind === 'unpublish' ? 'unpublish' : 'deploy', at: d.at, type: d.ok ? 'deploy' : 'error', text: `${d.kind === 'unpublish' ? '발행 취소' : '배포'} ${d.ok ? '성공' : '실패'}${d.targetName ? ` (${d.targetName})` : ''}${d.detail ? ' — ' + String(d.detail).slice(0, 300) : ''}` }); } return out.filter((x) => x.at); } /** * 걸러서 돌려준다. * @param {object[]} sites * @param {object[]} users auth.listUsers() * @param {{types?: string[], from?: string, to?: string, owner?: string, site?: string, q?: string, limit?: number}} f * from/to: YYYY-MM-DD (KST 하루 기준, to 는 그날 끝까지 포함) * @returns {{items, total, counts, owners, sites, types}} * counts: 종류를 뺀 나머지 조건으로 센 종류별 건수(종류 버튼에 숫자 표시용) */ function query(sites, users, f = {}) { const owners = new Map(users.map((u) => [u.id, u.name])); const all = []; for (const s of sites) all.push(...entriesOf(s, owners)); const kst = (d, end) => new Date(`${d}T${end ? '23:59:59.999' : '00:00:00'}+09:00`).getTime(); const from = f.from ? kst(f.from) : -Infinity; const to = f.to ? kst(f.to, true) : Infinity; const q = String(f.q || '').trim().toLowerCase(); const base = all.filter((x) => { const t = new Date(x.at).getTime(); if (t < from || t > to) return false; if (f.owner && x.ownerId !== f.owner) return false; if (f.site && x.siteId !== f.site) return false; if (q && !`${x.text} ${x.siteTitle} ${x.ownerName} ${x.ownerId}`.toLowerCase().includes(q)) return false; return true; }); const counts = Object.fromEntries(Object.keys(TYPES).map((k) => [k, base.filter((x) => x.type === k).length])); const types = f.types && f.types.length ? f.types : ['error', 'warn', 'cancelled']; const items = base.filter((x) => types.includes(x.type)).sort((a, b) => b.at.localeCompare(a.at)); const limit = Math.min(Number(f.limit) || 1000, 5000); return { items: items.slice(0, limit), total: items.length, counts, types: TYPES, owners: [...new Map(all.map((x) => [x.ownerId, x.ownerName])).entries()].filter(([id]) => id).map(([id, name]) => ({ id, name })).sort((a, b) => (a.name || a.id).localeCompare(b.name || b.id)), sites: sites.map((s) => ({ id: s.id, title: s.title, ownerId: s.ownerId })).sort((a, b) => (a.title || '').localeCompare(b.title || '')), }; } module.exports = { TYPES, query, entriesOf };