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

Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/273
This commit is contained in:
hyeonsu 2025-12-11 10:50:02 +09:00
commit d729d299a9
5 changed files with 211 additions and 25 deletions

View File

@ -702,6 +702,15 @@ export class DashboardController {
requestConfig.data = body; requestConfig.data = body;
} }
// 디버깅 로그: 실제 요청 정보 출력
logger.info(`[fetchExternalApi] 요청 정보:`, {
url: requestConfig.url,
method: requestConfig.method,
headers: requestConfig.headers,
body: requestConfig.data,
externalConnectionId,
});
// TLS 인증서 검증 예외 처리 (thiratis.com 등 내부망/레거시 API 대응) // TLS 인증서 검증 예외 처리 (thiratis.com 등 내부망/레거시 API 대응)
// ExternalRestApiConnectionService와 동일한 로직 적용 // ExternalRestApiConnectionService와 동일한 로직 적용
const bypassDomains = ["thiratis.com"]; const bypassDomains = ["thiratis.com"];

View File

@ -296,13 +296,20 @@ export function WidgetConfigSidebar({ element, isOpen, onClose, onApply }: Widge
element.subtype === "custom-metric-v2" || element.subtype === "custom-metric-v2" ||
element.subtype === "risk-alert-v2"; element.subtype === "risk-alert-v2";
// 리스트 위젯이 단일 데이터 소스 UI를 사용하는 경우, dataSource를 dataSources로 변환
let finalDataSources = dataSources;
if (isMultiDataSourceWidget && element.subtype === "list-v2" && dataSources.length === 0 && dataSource.endpoint) {
// 단일 데이터 소스가 설정되어 있으면 dataSources 배열로 변환
finalDataSources = [dataSource];
}
// chartConfig 구성 (위젯 타입별로 다르게 처리) // chartConfig 구성 (위젯 타입별로 다르게 처리)
let finalChartConfig = { ...chartConfig }; let finalChartConfig = { ...chartConfig };
if (isMultiDataSourceWidget) { if (isMultiDataSourceWidget) {
finalChartConfig = { finalChartConfig = {
...finalChartConfig, ...finalChartConfig,
dataSources: dataSources, dataSources: finalDataSources,
}; };
} }
@ -325,7 +332,7 @@ export function WidgetConfigSidebar({ element, isOpen, onClose, onApply }: Widge
// 다중 데이터 소스 위젯은 dataSources도 포함 (빈 배열도 허용 - 연결 해제) // 다중 데이터 소스 위젯은 dataSources도 포함 (빈 배열도 허용 - 연결 해제)
...(isMultiDataSourceWidget ...(isMultiDataSourceWidget
? { ? {
dataSources: dataSources, dataSources: finalDataSources,
} }
: {}), : {}),
} }

View File

@ -137,9 +137,18 @@ export function ApiConfig({ dataSource, onChange, onTestResult }: ApiConfigProps
} }
updates.type = "api"; // ⭐ 중요: type을 api로 명시 updates.type = "api"; // ⭐ 중요: type을 api로 명시
updates.method = "GET"; // 기본 메서드 updates.method = (connection.default_method as "GET" | "POST" | "PUT" | "PATCH" | "DELETE") || "GET"; // 커넥션에 설정된 메서드 사용
updates.headers = headers; updates.headers = headers;
updates.queryParams = queryParams; updates.queryParams = queryParams;
// Request Body가 있으면 적용
if (connection.default_body) {
updates.body = connection.default_body;
}
// 외부 커넥션 ID 저장 (백엔드에서 인증 정보 조회용)
updates.externalConnectionId = connection.id;
console.log("최종 업데이트:", updates); console.log("최종 업데이트:", updates);
onChange(updates); onChange(updates);
@ -254,6 +263,19 @@ export function ApiConfig({ dataSource, onChange, onTestResult }: ApiConfigProps
} }
}); });
// 요청 메서드 결정
const requestMethod = dataSource.method || "GET";
// Request Body 파싱 (POST, PUT, PATCH인 경우)
let requestBody: any = undefined;
if (["POST", "PUT", "PATCH"].includes(requestMethod) && dataSource.body) {
try {
requestBody = JSON.parse(dataSource.body);
} catch {
throw new Error("Request Body가 올바른 JSON 형식이 아닙니다");
}
}
// 백엔드 프록시를 통한 외부 API 호출 (CORS 우회) // 백엔드 프록시를 통한 외부 API 호출 (CORS 우회)
const response = await fetch(getApiUrl("/api/dashboards/fetch-external-api"), { const response = await fetch(getApiUrl("/api/dashboards/fetch-external-api"), {
method: "POST", method: "POST",
@ -262,9 +284,11 @@ export function ApiConfig({ dataSource, onChange, onTestResult }: ApiConfigProps
}, },
body: JSON.stringify({ body: JSON.stringify({
url: dataSource.endpoint, url: dataSource.endpoint,
method: "GET", method: requestMethod,
headers: headers, headers: headers,
queryParams: params, queryParams: params,
body: requestBody,
externalConnectionId: dataSource.externalConnectionId, // DB 토큰 등 인증 정보 조회용
}), }),
}); });
@ -314,10 +338,23 @@ export function ApiConfig({ dataSource, onChange, onTestResult }: ApiConfigProps
if (dataSource.jsonPath) { if (dataSource.jsonPath) {
const paths = dataSource.jsonPath.split("."); const paths = dataSource.jsonPath.split(".");
for (const path of paths) { for (const path of paths) {
if (data && typeof data === "object" && path in data) { // 배열인 경우 인덱스 접근, 객체인 경우 키 접근
data = data[path]; if (data === null || data === undefined) {
throw new Error(`JSON Path "${dataSource.jsonPath}"에서 데이터를 찾을 수 없습니다 (null/undefined)`);
}
if (Array.isArray(data)) {
// 배열인 경우 숫자 인덱스로 접근 시도
const index = parseInt(path);
if (!isNaN(index) && index >= 0 && index < data.length) {
data = data[index];
} else {
throw new Error(`JSON Path "${dataSource.jsonPath}"에서 배열 인덱스 "${path}"를 찾을 수 없습니다`);
}
} else if (typeof data === "object" && path in data) {
data = (data as Record<string, any>)[path];
} else { } else {
throw new Error(`JSON Path "${dataSource.jsonPath}"에서 데이터를 찾을 수 없습니다`); throw new Error(`JSON Path "${dataSource.jsonPath}"에서 "${path}" 키를 찾을 수 없습니다`);
} }
} }
} }
@ -331,6 +368,16 @@ export function ApiConfig({ dataSource, onChange, onTestResult }: ApiConfigProps
// 컬럼 추출 및 타입 분석 // 컬럼 추출 및 타입 분석
const firstRow = rows[0]; const firstRow = rows[0];
// firstRow가 null이거나 객체가 아닌 경우 처리
if (firstRow === null || firstRow === undefined) {
throw new Error("API 응답의 첫 번째 행이 비어있습니다");
}
if (typeof firstRow !== "object" || Array.isArray(firstRow)) {
throw new Error("API 응답 데이터가 올바른 객체 형식이 아닙니다");
}
const columns = Object.keys(firstRow); const columns = Object.keys(firstRow);
// 각 컬럼의 타입 분석 // 각 컬럼의 타입 분석
@ -400,21 +447,54 @@ export function ApiConfig({ dataSource, onChange, onTestResult }: ApiConfigProps
<p className="text-[11px] text-muted-foreground"> REST API </p> <p className="text-[11px] text-muted-foreground"> REST API </p>
</div> </div>
{/* API URL */} {/* HTTP 메서드 및 API URL */}
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label className="text-xs font-medium text-foreground">API URL *</Label> <Label className="text-xs font-medium text-foreground">API URL *</Label>
<Input <div className="flex gap-2">
type="url" <Select
placeholder="https://api.example.com/data 또는 /api/typ01/url/wrn_now_data.php" value={dataSource.method || "GET"}
value={dataSource.endpoint || ""} onValueChange={(value) => onChange({ method: value as "GET" | "POST" | "PUT" | "PATCH" | "DELETE" })}
onChange={(e) => onChange({ endpoint: e.target.value })} >
className="h-8 text-xs" <SelectTrigger className="h-8 w-24 text-xs">
/> <SelectValue placeholder="GET" />
</SelectTrigger>
<SelectContent className="z-[9999]">
<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="PATCH" className="text-xs">PATCH</SelectItem>
<SelectItem value="DELETE" className="text-xs">DELETE</SelectItem>
</SelectContent>
</Select>
<Input
type="url"
placeholder="https://api.example.com/data"
value={dataSource.endpoint || ""}
onChange={(e) => onChange({ endpoint: e.target.value })}
className="h-8 flex-1 text-xs"
/>
</div>
<p className="text-[11px] text-muted-foreground"> <p className="text-[11px] text-muted-foreground">
URL base_url ( base_url ) URL base_url ( base_url )
</p> </p>
</div> </div>
{/* Request Body (POST, PUT, PATCH인 경우) */}
{["POST", "PUT", "PATCH"].includes(dataSource.method || "") && (
<div className="space-y-1.5">
<Label className="text-xs font-medium text-foreground">Request Body (JSON)</Label>
<textarea
placeholder='{"key": "value"}'
value={dataSource.body || ""}
onChange={(e) => onChange({ body: e.target.value })}
className="h-24 w-full rounded-md border border-input bg-background px-3 py-2 font-mono text-xs ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
/>
<p className="text-[11px] text-muted-foreground">
JSON
</p>
</div>
)}
{/* 쿼리 파라미터 */} {/* 쿼리 파라미터 */}
<div className="space-y-2"> <div className="space-y-2">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
@ -544,6 +624,30 @@ export function ApiConfig({ dataSource, onChange, onTestResult }: ApiConfigProps
</p> </p>
</div> </div>
{/* 자동 새로고침 (HTTP Polling) */}
<div className="space-y-1.5">
<Label className="text-xs font-medium text-foreground"> </Label>
<Select
value={(dataSource.refreshInterval || 0).toString()}
onValueChange={(value) => onChange({ refreshInterval: parseInt(value) })}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="간격 선택" />
</SelectTrigger>
<SelectContent className="z-[9999]">
<SelectItem value="0" className="text-xs"> ()</SelectItem>
<SelectItem value="5" className="text-xs">5</SelectItem>
<SelectItem value="10" className="text-xs">10</SelectItem>
<SelectItem value="30" className="text-xs">30</SelectItem>
<SelectItem value="60" className="text-xs">1</SelectItem>
<SelectItem value="300" className="text-xs">5</SelectItem>
</SelectContent>
</Select>
<p className="text-[11px] text-muted-foreground">
API를
</p>
</div>
{/* 테스트 버튼 */} {/* 테스트 버튼 */}
<div className="flex justify-end"> <div className="flex justify-end">
<Button onClick={testApi} disabled={!dataSource.endpoint || testing}> <Button onClick={testApi} disabled={!dataSource.endpoint || testing}>

View File

@ -285,16 +285,46 @@ export function ListWidget({ element, onConfigUpdate }: ListWidgetProps) {
}); });
} }
// 요청 메서드 (기본값: GET)
const requestMethod = element.dataSource.method || "GET";
// 요청 body (POST, PUT, PATCH인 경우)
let requestBody = undefined;
if (["POST", "PUT", "PATCH"].includes(requestMethod) && element.dataSource.body) {
try {
requestBody = typeof element.dataSource.body === "string"
? JSON.parse(element.dataSource.body)
: element.dataSource.body;
} catch {
requestBody = element.dataSource.body;
}
}
// headers를 KeyValuePair[] 에서 객체로 변환
const headersObj: Record<string, string> = {};
if (element.dataSource.headers && Array.isArray(element.dataSource.headers)) {
element.dataSource.headers.forEach((h: any) => {
if (h.key && h.value) {
headersObj[h.key] = h.value;
}
});
} else if (element.dataSource.headers && typeof element.dataSource.headers === "object") {
Object.assign(headersObj, element.dataSource.headers);
}
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: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
}, },
credentials: "include",
body: JSON.stringify({ body: JSON.stringify({
url: element.dataSource.endpoint, url: element.dataSource.endpoint,
method: "GET", method: requestMethod,
headers: element.dataSource.headers || {}, headers: headersObj,
queryParams: Object.fromEntries(params), queryParams: Object.fromEntries(params),
body: requestBody,
externalConnectionId: element.dataSource.externalConnectionId,
}), }),
}); });

View File

@ -316,12 +316,14 @@ export function ListTestWidget({ element }: ListTestWidgetProps) {
// 다중 데이터 소스 로딩 // 다중 데이터 소스 로딩
const loadMultipleDataSources = useCallback(async () => { const loadMultipleDataSources = useCallback(async () => {
console.log("[ListTestWidget] dataSources:", dataSources);
if (!dataSources || dataSources.length === 0) { if (!dataSources || dataSources.length === 0) {
// console.log("⚠️ 데이터 소스가 없습니다."); console.log("[ListTestWidget] 데이터 소스가 없습니다.");
return; return;
} }
// console.log(`🔄 ${dataSources.length}개의 데이터 소스 로딩 시작...`); console.log(`[ListTestWidget] ${dataSources.length}개의 데이터 소스 로딩 시작...`, dataSources[0]);
setIsLoading(true); setIsLoading(true);
setError(null); setError(null);
@ -412,18 +414,52 @@ export function ListTestWidget({ element }: ListTestWidgetProps) {
}); });
} }
// 요청 메서드 (기본값: GET)
const requestMethod = source.method || "GET";
// 요청 body (POST, PUT, PATCH인 경우)
let requestBody = undefined;
if (["POST", "PUT", "PATCH"].includes(requestMethod) && source.body) {
try {
// body가 문자열이면 JSON 파싱 시도
requestBody = typeof source.body === "string" ? JSON.parse(source.body) : source.body;
} catch {
// 파싱 실패하면 문자열 그대로 사용
requestBody = source.body;
}
}
// headers를 KeyValuePair[] 에서 객체로 변환
const headersObj: Record<string, string> = {};
if (source.headers && Array.isArray(source.headers)) {
source.headers.forEach((h: any) => {
if (h.key && h.value) {
headersObj[h.key] = h.value;
}
});
} else if (source.headers && typeof source.headers === "object") {
// 이미 객체인 경우 그대로 사용
Object.assign(headersObj, source.headers);
}
const requestPayload = {
url: source.endpoint,
method: requestMethod,
headers: headersObj,
queryParams: Object.fromEntries(params),
body: requestBody,
externalConnectionId: source.externalConnectionId,
};
console.log("[ListTestWidget] API 요청:", requestPayload);
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: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
}, },
credentials: "include", credentials: "include",
body: JSON.stringify({ body: JSON.stringify(requestPayload),
url: source.endpoint,
method: "GET",
headers: source.headers || {},
queryParams: Object.fromEntries(params),
}),
}); });
if (!response.ok) { if (!response.ok) {