차트 구현 phase1 완료
This commit is contained in:
318
frontend/components/admin/dashboard/data-sources/ApiConfig.tsx
Normal file
318
frontend/components/admin/dashboard/data-sources/ApiConfig.tsx
Normal file
@@ -0,0 +1,318 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { ChartDataSource, QueryResult, ApiResponse } from "../types";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Plus, X, Play, AlertCircle } from "lucide-react";
|
||||
|
||||
interface ApiConfigProps {
|
||||
dataSource: ChartDataSource;
|
||||
onChange: (updates: Partial<ChartDataSource>) => void;
|
||||
onTestResult?: (result: QueryResult) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* REST API 설정 컴포넌트
|
||||
* - API 엔드포인트 설정
|
||||
* - 헤더 및 쿼리 파라미터 추가
|
||||
* - JSON Path 설정
|
||||
*/
|
||||
export function ApiConfig({ dataSource, onChange, onTestResult }: ApiConfigProps) {
|
||||
const [testing, setTesting] = useState(false);
|
||||
const [testResult, setTestResult] = useState<QueryResult | null>(null);
|
||||
const [testError, setTestError] = useState<string | null>(null);
|
||||
|
||||
// 헤더 추가
|
||||
const addHeader = () => {
|
||||
const headers = dataSource.headers || {};
|
||||
const newKey = `header_${Object.keys(headers).length + 1}`;
|
||||
onChange({ headers: { ...headers, [newKey]: "" } });
|
||||
};
|
||||
|
||||
// 헤더 제거
|
||||
const removeHeader = (key: string) => {
|
||||
const headers = { ...dataSource.headers };
|
||||
delete headers[key];
|
||||
onChange({ headers });
|
||||
};
|
||||
|
||||
// 헤더 업데이트
|
||||
const updateHeader = (oldKey: string, newKey: string, value: string) => {
|
||||
const headers = { ...dataSource.headers };
|
||||
delete headers[oldKey];
|
||||
headers[newKey] = value;
|
||||
onChange({ headers });
|
||||
};
|
||||
|
||||
// 쿼리 파라미터 추가
|
||||
const addQueryParam = () => {
|
||||
const queryParams = dataSource.queryParams || {};
|
||||
const newKey = `param_${Object.keys(queryParams).length + 1}`;
|
||||
onChange({ queryParams: { ...queryParams, [newKey]: "" } });
|
||||
};
|
||||
|
||||
// 쿼리 파라미터 제거
|
||||
const removeQueryParam = (key: string) => {
|
||||
const queryParams = { ...dataSource.queryParams };
|
||||
delete queryParams[key];
|
||||
onChange({ queryParams });
|
||||
};
|
||||
|
||||
// 쿼리 파라미터 업데이트
|
||||
const updateQueryParam = (oldKey: string, newKey: string, value: string) => {
|
||||
const queryParams = { ...dataSource.queryParams };
|
||||
delete queryParams[oldKey];
|
||||
queryParams[newKey] = value;
|
||||
onChange({ queryParams });
|
||||
};
|
||||
|
||||
// API 테스트
|
||||
const testApi = async () => {
|
||||
if (!dataSource.endpoint) {
|
||||
setTestError("API URL을 입력하세요");
|
||||
return;
|
||||
}
|
||||
|
||||
setTesting(true);
|
||||
setTestError(null);
|
||||
setTestResult(null);
|
||||
|
||||
try {
|
||||
// 쿼리 파라미터 구성
|
||||
const params = new URLSearchParams();
|
||||
if (dataSource.queryParams) {
|
||||
Object.entries(dataSource.queryParams).forEach(([key, value]) => {
|
||||
if (key && value) {
|
||||
params.append(key, value);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const url = `http://localhost:8080/api/dashboards/fetch-api?${params.toString()}`;
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${localStorage.getItem("token") || "test-token"}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
endpoint: dataSource.endpoint,
|
||||
headers: dataSource.headers || {},
|
||||
jsonPath: dataSource.jsonPath || "",
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const result: ApiResponse<QueryResult> = await response.json();
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(result.message || "API 호출에 실패했습니다");
|
||||
}
|
||||
|
||||
setTestResult(result.data);
|
||||
onTestResult?.(result.data);
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : "알 수 없는 오류가 발생했습니다";
|
||||
setTestError(errorMessage);
|
||||
} finally {
|
||||
setTesting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-800">2단계: REST API 설정</h3>
|
||||
<p className="mt-1 text-sm text-gray-600">외부 API에서 데이터를 가져올 설정을 입력하세요</p>
|
||||
</div>
|
||||
|
||||
{/* API URL */}
|
||||
<Card className="space-y-4 p-4">
|
||||
<div>
|
||||
<Label className="text-sm font-medium text-gray-700">API URL *</Label>
|
||||
<Input
|
||||
type="url"
|
||||
placeholder="https://api.example.com/data"
|
||||
value={dataSource.endpoint || ""}
|
||||
onChange={(e) => onChange({ endpoint: e.target.value })}
|
||||
className="mt-2"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-500">GET 요청을 보낼 API 엔드포인트</p>
|
||||
</div>
|
||||
|
||||
{/* HTTP 메서드 (고정) */}
|
||||
<div>
|
||||
<Label className="text-sm font-medium text-gray-700">HTTP 메서드</Label>
|
||||
<div className="mt-2 rounded border border-gray-300 bg-gray-100 p-2 text-sm text-gray-700">GET (고정)</div>
|
||||
<p className="mt-1 text-xs text-gray-500">데이터 조회는 GET 메서드만 지원합니다</p>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* 쿼리 파라미터 */}
|
||||
<Card className="space-y-4 p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-sm font-medium text-gray-700">URL 쿼리 파라미터</Label>
|
||||
<Button variant="outline" size="sm" onClick={addQueryParam}>
|
||||
<Plus className="mr-1 h-3 w-3" />
|
||||
추가
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{dataSource.queryParams && Object.keys(dataSource.queryParams).length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{Object.entries(dataSource.queryParams).map(([key, value]) => (
|
||||
<div key={key} className="flex gap-2">
|
||||
<Input
|
||||
placeholder="key"
|
||||
value={key}
|
||||
onChange={(e) => updateQueryParam(key, e.target.value, value)}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Input
|
||||
placeholder="value"
|
||||
value={value}
|
||||
onChange={(e) => updateQueryParam(key, key, e.target.value)}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button variant="ghost" size="icon" onClick={() => removeQueryParam(key)}>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="py-2 text-center text-sm text-gray-500">추가된 파라미터가 없습니다</p>
|
||||
)}
|
||||
|
||||
<p className="text-xs text-gray-500">예: category=electronics, limit=10</p>
|
||||
</Card>
|
||||
|
||||
{/* 헤더 */}
|
||||
<Card className="space-y-4 p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-sm font-medium text-gray-700">요청 헤더</Label>
|
||||
<Button variant="outline" size="sm" onClick={addHeader}>
|
||||
<Plus className="mr-1 h-3 w-3" />
|
||||
추가
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 빠른 헤더 템플릿 */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
onChange({
|
||||
headers: { ...dataSource.headers, Authorization: "Bearer YOUR_TOKEN" },
|
||||
});
|
||||
}}
|
||||
>
|
||||
+ Authorization
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
onChange({
|
||||
headers: { ...dataSource.headers, "Content-Type": "application/json" },
|
||||
});
|
||||
}}
|
||||
>
|
||||
+ Content-Type
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{dataSource.headers && Object.keys(dataSource.headers).length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{Object.entries(dataSource.headers).map(([key, value]) => (
|
||||
<div key={key} className="flex gap-2">
|
||||
<Input
|
||||
placeholder="Header Name"
|
||||
value={key}
|
||||
onChange={(e) => updateHeader(key, e.target.value, value)}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Input
|
||||
placeholder="Header Value"
|
||||
value={value}
|
||||
onChange={(e) => updateHeader(key, key, e.target.value)}
|
||||
className="flex-1"
|
||||
type={key.toLowerCase().includes("auth") ? "password" : "text"}
|
||||
/>
|
||||
<Button variant="ghost" size="icon" onClick={() => removeHeader(key)}>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="py-2 text-center text-sm text-gray-500">추가된 헤더가 없습니다</p>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* JSON Path */}
|
||||
<Card className="space-y-2 p-4">
|
||||
<Label className="text-sm font-medium text-gray-700">JSON Path (선택)</Label>
|
||||
<Input
|
||||
placeholder="data.results"
|
||||
value={dataSource.jsonPath || ""}
|
||||
onChange={(e) => onChange({ jsonPath: e.target.value })}
|
||||
/>
|
||||
<p className="text-xs text-gray-500">
|
||||
JSON 응답에서 데이터 배열의 경로 (예: data.results, items, response.data)
|
||||
<br />
|
||||
비워두면 전체 응답을 사용합니다
|
||||
</p>
|
||||
</Card>
|
||||
|
||||
{/* 테스트 버튼 */}
|
||||
<div className="flex justify-end">
|
||||
<Button onClick={testApi} disabled={!dataSource.endpoint || testing}>
|
||||
{testing ? (
|
||||
<>
|
||||
<div className="mr-2 h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent" />
|
||||
테스트 중...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Play className="mr-2 h-4 w-4" />
|
||||
API 테스트
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 테스트 오류 */}
|
||||
{testError && (
|
||||
<Card className="border-red-200 bg-red-50 p-4">
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertCircle className="mt-0.5 h-5 w-5 flex-shrink-0 text-red-600" />
|
||||
<div>
|
||||
<div className="text-sm font-medium text-red-800">API 호출 실패</div>
|
||||
<div className="mt-1 text-sm text-red-700">{testError}</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 테스트 결과 */}
|
||||
{testResult && (
|
||||
<Card className="border-green-200 bg-green-50 p-4">
|
||||
<div className="mb-2 text-sm font-medium text-green-800">✅ API 호출 성공</div>
|
||||
<div className="space-y-1 text-xs text-green-700">
|
||||
<div>총 {testResult.rows.length}개의 데이터를 불러왔습니다</div>
|
||||
<div>컬럼: {testResult.columns.join(", ")}</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { ChartDataSource } from "../types";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Database, Globe } from "lucide-react";
|
||||
|
||||
interface DataSourceSelectorProps {
|
||||
dataSource: ChartDataSource;
|
||||
onTypeChange: (type: "database" | "api") => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 데이터 소스 선택 컴포넌트
|
||||
* - DB vs API 선택
|
||||
* - 큰 카드 UI로 직관적인 선택
|
||||
*/
|
||||
export function DataSourceSelector({ dataSource, onTypeChange }: DataSourceSelectorProps) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-800">1단계: 데이터 소스 선택</h3>
|
||||
<p className="mt-1 text-sm text-gray-600">차트에 표시할 데이터를 어디서 가져올지 선택하세요</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{/* 데이터베이스 옵션 */}
|
||||
<Card
|
||||
className={`cursor-pointer p-6 transition-all ${
|
||||
dataSource.type === "database"
|
||||
? "border-2 border-blue-500 bg-blue-50"
|
||||
: "border-2 border-gray-200 hover:border-gray-300"
|
||||
}`}
|
||||
onClick={() => onTypeChange("database")}
|
||||
>
|
||||
<div className="flex flex-col items-center space-y-3 text-center">
|
||||
<div className={`rounded-full p-4 ${dataSource.type === "database" ? "bg-blue-100" : "bg-gray-100"}`}>
|
||||
<Database className={`h-8 w-8 ${dataSource.type === "database" ? "text-blue-600" : "text-gray-600"}`} />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-semibold text-gray-900">데이터베이스</h4>
|
||||
<p className="mt-1 text-sm text-gray-600">SQL 쿼리로 데이터 조회</p>
|
||||
</div>
|
||||
<div className="space-y-1 text-xs text-gray-500">
|
||||
<div>✓ 현재 DB 또는 외부 DB</div>
|
||||
<div>✓ SELECT 쿼리 지원</div>
|
||||
<div>✓ 실시간 데이터 조회</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* REST API 옵션 */}
|
||||
<Card
|
||||
className={`cursor-pointer p-6 transition-all ${
|
||||
dataSource.type === "api"
|
||||
? "border-2 border-green-500 bg-green-50"
|
||||
: "border-2 border-gray-200 hover:border-gray-300"
|
||||
}`}
|
||||
onClick={() => onTypeChange("api")}
|
||||
>
|
||||
<div className="flex flex-col items-center space-y-3 text-center">
|
||||
<div className={`rounded-full p-4 ${dataSource.type === "api" ? "bg-green-100" : "bg-gray-100"}`}>
|
||||
<Globe className={`h-8 w-8 ${dataSource.type === "api" ? "text-green-600" : "text-gray-600"}`} />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-semibold text-gray-900">REST API</h4>
|
||||
<p className="mt-1 text-sm text-gray-600">외부 API에서 데이터 가져오기</p>
|
||||
</div>
|
||||
<div className="space-y-1 text-xs text-gray-500">
|
||||
<div>✓ GET 요청 지원</div>
|
||||
<div>✓ JSON 응답 파싱</div>
|
||||
<div>✓ 커스텀 헤더 설정</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 선택된 타입 표시 */}
|
||||
{dataSource.type && (
|
||||
<div className="rounded-lg border border-gray-200 bg-gray-50 p-3">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<span className="font-medium text-gray-700">선택됨:</span>
|
||||
<span className="text-gray-900">{dataSource.type === "database" ? "🗄️ 데이터베이스" : "🌐 REST API"}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { ChartDataSource, ExternalConnection, ApiResponse } from "../types";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ExternalLink, Database, Server } from "lucide-react";
|
||||
|
||||
interface DatabaseConfigProps {
|
||||
dataSource: ChartDataSource;
|
||||
onChange: (updates: Partial<ChartDataSource>) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 데이터베이스 설정 컴포넌트
|
||||
* - 현재 DB / 외부 DB 선택
|
||||
* - 외부 커넥션 목록 불러오기
|
||||
*/
|
||||
export function DatabaseConfig({ dataSource, onChange }: DatabaseConfigProps) {
|
||||
const [connections, setConnections] = useState<ExternalConnection[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// 외부 커넥션 목록 불러오기
|
||||
useEffect(() => {
|
||||
if (dataSource.connectionType === "external") {
|
||||
loadExternalConnections();
|
||||
}
|
||||
}, [dataSource.connectionType]);
|
||||
|
||||
const loadExternalConnections = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const response = await fetch("http://localhost:8080/api/external-connections", {
|
||||
headers: {
|
||||
Authorization: `Bearer ${localStorage.getItem("token") || "test-token"}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("외부 커넥션 목록을 불러오는데 실패했습니다");
|
||||
}
|
||||
|
||||
const result: ApiResponse<ExternalConnection[]> = await response.json();
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(result.message || "외부 커넥션 목록을 불러오는데 실패했습니다");
|
||||
}
|
||||
|
||||
setConnections(result.data || []);
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : "알 수 없는 오류가 발생했습니다";
|
||||
setError(errorMessage);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 현재 선택된 커넥션 찾기
|
||||
const selectedConnection = connections.find((conn) => conn.id === dataSource.externalConnectionId);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-800">2단계: 데이터베이스 설정</h3>
|
||||
<p className="mt-1 text-sm text-gray-600">데이터를 조회할 데이터베이스를 선택하세요</p>
|
||||
</div>
|
||||
|
||||
{/* 현재 DB vs 외부 DB 선택 */}
|
||||
<Card className="p-4">
|
||||
<Label className="mb-3 block text-sm font-medium text-gray-700">데이터베이스 선택</Label>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Button
|
||||
variant={dataSource.connectionType === "current" ? "default" : "outline"}
|
||||
className="h-auto justify-start py-3"
|
||||
onClick={() => {
|
||||
onChange({ connectionType: "current", externalConnectionId: undefined });
|
||||
}}
|
||||
>
|
||||
<Database className="mr-2 h-4 w-4" />
|
||||
<div className="text-left">
|
||||
<div className="font-medium">현재 데이터베이스</div>
|
||||
<div className="text-xs opacity-80">애플리케이션 기본 DB</div>
|
||||
</div>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant={dataSource.connectionType === "external" ? "default" : "outline"}
|
||||
className="h-auto justify-start py-3"
|
||||
onClick={() => {
|
||||
onChange({ connectionType: "external" });
|
||||
}}
|
||||
>
|
||||
<Server className="mr-2 h-4 w-4" />
|
||||
<div className="text-left">
|
||||
<div className="font-medium">외부 데이터베이스</div>
|
||||
<div className="text-xs opacity-80">등록된 외부 커넥션</div>
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* 외부 DB 선택 시 커넥션 목록 */}
|
||||
{dataSource.connectionType === "external" && (
|
||||
<Card className="space-y-4 p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-sm font-medium text-gray-700">외부 커넥션 선택</Label>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
window.open("/admin/external-connections", "_blank");
|
||||
}}
|
||||
className="text-xs"
|
||||
>
|
||||
<ExternalLink className="mr-1 h-3 w-3" />
|
||||
커넥션 관리
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center py-4">
|
||||
<div className="h-5 w-5 animate-spin rounded-full border-2 border-gray-300 border-t-blue-600" />
|
||||
<span className="ml-2 text-sm text-gray-600">커넥션 목록 불러오는 중...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="rounded-lg border border-red-200 bg-red-50 p-3">
|
||||
<div className="text-sm text-red-800">❌ {error}</div>
|
||||
<Button variant="ghost" size="sm" onClick={loadExternalConnections} className="mt-2 text-xs">
|
||||
다시 시도
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && connections.length === 0 && (
|
||||
<div className="rounded-lg border border-yellow-200 bg-yellow-50 p-4 text-center">
|
||||
<div className="mb-2 text-sm text-yellow-800">등록된 외부 커넥션이 없습니다</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
window.open("/admin/external-connections", "_blank");
|
||||
}}
|
||||
>
|
||||
<ExternalLink className="mr-1 h-3 w-3" />
|
||||
커넥션 등록하기
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && connections.length > 0 && (
|
||||
<>
|
||||
<Select
|
||||
value={dataSource.externalConnectionId || ""}
|
||||
onValueChange={(value) => {
|
||||
onChange({ externalConnectionId: value });
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="커넥션을 선택하세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{connections.map((conn) => (
|
||||
<SelectItem key={conn.id} value={conn.id}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">{conn.name}</span>
|
||||
<span className="text-xs text-gray-500">({conn.type})</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{selectedConnection && (
|
||||
<div className="rounded-lg border border-gray-200 bg-gray-50 p-3">
|
||||
<div className="space-y-1 text-xs text-gray-600">
|
||||
<div>
|
||||
<span className="font-medium">커넥션명:</span> {selectedConnection.name}
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium">타입:</span> {selectedConnection.type.toUpperCase()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 다음 단계 안내 */}
|
||||
{(dataSource.connectionType === "current" ||
|
||||
(dataSource.connectionType === "external" && dataSource.externalConnectionId)) && (
|
||||
<div className="rounded-lg border border-blue-200 bg-blue-50 p-3">
|
||||
<div className="text-sm text-blue-800">✅ 데이터베이스가 선택되었습니다. 아래에서 SQL 쿼리를 작성하세요.</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
import { QueryResult } from "../types";
|
||||
|
||||
/**
|
||||
* JSON Path를 사용하여 객체에서 데이터 추출
|
||||
* @param obj JSON 객체
|
||||
* @param path 경로 (예: "data.results", "items")
|
||||
* @returns 추출된 데이터
|
||||
*/
|
||||
export function extractDataFromJsonPath(obj: any, path: string): any {
|
||||
if (!path || path.trim() === "") {
|
||||
return obj;
|
||||
}
|
||||
|
||||
const keys = path.split(".");
|
||||
let result = obj;
|
||||
|
||||
for (const key of keys) {
|
||||
if (result === null || result === undefined) {
|
||||
return null;
|
||||
}
|
||||
result = result[key];
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* API 응답을 QueryResult 형식으로 변환
|
||||
* @param data API 응답 데이터
|
||||
* @param jsonPath JSON Path (선택)
|
||||
* @returns QueryResult
|
||||
*/
|
||||
export function transformApiResponseToQueryResult(data: any, jsonPath?: string): QueryResult {
|
||||
try {
|
||||
// JSON Path가 있으면 데이터 추출
|
||||
let extractedData = jsonPath ? extractDataFromJsonPath(data, jsonPath) : data;
|
||||
|
||||
// 배열이 아니면 배열로 변환
|
||||
if (!Array.isArray(extractedData)) {
|
||||
// 객체인 경우 키-값 쌍을 배열로 변환
|
||||
if (typeof extractedData === "object" && extractedData !== null) {
|
||||
extractedData = Object.entries(extractedData).map(([key, value]) => ({
|
||||
key,
|
||||
value,
|
||||
}));
|
||||
} else {
|
||||
throw new Error("데이터가 배열 또는 객체 형식이 아닙니다");
|
||||
}
|
||||
}
|
||||
|
||||
if (extractedData.length === 0) {
|
||||
return {
|
||||
columns: [],
|
||||
rows: [],
|
||||
totalRows: 0,
|
||||
executionTime: 0,
|
||||
};
|
||||
}
|
||||
|
||||
// 첫 번째 행에서 컬럼 추출
|
||||
const firstRow = extractedData[0];
|
||||
const columns = Object.keys(firstRow);
|
||||
|
||||
return {
|
||||
columns,
|
||||
rows: extractedData,
|
||||
totalRows: extractedData.length,
|
||||
executionTime: 0,
|
||||
};
|
||||
} catch (error) {
|
||||
throw new Error(`API 응답 변환 실패: ${error instanceof Error ? error.message : "알 수 없는 오류"}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 데이터 소스가 유효한지 검증
|
||||
* @param type 데이터 소스 타입
|
||||
* @param connectionType 커넥션 타입 (DB일 때)
|
||||
* @param externalConnectionId 외부 커넥션 ID (외부 DB일 때)
|
||||
* @param query SQL 쿼리 (DB일 때)
|
||||
* @param endpoint API URL (API일 때)
|
||||
* @returns 유효성 검증 결과
|
||||
*/
|
||||
export function validateDataSource(
|
||||
type: "database" | "api",
|
||||
connectionType?: "current" | "external",
|
||||
externalConnectionId?: string,
|
||||
query?: string,
|
||||
endpoint?: string,
|
||||
): { valid: boolean; message?: string } {
|
||||
if (type === "database") {
|
||||
// DB 검증
|
||||
if (!connectionType) {
|
||||
return { valid: false, message: "데이터베이스 타입을 선택하세요" };
|
||||
}
|
||||
|
||||
if (connectionType === "external" && !externalConnectionId) {
|
||||
return { valid: false, message: "외부 커넥션을 선택하세요" };
|
||||
}
|
||||
|
||||
if (!query || query.trim() === "") {
|
||||
return { valid: false, message: "SQL 쿼리를 입력하세요" };
|
||||
}
|
||||
|
||||
// SELECT 쿼리인지 검증 (간단한 검증)
|
||||
const trimmedQuery = query.trim().toLowerCase();
|
||||
if (!trimmedQuery.startsWith("select")) {
|
||||
return { valid: false, message: "SELECT 쿼리만 허용됩니다" };
|
||||
}
|
||||
|
||||
// 위험한 키워드 체크
|
||||
const dangerousKeywords = ["drop", "delete", "insert", "update", "truncate", "alter", "create", "exec", "execute"];
|
||||
for (const keyword of dangerousKeywords) {
|
||||
if (trimmedQuery.includes(keyword)) {
|
||||
return {
|
||||
valid: false,
|
||||
message: `보안상 ${keyword.toUpperCase()} 명령은 사용할 수 없습니다`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return { valid: true };
|
||||
} else if (type === "api") {
|
||||
// API 검증
|
||||
if (!endpoint || endpoint.trim() === "") {
|
||||
return { valid: false, message: "API URL을 입력하세요" };
|
||||
}
|
||||
|
||||
// URL 형식 검증
|
||||
try {
|
||||
new URL(endpoint);
|
||||
} catch {
|
||||
return { valid: false, message: "올바른 URL 형식이 아닙니다" };
|
||||
}
|
||||
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
return { valid: false, message: "알 수 없는 데이터 소스 타입입니다" };
|
||||
}
|
||||
|
||||
/**
|
||||
* 쿼리 파라미터를 URL에 추가
|
||||
* @param baseUrl 기본 URL
|
||||
* @param params 쿼리 파라미터 객체
|
||||
* @returns 쿼리 파라미터가 추가된 URL
|
||||
*/
|
||||
export function buildUrlWithParams(baseUrl: string, params?: Record<string, string>): string {
|
||||
if (!params || Object.keys(params).length === 0) {
|
||||
return baseUrl;
|
||||
}
|
||||
|
||||
const url = new URL(baseUrl);
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
if (key && value) {
|
||||
url.searchParams.append(key, value);
|
||||
}
|
||||
});
|
||||
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* 컬럼 데이터 타입 추론
|
||||
* @param rows 데이터 행
|
||||
* @param columnName 컬럼명
|
||||
* @returns 데이터 타입 ('string' | 'number' | 'date' | 'boolean')
|
||||
*/
|
||||
export function inferColumnType(rows: Record<string, any>[], columnName: string): string {
|
||||
if (rows.length === 0) {
|
||||
return "string";
|
||||
}
|
||||
|
||||
const sampleValue = rows[0][columnName];
|
||||
|
||||
if (typeof sampleValue === "number") {
|
||||
return "number";
|
||||
}
|
||||
|
||||
if (typeof sampleValue === "boolean") {
|
||||
return "boolean";
|
||||
}
|
||||
|
||||
if (typeof sampleValue === "string") {
|
||||
// 날짜 형식인지 확인
|
||||
if (!isNaN(Date.parse(sampleValue))) {
|
||||
return "date";
|
||||
}
|
||||
return "string";
|
||||
}
|
||||
|
||||
return "string";
|
||||
}
|
||||
Reference in New Issue
Block a user