Files
Gnuboard7/public/install/assets/js/installation-monitor.js
T
HeuJung 01d319e74b fix(core,installer): 프로세스 출력 인코딩으로 인한 JSON 빈 응답 차단
한국어 Windows 에서 whoami 가 CP949 로 출력해 계정명에 한글이 있으면
invalid UTF-8 이 응답 배열에 실리고 json_encode 가 false 를 반환한다.
echo false 는 빈 문자열이라 HTTP 200 + 빈 본문이 나가는데 예외도 로그도
남지 않아, 설치 마법사 2단계가 진행 불가 상태로 멈춘다.

2026-07 수정(7.0.2)은 로그 축 4곳만 막았다. 출처에서 정규화하고(1차),
응답 경계를 단일 헬퍼로 닫고(2차), 실패 시 문제 필드 경로를 로그에
남기도록(3차) 세 층으로 처리해 다른 경로가 열린 채 남지 않게 했다.

정규화는 복원 가능한 코드페이지 출력을 먼저 되살리므로, 종전 mb_scrub
단독 처리와 달리 한글이 U+FFFD 로 훼손되지 않는다.

같은 원인으로 한국어 Windows 에서 관리자 환경설정의 시스템 정보가
500 이 되던 것도 함께 막았다.
2026-08-29 15:13:14 +09:00

362 lines
14 KiB
JavaScript

/**
* G7 인스톨러 - 설치 진행 모니터 (SSE/폴링 듀얼 모드)
*
* 두 모드 공통 콜백 인터페이스를 통해 UI 업데이트 로직을 공유합니다.
* - SseMonitor: EventSource 기반 실시간 스트리밍
* - PollingMonitor: setInterval 기반 state-management.php 폴링
*
* 콜백:
* onConnected() — 연결 성공 (SSE 전용, 폴링은 첫 poll 시 즉시 호출)
* onTaskStart(taskId, name, target) — 새 작업 시작
* onTaskComplete(taskId, message, target) — 작업 완료
* onLog(message) — 로그 라인 추가
* onComplete(data) — 전체 설치 성공
* onError(errorData) — 실패 (data: {message, message_key, error, task, target, manual_commands})
* onAbort(state) — 사용자 중단
* onRollbackFailed(data) — 롤백 실패
* onConnectionTimeout() — SSE 5초 내 연결 실패 (SSE 전용)
*/
(function (global) {
'use strict';
/**
* 기본 모니터 클래스 (공통 인터페이스)
*/
class InstallationMonitor {
constructor(callbacks, options = {}) {
this.callbacks = callbacks || {};
this.options = options;
this.stopped = false;
}
start() {
throw new Error('start() must be implemented by subclass');
}
stop() {
this.stopped = true;
}
_invoke(name, ...args) {
const cb = this.callbacks[name];
if (typeof cb === 'function') {
try {
cb(...args);
} catch (e) {
console.error(`[InstallationMonitor] callback ${name} failed:`, e);
}
}
}
}
/**
* SSE 기반 모니터 (EventSource)
*/
class SseMonitor extends InstallationMonitor {
constructor(callbacks, options = {}) {
super(callbacks, options);
this.workerUrl = options.workerUrl;
this.connectionTimeoutMs = options.connectionTimeoutMs || 5000;
this.eventSource = null;
this.connected = false;
this.connectionTimer = null;
}
start() {
this.eventSource = new EventSource(this.workerUrl);
this.connectionTimer = setTimeout(() => {
if (!this.connected && !this.stopped) {
this.stop();
this._invoke('onConnectionTimeout');
}
}, this.connectionTimeoutMs);
this.eventSource.addEventListener('connected', () => {
clearTimeout(this.connectionTimer);
this.connected = true;
this._invoke('onConnected');
});
this.eventSource.addEventListener('task_start', (e) => {
const data = JSON.parse(e.data);
this._invoke('onTaskStart', data.task, data.name, data.target || null);
});
this.eventSource.addEventListener('task_complete', (e) => {
const data = JSON.parse(e.data);
this._invoke('onTaskComplete', data.task, data.message, data.target || null);
});
this.eventSource.addEventListener('log', (e) => {
const data = JSON.parse(e.data);
this._invoke('onLog', data.message);
});
this.eventSource.addEventListener('completed', (e) => {
const data = JSON.parse(e.data);
this.stop();
this._invoke('onComplete', data);
});
this.eventSource.addEventListener('aborted', (e) => {
let data = {};
try {
data = JSON.parse(e.data);
} catch (_) {}
this.stop();
this._invoke('onAbort', data);
});
this.eventSource.addEventListener('rollback_failed', (e) => {
let data = {};
try {
data = JSON.parse(e.data);
} catch (_) {}
this._invoke('onRollbackFailed', data);
});
this.eventSource.addEventListener('error', (e) => {
// 서버에서 보낸 명시적 error 이벤트 (e.data 있음)
if (!e.data) {
return;
}
let data = {};
try {
data = JSON.parse(e.data);
} catch (_) {
data = { message: 'Unknown error' };
}
this.stop();
this._invoke('onError', data);
});
// 연결 오류 (네이티브 onerror) — e.data 없음
this.eventSource.onerror = () => {
clearTimeout(this.connectionTimer);
if (!this.connected && !this.stopped) {
this.stop();
this._invoke('onConnectionTimeout');
}
};
}
stop() {
super.stop();
if (this.connectionTimer) {
clearTimeout(this.connectionTimer);
this.connectionTimer = null;
}
if (this.eventSource) {
this.eventSource.close();
this.eventSource = null;
}
}
}
/**
* 폴링 기반 모니터 (state-management.php?action=get)
*
* 1초 간격으로 state를 조회하고 diff 발생 시 콜백을 호출합니다.
*/
class PollingMonitor extends InstallationMonitor {
/**
* 응답 파싱 연속 실패 허용 횟수.
*
* 일시적 오류로 사용자를 놀라게 하지 않을 만큼 크고,
* 서버가 계속 빈 본문을 돌려주는 상황에서 사용자를 무한정 기다리게 하지 않을 만큼 작다.
*/
static MAX_PARSE_FAILURES = 5;
constructor(callbacks, options = {}) {
super(callbacks, options);
this.stateUrl = options.stateUrl;
this.intervalMs = options.intervalMs || 1000;
// stuck 판정 임계값 (기본 90 초). last_updated 가 이 시간 이상 갱신되지 않으면
// 워커가 명령 실행 중 deadlock/silent-crash 됐다고 판정해 onError 발화.
this.stuckThresholdMs = options.stuckThresholdMs || 90000;
this.timer = null;
this.lastLogOffset = 0;
this.lastCompletedTasks = [];
this.lastCurrentTask = null;
this.lastStatus = null;
this.terminated = false;
// stuck 감지용 — 마지막으로 last_updated 가 변한 시각(client clock 기준 ms)
this.lastUpdatedSeen = null;
this.lastUpdatedClientMs = Date.now();
this.stuckEmitted = false;
// 응답 파싱 연속 실패 횟수 — 성공 시 0 으로 리셋한다.
// 서버가 빈 본문/비 JSON 을 계속 돌려주면 콘솔 경고만 무한히 쌓이고
// 화면에는 아무 일도 일어나지 않는다 (gnuboard/g7#62). 임계치에서 사용자에게 알린다.
this.parseFailures = 0;
}
start() {
this._invoke('onConnected');
this._poll();
this.timer = setInterval(() => this._poll(), this.intervalMs);
}
stop() {
super.stop();
this.terminated = true;
if (this.timer) {
clearInterval(this.timer);
this.timer = null;
}
}
async _poll() {
if (this.terminated) return;
try {
const url = `${this.stateUrl}${this.stateUrl.includes('?') ? '&' : '?'}log_offset=${this.lastLogOffset}`;
const res = await fetch(url, { cache: 'no-store' });
if (!res.ok) {
return; // 일시적인 네트워크 오류는 무시 (다음 tick에서 재시도)
}
const text = await res.text();
let state;
try {
state = JSON.parse(text);
} catch (parseError) {
this.parseFailures += 1;
console.warn('[PollingMonitor] poll response was not JSON:', this.parseFailures, parseError);
if (this.parseFailures >= PollingMonitor.MAX_PARSE_FAILURES) {
this.stop();
this._invoke('onError', {
message: null,
message_key: 'error_polling_response_invalid',
error: null,
task: null,
target: null,
manual_commands: null,
parse_failures: this.parseFailures,
});
}
return;
}
this.parseFailures = 0;
this._diffAndEmit(state);
} catch (e) {
console.warn('[PollingMonitor] poll failed:', e);
}
}
_diffAndEmit(state) {
// 1. 새 로그 송출
if (Array.isArray(state.logs) && state.logs.length > 0) {
for (const log of state.logs) {
const msg = (typeof log === 'object' && log.message) ? log.message : String(log);
this._invoke('onLog', msg);
}
}
if (typeof state.log_total === 'number') {
this.lastLogOffset = state.log_total;
}
// 2. current_task 변화 감지 → onTaskStart
const currentTask = state.current_task || null;
if (currentTask && currentTask !== this.lastCurrentTask) {
// target 분리: "taskId:target" 형식
let taskId = currentTask;
let target = null;
const colonIdx = currentTask.indexOf(':');
if (colonIdx !== -1) {
taskId = currentTask.substring(0, colonIdx);
target = currentTask.substring(colonIdx + 1);
}
this._invoke('onTaskStart', taskId, null, target);
this.lastCurrentTask = currentTask;
}
// 3. completed_tasks diff → onTaskComplete
const completedTasks = Array.isArray(state.completed_tasks) ? state.completed_tasks : [];
for (const key of completedTasks) {
if (!this.lastCompletedTasks.includes(key)) {
let taskId = key;
let target = null;
const colonIdx = key.indexOf(':');
if (colonIdx !== -1) {
taskId = key.substring(0, colonIdx);
target = key.substring(colonIdx + 1);
}
this._invoke('onTaskComplete', taskId, null, target);
}
}
this.lastCompletedTasks = completedTasks.slice();
// 4. rollback_failure 저장 (에러/중단 처리 시 UI에 표시)
if (state.rollback_failure && this.lastStatus !== state.status) {
this._invoke('onRollbackFailed', {
message: state.rollback_failure.message || null,
detail: state.rollback_failure.detail_key || null,
});
}
// 4-1. stuck 감지 — running 상태에서 last_updated 가 stuckThresholdMs 이상 갱신 안 되면 에러 처리
// 서버 측 워커가 exec() 중 deadlock 되거나 PHP 프로세스가 silent crash 된 케이스를 client 가 알림.
if (state.status === 'running' && !this.stuckEmitted) {
if (state.last_updated !== this.lastUpdatedSeen) {
this.lastUpdatedSeen = state.last_updated;
this.lastUpdatedClientMs = Date.now();
} else if (this.lastUpdatedSeen !== null) {
const idleMs = Date.now() - this.lastUpdatedClientMs;
if (idleMs >= this.stuckThresholdMs) {
this.stuckEmitted = true;
this.stop();
this._invoke('onError', {
message: null,
message_key: 'error_installation_stuck',
error: null,
task: state.current_task || null,
target: null,
manual_commands: null,
stuck: true,
idle_seconds: Math.floor(idleMs / 1000),
});
return;
}
}
}
// 5. status 전환 감지
const status = state.status;
if (status && status !== this.lastStatus) {
switch (status) {
case 'completed':
this.stop();
this._invoke('onComplete', { message: state.error_detail || null, redirect: '/install/' });
break;
case 'failed':
this.stop();
this._invoke('onError', {
message: state.error_detail || null,
message_key: state.error_message_key || null,
error: state.error_detail || null,
task: state.failed_task || null,
target: null,
manual_commands: state.manual_commands || null,
});
break;
case 'aborted':
this.stop();
this._invoke('onAbort', state);
break;
}
}
this.lastStatus = status;
}
}
// 전역 노출
global.G7InstallationMonitor = {
InstallationMonitor,
SseMonitor,
PollingMonitor,
};
})(window);