paginate app operations and refine runtime settings layout
ci/woodpecker/push/woodpecker Pipeline failed
ci/woodpecker/push/woodpecker Pipeline failed
This commit is contained in:
@@ -0,0 +1,168 @@
|
|||||||
|
# Studio Admin Page Agent Guide
|
||||||
|
|
||||||
|
이 문서는 웹 어드민 안에서 동작하는 PageAgent가 화면을 이해하고 작업할 때 따를 기준이다.
|
||||||
|
|
||||||
|
## 기본 원칙
|
||||||
|
|
||||||
|
- 항상 한국어로 간결하게 답한다.
|
||||||
|
- 사용자가 별도로 허용하지 않는 한 `플랫폼` 그룹의 하위 메뉴에서만 이동하고 작업한다.
|
||||||
|
- `운영`, `시스템` 등 `플랫폼` 외 메뉴에는 절대 진입하지 않는다. 사용자가 해당 메뉴 작업을 요청하면 직접 이동하지 말고 권한 범위 밖이라고 설명한다.
|
||||||
|
- 사용자가 명령한 화면 안에서만 작업하고, 임의로 배포/삭제/중지 같은 위험 작업을 실행하지 않는다.
|
||||||
|
- 삭제, 제거, 폐기, 정리, revoke, unlink, delete, remove, decommission, PVC 삭제, 데이터 볼륨 삭제, 워크로드 삭제처럼 데이터를 없애거나 연결을 끊는 명령은 절대 실행하지 않는다. 사용자가 요청해도 실제 클릭이나 API 실행을 하지 말고, 사람이 직접 수행해야 한다고 안내한다.
|
||||||
|
- 저장, 삭제, 배포, 중지, 재시작, 릴리즈 생성 같은 상태 변경 작업은 실행 직전에 사용자 확인을 받는다.
|
||||||
|
- 사용자가 단순히 위치나 방법을 물으면 실제 클릭하지 말고 경로와 의미를 설명한다.
|
||||||
|
- 앱 저장소는 사람이 `develop`에 push하고, 앱 릴리즈는 어드민웹 릴리즈 흐름으로 처리한다. 앱 repo에서 `release` 직접 merge/push를 안내하지 않는다.
|
||||||
|
- 사용자가 "앱 추가", "앱 새로 등록", "애플리케이션 추가"를 요청하면 빌드부터 실행하지 않는다. 앱 그룹 등록, 구성요소 등록, 저장소 생성, 저장소 연결이 끝난 뒤에만 빌드 관리로 이동한다.
|
||||||
|
|
||||||
|
## 주요 메뉴 구조
|
||||||
|
|
||||||
|
- `대시보드`: 플랫폼 현재 상태, 공통앱 상태, 라이센스 사용률을 확인한다.
|
||||||
|
- `런처 > 소스 관리`: 런처 클라이언트/백엔드 저장소 연결, clone URL 확인, 조회 브랜치별 커밋 목록과 diff 확인.
|
||||||
|
- `런처 > 빌드 관리`: 런처 빌드 작업 조회와 실행.
|
||||||
|
- `런처 > 버전 관리`: 런처 버전과 다운로드 산출물 관리.
|
||||||
|
- `런처 > 구성요소`: 런처 워크로드/서비스 구성요소 확인.
|
||||||
|
- `런처 > 공통 설정`: 런처 공통 설정 목록 확인.
|
||||||
|
- `어플리케이션 > 앱 관리`: 앱 그룹과 구성요소를 등록/수정한다. Redis 같은 외부 컨테이너도 구성요소로 등록한다.
|
||||||
|
- `어플리케이션 > 앱 관리`의 앱 삭제는 앱 폐기 흐름이다. 저장소 연결, 실행 설정, Kubernetes 워크로드, 리소스, PVC 정리 옵션을 확인한다.
|
||||||
|
- `어플리케이션 > 저장소 관리`: Gitea 저장소 생성, 앱 구성요소와 저장소 연결, 기본 브랜치/빌드 대상 확인.
|
||||||
|
- `어플리케이션 > 소스 관리`: 연결된 저장소의 커밋 목록과 diff를 조회한다. 조회 브랜치는 확인용이며 릴리즈 실행을 의미하지 않는다.
|
||||||
|
- `어플리케이션 > 빌드 관리`: develop 또는 release 기준 빌드 작업 조회/실행.
|
||||||
|
- `어플리케이션 > 릴리즈 관리`: 성공한 develop 빌드를 선택해 release 브랜치로 fast-forward 승격 후 release 기준 재빌드를 생성한다.
|
||||||
|
- `어플리케이션 > 배포 관리`: 성공한 릴리즈를 운영 환경에 배포한다. 이미 성공 배포된 릴리즈는 기본 후보에서 제외한다.
|
||||||
|
- `어플리케이션 > 실행 설정`: 앱 런타임 환경변수와 Secret 값을 관리한다. 추가/삭제는 즉시 저장된다.
|
||||||
|
- `어플리케이션 > 마이그레이션`: DB 마이그레이션 작업 실행/로그 확인.
|
||||||
|
- `어플리케이션 > 로그/이벤트`: namespace 기준 Kubernetes 리소스와 이벤트 확인.
|
||||||
|
아래 메뉴는 참고 정보로만 이해하고, 사용자가 명시적으로 허용하지 않으면 들어가지 않는다.
|
||||||
|
|
||||||
|
- `운영 > 관리자 관리`: 어드민 계정, 권한, SSH 키, 상태 관리.
|
||||||
|
- `운영 > 조직 관리`: 조직 등록/수정/삭제. 삭제는 비활성 처리로 목록에서 숨긴다.
|
||||||
|
- `운영 > 사용자/세션`: 사용자, 세션, 토큰 상태, 조직 정보를 확인하고 잠금/세션 종료를 수행한다.
|
||||||
|
- `운영 > 라이센스`: 라이센스 플랜과 사용자 라이센스 할당 관리.
|
||||||
|
- `운영 > 공지사항`: 런처/앱 공지 등록, 수정, 삭제.
|
||||||
|
|
||||||
|
## 앱 신규 등록 흐름
|
||||||
|
|
||||||
|
앱을 새로 추가할 때는 아래 순서를 반드시 지킨다.
|
||||||
|
|
||||||
|
1. `어플리케이션 > 앱 관리`에서 앱 그룹을 먼저 등록한다. 앱 이름, 앱 Key, namespace를 확인한다.
|
||||||
|
2. 같은 화면에서 FE, BE, DB, Redis, Worker 등 필요한 구성요소를 모두 등록한다.
|
||||||
|
3. `어플리케이션 > 저장소 관리`에서 필요한 Gitea 저장소를 생성한다. 저장소명에는 조직명 `platform/`을 직접 입력하지 않는다.
|
||||||
|
4. `어플리케이션 > 저장소 관리`의 저장소 연결 영역에서 각 소스 구성요소에 저장소, 기본 브랜치, 빌드 대상을 연결한다.
|
||||||
|
5. `어플리케이션 > 실행 설정`에서 필요한 환경변수와 Secret을 등록한다.
|
||||||
|
6. 위 단계가 끝난 뒤에만 `어플리케이션 > 빌드 관리`에서 develop 빌드를 실행한다.
|
||||||
|
7. 구성요소가 없거나 저장소 연결이 안 된 상태라면 빌드를 실행하지 말고, 누락된 항목을 사용자에게 알려준다.
|
||||||
|
|
||||||
|
## 앱 최초 등록부터 운영 배포까지
|
||||||
|
|
||||||
|
사용자가 앱을 처음 등록하고 배포까지 요청하면 아래 순서를 벗어나지 않는다.
|
||||||
|
|
||||||
|
1. `어플리케이션 > 앱 관리`에서 앱 그룹을 등록한다.
|
||||||
|
2. `어플리케이션 > 앱 관리`에서 필요한 구성요소를 모두 등록한다. FE, BE, Worker는 소스 빌드 구성요소로 등록하고, DB/Redis/외부 이미지는 컨테이너 또는 DB 구성요소로 등록한다.
|
||||||
|
3. `어플리케이션 > 저장소 관리`에서 FE/BE/Worker 등 소스가 필요한 저장소를 생성한다.
|
||||||
|
4. `어플리케이션 > 저장소 관리`에서 각 구성요소에 저장소, 기본 브랜치, 빌드 대상을 연결한다.
|
||||||
|
5. `어플리케이션 > 실행 설정`에서 운영에 필요한 환경변수와 Secret을 등록한다.
|
||||||
|
6. DB 마이그레이션 구성요소가 있으면 `어플리케이션 > 마이그레이션`에서 마이그레이션을 실행한다.
|
||||||
|
7. `어플리케이션 > 빌드 관리`에서 develop 빌드를 실행한다. 저장소 연결이 없는 구성요소는 빌드하지 않는다.
|
||||||
|
8. develop 빌드 성공 후 `어플리케이션 > 릴리즈 관리`에서 성공한 develop 빌드를 선택해 릴리즈를 생성한다.
|
||||||
|
9. 릴리즈 생성은 Admin BE가 develop에서 release로 fast-forward 승격한 뒤 release 기준 재빌드를 수행하는 흐름이다.
|
||||||
|
10. release 재빌드가 성공해 릴리즈 상태가 성공이 된 뒤에만 `어플리케이션 > 배포 관리`에서 배포를 실행한다.
|
||||||
|
|
||||||
|
앱 최초 등록 흐름에서 금지되는 행동:
|
||||||
|
|
||||||
|
- 앱 그룹만 등록하고 바로 빌드 관리로 이동하지 않는다.
|
||||||
|
- 구성요소 등록 전 저장소 연결을 시도하지 않는다.
|
||||||
|
- 저장소 연결 전 빌드를 실행하지 않는다.
|
||||||
|
- develop 빌드 성공 전 릴리즈를 만들지 않는다.
|
||||||
|
- 릴리즈 성공 전 배포를 실행하지 않는다.
|
||||||
|
- 앱 repo에서 release 브랜치로 직접 merge/push하라고 안내하지 않는다.
|
||||||
|
|
||||||
|
## 런처 최초 설정부터 배포까지
|
||||||
|
|
||||||
|
사용자가 런처를 처음 설정하고 배포까지 요청하면 아래 순서로 처리한다.
|
||||||
|
|
||||||
|
1. `런처 > 구성요소`에서 필요한 런처 구성요소를 등록한다.
|
||||||
|
2. `런처 > 소스 관리`에서 런처 구성요소에 저장소를 연결한다.
|
||||||
|
3. `런처 > 공통 설정`에서 런처가 사용하는 공통 설정값을 등록한다.
|
||||||
|
4. `런처 > 앱 연결`에서 런처에 노출할 앱을 연결하고 순서를 저장한다.
|
||||||
|
5. `런처 > 빌드 관리`에서 필요한 런처 빌드 작업을 실행한다.
|
||||||
|
6. 빌드 성공 후 `런처 > 버전 관리`에서 버전, 최소 지원 버전, 산출물 URL, 릴리즈 노트를 등록한다.
|
||||||
|
7. 필요한 경우 새 런처 버전을 활성화한다.
|
||||||
|
|
||||||
|
런처 흐름에서 금지되는 행동:
|
||||||
|
|
||||||
|
- 저장소 연결 없이 빌드를 실행하지 않는다.
|
||||||
|
- 빌드 성공 산출물이 없는데 버전만 먼저 활성화하지 않는다.
|
||||||
|
- 사용자가 명시적으로 요청하지 않은 앱 연결 순서 변경을 하지 않는다.
|
||||||
|
- 삭제, 제거, 폐기, unlink 계열 작업은 실행하지 않는다.
|
||||||
|
|
||||||
|
## 런처 버전 업데이트 흐름
|
||||||
|
|
||||||
|
이미 등록된 런처의 새 버전을 반영할 때는 아래 순서를 따른다.
|
||||||
|
|
||||||
|
1. 공통 설정 변경이 필요하면 `런처 > 공통 설정`에서 먼저 반영한다.
|
||||||
|
2. 런처 노출 앱이나 순서가 바뀌는 경우 `런처 > 앱 연결`에서 연결 상태를 저장한다. 사용자가 명시하지 않은 연결 변경은 하지 않는다.
|
||||||
|
3. `런처 > 빌드 관리`에서 새 버전에 필요한 빌드를 실행한다.
|
||||||
|
4. 빌드 작업이 성공하면 `런처 > 버전 관리`에서 새 버전, 최소 지원 버전, 다운로드 산출물, 릴리즈 노트를 등록하거나 갱신한다.
|
||||||
|
5. 필요한 경우 새 버전을 활성화한다.
|
||||||
|
|
||||||
|
런처 버전 업데이트 흐름에서 금지되는 행동:
|
||||||
|
|
||||||
|
- 빌드 성공 산출물이 없는 상태에서 버전만 활성화하지 않는다.
|
||||||
|
- 최소 지원 버전 변경은 사용자 요청 또는 정책 확인 없이 임의로 하지 않는다.
|
||||||
|
- 앱 연결 순서 변경은 사용자 요청 없이 하지 않는다.
|
||||||
|
- 삭제, 제거, 폐기, unlink 계열 작업은 실행하지 않는다.
|
||||||
|
|
||||||
|
## 앱 릴리즈 흐름
|
||||||
|
|
||||||
|
1. `어플리케이션 > 빌드 관리`에서 develop 빌드를 실행한다.
|
||||||
|
2. develop 빌드가 성공하면 `어플리케이션 > 릴리즈 관리`에서 성공한 develop 빌드를 선택한다.
|
||||||
|
3. Admin BE가 develop에서 release로 fast-forward 승격한다.
|
||||||
|
4. release 브랜치 기준으로 재빌드하고 성공 산출물을 릴리즈로 저장한다.
|
||||||
|
5. `어플리케이션 > 배포 관리`에서 성공 릴리즈를 운영 배포한다.
|
||||||
|
|
||||||
|
## 앱 버전 업데이트 흐름
|
||||||
|
|
||||||
|
이미 등록된 앱의 새 버전을 운영에 반영할 때는 아래 순서를 따른다.
|
||||||
|
|
||||||
|
1. 환경변수나 Secret 변경이 필요한 경우 `어플리케이션 > 실행 설정`에서 먼저 반영한다.
|
||||||
|
2. DB 변경이 있는 경우 `어플리케이션 > 마이그레이션`에서 마이그레이션을 실행한다.
|
||||||
|
3. `어플리케이션 > 빌드 관리`에서 대상 구성요소의 develop 빌드를 실행한다.
|
||||||
|
4. develop 빌드가 성공하면 `어플리케이션 > 릴리즈 관리`에서 성공한 develop 빌드를 선택해 새 릴리즈 버전을 생성한다.
|
||||||
|
5. 릴리즈 생성 중 Admin BE가 develop에서 release로 fast-forward 승격하고 release 기준 재빌드를 실행한다.
|
||||||
|
6. release 재빌드가 성공하면 `어플리케이션 > 배포 관리`에서 성공 릴리즈를 운영 배포한다.
|
||||||
|
|
||||||
|
앱 버전 업데이트 흐름에서 금지되는 행동:
|
||||||
|
|
||||||
|
- 앱 repo의 release 브랜치로 직접 merge/push하라고 안내하지 않는다.
|
||||||
|
- develop 빌드 성공 전 릴리즈를 생성하지 않는다.
|
||||||
|
- 릴리즈 성공 전 배포를 실행하지 않는다.
|
||||||
|
- 이미 배포된 릴리즈를 다시 배포 후보로 선택하지 않는다.
|
||||||
|
- 삭제, 제거, 폐기, unlink 계열 작업은 실행하지 않는다.
|
||||||
|
|
||||||
|
## 앱 폐기 흐름
|
||||||
|
|
||||||
|
사용자가 앱 삭제, 앱 제거, 앱 폐기를 요청하면 PageAgent는 실행하지 않는다. 아래 옵션의 의미만 설명하고, 실제 삭제/폐기 버튼 클릭은 사람이 직접 해야 한다.
|
||||||
|
|
||||||
|
- `저장소 연결 정보 삭제`: 구성요소의 repository, branch, buildTarget 연결을 지운다.
|
||||||
|
- `실행 설정 삭제`: 앱 런타임 설정 정리를 의미한다. 기본 선택 대상이다.
|
||||||
|
- `Kubernetes 워크로드 중지`: Deployment/StatefulSet replicas를 0으로 내려 실행을 멈춘다. 기본 선택 대상이다.
|
||||||
|
- `Kubernetes 리소스 삭제`: 앱 구성요소 이름 기준의 Deployment, StatefulSet, Service, Ingress, ConfigMap, Secret을 삭제한다. 사용자가 명시적으로 원할 때만 선택한다.
|
||||||
|
- `PVC/데이터 볼륨 삭제`: DB/StatefulSet 데이터 삭제로 이어질 수 있다. 사용자가 데이터 삭제와 백업 확인을 명시한 경우에만 선택한다.
|
||||||
|
|
||||||
|
기본적으로 `저장소 연결 정보 삭제`, `실행 설정 삭제`, `Kubernetes 워크로드 중지`만 선택한다. `Kubernetes 리소스 삭제`와 `PVC/데이터 볼륨 삭제`는 위험 작업이므로 임의로 선택하지 않는다.
|
||||||
|
|
||||||
|
## 구성요소 등록 기준
|
||||||
|
|
||||||
|
- 소스가 있는 FE/BE/Worker는 저장소를 연결하고 빌드 대상으로 관리한다.
|
||||||
|
- Redis 같은 외부 이미지는 `CONTAINER` 유형과 `외부 이미지`로 등록한다.
|
||||||
|
- Redis 예시: 이름 `studio-auth-redis`, 이미지 `redis:7-alpine`, 포트 `6379`, targetPort `6379`, replicas `1`.
|
||||||
|
- Redis를 사용하는 BE에는 실행 설정에 `REDIS_URL=redis://studio-auth-redis:6379`처럼 등록한다.
|
||||||
|
- DB/MIGRATION 구성요소는 저장소 연결 목록에서 숨겨질 수 있다.
|
||||||
|
|
||||||
|
## 용어
|
||||||
|
|
||||||
|
- `저장소 관리`: 저장소 생성과 구성요소 연결을 담당한다.
|
||||||
|
- `소스 관리`: 커밋 목록과 diff 조회를 담당한다.
|
||||||
|
- `조회 브랜치`: 커밋을 볼 브랜치일 뿐, 릴리즈나 배포 실행을 의미하지 않는다.
|
||||||
|
- `세션`: refresh token/session 식별자다.
|
||||||
|
- `상태`: 세션 또는 사용자 상태 판정값이다. 예: 활성, 만료, 종료, 잠김, 없음.
|
||||||
Generated
+22
@@ -11,6 +11,7 @@
|
|||||||
"@tailwindcss/vite": "^4.3.0",
|
"@tailwindcss/vite": "^4.3.0",
|
||||||
"@vitejs/plugin-react": "^4.4.1",
|
"@vitejs/plugin-react": "^4.4.1",
|
||||||
"lucide-react": "^1.14.0",
|
"lucide-react": "^1.14.0",
|
||||||
|
"page-agent": "file:../../page-agent/packages/page-agent",
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
"react-dom": "^19.1.0",
|
"react-dom": "^19.1.0",
|
||||||
"react-router-dom": "^7.18.0",
|
"react-router-dom": "^7.18.0",
|
||||||
@@ -23,6 +24,23 @@
|
|||||||
"typescript": "^5.8.3"
|
"typescript": "^5.8.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"../../page-agent/packages/page-agent": {
|
||||||
|
"version": "1.11.0",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@page-agent/core": "1.11.0",
|
||||||
|
"@page-agent/llms": "1.11.0",
|
||||||
|
"@page-agent/page-controller": "1.11.0",
|
||||||
|
"@page-agent/ui": "1.11.0",
|
||||||
|
"chalk": "^5.6.2"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"zod": "^4.4.3"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"zod": "^3.25.0 || ^4.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@babel/code-frame": {
|
"node_modules/@babel/code-frame": {
|
||||||
"version": "7.29.7",
|
"version": "7.29.7",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
|
||||||
@@ -2065,6 +2083,10 @@
|
|||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/page-agent": {
|
||||||
|
"resolved": "../../page-agent/packages/page-agent",
|
||||||
|
"link": true
|
||||||
|
},
|
||||||
"node_modules/picocolors": {
|
"node_modules/picocolors": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
"@tailwindcss/vite": "^4.3.0",
|
"@tailwindcss/vite": "^4.3.0",
|
||||||
"@vitejs/plugin-react": "^4.4.1",
|
"@vitejs/plugin-react": "^4.4.1",
|
||||||
"lucide-react": "^1.14.0",
|
"lucide-react": "^1.14.0",
|
||||||
|
"page-agent": "file:../../page-agent/packages/page-agent",
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
"react-dom": "^19.1.0",
|
"react-dom": "^19.1.0",
|
||||||
"react-router-dom": "^7.18.0",
|
"react-router-dom": "^7.18.0",
|
||||||
|
|||||||
+64
-34
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useRef, useState, type FormEvent, type ReactNode } from 'react';
|
import { useEffect, useRef, useState, type FormEvent, type MouseEvent as ReactMouseEvent, type ReactNode } from 'react';
|
||||||
import { useLocation, useNavigate } from 'react-router-dom';
|
import { useLocation, useNavigate } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
Activity,
|
Activity,
|
||||||
@@ -17,6 +17,7 @@ import { formatDateTime as formatDateTimeValue } from './lib/date-format';
|
|||||||
import { apiBaseUrl, apiRequest, authExpiredEventName, clearAdminSession } from './lib/api';
|
import { apiBaseUrl, apiRequest, authExpiredEventName, clearAdminSession } from './lib/api';
|
||||||
import { filterAppsByCurrentAdmin } from './lib/admin-app-permissions';
|
import { filterAppsByCurrentAdmin } from './lib/admin-app-permissions';
|
||||||
import { cn } from './lib/utils';
|
import { cn } from './lib/utils';
|
||||||
|
import { openPageAgent } from './lib/page-agent';
|
||||||
import { AppManagementPage as AppManagementApiPage } from './pages/applications/AppManagementPage';
|
import { AppManagementPage as AppManagementApiPage } from './pages/applications/AppManagementPage';
|
||||||
import { BuildManagementPage } from './pages/applications/BuildManagementPage';
|
import { BuildManagementPage } from './pages/applications/BuildManagementPage';
|
||||||
import { DeploymentManagementPage } from './pages/applications/DeploymentManagementPage';
|
import { DeploymentManagementPage } from './pages/applications/DeploymentManagementPage';
|
||||||
@@ -446,6 +447,19 @@ function filterNavGroupsByRole(groups: typeof navGroups, role?: string | null) {
|
|||||||
.filter((group) => group.items.length > 0);
|
.filter((group) => group.items.length > 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function pageAgentAccessAttrs(allowed: boolean) {
|
||||||
|
return allowed
|
||||||
|
? {}
|
||||||
|
: {
|
||||||
|
'data-page-agent-ignore': 'true',
|
||||||
|
'data-page-agent-not-interactive': 'true'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function isTrustedClick(event: ReactMouseEvent<HTMLElement>) {
|
||||||
|
return event.nativeEvent.isTrusted;
|
||||||
|
}
|
||||||
|
|
||||||
function Sidebar({
|
function Sidebar({
|
||||||
activeView,
|
activeView,
|
||||||
menuSearch,
|
menuSearch,
|
||||||
@@ -491,8 +505,11 @@ function Sidebar({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nav className="scrollbar-stable min-h-0 flex-1 space-y-5 overflow-y-auto pr-2">
|
<nav className="scrollbar-stable min-h-0 flex-1 space-y-5 overflow-y-auto pr-2">
|
||||||
{visibleGroups.map((group) => (
|
{visibleGroups.map((group) => {
|
||||||
<div key={group.label}>
|
const isPageAgentAllowedGroup = group.label === '플랫폼';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={group.label} {...pageAgentAccessAttrs(isPageAgentAllowedGroup)}>
|
||||||
<p className="mb-2 px-2 text-xs font-medium text-muted-foreground">{group.label}</p>
|
<p className="mb-2 px-2 text-xs font-medium text-muted-foreground">{group.label}</p>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
{group.items.map((item) => {
|
{group.items.map((item) => {
|
||||||
@@ -504,12 +521,14 @@ function Sidebar({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={item.label}>
|
<div key={item.label}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
{...pageAgentAccessAttrs(isPageAgentAllowedGroup)}
|
||||||
if (hasChildren) {
|
onClick={(event) => {
|
||||||
setOpenMenus((prev) => ({ ...prev, [item.label]: !prev[item.label] }));
|
if (!isPageAgentAllowedGroup && !isTrustedClick(event)) return;
|
||||||
return;
|
if (hasChildren) {
|
||||||
|
setOpenMenus((prev) => ({ ...prev, [item.label]: !prev[item.label] }));
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
const view = resolveNavView(item);
|
const view = resolveNavView(item);
|
||||||
if (view) onViewChange(view);
|
if (view) onViewChange(view);
|
||||||
@@ -535,13 +554,15 @@ function Sidebar({
|
|||||||
const isChildActive = activeView === resolveNavView(child);
|
const isChildActive = activeView === resolveNavView(child);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
key={child.label}
|
key={child.label}
|
||||||
onClick={() => {
|
{...pageAgentAccessAttrs(isPageAgentAllowedGroup)}
|
||||||
const view = resolveNavView(child);
|
onClick={(event) => {
|
||||||
if (view) onViewChange(view);
|
if (!isPageAgentAllowedGroup && !isTrustedClick(event)) return;
|
||||||
}}
|
const view = resolveNavView(child);
|
||||||
|
if (view) onViewChange(view);
|
||||||
|
}}
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex h-8 w-full items-center gap-2 rounded-md px-2 text-left text-[12.5px] font-medium text-muted-foreground transition-colors hover:bg-sidebar-accent hover:text-white',
|
'flex h-8 w-full items-center gap-2 rounded-md px-2 text-left text-[12.5px] font-medium text-muted-foreground transition-colors hover:bg-sidebar-accent hover:text-white',
|
||||||
isChildActive && 'font-semibold text-white'
|
isChildActive && 'font-semibold text-white'
|
||||||
@@ -559,7 +580,8 @@ function Sidebar({
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
{visibleGroups.length === 0 ? (
|
{visibleGroups.length === 0 ? (
|
||||||
<div className="px-2 py-6 text-center text-xs text-muted-foreground">검색된 메뉴가 없습니다.</div>
|
<div className="px-2 py-6 text-center text-xs text-muted-foreground">검색된 메뉴가 없습니다.</div>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -633,7 +655,7 @@ function Topbar({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Button variant="ghost" className="size-9 px-0">
|
<Button variant="ghost" className="size-9 px-0" title="AI 도움말" aria-label="AI 도움말" onClick={openPageAgent}>
|
||||||
<CircleHelp className="size-4" />
|
<CircleHelp className="size-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<div ref={profileRef} className="relative">
|
<div ref={profileRef} className="relative">
|
||||||
@@ -1028,16 +1050,20 @@ function SessionsPanel({ sessions, loading, onMore }: { sessions: UserSessionRow
|
|||||||
<Card>
|
<Card>
|
||||||
<CardHeader
|
<CardHeader
|
||||||
title="사용자/세션"
|
title="사용자/세션"
|
||||||
description="실시간 접근 상태와 세션 제어 항목입니다."
|
description="실시간 접근 상태와 세션 제어 항목입니다."
|
||||||
action={
|
action={
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="flex size-8 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
{...pageAgentAccessAttrs(false)}
|
||||||
onClick={onMore}
|
className="flex size-8 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||||
title="사용자/세션으로 이동"
|
onClick={(event) => {
|
||||||
aria-label="사용자/세션으로 이동"
|
if (!isTrustedClick(event)) return;
|
||||||
>
|
onMore();
|
||||||
<ChevronRight className="size-4" />
|
}}
|
||||||
|
title="사용자/세션으로 이동"
|
||||||
|
aria-label="사용자/세션으로 이동"
|
||||||
|
>
|
||||||
|
<ChevronRight className="size-4" />
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -1077,13 +1103,17 @@ function SessionsPanel({ sessions, loading, onMore }: { sessions: UserSessionRow
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DashboardLinkButton({ onClick, label }: { onClick: () => void; label: string }) {
|
function DashboardLinkButton({ onClick, label, pageAgentAllowed = true }: { onClick: () => void; label: string; pageAgentAllowed?: boolean }) {
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="flex size-8 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
{...pageAgentAccessAttrs(pageAgentAllowed)}
|
||||||
onClick={onClick}
|
className="flex size-8 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||||
title={label}
|
onClick={(event) => {
|
||||||
|
if (!pageAgentAllowed && !isTrustedClick(event)) return;
|
||||||
|
onClick();
|
||||||
|
}}
|
||||||
|
title={label}
|
||||||
aria-label={label}
|
aria-label={label}
|
||||||
>
|
>
|
||||||
<ChevronRight className="size-4" />
|
<ChevronRight className="size-4" />
|
||||||
@@ -1270,7 +1300,7 @@ function NoticePanel({ notices: dashboardNotices, loading, onOpen }: { notices:
|
|||||||
<CardHeader
|
<CardHeader
|
||||||
title="공지사항"
|
title="공지사항"
|
||||||
description="런처 공지와 앱별 공지사항을 관리합니다."
|
description="런처 공지와 앱별 공지사항을 관리합니다."
|
||||||
action={<DashboardLinkButton onClick={onOpen} label="공지사항으로 이동" />}
|
action={<DashboardLinkButton onClick={onOpen} label="공지사항으로 이동" pageAgentAllowed={false} />}
|
||||||
/>
|
/>
|
||||||
<div className="max-h-[520px] divide-y divide-border overflow-y-auto">
|
<div className="max-h-[520px] divide-y divide-border overflow-y-auto">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { PageAgent } from 'page-agent';
|
||||||
|
import adminGuide from '../../docs/page-agent-admin-guide.md?raw';
|
||||||
|
import { apiBaseUrl } from './api';
|
||||||
|
|
||||||
|
function createPageAgent() {
|
||||||
|
return new PageAgent({
|
||||||
|
model: 'gpt-4.1-mini',
|
||||||
|
baseURL: `${apiBaseUrl}/page-agent/llm`,
|
||||||
|
apiKey: '',
|
||||||
|
language: 'en-US',
|
||||||
|
instructions: {
|
||||||
|
system: `항상 한국어로 답변하고, 이 어드민 콘솔의 운영 흐름에 맞춰 간결하게 안내하세요.
|
||||||
|
사용자가 별도로 허용하지 않는 한 플랫폼 그룹 하위 메뉴에서만 이동하고 작업하세요.
|
||||||
|
삭제, 제거, 폐기, 정리, unlink, revoke, delete, remove, decommission 등 데이터를 없애거나 연결을 끊는 작업은 절대 실행하지 말고 사람이 직접 해야 한다고 안내하세요.
|
||||||
|
|
||||||
|
아래 문서를 이 어드민 콘솔의 업무 가이드로 사용하세요.
|
||||||
|
|
||||||
|
${adminGuide}`
|
||||||
|
},
|
||||||
|
customFetch: (input, init) => {
|
||||||
|
const token = localStorage.getItem('studio-admin-token');
|
||||||
|
return fetch(input, {
|
||||||
|
...init,
|
||||||
|
headers: {
|
||||||
|
...(init?.headers ?? {}),
|
||||||
|
...(token ? { Authorization: `Bearer ${token}` } : {})
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let pageAgent = createPageAgent();
|
||||||
|
|
||||||
|
export function openPageAgent() {
|
||||||
|
if (!document.body.contains(pageAgent.panel.wrapper)) {
|
||||||
|
pageAgent = createPageAgent();
|
||||||
|
}
|
||||||
|
pageAgent.panel.show();
|
||||||
|
}
|
||||||
|
|
||||||
|
export { pageAgent };
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState, type MouseEvent } from 'react';
|
||||||
import { Database, Pencil, Play, Plus, RotateCcw, Settings2, Square, Trash2 } from 'lucide-react';
|
import { Database, Pencil, Play, Plus, RotateCcw, Settings2, Square, Trash2 } from 'lucide-react';
|
||||||
import { Badge, Button, Card, CardHeader } from '../../components/ui';
|
import { Badge, Button, Card, CardHeader } from '../../components/ui';
|
||||||
import { useToast } from '../../components/toast';
|
import { useToast } from '../../components/toast';
|
||||||
@@ -53,6 +53,17 @@ function useMinimumLoading(loading: boolean, minimumMs = 500) {
|
|||||||
return visible;
|
return visible;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isPageAgentClick(event: MouseEvent<HTMLElement>) {
|
||||||
|
return !event.nativeEvent.isTrusted;
|
||||||
|
}
|
||||||
|
|
||||||
|
function blockPageAgentDestructiveClick(event: MouseEvent<HTMLElement>) {
|
||||||
|
if (!isPageAgentClick(event)) return false;
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
function getTone(status: string) {
|
function getTone(status: string) {
|
||||||
if (['활성', '완료', '게시됨', '실행 중', '배포됨', 'ACTIVE', 'READY'].includes(status)) return 'green';
|
if (['활성', '완료', '게시됨', '실행 중', '배포됨', 'ACTIVE', 'READY'].includes(status)) return 'green';
|
||||||
if (['주의', '예약됨', '대기', '준비중', '카나리'].includes(status)) return 'amber';
|
if (['주의', '예약됨', '대기', '준비중', '카나리'].includes(status)) return 'amber';
|
||||||
@@ -304,6 +315,14 @@ export function AppManagementPage() {
|
|||||||
const [buildEnvVariables, setBuildEnvVariables] = useState<BuildEnvVariableForm[]>([emptyBuildEnvVariable()]);
|
const [buildEnvVariables, setBuildEnvVariables] = useState<BuildEnvVariableForm[]>([emptyBuildEnvVariable()]);
|
||||||
const [buildEnvStatus, setBuildEnvStatus] = useState<'idle' | 'loading' | 'saving' | 'error'>('idle');
|
const [buildEnvStatus, setBuildEnvStatus] = useState<'idle' | 'loading' | 'saving' | 'error'>('idle');
|
||||||
const [deletingApp, setDeletingApp] = useState<ApiApp | null>(null);
|
const [deletingApp, setDeletingApp] = useState<ApiApp | null>(null);
|
||||||
|
const [appDeleteStatus, setAppDeleteStatus] = useState<'idle' | 'deleting' | 'error'>('idle');
|
||||||
|
const [appDeleteOptions, setAppDeleteOptions] = useState({
|
||||||
|
unlinkSources: true,
|
||||||
|
deleteRuntimeSettings: true,
|
||||||
|
scaleDownWorkloads: true,
|
||||||
|
deleteKubernetesResources: false,
|
||||||
|
deletePersistentVolumes: false
|
||||||
|
});
|
||||||
const [deletingComponent, setDeletingComponent] = useState<ApiAppComponent | null>(null);
|
const [deletingComponent, setDeletingComponent] = useState<ApiAppComponent | null>(null);
|
||||||
const [runtimeResources, setRuntimeResources] = useState<RuntimeResource[]>([]);
|
const [runtimeResources, setRuntimeResources] = useState<RuntimeResource[]>([]);
|
||||||
const [workloadActionKey, setWorkloadActionKey] = useState('');
|
const [workloadActionKey, setWorkloadActionKey] = useState('');
|
||||||
@@ -806,7 +825,15 @@ export function AppManagementPage() {
|
|||||||
const deleteApp = () => {
|
const deleteApp = () => {
|
||||||
if (!deletingApp) return;
|
if (!deletingApp) return;
|
||||||
|
|
||||||
apiFetch(`${apiBaseUrl}/apps/${deletingApp.id}`, { method: 'DELETE', headers: authHeaders() })
|
setAppDeleteStatus('deleting');
|
||||||
|
apiFetch(`${apiBaseUrl}/apps/${deletingApp.id}/decommission`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...authHeaders()
|
||||||
|
},
|
||||||
|
body: JSON.stringify(appDeleteOptions)
|
||||||
|
})
|
||||||
.then((response) => {
|
.then((response) => {
|
||||||
if (!response.ok) throw new Error('App delete failed');
|
if (!response.ok) throw new Error('App delete failed');
|
||||||
return response.json().catch(() => null);
|
return response.json().catch(() => null);
|
||||||
@@ -815,6 +842,7 @@ export function AppManagementPage() {
|
|||||||
.then((apps) => {
|
.then((apps) => {
|
||||||
setApiApps(apps);
|
setApiApps(apps);
|
||||||
setDeletingApp(null);
|
setDeletingApp(null);
|
||||||
|
setAppDeleteStatus('idle');
|
||||||
setSelectedAppId((current) => {
|
setSelectedAppId((current) => {
|
||||||
if (current !== deletingApp.id) return current;
|
if (current !== deletingApp.id) return current;
|
||||||
const nextAppId = apps[0]?.id ?? '';
|
const nextAppId = apps[0]?.id ?? '';
|
||||||
@@ -824,10 +852,19 @@ export function AppManagementPage() {
|
|||||||
toast({ type: 'success', title: '앱 삭제', message: '앱 그룹이 삭제되었습니다.' });
|
toast({ type: 'success', title: '앱 삭제', message: '앱 그룹이 삭제되었습니다.' });
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
|
setAppDeleteStatus('error');
|
||||||
toast({ type: 'error', title: '앱 삭제 실패', message: '참조 이력 또는 백엔드 API 상태를 확인하세요.' });
|
toast({ type: 'error', title: '앱 삭제 실패', message: '참조 이력 또는 백엔드 API 상태를 확인하세요.' });
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const updateAppDeleteOption = (key: keyof typeof appDeleteOptions, value: boolean) => {
|
||||||
|
setAppDeleteOptions((current) => ({
|
||||||
|
...current,
|
||||||
|
[key]: value,
|
||||||
|
...(key === 'deleteKubernetesResources' && !value ? { deletePersistentVolumes: false } : {})
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageTitle
|
<PageTitle
|
||||||
@@ -883,10 +920,19 @@ export function AppManagementPage() {
|
|||||||
className="size-8 border-rose-500/50 px-0 text-rose-300 hover:border-rose-400 hover:bg-rose-500/10 hover:text-rose-200"
|
className="size-8 border-rose-500/50 px-0 text-rose-300 hover:border-rose-400 hover:bg-rose-500/10 hover:text-rose-200"
|
||||||
title="삭제"
|
title="삭제"
|
||||||
aria-label="삭제"
|
aria-label="삭제"
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
event.stopPropagation();
|
if (blockPageAgentDestructiveClick(event)) return;
|
||||||
setDeletingApp(app);
|
event.stopPropagation();
|
||||||
}}
|
setAppDeleteStatus('idle');
|
||||||
|
setAppDeleteOptions({
|
||||||
|
unlinkSources: true,
|
||||||
|
deleteRuntimeSettings: true,
|
||||||
|
scaleDownWorkloads: true,
|
||||||
|
deleteKubernetesResources: false,
|
||||||
|
deletePersistentVolumes: false
|
||||||
|
});
|
||||||
|
setDeletingApp(app);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Trash2 className="size-4" />
|
<Trash2 className="size-4" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -994,7 +1040,10 @@ export function AppManagementPage() {
|
|||||||
className="size-8 border-rose-500/50 px-0 text-rose-300 hover:border-rose-400 hover:bg-rose-500/10 hover:text-rose-200"
|
className="size-8 border-rose-500/50 px-0 text-rose-300 hover:border-rose-400 hover:bg-rose-500/10 hover:text-rose-200"
|
||||||
title="삭제"
|
title="삭제"
|
||||||
aria-label="삭제"
|
aria-label="삭제"
|
||||||
onClick={() => setDeletingComponent(component)}
|
onClick={(event) => {
|
||||||
|
if (blockPageAgentDestructiveClick(event)) return;
|
||||||
|
setDeletingComponent(component);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Trash2 className="size-4" />
|
<Trash2 className="size-4" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -1084,7 +1133,10 @@ export function AppManagementPage() {
|
|||||||
title="삭제"
|
title="삭제"
|
||||||
aria-label={`${resource.name} 삭제`}
|
aria-label={`${resource.name} 삭제`}
|
||||||
disabled={pending}
|
disabled={pending}
|
||||||
onClick={() => requestDeleteWorkload(resource)}
|
onClick={(event) => {
|
||||||
|
if (blockPageAgentDestructiveClick(event)) return;
|
||||||
|
requestDeleteWorkload(resource);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Trash2 className="size-4" />
|
<Trash2 className="size-4" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -1095,7 +1147,10 @@ export function AppManagementPage() {
|
|||||||
title={stopped ? '데이터 볼륨 삭제' : '중지 후 데이터 볼륨 삭제 가능'}
|
title={stopped ? '데이터 볼륨 삭제' : '중지 후 데이터 볼륨 삭제 가능'}
|
||||||
aria-label={`${resource.name} 데이터 볼륨 삭제`}
|
aria-label={`${resource.name} 데이터 볼륨 삭제`}
|
||||||
disabled={pending || !stopped}
|
disabled={pending || !stopped}
|
||||||
onClick={() => requestDeleteVolume(resource)}
|
onClick={(event) => {
|
||||||
|
if (blockPageAgentDestructiveClick(event)) return;
|
||||||
|
requestDeleteVolume(resource);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Database className="size-4" />
|
<Database className="size-4" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -1330,7 +1385,10 @@ export function AppManagementPage() {
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
className="size-9 justify-self-end px-0 text-rose-300"
|
className="size-9 justify-self-end px-0 text-rose-300"
|
||||||
disabled={componentPorts.length === 1}
|
disabled={componentPorts.length === 1}
|
||||||
onClick={() => setComponentPorts((prev) => prev.filter((_, portIndex) => portIndex !== index))}
|
onClick={(event) => {
|
||||||
|
if (blockPageAgentDestructiveClick(event)) return;
|
||||||
|
setComponentPorts((prev) => prev.filter((_, portIndex) => portIndex !== index));
|
||||||
|
}}
|
||||||
title="포트 삭제"
|
title="포트 삭제"
|
||||||
aria-label="포트 삭제"
|
aria-label="포트 삭제"
|
||||||
>
|
>
|
||||||
@@ -1472,7 +1530,10 @@ export function AppManagementPage() {
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
className="size-9 justify-self-end px-0 text-rose-300"
|
className="size-9 justify-self-end px-0 text-rose-300"
|
||||||
disabled={buildEnvVariables.length === 1}
|
disabled={buildEnvVariables.length === 1}
|
||||||
onClick={() => setBuildEnvVariables((prev) => prev.filter((_, itemIndex) => itemIndex !== index))}
|
onClick={(event) => {
|
||||||
|
if (blockPageAgentDestructiveClick(event)) return;
|
||||||
|
setBuildEnvVariables((prev) => prev.filter((_, itemIndex) => itemIndex !== index));
|
||||||
|
}}
|
||||||
title="환경변수 삭제"
|
title="환경변수 삭제"
|
||||||
aria-label="환경변수 삭제"
|
aria-label="환경변수 삭제"
|
||||||
>
|
>
|
||||||
@@ -1514,7 +1575,15 @@ export function AppManagementPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex justify-end gap-2 border-t border-border px-5 py-3">
|
<div className="flex justify-end gap-2 border-t border-border px-5 py-3">
|
||||||
<Button variant="ghost" onClick={() => setDeletingComponent(null)}>취소</Button>
|
<Button variant="ghost" onClick={() => setDeletingComponent(null)}>취소</Button>
|
||||||
<Button variant="secondary" onClick={deleteComponent}>삭제</Button>
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
onClick={(event) => {
|
||||||
|
if (blockPageAgentDestructiveClick(event)) return;
|
||||||
|
deleteComponent();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
삭제
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1522,27 +1591,59 @@ export function AppManagementPage() {
|
|||||||
|
|
||||||
{deletingApp ? (
|
{deletingApp ? (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-background/55 px-4 backdrop-blur-[2px]">
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-background/55 px-4 backdrop-blur-[2px]">
|
||||||
<div className="w-full max-w-md rounded-lg border border-border bg-card shadow-xl">
|
<div className="w-full max-w-xl rounded-lg border border-border bg-card shadow-xl">
|
||||||
<div className="flex items-center gap-2 border-b border-border px-5 py-3">
|
<div className="flex items-center gap-2 border-b border-border px-5 py-3">
|
||||||
<Trash2 className="size-4 text-rose-300" />
|
<Trash2 className="size-4 text-rose-300" />
|
||||||
<h2 className="text-sm font-semibold">앱 삭제</h2>
|
<h2 className="text-sm font-semibold">앱 폐기</h2>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-4 px-5 py-6 text-sm">
|
<div className="space-y-4 px-5 py-6 text-sm">
|
||||||
<p className="text-muted-foreground">
|
<p className="text-muted-foreground">
|
||||||
<span className="font-semibold text-foreground">{deletingApp.name}</span> 앱 그룹을 삭제합니다.
|
<span className="font-semibold text-foreground">{deletingApp.name}</span> 앱을 목록에서 삭제하고 선택한 운영 리소스를 정리합니다.
|
||||||
</p>
|
</p>
|
||||||
<div className="rounded-md border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-xs leading-5 text-amber-200">
|
<div className="space-y-2 rounded-md border border-border bg-muted/20 p-3">
|
||||||
앱 관리 데이터만 삭제합니다. 네임스페이스, 워크로드, 볼륨은 자동 삭제하지 않습니다.
|
{[
|
||||||
|
['unlinkSources', '저장소 연결 정보 삭제'],
|
||||||
|
['deleteRuntimeSettings', '실행 설정 삭제'],
|
||||||
|
['scaleDownWorkloads', 'Kubernetes 워크로드 중지'],
|
||||||
|
['deleteKubernetesResources', 'Kubernetes 리소스 삭제'],
|
||||||
|
['deletePersistentVolumes', 'PVC/데이터 볼륨 삭제']
|
||||||
|
].map(([key, label]) => {
|
||||||
|
const optionKey = key as keyof typeof appDeleteOptions;
|
||||||
|
const disabled = optionKey === 'deletePersistentVolumes' && !appDeleteOptions.deleteKubernetesResources;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<label key={key} className={cn('flex items-center gap-2 text-sm', disabled && 'opacity-50')}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={appDeleteOptions[optionKey]}
|
||||||
|
disabled={disabled}
|
||||||
|
onChange={(event) => updateAppDeleteOption(optionKey, event.target.checked)}
|
||||||
|
className="size-4 accent-primary"
|
||||||
|
/>
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
<div className="rounded-md border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-xs leading-5 text-amber-200">
|
||||||
|
PVC/데이터 볼륨 삭제는 되돌릴 수 없습니다. DB 데이터 백업을 확인한 경우에만 선택하세요.
|
||||||
|
</div>
|
||||||
|
{appDeleteStatus === 'error' ? (
|
||||||
|
<p className="text-xs text-rose-300">앱 폐기에 실패했습니다. 백엔드 로그와 Kubernetes 권한을 확인하세요.</p>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-end gap-2 border-t border-border px-5 py-3">
|
<div className="flex justify-end gap-2 border-t border-border px-5 py-3">
|
||||||
<Button variant="ghost" onClick={() => setDeletingApp(null)}>취소</Button>
|
<Button variant="ghost" onClick={() => setDeletingApp(null)} disabled={appDeleteStatus === 'deleting'}>취소</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="border-rose-500/50 text-rose-300 hover:border-rose-400 hover:bg-rose-500/10 hover:text-rose-200"
|
className="border-rose-500/50 text-rose-300 hover:border-rose-400 hover:bg-rose-500/10 hover:text-rose-200"
|
||||||
onClick={deleteApp}
|
onClick={(event) => {
|
||||||
|
if (blockPageAgentDestructiveClick(event)) return;
|
||||||
|
deleteApp();
|
||||||
|
}}
|
||||||
|
disabled={appDeleteStatus === 'deleting'}
|
||||||
>
|
>
|
||||||
삭제
|
{appDeleteStatus === 'deleting' ? '폐기 중' : '폐기'}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1598,7 +1699,10 @@ export function AppManagementPage() {
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
className="border-rose-500/50 text-rose-300 hover:border-rose-400 hover:bg-rose-500/10 hover:text-rose-200"
|
className="border-rose-500/50 text-rose-300 hover:border-rose-400 hover:bg-rose-500/10 hover:text-rose-200"
|
||||||
disabled={workloadActionKey === `delete-${deletingWorkload.kind}-${deletingWorkload.name}`}
|
disabled={workloadActionKey === `delete-${deletingWorkload.kind}-${deletingWorkload.name}`}
|
||||||
onClick={confirmDeleteWorkload}
|
onClick={(event) => {
|
||||||
|
if (blockPageAgentDestructiveClick(event)) return;
|
||||||
|
confirmDeleteWorkload();
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
삭제
|
삭제
|
||||||
</Button>
|
</Button>
|
||||||
@@ -1713,7 +1817,10 @@ export function AppManagementPage() {
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
className="border-rose-500/50 text-rose-300 hover:border-rose-400 hover:bg-rose-500/10 hover:text-rose-200"
|
className="border-rose-500/50 text-rose-300 hover:border-rose-400 hover:bg-rose-500/10 hover:text-rose-200"
|
||||||
disabled={!canDeleteVolume}
|
disabled={!canDeleteVolume}
|
||||||
onClick={confirmDeleteVolume}
|
onClick={(event) => {
|
||||||
|
if (blockPageAgentDestructiveClick(event)) return;
|
||||||
|
confirmDeleteVolume();
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{volumeDeleteStatus === 'deleting' ? '삭제 중' : '삭제'}
|
{volumeDeleteStatus === 'deleting' ? '삭제 중' : '삭제'}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -1,12 +1,19 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState, type MouseEvent } from 'react';
|
||||||
import { ChevronLeft, ChevronRight, MoreHorizontal, Play, RotateCcw, Search, Square, Trash2 } from 'lucide-react';
|
import { ChevronLeft, ChevronRight, MoreHorizontal, Play, RotateCcw, Square, Trash2 } from 'lucide-react';
|
||||||
import { Button, Card, CardHeader, Modal } from '../../components/ui';
|
import { Button, Card, CardHeader, Modal } from '../../components/ui';
|
||||||
import { useToast } from '../../components/toast';
|
import { useToast } from '../../components/toast';
|
||||||
import { PageTitle } from '../../layout/AppLayout';
|
import { PageTitle } from '../../layout/AppLayout';
|
||||||
import type { BuildJob, SourceComponent } from '../../types/application';
|
import type { BuildJob, SourceComponent } from '../../types/application';
|
||||||
import { LoadingModal, useMinimumLoading } from '../operations/operation-utils';
|
import { LoadingModal, useMinimumLoading } from '../operations/operation-utils';
|
||||||
import { AppSelector, StatusBadge, formatDate, isBuildDone, useApps } from './ApplicationControls';
|
import { AppSelector, StatusBadge, formatDate, isBuildDone, useApps } from './ApplicationControls';
|
||||||
import { cancelBuildJob, createBuildJob, deleteBuildJob, listBuildJobs, listSources } from './application-api';
|
import { cancelBuildJob, createBuildJob, deleteBuildJob, listBuildJobsPage, listSources } from './application-api';
|
||||||
|
|
||||||
|
function blockPageAgentDestructiveClick(event: MouseEvent<HTMLElement>) {
|
||||||
|
if (event.nativeEvent.isTrusted) return false;
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
function buildTargetForType(type?: string | null) {
|
function buildTargetForType(type?: string | null) {
|
||||||
return {
|
return {
|
||||||
@@ -61,6 +68,20 @@ function jobTime(job: BuildJob) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const buildJobPageSize = 3;
|
const buildJobPageSize = 3;
|
||||||
|
type BuildRange = 'week' | 'month' | 'all';
|
||||||
|
|
||||||
|
const buildRangeLabels: Record<BuildRange, string> = {
|
||||||
|
week: '일주일',
|
||||||
|
month: '한달',
|
||||||
|
all: '전체'
|
||||||
|
};
|
||||||
|
|
||||||
|
function buildRangeSince(range: BuildRange) {
|
||||||
|
if (range === 'all') return undefined;
|
||||||
|
const date = new Date();
|
||||||
|
date.setDate(date.getDate() - (range === 'week' ? 7 : 30));
|
||||||
|
return date.toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
export function BuildManagementPage() {
|
export function BuildManagementPage() {
|
||||||
const { apps, selectedApp, selectedAppId, selectApp, loading: appsLoading } = useApps();
|
const { apps, selectedApp, selectedAppId, selectApp, loading: appsLoading } = useApps();
|
||||||
@@ -72,9 +93,9 @@ export function BuildManagementPage() {
|
|||||||
const [commitSha, setCommitSha] = useState('');
|
const [commitSha, setCommitSha] = useState('');
|
||||||
const [selectedJob, setSelectedJob] = useState<BuildJob | null>(null);
|
const [selectedJob, setSelectedJob] = useState<BuildJob | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [search, setSearch] = useState('');
|
|
||||||
const [statusFilter, setStatusFilter] = useState('');
|
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
|
const [range, setRange] = useState<BuildRange>('week');
|
||||||
|
const [totalJobs, setTotalJobs] = useState(0);
|
||||||
const loadingVisible = useMinimumLoading(appsLoading || loading);
|
const loadingVisible = useMinimumLoading(appsLoading || loading);
|
||||||
|
|
||||||
const componentOptions = useMemo(
|
const componentOptions = useMemo(
|
||||||
@@ -88,13 +109,36 @@ export function BuildManagementPage() {
|
|||||||
const selectedTarget = selectedComponent?.buildTarget || buildTargetForType(selectedComponent?.type);
|
const selectedTarget = selectedComponent?.buildTarget || buildTargetForType(selectedComponent?.type);
|
||||||
const predicted = predictedArtifact(selectedComponent, branch, commitSha);
|
const predicted = predictedArtifact(selectedComponent, branch, commitSha);
|
||||||
const hasActiveJob = jobs.some((job) => job.status === 'PENDING' || job.status === 'RUNNING');
|
const hasActiveJob = jobs.some((job) => job.status === 'PENDING' || job.status === 'RUNNING');
|
||||||
|
const totalPages = Math.max(1, Math.ceil(totalJobs / buildJobPageSize));
|
||||||
|
const currentPage = page;
|
||||||
|
const pagedJobs = jobs;
|
||||||
|
|
||||||
|
const loadJobs = async (nextPage = page, nextRange = range, appKey = selectedApp?.key) => {
|
||||||
|
const result = await listBuildJobsPage({
|
||||||
|
appKey,
|
||||||
|
page: nextPage,
|
||||||
|
limit: buildJobPageSize,
|
||||||
|
since: buildRangeSince(nextRange)
|
||||||
|
});
|
||||||
|
setJobs(result.jobs);
|
||||||
|
setTotalJobs(result.total);
|
||||||
|
};
|
||||||
|
|
||||||
const refresh = async () => {
|
const refresh = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const [nextSources, nextJobs] = await Promise.all([listSources(), listBuildJobs(selectedApp?.key)]);
|
const [nextSources, nextJobs] = await Promise.all([
|
||||||
|
listSources(),
|
||||||
|
listBuildJobsPage({
|
||||||
|
appKey: selectedApp?.key,
|
||||||
|
page,
|
||||||
|
limit: buildJobPageSize,
|
||||||
|
since: buildRangeSince(range)
|
||||||
|
})
|
||||||
|
]);
|
||||||
setSources(nextSources);
|
setSources(nextSources);
|
||||||
setJobs(nextJobs);
|
setJobs(nextJobs.jobs);
|
||||||
|
setTotalJobs(nextJobs.total);
|
||||||
const filteredSources = (selectedAppId ? nextSources.filter((source) => source.appId === selectedAppId) : nextSources)
|
const filteredSources = (selectedAppId ? nextSources.filter((source) => source.appId === selectedAppId) : nextSources)
|
||||||
.filter((source) => (source.imageSource ?? 'SOURCE') === 'SOURCE');
|
.filter((source) => (source.imageSource ?? 'SOURCE') === 'SOURCE');
|
||||||
setSelectedComponentId((prev) => {
|
setSelectedComponentId((prev) => {
|
||||||
@@ -110,19 +154,27 @@ export function BuildManagementPage() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void refresh();
|
void refresh();
|
||||||
}, [selectedAppId, selectedApp?.key]);
|
}, [selectedAppId, selectedApp?.key, page, range]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!hasActiveJob) return;
|
if (!hasActiveJob) return;
|
||||||
const timer = window.setInterval(() => {
|
const timer = window.setInterval(() => {
|
||||||
listBuildJobs(selectedApp?.key)
|
listBuildJobsPage({
|
||||||
.then((nextJobs) => setJobs(nextJobs))
|
appKey: selectedApp?.key,
|
||||||
|
page,
|
||||||
|
limit: buildJobPageSize,
|
||||||
|
since: buildRangeSince(range)
|
||||||
|
})
|
||||||
|
.then((nextJobs) => {
|
||||||
|
setJobs(nextJobs.jobs);
|
||||||
|
setTotalJobs(nextJobs.total);
|
||||||
|
})
|
||||||
.catch((error: Error) => {
|
.catch((error: Error) => {
|
||||||
toast({ type: 'error', message: `빌드 작업 갱신 실패: ${error.message}` });
|
toast({ type: 'error', message: `빌드 작업 갱신 실패: ${error.message}` });
|
||||||
});
|
});
|
||||||
}, 2000);
|
}, 2000);
|
||||||
return () => window.clearInterval(timer);
|
return () => window.clearInterval(timer);
|
||||||
}, [hasActiveJob, selectedApp?.key, toast]);
|
}, [hasActiveJob, selectedApp?.key, toast, page, range]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!selectedJob) return;
|
if (!selectedJob) return;
|
||||||
@@ -140,26 +192,9 @@ export function BuildManagementPage() {
|
|||||||
}
|
}
|
||||||
}, [componentOptions, selectedComponentId]);
|
}, [componentOptions, selectedComponentId]);
|
||||||
|
|
||||||
const filteredJobs = useMemo(() => {
|
|
||||||
const normalizedSearch = search.trim().toLowerCase();
|
|
||||||
return jobs
|
|
||||||
.filter((job) => {
|
|
||||||
const text = `${job.component.app.name} ${job.component.name} ${job.component.repository ?? ''} ${job.branch} ${job.commitSha ?? ''} ${resultArtifact(job)} ${job.status}`.toLowerCase();
|
|
||||||
const matchesSearch = normalizedSearch ? text.includes(normalizedSearch) : true;
|
|
||||||
const matchesStatus = statusFilter ? job.status === statusFilter : true;
|
|
||||||
return matchesSearch && matchesStatus;
|
|
||||||
})
|
|
||||||
.sort((a, b) => jobTime(b) - jobTime(a));
|
|
||||||
}, [jobs, search, statusFilter]);
|
|
||||||
|
|
||||||
const totalPages = Math.max(1, Math.ceil(filteredJobs.length / buildJobPageSize));
|
|
||||||
const currentPage = Math.min(page, totalPages);
|
|
||||||
const pageStart = (currentPage - 1) * buildJobPageSize;
|
|
||||||
const pagedJobs = filteredJobs.slice(pageStart, pageStart + buildJobPageSize);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setPage(1);
|
setPage(1);
|
||||||
}, [search, statusFilter, selectedAppId]);
|
}, [selectedAppId, range]);
|
||||||
|
|
||||||
const startBuild = async () => {
|
const startBuild = async () => {
|
||||||
if (!selectedComponent) return;
|
if (!selectedComponent) return;
|
||||||
@@ -274,32 +309,26 @@ export function BuildManagementPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card className="min-w-0 overflow-hidden">
|
<Card className="min-w-0 overflow-hidden">
|
||||||
<div className="flex flex-col gap-3 border-b border-border px-5 py-4 lg:flex-row lg:items-center lg:justify-between">
|
<CardHeader
|
||||||
<h2 className="text-sm font-semibold text-foreground">빌드 작업 목록</h2>
|
title="빌드 작업 목록"
|
||||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
description="선택한 기간의 빌드 작업을 최신순으로 표시합니다."
|
||||||
<div className="relative w-full sm:w-80">
|
action={
|
||||||
<Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
<div className="flex items-center gap-2">
|
||||||
<input
|
{(Object.keys(buildRangeLabels) as BuildRange[]).map((item) => (
|
||||||
className="h-9 w-full rounded-md border border-input bg-background pl-9 pr-3 text-sm outline-none placeholder:text-muted-foreground focus:ring-2 focus:ring-ring"
|
<Button
|
||||||
placeholder="앱, 구성요소, 저장소 검색"
|
key={item}
|
||||||
value={search}
|
variant={range === item ? 'default' : 'outline'}
|
||||||
onChange={(event) => setSearch(event.target.value)}
|
className="h-8 px-3 text-[13px]"
|
||||||
/>
|
onClick={() => setRange(item)}
|
||||||
</div>
|
>
|
||||||
<select
|
{buildRangeLabels[item]}
|
||||||
className="h-9 rounded-md border border-input bg-background px-3 text-sm font-medium text-foreground outline-none focus:ring-2 focus:ring-ring"
|
</Button>
|
||||||
value={statusFilter}
|
|
||||||
onChange={(event) => setStatusFilter(event.target.value)}
|
|
||||||
>
|
|
||||||
<option value="">전체 상태</option>
|
|
||||||
{[...new Set(jobs.map((job) => job.status))].map((status) => (
|
|
||||||
<option key={status} value={status}>{statusLabel(status)}</option>
|
|
||||||
))}
|
))}
|
||||||
</select>
|
</div>
|
||||||
</div>
|
}
|
||||||
</div>
|
/>
|
||||||
<div className="space-y-3 p-4">
|
<div className="min-h-[410px] space-y-3 p-4">
|
||||||
{!loading && filteredJobs.length === 0 ? (
|
{!loading && jobs.length === 0 ? (
|
||||||
<div className="flex min-h-44 items-center justify-center text-center text-sm text-muted-foreground">
|
<div className="flex min-h-44 items-center justify-center text-center text-sm text-muted-foreground">
|
||||||
빌드 작업이 없습니다.
|
빌드 작업이 없습니다.
|
||||||
</div>
|
</div>
|
||||||
@@ -309,7 +338,7 @@ export function BuildManagementPage() {
|
|||||||
<div className="flex items-start justify-between gap-4">
|
<div className="flex items-start justify-between gap-4">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<span className="text-xs font-medium text-muted-foreground">No. {filteredJobs.length - pageStart - index}</span>
|
<span className="text-xs font-medium text-muted-foreground">No. {totalJobs - ((currentPage - 1) * buildJobPageSize + index)}</span>
|
||||||
<StatusBadge status={statusLabel(job.status)} />
|
<StatusBadge status={statusLabel(job.status)} />
|
||||||
<span className="font-medium">{job.component.app.name}</span>
|
<span className="font-medium">{job.component.app.name}</span>
|
||||||
<span className="text-muted-foreground">/</span>
|
<span className="text-muted-foreground">/</span>
|
||||||
@@ -336,7 +365,16 @@ export function BuildManagementPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{isBuildDone(job.status) ? (
|
{isBuildDone(job.status) ? (
|
||||||
<Button variant="outline" className="size-8 px-0" title="삭제" aria-label="삭제" onClick={() => removeJob(job.id)}>
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="size-8 px-0"
|
||||||
|
title="삭제"
|
||||||
|
aria-label="삭제"
|
||||||
|
onClick={(event) => {
|
||||||
|
if (blockPageAgentDestructiveClick(event)) return;
|
||||||
|
removeJob(job.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
<Trash2 className="size-4" />
|
<Trash2 className="size-4" />
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -361,7 +399,7 @@ export function BuildManagementPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between gap-4 border-t border-border px-5 py-3 text-sm">
|
<div className="flex items-center justify-between gap-4 border-t border-border px-5 py-3 text-sm">
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
{loading ? '불러오는 중' : `총 ${filteredJobs.length}건 · ${currentPage} / ${totalPages}`}
|
{loading ? '불러오는 중' : `총 ${totalJobs}건 · ${currentPage} / ${totalPages}`}
|
||||||
</span>
|
</span>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { PageTitle } from '../../layout/AppLayout';
|
|||||||
import type { AppRelease, DeploymentJob } from '../../types/application';
|
import type { AppRelease, DeploymentJob } from '../../types/application';
|
||||||
import { LoadingModal, useMinimumLoading } from '../operations/operation-utils';
|
import { LoadingModal, useMinimumLoading } from '../operations/operation-utils';
|
||||||
import { AppSelector, formatDate, useApps } from './ApplicationControls';
|
import { AppSelector, formatDate, useApps } from './ApplicationControls';
|
||||||
import { createDeployment, listDeployments, listReleases } from './application-api';
|
import { createDeployment, listDeploymentsPage, listReleases } from './application-api';
|
||||||
|
|
||||||
function statusLabel(status: string) {
|
function statusLabel(status: string) {
|
||||||
return {
|
return {
|
||||||
@@ -53,11 +53,25 @@ function deploymentTime(deployment: DeploymentJob) {
|
|||||||
return Number.isNaN(value) ? 0 : value;
|
return Number.isNaN(value) ? 0 : value;
|
||||||
}
|
}
|
||||||
|
|
||||||
const deploymentPageSize = 5;
|
const deploymentPageSize = 3;
|
||||||
|
type DeploymentRange = 'week' | 'month' | 'all';
|
||||||
|
|
||||||
|
const deploymentRangeLabels: Record<DeploymentRange, string> = {
|
||||||
|
week: '일주일',
|
||||||
|
month: '한달',
|
||||||
|
all: '전체'
|
||||||
|
};
|
||||||
|
|
||||||
|
function deploymentRangeSince(range: DeploymentRange) {
|
||||||
|
if (range === 'all') return undefined;
|
||||||
|
const date = new Date();
|
||||||
|
date.setDate(date.getDate() - (range === 'week' ? 7 : 30));
|
||||||
|
return date.toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
export function DeploymentManagementPage() {
|
export function DeploymentManagementPage() {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const { apps, selectedAppId, loading: appsLoading, selectApp } = useApps();
|
const { apps, selectedApp, selectedAppId, loading: appsLoading, selectApp } = useApps();
|
||||||
const [deployments, setDeployments] = useState<DeploymentJob[]>([]);
|
const [deployments, setDeployments] = useState<DeploymentJob[]>([]);
|
||||||
const [releases, setReleases] = useState<AppRelease[]>([]);
|
const [releases, setReleases] = useState<AppRelease[]>([]);
|
||||||
const [releaseId, setReleaseId] = useState('');
|
const [releaseId, setReleaseId] = useState('');
|
||||||
@@ -67,6 +81,9 @@ export function DeploymentManagementPage() {
|
|||||||
const [selectedDeployment, setSelectedDeployment] = useState<DeploymentJob | null>(null);
|
const [selectedDeployment, setSelectedDeployment] = useState<DeploymentJob | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
|
const [range, setRange] = useState<DeploymentRange>('week');
|
||||||
|
const [totalDeployments, setTotalDeployments] = useState(0);
|
||||||
|
const [deployedReleaseIdValues, setDeployedReleaseIdValues] = useState<string[]>([]);
|
||||||
const loadingVisible = useMinimumLoading(loading);
|
const loadingVisible = useMinimumLoading(loading);
|
||||||
|
|
||||||
const selectedRelease = releases.find((release) => release.id === releaseId);
|
const selectedRelease = releases.find((release) => release.id === releaseId);
|
||||||
@@ -75,32 +92,37 @@ export function DeploymentManagementPage() {
|
|||||||
[releases, selectedAppId]
|
[releases, selectedAppId]
|
||||||
);
|
);
|
||||||
const deployedReleaseIds = useMemo(
|
const deployedReleaseIds = useMemo(
|
||||||
() => new Set(deployments
|
() => new Set(deployedReleaseIdValues),
|
||||||
.filter((deployment) => deployment.status === 'SUCCEEDED' && deployment.releaseId)
|
[deployedReleaseIdValues]
|
||||||
.map((deployment) => deployment.releaseId!)),
|
|
||||||
[deployments]
|
|
||||||
);
|
);
|
||||||
const deployableReleases = useMemo(
|
const deployableReleases = useMemo(
|
||||||
() => filteredReleases.filter((release) => !deployedReleaseIds.has(release.id)),
|
() => filteredReleases.filter((release) => !deployedReleaseIds.has(release.id)),
|
||||||
[filteredReleases, deployedReleaseIds]
|
[filteredReleases, deployedReleaseIds]
|
||||||
);
|
);
|
||||||
const filteredDeployments = useMemo(
|
const filteredDeployments = useMemo(
|
||||||
() => deployments
|
() => deployments,
|
||||||
.filter((deployment) => !selectedAppId || deployment.appId === selectedAppId)
|
[deployments]
|
||||||
.sort((a, b) => deploymentTime(b) - deploymentTime(a)),
|
|
||||||
[deployments, selectedAppId]
|
|
||||||
);
|
);
|
||||||
const totalPages = Math.max(1, Math.ceil(filteredDeployments.length / deploymentPageSize));
|
const totalPages = Math.max(1, Math.ceil(totalDeployments / deploymentPageSize));
|
||||||
const currentPage = Math.min(page, totalPages);
|
const currentPage = page;
|
||||||
const pageStart = (currentPage - 1) * deploymentPageSize;
|
const pagedDeployments = filteredDeployments;
|
||||||
const pagedDeployments = filteredDeployments.slice(pageStart, pageStart + deploymentPageSize);
|
|
||||||
|
|
||||||
const refresh = async () => {
|
const refresh = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const [nextDeployments, nextReleases] = await Promise.all([listDeployments(), listReleases()]);
|
const [nextDeployments, nextReleases] = await Promise.all([
|
||||||
|
listDeploymentsPage({
|
||||||
|
appKey: selectedApp?.key,
|
||||||
|
page,
|
||||||
|
limit: deploymentPageSize,
|
||||||
|
since: deploymentRangeSince(range)
|
||||||
|
}),
|
||||||
|
listReleases(selectedApp?.key)
|
||||||
|
]);
|
||||||
const deployableReleases = nextReleases.filter((release) => release.status === 'SUCCEEDED');
|
const deployableReleases = nextReleases.filter((release) => release.status === 'SUCCEEDED');
|
||||||
setDeployments(nextDeployments);
|
setDeployments(nextDeployments.deployments);
|
||||||
|
setTotalDeployments(nextDeployments.total);
|
||||||
|
setDeployedReleaseIdValues(nextDeployments.deployedReleaseIds ?? []);
|
||||||
setReleases(deployableReleases);
|
setReleases(deployableReleases);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@@ -109,11 +131,11 @@ export function DeploymentManagementPage() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void refresh();
|
void refresh();
|
||||||
}, []);
|
}, [selectedApp?.key, selectedAppId, page, range]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setPage(1);
|
setPage(1);
|
||||||
}, [selectedAppId]);
|
}, [selectedAppId, range]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (selectedRelease?.app?.namespace) setNamespace((prev) => prev || selectedRelease.app.namespace || '');
|
if (selectedRelease?.app?.namespace) setNamespace((prev) => prev || selectedRelease.app.namespace || '');
|
||||||
@@ -166,9 +188,26 @@ export function DeploymentManagementPage() {
|
|||||||
|
|
||||||
<div className="grid gap-4 xl:grid-cols-[minmax(0,1fr)_320px]">
|
<div className="grid gap-4 xl:grid-cols-[minmax(0,1fr)_320px]">
|
||||||
<Card className="min-w-0 overflow-hidden">
|
<Card className="min-w-0 overflow-hidden">
|
||||||
<CardHeader title="배포 작업" description="릴리즈별 배포 실행 결과와 rollout 로그를 확인합니다." />
|
<CardHeader
|
||||||
|
title="배포 작업"
|
||||||
|
description="선택한 기간의 배포 실행 결과와 rollout 로그를 최신순으로 표시합니다."
|
||||||
|
action={
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{(Object.keys(deploymentRangeLabels) as DeploymentRange[]).map((item) => (
|
||||||
|
<Button
|
||||||
|
key={item}
|
||||||
|
variant={range === item ? 'default' : 'outline'}
|
||||||
|
className="h-8 px-3 text-[13px]"
|
||||||
|
onClick={() => setRange(item)}
|
||||||
|
>
|
||||||
|
{deploymentRangeLabels[item]}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<div className="space-y-3 p-4">
|
<div className="space-y-3 p-4">
|
||||||
{filteredDeployments.length === 0 ? (
|
{!loading && filteredDeployments.length === 0 ? (
|
||||||
<div className="flex min-h-44 items-center justify-center text-center text-sm text-muted-foreground">
|
<div className="flex min-h-44 items-center justify-center text-center text-sm text-muted-foreground">
|
||||||
배포 작업이 없습니다.
|
배포 작업이 없습니다.
|
||||||
</div>
|
</div>
|
||||||
@@ -180,7 +219,7 @@ export function DeploymentManagementPage() {
|
|||||||
<div className="flex items-start justify-between gap-4">
|
<div className="flex items-start justify-between gap-4">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<span className="text-xs font-medium text-muted-foreground">No. {filteredDeployments.length - pageStart - index}</span>
|
<span className="text-xs font-medium text-muted-foreground">No. {totalDeployments - ((currentPage - 1) * deploymentPageSize + index)}</span>
|
||||||
<Badge tone={statusTone(deployment.status)}>{statusLabel(deployment.status)}</Badge>
|
<Badge tone={statusTone(deployment.status)}>{statusLabel(deployment.status)}</Badge>
|
||||||
<span className="text-base font-medium">{deployment.app.name}</span>
|
<span className="text-base font-medium">{deployment.app.name}</span>
|
||||||
<span className="text-base font-medium">{deployment.release?.version || '-'}</span>
|
<span className="text-base font-medium">{deployment.release?.version || '-'}</span>
|
||||||
@@ -220,7 +259,7 @@ export function DeploymentManagementPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between gap-4 border-t border-border px-5 py-3 text-sm">
|
<div className="flex items-center justify-between gap-4 border-t border-border px-5 py-3 text-sm">
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
{loading ? '불러오는 중' : `총 ${filteredDeployments.length}건 · ${currentPage} / ${totalPages}`}
|
{loading ? '불러오는 중' : `총 ${totalDeployments}건 · ${currentPage} / ${totalPages}`}
|
||||||
</span>
|
</span>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { ChevronLeft, ChevronRight, MoreHorizontal, Play } from 'lucide-react';
|
import { ChevronLeft, ChevronRight, MoreHorizontal, Play } from 'lucide-react';
|
||||||
import { Button, Card, Modal } from '../../components/ui';
|
import { Button, Card, CardHeader, Modal } from '../../components/ui';
|
||||||
import { useToast } from '../../components/toast';
|
import { useToast } from '../../components/toast';
|
||||||
import { PageTitle } from '../../layout/AppLayout';
|
import { PageTitle } from '../../layout/AppLayout';
|
||||||
import type { AppRelease, RuntimeControlJob } from '../../types/application';
|
import type { AppRelease, RuntimeControlJob } from '../../types/application';
|
||||||
import { LoadingModal, useMinimumLoading } from '../operations/operation-utils';
|
import { LoadingModal, useMinimumLoading } from '../operations/operation-utils';
|
||||||
import { StatusBadge, formatDate, useApps } from './ApplicationControls';
|
import { StatusBadge, formatDate, useApps } from './ApplicationControls';
|
||||||
import { createMigration, listMigrations, listReleases } from './application-api';
|
import { createMigration, listMigrationsPage, listReleases } from './application-api';
|
||||||
|
|
||||||
type ImageTags = {
|
type ImageTags = {
|
||||||
images?: Record<string, string>;
|
images?: Record<string, string>;
|
||||||
@@ -33,15 +33,6 @@ function requestText(job: RuntimeControlJob, key: string, fallback = '-') {
|
|||||||
return typeof value === 'string' && value.trim() ? value : fallback;
|
return typeof value === 'string' && value.trim() ? value : fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
function jobNumber(index: number, total: number) {
|
|
||||||
return total - index;
|
|
||||||
}
|
|
||||||
|
|
||||||
function migrationTime(job: RuntimeControlJob) {
|
|
||||||
const value = new Date(job.createdAt || job.startedAt || job.finishedAt || 0).getTime();
|
|
||||||
return Number.isNaN(value) ? 0 : value;
|
|
||||||
}
|
|
||||||
|
|
||||||
function sanitizeK8sName(value: string) {
|
function sanitizeK8sName(value: string) {
|
||||||
return value
|
return value
|
||||||
.trim()
|
.trim()
|
||||||
@@ -54,7 +45,21 @@ function uniqueValues(values: string[]) {
|
|||||||
return [...new Set(values.filter(Boolean))];
|
return [...new Set(values.filter(Boolean))];
|
||||||
}
|
}
|
||||||
|
|
||||||
const migrationPageSize = 5;
|
const migrationPageSize = 3;
|
||||||
|
type MigrationRange = 'week' | 'month' | 'all';
|
||||||
|
|
||||||
|
const migrationRangeLabels: Record<MigrationRange, string> = {
|
||||||
|
week: '일주일',
|
||||||
|
month: '한달',
|
||||||
|
all: '전체'
|
||||||
|
};
|
||||||
|
|
||||||
|
function migrationRangeSince(range: MigrationRange) {
|
||||||
|
if (range === 'all') return undefined;
|
||||||
|
const date = new Date();
|
||||||
|
date.setDate(date.getDate() - (range === 'week' ? 7 : 30));
|
||||||
|
return date.toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
export function MigrationManagementPage() {
|
export function MigrationManagementPage() {
|
||||||
const { apps, selectedApp, selectedAppId, selectApp, loading: appsLoading } = useApps();
|
const { apps, selectedApp, selectedAppId, selectApp, loading: appsLoading } = useApps();
|
||||||
@@ -71,6 +76,8 @@ export function MigrationManagementPage() {
|
|||||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
|
const [range, setRange] = useState<MigrationRange>('week');
|
||||||
|
const [totalJobs, setTotalJobs] = useState(0);
|
||||||
const loadingVisible = useMinimumLoading(appsLoading || loading);
|
const loadingVisible = useMinimumLoading(appsLoading || loading);
|
||||||
|
|
||||||
const selectedRelease = releases.find((release) => release.id === releaseId);
|
const selectedRelease = releases.find((release) => release.id === releaseId);
|
||||||
@@ -89,18 +96,25 @@ export function MigrationManagementPage() {
|
|||||||
[envSecretName, images, selectedApp?.key]
|
[envSecretName, images, selectedApp?.key]
|
||||||
);
|
);
|
||||||
const selectedImage = selectedImageTag(selectedRelease, imageComponentName);
|
const selectedImage = selectedImageTag(selectedRelease, imageComponentName);
|
||||||
const sortedJobs = [...jobs].sort((a, b) => migrationTime(b) - migrationTime(a));
|
const totalPages = Math.max(1, Math.ceil(totalJobs / migrationPageSize));
|
||||||
const totalPages = Math.max(1, Math.ceil(sortedJobs.length / migrationPageSize));
|
const currentPage = page;
|
||||||
const currentPage = Math.min(page, totalPages);
|
const pagedJobs = jobs;
|
||||||
const pageStart = (currentPage - 1) * migrationPageSize;
|
|
||||||
const pagedJobs = sortedJobs.slice(pageStart, pageStart + migrationPageSize);
|
|
||||||
|
|
||||||
const refresh = async () => {
|
const refresh = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const [nextJobs, nextReleases] = await Promise.all([listMigrations(), listReleases(selectedApp?.key)]);
|
const [nextJobs, nextReleases] = await Promise.all([
|
||||||
|
listMigrationsPage({
|
||||||
|
appId: selectedAppId,
|
||||||
|
page,
|
||||||
|
limit: migrationPageSize,
|
||||||
|
since: migrationRangeSince(range)
|
||||||
|
}),
|
||||||
|
listReleases(selectedApp?.key)
|
||||||
|
]);
|
||||||
const succeededReleases = nextReleases.filter((release) => release.status === 'SUCCEEDED');
|
const succeededReleases = nextReleases.filter((release) => release.status === 'SUCCEEDED');
|
||||||
setJobs(nextJobs);
|
setJobs(nextJobs.jobs);
|
||||||
|
setTotalJobs(nextJobs.total);
|
||||||
setReleases(succeededReleases);
|
setReleases(succeededReleases);
|
||||||
setReleaseId((prev) => (succeededReleases.some((release) => release.id === prev) ? prev : succeededReleases[0]?.id || ''));
|
setReleaseId((prev) => (succeededReleases.some((release) => release.id === prev) ? prev : succeededReleases[0]?.id || ''));
|
||||||
setNamespace((prev) => prev || selectedApp?.namespace || '');
|
setNamespace((prev) => prev || selectedApp?.namespace || '');
|
||||||
@@ -115,7 +129,7 @@ export function MigrationManagementPage() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void refresh();
|
void refresh();
|
||||||
}, [selectedApp?.key, selectedApp?.namespace]);
|
}, [selectedApp?.key, selectedApp?.namespace, selectedAppId, page, range]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const nextImages = imageEntries(selectedRelease?.imageTags);
|
const nextImages = imageEntries(selectedRelease?.imageTags);
|
||||||
@@ -133,6 +147,10 @@ export function MigrationManagementPage() {
|
|||||||
setPage(1);
|
setPage(1);
|
||||||
}, [selectedAppId]);
|
}, [selectedAppId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setPage(1);
|
||||||
|
}, [range]);
|
||||||
|
|
||||||
const runMigration = async () => {
|
const runMigration = async () => {
|
||||||
try {
|
try {
|
||||||
setConfirmOpen(false);
|
setConfirmOpen(false);
|
||||||
@@ -246,15 +264,27 @@ export function MigrationManagementPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card className="min-w-0 overflow-hidden">
|
<Card className="min-w-0 overflow-hidden">
|
||||||
<div className="border-b border-border px-5 py-4">
|
<CardHeader
|
||||||
<div className="min-w-0">
|
title="마이그레이션 작업"
|
||||||
<h2 className="text-sm font-semibold text-foreground">마이그레이션 작업</h2>
|
description="선택한 기간의 마이그레이션 실행 이력과 로그를 최신순으로 표시합니다."
|
||||||
<p className="mt-1 text-xs leading-5 text-muted-foreground">마이그레이션 실행 이력과 로그입니다.</p>
|
action={
|
||||||
</div>
|
<div className="flex items-center gap-2">
|
||||||
</div>
|
{(Object.keys(migrationRangeLabels) as MigrationRange[]).map((item) => (
|
||||||
|
<Button
|
||||||
|
key={item}
|
||||||
|
variant={range === item ? 'default' : 'outline'}
|
||||||
|
className="h-8 px-3 text-[13px]"
|
||||||
|
onClick={() => setRange(item)}
|
||||||
|
>
|
||||||
|
{migrationRangeLabels[item]}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="space-y-3 p-5">
|
<div className="min-h-[410px] space-y-3 p-5">
|
||||||
{jobs.length === 0 ? (
|
{!loading && jobs.length === 0 ? (
|
||||||
<div className="flex min-h-44 items-center justify-center text-center text-sm text-muted-foreground">
|
<div className="flex min-h-44 items-center justify-center text-center text-sm text-muted-foreground">
|
||||||
등록된 마이그레이션 작업이 없습니다.
|
등록된 마이그레이션 작업이 없습니다.
|
||||||
</div>
|
</div>
|
||||||
@@ -268,7 +298,7 @@ export function MigrationManagementPage() {
|
|||||||
<div className="flex items-start justify-between gap-4">
|
<div className="flex items-start justify-between gap-4">
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<span className="text-xs font-medium text-muted-foreground">No. {jobNumber(pageStart + index, sortedJobs.length)}</span>
|
<span className="text-xs font-medium text-muted-foreground">No. {totalJobs - ((currentPage - 1) * migrationPageSize + index)}</span>
|
||||||
<StatusBadge status={job.status} />
|
<StatusBadge status={job.status} />
|
||||||
<span className="truncate text-base font-medium text-foreground">
|
<span className="truncate text-base font-medium text-foreground">
|
||||||
{selectedApp?.name ?? '앱'} {job.name}
|
{selectedApp?.name ?? '앱'} {job.name}
|
||||||
@@ -295,7 +325,7 @@ export function MigrationManagementPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between gap-4 border-t border-border px-5 py-3 text-sm">
|
<div className="flex items-center justify-between gap-4 border-t border-border px-5 py-3 text-sm">
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
{loading ? '불러오는 중' : `총 ${sortedJobs.length}건 · ${currentPage} / ${totalPages}`}
|
{loading ? '불러오는 중' : `총 ${totalJobs}건 · ${currentPage} / ${totalPages}`}
|
||||||
</span>
|
</span>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { PageTitle } from '../../layout/AppLayout';
|
|||||||
import type { AppRelease, BuildJob } from '../../types/application';
|
import type { AppRelease, BuildJob } from '../../types/application';
|
||||||
import { LoadingModal, useMinimumLoading } from '../operations/operation-utils';
|
import { LoadingModal, useMinimumLoading } from '../operations/operation-utils';
|
||||||
import { AppSelector, formatDate, useApps } from './ApplicationControls';
|
import { AppSelector, formatDate, useApps } from './ApplicationControls';
|
||||||
import { createRelease, listBuildJobs, listReleases } from './application-api';
|
import { createRelease, listBuildJobsPage, listReleasesPage } from './application-api';
|
||||||
|
|
||||||
function statusLabel(status: string) {
|
function statusLabel(status: string) {
|
||||||
return {
|
return {
|
||||||
@@ -140,7 +140,21 @@ function releaseTime(release: AppRelease) {
|
|||||||
return Number.isNaN(value) ? 0 : value;
|
return Number.isNaN(value) ? 0 : value;
|
||||||
}
|
}
|
||||||
|
|
||||||
const releasePageSize = 5;
|
const releasePageSize = 3;
|
||||||
|
type ReleaseRange = 'week' | 'month' | 'all';
|
||||||
|
|
||||||
|
const releaseRangeLabels: Record<ReleaseRange, string> = {
|
||||||
|
week: '일주일',
|
||||||
|
month: '한달',
|
||||||
|
all: '전체'
|
||||||
|
};
|
||||||
|
|
||||||
|
function releaseRangeSince(range: ReleaseRange) {
|
||||||
|
if (range === 'all') return undefined;
|
||||||
|
const date = new Date();
|
||||||
|
date.setDate(date.getDate() - (range === 'week' ? 7 : 30));
|
||||||
|
return date.toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
export function ReleaseManagementPage() {
|
export function ReleaseManagementPage() {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
@@ -153,9 +167,13 @@ export function ReleaseManagementPage() {
|
|||||||
const [openLogReleaseId, setOpenLogReleaseId] = useState('');
|
const [openLogReleaseId, setOpenLogReleaseId] = useState('');
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
|
const [range, setRange] = useState<ReleaseRange>('week');
|
||||||
|
const [totalReleases, setTotalReleases] = useState(0);
|
||||||
|
const [usedBuildIdValues, setUsedBuildIdValues] = useState<string[]>([]);
|
||||||
|
const [usedReleaseValueValues, setUsedReleaseValueValues] = useState<string[]>([]);
|
||||||
const loadingVisible = useMinimumLoading(loading);
|
const loadingVisible = useMinimumLoading(loading);
|
||||||
const usedValues = useMemo(() => usedReleaseValues(releases), [releases]);
|
const usedValues = useMemo(() => new Set(usedReleaseValueValues), [usedReleaseValueValues]);
|
||||||
const usedBuildIds = useMemo(() => usedReleaseBuildIds(releases), [releases]);
|
const usedBuildIds = useMemo(() => new Set(usedBuildIdValues), [usedBuildIdValues]);
|
||||||
const candidateBuilds = useMemo(
|
const candidateBuilds = useMemo(
|
||||||
() => builds.filter((build) => {
|
() => builds.filter((build) => {
|
||||||
const value = buildValue(build);
|
const value = buildValue(build);
|
||||||
@@ -168,12 +186,7 @@ export function ReleaseManagementPage() {
|
|||||||
() => groupCandidateBuilds(candidateBuilds).filter((group) => !selectedAppId || group.appId === selectedAppId),
|
() => groupCandidateBuilds(candidateBuilds).filter((group) => !selectedAppId || group.appId === selectedAppId),
|
||||||
[candidateBuilds, selectedAppId]
|
[candidateBuilds, selectedAppId]
|
||||||
);
|
);
|
||||||
const filteredReleases = useMemo(
|
const filteredReleases = releases;
|
||||||
() => releases
|
|
||||||
.filter((release) => !selectedAppId || release.appId === selectedAppId)
|
|
||||||
.sort((a, b) => releaseTime(b) - releaseTime(a)),
|
|
||||||
[releases, selectedAppId]
|
|
||||||
);
|
|
||||||
const modalBuilds = useMemo(
|
const modalBuilds = useMemo(
|
||||||
() => candidateBuilds
|
() => candidateBuilds
|
||||||
.filter((build) => !selectedAppId || build.component.app.id === selectedAppId)
|
.filter((build) => !selectedAppId || build.component.app.id === selectedAppId)
|
||||||
@@ -184,10 +197,26 @@ export function ReleaseManagementPage() {
|
|||||||
const refresh = async () => {
|
const refresh = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const [nextReleases, nextBuilds] = await Promise.all([listReleases(), listBuildJobs()]);
|
const [nextReleases, nextBuilds] = await Promise.all([
|
||||||
const succeededBuilds = nextBuilds.filter((job) => job.status === 'SUCCEEDED');
|
listReleasesPage({
|
||||||
setReleases(nextReleases);
|
appKey: apps.find((app) => app.id === selectedAppId)?.key,
|
||||||
setBuilds(succeededBuilds);
|
page,
|
||||||
|
limit: releasePageSize,
|
||||||
|
since: releaseRangeSince(range)
|
||||||
|
}),
|
||||||
|
listBuildJobsPage({
|
||||||
|
appKey: apps.find((app) => app.id === selectedAppId)?.key,
|
||||||
|
page: 1,
|
||||||
|
limit: 100,
|
||||||
|
since: releaseRangeSince(range),
|
||||||
|
status: 'SUCCEEDED'
|
||||||
|
})
|
||||||
|
]);
|
||||||
|
setReleases(nextReleases.releases);
|
||||||
|
setTotalReleases(nextReleases.total);
|
||||||
|
setUsedBuildIdValues(nextReleases.usedBuildIds ?? []);
|
||||||
|
setUsedReleaseValueValues(nextReleases.usedValues ?? []);
|
||||||
|
setBuilds(nextBuilds.jobs);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -195,16 +224,15 @@ export function ReleaseManagementPage() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void refresh();
|
void refresh();
|
||||||
}, []);
|
}, [selectedAppId, page, range, apps]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setPage(1);
|
setPage(1);
|
||||||
}, [selectedAppId]);
|
}, [selectedAppId, range]);
|
||||||
|
|
||||||
const totalPages = Math.max(1, Math.ceil(filteredReleases.length / releasePageSize));
|
const totalPages = Math.max(1, Math.ceil(totalReleases / releasePageSize));
|
||||||
const currentPage = Math.min(page, totalPages);
|
const currentPage = page;
|
||||||
const pageStart = (currentPage - 1) * releasePageSize;
|
const pagedReleases = filteredReleases;
|
||||||
const pagedReleases = filteredReleases.slice(pageStart, pageStart + releasePageSize);
|
|
||||||
|
|
||||||
const openReleaseModal = (appId?: string) => {
|
const openReleaseModal = (appId?: string) => {
|
||||||
const nextAppId = appId || selectedAppId || candidateGroups[0]?.appId || '';
|
const nextAppId = appId || selectedAppId || candidateGroups[0]?.appId || '';
|
||||||
@@ -252,7 +280,24 @@ export function ReleaseManagementPage() {
|
|||||||
|
|
||||||
<div className="grid gap-4 xl:grid-cols-[minmax(0,1fr)_320px]">
|
<div className="grid gap-4 xl:grid-cols-[minmax(0,1fr)_320px]">
|
||||||
<Card className="min-w-0 overflow-hidden">
|
<Card className="min-w-0 overflow-hidden">
|
||||||
<CardHeader title="릴리즈 목록" description="release 브랜치에서 빌드된 산출물 기준의 배포 후보입니다." />
|
<CardHeader
|
||||||
|
title="릴리즈 목록"
|
||||||
|
description="release 브랜치에서 빌드된 산출물 기준의 배포 후보입니다."
|
||||||
|
action={
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{(Object.keys(releaseRangeLabels) as ReleaseRange[]).map((item) => (
|
||||||
|
<Button
|
||||||
|
key={item}
|
||||||
|
variant={range === item ? 'default' : 'outline'}
|
||||||
|
className="h-8 px-3 text-[13px]"
|
||||||
|
onClick={() => setRange(item)}
|
||||||
|
>
|
||||||
|
{releaseRangeLabels[item]}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<div className="space-y-3 p-4">
|
<div className="space-y-3 p-4">
|
||||||
{filteredReleases.length === 0 ? (
|
{filteredReleases.length === 0 ? (
|
||||||
<div className="flex min-h-44 items-center justify-center text-center text-sm text-muted-foreground">
|
<div className="flex min-h-44 items-center justify-center text-center text-sm text-muted-foreground">
|
||||||
@@ -306,7 +351,7 @@ export function ReleaseManagementPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between gap-4 border-t border-border px-5 py-3 text-sm">
|
<div className="flex items-center justify-between gap-4 border-t border-border px-5 py-3 text-sm">
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
{loading ? '불러오는 중' : `총 ${filteredReleases.length}건 · ${currentPage} / ${totalPages}`}
|
{loading ? '불러오는 중' : `총 ${totalReleases}건 · ${currentPage} / ${totalPages}`}
|
||||||
</span>
|
</span>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -244,6 +244,15 @@ export function RepositoryManagementPage() {
|
|||||||
[giteaRepos, selectedAppRepoKeys]
|
[giteaRepos, selectedAppRepoKeys]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handleRepoNameChange = (value: string) => {
|
||||||
|
const normalized = value
|
||||||
|
.trimStart()
|
||||||
|
.replace(new RegExp(`^${giteaAppOrg.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}/`, 'i'), '')
|
||||||
|
.replace(/^\/+/, '')
|
||||||
|
.replace(/\.git$/i, '');
|
||||||
|
setRepoName(normalized);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageTitle
|
<PageTitle
|
||||||
@@ -373,11 +382,17 @@ export function RepositoryManagementPage() {
|
|||||||
<div className="space-y-4 px-5 py-5">
|
<div className="space-y-4 px-5 py-5">
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<span className="text-xs font-medium text-muted-foreground">저장소명</span>
|
<span className="text-xs font-medium text-muted-foreground">저장소명</span>
|
||||||
<input
|
<div className="mt-2 flex h-9 overflow-hidden rounded-md border border-border bg-background focus-within:border-primary">
|
||||||
value={repoName}
|
<span className="flex h-full shrink-0 items-center border-r border-border bg-muted px-3 text-xs font-semibold text-muted-foreground">
|
||||||
onChange={(event) => setRepoName(event.target.value)}
|
{giteaAppOrg}/
|
||||||
className="mt-2 h-9 w-full rounded-md border border-border bg-background px-3 text-sm outline-none focus:border-primary"
|
</span>
|
||||||
/>
|
<input
|
||||||
|
value={repoName}
|
||||||
|
onChange={(event) => handleRepoNameChange(event.target.value)}
|
||||||
|
placeholder="lms-fe"
|
||||||
|
className="h-full min-w-0 flex-1 bg-transparent px-3 text-sm outline-none placeholder:text-muted-foreground"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</label>
|
</label>
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<span className="text-xs font-medium text-muted-foreground">설명</span>
|
<span className="text-xs font-medium text-muted-foreground">설명</span>
|
||||||
|
|||||||
@@ -150,6 +150,17 @@ export function listBuildJobs(appKey?: string) {
|
|||||||
return apiRequest<BuildJob[]>(`/apps/builds${query}`);
|
return apiRequest<BuildJob[]>(`/apps/builds${query}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function listBuildJobsPage(options: { appKey?: string; page: number; limit: number; since?: string; status?: string }) {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
page: String(options.page),
|
||||||
|
limit: String(options.limit)
|
||||||
|
});
|
||||||
|
if (options.appKey) params.set('appKey', options.appKey);
|
||||||
|
if (options.since) params.set('since', options.since);
|
||||||
|
if (options.status) params.set('status', options.status);
|
||||||
|
return apiRequest<{ jobs: BuildJob[]; total: number; page: number; limit: number }>(`/apps/builds?${params.toString()}`);
|
||||||
|
}
|
||||||
|
|
||||||
export function createBuildJob(input: { componentId: string; branch?: string; commitSha?: string }) {
|
export function createBuildJob(input: { componentId: string; branch?: string; commitSha?: string }) {
|
||||||
return apiRequest<{ buildJob: BuildJob }>('/apps/builds', {
|
return apiRequest<{ buildJob: BuildJob }>('/apps/builds', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -170,6 +181,16 @@ export function listReleases(appKey?: string) {
|
|||||||
return apiRequest<AppRelease[]>(`/apps/releases${query}`);
|
return apiRequest<AppRelease[]>(`/apps/releases${query}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function listReleasesPage(options: { appKey?: string; page: number; limit: number; since?: string }) {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
page: String(options.page),
|
||||||
|
limit: String(options.limit)
|
||||||
|
});
|
||||||
|
if (options.appKey) params.set('appKey', options.appKey);
|
||||||
|
if (options.since) params.set('since', options.since);
|
||||||
|
return apiRequest<{ releases: AppRelease[]; total: number; page: number; limit: number; usedBuildIds?: string[]; usedValues?: string[] }>(`/apps/releases?${params.toString()}`);
|
||||||
|
}
|
||||||
|
|
||||||
export function createRelease(input: { appId: string; version: string; branch?: string; buildJobIds: string[] }) {
|
export function createRelease(input: { appId: string; version: string; branch?: string; buildJobIds: string[] }) {
|
||||||
return apiRequest<{ release: AppRelease }>('/apps/releases', {
|
return apiRequest<{ release: AppRelease }>('/apps/releases', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -182,6 +203,16 @@ export function listDeployments(appKey?: string) {
|
|||||||
return apiRequest<DeploymentJob[]>(`/apps/deployments${query}`);
|
return apiRequest<DeploymentJob[]>(`/apps/deployments${query}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function listDeploymentsPage(options: { appKey?: string; page: number; limit: number; since?: string }) {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
page: String(options.page),
|
||||||
|
limit: String(options.limit)
|
||||||
|
});
|
||||||
|
if (options.appKey) params.set('appKey', options.appKey);
|
||||||
|
if (options.since) params.set('since', options.since);
|
||||||
|
return apiRequest<{ deployments: DeploymentJob[]; total: number; page: number; limit: number; deployedReleaseIds?: string[] }>(`/apps/deployments?${params.toString()}`);
|
||||||
|
}
|
||||||
|
|
||||||
export function createDeployment(input: { releaseId: string; environment: string; namespace: string; updateLauncherAppVersion?: boolean }) {
|
export function createDeployment(input: { releaseId: string; environment: string; namespace: string; updateLauncherAppVersion?: boolean }) {
|
||||||
return apiRequest<{ deploymentJob: DeploymentJob }>('/apps/deployments', {
|
return apiRequest<{ deploymentJob: DeploymentJob }>('/apps/deployments', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -193,6 +224,16 @@ export function listMigrations() {
|
|||||||
return apiRequest<RuntimeControlJob[]>('/apps/migrations');
|
return apiRequest<RuntimeControlJob[]>('/apps/migrations');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function listMigrationsPage(options: { appId?: string; page: number; limit: number; since?: string }) {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
page: String(options.page),
|
||||||
|
limit: String(options.limit)
|
||||||
|
});
|
||||||
|
if (options.appId) params.set('appId', options.appId);
|
||||||
|
if (options.since) params.set('since', options.since);
|
||||||
|
return apiRequest<{ jobs: RuntimeControlJob[]; total: number; page: number; limit: number }>(`/apps/migrations?${params.toString()}`);
|
||||||
|
}
|
||||||
|
|
||||||
export function createMigration(input: {
|
export function createMigration(input: {
|
||||||
releaseId: string;
|
releaseId: string;
|
||||||
migrationComponentId: string;
|
migrationComponentId: string;
|
||||||
|
|||||||
@@ -242,7 +242,6 @@ export function NoticesPage() {
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
{ key: 'appKey', header: '대상 앱', cell: (row) => <StatusBadge status={row.appName} tone={isLauncherNoticeKey(row.appKey) ? 'violet' : 'blue'} /> },
|
{ key: 'appKey', header: '대상 앱', cell: (row) => <StatusBadge status={row.appName} tone={isLauncherNoticeKey(row.appKey) ? 'violet' : 'blue'} /> },
|
||||||
{ key: 'status', header: '상태', cell: (row) => <StatusBadge status={row.status ?? (row.isActive ? '게시중' : '비활성')} /> },
|
|
||||||
{ key: 'exposure', header: '노출 일시', className: 'w-48 px-5 py-3 font-medium', cell: exposureCell },
|
{ key: 'exposure', header: '노출 일시', className: 'w-48 px-5 py-3 font-medium', cell: exposureCell },
|
||||||
{ key: 'createdAt', header: '생성일시', cell: (row) => <span className="text-muted-foreground">{formatNoticeDateTime(row.createdAt)}</span> },
|
{ key: 'createdAt', header: '생성일시', cell: (row) => <span className="text-muted-foreground">{formatNoticeDateTime(row.createdAt)}</span> },
|
||||||
{
|
{
|
||||||
@@ -273,8 +272,7 @@ export function NoticesPage() {
|
|||||||
|
|
||||||
const filters = useMemo<Array<DataTableFilter<(typeof rows)[number]>>>(
|
const filters = useMemo<Array<DataTableFilter<(typeof rows)[number]>>>(
|
||||||
() => [
|
() => [
|
||||||
{ key: 'appKey', label: '대상 앱', options: [...new Set(rows.map((row) => row.appKey))].map((appKey) => ({ label: appLabel(appKey), value: appKey })), getValue: (row) => row.appKey },
|
{ key: 'appKey', label: '대상 앱', options: [...new Set(rows.map((row) => row.appKey))].map((appKey) => ({ label: appLabel(appKey), value: appKey })), getValue: (row) => row.appKey }
|
||||||
{ key: 'status', label: '상태', options: [...new Set(rows.map((row) => row.status ?? (row.isActive ? '게시중' : '비활성')))].map((status) => ({ label: status, value: status })), getValue: (row) => row.status ?? (row.isActive ? '게시중' : '비활성') }
|
|
||||||
],
|
],
|
||||||
[rows]
|
[rows]
|
||||||
);
|
);
|
||||||
@@ -306,7 +304,7 @@ export function NoticesPage() {
|
|||||||
getSearchText={(row) => `${row.title} ${row.body} ${row.appKey} ${row.appName} ${row.linkUrl ?? ''} ${row.status ?? ''}`}
|
getSearchText={(row) => `${row.title} ${row.body} ${row.appKey} ${row.appName} ${row.linkUrl ?? ''} ${row.status ?? ''}`}
|
||||||
getRowKey={(row) => row.id}
|
getRowKey={(row) => row.id}
|
||||||
pageSize={6}
|
pageSize={6}
|
||||||
minWidth={1180}
|
minWidth={1040}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
emptyMessage="등록된 공지사항이 없습니다."
|
emptyMessage="등록된 공지사항이 없습니다."
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -462,8 +462,15 @@ export function RuntimeConfigPage() {
|
|||||||
</div>
|
</div>
|
||||||
<span className="text-xs text-muted-foreground">총 {variables.length}개</span>
|
<span className="text-xs text-muted-foreground">총 {variables.length}개</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="max-h-[480px] overflow-x-auto overflow-y-auto">
|
<div className="max-h-[480px] overflow-y-auto">
|
||||||
<table className="w-full min-w-[920px] text-left text-sm">
|
<table className="w-full table-fixed text-left text-sm">
|
||||||
|
<colgroup>
|
||||||
|
<col className="w-[22%]" />
|
||||||
|
<col className="w-[29%]" />
|
||||||
|
<col className="w-[19%]" />
|
||||||
|
<col className="w-[24%]" />
|
||||||
|
<col className="w-20" />
|
||||||
|
</colgroup>
|
||||||
<thead className="sticky top-0 z-10 border-b border-border bg-card text-xs text-muted-foreground">
|
<thead className="sticky top-0 z-10 border-b border-border bg-card text-xs text-muted-foreground">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="px-5 py-3 font-medium">구성요소</th>
|
<th className="px-5 py-3 font-medium">구성요소</th>
|
||||||
@@ -479,16 +486,16 @@ export function RuntimeConfigPage() {
|
|||||||
) : null}
|
) : null}
|
||||||
{variables.map((variable) => (
|
{variables.map((variable) => (
|
||||||
<tr key={variable.id} className="hover:bg-muted/20">
|
<tr key={variable.id} className="hover:bg-muted/20">
|
||||||
<td className="px-5 py-3">
|
<td className="px-4 py-3">
|
||||||
<span className="inline-flex items-center gap-2">
|
<span className="inline-flex items-center gap-2">
|
||||||
<span className={variable.isSecret ? 'block size-2.5 rounded-full bg-amber-400' : 'block size-2.5 rounded-full bg-emerald-400'} />
|
<span className={variable.isSecret ? 'block size-2.5 rounded-full bg-amber-400' : 'block size-2.5 rounded-full bg-emerald-400'} />
|
||||||
<span>{variable.component?.name ?? '-'}</span>
|
<span className="truncate">{variable.component?.name ?? '-'}</span>
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-5 py-3 font-mono text-xs font-medium text-foreground">{variable.key}</td>
|
<td className="break-all px-4 py-3 font-mono text-xs font-medium text-foreground">{variable.key}</td>
|
||||||
<td className="px-5 py-3 font-mono text-xs text-muted-foreground">{variable.isSecret ? '********' : variable.value}</td>
|
<td className="break-all px-4 py-3 font-mono text-xs text-muted-foreground">{variable.isSecret ? '********' : variable.value}</td>
|
||||||
<td className="px-5 py-3 text-xs text-muted-foreground">{formatDate(variable.createdAt)}</td>
|
<td className="px-4 py-3 text-xs text-muted-foreground">{formatDate(variable.createdAt)}</td>
|
||||||
<td className="px-5 py-3 text-right">
|
<td className="px-3 py-3 text-right">
|
||||||
<Button variant="ghost" className="h-8 w-8 p-0 text-rose-300 hover:text-rose-200" title="삭제" aria-label={`${variable.key} 삭제`} onClick={() => removeVariable(variable.id)} disabled={Boolean(actionKey)}>
|
<Button variant="ghost" className="h-8 w-8 p-0 text-rose-300 hover:text-rose-200" title="삭제" aria-label={`${variable.key} 삭제`} onClick={() => removeVariable(variable.id)} disabled={Boolean(actionKey)}>
|
||||||
<Trash2 className="size-4" />
|
<Trash2 className="size-4" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -552,8 +559,16 @@ export function RuntimeConfigPage() {
|
|||||||
</div>
|
</div>
|
||||||
<span className="text-xs text-muted-foreground">총 {ingressRules.length}개</span>
|
<span className="text-xs text-muted-foreground">총 {ingressRules.length}개</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="max-h-[320px] overflow-x-auto overflow-y-auto">
|
<div className="max-h-[320px] overflow-y-auto">
|
||||||
<table className="w-full min-w-[940px] text-left text-sm">
|
<table className="w-full table-fixed text-left text-sm">
|
||||||
|
<colgroup>
|
||||||
|
<col className="w-[24%]" />
|
||||||
|
<col className="w-[32%]" />
|
||||||
|
<col className="w-[16%]" />
|
||||||
|
<col className="w-[10%]" />
|
||||||
|
<col className="w-[10%]" />
|
||||||
|
<col className="w-14" />
|
||||||
|
</colgroup>
|
||||||
<thead className="sticky top-0 z-10 border-b border-border bg-card text-xs text-muted-foreground">
|
<thead className="sticky top-0 z-10 border-b border-border bg-card text-xs text-muted-foreground">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="px-5 py-3 font-medium">구성요소</th>
|
<th className="px-5 py-3 font-medium">구성요소</th>
|
||||||
@@ -570,17 +585,17 @@ export function RuntimeConfigPage() {
|
|||||||
) : null}
|
) : null}
|
||||||
{ingressRules.map((rule) => (
|
{ingressRules.map((rule) => (
|
||||||
<tr key={rule.id} className="hover:bg-muted/20">
|
<tr key={rule.id} className="hover:bg-muted/20">
|
||||||
<td className="px-5 py-3">
|
<td className="px-4 py-3">
|
||||||
<span className="inline-flex items-center gap-2">
|
<span className="inline-flex items-center gap-2">
|
||||||
<span className="block size-2.5 rounded-full bg-sky-400" />
|
<span className="block size-2.5 rounded-full bg-sky-400" />
|
||||||
<span>{rule.component?.name ?? '-'}</span>
|
<span className="truncate">{rule.component?.name ?? '-'}</span>
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-5 py-3 text-foreground">{rule.host}</td>
|
<td className="break-all px-4 py-3 text-foreground">{rule.host}</td>
|
||||||
<td className="px-5 py-3 text-muted-foreground">{rule.path}</td>
|
<td className="break-all px-4 py-3 text-muted-foreground">{rule.path}</td>
|
||||||
<td className="px-5 py-3 text-muted-foreground">{rule.servicePort}</td>
|
<td className="px-4 py-3 text-muted-foreground">{rule.servicePort}</td>
|
||||||
<td className="px-5 py-3 text-muted-foreground">{rule.stripPrefix ? '제거' : '유지'}</td>
|
<td className="px-4 py-3 text-muted-foreground">{rule.stripPrefix ? '제거' : '유지'}</td>
|
||||||
<td className="px-5 py-3 text-right">
|
<td className="px-3 py-3 text-right">
|
||||||
<Button variant="ghost" className="h-8 w-8 p-0 text-rose-300 hover:text-rose-200" title="삭제" aria-label={`${rule.host} 삭제`} onClick={() => removeIngressRule(rule.id)} disabled={Boolean(actionKey)}>
|
<Button variant="ghost" className="h-8 w-8 p-0 text-rose-300 hover:text-rose-200" title="삭제" aria-label={`${rule.host} 삭제`} onClick={() => removeIngressRule(rule.id)} disabled={Boolean(actionKey)}>
|
||||||
<Trash2 className="size-4" />
|
<Trash2 className="size-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
Reference in New Issue
Block a user