Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 26b163eab9 | |||
| 1c192ecdea | |||
| 82e18a9a22 | |||
| 6d9937c590 | |||
| ef6f9c74f6 | |||
| 24ec410f3d | |||
| 024122d310 | |||
| c6f269e30a | |||
| 97447a551f |
@@ -2,7 +2,7 @@ name: Deploy to Production
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
workflow_run:
|
workflow_run:
|
||||||
workflows: [Prepare Release]
|
workflows: ["Prepare Release"]
|
||||||
types: [completed]
|
types: [completed]
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
inputs:
|
inputs:
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
name: Prepare Release
|
name: Prepare Release
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
workflow_run:
|
||||||
branches: [main]
|
workflows: ["Validators (Pushes and Pull Requests)"]
|
||||||
|
types: [completed]
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
inputs:
|
inputs:
|
||||||
version:
|
version:
|
||||||
@@ -16,6 +17,7 @@ env:
|
|||||||
jobs:
|
jobs:
|
||||||
build-and-release:
|
build-and-release:
|
||||||
name: Build & Create Release
|
name: Build & Create Release
|
||||||
|
if: ${{ github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success' }}
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 30
|
timeout-minutes: 30
|
||||||
outputs:
|
outputs:
|
||||||
@@ -94,6 +96,10 @@ jobs:
|
|||||||
--no-restore \
|
--no-restore \
|
||||||
--no-build
|
--no-build
|
||||||
|
|
||||||
|
- name: Write Version Text
|
||||||
|
run: |
|
||||||
|
echo "${{ steps.metadata.outputs.version }}" > ./publish/version.txt
|
||||||
|
|
||||||
- name: Write Production Config
|
- name: Write Production Config
|
||||||
run: |
|
run: |
|
||||||
mkdir -p ./publish
|
mkdir -p ./publish
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
-- V7__Seed_Initial_Settings.sql
|
||||||
|
-- Insert default system settings into quantengine.settings
|
||||||
|
|
||||||
|
INSERT INTO quantengine.settings (ordinal, key, value_json, note, updated_at)
|
||||||
|
VALUES
|
||||||
|
(1, 'api_request_interval_ms', '{"value": 400}', 'KIS OpenAPI 요청 간격 딜레이 (밀리초)', NOW()::text),
|
||||||
|
(2, 'ip_lockout_duration_seconds', '{"value": 1800}', '비밀번호 실패 시 IP 차단 지속 시간 (초)', NOW()::text),
|
||||||
|
(3, 'max_login_attempts', '{"value": 3}', '로그인 잠금 전 최대 시도 가능 횟수', NOW()::text)
|
||||||
|
ON CONFLICT (key) DO NOTHING;
|
||||||
@@ -32,6 +32,8 @@ public class KisApiClient : IKisApiClient
|
|||||||
private readonly ITokenCache _tokenCache;
|
private readonly ITokenCache _tokenCache;
|
||||||
private readonly ILogger<KisApiClient> _logger;
|
private readonly ILogger<KisApiClient> _logger;
|
||||||
private static readonly ConcurrentDictionary<string, SemaphoreSlim> TokenLocks = new();
|
private static readonly ConcurrentDictionary<string, SemaphoreSlim> TokenLocks = new();
|
||||||
|
private readonly SemaphoreSlim _rateLimitSemaphore = new(1, 1);
|
||||||
|
private DateTime _lastRequestTime = DateTime.MinValue;
|
||||||
|
|
||||||
public KisApiClient(HttpClient httpClient, ITokenCache tokenCache, ILogger<KisApiClient> logger)
|
public KisApiClient(HttpClient httpClient, ITokenCache tokenCache, ILogger<KisApiClient> logger)
|
||||||
{
|
{
|
||||||
@@ -152,6 +154,7 @@ public class KisApiClient : IKisApiClient
|
|||||||
foreach (var header in headers)
|
foreach (var header in headers)
|
||||||
request.Headers.Add(header.Key, header.Value);
|
request.Headers.Add(header.Key, header.Value);
|
||||||
|
|
||||||
|
await ApplyRateLimitDelayAsync(account);
|
||||||
var response = await _httpClient.SendAsync(request);
|
var response = await _httpClient.SendAsync(request);
|
||||||
response.EnsureSuccessStatusCode();
|
response.EnsureSuccessStatusCode();
|
||||||
|
|
||||||
@@ -195,6 +198,7 @@ public class KisApiClient : IKisApiClient
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
await ApplyRateLimitDelayAsync(creds.Account);
|
||||||
var response = await _httpClient.PostAsJsonAsync(
|
var response = await _httpClient.PostAsJsonAsync(
|
||||||
$"{creds.Domain}/oauth2/tokenP",
|
$"{creds.Domain}/oauth2/tokenP",
|
||||||
tokenRequest
|
tokenRequest
|
||||||
@@ -318,4 +322,29 @@ public class KisApiClient : IKisApiClient
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task ApplyRateLimitDelayAsync(string account)
|
||||||
|
{
|
||||||
|
await _rateLimitSemaphore.WaitAsync();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var mode = account.Contains("real", StringComparison.OrdinalIgnoreCase) ? "real" : "mock";
|
||||||
|
int minIntervalMs = mode == "real" ? 150 : 400;
|
||||||
|
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
var elapsed = (now - _lastRequestTime).TotalMilliseconds;
|
||||||
|
if (elapsed < minIntervalMs)
|
||||||
|
{
|
||||||
|
var delay = minIntervalMs - (int)elapsed;
|
||||||
|
_logger.LogDebug("Rate limit throttling: delaying for {Delay}ms (Mode: {Mode})", delay, mode);
|
||||||
|
await Task.Delay(delay);
|
||||||
|
}
|
||||||
|
|
||||||
|
_lastRequestTime = DateTime.UtcNow;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_rateLimitSemaphore.Release();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,20 @@ public class LoginModel : PageModel
|
|||||||
{
|
{
|
||||||
get
|
get
|
||||||
{
|
{
|
||||||
|
// 1. Try reading version.txt from application base directory
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var txtPath = Path.Combine(AppContext.BaseDirectory, "version.txt");
|
||||||
|
if (System.IO.File.Exists(txtPath))
|
||||||
|
{
|
||||||
|
var txtVersion = System.IO.File.ReadAllText(txtPath).Trim();
|
||||||
|
if (!string.IsNullOrEmpty(txtVersion))
|
||||||
|
return txtVersion;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch {}
|
||||||
|
|
||||||
|
// 2. Try reading raw assembly version
|
||||||
var rawVersion = Assembly.GetEntryAssembly()
|
var rawVersion = Assembly.GetEntryAssembly()
|
||||||
?.GetCustomAttribute<AssemblyInformationalVersionAttribute>()
|
?.GetCustomAttribute<AssemblyInformationalVersionAttribute>()
|
||||||
?.InformationalVersion;
|
?.InformationalVersion;
|
||||||
@@ -31,8 +45,9 @@ public class LoginModel : PageModel
|
|||||||
return rawVersion;
|
return rawVersion;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 3. Fallback to dynamic local Git parsing if available
|
||||||
var dateStr = DateTime.UtcNow.AddHours(9).ToString("yyyyMMdd");
|
var dateStr = DateTime.UtcNow.AddHours(9).ToString("yyyyMMdd");
|
||||||
string gitHash = "a9986fb";
|
string gitHash = "024122d";
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var baseDir = AppContext.BaseDirectory;
|
var baseDir = AppContext.BaseDirectory;
|
||||||
@@ -55,7 +70,7 @@ public class LoginModel : PageModel
|
|||||||
var headContent = System.IO.File.ReadAllText(headPath).Trim();
|
var headContent = System.IO.File.ReadAllText(headPath).Trim();
|
||||||
if (headContent.StartsWith("ref:"))
|
if (headContent.StartsWith("ref:"))
|
||||||
{
|
{
|
||||||
var refPath = Path.Combine(repoRoot, ".git", headContent.Substring(4).Trim());
|
var refPath = Path.Combine(repoRoot, ".git", headContent.Substring(4).Trim().Replace('/', Path.DirectorySeparatorChar));
|
||||||
if (System.IO.File.Exists(refPath))
|
if (System.IO.File.Exists(refPath))
|
||||||
{
|
{
|
||||||
var fullHash = System.IO.File.ReadAllText(refPath).Trim();
|
var fullHash = System.IO.File.ReadAllText(refPath).Trim();
|
||||||
@@ -72,7 +87,7 @@ public class LoginModel : PageModel
|
|||||||
}
|
}
|
||||||
catch {}
|
catch {}
|
||||||
|
|
||||||
int runCount = 14;
|
int runCount = 11;
|
||||||
return $"quant_{dateStr}.{runCount}.{gitHash}";
|
return $"quant_{dateStr}.{runCount}.{gitHash}";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,20 @@ public class IndexModel : PageModel
|
|||||||
{
|
{
|
||||||
get
|
get
|
||||||
{
|
{
|
||||||
|
// 1. Try reading version.txt from application base directory
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var txtPath = Path.Combine(AppContext.BaseDirectory, "version.txt");
|
||||||
|
if (System.IO.File.Exists(txtPath))
|
||||||
|
{
|
||||||
|
var txtVersion = System.IO.File.ReadAllText(txtPath).Trim();
|
||||||
|
if (!string.IsNullOrEmpty(txtVersion))
|
||||||
|
return txtVersion;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch {}
|
||||||
|
|
||||||
|
// 2. Try reading raw assembly version
|
||||||
var rawVersion = Assembly.GetEntryAssembly()
|
var rawVersion = Assembly.GetEntryAssembly()
|
||||||
?.GetCustomAttribute<AssemblyInformationalVersionAttribute>()
|
?.GetCustomAttribute<AssemblyInformationalVersionAttribute>()
|
||||||
?.InformationalVersion;
|
?.InformationalVersion;
|
||||||
@@ -32,8 +46,9 @@ public class IndexModel : PageModel
|
|||||||
return rawVersion;
|
return rawVersion;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 3. Fallback to dynamic local Git parsing if available
|
||||||
var dateStr = DateTime.UtcNow.AddHours(9).ToString("yyyyMMdd");
|
var dateStr = DateTime.UtcNow.AddHours(9).ToString("yyyyMMdd");
|
||||||
string gitHash = "a9986fb";
|
string gitHash = "024122d";
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var baseDir = AppContext.BaseDirectory;
|
var baseDir = AppContext.BaseDirectory;
|
||||||
@@ -56,7 +71,7 @@ public class IndexModel : PageModel
|
|||||||
var headContent = System.IO.File.ReadAllText(headPath).Trim();
|
var headContent = System.IO.File.ReadAllText(headPath).Trim();
|
||||||
if (headContent.StartsWith("ref:"))
|
if (headContent.StartsWith("ref:"))
|
||||||
{
|
{
|
||||||
var refPath = Path.Combine(repoRoot, ".git", headContent.Substring(4).Trim());
|
var refPath = Path.Combine(repoRoot, ".git", headContent.Substring(4).Trim().Replace('/', Path.DirectorySeparatorChar));
|
||||||
if (System.IO.File.Exists(refPath))
|
if (System.IO.File.Exists(refPath))
|
||||||
{
|
{
|
||||||
var fullHash = System.IO.File.ReadAllText(refPath).Trim();
|
var fullHash = System.IO.File.ReadAllText(refPath).Trim();
|
||||||
@@ -73,7 +88,7 @@ public class IndexModel : PageModel
|
|||||||
}
|
}
|
||||||
catch {}
|
catch {}
|
||||||
|
|
||||||
int runCount = 14;
|
int runCount = 11;
|
||||||
return $"quant_{dateStr}.{runCount}.{gitHash}";
|
return $"quant_{dateStr}.{runCount}.{gitHash}";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,255 @@
|
|||||||
|
@page
|
||||||
|
@model QuantEngine.Web.Pages.Admin.Database.IndexModel
|
||||||
|
@{
|
||||||
|
ViewData["Title"] = "DB 테이블 관리";
|
||||||
|
Layout = "_AdminLayout";
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<!-- Left panel: Table list -->
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h3 class="card-title">데이터베이스 테이블 목록</h3>
|
||||||
|
</div>
|
||||||
|
<div class="list-group list-group-flush" style="max-height: 700px; overflow-y: auto;">
|
||||||
|
@foreach (var table in Model.TableList)
|
||||||
|
{
|
||||||
|
var parts = table.Split('.');
|
||||||
|
var schema = parts[0];
|
||||||
|
var name = parts[1];
|
||||||
|
var isActive = Model.SelectedTable == table ? "active" : "";
|
||||||
|
|
||||||
|
<a href="/Admin/Database?tableName=@table" class="list-group-item list-group-item-action @isActive d-flex justify-content-between align-items-center">
|
||||||
|
<div>
|
||||||
|
<span class="text-muted small">@schema.ToUpperInvariant().</span><strong>@name</strong>
|
||||||
|
</div>
|
||||||
|
<i class="ti ti-chevron-right text-muted"></i>
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Right panel: Selected table data and CRUD actions -->
|
||||||
|
<div class="col-md-9">
|
||||||
|
@if (!string.IsNullOrEmpty(Model.ErrorMessage))
|
||||||
|
{
|
||||||
|
<div class="alert alert-danger alert-dismissible" role="alert">
|
||||||
|
<div class="d-flex">
|
||||||
|
<div><i class="ti ti-alert-triangle me-2"></i></div>
|
||||||
|
<div>@Model.ErrorMessage</div>
|
||||||
|
</div>
|
||||||
|
<a class="btn-close" data-bs-dismiss="alert" aria-label="close"></a>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
@if (!string.IsNullOrEmpty(Model.SuccessMessage))
|
||||||
|
{
|
||||||
|
<div class="alert alert-success alert-dismissible" role="alert">
|
||||||
|
<div class="d-flex">
|
||||||
|
<div><i class="ti ti-circle-check me-2"></i></div>
|
||||||
|
<div>@Model.SuccessMessage</div>
|
||||||
|
</div>
|
||||||
|
<a class="btn-close" data-bs-dismiss="alert" aria-label="close"></a>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (!string.IsNullOrEmpty(Model.SelectedTable))
|
||||||
|
{
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header d-flex justify-content-between align-items-center">
|
||||||
|
<div>
|
||||||
|
<h3 class="card-title">@Model.SelectedTable 데이터 조회</h3>
|
||||||
|
<p class="card-subtitle text-muted">상위 100개 데이터 행을 출력합니다.</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<button class="btn btn-primary btn-sm" data-bs-toggle="modal" data-bs-target="#modal-add-row">
|
||||||
|
<i class="ti ti-plus me-1"></i> 새 데이터 추가
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="table-responsive" style="max-height: 600px;">
|
||||||
|
<table class="table table-vcenter table-mobile-md card-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
@foreach (var col in Model.ColumnNames)
|
||||||
|
{
|
||||||
|
<th>
|
||||||
|
@col
|
||||||
|
@if (col == Model.PrimaryKeyColumn)
|
||||||
|
{
|
||||||
|
<span class="badge bg-purple-lt ms-1">PK</span>
|
||||||
|
}
|
||||||
|
</th>
|
||||||
|
}
|
||||||
|
<th class="w-1">작업</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@if (Model.Rows.Count > 0)
|
||||||
|
{
|
||||||
|
@foreach (var row in Model.Rows)
|
||||||
|
{
|
||||||
|
var pkVal = Model.PrimaryKeyColumn != null && row.ContainsKey(Model.PrimaryKeyColumn)
|
||||||
|
? row[Model.PrimaryKeyColumn]?.ToString() ?? ""
|
||||||
|
: "";
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
@foreach (var col in Model.ColumnNames)
|
||||||
|
{
|
||||||
|
<td data-label="@col">
|
||||||
|
<span class="text-wrap">@row[col]</span>
|
||||||
|
</td>
|
||||||
|
}
|
||||||
|
<td>
|
||||||
|
<div class="btn-list flex-nowrap">
|
||||||
|
<button class="btn btn-sm btn-outline-primary edit-row-btn"
|
||||||
|
data-bs-toggle="modal"
|
||||||
|
data-bs-target="#modal-edit-row"
|
||||||
|
data-pk-val="@pkVal"
|
||||||
|
@foreach (var col in Model.ColumnNames)
|
||||||
|
{
|
||||||
|
@:data-field-@col="@row[col]"
|
||||||
|
}>
|
||||||
|
수정
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<tr>
|
||||||
|
<td colspan="@(Model.ColumnNames.Count + 1)" class="text-center text-muted py-4">
|
||||||
|
데이터가 없습니다.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="card card-md">
|
||||||
|
<div class="card-body text-center py-5">
|
||||||
|
<div class="mb-3 text-muted">
|
||||||
|
<i class="ti ti-database-off" style="font-size: 3rem;"></i>
|
||||||
|
</div>
|
||||||
|
<h3>선택된 테이블이 없습니다</h3>
|
||||||
|
<p class="text-muted">좌측 목록에서 조회 및 수정을 원하는 테이블을 선택해 주세요.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (!string.IsNullOrEmpty(Model.SelectedTable))
|
||||||
|
{
|
||||||
|
<!-- Modal: Add Row -->
|
||||||
|
<div class="modal modal-blur fade" id="modal-add-row" tabindex="-1" role="dialog" aria-hidden="true">
|
||||||
|
<div class="modal-dialog modal-lg" role="document">
|
||||||
|
<div class="modal-content">
|
||||||
|
<form method="post" asp-page-handler="AddRow">
|
||||||
|
@Html.AntiForgeryToken()
|
||||||
|
<input type="hidden" name="tableName" value="@Model.SelectedTable" />
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title">새 데이터 행 추가 (@Model.SelectedTable)</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="row">
|
||||||
|
@foreach (var col in Model.ColumnNames)
|
||||||
|
{
|
||||||
|
var isPk = col == Model.PrimaryKeyColumn;
|
||||||
|
var isSerial = col == "id" && Model.SelectedTable.Contains("workspace_change_log");
|
||||||
|
|
||||||
|
<div class="col-md-6 mb-3">
|
||||||
|
<label class="form-label">
|
||||||
|
@col
|
||||||
|
@if (isPk) { <span class="text-danger">* (PK)</span> }
|
||||||
|
</label>
|
||||||
|
@if (isSerial)
|
||||||
|
{
|
||||||
|
<input type="text" class="form-control" placeholder="자동 생성 (SERIAL)" disabled />
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<input type="text" class="form-control" name="@col" placeholder="@col 값을 입력하세요" required="@isPk" />
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-link link-secondary" data-bs-dismiss="modal">취소</button>
|
||||||
|
<button type="submit" class="btn btn-primary ms-auto">저장하기</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Modal: Edit Row -->
|
||||||
|
<div class="modal modal-blur fade" id="modal-edit-row" tabindex="-1" role="dialog" aria-hidden="true">
|
||||||
|
<div class="modal-dialog modal-lg" role="document">
|
||||||
|
<div class="modal-content">
|
||||||
|
<form method="post" asp-page-handler="SaveRow">
|
||||||
|
@Html.AntiForgeryToken()
|
||||||
|
<input type="hidden" name="tableName" value="@Model.SelectedTable" />
|
||||||
|
<input type="hidden" name="pkColumn" value="@Model.PrimaryKeyColumn" />
|
||||||
|
<input type="hidden" name="pkValue" id="edit-pk-value" />
|
||||||
|
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title">데이터 행 수정 (@Model.SelectedTable)</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="row" id="edit-fields-container">
|
||||||
|
@foreach (var col in Model.ColumnNames)
|
||||||
|
{
|
||||||
|
var isPk = col == Model.PrimaryKeyColumn;
|
||||||
|
<div class="col-md-6 mb-3">
|
||||||
|
<label class="form-label">
|
||||||
|
@col
|
||||||
|
@if (isPk) { <span class="text-muted">(PK - 수정 불가)</span> }
|
||||||
|
</label>
|
||||||
|
<input type="text" class="form-control" name="@col" id="edit-field-input-@col" @(isPk ? "readonly" : "") />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-link link-secondary" data-bs-dismiss="modal">취소</button>
|
||||||
|
<button type="submit" class="btn btn-primary ms-auto">저장하기</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
document.addEventListener("DOMContentLoaded", function () {
|
||||||
|
const editButtons = document.querySelectorAll(".edit-row-btn");
|
||||||
|
editButtons.forEach(btn => {
|
||||||
|
btn.addEventListener("click", function () {
|
||||||
|
const pkVal = this.getAttribute("data-pk-val");
|
||||||
|
document.getElementById("edit-pk-value").value = pkVal;
|
||||||
|
|
||||||
|
// Populate fields dynamically
|
||||||
|
Array.from(this.attributes).forEach(attr => {
|
||||||
|
if (attr.name.startsWith("data-field-")) {
|
||||||
|
const fieldName = attr.name.substring("data-field-".length);
|
||||||
|
const inputEl = document.getElementById("edit-field-input-" + fieldName);
|
||||||
|
if (inputEl) {
|
||||||
|
inputEl.value = attr.value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
}
|
||||||
@@ -0,0 +1,270 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Data;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Npgsql;
|
||||||
|
using QuantEngine.Infrastructure.Data;
|
||||||
|
|
||||||
|
namespace QuantEngine.Web.Pages.Admin.Database
|
||||||
|
{
|
||||||
|
public class IndexModel : PageModel
|
||||||
|
{
|
||||||
|
private readonly IDbConnectionFactory _connectionFactory;
|
||||||
|
private readonly ILogger<IndexModel> _logger;
|
||||||
|
|
||||||
|
public List<string> TableList { get; set; } = new();
|
||||||
|
public string? SelectedTable { get; set; }
|
||||||
|
public List<string> ColumnNames { get; set; } = new();
|
||||||
|
public List<Dictionary<string, object>> Rows { get; set; } = new();
|
||||||
|
public string? PrimaryKeyColumn { get; set; }
|
||||||
|
|
||||||
|
[BindProperty]
|
||||||
|
public string? ActionTableName { get; set; }
|
||||||
|
|
||||||
|
[BindProperty]
|
||||||
|
public string? ActionRowKey { get; set; }
|
||||||
|
|
||||||
|
public string? ErrorMessage { get; set; }
|
||||||
|
public string? SuccessMessage { get; set; }
|
||||||
|
|
||||||
|
public IndexModel(IDbConnectionFactory connectionFactory, ILogger<IndexModel> logger)
|
||||||
|
{
|
||||||
|
_connectionFactory = connectionFactory;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task OnGetAsync(string? tableName)
|
||||||
|
{
|
||||||
|
await LoadTableListAsync();
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(tableName))
|
||||||
|
{
|
||||||
|
// Validate table name is in whitelist to prevent SQL Injection
|
||||||
|
if (TableList.Contains(tableName, StringComparer.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
SelectedTable = tableName;
|
||||||
|
await LoadTableDataAsync(tableName);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
ErrorMessage = "허용되지 않은 테이블명입니다.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IActionResult> OnPostSaveRowAsync()
|
||||||
|
{
|
||||||
|
await LoadTableListAsync();
|
||||||
|
|
||||||
|
var tableName = Request.Form["tableName"].ToString();
|
||||||
|
var pkColumn = Request.Form["pkColumn"].ToString();
|
||||||
|
var pkValue = Request.Form["pkValue"].ToString();
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(tableName) || !TableList.Contains(tableName, StringComparer.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
ErrorMessage = "유효하지 않은 테이블입니다.";
|
||||||
|
return Page();
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var conn = _connectionFactory.CreateConnection();
|
||||||
|
if (conn.State != ConnectionState.Open) conn.Open();
|
||||||
|
|
||||||
|
// Load target columns to update
|
||||||
|
var columns = new List<string>();
|
||||||
|
var parameters = new List<NpgsqlParameter>();
|
||||||
|
|
||||||
|
foreach (var key in Request.Form.Keys)
|
||||||
|
{
|
||||||
|
if (key == "tableName" || key == "pkColumn" || key == "pkValue" || key == "__RequestVerificationToken")
|
||||||
|
continue;
|
||||||
|
|
||||||
|
var val = Request.Form[key].ToString();
|
||||||
|
columns.Add($"\"{key}\" = @{key}");
|
||||||
|
|
||||||
|
var param = new NpgsqlParameter($"@{key}", NpgsqlTypes.NpgsqlDbType.Text);
|
||||||
|
param.Value = (object?)val ?? DBNull.Value;
|
||||||
|
parameters.Add(param);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (columns.Count > 0 && !string.IsNullOrEmpty(pkColumn))
|
||||||
|
{
|
||||||
|
var sql = $"UPDATE {tableName} SET {string.Join(", ", columns)} WHERE \"{pkColumn}\" = @pk_val";
|
||||||
|
|
||||||
|
using var cmd = new NpgsqlCommand(sql, (NpgsqlConnection)conn);
|
||||||
|
foreach (var p in parameters) cmd.Parameters.Add(p);
|
||||||
|
|
||||||
|
var pkParam = new NpgsqlParameter("@pk_val", NpgsqlTypes.NpgsqlDbType.Text);
|
||||||
|
pkParam.Value = pkValue;
|
||||||
|
cmd.Parameters.Add(pkParam);
|
||||||
|
|
||||||
|
await cmd.ExecuteNonQueryAsync();
|
||||||
|
SuccessMessage = "행 데이터가 성공적으로 수정되었습니다.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Failed to update row for {Table}", tableName);
|
||||||
|
ErrorMessage = $"저장 실패: {ex.Message}";
|
||||||
|
}
|
||||||
|
|
||||||
|
return RedirectToPage(new { tableName });
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IActionResult> OnPostAddRowAsync()
|
||||||
|
{
|
||||||
|
await LoadTableListAsync();
|
||||||
|
|
||||||
|
var tableName = Request.Form["tableName"].ToString();
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(tableName) || !TableList.Contains(tableName, StringComparer.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
ErrorMessage = "유효하지 않은 테이블입니다.";
|
||||||
|
return Page();
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var conn = _connectionFactory.CreateConnection();
|
||||||
|
if (conn.State != ConnectionState.Open) conn.Open();
|
||||||
|
|
||||||
|
var colNames = new List<string>();
|
||||||
|
var paramNames = new List<string>();
|
||||||
|
var parameters = new List<NpgsqlParameter>();
|
||||||
|
|
||||||
|
foreach (var key in Request.Form.Keys)
|
||||||
|
{
|
||||||
|
if (key == "tableName" || key == "__RequestVerificationToken")
|
||||||
|
continue;
|
||||||
|
|
||||||
|
var val = Request.Form[key].ToString();
|
||||||
|
colNames.Add($"\"{key}\"");
|
||||||
|
paramNames.Add($"@{key}");
|
||||||
|
|
||||||
|
var param = new NpgsqlParameter($"@{key}", NpgsqlTypes.NpgsqlDbType.Text);
|
||||||
|
param.Value = (object?)val ?? DBNull.Value;
|
||||||
|
parameters.Add(param);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (colNames.Count > 0)
|
||||||
|
{
|
||||||
|
var sql = $"INSERT INTO {tableName} ({string.Join(", ", colNames)}) VALUES ({string.Join(", ", paramNames)})";
|
||||||
|
|
||||||
|
using var cmd = new NpgsqlCommand(sql, (NpgsqlConnection)conn);
|
||||||
|
foreach (var p in parameters) cmd.Parameters.Add(p);
|
||||||
|
|
||||||
|
await cmd.ExecuteNonQueryAsync();
|
||||||
|
SuccessMessage = "새 데이터 행이 성공적으로 추가되었습니다.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Failed to insert row for {Table}", tableName);
|
||||||
|
ErrorMessage = $"추가 실패: {ex.Message}";
|
||||||
|
}
|
||||||
|
|
||||||
|
return RedirectToPage(new { tableName });
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task LoadTableListAsync()
|
||||||
|
{
|
||||||
|
TableList.Clear();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var conn = _connectionFactory.CreateConnection();
|
||||||
|
if (conn.State != ConnectionState.Open) conn.Open();
|
||||||
|
|
||||||
|
var sql = @"
|
||||||
|
SELECT table_schema || '.' || table_name AS full_name
|
||||||
|
FROM information_schema.tables
|
||||||
|
WHERE table_schema IN ('quantengine', 'engine_history')
|
||||||
|
AND table_type = 'BASE TABLE'
|
||||||
|
ORDER BY table_schema, table_name;";
|
||||||
|
|
||||||
|
using var cmd = new NpgsqlCommand(sql, (NpgsqlConnection)conn);
|
||||||
|
using var reader = await cmd.ExecuteReaderAsync();
|
||||||
|
while (await reader.ReadAsync())
|
||||||
|
{
|
||||||
|
TableList.Add(reader.GetString(0));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Failed to load database table list.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task LoadTableDataAsync(string tableName)
|
||||||
|
{
|
||||||
|
ColumnNames.Clear();
|
||||||
|
Rows.Clear();
|
||||||
|
PrimaryKeyColumn = null;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var conn = _connectionFactory.CreateConnection();
|
||||||
|
if (conn.State != ConnectionState.Open) conn.Open();
|
||||||
|
|
||||||
|
var parts = tableName.Split('.');
|
||||||
|
var schema = parts[0];
|
||||||
|
var tableOnly = parts[1];
|
||||||
|
|
||||||
|
var pkSql = @"
|
||||||
|
SELECT a.attname
|
||||||
|
FROM pg_index i
|
||||||
|
JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = ANY(i.indkey)
|
||||||
|
WHERE i.indrelid = @table_name::regclass
|
||||||
|
AND i.indisprimary;";
|
||||||
|
|
||||||
|
using (var pkCmd = new NpgsqlCommand(pkSql, (NpgsqlConnection)conn))
|
||||||
|
{
|
||||||
|
pkCmd.Parameters.AddWithValue("@table_name", tableName);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var pkResult = await pkCmd.ExecuteScalarAsync();
|
||||||
|
if (pkResult != null) PrimaryKeyColumn = pkResult.ToString();
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(PrimaryKeyColumn))
|
||||||
|
{
|
||||||
|
if (tableName.Contains("settings")) PrimaryKeyColumn = "key";
|
||||||
|
else if (tableName.Contains("workspace_account")) PrimaryKeyColumn = "username";
|
||||||
|
else if (tableName.Contains("collection_runs")) PrimaryKeyColumn = "run_id";
|
||||||
|
else if (tableName.Contains("workspace_meta")) PrimaryKeyColumn = "key";
|
||||||
|
}
|
||||||
|
|
||||||
|
var dataSql = $"SELECT * FROM {tableName} LIMIT 100;";
|
||||||
|
using var cmd = new NpgsqlCommand(dataSql, (NpgsqlConnection)conn);
|
||||||
|
using var reader = await cmd.ExecuteReaderAsync();
|
||||||
|
|
||||||
|
for (int i = 0; i < reader.FieldCount; i++)
|
||||||
|
{
|
||||||
|
ColumnNames.Add(reader.GetName(i));
|
||||||
|
}
|
||||||
|
|
||||||
|
while (await reader.ReadAsync())
|
||||||
|
{
|
||||||
|
var row = new Dictionary<string, object>();
|
||||||
|
for (int i = 0; i < reader.FieldCount; i++)
|
||||||
|
{
|
||||||
|
var val = reader.GetValue(i);
|
||||||
|
row[reader.GetName(i)] = val == DBNull.Value ? "null" : val;
|
||||||
|
}
|
||||||
|
Rows.Add(row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Failed to load table data for {Table}", tableName);
|
||||||
|
ErrorMessage = $"테이블 데이터 조회 실패: {ex.Message}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -68,7 +68,7 @@
|
|||||||
}
|
}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<form method="post" asp-page-handler="TriggerJob" class="d-inline">
|
<form method="post" action="/Admin/Operations?handler=TriggerJob" class="d-inline">
|
||||||
@Html.AntiForgeryToken()
|
@Html.AntiForgeryToken()
|
||||||
<input type="hidden" name="jobId" value="@job.JobId" />
|
<input type="hidden" name="jobId" value="@job.JobId" />
|
||||||
<button type="submit" class="btn btn-sm btn-success text-white">즉시 실행</button>
|
<button type="submit" class="btn btn-sm btn-success text-white">즉시 실행</button>
|
||||||
|
|||||||
@@ -87,18 +87,26 @@ public class IndexModel : PageModel
|
|||||||
InactiveJobsCount = TotalJobsCount - ActiveJobsCount;
|
InactiveJobsCount = TotalJobsCount - ActiveJobsCount;
|
||||||
|
|
||||||
var succeeded = monitoringApi.SucceededJobs(0, 10)
|
var succeeded = monitoringApi.SucceededJobs(0, 10)
|
||||||
.Select(kv => new JobExecutionInfo(
|
.Select(kv => {
|
||||||
kv.Value.Job?.Method.Name ?? kv.Key,
|
var succeededAt = kv.Value.SucceededAt ?? DateTime.UtcNow;
|
||||||
kv.Value.SucceededAt ?? DateTime.UtcNow,
|
var durationMs = kv.Value.TotalDuration ?? 0;
|
||||||
kv.Value.SucceededAt,
|
var startedAt = succeededAt.AddMilliseconds(-durationMs);
|
||||||
true));
|
return new JobExecutionInfo(
|
||||||
|
kv.Value.Job?.Method.Name ?? kv.Key,
|
||||||
|
startedAt,
|
||||||
|
succeededAt,
|
||||||
|
true);
|
||||||
|
});
|
||||||
|
|
||||||
var failed = monitoringApi.FailedJobs(0, 10)
|
var failed = monitoringApi.FailedJobs(0, 10)
|
||||||
.Select(kv => new JobExecutionInfo(
|
.Select(kv => {
|
||||||
kv.Value.Job?.Method.Name ?? kv.Key,
|
var failedAt = kv.Value.FailedAt ?? DateTime.UtcNow;
|
||||||
kv.Value.FailedAt ?? DateTime.UtcNow,
|
return new JobExecutionInfo(
|
||||||
kv.Value.FailedAt,
|
kv.Value.Job?.Method.Name ?? kv.Key,
|
||||||
false));
|
failedAt,
|
||||||
|
failedAt,
|
||||||
|
false);
|
||||||
|
});
|
||||||
|
|
||||||
RecentExecutions = succeeded.Concat(failed)
|
RecentExecutions = succeeded.Concat(failed)
|
||||||
.OrderByDescending(e => e.StartedAt)
|
.OrderByDescending(e => e.StartedAt)
|
||||||
|
|||||||
@@ -66,6 +66,12 @@
|
|||||||
<span class="nav-link-title">운영 관리</span>
|
<span class="nav-link-title">운영 관리</span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link @NavActive("/Admin/Database")" href="/Admin/Database">
|
||||||
|
<span class="nav-link-icon"><i class="ti ti-table"></i></span>
|
||||||
|
<span class="nav-link-title">DB 테이블 관리</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user