Merge pull request 'common/feat/dashboard-map' (#229) from common/feat/dashboard-map into main

Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/229
This commit is contained in:
hyeonsu 2025-12-01 11:51:55 +09:00
commit 655eead3b6
12 changed files with 382 additions and 177 deletions

View File

@ -282,7 +282,7 @@ app.listen(PORT, HOST, async () => {
// 배치 스케줄러 초기화 // 배치 스케줄러 초기화
try { try {
await BatchSchedulerService.initialize(); await BatchSchedulerService.initializeScheduler();
logger.info(`⏰ 배치 스케줄러가 시작되었습니다.`); logger.info(`⏰ 배치 스케줄러가 시작되었습니다.`);
} catch (error) { } catch (error) {
logger.error(`❌ 배치 스케줄러 초기화 실패:`, error); logger.error(`❌ 배치 스케줄러 초기화 실패:`, error);

View File

@ -1,4 +1,7 @@
import { Response } from "express"; import { Response } from "express";
import https from "https";
import axios, { AxiosRequestConfig } from "axios";
import { logger } from "../utils/logger";
import { AuthenticatedRequest } from "../middleware/authMiddleware"; import { AuthenticatedRequest } from "../middleware/authMiddleware";
import { DashboardService } from "../services/DashboardService"; import { DashboardService } from "../services/DashboardService";
import { import {
@ -7,6 +10,7 @@ import {
DashboardListQuery, DashboardListQuery,
} from "../types/dashboard"; } from "../types/dashboard";
import { PostgreSQLService } from "../database/PostgreSQLService"; import { PostgreSQLService } from "../database/PostgreSQLService";
import { ExternalRestApiConnectionService } from "../services/externalRestApiConnectionService";
/** /**
* *
@ -415,7 +419,7 @@ export class DashboardController {
limit: Math.min(parseInt(req.query.limit as string) || 20, 100), limit: Math.min(parseInt(req.query.limit as string) || 20, 100),
search: req.query.search as string, search: req.query.search as string,
category: req.query.category as string, category: req.query.category as string,
createdBy: userId, // 본인이 만든 대시보드만 // createdBy 제거 - 회사 대시보드 전체 표시
}; };
const result = await DashboardService.getDashboards( const result = await DashboardService.getDashboards(
@ -590,7 +594,14 @@ export class DashboardController {
res: Response res: Response
): Promise<void> { ): Promise<void> {
try { try {
const { url, method = "GET", headers = {}, queryParams = {} } = req.body; const {
url,
method = "GET",
headers = {},
queryParams = {},
body,
externalConnectionId, // 프론트엔드에서 선택된 커넥션 ID를 전달받아야 함
} = req.body;
if (!url || typeof url !== "string") { if (!url || typeof url !== "string") {
res.status(400).json({ res.status(400).json({
@ -608,85 +619,131 @@ export class DashboardController {
} }
}); });
// 외부 API 호출 (타임아웃 30초) // Axios 요청 설정
// @ts-ignore - node-fetch dynamic import const requestConfig: AxiosRequestConfig = {
const fetch = (await import("node-fetch")).default; url: urlObj.toString(),
method: method.toUpperCase(),
// 타임아웃 설정 (Node.js 글로벌 AbortController 사용) headers: {
const controller = new (global as any).AbortController(); "Content-Type": "application/json",
const timeoutId = setTimeout(() => controller.abort(), 60000); // 60초 (기상청 API는 느림) Accept: "application/json",
...headers,
let response; },
try { timeout: 60000, // 60초 타임아웃
response = await fetch(urlObj.toString(), { validateStatus: () => true, // 모든 상태 코드 허용 (에러도 응답으로 처리)
method: method.toUpperCase(), };
headers: {
"Content-Type": "application/json", // 외부 커넥션 ID가 있는 경우, 해당 커넥션의 인증 정보(DB 토큰 등)를 적용
...headers, if (externalConnectionId) {
}, try {
signal: controller.signal, // 사용자 회사 코드가 있으면 사용하고, 없으면 '*' (최고 관리자)로 시도
}); let companyCode = req.user?.companyCode;
clearTimeout(timeoutId);
} catch (err: any) { if (!companyCode) {
clearTimeout(timeoutId); companyCode = "*";
if (err.name === 'AbortError') { }
throw new Error('외부 API 요청 타임아웃 (30초 초과)');
// 커넥션 로드
const connectionResult =
await ExternalRestApiConnectionService.getConnectionById(
Number(externalConnectionId),
companyCode
);
if (connectionResult.success && connectionResult.data) {
const connection = connectionResult.data;
// 인증 헤더 생성 (DB 토큰 등)
const authHeaders =
await ExternalRestApiConnectionService.getAuthHeaders(
connection.auth_type,
connection.auth_config,
connection.company_code
);
// 기존 헤더에 인증 헤더 병합
requestConfig.headers = {
...requestConfig.headers,
...authHeaders,
};
// API Key가 Query Param인 경우 처리
if (
connection.auth_type === "api-key" &&
connection.auth_config?.keyLocation === "query" &&
connection.auth_config?.keyName &&
connection.auth_config?.keyValue
) {
const currentUrl = new URL(requestConfig.url!);
currentUrl.searchParams.append(
connection.auth_config.keyName,
connection.auth_config.keyValue
);
requestConfig.url = currentUrl.toString();
}
}
} catch (connError) {
logger.error(
`외부 커넥션(${externalConnectionId}) 정보 로드 및 인증 적용 실패:`,
connError
);
} }
throw err;
} }
if (!response.ok) { // Body 처리
if (body) {
requestConfig.data = body;
}
// TLS 인증서 검증 예외 처리 (thiratis.com 등 내부망/레거시 API 대응)
// ExternalRestApiConnectionService와 동일한 로직 적용
const bypassDomains = ["thiratis.com"];
const hostname = urlObj.hostname;
const shouldBypassTls = bypassDomains.some((domain) =>
hostname.includes(domain)
);
if (shouldBypassTls) {
requestConfig.httpsAgent = new https.Agent({
rejectUnauthorized: false,
});
}
const response = await axios(requestConfig);
if (response.status >= 400) {
throw new Error( throw new Error(
`외부 API 오류: ${response.status} ${response.statusText}` `외부 API 오류: ${response.status} ${response.statusText}`
); );
} }
// Content-Type에 따라 응답 파싱 let data = response.data;
const contentType = response.headers.get("content-type"); const contentType = response.headers["content-type"];
let data: any;
// 한글 인코딩 처리 (EUC-KR → UTF-8) // 텍스트 응답인 경우 포맷팅
const isKoreanApi = urlObj.hostname.includes('kma.go.kr') || if (typeof data === "string") {
urlObj.hostname.includes('data.go.kr'); data = { text: data, contentType };
if (isKoreanApi) {
// 한국 정부 API는 EUC-KR 인코딩 사용
const buffer = await response.arrayBuffer();
const decoder = new TextDecoder('euc-kr');
const text = decoder.decode(buffer);
try {
data = JSON.parse(text);
} catch {
data = { text, contentType };
}
} else if (contentType && contentType.includes("application/json")) {
data = await response.json();
} else if (contentType && contentType.includes("text/")) {
// 텍스트 응답 (CSV, 일반 텍스트 등)
const text = await response.text();
data = { text, contentType };
} else {
// 기타 응답 (JSON으로 시도)
try {
data = await response.json();
} catch {
const text = await response.text();
data = { text, contentType };
}
} }
res.status(200).json({ res.status(200).json({
success: true, success: true,
data, data,
}); });
} catch (error) { } catch (error: any) {
const status = error.response?.status || 500;
const message = error.response?.statusText || error.message;
logger.error("외부 API 호출 오류:", {
message,
status,
data: error.response?.data,
});
res.status(500).json({ res.status(500).json({
success: false, success: false,
message: "외부 API 호출 중 오류가 발생했습니다.", message: "외부 API 호출 중 오류가 발생했습니다.",
error: error:
process.env.NODE_ENV === "development" process.env.NODE_ENV === "development"
? (error as Error).message ? message
: "외부 API 호출 오류", : "외부 API 호출 오류",
}); });
} }

View File

@ -594,7 +594,7 @@ export class BatchManagementController {
if (result.success && result.data) { if (result.success && result.data) {
// 스케줄러에 자동 등록 ✅ // 스케줄러에 자동 등록 ✅
try { try {
await BatchSchedulerService.scheduleBatchConfig(result.data); await BatchSchedulerService.scheduleBatch(result.data);
console.log( console.log(
`✅ 새로운 배치가 스케줄러에 등록되었습니다: ${batchName} (ID: ${result.data.id})` `✅ 새로운 배치가 스케줄러에 등록되었습니다: ${batchName} (ID: ${result.data.id})`
); );

View File

@ -22,11 +22,19 @@ export const getLayouts = async (
LEFT JOIN user_info u1 ON l.created_by = u1.user_id LEFT JOIN user_info u1 ON l.created_by = u1.user_id
LEFT JOIN user_info u2 ON l.updated_by = u2.user_id LEFT JOIN user_info u2 ON l.updated_by = u2.user_id
LEFT JOIN digital_twin_objects o ON l.id = o.layout_id LEFT JOIN digital_twin_objects o ON l.id = o.layout_id
WHERE l.company_code = $1
`; `;
const params: any[] = [companyCode]; const params: any[] = [];
let paramIndex = 2; let paramIndex = 1;
// 최고 관리자는 모든 레이아웃 조회 가능
if (companyCode && companyCode !== '*') {
query += ` WHERE l.company_code = $${paramIndex}`;
params.push(companyCode);
paramIndex++;
} else {
query += ` WHERE 1=1`;
}
if (externalDbConnectionId) { if (externalDbConnectionId) {
query += ` AND l.external_db_connection_id = $${paramIndex}`; query += ` AND l.external_db_connection_id = $${paramIndex}`;
@ -75,14 +83,27 @@ export const getLayoutById = async (
const companyCode = req.user?.companyCode; const companyCode = req.user?.companyCode;
const { id } = req.params; const { id } = req.params;
// 레이아웃 기본 정보 // 레이아웃 기본 정보 - 최고 관리자는 모든 레이아웃 조회 가능
const layoutQuery = ` let layoutQuery: string;
SELECT l.* let layoutParams: any[];
FROM digital_twin_layout l
WHERE l.id = $1 AND l.company_code = $2
`;
const layoutResult = await pool.query(layoutQuery, [id, companyCode]); if (companyCode && companyCode !== '*') {
layoutQuery = `
SELECT l.*
FROM digital_twin_layout l
WHERE l.id = $1 AND l.company_code = $2
`;
layoutParams = [id, companyCode];
} else {
layoutQuery = `
SELECT l.*
FROM digital_twin_layout l
WHERE l.id = $1
`;
layoutParams = [id];
}
const layoutResult = await pool.query(layoutQuery, layoutParams);
if (layoutResult.rowCount === 0) { if (layoutResult.rowCount === 0) {
return res.status(404).json({ return res.status(404).json({

View File

@ -178,21 +178,24 @@ export class DashboardService {
let params: any[] = []; let params: any[] = [];
let paramIndex = 1; let paramIndex = 1;
// 회사 코드 필터링 (최우선) // 회사 코드 필터링 - company_code가 일치하면 해당 회사 사용자는 모두 조회 가능
if (companyCode) { if (companyCode) {
whereConditions.push(`d.company_code = $${paramIndex}`); if (companyCode === '*') {
params.push(companyCode); // 최고 관리자는 모든 대시보드 조회 가능
paramIndex++; } else {
} whereConditions.push(`d.company_code = $${paramIndex}`);
params.push(companyCode);
// 권한 필터링 paramIndex++;
if (userId) { }
} else if (userId) {
// 회사 코드 없이 userId만 있는 경우 (본인 생성 또는 공개)
whereConditions.push( whereConditions.push(
`(d.created_by = $${paramIndex} OR d.is_public = true)` `(d.created_by = $${paramIndex} OR d.is_public = true)`
); );
params.push(userId); params.push(userId);
paramIndex++; paramIndex++;
} else { } else {
// 비로그인 사용자는 공개 대시보드만
whereConditions.push("d.is_public = true"); whereConditions.push("d.is_public = true");
} }
@ -228,7 +231,7 @@ export class DashboardService {
const whereClause = whereConditions.join(" AND "); const whereClause = whereConditions.join(" AND ");
// 대시보드 목록 조회 (users 테이블 조인 제거) // 대시보드 목록 조회 (user_info 조인하여 생성자 이름 포함)
const dashboardQuery = ` const dashboardQuery = `
SELECT SELECT
d.id, d.id,
@ -242,13 +245,16 @@ export class DashboardService {
d.tags, d.tags,
d.category, d.category,
d.view_count, d.view_count,
d.company_code,
u.user_name as created_by_name,
COUNT(de.id) as elements_count COUNT(de.id) as elements_count
FROM dashboards d FROM dashboards d
LEFT JOIN dashboard_elements de ON d.id = de.dashboard_id LEFT JOIN dashboard_elements de ON d.id = de.dashboard_id
LEFT JOIN user_info u ON d.created_by = u.user_id
WHERE ${whereClause} WHERE ${whereClause}
GROUP BY d.id, d.title, d.description, d.thumbnail_url, d.is_public, GROUP BY d.id, d.title, d.description, d.thumbnail_url, d.is_public,
d.created_by, d.created_at, d.updated_at, d.tags, d.category, d.created_by, d.created_at, d.updated_at, d.tags, d.category,
d.view_count d.view_count, d.company_code, u.user_name
ORDER BY d.updated_at DESC ORDER BY d.updated_at DESC
LIMIT $${paramIndex} OFFSET $${paramIndex + 1} LIMIT $${paramIndex} OFFSET $${paramIndex + 1}
`; `;
@ -277,12 +283,14 @@ export class DashboardService {
thumbnailUrl: row.thumbnail_url, thumbnailUrl: row.thumbnail_url,
isPublic: row.is_public, isPublic: row.is_public,
createdBy: row.created_by, createdBy: row.created_by,
createdByName: row.created_by_name || row.created_by,
createdAt: row.created_at, createdAt: row.created_at,
updatedAt: row.updated_at, updatedAt: row.updated_at,
tags: JSON.parse(row.tags || "[]"), tags: JSON.parse(row.tags || "[]"),
category: row.category, category: row.category,
viewCount: parseInt(row.view_count || "0"), viewCount: parseInt(row.view_count || "0"),
elementsCount: parseInt(row.elements_count || "0"), elementsCount: parseInt(row.elements_count || "0"),
companyCode: row.company_code,
})), })),
pagination: { pagination: {
page, page,

View File

@ -124,6 +124,14 @@ export class BatchSchedulerService {
try { try {
logger.info(`배치 실행 시작: ${config.batch_name} (ID: ${config.id})`); logger.info(`배치 실행 시작: ${config.batch_name} (ID: ${config.id})`);
// 매핑 정보가 없으면 상세 조회로 다시 가져오기
if (!config.batch_mappings || config.batch_mappings.length === 0) {
const fullConfig = await BatchService.getBatchConfigById(config.id);
if (fullConfig.success && fullConfig.data) {
config = fullConfig.data;
}
}
// 실행 로그 생성 // 실행 로그 생성
const executionLogResponse = const executionLogResponse =
await BatchExecutionLogService.createExecutionLog({ await BatchExecutionLogService.createExecutionLog({

View File

@ -474,6 +474,105 @@ export class ExternalRestApiConnectionService {
} }
} }
/**
*
*/
static async getAuthHeaders(
authType: AuthType,
authConfig: any,
companyCode?: string
): Promise<Record<string, string>> {
const headers: Record<string, string> = {};
if (authType === "db-token") {
const cfg = authConfig || {};
const {
dbTableName,
dbValueColumn,
dbWhereColumn,
dbWhereValue,
dbHeaderName,
dbHeaderTemplate,
} = cfg;
if (!dbTableName || !dbValueColumn) {
throw new Error("DB 토큰 설정이 올바르지 않습니다.");
}
if (!companyCode) {
throw new Error("DB 토큰 모드에서는 회사 코드가 필요합니다.");
}
const hasWhereColumn = !!dbWhereColumn;
const hasWhereValue =
dbWhereValue !== undefined &&
dbWhereValue !== null &&
dbWhereValue !== "";
// where 컬럼/값은 둘 다 비우거나 둘 다 채워야 함
if (hasWhereColumn !== hasWhereValue) {
throw new Error(
"DB 토큰 설정에서 조건 컬럼과 조건 값은 둘 다 비우거나 둘 다 입력해야 합니다."
);
}
// 식별자 검증 (간단한 화이트리스트)
const identifierRegex = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
if (
!identifierRegex.test(dbTableName) ||
!identifierRegex.test(dbValueColumn) ||
(hasWhereColumn && !identifierRegex.test(dbWhereColumn as string))
) {
throw new Error(
"DB 토큰 설정에 유효하지 않은 테이블 또는 컬럼명이 포함되어 있습니다."
);
}
let sql = `
SELECT ${dbValueColumn} AS token_value
FROM ${dbTableName}
WHERE company_code = $1
`;
const params: any[] = [companyCode];
if (hasWhereColumn && hasWhereValue) {
sql += ` AND ${dbWhereColumn} = $2`;
params.push(dbWhereValue);
}
sql += `
ORDER BY updated_date DESC
LIMIT 1
`;
const tokenResult: QueryResult<any> = await pool.query(sql, params);
if (tokenResult.rowCount === 0) {
throw new Error("DB에서 토큰을 찾을 수 없습니다.");
}
const tokenValue = tokenResult.rows[0]["token_value"];
const headerName = dbHeaderName || "Authorization";
const template = dbHeaderTemplate || "Bearer {{value}}";
headers[headerName] = template.replace("{{value}}", tokenValue);
} else if (authType === "bearer" && authConfig?.token) {
headers["Authorization"] = `Bearer ${authConfig.token}`;
} else if (authType === "basic" && authConfig) {
const credentials = Buffer.from(
`${authConfig.username}:${authConfig.password}`
).toString("base64");
headers["Authorization"] = `Basic ${credentials}`;
} else if (authType === "api-key" && authConfig) {
if (authConfig.keyLocation === "header") {
headers[authConfig.keyName] = authConfig.keyValue;
}
}
return headers;
}
/** /**
* REST API ( ) * REST API ( )
*/ */
@ -485,99 +584,15 @@ export class ExternalRestApiConnectionService {
try { try {
// 헤더 구성 // 헤더 구성
const headers = { ...testRequest.headers }; let headers = { ...testRequest.headers };
// 인증 헤더 추가 // 인증 헤더 생성 및 병합
if (testRequest.auth_type === "db-token") { const authHeaders = await this.getAuthHeaders(
const cfg = testRequest.auth_config || {}; testRequest.auth_type,
const { testRequest.auth_config,
dbTableName, userCompanyCode
dbValueColumn, );
dbWhereColumn, headers = { ...headers, ...authHeaders };
dbWhereValue,
dbHeaderName,
dbHeaderTemplate,
} = cfg;
if (!dbTableName || !dbValueColumn) {
throw new Error("DB 토큰 설정이 올바르지 않습니다.");
}
if (!userCompanyCode) {
throw new Error("DB 토큰 모드에서는 회사 코드가 필요합니다.");
}
const hasWhereColumn = !!dbWhereColumn;
const hasWhereValue =
dbWhereValue !== undefined && dbWhereValue !== null && dbWhereValue !== "";
// where 컬럼/값은 둘 다 비우거나 둘 다 채워야 함
if (hasWhereColumn !== hasWhereValue) {
throw new Error(
"DB 토큰 설정에서 조건 컬럼과 조건 값은 둘 다 비우거나 둘 다 입력해야 합니다."
);
}
// 식별자 검증 (간단한 화이트리스트)
const identifierRegex = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
if (
!identifierRegex.test(dbTableName) ||
!identifierRegex.test(dbValueColumn) ||
(hasWhereColumn && !identifierRegex.test(dbWhereColumn as string))
) {
throw new Error(
"DB 토큰 설정에 유효하지 않은 테이블 또는 컬럼명이 포함되어 있습니다."
);
}
let sql = `
SELECT ${dbValueColumn} AS token_value
FROM ${dbTableName}
WHERE company_code = $1
`;
const params: any[] = [userCompanyCode];
if (hasWhereColumn && hasWhereValue) {
sql += ` AND ${dbWhereColumn} = $2`;
params.push(dbWhereValue);
}
sql += `
ORDER BY updated_date DESC
LIMIT 1
`;
const tokenResult: QueryResult<any> = await pool.query(sql, params);
if (tokenResult.rowCount === 0) {
throw new Error("DB에서 토큰을 찾을 수 없습니다.");
}
const tokenValue = tokenResult.rows[0]["token_value"];
const headerName = dbHeaderName || "Authorization";
const template = dbHeaderTemplate || "Bearer {{value}}";
headers[headerName] = template.replace("{{value}}", tokenValue);
} else if (
testRequest.auth_type === "bearer" &&
testRequest.auth_config?.token
) {
headers["Authorization"] = `Bearer ${testRequest.auth_config.token}`;
} else if (testRequest.auth_type === "basic" && testRequest.auth_config) {
const credentials = Buffer.from(
`${testRequest.auth_config.username}:${testRequest.auth_config.password}`
).toString("base64");
headers["Authorization"] = `Basic ${credentials}`;
} else if (
testRequest.auth_type === "api-key" &&
testRequest.auth_config
) {
if (testRequest.auth_config.keyLocation === "header") {
headers[testRequest.auth_config.keyName] =
testRequest.auth_config.keyValue;
}
}
// URL 구성 // URL 구성
let url = testRequest.base_url; let url = testRequest.base_url;

View File

@ -195,6 +195,7 @@ export default function DashboardListClient() {
<TableRow className="bg-muted/50 hover:bg-muted/50 border-b"> <TableRow className="bg-muted/50 hover:bg-muted/50 border-b">
<TableHead className="h-12 text-sm font-semibold"></TableHead> <TableHead className="h-12 text-sm font-semibold"></TableHead>
<TableHead className="h-12 text-sm font-semibold"></TableHead> <TableHead className="h-12 text-sm font-semibold"></TableHead>
<TableHead className="h-12 text-sm font-semibold"></TableHead>
<TableHead className="h-12 text-sm font-semibold"></TableHead> <TableHead className="h-12 text-sm font-semibold"></TableHead>
<TableHead className="h-12 text-sm font-semibold"></TableHead> <TableHead className="h-12 text-sm font-semibold"></TableHead>
<TableHead className="h-12 text-right text-sm font-semibold"></TableHead> <TableHead className="h-12 text-right text-sm font-semibold"></TableHead>
@ -209,6 +210,9 @@ export default function DashboardListClient() {
<TableCell className="h-16"> <TableCell className="h-16">
<div className="bg-muted h-4 animate-pulse rounded"></div> <div className="bg-muted h-4 animate-pulse rounded"></div>
</TableCell> </TableCell>
<TableCell className="h-16">
<div className="bg-muted h-4 w-20 animate-pulse rounded"></div>
</TableCell>
<TableCell className="h-16"> <TableCell className="h-16">
<div className="bg-muted h-4 w-24 animate-pulse rounded"></div> <div className="bg-muted h-4 w-24 animate-pulse rounded"></div>
</TableCell> </TableCell>
@ -277,6 +281,7 @@ export default function DashboardListClient() {
<TableRow className="bg-muted/50 hover:bg-muted/50 border-b"> <TableRow className="bg-muted/50 hover:bg-muted/50 border-b">
<TableHead className="h-12 text-sm font-semibold"></TableHead> <TableHead className="h-12 text-sm font-semibold"></TableHead>
<TableHead className="h-12 text-sm font-semibold"></TableHead> <TableHead className="h-12 text-sm font-semibold"></TableHead>
<TableHead className="h-12 text-sm font-semibold"></TableHead>
<TableHead className="h-12 text-sm font-semibold"></TableHead> <TableHead className="h-12 text-sm font-semibold"></TableHead>
<TableHead className="h-12 text-sm font-semibold"></TableHead> <TableHead className="h-12 text-sm font-semibold"></TableHead>
<TableHead className="h-12 text-right text-sm font-semibold"></TableHead> <TableHead className="h-12 text-right text-sm font-semibold"></TableHead>
@ -296,6 +301,9 @@ export default function DashboardListClient() {
<TableCell className="text-muted-foreground h-16 max-w-md truncate text-sm"> <TableCell className="text-muted-foreground h-16 max-w-md truncate text-sm">
{dashboard.description || "-"} {dashboard.description || "-"}
</TableCell> </TableCell>
<TableCell className="text-muted-foreground h-16 text-sm">
{dashboard.createdByName || dashboard.createdBy || "-"}
</TableCell>
<TableCell className="text-muted-foreground h-16 text-sm"> <TableCell className="text-muted-foreground h-16 text-sm">
{formatDate(dashboard.createdAt)} {formatDate(dashboard.createdAt)}
</TableCell> </TableCell>
@ -363,6 +371,10 @@ export default function DashboardListClient() {
<span className="text-muted-foreground"></span> <span className="text-muted-foreground"></span>
<span className="max-w-[200px] truncate font-medium">{dashboard.description || "-"}</span> <span className="max-w-[200px] truncate font-medium">{dashboard.description || "-"}</span>
</div> </div>
<div className="flex justify-between text-sm">
<span className="text-muted-foreground"></span>
<span className="font-medium">{dashboard.createdByName || dashboard.createdBy || "-"}</span>
</div>
<div className="flex justify-between text-sm"> <div className="flex justify-between text-sm">
<span className="text-muted-foreground"></span> <span className="text-muted-foreground"></span>
<span className="font-medium">{formatDate(dashboard.createdAt)}</span> <span className="font-medium">{formatDate(dashboard.createdAt)}</span>

View File

@ -6,6 +6,7 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { Plus, Trash2, Loader2, CheckCircle, XCircle } from "lucide-react"; import { Plus, Trash2, Loader2, CheckCircle, XCircle } from "lucide-react";
import { ExternalDbConnectionAPI, ExternalApiConnection } from "@/lib/api/externalDbConnection"; import { ExternalDbConnectionAPI, ExternalApiConnection } from "@/lib/api/externalDbConnection";
import { getApiUrl } from "@/lib/utils/apiUrl"; import { getApiUrl } from "@/lib/utils/apiUrl";
@ -20,7 +21,7 @@ export default function MultiApiConfig({ dataSource, onChange, onTestResult }: M
const [testing, setTesting] = useState(false); const [testing, setTesting] = useState(false);
const [testResult, setTestResult] = useState<{ success: boolean; message: string } | null>(null); const [testResult, setTestResult] = useState<{ success: boolean; message: string } | null>(null);
const [apiConnections, setApiConnections] = useState<ExternalApiConnection[]>([]); const [apiConnections, setApiConnections] = useState<ExternalApiConnection[]>([]);
const [selectedConnectionId, setSelectedConnectionId] = useState<string>(""); const [selectedConnectionId, setSelectedConnectionId] = useState<string>(dataSource.externalConnectionId || "");
const [availableColumns, setAvailableColumns] = useState<string[]>([]); // API 테스트 후 발견된 컬럼 목록 const [availableColumns, setAvailableColumns] = useState<string[]>([]); // API 테스트 후 발견된 컬럼 목록
const [columnTypes, setColumnTypes] = useState<Record<string, string>>({}); // 컬럼 타입 정보 const [columnTypes, setColumnTypes] = useState<Record<string, string>>({}); // 컬럼 타입 정보
const [sampleData, setSampleData] = useState<any[]>([]); // 샘플 데이터 (최대 3개) const [sampleData, setSampleData] = useState<any[]>([]); // 샘플 데이터 (최대 3개)
@ -35,6 +36,13 @@ export default function MultiApiConfig({ dataSource, onChange, onTestResult }: M
loadApiConnections(); loadApiConnections();
}, []); }, []);
// dataSource.externalConnectionId가 변경되면 selectedConnectionId 업데이트
useEffect(() => {
if (dataSource.externalConnectionId) {
setSelectedConnectionId(dataSource.externalConnectionId);
}
}, [dataSource.externalConnectionId]);
// 외부 커넥션 선택 핸들러 // 외부 커넥션 선택 핸들러
const handleConnectionSelect = async (connectionId: string) => { const handleConnectionSelect = async (connectionId: string) => {
setSelectedConnectionId(connectionId); setSelectedConnectionId(connectionId);
@ -58,11 +66,20 @@ export default function MultiApiConfig({ dataSource, onChange, onTestResult }: M
const updates: Partial<ChartDataSource> = { const updates: Partial<ChartDataSource> = {
endpoint: fullEndpoint, endpoint: fullEndpoint,
externalConnectionId: connectionId, // 외부 연결 ID 저장
}; };
const headers: KeyValuePair[] = []; const headers: KeyValuePair[] = [];
const queryParams: KeyValuePair[] = []; const queryParams: KeyValuePair[] = [];
// 기본 메서드/바디가 있으면 적용
if (connection.default_method) {
updates.method = connection.default_method as ChartDataSource["method"];
}
if (connection.default_body) {
updates.body = connection.default_body;
}
// 기본 헤더가 있으면 적용 // 기본 헤더가 있으면 적용
if (connection.default_headers && Object.keys(connection.default_headers).length > 0) { if (connection.default_headers && Object.keys(connection.default_headers).length > 0) {
Object.entries(connection.default_headers).forEach(([key, value]) => { Object.entries(connection.default_headers).forEach(([key, value]) => {
@ -210,6 +227,11 @@ export default function MultiApiConfig({ dataSource, onChange, onTestResult }: M
} }
}); });
const bodyPayload =
dataSource.body && dataSource.body.trim().length > 0
? dataSource.body
: undefined;
const response = await fetch(getApiUrl("/api/dashboards/fetch-external-api"), { const response = await fetch(getApiUrl("/api/dashboards/fetch-external-api"), {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
@ -219,6 +241,8 @@ export default function MultiApiConfig({ dataSource, onChange, onTestResult }: M
method: dataSource.method || "GET", method: dataSource.method || "GET",
headers, headers,
queryParams, queryParams,
body: bodyPayload,
externalConnectionId: dataSource.externalConnectionId, // 외부 연결 ID 전달
}), }),
}); });
@ -415,6 +439,58 @@ export default function MultiApiConfig({ dataSource, onChange, onTestResult }: M
</p> </p>
</div> </div>
{/* HTTP 메서드 */}
<div className="space-y-2">
<Label className="text-xs">HTTP </Label>
<Select
value={dataSource.method || "GET"}
onValueChange={(value) =>
onChange({
method: value as ChartDataSource["method"],
})
}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="GET" className="text-xs">
GET
</SelectItem>
<SelectItem value="POST" className="text-xs">
POST
</SelectItem>
<SelectItem value="PUT" className="text-xs">
PUT
</SelectItem>
<SelectItem value="DELETE" className="text-xs">
DELETE
</SelectItem>
<SelectItem value="PATCH" className="text-xs">
PATCH
</SelectItem>
</SelectContent>
</Select>
</div>
{/* Request Body (POST/PUT/PATCH 일 때만) */}
{(dataSource.method === "POST" ||
dataSource.method === "PUT" ||
dataSource.method === "PATCH") && (
<div className="space-y-2">
<Label className="text-xs">Request Body ()</Label>
<Textarea
value={dataSource.body || ""}
onChange={(e) => onChange({ body: e.target.value })}
placeholder='{"key": "value"} 또는 원시 페이로드를 그대로 입력하세요'
className="h-24 text-xs font-mono"
/>
<p className="text-[10px] text-muted-foreground">
API Body로 . JSON이 .
</p>
</div>
)}
{/* JSON Path */} {/* JSON Path */}
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor={`jsonPath-\${dataSource.id}`} className="text-xs"> <Label htmlFor={`jsonPath-\${dataSource.id}`} className="text-xs">

View File

@ -149,7 +149,10 @@ export interface ChartDataSource {
// API 관련 // API 관련
endpoint?: string; // API URL endpoint?: string; // API URL
method?: "GET"; // HTTP 메서드 (GET만 지원) // HTTP 메서드 (기본 GET, POST/PUT/DELETE/PATCH도 지원)
method?: "GET" | "POST" | "PUT" | "DELETE" | "PATCH";
// 요청 Body (옵션) - 문자열 그대로 전송 (JSON 또는 일반 텍스트)
body?: string;
headers?: KeyValuePair[]; // 커스텀 헤더 (배열) headers?: KeyValuePair[]; // 커스텀 헤더 (배열)
queryParams?: KeyValuePair[]; // URL 쿼리 파라미터 (배열) queryParams?: KeyValuePair[]; // URL 쿼리 파라미터 (배열)
jsonPath?: string; // JSON 응답에서 데이터 추출 경로 (예: "data.results") jsonPath?: string; // JSON 응답에서 데이터 추출 경로 (예: "data.results")

View File

@ -90,6 +90,7 @@ export interface Dashboard {
thumbnailUrl?: string; thumbnailUrl?: string;
isPublic: boolean; isPublic: boolean;
createdBy: string; createdBy: string;
createdByName?: string;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
tags?: string[]; tags?: string[];
@ -97,6 +98,7 @@ export interface Dashboard {
viewCount: number; viewCount: number;
elementsCount?: number; elementsCount?: number;
creatorName?: string; creatorName?: string;
companyCode?: string;
elements?: DashboardElement[]; elements?: DashboardElement[];
settings?: { settings?: {
resolution?: string; resolution?: string;

View File

@ -36,6 +36,9 @@ export interface ExternalApiConnection {
base_url: string; base_url: string;
endpoint_path?: string; endpoint_path?: string;
default_headers: Record<string, string>; default_headers: Record<string, string>;
// 기본 HTTP 메서드/바디 (외부 REST API 커넥션과 동일한 필드)
default_method?: string;
default_body?: string;
auth_type: AuthType; auth_type: AuthType;
auth_config?: { auth_config?: {
keyLocation?: "header" | "query"; keyLocation?: "header" | "query";