diff --git a/public/assets/brand/logo.png b/public/assets/brand/logo.png new file mode 100644 index 0000000..5c87d02 Binary files /dev/null and b/public/assets/brand/logo.png differ diff --git a/src/components/ui.tsx b/src/components/ui.tsx index 772201c..13abb32 100644 --- a/src/components/ui.tsx +++ b/src/components/ui.tsx @@ -110,7 +110,8 @@ export function Modal({ children, footer, onClose, - className + className, + bodyClassName }: { open: boolean; title: ReactNode; @@ -118,6 +119,7 @@ export function Modal({ footer?: ReactNode; onClose: () => void; className?: string; + bodyClassName?: string; }) { if (!open) return null; @@ -135,7 +137,7 @@ export function Modal({ -
{children}
+
{children}
{footer ?
{footer}
: null} diff --git a/src/pages/launcher/LauncherBuildPage.tsx b/src/pages/launcher/LauncherBuildPage.tsx index 1c5e530..f2a928d 100644 --- a/src/pages/launcher/LauncherBuildPage.tsx +++ b/src/pages/launcher/LauncherBuildPage.tsx @@ -272,10 +272,16 @@ export function LauncherBuildPage() { title="빌드 관리" description="런처 클라이언트 설치 파일과 백엔드 컨테이너 이미지를 구성요소별로 빌드합니다." actions={ - +
+ + +
} /> @@ -381,12 +387,6 @@ export function LauncherBuildPage() { } /> -
- -
{jobs.length === 0 ? (
diff --git a/src/pages/launcher/LauncherComponentsPage.tsx b/src/pages/launcher/LauncherComponentsPage.tsx index cd4757b..9e18f51 100644 --- a/src/pages/launcher/LauncherComponentsPage.tsx +++ b/src/pages/launcher/LauncherComponentsPage.tsx @@ -232,7 +232,7 @@ export function LauncherComponentsPage() { title="구성요소" description="런처 클라이언트, 백엔드, DB의 배포 기준과 런타임 상태를 한 화면에서 확인합니다." actions={ - diff --git a/src/pages/launcher/LauncherSourcePage.tsx b/src/pages/launcher/LauncherSourcePage.tsx index d085b30..cd16798 100644 --- a/src/pages/launcher/LauncherSourcePage.tsx +++ b/src/pages/launcher/LauncherSourcePage.tsx @@ -50,6 +50,14 @@ const commitRangeLabels: Record = { const commitPageSize = 5; +type DiffFile = { + id: string; + path: string; + lines: string[]; + added: number; + removed: number; +}; + function repoFullName(repo: GiteaRepo) { return (repo as GiteaRepo & { fullName?: string }).fullName ?? repo.full_name ?? normalizeRepository(repo.name); } @@ -130,38 +138,118 @@ function commitRangeSince(range: CommitRange) { return date.toISOString(); } +function normalizeDiffPath(value?: string) { + if (!value) return ''; + const path = value.trim(); + if (path === '/dev/null') return path; + return path.replace(/^[ab]\//, ''); +} + +function diffPathFromHeader(line: string) { + const match = line.match(/^diff --git a\/(.+?) b\/(.+)$/); + return normalizeDiffPath(match?.[2] ?? match?.[1]); +} + +function parseDiffFiles(diff: string): DiffFile[] { + if (!diff.trim()) { + return [{ id: 'empty', path: '변경 내역', lines: ['표시할 변경 내역이 없습니다.'], added: 0, removed: 0 }]; + } + + const files: DiffFile[] = []; + let current: DiffFile | null = null; + + const pushCurrent = () => { + if (current) files.push(current); + }; + + diff.split(/\r?\n/).forEach((line) => { + if (line.startsWith('diff --git ')) { + pushCurrent(); + current = { + id: `file-${files.length}`, + path: diffPathFromHeader(line) || '변경 파일', + lines: [line], + added: 0, + removed: 0 + }; + return; + } + + if (!current) { + current = { + id: 'file-0', + path: '변경 내역', + lines: [], + added: 0, + removed: 0 + }; + } + + if (line.startsWith('+++ ')) { + const nextPath = normalizeDiffPath(line.slice(4)); + if (nextPath && nextPath !== '/dev/null') current.path = nextPath; + } + if (line.startsWith('+') && !line.startsWith('+++')) current.added += 1; + if (line.startsWith('-') && !line.startsWith('---')) current.removed += 1; + current.lines.push(line); + }); + + pushCurrent(); + return files; +} + function diffLineClass(line: string) { if (line.startsWith('+++') || line.startsWith('---')) { - return 'bg-sky-500/10 text-sky-200'; + return 'bg-[#111827] text-sky-200'; } if (line.startsWith('@@')) { - return 'bg-violet-500/15 text-violet-200'; + return 'bg-[#1e1b4b] text-violet-100'; } if (line.startsWith('+')) { - return 'bg-emerald-500/15 text-emerald-100'; + return 'bg-[#123524] text-emerald-50'; } if (line.startsWith('-')) { - return 'bg-rose-500/15 text-rose-100'; + return 'bg-[#3a1418] text-rose-50'; } - if (line.startsWith('diff --git') || line.startsWith('index ')) { - return 'bg-muted/40 text-foreground'; + if ( + line.startsWith('diff --git') + || line.startsWith('index ') + || line.startsWith('new file mode') + || line.startsWith('deleted file mode') + ) { + return 'bg-[#0f172a] text-slate-200'; } - return 'text-muted-foreground'; + return 'text-slate-300'; } function DiffViewer({ diff }: { diff: string }) { - const lines = diff ? diff.split(/\r?\n/) : ['표시할 변경 내역이 없습니다.']; + const files = parseDiffFiles(diff); return ( -
- {lines.map((line, index) => ( -
- - {index + 1} - -
{line || ' '}
-
- ))} +
+
+ {files.map((file) => ( +
+
+
+

{file.path}

+
+
+ +{file.added} + -{file.removed} +
+
+ {file.lines.map((line, index) => ( +
+ + {index + 1} + +
{line || ' '}
+
+ ))} +
+ ))} +
); } @@ -451,7 +539,7 @@ export function LauncherSourcePage() {
} /> -
+
@@ -465,17 +553,17 @@ export function LauncherSourcePage() { {commitsLoading ? ( - + ) : null} {!commitsLoading && commitsError ? ( - + ) : null} {!commitsLoading && !commitsError && commits.length === 0 ? ( - + ) : null} {!commitsLoading && !commitsError ? commits.map((commit) => ( @@ -529,7 +617,8 @@ export function LauncherSourcePage() { open={Boolean(diffTarget)} title="커밋 내역 상세" onClose={() => setDiffTarget(null)} - className="max-w-[min(96vw,1400px)]" + className="max-w-[min(98vw,1800px)]" + bodyClassName="max-h-[84vh] px-4 py-4" footer={} > {diffTarget ? ( diff --git a/src/pages/runtime/RuntimeConfigPage.tsx b/src/pages/runtime/RuntimeConfigPage.tsx index f92e506..d045e81 100644 --- a/src/pages/runtime/RuntimeConfigPage.tsx +++ b/src/pages/runtime/RuntimeConfigPage.tsx @@ -1,5 +1,5 @@ import { useEffect, useMemo, useState } from 'react'; -import { Play, Plus, RotateCcw, Square, Trash2 } from 'lucide-react'; +import { Plus, RotateCcw, Square, Trash2 } from 'lucide-react'; import { Badge, Button, Card, Modal } from '../../components/ui'; import { useToast } from '../../components/toast'; import { PageTitle } from '../../layout/AppLayout'; @@ -161,7 +161,7 @@ export function RuntimeConfigPage() { setProfileNamespace(nextApp?.namespace ?? ''); setVariableComponentId(nextApp?.components?.[0]?.id ?? ''); setIngressComponentId(nextApp?.components?.[0]?.id ?? ''); - setIngressPort(nextApp?.components?.[0]?.port ? String(nextApp.components[0].port) : ''); + setIngressPort(''); return nextAppId ? loadRuntimeProfiles(nextAppId) : Promise.resolve([]); }) .then((nextProfiles) => applyProfiles(nextProfiles)) @@ -176,7 +176,7 @@ export function RuntimeConfigPage() { setProfileNamespace(nextApp?.namespace ?? ''); setVariableComponentId(nextApp?.components?.[0]?.id ?? ''); setIngressComponentId(nextApp?.components?.[0]?.id ?? ''); - setIngressPort(nextApp?.components?.[0]?.port ? String(nextApp.components[0].port) : ''); + setIngressPort(''); refreshProfiles(nextAppId).catch((loadError: Error) => setError(loadError.message)); }; @@ -209,14 +209,17 @@ export function RuntimeConfigPage() { const addVariable = () => { if (!selectedProfile || !variableComponentId || !variableKey.trim() || !variableValue.trim()) return; + const profile = selectedProfile; const key = variableKey.trim(); setError(''); + setActionKey(`variable-add-${profile.id}`); createRuntimeVariable(selectedProfile.id, { componentId: variableComponentId, key, value: variableValue.trim(), isSecret: variableIsSecret }) + .then(() => applyRuntimeProfile(profile.id)) .then(() => { setVariableKey(''); setVariableValue(''); @@ -224,58 +227,80 @@ export function RuntimeConfigPage() { toast({ type: 'success', title: '설정값 등록', - message: `${key} 설정값을 추가했습니다. 실행 중인 파드에는 적용 버튼을 눌러야 반영됩니다.` + message: `${key} 설정값을 추가하고 클러스터 설정에 반영했습니다.` }); - refreshProfiles(appId, selectedProfile.id); + refreshResources(profile.namespace); + refreshProfiles(appId, profile.id); }) .catch((createError: Error) => { setError(createError.message); toast({ type: 'error', title: '설정값 등록 실패', message: createError.message }); - }); + }) + .finally(() => setActionKey('')); }; const removeVariable = (variableId: string) => { if (!selectedProfile) return; - deleteRuntimeVariable(selectedProfile.id, variableId) - .then(() => refreshProfiles(appId, selectedProfile.id)) - .catch((deleteError: Error) => setError(deleteError.message)); + const profile = selectedProfile; + setActionKey(`variable-delete-${variableId}`); + deleteRuntimeVariable(profile.id, variableId) + .then(() => applyRuntimeProfile(profile.id)) + .then(() => { + toast({ type: 'success', title: '설정값 삭제', message: '설정값을 삭제하고 클러스터 설정에 반영했습니다.' }); + refreshResources(profile.namespace); + refreshProfiles(appId, profile.id); + }) + .catch((deleteError: Error) => { + setError(deleteError.message); + toast({ type: 'error', title: '설정값 삭제 실패', message: deleteError.message }); + }) + .finally(() => setActionKey('')); }; const addIngressRule = () => { if (!selectedProfile || !ingressComponentId || !ingressHost.trim() || !ingressPort.trim()) return; - createRuntimeIngressRule(selectedProfile.id, { + const profile = selectedProfile; + const host = ingressHost.trim(); + setActionKey(`ingress-add-${profile.id}`); + createRuntimeIngressRule(profile.id, { componentId: ingressComponentId, - host: ingressHost.trim(), + host, path: ingressPath.trim() || '/', servicePort: Number(ingressPort), stripPrefix: ingressStripPrefix, tlsEnabled: false }) + .then(() => applyRuntimeProfile(profile.id)) .then(() => { setIngressHost(''); setIngressPath('/'); setIngressStripPrefix(false); - refreshProfiles(appId, selectedProfile.id); + toast({ type: 'success', title: '접속 설정 등록', message: `${host} 접속 설정을 추가하고 클러스터 설정에 반영했습니다.` }); + refreshResources(profile.namespace); + refreshProfiles(appId, profile.id); }) - .catch((createError: Error) => setError(createError.message)); + .catch((createError: Error) => { + setError(createError.message); + toast({ type: 'error', title: '접속 설정 등록 실패', message: createError.message }); + }) + .finally(() => setActionKey('')); }; const removeIngressRule = (ruleId: string) => { if (!selectedProfile) return; - deleteRuntimeIngressRule(selectedProfile.id, ruleId) - .then(() => refreshProfiles(appId, selectedProfile.id)) - .catch((deleteError: Error) => setError(deleteError.message)); - }; - - const applyProfile = (profile = selectedProfile) => { - if (!profile) return; - setActionKey(`profile-${profile.id}`); - applyRuntimeProfile(profile.id) + const profile = selectedProfile; + setActionKey(`ingress-delete-${ruleId}`); + deleteRuntimeIngressRule(profile.id, ruleId) + .then(() => applyRuntimeProfile(profile.id)) .then(() => { + toast({ type: 'success', title: '접속 설정 삭제', message: '접속 설정을 삭제하고 클러스터 설정에 반영했습니다.' }); refreshResources(profile.namespace); refreshProfiles(appId, profile.id); }) - .catch((applyError: Error) => setError(applyError.message)) + .catch((deleteError: Error) => { + setError(deleteError.message); + toast({ type: 'error', title: '접속 설정 삭제 실패', message: deleteError.message }); + }) .finally(() => setActionKey('')); }; @@ -422,20 +447,6 @@ export function RuntimeConfigPage() { 수정 {formatDate(profile.updatedAt ?? profile.createdAt)} -
- -
))} @@ -478,7 +489,7 @@ export function RuntimeConfigPage() { @@ -524,9 +535,9 @@ export function RuntimeConfigPage() { placeholder="값 입력" /> - @@ -570,7 +581,7 @@ export function RuntimeConfigPage() { @@ -592,9 +603,8 @@ export function RuntimeConfigPage() { value={ingressComponentId} onChange={(event) => { const nextId = event.target.value; - const component = components.find((item) => item.id === nextId); setIngressComponentId(nextId); - setIngressPort(component?.port ? String(component.port) : ''); + setIngressPort(''); }} > {components.map((component) => )} @@ -624,9 +634,9 @@ export function RuntimeConfigPage() { /> Path prefix 제거 - diff --git a/src/pages/runtime/RuntimeLogsPage.tsx b/src/pages/runtime/RuntimeLogsPage.tsx index 14ae1d8..dd4e739 100644 --- a/src/pages/runtime/RuntimeLogsPage.tsx +++ b/src/pages/runtime/RuntimeLogsPage.tsx @@ -230,7 +230,7 @@ export function RuntimeLogsPage() { title="로그/이벤트" description="네임스페이스 기준으로 워크로드 로그와 이벤트를 확인합니다." actions={( -
커밋을 불러오는 중입니다.커밋을 불러오는 중입니다.
커밋 조회 실패: {commitsError}커밋 조회 실패: {commitsError}
표시할 커밋이 없습니다.표시할 커밋이 없습니다.
{variable.isSecret ? '********' : variable.value} {formatDate(variable.createdAt)} - {rule.servicePort} {rule.stripPrefix ? '제거' : '유지'} -