530 lines
18 KiB
TypeScript
530 lines
18 KiB
TypeScript
"use client";
|
|
|
|
import React, { useState, useEffect } from "react";
|
|
import { ChartDataSource, KeyValuePair } from "@/components/admin/dashboard/types";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Label } from "@/components/ui/label";
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
|
import { Plus, Trash2, Loader2, CheckCircle, XCircle } from "lucide-react";
|
|
import { ExternalDbConnectionAPI, ExternalApiConnection } from "@/lib/api/externalDbConnection";
|
|
|
|
interface MultiApiConfigProps {
|
|
dataSource: ChartDataSource;
|
|
onChange: (updates: Partial<ChartDataSource>) => void;
|
|
onTestResult?: (data: any) => void; // 테스트 결과 데이터 전달
|
|
}
|
|
|
|
export default function MultiApiConfig({ dataSource, onChange, onTestResult }: MultiApiConfigProps) {
|
|
const [testing, setTesting] = useState(false);
|
|
const [testResult, setTestResult] = useState<{ success: boolean; message: string } | null>(null);
|
|
const [apiConnections, setApiConnections] = useState<ExternalApiConnection[]>([]);
|
|
const [selectedConnectionId, setSelectedConnectionId] = useState<string>("");
|
|
|
|
console.log("🔧 MultiApiConfig - dataSource:", dataSource);
|
|
|
|
// 외부 API 커넥션 목록 로드
|
|
useEffect(() => {
|
|
const loadApiConnections = async () => {
|
|
const connections = await ExternalDbConnectionAPI.getApiConnections({ is_active: "Y" });
|
|
setApiConnections(connections);
|
|
};
|
|
loadApiConnections();
|
|
}, []);
|
|
|
|
// 외부 커넥션 선택 핸들러
|
|
const handleConnectionSelect = async (connectionId: string) => {
|
|
setSelectedConnectionId(connectionId);
|
|
|
|
if (!connectionId || connectionId === "manual") {
|
|
return;
|
|
}
|
|
|
|
const connection = await ExternalDbConnectionAPI.getApiConnectionById(Number(connectionId));
|
|
if (!connection) {
|
|
console.error("커넥션을 찾을 수 없습니다:", connectionId);
|
|
return;
|
|
}
|
|
|
|
console.log("불러온 커넥션:", connection);
|
|
|
|
// base_url과 endpoint_path를 조합하여 전체 URL 생성
|
|
const fullEndpoint = connection.endpoint_path
|
|
? `${connection.base_url}${connection.endpoint_path}`
|
|
: connection.base_url;
|
|
|
|
console.log("전체 엔드포인트:", fullEndpoint);
|
|
|
|
const updates: Partial<ChartDataSource> = {
|
|
endpoint: fullEndpoint,
|
|
};
|
|
|
|
const headers: KeyValuePair[] = [];
|
|
const queryParams: KeyValuePair[] = [];
|
|
|
|
// 기본 헤더가 있으면 적용
|
|
if (connection.default_headers && Object.keys(connection.default_headers).length > 0) {
|
|
Object.entries(connection.default_headers).forEach(([key, value]) => {
|
|
headers.push({
|
|
id: `header_${Date.now()}_${Math.random()}`,
|
|
key,
|
|
value,
|
|
});
|
|
});
|
|
console.log("기본 헤더 적용:", headers);
|
|
}
|
|
|
|
// 인증 설정이 있으면 헤더 또는 쿼리 파라미터에 추가
|
|
if (connection.auth_type && connection.auth_type !== "none" && connection.auth_config) {
|
|
const authConfig = connection.auth_config;
|
|
|
|
switch (connection.auth_type) {
|
|
case "api-key":
|
|
if (authConfig.keyLocation === "header" && authConfig.keyName && authConfig.keyValue) {
|
|
headers.push({
|
|
id: `auth_header_${Date.now()}`,
|
|
key: authConfig.keyName,
|
|
value: authConfig.keyValue,
|
|
});
|
|
console.log("API Key 헤더 추가:", authConfig.keyName);
|
|
} else if (authConfig.keyLocation === "query" && authConfig.keyName && authConfig.keyValue) {
|
|
queryParams.push({
|
|
id: `auth_query_${Date.now()}`,
|
|
key: authConfig.keyName,
|
|
value: authConfig.keyValue,
|
|
});
|
|
console.log("API Key 쿼리 파라미터 추가:", authConfig.keyName);
|
|
}
|
|
break;
|
|
|
|
case "bearer":
|
|
if (authConfig.token) {
|
|
headers.push({
|
|
id: `auth_bearer_${Date.now()}`,
|
|
key: "Authorization",
|
|
value: `Bearer ${authConfig.token}`,
|
|
});
|
|
console.log("Bearer Token 헤더 추가");
|
|
}
|
|
break;
|
|
|
|
case "basic":
|
|
if (authConfig.username && authConfig.password) {
|
|
const credentials = btoa(`${authConfig.username}:${authConfig.password}`);
|
|
headers.push({
|
|
id: `auth_basic_${Date.now()}`,
|
|
key: "Authorization",
|
|
value: `Basic ${credentials}`,
|
|
});
|
|
console.log("Basic Auth 헤더 추가");
|
|
}
|
|
break;
|
|
|
|
case "oauth2":
|
|
if (authConfig.accessToken) {
|
|
headers.push({
|
|
id: `auth_oauth_${Date.now()}`,
|
|
key: "Authorization",
|
|
value: `Bearer ${authConfig.accessToken}`,
|
|
});
|
|
console.log("OAuth2 Token 헤더 추가");
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
|
|
// 헤더와 쿼리 파라미터 적용
|
|
if (headers.length > 0) {
|
|
updates.headers = headers;
|
|
}
|
|
if (queryParams.length > 0) {
|
|
updates.queryParams = queryParams;
|
|
}
|
|
|
|
console.log("최종 업데이트:", updates);
|
|
onChange(updates);
|
|
};
|
|
|
|
// 헤더 추가
|
|
const handleAddHeader = () => {
|
|
const headers = dataSource.headers || [];
|
|
onChange({
|
|
headers: [...headers, { id: Date.now().toString(), key: "", value: "" }],
|
|
});
|
|
};
|
|
|
|
// 헤더 삭제
|
|
const handleDeleteHeader = (id: string) => {
|
|
const headers = (dataSource.headers || []).filter((h) => h.id !== id);
|
|
onChange({ headers });
|
|
};
|
|
|
|
// 헤더 업데이트
|
|
const handleUpdateHeader = (id: string, field: "key" | "value", value: string) => {
|
|
const headers = (dataSource.headers || []).map((h) =>
|
|
h.id === id ? { ...h, [field]: value } : h
|
|
);
|
|
onChange({ headers });
|
|
};
|
|
|
|
// 쿼리 파라미터 추가
|
|
const handleAddQueryParam = () => {
|
|
const queryParams = dataSource.queryParams || [];
|
|
onChange({
|
|
queryParams: [...queryParams, { id: Date.now().toString(), key: "", value: "" }],
|
|
});
|
|
};
|
|
|
|
// 쿼리 파라미터 삭제
|
|
const handleDeleteQueryParam = (id: string) => {
|
|
const queryParams = (dataSource.queryParams || []).filter((q) => q.id !== id);
|
|
onChange({ queryParams });
|
|
};
|
|
|
|
// 쿼리 파라미터 업데이트
|
|
const handleUpdateQueryParam = (id: string, field: "key" | "value", value: string) => {
|
|
const queryParams = (dataSource.queryParams || []).map((q) =>
|
|
q.id === id ? { ...q, [field]: value } : q
|
|
);
|
|
onChange({ queryParams });
|
|
};
|
|
|
|
// API 테스트
|
|
const handleTestApi = async () => {
|
|
if (!dataSource.endpoint) {
|
|
setTestResult({ success: false, message: "API URL을 입력해주세요" });
|
|
return;
|
|
}
|
|
|
|
setTesting(true);
|
|
setTestResult(null);
|
|
|
|
try {
|
|
const queryParams: Record<string, string> = {};
|
|
(dataSource.queryParams || []).forEach((param) => {
|
|
if (param.key && param.value) {
|
|
queryParams[param.key] = param.value;
|
|
}
|
|
});
|
|
|
|
const headers: Record<string, string> = {};
|
|
(dataSource.headers || []).forEach((header) => {
|
|
if (header.key && header.value) {
|
|
headers[header.key] = header.value;
|
|
}
|
|
});
|
|
|
|
const response = await fetch("/api/dashboards/fetch-external-api", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
credentials: "include",
|
|
body: JSON.stringify({
|
|
url: dataSource.endpoint,
|
|
method: dataSource.method || "GET",
|
|
headers,
|
|
queryParams,
|
|
}),
|
|
});
|
|
|
|
const result = await response.json();
|
|
|
|
if (result.success) {
|
|
// 텍스트 데이터 파싱 함수 (MapTestWidgetV2와 동일)
|
|
const parseTextData = (text: string): any[] => {
|
|
try {
|
|
console.log("🔍 텍스트 파싱 시작 (처음 500자):", text.substring(0, 500));
|
|
|
|
const lines = text.split('\n').filter(line => {
|
|
const trimmed = line.trim();
|
|
return trimmed &&
|
|
!trimmed.startsWith('#') &&
|
|
!trimmed.startsWith('=') &&
|
|
!trimmed.startsWith('---');
|
|
});
|
|
|
|
console.log(`📝 유효한 라인: ${lines.length}개`);
|
|
|
|
if (lines.length === 0) return [];
|
|
|
|
const result: any[] = [];
|
|
|
|
for (let i = 0; i < lines.length; i++) {
|
|
const line = lines[i];
|
|
const values = line.split(',').map(v => v.trim().replace(/,=$/g, ''));
|
|
|
|
// 기상특보 형식: 지역코드, 지역명, 하위코드, 하위지역명, 발표시각, 특보종류, 등급, 발표상태, 설명
|
|
if (values.length >= 4) {
|
|
const obj: any = {
|
|
code: values[0] || '', // 지역 코드 (예: L1070000)
|
|
region: values[1] || '', // 지역명 (예: 경상북도)
|
|
subCode: values[2] || '', // 하위 코드 (예: L1071600)
|
|
subRegion: values[3] || '', // 하위 지역명 (예: 영주시)
|
|
tmFc: values[4] || '', // 발표시각
|
|
type: values[5] || '', // 특보종류 (강풍, 호우 등)
|
|
level: values[6] || '', // 등급 (주의, 경보)
|
|
status: values[7] || '', // 발표상태
|
|
description: values.slice(8).join(', ').trim() || '',
|
|
name: values[3] || values[1] || values[0], // 하위 지역명 우선
|
|
};
|
|
|
|
result.push(obj);
|
|
}
|
|
}
|
|
|
|
console.log("📊 파싱 결과:", result.length, "개");
|
|
return result;
|
|
} catch (error) {
|
|
console.error("❌ 텍스트 파싱 오류:", error);
|
|
return [];
|
|
}
|
|
};
|
|
|
|
// JSON Path로 데이터 추출
|
|
let data = result.data;
|
|
|
|
// 텍스트 데이터 체크 (기상청 API 등)
|
|
if (data && typeof data === 'object' && data.text && typeof data.text === 'string') {
|
|
console.log("📄 텍스트 형식 데이터 감지, CSV 파싱 시도");
|
|
const parsedData = parseTextData(data.text);
|
|
if (parsedData.length > 0) {
|
|
console.log(`✅ CSV 파싱 성공: ${parsedData.length}개 행`);
|
|
data = parsedData;
|
|
}
|
|
} else if (dataSource.jsonPath) {
|
|
const pathParts = dataSource.jsonPath.split(".");
|
|
for (const part of pathParts) {
|
|
data = data?.[part];
|
|
}
|
|
}
|
|
|
|
const rows = Array.isArray(data) ? data : [data];
|
|
|
|
// 위도/경도 또는 coordinates 필드 또는 지역 코드 체크
|
|
const hasLocationData = rows.some((row) => {
|
|
const hasLatLng = (row.lat || row.latitude) && (row.lng || row.longitude);
|
|
const hasCoordinates = row.coordinates && Array.isArray(row.coordinates);
|
|
const hasRegionCode = row.code || row.areaCode || row.regionCode;
|
|
return hasLatLng || hasCoordinates || hasRegionCode;
|
|
});
|
|
|
|
if (hasLocationData) {
|
|
const markerCount = rows.filter(r =>
|
|
((r.lat || r.latitude) && (r.lng || r.longitude)) ||
|
|
r.code || r.areaCode || r.regionCode
|
|
).length;
|
|
const polygonCount = rows.filter(r => r.coordinates && Array.isArray(r.coordinates)).length;
|
|
|
|
setTestResult({
|
|
success: true,
|
|
message: `API 연결 성공 - 마커 ${markerCount}개, 영역 ${polygonCount}개 발견`
|
|
});
|
|
|
|
// 부모에게 테스트 결과 전달 (지도 미리보기용)
|
|
if (onTestResult) {
|
|
onTestResult(rows);
|
|
}
|
|
} else {
|
|
setTestResult({
|
|
success: true,
|
|
message: `API 연결 성공 - ${rows.length}개 데이터 (위치 정보 없음)`
|
|
});
|
|
}
|
|
} else {
|
|
setTestResult({ success: false, message: result.message || "API 호출 실패" });
|
|
}
|
|
} catch (error: any) {
|
|
setTestResult({ success: false, message: error.message || "네트워크 오류" });
|
|
} finally {
|
|
setTesting(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="space-y-4 rounded-lg border p-4">
|
|
<h5 className="text-sm font-semibold">REST API 설정</h5>
|
|
|
|
{/* 외부 연결 선택 */}
|
|
<div className="space-y-2">
|
|
<Label htmlFor={`connection-${dataSource.id}`} className="text-xs">
|
|
외부 연결 선택
|
|
</Label>
|
|
<Select
|
|
value={selectedConnectionId}
|
|
onValueChange={handleConnectionSelect}
|
|
>
|
|
<SelectTrigger className="h-8 text-xs">
|
|
<SelectValue placeholder="외부 연결 선택 (선택사항)" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="manual" className="text-xs">
|
|
직접 입력
|
|
</SelectItem>
|
|
{apiConnections.map((conn) => (
|
|
<SelectItem key={conn.id} value={conn.id?.toString() || ""} className="text-xs">
|
|
{conn.connection_name}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
<p className="text-[10px] text-muted-foreground">
|
|
외부 연결을 선택하면 API URL이 자동으로 입력됩니다
|
|
</p>
|
|
</div>
|
|
|
|
{/* API URL (직접 입력 또는 수정) */}
|
|
<div className="space-y-2">
|
|
<Label htmlFor={`endpoint-${dataSource.id}`} className="text-xs">
|
|
API URL *
|
|
</Label>
|
|
<Input
|
|
id={`endpoint-${dataSource.id}`}
|
|
value={dataSource.endpoint || ""}
|
|
onChange={(e) => {
|
|
console.log("📝 API URL 변경:", e.target.value);
|
|
onChange({ endpoint: e.target.value });
|
|
}}
|
|
placeholder="https://api.example.com/data"
|
|
className="h-8 text-xs"
|
|
/>
|
|
<p className="text-[10px] text-muted-foreground">
|
|
외부 연결을 선택하거나 직접 입력할 수 있습니다
|
|
</p>
|
|
</div>
|
|
|
|
{/* JSON Path */}
|
|
<div className="space-y-2">
|
|
<Label htmlFor={`jsonPath-\${dataSource.id}`} className="text-xs">
|
|
JSON Path (선택)
|
|
</Label>
|
|
<Input
|
|
id={`jsonPath-\${dataSource.id}`}
|
|
value={dataSource.jsonPath || ""}
|
|
onChange={(e) => onChange({ jsonPath: e.target.value })}
|
|
placeholder="예: data.results"
|
|
className="h-8 text-xs"
|
|
/>
|
|
<p className="text-[10px] text-muted-foreground">
|
|
응답 JSON에서 데이터를 추출할 경로
|
|
</p>
|
|
</div>
|
|
|
|
{/* 쿼리 파라미터 */}
|
|
<div className="space-y-2">
|
|
<div className="flex items-center justify-between">
|
|
<Label className="text-xs">쿼리 파라미터</Label>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={handleAddQueryParam}
|
|
className="h-6 gap-1 text-xs"
|
|
>
|
|
<Plus className="h-3 w-3" />
|
|
추가
|
|
</Button>
|
|
</div>
|
|
{(dataSource.queryParams || []).map((param) => (
|
|
<div key={param.id} className="flex gap-2">
|
|
<Input
|
|
value={param.key}
|
|
onChange={(e) => handleUpdateQueryParam(param.id, "key", e.target.value)}
|
|
placeholder="키"
|
|
className="h-8 text-xs"
|
|
/>
|
|
<Input
|
|
value={param.value}
|
|
onChange={(e) => handleUpdateQueryParam(param.id, "value", e.target.value)}
|
|
placeholder="값"
|
|
className="h-8 text-xs"
|
|
/>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => handleDeleteQueryParam(param.id)}
|
|
className="h-8 w-8 p-0"
|
|
>
|
|
<Trash2 className="h-3 w-3" />
|
|
</Button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{/* 헤더 */}
|
|
<div className="space-y-2">
|
|
<div className="flex items-center justify-between">
|
|
<Label className="text-xs">헤더</Label>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={handleAddHeader}
|
|
className="h-6 gap-1 text-xs"
|
|
>
|
|
<Plus className="h-3 w-3" />
|
|
추가
|
|
</Button>
|
|
</div>
|
|
{(dataSource.headers || []).map((header) => (
|
|
<div key={header.id} className="flex gap-2">
|
|
<Input
|
|
value={header.key}
|
|
onChange={(e) => handleUpdateHeader(header.id, "key", e.target.value)}
|
|
placeholder="키"
|
|
className="h-8 text-xs"
|
|
/>
|
|
<Input
|
|
value={header.value}
|
|
onChange={(e) => handleUpdateHeader(header.id, "value", e.target.value)}
|
|
placeholder="값"
|
|
className="h-8 text-xs"
|
|
/>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => handleDeleteHeader(header.id)}
|
|
className="h-8 w-8 p-0"
|
|
>
|
|
<Trash2 className="h-3 w-3" />
|
|
</Button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{/* 테스트 버튼 */}
|
|
<div className="space-y-2 border-t pt-4">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={handleTestApi}
|
|
disabled={testing || !dataSource.endpoint}
|
|
className="h-8 w-full gap-2 text-xs"
|
|
>
|
|
{testing ? (
|
|
<>
|
|
<Loader2 className="h-3 w-3 animate-spin" />
|
|
테스트 중...
|
|
</>
|
|
) : (
|
|
"API 테스트"
|
|
)}
|
|
</Button>
|
|
|
|
{testResult && (
|
|
<div
|
|
className={`flex items-center gap-2 rounded-md p-2 text-xs \${
|
|
testResult.success
|
|
? "bg-green-50 text-green-700"
|
|
: "bg-red-50 text-red-700"
|
|
}`}
|
|
>
|
|
{testResult.success ? (
|
|
<CheckCircle className="h-3 w-3" />
|
|
) : (
|
|
<XCircle className="h-3 w-3" />
|
|
)}
|
|
{testResult.message}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|