From b0cf7d740b0eb2848f8428b8321e7f7f5e22c685 Mon Sep 17 00:00:00 2001 From: ryan-dia Date: Tue, 16 Jun 2026 09:38:08 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20Flyway=20=EB=B7=B0=EC=96=B4=20UI=20?= =?UTF-8?q?=EA=B0=9C=EC=84=A0=20=E2=80=94=20=EC=88=9C=EC=84=9C=EC=97=AD?= =?UTF-8?q?=EC=A0=84=20=ED=96=89=20=ED=91=9C=EC=8B=9C=20/=20dev=C2=B7prod?= =?UTF-8?q?=20=ED=8C=8C=EC=9D=B4=ED=94=84=EB=9D=BC=EC=9D=B8=20=EB=A0=88?= =?UTF-8?q?=EC=9D=B4=EB=B8=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - STATUS_META: MERGE_PENDING→'dev 반영', DB_APPLIED→'prod 반영' - 순서역전 행: 좌측 컬러 세로선(컬럼=빨강, 테이블=주황) + 인라인 배지 ('⚠ 컬럼 역전' / '🔀 순서 역전') - 상단 배너 severity 표기: TABLE→'테이블', COLUMN→'컬럼'으로 한글화 - SQL 패널: 선택 마이그레이션이 순서역전 대상이면 경고 블록 표시 (공유 테이블/컬럼명 하이라이트, 강약 구분 색상) --- admin/src/pages/flyway/FlywayViewer.tsx | 211 +++++++++++++++++++----- 1 file changed, 173 insertions(+), 38 deletions(-) diff --git a/admin/src/pages/flyway/FlywayViewer.tsx b/admin/src/pages/flyway/FlywayViewer.tsx index 6620fdf86..ca0952424 100644 --- a/admin/src/pages/flyway/FlywayViewer.tsx +++ b/admin/src/pages/flyway/FlywayViewer.tsx @@ -7,7 +7,12 @@ import { WipRegisterForm } from './WipRegisterForm'; import { flywayQueries } from '@/apis/flyway/flyway.query'; import { Button } from '@/components/Button'; import { Layout } from '@/components/Layout'; -import type { MigrationItem, MigrationStatus } from '@/apis/flyway/flyway.api'; +import type { + ConflictSeverity, + FlywayLeapfrog, + MigrationItem, + MigrationStatus, +} from '@/apis/flyway/flyway.api'; const STATUS_META: Record< MigrationStatus, @@ -15,8 +20,8 @@ const STATUS_META: Record< > = { LOCAL_WIP: { label: '로컬 작업중', dot: '🟣', order: 0 }, PR_REVIEW: { label: 'PR 리뷰중', dot: '🟠', order: 1 }, - MERGE_PENDING: { label: '머지·배포 대기', dot: '🔵', order: 2 }, - DB_APPLIED: { label: 'DB 서버 반영', dot: '🟢', order: 3 }, + MERGE_PENDING: { label: 'dev 반영', dot: '🔵', order: 2 }, + DB_APPLIED: { label: 'prod 반영', dot: '🟢', order: 3 }, }; const STATUS_ORDER: MigrationStatus[] = [ @@ -61,8 +66,25 @@ export const FlywayViewer = () => { const conflictVersions = new Set( (data?.conflicts ?? []).map((conflict) => conflict.version), ); - const leapfrogVersions = new Set( - (data?.leapfrogWarnings ?? []).map((warning) => warning.mineVersion), + + // version → 가장 심각한 severity (COLUMN > TABLE) + const leapfrogSeverityMap = useMemo(() => { + const map = new Map(); + for (const warning of data?.leapfrogWarnings ?? []) { + const current = map.get(warning.mineVersion); + if (!current || warning.severity === 'COLUMN') { + map.set(warning.mineVersion, warning.severity); + } + } + return map; + }, [data]); + + const selectedLeapfrogs = useMemo( + () => + (data?.leapfrogWarnings ?? []).filter( + (w) => w.mineVersion === selected?.version, + ), + [data, selected], ); const filtered = useMemo( @@ -138,7 +160,7 @@ export const FlywayViewer = () => { {data.leapfrogWarnings .map( (warning) => - `${warning.mineVersion} ↔ ${warning.aheadVersion} (${warning.sharedTables.join(', ')}, ${warning.severity})`, + `${warning.mineVersion} ↔ ${warning.aheadVersion} (${warning.sharedTables.join(', ')}, ${warning.severity === 'COLUMN' ? '컬럼' : '테이블'})`, ) .join(' · ')} @@ -171,28 +193,39 @@ export const FlywayViewer = () => { - {filtered.map((item) => ( - setSelectedFileName(item.fileName)} - > - {item.version} - {item.description} - {item.tables.slice(0, 1).map((table) => ( - - {item.createsNewTable ? '🆕' : '🗂'} {table} - - ))} - {item.sourceLabel ? {item.sourceLabel} : null} - - {STATUS_META[item.status].label} - - - ))} + {filtered.map((item) => { + const leapfrogSeverity = + leapfrogSeverityMap.get(item.version) ?? null; + return ( + setSelectedFileName(item.fileName)} + > + {item.version} + {item.description} + {leapfrogSeverity ? ( + + {leapfrogSeverity === 'COLUMN' + ? '⚠ 컬럼 역전' + : '🔀 순서 역전'} + + ) : null} + {item.tables.slice(0, 1).map((table) => ( + + {item.createsNewTable ? '🆕' : '🗂'} {table} + + ))} + {item.sourceLabel ? {item.sourceLabel} : null} + + {STATUS_META[item.status].label} + + + ); + })} {filtered.length === 0 ? ( 조건에 맞는 마이그레이션이 없습니다. ) : null} @@ -200,7 +233,7 @@ export const FlywayViewer = () => { {selected ? ( - + ) : ( 왼쪽에서 마이그레이션을 선택하세요. )} @@ -212,9 +245,10 @@ export const FlywayViewer = () => { interface DetailContentProps { item: MigrationItem; + leapfrogs: FlywayLeapfrog[]; } -const DetailContent = ({ item }: DetailContentProps) => { +const DetailContent = ({ item, leapfrogs }: DetailContentProps) => { const hasFile = item.status === 'DB_APPLIED' || item.status === 'MERGE_PENDING'; const { data, isLoading, isError } = useQuery({ @@ -237,6 +271,35 @@ const DetailContent = ({ item }: DetailContentProps) => { {item.author ? ` · 담당 ${item.author}` : ''} {item.tables.length > 0 ? ` · ${item.tables.join(', ')}` : ''} + + {leapfrogs.map((leapfrog) => ( + + + {leapfrog.severity === 'COLUMN' + ? '⚠ 순서 역전 — 같은 컬럼 접근 (강한 경고)' + : '🔀 순서 역전 — 같은 테이블 접근'} + + + 이 버전({leapfrog.mineVersion})보다 높은{' '} + {leapfrog.aheadVersion}이 이미 반영되어 있습니다. + {leapfrog.severity === 'COLUMN' + ? ' out-of-order로 끼워 넣으면 같은 컬럼이 덮어씌워질 수 있습니다.' + : ' out-of-order로 끼워 넣어도 컬럼 충돌은 없지만, 같은 테이블을 다룹니다.'} + + + 공유 {leapfrog.severity === 'COLUMN' ? '컬럼' : '테이블'}:{' '} + {leapfrog.sharedTables.map((name) => ( + + {name} + + ))} + + + ))} + {hasFile ? ( {isLoading ? 스크립트 로딩 중... : null} @@ -255,8 +318,8 @@ const DetailContent = ({ item }: DetailContentProps) => { ) : ( - 아직 server/main 에 없는 작업중 항목입니다. 상세 SQL 은{' '} - {item.sourceUrl ? 'PR/이슈' : '소스'} 에서 확인하세요. + 아직 dev/prod에 없는 작업중 항목입니다. 상세 SQL은{' '} + {item.sourceUrl ? 'PR/이슈' : '소스'}에서 확인하세요. )} @@ -403,10 +466,25 @@ const List = styled.div` background-color: ${({ theme }) => theme.colors.white}; `; +const leapfrogBorderColor = (severity: ConflictSeverity | null) => { + if (severity === 'COLUMN') return '#EF4444'; + if (severity === 'TABLE') return '#F59E0B'; + return 'transparent'; +}; + +const leapfrogBackground = ( + severity: ConflictSeverity | null, + active: boolean, +) => { + if (severity === 'COLUMN') return '#FFF5F5'; + if (severity === 'TABLE') return active ? '#FFFBEB' : '#FFFEF5'; + return null; +}; + const RowButton = styled.button<{ $active: boolean; $conflict: boolean; - $leapfrog: boolean; + $leapfrogSeverity: ConflictSeverity | null; }>` width: 100%; padding: ${({ theme }) => theme.spacing.sm} ${({ theme }) => theme.spacing.md}; @@ -418,13 +496,17 @@ const RowButton = styled.button<{ cursor: pointer; text-align: left; - background-color: ${({ theme, $active, $conflict }) => - $conflict ? '#FDEAEA' : $active ? theme.colors.gray50 : theme.colors.white}; - box-shadow: ${({ theme, $active, $conflict, $leapfrog }) => + background-color: ${({ theme, $active, $conflict, $leapfrogSeverity }) => { + if ($conflict) return '#FDEAEA'; + const leap = leapfrogBackground($leapfrogSeverity, $active); + if (leap) return leap; + return $active ? theme.colors.gray50 : theme.colors.white; + }}; + box-shadow: ${({ theme, $active, $conflict, $leapfrogSeverity }) => $conflict ? `inset 3px 0 0 ${theme.colors.error}` - : $leapfrog - ? `inset 3px 0 0 ${theme.colors.warning}` + : $leapfrogSeverity + ? `inset 3px 0 0 ${leapfrogBorderColor($leapfrogSeverity)}` : $active ? `inset 3px 0 0 ${theme.colors.primary}` : 'none'}; @@ -450,6 +532,18 @@ const Desc = styled.span` font-size: ${({ theme }) => theme.fontSize.sm}; `; +const LeapfrogBadge = styled.span<{ $severity: ConflictSeverity }>` + flex-shrink: 0; + padding: 1px ${({ theme }) => theme.spacing.xs}; + border-radius: ${({ theme }) => theme.borderRadius.sm}; + font-size: ${({ theme }) => theme.fontSize.xs}; + font-weight: ${({ theme }) => theme.fontWeight.semibold}; + white-space: nowrap; + color: ${({ $severity }) => ($severity === 'COLUMN' ? '#B91C1C' : '#92400E')}; + background-color: ${({ $severity }) => + $severity === 'COLUMN' ? '#FEE2E2' : '#FEF3C7'}; +`; + const Tag = styled.span` padding: 0 ${({ theme }) => theme.spacing.xs}; border-radius: ${({ theme }) => theme.borderRadius.sm}; @@ -520,6 +614,47 @@ const DetailMeta = styled.div` font-size: ${({ theme }) => theme.fontSize.sm}; `; +const LeapfrogWarning = styled.div<{ $severity: ConflictSeverity }>` + margin: ${({ theme }) => theme.spacing.md} ${({ theme }) => theme.spacing.md} + 0; + padding: ${({ theme }) => theme.spacing.md}; + border-radius: ${({ theme }) => theme.borderRadius.md}; + border: 1px solid + ${({ $severity }) => ($severity === 'COLUMN' ? '#FCA5A5' : '#FCD34D')}; + background-color: ${({ $severity }) => + $severity === 'COLUMN' ? '#FFF5F5' : '#FFFBEB'}; +`; + +const LeapfrogWarningTitle = styled.div` + font-size: ${({ theme }) => theme.fontSize.sm}; + font-weight: ${({ theme }) => theme.fontWeight.semibold}; + margin-bottom: ${({ theme }) => theme.spacing.xs}; +`; + +const LeapfrogWarningBody = styled.p` + font-size: ${({ theme }) => theme.fontSize.sm}; + color: ${({ theme }) => theme.colors.gray700}; + margin-bottom: ${({ theme }) => theme.spacing.xs}; + line-height: 1.5; +`; + +const LeapfrogWarningDetail = styled.div` + font-size: ${({ theme }) => theme.fontSize.xs}; + color: ${({ theme }) => theme.colors.gray600}; + display: flex; + align-items: center; + gap: ${({ theme }) => theme.spacing.xs}; + flex-wrap: wrap; +`; + +const LeapfrogTag = styled.code<{ $severity: ConflictSeverity }>` + padding: 1px 5px; + border-radius: ${({ theme }) => theme.borderRadius.sm}; + background-color: ${({ $severity }) => + $severity === 'COLUMN' ? '#FEE2E2' : '#FEF3C7'}; + font-size: ${({ theme }) => theme.fontSize.xs}; +`; + const ScriptArea = styled.div` padding: ${({ theme }) => theme.spacing.sm}; `; From 5a7275b29cdc97c931558bbdd2d52c3c989b3fb3 Mon Sep 17 00:00:00 2001 From: ryan-dia Date: Tue, 16 Jun 2026 09:52:37 +0900 Subject: [PATCH 2/3] =?UTF-8?q?fix:=20Flyway=20=EB=B7=B0=EC=96=B4=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=EB=A6=AC=EB=B7=B0=20=EB=B0=98=EC=98=81=20?= =?UTF-8?q?=E2=80=94=20=EC=A4=91=EB=B3=B5=20=EC=A0=9C=EC=B6=9C=20=EB=B0=A9?= =?UTF-8?q?=EC=A7=80=20/=20=EC=BB=AC=EB=9F=BC=20=EC=97=AD=EC=A0=84=20activ?= =?UTF-8?q?e=20=EC=83=89=EC=83=81=20/=20Panes=20=EB=86=92=EC=9D=B4=20?= =?UTF-8?q?=EB=8F=99=EC=A0=81=20=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- admin/src/pages/flyway/FlywayViewer.tsx | 5 +++-- admin/src/pages/flyway/WipRegisterForm.tsx | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/admin/src/pages/flyway/FlywayViewer.tsx b/admin/src/pages/flyway/FlywayViewer.tsx index ca0952424..3508a696c 100644 --- a/admin/src/pages/flyway/FlywayViewer.tsx +++ b/admin/src/pages/flyway/FlywayViewer.tsx @@ -455,7 +455,8 @@ const NextSafe = styled.span` const Panes = styled.div` display: flex; gap: ${({ theme }) => theme.spacing.md}; - height: 540px; + height: calc(100vh - 400px); + min-height: 400px; `; const List = styled.div` @@ -476,7 +477,7 @@ const leapfrogBackground = ( severity: ConflictSeverity | null, active: boolean, ) => { - if (severity === 'COLUMN') return '#FFF5F5'; + if (severity === 'COLUMN') return active ? '#FFE4E4' : '#FFF5F5'; if (severity === 'TABLE') return active ? '#FFFBEB' : '#FFFEF5'; return null; }; diff --git a/admin/src/pages/flyway/WipRegisterForm.tsx b/admin/src/pages/flyway/WipRegisterForm.tsx index 4c9ecb2c1..d4c08d2d9 100644 --- a/admin/src/pages/flyway/WipRegisterForm.tsx +++ b/admin/src/pages/flyway/WipRegisterForm.tsx @@ -122,7 +122,7 @@ export const WipRegisterForm = ({ From 2ec3754847685a9b2930ff4396f42c6bf7ed12a8 Mon Sep 17 00:00:00 2001 From: ryan-dia Date: Tue, 16 Jun 2026 10:24:56 +0900 Subject: [PATCH 3/3] =?UTF-8?q?feat:=20Flyway=20=ED=98=95=EC=83=81=20?= =?UTF-8?q?=EC=83=88=EB=A1=9C=EA=B3=A0=EC=B9=A8=20=EB=B2=84=ED=8A=BC=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20=EB=B0=8F=20=EC=BA=90=EC=8B=9C=20staleTime?= =?UTF-8?q?=201=EC=8B=9C=EA=B0=84=EC=9C=BC=EB=A1=9C=20=EC=A1=B0=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 서버 캐시 TTL(1시간)에 맞춰 클라이언트 staleTime도 1시간으로 조정하고, 즉시 갱신이 필요한 경우를 위한 새로고침 버튼을 헤더에 추가. 버튼 클릭 시 DELETE /flyway/cache 호출 후 overview 쿼리를 무효화해 재조회. --- admin/src/apis/flyway/flyway.api.ts | 6 ++++++ admin/src/apis/flyway/flyway.query.ts | 6 +++++- admin/src/pages/flyway/FlywayViewer.tsx | 16 +++++++++++++++- 3 files changed, 26 insertions(+), 2 deletions(-) diff --git a/admin/src/apis/flyway/flyway.api.ts b/admin/src/apis/flyway/flyway.api.ts index 88a28d1ae..9d92c1067 100644 --- a/admin/src/apis/flyway/flyway.api.ts +++ b/admin/src/apis/flyway/flyway.api.ts @@ -86,3 +86,9 @@ export const createWipIssue = async (payload: CreateWipIssuePayload) => { body: payload, }); }; + +export const refreshFlywayCache = async () => { + return fetcher.delete({ + path: '/flyway/cache', + }); +}; diff --git a/admin/src/apis/flyway/flyway.query.ts b/admin/src/apis/flyway/flyway.query.ts index aa16d13ca..953fff720 100644 --- a/admin/src/apis/flyway/flyway.query.ts +++ b/admin/src/apis/flyway/flyway.query.ts @@ -3,9 +3,10 @@ import { getFlywayOverview, getMigrationScript, createWipIssue, + refreshFlywayCache, } from './flyway.api'; -const OVERVIEW_STALE_TIME = 1000 * 30; // 30s +const OVERVIEW_STALE_TIME = 1000 * 60 * 60; // 1h (서버 캐시 TTL과 동일) const SCRIPT_STALE_TIME = 1000 * 60 * 10; // 10m export const flywayQueries = { @@ -30,5 +31,8 @@ export const flywayQueries = { createWip: () => ({ mutationFn: createWipIssue, }), + refreshCache: () => ({ + mutationFn: refreshFlywayCache, + }), }, }; diff --git a/admin/src/pages/flyway/FlywayViewer.tsx b/admin/src/pages/flyway/FlywayViewer.tsx index 3508a696c..8f35d24bb 100644 --- a/admin/src/pages/flyway/FlywayViewer.tsx +++ b/admin/src/pages/flyway/FlywayViewer.tsx @@ -1,6 +1,6 @@ import { EditorView } from '@codemirror/view'; import styled from '@emotion/styled'; -import { useQuery } from '@tanstack/react-query'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import CodeMirror from '@uiw/react-codemirror'; import { useMemo, useState } from 'react'; import { WipRegisterForm } from './WipRegisterForm'; @@ -51,7 +51,14 @@ const parseVersion = (version: string) => .map((token) => Number(token) || 0); export const FlywayViewer = () => { + const queryClient = useQueryClient(); const { data, isLoading, isError } = useQuery(flywayQueries.overview()); + const { mutate: refresh, isPending: isRefreshing } = useMutation({ + ...flywayQueries.mutation.refreshCache(), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: flywayQueries.all }); + }, + }); const [keyword, setKeyword] = useState(''); const [statusFilter, setStatusFilter] = useState<'ALL' | MigrationStatus>( 'ALL', @@ -118,6 +125,13 @@ export const FlywayViewer = () => { {data.integrationBranch} +