using Hangfire; using Hangfire.Storage; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.RazorPages; using QuantEngine.Web.Services; namespace QuantEngine.Web.Pages.Admin.Operations; [Authorize(AuthenticationSchemes = AdminAuthDefaults.Scheme)] public class IndexModel : PageModel { private readonly ILogger _logger; public List? ScheduledJobs { get; set; } public List? RecentExecutions { get; set; } public int TotalJobsCount { get; set; } public int ActiveJobsCount { get; set; } public int InactiveJobsCount { get; set; } public int PendingJobsCount { get; set; } public bool IsJobProcessorRunning { get; set; } public DateTime? LastRefreshTime { get; set; } public string? StatusMessage { get; set; } public IndexModel(ILogger logger) { _logger = logger; } public Task OnGetAsync() { LoadOperationsData(); return Task.CompletedTask; } public async Task OnPostTriggerJobAsync(string jobId) { if (string.IsNullOrEmpty(jobId)) { TempData["ErrorMessage"] = "올바르지 않은 작업 ID입니다."; return RedirectToPage(); } try { RecurringJob.TriggerJob(jobId); TempData["SuccessMessage"] = $"작업 '{DescribeJobId(jobId)}'이(가) 즉시 실행 큐에 등록되었습니다."; _logger.LogInformation("Manually triggered Hangfire recurring job: {JobId}", jobId); } catch (Exception ex) { _logger.LogError(ex, "Failed to trigger Hangfire job: {JobId}", jobId); TempData["ErrorMessage"] = $"작업 실행 실패: {ex.Message}"; } return RedirectToPage(); } private void LoadOperationsData() { try { LastRefreshTime = DateTime.UtcNow; // All data below comes directly from Hangfire's own storage // (JobStorage.Current), which already backs the real // recurring jobs registered in SchedulerService.InitializeSchedules // (daily-collection, hourly-price-update, weekly-report, // monthly-optimization) and every job it has actually run. // This page previously returned four fabricated job names and // fake execution timestamps with no connection to Hangfire at all. using IStorageConnection connection = JobStorage.Current.GetConnection(); var monitoringApi = JobStorage.Current.GetMonitoringApi(); var recurringJobs = connection.GetRecurringJobs(); ScheduledJobs = recurringJobs .Select(j => new ScheduledJobInfo( j.Id, DescribeJobId(j.Id), DescribeCron(j.Cron), j.NextExecution, string.IsNullOrEmpty(j.Error))) .ToList(); TotalJobsCount = ScheduledJobs.Count; ActiveJobsCount = ScheduledJobs.Count(j => j.IsEnabled); InactiveJobsCount = TotalJobsCount - ActiveJobsCount; var succeeded = monitoringApi.SucceededJobs(0, 10) .Select(kv => new JobExecutionInfo( kv.Value.Job?.Method.Name ?? kv.Key, kv.Value.SucceededAt ?? DateTime.UtcNow, kv.Value.SucceededAt, true)); var failed = monitoringApi.FailedJobs(0, 10) .Select(kv => new JobExecutionInfo( kv.Value.Job?.Method.Name ?? kv.Key, kv.Value.FailedAt ?? DateTime.UtcNow, kv.Value.FailedAt, false)); RecentExecutions = succeeded.Concat(failed) .OrderByDescending(e => e.StartedAt) .Take(10) .ToList(); var servers = monitoringApi.Servers(); IsJobProcessorRunning = servers.Count > 0; PendingJobsCount = (int)monitoringApi.EnqueuedCount("default"); StatusMessage = IsJobProcessorRunning ? $"{servers.Count}개 워커 서버 운영 중" : "Hangfire 서버가 등록되어 있지 않습니다"; _logger.LogInformation("Operations data loaded from Hangfire ({Count} recurring jobs, {Servers} servers)", ScheduledJobs.Count, servers.Count); } catch (Exception ex) { _logger.LogError(ex, "Failed to load operations data from Hangfire"); ScheduledJobs = []; RecentExecutions = []; IsJobProcessorRunning = false; StatusMessage = "Hangfire 상태 조회 실패 - 로그 확인 필요"; } } // Purely cosmetic: renders the actual CRON expression from Hangfire in // readable Korean where we recognize the exact pattern SchedulerService // registers, falling back to the raw CRON string for anything else so // this never hides or fabricates a schedule that isn't really there. private static string DescribeCron(string cron) => cron switch { "0 9 * * *" => "매일 09:00", "0 9-15 * * 1-5" => "평일 09-15시 매시", "0 17 * * 5" => "매주 금요일 17:00", "0 2 1 * *" => "매월 1일 02:00", _ => cron, }; // Same rationale as DescribeCron: display label for a real Hangfire // recurring-job id, not a substitute for it. Unknown ids pass through // unchanged. private static string DescribeJobId(string jobId) => jobId switch { "daily-collection" => "일일 데이터 수집", "hourly-price-update" => "시간별 가격 갱신", "weekly-report" => "주간 리포트 생성", "monthly-optimization" => "월간 최적화", _ => jobId, }; } public record ScheduledJobInfo(string JobId, string JobName, string Schedule, DateTime? NextRun, bool IsEnabled); public record JobExecutionInfo(string JobName, DateTime StartedAt, DateTime? CompletedAt, bool IsSuccess);