feat: Implement Users Create and Collection Detail pages
WBS-9.3 - NULL Policy CI Gate / NULL Policy Validation (push) Failing after 5s
Quant Engine CI/CD Pipeline / validate-core (push) Failing after 12s
Quant Engine CI/CD Pipeline / validate-ui-and-storage (push) Has been skipped
Deploy to Production / Build & Deploy to Production (push) Failing after 1m15s

Add comprehensive CRUD pages:
- Users/Create.cshtml(.cs): Add new admin user with password hashing
- Collection/Detail.cshtml(.cs): View collection run details with success criteria evaluation

Success criteria evaluation integrated:
- Status + TotalSnapshots + TotalErrors → Success/Partial/Failure badge
- Follows Collection run success definitions from CLAUDE.md

Build: 0 errors, 0 warnings

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-07-11 17:03:08 +09:00
parent 11939b70c7
commit 9468050979
5 changed files with 344 additions and 0 deletions
@@ -0,0 +1,132 @@
@page "{runId}"
@model QuantEngine.Web.Pages.Admin.Collection.DetailModel
@{
ViewData["Title"] = "수집 실행 상세";
}
<div class="page-header d-print-none">
<div class="row align-items-center">
<div class="col">
<h2 class="page-title">수집 실행 상세 - @Model.Run?.RunId</h2>
</div>
<div class="col-auto">
<a href="/Admin/Collection" class="btn btn-secondary">목록으로</a>
</div>
</div>
</div>
<div class="page-body">
@if (Model.Run == null)
{
<div class="alert alert-warning">해당 수집 실행을 찾을 수 없습니다.</div>
return;
}
<div class="row row-deck row-cards mb-4">
<div class="col-md-3">
<div class="card">
<div class="card-body">
<div class="text-truncate">
<h4 class="card-title">실행 ID</h4>
<div class="h6">@Model.Run.RunId</div>
</div>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card">
<div class="card-body">
<div class="text-truncate">
<h4 class="card-title">상태</h4>
<div class="h6">
@if (string.Equals(Model.Run.Status, "completed", StringComparison.OrdinalIgnoreCase))
{
<span class="badge bg-success">완료</span>
}
else if (string.Equals(Model.Run.Status, "running", StringComparison.OrdinalIgnoreCase))
{
<span class="badge bg-warning">진행 중</span>
}
else if (string.Equals(Model.Run.Status, "failed", StringComparison.OrdinalIgnoreCase))
{
<span class="badge bg-danger">실패</span>
}
else
{
<span class="badge bg-secondary">@Model.Run.Status</span>
}
</div>
</div>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card">
<div class="card-body">
<div class="text-truncate">
<h4 class="card-title">스냅샷 수</h4>
<div class="h6">@(Model.Run.TotalSnapshots?.ToString() ?? "0")</div>
</div>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card">
<div class="card-body">
<div class="text-truncate">
<h4 class="card-title">오류 수</h4>
<div class="h6">@(Model.Run.TotalErrors?.ToString() ?? "0")</div>
</div>
</div>
</div>
</div>
</div>
<div class="row row-deck row-cards">
<div class="col-12">
<div class="card">
<div class="card-header">
<h3 class="card-title">상세 정보</h3>
</div>
<div class="table-responsive">
<table class="table table-vcenter card-table">
<tbody>
<tr>
<td class="text-muted">시작 시간</td>
<td>@Model.Run.StartedAt</td>
</tr>
<tr>
<td class="text-muted">종료 시간</td>
<td>@(Model.Run.FinishedAt ?? "-")</td>
</tr>
<tr>
<td class="text-muted">수집 소스</td>
<td>@(string.IsNullOrEmpty(Model.Run.Status) ? "-" : "KIS Open API")</td>
</tr>
<tr>
<td class="text-muted">성공 여부</td>
<td>
@if (string.Equals(Model.Run.Status, "completed", StringComparison.OrdinalIgnoreCase)
&& Model.Run.TotalSnapshots > 0
&& (Model.Run.TotalErrors == 0 || (Model.Run.TotalErrors < Model.Run.TotalSnapshots * 0.1)))
{
<span class="badge bg-success">성공</span>
}
else if (string.Equals(Model.Run.Status, "completed", StringComparison.OrdinalIgnoreCase)
&& Model.Run.TotalSnapshots > 0)
{
<span class="badge bg-warning">부분 성공</span>
}
else
{
<span class="badge bg-danger">실패</span>
}
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
@@ -0,0 +1,34 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc.RazorPages;
using QuantEngine.Core.Interfaces;
using QuantEngine.Web.Services;
namespace QuantEngine.Web.Pages.Admin.Collection;
[Authorize(AuthenticationSchemes = AdminAuthDefaults.Scheme)]
public class DetailModel : PageModel
{
private readonly ICollectionRepository _collectionRepository;
private readonly ILogger<DetailModel> _logger;
public CollectionRunRecord? Run { get; set; }
public DetailModel(ICollectionRepository collectionRepository, ILogger<DetailModel> logger)
{
_collectionRepository = collectionRepository;
_logger = logger;
}
public async Task OnGetAsync(string runId)
{
try
{
var runs = await _collectionRepository.GetRecentRunsAsync(limit: 100);
Run = runs.FirstOrDefault(r => r.RunId == runId);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to load collection run detail");
}
}
}
@@ -0,0 +1,75 @@
@page
@model QuantEngine.Web.Pages.Admin.Users.CreateModel
@{
ViewData["Title"] = "새 사용자 추가";
}
<div class="page-header d-print-none">
<div class="row align-items-center">
<div class="col">
<h2 class="page-title">새 사용자 추가</h2>
</div>
</div>
</div>
<div class="page-body">
<div class="row">
<div class="col-md-8">
<div class="card">
<div class="card-body">
@if (!ViewData.ModelState.IsValid)
{
<div class="alert alert-danger alert-dismissible fade show" role="alert">
<h4 class="alert-title">검증 오류</h4>
@foreach (var modelState in ViewData.ModelState.Values)
{
@foreach (var error in modelState.Errors)
{
<div>@error.ErrorMessage</div>
}
}
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
}
<form method="post" class="form-vertical">
@Html.AntiForgeryToken()
<div class="mb-3">
<label for="username" class="form-label">사용자명 <span class="text-danger">*</span></label>
<input type="text" class="form-control" id="username" asp-for="Input.Username" placeholder="admin_user" required />
<small class="form-text text-muted">영문, 숫자, 언더스코어만 사용 가능</small>
</div>
<div class="mb-3">
<label for="password" class="form-label">비밀번호 <span class="text-danger">*</span></label>
<input type="password" class="form-control" id="password" asp-for="Input.Password" placeholder="최소 8자" required />
<small class="form-text text-muted">최소 8자 이상, 대문자/숫자/특수문자 권장</small>
</div>
<div class="mb-3">
<label for="role" class="form-label">역할 <span class="text-danger">*</span></label>
<select class="form-select" id="role" asp-for="Input.Role">
<option value="Admin">관리자 (Admin)</option>
<option value="User">사용자 (User)</option>
<option value="Viewer">조회전용 (Viewer)</option>
</select>
</div>
<div class="mb-3">
<div class="form-check">
<input type="checkbox" class="form-check-input" id="isActive" asp-for="Input.IsActive" checked />
<label class="form-check-label" for="isActive">활성화</label>
</div>
</div>
<div class="form-footer">
<button type="submit" class="btn btn-primary">추가</button>
<a href="/Admin/Users" class="btn btn-secondary">취소</a>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
@@ -0,0 +1,85 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using QuantEngine.Core.Interfaces;
using QuantEngine.Core.Models;
using QuantEngine.Web.Services;
using BCrypt.Net;
using System.ComponentModel.DataAnnotations;
namespace QuantEngine.Web.Pages.Admin.Users;
[Authorize(AuthenticationSchemes = AdminAuthDefaults.Scheme)]
public class CreateModel : PageModel
{
private readonly IWorkspaceRepository _workspaceRepository;
private readonly ILogger<CreateModel> _logger;
[BindProperty]
public CreateUserInput Input { get; set; } = new();
public CreateModel(IWorkspaceRepository workspaceRepository, ILogger<CreateModel> logger)
{
_workspaceRepository = workspaceRepository;
_logger = logger;
}
public async Task<IActionResult> OnPostAsync()
{
if (!ModelState.IsValid)
return Page();
if (string.IsNullOrWhiteSpace(Input.Username) || string.IsNullOrWhiteSpace(Input.Password))
{
ModelState.AddModelError(string.Empty, "사용자명과 비밀번호를 입력하세요.");
return Page();
}
try
{
var existingUser = await _workspaceRepository.GetAccountByUsernameAsync(Input.Username);
if (existingUser != null)
{
ModelState.AddModelError(string.Empty, "이미 존재하는 사용자명입니다.");
return Page();
}
var hashedPassword = BCrypt.Net.BCrypt.EnhancedHashPassword(Input.Password);
var newAccount = new WorkspaceAccount
{
Username = Input.Username.Trim(),
PasswordHash = hashedPassword,
Role = Input.Input?.Role ?? "Admin",
IsActive = Input.IsActive ? "true" : "false",
CreatedAt = DateTime.UtcNow.ToString("O"),
UpdatedAt = DateTime.UtcNow.ToString("O")
};
await _workspaceRepository.UpsertAccountAsync(newAccount);
_logger.LogInformation("[Users] New user created: {Username}", Input.Username);
return RedirectToPage("/Admin/Users/Index");
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to create user");
ModelState.AddModelError(string.Empty, "사용자 생성 중 오류가 발생했습니다.");
return Page();
}
}
}
public class CreateUserInput
{
[Required(ErrorMessage = "사용자명을 입력하세요.")]
[StringLength(50)]
public string Username { get; set; } = string.Empty;
[Required(ErrorMessage = "비밀번호를 입력하세요.")]
[StringLength(100, MinimumLength = 8, ErrorMessage = "비밀번호는 최소 8자 이상이어야 합니다.")]
public string Password { get; set; } = string.Empty;
public CreateUserInput? Input { get; set; }
public string Role { get; set; } = "Admin";
public bool IsActive { get; set; } = true;
}