diff --git a/src/dotnet/QuantEngine.Web/Pages/Admin/Database/Index.cshtml b/src/dotnet/QuantEngine.Web/Pages/Admin/Database/Index.cshtml new file mode 100644 index 00000000..03464cdd --- /dev/null +++ b/src/dotnet/QuantEngine.Web/Pages/Admin/Database/Index.cshtml @@ -0,0 +1,255 @@ +@page +@model QuantEngine.Web.Pages.Admin.Database.IndexModel +@{ + ViewData["Title"] = "DB 테이블 관리"; + Layout = "_AdminLayout"; +} + +
+ +
+
+
+

데이터베이스 테이블 목록

+
+
+ @foreach (var table in Model.TableList) + { + var parts = table.Split('.'); + var schema = parts[0]; + var name = parts[1]; + var isActive = Model.SelectedTable == table ? "active" : ""; + + +
+ @schema.ToUpperInvariant().@name +
+ +
+ } +
+
+
+ + +
+ @if (!string.IsNullOrEmpty(Model.ErrorMessage)) + { + + } + @if (!string.IsNullOrEmpty(Model.SuccessMessage)) + { + + } + + @if (!string.IsNullOrEmpty(Model.SelectedTable)) + { +
+
+
+

@Model.SelectedTable 데이터 조회

+

상위 100개 데이터 행을 출력합니다.

+
+
+ +
+
+
+ + + + @foreach (var col in Model.ColumnNames) + { + + } + + + + + @if (Model.Rows.Count > 0) + { + @foreach (var row in Model.Rows) + { + var pkVal = Model.PrimaryKeyColumn != null && row.ContainsKey(Model.PrimaryKeyColumn) + ? row[Model.PrimaryKeyColumn]?.ToString() ?? "" + : ""; + + + @foreach (var col in Model.ColumnNames) + { + + } + + + } + } + else + { + + + + } + +
+ @col + @if (col == Model.PrimaryKeyColumn) + { + PK + } + 작업
+ @row[col] + +
+ +
+
+ 데이터가 없습니다. +
+
+
+ } + else + { +
+
+
+ +
+

선택된 테이블이 없습니다

+

좌측 목록에서 조회 및 수정을 원하는 테이블을 선택해 주세요.

+
+
+ } +
+
+ +@if (!string.IsNullOrEmpty(Model.SelectedTable)) +{ + + + + + + + +} diff --git a/src/dotnet/QuantEngine.Web/Pages/Admin/Database/Index.cshtml.cs b/src/dotnet/QuantEngine.Web/Pages/Admin/Database/Index.cshtml.cs new file mode 100644 index 00000000..13f14289 --- /dev/null +++ b/src/dotnet/QuantEngine.Web/Pages/Admin/Database/Index.cshtml.cs @@ -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 _logger; + + public List TableList { get; set; } = new(); + public string? SelectedTable { get; set; } + public List ColumnNames { get; set; } = new(); + public List> 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 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 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(); + var parameters = new List(); + + 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 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(); + var paramNames = new List(); + var parameters = new List(); + + 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(); + 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}"; + } + } + } +} diff --git a/src/dotnet/QuantEngine.Web/Pages/Admin/_AdminLayout.cshtml b/src/dotnet/QuantEngine.Web/Pages/Admin/_AdminLayout.cshtml index f367ea8f..8019000d 100644 --- a/src/dotnet/QuantEngine.Web/Pages/Admin/_AdminLayout.cshtml +++ b/src/dotnet/QuantEngine.Web/Pages/Admin/_AdminLayout.cshtml @@ -66,6 +66,12 @@ 운영 관리 +