fix: Remove fake hardcoded data, rebuild admin layout with real Tabler components

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.
This commit is contained in:
2026-07-12 01:54:00 +09:00
parent 4b29fafcff
commit 489da25f1b
7 changed files with 218 additions and 316 deletions
@@ -1,3 +1,5 @@
using Hangfire;
using Hangfire.Storage;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc.RazorPages;
using QuantEngine.Web.Services;
@@ -24,50 +26,105 @@ public class IndexModel : PageModel
_logger = logger;
}
public async Task OnGetAsync()
public Task OnGetAsync()
{
await LoadOperationsData();
LoadOperationsData();
return Task.CompletedTask;
}
private async Task LoadOperationsData()
private void LoadOperationsData()
{
try
{
LastRefreshTime = DateTime.UtcNow;
ScheduledJobs = new List<ScheduledJobInfo>
{
new("KIS 데이터 수집", "매일 09:00", DateTime.UtcNow.AddHours(1), true),
new("포트폴리오 스냅샷", "매일 17:00", DateTime.UtcNow.AddHours(8), true),
new("일일 리포트 생성", "매일 08:00", DateTime.UtcNow.AddHours(-1), true),
new("데이터 정리", "주 1회 (월)", DateTime.UtcNow.AddDays(5), true)
};
// 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();
RecentExecutions = new List<JobExecutionInfo>
{
new("포트폴리오 스냅샷", DateTime.UtcNow.AddHours(-2), DateTime.UtcNow.AddHours(-2).AddSeconds(45), true),
new("KIS 데이터 수집", DateTime.UtcNow.AddHours(-4), DateTime.UtcNow.AddHours(-4).AddSeconds(120), true),
new("일일 리포트 생성", DateTime.UtcNow.AddHours(-6), DateTime.UtcNow.AddHours(-6).AddSeconds(30), true),
new("KIS 데이터 수집", DateTime.UtcNow.AddHours(-24), DateTime.UtcNow.AddHours(-24).AddSeconds(110), true)
};
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;
PendingJobsCount = 0;
IsJobProcessorRunning = true;
StatusMessage = "모든 작업이 정상적으로 실행 중입니다.";
_logger.LogInformation("Operations data loaded successfully");
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");
_logger.LogError(ex, "Failed to load operations data from Hangfire");
ScheduledJobs = [];
RecentExecutions = [];
StatusMessage = "데이터 로딩 중 오류가 발생했습니다.";
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);