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:
commit
d729d299a9
|
|
@ -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"];
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
}
|
}
|
||||||
: {}),
|
: {}),
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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}>
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue