Files

76 lines
5.3 KiB
JavaScript

'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 };