워커 서비스 로그 통합
This commit is contained in:
@@ -661,6 +661,12 @@ $routes->group('manage', ['namespace' => 'App\Controllers\Manage'], function ($r
|
||||
/** API - 로그인로그관리 */
|
||||
$routes->get('loginlog/getLogList', 'LoginLog::getLogList');
|
||||
$routes->get('loginlog/excel', 'LoginLog::excel');
|
||||
|
||||
/** Worker 로그 관리 */
|
||||
$routes->get('workerlog', 'WorkerLog::index');
|
||||
$routes->get('workerlog/stream', 'WorkerLog::stream');
|
||||
$routes->get('workerlog/download', 'WorkerLog::download');
|
||||
$routes->post('workerlog/delete', 'WorkerLog::delete');
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
221
app/Controllers/Manage/WorkerLog.php
Normal file
221
app/Controllers/Manage/WorkerLog.php
Normal file
@@ -0,0 +1,221 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controllers\Manage;
|
||||
|
||||
use App\Controllers\BaseController;
|
||||
|
||||
class WorkerLog extends BaseController
|
||||
{
|
||||
/**
|
||||
* Worker 로그 통합 뷰어
|
||||
*/
|
||||
public function index()
|
||||
{
|
||||
$data['title'] = 'Worker 로그 통합 관리';
|
||||
|
||||
// 로그 디렉토리 목록
|
||||
$logDirs = [
|
||||
'api_receiver' => ROOTPATH . 'worker/logs',
|
||||
'naver_worker' => WRITEPATH . 'logs/worker'
|
||||
];
|
||||
|
||||
// 날짜 필터 (기본값: 오늘)
|
||||
$date = $this->request->getGet('date') ?? date('Y-m-d');
|
||||
$logType = $this->request->getGet('type') ?? 'all';
|
||||
|
||||
$logs = [];
|
||||
|
||||
// API Receiver 로그 읽기
|
||||
if ($logType === 'all' || $logType === 'api_receiver') {
|
||||
$apiLogFile = $logDirs['api_receiver'] . '/' . $date . '.log';
|
||||
if (file_exists($apiLogFile)) {
|
||||
$logs['api_receiver'] = $this->parseLogFile($apiLogFile);
|
||||
}
|
||||
}
|
||||
|
||||
// Naver Worker 로그 읽기
|
||||
if ($logType === 'all' || $logType === 'naver_worker') {
|
||||
$workerLogFile = $logDirs['naver_worker'] . '/' . $date . '.log';
|
||||
if (file_exists($workerLogFile)) {
|
||||
$logs['naver_worker'] = $this->parseLogFile($workerLogFile);
|
||||
}
|
||||
|
||||
// Failed 로그도 읽기
|
||||
$failedLogFile = $logDirs['naver_worker'] . '/' . $date . '_failed.log';
|
||||
if (file_exists($failedLogFile)) {
|
||||
$logs['naver_worker_failed'] = $this->parseLogFile($failedLogFile);
|
||||
}
|
||||
}
|
||||
|
||||
// 모든 로그를 시간순으로 통합 정렬
|
||||
$allLogs = [];
|
||||
foreach ($logs as $type => $entries) {
|
||||
foreach ($entries as $entry) {
|
||||
$entry['source'] = $type;
|
||||
$allLogs[] = $entry;
|
||||
}
|
||||
}
|
||||
|
||||
// 시간 역순 정렬 (최신순)
|
||||
usort($allLogs, function($a, $b) {
|
||||
return strtotime($b['timestamp']) - strtotime($a['timestamp']);
|
||||
});
|
||||
|
||||
$data['logs'] = $allLogs;
|
||||
$data['date'] = $date;
|
||||
$data['logType'] = $logType;
|
||||
$data['logDirs'] = $logDirs;
|
||||
|
||||
// 사용 가능한 날짜 목록 (최근 30일)
|
||||
$data['availableDates'] = $this->getAvailableLogDates($logDirs);
|
||||
|
||||
return view('pages/manage/worker_log', $data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 로그 파일 파싱
|
||||
*/
|
||||
private function parseLogFile($filePath)
|
||||
{
|
||||
$logs = [];
|
||||
$lines = file($filePath, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
|
||||
|
||||
foreach ($lines as $line) {
|
||||
// 로그 포맷: [2025-12-22 10:30:45] [INFO] [NaverWorker::run:95] 메시지
|
||||
if (preg_match('/\[(.+?)\]\s*\[(.+?)\]\s*(?:\[(.+?)\]\s*)?(.+)/', $line, $matches)) {
|
||||
$logs[] = [
|
||||
'timestamp' => $matches[1],
|
||||
'level' => $matches[2],
|
||||
'location' => $matches[3] ?? '',
|
||||
'message' => $matches[4]
|
||||
];
|
||||
} else {
|
||||
// 파싱 실패한 경우 원본 그대로
|
||||
$logs[] = [
|
||||
'timestamp' => date('Y-m-d H:i:s'),
|
||||
'level' => 'UNKNOWN',
|
||||
'location' => '',
|
||||
'message' => $line
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $logs;
|
||||
}
|
||||
|
||||
/**
|
||||
* 로그 파일이 존재하는 날짜 목록 가져오기
|
||||
*/
|
||||
private function getAvailableLogDates($logDirs)
|
||||
{
|
||||
$dates = [];
|
||||
|
||||
foreach ($logDirs as $dir) {
|
||||
if (!is_dir($dir)) continue;
|
||||
|
||||
$files = scandir($dir);
|
||||
foreach ($files as $file) {
|
||||
if (preg_match('/(\d{4}-\d{2}-\d{2})(?:_failed)?\.log$/', $file, $matches)) {
|
||||
$dates[$matches[1]] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$dates = array_keys($dates);
|
||||
rsort($dates); // 최신순 정렬
|
||||
|
||||
return array_slice($dates, 0, 30); // 최근 30일만
|
||||
}
|
||||
|
||||
/**
|
||||
* 실시간 로그 스트리밍 (Ajax)
|
||||
*/
|
||||
public function stream()
|
||||
{
|
||||
$type = $this->request->getGet('type') ?? 'naver_worker';
|
||||
$lastId = (int) ($this->request->getGet('lastId') ?? 0);
|
||||
|
||||
$logDirs = [
|
||||
'api_receiver' => ROOTPATH . 'worker/logs',
|
||||
'naver_worker' => WRITEPATH . 'logs/worker'
|
||||
];
|
||||
|
||||
$logFile = $logDirs[$type] . '/' . date('Y-m-d') . '.log';
|
||||
|
||||
$newLogs = [];
|
||||
if (file_exists($logFile)) {
|
||||
$lines = file($logFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
|
||||
|
||||
// lastId 이후의 로그만 반환
|
||||
if ($lastId < count($lines)) {
|
||||
$newLines = array_slice($lines, $lastId);
|
||||
|
||||
foreach ($newLines as $line) {
|
||||
if (preg_match('/\[(.+?)\]\s*\[(.+?)\]\s*(?:\[(.+?)\]\s*)?(.+)/', $line, $matches)) {
|
||||
$newLogs[] = [
|
||||
'timestamp' => $matches[1],
|
||||
'level' => $matches[2],
|
||||
'location' => $matches[3] ?? '',
|
||||
'message' => $matches[4]
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $this->response->setJSON([
|
||||
'success' => true,
|
||||
'logs' => $newLogs,
|
||||
'lastId' => $lastId + count($newLogs)
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 로그 파일 다운로드
|
||||
*/
|
||||
public function download()
|
||||
{
|
||||
$date = $this->request->getGet('date') ?? date('Y-m-d');
|
||||
$type = $this->request->getGet('type') ?? 'naver_worker';
|
||||
|
||||
$logDirs = [
|
||||
'api_receiver' => ROOTPATH . 'worker/logs',
|
||||
'naver_worker' => WRITEPATH . 'logs/worker'
|
||||
];
|
||||
|
||||
$logFile = $logDirs[$type] . '/' . $date . '.log';
|
||||
|
||||
if (!file_exists($logFile)) {
|
||||
return $this->response->setStatusCode(404)->setBody('로그 파일을 찾을 수 없습니다.');
|
||||
}
|
||||
|
||||
return $this->response->download($logFile, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 로그 파일 삭제
|
||||
*/
|
||||
public function delete()
|
||||
{
|
||||
$date = $this->request->getPost('date');
|
||||
$type = $this->request->getPost('type');
|
||||
|
||||
if (!$date || !$type) {
|
||||
return $this->response->setJSON(['success' => false, 'message' => '필수 파라미터 누락']);
|
||||
}
|
||||
|
||||
$logDirs = [
|
||||
'api_receiver' => ROOTPATH . 'worker/logs',
|
||||
'naver_worker' => WRITEPATH . 'logs/worker'
|
||||
];
|
||||
|
||||
$logFile = $logDirs[$type] . '/' . $date . '.log';
|
||||
|
||||
if (file_exists($logFile)) {
|
||||
unlink($logFile);
|
||||
return $this->response->setJSON(['success' => true, 'message' => '로그 파일이 삭제되었습니다.']);
|
||||
}
|
||||
|
||||
return $this->response->setJSON(['success' => false, 'message' => '로그 파일을 찾을 수 없습니다.']);
|
||||
}
|
||||
}
|
||||
284
app/Views/pages/manage/worker_log.php
Normal file
284
app/Views/pages/manage/worker_log.php
Normal file
@@ -0,0 +1,284 @@
|
||||
<?= $this->extend('layouts/base') ?>
|
||||
|
||||
<?= $this->section('content') ?>
|
||||
<div class="app-page-title">
|
||||
<div class="page-title-wrapper">
|
||||
<div class="page-title-heading">
|
||||
<div class="page-title-icon">
|
||||
<i class="pe-7s-monitor icon-gradient bg-mean-fruit"></i>
|
||||
</div>
|
||||
<div>
|
||||
Worker 로그 통합 관리
|
||||
<div class="page-title-subheading">
|
||||
API Receiver 및 Worker 서비스의 로그를 한 곳에서 확인할 수 있습니다.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
<div class="main-card mb-3 card">
|
||||
<div class="card-header">
|
||||
<div class="btn-actions-pane-left">
|
||||
<div class="nav">
|
||||
<button class="btn btn-sm btn-primary" onclick="refreshLogs()">
|
||||
<i class="fa fa-sync"></i> 새로고침
|
||||
</button>
|
||||
<button class="btn btn-sm btn-success" id="autoRefreshBtn" onclick="toggleAutoRefresh()">
|
||||
<i class="fa fa-play"></i> 자동 새로고침 시작
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="btn-actions-pane-right">
|
||||
<button class="btn btn-sm btn-info" onclick="downloadLog()">
|
||||
<i class="fa fa-download"></i> 로그 다운로드
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<!-- 필터 영역 -->
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-4">
|
||||
<label>날짜 선택</label>
|
||||
<select class="form-control" id="dateSelect" onchange="changeDate()">
|
||||
<?php foreach ($availableDates as $availDate): ?>
|
||||
<option value="<?= $availDate ?>" <?= $availDate === $date ? 'selected' : '' ?>>
|
||||
<?= $availDate ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label>로그 타입</label>
|
||||
<select class="form-control" id="typeSelect" onchange="changeType()">
|
||||
<option value="all" <?= $logType === 'all' ? 'selected' : '' ?>>전체</option>
|
||||
<option value="api_receiver" <?= $logType === 'api_receiver' ? 'selected' : '' ?>>API Receiver</option>
|
||||
<option value="naver_worker" <?= $logType === 'naver_worker' ? 'selected' : '' ?>>Naver Worker</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label>레벨 필터</label>
|
||||
<select class="form-control" id="levelFilter" onchange="filterByLevel()">
|
||||
<option value="all">전체</option>
|
||||
<option value="ERROR">ERROR</option>
|
||||
<option value="INFO">INFO</option>
|
||||
<option value="DEBUG">DEBUG</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 통계 영역 -->
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-3">
|
||||
<div class="card mb-3 widget-content bg-midnight-bloom">
|
||||
<div class="widget-content-wrapper text-white">
|
||||
<div class="widget-content-left">
|
||||
<div class="widget-heading">전체 로그</div>
|
||||
<div class="widget-subheading">Total Logs</div>
|
||||
</div>
|
||||
<div class="widget-content-right">
|
||||
<div class="widget-numbers text-white">
|
||||
<span id="totalCount"><?= count($logs) ?></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="card mb-3 widget-content bg-arielle-smile">
|
||||
<div class="widget-content-wrapper text-white">
|
||||
<div class="widget-content-left">
|
||||
<div class="widget-heading">INFO</div>
|
||||
<div class="widget-subheading">Information</div>
|
||||
</div>
|
||||
<div class="widget-content-right">
|
||||
<div class="widget-numbers text-white">
|
||||
<span id="infoCount"><?= count(array_filter($logs, fn($l) => $l['level'] === 'INFO')) ?></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="card mb-3 widget-content bg-grow-early">
|
||||
<div class="widget-content-wrapper text-white">
|
||||
<div class="widget-content-left">
|
||||
<div class="widget-heading">ERROR</div>
|
||||
<div class="widget-subheading">Errors</div>
|
||||
</div>
|
||||
<div class="widget-content-right">
|
||||
<div class="widget-numbers text-white">
|
||||
<span id="errorCount"><?= count(array_filter($logs, fn($l) => $l['level'] === 'ERROR')) ?></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="card mb-3 widget-content bg-premium-dark">
|
||||
<div class="widget-content-wrapper text-white">
|
||||
<div class="widget-content-left">
|
||||
<div class="widget-heading">DEBUG</div>
|
||||
<div class="widget-subheading">Debug Logs</div>
|
||||
</div>
|
||||
<div class="widget-content-right">
|
||||
<div class="widget-numbers text-white">
|
||||
<span id="debugCount"><?= count(array_filter($logs, fn($l) => $l['level'] === 'DEBUG')) ?></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 로그 테이블 -->
|
||||
<div class="table-responsive" style="max-height: 600px; overflow-y: auto;">
|
||||
<table class="table table-hover table-striped table-sm" id="logTable">
|
||||
<thead class="thead-dark" style="position: sticky; top: 0; z-index: 10;">
|
||||
<tr>
|
||||
<th style="width: 150px;">시간</th>
|
||||
<th style="width: 80px;">레벨</th>
|
||||
<th style="width: 120px;">소스</th>
|
||||
<th style="width: 150px;">위치</th>
|
||||
<th>메시지</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="logTableBody">
|
||||
<?php if (empty($logs)): ?>
|
||||
<tr>
|
||||
<td colspan="5" class="text-center">로그가 없습니다.</td>
|
||||
</tr>
|
||||
<?php else: ?>
|
||||
<?php foreach ($logs as $log): ?>
|
||||
<tr class="log-row" data-level="<?= esc($log['level']) ?>">
|
||||
<td><small><?= esc($log['timestamp']) ?></small></td>
|
||||
<td>
|
||||
<span class="badge badge-<?= $log['level'] === 'ERROR' ? 'danger' : ($log['level'] === 'INFO' ? 'success' : 'secondary') ?>">
|
||||
<?= esc($log['level']) ?>
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge badge-<?= strpos($log['source'], 'failed') !== false ? 'warning' : 'info' ?>">
|
||||
<?php
|
||||
$sourceLabel = [
|
||||
'api_receiver' => 'API Receiver',
|
||||
'naver_worker' => 'Worker',
|
||||
'naver_worker_failed' => 'Worker (Failed)'
|
||||
];
|
||||
echo $sourceLabel[$log['source']] ?? $log['source'];
|
||||
?>
|
||||
</span>
|
||||
</td>
|
||||
<td><small><?= esc($log['location']) ?></small></td>
|
||||
<td>
|
||||
<small style="word-break: break-all;">
|
||||
<?= esc($log['message']) ?>
|
||||
</small>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
<?php endif; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let autoRefreshInterval = null;
|
||||
let isAutoRefresh = false;
|
||||
|
||||
function changeDate() {
|
||||
const date = document.getElementById('dateSelect').value;
|
||||
const type = document.getElementById('typeSelect').value;
|
||||
window.location.href = '<?= base_url('manage/workerlog') ?>?date=' + date + '&type=' + type;
|
||||
}
|
||||
|
||||
function changeType() {
|
||||
const date = document.getElementById('dateSelect').value;
|
||||
const type = document.getElementById('typeSelect').value;
|
||||
window.location.href = '<?= base_url('manage/workerlog') ?>?date=' + date + '&type=' + type;
|
||||
}
|
||||
|
||||
function refreshLogs() {
|
||||
location.reload();
|
||||
}
|
||||
|
||||
function toggleAutoRefresh() {
|
||||
if (isAutoRefresh) {
|
||||
clearInterval(autoRefreshInterval);
|
||||
isAutoRefresh = false;
|
||||
document.getElementById('autoRefreshBtn').innerHTML = '<i class="fa fa-play"></i> 자동 새로고침 시작';
|
||||
document.getElementById('autoRefreshBtn').classList.remove('btn-danger');
|
||||
document.getElementById('autoRefreshBtn').classList.add('btn-success');
|
||||
} else {
|
||||
autoRefreshInterval = setInterval(refreshLogs, 10000); // 10초마다
|
||||
isAutoRefresh = true;
|
||||
document.getElementById('autoRefreshBtn').innerHTML = '<i class="fa fa-stop"></i> 자동 새로고침 중지';
|
||||
document.getElementById('autoRefreshBtn').classList.remove('btn-success');
|
||||
document.getElementById('autoRefreshBtn').classList.add('btn-danger');
|
||||
}
|
||||
}
|
||||
|
||||
function filterByLevel() {
|
||||
const level = document.getElementById('levelFilter').value;
|
||||
const rows = document.querySelectorAll('.log-row');
|
||||
|
||||
let visibleCount = 0;
|
||||
let infoCount = 0;
|
||||
let errorCount = 0;
|
||||
let debugCount = 0;
|
||||
|
||||
rows.forEach(row => {
|
||||
const rowLevel = row.getAttribute('data-level');
|
||||
if (level === 'all' || rowLevel === level) {
|
||||
row.style.display = '';
|
||||
visibleCount++;
|
||||
} else {
|
||||
row.style.display = 'none';
|
||||
}
|
||||
|
||||
// 통계 계산
|
||||
if (rowLevel === 'INFO') infoCount++;
|
||||
if (rowLevel === 'ERROR') errorCount++;
|
||||
if (rowLevel === 'DEBUG') debugCount++;
|
||||
});
|
||||
|
||||
// 통계 업데이트
|
||||
if (level === 'all') {
|
||||
document.getElementById('totalCount').textContent = visibleCount;
|
||||
}
|
||||
}
|
||||
|
||||
function downloadLog() {
|
||||
const date = document.getElementById('dateSelect').value;
|
||||
const type = document.getElementById('typeSelect').value;
|
||||
window.location.href = '<?= base_url('manage/workerlog/download') ?>?date=' + date + '&type=' + type;
|
||||
}
|
||||
|
||||
// 페이지 로드 시 스크롤을 테이블 하단으로 (최신 로그가 위에 있으므로 생략)
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.table-responsive {
|
||||
border: 1px solid #dee2e6;
|
||||
}
|
||||
|
||||
.log-row:hover {
|
||||
background-color: #f8f9fa !important;
|
||||
}
|
||||
|
||||
.badge {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.widget-content {
|
||||
padding: 1rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
<?= $this->endSection() ?>
|
||||
Reference in New Issue
Block a user