feat: 배치 관리 페이지 및 API 개선
- 배치 작업 타입 조회 API 추가 및 오류 처리 개선 - 배치 관리 페이지에서 작업 타입을 객체 형태로 변환하여 설정 - 성공률 계산 로직에서 null 체크 추가 - 누적 실행 횟수, 총 성공 횟수, 총 실패 횟수 계산 시 null 체크 추가 Made-with: Cursor
This commit is contained in:
parent
c120492378
commit
b6ec4a4904
|
|
@ -77,7 +77,7 @@ export default function BatchManagementPage() {
|
||||||
const loadJobTypes = async () => {
|
const loadJobTypes = async () => {
|
||||||
try {
|
try {
|
||||||
const types = await BatchAPI.getSupportedJobTypes();
|
const types = await BatchAPI.getSupportedJobTypes();
|
||||||
setJobTypes(types);
|
setJobTypes(types.map(t => ({ value: t, label: t })));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("작업 타입 조회 오류:", error);
|
console.error("작업 타입 조회 오류:", error);
|
||||||
}
|
}
|
||||||
|
|
@ -172,8 +172,8 @@ export default function BatchManagementPage() {
|
||||||
};
|
};
|
||||||
|
|
||||||
const getSuccessRate = (job: BatchJob) => {
|
const getSuccessRate = (job: BatchJob) => {
|
||||||
if (job.execution_count === 0) return 100;
|
if (!job.execution_count) return 100;
|
||||||
return Math.round((job.success_count / job.execution_count) * 100);
|
return Math.round(((job.success_count ?? 0) / job.execution_count) * 100);
|
||||||
};
|
};
|
||||||
|
|
||||||
const getSuccessRateColor = (rate: number) => {
|
const getSuccessRateColor = (rate: number) => {
|
||||||
|
|
@ -361,7 +361,7 @@ export default function BatchManagementPage() {
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="text-2xl font-bold">
|
<div className="text-2xl font-bold">
|
||||||
{jobs.reduce((sum, job) => sum + job.execution_count, 0)}
|
{jobs.reduce((sum, job) => sum + (job.execution_count ?? 0), 0)}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground">누적 실행 횟수</p>
|
<p className="text-xs text-muted-foreground">누적 실행 횟수</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|
@ -373,7 +373,7 @@ export default function BatchManagementPage() {
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="text-2xl font-bold text-success">
|
<div className="text-2xl font-bold text-success">
|
||||||
{jobs.reduce((sum, job) => sum + job.success_count, 0)}
|
{jobs.reduce((sum, job) => sum + (job.success_count ?? 0), 0)}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground">총 성공 횟수</p>
|
<p className="text-xs text-muted-foreground">총 성공 횟수</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|
@ -385,7 +385,7 @@ export default function BatchManagementPage() {
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="text-2xl font-bold text-destructive">
|
<div className="text-2xl font-bold text-destructive">
|
||||||
{jobs.reduce((sum, job) => sum + job.failure_count, 0)}
|
{jobs.reduce((sum, job) => sum + (job.failure_count ?? 0), 0)}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground">총 실패 횟수</p>
|
<p className="text-xs text-muted-foreground">총 실패 횟수</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|
|
||||||
|
|
@ -209,16 +209,101 @@ function FullWidthOverlayRow({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 데스크톱: 원본 캔버스 좌표 기반 렌더링 (transform: scale 로 축소)
|
||||||
|
* 디자이너에서 설계한 레이아웃을 그대로 유지하면서 뷰포트에 맞춤
|
||||||
|
*/
|
||||||
|
function DesktopCanvasRenderer({
|
||||||
|
components,
|
||||||
|
canvasWidth,
|
||||||
|
canvasHeight,
|
||||||
|
renderComponent,
|
||||||
|
}: ResponsiveGridRendererProps) {
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [containerW, setContainerW] = useState(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const el = containerRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
const ro = new ResizeObserver((entries) => {
|
||||||
|
const w = entries[0]?.contentRect.width;
|
||||||
|
if (w && w > 0) setContainerW(w);
|
||||||
|
});
|
||||||
|
ro.observe(el);
|
||||||
|
return () => ro.disconnect();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const topLevel = components.filter((c) => !c.parentId);
|
||||||
|
const scale = containerW > 0 ? Math.min(containerW / canvasWidth, 1) : 1;
|
||||||
|
const scaledHeight = canvasHeight * scale;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
data-screen-runtime="true"
|
||||||
|
className="bg-background relative w-full overflow-x-hidden"
|
||||||
|
style={{ minHeight: `${scaledHeight}px` }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: `${canvasWidth}px`,
|
||||||
|
height: `${canvasHeight}px`,
|
||||||
|
transform: `scale(${scale})`,
|
||||||
|
transformOrigin: "top left",
|
||||||
|
position: "relative",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{topLevel.map((component) => {
|
||||||
|
const typeId = getComponentTypeId(component);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={component.id}
|
||||||
|
data-component-id={component.id}
|
||||||
|
data-component-type={typeId}
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
left: `${component.position.x}px`,
|
||||||
|
top: `${component.position.y}px`,
|
||||||
|
width: component.size?.width ? `${component.size.width}px` : "auto",
|
||||||
|
height: component.size?.height ? `${component.size.height}px` : "auto",
|
||||||
|
zIndex: component.position.z || 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{renderComponent(component)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function ResponsiveGridRenderer({
|
export function ResponsiveGridRenderer({
|
||||||
components,
|
components,
|
||||||
canvasWidth,
|
canvasWidth,
|
||||||
canvasHeight,
|
canvasHeight,
|
||||||
renderComponent,
|
renderComponent,
|
||||||
}: ResponsiveGridRendererProps) {
|
}: ResponsiveGridRendererProps) {
|
||||||
const { isMobile } = useResponsive();
|
const { isMobile, isTablet } = useResponsive();
|
||||||
|
|
||||||
|
// 데스크톱: 원본 캔버스 좌표 유지 (스케일링)
|
||||||
|
// 풀위드스 컴포넌트(테이블, 스플릿패널 등)가 있으면 flex 레이아웃 사용
|
||||||
|
const topLevel = components.filter((c) => !c.parentId);
|
||||||
|
const hasFullWidthComponent = topLevel.some((c) => isFullWidthComponent(c));
|
||||||
|
const useCanvasLayout = !isMobile && !isTablet && !hasFullWidthComponent;
|
||||||
|
|
||||||
|
if (useCanvasLayout) {
|
||||||
|
return (
|
||||||
|
<DesktopCanvasRenderer
|
||||||
|
components={components}
|
||||||
|
canvasWidth={canvasWidth}
|
||||||
|
canvasHeight={canvasHeight}
|
||||||
|
renderComponent={renderComponent}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const processedRows = useMemo(() => {
|
const processedRows = useMemo(() => {
|
||||||
const topLevel = components.filter((c) => !c.parentId);
|
|
||||||
const rows = groupComponentsIntoRows(topLevel);
|
const rows = groupComponentsIntoRows(topLevel);
|
||||||
|
|
||||||
const result: ProcessedRow[] = [];
|
const result: ProcessedRow[] = [];
|
||||||
|
|
@ -261,7 +346,7 @@ export function ResponsiveGridRenderer({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
}, [components]);
|
}, [topLevel]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|
|
||||||
|
|
@ -88,8 +88,8 @@ export const useWebTypes = (params?: WebTypeQueryParams) => {
|
||||||
|
|
||||||
return result.data || [];
|
return result.data || [];
|
||||||
},
|
},
|
||||||
staleTime: 5 * 60 * 1000, // 5분간 캐시 유지
|
staleTime: 5 * 60 * 1000,
|
||||||
cacheTime: 10 * 60 * 1000, // 10분간 메모리 보관
|
gcTime: 10 * 60 * 1000,
|
||||||
});
|
});
|
||||||
|
|
||||||
// 웹타입 생성
|
// 웹타입 생성
|
||||||
|
|
|
||||||
|
|
@ -56,9 +56,14 @@ export interface BatchJob {
|
||||||
job_type: string;
|
job_type: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
cron_schedule: string;
|
cron_schedule: string;
|
||||||
|
schedule_cron?: string;
|
||||||
is_active: string;
|
is_active: string;
|
||||||
last_execution?: Date;
|
last_execution?: Date;
|
||||||
|
last_executed_at?: string;
|
||||||
next_execution?: Date;
|
next_execution?: Date;
|
||||||
|
execution_count?: number;
|
||||||
|
success_count?: number;
|
||||||
|
failure_count?: number;
|
||||||
status?: string;
|
status?: string;
|
||||||
created_date?: Date;
|
created_date?: Date;
|
||||||
created_by?: string;
|
created_by?: string;
|
||||||
|
|
@ -390,6 +395,52 @@ export class BatchAPI {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 지원되는 배치 작업 타입 조회
|
||||||
|
*/
|
||||||
|
static async getSupportedJobTypes(): Promise<string[]> {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.get<ApiResponse<string[]>>('/batch-management/job-types');
|
||||||
|
if (!response.data.success) {
|
||||||
|
throw new Error(response.data.message || "작업 타입 조회에 실패했습니다.");
|
||||||
|
}
|
||||||
|
return response.data.data || [];
|
||||||
|
} catch (error) {
|
||||||
|
console.error("작업 타입 조회 오류:", error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 배치 작업 삭제
|
||||||
|
*/
|
||||||
|
static async deleteBatchJob(id: number): Promise<void> {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.delete<ApiResponse<void>>(`/batch-management/jobs/${id}`);
|
||||||
|
if (!response.data.success) {
|
||||||
|
throw new Error(response.data.message || "배치 작업 삭제에 실패했습니다.");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("배치 작업 삭제 오류:", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 배치 작업 실행
|
||||||
|
*/
|
||||||
|
static async executeBatchJob(id: number): Promise<void> {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.post<ApiResponse<void>>(`/batch-management/jobs/${id}/execute`);
|
||||||
|
if (!response.data.success) {
|
||||||
|
throw new Error(response.data.message || "배치 작업 실행에 실패했습니다.");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("배치 작업 실행 오류:", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* auth_tokens 테이블의 서비스명 목록 조회
|
* auth_tokens 테이블의 서비스명 목록 조회
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue