Compare commits

..

No commits in common. "d5b63d1c9b5d46e2ffc332769fad7e05d96b251f" and "e75889a127160f3c3bf649db63becb5dea4b8149" have entirely different histories.

36 changed files with 98 additions and 7382 deletions

File diff suppressed because it is too large Load Diff

View File

@ -28,7 +28,6 @@
"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",
@ -39,12 +38,9 @@
"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",
@ -63,7 +59,6 @@
"@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",

View File

@ -27,26 +27,16 @@ model external_call_configs {
api_type String? @db.VarChar(20)
config_data Json
description String?
is_active String? @default("Y") @db.Char(1)
created_by String? @db.VarChar(50)
updated_by String? @db.VarChar(50)
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)
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[]
@@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 external_db_connections {
@ -72,12 +62,7 @@ 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 {
@ -3983,6 +3968,9 @@ 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 {
@ -4005,6 +3993,9 @@ 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")
}
@ -4097,434 +4088,55 @@ model table_relationships_backup {
}
model test_sales_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)
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[]
}
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?
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)
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])
@@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")
}

View File

@ -31,11 +31,8 @@ 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';
@ -130,11 +127,8 @@ 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);

View File

@ -1,294 +0,0 @@
// 배치 관리 컨트롤러
// 작성일: 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: '스케줄 프리셋 조회에 실패했습니다.',
});
}
}
}

View File

@ -1,258 +0,0 @@
// 수집 관리 컨트롤러
// 작성일: 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 : '수집 이력 조회에 실패했습니다.',
});
}
}
}

View File

@ -1,215 +0,0 @@
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 : "알 수 없는 오류"
});
}
}
}

View File

@ -1,8 +1,5 @@
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>();
@ -23,16 +20,6 @@ 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}`);

View File

@ -1,182 +0,0 @@
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];
}
}

View File

@ -1,127 +0,0 @@
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}`);
}
}
}

View File

@ -1,225 +0,0 @@
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;
}
}
}

View File

@ -6,9 +6,6 @@ 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 {

View File

@ -1,73 +0,0 @@
// 배치 관리 라우트
// 작성일: 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;

View File

@ -1,61 +0,0 @@
// 수집 관리 라우트
// 작성일: 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;

View File

@ -1,49 +0,0 @@
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;

View File

@ -85,46 +85,6 @@ 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
@ -302,11 +262,7 @@ router.post(
});
}
// 테스트용 비밀번호가 제공된 경우 사용
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);
const result = await ExternalDbConnectionService.testConnectionById(id);
return res.status(200).json({
success: result.success,
@ -382,37 +338,5 @@ 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;

View File

@ -1,320 +0,0 @@
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 : "알 수 없는 오류"
};
}
}
}

View File

@ -9,7 +9,7 @@ import {
TableInfo,
} from "../types/externalDbTypes";
import { PasswordEncryption } from "../utils/passwordEncryption";
import { DatabaseConnectorFactory } from "../database/DatabaseConnectorFactory";
import { DbConnectionManager } from "./dbConnectionManager";
const prisma = new PrismaClient();
@ -81,93 +81,6 @@ 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
*/
@ -326,40 +239,13 @@ 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 {
@ -434,8 +320,7 @@ export class ExternalDbConnectionService {
* (ID )
*/
static async testConnectionById(
id: number,
testData?: { password?: string }
id: number
): Promise<import("../types/externalDbTypes").ConnectionTestResult> {
try {
// 저장된 연결 정보 조회
@ -454,17 +339,9 @@ export class ExternalDbConnectionService {
};
}
// 비밀번호 결정 (테스트용 비밀번호가 제공된 경우 그것을 사용, 아니면 저장된 비밀번호 복호화)
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) {
// 비밀번호 복호화
const decryptedPassword = await this.getDecryptedPassword(id);
if (!decryptedPassword) {
return {
success: false,
message: "비밀번호 복호화에 실패했습니다.",
@ -481,46 +358,14 @@ export class ExternalDbConnectionService {
port: connection.port,
database: connection.database_name,
user: connection.username,
password: password,
password: decryptedPassword,
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
};
// 연결 테스트용 임시 커넥터 생성 (캐시 사용하지 않음)
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
};
// DbConnectionManager를 통한 연결 테스트
return await DbConnectionManager.testConnection(id, connection.db_type, config);
} catch (error) {
return {
success: false,
@ -571,7 +416,7 @@ export class ExternalDbConnectionService {
}
// DB 타입 유효성 검사
const validDbTypes = ["mysql", "postgresql", "oracle", "mssql", "sqlite", "mariadb"];
const validDbTypes = ["mysql", "postgresql", "oracle", "mssql", "sqlite"];
if (!validDbTypes.includes(data.db_type)) {
throw new Error("지원하지 않는 DB 타입입니다.");
}
@ -642,9 +487,8 @@ export class ExternalDbConnectionService {
ssl: connection.ssl_enabled === "Y" ? { rejectUnauthorized: false } : false
};
// DatabaseConnectorFactory를 통한 쿼리 실행
const connector = await DatabaseConnectorFactory.createConnector(connection.db_type, config, id);
const result = await connector.executeQuery(query);
// DbConnectionManager를 통한 쿼리 실행
const result = await DbConnectionManager.executeQuery(id, connection.db_type, config, query);
return {
success: true,
@ -751,9 +595,8 @@ export class ExternalDbConnectionService {
ssl: connection.ssl_enabled === "Y" ? { rejectUnauthorized: false } : false
};
// DatabaseConnectorFactory를 통한 테이블 목록 조회
const connector = await DatabaseConnectorFactory.createConnector(connection.db_type, config, id);
const tables = await connector.getTables();
// DbConnectionManager를 통한 테이블 목록 조회
const tables = await DbConnectionManager.getTables(id, connection.db_type, config);
return {
success: true,
@ -833,57 +676,4 @@ 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 : "알 수 없는 오류"
};
}
}
}

View File

@ -1,98 +0,0 @@
// 배치 관리 관련 타입 정의
// 작성일: 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;
}

View File

@ -1,75 +0,0 @@
// 수집 관리 관련 타입 정의
// 작성일: 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;
}

View File

@ -5,7 +5,7 @@ export interface ExternalDbConnection {
id?: number;
connection_name: string;
description?: string | null;
db_type: "mysql" | "postgresql" | "oracle" | "mssql" | "sqlite" | "mariadb";
db_type: "mysql" | "postgresql" | "oracle" | "mssql" | "sqlite";
host: string;
port: number;
database_name: string;
@ -58,7 +58,6 @@ 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" },
];
@ -68,7 +67,6 @@ 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" },
};

View File

@ -1,214 +0,0 @@
# 외부 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. 보안 강화
- 역할 기반 접근 제어
- 감사 로그 강화
- 연결 암호화 강화

View File

@ -1,265 +0,0 @@
# 외부 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. 비즈니스 가치
- 데이터 활용도 증가
- 운영 비용 절감
- 규정 준수 보장

View File

@ -1,433 +0,0 @@
"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>
);
}

View File

@ -1,337 +0,0 @@
"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>
);
}

View File

@ -1,21 +0,0 @@
"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>
);
}

View File

@ -1,374 +0,0 @@
"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>
);
}

View File

@ -1,346 +0,0 @@
"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>
);
}

View File

@ -221,7 +221,7 @@ export const ExternalDbConnectionModal: React.FC<ExternalDbConnectionModalProps>
return;
}
const result = await ExternalDbConnectionAPI.testConnection(connection.id, formData.password);
const result = await ExternalDbConnectionAPI.testConnection(connection.id);
setTestResult(result);
if (result.success) {

View File

@ -1,288 +0,0 @@
"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>
);
}

View File

@ -54,8 +54,6 @@ 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(() => {
@ -81,31 +79,6 @@ 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()) {
@ -167,7 +140,6 @@ 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");
@ -194,72 +166,35 @@ export const SqlQueryModal: React.FC<SqlQueryModalProps> = ({ isOpen, onClose, c
</div>
{/* 테이블 정보 */}
<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="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="flex items-center justify-between">
<h4 className="font-mono font-bold">{table.table_name}</h4>
<Button
variant="ghost"
size="sm"
onClick={() => {
setSelectedTable(table.table_name);
loadTableColumns(table.table_name);
setQuery(`SELECT * FROM ${table.table_name}`);
}}
>
<Button variant="ghost" size="sm" onClick={() => setQuery(`SELECT * FROM ${table.table_name}`)}>
</Button>
</div>
{table.description && (
<p className="text-muted-foreground mt-1 text-sm">{table.description}</p>
)}
</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 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 className="text-sm text-muted-foreground"> .</div>
)}
))}
</div>
)}
</div>
</div>
</div>

View File

@ -1,297 +0,0 @@
// 배치 관리 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: '취소됨' },
];
}
}

View File

@ -1,265 +0,0 @@
// 수집 관리 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: '실패' },
];
}
}

View File

@ -7,7 +7,7 @@ export interface ExternalDbConnection {
id?: number;
connection_name: string;
description?: string;
db_type: "mysql" | "postgresql" | "oracle" | "mssql" | "sqlite" | "mariadb";
db_type: "mysql" | "postgresql" | "oracle" | "mssql" | "sqlite";
host: string;
port: number;
database_name: string;
@ -205,11 +205,10 @@ export class ExternalDbConnectionAPI {
/**
* (ID )
*/
static async testConnection(connectionId: number, password?: string): Promise<ConnectionTestResult> {
static async testConnection(connectionId: number): Promise<ConnectionTestResult> {
try {
const response = await apiClient.post<ApiResponse<ConnectionTestResult>>(
`${this.BASE_PATH}/${connectionId}/test`,
password ? { password } : undefined
`${this.BASE_PATH}/${connectionId}/test`
);
if (!response.data.success) {
@ -256,20 +255,6 @@ 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 });

921
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,9 +1,7 @@
{
"dependencies": {
"@prisma/client": "^6.16.2",
"@types/mssql": "^9.1.8",
"axios": "^1.12.2",
"mssql": "^11.0.1",
"prisma": "^6.16.2"
}
}