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:
@@ -26,71 +26,28 @@
|
||||
<small class="text-muted">데이터 수집 실행</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="stat-card">
|
||||
<div class="stat-card-label">시스템 상태</div>
|
||||
<div class="stat-card-number">
|
||||
<span class="status-dot active"></span>정상
|
||||
</div>
|
||||
<small class="text-muted">모든 서비스 운영 중</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="stat-card">
|
||||
<div class="stat-card-label">데이터베이스</div>
|
||||
<div class="stat-card-number">
|
||||
<span class="status-dot active"></span>연결됨
|
||||
@if (Model.IsDatabaseConnected)
|
||||
{
|
||||
<span class="status-dot active"></span><text>연결됨</text>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="status-dot" style="background-color:#e74c3c;"></span><text>연결 끊김</text>
|
||||
}
|
||||
</div>
|
||||
<small class="text-muted">PostgreSQL 정상</small>
|
||||
<small class="text-muted">@(Model.IsDatabaseConnected ? "PostgreSQL 정상" : "데이터 조회 실패 - 로그 확인 필요")</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main Content Row -->
|
||||
<div class="row">
|
||||
<!-- System Events Card -->
|
||||
<div class="col-lg-8">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3 class="card-title">최근 시스템 이벤트</h3>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table card-table table-vcenter">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>시간</th>
|
||||
<th>이벤트</th>
|
||||
<th>유형</th>
|
||||
<th>상태</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>@DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss")</td>
|
||||
<td>시스템 초기화</td>
|
||||
<td><span class="badge badge-info">INFO</span></td>
|
||||
<td><span class="badge badge-success">완료</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>@DateTime.Now.AddHours(-1).ToString("yyyy-MM-dd HH:mm:ss")</td>
|
||||
<td>데이터 수집 완료</td>
|
||||
<td><span class="badge badge-info">INFO</span></td>
|
||||
<td><span class="badge badge-success">완료</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>@DateTime.Now.AddHours(-2).ToString("yyyy-MM-dd HH:mm:ss")</td>
|
||||
<td>데이터베이스 백업</td>
|
||||
<td><span class="badge badge-secondary">SYSTEM</span></td>
|
||||
<td><span class="badge badge-success">완료</span></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick Actions Card -->
|
||||
<div class="col-lg-4">
|
||||
<div class="col-lg-6">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3 class="card-title">빠른 작업</h3>
|
||||
@@ -117,24 +74,22 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- System Info Card -->
|
||||
<div class="card mt-3">
|
||||
<!-- System Info Card -->
|
||||
<div class="col-lg-6">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3 class="card-title">시스템 정보</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="mb-2">
|
||||
<span class="text-muted">버전:</span>
|
||||
<strong>v0.1.0</strong>
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<span class="text-muted">환경:</span>
|
||||
<strong>Production</strong>
|
||||
<strong>@Model.EnvironmentName</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-muted">업타임:</span>
|
||||
<strong>정상</strong>
|
||||
<span class="text-muted">데이터베이스:</span>
|
||||
<strong>@(Model.IsDatabaseConnected ? "연결됨" : "연결 끊김")</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -10,15 +10,23 @@ public class IndexModel : PageModel
|
||||
{
|
||||
private readonly IWorkspaceRepository _workspaceRepository;
|
||||
private readonly ICollectionRepository _collectionRepository;
|
||||
private readonly IWebHostEnvironment _environment;
|
||||
private readonly ILogger<IndexModel> _logger;
|
||||
|
||||
public int? ActiveUsersCount { get; set; }
|
||||
public int? RecentRunsCount { get; set; }
|
||||
public bool IsDatabaseConnected { get; set; }
|
||||
public string EnvironmentName => _environment.EnvironmentName;
|
||||
|
||||
public IndexModel(IWorkspaceRepository workspaceRepository, ICollectionRepository collectionRepository, ILogger<IndexModel> logger)
|
||||
public IndexModel(
|
||||
IWorkspaceRepository workspaceRepository,
|
||||
ICollectionRepository collectionRepository,
|
||||
IWebHostEnvironment environment,
|
||||
ILogger<IndexModel> logger)
|
||||
{
|
||||
_workspaceRepository = workspaceRepository;
|
||||
_collectionRepository = collectionRepository;
|
||||
_environment = environment;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
@@ -31,10 +39,16 @@ public class IndexModel : PageModel
|
||||
|
||||
var dashboard = await _collectionRepository.GetDashboardStateAsync();
|
||||
RecentRunsCount = string.IsNullOrEmpty(dashboard?.LastRunId) ? 0 : 1;
|
||||
|
||||
// These two queries only complete if the DB round-trip actually
|
||||
// succeeded, so reaching this line is the real signal -- do not
|
||||
// hardcode a static "정상"/"연결됨" badge independent of it.
|
||||
IsDatabaseConnected = true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Dashboard data loading failed");
|
||||
IsDatabaseConnected = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
@page
|
||||
@model QuantEngine.Web.Pages.Admin.Monitoring.IndexModel
|
||||
@{
|
||||
ViewData["Title"] = "모니터링 - QuantEngine";
|
||||
ViewData["Title"] = "모니터링";
|
||||
}
|
||||
|
||||
<div class="page-header d-print-none">
|
||||
@@ -113,17 +113,14 @@
|
||||
<strong>데이터베이스</strong>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<span class="badge bg-success">연결 정상</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="list-group-item">
|
||||
<div class="row align-items-center">
|
||||
<div class="col">
|
||||
<strong>API 서버</strong>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<span class="badge bg-success">운영 중</span>
|
||||
@if (Model.IsDatabaseConnected)
|
||||
{
|
||||
<span class="badge bg-success">연결 정상</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="badge bg-danger">연결 끊김</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -17,6 +17,7 @@ public class IndexModel : PageModel
|
||||
public int FailedRuns24h { get; set; }
|
||||
public DateTime? LastRefreshTime { get; set; }
|
||||
public List<CollectionErrorRecord>? RecentErrors { get; set; }
|
||||
public bool IsDatabaseConnected { get; set; }
|
||||
|
||||
public IndexModel(ICollectionRepository collectionRepository, ILogger<IndexModel> logger)
|
||||
{
|
||||
@@ -60,12 +61,14 @@ public class IndexModel : PageModel
|
||||
}
|
||||
|
||||
RecentErrors = allErrors.OrderByDescending(e => e.CreatedAt).Take(10).ToList();
|
||||
IsDatabaseConnected = true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to load monitoring data");
|
||||
OngoingRuns = [];
|
||||
RecentErrors = [];
|
||||
IsDatabaseConnected = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
@page
|
||||
@model QuantEngine.Web.Pages.Admin.Operations.IndexModel
|
||||
@{
|
||||
ViewData["Title"] = "작업 관리 - QuantEngine";
|
||||
ViewData["Title"] = "작업 관리";
|
||||
}
|
||||
|
||||
<div class="page-header d-print-none">
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
@{
|
||||
Layout = null;
|
||||
|
||||
var currentPath = Context.Request.Path.Value?.ToLowerInvariant() ?? "";
|
||||
string NavActive(string href) => currentPath.StartsWith(href.ToLowerInvariant()) ? "active" : "";
|
||||
}
|
||||
|
||||
<!DOCTYPE html>
|
||||
@@ -16,233 +19,106 @@
|
||||
|
||||
<!-- Custom Admin CSS -->
|
||||
<link rel="stylesheet" href="~/css/admin.css" asp-append-version="true" />
|
||||
|
||||
<style>
|
||||
html, body {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.page {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
width: 260px;
|
||||
background-color: #2c3e50;
|
||||
color: white;
|
||||
overflow-y: auto;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sidebar-brand {
|
||||
padding: 1.5rem;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.sidebar-brand a {
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
font-weight: 600;
|
||||
font-size: 1.25rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.sidebar-nav {
|
||||
padding: 1rem 0;
|
||||
}
|
||||
|
||||
.sidebar-nav .nav-link {
|
||||
padding: 0.75rem 1.5rem;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
text-decoration: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.sidebar-nav .nav-link:hover,
|
||||
.sidebar-nav .nav-link.active {
|
||||
color: white;
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.sidebar-nav .nav-link i {
|
||||
width: 1.25rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
background-color: white;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
padding: 1rem 1.5rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
height: 64px;
|
||||
}
|
||||
|
||||
.topbar-left {
|
||||
font-weight: 600;
|
||||
color: #2c3e50;
|
||||
}
|
||||
|
||||
.topbar-right {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.topbar-right .btn {
|
||||
padding: 0.375rem 0.75rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.page-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 2rem;
|
||||
background-color: #f5f7fa;
|
||||
}
|
||||
|
||||
.container-xl {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.page-header .page-title {
|
||||
font-size: 1.75rem;
|
||||
font-weight: 600;
|
||||
color: #2c3e50;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.page-header .page-subtitle {
|
||||
color: #7a8a99;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
@@media (max-width: 768px) {
|
||||
.sidebar {
|
||||
position: fixed;
|
||||
left: -260px;
|
||||
height: 100vh;
|
||||
z-index: 999;
|
||||
transition: left 0.3s;
|
||||
}
|
||||
|
||||
.sidebar.show {
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrapper">
|
||||
<!-- Topbar -->
|
||||
<div class="topbar">
|
||||
<div class="topbar-left">
|
||||
<span id="page-title">QuantEngine Admin</span>
|
||||
</div>
|
||||
<div class="topbar-right">
|
||||
<a href="/Account/Logout" class="btn btn-sm btn-outline-danger">
|
||||
<i class="ti ti-logout me-1"></i> 로그아웃
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main -->
|
||||
<div class="page">
|
||||
<!-- Sidebar -->
|
||||
<nav class="sidebar">
|
||||
<div class="sidebar-brand">
|
||||
<a href="/Admin/Dashboard">
|
||||
<div class="page">
|
||||
<!-- Sidebar (left) -->
|
||||
<aside class="navbar navbar-vertical navbar-expand-lg navbar-dark" data-bs-theme="dark">
|
||||
<div class="container-fluid">
|
||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#sidebar-menu" aria-controls="sidebar-menu" aria-expanded="false" aria-label="메뉴 토글">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
<h1 class="navbar-brand navbar-brand-autodark">
|
||||
<a href="/Admin/Dashboard" class="d-flex align-items-center gap-2 text-decoration-none">
|
||||
<i class="ti ti-chart-line" style="font-size: 1.5rem;"></i>
|
||||
<span>QuantEngine</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-nav">
|
||||
<a href="/Admin/Dashboard" class="nav-link">
|
||||
<i class="ti ti-dashboard"></i>
|
||||
<span>대시보드</span>
|
||||
</a>
|
||||
<a href="/Admin/Collection" class="nav-link">
|
||||
<i class="ti ti-database"></i>
|
||||
<span>데이터 수집</span>
|
||||
</a>
|
||||
<a href="/Admin/Monitoring" class="nav-link">
|
||||
<i class="ti ti-eye"></i>
|
||||
<span>모니터링</span>
|
||||
</a>
|
||||
<a href="/Admin/Users" class="nav-link">
|
||||
<i class="ti ti-users"></i>
|
||||
<span>사용자 관리</span>
|
||||
</a>
|
||||
<a href="/Admin/Operations" class="nav-link">
|
||||
<i class="ti ti-settings"></i>
|
||||
<span>운영 관리</span>
|
||||
</a>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- Page Content -->
|
||||
<div class="main-content">
|
||||
<div class="page-content">
|
||||
<div class="container-xl">
|
||||
@RenderBody()
|
||||
</div>
|
||||
</h1>
|
||||
<div class="collapse navbar-collapse" id="sidebar-menu">
|
||||
<ul class="navbar-nav pt-lg-3">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link @NavActive("/Admin/Dashboard")" href="/Admin/Dashboard">
|
||||
<span class="nav-link-icon"><i class="ti ti-dashboard"></i></span>
|
||||
<span class="nav-link-title">대시보드</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link @NavActive("/Admin/Collection")" href="/Admin/Collection">
|
||||
<span class="nav-link-icon"><i class="ti ti-database"></i></span>
|
||||
<span class="nav-link-title">데이터 수집</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link @NavActive("/Admin/Monitoring")" href="/Admin/Monitoring">
|
||||
<span class="nav-link-icon"><i class="ti ti-eye"></i></span>
|
||||
<span class="nav-link-title">모니터링</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link @NavActive("/Admin/Users")" href="/Admin/Users">
|
||||
<span class="nav-link-icon"><i class="ti ti-users"></i></span>
|
||||
<span class="nav-link-title">사용자 관리</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link @NavActive("/Admin/Operations")" href="/Admin/Operations">
|
||||
<span class="nav-link-icon"><i class="ti ti-settings"></i></span>
|
||||
<span class="nav-link-title">운영 관리</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- Topbar -->
|
||||
<header class="navbar navbar-expand-md navbar-light d-print-none">
|
||||
<div class="container-xl">
|
||||
<div class="navbar-nav flex-row flex-fill justify-content-between align-items-center">
|
||||
<span class="fw-medium">@ViewData["Title"]</span>
|
||||
<a href="/Account/Logout" class="btn btn-sm btn-outline-danger">
|
||||
<i class="ti ti-logout me-1"></i> 로그아웃
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="page-wrapper">
|
||||
<!-- Center content -->
|
||||
<div class="page-body">
|
||||
<div class="container-xl">
|
||||
@RenderBody()
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<footer class="footer footer-transparent d-print-none">
|
||||
<div class="container-xl">
|
||||
<div class="row text-center align-items-center flex-row-reverse">
|
||||
<div class="col-lg-auto ms-lg-auto">
|
||||
<ul class="list-inline list-inline-dots mb-0">
|
||||
<li class="list-inline-item">
|
||||
<a href="/Admin/Dashboard" class="link-secondary">대시보드</a>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<a href="/Admin/Operations" class="link-secondary">운영 관리</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="col-12 col-lg-auto mt-3 mt-lg-0">
|
||||
<ul class="list-inline list-inline-dots mb-0">
|
||||
<li class="list-inline-item">
|
||||
© @DateTime.UtcNow.Year QuantEngine
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tabler JS -->
|
||||
<!-- Tabler JS (bundles Bootstrap JS, incl. the Collapse plugin used by the mobile sidebar toggle above) -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/@@tabler/core@1.0.0/dist/js/tabler.min.js"></script>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const links = document.querySelectorAll('.sidebar-nav .nav-link');
|
||||
const currentPath = window.location.pathname.toLowerCase();
|
||||
|
||||
links.forEach(link => {
|
||||
const href = link.getAttribute('href').toLowerCase();
|
||||
if (currentPath.startsWith(href)) {
|
||||
link.classList.add('active');
|
||||
} else {
|
||||
link.classList.remove('active');
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user