한국어 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 이 되던 것도 함께 막았다.
362 lines
14 KiB
JavaScript
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);
|