489da25f1b
Root cause: user asked why logout was missing. Playwright audit against production found logout works fine, but surfaced two real defects and led to a wider audit that found extensive fabricated data across the admin pages -- none of it backed by the database despite CLAUDE.md's policy that all data must come from DB records. Layout (_AdminLayout.cshtml): - Full rewrite using Tabler's actual navbar-vertical/page-wrapper/footer component structure instead of ad-hoc inline CSS. The old layout had no footer element at all, and its mobile breakpoint CSS hid the sidebar off-screen (left: -260px) with no hamburger button to bring it back -- verified via Playwright screenshot at 375px width that the entire nav menu was inaccessible on mobile, leaving only Logout reachable. Tabler's navbar-toggler + Bootstrap collapse (bundled in tabler.min.js) now restores it; verified the toggle actually opens the menu via Playwright. - Active nav-link highlighting moved from client-side JS string matching to a server-side Razor helper against Context.Request.Path. Fake/hardcoded data removed or replaced with real DB/Hangfire state: - Dashboard: deleted the "최근 시스템 이벤트" table (3 rows hardcoded from DateTime.Now with fake descriptions like "시스템 초기화" / "데이터베이스 백업" -- no backing table exists). Removed hardcoded "정상"/"연결됨" status badges and "버전: v0.1.0"/"업타임: 정상"; replaced with a real IsDatabaseConnected flag (true only if the page's actual DB queries succeeded) and the real IWebHostEnvironment.EnvironmentName. - Monitoring: removed hardcoded "API 서버: 운영 중" (no real signal backs it) and wired "데이터베이스: 연결 정상/끊김" to the same real success/failure state as the page's own DB calls. - Operations: this page was entirely fabricated -- ScheduledJobs, RecentExecutions, IsJobProcessorRunning, PendingJobsCount, and StatusMessage were all static values with zero connection to Hangfire, despite Hangfire actually running in production (confirmed via journalctl: ServerWatchdog, RecurringJobScheduler dispatchers active) with 4 real recurring jobs registered in SchedulerService (daily-collection, hourly-price-update, weekly-report, monthly-optimization). Rewrote to query JobStorage.Current.GetConnection().GetRecurringJobs() and GetMonitoringApi() directly: real scheduled jobs, real succeeded/ failed executions, real server count, real enqueued count. Verified locally (SSH-tunneled to prod DB) that this now returns the actual 4 registered jobs with correct next-run times and one real RunDailyCollectionAsync execution. Also fixed the page-title duplication on Monitoring/Operations (ViewData["Title"] included "- QuantEngine" AND the layout appended it again -> "모니터링 - QuantEngine - QuantEngine" in the browser tab). Separately discovered (not fixed in this commit, flagging for follow-up): Hangfire's SchedulerService.InitializeSchedules() fails every startup with "Cannot resolve scoped service 'SchedulerService' from root provider" -- the 4 recurring jobs above still show up because they persist from an earlier successful registration, but re-registration is silently broken on every current boot. Verified end-to-end with Playwright against a local instance (SSH tunnel to production Postgres): login, all 5 admin pages render without errors, mobile hamburger opens the sidebar, and Operations shows genuine Hangfire data.
132 lines
5.2 KiB
C#
132 lines
5.2 KiB
C#
using Hangfire;
|
|
using Hangfire.Storage;
|
|
using Microsoft.AspNetCore.Authorization;
|
|
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<IndexModel> _logger;
|
|
|
|
public List<ScheduledJobInfo>? ScheduledJobs { get; set; }
|
|
public List<JobExecutionInfo>? 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<IndexModel> logger)
|
|
{
|
|
_logger = logger;
|
|
}
|
|
|
|
public Task OnGetAsync()
|
|
{
|
|
LoadOperationsData();
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
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(
|
|
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 JobName, string Schedule, DateTime? NextRun, bool IsEnabled);
|
|
public record JobExecutionInfo(string JobName, DateTime StartedAt, DateTime? CompletedAt, bool IsSuccess);
|