Merge branch 'main' into feature/screen-management
This commit is contained in:
commit
d5b63d1c9b
File diff suppressed because it is too large
Load Diff
|
|
@ -28,6 +28,7 @@
|
|||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@prisma/client": "^5.7.1",
|
||||
"@types/mssql": "^9.1.8",
|
||||
"axios": "^1.11.0",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"compression": "^1.7.4",
|
||||
|
|
@ -38,9 +39,12 @@
|
|||
"helmet": "^7.1.0",
|
||||
"joi": "^17.11.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"mssql": "^11.0.1",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"mysql2": "^3.15.0",
|
||||
"node-cron": "^4.2.1",
|
||||
"nodemailer": "^6.9.7",
|
||||
"oracledb": "^6.9.0",
|
||||
"pg": "^8.16.3",
|
||||
"prisma": "^5.7.1",
|
||||
"redis": "^4.6.10",
|
||||
|
|
@ -59,6 +63,7 @@
|
|||
"@types/node": "^20.10.5",
|
||||
"@types/node-cron": "^3.0.11",
|
||||
"@types/nodemailer": "^6.4.14",
|
||||
"@types/oracledb": "^6.9.1",
|
||||
"@types/pg": "^8.15.5",
|
||||
"@types/sanitize-html": "^2.9.5",
|
||||
"@types/supertest": "^6.0.2",
|
||||
|
|
|
|||
|
|
@ -27,16 +27,26 @@ model external_call_configs {
|
|||
api_type String? @db.VarChar(20)
|
||||
config_data Json
|
||||
description String?
|
||||
company_code String @default("*") @db.VarChar(20)
|
||||
is_active String? @default("Y") @db.Char(1)
|
||||
created_date DateTime? @default(now()) @db.Timestamp(6)
|
||||
created_by String? @db.VarChar(50)
|
||||
updated_date DateTime? @default(now()) @updatedAt @db.Timestamp(6)
|
||||
updated_by String? @db.VarChar(50)
|
||||
company_code String @default("*") @db.VarChar(20)
|
||||
created_date DateTime? @default(now()) @db.Timestamp(6)
|
||||
updated_date DateTime? @default(now()) @updatedAt @db.Timestamp(6)
|
||||
}
|
||||
|
||||
@@index([is_active], map: "idx_external_call_configs_active")
|
||||
@@index([company_code], map: "idx_external_call_configs_company")
|
||||
@@index([call_type, api_type], map: "idx_external_call_configs_type")
|
||||
model db_type_categories {
|
||||
type_code String @id @db.VarChar(20)
|
||||
display_name String @db.VarChar(50)
|
||||
icon String? @db.VarChar(50)
|
||||
color String? @db.VarChar(20)
|
||||
sort_order Int? @default(0)
|
||||
is_active Boolean @default(true)
|
||||
created_at DateTime @default(now()) @db.Timestamp(6)
|
||||
updated_at DateTime @default(now()) @updatedAt @db.Timestamp(6)
|
||||
|
||||
// 관계 설정
|
||||
external_db_connections external_db_connections[]
|
||||
}
|
||||
|
||||
model external_db_connections {
|
||||
|
|
@ -62,7 +72,12 @@ model external_db_connections {
|
|||
updated_date DateTime? @default(now()) @updatedAt @db.Timestamp(6)
|
||||
updated_by String? @db.VarChar(50)
|
||||
|
||||
// 관계
|
||||
db_type_category db_type_categories? @relation(fields: [db_type], references: [type_code])
|
||||
collection_configs data_collection_configs[]
|
||||
|
||||
@@index([connection_name], map: "idx_external_db_connections_name")
|
||||
@@index([db_type], map: "idx_external_db_connections_db_type")
|
||||
}
|
||||
|
||||
model admin_supply_mng {
|
||||
|
|
@ -3968,9 +3983,6 @@ model table_relationships {
|
|||
updated_date DateTime? @db.Timestamp(6)
|
||||
updated_by String? @db.VarChar(50)
|
||||
diagram_id Int?
|
||||
|
||||
// 역방향 관계
|
||||
bridges data_relationship_bridge[]
|
||||
}
|
||||
|
||||
model data_relationship_bridge {
|
||||
|
|
@ -3993,9 +4005,6 @@ model data_relationship_bridge {
|
|||
to_key_value String? @db.VarChar(500)
|
||||
to_record_id String? @db.VarChar(100)
|
||||
|
||||
// 관계 정의
|
||||
relationship table_relationships? @relation(fields: [relationship_id], references: [relationship_id])
|
||||
|
||||
@@index([connection_type], map: "idx_data_bridge_connection_type")
|
||||
@@index([company_code, is_active], map: "idx_data_bridge_company_active")
|
||||
}
|
||||
|
|
@ -4088,55 +4097,434 @@ model table_relationships_backup {
|
|||
}
|
||||
|
||||
model test_sales_info {
|
||||
sales_no String @id @db.VarChar(20)
|
||||
contract_type String? @db.VarChar(50)
|
||||
order_seq Int?
|
||||
domestic_foreign String? @db.VarChar(20)
|
||||
customer_name String? @db.VarChar(200)
|
||||
product_type String? @db.VarChar(100)
|
||||
machine_type String? @db.VarChar(100)
|
||||
customer_project_name String? @db.VarChar(200)
|
||||
expected_delivery_date DateTime? @db.Date
|
||||
receiving_location String? @db.VarChar(200)
|
||||
setup_location String? @db.VarChar(200)
|
||||
equipment_direction String? @db.VarChar(100)
|
||||
equipment_count Int? @default(0)
|
||||
equipment_type String? @db.VarChar(100)
|
||||
equipment_length Decimal? @db.Decimal(10,2)
|
||||
manager_name String? @db.VarChar(100)
|
||||
reg_date DateTime? @default(now()) @db.Timestamp(6)
|
||||
status String? @default("진행중") @db.VarChar(50)
|
||||
|
||||
// 관계 정의: 영업 정보에서 프로젝트로
|
||||
projects test_project_info[]
|
||||
sales_no String @id(map: "pk_test_sales_info") @db.VarChar(200)
|
||||
contract_type String? @db.VarChar(50)
|
||||
order_seq Int?
|
||||
domestic_foreign String? @db.VarChar(20)
|
||||
customer_name String? @db.VarChar(200)
|
||||
product_type String? @db.VarChar(100)
|
||||
machine_type String? @db.VarChar(100)
|
||||
customer_project_name String? @db.VarChar(200)
|
||||
expected_delivery_date DateTime? @db.Date
|
||||
receiving_location String? @db.VarChar(200)
|
||||
setup_location String? @db.VarChar(200)
|
||||
equipment_direction String? @db.VarChar(100)
|
||||
equipment_count Int? @default(0)
|
||||
equipment_type String? @db.VarChar(100)
|
||||
equipment_length Decimal? @db.Decimal(10, 2)
|
||||
manager_name String? @db.VarChar(100)
|
||||
reg_date DateTime? @default(now()) @db.Timestamp(6)
|
||||
status String? @default("진행중") @db.VarChar(50)
|
||||
}
|
||||
|
||||
model test_project_info {
|
||||
project_no String @id @db.VarChar(200)
|
||||
sales_no String? @db.VarChar(20)
|
||||
contract_type String? @db.VarChar(50)
|
||||
order_seq Int?
|
||||
domestic_foreign String? @db.VarChar(20)
|
||||
customer_name String? @db.VarChar(200)
|
||||
|
||||
// 프로젝트 전용 컬럼들
|
||||
project_status String? @default("PLANNING") @db.VarChar(50)
|
||||
project_start_date DateTime? @db.Date
|
||||
project_end_date DateTime? @db.Date
|
||||
project_manager String? @db.VarChar(100)
|
||||
project_description String? @db.Text
|
||||
|
||||
// 시스템 관리 컬럼들
|
||||
created_by String? @db.VarChar(100)
|
||||
created_date DateTime? @default(now()) @db.Timestamp(6)
|
||||
updated_by String? @db.VarChar(100)
|
||||
updated_date DateTime? @default(now()) @updatedAt @db.Timestamp(6)
|
||||
|
||||
// 관계 정의: 영업 정보 참조
|
||||
sales test_sales_info? @relation(fields: [sales_no], references: [sales_no])
|
||||
|
||||
project_no String @id @db.VarChar(200)
|
||||
sales_no String? @db.VarChar(20)
|
||||
contract_type String? @db.VarChar(50)
|
||||
order_seq Int?
|
||||
domestic_foreign String? @db.VarChar(20)
|
||||
customer_name String? @db.VarChar(200)
|
||||
project_status String? @default("PLANNING") @db.VarChar(50)
|
||||
project_start_date DateTime? @db.Date
|
||||
project_end_date DateTime? @db.Date
|
||||
project_manager String? @db.VarChar(100)
|
||||
project_description String?
|
||||
created_by String? @db.VarChar(100)
|
||||
created_date DateTime? @default(now()) @db.Timestamp(6)
|
||||
updated_by String? @db.VarChar(100)
|
||||
updated_date DateTime? @default(now()) @updatedAt @db.Timestamp(6)
|
||||
|
||||
@@index([sales_no], map: "idx_project_sales_no")
|
||||
@@index([project_status], map: "idx_project_status")
|
||||
@@index([customer_name], map: "idx_project_customer")
|
||||
@@index([project_manager], map: "idx_project_manager")
|
||||
}
|
||||
|
||||
model batch_jobs {
|
||||
id Int @id @default(autoincrement())
|
||||
job_name String @db.VarChar(100)
|
||||
job_type String @db.VarChar(20)
|
||||
description String?
|
||||
created_by String? @db.VarChar(50)
|
||||
updated_by String? @db.VarChar(50)
|
||||
company_code String @default("*") @db.VarChar(20)
|
||||
config_json Json?
|
||||
created_date DateTime? @default(now()) @db.Timestamp(6)
|
||||
execution_count Int @default(0)
|
||||
failure_count Int @default(0)
|
||||
last_executed_at DateTime? @db.Timestamp(6)
|
||||
next_execution_at DateTime? @db.Timestamp(6)
|
||||
schedule_cron String? @db.VarChar(100)
|
||||
success_count Int @default(0)
|
||||
updated_date DateTime? @default(now()) @updatedAt @db.Timestamp(6)
|
||||
is_active String @default("Y") @db.Char(1)
|
||||
|
||||
@@index([job_type], map: "idx_batch_jobs_type")
|
||||
@@index([company_code], map: "idx_batch_jobs_company_code")
|
||||
}
|
||||
|
||||
/// This table contains check constraints and requires additional setup for migrations. Visit https://pris.ly/d/check-constraints for more info.
|
||||
model batch_job_executions {
|
||||
id Int @id @default(autoincrement())
|
||||
job_id Int
|
||||
execution_id String @unique @db.VarChar(100)
|
||||
start_time DateTime @db.Timestamp(6)
|
||||
end_time DateTime? @db.Timestamp(6)
|
||||
status String @default("STARTED") @db.VarChar(20)
|
||||
exit_code Int?
|
||||
exit_message String?
|
||||
parameters Json?
|
||||
logs String?
|
||||
created_at DateTime? @default(now()) @db.Timestamp(6)
|
||||
|
||||
@@index([execution_id], map: "idx_batch_executions_execution_id")
|
||||
@@index([job_id], map: "idx_batch_executions_job_id")
|
||||
@@index([start_time], map: "idx_batch_executions_start_time")
|
||||
@@index([status], map: "idx_batch_executions_status")
|
||||
}
|
||||
|
||||
model batch_job_parameters {
|
||||
id Int @id @default(autoincrement())
|
||||
job_id Int
|
||||
parameter_name String @db.VarChar(100)
|
||||
parameter_value String?
|
||||
parameter_type String? @default("STRING") @db.VarChar(50)
|
||||
is_required Boolean? @default(false)
|
||||
description String?
|
||||
created_at DateTime? @default(now()) @db.Timestamp(6)
|
||||
updated_at DateTime? @db.Timestamp(6)
|
||||
|
||||
@@unique([job_id, parameter_name])
|
||||
@@index([job_id], map: "idx_batch_parameters_job_id")
|
||||
}
|
||||
|
||||
model batch_schedules {
|
||||
id Int @id @default(autoincrement())
|
||||
job_id Int
|
||||
schedule_name String @db.VarChar(255)
|
||||
cron_expression String @db.VarChar(100)
|
||||
timezone String? @default("Asia/Seoul") @db.VarChar(50)
|
||||
is_active Boolean? @default(true)
|
||||
start_date DateTime? @db.Date
|
||||
end_date DateTime? @db.Date
|
||||
created_by String @db.VarChar(100)
|
||||
created_at DateTime? @default(now()) @db.Timestamp(6)
|
||||
updated_by String? @db.VarChar(100)
|
||||
updated_at DateTime? @db.Timestamp(6)
|
||||
|
||||
@@index([is_active], map: "idx_batch_schedules_active")
|
||||
@@index([job_id], map: "idx_batch_schedules_job_id")
|
||||
}
|
||||
|
||||
/// This table contains check constraints and requires additional setup for migrations. Visit https://pris.ly/d/check-constraints for more info.
|
||||
/// This model or at least one of its fields has comments in the database, and requires an additional setup for migrations: Read more: https://pris.ly/d/database-comments
|
||||
model dataflow_external_calls {
|
||||
id Int @id @default(autoincrement())
|
||||
diagram_id Int
|
||||
source_table String @db.VarChar(100)
|
||||
trigger_condition Json
|
||||
external_call_config_id Int
|
||||
message_template String?
|
||||
is_active String? @default("Y") @db.Char(1)
|
||||
created_by Int?
|
||||
updated_by Int?
|
||||
created_at DateTime? @default(now()) @db.Timestamp(6)
|
||||
updated_at DateTime? @default(now()) @db.Timestamp(6)
|
||||
}
|
||||
|
||||
model ddl_execution_log {
|
||||
id Int @id @default(autoincrement())
|
||||
user_id String @db.VarChar(100)
|
||||
company_code String @db.VarChar(50)
|
||||
ddl_type String @db.VarChar(50)
|
||||
table_name String @db.VarChar(100)
|
||||
ddl_query String
|
||||
success Boolean
|
||||
error_message String?
|
||||
executed_at DateTime? @default(now()) @db.Timestamp(6)
|
||||
}
|
||||
|
||||
/// This table contains check constraints and requires additional setup for migrations. Visit https://pris.ly/d/check-constraints for more info.
|
||||
/// This model or at least one of its fields has comments in the database, and requires an additional setup for migrations: Read more: https://pris.ly/d/database-comments
|
||||
model external_call_logs {
|
||||
id Int @id @default(autoincrement())
|
||||
dataflow_external_call_id Int?
|
||||
external_call_config_id Int
|
||||
trigger_data Json?
|
||||
request_data Json?
|
||||
response_data Json?
|
||||
status String @db.VarChar(20)
|
||||
error_message String?
|
||||
execution_time Int?
|
||||
executed_at DateTime? @default(now()) @db.Timestamp(6)
|
||||
|
||||
@@index([executed_at], map: "idx_external_call_logs_executed")
|
||||
}
|
||||
|
||||
model my_custom_table {
|
||||
id Int @id @default(autoincrement())
|
||||
created_date DateTime? @default(now()) @db.Timestamp(6)
|
||||
updated_date DateTime? @default(now()) @db.Timestamp(6)
|
||||
company_code String? @default("*") @db.VarChar(50)
|
||||
customer_name String? @db.VarChar
|
||||
email_address String? @db.VarChar(255)
|
||||
}
|
||||
|
||||
model table_type_columns {
|
||||
id Int @id @default(autoincrement())
|
||||
table_name String @db.VarChar(255)
|
||||
column_name String @db.VarChar(255)
|
||||
input_type String @default("text") @db.VarChar(50)
|
||||
detail_settings String? @default("{}")
|
||||
is_nullable String? @default("Y") @db.VarChar(10)
|
||||
display_order Int? @default(0)
|
||||
created_date DateTime? @default(now()) @db.Timestamp(6)
|
||||
updated_date DateTime? @default(now()) @db.Timestamp(6)
|
||||
|
||||
@@unique([table_name, column_name])
|
||||
@@index([input_type], map: "idx_table_type_columns_input_type")
|
||||
@@index([table_name], map: "idx_table_type_columns_table_name")
|
||||
}
|
||||
|
||||
model test_api_integration_1758589777139 {
|
||||
id String @id @default(dbgenerated("(gen_random_uuid())::text")) @db.VarChar(500)
|
||||
created_date String? @default(dbgenerated("(now())::text")) @db.VarChar(500)
|
||||
updated_date String? @default(dbgenerated("(now())::text")) @db.VarChar(500)
|
||||
writer String? @db.VarChar(500)
|
||||
company_code String? @default("*") @db.VarChar(500)
|
||||
product_name String? @db.VarChar(500)
|
||||
price String? @db.VarChar(500)
|
||||
category String? @db.VarChar(500)
|
||||
}
|
||||
|
||||
model test_new_table {
|
||||
id Int @id @default(autoincrement())
|
||||
created_date DateTime? @default(now()) @db.Timestamp(6)
|
||||
updated_date DateTime? @default(now()) @db.Timestamp(6)
|
||||
company_code String? @default("*") @db.VarChar(50)
|
||||
name String? @db.VarChar
|
||||
email String? @db.VarChar(255)
|
||||
user_test_column String? @db.VarChar
|
||||
dsfsdf123215 String? @db.VarChar
|
||||
aaaassda String? @db.VarChar
|
||||
}
|
||||
|
||||
model test_new_table33333 {
|
||||
id Int @id @default(autoincrement())
|
||||
created_date DateTime? @default(now()) @db.Timestamp(6)
|
||||
updated_date DateTime? @default(now()) @db.Timestamp(6)
|
||||
writer String? @db.VarChar(100)
|
||||
company_code String? @default("*") @db.VarChar(50)
|
||||
eeeeeeee String? @db.VarChar(500)
|
||||
wwww String? @db.VarChar(500)
|
||||
sssss String? @db.VarChar(500)
|
||||
}
|
||||
|
||||
model test_new_table44444 {
|
||||
id String @id @default(dbgenerated("(gen_random_uuid())::text")) @db.VarChar(500)
|
||||
created_date String? @default(dbgenerated("(now())::text")) @db.VarChar(500)
|
||||
updated_date String? @default(dbgenerated("(now())::text")) @db.VarChar(500)
|
||||
writer String? @db.VarChar(500)
|
||||
company_code String? @db.VarChar(500)
|
||||
ttttttt String? @db.VarChar(500)
|
||||
yyyyyyy String? @db.VarChar(500)
|
||||
uuuuuuu String? @db.VarChar(500)
|
||||
iiiiiii String? @db.VarChar(500)
|
||||
}
|
||||
|
||||
model test_new_table555555 {
|
||||
id String @id @default(dbgenerated("(gen_random_uuid())::text")) @db.VarChar(500)
|
||||
created_date String? @default(dbgenerated("(now())::text")) @db.VarChar(500)
|
||||
updated_date String? @default(dbgenerated("(now())::text")) @db.VarChar(500)
|
||||
writer String? @db.VarChar(500)
|
||||
company_code String? @db.VarChar(500)
|
||||
rtrtrtrtr String? @db.VarChar(500)
|
||||
ererwewewe String? @db.VarChar(500)
|
||||
wetyeryrtyut String? @db.VarChar(500)
|
||||
werwqq String? @db.VarChar(500)
|
||||
saved_file_name String? @db.VarChar(500)
|
||||
}
|
||||
|
||||
model test_table_info {
|
||||
id Int @id @default(autoincrement())
|
||||
created_date DateTime? @default(now()) @db.Timestamp(6)
|
||||
updated_date DateTime? @default(now()) @db.Timestamp(6)
|
||||
company_code String? @default("*") @db.VarChar(50)
|
||||
objid Int
|
||||
test_name String? @db.VarChar(250)
|
||||
ggggggggggg String? @db.VarChar
|
||||
test_column_1 String? @db.VarChar
|
||||
test_column_2 String? @db.VarChar
|
||||
test_column_3 String? @db.VarChar
|
||||
final_test_column String? @db.VarChar
|
||||
zzzzzzz String? @db.VarChar
|
||||
bbbbbbb String? @db.VarChar
|
||||
realtime_test String? @db.VarChar
|
||||
table_update_test String? @db.VarChar
|
||||
}
|
||||
|
||||
model test_table_info2222 {
|
||||
id Int @id @default(autoincrement())
|
||||
created_date DateTime? @default(now()) @db.Timestamp(6)
|
||||
updated_date DateTime? @default(now()) @db.Timestamp(6)
|
||||
company_code String? @default("*") @db.VarChar(50)
|
||||
clll_cc String? @db.VarChar
|
||||
eeee_eee String? @db.VarChar
|
||||
saved_file_name String? @db.VarChar
|
||||
debug_test_column String? @db.VarChar
|
||||
field_1 String? @db.VarChar
|
||||
rrrrrrrrrr String? @db.VarChar
|
||||
tttttttt String? @db.VarChar
|
||||
}
|
||||
|
||||
model test_varchar_unified {
|
||||
id String @id @default(dbgenerated("(gen_random_uuid())::text")) @db.VarChar(500)
|
||||
created_date String? @default(dbgenerated("(now())::text")) @db.VarChar(500)
|
||||
updated_date String? @default(dbgenerated("(now())::text")) @db.VarChar(500)
|
||||
writer String? @db.VarChar(500)
|
||||
company_code String? @default("*") @db.VarChar(500)
|
||||
product_name String? @db.VarChar(500)
|
||||
price String? @db.VarChar(500)
|
||||
launch_date String? @db.VarChar(500)
|
||||
is_active String? @db.VarChar(500)
|
||||
}
|
||||
|
||||
model test_varchar_unified_1758588878993 {
|
||||
id String @id @default(dbgenerated("(gen_random_uuid())::text")) @db.VarChar(500)
|
||||
created_date String? @default(dbgenerated("(now())::text")) @db.VarChar(500)
|
||||
updated_date String? @default(dbgenerated("(now())::text")) @db.VarChar(500)
|
||||
writer String? @db.VarChar(500)
|
||||
company_code String? @default("*") @db.VarChar(500)
|
||||
product_name String? @db.VarChar(500)
|
||||
price String? @db.VarChar(500)
|
||||
launch_date String? @db.VarChar(500)
|
||||
is_active String? @db.VarChar(500)
|
||||
}
|
||||
|
||||
model writer_test_table {
|
||||
id Int @id @default(autoincrement())
|
||||
created_date DateTime? @default(now()) @db.Timestamp(6)
|
||||
updated_date DateTime? @default(now()) @db.Timestamp(6)
|
||||
writer String? @db.VarChar(100)
|
||||
company_code String? @default("*") @db.VarChar(50)
|
||||
test_field String? @db.VarChar
|
||||
field_1 String? @db.VarChar
|
||||
}
|
||||
|
||||
// 데이터 수집 설정 테이블
|
||||
model data_collection_configs {
|
||||
id Int @id @default(autoincrement())
|
||||
config_name String @db.VarChar(100)
|
||||
description String?
|
||||
source_connection_id Int
|
||||
source_table String @db.VarChar(100)
|
||||
target_table String? @db.VarChar(100)
|
||||
collection_type String @db.VarChar(20) // full, incremental, delta
|
||||
schedule_cron String? @db.VarChar(100)
|
||||
is_active String @default("Y") @db.Char(1)
|
||||
last_collected_at DateTime? @db.Timestamp(6)
|
||||
collection_options Json?
|
||||
created_date DateTime? @default(now()) @db.Timestamp(6)
|
||||
created_by String? @db.VarChar(50)
|
||||
updated_date DateTime? @default(now()) @updatedAt @db.Timestamp(6)
|
||||
updated_by String? @db.VarChar(50)
|
||||
company_code String @default("*") @db.VarChar(20)
|
||||
|
||||
// 관계
|
||||
collection_jobs data_collection_jobs[]
|
||||
collection_history data_collection_history[]
|
||||
external_connection external_db_connections @relation(fields: [source_connection_id], references: [id])
|
||||
|
||||
@@index([source_connection_id], map: "idx_data_collection_configs_connection")
|
||||
@@index([is_active], map: "idx_data_collection_configs_active")
|
||||
@@index([company_code], map: "idx_data_collection_configs_company")
|
||||
}
|
||||
|
||||
// 데이터 수집 작업 테이블
|
||||
model data_collection_jobs {
|
||||
id Int @id @default(autoincrement())
|
||||
config_id Int
|
||||
job_status String @db.VarChar(20) // pending, running, completed, failed
|
||||
started_at DateTime? @db.Timestamp(6)
|
||||
completed_at DateTime? @db.Timestamp(6)
|
||||
records_processed Int? @default(0)
|
||||
error_message String?
|
||||
job_details Json?
|
||||
created_date DateTime? @default(now()) @db.Timestamp(6)
|
||||
|
||||
// 관계
|
||||
config data_collection_configs @relation(fields: [config_id], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([config_id], map: "idx_data_collection_jobs_config")
|
||||
@@index([job_status], map: "idx_data_collection_jobs_status")
|
||||
@@index([created_date], map: "idx_data_collection_jobs_created")
|
||||
}
|
||||
|
||||
// 데이터 수집 이력 테이블
|
||||
model data_collection_history {
|
||||
id Int @id @default(autoincrement())
|
||||
config_id Int
|
||||
collection_date DateTime @db.Timestamp(6)
|
||||
records_collected Int @default(0)
|
||||
execution_time_ms Int @default(0)
|
||||
status String @db.VarChar(20) // success, partial, failed
|
||||
error_details String?
|
||||
created_date DateTime? @default(now()) @db.Timestamp(6)
|
||||
|
||||
// 관계
|
||||
config data_collection_configs @relation(fields: [config_id], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([config_id], map: "idx_data_collection_history_config")
|
||||
@@index([collection_date], map: "idx_data_collection_history_date")
|
||||
@@index([status], map: "idx_data_collection_history_status")
|
||||
}
|
||||
|
||||
// 데이터 수집 배치 관리 테이블 (기존 batch_jobs와 구분)
|
||||
model collection_batch_management {
|
||||
id Int @id @default(autoincrement())
|
||||
batch_name String @db.VarChar(100)
|
||||
description String?
|
||||
batch_type String @db.VarChar(20) // collection, sync, cleanup, custom
|
||||
schedule_cron String? @db.VarChar(100)
|
||||
is_active String @default("Y") @db.Char(1)
|
||||
config_json Json?
|
||||
last_executed_at DateTime? @db.Timestamp(6)
|
||||
next_execution_at DateTime? @db.Timestamp(6)
|
||||
execution_count Int @default(0)
|
||||
success_count Int @default(0)
|
||||
failure_count Int @default(0)
|
||||
created_date DateTime? @default(now()) @db.Timestamp(6)
|
||||
created_by String? @db.VarChar(50)
|
||||
updated_date DateTime? @default(now()) @updatedAt @db.Timestamp(6)
|
||||
updated_by String? @db.VarChar(50)
|
||||
company_code String @default("*") @db.VarChar(20)
|
||||
|
||||
// 관계
|
||||
batch_executions collection_batch_executions[]
|
||||
|
||||
@@index([batch_type], map: "idx_collection_batch_mgmt_type")
|
||||
@@index([is_active], map: "idx_collection_batch_mgmt_active")
|
||||
@@index([company_code], map: "idx_collection_batch_mgmt_company")
|
||||
@@index([next_execution_at], map: "idx_collection_batch_mgmt_next_execution")
|
||||
}
|
||||
|
||||
// 데이터 수집 배치 실행 테이블
|
||||
model collection_batch_executions {
|
||||
id Int @id @default(autoincrement())
|
||||
batch_id Int
|
||||
execution_status String @db.VarChar(20) // pending, running, completed, failed, cancelled
|
||||
started_at DateTime? @db.Timestamp(6)
|
||||
completed_at DateTime? @db.Timestamp(6)
|
||||
execution_time_ms Int?
|
||||
result_data Json?
|
||||
error_message String?
|
||||
log_details String?
|
||||
created_date DateTime? @default(now()) @db.Timestamp(6)
|
||||
|
||||
// 관계
|
||||
batch collection_batch_management @relation(fields: [batch_id], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([batch_id], map: "idx_collection_batch_executions_batch")
|
||||
@@index([execution_status], map: "idx_collection_batch_executions_status")
|
||||
@@index([created_date], map: "idx_collection_batch_executions_created")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,8 +31,11 @@ import layoutRoutes from "./routes/layoutRoutes";
|
|||
import dataRoutes from "./routes/dataRoutes";
|
||||
import testButtonDataflowRoutes from "./routes/testButtonDataflowRoutes";
|
||||
import externalDbConnectionRoutes from "./routes/externalDbConnectionRoutes";
|
||||
import dbTypeCategoryRoutes from "./routes/dbTypeCategoryRoutes";
|
||||
import ddlRoutes from "./routes/ddlRoutes";
|
||||
import entityReferenceRoutes from "./routes/entityReferenceRoutes";
|
||||
// import collectionRoutes from "./routes/collectionRoutes"; // 임시 주석
|
||||
// import batchRoutes from "./routes/batchRoutes"; // 임시 주석
|
||||
// import userRoutes from './routes/userRoutes';
|
||||
// import menuRoutes from './routes/menuRoutes';
|
||||
|
||||
|
|
@ -127,8 +130,11 @@ app.use("/api/screen", screenStandardRoutes);
|
|||
app.use("/api/data", dataRoutes);
|
||||
app.use("/api/test-button-dataflow", testButtonDataflowRoutes);
|
||||
app.use("/api/external-db-connections", externalDbConnectionRoutes);
|
||||
app.use("/api/db-type-categories", dbTypeCategoryRoutes);
|
||||
app.use("/api/ddl", ddlRoutes);
|
||||
app.use("/api/entity-reference", entityReferenceRoutes);
|
||||
// app.use("/api/collections", collectionRoutes); // 임시 주석
|
||||
// app.use("/api/batch", batchRoutes); // 임시 주석
|
||||
// app.use('/api/users', userRoutes);
|
||||
// app.use('/api/menus', menuRoutes);
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,294 @@
|
|||
// 배치 관리 컨트롤러
|
||||
// 작성일: 2024-12-23
|
||||
|
||||
import { Request, Response } from 'express';
|
||||
import { BatchService } from '../services/batchService';
|
||||
import { BatchJob, BatchJobFilter } from '../types/batchManagement';
|
||||
import { AuthenticatedRequest } from '../middleware/authMiddleware';
|
||||
|
||||
export class BatchController {
|
||||
/**
|
||||
* 배치 작업 목록 조회
|
||||
*/
|
||||
static async getBatchJobs(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const filter: BatchJobFilter = {
|
||||
job_name: req.query.job_name as string,
|
||||
job_type: req.query.job_type as string,
|
||||
is_active: req.query.is_active as string,
|
||||
company_code: req.user?.companyCode || '*',
|
||||
search: req.query.search as string,
|
||||
};
|
||||
|
||||
const jobs = await BatchService.getBatchJobs(filter);
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
data: jobs,
|
||||
message: '배치 작업 목록을 조회했습니다.',
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('배치 작업 목록 조회 오류:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: error instanceof Error ? error.message : '배치 작업 목록 조회에 실패했습니다.',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 배치 작업 상세 조회
|
||||
*/
|
||||
static async getBatchJobById(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const id = parseInt(req.params.id);
|
||||
if (isNaN(id)) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: '유효하지 않은 ID입니다.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const job = await BatchService.getBatchJobById(id);
|
||||
if (!job) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
message: '배치 작업을 찾을 수 없습니다.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
data: job,
|
||||
message: '배치 작업을 조회했습니다.',
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('배치 작업 조회 오류:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: error instanceof Error ? error.message : '배치 작업 조회에 실패했습니다.',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 배치 작업 생성
|
||||
*/
|
||||
static async createBatchJob(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const data: BatchJob = {
|
||||
...req.body,
|
||||
company_code: req.user?.companyCode || '*',
|
||||
created_by: req.user?.userId,
|
||||
};
|
||||
|
||||
// 필수 필드 검증
|
||||
if (!data.job_name || !data.job_type) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: '필수 필드가 누락되었습니다.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const job = await BatchService.createBatchJob(data);
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
data: job,
|
||||
message: '배치 작업을 생성했습니다.',
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('배치 작업 생성 오류:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: error instanceof Error ? error.message : '배치 작업 생성에 실패했습니다.',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 배치 작업 수정
|
||||
*/
|
||||
static async updateBatchJob(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const id = parseInt(req.params.id);
|
||||
if (isNaN(id)) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: '유효하지 않은 ID입니다.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const data: Partial<BatchJob> = {
|
||||
...req.body,
|
||||
updated_by: req.user?.userId,
|
||||
};
|
||||
|
||||
const job = await BatchService.updateBatchJob(id, data);
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
data: job,
|
||||
message: '배치 작업을 수정했습니다.',
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('배치 작업 수정 오류:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: error instanceof Error ? error.message : '배치 작업 수정에 실패했습니다.',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 배치 작업 삭제
|
||||
*/
|
||||
static async deleteBatchJob(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const id = parseInt(req.params.id);
|
||||
if (isNaN(id)) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: '유효하지 않은 ID입니다.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await BatchService.deleteBatchJob(id);
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
message: '배치 작업을 삭제했습니다.',
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('배치 작업 삭제 오류:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: error instanceof Error ? error.message : '배치 작업 삭제에 실패했습니다.',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 배치 작업 수동 실행
|
||||
*/
|
||||
static async executeBatchJob(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const id = parseInt(req.params.id);
|
||||
if (isNaN(id)) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: '유효하지 않은 ID입니다.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const execution = await BatchService.executeBatchJob(id);
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
data: execution,
|
||||
message: '배치 작업을 실행했습니다.',
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('배치 작업 실행 오류:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: error instanceof Error ? error.message : '배치 작업 실행에 실패했습니다.',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 배치 실행 목록 조회
|
||||
*/
|
||||
static async getBatchExecutions(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const jobId = req.query.job_id ? parseInt(req.query.job_id as string) : undefined;
|
||||
const executions = await BatchService.getBatchExecutions(jobId);
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
data: executions,
|
||||
message: '배치 실행 목록을 조회했습니다.',
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('배치 실행 목록 조회 오류:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: error instanceof Error ? error.message : '배치 실행 목록 조회에 실패했습니다.',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 배치 모니터링 정보 조회
|
||||
*/
|
||||
static async getBatchMonitoring(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const monitoring = await BatchService.getBatchMonitoring();
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
data: monitoring,
|
||||
message: '배치 모니터링 정보를 조회했습니다.',
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('배치 모니터링 조회 오류:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: error instanceof Error ? error.message : '배치 모니터링 조회에 실패했습니다.',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 지원되는 작업 타입 조회
|
||||
*/
|
||||
static async getSupportedJobTypes(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const { BATCH_JOB_TYPE_OPTIONS } = await import('../types/batchManagement');
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
data: {
|
||||
types: BATCH_JOB_TYPE_OPTIONS,
|
||||
},
|
||||
message: '지원하는 작업 타입 목록을 조회했습니다.',
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('작업 타입 조회 오류:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: '작업 타입 조회에 실패했습니다.',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 스케줄 프리셋 조회
|
||||
*/
|
||||
static async getSchedulePresets(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const { SCHEDULE_PRESETS } = await import('../types/batchManagement');
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
data: {
|
||||
presets: SCHEDULE_PRESETS,
|
||||
},
|
||||
message: '스케줄 프리셋 목록을 조회했습니다.',
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('스케줄 프리셋 조회 오류:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: '스케줄 프리셋 조회에 실패했습니다.',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,258 @@
|
|||
// 수집 관리 컨트롤러
|
||||
// 작성일: 2024-12-23
|
||||
|
||||
import { Request, Response } from 'express';
|
||||
import { CollectionService } from '../services/collectionService';
|
||||
import { DataCollectionConfig, CollectionFilter } from '../types/collectionManagement';
|
||||
import { AuthenticatedRequest } from '../middleware/authMiddleware';
|
||||
|
||||
export class CollectionController {
|
||||
/**
|
||||
* 수집 설정 목록 조회
|
||||
*/
|
||||
static async getCollectionConfigs(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const filter: CollectionFilter = {
|
||||
config_name: req.query.config_name as string,
|
||||
source_connection_id: req.query.source_connection_id ? parseInt(req.query.source_connection_id as string) : undefined,
|
||||
collection_type: req.query.collection_type as string,
|
||||
is_active: req.query.is_active as string,
|
||||
company_code: req.user?.companyCode || '*',
|
||||
search: req.query.search as string,
|
||||
};
|
||||
|
||||
const configs = await CollectionService.getCollectionConfigs(filter);
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
data: configs,
|
||||
message: '수집 설정 목록을 조회했습니다.',
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('수집 설정 목록 조회 오류:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: error instanceof Error ? error.message : '수집 설정 목록 조회에 실패했습니다.',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 수집 설정 상세 조회
|
||||
*/
|
||||
static async getCollectionConfigById(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const id = parseInt(req.params.id);
|
||||
if (isNaN(id)) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: '유효하지 않은 ID입니다.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const config = await CollectionService.getCollectionConfigById(id);
|
||||
if (!config) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
message: '수집 설정을 찾을 수 없습니다.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
data: config,
|
||||
message: '수집 설정을 조회했습니다.',
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('수집 설정 조회 오류:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: error instanceof Error ? error.message : '수집 설정 조회에 실패했습니다.',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 수집 설정 생성
|
||||
*/
|
||||
static async createCollectionConfig(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const data: DataCollectionConfig = {
|
||||
...req.body,
|
||||
company_code: req.user?.companyCode || '*',
|
||||
created_by: req.user?.userId,
|
||||
};
|
||||
|
||||
// 필수 필드 검증
|
||||
if (!data.config_name || !data.source_connection_id || !data.source_table || !data.collection_type) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: '필수 필드가 누락되었습니다.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const config = await CollectionService.createCollectionConfig(data);
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
data: config,
|
||||
message: '수집 설정을 생성했습니다.',
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('수집 설정 생성 오류:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: error instanceof Error ? error.message : '수집 설정 생성에 실패했습니다.',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 수집 설정 수정
|
||||
*/
|
||||
static async updateCollectionConfig(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const id = parseInt(req.params.id);
|
||||
if (isNaN(id)) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: '유효하지 않은 ID입니다.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const data: Partial<DataCollectionConfig> = {
|
||||
...req.body,
|
||||
updated_by: req.user?.userId,
|
||||
};
|
||||
|
||||
const config = await CollectionService.updateCollectionConfig(id, data);
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
data: config,
|
||||
message: '수집 설정을 수정했습니다.',
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('수집 설정 수정 오류:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: error instanceof Error ? error.message : '수집 설정 수정에 실패했습니다.',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 수집 설정 삭제
|
||||
*/
|
||||
static async deleteCollectionConfig(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const id = parseInt(req.params.id);
|
||||
if (isNaN(id)) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: '유효하지 않은 ID입니다.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await CollectionService.deleteCollectionConfig(id);
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
message: '수집 설정을 삭제했습니다.',
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('수집 설정 삭제 오류:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: error instanceof Error ? error.message : '수집 설정 삭제에 실패했습니다.',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 수집 작업 실행
|
||||
*/
|
||||
static async executeCollection(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const id = parseInt(req.params.id);
|
||||
if (isNaN(id)) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: '유효하지 않은 ID입니다.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const job = await CollectionService.executeCollection(id);
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
data: job,
|
||||
message: '수집 작업을 시작했습니다.',
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('수집 작업 실행 오류:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: error instanceof Error ? error.message : '수집 작업 실행에 실패했습니다.',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 수집 작업 목록 조회
|
||||
*/
|
||||
static async getCollectionJobs(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const configId = req.query.config_id ? parseInt(req.query.config_id as string) : undefined;
|
||||
const jobs = await CollectionService.getCollectionJobs(configId);
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
data: jobs,
|
||||
message: '수집 작업 목록을 조회했습니다.',
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('수집 작업 목록 조회 오류:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: error instanceof Error ? error.message : '수집 작업 목록 조회에 실패했습니다.',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 수집 이력 조회
|
||||
*/
|
||||
static async getCollectionHistory(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const configId = parseInt(req.params.configId);
|
||||
if (isNaN(configId)) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: '유효하지 않은 설정 ID입니다.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const history = await CollectionService.getCollectionHistory(configId);
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
data: history,
|
||||
message: '수집 이력을 조회했습니다.',
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('수집 이력 조회 오류:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: error instanceof Error ? error.message : '수집 이력 조회에 실패했습니다.',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,215 @@
|
|||
import { Request, Response } from "express";
|
||||
import { DbTypeCategoryService } from "../services/dbTypeCategoryService";
|
||||
import { AuthenticatedRequest } from "../types/auth";
|
||||
|
||||
export class DbTypeCategoryController {
|
||||
/**
|
||||
* GET /api/db-type-categories
|
||||
* 모든 DB 타입 카테고리 조회
|
||||
*/
|
||||
static async getAllCategories(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const result = await DbTypeCategoryService.getAllCategories();
|
||||
|
||||
if (result.success) {
|
||||
return res.json(result);
|
||||
} else {
|
||||
return res.status(400).json(result);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("DB 타입 카테고리 조회 오류:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "DB 타입 카테고리 조회 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/db-type-categories/:typeCode
|
||||
* 특정 DB 타입 카테고리 조회
|
||||
*/
|
||||
static async getCategoryByTypeCode(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const { typeCode } = req.params;
|
||||
|
||||
if (!typeCode) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "DB 타입 코드가 필요합니다."
|
||||
});
|
||||
}
|
||||
|
||||
const result = await DbTypeCategoryService.getCategoryByTypeCode(typeCode);
|
||||
|
||||
if (result.success) {
|
||||
return res.json(result);
|
||||
} else {
|
||||
return res.status(404).json(result);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("DB 타입 카테고리 조회 오류:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "DB 타입 카테고리 조회 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/db-type-categories
|
||||
* DB 타입 카테고리 생성
|
||||
*/
|
||||
static async createCategory(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const { type_code, display_name, icon, color, sort_order } = req.body;
|
||||
|
||||
if (!type_code || !display_name) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "DB 타입 코드와 표시명은 필수입니다."
|
||||
});
|
||||
}
|
||||
|
||||
const result = await DbTypeCategoryService.createCategory({
|
||||
type_code,
|
||||
display_name,
|
||||
icon,
|
||||
color,
|
||||
sort_order
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
return res.status(201).json(result);
|
||||
} else {
|
||||
return res.status(400).json(result);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("DB 타입 카테고리 생성 오류:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "DB 타입 카테고리 생성 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT /api/db-type-categories/:typeCode
|
||||
* DB 타입 카테고리 수정
|
||||
*/
|
||||
static async updateCategory(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const { typeCode } = req.params;
|
||||
const { display_name, icon, color, sort_order, is_active } = req.body;
|
||||
|
||||
if (!typeCode) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "DB 타입 코드가 필요합니다."
|
||||
});
|
||||
}
|
||||
|
||||
const result = await DbTypeCategoryService.updateCategory(typeCode, {
|
||||
display_name,
|
||||
icon,
|
||||
color,
|
||||
sort_order,
|
||||
is_active
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
return res.json(result);
|
||||
} else {
|
||||
return res.status(400).json(result);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("DB 타입 카테고리 수정 오류:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "DB 타입 카테고리 수정 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/db-type-categories/:typeCode
|
||||
* DB 타입 카테고리 삭제 (비활성화)
|
||||
*/
|
||||
static async deleteCategory(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const { typeCode } = req.params;
|
||||
|
||||
if (!typeCode) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "DB 타입 코드가 필요합니다."
|
||||
});
|
||||
}
|
||||
|
||||
const result = await DbTypeCategoryService.deleteCategory(typeCode);
|
||||
|
||||
if (result.success) {
|
||||
return res.json(result);
|
||||
} else {
|
||||
return res.status(400).json(result);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("DB 타입 카테고리 삭제 오류:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "DB 타입 카테고리 삭제 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/db-type-categories/stats/connections
|
||||
* DB 타입별 연결 통계 조회
|
||||
*/
|
||||
static async getConnectionStatsByType(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const result = await DbTypeCategoryService.getConnectionStatsByType();
|
||||
|
||||
if (result.success) {
|
||||
return res.json(result);
|
||||
} else {
|
||||
return res.status(400).json(result);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("DB 타입별 통계 조회 오류:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "DB 타입별 통계 조회 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/db-type-categories/initialize
|
||||
* 기본 DB 타입 카테고리 초기화
|
||||
*/
|
||||
static async initializeDefaultCategories(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const result = await DbTypeCategoryService.initializeDefaultCategories();
|
||||
|
||||
if (result.success) {
|
||||
return res.json(result);
|
||||
} else {
|
||||
return res.status(400).json(result);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("기본 카테고리 초기화 오류:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "기본 카테고리 초기화 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류"
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,8 @@
|
|||
import { DatabaseConnector, ConnectionConfig } from '../interfaces/DatabaseConnector';
|
||||
import { PostgreSQLConnector } from './PostgreSQLConnector';
|
||||
import { MariaDBConnector } from './MariaDBConnector';
|
||||
import { MSSQLConnector } from './MSSQLConnector';
|
||||
import { OracleConnector } from './OracleConnector';
|
||||
|
||||
export class DatabaseConnectorFactory {
|
||||
private static connectors = new Map<string, DatabaseConnector>();
|
||||
|
|
@ -20,6 +23,16 @@ export class DatabaseConnectorFactory {
|
|||
case 'postgresql':
|
||||
connector = new PostgreSQLConnector(config);
|
||||
break;
|
||||
case 'mariadb':
|
||||
case 'mysql': // mysql 타입도 MariaDB 커넥터 사용
|
||||
connector = new MariaDBConnector(config);
|
||||
break;
|
||||
case 'mssql':
|
||||
connector = new MSSQLConnector(config);
|
||||
break;
|
||||
case 'oracle':
|
||||
connector = new OracleConnector(config);
|
||||
break;
|
||||
// Add other database types here
|
||||
default:
|
||||
throw new Error(`지원하지 않는 데이터베이스 타입: ${type}`);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,182 @@
|
|||
import { DatabaseConnector, ConnectionConfig, QueryResult } from '../interfaces/DatabaseConnector';
|
||||
import { ConnectionTestResult, TableInfo } from '../types/externalDbTypes';
|
||||
import * as mssql from 'mssql';
|
||||
|
||||
export class MSSQLConnector implements DatabaseConnector {
|
||||
private pool: mssql.ConnectionPool | null = null;
|
||||
private config: ConnectionConfig;
|
||||
|
||||
constructor(config: ConnectionConfig) {
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
async connect(): Promise<void> {
|
||||
if (!this.pool) {
|
||||
const config: mssql.config = {
|
||||
server: this.config.host,
|
||||
port: this.config.port,
|
||||
user: this.config.user,
|
||||
password: this.config.password,
|
||||
database: this.config.database,
|
||||
options: {
|
||||
encrypt: this.config.ssl === true,
|
||||
trustServerCertificate: true
|
||||
},
|
||||
connectionTimeout: this.config.connectionTimeoutMillis || 15000,
|
||||
requestTimeout: this.config.queryTimeoutMillis || 15000
|
||||
};
|
||||
this.pool = await new mssql.ConnectionPool(config).connect();
|
||||
}
|
||||
}
|
||||
|
||||
async disconnect(): Promise<void> {
|
||||
if (this.pool) {
|
||||
await this.pool.close();
|
||||
this.pool = null;
|
||||
}
|
||||
}
|
||||
|
||||
async testConnection(): Promise<ConnectionTestResult> {
|
||||
const startTime = Date.now();
|
||||
try {
|
||||
await this.connect();
|
||||
|
||||
// 버전 정보 조회
|
||||
const versionResult = await this.pool!.request().query('SELECT @@VERSION as version');
|
||||
|
||||
// 데이터베이스 크기 조회
|
||||
const sizeResult = await this.pool!.request()
|
||||
.input('dbName', mssql.VarChar, this.config.database)
|
||||
.query(`
|
||||
SELECT SUM(size * 8 * 1024) as size
|
||||
FROM sys.master_files
|
||||
WHERE database_id = DB_ID(@dbName)
|
||||
`);
|
||||
|
||||
const responseTime = Date.now() - startTime;
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: "MSSQL 연결이 성공했습니다.",
|
||||
details: {
|
||||
response_time: responseTime,
|
||||
server_version: versionResult.recordset[0]?.version || "알 수 없음",
|
||||
database_size: this.formatBytes(parseInt(sizeResult.recordset[0]?.size || "0")),
|
||||
},
|
||||
};
|
||||
} catch (error: any) {
|
||||
return {
|
||||
success: false,
|
||||
message: "MSSQL 연결에 실패했습니다.",
|
||||
error: {
|
||||
code: "CONNECTION_FAILED",
|
||||
details: error.message || "알 수 없는 오류",
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async executeQuery(query: string): Promise<QueryResult> {
|
||||
try {
|
||||
await this.connect();
|
||||
const result = await this.pool!.request().query(query);
|
||||
return {
|
||||
rows: result.recordset,
|
||||
rowCount: result.rowsAffected[0],
|
||||
};
|
||||
} catch (error: any) {
|
||||
throw new Error(`쿼리 실행 오류: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async getTables(): Promise<TableInfo[]> {
|
||||
try {
|
||||
await this.connect();
|
||||
const result = await this.pool!.request().query(`
|
||||
SELECT
|
||||
t.TABLE_NAME as table_name,
|
||||
c.COLUMN_NAME as column_name,
|
||||
c.DATA_TYPE as data_type,
|
||||
c.IS_NULLABLE as is_nullable,
|
||||
c.COLUMN_DEFAULT as column_default,
|
||||
CAST(p.value AS NVARCHAR(MAX)) as description
|
||||
FROM INFORMATION_SCHEMA.TABLES t
|
||||
LEFT JOIN INFORMATION_SCHEMA.COLUMNS c
|
||||
ON c.TABLE_NAME = t.TABLE_NAME
|
||||
LEFT JOIN sys.extended_properties p
|
||||
ON p.major_id = OBJECT_ID(t.TABLE_NAME)
|
||||
AND p.minor_id = 0
|
||||
AND p.name = 'MS_Description'
|
||||
WHERE t.TABLE_TYPE = 'BASE TABLE'
|
||||
ORDER BY t.TABLE_NAME, c.ORDINAL_POSITION
|
||||
`);
|
||||
|
||||
// 결과를 TableInfo[] 형식으로 변환
|
||||
const tables = new Map<string, TableInfo>();
|
||||
|
||||
result.recordset.forEach((row: any) => {
|
||||
if (!tables.has(row.table_name)) {
|
||||
tables.set(row.table_name, {
|
||||
table_name: row.table_name,
|
||||
columns: [],
|
||||
description: row.description || null
|
||||
});
|
||||
}
|
||||
|
||||
if (row.column_name) {
|
||||
tables.get(row.table_name)!.columns.push({
|
||||
column_name: row.column_name,
|
||||
data_type: row.data_type,
|
||||
is_nullable: row.is_nullable === 'YES' ? 'Y' : 'N',
|
||||
column_default: row.column_default
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return Array.from(tables.values());
|
||||
} catch (error: any) {
|
||||
throw new Error(`테이블 목록 조회 오류: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async getColumns(tableName: string): Promise<any[]> {
|
||||
try {
|
||||
await this.connect();
|
||||
const result = await this.pool!.request()
|
||||
.input('tableName', mssql.VarChar, tableName)
|
||||
.query(`
|
||||
SELECT
|
||||
c.COLUMN_NAME as name,
|
||||
c.DATA_TYPE as type,
|
||||
c.IS_NULLABLE as nullable,
|
||||
c.COLUMN_DEFAULT as default_value,
|
||||
c.CHARACTER_MAXIMUM_LENGTH as max_length,
|
||||
c.NUMERIC_PRECISION as precision,
|
||||
c.NUMERIC_SCALE as scale,
|
||||
CAST(p.value AS NVARCHAR(MAX)) as description
|
||||
FROM INFORMATION_SCHEMA.COLUMNS c
|
||||
LEFT JOIN sys.columns sc
|
||||
ON sc.object_id = OBJECT_ID(@tableName)
|
||||
AND sc.name = c.COLUMN_NAME
|
||||
LEFT JOIN sys.extended_properties p
|
||||
ON p.major_id = sc.object_id
|
||||
AND p.minor_id = sc.column_id
|
||||
AND p.name = 'MS_Description'
|
||||
WHERE c.TABLE_NAME = @tableName
|
||||
ORDER BY c.ORDINAL_POSITION
|
||||
`);
|
||||
|
||||
return result.recordset;
|
||||
} catch (error: any) {
|
||||
throw new Error(`컬럼 정보 조회 오류: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
private formatBytes(bytes: number): string {
|
||||
if (bytes === 0) return "0 Bytes";
|
||||
const k = 1024;
|
||||
const sizes = ["Bytes", "KB", "MB", "GB", "TB"];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,127 @@
|
|||
import { DatabaseConnector, ConnectionConfig, QueryResult } from '../interfaces/DatabaseConnector';
|
||||
import { ConnectionTestResult, TableInfo } from '../types/externalDbTypes';
|
||||
import * as mysql from 'mysql2/promise';
|
||||
|
||||
export class MariaDBConnector implements DatabaseConnector {
|
||||
private connection: mysql.Connection | null = null;
|
||||
private config: ConnectionConfig;
|
||||
|
||||
constructor(config: ConnectionConfig) {
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
async connect(): Promise<void> {
|
||||
if (!this.connection) {
|
||||
this.connection = await mysql.createConnection({
|
||||
host: this.config.host,
|
||||
port: this.config.port,
|
||||
user: this.config.user,
|
||||
password: this.config.password,
|
||||
database: this.config.database,
|
||||
connectTimeout: this.config.connectionTimeoutMillis,
|
||||
ssl: typeof this.config.ssl === 'boolean' ? undefined : this.config.ssl,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async disconnect(): Promise<void> {
|
||||
if (this.connection) {
|
||||
await this.connection.end();
|
||||
this.connection = null;
|
||||
}
|
||||
}
|
||||
|
||||
async testConnection(): Promise<ConnectionTestResult> {
|
||||
const startTime = Date.now();
|
||||
try {
|
||||
await this.connect();
|
||||
const [rows] = await this.connection!.query("SELECT VERSION() as version");
|
||||
const version = (rows as any[])[0]?.version || "Unknown";
|
||||
const responseTime = Date.now() - startTime;
|
||||
await this.disconnect();
|
||||
return {
|
||||
success: true,
|
||||
message: "MariaDB/MySQL 연결이 성공했습니다.",
|
||||
details: {
|
||||
response_time: responseTime,
|
||||
server_version: version,
|
||||
},
|
||||
};
|
||||
} catch (error: any) {
|
||||
await this.disconnect();
|
||||
return {
|
||||
success: false,
|
||||
message: "MariaDB/MySQL 연결에 실패했습니다.",
|
||||
error: {
|
||||
code: "CONNECTION_FAILED",
|
||||
details: error.message || "알 수 없는 오류",
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async executeQuery(query: string): Promise<QueryResult> {
|
||||
try {
|
||||
await this.connect();
|
||||
const [rows, fields] = await this.connection!.query(query);
|
||||
await this.disconnect();
|
||||
return {
|
||||
rows: rows as any[],
|
||||
fields: fields as any[],
|
||||
};
|
||||
} catch (error: any) {
|
||||
await this.disconnect();
|
||||
throw new Error(`쿼리 실행 실패: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async getTables(): Promise<TableInfo[]> {
|
||||
try {
|
||||
await this.connect();
|
||||
const [rows] = await this.connection!.query(`
|
||||
SELECT
|
||||
TABLE_NAME as table_name,
|
||||
TABLE_COMMENT as description
|
||||
FROM information_schema.TABLES
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
ORDER BY TABLE_NAME;
|
||||
`);
|
||||
|
||||
const tables: TableInfo[] = [];
|
||||
for (const row of rows as any[]) {
|
||||
const columns = await this.getColumns(row.table_name);
|
||||
tables.push({
|
||||
table_name: row.table_name,
|
||||
description: row.description || null,
|
||||
columns: columns,
|
||||
});
|
||||
}
|
||||
await this.disconnect();
|
||||
return tables;
|
||||
} catch (error: any) {
|
||||
await this.disconnect();
|
||||
throw new Error(`테이블 목록 조회 실패: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async getColumns(tableName: string): Promise<any[]> {
|
||||
try {
|
||||
await this.connect();
|
||||
const [rows] = await this.connection!.query(`
|
||||
SELECT
|
||||
COLUMN_NAME as column_name,
|
||||
DATA_TYPE as data_type,
|
||||
IS_NULLABLE as is_nullable,
|
||||
COLUMN_DEFAULT as column_default
|
||||
FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ?
|
||||
ORDER BY ORDINAL_POSITION;
|
||||
`, [tableName]);
|
||||
await this.disconnect();
|
||||
return rows as any[];
|
||||
} catch (error: any) {
|
||||
await this.disconnect();
|
||||
throw new Error(`컬럼 정보 조회 실패: ${error.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,225 @@
|
|||
import * as oracledb from 'oracledb';
|
||||
import { DatabaseConnector, ConnectionConfig, QueryResult } from '../interfaces/DatabaseConnector';
|
||||
import { ConnectionTestResult, TableInfo } from '../types/externalDbTypes';
|
||||
|
||||
export class OracleConnector implements DatabaseConnector {
|
||||
private connection: oracledb.Connection | null = null;
|
||||
private config: ConnectionConfig;
|
||||
|
||||
constructor(config: ConnectionConfig) {
|
||||
this.config = config;
|
||||
|
||||
// Oracle XE 21c 특화 설정
|
||||
// oracledb.outFormat = oracledb.OUT_FORMAT_OBJECT;
|
||||
// oracledb.autoCommit = true;
|
||||
}
|
||||
|
||||
async connect(): Promise<void> {
|
||||
try {
|
||||
// Oracle XE 21c 연결 문자열 구성
|
||||
const connectionString = this.buildConnectionString();
|
||||
|
||||
const connectionConfig: any = {
|
||||
user: this.config.user,
|
||||
password: this.config.password,
|
||||
connectString: connectionString
|
||||
};
|
||||
|
||||
this.connection = await oracledb.getConnection(connectionConfig);
|
||||
console.log('Oracle XE 21c 연결 성공');
|
||||
} catch (error: any) {
|
||||
console.error('Oracle XE 21c 연결 실패:', error);
|
||||
throw new Error(`Oracle 연결 실패: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
private buildConnectionString(): string {
|
||||
const { host, port, database } = this.config;
|
||||
|
||||
// Oracle XE 21c는 기본적으로 XE 서비스명을 사용
|
||||
// 다양한 연결 문자열 형식 지원
|
||||
if (database.includes('/') || database.includes(':')) {
|
||||
// 이미 완전한 연결 문자열인 경우
|
||||
return database;
|
||||
}
|
||||
|
||||
// Oracle XE 21c 표준 형식
|
||||
return `${host}:${port}/${database}`;
|
||||
}
|
||||
|
||||
async disconnect(): Promise<void> {
|
||||
if (this.connection) {
|
||||
try {
|
||||
await this.connection.close();
|
||||
this.connection = null;
|
||||
console.log('Oracle 연결 해제됨');
|
||||
} catch (error: any) {
|
||||
console.error('Oracle 연결 해제 실패:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async testConnection(): Promise<ConnectionTestResult> {
|
||||
try {
|
||||
if (!this.connection) {
|
||||
await this.connect();
|
||||
}
|
||||
|
||||
// Oracle XE 21c 버전 확인 쿼리
|
||||
const result = await this.connection!.execute(
|
||||
'SELECT BANNER FROM V$VERSION WHERE BANNER LIKE \'Oracle%\''
|
||||
);
|
||||
|
||||
console.log('Oracle 버전:', result.rows);
|
||||
return {
|
||||
success: true,
|
||||
message: '연결 성공',
|
||||
details: {
|
||||
server_version: (result.rows as any)?.[0]?.BANNER || 'Unknown'
|
||||
}
|
||||
};
|
||||
} catch (error: any) {
|
||||
console.error('Oracle 연결 테스트 실패:', error);
|
||||
return {
|
||||
success: false,
|
||||
message: '연결 실패',
|
||||
details: {
|
||||
server_version: error.message
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async executeQuery(query: string, params: any[] = []): Promise<QueryResult> {
|
||||
if (!this.connection) {
|
||||
await this.connect();
|
||||
}
|
||||
|
||||
try {
|
||||
const startTime = Date.now();
|
||||
|
||||
// Oracle XE 21c 쿼리 실행 옵션
|
||||
const options: any = {
|
||||
outFormat: oracledb.OUT_FORMAT_OBJECT, // OBJECT format
|
||||
maxRows: 10000, // XE 제한 고려
|
||||
fetchArraySize: 100
|
||||
};
|
||||
|
||||
const result = await this.connection!.execute(query, params, options);
|
||||
const executionTime = Date.now() - startTime;
|
||||
|
||||
console.log('Oracle 쿼리 실행 결과:', {
|
||||
query,
|
||||
rowCount: result.rows?.length || 0,
|
||||
metaData: result.metaData?.length || 0,
|
||||
executionTime: `${executionTime}ms`,
|
||||
actualRows: result.rows,
|
||||
metaDataInfo: result.metaData
|
||||
});
|
||||
|
||||
return {
|
||||
rows: result.rows || [],
|
||||
rowCount: result.rowsAffected || (result.rows?.length || 0),
|
||||
fields: this.extractFieldInfo(result.metaData || [])
|
||||
};
|
||||
} catch (error: any) {
|
||||
console.error('Oracle 쿼리 실행 실패:', error);
|
||||
throw new Error(`쿼리 실행 실패: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
private extractFieldInfo(metaData: any[]): any[] {
|
||||
return metaData.map(field => ({
|
||||
name: field.name,
|
||||
type: this.mapOracleType(field.dbType),
|
||||
length: field.precision || field.byteSize,
|
||||
nullable: field.nullable
|
||||
}));
|
||||
}
|
||||
|
||||
private mapOracleType(oracleType: any): string {
|
||||
// Oracle XE 21c 타입 매핑 (간단한 방식)
|
||||
if (typeof oracleType === 'string') {
|
||||
return oracleType;
|
||||
}
|
||||
return 'UNKNOWN';
|
||||
}
|
||||
|
||||
async getTables(): Promise<TableInfo[]> {
|
||||
try {
|
||||
// 현재 사용자 스키마의 테이블들만 조회
|
||||
const query = `
|
||||
SELECT table_name, USER as owner
|
||||
FROM user_tables
|
||||
ORDER BY table_name
|
||||
`;
|
||||
|
||||
console.log('Oracle 테이블 조회 시작 - 사용자:', this.config.user);
|
||||
|
||||
const result = await this.executeQuery(query);
|
||||
console.log('사용자 스키마 테이블 조회 결과:', result.rows);
|
||||
|
||||
const tables = result.rows.map((row: any) => ({
|
||||
table_name: row.TABLE_NAME,
|
||||
columns: [],
|
||||
description: null
|
||||
}));
|
||||
|
||||
console.log(`총 ${tables.length}개의 사용자 테이블을 찾았습니다.`);
|
||||
return tables;
|
||||
|
||||
} catch (error: any) {
|
||||
console.error('Oracle 테이블 목록 조회 실패:', error);
|
||||
throw new Error(`테이블 목록 조회 실패: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async getColumns(tableName: string): Promise<any[]> {
|
||||
try {
|
||||
const query = `
|
||||
SELECT
|
||||
column_name,
|
||||
data_type,
|
||||
data_length,
|
||||
data_precision,
|
||||
data_scale,
|
||||
nullable,
|
||||
data_default
|
||||
FROM user_tab_columns
|
||||
WHERE table_name = UPPER(:tableName)
|
||||
ORDER BY column_id
|
||||
`;
|
||||
|
||||
const result = await this.executeQuery(query, [tableName]);
|
||||
|
||||
return result.rows.map((row: any) => ({
|
||||
column_name: row.COLUMN_NAME,
|
||||
data_type: this.formatOracleDataType(row),
|
||||
is_nullable: row.NULLABLE === 'Y' ? 'YES' : 'NO',
|
||||
column_default: row.DATA_DEFAULT
|
||||
}));
|
||||
} catch (error: any) {
|
||||
console.error('Oracle 테이블 컬럼 조회 실패:', error);
|
||||
throw new Error(`테이블 컬럼 조회 실패: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
private formatOracleDataType(row: any): string {
|
||||
const { DATA_TYPE, DATA_LENGTH, DATA_PRECISION, DATA_SCALE } = row;
|
||||
|
||||
switch (DATA_TYPE) {
|
||||
case 'NUMBER':
|
||||
if (DATA_PRECISION && DATA_SCALE !== null) {
|
||||
return `NUMBER(${DATA_PRECISION},${DATA_SCALE})`;
|
||||
} else if (DATA_PRECISION) {
|
||||
return `NUMBER(${DATA_PRECISION})`;
|
||||
}
|
||||
return 'NUMBER';
|
||||
case 'VARCHAR2':
|
||||
case 'CHAR':
|
||||
return `${DATA_TYPE}(${DATA_LENGTH})`;
|
||||
default:
|
||||
return DATA_TYPE;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -6,6 +6,9 @@ import { JwtUtils } from "../utils/jwtUtils";
|
|||
import { AuthenticatedRequest, PersonBean } from "../types/auth";
|
||||
import { logger } from "../utils/logger";
|
||||
|
||||
// AuthenticatedRequest 타입을 다른 모듈에서 사용할 수 있도록 re-export
|
||||
export { AuthenticatedRequest } from "../types/auth";
|
||||
|
||||
// Express Request 타입 확장
|
||||
declare global {
|
||||
namespace Express {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,73 @@
|
|||
// 배치 관리 라우트
|
||||
// 작성일: 2024-12-23
|
||||
|
||||
import { Router } from 'express';
|
||||
import { BatchController } from '../controllers/batchController';
|
||||
import { authenticateToken } from '../middleware/authMiddleware';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// 모든 라우트에 인증 미들웨어 적용
|
||||
router.use(authenticateToken);
|
||||
|
||||
/**
|
||||
* GET /api/batch
|
||||
* 배치 작업 목록 조회
|
||||
*/
|
||||
router.get('/', BatchController.getBatchJobs);
|
||||
|
||||
/**
|
||||
* GET /api/batch/:id
|
||||
* 배치 작업 상세 조회
|
||||
*/
|
||||
router.get('/:id', BatchController.getBatchJobById);
|
||||
|
||||
/**
|
||||
* POST /api/batch
|
||||
* 배치 작업 생성
|
||||
*/
|
||||
router.post('/', BatchController.createBatchJob);
|
||||
|
||||
/**
|
||||
* PUT /api/batch/:id
|
||||
* 배치 작업 수정
|
||||
*/
|
||||
router.put('/:id', BatchController.updateBatchJob);
|
||||
|
||||
/**
|
||||
* DELETE /api/batch/:id
|
||||
* 배치 작업 삭제
|
||||
*/
|
||||
router.delete('/:id', BatchController.deleteBatchJob);
|
||||
|
||||
/**
|
||||
* POST /api/batch/:id/execute
|
||||
* 배치 작업 수동 실행
|
||||
*/
|
||||
router.post('/:id/execute', BatchController.executeBatchJob);
|
||||
|
||||
/**
|
||||
* GET /api/batch/executions
|
||||
* 배치 실행 목록 조회
|
||||
*/
|
||||
router.get('/executions/list', BatchController.getBatchExecutions);
|
||||
|
||||
/**
|
||||
* GET /api/batch/monitoring
|
||||
* 배치 모니터링 정보 조회
|
||||
*/
|
||||
router.get('/monitoring/status', BatchController.getBatchMonitoring);
|
||||
|
||||
/**
|
||||
* GET /api/batch/types/supported
|
||||
* 지원되는 작업 타입 조회
|
||||
*/
|
||||
router.get('/types/supported', BatchController.getSupportedJobTypes);
|
||||
|
||||
/**
|
||||
* GET /api/batch/schedules/presets
|
||||
* 스케줄 프리셋 조회
|
||||
*/
|
||||
router.get('/schedules/presets', BatchController.getSchedulePresets);
|
||||
|
||||
export default router;
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
// 수집 관리 라우트
|
||||
// 작성일: 2024-12-23
|
||||
|
||||
import { Router } from 'express';
|
||||
import { CollectionController } from '../controllers/collectionController';
|
||||
import { authenticateToken } from '../middleware/authMiddleware';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// 모든 라우트에 인증 미들웨어 적용
|
||||
router.use(authenticateToken);
|
||||
|
||||
/**
|
||||
* GET /api/collections
|
||||
* 수집 설정 목록 조회
|
||||
*/
|
||||
router.get('/', CollectionController.getCollectionConfigs);
|
||||
|
||||
/**
|
||||
* GET /api/collections/:id
|
||||
* 수집 설정 상세 조회
|
||||
*/
|
||||
router.get('/:id', CollectionController.getCollectionConfigById);
|
||||
|
||||
/**
|
||||
* POST /api/collections
|
||||
* 수집 설정 생성
|
||||
*/
|
||||
router.post('/', CollectionController.createCollectionConfig);
|
||||
|
||||
/**
|
||||
* PUT /api/collections/:id
|
||||
* 수집 설정 수정
|
||||
*/
|
||||
router.put('/:id', CollectionController.updateCollectionConfig);
|
||||
|
||||
/**
|
||||
* DELETE /api/collections/:id
|
||||
* 수집 설정 삭제
|
||||
*/
|
||||
router.delete('/:id', CollectionController.deleteCollectionConfig);
|
||||
|
||||
/**
|
||||
* POST /api/collections/:id/execute
|
||||
* 수집 작업 실행
|
||||
*/
|
||||
router.post('/:id/execute', CollectionController.executeCollection);
|
||||
|
||||
/**
|
||||
* GET /api/collections/jobs
|
||||
* 수집 작업 목록 조회
|
||||
*/
|
||||
router.get('/jobs/list', CollectionController.getCollectionJobs);
|
||||
|
||||
/**
|
||||
* GET /api/collections/:configId/history
|
||||
* 수집 이력 조회
|
||||
*/
|
||||
router.get('/:configId/history', CollectionController.getCollectionHistory);
|
||||
|
||||
export default router;
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
import { Router } from "express";
|
||||
import { DbTypeCategoryController } from "../controllers/dbTypeCategoryController";
|
||||
import { authenticateToken } from "../middleware/authMiddleware";
|
||||
|
||||
const router = Router();
|
||||
|
||||
/**
|
||||
* GET /api/db-type-categories
|
||||
* 모든 DB 타입 카테고리 조회
|
||||
*/
|
||||
router.get("/", authenticateToken, DbTypeCategoryController.getAllCategories);
|
||||
|
||||
/**
|
||||
* GET /api/db-type-categories/stats/connections
|
||||
* DB 타입별 연결 통계 조회
|
||||
*/
|
||||
router.get("/stats/connections", authenticateToken, DbTypeCategoryController.getConnectionStatsByType);
|
||||
|
||||
/**
|
||||
* GET /api/db-type-categories/:typeCode
|
||||
* 특정 DB 타입 카테고리 조회
|
||||
*/
|
||||
router.get("/:typeCode", authenticateToken, DbTypeCategoryController.getCategoryByTypeCode);
|
||||
|
||||
/**
|
||||
* POST /api/db-type-categories
|
||||
* DB 타입 카테고리 생성
|
||||
*/
|
||||
router.post("/", authenticateToken, DbTypeCategoryController.createCategory);
|
||||
|
||||
/**
|
||||
* POST /api/db-type-categories/initialize
|
||||
* 기본 DB 타입 카테고리 초기화
|
||||
*/
|
||||
router.post("/initialize", authenticateToken, DbTypeCategoryController.initializeDefaultCategories);
|
||||
|
||||
/**
|
||||
* PUT /api/db-type-categories/:typeCode
|
||||
* DB 타입 카테고리 수정
|
||||
*/
|
||||
router.put("/:typeCode", authenticateToken, DbTypeCategoryController.updateCategory);
|
||||
|
||||
/**
|
||||
* DELETE /api/db-type-categories/:typeCode
|
||||
* DB 타입 카테고리 삭제 (비활성화)
|
||||
*/
|
||||
router.delete("/:typeCode", authenticateToken, DbTypeCategoryController.deleteCategory);
|
||||
|
||||
export default router;
|
||||
|
|
@ -85,6 +85,46 @@ router.get(
|
|||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* GET /api/external-db-connections/grouped
|
||||
* DB 타입별로 그룹화된 외부 DB 연결 목록 조회
|
||||
*/
|
||||
router.get(
|
||||
"/grouped",
|
||||
authenticateToken,
|
||||
async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const filter: ExternalDbConnectionFilter = {
|
||||
db_type: req.query.db_type as string,
|
||||
is_active: req.query.is_active as string,
|
||||
company_code: req.query.company_code as string,
|
||||
search: req.query.search as string,
|
||||
};
|
||||
|
||||
// 빈 값 제거
|
||||
Object.keys(filter).forEach((key) => {
|
||||
if (!filter[key as keyof ExternalDbConnectionFilter]) {
|
||||
delete filter[key as keyof ExternalDbConnectionFilter];
|
||||
}
|
||||
});
|
||||
|
||||
const result = await ExternalDbConnectionService.getConnectionsGroupedByType(filter);
|
||||
|
||||
if (result.success) {
|
||||
return res.status(200).json(result);
|
||||
} else {
|
||||
return res.status(400).json(result);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("그룹화된 외부 DB 연결 목록 조회 오류:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "그룹화된 연결 목록 조회 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류"
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* GET /api/external-db-connections/:id
|
||||
|
|
@ -262,7 +302,11 @@ router.post(
|
|||
});
|
||||
}
|
||||
|
||||
const result = await ExternalDbConnectionService.testConnectionById(id);
|
||||
// 테스트용 비밀번호가 제공된 경우 사용
|
||||
const testData = req.body.password ? { password: req.body.password } : undefined;
|
||||
console.log(`🔍 [API] 연결테스트 요청 - ID: ${id}, 비밀번호 제공됨: ${!!req.body.password}`);
|
||||
|
||||
const result = await ExternalDbConnectionService.testConnectionById(id, testData);
|
||||
|
||||
return res.status(200).json({
|
||||
success: result.success,
|
||||
|
|
@ -338,5 +382,37 @@ router.get(
|
|||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* GET /api/external-db-connections/:id/tables/:tableName/columns
|
||||
* 특정 테이블의 컬럼 정보 조회
|
||||
*/
|
||||
router.get(
|
||||
"/:id/tables/:tableName/columns",
|
||||
authenticateToken,
|
||||
async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const id = parseInt(req.params.id);
|
||||
const tableName = req.params.tableName;
|
||||
|
||||
if (!tableName) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "테이블명이 입력되지 않았습니다."
|
||||
});
|
||||
}
|
||||
|
||||
const result = await ExternalDbConnectionService.getTableColumns(id, tableName);
|
||||
return res.json(result);
|
||||
} catch (error) {
|
||||
console.error("테이블 컬럼 조회 오류:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "테이블 컬럼 조회 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류"
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
export default router;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,320 @@
|
|||
import { PrismaClient } from "@prisma/client";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
export interface DbTypeCategory {
|
||||
type_code: string;
|
||||
display_name: string;
|
||||
icon?: string | null;
|
||||
color?: string | null;
|
||||
sort_order?: number | null;
|
||||
is_active: boolean;
|
||||
created_at: Date;
|
||||
updated_at: Date;
|
||||
}
|
||||
|
||||
export interface CreateDbTypeCategoryRequest {
|
||||
type_code: string;
|
||||
display_name: string;
|
||||
icon?: string;
|
||||
color?: string;
|
||||
sort_order?: number;
|
||||
}
|
||||
|
||||
export interface UpdateDbTypeCategoryRequest {
|
||||
display_name?: string;
|
||||
icon?: string;
|
||||
color?: string;
|
||||
sort_order?: number;
|
||||
is_active?: boolean;
|
||||
}
|
||||
|
||||
export interface ApiResponse<T> {
|
||||
success: boolean;
|
||||
data?: T;
|
||||
message: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export class DbTypeCategoryService {
|
||||
/**
|
||||
* 모든 DB 타입 카테고리 조회
|
||||
*/
|
||||
static async getAllCategories(): Promise<ApiResponse<DbTypeCategory[]>> {
|
||||
try {
|
||||
const categories = await prisma.db_type_categories.findMany({
|
||||
where: { is_active: true },
|
||||
orderBy: [
|
||||
{ sort_order: 'asc' },
|
||||
{ display_name: 'asc' }
|
||||
]
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: categories,
|
||||
message: "DB 타입 카테고리 목록을 조회했습니다."
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("DB 타입 카테고리 조회 오류:", error);
|
||||
return {
|
||||
success: false,
|
||||
message: "DB 타입 카테고리 조회 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류"
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 DB 타입 카테고리 조회
|
||||
*/
|
||||
static async getCategoryByTypeCode(typeCode: string): Promise<ApiResponse<DbTypeCategory>> {
|
||||
try {
|
||||
const category = await prisma.db_type_categories.findUnique({
|
||||
where: { type_code: typeCode }
|
||||
});
|
||||
|
||||
if (!category) {
|
||||
return {
|
||||
success: false,
|
||||
message: "해당 DB 타입 카테고리를 찾을 수 없습니다."
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: category,
|
||||
message: "DB 타입 카테고리를 조회했습니다."
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("DB 타입 카테고리 조회 오류:", error);
|
||||
return {
|
||||
success: false,
|
||||
message: "DB 타입 카테고리 조회 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류"
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DB 타입 카테고리 생성
|
||||
*/
|
||||
static async createCategory(data: CreateDbTypeCategoryRequest): Promise<ApiResponse<DbTypeCategory>> {
|
||||
try {
|
||||
// 중복 체크
|
||||
const existing = await prisma.db_type_categories.findUnique({
|
||||
where: { type_code: data.type_code }
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
return {
|
||||
success: false,
|
||||
message: "이미 존재하는 DB 타입 코드입니다."
|
||||
};
|
||||
}
|
||||
|
||||
const category = await prisma.db_type_categories.create({
|
||||
data: {
|
||||
type_code: data.type_code,
|
||||
display_name: data.display_name,
|
||||
icon: data.icon,
|
||||
color: data.color,
|
||||
sort_order: data.sort_order || 0
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: category,
|
||||
message: "DB 타입 카테고리가 생성되었습니다."
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("DB 타입 카테고리 생성 오류:", error);
|
||||
return {
|
||||
success: false,
|
||||
message: "DB 타입 카테고리 생성 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류"
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DB 타입 카테고리 수정
|
||||
*/
|
||||
static async updateCategory(typeCode: string, data: UpdateDbTypeCategoryRequest): Promise<ApiResponse<DbTypeCategory>> {
|
||||
try {
|
||||
const category = await prisma.db_type_categories.update({
|
||||
where: { type_code: typeCode },
|
||||
data: {
|
||||
display_name: data.display_name,
|
||||
icon: data.icon,
|
||||
color: data.color,
|
||||
sort_order: data.sort_order,
|
||||
is_active: data.is_active,
|
||||
updated_at: new Date()
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: category,
|
||||
message: "DB 타입 카테고리가 수정되었습니다."
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("DB 타입 카테고리 수정 오류:", error);
|
||||
return {
|
||||
success: false,
|
||||
message: "DB 타입 카테고리 수정 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류"
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DB 타입 카테고리 삭제 (비활성화)
|
||||
*/
|
||||
static async deleteCategory(typeCode: string): Promise<ApiResponse<void>> {
|
||||
try {
|
||||
// 해당 타입을 사용하는 연결이 있는지 확인
|
||||
const connectionsCount = await prisma.external_db_connections.count({
|
||||
where: {
|
||||
db_type: typeCode,
|
||||
is_active: "Y"
|
||||
}
|
||||
});
|
||||
|
||||
if (connectionsCount > 0) {
|
||||
return {
|
||||
success: false,
|
||||
message: `해당 DB 타입을 사용하는 연결이 ${connectionsCount}개 있어 삭제할 수 없습니다.`
|
||||
};
|
||||
}
|
||||
|
||||
await prisma.db_type_categories.update({
|
||||
where: { type_code: typeCode },
|
||||
data: {
|
||||
is_active: false,
|
||||
updated_at: new Date()
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: "DB 타입 카테고리가 삭제되었습니다."
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("DB 타입 카테고리 삭제 오류:", error);
|
||||
return {
|
||||
success: false,
|
||||
message: "DB 타입 카테고리 삭제 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류"
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DB 타입별 연결 통계 조회
|
||||
*/
|
||||
static async getConnectionStatsByType(): Promise<ApiResponse<any[]>> {
|
||||
try {
|
||||
const stats = await prisma.external_db_connections.groupBy({
|
||||
by: ['db_type'],
|
||||
where: { is_active: "Y" },
|
||||
_count: {
|
||||
id: true
|
||||
}
|
||||
});
|
||||
|
||||
// 카테고리 정보와 함께 반환
|
||||
const categories = await prisma.db_type_categories.findMany({
|
||||
where: { is_active: true }
|
||||
});
|
||||
|
||||
const result = categories.map(category => {
|
||||
const stat = stats.find(s => s.db_type === category.type_code);
|
||||
return {
|
||||
...category,
|
||||
connection_count: stat?._count.id || 0
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: result,
|
||||
message: "DB 타입별 연결 통계를 조회했습니다."
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("DB 타입별 통계 조회 오류:", error);
|
||||
return {
|
||||
success: false,
|
||||
message: "DB 타입별 통계 조회 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류"
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 기본 DB 타입 카테고리 초기화
|
||||
*/
|
||||
static async initializeDefaultCategories(): Promise<ApiResponse<void>> {
|
||||
try {
|
||||
const defaultCategories = [
|
||||
{
|
||||
type_code: 'postgresql',
|
||||
display_name: 'PostgreSQL',
|
||||
icon: 'postgresql',
|
||||
color: '#336791',
|
||||
sort_order: 1
|
||||
},
|
||||
{
|
||||
type_code: 'oracle',
|
||||
display_name: 'Oracle',
|
||||
icon: 'oracle',
|
||||
color: '#F80000',
|
||||
sort_order: 2
|
||||
},
|
||||
{
|
||||
type_code: 'mysql',
|
||||
display_name: 'MySQL',
|
||||
icon: 'mysql',
|
||||
color: '#4479A1',
|
||||
sort_order: 3
|
||||
},
|
||||
{
|
||||
type_code: 'mariadb',
|
||||
display_name: 'MariaDB',
|
||||
icon: 'mariadb',
|
||||
color: '#003545',
|
||||
sort_order: 4
|
||||
},
|
||||
{
|
||||
type_code: 'mssql',
|
||||
display_name: 'SQL Server',
|
||||
icon: 'mssql',
|
||||
color: '#CC2927',
|
||||
sort_order: 5
|
||||
}
|
||||
];
|
||||
|
||||
for (const category of defaultCategories) {
|
||||
await prisma.db_type_categories.upsert({
|
||||
where: { type_code: category.type_code },
|
||||
update: {},
|
||||
create: category
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: "기본 DB 타입 카테고리가 초기화되었습니다."
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("기본 카테고리 초기화 오류:", error);
|
||||
return {
|
||||
success: false,
|
||||
message: "기본 카테고리 초기화 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류"
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -9,7 +9,7 @@ import {
|
|||
TableInfo,
|
||||
} from "../types/externalDbTypes";
|
||||
import { PasswordEncryption } from "../utils/passwordEncryption";
|
||||
import { DbConnectionManager } from "./dbConnectionManager";
|
||||
import { DatabaseConnectorFactory } from "../database/DatabaseConnectorFactory";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
|
|
@ -81,6 +81,93 @@ export class ExternalDbConnectionService {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DB 타입별로 그룹화된 외부 DB 연결 목록 조회
|
||||
*/
|
||||
static async getConnectionsGroupedByType(
|
||||
filter: ExternalDbConnectionFilter = {}
|
||||
): Promise<ApiResponse<Record<string, ExternalDbConnection[]>>> {
|
||||
try {
|
||||
// 기본 연결 목록 조회
|
||||
const connectionsResult = await this.getConnections(filter);
|
||||
|
||||
if (!connectionsResult.success || !connectionsResult.data) {
|
||||
return {
|
||||
success: false,
|
||||
message: "연결 목록 조회에 실패했습니다."
|
||||
};
|
||||
}
|
||||
|
||||
// DB 타입 카테고리 정보 조회
|
||||
const categories = await prisma.db_type_categories.findMany({
|
||||
where: { is_active: true },
|
||||
orderBy: [
|
||||
{ sort_order: 'asc' },
|
||||
{ display_name: 'asc' }
|
||||
]
|
||||
});
|
||||
|
||||
// DB 타입별로 그룹화
|
||||
const groupedConnections: Record<string, any> = {};
|
||||
|
||||
// 카테고리 정보를 포함한 그룹 초기화
|
||||
categories.forEach((category: any) => {
|
||||
groupedConnections[category.type_code] = {
|
||||
category: {
|
||||
type_code: category.type_code,
|
||||
display_name: category.display_name,
|
||||
icon: category.icon,
|
||||
color: category.color,
|
||||
sort_order: category.sort_order
|
||||
},
|
||||
connections: []
|
||||
};
|
||||
});
|
||||
|
||||
// 연결을 해당 타입 그룹에 배치
|
||||
connectionsResult.data.forEach(connection => {
|
||||
if (groupedConnections[connection.db_type]) {
|
||||
groupedConnections[connection.db_type].connections.push(connection);
|
||||
} else {
|
||||
// 카테고리에 없는 DB 타입인 경우 기타 그룹에 추가
|
||||
if (!groupedConnections['other']) {
|
||||
groupedConnections['other'] = {
|
||||
category: {
|
||||
type_code: 'other',
|
||||
display_name: '기타',
|
||||
icon: 'database',
|
||||
color: '#6B7280',
|
||||
sort_order: 999
|
||||
},
|
||||
connections: []
|
||||
};
|
||||
}
|
||||
groupedConnections['other'].connections.push(connection);
|
||||
}
|
||||
});
|
||||
|
||||
// 연결이 없는 빈 그룹 제거
|
||||
Object.keys(groupedConnections).forEach(key => {
|
||||
if (groupedConnections[key].connections.length === 0) {
|
||||
delete groupedConnections[key];
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: groupedConnections,
|
||||
message: `DB 타입별로 그룹화된 연결 목록을 조회했습니다.`
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("그룹화된 연결 목록 조회 실패:", error);
|
||||
return {
|
||||
success: false,
|
||||
message: "그룹화된 연결 목록 조회 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류"
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 외부 DB 연결 조회
|
||||
*/
|
||||
|
|
@ -239,13 +326,40 @@ export class ExternalDbConnectionService {
|
|||
}
|
||||
}
|
||||
|
||||
// 비밀번호가 변경되는 경우, 연결 테스트 먼저 수행
|
||||
if (data.password && data.password !== "***ENCRYPTED***") {
|
||||
// 임시 연결 설정으로 테스트
|
||||
const testConfig = {
|
||||
host: data.host || existingConnection.host,
|
||||
port: data.port || existingConnection.port,
|
||||
database: data.database_name || existingConnection.database_name,
|
||||
user: data.username || existingConnection.username,
|
||||
password: data.password, // 새로 입력된 비밀번호로 테스트
|
||||
connectionTimeoutMillis: data.connection_timeout != null ? data.connection_timeout * 1000 : undefined,
|
||||
queryTimeoutMillis: data.query_timeout != null ? data.query_timeout * 1000 : undefined,
|
||||
ssl: (data.ssl_enabled || existingConnection.ssl_enabled) === "Y" ? { rejectUnauthorized: false } : false
|
||||
};
|
||||
|
||||
// 연결 테스트 수행
|
||||
const connector = await DatabaseConnectorFactory.createConnector(existingConnection.db_type, testConfig, id);
|
||||
const testResult = await connector.testConnection();
|
||||
|
||||
if (!testResult.success) {
|
||||
return {
|
||||
success: false,
|
||||
message: "새로운 연결 정보로 테스트에 실패했습니다. 수정할 수 없습니다.",
|
||||
error: testResult.error ? `${testResult.error.code}: ${testResult.error.details}` : undefined
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 업데이트 데이터 준비
|
||||
const updateData: any = {
|
||||
...data,
|
||||
updated_date: new Date(),
|
||||
};
|
||||
|
||||
// 비밀번호가 변경된 경우 암호화
|
||||
// 비밀번호가 변경된 경우 암호화 (연결 테스트 통과 후)
|
||||
if (data.password && data.password !== "***ENCRYPTED***") {
|
||||
updateData.password = PasswordEncryption.encrypt(data.password);
|
||||
} else {
|
||||
|
|
@ -320,7 +434,8 @@ export class ExternalDbConnectionService {
|
|||
* 데이터베이스 연결 테스트 (ID 기반)
|
||||
*/
|
||||
static async testConnectionById(
|
||||
id: number
|
||||
id: number,
|
||||
testData?: { password?: string }
|
||||
): Promise<import("../types/externalDbTypes").ConnectionTestResult> {
|
||||
try {
|
||||
// 저장된 연결 정보 조회
|
||||
|
|
@ -339,9 +454,17 @@ export class ExternalDbConnectionService {
|
|||
};
|
||||
}
|
||||
|
||||
// 비밀번호 복호화
|
||||
const decryptedPassword = await this.getDecryptedPassword(id);
|
||||
if (!decryptedPassword) {
|
||||
// 비밀번호 결정 (테스트용 비밀번호가 제공된 경우 그것을 사용, 아니면 저장된 비밀번호 복호화)
|
||||
let password: string | null;
|
||||
if (testData?.password) {
|
||||
password = testData.password;
|
||||
console.log(`🔍 [연결테스트] 새로 입력된 비밀번호 사용: ${password.substring(0, 3)}***`);
|
||||
} else {
|
||||
password = await this.getDecryptedPassword(id);
|
||||
console.log(`🔍 [연결테스트] 저장된 비밀번호 사용: ${password ? password.substring(0, 3) + '***' : 'null'}`);
|
||||
}
|
||||
|
||||
if (!password) {
|
||||
return {
|
||||
success: false,
|
||||
message: "비밀번호 복호화에 실패했습니다.",
|
||||
|
|
@ -358,14 +481,46 @@ export class ExternalDbConnectionService {
|
|||
port: connection.port,
|
||||
database: connection.database_name,
|
||||
user: connection.username,
|
||||
password: decryptedPassword,
|
||||
password: password,
|
||||
connectionTimeoutMillis: connection.connection_timeout != null ? connection.connection_timeout * 1000 : undefined,
|
||||
queryTimeoutMillis: connection.query_timeout != null ? connection.query_timeout * 1000 : undefined,
|
||||
ssl: connection.ssl_enabled === "Y" ? { rejectUnauthorized: false } : false
|
||||
};
|
||||
|
||||
// DbConnectionManager를 통한 연결 테스트
|
||||
return await DbConnectionManager.testConnection(id, connection.db_type, config);
|
||||
// 연결 테스트용 임시 커넥터 생성 (캐시 사용하지 않음)
|
||||
let connector: any;
|
||||
switch (connection.db_type.toLowerCase()) {
|
||||
case 'postgresql':
|
||||
const { PostgreSQLConnector } = await import('../database/PostgreSQLConnector');
|
||||
connector = new PostgreSQLConnector(config);
|
||||
break;
|
||||
case 'oracle':
|
||||
const { OracleConnector } = await import('../database/OracleConnector');
|
||||
connector = new OracleConnector(config);
|
||||
break;
|
||||
case 'mariadb':
|
||||
case 'mysql':
|
||||
const { MariaDBConnector } = await import('../database/MariaDBConnector');
|
||||
connector = new MariaDBConnector(config);
|
||||
break;
|
||||
case 'mssql':
|
||||
const { MSSQLConnector } = await import('../database/MSSQLConnector');
|
||||
connector = new MSSQLConnector(config);
|
||||
break;
|
||||
default:
|
||||
throw new Error(`지원하지 않는 데이터베이스 타입: ${connection.db_type}`);
|
||||
}
|
||||
|
||||
console.log(`🔍 [연결테스트] 새 커넥터로 DB 연결 시도 - Host: ${config.host}, DB: ${config.database}, User: ${config.user}`);
|
||||
|
||||
const testResult = await connector.testConnection();
|
||||
console.log(`🔍 [연결테스트] 결과 - Success: ${testResult.success}, Message: ${testResult.message}`);
|
||||
|
||||
return {
|
||||
success: testResult.success,
|
||||
message: testResult.message,
|
||||
details: testResult.details
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
|
|
@ -416,7 +571,7 @@ export class ExternalDbConnectionService {
|
|||
}
|
||||
|
||||
// DB 타입 유효성 검사
|
||||
const validDbTypes = ["mysql", "postgresql", "oracle", "mssql", "sqlite"];
|
||||
const validDbTypes = ["mysql", "postgresql", "oracle", "mssql", "sqlite", "mariadb"];
|
||||
if (!validDbTypes.includes(data.db_type)) {
|
||||
throw new Error("지원하지 않는 DB 타입입니다.");
|
||||
}
|
||||
|
|
@ -487,8 +642,9 @@ export class ExternalDbConnectionService {
|
|||
ssl: connection.ssl_enabled === "Y" ? { rejectUnauthorized: false } : false
|
||||
};
|
||||
|
||||
// DbConnectionManager를 통한 쿼리 실행
|
||||
const result = await DbConnectionManager.executeQuery(id, connection.db_type, config, query);
|
||||
// DatabaseConnectorFactory를 통한 쿼리 실행
|
||||
const connector = await DatabaseConnectorFactory.createConnector(connection.db_type, config, id);
|
||||
const result = await connector.executeQuery(query);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
|
|
@ -595,8 +751,9 @@ export class ExternalDbConnectionService {
|
|||
ssl: connection.ssl_enabled === "Y" ? { rejectUnauthorized: false } : false
|
||||
};
|
||||
|
||||
// DbConnectionManager를 통한 테이블 목록 조회
|
||||
const tables = await DbConnectionManager.getTables(id, connection.db_type, config);
|
||||
// DatabaseConnectorFactory를 통한 테이블 목록 조회
|
||||
const connector = await DatabaseConnectorFactory.createConnector(connection.db_type, config, id);
|
||||
const tables = await connector.getTables();
|
||||
|
||||
return {
|
||||
success: true,
|
||||
|
|
@ -676,4 +833,57 @@ export class ExternalDbConnectionService {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 테이블의 컬럼 정보 조회
|
||||
*/
|
||||
static async getTableColumns(connectionId: number, tableName: string): Promise<ApiResponse<any[]>> {
|
||||
let client: any = null;
|
||||
|
||||
try {
|
||||
const connection = await this.getConnectionById(connectionId);
|
||||
if (!connection.success || !connection.data) {
|
||||
return {
|
||||
success: false,
|
||||
message: "연결 정보를 찾을 수 없습니다."
|
||||
};
|
||||
}
|
||||
|
||||
const connectionData = connection.data;
|
||||
|
||||
// 비밀번호 복호화
|
||||
const decryptedPassword = PasswordEncryption.decrypt(connectionData.password);
|
||||
|
||||
// 연결 설정 준비
|
||||
const config = {
|
||||
host: connectionData.host,
|
||||
port: connectionData.port,
|
||||
database: connectionData.database_name,
|
||||
user: connectionData.username,
|
||||
password: decryptedPassword,
|
||||
connectionTimeoutMillis: connectionData.connection_timeout != null ? connectionData.connection_timeout * 1000 : undefined,
|
||||
queryTimeoutMillis: connectionData.query_timeout != null ? connectionData.query_timeout * 1000 : undefined,
|
||||
ssl: connectionData.ssl_enabled === "Y" ? { rejectUnauthorized: false } : false
|
||||
};
|
||||
|
||||
// 데이터베이스 타입에 따른 커넥터 생성
|
||||
const connector = await DatabaseConnectorFactory.createConnector(connectionData.db_type, config, connectionId);
|
||||
|
||||
// 컬럼 정보 조회
|
||||
const columns = await connector.getColumns(tableName);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: columns,
|
||||
message: "컬럼 정보를 조회했습니다."
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("컬럼 정보 조회 오류:", error);
|
||||
return {
|
||||
success: false,
|
||||
message: "컬럼 정보 조회 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류"
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,98 @@
|
|||
// 배치 관리 관련 타입 정의
|
||||
// 작성일: 2024-12-23
|
||||
|
||||
export interface BatchJob {
|
||||
id?: number;
|
||||
job_name: string;
|
||||
description?: string | null;
|
||||
job_type: string;
|
||||
schedule_cron?: string | null;
|
||||
is_active: string; // 'Y' | 'N'
|
||||
config_json?: Record<string, any> | null;
|
||||
last_executed_at?: Date | null;
|
||||
next_execution_at?: Date | null;
|
||||
execution_count: number;
|
||||
success_count: number;
|
||||
failure_count: number;
|
||||
created_date?: Date | null;
|
||||
created_by?: string | null;
|
||||
updated_date?: Date | null;
|
||||
updated_by?: string | null;
|
||||
company_code: string;
|
||||
}
|
||||
|
||||
export interface BatchJobFilter {
|
||||
job_name?: string;
|
||||
job_type?: string;
|
||||
is_active?: string;
|
||||
company_code?: string;
|
||||
search?: string;
|
||||
}
|
||||
|
||||
export interface BatchExecution {
|
||||
id?: number;
|
||||
job_id: number;
|
||||
execution_status: 'pending' | 'running' | 'completed' | 'failed' | 'cancelled';
|
||||
started_at?: Date;
|
||||
completed_at?: Date;
|
||||
execution_time_ms?: number;
|
||||
result_data?: Record<string, any>;
|
||||
error_message?: string;
|
||||
log_details?: string;
|
||||
created_date?: Date;
|
||||
}
|
||||
|
||||
export interface BatchSchedule {
|
||||
id?: number;
|
||||
job_id: number;
|
||||
schedule_name: string;
|
||||
cron_expression: string;
|
||||
timezone?: string;
|
||||
is_active: string;
|
||||
last_triggered_at?: Date;
|
||||
next_trigger_at?: Date;
|
||||
created_date?: Date;
|
||||
created_by?: string;
|
||||
}
|
||||
|
||||
export interface BatchMonitoring {
|
||||
total_jobs: number;
|
||||
active_jobs: number;
|
||||
running_jobs: number;
|
||||
failed_jobs_today: number;
|
||||
successful_jobs_today: number;
|
||||
recent_executions: BatchExecution[];
|
||||
}
|
||||
|
||||
// 배치 작업 타입 옵션
|
||||
export const BATCH_JOB_TYPE_OPTIONS = [
|
||||
{ value: 'collection', label: '데이터 수집' },
|
||||
{ value: 'sync', label: '데이터 동기화' },
|
||||
{ value: 'cleanup', label: '데이터 정리' },
|
||||
{ value: 'custom', label: '사용자 정의' },
|
||||
];
|
||||
|
||||
// 실행 상태 옵션
|
||||
export const EXECUTION_STATUS_OPTIONS = [
|
||||
{ value: 'pending', label: '대기 중' },
|
||||
{ value: 'running', label: '실행 중' },
|
||||
{ value: 'completed', label: '완료' },
|
||||
{ value: 'failed', label: '실패' },
|
||||
{ value: 'cancelled', label: '취소됨' },
|
||||
];
|
||||
|
||||
// 스케줄 프리셋
|
||||
export const SCHEDULE_PRESETS = [
|
||||
{ value: '0 */1 * * *', label: '매시간' },
|
||||
{ value: '0 0 */6 * *', label: '6시간마다' },
|
||||
{ value: '0 0 * * *', label: '매일 자정' },
|
||||
{ value: '0 0 * * 0', label: '매주 일요일' },
|
||||
{ value: '0 0 1 * *', label: '매월 1일' },
|
||||
];
|
||||
|
||||
export interface ApiResponse<T> {
|
||||
success: boolean;
|
||||
data?: T;
|
||||
message?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
|
@ -0,0 +1,75 @@
|
|||
// 수집 관리 관련 타입 정의
|
||||
// 작성일: 2024-12-23
|
||||
|
||||
export interface DataCollectionConfig {
|
||||
id?: number;
|
||||
config_name: string;
|
||||
description?: string | null;
|
||||
source_connection_id: number;
|
||||
source_table: string;
|
||||
target_table?: string | null;
|
||||
collection_type: string;
|
||||
schedule_cron?: string | null;
|
||||
is_active: string; // 'Y' | 'N'
|
||||
last_collected_at?: Date | null;
|
||||
collection_options?: Record<string, any> | null;
|
||||
created_date?: Date | null;
|
||||
created_by?: string | null;
|
||||
updated_date?: Date | null;
|
||||
updated_by?: string | null;
|
||||
company_code: string;
|
||||
}
|
||||
|
||||
export interface CollectionFilter {
|
||||
config_name?: string;
|
||||
source_connection_id?: number;
|
||||
collection_type?: string;
|
||||
is_active?: string;
|
||||
company_code?: string;
|
||||
search?: string;
|
||||
}
|
||||
|
||||
export interface CollectionJob {
|
||||
id?: number;
|
||||
config_id: number;
|
||||
job_status: string;
|
||||
started_at?: Date | null;
|
||||
completed_at?: Date | null;
|
||||
records_processed?: number | null;
|
||||
error_message?: string | null;
|
||||
job_details?: Record<string, any> | null;
|
||||
created_date?: Date | null;
|
||||
}
|
||||
|
||||
export interface CollectionHistory {
|
||||
id?: number;
|
||||
config_id: number;
|
||||
collection_date: Date;
|
||||
records_collected: number;
|
||||
execution_time_ms: number;
|
||||
status: string;
|
||||
error_details?: string | null;
|
||||
created_date?: Date | null;
|
||||
}
|
||||
|
||||
// 수집 타입 옵션
|
||||
export const COLLECTION_TYPE_OPTIONS = [
|
||||
{ value: 'full', label: '전체 수집' },
|
||||
{ value: 'incremental', label: '증분 수집' },
|
||||
{ value: 'delta', label: '변경분 수집' },
|
||||
];
|
||||
|
||||
// 작업 상태 옵션
|
||||
export const JOB_STATUS_OPTIONS = [
|
||||
{ value: 'pending', label: '대기 중' },
|
||||
{ value: 'running', label: '실행 중' },
|
||||
{ value: 'completed', label: '완료' },
|
||||
{ value: 'failed', label: '실패' },
|
||||
];
|
||||
|
||||
export interface ApiResponse<T> {
|
||||
success: boolean;
|
||||
data?: T;
|
||||
message?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
|
@ -5,7 +5,7 @@ export interface ExternalDbConnection {
|
|||
id?: number;
|
||||
connection_name: string;
|
||||
description?: string | null;
|
||||
db_type: "mysql" | "postgresql" | "oracle" | "mssql" | "sqlite";
|
||||
db_type: "mysql" | "postgresql" | "oracle" | "mssql" | "sqlite" | "mariadb";
|
||||
host: string;
|
||||
port: number;
|
||||
database_name: string;
|
||||
|
|
@ -58,6 +58,7 @@ export const DB_TYPE_OPTIONS = [
|
|||
{ value: "postgresql", label: "PostgreSQL" },
|
||||
{ value: "oracle", label: "Oracle" },
|
||||
{ value: "mssql", label: "SQL Server" },
|
||||
{ value: "mariadb", label: "MariaDB" },
|
||||
{ value: "sqlite", label: "SQLite" },
|
||||
];
|
||||
|
||||
|
|
@ -67,6 +68,7 @@ export const DB_TYPE_DEFAULTS = {
|
|||
postgresql: { port: 5432, driver: "pg" },
|
||||
oracle: { port: 1521, driver: "oracledb" },
|
||||
mssql: { port: 1433, driver: "mssql" },
|
||||
mariadb: { port: 3306, driver: "mysql2" },
|
||||
sqlite: { port: 0, driver: "sqlite3" },
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,214 @@
|
|||
# 외부 DB 연결 관리 기능 가이드
|
||||
|
||||
## 개요
|
||||
|
||||
외부 DB 연결 관리 기능은 다양한 외부 데이터베이스와의 연결을 설정하고 관리하는 기능을 제공합니다. 이 기능을 통해 PostgreSQL, MySQL, MariaDB 등 다양한 데이터베이스에 연결하여 데이터를 조회하고 저장할 수 있습니다.
|
||||
|
||||
## 주요 기능
|
||||
|
||||
### 1. 연결 관리
|
||||
|
||||
- 새 연결 생성
|
||||
- 기존 연결 수정
|
||||
- 연결 삭제
|
||||
- 연결 활성화/비활성화
|
||||
- 연결 테스트
|
||||
|
||||
### 2. 지원하는 데이터베이스 타입
|
||||
|
||||
- PostgreSQL
|
||||
- MySQL
|
||||
- MariaDB
|
||||
- Oracle
|
||||
- SQL Server
|
||||
- SQLite
|
||||
|
||||
### 3. 연결 설정 항목
|
||||
|
||||
#### 기본 정보
|
||||
- 연결명 (필수)
|
||||
- DB 타입 (필수)
|
||||
- 설명 (선택)
|
||||
|
||||
#### 연결 정보
|
||||
- 호스트 (필수)
|
||||
- 포트 (필수)
|
||||
- 데이터베이스명 (필수)
|
||||
- 사용자명 (필수)
|
||||
- 비밀번호 (필수)
|
||||
|
||||
#### 고급 설정
|
||||
- 연결 타임아웃 (기본값: 30초)
|
||||
- 쿼리 타임아웃 (기본값: 60초)
|
||||
- 최대 연결 수 (기본값: 10)
|
||||
- SSL 사용 여부
|
||||
- SSL 인증서 경로 (SSL 사용 시)
|
||||
|
||||
## 사용 방법
|
||||
|
||||
### 1. 새 연결 생성
|
||||
|
||||
1. "외부 DB 연결 관리" 화면에서 "새 연결 추가" 버튼 클릭
|
||||
2. 기본 정보 입력
|
||||
- 연결명: 고유한 식별자로 사용
|
||||
- DB 타입: 연결할 데이터베이스 종류 선택
|
||||
- 설명: 연결에 대한 부가 설명 (선택사항)
|
||||
3. 연결 정보 입력
|
||||
- 호스트: 데이터베이스 서버 주소
|
||||
- 포트: DB 타입에 따라 기본값 제공
|
||||
- 데이터베이스명: 연결할 데이터베이스 이름
|
||||
- 사용자명: 데이터베이스 접속 계정
|
||||
- 비밀번호: 계정 비밀번호
|
||||
4. 필요한 경우 고급 설정 구성
|
||||
5. "연결 테스트" 버튼으로 연결 가능 여부 확인
|
||||
6. "생성" 버튼으로 연결 저장
|
||||
|
||||
### 2. 연결 수정
|
||||
|
||||
1. 연결 목록에서 수정할 연결의 "편집" 버튼 클릭
|
||||
2. 필요한 정보 수정
|
||||
- 비밀번호는 변경 시에만 입력
|
||||
- 비밀번호 필드를 비워두면 기존 비밀번호 유지
|
||||
3. "연결 테스트"로 수정된 정보 확인
|
||||
4. "수정" 버튼으로 변경사항 저장
|
||||
|
||||
### 3. 연결 테스트
|
||||
|
||||
- 새 연결 생성 시: 임시 연결을 생성하여 테스트 후 삭제
|
||||
- 기존 연결 수정 시: 현재 연결로 테스트 실행
|
||||
- 테스트 결과에 서버 버전, 응답 시간 등 상세 정보 표시
|
||||
- 실패 시 상세한 오류 메시지 제공
|
||||
|
||||
### 4. 연결 삭제
|
||||
|
||||
1. 연결 목록에서 삭제할 연결의 "삭제" 버튼 클릭
|
||||
2. 확인 대화상자에서 "삭제" 선택
|
||||
3. 해당 연결과 관련된 모든 설정 제거
|
||||
|
||||
## 보안 고려사항
|
||||
|
||||
1. 비밀번호 관리
|
||||
- 비밀번호는 AES-256-CBC로 암호화하여 저장
|
||||
- 비밀번호는 UI에 표시되지 않음
|
||||
- 수정 시 비밀번호 필드를 비워두면 기존 비밀번호 유지
|
||||
|
||||
2. SSL 연결
|
||||
- SSL 사용 옵션 제공
|
||||
- 인증서 경로 설정 가능
|
||||
- 자체 서명 인증서 지원
|
||||
|
||||
3. 접근 제어
|
||||
- 회사 코드별 연결 관리
|
||||
- 활성/비활성 상태 관리
|
||||
- 연결별 최대 연결 수 제한
|
||||
|
||||
## 문제 해결
|
||||
|
||||
### 일반적인 문제
|
||||
|
||||
1. 연결 테스트 실패
|
||||
- 호스트/포트 접근 가능 여부 확인
|
||||
- 데이터베이스명 정확성 확인
|
||||
- 사용자 계정 권한 확인
|
||||
- 방화벽 설정 확인
|
||||
|
||||
2. 타임아웃 발생
|
||||
- 연결 타임아웃 값 조정
|
||||
- 네트워크 상태 확인
|
||||
- 데이터베이스 서버 부하 확인
|
||||
|
||||
3. SSL 연결 오류
|
||||
- 인증서 파일 경로 확인
|
||||
- 인증서 유효성 확인
|
||||
- SSL 설정 일치 여부 확인
|
||||
|
||||
### 오류 메시지 해석
|
||||
|
||||
1. "ECONNREFUSED"
|
||||
- 원인: 호스트/포트에 연결할 수 없음
|
||||
- 해결: 호스트/포트 정확성 확인, 방화벽 설정 확인
|
||||
|
||||
2. "password authentication failed"
|
||||
- 원인: 사용자명/비밀번호 불일치
|
||||
- 해결: 계정 정보 정확성 확인
|
||||
|
||||
3. "database does not exist"
|
||||
- 원인: 데이터베이스가 존재하지 않음
|
||||
- 해결: 데이터베이스명 정확성 확인
|
||||
|
||||
## 모범 사례
|
||||
|
||||
1. 연결명 작성
|
||||
- 용도를 명확히 알 수 있는 이름 사용
|
||||
- 회사/환경 정보 포함 권장
|
||||
- 예: "운영_회계DB", "개발_테스트DB"
|
||||
|
||||
2. 보안 설정
|
||||
- 가능한 SSL 사용
|
||||
- 최소 권한의 계정 사용
|
||||
- 연결 타임아웃 적절히 설정
|
||||
|
||||
3. 성능 최적화
|
||||
- 필요한 최소한의 최대 연결 수 설정
|
||||
- 쿼리 타임아웃 적절히 설정
|
||||
- 주기적인 연결 테스트 수행
|
||||
|
||||
## API 참조
|
||||
|
||||
### ExternalDbConnectionAPI
|
||||
|
||||
#### 연결 관리
|
||||
```typescript
|
||||
// 연결 목록 조회
|
||||
getConnections(filter: ExternalDbConnectionFilter): Promise<ExternalDbConnection[]>
|
||||
|
||||
// 연결 상세 조회
|
||||
getConnectionById(id: number): Promise<ExternalDbConnection>
|
||||
|
||||
// 새 연결 생성
|
||||
createConnection(data: ExternalDbConnection): Promise<ExternalDbConnection>
|
||||
|
||||
// 연결 수정
|
||||
updateConnection(id: number, data: ExternalDbConnection): Promise<ExternalDbConnection>
|
||||
|
||||
// 연결 삭제
|
||||
deleteConnection(id: number): Promise<void>
|
||||
```
|
||||
|
||||
#### 연결 테스트
|
||||
```typescript
|
||||
// 연결 테스트
|
||||
testConnection(connectionId: number, password?: string): Promise<ConnectionTestResult>
|
||||
```
|
||||
|
||||
#### 데이터 조회/저장
|
||||
```typescript
|
||||
// 테이블 목록 조회
|
||||
getTables(connectionId: number): Promise<string[]>
|
||||
|
||||
// 테이블 컬럼 정보 조회
|
||||
getTableColumns(connectionId: number, tableName: string): Promise<ColumnInfo[]>
|
||||
|
||||
// 테이블 데이터 조회
|
||||
getTableData(connectionId: number, tableName: string, options?: QueryOptions): Promise<any[]>
|
||||
|
||||
// 테이블 데이터 저장
|
||||
saveTableData(connectionId: number, tableName: string, data: any[], options: SaveOptions): Promise<{ affected_rows: number }>
|
||||
```
|
||||
|
||||
## 향후 계획
|
||||
|
||||
1. 기능 개선
|
||||
- 연결 풀 모니터링
|
||||
- 연결 통계 대시보드
|
||||
- 쿼리 실행 이력 관리
|
||||
|
||||
2. 지원 예정 기능
|
||||
- 연결 복제
|
||||
- 연결 설정 임포트/익스포트
|
||||
- 연결 그룹 관리
|
||||
|
||||
3. 보안 강화
|
||||
- 역할 기반 접근 제어
|
||||
- 감사 로그 강화
|
||||
- 연결 암호화 강화
|
||||
|
|
@ -0,0 +1,265 @@
|
|||
# 외부 DB 연결 관리 기능 개선 계획
|
||||
|
||||
## 1. 모니터링 및 관리 기능 강화
|
||||
|
||||
### 1.1 연결 풀 모니터링
|
||||
- [ ] 실시간 연결 상태 모니터링
|
||||
- 활성 연결 수
|
||||
- 대기 중인 연결 수
|
||||
- 연결 사용량 통계
|
||||
- [ ] 연결 풀 설정 관리
|
||||
- 최소/최대 연결 수 조정
|
||||
- 연결 타임아웃 관리
|
||||
- 유휴 연결 정리 정책
|
||||
- [ ] 알림 설정
|
||||
- 연결 풀 포화 시 알림
|
||||
- 연결 오류 발생 시 알림
|
||||
- 성능 저하 시 알림
|
||||
|
||||
### 1.2 연결 통계 대시보드
|
||||
- [ ] 연결별 사용 통계
|
||||
- 일/주/월별 사용량
|
||||
- 피크 타임 분석
|
||||
- 오류 발생 빈도
|
||||
- [ ] 성능 메트릭
|
||||
- 응답 시간 추이
|
||||
- 쿼리 실행 시간
|
||||
- 리소스 사용량
|
||||
- [ ] 시각화 도구
|
||||
- 그래프 및 차트
|
||||
- 실시간 모니터링
|
||||
- 추세 분석
|
||||
|
||||
### 1.3 쿼리 실행 이력
|
||||
- [ ] 쿼리 로깅
|
||||
- 실행된 쿼리 기록
|
||||
- 실행 시간 및 결과
|
||||
- 오류 정보
|
||||
- [ ] 분석 도구
|
||||
- 자주 사용되는 쿼리 분석
|
||||
- 성능 문제 쿼리 식별
|
||||
- 패턴 분석
|
||||
- [ ] 감사 기능
|
||||
- 접근 이력 관리
|
||||
- 변경 사항 추적
|
||||
- 보안 감사
|
||||
|
||||
## 2. 사용자 편의성 개선
|
||||
|
||||
### 2.1 연결 관리 기능
|
||||
- [ ] 연결 복제
|
||||
- 기존 연결 설정 복사
|
||||
- 환경별 설정 관리
|
||||
- 빠른 설정 생성
|
||||
- [ ] 설정 임포트/익스포트
|
||||
- JSON/YAML 형식 지원
|
||||
- 대량 설정 관리
|
||||
- 백업/복원 기능
|
||||
- [ ] 연결 그룹 관리
|
||||
- 논리적 그룹화
|
||||
- 권한 일괄 관리
|
||||
- 설정 템플릿
|
||||
|
||||
### 2.2 UI/UX 개선
|
||||
- [ ] 연결 테스트 강화
|
||||
- 상세 진단 정보
|
||||
- 문제 해결 가이드
|
||||
- 자동 재시도 옵션
|
||||
- [ ] 설정 마법사
|
||||
- 단계별 설정 가이드
|
||||
- 유효성 검사 강화
|
||||
- 모범 사례 추천
|
||||
- [ ] 검색 및 필터
|
||||
- 고급 검색 옵션
|
||||
- 커스텀 필터 저장
|
||||
- 빠른 액세스
|
||||
|
||||
### 2.3 자동화 기능
|
||||
- [ ] 스케줄링
|
||||
- 주기적 연결 테스트
|
||||
- 상태 점검 자동화
|
||||
- 리포트 생성
|
||||
- [ ] 배치 작업
|
||||
- 대량 설정 변경
|
||||
- 일괄 작업 실행
|
||||
- 작업 이력 관리
|
||||
- [ ] 알림 자동화
|
||||
- 상태 변경 알림
|
||||
- 문제 발생 알림
|
||||
- 알림 채널 설정
|
||||
|
||||
## 3. 보안 강화
|
||||
|
||||
### 3.1 접근 제어
|
||||
- [ ] 역할 기반 접근 제어 (RBAC)
|
||||
- 세분화된 권한 관리
|
||||
- 역할 템플릿
|
||||
- 권한 상속
|
||||
- [ ] 다단계 인증
|
||||
- 중요 작업 승인
|
||||
- IP 기반 접근 제어
|
||||
- 세션 관리
|
||||
- [ ] 감사 로그
|
||||
- 상세 작업 이력
|
||||
- 변경 사항 추적
|
||||
- 보안 이벤트 기록
|
||||
|
||||
### 3.2 데이터 보안
|
||||
- [ ] 암호화 강화
|
||||
- 고급 암호화 알고리즘
|
||||
- 키 관리 시스템
|
||||
- 전송 구간 암호화
|
||||
- [ ] 데이터 마스킹
|
||||
- 민감 정보 보호
|
||||
- 동적 마스킹 규칙
|
||||
- 접근 수준별 마스킹
|
||||
- [ ] 보안 정책
|
||||
- 비밀번호 정책
|
||||
- 연결 제한 정책
|
||||
- 데이터 접근 정책
|
||||
|
||||
### 3.3 컴플라이언스
|
||||
- [ ] 규정 준수
|
||||
- GDPR 대응
|
||||
- 개인정보보호법
|
||||
- 산업별 규제
|
||||
- [ ] 보안 감사
|
||||
- 정기 보안 검사
|
||||
- 취약점 분석
|
||||
- 보안 리포트
|
||||
- [ ] 문서화
|
||||
- 보안 가이드라인
|
||||
- 절차 문서
|
||||
- 교육 자료
|
||||
|
||||
## 4. 성능 최적화
|
||||
|
||||
### 4.1 연결 풀 최적화
|
||||
- [ ] 동적 조정
|
||||
- 부하 기반 조정
|
||||
- 자동 스케일링
|
||||
- 리소스 최적화
|
||||
- [ ] 캐싱 전략
|
||||
- 쿼리 결과 캐싱
|
||||
- 메타데이터 캐싱
|
||||
- 캐시 무효화
|
||||
- [ ] 부하 분산
|
||||
- 읽기/쓰기 분리
|
||||
- 연결 분산
|
||||
- 장애 조치
|
||||
|
||||
### 4.2 쿼리 최적화
|
||||
- [ ] 쿼리 분석
|
||||
- 실행 계획 분석
|
||||
- 병목 지점 식별
|
||||
- 인덱스 추천
|
||||
- [ ] 성능 튜닝
|
||||
- 쿼리 재작성
|
||||
- 인덱스 최적화
|
||||
- 파라미터 조정
|
||||
- [ ] 모니터링
|
||||
- 성능 메트릭 수집
|
||||
- 알림 설정
|
||||
- 트렌드 분석
|
||||
|
||||
### 4.3 리소스 관리
|
||||
- [ ] 메모리 관리
|
||||
- 메모리 사용량 모니터링
|
||||
- 누수 감지
|
||||
- 자동 정리
|
||||
- [ ] 디스크 I/O
|
||||
- I/O 패턴 분석
|
||||
- 버퍼링 최적화
|
||||
- 저장소 관리
|
||||
- [ ] CPU 사용
|
||||
- 프로세스 모니터링
|
||||
- 스레드 관리
|
||||
- 부하 분산
|
||||
|
||||
## 5. 확장성
|
||||
|
||||
### 5.1 아키텍처 개선
|
||||
- [ ] 마이크로서비스 전환
|
||||
- 서비스 분리
|
||||
- API 게이트웨이
|
||||
- 서비스 디스커버리
|
||||
- [ ] 컨테이너화
|
||||
- Docker 이미지
|
||||
- Kubernetes 배포
|
||||
- 오케스트레이션
|
||||
- [ ] 확장 가능한 설계
|
||||
- 모듈화
|
||||
- 플러그인 아키텍처
|
||||
- 인터페이스 표준화
|
||||
|
||||
### 5.2 통합 기능
|
||||
- [ ] ETL 도구 연동
|
||||
- 데이터 추출
|
||||
- 변환 규칙
|
||||
- 로드 프로세스
|
||||
- [ ] BI 도구 연동
|
||||
- 데이터 시각화
|
||||
- 리포트 생성
|
||||
- 대시보드 통합
|
||||
- [ ] 외부 시스템 연동
|
||||
- API 연동
|
||||
- 이벤트 처리
|
||||
- 데이터 동기화
|
||||
|
||||
### 5.3 데이터 관리
|
||||
- [ ] 데이터 카탈로그
|
||||
- 메타데이터 관리
|
||||
- 데이터 계보
|
||||
- 검색 기능
|
||||
- [ ] 데이터 품질
|
||||
- 유효성 검사
|
||||
- 정합성 체크
|
||||
- 품질 메트릭
|
||||
- [ ] 데이터 거버넌스
|
||||
- 정책 관리
|
||||
- 접근 제어
|
||||
- 라이프사이클 관리
|
||||
|
||||
## 구현 우선순위
|
||||
|
||||
### Phase 1 (1-3개월)
|
||||
1. 연결 풀 모니터링 기본 기능
|
||||
2. 보안 강화 (RBAC, 암호화)
|
||||
3. UI/UX 개선 (연결 테스트 강화)
|
||||
|
||||
### Phase 2 (4-6개월)
|
||||
1. 통계 대시보드
|
||||
2. 쿼리 실행 이력
|
||||
3. 자동화 기능 (스케줄링)
|
||||
|
||||
### Phase 3 (7-9개월)
|
||||
1. 성능 최적화
|
||||
2. 확장성 개선
|
||||
3. 통합 기능
|
||||
|
||||
### Phase 4 (10-12개월)
|
||||
1. 고급 모니터링
|
||||
2. 데이터 관리 기능
|
||||
3. 컴플라이언스 대응
|
||||
|
||||
## 기대 효과
|
||||
|
||||
1. 운영 효율성
|
||||
- 모니터링 강화로 문제 조기 발견
|
||||
- 자동화를 통한 관리 부담 감소
|
||||
- 성능 최적화로 리소스 효율성 향상
|
||||
|
||||
2. 보안 강화
|
||||
- 체계적인 접근 제어
|
||||
- 데이터 보안 강화
|
||||
- 감사 추적성 확보
|
||||
|
||||
3. 사용자 만족도
|
||||
- 직관적인 UI/UX
|
||||
- 자동화된 작업 처리
|
||||
- 빠른 문제 해결
|
||||
|
||||
4. 비즈니스 가치
|
||||
- 데이터 활용도 증가
|
||||
- 운영 비용 절감
|
||||
- 규정 준수 보장
|
||||
|
|
@ -0,0 +1,433 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
Plus,
|
||||
Search,
|
||||
MoreHorizontal,
|
||||
Edit,
|
||||
Trash2,
|
||||
Play,
|
||||
RefreshCw,
|
||||
BarChart3
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { BatchAPI, BatchJob } from "@/lib/api/batch";
|
||||
import BatchJobModal from "@/components/admin/BatchJobModal";
|
||||
|
||||
export default function BatchManagementPage() {
|
||||
const [jobs, setJobs] = useState<BatchJob[]>([]);
|
||||
const [filteredJobs, setFilteredJobs] = useState<BatchJob[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [statusFilter, setStatusFilter] = useState("all");
|
||||
const [typeFilter, setTypeFilter] = useState("all");
|
||||
const [jobTypes, setJobTypes] = useState<Array<{ value: string; label: string }>>([]);
|
||||
|
||||
// 모달 상태
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [selectedJob, setSelectedJob] = useState<BatchJob | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
loadJobs();
|
||||
loadJobTypes();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
filterJobs();
|
||||
}, [jobs, searchTerm, statusFilter, typeFilter]);
|
||||
|
||||
const loadJobs = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const data = await BatchAPI.getBatchJobs();
|
||||
setJobs(data);
|
||||
} catch (error) {
|
||||
console.error("배치 작업 목록 조회 오류:", error);
|
||||
toast.error("배치 작업 목록을 불러오는데 실패했습니다.");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadJobTypes = async () => {
|
||||
try {
|
||||
const types = await BatchAPI.getSupportedJobTypes();
|
||||
setJobTypes(types);
|
||||
} catch (error) {
|
||||
console.error("작업 타입 조회 오류:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const filterJobs = () => {
|
||||
let filtered = jobs;
|
||||
|
||||
// 검색어 필터
|
||||
if (searchTerm) {
|
||||
filtered = filtered.filter(job =>
|
||||
job.job_name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
job.description?.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
);
|
||||
}
|
||||
|
||||
// 상태 필터
|
||||
if (statusFilter !== "all") {
|
||||
filtered = filtered.filter(job => job.is_active === statusFilter);
|
||||
}
|
||||
|
||||
// 타입 필터
|
||||
if (typeFilter !== "all") {
|
||||
filtered = filtered.filter(job => job.job_type === typeFilter);
|
||||
}
|
||||
|
||||
setFilteredJobs(filtered);
|
||||
};
|
||||
|
||||
const handleCreate = () => {
|
||||
setSelectedJob(null);
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
const handleEdit = (job: BatchJob) => {
|
||||
setSelectedJob(job);
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
const handleDelete = async (job: BatchJob) => {
|
||||
if (!confirm(`"${job.job_name}" 배치 작업을 삭제하시겠습니까?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await BatchAPI.deleteBatchJob(job.id!);
|
||||
toast.success("배치 작업이 삭제되었습니다.");
|
||||
loadJobs();
|
||||
} catch (error) {
|
||||
console.error("배치 작업 삭제 오류:", error);
|
||||
toast.error("배치 작업 삭제에 실패했습니다.");
|
||||
}
|
||||
};
|
||||
|
||||
const handleExecute = async (job: BatchJob) => {
|
||||
try {
|
||||
await BatchAPI.executeBatchJob(job.id!);
|
||||
toast.success(`"${job.job_name}" 배치 작업을 실행했습니다.`);
|
||||
} catch (error) {
|
||||
console.error("배치 작업 실행 오류:", error);
|
||||
toast.error("배치 작업 실행에 실패했습니다.");
|
||||
}
|
||||
};
|
||||
|
||||
const handleModalSave = () => {
|
||||
loadJobs();
|
||||
};
|
||||
|
||||
const getStatusBadge = (isActive: string) => {
|
||||
return isActive === "Y" ? (
|
||||
<Badge className="bg-green-100 text-green-800">활성</Badge>
|
||||
) : (
|
||||
<Badge className="bg-red-100 text-red-800">비활성</Badge>
|
||||
);
|
||||
};
|
||||
|
||||
const getTypeBadge = (type: string) => {
|
||||
const option = jobTypes.find(opt => opt.value === type);
|
||||
const colors = {
|
||||
collection: "bg-blue-100 text-blue-800",
|
||||
sync: "bg-purple-100 text-purple-800",
|
||||
cleanup: "bg-orange-100 text-orange-800",
|
||||
custom: "bg-gray-100 text-gray-800",
|
||||
};
|
||||
|
||||
const icons = {
|
||||
collection: "📥",
|
||||
sync: "🔄",
|
||||
cleanup: "🧹",
|
||||
custom: "⚙️",
|
||||
};
|
||||
|
||||
return (
|
||||
<Badge className={colors[type as keyof typeof colors] || "bg-gray-100 text-gray-800"}>
|
||||
<span className="mr-1">{icons[type as keyof typeof icons] || "📋"}</span>
|
||||
{option?.label || type}
|
||||
</Badge>
|
||||
);
|
||||
};
|
||||
|
||||
const getSuccessRate = (job: BatchJob) => {
|
||||
if (job.execution_count === 0) return 100;
|
||||
return Math.round((job.success_count / job.execution_count) * 100);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">배치 관리</h1>
|
||||
<p className="text-muted-foreground">
|
||||
스케줄된 배치 작업을 관리하고 실행 상태를 모니터링합니다.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" onClick={() => window.open('/admin/monitoring', '_blank')}>
|
||||
<BarChart3 className="h-4 w-4 mr-2" />
|
||||
모니터링
|
||||
</Button>
|
||||
<Button onClick={handleCreate}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
새 배치 작업
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 통계 카드 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">총 작업</CardTitle>
|
||||
<div className="text-2xl">📋</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{jobs.length}</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
활성: {jobs.filter(j => j.is_active === 'Y').length}개
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">총 실행</CardTitle>
|
||||
<div className="text-2xl">▶️</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{jobs.reduce((sum, job) => sum + job.execution_count, 0)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">누적 실행 횟수</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">성공</CardTitle>
|
||||
<div className="text-2xl">✅</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-green-600">
|
||||
{jobs.reduce((sum, job) => sum + job.success_count, 0)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">총 성공 횟수</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">실패</CardTitle>
|
||||
<div className="text-2xl">❌</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-red-600">
|
||||
{jobs.reduce((sum, job) => sum + job.failure_count, 0)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">총 실패 횟수</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 필터 및 검색 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>필터 및 검색</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-col md:flex-row gap-4">
|
||||
<div className="flex-1">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="작업명, 설명으로 검색..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||
<SelectTrigger className="w-32">
|
||||
<SelectValue placeholder="상태" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">전체</SelectItem>
|
||||
<SelectItem value="Y">활성</SelectItem>
|
||||
<SelectItem value="N">비활성</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select value={typeFilter} onValueChange={setTypeFilter}>
|
||||
<SelectTrigger className="w-40">
|
||||
<SelectValue placeholder="작업 타입" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">전체 타입</SelectItem>
|
||||
{jobTypes.map((type) => (
|
||||
<SelectItem key={type.value} value={type.value}>
|
||||
{type.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Button variant="outline" onClick={loadJobs} disabled={isLoading}>
|
||||
<RefreshCw className={`h-4 w-4 mr-2 ${isLoading ? 'animate-spin' : ''}`} />
|
||||
새로고침
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 배치 작업 목록 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>배치 작업 목록 ({filteredJobs.length}개)</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<div className="text-center py-8">
|
||||
<RefreshCw className="h-8 w-8 animate-spin mx-auto mb-2" />
|
||||
<p>배치 작업을 불러오는 중...</p>
|
||||
</div>
|
||||
) : filteredJobs.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
{jobs.length === 0 ? "배치 작업이 없습니다." : "검색 결과가 없습니다."}
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>작업명</TableHead>
|
||||
<TableHead>타입</TableHead>
|
||||
<TableHead>스케줄</TableHead>
|
||||
<TableHead>상태</TableHead>
|
||||
<TableHead>실행 통계</TableHead>
|
||||
<TableHead>성공률</TableHead>
|
||||
<TableHead>마지막 실행</TableHead>
|
||||
<TableHead>작업</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredJobs.map((job) => (
|
||||
<TableRow key={job.id}>
|
||||
<TableCell>
|
||||
<div>
|
||||
<div className="font-medium">{job.job_name}</div>
|
||||
{job.description && (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{job.description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{getTypeBadge(job.job_type)}
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-sm">
|
||||
{job.schedule_cron || "-"}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{getStatusBadge(job.is_active)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="text-sm">
|
||||
<div>총 {job.execution_count}회</div>
|
||||
<div className="text-muted-foreground">
|
||||
성공 {job.success_count} / 실패 {job.failure_count}
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={`text-sm font-medium ${
|
||||
getSuccessRate(job) >= 90 ? 'text-green-600' :
|
||||
getSuccessRate(job) >= 70 ? 'text-yellow-600' : 'text-red-600'
|
||||
}`}>
|
||||
{getSuccessRate(job)}%
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{job.last_executed_at
|
||||
? new Date(job.last_executed_at).toLocaleString()
|
||||
: "-"}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="h-8 w-8 p-0">
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => handleEdit(job)}>
|
||||
<Edit className="h-4 w-4 mr-2" />
|
||||
수정
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleExecute(job)}
|
||||
disabled={job.is_active !== "Y"}
|
||||
>
|
||||
<Play className="h-4 w-4 mr-2" />
|
||||
실행
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => handleDelete(job)}>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
삭제
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 배치 작업 모달 */}
|
||||
<BatchJobModal
|
||||
isOpen={isModalOpen}
|
||||
onClose={() => setIsModalOpen(false)}
|
||||
onSave={handleModalSave}
|
||||
job={selectedJob}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,337 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
Plus,
|
||||
Search,
|
||||
MoreHorizontal,
|
||||
Edit,
|
||||
Trash2,
|
||||
Play,
|
||||
History,
|
||||
RefreshCw
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { CollectionAPI, DataCollectionConfig } from "@/lib/api/collection";
|
||||
import CollectionConfigModal from "@/components/admin/CollectionConfigModal";
|
||||
|
||||
export default function CollectionManagementPage() {
|
||||
const [configs, setConfigs] = useState<DataCollectionConfig[]>([]);
|
||||
const [filteredConfigs, setFilteredConfigs] = useState<DataCollectionConfig[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [statusFilter, setStatusFilter] = useState("all");
|
||||
const [typeFilter, setTypeFilter] = useState("all");
|
||||
|
||||
// 모달 상태
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [selectedConfig, setSelectedConfig] = useState<DataCollectionConfig | null>(null);
|
||||
|
||||
const collectionTypeOptions = CollectionAPI.getCollectionTypeOptions();
|
||||
|
||||
useEffect(() => {
|
||||
loadConfigs();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
filterConfigs();
|
||||
}, [configs, searchTerm, statusFilter, typeFilter]);
|
||||
|
||||
const loadConfigs = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const data = await CollectionAPI.getCollectionConfigs();
|
||||
setConfigs(data);
|
||||
} catch (error) {
|
||||
console.error("수집 설정 목록 조회 오류:", error);
|
||||
toast.error("수집 설정 목록을 불러오는데 실패했습니다.");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const filterConfigs = () => {
|
||||
let filtered = configs;
|
||||
|
||||
// 검색어 필터
|
||||
if (searchTerm) {
|
||||
filtered = filtered.filter(config =>
|
||||
config.config_name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
config.source_table.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
config.description?.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
);
|
||||
}
|
||||
|
||||
// 상태 필터
|
||||
if (statusFilter !== "all") {
|
||||
filtered = filtered.filter(config => config.is_active === statusFilter);
|
||||
}
|
||||
|
||||
// 타입 필터
|
||||
if (typeFilter !== "all") {
|
||||
filtered = filtered.filter(config => config.collection_type === typeFilter);
|
||||
}
|
||||
|
||||
setFilteredConfigs(filtered);
|
||||
};
|
||||
|
||||
const handleCreate = () => {
|
||||
setSelectedConfig(null);
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
const handleEdit = (config: DataCollectionConfig) => {
|
||||
setSelectedConfig(config);
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
const handleDelete = async (config: DataCollectionConfig) => {
|
||||
if (!confirm(`"${config.config_name}" 수집 설정을 삭제하시겠습니까?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await CollectionAPI.deleteCollectionConfig(config.id!);
|
||||
toast.success("수집 설정이 삭제되었습니다.");
|
||||
loadConfigs();
|
||||
} catch (error) {
|
||||
console.error("수집 설정 삭제 오류:", error);
|
||||
toast.error("수집 설정 삭제에 실패했습니다.");
|
||||
}
|
||||
};
|
||||
|
||||
const handleExecute = async (config: DataCollectionConfig) => {
|
||||
try {
|
||||
await CollectionAPI.executeCollection(config.id!);
|
||||
toast.success(`"${config.config_name}" 수집 작업을 시작했습니다.`);
|
||||
} catch (error) {
|
||||
console.error("수집 작업 실행 오류:", error);
|
||||
toast.error("수집 작업 실행에 실패했습니다.");
|
||||
}
|
||||
};
|
||||
|
||||
const handleModalSave = () => {
|
||||
loadConfigs();
|
||||
};
|
||||
|
||||
const getStatusBadge = (isActive: string) => {
|
||||
return isActive === "Y" ? (
|
||||
<Badge className="bg-green-100 text-green-800">활성</Badge>
|
||||
) : (
|
||||
<Badge className="bg-red-100 text-red-800">비활성</Badge>
|
||||
);
|
||||
};
|
||||
|
||||
const getTypeBadge = (type: string) => {
|
||||
const option = collectionTypeOptions.find(opt => opt.value === type);
|
||||
const colors = {
|
||||
full: "bg-blue-100 text-blue-800",
|
||||
incremental: "bg-purple-100 text-purple-800",
|
||||
delta: "bg-orange-100 text-orange-800",
|
||||
};
|
||||
return (
|
||||
<Badge className={colors[type as keyof typeof colors] || "bg-gray-100 text-gray-800"}>
|
||||
{option?.label || type}
|
||||
</Badge>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">수집 관리</h1>
|
||||
<p className="text-muted-foreground">
|
||||
외부 데이터베이스에서 데이터를 수집하는 설정을 관리합니다.
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={handleCreate}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
새 수집 설정
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 필터 및 검색 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>필터 및 검색</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-col md:flex-row gap-4">
|
||||
<div className="flex-1">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="설정명, 테이블명, 설명으로 검색..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||
<SelectTrigger className="w-32">
|
||||
<SelectValue placeholder="상태" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">전체</SelectItem>
|
||||
<SelectItem value="Y">활성</SelectItem>
|
||||
<SelectItem value="N">비활성</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select value={typeFilter} onValueChange={setTypeFilter}>
|
||||
<SelectTrigger className="w-40">
|
||||
<SelectValue placeholder="수집 타입" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">전체 타입</SelectItem>
|
||||
{collectionTypeOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Button variant="outline" onClick={loadConfigs} disabled={isLoading}>
|
||||
<RefreshCw className={`h-4 w-4 mr-2 ${isLoading ? 'animate-spin' : ''}`} />
|
||||
새로고침
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 수집 설정 목록 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>수집 설정 목록 ({filteredConfigs.length}개)</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<div className="text-center py-8">
|
||||
<RefreshCw className="h-8 w-8 animate-spin mx-auto mb-2" />
|
||||
<p>수집 설정을 불러오는 중...</p>
|
||||
</div>
|
||||
) : filteredConfigs.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
{configs.length === 0 ? "수집 설정이 없습니다." : "검색 결과가 없습니다."}
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>설정명</TableHead>
|
||||
<TableHead>수집 타입</TableHead>
|
||||
<TableHead>소스 테이블</TableHead>
|
||||
<TableHead>대상 테이블</TableHead>
|
||||
<TableHead>스케줄</TableHead>
|
||||
<TableHead>상태</TableHead>
|
||||
<TableHead>마지막 수집</TableHead>
|
||||
<TableHead>작업</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredConfigs.map((config) => (
|
||||
<TableRow key={config.id}>
|
||||
<TableCell>
|
||||
<div>
|
||||
<div className="font-medium">{config.config_name}</div>
|
||||
{config.description && (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{config.description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{getTypeBadge(config.collection_type)}
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-sm">
|
||||
{config.source_table}
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-sm">
|
||||
{config.target_table || "-"}
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-sm">
|
||||
{config.schedule_cron || "-"}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{getStatusBadge(config.is_active)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{config.last_collected_at
|
||||
? new Date(config.last_collected_at).toLocaleString()
|
||||
: "-"}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="h-8 w-8 p-0">
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => handleEdit(config)}>
|
||||
<Edit className="h-4 w-4 mr-2" />
|
||||
수정
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleExecute(config)}
|
||||
disabled={config.is_active !== "Y"}
|
||||
>
|
||||
<Play className="h-4 w-4 mr-2" />
|
||||
실행
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => handleDelete(config)}>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
삭제
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 수집 설정 모달 */}
|
||||
<CollectionConfigModal
|
||||
isOpen={isModalOpen}
|
||||
onClose={() => setIsModalOpen(false)}
|
||||
onSave={handleModalSave}
|
||||
config={selectedConfig}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import MonitoringDashboard from "@/components/admin/MonitoringDashboard";
|
||||
|
||||
export default function MonitoringPage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* 헤더 */}
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">모니터링</h1>
|
||||
<p className="text-muted-foreground">
|
||||
배치 작업 실행 상태를 실시간으로 모니터링합니다.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 모니터링 대시보드 */}
|
||||
<MonitoringDashboard />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,374 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { toast } from "sonner";
|
||||
import { BatchAPI, BatchJob } from "@/lib/api/batch";
|
||||
import { CollectionAPI } from "@/lib/api/collection";
|
||||
|
||||
interface BatchJobModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSave: () => void;
|
||||
job?: BatchJob | null;
|
||||
}
|
||||
|
||||
export default function BatchJobModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
onSave,
|
||||
job,
|
||||
}: BatchJobModalProps) {
|
||||
const [formData, setFormData] = useState<Partial<BatchJob>>({
|
||||
job_name: "",
|
||||
description: "",
|
||||
job_type: "collection",
|
||||
schedule_cron: "",
|
||||
is_active: "Y",
|
||||
config_json: {},
|
||||
execution_count: 0,
|
||||
success_count: 0,
|
||||
failure_count: 0,
|
||||
});
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [jobTypes, setJobTypes] = useState<Array<{ value: string; label: string }>>([]);
|
||||
const [schedulePresets, setSchedulePresets] = useState<Array<{ value: string; label: string }>>([]);
|
||||
const [collectionConfigs, setCollectionConfigs] = useState<any[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
loadJobTypes();
|
||||
loadSchedulePresets();
|
||||
loadCollectionConfigs();
|
||||
|
||||
if (job) {
|
||||
setFormData({
|
||||
...job,
|
||||
config_json: job.config_json || {},
|
||||
});
|
||||
} else {
|
||||
setFormData({
|
||||
job_name: "",
|
||||
description: "",
|
||||
job_type: "collection",
|
||||
schedule_cron: "",
|
||||
is_active: "Y",
|
||||
config_json: {},
|
||||
execution_count: 0,
|
||||
success_count: 0,
|
||||
failure_count: 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [isOpen, job]);
|
||||
|
||||
const loadJobTypes = async () => {
|
||||
try {
|
||||
const types = await BatchAPI.getSupportedJobTypes();
|
||||
setJobTypes(types);
|
||||
} catch (error) {
|
||||
console.error("작업 타입 조회 오류:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const loadSchedulePresets = async () => {
|
||||
try {
|
||||
const presets = await BatchAPI.getSchedulePresets();
|
||||
setSchedulePresets(presets);
|
||||
} catch (error) {
|
||||
console.error("스케줄 프리셋 조회 오류:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const loadCollectionConfigs = async () => {
|
||||
try {
|
||||
const configs = await CollectionAPI.getCollectionConfigs({
|
||||
is_active: "Y",
|
||||
});
|
||||
setCollectionConfigs(configs);
|
||||
} catch (error) {
|
||||
console.error("수집 설정 조회 오류:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!formData.job_name || !formData.job_type) {
|
||||
toast.error("필수 필드를 모두 입력해주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
if (job?.id) {
|
||||
await BatchAPI.updateBatchJob(job.id, formData);
|
||||
toast.success("배치 작업이 수정되었습니다.");
|
||||
} else {
|
||||
await BatchAPI.createBatchJob(formData as BatchJob);
|
||||
toast.success("배치 작업이 생성되었습니다.");
|
||||
}
|
||||
onSave();
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error("배치 작업 저장 오류:", error);
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : "배치 작업 저장에 실패했습니다."
|
||||
);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSchedulePresetSelect = (preset: string) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
schedule_cron: preset,
|
||||
}));
|
||||
};
|
||||
|
||||
const handleJobTypeChange = (jobType: string) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
job_type: jobType as any,
|
||||
config_json: {},
|
||||
}));
|
||||
};
|
||||
|
||||
const handleCollectionConfigChange = (configId: string) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
config_json: {
|
||||
...prev.config_json,
|
||||
collectionConfigId: parseInt(configId),
|
||||
},
|
||||
}));
|
||||
};
|
||||
|
||||
const getJobTypeIcon = (type: string) => {
|
||||
switch (type) {
|
||||
case 'collection': return '📥';
|
||||
case 'sync': return '🔄';
|
||||
case 'cleanup': return '🧹';
|
||||
case 'custom': return '⚙️';
|
||||
default: return '📋';
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'Y': return 'bg-green-100 text-green-800';
|
||||
case 'N': return 'bg-red-100 text-red-800';
|
||||
default: return 'bg-gray-100 text-gray-800';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{job ? "배치 작업 수정" : "새 배치 작업"}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{/* 기본 정보 */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-medium">기본 정보</h3>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="job_name">작업명 *</Label>
|
||||
<Input
|
||||
id="job_name"
|
||||
value={formData.job_name || ""}
|
||||
onChange={(e) =>
|
||||
setFormData(prev => ({ ...prev, job_name: e.target.value }))
|
||||
}
|
||||
placeholder="배치 작업명을 입력하세요"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="job_type">작업 타입 *</Label>
|
||||
<Select
|
||||
value={formData.job_type || "collection"}
|
||||
onValueChange={handleJobTypeChange}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{jobTypes.map((type) => (
|
||||
<SelectItem key={type.value} value={type.value}>
|
||||
<span className="flex items-center gap-2">
|
||||
<span>{getJobTypeIcon(type.value)}</span>
|
||||
{type.label}
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="description">설명</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
value={formData.description || ""}
|
||||
onChange={(e) =>
|
||||
setFormData(prev => ({ ...prev, description: e.target.value }))
|
||||
}
|
||||
placeholder="배치 작업에 대한 설명을 입력하세요"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 작업 설정 */}
|
||||
{formData.job_type === 'collection' && (
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-medium">수집 설정</h3>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="collection_config">수집 설정</Label>
|
||||
<Select
|
||||
value={formData.config_json?.collectionConfigId?.toString() || ""}
|
||||
onValueChange={handleCollectionConfigChange}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="수집 설정을 선택하세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{collectionConfigs.map((config) => (
|
||||
<SelectItem key={config.id} value={config.id.toString()}>
|
||||
{config.config_name} - {config.source_table}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 스케줄 설정 */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-medium">스케줄 설정</h3>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="schedule_cron">Cron 표현식</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="schedule_cron"
|
||||
value={formData.schedule_cron || ""}
|
||||
onChange={(e) =>
|
||||
setFormData(prev => ({ ...prev, schedule_cron: e.target.value }))
|
||||
}
|
||||
placeholder="예: 0 0 * * * (매일 자정)"
|
||||
className="flex-1"
|
||||
/>
|
||||
<Select onValueChange={handleSchedulePresetSelect}>
|
||||
<SelectTrigger className="w-32">
|
||||
<SelectValue placeholder="프리셋" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{schedulePresets.map((preset) => (
|
||||
<SelectItem key={preset.value} value={preset.value}>
|
||||
{preset.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 실행 통계 (수정 모드일 때만) */}
|
||||
{job?.id && (
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-medium">실행 통계</h3>
|
||||
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="p-4 border rounded-lg">
|
||||
<div className="text-2xl font-bold text-blue-600">
|
||||
{formData.execution_count || 0}
|
||||
</div>
|
||||
<div className="text-sm text-gray-600">총 실행 횟수</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 border rounded-lg">
|
||||
<div className="text-2xl font-bold text-green-600">
|
||||
{formData.success_count || 0}
|
||||
</div>
|
||||
<div className="text-sm text-gray-600">성공 횟수</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 border rounded-lg">
|
||||
<div className="text-2xl font-bold text-red-600">
|
||||
{formData.failure_count || 0}
|
||||
</div>
|
||||
<div className="text-sm text-gray-600">실패 횟수</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{formData.last_executed_at && (
|
||||
<div className="text-sm text-gray-600">
|
||||
마지막 실행: {new Date(formData.last_executed_at).toLocaleString()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 활성화 설정 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Switch
|
||||
id="is_active"
|
||||
checked={formData.is_active === "Y"}
|
||||
onCheckedChange={(checked) =>
|
||||
setFormData(prev => ({ ...prev, is_active: checked ? "Y" : "N" }))
|
||||
}
|
||||
/>
|
||||
<Label htmlFor="is_active">활성화</Label>
|
||||
</div>
|
||||
|
||||
<Badge className={getStatusColor(formData.is_active || "N")}>
|
||||
{formData.is_active === "Y" ? "활성" : "비활성"}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={onClose}>
|
||||
취소
|
||||
</Button>
|
||||
<Button type="submit" disabled={isLoading}>
|
||||
{isLoading ? "저장 중..." : "저장"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,346 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { toast } from "sonner";
|
||||
import { CollectionAPI, DataCollectionConfig } from "@/lib/api/collection";
|
||||
import { ExternalDbConnectionAPI } from "@/lib/api/externalDbConnection";
|
||||
|
||||
interface CollectionConfigModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSave: () => void;
|
||||
config?: DataCollectionConfig | null;
|
||||
}
|
||||
|
||||
export default function CollectionConfigModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
onSave,
|
||||
config,
|
||||
}: CollectionConfigModalProps) {
|
||||
const [formData, setFormData] = useState<Partial<DataCollectionConfig>>({
|
||||
config_name: "",
|
||||
description: "",
|
||||
source_connection_id: 0,
|
||||
source_table: "",
|
||||
target_table: "",
|
||||
collection_type: "full",
|
||||
schedule_cron: "",
|
||||
is_active: "Y",
|
||||
collection_options: {},
|
||||
});
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [connections, setConnections] = useState<any[]>([]);
|
||||
const [tables, setTables] = useState<string[]>([]);
|
||||
|
||||
const collectionTypeOptions = CollectionAPI.getCollectionTypeOptions();
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
loadConnections();
|
||||
if (config) {
|
||||
setFormData({
|
||||
...config,
|
||||
collection_options: config.collection_options || {},
|
||||
});
|
||||
if (config.source_connection_id) {
|
||||
loadTables(config.source_connection_id);
|
||||
}
|
||||
} else {
|
||||
setFormData({
|
||||
config_name: "",
|
||||
description: "",
|
||||
source_connection_id: 0,
|
||||
source_table: "",
|
||||
target_table: "",
|
||||
collection_type: "full",
|
||||
schedule_cron: "",
|
||||
is_active: "Y",
|
||||
collection_options: {},
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [isOpen, config]);
|
||||
|
||||
const loadConnections = async () => {
|
||||
try {
|
||||
const connectionList = await ExternalDbConnectionAPI.getConnections({
|
||||
is_active: "Y",
|
||||
});
|
||||
setConnections(connectionList);
|
||||
} catch (error) {
|
||||
console.error("외부 연결 목록 조회 오류:", error);
|
||||
toast.error("외부 연결 목록을 불러오는데 실패했습니다.");
|
||||
}
|
||||
};
|
||||
|
||||
const loadTables = async (connectionId: number) => {
|
||||
try {
|
||||
const result = await ExternalDbConnectionAPI.getTables(connectionId);
|
||||
if (result.success && result.data) {
|
||||
setTables(result.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("테이블 목록 조회 오류:", error);
|
||||
toast.error("테이블 목록을 불러오는데 실패했습니다.");
|
||||
}
|
||||
};
|
||||
|
||||
const handleConnectionChange = (connectionId: string) => {
|
||||
const id = parseInt(connectionId);
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
source_connection_id: id,
|
||||
source_table: "",
|
||||
}));
|
||||
if (id > 0) {
|
||||
loadTables(id);
|
||||
} else {
|
||||
setTables([]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!formData.config_name || !formData.source_connection_id || !formData.source_table) {
|
||||
toast.error("필수 필드를 모두 입력해주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
if (config?.id) {
|
||||
await CollectionAPI.updateCollectionConfig(config.id, formData);
|
||||
toast.success("수집 설정이 수정되었습니다.");
|
||||
} else {
|
||||
await CollectionAPI.createCollectionConfig(formData as DataCollectionConfig);
|
||||
toast.success("수집 설정이 생성되었습니다.");
|
||||
}
|
||||
onSave();
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error("수집 설정 저장 오류:", error);
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : "수집 설정 저장에 실패했습니다."
|
||||
);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSchedulePresetSelect = (preset: string) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
schedule_cron: preset,
|
||||
}));
|
||||
};
|
||||
|
||||
const schedulePresets = [
|
||||
{ value: "0 */1 * * *", label: "매시간" },
|
||||
{ value: "0 0 */6 * *", label: "6시간마다" },
|
||||
{ value: "0 0 * * *", label: "매일 자정" },
|
||||
{ value: "0 0 * * 0", label: "매주 일요일" },
|
||||
{ value: "0 0 1 * *", label: "매월 1일" },
|
||||
];
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{config ? "수집 설정 수정" : "새 수집 설정"}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{/* 기본 정보 */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-medium">기본 정보</h3>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="config_name">설정명 *</Label>
|
||||
<Input
|
||||
id="config_name"
|
||||
value={formData.config_name || ""}
|
||||
onChange={(e) =>
|
||||
setFormData(prev => ({ ...prev, config_name: e.target.value }))
|
||||
}
|
||||
placeholder="수집 설정명을 입력하세요"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="collection_type">수집 타입 *</Label>
|
||||
<Select
|
||||
value={formData.collection_type || "full"}
|
||||
onValueChange={(value) =>
|
||||
setFormData(prev => ({ ...prev, collection_type: value as any }))
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{collectionTypeOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="description">설명</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
value={formData.description || ""}
|
||||
onChange={(e) =>
|
||||
setFormData(prev => ({ ...prev, description: e.target.value }))
|
||||
}
|
||||
placeholder="수집 설정에 대한 설명을 입력하세요"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 소스 설정 */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-medium">소스 설정</h3>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="source_connection">소스 연결 *</Label>
|
||||
<Select
|
||||
value={formData.source_connection_id?.toString() || ""}
|
||||
onValueChange={handleConnectionChange}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="연결을 선택하세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{connections.map((conn) => (
|
||||
<SelectItem key={conn.id} value={conn.id.toString()}>
|
||||
{conn.connection_name} ({conn.db_type})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="source_table">소스 테이블 *</Label>
|
||||
<Select
|
||||
value={formData.source_table || ""}
|
||||
onValueChange={(value) =>
|
||||
setFormData(prev => ({ ...prev, source_table: value }))
|
||||
}
|
||||
disabled={!formData.source_connection_id}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="테이블을 선택하세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{tables.map((table) => (
|
||||
<SelectItem key={table} value={table}>
|
||||
{table}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="target_table">대상 테이블</Label>
|
||||
<Input
|
||||
id="target_table"
|
||||
value={formData.target_table || ""}
|
||||
onChange={(e) =>
|
||||
setFormData(prev => ({ ...prev, target_table: e.target.value }))
|
||||
}
|
||||
placeholder="대상 테이블명 (선택사항)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 스케줄 설정 */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-medium">스케줄 설정</h3>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="schedule_cron">Cron 표현식</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="schedule_cron"
|
||||
value={formData.schedule_cron || ""}
|
||||
onChange={(e) =>
|
||||
setFormData(prev => ({ ...prev, schedule_cron: e.target.value }))
|
||||
}
|
||||
placeholder="예: 0 0 * * * (매일 자정)"
|
||||
className="flex-1"
|
||||
/>
|
||||
<Select onValueChange={handleSchedulePresetSelect}>
|
||||
<SelectTrigger className="w-32">
|
||||
<SelectValue placeholder="프리셋" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{schedulePresets.map((preset) => (
|
||||
<SelectItem key={preset.value} value={preset.value}>
|
||||
{preset.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 활성화 설정 */}
|
||||
<div className="flex items-center space-x-2">
|
||||
<Switch
|
||||
id="is_active"
|
||||
checked={formData.is_active === "Y"}
|
||||
onCheckedChange={(checked) =>
|
||||
setFormData(prev => ({ ...prev, is_active: checked ? "Y" : "N" }))
|
||||
}
|
||||
/>
|
||||
<Label htmlFor="is_active">활성화</Label>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={onClose}>
|
||||
취소
|
||||
</Button>
|
||||
<Button type="submit" disabled={isLoading}>
|
||||
{isLoading ? "저장 중..." : "저장"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
|
@ -221,7 +221,7 @@ export const ExternalDbConnectionModal: React.FC<ExternalDbConnectionModalProps>
|
|||
return;
|
||||
}
|
||||
|
||||
const result = await ExternalDbConnectionAPI.testConnection(connection.id);
|
||||
const result = await ExternalDbConnectionAPI.testConnection(connection.id, formData.password);
|
||||
setTestResult(result);
|
||||
|
||||
if (result.success) {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,288 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { RefreshCw, Play, Pause, AlertCircle, CheckCircle, Clock } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { BatchAPI, BatchMonitoring, BatchExecution } from "@/lib/api/batch";
|
||||
|
||||
export default function MonitoringDashboard() {
|
||||
const [monitoring, setMonitoring] = useState<BatchMonitoring | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [autoRefresh, setAutoRefresh] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
loadMonitoringData();
|
||||
|
||||
let interval: NodeJS.Timeout;
|
||||
if (autoRefresh) {
|
||||
interval = setInterval(loadMonitoringData, 30000); // 30초마다 자동 새로고침
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (interval) clearInterval(interval);
|
||||
};
|
||||
}, [autoRefresh]);
|
||||
|
||||
const loadMonitoringData = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const data = await BatchAPI.getBatchMonitoring();
|
||||
setMonitoring(data);
|
||||
} catch (error) {
|
||||
console.error("모니터링 데이터 조회 오류:", error);
|
||||
toast.error("모니터링 데이터를 불러오는데 실패했습니다.");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRefresh = () => {
|
||||
loadMonitoringData();
|
||||
};
|
||||
|
||||
const toggleAutoRefresh = () => {
|
||||
setAutoRefresh(!autoRefresh);
|
||||
};
|
||||
|
||||
const getStatusIcon = (status: string) => {
|
||||
switch (status) {
|
||||
case 'completed':
|
||||
return <CheckCircle className="h-4 w-4 text-green-500" />;
|
||||
case 'failed':
|
||||
return <AlertCircle className="h-4 w-4 text-red-500" />;
|
||||
case 'running':
|
||||
return <Play className="h-4 w-4 text-blue-500" />;
|
||||
case 'pending':
|
||||
return <Clock className="h-4 w-4 text-yellow-500" />;
|
||||
default:
|
||||
return <Clock className="h-4 w-4 text-gray-500" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
const variants = {
|
||||
completed: "bg-green-100 text-green-800",
|
||||
failed: "bg-red-100 text-red-800",
|
||||
running: "bg-blue-100 text-blue-800",
|
||||
pending: "bg-yellow-100 text-yellow-800",
|
||||
cancelled: "bg-gray-100 text-gray-800",
|
||||
};
|
||||
|
||||
const labels = {
|
||||
completed: "완료",
|
||||
failed: "실패",
|
||||
running: "실행 중",
|
||||
pending: "대기 중",
|
||||
cancelled: "취소됨",
|
||||
};
|
||||
|
||||
return (
|
||||
<Badge className={variants[status as keyof typeof variants] || variants.pending}>
|
||||
{labels[status as keyof typeof labels] || status}
|
||||
</Badge>
|
||||
);
|
||||
};
|
||||
|
||||
const formatDuration = (ms: number) => {
|
||||
if (ms < 1000) return `${ms}ms`;
|
||||
if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
|
||||
return `${(ms / 60000).toFixed(1)}m`;
|
||||
};
|
||||
|
||||
const getSuccessRate = () => {
|
||||
if (!monitoring) return 0;
|
||||
const total = monitoring.successful_jobs_today + monitoring.failed_jobs_today;
|
||||
if (total === 0) return 100;
|
||||
return Math.round((monitoring.successful_jobs_today / total) * 100);
|
||||
};
|
||||
|
||||
if (!monitoring) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-center">
|
||||
<RefreshCw className="h-8 w-8 animate-spin mx-auto mb-2" />
|
||||
<p>모니터링 데이터를 불러오는 중...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-2xl font-bold">배치 모니터링</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={toggleAutoRefresh}
|
||||
className={autoRefresh ? "bg-blue-50 text-blue-600" : ""}
|
||||
>
|
||||
{autoRefresh ? <Pause className="h-4 w-4 mr-1" /> : <Play className="h-4 w-4 mr-1" />}
|
||||
자동 새로고침
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleRefresh}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 mr-1 ${isLoading ? 'animate-spin' : ''}`} />
|
||||
새로고침
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 통계 카드 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">총 작업 수</CardTitle>
|
||||
<div className="text-2xl">📋</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{monitoring.total_jobs}</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
활성: {monitoring.active_jobs}개
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">실행 중</CardTitle>
|
||||
<div className="text-2xl">🔄</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-blue-600">{monitoring.running_jobs}</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
현재 실행 중인 작업
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">오늘 성공</CardTitle>
|
||||
<div className="text-2xl">✅</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-green-600">{monitoring.successful_jobs_today}</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
성공률: {getSuccessRate()}%
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">오늘 실패</CardTitle>
|
||||
<div className="text-2xl">❌</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-red-600">{monitoring.failed_jobs_today}</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
주의가 필요한 작업
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 성공률 진행바 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">오늘 실행 성공률</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span>성공: {monitoring.successful_jobs_today}건</span>
|
||||
<span>실패: {monitoring.failed_jobs_today}건</span>
|
||||
</div>
|
||||
<Progress value={getSuccessRate()} className="h-2" />
|
||||
<div className="text-center text-sm text-muted-foreground">
|
||||
{getSuccessRate()}% 성공률
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 최근 실행 이력 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">최근 실행 이력</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{monitoring.recent_executions.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
최근 실행 이력이 없습니다.
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>상태</TableHead>
|
||||
<TableHead>작업 ID</TableHead>
|
||||
<TableHead>시작 시간</TableHead>
|
||||
<TableHead>완료 시간</TableHead>
|
||||
<TableHead>실행 시간</TableHead>
|
||||
<TableHead>오류 메시지</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{monitoring.recent_executions.map((execution) => (
|
||||
<TableRow key={execution.id}>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
{getStatusIcon(execution.execution_status)}
|
||||
{getStatusBadge(execution.execution_status)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="font-mono">#{execution.job_id}</TableCell>
|
||||
<TableCell>
|
||||
{execution.started_at
|
||||
? new Date(execution.started_at).toLocaleString()
|
||||
: "-"}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{execution.completed_at
|
||||
? new Date(execution.completed_at).toLocaleString()
|
||||
: "-"}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{execution.execution_time_ms
|
||||
? formatDuration(execution.execution_time_ms)
|
||||
: "-"}
|
||||
</TableCell>
|
||||
<TableCell className="max-w-xs">
|
||||
{execution.error_message ? (
|
||||
<span className="text-red-600 text-sm truncate block">
|
||||
{execution.error_message}
|
||||
</span>
|
||||
) : (
|
||||
"-"
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -54,6 +54,8 @@ export const SqlQueryModal: React.FC<SqlQueryModalProps> = ({ isOpen, onClose, c
|
|||
const [tables, setTables] = useState<TableInfo[]>([]);
|
||||
const [selectedTable, setSelectedTable] = useState("");
|
||||
const [loadingTables, setLoadingTables] = useState(false);
|
||||
const [selectedTableColumns, setSelectedTableColumns] = useState<TableColumn[]>([]);
|
||||
const [loadingColumns, setLoadingColumns] = useState(false);
|
||||
|
||||
// 테이블 목록 로딩
|
||||
useEffect(() => {
|
||||
|
|
@ -79,6 +81,31 @@ export const SqlQueryModal: React.FC<SqlQueryModalProps> = ({ isOpen, onClose, c
|
|||
loadTables();
|
||||
}, []);
|
||||
|
||||
// 테이블 선택 시 컬럼 정보 로딩
|
||||
const loadTableColumns = async (tableName: string) => {
|
||||
if (!tableName) {
|
||||
setSelectedTableColumns([]);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoadingColumns(true);
|
||||
try {
|
||||
const result = await ExternalDbConnectionAPI.getTableColumns(connectionId, tableName);
|
||||
if (result.success && result.data) {
|
||||
setSelectedTableColumns(result.data as TableColumn[]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("컬럼 정보 로딩 오류:", error);
|
||||
toast({
|
||||
title: "오류",
|
||||
description: "컬럼 정보를 불러오는데 실패했습니다.",
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setLoadingColumns(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleExecute = async () => {
|
||||
console.log("실행 버튼 클릭");
|
||||
if (!query.trim()) {
|
||||
|
|
@ -140,6 +167,7 @@ export const SqlQueryModal: React.FC<SqlQueryModalProps> = ({ isOpen, onClose, c
|
|||
value={selectedTable}
|
||||
onValueChange={(value) => {
|
||||
setSelectedTable(value);
|
||||
loadTableColumns(value);
|
||||
// 현재 커서 위치에 테이블 이름 삽입
|
||||
setQuery((prev) => {
|
||||
const fromIndex = prev.toUpperCase().lastIndexOf("FROM");
|
||||
|
|
@ -166,35 +194,72 @@ export const SqlQueryModal: React.FC<SqlQueryModalProps> = ({ isOpen, onClose, c
|
|||
</div>
|
||||
|
||||
{/* 테이블 정보 */}
|
||||
<div className="bg-muted/50 rounded-md border p-4">
|
||||
<h3 className="mb-2 font-medium">사용 가능한 테이블</h3>
|
||||
<div className="space-y-4 max-h-[300px] overflow-y-auto">
|
||||
<div className="pr-2">
|
||||
{tables.map((table) => (
|
||||
<div key={table.table_name} className="mb-4 bg-white rounded-lg shadow-sm border last:mb-0">
|
||||
<div className="p-3">
|
||||
<div className="bg-muted/50 rounded-md border p-4 space-y-4">
|
||||
<div>
|
||||
<h3 className="mb-2 font-medium">사용 가능한 테이블</h3>
|
||||
<div className="max-h-[200px] overflow-y-auto">
|
||||
<div className="pr-2 space-y-2">
|
||||
{tables.map((table) => (
|
||||
<div key={table.table_name} className="bg-white rounded-lg shadow-sm border p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="font-mono font-bold">{table.table_name}</h4>
|
||||
<Button variant="ghost" size="sm" onClick={() => setQuery(`SELECT * FROM ${table.table_name}`)}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setSelectedTable(table.table_name);
|
||||
loadTableColumns(table.table_name);
|
||||
setQuery(`SELECT * FROM ${table.table_name}`);
|
||||
}}
|
||||
>
|
||||
선택
|
||||
</Button>
|
||||
</div>
|
||||
{table.description && (
|
||||
<p className="text-muted-foreground mt-1 text-sm">{table.description}</p>
|
||||
)}
|
||||
<div className="mt-2 grid grid-cols-3 gap-2">
|
||||
{table.columns.map((column: TableColumn) => (
|
||||
<div key={column.column_name} className="text-sm">
|
||||
<span className="font-mono">{column.column_name}</span>
|
||||
<span className="text-muted-foreground ml-1">({column.data_type})</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 선택된 테이블의 컬럼 정보 */}
|
||||
{selectedTable && (
|
||||
<div>
|
||||
<h3 className="mb-2 font-medium">테이블 컬럼 정보: {selectedTable}</h3>
|
||||
{loadingColumns ? (
|
||||
<div className="text-sm text-muted-foreground">컬럼 정보 로딩 중...</div>
|
||||
) : selectedTableColumns.length > 0 ? (
|
||||
<div className="max-h-[200px] overflow-y-auto">
|
||||
<div className="bg-white rounded-lg shadow-sm border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[200px]">컬럼명</TableHead>
|
||||
<TableHead className="w-[150px]">데이터 타입</TableHead>
|
||||
<TableHead className="w-[100px]">NULL 허용</TableHead>
|
||||
<TableHead>기본값</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{selectedTableColumns.map((column) => (
|
||||
<TableRow key={column.column_name}>
|
||||
<TableCell className="font-mono font-medium">{column.column_name}</TableCell>
|
||||
<TableCell className="text-sm">{column.data_type}</TableCell>
|
||||
<TableCell className="text-sm">{column.is_nullable}</TableCell>
|
||||
<TableCell className="text-sm">{column.column_default || '-'}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-sm text-muted-foreground">컬럼 정보를 불러올 수 없습니다.</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,297 @@
|
|||
// 배치 관리 API 클라이언트
|
||||
// 작성일: 2024-12-23
|
||||
|
||||
import { apiClient } from "./client";
|
||||
|
||||
export interface BatchJob {
|
||||
id?: number;
|
||||
job_name: string;
|
||||
description?: string;
|
||||
job_type: 'collection' | 'sync' | 'cleanup' | 'custom';
|
||||
schedule_cron?: string;
|
||||
is_active: string;
|
||||
config_json?: Record<string, any>;
|
||||
last_executed_at?: Date;
|
||||
next_execution_at?: Date;
|
||||
execution_count: number;
|
||||
success_count: number;
|
||||
failure_count: number;
|
||||
created_date?: Date;
|
||||
created_by?: string;
|
||||
updated_date?: Date;
|
||||
updated_by?: string;
|
||||
company_code: string;
|
||||
}
|
||||
|
||||
export interface BatchJobFilter {
|
||||
job_name?: string;
|
||||
job_type?: string;
|
||||
is_active?: string;
|
||||
company_code?: string;
|
||||
search?: string;
|
||||
}
|
||||
|
||||
export interface BatchExecution {
|
||||
id?: number;
|
||||
job_id: number;
|
||||
execution_status: 'pending' | 'running' | 'completed' | 'failed' | 'cancelled';
|
||||
started_at?: Date;
|
||||
completed_at?: Date;
|
||||
execution_time_ms?: number;
|
||||
result_data?: Record<string, any>;
|
||||
error_message?: string;
|
||||
log_details?: string;
|
||||
created_date?: Date;
|
||||
}
|
||||
|
||||
export interface BatchMonitoring {
|
||||
total_jobs: number;
|
||||
active_jobs: number;
|
||||
running_jobs: number;
|
||||
failed_jobs_today: number;
|
||||
successful_jobs_today: number;
|
||||
recent_executions: BatchExecution[];
|
||||
}
|
||||
|
||||
export interface ApiResponse<T> {
|
||||
success: boolean;
|
||||
data?: T;
|
||||
message?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export class BatchAPI {
|
||||
private static readonly BASE_PATH = "/batch";
|
||||
|
||||
/**
|
||||
* 배치 작업 목록 조회
|
||||
*/
|
||||
static async getBatchJobs(filter: BatchJobFilter = {}): Promise<BatchJob[]> {
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
|
||||
if (filter.job_name) params.append("job_name", filter.job_name);
|
||||
if (filter.job_type) params.append("job_type", filter.job_type);
|
||||
if (filter.is_active) params.append("is_active", filter.is_active);
|
||||
if (filter.company_code) params.append("company_code", filter.company_code);
|
||||
if (filter.search) params.append("search", filter.search);
|
||||
|
||||
const response = await apiClient.get<ApiResponse<BatchJob[]>>(
|
||||
`${this.BASE_PATH}?${params.toString()}`
|
||||
);
|
||||
|
||||
if (!response.data.success) {
|
||||
throw new Error(response.data.message || "배치 작업 목록 조회에 실패했습니다.");
|
||||
}
|
||||
|
||||
return response.data.data || [];
|
||||
} catch (error) {
|
||||
console.error("배치 작업 목록 조회 오류:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 배치 작업 상세 조회
|
||||
*/
|
||||
static async getBatchJobById(id: number): Promise<BatchJob> {
|
||||
try {
|
||||
const response = await apiClient.get<ApiResponse<BatchJob>>(`${this.BASE_PATH}/${id}`);
|
||||
|
||||
if (!response.data.success) {
|
||||
throw new Error(response.data.message || "배치 작업 조회에 실패했습니다.");
|
||||
}
|
||||
|
||||
if (!response.data.data) {
|
||||
throw new Error("배치 작업을 찾을 수 없습니다.");
|
||||
}
|
||||
|
||||
return response.data.data;
|
||||
} catch (error) {
|
||||
console.error("배치 작업 조회 오류:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 배치 작업 생성
|
||||
*/
|
||||
static async createBatchJob(data: BatchJob): Promise<BatchJob> {
|
||||
try {
|
||||
const response = await apiClient.post<ApiResponse<BatchJob>>(this.BASE_PATH, data);
|
||||
|
||||
if (!response.data.success) {
|
||||
throw new Error(response.data.message || "배치 작업 생성에 실패했습니다.");
|
||||
}
|
||||
|
||||
if (!response.data.data) {
|
||||
throw new Error("생성된 배치 작업 정보를 받을 수 없습니다.");
|
||||
}
|
||||
|
||||
return response.data.data;
|
||||
} catch (error) {
|
||||
console.error("배치 작업 생성 오류:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 배치 작업 수정
|
||||
*/
|
||||
static async updateBatchJob(id: number, data: Partial<BatchJob>): Promise<BatchJob> {
|
||||
try {
|
||||
const response = await apiClient.put<ApiResponse<BatchJob>>(`${this.BASE_PATH}/${id}`, data);
|
||||
|
||||
if (!response.data.success) {
|
||||
throw new Error(response.data.message || "배치 작업 수정에 실패했습니다.");
|
||||
}
|
||||
|
||||
if (!response.data.data) {
|
||||
throw new Error("수정된 배치 작업 정보를 받을 수 없습니다.");
|
||||
}
|
||||
|
||||
return response.data.data;
|
||||
} catch (error) {
|
||||
console.error("배치 작업 수정 오류:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 배치 작업 삭제
|
||||
*/
|
||||
static async deleteBatchJob(id: number): Promise<void> {
|
||||
try {
|
||||
const response = await apiClient.delete<ApiResponse<null>>(`${this.BASE_PATH}/${id}`);
|
||||
|
||||
if (!response.data.success) {
|
||||
throw new Error(response.data.message || "배치 작업 삭제에 실패했습니다.");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("배치 작업 삭제 오류:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 배치 작업 수동 실행
|
||||
*/
|
||||
static async executeBatchJob(id: number): Promise<BatchExecution> {
|
||||
try {
|
||||
const response = await apiClient.post<ApiResponse<BatchExecution>>(`${this.BASE_PATH}/${id}/execute`);
|
||||
|
||||
if (!response.data.success) {
|
||||
throw new Error(response.data.message || "배치 작업 실행에 실패했습니다.");
|
||||
}
|
||||
|
||||
if (!response.data.data) {
|
||||
throw new Error("배치 실행 정보를 받을 수 없습니다.");
|
||||
}
|
||||
|
||||
return response.data.data;
|
||||
} catch (error) {
|
||||
console.error("배치 작업 실행 오류:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 배치 실행 목록 조회
|
||||
*/
|
||||
static async getBatchExecutions(jobId?: number): Promise<BatchExecution[]> {
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
if (jobId) params.append("job_id", jobId.toString());
|
||||
|
||||
const response = await apiClient.get<ApiResponse<BatchExecution[]>>(
|
||||
`${this.BASE_PATH}/executions/list?${params.toString()}`
|
||||
);
|
||||
|
||||
if (!response.data.success) {
|
||||
throw new Error(response.data.message || "배치 실행 목록 조회에 실패했습니다.");
|
||||
}
|
||||
|
||||
return response.data.data || [];
|
||||
} catch (error) {
|
||||
console.error("배치 실행 목록 조회 오류:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 배치 모니터링 정보 조회
|
||||
*/
|
||||
static async getBatchMonitoring(): Promise<BatchMonitoring> {
|
||||
try {
|
||||
const response = await apiClient.get<ApiResponse<BatchMonitoring>>(
|
||||
`${this.BASE_PATH}/monitoring/status`
|
||||
);
|
||||
|
||||
if (!response.data.success) {
|
||||
throw new Error(response.data.message || "배치 모니터링 조회에 실패했습니다.");
|
||||
}
|
||||
|
||||
if (!response.data.data) {
|
||||
throw new Error("배치 모니터링 정보를 받을 수 없습니다.");
|
||||
}
|
||||
|
||||
return response.data.data;
|
||||
} catch (error) {
|
||||
console.error("배치 모니터링 조회 오류:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 지원되는 작업 타입 조회
|
||||
*/
|
||||
static async getSupportedJobTypes(): Promise<Array<{ value: string; label: string }>> {
|
||||
try {
|
||||
const response = await apiClient.get<ApiResponse<{ types: Array<{ value: string; label: string }> }>>(
|
||||
`${this.BASE_PATH}/types/supported`
|
||||
);
|
||||
|
||||
if (!response.data.success) {
|
||||
throw new Error(response.data.message || "작업 타입 조회에 실패했습니다.");
|
||||
}
|
||||
|
||||
return response.data.data?.types || [];
|
||||
} catch (error) {
|
||||
console.error("작업 타입 조회 오류:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 스케줄 프리셋 조회
|
||||
*/
|
||||
static async getSchedulePresets(): Promise<Array<{ value: string; label: string }>> {
|
||||
try {
|
||||
const response = await apiClient.get<ApiResponse<{ presets: Array<{ value: string; label: string }> }>>(
|
||||
`${this.BASE_PATH}/schedules/presets`
|
||||
);
|
||||
|
||||
if (!response.data.success) {
|
||||
throw new Error(response.data.message || "스케줄 프리셋 조회에 실패했습니다.");
|
||||
}
|
||||
|
||||
return response.data.data?.presets || [];
|
||||
} catch (error) {
|
||||
console.error("스케줄 프리셋 조회 오류:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 실행 상태 옵션 조회
|
||||
*/
|
||||
static getExecutionStatusOptions() {
|
||||
return [
|
||||
{ value: 'pending', label: '대기 중' },
|
||||
{ value: 'running', label: '실행 중' },
|
||||
{ value: 'completed', label: '완료' },
|
||||
{ value: 'failed', label: '실패' },
|
||||
{ value: 'cancelled', label: '취소됨' },
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,265 @@
|
|||
// 수집 관리 API 클라이언트
|
||||
// 작성일: 2024-12-23
|
||||
|
||||
import { apiClient } from "./client";
|
||||
|
||||
export interface DataCollectionConfig {
|
||||
id?: number;
|
||||
config_name: string;
|
||||
description?: string;
|
||||
source_connection_id: number;
|
||||
source_table: string;
|
||||
target_table?: string;
|
||||
collection_type: 'full' | 'incremental' | 'delta';
|
||||
schedule_cron?: string;
|
||||
is_active: string;
|
||||
last_collected_at?: Date;
|
||||
collection_options?: Record<string, any>;
|
||||
created_date?: Date;
|
||||
created_by?: string;
|
||||
updated_date?: Date;
|
||||
updated_by?: string;
|
||||
company_code: string;
|
||||
}
|
||||
|
||||
export interface CollectionFilter {
|
||||
config_name?: string;
|
||||
source_connection_id?: number;
|
||||
collection_type?: string;
|
||||
is_active?: string;
|
||||
company_code?: string;
|
||||
search?: string;
|
||||
}
|
||||
|
||||
export interface CollectionJob {
|
||||
id?: number;
|
||||
config_id: number;
|
||||
job_status: 'pending' | 'running' | 'completed' | 'failed';
|
||||
started_at?: Date;
|
||||
completed_at?: Date;
|
||||
records_processed?: number;
|
||||
error_message?: string;
|
||||
job_details?: Record<string, any>;
|
||||
created_date?: Date;
|
||||
}
|
||||
|
||||
export interface CollectionHistory {
|
||||
id?: number;
|
||||
config_id: number;
|
||||
collection_date: Date;
|
||||
records_collected: number;
|
||||
execution_time_ms: number;
|
||||
status: 'success' | 'partial' | 'failed';
|
||||
error_details?: string;
|
||||
created_date?: Date;
|
||||
}
|
||||
|
||||
export interface ApiResponse<T> {
|
||||
success: boolean;
|
||||
data?: T;
|
||||
message?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export class CollectionAPI {
|
||||
private static readonly BASE_PATH = "/collections";
|
||||
|
||||
/**
|
||||
* 수집 설정 목록 조회
|
||||
*/
|
||||
static async getCollectionConfigs(filter: CollectionFilter = {}): Promise<DataCollectionConfig[]> {
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
|
||||
if (filter.config_name) params.append("config_name", filter.config_name);
|
||||
if (filter.source_connection_id) params.append("source_connection_id", filter.source_connection_id.toString());
|
||||
if (filter.collection_type) params.append("collection_type", filter.collection_type);
|
||||
if (filter.is_active) params.append("is_active", filter.is_active);
|
||||
if (filter.company_code) params.append("company_code", filter.company_code);
|
||||
if (filter.search) params.append("search", filter.search);
|
||||
|
||||
const response = await apiClient.get<ApiResponse<DataCollectionConfig[]>>(
|
||||
`${this.BASE_PATH}?${params.toString()}`
|
||||
);
|
||||
|
||||
if (!response.data.success) {
|
||||
throw new Error(response.data.message || "수집 설정 목록 조회에 실패했습니다.");
|
||||
}
|
||||
|
||||
return response.data.data || [];
|
||||
} catch (error) {
|
||||
console.error("수집 설정 목록 조회 오류:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 수집 설정 상세 조회
|
||||
*/
|
||||
static async getCollectionConfigById(id: number): Promise<DataCollectionConfig> {
|
||||
try {
|
||||
const response = await apiClient.get<ApiResponse<DataCollectionConfig>>(`${this.BASE_PATH}/${id}`);
|
||||
|
||||
if (!response.data.success) {
|
||||
throw new Error(response.data.message || "수집 설정 조회에 실패했습니다.");
|
||||
}
|
||||
|
||||
if (!response.data.data) {
|
||||
throw new Error("수집 설정을 찾을 수 없습니다.");
|
||||
}
|
||||
|
||||
return response.data.data;
|
||||
} catch (error) {
|
||||
console.error("수집 설정 조회 오류:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 수집 설정 생성
|
||||
*/
|
||||
static async createCollectionConfig(data: DataCollectionConfig): Promise<DataCollectionConfig> {
|
||||
try {
|
||||
const response = await apiClient.post<ApiResponse<DataCollectionConfig>>(this.BASE_PATH, data);
|
||||
|
||||
if (!response.data.success) {
|
||||
throw new Error(response.data.message || "수집 설정 생성에 실패했습니다.");
|
||||
}
|
||||
|
||||
if (!response.data.data) {
|
||||
throw new Error("생성된 수집 설정 정보를 받을 수 없습니다.");
|
||||
}
|
||||
|
||||
return response.data.data;
|
||||
} catch (error) {
|
||||
console.error("수집 설정 생성 오류:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 수집 설정 수정
|
||||
*/
|
||||
static async updateCollectionConfig(id: number, data: Partial<DataCollectionConfig>): Promise<DataCollectionConfig> {
|
||||
try {
|
||||
const response = await apiClient.put<ApiResponse<DataCollectionConfig>>(`${this.BASE_PATH}/${id}`, data);
|
||||
|
||||
if (!response.data.success) {
|
||||
throw new Error(response.data.message || "수집 설정 수정에 실패했습니다.");
|
||||
}
|
||||
|
||||
if (!response.data.data) {
|
||||
throw new Error("수정된 수집 설정 정보를 받을 수 없습니다.");
|
||||
}
|
||||
|
||||
return response.data.data;
|
||||
} catch (error) {
|
||||
console.error("수집 설정 수정 오류:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 수집 설정 삭제
|
||||
*/
|
||||
static async deleteCollectionConfig(id: number): Promise<void> {
|
||||
try {
|
||||
const response = await apiClient.delete<ApiResponse<null>>(`${this.BASE_PATH}/${id}`);
|
||||
|
||||
if (!response.data.success) {
|
||||
throw new Error(response.data.message || "수집 설정 삭제에 실패했습니다.");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("수집 설정 삭제 오류:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 수집 작업 실행
|
||||
*/
|
||||
static async executeCollection(id: number): Promise<CollectionJob> {
|
||||
try {
|
||||
const response = await apiClient.post<ApiResponse<CollectionJob>>(`${this.BASE_PATH}/${id}/execute`);
|
||||
|
||||
if (!response.data.success) {
|
||||
throw new Error(response.data.message || "수집 작업 실행에 실패했습니다.");
|
||||
}
|
||||
|
||||
if (!response.data.data) {
|
||||
throw new Error("수집 작업 정보를 받을 수 없습니다.");
|
||||
}
|
||||
|
||||
return response.data.data;
|
||||
} catch (error) {
|
||||
console.error("수집 작업 실행 오류:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 수집 작업 목록 조회
|
||||
*/
|
||||
static async getCollectionJobs(configId?: number): Promise<CollectionJob[]> {
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
if (configId) params.append("config_id", configId.toString());
|
||||
|
||||
const response = await apiClient.get<ApiResponse<CollectionJob[]>>(
|
||||
`${this.BASE_PATH}/jobs/list?${params.toString()}`
|
||||
);
|
||||
|
||||
if (!response.data.success) {
|
||||
throw new Error(response.data.message || "수집 작업 목록 조회에 실패했습니다.");
|
||||
}
|
||||
|
||||
return response.data.data || [];
|
||||
} catch (error) {
|
||||
console.error("수집 작업 목록 조회 오류:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 수집 이력 조회
|
||||
*/
|
||||
static async getCollectionHistory(configId: number): Promise<CollectionHistory[]> {
|
||||
try {
|
||||
const response = await apiClient.get<ApiResponse<CollectionHistory[]>>(
|
||||
`${this.BASE_PATH}/${configId}/history`
|
||||
);
|
||||
|
||||
if (!response.data.success) {
|
||||
throw new Error(response.data.message || "수집 이력 조회에 실패했습니다.");
|
||||
}
|
||||
|
||||
return response.data.data || [];
|
||||
} catch (error) {
|
||||
console.error("수집 이력 조회 오류:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 수집 타입 옵션 조회
|
||||
*/
|
||||
static getCollectionTypeOptions() {
|
||||
return [
|
||||
{ value: 'full', label: '전체 수집' },
|
||||
{ value: 'incremental', label: '증분 수집' },
|
||||
{ value: 'delta', label: '변경분 수집' },
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 작업 상태 옵션 조회
|
||||
*/
|
||||
static getJobStatusOptions() {
|
||||
return [
|
||||
{ value: 'pending', label: '대기 중' },
|
||||
{ value: 'running', label: '실행 중' },
|
||||
{ value: 'completed', label: '완료' },
|
||||
{ value: 'failed', label: '실패' },
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -7,7 +7,7 @@ export interface ExternalDbConnection {
|
|||
id?: number;
|
||||
connection_name: string;
|
||||
description?: string;
|
||||
db_type: "mysql" | "postgresql" | "oracle" | "mssql" | "sqlite";
|
||||
db_type: "mysql" | "postgresql" | "oracle" | "mssql" | "sqlite" | "mariadb";
|
||||
host: string;
|
||||
port: number;
|
||||
database_name: string;
|
||||
|
|
@ -205,10 +205,11 @@ export class ExternalDbConnectionAPI {
|
|||
/**
|
||||
* 데이터베이스 연결 테스트 (ID 기반)
|
||||
*/
|
||||
static async testConnection(connectionId: number): Promise<ConnectionTestResult> {
|
||||
static async testConnection(connectionId: number, password?: string): Promise<ConnectionTestResult> {
|
||||
try {
|
||||
const response = await apiClient.post<ApiResponse<ConnectionTestResult>>(
|
||||
`${this.BASE_PATH}/${connectionId}/test`
|
||||
`${this.BASE_PATH}/${connectionId}/test`,
|
||||
password ? { password } : undefined
|
||||
);
|
||||
|
||||
if (!response.data.success) {
|
||||
|
|
@ -255,6 +256,20 @@ export class ExternalDbConnectionAPI {
|
|||
}
|
||||
}
|
||||
|
||||
static async getTableColumns(connectionId: number, tableName: string): Promise<ApiResponse<any[]>> {
|
||||
try {
|
||||
console.log("컬럼 정보 API 요청:", `${this.BASE_PATH}/${connectionId}/tables/${tableName}/columns`);
|
||||
const response = await apiClient.get<ApiResponse<any[]>>(
|
||||
`${this.BASE_PATH}/${connectionId}/tables/${tableName}/columns`
|
||||
);
|
||||
console.log("컬럼 정보 API 응답:", response.data);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error("테이블 컬럼 조회 오류:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
static async executeQuery(connectionId: number, query: string): Promise<ApiResponse<any[]>> {
|
||||
try {
|
||||
console.log("API 요청:", `${this.BASE_PATH}/${connectionId}/execute`, { query });
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,7 +1,9 @@
|
|||
{
|
||||
"dependencies": {
|
||||
"@prisma/client": "^6.16.2",
|
||||
"@types/mssql": "^9.1.8",
|
||||
"axios": "^1.12.2",
|
||||
"mssql": "^11.0.1",
|
||||
"prisma": "^6.16.2"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue