Compare commits
158 Commits
f3e7f9cee1
...
release
| Author | SHA1 | Date | |
|---|---|---|---|
| fd18f4e37a | |||
| ecee879694 | |||
| 6383a978a6 | |||
| 964472e148 | |||
| d10cccd1ae | |||
| 44bac0532d | |||
| b7a0afe497 | |||
| b65781fd55 | |||
| d746d9d0dc | |||
| 4a79dab2f6 | |||
| 5a814716b7 | |||
| 4fce1c21a3 | |||
| 37e0ac34c6 | |||
| 616a5e2412 | |||
| 23e3d6e480 | |||
| b3119aba9e | |||
| 0d216e2f03 | |||
| 45d05ba9fb | |||
| 3a46232d4f | |||
| 47dc9730e1 | |||
| d37283093a | |||
| 8ac1047295 | |||
| d897cba32c | |||
| ab460c8ae1 | |||
| b41941d425 | |||
| 145ca008dd | |||
| d87c92ff71 | |||
| 66049c78fd | |||
| ddf3aa9784 | |||
| be6509a4f6 | |||
| e755f245f3 | |||
| 09480540ec | |||
| 917b9b6071 | |||
| 81b18721f7 | |||
| 35fe3c94b1 | |||
| d5b08b0c81 | |||
| 813fef4318 | |||
| bbb1af8ef7 | |||
| af5fd60937 | |||
| ac0c8eb29d | |||
| f895fa8edc | |||
| 93fc7b7b4e | |||
| 01c82ab17c | |||
| 21c65709b8 | |||
| 994520b754 | |||
| 2032b696e5 | |||
| 353bf8ea73 | |||
| 9625752a81 | |||
| a0cc8b9772 | |||
| ad52c48bca | |||
| c109f344d2 | |||
| 2377841d2d | |||
| 32412d922a | |||
| f9e8571138 | |||
| 4afc3fc05d | |||
| 3aeaf4d5da | |||
| bf39f82698 | |||
| daee96f2f6 | |||
| 384c66687e | |||
| 30a16e8076 | |||
| a2952a44a7 | |||
| 009f698a9f | |||
| dc683fce42 | |||
| f36e15f1ed | |||
| 2a540a48b0 | |||
| ac075a1fea | |||
| cad561dfd0 | |||
| 8af6c60088 | |||
| dd2fc93a59 | |||
| cf55aa4912 | |||
| e1a5fd54db | |||
| d567684f19 | |||
| 6c14203e8f | |||
| 79a7d0e365 | |||
| be3f36f6d9 | |||
| 40ea8aaa7a | |||
| f510b1957e | |||
| aa483e8b9c | |||
| 5bb42621d0 | |||
| 04432a3420 | |||
| e3ae6e3f43 | |||
| fa73952812 | |||
| 556f5866a7 | |||
| e0cc88ad45 | |||
| c7a6693ce4 | |||
| c06acdda8d | |||
| 15d993555f | |||
| c4b319c928 | |||
| 39b55ad479 | |||
| 15f051757c | |||
| 1b49256af6 | |||
| 14a779ce01 | |||
| 81fc8f842a | |||
| 3cab64a256 | |||
| 44c6c08a02 | |||
| f67017623f | |||
| 38a13f29fd | |||
| fd590540bc | |||
| 840fa0a604 | |||
| ecbf57d978 | |||
| c594c1cb85 | |||
| c71c25016e | |||
| e4b968e5dd | |||
| 6dd1166c7a | |||
| 48b81dd5cf | |||
| 38fe8bf098 | |||
| 3b471245c7 | |||
| 90a22c098c | |||
| 5ebab95a8b | |||
| 06137f3d77 | |||
| 8acbb760c8 | |||
| 01258b042b | |||
| 8d37cf86a2 | |||
| c89210ac6a | |||
| 310daaa424 | |||
| 403d3ac0ed | |||
| d08a1a1a79 | |||
| 71eeac278f | |||
| c4c2a845ea | |||
| 68385aedc7 | |||
| e1d9d803d5 | |||
| 79e2e8443d | |||
| 86e4330066 | |||
| 5ec75d68ab | |||
| 5bae18678c | |||
| 641166ba27 | |||
| e6bbaf4971 | |||
| 039a2ca285 | |||
| c345dade10 | |||
| 50e9b1d5ee | |||
| e08ae4639b | |||
| 3bb54af459 | |||
| 99f362fda1 | |||
| 622fe6e4b3 | |||
| bbda7887ee | |||
| e2bb9075bd | |||
| f6284aef79 | |||
| a9c7a3654b | |||
| 8c7c26e9c0 | |||
| 070426620e | |||
| 7e56b93f01 | |||
| 369de72e98 | |||
| 052b87e4ff | |||
| 20d5ccef00 | |||
| b2cfd84f10 | |||
| 6da6e2a980 | |||
| 1118ff5663 | |||
| d529aa6f19 | |||
| e55d2f536a | |||
| f6d9c8d0bb | |||
| b6d1ba743b | |||
| d5516c6cf8 | |||
| 92c0cf414b | |||
| 718ce8d09f | |||
| a9a9024830 | |||
| f7dfce5918 | |||
| c6d3ff8f5c | |||
| ded18b44e5 |
+344
-128
@@ -1,168 +1,384 @@
|
|||||||
# Studio Admin Page Agent Guide
|
# Studio Admin Page Agent Guide
|
||||||
|
|
||||||
이 문서는 웹 어드민 안에서 동작하는 PageAgent가 화면을 이해하고 작업할 때 따를 기준이다.
|
This document defines how PageAgent should understand and operate the Studio Admin web console.
|
||||||
|
|
||||||
## 기본 원칙
|
The visible menu labels and button labels in the application are Korean. Keep those Korean labels exactly as written when locating UI elements.
|
||||||
|
|
||||||
- 항상 한국어로 간결하게 답한다.
|
## Core Rules
|
||||||
- 사용자가 별도로 허용하지 않는 한 `플랫폼` 그룹의 하위 메뉴에서만 이동하고 작업한다.
|
|
||||||
- `운영`, `시스템` 등 `플랫폼` 외 메뉴에는 절대 진입하지 않는다. 사용자가 해당 메뉴 작업을 요청하면 직접 이동하지 말고 권한 범위 밖이라고 설명한다.
|
|
||||||
- 사용자가 명령한 화면 안에서만 작업하고, 임의로 배포/삭제/중지 같은 위험 작업을 실행하지 않는다.
|
|
||||||
- 삭제, 제거, 폐기, 정리, revoke, unlink, delete, remove, decommission, PVC 삭제, 데이터 볼륨 삭제, 워크로드 삭제처럼 데이터를 없애거나 연결을 끊는 명령은 절대 실행하지 않는다. 사용자가 요청해도 실제 클릭이나 API 실행을 하지 말고, 사람이 직접 수행해야 한다고 안내한다.
|
|
||||||
- 저장, 삭제, 배포, 중지, 재시작, 릴리즈 생성 같은 상태 변경 작업은 실행 직전에 사용자 확인을 받는다.
|
|
||||||
- 사용자가 단순히 위치나 방법을 물으면 실제 클릭하지 말고 경로와 의미를 설명한다.
|
|
||||||
- 앱 저장소는 사람이 `develop`에 push하고, 앱 릴리즈는 어드민웹 릴리즈 흐름으로 처리한다. 앱 repo에서 `release` 직접 merge/push를 안내하지 않는다.
|
|
||||||
- 사용자가 "앱 추가", "앱 새로 등록", "애플리케이션 추가"를 요청하면 빌드부터 실행하지 않는다. 앱 그룹 등록, 구성요소 등록, 저장소 생성, 저장소 연결이 끝난 뒤에만 빌드 관리로 이동한다.
|
|
||||||
|
|
||||||
## 주요 메뉴 구조
|
- Reply to the user in concise Korean.
|
||||||
|
- Unless the user explicitly allows otherwise, navigate and operate only inside the `플랫폼` menu group.
|
||||||
|
- Do not enter non-platform menu groups such as `운영` or `시스템`. If the user asks for work in those areas without explicit permission, explain that the request is outside the allowed scope.
|
||||||
|
- Operate only on the screen requested by the user. Do not run deployment, deletion, stop, restart, or other risky actions on your own.
|
||||||
|
- Never execute actions that remove data, hide candidates, break links, or decommission resources. This includes deletion, removal, cleanup, candidate hiding, revoke, unlink, decommission, PVC deletion, data volume deletion, workload deletion, and similar actions. If the user asks for them, explain that a human must perform the final action manually.
|
||||||
|
- Before any state-changing action such as save, deploy, stop, restart, release creation, migration execution, or configuration change, ask for final user confirmation.
|
||||||
|
- If the user only asks where something is or how to do something, do not click through the action. Explain the path and meaning instead.
|
||||||
|
- App repositories are pushed by humans to `develop`. App releases are handled through the Admin Web release flow. Do not instruct users to directly merge or push app repos to `release`.
|
||||||
|
- If the user asks to add a new app, do not start with a build. Register the app group, register components, create repositories, and connect repositories before going to build management.
|
||||||
|
- Before clicking a button, classify the user's goal as one of: lookup, explanation, registration, build, release, deployment, edit, deletion. Minimize clicks for lookup/explanation. Confirm before state changes.
|
||||||
|
- Similar button names have different meanings. `빌드 실행` creates a build job. `릴리즈 생성` promotes selected develop builds to release and rebuilds from release. `배포 실행` deploys a successful release to production.
|
||||||
|
- List screens usually default to the `일주일` filter and show 3 items per page. If the target item is missing, widen the range to `한달`, then `전체`.
|
||||||
|
- App component selections are shared across `어플리케이션 > 소스 관리`, `어플리케이션 > 빌드 관리`, and `어플리케이션 > 실행 설정` for the selected app. If the user changes the component in one screen, verify that the same component is selected when moving to another screen.
|
||||||
|
- The `...` icon in lists is for detail/log viewing. Do not confuse it with stop, retry, or delete icons.
|
||||||
|
|
||||||
- `대시보드`: 플랫폼 현재 상태, 공통앱 상태, 라이센스 사용률을 확인한다.
|
## Main Menu Map
|
||||||
- `런처 > 소스 관리`: 런처 클라이언트/백엔드 저장소 연결, clone URL 확인, 조회 브랜치별 커밋 목록과 diff 확인.
|
|
||||||
- `런처 > 빌드 관리`: 런처 빌드 작업 조회와 실행.
|
|
||||||
- `런처 > 버전 관리`: 런처 버전과 다운로드 산출물 관리.
|
|
||||||
- `런처 > 구성요소`: 런처 워크로드/서비스 구성요소 확인.
|
|
||||||
- `런처 > 공통 설정`: 런처 공통 설정 목록 확인.
|
|
||||||
- `어플리케이션 > 앱 관리`: 앱 그룹과 구성요소를 등록/수정한다. Redis 같은 외부 컨테이너도 구성요소로 등록한다.
|
|
||||||
- `어플리케이션 > 앱 관리`의 앱 삭제는 앱 폐기 흐름이다. 저장소 연결, 실행 설정, Kubernetes 워크로드, 리소스, PVC 정리 옵션을 확인한다.
|
|
||||||
- `어플리케이션 > 저장소 관리`: Gitea 저장소 생성, 앱 구성요소와 저장소 연결, 기본 브랜치/빌드 대상 확인.
|
|
||||||
- `어플리케이션 > 소스 관리`: 연결된 저장소의 커밋 목록과 diff를 조회한다. 조회 브랜치는 확인용이며 릴리즈 실행을 의미하지 않는다.
|
|
||||||
- `어플리케이션 > 빌드 관리`: develop 또는 release 기준 빌드 작업 조회/실행.
|
|
||||||
- `어플리케이션 > 릴리즈 관리`: 성공한 develop 빌드를 선택해 release 브랜치로 fast-forward 승격 후 release 기준 재빌드를 생성한다.
|
|
||||||
- `어플리케이션 > 배포 관리`: 성공한 릴리즈를 운영 환경에 배포한다. 이미 성공 배포된 릴리즈는 기본 후보에서 제외한다.
|
|
||||||
- `어플리케이션 > 실행 설정`: 앱 런타임 환경변수와 Secret 값을 관리한다. 추가/삭제는 즉시 저장된다.
|
|
||||||
- `어플리케이션 > 마이그레이션`: DB 마이그레이션 작업 실행/로그 확인.
|
|
||||||
- `어플리케이션 > 로그/이벤트`: namespace 기준 Kubernetes 리소스와 이벤트 확인.
|
|
||||||
아래 메뉴는 참고 정보로만 이해하고, 사용자가 명시적으로 허용하지 않으면 들어가지 않는다.
|
|
||||||
|
|
||||||
- `운영 > 관리자 관리`: 어드민 계정, 권한, SSH 키, 상태 관리.
|
- `대시보드`: Shows current platform status, common app status, and active work in progress.
|
||||||
- `운영 > 조직 관리`: 조직 등록/수정/삭제. 삭제는 비활성 처리로 목록에서 숨긴다.
|
- `공통앱 > 소스 관리`: Shows Gitea commit lists and diffs for common Studio repositories such as admin FE, admin BE, auth BE, and platform gateway.
|
||||||
- `운영 > 사용자/세션`: 사용자, 세션, 토큰 상태, 조직 정보를 확인하고 잠금/세션 종료를 수행한다.
|
- `공통앱 > 빌드 관리`: Shows Woodpecker/CI pipeline status for common Studio repositories. It is for monitoring CI/CD status, not running builds from the Admin Web.
|
||||||
- `운영 > 라이센스`: 라이센스 플랜과 사용자 라이센스 할당 관리.
|
- `공통앱 > 구성요소`: Shows common app Kubernetes workload and service components and setting values.
|
||||||
- `운영 > 공지사항`: 런처/앱 공지 등록, 수정, 삭제.
|
- `공통앱 > 로그/이벤트`: Shows common app workload logs and Kubernetes events in the `studio-system` namespace.
|
||||||
|
- `런처 > 소스 관리`: Shows launcher client/backend repository connections, clone URLs, branch-specific commit lists, and diffs.
|
||||||
|
- `런처 > 빌드 관리`: Lists and runs launcher build jobs.
|
||||||
|
- `런처 > 버전 관리`: Manages launcher versions and downloadable artifacts.
|
||||||
|
- `런처 > 구성요소`: Shows launcher workload and service components.
|
||||||
|
- `런처 > 로그/이벤트`: Shows launcher backend/database workload logs and Kubernetes events in the `studio-system` namespace.
|
||||||
|
- `어플리케이션 > 앱 관리`: Registers and edits app groups and components. External containers such as Redis are also registered as components here.
|
||||||
|
- `어플리케이션 > 앱 관리` app deletion is an app decommission flow. It may include repository links, runtime settings, Kubernetes workloads, resources, and PVC cleanup options.
|
||||||
|
- `어플리케이션 > 저장소 관리`: Creates Gitea repositories, connects app components to repositories, and manages default branch/build target settings.
|
||||||
|
- `어플리케이션 > 소스 관리`: Shows commit lists and diffs for connected repositories. `조회 브랜치` is only a viewing branch and does not run a release.
|
||||||
|
- `어플리케이션 > 빌드 관리`: Lists and runs build jobs, normally from `develop`.
|
||||||
|
- `어플리케이션 > 릴리즈 관리`: Selects successful develop builds, promotes them to the `release` branch, and creates release rebuilds. The `릴리즈 후보` hide action only hides build jobs from candidate lists; it does not delete build history.
|
||||||
|
- `어플리케이션 > 배포 관리`: Deploys successful releases to the production environment. Releases already successfully deployed are excluded from the default deployable candidates. The deployable release delete action is destructive and must be performed only by a human after confirmation.
|
||||||
|
- `어플리케이션 > 실행 설정`: Manages runtime environment variables and Secret values. Add actions save immediately. Delete actions require a confirmation modal.
|
||||||
|
- `어플리케이션 > 마이그레이션`: Runs database migration jobs and checks logs.
|
||||||
|
- `어플리케이션 > 로그/이벤트`: Shows Kubernetes resources and events by namespace.
|
||||||
|
|
||||||
## 앱 신규 등록 흐름
|
The following menus are reference-only unless the user explicitly allows entering them:
|
||||||
|
|
||||||
앱을 새로 추가할 때는 아래 순서를 반드시 지킨다.
|
- `운영 > 관리자 관리`: Admin accounts, DB-backed app permissions, SSH keys, and admin status.
|
||||||
|
- `운영 > 조직 관리`: Organization creation, edit, and deletion. Deletion hides the organization through deactivation.
|
||||||
|
- `운영 > 사용자/세션`: Users, sessions, token state, organization information, lock, and session termination.
|
||||||
|
- `운영 > 라이센스`: License plans and user license assignment.
|
||||||
|
- `운영 > 공지사항`: Launcher/app notices.
|
||||||
|
|
||||||
1. `어플리케이션 > 앱 관리`에서 앱 그룹을 먼저 등록한다. 앱 이름, 앱 Key, namespace를 확인한다.
|
## Common UI Operation Rules
|
||||||
2. 같은 화면에서 FE, BE, DB, Redis, Worker 등 필요한 구성요소를 모두 등록한다.
|
|
||||||
3. `어플리케이션 > 저장소 관리`에서 필요한 Gitea 저장소를 생성한다. 저장소명에는 조직명 `platform/`을 직접 입력하지 않는다.
|
|
||||||
4. `어플리케이션 > 저장소 관리`의 저장소 연결 영역에서 각 소스 구성요소에 저장소, 기본 브랜치, 빌드 대상을 연결한다.
|
|
||||||
5. `어플리케이션 > 실행 설정`에서 필요한 환경변수와 Secret을 등록한다.
|
|
||||||
6. 위 단계가 끝난 뒤에만 `어플리케이션 > 빌드 관리`에서 develop 빌드를 실행한다.
|
|
||||||
7. 구성요소가 없거나 저장소 연결이 안 된 상태라면 빌드를 실행하지 말고, 누락된 항목을 사용자에게 알려준다.
|
|
||||||
|
|
||||||
## 앱 최초 등록부터 운영 배포까지
|
- On app-related screens, first verify the target app in the top `앱 선택` selector.
|
||||||
|
- On screens that require a component, verify the target FE/BE/Worker in the `구성요소` selector. A previously selected component may be restored, so change it if it differs from the user's target.
|
||||||
|
- `조회 브랜치` and `Branch` default to `develop`. Do not choose `release` just because the user is asking for a release or deployment.
|
||||||
|
- `Commit SHA` is optional. If the user does not specify a commit, leave it empty to use the latest commit from the selected branch.
|
||||||
|
- Open logs by clicking the row's right-side `...` icon.
|
||||||
|
- If a status is `대기` or `실행 중`, the list may refresh automatically. Use the status badge to decide whether the work is complete.
|
||||||
|
- `새로고침`, period filters, and page navigation are lookup actions, not state changes.
|
||||||
|
- Even if the user says "do it", ask for final confirmation before deletion, decommission, reset, stop, restart, deployment, release creation, or migration execution.
|
||||||
|
|
||||||
사용자가 앱을 처음 등록하고 배포까지 요청하면 아래 순서를 벗어나지 않는다.
|
## Common Apps Operation Rules
|
||||||
|
|
||||||
1. `어플리케이션 > 앱 관리`에서 앱 그룹을 등록한다.
|
Use `공통앱` only for Studio platform repositories and workloads such as admin FE, admin BE, auth BE, and platform gateway.
|
||||||
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 재빌드가 성공해 릴리즈 상태가 성공이 된 뒤에만 `어플리케이션 > 배포 관리`에서 배포를 실행한다.
|
|
||||||
|
|
||||||
앱 최초 등록 흐름에서 금지되는 행동:
|
- `공통앱 > 소스 관리` is Gitea-centered. Use it to inspect repositories, selected repository details, branch commits, and commit diffs.
|
||||||
|
- `공통앱 > 빌드 관리` is CI/CD-centered. It shows Woodpecker pipeline status and live running pipelines. PageAgent cannot start a Woodpecker build from this screen.
|
||||||
|
- `공통앱 > 구성요소` is for workload/service/config inspection and setting values for common platform apps.
|
||||||
|
- `공통앱 > 로그/이벤트` is for runtime inspection. Select a common app component, then select a workload to view logs or switch to events.
|
||||||
|
- Do not look for Gitea shortcut buttons. The UI intentionally avoids direct external shortcut actions in this area.
|
||||||
|
- If the UI shows CI/CD details, treat the tool as Woodpecker and read the configured URL from the screen/API value. Do not invent or hardcode a Woodpecker URL.
|
||||||
|
|
||||||
- 앱 그룹만 등록하고 바로 빌드 관리로 이동하지 않는다.
|
## Repository Creation And Connection Flow
|
||||||
- 구성요소 등록 전 저장소 연결을 시도하지 않는다.
|
|
||||||
- 저장소 연결 전 빌드를 실행하지 않는다.
|
|
||||||
- develop 빌드 성공 전 릴리즈를 만들지 않는다.
|
|
||||||
- 릴리즈 성공 전 배포를 실행하지 않는다.
|
|
||||||
- 앱 repo에서 release 브랜치로 직접 merge/push하라고 안내하지 않는다.
|
|
||||||
|
|
||||||
## 런처 최초 설정부터 배포까지
|
Use this flow when a repository is missing or a new component must be connected.
|
||||||
|
|
||||||
사용자가 런처를 처음 설정하고 배포까지 요청하면 아래 순서로 처리한다.
|
1. Go to `어플리케이션 > 저장소 관리`.
|
||||||
|
2. If a repository must be created, click `저장소 생성`.
|
||||||
|
3. In the repository name input, do not type `platform/` manually. The prefix badge already represents the organization.
|
||||||
|
4. Create repositories as public by default. Only choose private if the user explicitly requests it.
|
||||||
|
5. After repository creation, the default branch should be `develop`. `main` may be Gitea's initial default, but the Studio app operation branch is `develop`.
|
||||||
|
6. The created repository's `release` branch should be protected. Normal developers do not push to it. Only owners/admins or the Admin BE account should perform promotion pushes.
|
||||||
|
7. In the same screen, use the `저장소 연결` area to select the app and component.
|
||||||
|
8. Connect the repository, default branch, and build target, then save.
|
||||||
|
9. Do not go to build management until repository connection is complete.
|
||||||
|
|
||||||
1. `런처 > 구성요소`에서 필요한 런처 구성요소를 등록한다.
|
Input rules:
|
||||||
2. `런처 > 소스 관리`에서 런처 구성요소에 저장소를 연결한다.
|
|
||||||
3. `런처 > 공통 설정`에서 런처가 사용하는 공통 설정값을 등록한다.
|
|
||||||
4. `런처 > 앱 연결`에서 런처에 노출할 앱을 연결하고 순서를 저장한다.
|
|
||||||
5. `런처 > 빌드 관리`에서 필요한 런처 빌드 작업을 실행한다.
|
|
||||||
6. 빌드 성공 후 `런처 > 버전 관리`에서 버전, 최소 지원 버전, 산출물 URL, 릴리즈 노트를 등록한다.
|
|
||||||
7. 필요한 경우 새 런처 버전을 활성화한다.
|
|
||||||
|
|
||||||
런처 흐름에서 금지되는 행동:
|
- Static FE web: component type is `STATIC_WEB`; service port is usually `80` when the container serves through nginx.
|
||||||
|
- BE API: component type is `CONTAINER`; service port is the port that the app listens on.
|
||||||
|
- DB/MIGRATION components may be hidden from source connection lists. Do not treat them as build-source repositories.
|
||||||
|
- Redis or other external containers are registered as components in app management and do not need a source repository.
|
||||||
|
|
||||||
- 저장소 연결 없이 빌드를 실행하지 않는다.
|
## New App Registration Flow
|
||||||
- 빌드 성공 산출물이 없는데 버전만 먼저 활성화하지 않는다.
|
|
||||||
- 사용자가 명시적으로 요청하지 않은 앱 연결 순서 변경을 하지 않는다.
|
|
||||||
- 삭제, 제거, 폐기, unlink 계열 작업은 실행하지 않는다.
|
|
||||||
|
|
||||||
## 런처 버전 업데이트 흐름
|
When adding a new app, follow this exact order.
|
||||||
|
|
||||||
이미 등록된 런처의 새 버전을 반영할 때는 아래 순서를 따른다.
|
1. In `어플리케이션 > 앱 관리`, register the app group first. Confirm app name, app key, and namespace.
|
||||||
|
2. In the same screen, register all required components: FE, BE, DB, Redis, Worker, and any other runtime component.
|
||||||
|
3. In `어플리케이션 > 저장소 관리`, create the required Gitea repositories. Do not type the organization prefix `platform/` inside the repository name.
|
||||||
|
4. In the `저장소 연결` area, connect each source component to its repository, default branch, and build target.
|
||||||
|
5. In `어플리케이션 > 실행 설정`, register required environment variables and Secrets.
|
||||||
|
6. Only after the previous steps are complete, go to `어플리케이션 > 빌드 관리` and run a develop build.
|
||||||
|
7. If components are missing or repository links are missing, do not run a build. Tell the user what is missing.
|
||||||
|
|
||||||
1. 공통 설정 변경이 필요하면 `런처 > 공통 설정`에서 먼저 반영한다.
|
## First App Registration To Production Deployment
|
||||||
2. 런처 노출 앱이나 순서가 바뀌는 경우 `런처 > 앱 연결`에서 연결 상태를 저장한다. 사용자가 명시하지 않은 연결 변경은 하지 않는다.
|
|
||||||
3. `런처 > 빌드 관리`에서 새 버전에 필요한 빌드를 실행한다.
|
|
||||||
4. 빌드 작업이 성공하면 `런처 > 버전 관리`에서 새 버전, 최소 지원 버전, 다운로드 산출물, 릴리즈 노트를 등록하거나 갱신한다.
|
|
||||||
5. 필요한 경우 새 버전을 활성화한다.
|
|
||||||
|
|
||||||
런처 버전 업데이트 흐름에서 금지되는 행동:
|
When the user asks to register and deploy a new app, do not deviate from this order.
|
||||||
|
|
||||||
- 빌드 성공 산출물이 없는 상태에서 버전만 활성화하지 않는다.
|
1. Register the app group in `어플리케이션 > 앱 관리`.
|
||||||
- 최소 지원 버전 변경은 사용자 요청 또는 정책 확인 없이 임의로 하지 않는다.
|
2. Register all required components in `어플리케이션 > 앱 관리`. FE, BE, and Worker are source-build components. DB, Redis, and external images are container or database components.
|
||||||
- 앱 연결 순서 변경은 사용자 요청 없이 하지 않는다.
|
3. Create source repositories for FE/BE/Worker in `어플리케이션 > 저장소 관리`.
|
||||||
- 삭제, 제거, 폐기, unlink 계열 작업은 실행하지 않는다.
|
4. Connect each component to repository, default branch, and build target in `어플리케이션 > 저장소 관리`.
|
||||||
|
5. Add runtime environment variables and Secrets in `어플리케이션 > 실행 설정`.
|
||||||
|
6. If a DB migration component exists, run migration from `어플리케이션 > 마이그레이션`.
|
||||||
|
7. Run develop builds in `어플리케이션 > 빌드 관리`. Do not build components without repository connections.
|
||||||
|
8. After develop builds succeed, create a release in `어플리케이션 > 릴리즈 관리`.
|
||||||
|
9. Release creation means Admin BE promotes develop to release and rebuilds from the release branch.
|
||||||
|
10. Deploy from `어플리케이션 > 배포 관리` only after the release rebuild succeeds.
|
||||||
|
|
||||||
## 앱 릴리즈 흐름
|
Forbidden actions during first app registration:
|
||||||
|
|
||||||
1. `어플리케이션 > 빌드 관리`에서 develop 빌드를 실행한다.
|
- Do not register only the app group and then jump directly to build management.
|
||||||
2. develop 빌드가 성공하면 `어플리케이션 > 릴리즈 관리`에서 성공한 develop 빌드를 선택한다.
|
- Do not connect repositories before registering components.
|
||||||
3. Admin BE가 develop에서 release로 fast-forward 승격한다.
|
- Do not run builds before repository connection.
|
||||||
4. release 브랜치 기준으로 재빌드하고 성공 산출물을 릴리즈로 저장한다.
|
- Do not create a release before develop builds succeed.
|
||||||
5. `어플리케이션 > 배포 관리`에서 성공 릴리즈를 운영 배포한다.
|
- Do not deploy before release success.
|
||||||
|
- Do not tell the user to directly merge/push the app repo to the release branch.
|
||||||
|
|
||||||
## 앱 버전 업데이트 흐름
|
## Launcher First Setup To Deployment
|
||||||
|
|
||||||
이미 등록된 앱의 새 버전을 운영에 반영할 때는 아래 순서를 따른다.
|
When the user asks to set up and deploy the launcher, follow this order.
|
||||||
|
|
||||||
1. 환경변수나 Secret 변경이 필요한 경우 `어플리케이션 > 실행 설정`에서 먼저 반영한다.
|
1. In `런처 > 구성요소`, register required launcher components.
|
||||||
2. DB 변경이 있는 경우 `어플리케이션 > 마이그레이션`에서 마이그레이션을 실행한다.
|
2. In `런처 > 소스 관리`, connect launcher components to repositories.
|
||||||
3. `어플리케이션 > 빌드 관리`에서 대상 구성요소의 develop 빌드를 실행한다.
|
3. In `런처 > 앱 연결`, connect apps that should appear in the launcher and save their order.
|
||||||
4. develop 빌드가 성공하면 `어플리케이션 > 릴리즈 관리`에서 성공한 develop 빌드를 선택해 새 릴리즈 버전을 생성한다.
|
4. In `런처 > 빌드 관리`, run required launcher build jobs.
|
||||||
5. 릴리즈 생성 중 Admin BE가 develop에서 release로 fast-forward 승격하고 release 기준 재빌드를 실행한다.
|
5. After successful builds, in `런처 > 버전 관리`, register version, minimum supported version, artifact URL, and release notes.
|
||||||
6. release 재빌드가 성공하면 `어플리케이션 > 배포 관리`에서 성공 릴리즈를 운영 배포한다.
|
6. Activate the new launcher version only if needed and confirmed.
|
||||||
|
|
||||||
앱 버전 업데이트 흐름에서 금지되는 행동:
|
Forbidden launcher actions:
|
||||||
|
|
||||||
- 앱 repo의 release 브랜치로 직접 merge/push하라고 안내하지 않는다.
|
- Do not run builds without repository connections.
|
||||||
- develop 빌드 성공 전 릴리즈를 생성하지 않는다.
|
- Do not activate a version before a successful build artifact exists.
|
||||||
- 릴리즈 성공 전 배포를 실행하지 않는다.
|
- Do not change app connection order unless the user explicitly asks.
|
||||||
- 이미 배포된 릴리즈를 다시 배포 후보로 선택하지 않는다.
|
- Do not execute deletion, removal, decommission, or unlink actions.
|
||||||
- 삭제, 제거, 폐기, unlink 계열 작업은 실행하지 않는다.
|
- `런처 > 앱 연결` unlink opens an `앱 연결 해제` confirmation modal. PageAgent must not confirm unlink.
|
||||||
|
|
||||||
## 앱 폐기 흐름
|
## Launcher Version Update Flow
|
||||||
|
|
||||||
사용자가 앱 삭제, 앱 제거, 앱 폐기를 요청하면 PageAgent는 실행하지 않는다. 아래 옵션의 의미만 설명하고, 실제 삭제/폐기 버튼 클릭은 사람이 직접 해야 한다.
|
For an existing launcher version update, follow this order.
|
||||||
|
|
||||||
- `저장소 연결 정보 삭제`: 구성요소의 repository, branch, buildTarget 연결을 지운다.
|
1. If launcher app exposure or order changes, update `런처 > 앱 연결`. Do not change app links unless the user explicitly asks.
|
||||||
- `실행 설정 삭제`: 앱 런타임 설정 정리를 의미한다. 기본 선택 대상이다.
|
2. Run the new required build in `런처 > 빌드 관리`.
|
||||||
- `Kubernetes 워크로드 중지`: Deployment/StatefulSet replicas를 0으로 내려 실행을 멈춘다. 기본 선택 대상이다.
|
3. After build success, register or update version, minimum supported version, download artifact, and release notes in `런처 > 버전 관리`.
|
||||||
- `Kubernetes 리소스 삭제`: 앱 구성요소 이름 기준의 Deployment, StatefulSet, Service, Ingress, ConfigMap, Secret을 삭제한다. 사용자가 명시적으로 원할 때만 선택한다.
|
4. Activate the new version only if needed and confirmed.
|
||||||
- `PVC/데이터 볼륨 삭제`: DB/StatefulSet 데이터 삭제로 이어질 수 있다. 사용자가 데이터 삭제와 백업 확인을 명시한 경우에만 선택한다.
|
|
||||||
|
|
||||||
기본적으로 `저장소 연결 정보 삭제`, `실행 설정 삭제`, `Kubernetes 워크로드 중지`만 선택한다. `Kubernetes 리소스 삭제`와 `PVC/데이터 볼륨 삭제`는 위험 작업이므로 임의로 선택하지 않는다.
|
Forbidden launcher update actions:
|
||||||
|
|
||||||
## 구성요소 등록 기준
|
- Do not activate a version without a successful build artifact.
|
||||||
|
- Do not change minimum supported version without user request or policy confirmation.
|
||||||
|
- Do not change app connection order without user request.
|
||||||
|
- Do not execute deletion, removal, decommission, or unlink actions.
|
||||||
|
- `런처 > 앱 연결` unlink opens an `앱 연결 해제` confirmation modal. PageAgent must not confirm unlink.
|
||||||
|
|
||||||
- 소스가 있는 FE/BE/Worker는 저장소를 연결하고 빌드 대상으로 관리한다.
|
## App Release Flow
|
||||||
- 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 구성요소는 저장소 연결 목록에서 숨겨질 수 있다.
|
|
||||||
|
|
||||||
## 용어
|
1. Run a develop build in `어플리케이션 > 빌드 관리`.
|
||||||
|
2. After the develop build succeeds, select the successful develop build in `어플리케이션 > 릴리즈 관리`.
|
||||||
|
3. Admin BE promotes develop to release.
|
||||||
|
4. Admin BE rebuilds from the release branch and stores successful release artifacts.
|
||||||
|
5. Deploy the successful release from `어플리케이션 > 배포 관리`.
|
||||||
|
|
||||||
- `저장소 관리`: 저장소 생성과 구성요소 연결을 담당한다.
|
## App Build Execution Details
|
||||||
- `소스 관리`: 커밋 목록과 diff 조회를 담당한다.
|
|
||||||
- `조회 브랜치`: 커밋을 볼 브랜치일 뿐, 릴리즈나 배포 실행을 의미하지 않는다.
|
When the user says "build it", "run develop build", or "build this component", follow this order.
|
||||||
- `세션`: refresh token/session 식별자다.
|
|
||||||
- `상태`: 세션 또는 사용자 상태 판정값이다. 예: 활성, 만료, 종료, 잠김, 없음.
|
1. Go to `어플리케이션 > 빌드 관리`.
|
||||||
|
2. Select the target app in the top app selector.
|
||||||
|
3. In the `빌드 요청` card, choose the target `구성요소`.
|
||||||
|
4. Keep `Branch` as `develop`.
|
||||||
|
5. Enter `Commit SHA` only if the user gave a specific commit. Otherwise leave it empty.
|
||||||
|
6. If the right-side summary shows Repository as `미연결`, do not build. Tell the user that repository connection is required.
|
||||||
|
7. Click `빌드 실행`.
|
||||||
|
8. In the `빌드 실행 확인` modal, verify app, repository, branch, commit, and build target.
|
||||||
|
9. Ask for final user confirmation, then click `실행`.
|
||||||
|
10. Check the created job in `빌드 작업 목록`.
|
||||||
|
11. Open logs with the row's right-side `...` button.
|
||||||
|
|
||||||
|
Build list handling:
|
||||||
|
|
||||||
|
- Default range is `일주일`.
|
||||||
|
- The footer shows `총 N건 · current page / total pages`.
|
||||||
|
- If the target job is missing, widen to `한달`, then `전체`, and use `이전`/`다음`.
|
||||||
|
- Handle `중지`, `재실행`, and `삭제` only if the user explicitly asks. PageAgent must not execute `삭제`.
|
||||||
|
- Build job deletion opens a `빌드 작업 삭제` confirmation modal. A human must perform the final delete confirmation.
|
||||||
|
|
||||||
|
## App Release Creation Details
|
||||||
|
|
||||||
|
When the user says "create release", "version up", or "make a release for production deployment", first verify successful develop builds.
|
||||||
|
|
||||||
|
1. Go to `어플리케이션 > 릴리즈 관리`.
|
||||||
|
2. Select the target app in the top app selector.
|
||||||
|
3. Check whether the right-side `릴리즈 후보` area has successful develop builds.
|
||||||
|
4. If there are no candidates, do not click release creation. Tell the user to run a develop build first in `어플리케이션 > 빌드 관리`.
|
||||||
|
5. If candidates exist, click `릴리즈 생성` or the candidate app card.
|
||||||
|
6. In the `릴리즈 생성` modal, verify the app.
|
||||||
|
7. Enter the release version. If the user did not provide a version, do not guess from memory. Ask whether the next patch version should be used.
|
||||||
|
8. Select the develop successful build checkboxes that belong in this release. If FE, BE, and ADM-FE must be deployed together, select all relevant component builds.
|
||||||
|
9. Verify that the selected builds match the requested components. Develop build IDs already used by a release are excluded from candidates.
|
||||||
|
10. Ask for final user confirmation, then click `생성`.
|
||||||
|
11. If release status is `대기` or `실행 중`, wait.
|
||||||
|
12. Open release logs with the row's right-side `...` button.
|
||||||
|
13. If status becomes `성공`, the release is deployable. If it becomes `실패`, inspect logs first.
|
||||||
|
14. The trash icon in `릴리즈 후보` opens a `릴리즈 후보 숨김` confirmation modal. It hides current candidate build jobs from the candidate list and keeps build history/logs. PageAgent must not confirm this action.
|
||||||
|
|
||||||
|
Release internals:
|
||||||
|
|
||||||
|
- Admin BE uses the develop build commit to promote each repository to the `release` branch.
|
||||||
|
- Admin BE rebuilds from the release branch.
|
||||||
|
- Successful release artifacts are stored in the release.
|
||||||
|
- Do not tell humans to directly merge/push app repositories to release.
|
||||||
|
|
||||||
|
Release failure diagnosis:
|
||||||
|
|
||||||
|
- `fast-forward` failure: the release branch diverged from develop or has protection/permission/history problems.
|
||||||
|
- `unrelated histories`: the initial repository branch and develop/release branch started from different histories. This must be handled by Admin BE code/permissions, not by asking app developers to push directly to release.
|
||||||
|
- `protected branch`: check release push/merge permission for the Admin BE account or owner team.
|
||||||
|
- `Committer identity unknown`: check Admin BE environment variables `GIT_USER_NAME` and `GIT_USER_EMAIL`.
|
||||||
|
- Worker worktree conflict: the worker should abort merge, reset, clean, and retry.
|
||||||
|
|
||||||
|
## App Version Update Flow
|
||||||
|
|
||||||
|
For an existing app version update, follow this order.
|
||||||
|
|
||||||
|
1. If environment variables or Secrets must change, update `어플리케이션 > 실행 설정` first.
|
||||||
|
2. If DB changes exist, run migration in `어플리케이션 > 마이그레이션`.
|
||||||
|
3. Run develop builds for the target components in `어플리케이션 > 빌드 관리`.
|
||||||
|
4. After develop builds succeed, create a new release version in `어플리케이션 > 릴리즈 관리`.
|
||||||
|
5. During release creation, Admin BE promotes develop to release and runs release rebuilds.
|
||||||
|
6. After release rebuild success, deploy the successful release in `어플리케이션 > 배포 관리`.
|
||||||
|
|
||||||
|
Forbidden app version update actions:
|
||||||
|
|
||||||
|
- Do not tell the user to directly merge/push to the app repo's release branch.
|
||||||
|
- Do not create a release before develop build success.
|
||||||
|
- Do not deploy before release success.
|
||||||
|
- Do not select already-deployed releases as default deployment candidates.
|
||||||
|
- Do not execute deletion, removal, decommission, or unlink actions.
|
||||||
|
|
||||||
|
## App Deployment Execution Details
|
||||||
|
|
||||||
|
When the user says "deploy", "production deploy", or "deploy this release", follow this order. Deployment is production by default; there is no separate dev/prod choice.
|
||||||
|
|
||||||
|
1. Go to `어플리케이션 > 배포 관리`.
|
||||||
|
2. Select the target app in the top app selector.
|
||||||
|
3. Check the right-side `배포 가능 릴리즈` list for releases not yet successfully deployed.
|
||||||
|
4. If the target release is visible, click its card or the top `배포 실행` button.
|
||||||
|
5. In the `배포 실행` modal, verify the selected release.
|
||||||
|
6. Verify that `namespace` matches the app namespace. Example: LMS usually uses `stdo-lms`.
|
||||||
|
7. Keep `런처 앱 버전 업데이트` checked for normal launcher-exposed apps. Uncheck it only if the user says so.
|
||||||
|
8. Ask for final user confirmation, then click `실행`.
|
||||||
|
9. If the button changes to `실행 중`, do not click it again.
|
||||||
|
10. Check deployment job status in the list and open logs with the row's right-side `...` button.
|
||||||
|
|
||||||
|
Deployment list handling:
|
||||||
|
|
||||||
|
- Default range is `일주일`.
|
||||||
|
- The list shows 3 items per page and displays `총 N건 · current page / total pages`.
|
||||||
|
- Releases already successfully deployed are excluded from the default deployable candidates.
|
||||||
|
- The redeploy icon deploys the same release again. Use it only if the user explicitly asks.
|
||||||
|
- The trash icon in `배포 가능 릴리즈` opens a `릴리즈 삭제` confirmation modal. It deletes the release record only if it has not been successfully deployed. PageAgent must not confirm this action.
|
||||||
|
|
||||||
|
## Runtime Configuration Change Flow
|
||||||
|
|
||||||
|
For environment variable or Secret additions/edits, follow this order.
|
||||||
|
|
||||||
|
1. Go to `어플리케이션 > 실행 설정`.
|
||||||
|
2. Select the target app in the top app selector.
|
||||||
|
3. In the `설정값` area, enter Key and Value. The Key placeholder is only a hint; enter the real key.
|
||||||
|
4. If the value is secret, select the Secret option.
|
||||||
|
5. Click `설정값 추가`. This saves immediately. Do not expect a separate apply button.
|
||||||
|
6. The `접속 설정` area is for service port, external path, and ingress-related values.
|
||||||
|
7. Deleting a setting value or ingress connection opens a confirmation modal. PageAgent must not confirm deletion.
|
||||||
|
8. Do not expect automatic rollout after saving settings. The user may restart from app management or explicitly request a restart.
|
||||||
|
|
||||||
|
Common runtime settings:
|
||||||
|
|
||||||
|
- Vite static web build variables: `VITE_API_BASE_URL`, `VITE_LAUNCHER_API_BASE_URL`, `VITE_ATNMY_AUTH_URL`, `VITE_PUBLIC_BASE_PATH`.
|
||||||
|
- R2 file storage: `R2_ENDPOINT`, `R2_BUCKET`, `R2_PUBLIC_FILE_PREFIX`, `R2_PUBLIC_BASE_URL`, `R2_ACCESS_KEY_ID`, `R2_SECRET_ACCESS_KEY`, `R2_ACCOUNT_ID`, `R2_REGION`, `R2_FORCE_PATH_STYLE`.
|
||||||
|
- Redis connection: `REDIS_URL=redis://service-name:6379`.
|
||||||
|
|
||||||
|
## Migration Execution Flow
|
||||||
|
|
||||||
|
Migration is a database-changing action. Run it only when the user explicitly asks.
|
||||||
|
|
||||||
|
1. Go to `어플리케이션 > 마이그레이션`.
|
||||||
|
2. Verify the app and target component.
|
||||||
|
3. For namespace and envFrom Secret, use selectable values if available instead of free typing.
|
||||||
|
4. Before execution, confirm migration target, namespace, image/command with the user.
|
||||||
|
5. After execution, the job may not appear instantly. It appears after completion, and logs can be checked in the log modal.
|
||||||
|
6. If it fails, inspect logs first, then check DB connection, env values, and migration state.
|
||||||
|
|
||||||
|
## Source Inspection Flow
|
||||||
|
|
||||||
|
Source inspection is not deployment. It only checks commit lists and diffs.
|
||||||
|
|
||||||
|
1. Go to `어플리케이션 > 소스 관리` or `런처 > 소스 관리`.
|
||||||
|
2. Select the app or launcher component.
|
||||||
|
3. `조회 브랜치` defaults to `develop`. Select `release` only if the user specifically asks to inspect release.
|
||||||
|
4. `SHA1` is the latest commit identifier for the selected branch.
|
||||||
|
5. Commit list range defaults to `일주일`; widen to `한달` or `전체` when needed.
|
||||||
|
6. Open a commit diff with the row's `내역 상세` or `...` icon.
|
||||||
|
7. In the diff modal, click a file in the left file tree to move to that file's diff.
|
||||||
|
|
||||||
|
## Launcher Build And Version Update Details
|
||||||
|
|
||||||
|
Launcher flow is separate from app release flow.
|
||||||
|
|
||||||
|
1. Go to `런처 > 빌드 관리`.
|
||||||
|
2. Verify target component and branch. Default branch is `develop`.
|
||||||
|
3. Leave Commit SHA empty unless a specific commit is requested.
|
||||||
|
4. Click `빌드 실행` and then `실행` in the confirmation modal.
|
||||||
|
5. After build success, go to `런처 > 버전 관리`.
|
||||||
|
6. For client versions, save version, minimum supported version, download URL, and release notes from the successful build artifact.
|
||||||
|
7. Backend deployment uses only the top backend deployment area. Treat any duplicate backend deployment area inside content as removed.
|
||||||
|
8. Ask for user confirmation before activating a new launcher version.
|
||||||
|
|
||||||
|
Launcher forbidden actions:
|
||||||
|
|
||||||
|
- Do not assume develop push automatically performs release merge and deployment.
|
||||||
|
- Do not run builds without repository connections.
|
||||||
|
- Do not activate a version without a successful build artifact.
|
||||||
|
|
||||||
|
## Launcher Runtime Log Flow
|
||||||
|
|
||||||
|
Use `런처 > 로그/이벤트` when the user asks for launcher backend logs, launcher DB logs, or launcher runtime events.
|
||||||
|
|
||||||
|
1. Go to `런처 > 로그/이벤트`.
|
||||||
|
2. Select `런처 백엔드` for API/provision/login/update issues.
|
||||||
|
3. Select `런처 DB` only when database workload logs or events are needed.
|
||||||
|
4. Choose a running workload from the left list.
|
||||||
|
5. Use the `로그` tab for workload logs and the `이벤트` tab for Kubernetes events.
|
||||||
|
6. Use `최근 100줄`, `최근 300줄`, or `최근 1000줄` based on the amount of detail needed.
|
||||||
|
|
||||||
|
## App Decommission Flow
|
||||||
|
|
||||||
|
If the user asks to delete, remove, or decommission an app, PageAgent must not execute it. Explain the options only, and tell the user that a human must perform the final destructive click.
|
||||||
|
|
||||||
|
- `저장소 연결 정보 삭제`: removes repository, branch, and build target links from components.
|
||||||
|
- `실행 설정 삭제`: cleans app runtime settings. This is part of the default cleanup option set.
|
||||||
|
- `Kubernetes 워크로드 중지`: scales Deployment/StatefulSet replicas to 0. This is part of the default cleanup option set.
|
||||||
|
- `Kubernetes 리소스 삭제`: deletes Deployment, StatefulSet, Service, Ingress, ConfigMap, and Secret by app component name. Select only when the user explicitly asks.
|
||||||
|
- `PVC/데이터 볼륨 삭제`: may delete DB/StatefulSet data. Select only if the user explicitly confirms data deletion and backup state.
|
||||||
|
|
||||||
|
By default, only explain `저장소 연결 정보 삭제`, `실행 설정 삭제`, and `Kubernetes 워크로드 중지`. Do not select `Kubernetes 리소스 삭제` or `PVC/데이터 볼륨 삭제` on your own.
|
||||||
|
|
||||||
|
## Admin Permission Notes
|
||||||
|
|
||||||
|
`운영 > 관리자 관리` is outside the default allowed platform menu group. Enter it only if the user explicitly allows it.
|
||||||
|
|
||||||
|
- App permission settings are stored through the Admin BE database API.
|
||||||
|
- Do not use browser localStorage as the source of truth for admin app permissions.
|
||||||
|
- Changing another admin's app permission is a state-changing action and requires user confirmation before saving.
|
||||||
|
|
||||||
|
## Component Registration Rules
|
||||||
|
|
||||||
|
- FE/BE/Worker with source code should connect to repositories and be managed as build targets.
|
||||||
|
- External images such as Redis are registered as `CONTAINER` type with an external image.
|
||||||
|
- Redis example: name `studio-auth-redis`, image `redis:7-alpine`, port `6379`, targetPort `6379`, replicas `1`.
|
||||||
|
- For a BE using Redis, add runtime config such as `REDIS_URL=redis://studio-auth-redis:6379`.
|
||||||
|
- DB/MIGRATION components may be hidden from repository connection lists.
|
||||||
|
|
||||||
|
## Terms
|
||||||
|
|
||||||
|
- `저장소 관리`: Handles repository creation and component-repository connections.
|
||||||
|
- `소스 관리`: Handles commit lists and diff viewing.
|
||||||
|
- `조회 브랜치`: The branch used only for viewing commits. It does not run release or deployment.
|
||||||
|
- `세션`: Refresh token or session identifier.
|
||||||
|
- `상태`: Session or user state value, such as active, expired, terminated, locked, or none.
|
||||||
|
|||||||
Generated
+399
-2
@@ -15,6 +15,7 @@
|
|||||||
"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",
|
||||||
|
"recharts": "^3.9.2",
|
||||||
"tailwindcss": "^4.3.0",
|
"tailwindcss": "^4.3.0",
|
||||||
"vite": "^6.3.5"
|
"vite": "^6.3.5"
|
||||||
},
|
},
|
||||||
@@ -789,6 +790,32 @@
|
|||||||
"integrity": "sha512-XSJlaNzj3j+uSNfGkkkxw6hyKw+d8fuZd/N1/NrxvTJIiUhVMXQ1PE30JRu5yWzbvmezKSCmloz8wbq4sWsrFw==",
|
"integrity": "sha512-XSJlaNzj3j+uSNfGkkkxw6hyKw+d8fuZd/N1/NrxvTJIiUhVMXQ1PE30JRu5yWzbvmezKSCmloz8wbq4sWsrFw==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@reduxjs/toolkit": {
|
||||||
|
"version": "2.12.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.12.0.tgz",
|
||||||
|
"integrity": "sha512-KiT+RzZbp6mQET+Mg+h2c97+9j1sNflUxQkIHI7Yuzf6Peu+OYpmkn6nbHWmLLWj+1ZODUJFwGZ7gx3L9R9EOw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@standard-schema/spec": "^1.0.0",
|
||||||
|
"@standard-schema/utils": "^0.3.0",
|
||||||
|
"immer": "^11.0.0",
|
||||||
|
"redux": "^5.0.1",
|
||||||
|
"redux-thunk": "^3.1.0",
|
||||||
|
"reselect": "^5.1.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.9.0 || ^17.0.0 || ^18 || ^19",
|
||||||
|
"react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"react-redux": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@rolldown/pluginutils": {
|
"node_modules/@rolldown/pluginutils": {
|
||||||
"version": "1.0.0-beta.27",
|
"version": "1.0.0-beta.27",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
|
||||||
@@ -1159,6 +1186,18 @@
|
|||||||
"win32"
|
"win32"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"node_modules/@standard-schema/spec": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@standard-schema/utils": {
|
||||||
|
"version": "0.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
|
||||||
|
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@tailwindcss/node": {
|
"node_modules/@tailwindcss/node": {
|
||||||
"version": "4.3.1",
|
"version": "4.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.1.tgz",
|
||||||
@@ -1529,6 +1568,69 @@
|
|||||||
"@babel/types": "^7.28.2"
|
"@babel/types": "^7.28.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/d3-array": {
|
||||||
|
"version": "3.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
|
||||||
|
"integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-color": {
|
||||||
|
"version": "3.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
|
||||||
|
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-ease": {
|
||||||
|
"version": "3.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
|
||||||
|
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-interpolate": {
|
||||||
|
"version": "3.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
|
||||||
|
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/d3-color": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-path": {
|
||||||
|
"version": "3.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
|
||||||
|
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-scale": {
|
||||||
|
"version": "4.0.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
|
||||||
|
"integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/d3-time": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-shape": {
|
||||||
|
"version": "3.1.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz",
|
||||||
|
"integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/d3-path": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-time": {
|
||||||
|
"version": "3.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
|
||||||
|
"integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-timer": {
|
||||||
|
"version": "3.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
|
||||||
|
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/estree": {
|
"node_modules/@types/estree": {
|
||||||
"version": "1.0.9",
|
"version": "1.0.9",
|
||||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
|
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
|
||||||
@@ -1539,7 +1641,7 @@
|
|||||||
"version": "19.2.17",
|
"version": "19.2.17",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz",
|
||||||
"integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==",
|
"integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==",
|
||||||
"dev": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"csstype": "^3.2.2"
|
"csstype": "^3.2.2"
|
||||||
@@ -1555,6 +1657,12 @@
|
|||||||
"@types/react": "^19.2.0"
|
"@types/react": "^19.2.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/use-sync-external-store": {
|
||||||
|
"version": "0.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
|
||||||
|
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@vitejs/plugin-react": {
|
"node_modules/@vitejs/plugin-react": {
|
||||||
"version": "4.7.0",
|
"version": "4.7.0",
|
||||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
|
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
|
||||||
@@ -1658,6 +1766,15 @@
|
|||||||
"url": "https://github.com/chalk/chalk?sponsor=1"
|
"url": "https://github.com/chalk/chalk?sponsor=1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/clsx": {
|
||||||
|
"version": "2.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
|
||||||
|
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/convert-source-map": {
|
"node_modules/convert-source-map": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
|
||||||
@@ -1681,9 +1798,130 @@
|
|||||||
"version": "3.2.3",
|
"version": "3.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||||
"dev": true,
|
"devOptional": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/d3-array": {
|
||||||
|
"version": "3.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
|
||||||
|
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"internmap": "1 - 2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-color": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-ease": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-format": {
|
||||||
|
"version": "3.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
|
||||||
|
"integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-interpolate": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-color": "1 - 3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-path": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-scale": {
|
||||||
|
"version": "4.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
|
||||||
|
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-array": "2.10.0 - 3",
|
||||||
|
"d3-format": "1 - 3",
|
||||||
|
"d3-interpolate": "1.2.0 - 3",
|
||||||
|
"d3-time": "2.1.1 - 3",
|
||||||
|
"d3-time-format": "2 - 4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-shape": {
|
||||||
|
"version": "3.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
|
||||||
|
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-path": "^3.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-time": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-array": "2 - 3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-time-format": {
|
||||||
|
"version": "4.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
|
||||||
|
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-time": "1 - 3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-timer": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/debug": {
|
"node_modules/debug": {
|
||||||
"version": "4.4.3",
|
"version": "4.4.3",
|
||||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||||
@@ -1701,6 +1939,12 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/decimal.js-light": {
|
||||||
|
"version": "2.5.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
|
||||||
|
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/detect-libc": {
|
"node_modules/detect-libc": {
|
||||||
"version": "2.1.2",
|
"version": "2.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||||
@@ -1729,6 +1973,16 @@
|
|||||||
"node": ">=10.13.0"
|
"node": ">=10.13.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/es-toolkit": {
|
||||||
|
"version": "1.49.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.49.0.tgz",
|
||||||
|
"integrity": "sha512-G5iZ6Pc/FNRY/soKZHC+TxGDD83rHUDXxzaWhGCX44vAv/tMs56WMusnm/KMNK+luUPsgA9U28cGr4RDlSzL2g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"workspaces": [
|
||||||
|
"docs",
|
||||||
|
"benchmarks"
|
||||||
|
]
|
||||||
|
},
|
||||||
"node_modules/esbuild": {
|
"node_modules/esbuild": {
|
||||||
"version": "0.25.12",
|
"version": "0.25.12",
|
||||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
|
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
|
||||||
@@ -1779,6 +2033,12 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/eventemitter3": {
|
||||||
|
"version": "5.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
|
||||||
|
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/fdir": {
|
"node_modules/fdir": {
|
||||||
"version": "6.5.0",
|
"version": "6.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
||||||
@@ -1825,6 +2085,25 @@
|
|||||||
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
|
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/immer": {
|
||||||
|
"version": "11.1.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/immer/-/immer-11.1.11.tgz",
|
||||||
|
"integrity": "sha512-qzXuyXAkPySAGYkfsAwodDPWT8Zm7/Uo5BNt4BjhMhG5WlWyZZ4wQqnWwdS8kjlQ1Cwu6gjw3A6+0gTQwlyYtw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/immer"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/internmap": {
|
||||||
|
"version": "2.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
|
||||||
|
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/jiti": {
|
"node_modules/jiti": {
|
||||||
"version": "2.7.0",
|
"version": "2.7.0",
|
||||||
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz",
|
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz",
|
||||||
@@ -2256,6 +2535,36 @@
|
|||||||
"react": "^19.2.7"
|
"react": "^19.2.7"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-is": {
|
||||||
|
"version": "19.2.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.7.tgz",
|
||||||
|
"integrity": "sha512-kZFnouyVv7eP/Phmrlo9FK+zcAdriZJvzxXHF1Sl1P377WSGe2G/JxVolhTrB/jeV47lKImhNUsijjHAAbcl/A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true
|
||||||
|
},
|
||||||
|
"node_modules/react-redux": {
|
||||||
|
"version": "9.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.3.0.tgz",
|
||||||
|
"integrity": "sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/use-sync-external-store": "^0.0.6",
|
||||||
|
"use-sync-external-store": "^1.4.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "^18.2.25 || ^19",
|
||||||
|
"react": "^18.0 || ^19",
|
||||||
|
"redux": "^5.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"redux": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react-refresh": {
|
"node_modules/react-refresh": {
|
||||||
"version": "0.17.0",
|
"version": "0.17.0",
|
||||||
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
|
||||||
@@ -2303,6 +2612,57 @@
|
|||||||
"react-dom": ">=18"
|
"react-dom": ">=18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/recharts": {
|
||||||
|
"version": "3.9.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/recharts/-/recharts-3.9.2.tgz",
|
||||||
|
"integrity": "sha512-G4fy+Pk46RaXgwWMh+Nzhyo/lbFAVqXo9gtetlyehe6Ehge9CsgDuOTwQDD+i1+llaLktNBiNq4bhnGlDRXFtw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"workspaces": [
|
||||||
|
"www"
|
||||||
|
],
|
||||||
|
"dependencies": {
|
||||||
|
"@reduxjs/toolkit": "^1.9.0 || 2.x.x",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"decimal.js-light": "^2.5.1",
|
||||||
|
"es-toolkit": "^1.39.3",
|
||||||
|
"eventemitter3": "^5.0.1",
|
||||||
|
"immer": "^11.1.8",
|
||||||
|
"react-redux": "8.x.x || 9.x.x",
|
||||||
|
"reselect": "5.2.0",
|
||||||
|
"tiny-invariant": "^1.3.3",
|
||||||
|
"use-sync-external-store": "^1.2.2",
|
||||||
|
"victory-vendor": "^37.0.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||||
|
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||||
|
"react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/redux": {
|
||||||
|
"version": "5.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
|
||||||
|
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/redux-thunk": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"redux": "^5.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/reselect": {
|
||||||
|
"version": "5.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.2.0.tgz",
|
||||||
|
"integrity": "sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/rollup": {
|
"node_modules/rollup": {
|
||||||
"version": "4.62.0",
|
"version": "4.62.0",
|
||||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.0.tgz",
|
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.0.tgz",
|
||||||
@@ -2396,6 +2756,12 @@
|
|||||||
"url": "https://opencollective.com/webpack"
|
"url": "https://opencollective.com/webpack"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/tiny-invariant": {
|
||||||
|
"version": "1.3.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
|
||||||
|
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/tinyglobby": {
|
"node_modules/tinyglobby": {
|
||||||
"version": "0.2.17",
|
"version": "0.2.17",
|
||||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
|
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
|
||||||
@@ -2456,6 +2822,37 @@
|
|||||||
"browserslist": ">= 4.21.0"
|
"browserslist": ">= 4.21.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/use-sync-external-store": {
|
||||||
|
"version": "1.6.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
|
||||||
|
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/victory-vendor": {
|
||||||
|
"version": "37.3.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz",
|
||||||
|
"integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==",
|
||||||
|
"license": "MIT AND ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/d3-array": "^3.0.3",
|
||||||
|
"@types/d3-ease": "^3.0.0",
|
||||||
|
"@types/d3-interpolate": "^3.0.1",
|
||||||
|
"@types/d3-scale": "^4.0.2",
|
||||||
|
"@types/d3-shape": "^3.1.0",
|
||||||
|
"@types/d3-time": "^3.0.0",
|
||||||
|
"@types/d3-timer": "^3.0.0",
|
||||||
|
"d3-array": "^3.1.6",
|
||||||
|
"d3-ease": "^3.0.1",
|
||||||
|
"d3-interpolate": "^3.0.1",
|
||||||
|
"d3-scale": "^4.0.2",
|
||||||
|
"d3-shape": "^3.1.0",
|
||||||
|
"d3-time": "^3.0.0",
|
||||||
|
"d3-timer": "^3.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/vite": {
|
"node_modules/vite": {
|
||||||
"version": "6.4.3",
|
"version": "6.4.3",
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz",
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
"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",
|
||||||
|
"recharts": "^3.9.2",
|
||||||
"tailwindcss": "^4.3.0",
|
"tailwindcss": "^4.3.0",
|
||||||
"vite": "^6.3.5"
|
"vite": "^6.3.5"
|
||||||
},
|
},
|
||||||
|
|||||||
+933
-145
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,66 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
type CommitAuthorUser = {
|
||||||
|
login?: string;
|
||||||
|
username?: string;
|
||||||
|
full_name?: string;
|
||||||
|
name?: string;
|
||||||
|
avatar_url?: string;
|
||||||
|
avatarUrl?: string;
|
||||||
|
} | null | undefined;
|
||||||
|
|
||||||
|
export type CommitAuthorLike = {
|
||||||
|
commit?: {
|
||||||
|
author?: {
|
||||||
|
name?: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
author?: CommitAuthorUser;
|
||||||
|
committer?: CommitAuthorUser;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function commitAuthorName(commit: CommitAuthorLike) {
|
||||||
|
return commit.author?.login
|
||||||
|
|| commit.author?.username
|
||||||
|
|| commit.author?.full_name
|
||||||
|
|| commit.author?.name
|
||||||
|
|| commit.commit?.author?.name
|
||||||
|
|| commit.committer?.login
|
||||||
|
|| commit.committer?.username
|
||||||
|
|| commit.committer?.full_name
|
||||||
|
|| commit.committer?.name
|
||||||
|
|| '-';
|
||||||
|
}
|
||||||
|
|
||||||
|
function commitAuthorAvatarUrl(commit: CommitAuthorLike) {
|
||||||
|
return commit.author?.avatar_url
|
||||||
|
|| commit.author?.avatarUrl
|
||||||
|
|| commit.committer?.avatar_url
|
||||||
|
|| commit.committer?.avatarUrl
|
||||||
|
|| '';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CommitAuthor({ commit }: { commit: CommitAuthorLike }) {
|
||||||
|
const [failed, setFailed] = useState(false);
|
||||||
|
const name = commitAuthorName(commit);
|
||||||
|
const avatarUrl = commitAuthorAvatarUrl(commit);
|
||||||
|
const initial = name === '-' ? '?' : name.trim().charAt(0).toUpperCase() || '?';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className="flex min-w-0 items-center gap-2">
|
||||||
|
{avatarUrl && !failed ? (
|
||||||
|
<img
|
||||||
|
src={avatarUrl}
|
||||||
|
alt=""
|
||||||
|
className="size-7 shrink-0 rounded-full border border-border bg-muted object-cover"
|
||||||
|
onError={() => setFailed(true)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span className="inline-flex size-7 shrink-0 items-center justify-center rounded-full border border-border bg-muted text-xs font-semibold text-muted-foreground">
|
||||||
|
{initial}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="truncate font-medium">{name}</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
+110
-17
@@ -6,6 +6,8 @@ export type DataTableColumn<T> = {
|
|||||||
key: string;
|
key: string;
|
||||||
header: string;
|
header: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
width?: number;
|
||||||
|
flexible?: boolean;
|
||||||
cell: (row: T) => ReactNode;
|
cell: (row: T) => ReactNode;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -16,6 +18,25 @@ export type DataTableFilter<T> = {
|
|||||||
getValue: (row: T) => string;
|
getValue: (row: T) => string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const spacingWidthPattern = /\bw-(?:\[(\d+)px\]|(\d+))\b/;
|
||||||
|
const widthClassPattern = /\bw-(?:\[[^\]]+\]|\d+)\b/g;
|
||||||
|
|
||||||
|
function getColumnWidth<T>(column: DataTableColumn<T>) {
|
||||||
|
if (column.key === 'no') return 75;
|
||||||
|
if (column.width) return column.width;
|
||||||
|
|
||||||
|
const widthMatch = column.className?.match(spacingWidthPattern);
|
||||||
|
if (widthMatch?.[1]) return Number(widthMatch[1]);
|
||||||
|
if (widthMatch?.[2]) return Number(widthMatch[2]) * 4;
|
||||||
|
if (column.key === 'actions') return 80;
|
||||||
|
|
||||||
|
return 160;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripWidthClass(className?: string) {
|
||||||
|
return className?.replace(widthClassPattern, '').replace(/\s+/g, ' ').trim();
|
||||||
|
}
|
||||||
|
|
||||||
export function DataTableCard<T>({
|
export function DataTableCard<T>({
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
@@ -28,7 +49,8 @@ export function DataTableCard<T>({
|
|||||||
pageSize = 8,
|
pageSize = 8,
|
||||||
minWidth = 980,
|
minWidth = 980,
|
||||||
loading = false,
|
loading = false,
|
||||||
emptyMessage = '데이터가 없습니다.'
|
emptyMessage = '데이터가 없습니다.',
|
||||||
|
serverPagination
|
||||||
}: {
|
}: {
|
||||||
title: string;
|
title: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
@@ -42,12 +64,23 @@ export function DataTableCard<T>({
|
|||||||
minWidth?: number;
|
minWidth?: number;
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
emptyMessage?: string;
|
emptyMessage?: string;
|
||||||
|
serverPagination?: {
|
||||||
|
page: number;
|
||||||
|
total: number;
|
||||||
|
onPageChange: (page: number) => void;
|
||||||
|
onSearchChange?: (keyword: string) => void;
|
||||||
|
filterValues?: Record<string, string>;
|
||||||
|
onFilterChange?: (key: string, value: string) => void;
|
||||||
|
};
|
||||||
}) {
|
}) {
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [filterValues, setFilterValues] = useState<Record<string, string>>({});
|
const [filterValues, setFilterValues] = useState<Record<string, string>>({});
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
|
const serverMode = Boolean(serverPagination);
|
||||||
|
const activeFilterValues = serverPagination?.filterValues ?? filterValues;
|
||||||
|
|
||||||
const filteredRows = useMemo(() => {
|
const filteredRows = useMemo(() => {
|
||||||
|
if (serverMode) return rows;
|
||||||
const normalizedSearch = search.trim().toLowerCase();
|
const normalizedSearch = search.trim().toLowerCase();
|
||||||
|
|
||||||
return rows.filter((row) => {
|
return rows.filter((row) => {
|
||||||
@@ -55,23 +88,40 @@ export function DataTableCard<T>({
|
|||||||
? getSearchText(row).toLowerCase().includes(normalizedSearch)
|
? getSearchText(row).toLowerCase().includes(normalizedSearch)
|
||||||
: true;
|
: true;
|
||||||
const matchesFilters = filters.every((filter) => {
|
const matchesFilters = filters.every((filter) => {
|
||||||
const selected = filterValues[filter.key];
|
const selected = activeFilterValues[filter.key];
|
||||||
return !selected || filter.getValue(row) === selected;
|
return !selected || filter.getValue(row) === selected;
|
||||||
});
|
});
|
||||||
|
|
||||||
return matchesSearch && matchesFilters;
|
return matchesSearch && matchesFilters;
|
||||||
});
|
});
|
||||||
}, [filterValues, filters, getSearchText, rows, search]);
|
}, [activeFilterValues, filters, getSearchText, rows, search, serverMode]);
|
||||||
|
|
||||||
const totalPages = Math.max(1, Math.ceil(filteredRows.length / pageSize));
|
const totalCount = serverPagination?.total ?? filteredRows.length;
|
||||||
const currentPage = Math.min(page, totalPages);
|
const totalPages = Math.max(1, Math.ceil(totalCount / pageSize));
|
||||||
const pageRows = filteredRows.slice((currentPage - 1) * pageSize, currentPage * pageSize);
|
const currentPage = Math.min(serverPagination?.page ?? page, totalPages);
|
||||||
|
const pageRows = serverMode ? rows : filteredRows.slice((currentPage - 1) * pageSize, currentPage * pageSize);
|
||||||
|
const configuredFlexibleColumnIndex = columns.findIndex((column) => column.flexible);
|
||||||
|
const flexibleColumnIndex = configuredFlexibleColumnIndex >= 0
|
||||||
|
? configuredFlexibleColumnIndex
|
||||||
|
: columns[0]?.key === 'no' && columns.length > 1 ? 1 : -1;
|
||||||
|
|
||||||
const updateFilter = (key: string, value: string) => {
|
const updateFilter = (key: string, value: string) => {
|
||||||
|
if (serverPagination?.onFilterChange) {
|
||||||
|
serverPagination.onFilterChange(key, value);
|
||||||
|
return;
|
||||||
|
}
|
||||||
setFilterValues((prev) => ({ ...prev, [key]: value }));
|
setFilterValues((prev) => ({ ...prev, [key]: value }));
|
||||||
setPage(1);
|
setPage(1);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const movePage = (nextPage: number) => {
|
||||||
|
if (serverPagination) {
|
||||||
|
serverPagination.onPageChange(nextPage);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setPage(nextPage);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<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-3 lg:flex-row lg:items-center lg:justify-between">
|
<div className="flex flex-col gap-3 border-b border-border px-5 py-3 lg:flex-row lg:items-center lg:justify-between">
|
||||||
@@ -87,7 +137,9 @@ export function DataTableCard<T>({
|
|||||||
placeholder={searchPlaceholder}
|
placeholder={searchPlaceholder}
|
||||||
value={search}
|
value={search}
|
||||||
onChange={(event) => {
|
onChange={(event) => {
|
||||||
setSearch(event.target.value);
|
const value = event.target.value;
|
||||||
|
setSearch(value);
|
||||||
|
serverPagination?.onSearchChange?.(value);
|
||||||
setPage(1);
|
setPage(1);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -99,7 +151,7 @@ export function DataTableCard<T>({
|
|||||||
{filter.label}
|
{filter.label}
|
||||||
<select
|
<select
|
||||||
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"
|
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"
|
||||||
value={filterValues[filter.key] ?? ''}
|
value={activeFilterValues[filter.key] ?? ''}
|
||||||
onChange={(event) => updateFilter(filter.key, event.target.value)}
|
onChange={(event) => updateFilter(filter.key, event.target.value)}
|
||||||
>
|
>
|
||||||
<option value="">전체</option>
|
<option value="">전체</option>
|
||||||
@@ -116,11 +168,36 @@ export function DataTableCard<T>({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="max-h-[560px] overflow-x-auto overflow-y-auto">
|
<div className="max-h-[560px] overflow-x-auto overflow-y-auto">
|
||||||
<table className="w-full text-left text-sm" style={{ minWidth }}>
|
<table className="w-full table-fixed text-left text-sm" style={{ minWidth }}>
|
||||||
|
<colgroup>
|
||||||
|
{columns.map((column, columnIndex) => (
|
||||||
|
<col
|
||||||
|
key={column.key}
|
||||||
|
style={
|
||||||
|
columnIndex === flexibleColumnIndex
|
||||||
|
? { width: '100%' }
|
||||||
|
: { width: getColumnWidth(column) }
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</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>
|
||||||
{columns.map((column) => (
|
{columns.map((column, columnIndex) => (
|
||||||
<th key={column.key} className={column.className ?? 'px-5 py-3 font-medium'}>
|
<th
|
||||||
|
key={column.key}
|
||||||
|
className={
|
||||||
|
column.key === 'no'
|
||||||
|
? 'py-3 text-center font-medium'
|
||||||
|
: columnIndex === flexibleColumnIndex
|
||||||
|
? stripWidthClass(column.className) || 'px-5 py-3 font-medium'
|
||||||
|
: column.className ?? 'px-5 py-3 font-medium'
|
||||||
|
}
|
||||||
|
style={{
|
||||||
|
...(column.key === 'no' ? { boxSizing: 'border-box', width: 75, minWidth: 75, maxWidth: 75, paddingLeft: 0, paddingRight: 0 } : columnIndex === flexibleColumnIndex ? { width: '100%' } : { width: getColumnWidth(column) }),
|
||||||
|
...(columnIndex === flexibleColumnIndex ? { paddingLeft: 12 } : {}),
|
||||||
|
}}
|
||||||
|
>
|
||||||
{column.header}
|
{column.header}
|
||||||
</th>
|
</th>
|
||||||
))}
|
))}
|
||||||
@@ -129,9 +206,25 @@ export function DataTableCard<T>({
|
|||||||
<tbody className="divide-y divide-border">
|
<tbody className="divide-y divide-border">
|
||||||
{!loading && pageRows.map((row) => (
|
{!loading && pageRows.map((row) => (
|
||||||
<tr key={getRowKey(row)} className="hover:bg-muted/20">
|
<tr key={getRowKey(row)} className="hover:bg-muted/20">
|
||||||
{columns.map((column) => (
|
{columns.map((column, columnIndex) => (
|
||||||
<td key={column.key} className="px-5 py-3">
|
<td
|
||||||
{column.cell(row)}
|
key={column.key}
|
||||||
|
className={column.key === 'no' ? 'py-3 text-center font-medium' : columns[0]?.key === 'no' && columnIndex === 1 ? 'py-3 pl-3 pr-5' : 'px-5 py-3'}
|
||||||
|
style={
|
||||||
|
column.key === 'no'
|
||||||
|
? { boxSizing: 'border-box', width: 75, minWidth: 75, maxWidth: 75, paddingLeft: 0, paddingRight: 0 }
|
||||||
|
: columnIndex === flexibleColumnIndex
|
||||||
|
? { width: '100%' }
|
||||||
|
: { width: getColumnWidth(column) }
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{column.key === 'no' ? (
|
||||||
|
<span className="block w-full text-center font-semibold">
|
||||||
|
{String((row as { no?: unknown }).no ?? '')}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
column.cell(row)
|
||||||
|
)}
|
||||||
</td>
|
</td>
|
||||||
))}
|
))}
|
||||||
</tr>
|
</tr>
|
||||||
@@ -148,14 +241,14 @@ export function DataTableCard<T>({
|
|||||||
</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 ? '불러오는 중' : `총 ${filteredRows.length}건 · ${currentPage} / ${totalPages}`}
|
{loading ? '불러오는 중' : `총 ${totalCount}건 · ${currentPage} / ${totalPages}`}
|
||||||
</span>
|
</span>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="h-8 px-3 text-[13px]"
|
className="h-8 px-3 text-[13px]"
|
||||||
disabled={currentPage <= 1}
|
disabled={currentPage <= 1}
|
||||||
onClick={() => setPage((prev) => Math.max(1, prev - 1))}
|
onClick={() => movePage(Math.max(1, currentPage - 1))}
|
||||||
>
|
>
|
||||||
<ChevronLeft className="size-4" />
|
<ChevronLeft className="size-4" />
|
||||||
이전
|
이전
|
||||||
@@ -164,7 +257,7 @@ export function DataTableCard<T>({
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
className="h-8 px-3 text-[13px]"
|
className="h-8 px-3 text-[13px]"
|
||||||
disabled={currentPage >= totalPages}
|
disabled={currentPage >= totalPages}
|
||||||
onClick={() => setPage((prev) => Math.min(totalPages, prev + 1))}
|
onClick={() => movePage(Math.min(totalPages, currentPage + 1))}
|
||||||
>
|
>
|
||||||
다음
|
다음
|
||||||
<ChevronRight className="size-4" />
|
<ChevronRight className="size-4" />
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
export type RequesterLike = {
|
||||||
|
requestedBy?: string | null;
|
||||||
|
requestedByName?: string | null;
|
||||||
|
requestedByEmail?: string | null;
|
||||||
|
requestedByAvatarUrl?: string | null;
|
||||||
|
createdBy?: string | null;
|
||||||
|
createdByName?: string | null;
|
||||||
|
createdByEmail?: string | null;
|
||||||
|
createdByAvatarUrl?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function requesterName(item: RequesterLike) {
|
||||||
|
return item.requestedByName
|
||||||
|
|| item.createdByName
|
||||||
|
|| item.requestedBy
|
||||||
|
|| item.createdBy
|
||||||
|
|| item.requestedByEmail
|
||||||
|
|| item.createdByEmail
|
||||||
|
|| '-';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Requester({ item, label = '작업자' }: { item: RequesterLike; label?: string }) {
|
||||||
|
const [failed, setFailed] = useState(false);
|
||||||
|
const name = requesterName(item);
|
||||||
|
const avatarUrl = item.requestedByAvatarUrl || item.createdByAvatarUrl || '';
|
||||||
|
const initial = name.trim().charAt(0).toUpperCase() || '?';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className="inline-flex h-4 min-w-0 items-center gap-1.5 align-middle text-muted-foreground">
|
||||||
|
<span className="leading-none">{label}</span>
|
||||||
|
{name === '-' ? null : avatarUrl && !failed ? (
|
||||||
|
<img
|
||||||
|
src={avatarUrl}
|
||||||
|
alt=""
|
||||||
|
className="size-4 shrink-0 rounded-full border border-border bg-muted object-cover"
|
||||||
|
onError={() => setFailed(true)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span className="inline-flex size-4 shrink-0 items-center justify-center rounded-full border border-border bg-muted text-[9px] font-semibold leading-none text-muted-foreground">
|
||||||
|
{initial}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<b className="truncate leading-none text-foreground">{name}</b>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,880 +0,0 @@
|
|||||||
import {
|
|
||||||
Bell,
|
|
||||||
Boxes,
|
|
||||||
ChartNoAxesColumnIncreasing,
|
|
||||||
FileKey,
|
|
||||||
KeyRound,
|
|
||||||
LayoutDashboard,
|
|
||||||
Megaphone,
|
|
||||||
Network,
|
|
||||||
Rocket,
|
|
||||||
ServerCog,
|
|
||||||
ShieldCheck,
|
|
||||||
Users
|
|
||||||
} from 'lucide-react';
|
|
||||||
|
|
||||||
export const navGroups = [
|
|
||||||
{
|
|
||||||
label: '플랫폼',
|
|
||||||
items: [
|
|
||||||
{ label: '대시보드', icon: LayoutDashboard, active: true },
|
|
||||||
{
|
|
||||||
label: '런처',
|
|
||||||
icon: Rocket,
|
|
||||||
children: [
|
|
||||||
{ label: '소스 관리', view: 'launcherSources' },
|
|
||||||
{ label: '빌드 관리', view: 'launcherBuilds' },
|
|
||||||
{ label: '버전 관리' },
|
|
||||||
{ label: '구성요소', view: 'launcherComponents' },
|
|
||||||
{ label: '공통 설정' },
|
|
||||||
{ label: '앱 연결' }
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: '어플리케이션',
|
|
||||||
icon: Boxes,
|
|
||||||
children: [
|
|
||||||
{ label: '앱 관리' },
|
|
||||||
{ label: '저장소 관리' },
|
|
||||||
{ label: '소스 관리' },
|
|
||||||
{ label: '빌드 관리' },
|
|
||||||
{ label: '릴리즈 관리' },
|
|
||||||
{ label: '실행 설정' },
|
|
||||||
{ label: '배포 관리' },
|
|
||||||
{ label: '마이그레이션' },
|
|
||||||
{ label: '로그/이벤트' }
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: '테일스케일',
|
|
||||||
icon: Network,
|
|
||||||
children: [
|
|
||||||
{ label: '노드 현황' },
|
|
||||||
{ label: '라우트' },
|
|
||||||
{ label: 'ACL' },
|
|
||||||
{ label: '연결 진단' }
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Agent',
|
|
||||||
icon: ShieldCheck,
|
|
||||||
children: [
|
|
||||||
{ label: '장비 등록' },
|
|
||||||
{ label: '장비 제어' },
|
|
||||||
{ label: '설치 상태' },
|
|
||||||
{ label: 'SSH 키 배포' },
|
|
||||||
{ label: 'Workspace 설정' },
|
|
||||||
{ label: '원격 명령' }
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: '플랫폼 게이트웨이',
|
|
||||||
icon: ServerCog,
|
|
||||||
children: [
|
|
||||||
{ label: '연결 상태' },
|
|
||||||
{ label: 'Agent 관리' },
|
|
||||||
{ label: '장비 동기화' }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: '운영',
|
|
||||||
items: [
|
|
||||||
{ label: '관리자 관리', icon: ShieldCheck },
|
|
||||||
{ label: '조직 관리', icon: Boxes },
|
|
||||||
{ label: '사용자/세션', icon: Users },
|
|
||||||
{ label: '라이센스', icon: FileKey },
|
|
||||||
{ label: '공지사항', icon: Megaphone }
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: '시스템',
|
|
||||||
items: [
|
|
||||||
{ label: '모니터링', icon: ChartNoAxesColumnIncreasing },
|
|
||||||
{ label: '정책', icon: ShieldCheck },
|
|
||||||
{ label: '인프라', icon: ServerCog }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
export const metrics = [
|
|
||||||
{ label: '활성 앱', value: '10', delta: '+0', hint: '런처 등록 앱 10개', tone: 'emerald' },
|
|
||||||
{ label: '접속 사용자', value: '1,284', delta: '+9.8%', hint: '활성 세션 176개', tone: 'sky' },
|
|
||||||
{ label: '라이센스 사용률', value: '72%', delta: '+4%', hint: '468 / 650 시트', tone: 'amber' },
|
|
||||||
{ label: '런타임 정상률', value: '96.4%', delta: '-1.2%', hint: '주의 워크로드 3개', tone: 'rose' }
|
|
||||||
];
|
|
||||||
|
|
||||||
export const launcherVersions = [
|
|
||||||
{
|
|
||||||
name: 'Autonomy Studio Launcher',
|
|
||||||
appKey: 'launcher',
|
|
||||||
latestVersion: '1.0.1',
|
|
||||||
minimumSupportedVersion: '1.0.0',
|
|
||||||
status: '활성',
|
|
||||||
artifact: 'autonomy-studio-launcher-1.0.1.deb',
|
|
||||||
downloadUrl: 'https://testfile.autonomystudio.net/media/platform/autonomy-studio-launcher-1.0.1.deb',
|
|
||||||
releaseNotes: '런처 업데이트 확인 및 앱별 설치 상태 판단 기능이 추가되었습니다.'
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
export const launcherCommonSettings = [
|
|
||||||
{
|
|
||||||
name: '공통 인증 서버',
|
|
||||||
key: 'AUTH_BASE_URL',
|
|
||||||
value: 'http://localhost:4100',
|
|
||||||
type: 'endpoint',
|
|
||||||
status: '활성',
|
|
||||||
description: 'atnmy-stdo-auth. 로그인, 토큰 갱신, 사용자 프로필, handoff 토큰을 제공하는 공통 인증 API'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: '인증 DB',
|
|
||||||
key: 'AUTH_DATABASE_URL',
|
|
||||||
value: 'postgres://atnmy_auth:***@auth-postgres:5432/atnmy_auth',
|
|
||||||
type: 'database',
|
|
||||||
status: '활성',
|
|
||||||
description: 'users, refresh_tokens, handoff_tokens, licenses, user_app_roles, organizations 저장소'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: '인증 Redis',
|
|
||||||
key: 'AUTH_REDIS_URL',
|
|
||||||
value: 'redis://auth-redis:6379',
|
|
||||||
type: 'cache',
|
|
||||||
status: '활성',
|
|
||||||
description: '이메일 인증 코드, 비밀번호 재설정 코드, 로그인 실패 제한 상태를 저장'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'JWT Issuer',
|
|
||||||
key: 'AUTH_JWT_ISSUER',
|
|
||||||
value: 'atnmy-stdo-auth',
|
|
||||||
type: 'security',
|
|
||||||
status: '활성',
|
|
||||||
description: '공통 인증 서버가 발급하는 access token issuer'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Refresh Cookie',
|
|
||||||
key: 'AUTH_REFRESH_COOKIE_NAME',
|
|
||||||
value: 'atnmy_refresh',
|
|
||||||
type: 'security',
|
|
||||||
status: '활성',
|
|
||||||
description: 'refresh token 저장용 HttpOnly 쿠키 이름. SameSite=Lax, Path=/ 기준'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Handoff Token',
|
|
||||||
key: 'AUTH_HANDOFF_TOKEN_TTL_SECONDS',
|
|
||||||
value: '60',
|
|
||||||
type: 'security',
|
|
||||||
status: '활성',
|
|
||||||
description: '런처에서 앱으로 인증 상태를 넘길 때 사용하는 일회성 토큰 TTL'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'CORS Origins',
|
|
||||||
key: 'AUTH_CORS_ORIGINS',
|
|
||||||
value: '5173,5174,5175,5176,4200,4300,4400',
|
|
||||||
type: 'cors',
|
|
||||||
status: '활성',
|
|
||||||
description: '런처와 앱 개발 포트에서 인증 API 호출을 허용하는 origin 목록'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: '인증 메일',
|
|
||||||
key: 'AUTH_SMTP_HOST',
|
|
||||||
value: 'smtp.gmail.com:587',
|
|
||||||
type: 'email',
|
|
||||||
status: '활성',
|
|
||||||
description: '회원가입 이메일 인증 코드와 비밀번호 재설정 코드 발송'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: '플랫폼 게이트웨이',
|
|
||||||
key: 'PLATFORM_GATEWAY_BASE_URL',
|
|
||||||
value: 'https://platform-gateway.autonomystudio.local',
|
|
||||||
type: 'endpoint',
|
|
||||||
status: '활성',
|
|
||||||
description: '런처가 클러스터, 테일스케일, 원격 실행 상태를 조회할 때 거치는 공통 게이트웨이'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: '런처 API',
|
|
||||||
key: 'LAUNCHER_API_BASE_URL',
|
|
||||||
value: 'https://launcher-api.autonomystudio.local',
|
|
||||||
type: 'endpoint',
|
|
||||||
status: '활성',
|
|
||||||
description: '앱 목록, 런처 버전, 공지, 다운로드 정보를 제공하는 API'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: '플랫폼 Gitea',
|
|
||||||
key: 'PLATFORM_GITEA_BASE_URL',
|
|
||||||
value: 'https://gitea.autonomystudio.local',
|
|
||||||
type: 'repository',
|
|
||||||
status: '활성',
|
|
||||||
description: '런처와 앱별 소스, 버전 태그, 릴리즈 산출물 기준 저장소. 앱 내부 업무용 저장소와 분리'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: '플랫폼 Gitea 토큰',
|
|
||||||
key: 'PLATFORM_GITEA_TOKEN_REF',
|
|
||||||
value: 'secret/platform-gitea-readonly',
|
|
||||||
type: 'secret',
|
|
||||||
status: '활성',
|
|
||||||
description: '앱 버전별 소스 조회와 릴리즈 메타데이터 수집에 사용하는 읽기 권한 토큰'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: '테일스케일 연결',
|
|
||||||
key: 'TAILSCALE_STATUS_PROVIDER',
|
|
||||||
value: 'platform-gateway/tailscale',
|
|
||||||
type: 'integration',
|
|
||||||
status: '활성',
|
|
||||||
description: '런처가 테일스케일 세부 설정을 직접 관리하지 않고 게이트웨이에서 연결 상태만 조회'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: '파일 서버',
|
|
||||||
key: 'DOWNLOAD_BASE_URL',
|
|
||||||
value: 'https://testfile.autonomystudio.net/media/platform',
|
|
||||||
type: 'storage',
|
|
||||||
status: '활성',
|
|
||||||
description: '런처와 앱 설치 파일을 제공하는 다운로드 베이스 URL'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: '라이센스 검증',
|
|
||||||
key: 'LICENSE_CHECK_MODE',
|
|
||||||
value: 'online',
|
|
||||||
type: 'policy',
|
|
||||||
status: '활성',
|
|
||||||
description: '런처 앱 실행 전 라이센스와 앱 권한 검증 방식'
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
export const authApiEndpoints = [
|
|
||||||
{ group: '상태', method: 'GET', path: '/health', purpose: '서비스 및 Redis 상태 확인', auth: '없음' },
|
|
||||||
{ group: '인증', method: 'POST', path: '/auth/login', purpose: 'access token 발급 및 refresh cookie 설정', auth: '없음' },
|
|
||||||
{ group: '인증', method: 'POST', path: '/auth/refresh', purpose: 'refresh token 회전 및 access token 재발급', auth: '쿠키/토큰' },
|
|
||||||
{ group: '인증', method: 'POST', path: '/auth/logout', purpose: 'refresh token 폐기 및 쿠키 제거', auth: '쿠키/토큰' },
|
|
||||||
{ group: '사용자', method: 'GET', path: '/auth/me', purpose: '현재 사용자, 앱 권한, 라이센스, 소속 조회', auth: 'Bearer' },
|
|
||||||
{ group: '앱 연결', method: 'POST', path: '/auth/handoff', purpose: '런처에서 앱으로 넘길 handoff token 생성', auth: 'Bearer' },
|
|
||||||
{ group: '앱 연결', method: 'POST', path: '/auth/handoff/exchange', purpose: 'handoff token을 앱용 token pair로 교환', auth: '없음' },
|
|
||||||
{ group: '관리자', method: 'POST', path: '/auth/admin/users', purpose: '관리자 사용자 생성, 소속, 라이센스 할당', auth: 'ADMIN' },
|
|
||||||
{ group: '관리자', method: 'GET', path: '/auth/admin/organizations', purpose: '조직 목록 조회', auth: 'ADMIN' }
|
|
||||||
];
|
|
||||||
|
|
||||||
export const apps = [
|
|
||||||
{
|
|
||||||
name: 'ADS IDE',
|
|
||||||
key: 'ADS IDE',
|
|
||||||
status: '활성',
|
|
||||||
version: '1.1.0',
|
|
||||||
containers: 2,
|
|
||||||
cpu: 52,
|
|
||||||
memory: 68,
|
|
||||||
gitea: './apps/ads-ide/ads-ide-fe',
|
|
||||||
release: 'ADS-IDE-1.1.0.AppImage',
|
|
||||||
secrets: 8,
|
|
||||||
launcherLinked: true,
|
|
||||||
launcherOrder: 1,
|
|
||||||
launchUrl: 'Ingress 예정 / localhost:5175'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'LMS',
|
|
||||||
key: 'LMS',
|
|
||||||
status: '활성',
|
|
||||||
version: '1.0.0',
|
|
||||||
containers: 3,
|
|
||||||
cpu: 74,
|
|
||||||
memory: 81,
|
|
||||||
gitea: './apps/lms/lms-fe',
|
|
||||||
release: 'LMS-1.0.0.AppImage',
|
|
||||||
secrets: 11,
|
|
||||||
launcherLinked: true,
|
|
||||||
launcherOrder: 2,
|
|
||||||
launchUrl: 'Ingress 예정 / localhost:5176'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Data Management',
|
|
||||||
key: 'Data Management',
|
|
||||||
status: '준비중',
|
|
||||||
version: '1.0.0',
|
|
||||||
containers: 0,
|
|
||||||
cpu: 31,
|
|
||||||
memory: 48,
|
|
||||||
gitea: '미연결',
|
|
||||||
release: 'data-management',
|
|
||||||
secrets: 0,
|
|
||||||
launcherLinked: false,
|
|
||||||
launcherOrder: null,
|
|
||||||
launchUrl: '미설정'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'FMS',
|
|
||||||
key: 'FMS',
|
|
||||||
status: '활성',
|
|
||||||
version: '2.3.1',
|
|
||||||
containers: 0,
|
|
||||||
cpu: 63,
|
|
||||||
memory: 57,
|
|
||||||
gitea: '미연결',
|
|
||||||
release: 'fms',
|
|
||||||
secrets: 0,
|
|
||||||
launcherLinked: true,
|
|
||||||
launcherOrder: 3,
|
|
||||||
launchUrl: '미설정'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'ADS Education',
|
|
||||||
key: 'ADS Education',
|
|
||||||
status: '활성',
|
|
||||||
version: '1.0.0',
|
|
||||||
containers: 0,
|
|
||||||
cpu: 18,
|
|
||||||
memory: 22,
|
|
||||||
gitea: '미연결',
|
|
||||||
release: 'ads-education',
|
|
||||||
secrets: 0,
|
|
||||||
launcherLinked: true,
|
|
||||||
launcherOrder: 4,
|
|
||||||
launchUrl: '미설정'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Calibration',
|
|
||||||
key: 'Calibration',
|
|
||||||
status: '활성',
|
|
||||||
version: '1.0.4',
|
|
||||||
containers: 0,
|
|
||||||
cpu: 21,
|
|
||||||
memory: 26,
|
|
||||||
gitea: '미연결',
|
|
||||||
release: 'calibration',
|
|
||||||
secrets: 0,
|
|
||||||
launcherLinked: false,
|
|
||||||
launcherOrder: null,
|
|
||||||
launchUrl: '미설정'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'HDMAP Builder',
|
|
||||||
key: 'HDMAP Builder',
|
|
||||||
status: '활성',
|
|
||||||
version: '1.0.0',
|
|
||||||
containers: 0,
|
|
||||||
cpu: 15,
|
|
||||||
memory: 19,
|
|
||||||
gitea: '미연결',
|
|
||||||
release: 'hdmap-builder',
|
|
||||||
secrets: 0,
|
|
||||||
launcherLinked: false,
|
|
||||||
launcherOrder: null,
|
|
||||||
launchUrl: '미설정'
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
export const sessions = [
|
|
||||||
{ user: 'kim.admin', role: '소유자', location: '서울', device: 'Chrome macOS', state: '활성', lastSeen: '2분 전' },
|
|
||||||
{ user: 'park.ops', role: '운영자', location: '판교', device: 'Edge Windows', state: '활성', lastSeen: '7분 전' },
|
|
||||||
{ user: 'lee.viewer', role: '조회자', location: '부산', device: 'Safari iOS', state: '대기', lastSeen: '31분 전' },
|
|
||||||
{ user: 'choi.dev', role: '개발자', location: '서울', device: 'Firefox Linux', state: '잠김', lastSeen: '1시간 전' },
|
|
||||||
{ user: 'han.pm', role: '관리자', location: '대전', device: 'Chrome Windows', state: '활성', lastSeen: '12분 전' },
|
|
||||||
{ user: 'jung.viewer', role: '조회자', location: '광주', device: 'Safari macOS', state: '대기', lastSeen: '46분 전' },
|
|
||||||
{ user: 'yoon.ops', role: '운영자', location: '서울', device: 'Edge Windows', state: '활성', lastSeen: '5분 전' },
|
|
||||||
{ user: 'seo.admin', role: '관리자', location: '판교', device: 'Chrome Linux', state: '활성', lastSeen: '1분 전' },
|
|
||||||
{ user: 'moon.dev', role: '개발자', location: '부산', device: 'Firefox Linux', state: '잠김', lastSeen: '2시간 전' },
|
|
||||||
{ user: 'lim.user', role: '사용자', location: '인천', device: 'Chrome Android', state: '대기', lastSeen: '28분 전' },
|
|
||||||
{ user: 'oh.instructor', role: '강사', location: '서울', device: 'Safari iPadOS', state: '활성', lastSeen: '9분 전' }
|
|
||||||
];
|
|
||||||
|
|
||||||
export const licensePlans = [
|
|
||||||
{
|
|
||||||
code: 'FREE',
|
|
||||||
name: 'Free',
|
|
||||||
description: '기본 무료 라이선스',
|
|
||||||
defaultDurationDays: 365,
|
|
||||||
assigned: 182,
|
|
||||||
activeUsers: 164,
|
|
||||||
expiringSoon: 18,
|
|
||||||
status: '활성'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
code: 'EDU',
|
|
||||||
name: 'Education',
|
|
||||||
description: '교육기관용 라이선스',
|
|
||||||
defaultDurationDays: 365,
|
|
||||||
assigned: 296,
|
|
||||||
activeUsers: 271,
|
|
||||||
expiringSoon: 21,
|
|
||||||
status: '활성'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
code: 'PRO',
|
|
||||||
name: 'Professional',
|
|
||||||
description: '전문가용 라이선스',
|
|
||||||
defaultDurationDays: 365,
|
|
||||||
assigned: 172,
|
|
||||||
activeUsers: 158,
|
|
||||||
expiringSoon: 3,
|
|
||||||
status: '활성'
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
export const userLicenseAssignments = [
|
|
||||||
{ user: 'admin@futuredrive.net', name: 'ATNMY Studio Admin', code: 'PRO', licenseName: 'Professional', startsAt: '06.01.', expiresAt: '무기한', assignedBy: 'system', status: '활성' },
|
|
||||||
{ user: 'teacher@futuredrive.net', name: 'Future Drive Teacher', code: 'EDU', licenseName: 'Education', startsAt: '06.02.', expiresAt: '06.02.', assignedBy: 'admin@futuredrive.net', status: '활성' },
|
|
||||||
{ user: 'teacher@yu.ac.kr', name: 'YU Instructor', code: 'EDU', licenseName: 'Education', startsAt: '06.02.', expiresAt: '06.02.', assignedBy: 'admin@futuredrive.net', status: '활성' },
|
|
||||||
{ user: 'student01@example.com', name: 'student01', code: 'FREE', licenseName: 'Free', startsAt: '06.05.', expiresAt: '06.05.', assignedBy: 'system', status: '활성' },
|
|
||||||
{ user: 'student03@example.com', name: 'student03', code: 'FREE', licenseName: 'Free', startsAt: '06.05.', expiresAt: '06.05.', assignedBy: 'system', status: '활성' },
|
|
||||||
{ user: 'ops@autonomystudio.local', name: 'ops', code: 'PRO', licenseName: 'Professional', startsAt: '06.10.', expiresAt: '06.10.', assignedBy: 'admin@futuredrive.net', status: '활성' },
|
|
||||||
{ user: 'viewer@example.com', name: 'viewer', code: 'FREE', licenseName: 'Free', startsAt: '07.01.', expiresAt: '07.01.', assignedBy: 'system', status: '주의' }
|
|
||||||
];
|
|
||||||
|
|
||||||
export const appBundles = [
|
|
||||||
{
|
|
||||||
name: 'LMS',
|
|
||||||
key: 'LMS',
|
|
||||||
status: '활성',
|
|
||||||
namespace: 'stdo-lms',
|
|
||||||
launcherTarget: 'lms-fe',
|
|
||||||
launcherArtifact: 'LMS-1.0.0.AppImage',
|
|
||||||
ingress: 'https://lms.apps.autonomystudio.local',
|
|
||||||
release: 'lms-1.0.0',
|
|
||||||
components: [
|
|
||||||
{
|
|
||||||
name: 'lms-fe',
|
|
||||||
role: '런처 다운로드 앱',
|
|
||||||
type: 'electron',
|
|
||||||
workload: 'artifact',
|
|
||||||
image: 'LMS-1.0.0.AppImage',
|
|
||||||
replicas: '-',
|
|
||||||
status: '배포됨',
|
|
||||||
repository: './apps/lms/lms-fe'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'lms-be',
|
|
||||||
role: 'API 서버',
|
|
||||||
type: 'deployment',
|
|
||||||
workload: 'Deployment/lms-api',
|
|
||||||
image: 'registry.local/lms-be:1.0.0',
|
|
||||||
replicas: '2/2',
|
|
||||||
status: '실행 중',
|
|
||||||
repository: './apps/lms/lms-be'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'lms-adm-fe',
|
|
||||||
role: '강사용 웹 어드민',
|
|
||||||
type: 'deployment',
|
|
||||||
workload: 'Deployment/lms-adm-web',
|
|
||||||
image: 'registry.local/lms-adm-fe:0.1.0',
|
|
||||||
replicas: '1/1',
|
|
||||||
status: '실행 중',
|
|
||||||
repository: './apps/lms/lms-adm-fe'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'lms-db',
|
|
||||||
role: 'PostgreSQL',
|
|
||||||
type: 'statefulset',
|
|
||||||
workload: 'StatefulSet/lms-postgres',
|
|
||||||
image: 'postgres:16',
|
|
||||||
replicas: '1/1',
|
|
||||||
status: '실행 중',
|
|
||||||
repository: 'PVC/lms-postgres-data'
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'ADS IDE',
|
|
||||||
key: 'ADS IDE',
|
|
||||||
status: '활성',
|
|
||||||
namespace: 'stdo-ads-ide',
|
|
||||||
launcherTarget: 'ads-ide-fe',
|
|
||||||
launcherArtifact: 'ADS-IDE-1.1.0.AppImage',
|
|
||||||
ingress: 'https://ads-ide.apps.autonomystudio.local',
|
|
||||||
release: 'ads-ide-1.1.0',
|
|
||||||
components: [
|
|
||||||
{
|
|
||||||
name: 'ads-ide-fe',
|
|
||||||
role: '런처 다운로드 앱',
|
|
||||||
type: 'electron',
|
|
||||||
workload: 'artifact',
|
|
||||||
image: 'ADS-IDE-1.1.0.AppImage',
|
|
||||||
replicas: '-',
|
|
||||||
status: '배포됨',
|
|
||||||
repository: './apps/ads-ide/ads-ide-fe'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'ads-ide-be',
|
|
||||||
role: 'API 서버',
|
|
||||||
type: 'deployment',
|
|
||||||
workload: 'Deployment/ads-ide-api',
|
|
||||||
image: 'registry.local/ads-ide-be:0.1.0',
|
|
||||||
replicas: '1/1',
|
|
||||||
status: '주의',
|
|
||||||
repository: './apps/ads-ide/ads-ide-be'
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Data Management',
|
|
||||||
key: 'Data Management',
|
|
||||||
status: '준비중',
|
|
||||||
namespace: 'stdo-data',
|
|
||||||
launcherTarget: 'data-management-fe',
|
|
||||||
launcherArtifact: '미등록',
|
|
||||||
ingress: '미설정',
|
|
||||||
release: 'data-management-1.0.0',
|
|
||||||
components: [
|
|
||||||
{
|
|
||||||
name: 'data-management-fe',
|
|
||||||
role: '런처 다운로드 앱',
|
|
||||||
type: 'electron',
|
|
||||||
workload: 'artifact',
|
|
||||||
image: '미등록',
|
|
||||||
replicas: '-',
|
|
||||||
status: '준비중',
|
|
||||||
repository: '미연결'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'data-api',
|
|
||||||
role: 'API 서버',
|
|
||||||
type: 'deployment',
|
|
||||||
workload: 'Deployment/data-api',
|
|
||||||
image: 'registry.local/data-api:1.0.0',
|
|
||||||
replicas: '0/1',
|
|
||||||
status: '준비중',
|
|
||||||
repository: '미연결'
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
export const appSourceConfigs = [
|
|
||||||
{
|
|
||||||
app: 'LMS',
|
|
||||||
component: 'lms-fe',
|
|
||||||
repository: 'platform/lms-fe',
|
|
||||||
branch: 'release',
|
|
||||||
commit: '8d41c2a',
|
|
||||||
sourceType: 'electron',
|
|
||||||
target: 'LMS-1.0.0.AppImage',
|
|
||||||
status: '활성'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
app: 'LMS',
|
|
||||||
component: 'lms-be',
|
|
||||||
repository: 'platform/lms-be',
|
|
||||||
branch: 'release',
|
|
||||||
commit: '3ac92ef',
|
|
||||||
sourceType: 'container',
|
|
||||||
target: 'registry.local/lms-be:1.0.0',
|
|
||||||
status: '활성'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
app: 'LMS',
|
|
||||||
component: 'lms-adm-fe',
|
|
||||||
repository: 'platform/lms-adm-fe',
|
|
||||||
branch: 'release',
|
|
||||||
commit: 'f17ab31',
|
|
||||||
sourceType: 'static-web',
|
|
||||||
target: 'registry.local/lms-adm-fe:0.1.0',
|
|
||||||
status: '활성'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
app: 'LMS',
|
|
||||||
component: 'lms-db',
|
|
||||||
repository: 'platform/lms-be',
|
|
||||||
branch: 'release',
|
|
||||||
commit: '3ac92ef',
|
|
||||||
sourceType: 'migration',
|
|
||||||
target: 'prisma/migrations',
|
|
||||||
status: '활성'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
app: 'ADS IDE',
|
|
||||||
component: 'ads-ide-fe',
|
|
||||||
repository: 'platform/ads-ide-fe',
|
|
||||||
branch: 'develop',
|
|
||||||
commit: 'b902a11',
|
|
||||||
sourceType: 'electron',
|
|
||||||
target: 'ADS-IDE-1.1.0.AppImage',
|
|
||||||
status: '활성'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
app: 'ADS IDE',
|
|
||||||
component: 'ads-ide-be',
|
|
||||||
repository: 'platform/ads-ide-be',
|
|
||||||
branch: 'develop',
|
|
||||||
commit: '10fd8b0',
|
|
||||||
sourceType: 'container',
|
|
||||||
target: 'registry.local/ads-ide-be:0.1.0',
|
|
||||||
status: '주의'
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
export const appBuildJobs = [
|
|
||||||
{ app: 'LMS', component: 'lms-fe', branch: 'release', job: 'electron build', artifact: 'LMS-1.0.0.AppImage', duration: '6분 12초', status: '완료', time: '12분 전' },
|
|
||||||
{ app: 'LMS', component: 'lms-be', branch: 'release', job: 'image build', artifact: 'registry.local/lms-be:1.0.0', duration: '4분 31초', status: '완료', time: '10분 전' },
|
|
||||||
{ app: 'LMS', component: 'lms-adm-fe', branch: 'release', job: 'web image build', artifact: 'registry.local/lms-adm-fe:0.1.0', duration: '3분 18초', status: '완료', time: '9분 전' },
|
|
||||||
{ app: 'ADS IDE', component: 'ads-ide-be', branch: 'develop', job: 'image build', artifact: 'registry.local/ads-ide-be:0.1.0', duration: '진행 중', status: '실행 중', time: '3분 전' },
|
|
||||||
{ app: 'FMS', component: 'fms-api', branch: 'develop', job: 'image build', artifact: '미생성', duration: '1분 44초', status: '주의', time: '24분 전' }
|
|
||||||
];
|
|
||||||
|
|
||||||
export const appReleasePipelines = [
|
|
||||||
{ app: 'LMS', version: '1.0.0', sourceBranch: 'release', releaseBranch: 'main', commit: '3ac92ef', artifacts: 'electron 1, images 2, migrations 4', status: '배포됨', createdAt: '06.18.' },
|
|
||||||
{ app: 'ADS IDE', version: '1.1.0', sourceBranch: 'develop', releaseBranch: 'release', commit: 'b902a11', artifacts: 'electron 1, images 1', status: '카나리', createdAt: '06.16.' },
|
|
||||||
{ app: 'FMS', version: '2.3.1', sourceBranch: 'develop', releaseBranch: 'release', commit: 'c82f09d', artifacts: 'images 1', status: '초안', createdAt: '06.14.' }
|
|
||||||
];
|
|
||||||
|
|
||||||
export const appDeploymentJobs = [
|
|
||||||
{ app: 'LMS', environment: '운영', release: '1.0.0', strategy: 'rolling update', target: 'stdo-lms', status: '완료', requestedBy: 'kim.admin', time: '8분 전' },
|
|
||||||
{ app: 'ADS IDE', environment: '개발', release: '1.1.0-canary', strategy: 'redeploy', target: 'stdo-ads-ide', status: '실행 중', requestedBy: 'park.ops', time: '2분 전' },
|
|
||||||
{ app: 'FMS', environment: '개발', release: '2.3.1-dev', strategy: 'deploy', target: 'stdo-fms', status: '주의', requestedBy: 'choi.dev', time: '27분 전' }
|
|
||||||
];
|
|
||||||
|
|
||||||
export const appMigrationJobs = [
|
|
||||||
{ app: 'LMS', component: 'lms-db', branch: 'release', path: 'prisma/migrations', version: '20260618091000_add_course_indexes', status: '완료', time: '7분 전' },
|
|
||||||
{ app: 'LMS', component: 'lms-db', branch: 'release', path: 'prisma/migrations', version: '20260617083000_add_assignment_status', status: '완료', time: '1일 전' },
|
|
||||||
{ app: 'ADS IDE', component: 'ads-ide-db', branch: 'develop', path: 'migrations', version: '20260616070000_workspace_schema', status: '대기', time: '대기 중' }
|
|
||||||
];
|
|
||||||
|
|
||||||
export const containers = [
|
|
||||||
{ name: 'launcher-api', app: 'Autonomy Studio Launcher', image: 'atnmy-stdo-v2-be:local', cpu: 43, memory: 62, status: '실행 중' },
|
|
||||||
{ name: 'launcher-postgres', app: 'Autonomy Studio Launcher', image: 'postgres:launcher', cpu: 24, memory: 39, status: '실행 중' },
|
|
||||||
{ name: 'lms-api', app: 'LMS', image: 'lms-be:local', cpu: 67, memory: 59, status: '실행 중' },
|
|
||||||
{ name: 'ads-ide-api', app: 'ADS IDE', image: 'ads-ide-be:local', cpu: 89, memory: 84, status: '주의' }
|
|
||||||
];
|
|
||||||
|
|
||||||
export const k3sWorkloads = [
|
|
||||||
{
|
|
||||||
namespace: 'stdo-launcher',
|
|
||||||
app: 'Autonomy Studio Launcher',
|
|
||||||
workload: 'Deployment/launcher-api',
|
|
||||||
pod: 'launcher-api-7c9d85f9b7-q2x4m',
|
|
||||||
image: 'registry.local/atnmy-stdo-v2-be:1.0.1',
|
|
||||||
replicas: '2/2',
|
|
||||||
node: 'cluster-master-01',
|
|
||||||
cpu: 43,
|
|
||||||
memory: 62,
|
|
||||||
restarts: 0,
|
|
||||||
age: '3d',
|
|
||||||
status: '실행 중'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
namespace: 'stdo-launcher',
|
|
||||||
app: 'Autonomy Studio Launcher',
|
|
||||||
workload: 'StatefulSet/launcher-postgres',
|
|
||||||
pod: 'launcher-postgres-0',
|
|
||||||
image: 'postgres:16',
|
|
||||||
replicas: '1/1',
|
|
||||||
node: 'cluster-master-01',
|
|
||||||
cpu: 24,
|
|
||||||
memory: 39,
|
|
||||||
restarts: 0,
|
|
||||||
age: '3d',
|
|
||||||
status: '실행 중'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
namespace: 'stdo-lms',
|
|
||||||
app: 'LMS',
|
|
||||||
workload: 'Deployment/lms-api',
|
|
||||||
pod: 'lms-api-5f6db8d675-jt2xk',
|
|
||||||
image: 'registry.local/lms-be:1.0.0',
|
|
||||||
replicas: '2/2',
|
|
||||||
node: 'cluster-worker-01',
|
|
||||||
cpu: 67,
|
|
||||||
memory: 59,
|
|
||||||
restarts: 1,
|
|
||||||
age: '18h',
|
|
||||||
status: '실행 중'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
namespace: 'stdo-lms',
|
|
||||||
app: 'LMS',
|
|
||||||
workload: 'Deployment/lms-adm-web',
|
|
||||||
pod: 'lms-adm-web-778b4d4f9b-bxz92',
|
|
||||||
image: 'registry.local/lms-adm-fe:0.1.0',
|
|
||||||
replicas: '1/1',
|
|
||||||
node: 'cluster-worker-02',
|
|
||||||
cpu: 31,
|
|
||||||
memory: 44,
|
|
||||||
restarts: 0,
|
|
||||||
age: '18h',
|
|
||||||
status: '실행 중'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
namespace: 'stdo-lms',
|
|
||||||
app: 'LMS',
|
|
||||||
workload: 'StatefulSet/lms-postgres',
|
|
||||||
pod: 'lms-postgres-0',
|
|
||||||
image: 'postgres:16',
|
|
||||||
replicas: '1/1',
|
|
||||||
node: 'cluster-worker-01',
|
|
||||||
cpu: 18,
|
|
||||||
memory: 71,
|
|
||||||
restarts: 0,
|
|
||||||
age: '18h',
|
|
||||||
status: '실행 중'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
namespace: 'stdo-ads-ide',
|
|
||||||
app: 'ADS IDE',
|
|
||||||
workload: 'Deployment/ads-ide-api',
|
|
||||||
pod: 'ads-ide-api-84b8787d7d-r8qkp',
|
|
||||||
image: 'registry.local/ads-ide-be:0.1.0',
|
|
||||||
replicas: '1/1',
|
|
||||||
node: 'cluster-worker-02',
|
|
||||||
cpu: 89,
|
|
||||||
memory: 84,
|
|
||||||
restarts: 4,
|
|
||||||
age: '7h',
|
|
||||||
status: '주의'
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
export const containerBlueprints = [
|
|
||||||
{
|
|
||||||
app: 'LMS',
|
|
||||||
namespace: 'stdo-lms',
|
|
||||||
release: 'lms-1.0.0',
|
|
||||||
resources: [
|
|
||||||
{
|
|
||||||
name: 'lms-api',
|
|
||||||
kind: 'Deployment',
|
|
||||||
image: 'registry.local/lms-be:1.0.0',
|
|
||||||
replicas: 2,
|
|
||||||
ports: '4000/TCP',
|
|
||||||
env: 14,
|
|
||||||
secrets: 5,
|
|
||||||
storage: '-',
|
|
||||||
ingress: 'api.lms.apps.local',
|
|
||||||
status: '구성완료'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'lms-adm-web',
|
|
||||||
kind: 'Deployment',
|
|
||||||
image: 'registry.local/lms-adm-fe:0.1.0',
|
|
||||||
replicas: 1,
|
|
||||||
ports: '80/TCP',
|
|
||||||
env: 6,
|
|
||||||
secrets: 2,
|
|
||||||
storage: '-',
|
|
||||||
ingress: 'admin.lms.apps.local',
|
|
||||||
status: '구성완료'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'lms-postgres',
|
|
||||||
kind: 'StatefulSet',
|
|
||||||
image: 'postgres:16',
|
|
||||||
replicas: 1,
|
|
||||||
ports: '5432/TCP',
|
|
||||||
env: 8,
|
|
||||||
secrets: 3,
|
|
||||||
storage: '20Gi / RWO',
|
|
||||||
ingress: '-',
|
|
||||||
status: '구성완료'
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
app: 'ADS IDE',
|
|
||||||
namespace: 'stdo-ads-ide',
|
|
||||||
release: 'ads-ide-1.1.0',
|
|
||||||
resources: [
|
|
||||||
{
|
|
||||||
name: 'ads-ide-api',
|
|
||||||
kind: 'Deployment',
|
|
||||||
image: 'registry.local/ads-ide-be:0.1.0',
|
|
||||||
replicas: 1,
|
|
||||||
ports: '4300/TCP',
|
|
||||||
env: 9,
|
|
||||||
secrets: 4,
|
|
||||||
storage: '-',
|
|
||||||
ingress: 'api.ads-ide.apps.local',
|
|
||||||
status: '검토필요'
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
export const tailscaleNodes = [
|
|
||||||
{
|
|
||||||
name: 'cluster-master-01',
|
|
||||||
ip: '100.64.0.10',
|
|
||||||
role: 'control-plane',
|
|
||||||
user: 'ops@autonomystudio.local',
|
|
||||||
routes: '10.42.0.0/16, 10.43.0.0/16',
|
|
||||||
lastSeen: '1분 전',
|
|
||||||
status: '활성'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'cluster-worker-01',
|
|
||||||
ip: '100.64.0.11',
|
|
||||||
role: 'worker',
|
|
||||||
user: 'ops@autonomystudio.local',
|
|
||||||
routes: '-',
|
|
||||||
lastSeen: '3분 전',
|
|
||||||
status: '활성'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'gpu-runner-02',
|
|
||||||
ip: '100.64.20.2',
|
|
||||||
role: 'gpu-runner',
|
|
||||||
user: 'mlops@autonomystudio.local',
|
|
||||||
routes: '-',
|
|
||||||
lastSeen: '오프라인',
|
|
||||||
status: '주의'
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
export const tailscaleSettings = [
|
|
||||||
{
|
|
||||||
name: 'Tailnet',
|
|
||||||
key: 'TAILSCALE_TAILNET',
|
|
||||||
value: 'autonomy-studio.ts.net',
|
|
||||||
status: '활성',
|
|
||||||
description: '런처와 클러스터 운영 노드가 연결되는 tailnet'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Subnet Routes',
|
|
||||||
key: 'TAILSCALE_ADVERTISE_ROUTES',
|
|
||||||
value: '10.42.0.0/16,10.43.0.0/16',
|
|
||||||
status: '활성',
|
|
||||||
description: 'Pod/Service CIDR 접근용 광고 라우트'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Platform Gateway 연동',
|
|
||||||
key: 'TAILSCALE_GATEWAY_PROVIDER',
|
|
||||||
value: 'platform-gateway',
|
|
||||||
status: '활성',
|
|
||||||
description: '런처에는 연결 상태만 제공하고 세부 조회는 플랫폼 게이트웨이가 담당'
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
export const gatewayConnections = [
|
|
||||||
{ name: 'Headscale Server', endpoint: 'https://headscale.autonomystudio.local', role: '노드/ACL 동기화', status: '활성', lastSync: '1분 전' },
|
|
||||||
{ name: 'Autonomy Agent', endpoint: 'agent heartbeat stream', role: 'Agent 등록/상태 집계', status: '활성', lastSync: '30초 전' },
|
|
||||||
{ name: 'Platform Gitea', endpoint: 'https://gitea.autonomystudio.local', role: '런처/앱 소스 버전 저장소', status: '활성', lastSync: '5분 전' },
|
|
||||||
{ name: 'Platform Runtime', endpoint: 'runtime command bus', role: '원격 명령/로그/이벤트', status: '주의', lastSync: '12분 전' }
|
|
||||||
];
|
|
||||||
|
|
||||||
export const agentDevices = [
|
|
||||||
{ name: 'lab-mkmini-01', account: 'student01@example.com', status: '활성', workspace: '~/autonomy-studio/workspaces', sshKey: '배포됨', lastSeen: '2분 전' },
|
|
||||||
{ name: 'lab-f1tenth-03', account: 'student03@example.com', status: '활성', workspace: '~/autonomy-studio/workspaces', sshKey: '배포됨', lastSeen: '4분 전' },
|
|
||||||
{ name: 'sim-runner-02', account: 'instructor@example.com', status: '주의', workspace: '/opt/studio/workspaces', sshKey: '대기', lastSeen: '35분 전' }
|
|
||||||
];
|
|
||||||
|
|
||||||
export const runtimeCommands = [
|
|
||||||
{ target: 'lab-mkmini-01', command: 'workspace sync', status: '완료', requestedBy: 'park.ops', time: '8분 전' },
|
|
||||||
{ target: 'lab-f1tenth-03', command: 'build module', status: '실행 중', requestedBy: 'kim.admin', time: '3분 전' },
|
|
||||||
{ target: 'sim-runner-02', command: 'collect logs', status: '주의', requestedBy: 'choi.dev', time: '22분 전' }
|
|
||||||
];
|
|
||||||
|
|
||||||
export const notices = [
|
|
||||||
{ scope: '런처', title: 'v1.0.1 업데이트 안내', audience: '전체 사용자', status: '게시됨', date: '04.23.' },
|
|
||||||
{ scope: 'ADS IDE', title: '정기 점검 안내', audience: 'ADS IDE 사용자', status: '게시됨', date: '04.10.' },
|
|
||||||
{ scope: 'LMS', title: '신규 과제 검토 요청', audience: '강사', status: '게시됨', date: '06.02.' },
|
|
||||||
{ scope: '런처', title: '로그인 화면 점검 배너', audience: '전체 사용자', status: '예약됨', date: '06.24.' },
|
|
||||||
{ scope: 'FMS', title: 'FMS 릴리즈 사전 안내', audience: 'FMS 사용자', status: '초안', date: '06.25.' },
|
|
||||||
{ scope: 'ADS Education', title: '교육 콘텐츠 업데이트', audience: 'ADS Education 사용자', status: '게시됨', date: '06.12.' },
|
|
||||||
{ scope: 'Data Management', title: '베타 신청 접수', audience: '운영자', status: '초안', date: '06.18.' },
|
|
||||||
{ scope: '런처', title: '구버전 런처 지원 종료 안내', audience: '전체 사용자', status: '예약됨', date: '07.01.' },
|
|
||||||
{ scope: 'Calibration', title: '캘리브레이션 템플릿 변경', audience: 'Calibration 사용자', status: '게시됨', date: '06.09.' },
|
|
||||||
{ scope: 'HDMAP Builder', title: 'HDMAP Builder 등록 예정', audience: '운영자', status: '초안', date: '06.28.' },
|
|
||||||
{ scope: '런처', title: '파일 서버 점검 공지', audience: '전체 사용자', status: '게시됨', date: '06.15.' },
|
|
||||||
{ scope: 'ADS IDE', title: 'IDE 확장 기능 업데이트', audience: 'ADS IDE 사용자', status: '예약됨', date: '06.30.' }
|
|
||||||
];
|
|
||||||
|
|
||||||
export const alerts = [
|
|
||||||
{ icon: Bell, label: '확인이 필요한 워크로드 3개' },
|
|
||||||
{ icon: FileKey, label: '14일 내 만료되는 라이센스 42개' },
|
|
||||||
{ icon: KeyRound, label: '이번 주 교체된 시크릿 5개' }
|
|
||||||
];
|
|
||||||
+32
-18
@@ -1,11 +1,15 @@
|
|||||||
import {
|
import {
|
||||||
|
BookOpen,
|
||||||
Boxes,
|
Boxes,
|
||||||
ChartNoAxesColumnIncreasing,
|
ChartNoAxesColumnIncreasing,
|
||||||
FileKey,
|
FileKey,
|
||||||
|
HardDrive,
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
Megaphone,
|
Megaphone,
|
||||||
|
MonitorPlay,
|
||||||
|
MonitorUp,
|
||||||
Network,
|
Network,
|
||||||
Rocket,
|
ScrollText,
|
||||||
ServerCog,
|
ServerCog,
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
Users
|
Users
|
||||||
@@ -34,15 +38,25 @@ export const navGroups: NavGroup[] = [
|
|||||||
label: '플랫폼',
|
label: '플랫폼',
|
||||||
items: [
|
items: [
|
||||||
{ label: '대시보드', path: '/dashboard', icon: LayoutDashboard },
|
{ label: '대시보드', path: '/dashboard', icon: LayoutDashboard },
|
||||||
|
{
|
||||||
|
label: '공통앱',
|
||||||
|
icon: ServerCog,
|
||||||
|
children: [
|
||||||
|
{ label: '소스 관리', path: '/common-apps/sources' },
|
||||||
|
{ label: '빌드 관리', path: '/common-apps/builds' },
|
||||||
|
{ label: '구성요소', path: '/common-apps/components' },
|
||||||
|
{ label: '로그/이벤트', path: '/common-apps/logs' }
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: '런처',
|
label: '런처',
|
||||||
icon: Rocket,
|
icon: MonitorPlay,
|
||||||
children: [
|
children: [
|
||||||
{ label: '소스 관리', path: '/launcher/sources' },
|
{ label: '소스 관리', path: '/launcher/sources' },
|
||||||
{ label: '빌드 관리', path: '/launcher/builds' },
|
{ label: '빌드 관리', path: '/launcher/builds' },
|
||||||
{ label: '버전 관리', path: '/launcher/versions' },
|
{ label: '버전 관리', path: '/launcher/versions' },
|
||||||
{ label: '구성요소', path: '/launcher/components' },
|
{ label: '구성요소', path: '/launcher/components' },
|
||||||
{ label: '공통 설정', path: '/launcher/common-settings' },
|
{ label: '로그/이벤트', path: '/launcher/logs' },
|
||||||
{ label: '앱 연결', path: '/launcher/apps' }
|
{ label: '앱 연결', path: '/launcher/apps' }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -68,28 +82,26 @@ export const navGroups: NavGroup[] = [
|
|||||||
{ label: '노드 현황', path: '/tailscale/status' },
|
{ label: '노드 현황', path: '/tailscale/status' },
|
||||||
{ label: '라우트', path: '/tailscale/routes' },
|
{ label: '라우트', path: '/tailscale/routes' },
|
||||||
{ label: 'ACL', path: '/tailscale/acl' },
|
{ label: 'ACL', path: '/tailscale/acl' },
|
||||||
{ label: '연결 진단', path: '/tailscale/diagnostics' }
|
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Agent',
|
label: '플랫폼 관리',
|
||||||
icon: ShieldCheck,
|
icon: MonitorUp,
|
||||||
children: [
|
children: [
|
||||||
{ label: '장비 등록', path: '/agents/registration' },
|
{ label: '플랫폼 등록', path: '/agents/registration' },
|
||||||
{ label: '장비 제어', path: '/runtime/devices' },
|
{ label: '연결 제어', path: '/runtime/devices' },
|
||||||
{ label: '설치 상태', path: '/agents/install-status' },
|
{ label: 'SSH 키', path: '/agents/ssh-keys' },
|
||||||
{ label: 'SSH 키 배포', path: '/agents/ssh-keys' },
|
|
||||||
{ label: 'Workspace 설정', path: '/agents/workspace' },
|
|
||||||
{ label: '원격 명령', path: '/agents/commands' }
|
{ label: '원격 명령', path: '/agents/commands' }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: '플랫폼 게이트웨이',
|
label: '인프라',
|
||||||
icon: ServerCog,
|
icon: HardDrive,
|
||||||
children: [
|
children: [
|
||||||
{ label: '연결 상태', path: '/gateway/status' },
|
{ label: '클러스터', path: '/infrastructure/cluster' },
|
||||||
{ label: 'Agent 관리', path: '/gateway/agents' },
|
{ label: '워크로드', path: '/infrastructure/workloads' },
|
||||||
{ label: '장비 동기화', path: '/gateway/devices' }
|
{ label: '네트워크', path: '/infrastructure/network' },
|
||||||
|
{ label: '스토리지', path: '/infrastructure/storage' }
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -99,9 +111,11 @@ export const navGroups: NavGroup[] = [
|
|||||||
items: [
|
items: [
|
||||||
{ label: '관리자 관리', path: '/operations/admins', icon: ShieldCheck },
|
{ label: '관리자 관리', path: '/operations/admins', icon: ShieldCheck },
|
||||||
{ label: '조직 관리', path: '/operations/organizations', icon: Boxes },
|
{ label: '조직 관리', path: '/operations/organizations', icon: Boxes },
|
||||||
{ label: '사용자/세션', path: '/operations/users', icon: Users },
|
{ label: '사용자 관리', path: '/operations/users', icon: Users },
|
||||||
{ label: '라이센스', path: '/operations/licenses', icon: FileKey },
|
{ label: '라이센스', path: '/operations/licenses', icon: FileKey },
|
||||||
{ label: '공지사항', path: '/operations/notices', icon: Megaphone }
|
{ label: '공지사항', path: '/operations/notices', icon: Megaphone },
|
||||||
|
{ label: '약관', path: '/operations/terms', icon: ScrollText },
|
||||||
|
{ label: '매뉴얼', path: '/operations/manuals', icon: BookOpen }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,14 +1,12 @@
|
|||||||
export const adminAppPermissionsStorageKey = 'studio-admin-app-permissions';
|
|
||||||
|
|
||||||
export type CurrentAdminLike = {
|
export type CurrentAdminLike = {
|
||||||
id?: string;
|
id?: string;
|
||||||
email?: string;
|
email?: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
role?: string;
|
role?: string;
|
||||||
|
avatarUrl?: string | null;
|
||||||
|
appPermissions?: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
type PermissionMap = Record<string, string[]>;
|
|
||||||
|
|
||||||
export function readCurrentAdmin(): CurrentAdminLike | null {
|
export function readCurrentAdmin(): CurrentAdminLike | null {
|
||||||
try {
|
try {
|
||||||
const raw = window.localStorage.getItem('studio-admin');
|
const raw = window.localStorage.getItem('studio-admin');
|
||||||
@@ -20,44 +18,20 @@ export function readCurrentAdmin(): CurrentAdminLike | null {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getAdminPermissionKey(admin: CurrentAdminLike) {
|
|
||||||
return admin.id || admin.email || '';
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isOwnerAdmin(admin?: CurrentAdminLike | null) {
|
export function isOwnerAdmin(admin?: CurrentAdminLike | null) {
|
||||||
return admin?.role === 'OWNER';
|
return admin?.role === 'OWNER';
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getAdminAppPermissionMap(): PermissionMap {
|
|
||||||
try {
|
|
||||||
const raw = window.localStorage.getItem(adminAppPermissionsStorageKey);
|
|
||||||
if (!raw) return {};
|
|
||||||
const parsed = JSON.parse(raw) as PermissionMap;
|
|
||||||
return parsed && typeof parsed === 'object' ? parsed : {};
|
|
||||||
} catch {
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getAdminAppPermissions(admin: CurrentAdminLike) {
|
export function getAdminAppPermissions(admin: CurrentAdminLike) {
|
||||||
const key = getAdminPermissionKey(admin);
|
return Array.isArray(admin.appPermissions) ? admin.appPermissions : [];
|
||||||
const map = getAdminAppPermissionMap();
|
|
||||||
if (key && map[key]) return map[key];
|
|
||||||
if (admin.id && map[admin.id]) return map[admin.id];
|
|
||||||
if (admin.email && map[admin.email]) return map[admin.email];
|
|
||||||
return [];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function setAdminAppPermissions(admin: CurrentAdminLike, appIds: string[]) {
|
export function setAdminAppPermissions(admin: CurrentAdminLike, appIds: string[]) {
|
||||||
const key = getAdminPermissionKey(admin);
|
const current = readCurrentAdmin();
|
||||||
if (!key) return;
|
if (!current || !admin.id || current.id !== admin.id) return;
|
||||||
|
|
||||||
const nextMap = getAdminAppPermissionMap();
|
|
||||||
const nextAppIds = [...new Set(appIds.filter(Boolean))];
|
const nextAppIds = [...new Set(appIds.filter(Boolean))];
|
||||||
nextMap[key] = nextAppIds;
|
window.localStorage.setItem('studio-admin', JSON.stringify({ ...current, appPermissions: nextAppIds }));
|
||||||
if (admin.id) nextMap[admin.id] = nextAppIds;
|
|
||||||
if (admin.email) nextMap[admin.email] = nextAppIds;
|
|
||||||
window.localStorage.setItem(adminAppPermissionsStorageKey, JSON.stringify(nextMap));
|
|
||||||
window.dispatchEvent(new CustomEvent('studio-admin-app-permissions-change'));
|
window.dispatchEvent(new CustomEvent('studio-admin-app-permissions-change'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
export const selectedAppStorageKey = 'studio-admin:selected-app-id';
|
export const selectedAppStorageKey = 'studio-admin:selected-app-id';
|
||||||
|
export const selectedComponentByAppStorageKey = 'studio-admin:selected-component-by-app-id';
|
||||||
|
export const selectedComponentChangedEventName = 'studio-admin:selected-component-change';
|
||||||
const legacySelectedAppStorageKey = 'studio-selected-app-id';
|
const legacySelectedAppStorageKey = 'studio-selected-app-id';
|
||||||
|
|
||||||
export function getStoredSelectedAppId() {
|
export function getStoredSelectedAppId() {
|
||||||
@@ -19,3 +21,35 @@ export function setStoredSelectedAppId(appId: string) {
|
|||||||
if (!appId) return;
|
if (!appId) return;
|
||||||
window.localStorage.setItem(selectedAppStorageKey, appId);
|
window.localStorage.setItem(selectedAppStorageKey, appId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function readSelectedComponentMap(): Record<string, string> {
|
||||||
|
const raw = window.localStorage.getItem(selectedComponentByAppStorageKey);
|
||||||
|
if (!raw) return {};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw) as unknown;
|
||||||
|
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {};
|
||||||
|
return Object.fromEntries(
|
||||||
|
Object.entries(parsed)
|
||||||
|
.filter((entry): entry is [string, string] => typeof entry[0] === 'string' && typeof entry[1] === 'string')
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getStoredSelectedComponentId(appId: string) {
|
||||||
|
if (!appId) return '';
|
||||||
|
return readSelectedComponentMap()[appId] ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setStoredSelectedComponentId(appId: string, componentId: string) {
|
||||||
|
if (!appId || !componentId) return;
|
||||||
|
window.localStorage.setItem(selectedComponentByAppStorageKey, JSON.stringify({
|
||||||
|
...readSelectedComponentMap(),
|
||||||
|
[appId]: componentId,
|
||||||
|
}));
|
||||||
|
window.dispatchEvent(new CustomEvent(selectedComponentChangedEventName, {
|
||||||
|
detail: { appId, componentId },
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
export type StatusDotTone = 'green' | 'amber' | 'red' | 'blue' | 'neutral';
|
||||||
|
|
||||||
|
export function statusDotToneClass(tone: StatusDotTone) {
|
||||||
|
const tones: Record<StatusDotTone, string> = {
|
||||||
|
green: 'bg-emerald-400 shadow-[0_0_0_3px_rgba(52,211,153,0.15)]',
|
||||||
|
amber: 'bg-amber-400 shadow-[0_0_0_3px_rgba(251,191,36,0.15)]',
|
||||||
|
red: 'bg-rose-400 shadow-[0_0_0_3px_rgba(251,113,133,0.15)]',
|
||||||
|
blue: 'bg-sky-400 shadow-[0_0_0_3px_rgba(56,189,248,0.15)]',
|
||||||
|
neutral: 'bg-muted-foreground/60 shadow-[0_0_0_3px_rgba(148,163,184,0.12)]'
|
||||||
|
};
|
||||||
|
|
||||||
|
return tones[tone];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function runtimeStatusDotClass(status?: string | null) {
|
||||||
|
if (['Running', 'Bound', 'ClusterIP', 'LoadBalancer', 'Normal'].includes(status ?? '')) return statusDotToneClass('green');
|
||||||
|
if (['Pending', 'ContainerCreating', 'Warning'].includes(status ?? '')) return statusDotToneClass('amber');
|
||||||
|
if (['Error', 'CrashLoopBackOff', 'ImagePullBackOff', 'Failed'].includes(status ?? '')) return statusDotToneClass('red');
|
||||||
|
return statusDotToneClass('neutral');
|
||||||
|
}
|
||||||
|
|
||||||
+701
-242
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,78 @@
|
|||||||
|
import { apiRequest } from '../../lib/api';
|
||||||
|
|
||||||
|
export type PlatformDevice = {
|
||||||
|
id: string;
|
||||||
|
accountEmail: string;
|
||||||
|
hostname: string;
|
||||||
|
tailscaleHostname: string;
|
||||||
|
sshUser?: string | null;
|
||||||
|
workspaceRoot?: string | null;
|
||||||
|
usageType: string;
|
||||||
|
os?: string | null;
|
||||||
|
arch?: string | null;
|
||||||
|
distro?: string | null;
|
||||||
|
agentVersion?: string | null;
|
||||||
|
agentUpdatedAt?: string | null;
|
||||||
|
agentLastSeenAt?: string | null;
|
||||||
|
installedAt: string;
|
||||||
|
lastProvisionedAt: string;
|
||||||
|
status: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PlatformSshKey = {
|
||||||
|
id: string;
|
||||||
|
accountEmail: string;
|
||||||
|
label?: string | null;
|
||||||
|
publicKeyHash: string;
|
||||||
|
keyType?: string | null;
|
||||||
|
lastProvisionedAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PlatformRegistrationUser = {
|
||||||
|
id: string;
|
||||||
|
name?: string | null;
|
||||||
|
email: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DeviceAuthorization = {
|
||||||
|
id: string;
|
||||||
|
userCode: string;
|
||||||
|
status: string;
|
||||||
|
authUserId?: string | null;
|
||||||
|
accountEmail?: string | null;
|
||||||
|
authUserName?: string | null;
|
||||||
|
expiresAt: string;
|
||||||
|
createdAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function loadPlatformDevices() {
|
||||||
|
const response = await apiRequest<{ devices?: PlatformDevice[] }>('/agents/devices');
|
||||||
|
return response.devices ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadPlatformSshKeys() {
|
||||||
|
const response = await apiRequest<{ sshKeys?: PlatformSshKey[] }>('/agents/ssh-keys');
|
||||||
|
return response.sshKeys ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function searchPlatformRegistrationUsers(keyword: string) {
|
||||||
|
const response = await apiRequest<{ users?: PlatformRegistrationUser[] }>(`/agents/registration-users?keyword=${encodeURIComponent(keyword)}`);
|
||||||
|
return response.users ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadDeviceAuthorizations() {
|
||||||
|
const response = await apiRequest<{ authorizations?: DeviceAuthorization[] }>('/agents/device-authorizations');
|
||||||
|
return response.authorizations ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createDeviceAuthorization(userId: string) {
|
||||||
|
const response = await apiRequest<{ authorization: DeviceAuthorization }>('/agents/device-authorizations', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ userId }),
|
||||||
|
});
|
||||||
|
return response.authorization;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function cancelDeviceAuthorization(id: string) {
|
||||||
|
return apiRequest<{ ok: boolean }>(`/agents/device-authorizations/${id}/cancel`, { method: 'POST' });
|
||||||
|
}
|
||||||
@@ -1,19 +1,22 @@
|
|||||||
import { useEffect, useState, type MouseEvent } 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, UserCog } from 'lucide-react';
|
||||||
import { Badge, Button, Card, CardHeader } from '../../components/ui';
|
import { Badge, 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 { apiBaseUrl, apiFetch, authHeaders } from '../../lib/api';
|
import { apiBaseUrl, apiFetch, authHeaders } from '../../lib/api';
|
||||||
import { filterAppsByCurrentAdmin, grantAppPermissionToCurrentAdmin } from '../../lib/admin-app-permissions';
|
import { filterAppsByCurrentAdmin, grantAppPermissionToCurrentAdmin, isOwnerAdmin, setAdminAppPermissions } from '../../lib/admin-app-permissions';
|
||||||
|
import { runtimeStatusDotClass, statusDotToneClass } from '../../lib/status-dot';
|
||||||
import { cn } from '../../lib/utils';
|
import { cn } from '../../lib/utils';
|
||||||
import { getStoredSelectedAppId, setStoredSelectedAppId } from '../../lib/selected-app';
|
import { getStoredSelectedAppId, setStoredSelectedAppId } from '../../lib/selected-app';
|
||||||
import type { ApiApp, ApiAppComponent, BuildEnvVariable, RuntimePersistentVolumeClaim, RuntimeResource } from '../../types/runtime';
|
import type { ApiApp, ApiAppComponent, BuildEnvVariable, RuntimePersistentVolumeClaim, RuntimeResource } from '../../types/runtime';
|
||||||
import { LoadingModal } from '../operations/operation-utils';
|
import { LoadingModal } from '../operations/operation-utils';
|
||||||
|
import { loadAdmins, updateAdminAppPermissions, type AdminAccount } from '../operations/operations-api';
|
||||||
|
|
||||||
type ComponentPortForm = {
|
type ComponentPortForm = {
|
||||||
name: string;
|
name: string;
|
||||||
port: string;
|
port: string;
|
||||||
targetPort: string;
|
targetPort: string;
|
||||||
|
nodePort: string;
|
||||||
protocol: string;
|
protocol: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -28,6 +31,7 @@ const emptyComponentPort = (): ComponentPortForm => ({
|
|||||||
name: '',
|
name: '',
|
||||||
port: '',
|
port: '',
|
||||||
targetPort: '',
|
targetPort: '',
|
||||||
|
nodePort: '',
|
||||||
protocol: 'TCP'
|
protocol: 'TCP'
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -72,18 +76,40 @@ function getTone(status: string) {
|
|||||||
return 'neutral';
|
return 'neutral';
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatStatus(status?: string | null) {
|
function formatStatus(status?: string | null, launcherLinked?: boolean) {
|
||||||
|
if (status === 'DISABLED' || status === 'INACTIVE') return '비활성';
|
||||||
|
if (launcherLinked) return '활성';
|
||||||
const statusMap: Record<string, string> = {
|
const statusMap: Record<string, string> = {
|
||||||
ACTIVE: '준비됨',
|
ACTIVE: '활성',
|
||||||
READY: '준비됨',
|
READY: '준비됨',
|
||||||
INACTIVE: '비활성',
|
|
||||||
DISABLED: '비활성',
|
|
||||||
ERROR: '오류'
|
ERROR: '오류'
|
||||||
};
|
};
|
||||||
|
|
||||||
return status ? statusMap[status] ?? status : '-';
|
return status ? statusMap[status] ?? status : '-';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function AppAdminAvatar({ admin }: { admin: AdminAccount }) {
|
||||||
|
const [failed, setFailed] = useState(false);
|
||||||
|
const initial = (admin.name || admin.email || '?').trim().charAt(0).toUpperCase() || '?';
|
||||||
|
|
||||||
|
if (admin.avatarUrl && !failed) {
|
||||||
|
return (
|
||||||
|
<img
|
||||||
|
src={admin.avatarUrl}
|
||||||
|
alt=""
|
||||||
|
className="size-8 shrink-0 rounded-full border border-border bg-muted object-cover"
|
||||||
|
onError={() => setFailed(true)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className="inline-flex size-8 shrink-0 items-center justify-center rounded-full border border-border bg-muted text-xs font-semibold text-muted-foreground">
|
||||||
|
{initial}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function loadApps() {
|
function loadApps() {
|
||||||
return apiFetch(`${apiBaseUrl}/apps`, { headers: authHeaders() })
|
return apiFetch(`${apiBaseUrl}/apps`, { headers: authHeaders() })
|
||||||
.then((response) => {
|
.then((response) => {
|
||||||
@@ -237,13 +263,6 @@ function runtimeStatusTone(resource: RuntimeResource, stopped: boolean): 'neutra
|
|||||||
return 'blue';
|
return 'blue';
|
||||||
}
|
}
|
||||||
|
|
||||||
function statusDot(status?: string) {
|
|
||||||
if (['Running', 'Bound', 'ClusterIP', 'LoadBalancer'].includes(status ?? '')) return 'bg-emerald-400';
|
|
||||||
if (['Pending', 'ContainerCreating'].includes(status ?? '')) return 'bg-amber-400';
|
|
||||||
if (['Error', 'CrashLoopBackOff', 'ImagePullBackOff', 'Failed'].includes(status ?? '')) return 'bg-rose-400';
|
|
||||||
return 'bg-muted-foreground';
|
|
||||||
}
|
|
||||||
|
|
||||||
function isComponentRunning(component: ApiAppComponent, resources: RuntimeResource[]) {
|
function isComponentRunning(component: ApiAppComponent, resources: RuntimeResource[]) {
|
||||||
const name = component.name.toLowerCase();
|
const name = component.name.toLowerCase();
|
||||||
return resources.some((resource) => {
|
return resources.some((resource) => {
|
||||||
@@ -258,6 +277,7 @@ function componentPortForms(component: ApiAppComponent): ComponentPortForm[] {
|
|||||||
name: port.name ?? '',
|
name: port.name ?? '',
|
||||||
port: port.port ? String(port.port) : '',
|
port: port.port ? String(port.port) : '',
|
||||||
targetPort: port.targetPort ? String(port.targetPort) : '',
|
targetPort: port.targetPort ? String(port.targetPort) : '',
|
||||||
|
nodePort: port.nodePort ? String(port.nodePort) : '',
|
||||||
protocol: port.protocol ?? 'TCP'
|
protocol: port.protocol ?? 'TCP'
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
@@ -267,6 +287,7 @@ function componentPortForms(component: ApiAppComponent): ComponentPortForm[] {
|
|||||||
name: 'http',
|
name: 'http',
|
||||||
port: String(component.port),
|
port: String(component.port),
|
||||||
targetPort: String(component.targetPort ?? component.port),
|
targetPort: String(component.targetPort ?? component.port),
|
||||||
|
nodePort: '',
|
||||||
protocol: 'TCP'
|
protocol: 'TCP'
|
||||||
}];
|
}];
|
||||||
}
|
}
|
||||||
@@ -277,7 +298,20 @@ function componentPortForms(component: ApiAppComponent): ComponentPortForm[] {
|
|||||||
function formatComponentPorts(component: ApiAppComponent) {
|
function formatComponentPorts(component: ApiAppComponent) {
|
||||||
const ports = componentPortForms(component).filter((port) => port.port);
|
const ports = componentPortForms(component).filter((port) => port.port);
|
||||||
if (ports.length === 0) return '-';
|
if (ports.length === 0) return '-';
|
||||||
return ports.map((port) => `${port.name || 'port'} ${port.port}:${port.targetPort || port.port}`).join(', ');
|
return ports.map((port) => {
|
||||||
|
const nodePort = port.nodePort ? ` / node ${port.nodePort}` : '';
|
||||||
|
return `${port.name || 'port'} ${port.port}:${port.targetPort || port.port}${nodePort}`;
|
||||||
|
}).join(', ');
|
||||||
|
}
|
||||||
|
|
||||||
|
function appKeyFromName(name: string) {
|
||||||
|
return name
|
||||||
|
.toLowerCase()
|
||||||
|
.trim()
|
||||||
|
.replace(/\s+/g, '-')
|
||||||
|
.replace(/[^a-z0-9-]/g, '')
|
||||||
|
.replace(/-+/g, '-')
|
||||||
|
.replace(/^-|-$/g, '');
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AppManagementPage() {
|
export function AppManagementPage() {
|
||||||
@@ -323,6 +357,13 @@ export function AppManagementPage() {
|
|||||||
deleteKubernetesResources: false,
|
deleteKubernetesResources: false,
|
||||||
deletePersistentVolumes: false
|
deletePersistentVolumes: false
|
||||||
});
|
});
|
||||||
|
const [isAppAdminsOpen, setIsAppAdminsOpen] = useState(false);
|
||||||
|
const [appAdmins, setAppAdmins] = useState<AdminAccount[]>([]);
|
||||||
|
const [appAdminPermissions, setAppAdminPermissions] = useState<Record<string, string[]>>({});
|
||||||
|
const [appAdminsLoading, setAppAdminsLoading] = useState(false);
|
||||||
|
const [appAdminSavingId, setAppAdminSavingId] = useState('');
|
||||||
|
const [appAdminSearch, setAppAdminSearch] = useState('');
|
||||||
|
const [appAdminConfirm, setAppAdminConfirm] = useState<{ admin: AdminAccount; allowed: boolean } | null>(null);
|
||||||
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('');
|
||||||
@@ -340,6 +381,20 @@ export function AppManagementPage() {
|
|||||||
const workloads = runtimeResources.filter(isWorkloadResource);
|
const workloads = runtimeResources.filter(isWorkloadResource);
|
||||||
const selectedVolumeClaim = volumeClaims.find((claim) => claim.name === selectedVolumeClaimName);
|
const selectedVolumeClaim = volumeClaims.find((claim) => claim.name === selectedVolumeClaimName);
|
||||||
const volumeDeleteConfirmPhrase = selectedVolumeClaimName ? `${selectedVolumeClaimName} 삭제` : '';
|
const volumeDeleteConfirmPhrase = selectedVolumeClaimName ? `${selectedVolumeClaimName} 삭제` : '';
|
||||||
|
const selectedAppAuthorizedAdmins = selectedApp
|
||||||
|
? appAdmins.filter((admin) => isOwnerAdmin(admin) || (appAdminPermissions[admin.id] ?? admin.appPermissions ?? []).includes(selectedApp.id))
|
||||||
|
: [];
|
||||||
|
const appAdminAddOptions = selectedApp
|
||||||
|
? appAdmins.filter((admin) => (
|
||||||
|
admin.isActive
|
||||||
|
&& !isOwnerAdmin(admin)
|
||||||
|
&& !(appAdminPermissions[admin.id] ?? admin.appPermissions ?? []).includes(selectedApp.id)
|
||||||
|
))
|
||||||
|
: [];
|
||||||
|
const appAdminSearchKeyword = appAdminSearch.trim().toLowerCase();
|
||||||
|
const appAdminSearchResults = appAdminSearchKeyword.length >= 2
|
||||||
|
? appAdminAddOptions.filter((admin) => `${admin.name} ${admin.email} ${admin.role}`.toLowerCase().includes(appAdminSearchKeyword)).slice(0, 8)
|
||||||
|
: [];
|
||||||
const canDeleteVolume = Boolean(
|
const canDeleteVolume = Boolean(
|
||||||
deletingVolumeWorkload &&
|
deletingVolumeWorkload &&
|
||||||
selectedVolumeClaimName &&
|
selectedVolumeClaimName &&
|
||||||
@@ -514,6 +569,13 @@ export function AppManagementPage() {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const changeNewAppName = (name: string) => {
|
||||||
|
const appKey = appKeyFromName(name);
|
||||||
|
setNewAppName(name);
|
||||||
|
setNewAppKey(appKey);
|
||||||
|
setNewAppNamespace(appKey ? `stdo-${appKey}` : '');
|
||||||
|
};
|
||||||
|
|
||||||
const createApp = () => {
|
const createApp = () => {
|
||||||
if (!newAppKey.trim() || !newAppName.trim()) return;
|
if (!newAppKey.trim() || !newAppName.trim()) return;
|
||||||
|
|
||||||
@@ -609,6 +671,7 @@ export function AppManagementPage() {
|
|||||||
name: item.name.trim() || undefined,
|
name: item.name.trim() || undefined,
|
||||||
port: item.port ? Number(item.port) : undefined,
|
port: item.port ? Number(item.port) : undefined,
|
||||||
targetPort: item.targetPort ? Number(item.targetPort) : item.port ? Number(item.port) : undefined,
|
targetPort: item.targetPort ? Number(item.targetPort) : item.port ? Number(item.port) : undefined,
|
||||||
|
nodePort: componentServiceType === 'NodePort' && item.nodePort ? Number(item.nodePort) : undefined,
|
||||||
protocol: item.protocol || 'TCP'
|
protocol: item.protocol || 'TCP'
|
||||||
}))
|
}))
|
||||||
.filter((item) => item.port);
|
.filter((item) => item.port);
|
||||||
@@ -865,6 +928,57 @@ export function AppManagementPage() {
|
|||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const openAppAdmins = () => {
|
||||||
|
if (!selectedApp) return;
|
||||||
|
setIsAppAdminsOpen(true);
|
||||||
|
setAppAdminsLoading(true);
|
||||||
|
setAppAdminSearch('');
|
||||||
|
loadAdmins()
|
||||||
|
.then((admins) => {
|
||||||
|
setAppAdmins(admins);
|
||||||
|
setAppAdminPermissions(Object.fromEntries(admins.map((admin) => [admin.id, admin.appPermissions ?? []])));
|
||||||
|
})
|
||||||
|
.catch((error: Error) => {
|
||||||
|
setAppAdmins([]);
|
||||||
|
setAppAdminPermissions({});
|
||||||
|
toast({ type: 'error', title: '관리자 조회 실패', message: error.message });
|
||||||
|
})
|
||||||
|
.finally(() => setAppAdminsLoading(false));
|
||||||
|
};
|
||||||
|
|
||||||
|
const requestAppAdminAccess = (admin: AdminAccount, allowed: boolean) => {
|
||||||
|
if (isOwnerAdmin(admin) || appAdminSavingId) return;
|
||||||
|
setAppAdminConfirm({ admin, allowed });
|
||||||
|
};
|
||||||
|
|
||||||
|
const setAppAdminAccess = (admin: AdminAccount, allowed: boolean) => {
|
||||||
|
if (!selectedApp || isOwnerAdmin(admin) || appAdminSavingId) return;
|
||||||
|
const currentAppIds = appAdminPermissions[admin.id] ?? admin.appPermissions ?? [];
|
||||||
|
const nextAppIds = allowed
|
||||||
|
? [...new Set([...currentAppIds, selectedApp.id])]
|
||||||
|
: currentAppIds.filter((appId) => appId !== selectedApp.id);
|
||||||
|
|
||||||
|
setAppAdminSavingId(admin.id);
|
||||||
|
updateAdminAppPermissions(admin.id, nextAppIds)
|
||||||
|
.then((result) => {
|
||||||
|
const savedAppIds = result.appIds ?? nextAppIds;
|
||||||
|
setAppAdminPermissions((current) => ({ ...current, [admin.id]: savedAppIds }));
|
||||||
|
setAppAdmins((current) => current.map((item) => item.id === admin.id ? { ...item, appPermissions: savedAppIds } : item));
|
||||||
|
setAdminAppPermissions(admin, savedAppIds);
|
||||||
|
setAppAdminSearch('');
|
||||||
|
setAppAdminConfirm(null);
|
||||||
|
toast({
|
||||||
|
type: 'success',
|
||||||
|
title: allowed ? '관리자 추가' : '관리자 해제',
|
||||||
|
message: `${admin.name} 관리자 권한이 저장되었습니다.`
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch((error: Error) => {
|
||||||
|
toast({ type: 'error', title: '앱 관리자 저장 실패', message: error.message });
|
||||||
|
})
|
||||||
|
.finally(() => setAppAdminSavingId(''));
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageTitle
|
<PageTitle
|
||||||
@@ -872,9 +986,14 @@ export function AppManagementPage() {
|
|||||||
title="앱 관리"
|
title="앱 관리"
|
||||||
description="런처에 등록할 앱을 제품 단위로 관리하고, 앱을 구성하는 실행 요소와 운영 리소스를 하나의 묶음으로 운영합니다."
|
description="런처에 등록할 앱을 제품 단위로 관리하고, 앱을 구성하는 실행 요소와 운영 리소스를 하나의 묶음으로 운영합니다."
|
||||||
actions={
|
actions={
|
||||||
<Button className="h-8 px-3 text-[13px]" onClick={() => setIsCreateAppOpen(true)}>
|
<div className="flex items-center gap-2">
|
||||||
<Plus className="size-4" />앱 등록
|
<Button variant="outline" className="h-8 px-3 text-[13px]" disabled={!selectedApp} onClick={openAppAdmins}>
|
||||||
</Button>
|
<UserCog className="size-4" />관리자
|
||||||
|
</Button>
|
||||||
|
<Button className="h-8 px-3 text-[13px]" onClick={() => setIsCreateAppOpen(true)}>
|
||||||
|
<Plus className="size-4" />앱 등록
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -912,7 +1031,9 @@ export function AppManagementPage() {
|
|||||||
<td className="px-5 py-3">{app.name}</td>
|
<td className="px-5 py-3">{app.name}</td>
|
||||||
<td className="px-5 py-3 text-muted-foreground">{app.namespace || '-'}</td>
|
<td className="px-5 py-3 text-muted-foreground">{app.namespace || '-'}</td>
|
||||||
<td className="px-5 py-3">{app.components?.length ?? 0}</td>
|
<td className="px-5 py-3">{app.components?.length ?? 0}</td>
|
||||||
<td className="px-5 py-3"><Badge tone={getTone(app.status)}>{formatStatus(app.status)}</Badge></td>
|
<td className="px-5 py-3">
|
||||||
|
<Badge tone={getTone(formatStatus(app.status, app.launcherLinked))}>{formatStatus(app.status, app.launcherLinked)}</Badge>
|
||||||
|
</td>
|
||||||
<td className="px-5 py-3">
|
<td className="px-5 py-3">
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
<Button
|
<Button
|
||||||
@@ -958,7 +1079,7 @@ export function AppManagementPage() {
|
|||||||
['네임스페이스', selectedApp.namespace],
|
['네임스페이스', selectedApp.namespace],
|
||||||
['연결 소스', `${linkedComponents}개`],
|
['연결 소스', `${linkedComponents}개`],
|
||||||
['구성요소', `${totalComponents}개`],
|
['구성요소', `${totalComponents}개`],
|
||||||
['상태', formatStatus(selectedApp.status)]
|
['상태', formatStatus(selectedApp.status, selectedApp.launcherLinked)]
|
||||||
].map(([label, value]) => (
|
].map(([label, value]) => (
|
||||||
<div key={label} className="rounded-md border border-border bg-muted/20 p-4">
|
<div key={label} className="rounded-md border border-border bg-muted/20 p-4">
|
||||||
<p className="text-xs text-muted-foreground">{label}</p>
|
<p className="text-xs text-muted-foreground">{label}</p>
|
||||||
@@ -985,7 +1106,7 @@ export function AppManagementPage() {
|
|||||||
title="앱 구성요소"
|
title="앱 구성요소"
|
||||||
description="앱을 구성하는 실행 요소와 관련 리소스를 관리합니다."
|
description="앱을 구성하는 실행 요소와 관련 리소스를 관리합니다."
|
||||||
action={
|
action={
|
||||||
<Button variant="outline" className="h-8 px-3 text-[13px]" disabled={!selectedApp} onClick={() => setIsComponentOpen(true)}>
|
<Button className="h-8 px-3 text-[13px]" disabled={!selectedApp} onClick={() => setIsComponentOpen(true)}>
|
||||||
<Plus className="size-4" />
|
<Plus className="size-4" />
|
||||||
추가
|
추가
|
||||||
</Button>
|
</Button>
|
||||||
@@ -1012,9 +1133,8 @@ export function AppManagementPage() {
|
|||||||
<tr key={component.name} className="hover:bg-muted/20">
|
<tr key={component.name} className="hover:bg-muted/20">
|
||||||
<td className="px-5 py-3 font-medium">
|
<td className="px-5 py-3 font-medium">
|
||||||
<span className="inline-flex items-center gap-2">
|
<span className="inline-flex items-center gap-2">
|
||||||
<span className={cn('size-2 rounded-full', running ? 'bg-emerald-400' : component.repository ? 'bg-blue-400' : 'bg-muted-foreground')} />
|
<span className={cn('size-2.5 shrink-0 rounded-full', running ? statusDotToneClass('green') : component.repository ? statusDotToneClass('blue') : statusDotToneClass('neutral'))} />
|
||||||
{component.name}
|
{component.name}
|
||||||
{running ? <Badge tone="green">실행 중</Badge> : null}
|
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-5 py-3 text-muted-foreground">{imageSourceLabel}</td>
|
<td className="px-5 py-3 text-muted-foreground">{imageSourceLabel}</td>
|
||||||
@@ -1095,9 +1215,8 @@ export function AppManagementPage() {
|
|||||||
<tr key={actionBase} className="hover:bg-muted/20">
|
<tr key={actionBase} className="hover:bg-muted/20">
|
||||||
<td className="px-5 py-3">
|
<td className="px-5 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 ${stopped ? 'bg-muted-foreground' : statusDot(resource.status)}`} />
|
<span className={cn('block size-2.5 shrink-0 rounded-full', stopped ? statusDotToneClass('neutral') : runtimeStatusDotClass(resource.status))} />
|
||||||
<span className="text-sm text-foreground">{resource.name}</span>
|
<span className="text-sm text-foreground">{resource.name}</span>
|
||||||
<Badge tone={runtimeStatusTone(resource, stopped)}>{runtimeStatusLabel(resource, stopped)}</Badge>
|
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-5 py-3 text-foreground">{resource.kind}</td>
|
<td className="px-5 py-3 text-foreground">{resource.kind}</td>
|
||||||
@@ -1172,24 +1291,159 @@ export function AppManagementPage() {
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{isAppAdminsOpen ? (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-background/55 px-4 backdrop-blur-[2px]">
|
||||||
|
<div className="flex max-h-[86vh] w-full max-w-2xl flex-col rounded-lg border border-border bg-card shadow-xl">
|
||||||
|
<div className="border-b border-border px-5 py-3">
|
||||||
|
<h2 className="text-sm font-semibold">관리자</h2>
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
|
{selectedApp?.name ?? '-'} · {selectedApp?.key ?? '-'} 앱에 접근할 수 있는 관리자입니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="min-h-0 space-y-4 overflow-y-auto px-5 py-5">
|
||||||
|
<div className="rounded-md border border-border p-3">
|
||||||
|
<p className="text-xs font-medium text-muted-foreground">관리자 추가</p>
|
||||||
|
<input
|
||||||
|
className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none placeholder:text-muted-foreground focus:ring-2 focus:ring-ring"
|
||||||
|
value={appAdminSearch}
|
||||||
|
onChange={(event) => setAppAdminSearch(event.target.value)}
|
||||||
|
placeholder="이름 또는 이메일 입력"
|
||||||
|
disabled={appAdminsLoading || appAdminAddOptions.length === 0 || Boolean(appAdminSavingId)}
|
||||||
|
/>
|
||||||
|
{appAdminSearchKeyword.length >= 2 ? (
|
||||||
|
<div className="mt-2 overflow-hidden rounded-md border border-border">
|
||||||
|
{appAdminSearchResults.length === 0 ? (
|
||||||
|
<div className="px-3 py-4 text-center text-sm text-muted-foreground">검색 결과가 없습니다.</div>
|
||||||
|
) : (
|
||||||
|
appAdminSearchResults.map((admin) => (
|
||||||
|
<div
|
||||||
|
key={admin.id}
|
||||||
|
className="flex w-full items-center justify-between gap-3 border-b border-border px-3 py-2 text-left last:border-b-0 hover:bg-muted/20"
|
||||||
|
>
|
||||||
|
<span className="flex min-w-0 items-center gap-3">
|
||||||
|
<AppAdminAvatar admin={admin} />
|
||||||
|
<span className="min-w-0">
|
||||||
|
<span className="block truncate text-sm font-semibold text-foreground">{admin.name}</span>
|
||||||
|
<span className="mt-0.5 block truncate text-xs text-muted-foreground">{admin.email}</span>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<div className="flex shrink-0 items-center gap-2">
|
||||||
|
<Badge tone="blue">{admin.role}</Badge>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="h-7 px-2 text-xs"
|
||||||
|
disabled={Boolean(appAdminSavingId)}
|
||||||
|
onClick={() => requestAppAdminAccess(admin, true)}
|
||||||
|
>
|
||||||
|
선택
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="overflow-hidden rounded-md border border-border">
|
||||||
|
{appAdminsLoading ? (
|
||||||
|
<div className="px-4 py-10 text-center text-sm text-muted-foreground">관리자 목록을 불러오는 중입니다.</div>
|
||||||
|
) : selectedAppAuthorizedAdmins.length === 0 ? (
|
||||||
|
<div className="px-4 py-10 text-center text-sm text-muted-foreground">권한이 있는 관리자가 없습니다.</div>
|
||||||
|
) : (
|
||||||
|
selectedAppAuthorizedAdmins.map((admin) => {
|
||||||
|
const owner = isOwnerAdmin(admin);
|
||||||
|
const saving = appAdminSavingId === admin.id;
|
||||||
|
return (
|
||||||
|
<div key={admin.id} className="flex items-center justify-between gap-4 border-b border-border px-4 py-3 last:border-b-0 hover:bg-muted/20">
|
||||||
|
<div className="flex min-w-0 items-center gap-3">
|
||||||
|
<AppAdminAvatar admin={admin} />
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<p className="truncate text-sm font-semibold text-foreground">{admin.name}</p>
|
||||||
|
<Badge tone={owner ? 'green' : 'blue'}>{owner ? '오너' : '관리자'}</Badge>
|
||||||
|
{!admin.isActive ? <Badge tone="red">비활성</Badge> : null}
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 truncate text-xs text-muted-foreground">{admin.email}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{owner ? (
|
||||||
|
<span className="shrink-0 text-xs text-muted-foreground">전체 앱 접근</span>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="h-8 shrink-0 border-rose-500/50 px-3 text-[13px] text-rose-300 hover:border-rose-400 hover:bg-rose-500/10 hover:text-rose-200"
|
||||||
|
disabled={saving || Boolean(appAdminSavingId)}
|
||||||
|
onClick={() => requestAppAdminAccess(admin, false)}
|
||||||
|
>
|
||||||
|
{saving ? '저장 중' : '해제'}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end gap-2 border-t border-border px-5 py-3">
|
||||||
|
<Button variant="outline" onClick={() => setIsAppAdminsOpen(false)} disabled={Boolean(appAdminSavingId)}>
|
||||||
|
닫기
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
open={Boolean(appAdminConfirm)}
|
||||||
|
title={appAdminConfirm?.allowed ? '관리자 권한 추가' : '관리자 권한 해제'}
|
||||||
|
onClose={() => {
|
||||||
|
if (!appAdminSavingId) setAppAdminConfirm(null);
|
||||||
|
}}
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button variant="outline" disabled={Boolean(appAdminSavingId)} onClick={() => setAppAdminConfirm(null)}>취소</Button>
|
||||||
|
<Button
|
||||||
|
className={appAdminConfirm?.allowed ? undefined : 'border border-rose-500/40 bg-rose-500/15 text-rose-200 hover:bg-rose-500/25'}
|
||||||
|
disabled={!appAdminConfirm || Boolean(appAdminSavingId)}
|
||||||
|
onClick={() => appAdminConfirm && setAppAdminAccess(appAdminConfirm.admin, appAdminConfirm.allowed)}
|
||||||
|
>
|
||||||
|
{appAdminSavingId ? '저장 중' : appAdminConfirm?.allowed ? '추가' : '해제'}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="rounded-md border border-border bg-muted/20 px-4 py-3">
|
||||||
|
<p className="text-sm font-semibold text-foreground">{appAdminConfirm?.admin.name ?? '-'}</p>
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">{appAdminConfirm?.admin.email ?? '-'}</p>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm leading-6 text-muted-foreground">
|
||||||
|
{appAdminConfirm?.allowed
|
||||||
|
? `${selectedApp?.name ?? '-'} 앱 접근 권한을 추가합니다.`
|
||||||
|
: `${selectedApp?.name ?? '-'} 앱 접근 권한을 해제합니다.`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
{isCreateAppOpen ? (
|
{isCreateAppOpen ? (
|
||||||
<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-lg rounded-lg border border-border bg-card shadow-xl">
|
<div className="w-full max-w-lg rounded-lg border border-border bg-card shadow-xl">
|
||||||
<div className="border-b border-border px-5 py-3">
|
<div className="border-b border-border px-5 py-3">
|
||||||
<h2 className="text-sm font-semibold">앱 그룹 등록</h2>
|
<h2 className="text-sm font-semibold">앱 등록</h2>
|
||||||
</div>
|
</div>
|
||||||
<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">앱 Key</span>
|
<span className="text-xs font-medium text-muted-foreground">앱 이름 <b className="text-foreground">*</b></span>
|
||||||
<input value={newAppKey} onChange={(event) => setNewAppKey(event.target.value)} className="mt-2 h-9 w-full rounded-md border border-border bg-background px-3 text-sm outline-none focus:border-primary" />
|
<input value={newAppName} onChange={(event) => changeNewAppName(event.target.value)} className="mt-2 h-9 w-full rounded-md border border-border bg-background px-3 text-sm outline-none focus:border-primary" />
|
||||||
</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">앱 Key</span>
|
||||||
<input value={newAppName} onChange={(event) => setNewAppName(event.target.value)} className="mt-2 h-9 w-full rounded-md border border-border bg-background px-3 text-sm outline-none focus:border-primary" />
|
<input value={newAppKey} readOnly className="mt-2 h-9 w-full rounded-md border border-border bg-muted/40 px-3 font-mono text-sm text-muted-foreground outline-none" />
|
||||||
</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>
|
||||||
<input value={newAppNamespace} onChange={(event) => setNewAppNamespace(event.target.value)} className="mt-2 h-9 w-full rounded-md border border-border bg-background px-3 text-sm outline-none focus:border-primary" />
|
<input value={newAppNamespace} readOnly className="mt-2 h-9 w-full rounded-md border border-border bg-muted/40 px-3 font-mono text-sm text-muted-foreground outline-none" />
|
||||||
</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>
|
||||||
@@ -1221,11 +1475,11 @@ export function AppManagementPage() {
|
|||||||
/>
|
/>
|
||||||
</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">구성요소명 <b className="text-foreground">*</b></span>
|
||||||
<input value={componentName} onChange={(event) => setComponentName(event.target.value)} className="mt-2 h-9 w-full rounded-md border border-border bg-background px-3 text-sm outline-none focus:border-primary" />
|
<input value={componentName} onChange={(event) => setComponentName(event.target.value)} className="mt-2 h-9 w-full rounded-md border border-border bg-background px-3 text-sm outline-none focus:border-primary" />
|
||||||
</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">유형 <b className="text-foreground">*</b></span>
|
||||||
<select
|
<select
|
||||||
value={componentType}
|
value={componentType}
|
||||||
onChange={(event) => {
|
onChange={(event) => {
|
||||||
@@ -1252,7 +1506,7 @@ export function AppManagementPage() {
|
|||||||
</label>
|
</label>
|
||||||
<div className="grid gap-3 sm:grid-cols-2">
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
<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">이미지 소스 <b className="text-foreground">*</b></span>
|
||||||
<select
|
<select
|
||||||
value={componentImageSource}
|
value={componentImageSource}
|
||||||
onChange={(event) => {
|
onChange={(event) => {
|
||||||
@@ -1276,7 +1530,7 @@ export function AppManagementPage() {
|
|||||||
</select>
|
</select>
|
||||||
</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">워크로드 <b className="text-foreground">*</b></span>
|
||||||
<select
|
<select
|
||||||
value={componentWorkloadType}
|
value={componentWorkloadType}
|
||||||
onChange={(event) => {
|
onChange={(event) => {
|
||||||
@@ -1317,7 +1571,7 @@ export function AppManagementPage() {
|
|||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<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">이미지명 <b className="text-foreground">*</b></span>
|
||||||
<input
|
<input
|
||||||
value={componentExternalImage}
|
value={componentExternalImage}
|
||||||
onChange={(event) => setComponentExternalImage(event.target.value)}
|
onChange={(event) => setComponentExternalImage(event.target.value)}
|
||||||
@@ -1349,7 +1603,15 @@ export function AppManagementPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="mt-3 space-y-2">
|
<div className="mt-3 space-y-2">
|
||||||
{componentPorts.map((item, index) => (
|
{componentPorts.map((item, index) => (
|
||||||
<div key={index} className="grid min-w-0 gap-2 sm:grid-cols-[minmax(0,1fr)_minmax(0,0.85fr)_minmax(0,0.85fr)_minmax(0,0.7fr)_2.5rem]">
|
<div
|
||||||
|
key={index}
|
||||||
|
className={cn(
|
||||||
|
'grid min-w-0 gap-2',
|
||||||
|
componentServiceType === 'NodePort'
|
||||||
|
? 'sm:grid-cols-[minmax(0,1fr)_minmax(0,0.8fr)_minmax(0,0.8fr)_minmax(0,0.8fr)_minmax(0,0.65fr)_2.5rem]'
|
||||||
|
: 'sm:grid-cols-[minmax(0,1fr)_minmax(0,0.85fr)_minmax(0,0.85fr)_minmax(0,0.7fr)_2.5rem]'
|
||||||
|
)}
|
||||||
|
>
|
||||||
<input
|
<input
|
||||||
value={item.name}
|
value={item.name}
|
||||||
onChange={(event) => setComponentPorts((prev) => prev.map((port, portIndex) => portIndex === index ? { ...port, name: event.target.value } : port))}
|
onChange={(event) => setComponentPorts((prev) => prev.map((port, portIndex) => portIndex === index ? { ...port, name: event.target.value } : port))}
|
||||||
@@ -1372,6 +1634,17 @@ export function AppManagementPage() {
|
|||||||
className="h-9 min-w-0 rounded-md border border-border bg-background px-3 text-sm outline-none focus:border-primary"
|
className="h-9 min-w-0 rounded-md border border-border bg-background px-3 text-sm outline-none focus:border-primary"
|
||||||
placeholder="컨테이너 포트"
|
placeholder="컨테이너 포트"
|
||||||
/>
|
/>
|
||||||
|
{componentServiceType === 'NodePort' ? (
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="30000"
|
||||||
|
max="32767"
|
||||||
|
value={item.nodePort}
|
||||||
|
onChange={(event) => setComponentPorts((prev) => prev.map((port, portIndex) => portIndex === index ? { ...port, nodePort: event.target.value } : port))}
|
||||||
|
className="h-9 min-w-0 rounded-md border border-border bg-background px-3 text-sm outline-none focus:border-primary"
|
||||||
|
placeholder="노드 포트"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
<select
|
<select
|
||||||
value={item.protocol}
|
value={item.protocol}
|
||||||
onChange={(event) => setComponentPorts((prev) => prev.map((port, portIndex) => portIndex === index ? { ...port, protocol: event.target.value } : port))}
|
onChange={(event) => setComponentPorts((prev) => prev.map((port, portIndex) => portIndex === index ? { ...port, protocol: event.target.value } : port))}
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
import { useEffect, useMemo, useState, type MouseEvent } from 'react';
|
import { useEffect, useMemo, useState, type MouseEvent } from 'react';
|
||||||
import { ChevronLeft, ChevronRight, MoreHorizontal, Play, RotateCcw, Square, Trash2 } from 'lucide-react';
|
import { ChevronLeft, ChevronRight, MoreHorizontal, Play, RotateCcw, Square, Trash2, Upload } from 'lucide-react';
|
||||||
import { Button, Card, CardHeader, Modal } from '../../components/ui';
|
import { Button, Card, CardHeader, Modal } from '../../components/ui';
|
||||||
|
import { Requester } from '../../components/requester';
|
||||||
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, listBuildJobsPage, listSources } from './application-api';
|
import { cancelBuildJob, createBuildJob, createQuickDeployment, deleteBuildJob, listBuildJobsPage, listSources } from './application-api';
|
||||||
|
import { getStoredSelectedComponentId, selectedComponentChangedEventName, setStoredSelectedComponentId } from '../../lib/selected-app';
|
||||||
|
|
||||||
function blockPageAgentDestructiveClick(event: MouseEvent<HTMLElement>) {
|
function blockPageAgentDestructiveClick(event: MouseEvent<HTMLElement>) {
|
||||||
if (event.nativeEvent.isTrusted) return false;
|
if (event.nativeEvent.isTrusted) return false;
|
||||||
@@ -27,7 +29,7 @@ function buildTargetForType(type?: string | null) {
|
|||||||
|
|
||||||
function predictedArtifact(component: SourceComponent | undefined, branch: string, commitSha: string) {
|
function predictedArtifact(component: SourceComponent | undefined, branch: string, commitSha: string) {
|
||||||
if (!component) return '-';
|
if (!component) return '-';
|
||||||
const suffix = commitSha.trim() ? commitSha.trim().slice(0, 7) : branch;
|
const suffix = commitSha.trim() ? commitSha.trim().slice(0, 7) : '<commit-sha>';
|
||||||
if (component.type === 'ELECTRON') return `${component.name}-${suffix}.AppImage`;
|
if (component.type === 'ELECTRON') return `${component.name}-${suffix}.AppImage`;
|
||||||
if (component.type === 'DATABASE' || component.type === 'MIGRATION') return `${component.name}:${branch}:migration-check`;
|
if (component.type === 'DATABASE' || component.type === 'MIGRATION') return `${component.name}:${branch}:migration-check`;
|
||||||
return `localhost:5000/${component.name}:${suffix}`;
|
return `localhost:5000/${component.name}:${suffix}`;
|
||||||
@@ -62,6 +64,12 @@ function resultArtifact(job: BuildJob) {
|
|||||||
return job.image || job.artifact || '-';
|
return job.image || job.artifact || '-';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isQuickDeployable(job: BuildJob) {
|
||||||
|
return job.status === 'SUCCEEDED'
|
||||||
|
&& Boolean(job.image)
|
||||||
|
&& ['CONTAINER', 'STATIC_WEB'].includes(job.component.type);
|
||||||
|
}
|
||||||
|
|
||||||
function jobTime(job: BuildJob) {
|
function jobTime(job: BuildJob) {
|
||||||
const value = new Date(job.createdAt || job.startedAt || job.finishedAt || 0).getTime();
|
const value = new Date(job.createdAt || job.startedAt || job.finishedAt || 0).getTime();
|
||||||
return Number.isNaN(value) ? 0 : value;
|
return Number.isNaN(value) ? 0 : value;
|
||||||
@@ -92,7 +100,13 @@ export function BuildManagementPage() {
|
|||||||
const [branch, setBranch] = useState('develop');
|
const [branch, setBranch] = useState('develop');
|
||||||
const [commitSha, setCommitSha] = useState('');
|
const [commitSha, setCommitSha] = useState('');
|
||||||
const [selectedJob, setSelectedJob] = useState<BuildJob | null>(null);
|
const [selectedJob, setSelectedJob] = useState<BuildJob | null>(null);
|
||||||
|
const [deleteTarget, setDeleteTarget] = useState<BuildJob | null>(null);
|
||||||
|
const [quickDeployTarget, setQuickDeployTarget] = useState<BuildJob | null>(null);
|
||||||
|
const [quickDeployNamespace, setQuickDeployNamespace] = useState('');
|
||||||
|
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [deletingJob, setDeletingJob] = useState(false);
|
||||||
|
const [quickDeploying, setQuickDeploying] = useState(false);
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [range, setRange] = useState<BuildRange>('week');
|
const [range, setRange] = useState<BuildRange>('week');
|
||||||
const [totalJobs, setTotalJobs] = useState(0);
|
const [totalJobs, setTotalJobs] = useState(0);
|
||||||
@@ -143,8 +157,11 @@ export function BuildManagementPage() {
|
|||||||
.filter((source) => (source.imageSource ?? 'SOURCE') === 'SOURCE');
|
.filter((source) => (source.imageSource ?? 'SOURCE') === 'SOURCE');
|
||||||
setSelectedComponentId((prev) => {
|
setSelectedComponentId((prev) => {
|
||||||
const valid = filteredSources.some((source) => source.id === prev);
|
const valid = filteredSources.some((source) => source.id === prev);
|
||||||
const next = valid ? prev : filteredSources[0]?.id || '';
|
const storedComponentId = getStoredSelectedComponentId(selectedAppId);
|
||||||
setBranch('develop');
|
const storedValid = filteredSources.some((source) => source.id === storedComponentId);
|
||||||
|
const next = valid ? prev : storedValid ? storedComponentId : filteredSources[0]?.id || '';
|
||||||
|
if (next && next !== prev) setStoredSelectedComponentId(selectedAppId, next);
|
||||||
|
if (next !== prev) setBranch('develop');
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
@@ -186,11 +203,34 @@ export function BuildManagementPage() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!componentOptions.some((source) => source.id === selectedComponentId)) {
|
if (!componentOptions.some((source) => source.id === selectedComponentId)) {
|
||||||
const nextComponent = componentOptions[0];
|
const storedComponentId = getStoredSelectedComponentId(selectedAppId);
|
||||||
|
const nextComponent = componentOptions.find((source) => source.id === storedComponentId) ?? componentOptions[0];
|
||||||
setSelectedComponentId(nextComponent?.id || '');
|
setSelectedComponentId(nextComponent?.id || '');
|
||||||
|
if (nextComponent?.id) setStoredSelectedComponentId(selectedAppId, nextComponent.id);
|
||||||
setBranch('develop');
|
setBranch('develop');
|
||||||
}
|
}
|
||||||
}, [componentOptions, selectedComponentId]);
|
}, [componentOptions, selectedAppId, selectedComponentId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const storedComponentId = getStoredSelectedComponentId(selectedAppId);
|
||||||
|
if (!storedComponentId || storedComponentId === selectedComponentId) return;
|
||||||
|
if (!componentOptions.some((source) => source.id === storedComponentId)) return;
|
||||||
|
setSelectedComponentId(storedComponentId);
|
||||||
|
setBranch('develop');
|
||||||
|
}, [componentOptions, selectedAppId, selectedComponentId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleSelectedComponentChange = (event: Event) => {
|
||||||
|
const detail = (event as CustomEvent<{ appId?: string; componentId?: string }>).detail;
|
||||||
|
if (detail?.appId !== selectedAppId || !detail.componentId) return;
|
||||||
|
if (!componentOptions.some((source) => source.id === detail.componentId)) return;
|
||||||
|
setSelectedComponentId(detail.componentId);
|
||||||
|
setBranch('develop');
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener(selectedComponentChangedEventName, handleSelectedComponentChange);
|
||||||
|
return () => window.removeEventListener(selectedComponentChangedEventName, handleSelectedComponentChange);
|
||||||
|
}, [componentOptions, selectedAppId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setPage(1);
|
setPage(1);
|
||||||
@@ -199,6 +239,7 @@ export function BuildManagementPage() {
|
|||||||
const startBuild = async () => {
|
const startBuild = async () => {
|
||||||
if (!selectedComponent) return;
|
if (!selectedComponent) return;
|
||||||
try {
|
try {
|
||||||
|
setConfirmOpen(false);
|
||||||
const result = await createBuildJob({ componentId: selectedComponent.id, branch, commitSha });
|
const result = await createBuildJob({ componentId: selectedComponent.id, branch, commitSha });
|
||||||
toast({ type: 'success', message: '빌드 작업이 생성되었습니다.' });
|
toast({ type: 'success', message: '빌드 작업이 생성되었습니다.' });
|
||||||
setSelectedJob(result.buildJob);
|
setSelectedJob(result.buildJob);
|
||||||
@@ -229,13 +270,41 @@ export function BuildManagementPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const removeJob = async (id: string) => {
|
const removeJob = async () => {
|
||||||
|
if (!deleteTarget || deletingJob) return;
|
||||||
|
setDeletingJob(true);
|
||||||
try {
|
try {
|
||||||
await deleteBuildJob(id);
|
await deleteBuildJob(deleteTarget.id);
|
||||||
toast({ type: 'success', message: '빌드 작업이 삭제되었습니다.' });
|
toast({ type: 'success', message: '빌드 작업이 삭제되었습니다.' });
|
||||||
|
setDeleteTarget(null);
|
||||||
await refresh();
|
await refresh();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast({ type: 'error', message: error instanceof Error ? error.message : '삭제에 실패했습니다.' });
|
toast({ type: 'error', message: error instanceof Error ? error.message : '삭제에 실패했습니다.' });
|
||||||
|
} finally {
|
||||||
|
setDeletingJob(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const openQuickDeploy = (job: BuildJob) => {
|
||||||
|
setQuickDeployTarget(job);
|
||||||
|
setQuickDeployNamespace(job.component.app.namespace || '');
|
||||||
|
};
|
||||||
|
|
||||||
|
const quickDeploy = async () => {
|
||||||
|
if (!quickDeployTarget || !quickDeployNamespace.trim() || quickDeploying) return;
|
||||||
|
setQuickDeploying(true);
|
||||||
|
try {
|
||||||
|
await createQuickDeployment({
|
||||||
|
buildJobId: quickDeployTarget.id,
|
||||||
|
environment: 'prod',
|
||||||
|
namespace: quickDeployNamespace.trim()
|
||||||
|
});
|
||||||
|
toast({ type: 'success', message: '빠른 배포 작업이 실행되었습니다.' });
|
||||||
|
setQuickDeployTarget(null);
|
||||||
|
} catch (error) {
|
||||||
|
toast({ type: 'error', message: error instanceof Error ? error.message : '빠른 배포에 실패했습니다.' });
|
||||||
|
} finally {
|
||||||
|
setQuickDeploying(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -259,7 +328,9 @@ export function BuildManagementPage() {
|
|||||||
className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring"
|
className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring"
|
||||||
value={selectedComponent?.id ?? ''}
|
value={selectedComponent?.id ?? ''}
|
||||||
onChange={(event) => {
|
onChange={(event) => {
|
||||||
setSelectedComponentId(event.target.value);
|
const nextComponentId = event.target.value;
|
||||||
|
setSelectedComponentId(nextComponentId);
|
||||||
|
setStoredSelectedComponentId(selectedAppId, nextComponentId);
|
||||||
setBranch('develop');
|
setBranch('develop');
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -300,7 +371,7 @@ export function BuildManagementPage() {
|
|||||||
<span className="text-muted-foreground">예상 결과물</span>
|
<span className="text-muted-foreground">예상 결과물</span>
|
||||||
<span className="truncate font-medium">{predicted}</span>
|
<span className="truncate font-medium">{predicted}</span>
|
||||||
</div>
|
</div>
|
||||||
<Button className="mt-1 h-8 px-3 text-[13px]" onClick={startBuild} disabled={!selectedComponent || appsLoading || loading}>
|
<Button className="mt-1 h-8 px-3 text-[13px]" onClick={() => setConfirmOpen(true)} disabled={!selectedComponent || appsLoading || loading}>
|
||||||
<Play className="size-4" />
|
<Play className="size-4" />
|
||||||
빌드 실행
|
빌드 실행
|
||||||
</Button>
|
</Button>
|
||||||
@@ -345,6 +416,7 @@ export function BuildManagementPage() {
|
|||||||
<span className="font-medium">{job.component.name}</span>
|
<span className="font-medium">{job.component.name}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-3 flex flex-wrap gap-x-4 gap-y-1 text-xs">
|
<div className="mt-3 flex flex-wrap gap-x-4 gap-y-1 text-xs">
|
||||||
|
<Requester item={job} />
|
||||||
<span className="text-muted-foreground">Repository <b className="text-foreground">{job.component.repository || '-'}</b></span>
|
<span className="text-muted-foreground">Repository <b className="text-foreground">{job.component.repository || '-'}</b></span>
|
||||||
<span className="text-muted-foreground">Branch <b className="text-foreground">{job.branch}</b></span>
|
<span className="text-muted-foreground">Branch <b className="text-foreground">{job.branch}</b></span>
|
||||||
<span className="text-muted-foreground">Commit <b className="font-mono text-foreground">{job.commitSha || '-'}</b></span>
|
<span className="text-muted-foreground">Commit <b className="font-mono text-foreground">{job.commitSha || '-'}</b></span>
|
||||||
@@ -364,15 +436,20 @@ export function BuildManagementPage() {
|
|||||||
<RotateCcw className="size-4" />
|
<RotateCcw className="size-4" />
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
{isQuickDeployable(job) ? (
|
||||||
|
<Button variant="outline" className="size-8 px-0" title="빠른 배포" aria-label="빠른 배포" onClick={() => openQuickDeploy(job)}>
|
||||||
|
<Upload className="size-4" />
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
{isBuildDone(job.status) ? (
|
{isBuildDone(job.status) ? (
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="ghost"
|
||||||
className="size-8 px-0"
|
className="size-8 px-0 text-rose-300 hover:text-rose-200"
|
||||||
title="삭제"
|
title="삭제"
|
||||||
aria-label="삭제"
|
aria-label="삭제"
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
if (blockPageAgentDestructiveClick(event)) return;
|
if (blockPageAgentDestructiveClick(event)) return;
|
||||||
removeJob(job.id);
|
setDeleteTarget(job);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Trash2 className="size-4" />
|
<Trash2 className="size-4" />
|
||||||
@@ -441,6 +518,106 @@ export function BuildManagementPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Modal>
|
</Modal>
|
||||||
|
<Modal
|
||||||
|
open={Boolean(deleteTarget)}
|
||||||
|
title="빌드 작업 삭제"
|
||||||
|
onClose={() => {
|
||||||
|
if (!deletingJob) setDeleteTarget(null);
|
||||||
|
}}
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button variant="outline" disabled={deletingJob} onClick={() => setDeleteTarget(null)}>취소</Button>
|
||||||
|
<Button className="bg-rose-600 text-white hover:bg-rose-500" disabled={deletingJob} onClick={removeJob}>
|
||||||
|
{deletingJob ? '삭제 중' : '삭제'}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="space-y-3 text-sm">
|
||||||
|
<p>
|
||||||
|
<span className="font-semibold text-foreground">{deleteTarget?.component.app.name ?? '-'}</span>
|
||||||
|
{' / '}
|
||||||
|
<span className="font-semibold text-foreground">{deleteTarget?.component.name ?? '-'}</span>
|
||||||
|
{' '}빌드 작업을 삭제합니다.
|
||||||
|
</p>
|
||||||
|
<p className="text-muted-foreground">삭제된 빌드 작업 이력과 로그는 목록에서 사라집니다.</p>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
<Modal
|
||||||
|
open={Boolean(quickDeployTarget)}
|
||||||
|
title="빠른 배포"
|
||||||
|
onClose={() => {
|
||||||
|
if (!quickDeploying) setQuickDeployTarget(null);
|
||||||
|
}}
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button variant="outline" disabled={quickDeploying} onClick={() => setQuickDeployTarget(null)}>취소</Button>
|
||||||
|
<Button disabled={quickDeploying || !quickDeployNamespace.trim()} onClick={quickDeploy}>
|
||||||
|
<Upload className="size-4" />
|
||||||
|
{quickDeploying ? '배포 중' : '배포'}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="space-y-3 text-sm">
|
||||||
|
<p className="text-muted-foreground">릴리즈를 만들지 않고 선택한 빌드 이미지로 현재 워크로드를 교체합니다. 런처 앱 버전은 갱신하지 않습니다.</p>
|
||||||
|
<div className="grid gap-2 rounded-md border border-border bg-muted/20 p-4 text-xs">
|
||||||
|
<div className="flex items-center justify-between gap-4"><span className="text-muted-foreground">구성요소</span><span className="font-medium">{quickDeployTarget?.component.name ?? '-'}</span></div>
|
||||||
|
<div className="min-w-0"><p className="text-muted-foreground">이미지</p><p className="mt-1 truncate font-mono font-medium">{quickDeployTarget?.image ?? '-'}</p></div>
|
||||||
|
</div>
|
||||||
|
<label className="block">
|
||||||
|
<span className="text-xs font-medium text-muted-foreground">네임스페이스 <b className="text-foreground">*</b></span>
|
||||||
|
<input className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm outline-none" value={quickDeployNamespace} disabled={quickDeploying} onChange={(event) => setQuickDeployNamespace(event.target.value)} />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
<Modal
|
||||||
|
open={confirmOpen}
|
||||||
|
title="빌드 실행 확인"
|
||||||
|
onClose={() => {
|
||||||
|
if (!loading) setConfirmOpen(false);
|
||||||
|
}}
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button variant="outline" disabled={loading} onClick={() => setConfirmOpen(false)}>취소</Button>
|
||||||
|
<Button disabled={!selectedComponent || appsLoading || loading} onClick={startBuild}>
|
||||||
|
<Play className="size-4" />
|
||||||
|
실행
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="space-y-3 text-sm">
|
||||||
|
<div className="rounded-md border border-border bg-muted/20 px-4 py-3">
|
||||||
|
<p className="font-medium">{selectedComponent?.name ?? '-'} 구성요소 빌드를 실행합니다.</p>
|
||||||
|
<p className="mt-1 text-xs leading-5 text-muted-foreground">
|
||||||
|
실행 후 빌드 작업이 생성되고, 작업 목록에서 진행 상태와 로그를 확인할 수 있습니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2 rounded-md border border-border bg-background p-4">
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<span className="text-muted-foreground">앱</span>
|
||||||
|
<span className="truncate font-medium">{selectedApp?.name ?? '-'}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<span className="text-muted-foreground">저장소</span>
|
||||||
|
<span className="truncate font-medium">{selectedComponent?.repository || '-'}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<span className="text-muted-foreground">브랜치</span>
|
||||||
|
<span className="font-medium">{branch}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<span className="text-muted-foreground">커밋</span>
|
||||||
|
<span className="font-mono text-xs font-medium">{commitSha.trim() || '브랜치 최신'}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<span className="text-muted-foreground">빌드 대상</span>
|
||||||
|
<span className="truncate font-medium">{selectedTarget}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
<LoadingModal open={loadingVisible} />
|
<LoadingModal open={loadingVisible} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { ChevronLeft, ChevronRight, MoreHorizontal, Plus, RotateCcw } from 'lucide-react';
|
import { ChevronLeft, ChevronRight, MoreHorizontal, Plus, RotateCcw, Trash2 } from 'lucide-react';
|
||||||
import { Badge, Button, Card, CardHeader, Modal } from '../../components/ui';
|
import { Badge, Button, Card, CardHeader, Modal } from '../../components/ui';
|
||||||
|
import { Requester } from '../../components/requester';
|
||||||
import { useToast } from '../../components/toast';
|
import { useToast } from '../../components/toast';
|
||||||
import { PageTitle } from '../../layout/AppLayout';
|
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, listDeploymentsPage, listReleases } from './application-api';
|
import { createDeployment, deleteRelease, listDeploymentsPage, listReleases } from './application-api';
|
||||||
|
|
||||||
function statusLabel(status: string) {
|
function statusLabel(status: string) {
|
||||||
return {
|
return {
|
||||||
@@ -35,11 +36,13 @@ type ImageTags = {
|
|||||||
artifacts?: Record<string, string>;
|
artifacts?: Record<string, string>;
|
||||||
};
|
};
|
||||||
|
|
||||||
function releaseArtifacts(release?: AppRelease | null) {
|
function releaseArtifacts(release?: AppRelease | null, appName?: string) {
|
||||||
const imageTags = release?.imageTags as ImageTags | null | undefined;
|
const imageTags = release?.imageTags as ImageTags | null | undefined;
|
||||||
const entries = Object.entries(imageTags?.images ?? {});
|
const entries = Object.entries(imageTags?.images ?? {}).filter(([, image]) => (
|
||||||
|
!/^(postgres|mysql|mariadb|mongo)(:|@)/i.test(image.trim())
|
||||||
|
));
|
||||||
if (entries.length) return entries;
|
if (entries.length) return entries;
|
||||||
if (release?.artifact) return [[release.app.name, release.artifact]];
|
if (release?.artifact) return [[release.app?.name ?? appName ?? 'artifact', release.artifact]];
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,11 +79,18 @@ export function DeploymentManagementPage() {
|
|||||||
const [releases, setReleases] = useState<AppRelease[]>([]);
|
const [releases, setReleases] = useState<AppRelease[]>([]);
|
||||||
const [releaseId, setReleaseId] = useState('');
|
const [releaseId, setReleaseId] = useState('');
|
||||||
const [namespace, setNamespace] = useState('');
|
const [namespace, setNamespace] = useState('');
|
||||||
|
const [replicas, setReplicas] = useState('1');
|
||||||
|
const [cpuRequest, setCpuRequest] = useState('100m');
|
||||||
|
const [cpuLimit, setCpuLimit] = useState('500m');
|
||||||
|
const [memoryRequest, setMemoryRequest] = useState('128Mi');
|
||||||
|
const [memoryLimit, setMemoryLimit] = useState('256Mi');
|
||||||
const [updateLauncherAppVersion, setUpdateLauncherAppVersion] = useState(true);
|
const [updateLauncherAppVersion, setUpdateLauncherAppVersion] = useState(true);
|
||||||
const [deployModalOpen, setDeployModalOpen] = useState(false);
|
const [deployModalOpen, setDeployModalOpen] = useState(false);
|
||||||
const [selectedDeployment, setSelectedDeployment] = useState<DeploymentJob | null>(null);
|
const [selectedDeployment, setSelectedDeployment] = useState<DeploymentJob | null>(null);
|
||||||
|
const [deleteTarget, setDeleteTarget] = useState<AppRelease | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [deploying, setDeploying] = useState(false);
|
const [deploying, setDeploying] = useState(false);
|
||||||
|
const [deletingRelease, setDeletingRelease] = useState(false);
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [range, setRange] = useState<DeploymentRange>('week');
|
const [range, setRange] = useState<DeploymentRange>('week');
|
||||||
const [totalDeployments, setTotalDeployments] = useState(0);
|
const [totalDeployments, setTotalDeployments] = useState(0);
|
||||||
@@ -155,6 +165,12 @@ export function DeploymentManagementPage() {
|
|||||||
setReleaseId(release.id);
|
setReleaseId(release.id);
|
||||||
setNamespace(release.app.namespace || '');
|
setNamespace(release.app.namespace || '');
|
||||||
}
|
}
|
||||||
|
if (!release && !releaseId && !releases[0]?.app?.namespace) setNamespace('');
|
||||||
|
setReplicas('1');
|
||||||
|
setCpuRequest('100m');
|
||||||
|
setCpuLimit('500m');
|
||||||
|
setMemoryRequest('128Mi');
|
||||||
|
setMemoryLimit('256Mi');
|
||||||
setUpdateLauncherAppVersion(true);
|
setUpdateLauncherAppVersion(true);
|
||||||
setDeployModalOpen(true);
|
setDeployModalOpen(true);
|
||||||
};
|
};
|
||||||
@@ -162,8 +178,22 @@ export function DeploymentManagementPage() {
|
|||||||
const deploy = async () => {
|
const deploy = async () => {
|
||||||
if (deploying) return;
|
if (deploying) return;
|
||||||
setDeploying(true);
|
setDeploying(true);
|
||||||
|
const requestedReplicas = Number.parseInt(replicas, 10);
|
||||||
|
const resources = {
|
||||||
|
...(Number.isFinite(requestedReplicas) && requestedReplicas > 0 ? { replicas: requestedReplicas } : {}),
|
||||||
|
...(cpuRequest.trim() ? { cpuRequest: cpuRequest.trim() } : {}),
|
||||||
|
...(cpuLimit.trim() ? { cpuLimit: cpuLimit.trim() } : {}),
|
||||||
|
...(memoryRequest.trim() ? { memoryRequest: memoryRequest.trim() } : {}),
|
||||||
|
...(memoryLimit.trim() ? { memoryLimit: memoryLimit.trim() } : {})
|
||||||
|
};
|
||||||
try {
|
try {
|
||||||
const result = await createDeployment({ releaseId, environment: 'prod', namespace, updateLauncherAppVersion });
|
const result = await createDeployment({
|
||||||
|
releaseId,
|
||||||
|
environment: 'prod',
|
||||||
|
namespace,
|
||||||
|
updateLauncherAppVersion,
|
||||||
|
resources: Object.keys(resources).length ? resources : undefined
|
||||||
|
});
|
||||||
toast({ type: 'success', message: '배포 작업이 실행되었습니다.' });
|
toast({ type: 'success', message: '배포 작업이 실행되었습니다.' });
|
||||||
setSelectedDeployment(result.deploymentJob);
|
setSelectedDeployment(result.deploymentJob);
|
||||||
setDeployModalOpen(false);
|
setDeployModalOpen(false);
|
||||||
@@ -175,6 +205,21 @@ export function DeploymentManagementPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const removeRelease = async () => {
|
||||||
|
if (!deleteTarget || deletingRelease) return;
|
||||||
|
setDeletingRelease(true);
|
||||||
|
try {
|
||||||
|
await deleteRelease(deleteTarget.id);
|
||||||
|
toast({ type: 'success', message: '릴리즈를 삭제했습니다.' });
|
||||||
|
setDeleteTarget(null);
|
||||||
|
await refresh();
|
||||||
|
} catch (error) {
|
||||||
|
toast({ type: 'error', message: error instanceof Error ? error.message : '릴리즈 삭제에 실패했습니다.' });
|
||||||
|
} finally {
|
||||||
|
setDeletingRelease(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageTitle
|
<PageTitle
|
||||||
@@ -218,7 +263,7 @@ export function DeploymentManagementPage() {
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{pagedDeployments.map((deployment, index) => {
|
{pagedDeployments.map((deployment, index) => {
|
||||||
const artifacts = releaseArtifacts(deployment.release);
|
const artifacts = releaseArtifacts(deployment.release, deployment.app?.name);
|
||||||
return (
|
return (
|
||||||
<div key={deployment.id} className="rounded-md border border-border bg-background/70 p-4">
|
<div key={deployment.id} className="rounded-md border border-border bg-background/70 p-4">
|
||||||
<div className="flex items-start justify-between gap-4">
|
<div className="flex items-start justify-between gap-4">
|
||||||
@@ -227,12 +272,12 @@ export function DeploymentManagementPage() {
|
|||||||
<span className="text-xs font-medium text-muted-foreground">No. {totalDeployments - ((currentPage - 1) * deploymentPageSize + 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>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-3 flex flex-wrap gap-x-4 gap-y-1 text-xs">
|
<div className="mt-3 flex flex-wrap gap-x-4 gap-y-1 text-xs">
|
||||||
<span className="text-muted-foreground">환경 <b className="text-foreground">{environmentLabel(deployment.environment)}</b></span>
|
<span className="text-muted-foreground">환경 <b className="text-foreground">{environmentLabel(deployment.environment)}</b></span>
|
||||||
<span className="text-muted-foreground">네임스페이스 <b className="text-foreground">{deployment.namespace}</b></span>
|
<span className="text-muted-foreground">네임스페이스 <b className="text-foreground">{deployment.namespace}</b></span>
|
||||||
<span className="text-muted-foreground">요청자 <b className="text-foreground">{deployment.requestedBy || 'admin'}</b></span>
|
<Requester item={deployment} label="요청자" />
|
||||||
<span className="text-muted-foreground">시작 <b className="text-foreground">{formatDate(deployment.startedAt || deployment.createdAt)}</b></span>
|
<span className="text-muted-foreground">시작 <b className="text-foreground">{formatDate(deployment.startedAt || deployment.createdAt)}</b></span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -298,18 +343,29 @@ export function DeploymentManagementPage() {
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{deployableCards.map((release) => (
|
{deployableCards.map((release) => (
|
||||||
<button
|
<div
|
||||||
type="button"
|
|
||||||
key={release.id}
|
key={release.id}
|
||||||
className="w-full rounded-md border border-border bg-background px-3 py-3 text-left transition-colors hover:bg-muted/20"
|
className="rounded-md border border-border bg-background px-3 py-3 transition-colors hover:bg-muted/20"
|
||||||
onClick={() => openDeployModal(release)}
|
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between gap-3">
|
<div className="flex items-start justify-between gap-3">
|
||||||
<span className="text-sm font-medium">{release.app.name}</span>
|
<button type="button" className="min-w-0 flex-1 text-left" onClick={() => openDeployModal(release)}>
|
||||||
<Badge tone="green">{release.version}</Badge>
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="truncate text-sm font-medium">{release.app.name}</span>
|
||||||
|
<Badge tone="green">{release.version}</Badge>
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 truncate font-mono text-xs text-muted-foreground">{firstArtifact(release)}</p>
|
||||||
|
</button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="size-8 shrink-0 px-0 text-rose-300 hover:text-rose-200"
|
||||||
|
title="릴리즈 삭제"
|
||||||
|
aria-label={`${release.app.name} ${release.version} 릴리즈 삭제`}
|
||||||
|
onClick={() => setDeleteTarget(release)}
|
||||||
|
>
|
||||||
|
<Trash2 className="size-4" />
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-2 truncate font-mono text-xs text-muted-foreground">{firstArtifact(release)}</p>
|
</div>
|
||||||
</button>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -328,6 +384,60 @@ export function DeploymentManagementPage() {
|
|||||||
{deployableReleases.map((release) => <option key={release.id} value={release.id}>{release.app.name} {release.version}</option>)}
|
{deployableReleases.map((release) => <option key={release.id} value={release.id}>{release.app.name} {release.version}</option>)}
|
||||||
</select>
|
</select>
|
||||||
<input className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm outline-none" value={namespace} disabled={deploying} onChange={(event) => setNamespace(event.target.value)} placeholder="stdo-lms" />
|
<input className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm outline-none" value={namespace} disabled={deploying} onChange={(event) => setNamespace(event.target.value)} placeholder="stdo-lms" />
|
||||||
|
<div className="grid gap-3 md:grid-cols-2">
|
||||||
|
<label className="block">
|
||||||
|
<span className="text-xs font-medium text-muted-foreground">Replicas</span>
|
||||||
|
<input
|
||||||
|
className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm outline-none"
|
||||||
|
value={replicas}
|
||||||
|
disabled={deploying}
|
||||||
|
onChange={(event) => setReplicas(event.target.value)}
|
||||||
|
placeholder="1"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="block">
|
||||||
|
<span className="text-xs font-medium text-muted-foreground">CPU 요청</span>
|
||||||
|
<input
|
||||||
|
className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm outline-none"
|
||||||
|
value={cpuRequest}
|
||||||
|
disabled={deploying}
|
||||||
|
onChange={(event) => setCpuRequest(event.target.value)}
|
||||||
|
placeholder="예: 100m"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-3 md:grid-cols-2">
|
||||||
|
<label className="block">
|
||||||
|
<span className="text-xs font-medium text-muted-foreground">CPU 상한</span>
|
||||||
|
<input
|
||||||
|
className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm outline-none"
|
||||||
|
value={cpuLimit}
|
||||||
|
disabled={deploying}
|
||||||
|
onChange={(event) => setCpuLimit(event.target.value)}
|
||||||
|
placeholder="예: 500m"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="block">
|
||||||
|
<span className="text-xs font-medium text-muted-foreground">메모리 요청</span>
|
||||||
|
<input
|
||||||
|
className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm outline-none"
|
||||||
|
value={memoryRequest}
|
||||||
|
disabled={deploying}
|
||||||
|
onChange={(event) => setMemoryRequest(event.target.value)}
|
||||||
|
placeholder="예: 256Mi"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<label className="block">
|
||||||
|
<span className="text-xs font-medium text-muted-foreground">메모리 상한</span>
|
||||||
|
<input
|
||||||
|
className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm outline-none"
|
||||||
|
value={memoryLimit}
|
||||||
|
disabled={deploying}
|
||||||
|
onChange={(event) => setMemoryLimit(event.target.value)}
|
||||||
|
placeholder="예: 512Mi"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
<label className="flex items-center gap-2 rounded-md border border-border bg-muted/20 px-3 py-2 text-sm text-foreground">
|
<label className="flex items-center gap-2 rounded-md border border-border bg-muted/20 px-3 py-2 text-sm text-foreground">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
@@ -358,6 +468,34 @@ export function DeploymentManagementPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
open={Boolean(deleteTarget)}
|
||||||
|
title="릴리즈 삭제"
|
||||||
|
onClose={() => {
|
||||||
|
if (!deletingRelease) setDeleteTarget(null);
|
||||||
|
}}
|
||||||
|
footer={(
|
||||||
|
<>
|
||||||
|
<Button variant="outline" disabled={deletingRelease} onClick={() => setDeleteTarget(null)}>취소</Button>
|
||||||
|
<Button className="bg-rose-600 text-white hover:bg-rose-500" disabled={deletingRelease} onClick={removeRelease}>
|
||||||
|
{deletingRelease ? '삭제 중' : '삭제'}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="space-y-3 text-sm">
|
||||||
|
<p>
|
||||||
|
<span className="font-semibold text-foreground">{deleteTarget?.app.name ?? '-'}</span>
|
||||||
|
{' '}
|
||||||
|
<span className="font-semibold text-foreground">{deleteTarget?.version ?? '-'}</span>
|
||||||
|
{' '}릴리즈를 삭제합니다.
|
||||||
|
</p>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
이미 성공 배포된 릴리즈는 서버에서 삭제가 차단됩니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
<LoadingModal open={loadingVisible} />
|
<LoadingModal open={loadingVisible} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
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, CardHeader, Modal } from '../../components/ui';
|
import { Button, Card, CardHeader, Modal } from '../../components/ui';
|
||||||
|
import { Requester } from '../../components/requester';
|
||||||
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';
|
||||||
@@ -312,6 +313,7 @@ export function MigrationManagementPage() {
|
|||||||
<div className="mt-3 flex flex-wrap gap-x-4 gap-y-1 text-xs">
|
<div className="mt-3 flex flex-wrap gap-x-4 gap-y-1 text-xs">
|
||||||
<span className="text-muted-foreground">릴리즈 <b className="text-foreground">{releaseVersion}</b></span>
|
<span className="text-muted-foreground">릴리즈 <b className="text-foreground">{releaseVersion}</b></span>
|
||||||
<span className="text-muted-foreground">네임스페이스 <b className="text-foreground">{job.namespace}</b></span>
|
<span className="text-muted-foreground">네임스페이스 <b className="text-foreground">{job.namespace}</b></span>
|
||||||
|
<Requester item={job} />
|
||||||
<span className="text-muted-foreground">이미지 <b className="text-foreground">{imageName}</b></span>
|
<span className="text-muted-foreground">이미지 <b className="text-foreground">{imageName}</b></span>
|
||||||
<span className="text-muted-foreground">시작 <b className="text-foreground">{formatDate(job.startedAt || job.createdAt)}</b></span>
|
<span className="text-muted-foreground">시작 <b className="text-foreground">{formatDate(job.startedAt || job.createdAt)}</b></span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { ChevronLeft, ChevronRight, MoreHorizontal, Plus } from 'lucide-react';
|
import { ChevronLeft, ChevronRight, MoreHorizontal, Plus, Trash2 } from 'lucide-react';
|
||||||
import { Badge, Button, Card, CardHeader, Modal } from '../../components/ui';
|
import { Badge, Button, Card, CardHeader, Modal } from '../../components/ui';
|
||||||
|
import { Requester } from '../../components/requester';
|
||||||
import { useToast } from '../../components/toast';
|
import { useToast } from '../../components/toast';
|
||||||
import { PageTitle } from '../../layout/AppLayout';
|
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, listBuildJobsPage, listReleasesPage } from './application-api';
|
import { createRelease, hideReleaseCandidate, listBuildJobsPage, listReleasesPage } from './application-api';
|
||||||
|
|
||||||
function statusLabel(status: string) {
|
function statusLabel(status: string) {
|
||||||
return {
|
return {
|
||||||
@@ -29,17 +30,43 @@ function statusTone(status: string): 'neutral' | 'green' | 'amber' | 'red' | 'bl
|
|||||||
type ImageTags = {
|
type ImageTags = {
|
||||||
images?: Record<string, string>;
|
images?: Record<string, string>;
|
||||||
artifacts?: Record<string, string>;
|
artifacts?: Record<string, string>;
|
||||||
|
buildJobs?: Array<{
|
||||||
|
component?: string;
|
||||||
|
type?: string;
|
||||||
|
}>;
|
||||||
error?: string;
|
error?: string;
|
||||||
promotion?: {
|
promotion?: {
|
||||||
sourceBuildJobIds?: string[];
|
sourceBuildJobIds?: string[];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function releaseBuildComponentTypes(imageTags: ImageTags | null | undefined) {
|
||||||
|
const map = new Map<string, string>();
|
||||||
|
for (const job of imageTags?.buildJobs ?? []) {
|
||||||
|
if (job.component && job.type) map.set(job.component, job.type);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRuntimeOnlyImage(value: string) {
|
||||||
|
return /^(postgres|redis|mysql|mariadb|mongo|nginx|rabbitmq)(:|@)/i.test(value.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
function isReleaseArtifactVisible(componentName: string, value: string, componentTypes: Map<string, string>) {
|
||||||
|
const type = componentTypes.get(componentName);
|
||||||
|
return type !== 'DATABASE' && type !== 'MIGRATION' && !isRuntimeOnlyImage(value);
|
||||||
|
}
|
||||||
|
|
||||||
function releaseArtifacts(release: AppRelease) {
|
function releaseArtifacts(release: AppRelease) {
|
||||||
const imageTags = release.imageTags as ImageTags | null | undefined;
|
const imageTags = release.imageTags as ImageTags | null | undefined;
|
||||||
|
const componentTypes = releaseBuildComponentTypes(imageTags);
|
||||||
const values = [
|
const values = [
|
||||||
...Object.values(imageTags?.images ?? {}),
|
...Object.entries(imageTags?.images ?? {})
|
||||||
...Object.values(imageTags?.artifacts ?? {})
|
.filter(([componentName, value]) => isReleaseArtifactVisible(componentName, value, componentTypes))
|
||||||
|
.map(([, value]) => value),
|
||||||
|
...Object.entries(imageTags?.artifacts ?? {})
|
||||||
|
.filter(([componentName, value]) => isReleaseArtifactVisible(componentName, value, componentTypes))
|
||||||
|
.map(([, value]) => value)
|
||||||
].filter(Boolean);
|
].filter(Boolean);
|
||||||
|
|
||||||
if (values.length) return values;
|
if (values.length) return values;
|
||||||
@@ -114,9 +141,32 @@ function newestBuildVersion(builds: BuildJob[]) {
|
|||||||
.find((value): value is string => Boolean(value)) ?? '';
|
.find((value): value is string => Boolean(value)) ?? '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function incrementPatchVersion(value: string) {
|
||||||
|
const match = value.trim().match(/^(v?)(\d+)\.(\d+)\.(\d+)(.*)$/);
|
||||||
|
if (!match) return value;
|
||||||
|
const [, prefix, major, minor, patch, suffix] = match;
|
||||||
|
return `${prefix}${major}.${minor}.${Number(patch) + 1}${suffix}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function newestReleaseVersion(releases: AppRelease[], appId: string) {
|
||||||
|
return releases
|
||||||
|
.filter((release) => release.app.id === appId && release.version)
|
||||||
|
.sort((a, b) => releaseTime(b) - releaseTime(a))
|
||||||
|
.map((release) => release.version)
|
||||||
|
.find((value): value is string => Boolean(value)) ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function nextReleaseVersion(releases: AppRelease[], appId: string, builds: BuildJob[]) {
|
||||||
|
const latestReleaseVersion = newestReleaseVersion(releases, appId);
|
||||||
|
if (latestReleaseVersion) return incrementPatchVersion(latestReleaseVersion);
|
||||||
|
return newestBuildVersion(builds);
|
||||||
|
}
|
||||||
|
|
||||||
function buildCount(release: AppRelease) {
|
function buildCount(release: AppRelease) {
|
||||||
const imageTags = release.imageTags as ImageTags | null | undefined;
|
const imageTags = release.imageTags as ImageTags | null | undefined;
|
||||||
const count = Object.keys(imageTags?.images ?? {}).length + Object.keys(imageTags?.artifacts ?? {}).length;
|
const componentTypes = releaseBuildComponentTypes(imageTags);
|
||||||
|
const count = Object.entries(imageTags?.images ?? {}).filter(([componentName, value]) => isReleaseArtifactVisible(componentName, value, componentTypes)).length
|
||||||
|
+ Object.entries(imageTags?.artifacts ?? {}).filter(([componentName, value]) => isReleaseArtifactVisible(componentName, value, componentTypes)).length;
|
||||||
return count || (release.artifact ? 1 : 0);
|
return count || (release.artifact ? 1 : 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -164,13 +214,16 @@ export function ReleaseManagementPage() {
|
|||||||
const [version, setVersion] = useState('');
|
const [version, setVersion] = useState('');
|
||||||
const [releaseModalOpen, setReleaseModalOpen] = useState(false);
|
const [releaseModalOpen, setReleaseModalOpen] = useState(false);
|
||||||
const [selectedBuildIds, setSelectedBuildIds] = useState<string[]>([]);
|
const [selectedBuildIds, setSelectedBuildIds] = useState<string[]>([]);
|
||||||
const [openLogReleaseId, setOpenLogReleaseId] = useState('');
|
const [selectedReleaseLogId, setSelectedReleaseLogId] = useState('');
|
||||||
|
const [hideTargetAppId, setHideTargetAppId] = useState('');
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [hidingCandidates, setHidingCandidates] = useState(false);
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [range, setRange] = useState<ReleaseRange>('week');
|
const [range, setRange] = useState<ReleaseRange>('week');
|
||||||
const [totalReleases, setTotalReleases] = useState(0);
|
const [totalReleases, setTotalReleases] = useState(0);
|
||||||
const [usedBuildIdValues, setUsedBuildIdValues] = useState<string[]>([]);
|
const [usedBuildIdValues, setUsedBuildIdValues] = useState<string[]>([]);
|
||||||
const [usedReleaseValueValues, setUsedReleaseValueValues] = useState<string[]>([]);
|
const [usedReleaseValueValues, setUsedReleaseValueValues] = useState<string[]>([]);
|
||||||
|
const hasActiveRelease = releases.some((release) => release.status === 'PENDING' || release.status === 'RUNNING');
|
||||||
const loadingVisible = useMinimumLoading(loading);
|
const loadingVisible = useMinimumLoading(loading);
|
||||||
const usedValues = useMemo(() => new Set(usedReleaseValueValues), [usedReleaseValueValues]);
|
const usedValues = useMemo(() => new Set(usedReleaseValueValues), [usedReleaseValueValues]);
|
||||||
const usedBuildIds = useMemo(() => new Set(usedBuildIdValues), [usedBuildIdValues]);
|
const usedBuildIds = useMemo(() => new Set(usedBuildIdValues), [usedBuildIdValues]);
|
||||||
@@ -209,7 +262,8 @@ export function ReleaseManagementPage() {
|
|||||||
page: 1,
|
page: 1,
|
||||||
limit: 100,
|
limit: 100,
|
||||||
since: releaseRangeSince(range),
|
since: releaseRangeSince(range),
|
||||||
status: 'SUCCEEDED'
|
status: 'SUCCEEDED',
|
||||||
|
hideReleaseCandidates: true
|
||||||
})
|
})
|
||||||
]);
|
]);
|
||||||
setReleases(nextReleases.releases);
|
setReleases(nextReleases.releases);
|
||||||
@@ -226,6 +280,28 @@ export function ReleaseManagementPage() {
|
|||||||
void refresh();
|
void refresh();
|
||||||
}, [selectedAppId, page, range, apps]);
|
}, [selectedAppId, page, range, apps]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!hasActiveRelease) return;
|
||||||
|
const timer = window.setInterval(() => {
|
||||||
|
listReleasesPage({
|
||||||
|
appKey: apps.find((app) => app.id === selectedAppId)?.key,
|
||||||
|
page,
|
||||||
|
limit: releasePageSize,
|
||||||
|
since: releaseRangeSince(range)
|
||||||
|
})
|
||||||
|
.then((nextReleases) => {
|
||||||
|
setReleases(nextReleases.releases);
|
||||||
|
setTotalReleases(nextReleases.total);
|
||||||
|
setUsedBuildIdValues(nextReleases.usedBuildIds ?? []);
|
||||||
|
setUsedReleaseValueValues(nextReleases.usedValues ?? []);
|
||||||
|
})
|
||||||
|
.catch((error: Error) => {
|
||||||
|
toast({ type: 'error', message: `릴리즈 목록 갱신 실패: ${error.message}` });
|
||||||
|
});
|
||||||
|
}, 2000);
|
||||||
|
return () => window.clearInterval(timer);
|
||||||
|
}, [hasActiveRelease, apps, selectedAppId, page, range, toast]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setPage(1);
|
setPage(1);
|
||||||
}, [selectedAppId, range]);
|
}, [selectedAppId, range]);
|
||||||
@@ -240,7 +316,7 @@ export function ReleaseManagementPage() {
|
|||||||
const appBuilds = candidateBuilds.filter((build) => build.component.app.id === nextAppId);
|
const appBuilds = candidateBuilds.filter((build) => build.component.app.id === nextAppId);
|
||||||
const latestBuilds = latestCandidateBuilds(appBuilds);
|
const latestBuilds = latestCandidateBuilds(appBuilds);
|
||||||
setSelectedBuildIds(latestBuildIdsByComponent(latestBuilds));
|
setSelectedBuildIds(latestBuildIdsByComponent(latestBuilds));
|
||||||
setVersion((prev) => prev || newestBuildVersion(appBuilds));
|
setVersion(nextReleaseVersion(releases, nextAppId, appBuilds));
|
||||||
setReleaseModalOpen(true);
|
setReleaseModalOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -262,6 +338,30 @@ export function ReleaseManagementPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const hideTargetBuilds = useMemo(
|
||||||
|
() => candidateBuilds.filter((build) => build.component.app.id === hideTargetAppId),
|
||||||
|
[candidateBuilds, hideTargetAppId]
|
||||||
|
);
|
||||||
|
|
||||||
|
const hideTargetAppName = hideTargetBuilds[0]?.component.app.name ?? '';
|
||||||
|
|
||||||
|
const hideCandidates = async () => {
|
||||||
|
if (!hideTargetAppId || hidingCandidates) return;
|
||||||
|
setHidingCandidates(true);
|
||||||
|
try {
|
||||||
|
await Promise.all(hideTargetBuilds.map((build) => hideReleaseCandidate(build.id)));
|
||||||
|
toast({ type: 'success', message: '릴리즈 후보에서 숨겼습니다.' });
|
||||||
|
setHideTargetAppId('');
|
||||||
|
await refresh();
|
||||||
|
} catch (error) {
|
||||||
|
toast({ type: 'error', message: error instanceof Error ? error.message : '릴리즈 후보 숨김에 실패했습니다.' });
|
||||||
|
} finally {
|
||||||
|
setHidingCandidates(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectedReleaseLog = releases.find((release) => release.id === selectedReleaseLogId) ?? null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageTitle
|
<PageTitle
|
||||||
@@ -306,7 +406,6 @@ export function ReleaseManagementPage() {
|
|||||||
) : null}
|
) : null}
|
||||||
{pagedReleases.map((release) => {
|
{pagedReleases.map((release) => {
|
||||||
const artifacts = releaseArtifacts(release);
|
const artifacts = releaseArtifacts(release);
|
||||||
const isLogOpen = openLogReleaseId === release.id;
|
|
||||||
return (
|
return (
|
||||||
<div key={release.id} className="rounded-md border border-border bg-background/70 p-4">
|
<div key={release.id} className="rounded-md border border-border bg-background/70 p-4">
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
@@ -319,14 +418,15 @@ export function ReleaseManagementPage() {
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
className="size-8 px-0"
|
className="size-8 px-0"
|
||||||
disabled={!release.logs}
|
disabled={!release.logs}
|
||||||
title={isLogOpen ? '로그 닫기' : '로그'}
|
title="로그"
|
||||||
aria-label={isLogOpen ? '로그 닫기' : '로그'}
|
aria-label="로그"
|
||||||
onClick={() => setOpenLogReleaseId((prev) => prev === release.id ? '' : release.id)}
|
onClick={() => setSelectedReleaseLogId(release.id)}
|
||||||
>
|
>
|
||||||
<MoreHorizontal className="size-4" />
|
<MoreHorizontal className="size-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-3 flex flex-wrap gap-x-4 gap-y-1 text-xs">
|
<div className="mt-3 flex flex-wrap gap-x-4 gap-y-1 text-xs">
|
||||||
|
<Requester item={release} />
|
||||||
<span className="text-muted-foreground">Branch <b className="text-foreground">{release.branch}</b></span>
|
<span className="text-muted-foreground">Branch <b className="text-foreground">{release.branch}</b></span>
|
||||||
<span className="text-muted-foreground">Commit <b className="font-mono text-foreground">{release.commitSha || 'multiple'}</b></span>
|
<span className="text-muted-foreground">Commit <b className="font-mono text-foreground">{release.commitSha || 'multiple'}</b></span>
|
||||||
<span className="text-muted-foreground">Build <b className="text-foreground">{buildCount(release)}개</b></span>
|
<span className="text-muted-foreground">Build <b className="text-foreground">{buildCount(release)}개</b></span>
|
||||||
@@ -342,11 +442,6 @@ export function ReleaseManagementPage() {
|
|||||||
<p className="mt-1 text-xs text-muted-foreground">-</p>
|
<p className="mt-1 text-xs text-muted-foreground">-</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{isLogOpen ? (
|
|
||||||
<pre className="mt-3 max-h-72 overflow-auto rounded-md border border-border bg-black/70 p-3 font-mono text-xs leading-5 text-slate-100">
|
|
||||||
{release.logs}
|
|
||||||
</pre>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -387,15 +482,24 @@ export function ReleaseManagementPage() {
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{candidateGroups.map((group) => (
|
{candidateGroups.map((group) => (
|
||||||
<button
|
<div
|
||||||
key={group.appId}
|
key={group.appId}
|
||||||
type="button"
|
className="flex items-center justify-between gap-3 rounded-md border border-border bg-background px-3 py-3 transition-colors hover:bg-muted/20"
|
||||||
className="flex w-full items-center justify-between rounded-md border border-border bg-background px-3 py-3 text-left transition-colors hover:bg-muted/20"
|
|
||||||
onClick={() => openReleaseModal(group.appId)}
|
|
||||||
>
|
>
|
||||||
<span className="text-sm font-medium">{group.appName}</span>
|
<button type="button" className="min-w-0 flex-1 text-left" onClick={() => openReleaseModal(group.appId)}>
|
||||||
|
<span className="block truncate text-sm font-medium">{group.appName}</span>
|
||||||
|
</button>
|
||||||
<Badge tone={group.count > 0 ? 'green' : 'neutral'}>{group.count}개</Badge>
|
<Badge tone={group.count > 0 ? 'green' : 'neutral'}>{group.count}개</Badge>
|
||||||
</button>
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="size-8 shrink-0 px-0 text-rose-300 hover:text-rose-200"
|
||||||
|
title="후보 숨김"
|
||||||
|
aria-label={`${group.appName} 릴리즈 후보 숨김`}
|
||||||
|
onClick={() => setHideTargetAppId(group.appId)}
|
||||||
|
>
|
||||||
|
<Trash2 className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -403,19 +507,27 @@ export function ReleaseManagementPage() {
|
|||||||
|
|
||||||
<Modal open={releaseModalOpen} title="릴리즈 생성" onClose={() => setReleaseModalOpen(false)} footer={<><Button variant="outline" onClick={() => setReleaseModalOpen(false)}>취소</Button><Button disabled={!version || selectedBuildIds.length === 0} onClick={submit}>생성</Button></>}>
|
<Modal open={releaseModalOpen} title="릴리즈 생성" onClose={() => setReleaseModalOpen(false)} footer={<><Button variant="outline" onClick={() => setReleaseModalOpen(false)}>취소</Button><Button disabled={!version || selectedBuildIds.length === 0} onClick={submit}>생성</Button></>}>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<select className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm outline-none" value={selectedAppId} onChange={(event) => {
|
<label className="block">
|
||||||
selectApp(event.target.value);
|
<span className="text-xs font-medium text-muted-foreground">앱 <b className="text-foreground">*</b></span>
|
||||||
const appBuilds = candidateBuilds.filter((build) => build.component.app.id === event.target.value);
|
<select className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm outline-none" value={selectedAppId} onChange={(event) => {
|
||||||
setSelectedBuildIds(latestBuildIdsByComponent(appBuilds));
|
selectApp(event.target.value);
|
||||||
setVersion(newestBuildVersion(appBuilds));
|
const appBuilds = candidateBuilds.filter((build) => build.component.app.id === event.target.value);
|
||||||
}}>
|
setSelectedBuildIds(latestBuildIdsByComponent(appBuilds));
|
||||||
{candidateGroups.map((group) => <option key={group.appId} value={group.appId}>{group.appName}</option>)}
|
setVersion(nextReleaseVersion(releases, event.target.value, appBuilds));
|
||||||
</select>
|
}}>
|
||||||
<input className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm outline-none" value={version} onChange={(event) => setVersion(event.target.value)} placeholder="버전 예: 0.1.1" />
|
{candidateGroups.map((group) => <option key={group.appId} value={group.appId}>{group.appName}</option>)}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="block">
|
||||||
|
<span className="text-xs font-medium text-muted-foreground">버전 <b className="text-foreground">*</b></span>
|
||||||
|
<input className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm outline-none" value={version} onChange={(event) => setVersion(event.target.value)} placeholder="버전 예: 0.1.1" />
|
||||||
|
</label>
|
||||||
<div className="rounded-md border border-border bg-muted/20 px-3 py-2 text-xs leading-5 text-muted-foreground">
|
<div className="rounded-md border border-border bg-muted/20 px-3 py-2 text-xs leading-5 text-muted-foreground">
|
||||||
선택한 develop 커밋을 release로 승격한 뒤 release 브랜치에서 다시 빌드합니다.
|
선택한 develop 커밋을 release로 승격한 뒤 release 브랜치에서 다시 빌드합니다.
|
||||||
</div>
|
</div>
|
||||||
<div className="max-h-72 space-y-2 overflow-y-auto rounded-md border border-border p-2">
|
<div>
|
||||||
|
<p className="text-xs font-medium text-muted-foreground">빌드 선택 <b className="text-foreground">*</b></p>
|
||||||
|
<div className="mt-2 max-h-72 space-y-2 overflow-y-auto rounded-md border border-border p-2">
|
||||||
{modalBuilds.length === 0 ? (
|
{modalBuilds.length === 0 ? (
|
||||||
<div className="px-2 py-8 text-center text-sm text-muted-foreground">
|
<div className="px-2 py-8 text-center text-sm text-muted-foreground">
|
||||||
등록 가능한 새 빌드 결과가 없습니다.
|
등록 가능한 새 빌드 결과가 없습니다.
|
||||||
@@ -439,9 +551,52 @@ export function ReleaseManagementPage() {
|
|||||||
</label>
|
</label>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
<Modal
|
||||||
|
open={Boolean(selectedReleaseLog)}
|
||||||
|
title="릴리즈 로그"
|
||||||
|
onClose={() => setSelectedReleaseLogId('')}
|
||||||
|
className="log-modal-shell"
|
||||||
|
footer={<Button variant="outline" onClick={() => setSelectedReleaseLogId('')}>닫기</Button>}
|
||||||
|
>
|
||||||
|
{selectedReleaseLog?.logs ? (
|
||||||
|
<pre className="whitespace-pre-wrap rounded-md border border-border bg-background p-4 font-mono text-xs leading-5 text-muted-foreground">
|
||||||
|
{selectedReleaseLog.logs}
|
||||||
|
</pre>
|
||||||
|
) : (
|
||||||
|
<div className="flex min-h-44 items-center justify-center text-center text-sm text-muted-foreground">
|
||||||
|
로그가 없습니다.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
<Modal
|
||||||
|
open={Boolean(hideTargetAppId)}
|
||||||
|
title="릴리즈 후보 숨김"
|
||||||
|
onClose={() => {
|
||||||
|
if (!hidingCandidates) setHideTargetAppId('');
|
||||||
|
}}
|
||||||
|
footer={(
|
||||||
|
<>
|
||||||
|
<Button variant="outline" disabled={hidingCandidates} onClick={() => setHideTargetAppId('')}>취소</Button>
|
||||||
|
<Button className="bg-rose-600 text-white hover:bg-rose-500" disabled={hidingCandidates || hideTargetBuilds.length === 0} onClick={hideCandidates}>
|
||||||
|
{hidingCandidates ? '숨기는 중' : '숨김'}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="space-y-3 text-sm">
|
||||||
|
<p>
|
||||||
|
<span className="font-semibold text-foreground">{hideTargetAppName || '-'}</span>
|
||||||
|
{' '}릴리즈 후보 {hideTargetBuilds.length}개를 목록에서 숨깁니다.
|
||||||
|
</p>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
빌드 이력과 로그는 삭제되지 않습니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
<LoadingModal open={loadingVisible} />
|
<LoadingModal open={loadingVisible} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -97,7 +97,7 @@ export function RepositoryManagementPage() {
|
|||||||
const [isCreateRepoOpen, setIsCreateRepoOpen] = useState(false);
|
const [isCreateRepoOpen, setIsCreateRepoOpen] = useState(false);
|
||||||
const [repoName, setRepoName] = useState('');
|
const [repoName, setRepoName] = useState('');
|
||||||
const [repoDescription, setRepoDescription] = useState('');
|
const [repoDescription, setRepoDescription] = useState('');
|
||||||
const [repoPrivate, setRepoPrivate] = useState(true);
|
const [repoPrivate, setRepoPrivate] = useState(false);
|
||||||
const [createDevelopBranch, setCreateDevelopBranch] = useState(true);
|
const [createDevelopBranch, setCreateDevelopBranch] = useState(true);
|
||||||
const [createReleaseBranch, setCreateReleaseBranch] = useState(true);
|
const [createReleaseBranch, setCreateReleaseBranch] = useState(true);
|
||||||
const [repoCreateStatus, setRepoCreateStatus] = useState<'idle' | 'saving' | 'error'>('idle');
|
const [repoCreateStatus, setRepoCreateStatus] = useState<'idle' | 'saving' | 'error'>('idle');
|
||||||
@@ -165,7 +165,7 @@ export function RepositoryManagementPage() {
|
|||||||
.then(() => {
|
.then(() => {
|
||||||
setRepoName('');
|
setRepoName('');
|
||||||
setRepoDescription('');
|
setRepoDescription('');
|
||||||
setRepoPrivate(true);
|
setRepoPrivate(false);
|
||||||
setCreateDevelopBranch(true);
|
setCreateDevelopBranch(true);
|
||||||
setCreateReleaseBranch(true);
|
setCreateReleaseBranch(true);
|
||||||
setRepoCreateStatus('idle');
|
setRepoCreateStatus('idle');
|
||||||
@@ -381,7 +381,7 @@ export function RepositoryManagementPage() {
|
|||||||
</div>
|
</div>
|
||||||
<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">저장소명 <b className="text-foreground">*</b></span>
|
||||||
<div className="mt-2 flex h-9 overflow-hidden rounded-md border border-border bg-background focus-within:border-primary">
|
<div className="mt-2 flex h-9 overflow-hidden rounded-md border border-border bg-background focus-within:border-primary">
|
||||||
<span className="flex h-full shrink-0 items-center border-r border-border bg-muted px-3 text-xs font-semibold text-muted-foreground">
|
<span className="flex h-full shrink-0 items-center border-r border-border bg-muted px-3 text-xs font-semibold text-muted-foreground">
|
||||||
{giteaAppOrg}/
|
{giteaAppOrg}/
|
||||||
@@ -389,7 +389,6 @@ export function RepositoryManagementPage() {
|
|||||||
<input
|
<input
|
||||||
value={repoName}
|
value={repoName}
|
||||||
onChange={(event) => handleRepoNameChange(event.target.value)}
|
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"
|
className="h-full min-w-0 flex-1 bg-transparent px-3 text-sm outline-none placeholder:text-muted-foreground"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,18 +2,18 @@ import { Copy, FileText, RotateCcw } from 'lucide-react';
|
|||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { useToast } from '../../components/toast';
|
import { useToast } from '../../components/toast';
|
||||||
import { Badge, Button, Card, CardHeader, Modal } from '../../components/ui';
|
import { Badge, Button, Card, CardHeader, Modal } from '../../components/ui';
|
||||||
|
import { CommitAuthor } from '../../components/commit-author';
|
||||||
import { PageTitle } from '../../layout/AppLayout';
|
import { PageTitle } from '../../layout/AppLayout';
|
||||||
import type { AppRelease } from '../../types/application';
|
|
||||||
import type { ApiApp, ApiAppComponent } from '../../types/runtime';
|
import type { ApiApp, ApiAppComponent } from '../../types/runtime';
|
||||||
import { LoadingModal, useMinimumLoading } from '../operations/operation-utils';
|
import { LoadingModal, useMinimumLoading } from '../operations/operation-utils';
|
||||||
import { AppSelector, useApps } from './ApplicationControls';
|
import { AppSelector, useApps } from './ApplicationControls';
|
||||||
import {
|
import {
|
||||||
getGiteaCommitDiff,
|
getGiteaCommitDiff,
|
||||||
listGiteaCommitsPage,
|
listGiteaCommitsPage,
|
||||||
listReleases,
|
|
||||||
listSources,
|
listSources,
|
||||||
type GiteaCommit
|
type GiteaCommit
|
||||||
} from './application-api';
|
} from './application-api';
|
||||||
|
import { getStoredSelectedComponentId, selectedComponentChangedEventName, setStoredSelectedComponentId } from '../../lib/selected-app';
|
||||||
|
|
||||||
const giteaBaseUrl = import.meta.env.VITE_GITEA_BASE_URL ?? 'http://localhost:3000';
|
const giteaBaseUrl = import.meta.env.VITE_GITEA_BASE_URL ?? 'http://localhost:3000';
|
||||||
const giteaAppOrg = import.meta.env.VITE_GITEA_APP_ORG ?? import.meta.env.GITEA_APP_ORG ?? 'platform';
|
const giteaAppOrg = import.meta.env.VITE_GITEA_APP_ORG ?? import.meta.env.GITEA_APP_ORG ?? 'platform';
|
||||||
@@ -63,14 +63,17 @@ function splitRepository(repository?: string | null) {
|
|||||||
return owner && repo ? { owner, repo } : null;
|
return owner && repo ? { owner, repo } : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function repositoryGitUrl(repository?: string | null) {
|
function httpCloneUrl(repository?: string | null) {
|
||||||
const normalized = normalizeRepository(repository);
|
const normalized = normalizeRepository(repository);
|
||||||
return normalized ? `${giteaBaseUrl.replace(/\/$/, '')}/${normalized}.git` : '-';
|
return normalized ? `${giteaBaseUrl.replace(/\/$/, '')}/${normalized}.git` : '-';
|
||||||
}
|
}
|
||||||
|
|
||||||
function releaseTime(release: AppRelease) {
|
function sshCloneUrl(repository?: string | null) {
|
||||||
const value = new Date(release.createdAt).getTime();
|
const normalized = normalizeRepository(repository);
|
||||||
return Number.isNaN(value) ? 0 : value;
|
if (!normalized) return '-';
|
||||||
|
const base = giteaBaseUrl.replace(/\/$/, '');
|
||||||
|
const host = base.replace(/^https?:\/\//, '').split('/')[0]?.split(':')[0] || 'localhost';
|
||||||
|
return `ssh://git@${host}:2222/${normalized}.git`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function commitAuthor(commit: GiteaCommit) {
|
function commitAuthor(commit: GiteaCommit) {
|
||||||
@@ -304,10 +307,9 @@ export function SourceManagementPage() {
|
|||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const { apps, selectedAppId, loading: appsLoading, selectApp } = useApps();
|
const { apps, selectedAppId, loading: appsLoading, selectApp } = useApps();
|
||||||
const [sources, setSources] = useState<SourceComponent[]>([]);
|
const [sources, setSources] = useState<SourceComponent[]>([]);
|
||||||
const [releases, setReleases] = useState<AppRelease[]>([]);
|
|
||||||
const [sourcesLoading, setSourcesLoading] = useState(true);
|
const [sourcesLoading, setSourcesLoading] = useState(true);
|
||||||
const [selectedSourceId, setSelectedSourceId] = useState('');
|
const [selectedSourceId, setSelectedSourceId] = useState('');
|
||||||
const [selectedBranch, setSelectedBranch] = useState('release');
|
const [selectedBranch, setSelectedBranch] = useState('develop');
|
||||||
const [commitRange, setCommitRange] = useState<CommitRange>('week');
|
const [commitRange, setCommitRange] = useState<CommitRange>('week');
|
||||||
const [commitPage, setCommitPage] = useState(1);
|
const [commitPage, setCommitPage] = useState(1);
|
||||||
const [commits, setCommits] = useState<GiteaCommit[]>([]);
|
const [commits, setCommits] = useState<GiteaCommit[]>([]);
|
||||||
@@ -327,34 +329,20 @@ export function SourceManagementPage() {
|
|||||||
);
|
);
|
||||||
const selectedSource = filteredSources.find((source) => source.id === selectedSourceId) ?? filteredSources[0] ?? null;
|
const selectedSource = filteredSources.find((source) => source.id === selectedSourceId) ?? filteredSources[0] ?? null;
|
||||||
const repository = splitRepository(selectedSource?.repository);
|
const repository = splitRepository(selectedSource?.repository);
|
||||||
const branch = selectedBranch || 'release';
|
const branch = selectedBranch || 'develop';
|
||||||
const branchOptions = useMemo(
|
const branchOptions = useMemo(
|
||||||
() => [...new Set(['release', 'develop', 'main', selectedSource?.branch].filter((item): item is string => Boolean(item)))],
|
() => [...new Set(['develop', 'release', 'main', selectedSource?.branch].filter((item): item is string => Boolean(item)))],
|
||||||
[selectedSource?.branch]
|
[selectedSource?.branch]
|
||||||
);
|
);
|
||||||
const latestRelease = useMemo(
|
|
||||||
() => releases
|
|
||||||
.filter((release) => release.appId === selectedSource?.appId && release.status === 'SUCCEEDED')
|
|
||||||
.sort((a, b) => releaseTime(b) - releaseTime(a))[0],
|
|
||||||
[releases, selectedSource?.appId]
|
|
||||||
);
|
|
||||||
const totalCommitPages = totalCommits === null ? null : Math.max(1, Math.ceil(totalCommits / commitPageSize));
|
const totalCommitPages = totalCommits === null ? null : Math.max(1, Math.ceil(totalCommits / commitPageSize));
|
||||||
const selectedRepositoryGitUrl = repositoryGitUrl(selectedSource?.repository);
|
const selectedHttpCloneUrl = httpCloneUrl(selectedSource?.repository);
|
||||||
|
const selectedSshCloneUrl = sshCloneUrl(selectedSource?.repository);
|
||||||
|
|
||||||
const refreshSources = () => {
|
const refreshSources = () => {
|
||||||
setSourcesLoading(true);
|
setSourcesLoading(true);
|
||||||
Promise.all([
|
listSources()
|
||||||
listSources(),
|
.then((sourceItems) => setSources(sourceItems))
|
||||||
listReleases().catch(() => [])
|
.catch(() => setSources([]))
|
||||||
])
|
|
||||||
.then(([sourceItems, releaseItems]) => {
|
|
||||||
setSources(sourceItems);
|
|
||||||
setReleases(releaseItems);
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
setSources([]);
|
|
||||||
setReleases([]);
|
|
||||||
})
|
|
||||||
.finally(() => setSourcesLoading(false));
|
.finally(() => setSourcesLoading(false));
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -363,8 +351,34 @@ export function SourceManagementPage() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setSelectedSourceId((current) => filteredSources.some((source) => source.id === current) ? current : filteredSources[0]?.id ?? '');
|
setSelectedSourceId((current) => {
|
||||||
}, [filteredSources]);
|
if (filteredSources.some((source) => source.id === current)) return current;
|
||||||
|
|
||||||
|
const storedSourceId = getStoredSelectedComponentId(selectedAppId);
|
||||||
|
const nextSource = filteredSources.find((source) => source.id === storedSourceId) ?? filteredSources[0];
|
||||||
|
if (nextSource?.id) setStoredSelectedComponentId(selectedAppId, nextSource.id);
|
||||||
|
return nextSource?.id ?? '';
|
||||||
|
});
|
||||||
|
}, [filteredSources, selectedAppId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const storedSourceId = getStoredSelectedComponentId(selectedAppId);
|
||||||
|
if (!storedSourceId || storedSourceId === selectedSourceId) return;
|
||||||
|
if (!filteredSources.some((source) => source.id === storedSourceId)) return;
|
||||||
|
setSelectedSourceId(storedSourceId);
|
||||||
|
}, [filteredSources, selectedAppId, selectedSourceId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleSelectedComponentChange = (event: Event) => {
|
||||||
|
const detail = (event as CustomEvent<{ appId?: string; componentId?: string }>).detail;
|
||||||
|
if (detail?.appId !== selectedAppId || !detail.componentId) return;
|
||||||
|
if (!filteredSources.some((source) => source.id === detail.componentId)) return;
|
||||||
|
setSelectedSourceId(detail.componentId);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener(selectedComponentChangedEventName, handleSelectedComponentChange);
|
||||||
|
return () => window.removeEventListener(selectedComponentChangedEventName, handleSelectedComponentChange);
|
||||||
|
}, [filteredSources, selectedAppId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setCommitPage(1);
|
setCommitPage(1);
|
||||||
@@ -421,11 +435,11 @@ export function SourceManagementPage() {
|
|||||||
.finally(() => setDiffLoading(false));
|
.finally(() => setDiffLoading(false));
|
||||||
};
|
};
|
||||||
|
|
||||||
const copyGitUrl = () => {
|
const copyCloneUrl = (value: string, label: string) => {
|
||||||
if (!selectedSource?.repository || selectedRepositoryGitUrl === '-') return;
|
if (!selectedSource?.repository || value === '-') return;
|
||||||
navigator.clipboard?.writeText(selectedRepositoryGitUrl)
|
navigator.clipboard?.writeText(value)
|
||||||
.then(() => toast({ type: 'success', message: 'Git URL을 복사했습니다.' }))
|
.then(() => toast({ type: 'success', message: `${label}을 복사했습니다.` }))
|
||||||
.catch(() => toast({ type: 'error', message: 'Git URL 복사에 실패했습니다.' }));
|
.catch(() => toast({ type: 'error', message: `${label} 복사에 실패했습니다.` }));
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -453,38 +467,21 @@ export function SourceManagementPage() {
|
|||||||
<select
|
<select
|
||||||
className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring"
|
className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring"
|
||||||
value={selectedSource?.id ?? ''}
|
value={selectedSource?.id ?? ''}
|
||||||
onChange={(event) => setSelectedSourceId(event.target.value)}
|
onChange={(event) => {
|
||||||
|
const nextSourceId = event.target.value;
|
||||||
|
setSelectedSourceId(nextSourceId);
|
||||||
|
setStoredSelectedComponentId(selectedAppId, nextSourceId);
|
||||||
|
}}
|
||||||
disabled={filteredSources.length === 0}
|
disabled={filteredSources.length === 0}
|
||||||
>
|
>
|
||||||
{filteredSources.map((source) => (
|
{filteredSources.map((source) => (
|
||||||
<option key={source.id} value={source.id}>{source.app?.name ?? '-'} / {source.name}</option>
|
<option key={source.id} value={source.id}>{source.name}</option>
|
||||||
))}
|
))}
|
||||||
{filteredSources.length === 0 ? <option value="">연결된 소스 없음</option> : null}
|
{filteredSources.length === 0 ? <option value="">연결된 소스 없음</option> : null}
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-3 rounded-md border border-border bg-muted/20 p-3 text-sm md:grid-cols-2">
|
<div className="space-y-3 rounded-md border border-border bg-muted/20 p-3 text-sm">
|
||||||
<div className="min-w-0 md:col-span-2">
|
|
||||||
<p className="text-xs text-muted-foreground">저장소</p>
|
|
||||||
<div className="mt-2 rounded-md border border-border bg-background px-3 py-2">
|
|
||||||
<p className="truncate text-sm font-medium">{normalizeRepository(selectedSource?.repository) || '-'}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="min-w-0 md:col-span-2">
|
|
||||||
<p className="text-xs text-muted-foreground">Git URL</p>
|
|
||||||
<div className="mt-2 flex min-w-0 items-center gap-2 rounded-md border border-border bg-background px-3 py-2">
|
|
||||||
<p className="min-w-0 flex-1 truncate text-sm font-medium" title={selectedRepositoryGitUrl}>{selectedRepositoryGitUrl}</p>
|
|
||||||
<Button variant="ghost" className="size-8 shrink-0 px-0" title="Git URL 복사" aria-label="Git URL 복사" onClick={copyGitUrl} disabled={!selectedSource?.repository}>
|
|
||||||
<Copy className="size-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-xs text-muted-foreground">버전</p>
|
|
||||||
<div className="mt-2 rounded-md border border-border bg-background px-3 py-2">
|
|
||||||
<p className="truncate text-sm font-medium">{latestRelease?.version ?? '-'}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs text-muted-foreground">조회 브랜치</p>
|
<p className="text-xs text-muted-foreground">조회 브랜치</p>
|
||||||
<select
|
<select
|
||||||
@@ -497,6 +494,30 @@ export function SourceManagementPage() {
|
|||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-xs text-muted-foreground">저장소</p>
|
||||||
|
<div className="mt-2 rounded-md border border-border bg-background px-3 py-2">
|
||||||
|
<p className="truncate text-sm font-medium">{normalizeRepository(selectedSource?.repository) || '-'}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-xs text-muted-foreground">HTTP Clone URL</p>
|
||||||
|
<div className="mt-2 flex min-w-0 items-center gap-2 rounded-md border border-border bg-background px-3 py-2">
|
||||||
|
<p className="min-w-0 flex-1 truncate font-mono text-xs text-muted-foreground" title={selectedHttpCloneUrl}>{selectedHttpCloneUrl}</p>
|
||||||
|
<Button variant="ghost" className="size-8 shrink-0 px-0" title="HTTP Clone URL 복사" aria-label="HTTP Clone URL 복사" onClick={() => copyCloneUrl(selectedHttpCloneUrl, 'HTTP Clone URL')} disabled={!selectedSource?.repository}>
|
||||||
|
<Copy className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-xs text-muted-foreground">SSH Clone URL</p>
|
||||||
|
<div className="mt-2 flex min-w-0 items-center gap-2 rounded-md border border-border bg-background px-3 py-2">
|
||||||
|
<p className="min-w-0 flex-1 truncate font-mono text-xs text-muted-foreground" title={selectedSshCloneUrl}>{selectedSshCloneUrl}</p>
|
||||||
|
<Button variant="ghost" className="size-8 shrink-0 px-0" title="SSH Clone URL 복사" aria-label="SSH Clone URL 복사" onClick={() => copyCloneUrl(selectedSshCloneUrl, 'SSH Clone URL')} disabled={!selectedSource?.repository}>
|
||||||
|
<Copy className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -504,7 +525,7 @@ export function SourceManagementPage() {
|
|||||||
<Card className="min-w-0 overflow-hidden">
|
<Card className="min-w-0 overflow-hidden">
|
||||||
<CardHeader
|
<CardHeader
|
||||||
title="커밋 목록"
|
title="커밋 목록"
|
||||||
description={selectedSource ? `${selectedSource.app?.name ?? '-'} / ${selectedSource.name} 기준 커밋 내역입니다.` : '소스를 선택하세요.'}
|
description={selectedSource ? `${selectedSource.name} 기준 커밋 내역입니다.` : '소스를 선택하세요.'}
|
||||||
action={
|
action={
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{(Object.keys(commitRangeLabels) as CommitRange[]).map((range) => (
|
{(Object.keys(commitRangeLabels) as CommitRange[]).map((range) => (
|
||||||
@@ -538,7 +559,7 @@ export function SourceManagementPage() {
|
|||||||
) : null}
|
) : null}
|
||||||
{!commitsLoading && !commitsError ? commits.map((commit) => (
|
{!commitsLoading && !commitsError ? commits.map((commit) => (
|
||||||
<tr key={commit.sha} className="border-b border-border last:border-0">
|
<tr key={commit.sha} className="border-b border-border last:border-0">
|
||||||
<td className="whitespace-nowrap px-5 py-3 font-medium">{commitAuthor(commit)}</td>
|
<td className="whitespace-nowrap px-5 py-3"><CommitAuthor commit={commit} /></td>
|
||||||
<td className="whitespace-nowrap px-5 py-3 font-mono text-xs text-muted-foreground">{commit.sha.slice(0, 10)}</td>
|
<td className="whitespace-nowrap px-5 py-3 font-mono text-xs text-muted-foreground">{commit.sha.slice(0, 10)}</td>
|
||||||
<td className="max-w-[360px] truncate px-5 py-3">{commitMessage(commit)}</td>
|
<td className="max-w-[360px] truncate px-5 py-3">{commitMessage(commit)}</td>
|
||||||
<td className="whitespace-nowrap px-5 py-3 text-muted-foreground">{relativeTime(commitDate(commit))}</td>
|
<td className="whitespace-nowrap px-5 py-3 text-muted-foreground">{relativeTime(commitDate(commit))}</td>
|
||||||
@@ -553,7 +574,7 @@ export function SourceManagementPage() {
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between gap-3 border-t border-border px-5 py-4 text-sm">
|
<div className="flex items-center justify-between gap-3 border-t border-border px-5 py-4 text-sm">
|
||||||
<span className="text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
{totalCommits === null ? `페이지 ${commitPage}` : `총 ${totalCommits}건 · ${commitPage} / ${totalCommitPages}`}
|
{totalCommits === null ? `페이지 ${commitPage}` : `총 ${totalCommits}건 · ${commitPage} / ${totalCommitPages}`}
|
||||||
</span>
|
</span>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
|||||||
@@ -12,6 +12,14 @@ import type {
|
|||||||
} from '../../types/application';
|
} from '../../types/application';
|
||||||
import type { ApiAppComponent } from '../../types/runtime';
|
import type { ApiAppComponent } from '../../types/runtime';
|
||||||
|
|
||||||
|
export type DeploymentResourceLimits = {
|
||||||
|
replicas?: number;
|
||||||
|
cpuRequest?: string;
|
||||||
|
cpuLimit?: string;
|
||||||
|
memoryRequest?: string;
|
||||||
|
memoryLimit?: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type GiteaCommit = {
|
export type GiteaCommit = {
|
||||||
sha: string;
|
sha: string;
|
||||||
commit?: {
|
commit?: {
|
||||||
@@ -30,12 +38,16 @@ export type GiteaCommit = {
|
|||||||
username?: string;
|
username?: string;
|
||||||
full_name?: string;
|
full_name?: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
|
avatar_url?: string;
|
||||||
|
avatarUrl?: string;
|
||||||
} | null;
|
} | null;
|
||||||
committer?: {
|
committer?: {
|
||||||
login?: string;
|
login?: string;
|
||||||
username?: string;
|
username?: string;
|
||||||
full_name?: string;
|
full_name?: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
|
avatar_url?: string;
|
||||||
|
avatarUrl?: string;
|
||||||
} | null;
|
} | null;
|
||||||
created?: string;
|
created?: string;
|
||||||
};
|
};
|
||||||
@@ -76,7 +88,7 @@ export function createComponent(
|
|||||||
buildTarget?: string;
|
buildTarget?: string;
|
||||||
port?: number;
|
port?: number;
|
||||||
targetPort?: number;
|
targetPort?: number;
|
||||||
ports?: Array<{ name?: string; port?: number; targetPort?: number; protocol?: string }>;
|
ports?: Array<{ name?: string; port?: number; targetPort?: number; nodePort?: number; protocol?: string }>;
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
return apiRequest<{ component: ApiAppComponent }>(`/apps/${appId}/components`, {
|
return apiRequest<{ component: ApiAppComponent }>(`/apps/${appId}/components`, {
|
||||||
@@ -150,7 +162,7 @@ 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 }) {
|
export function listBuildJobsPage(options: { appKey?: string; page: number; limit: number; since?: string; status?: string; hideReleaseCandidates?: boolean }) {
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
page: String(options.page),
|
page: String(options.page),
|
||||||
limit: String(options.limit)
|
limit: String(options.limit)
|
||||||
@@ -158,6 +170,7 @@ export function listBuildJobsPage(options: { appKey?: string; page: number; limi
|
|||||||
if (options.appKey) params.set('appKey', options.appKey);
|
if (options.appKey) params.set('appKey', options.appKey);
|
||||||
if (options.since) params.set('since', options.since);
|
if (options.since) params.set('since', options.since);
|
||||||
if (options.status) params.set('status', options.status);
|
if (options.status) params.set('status', options.status);
|
||||||
|
if (options.hideReleaseCandidates) params.set('hideReleaseCandidates', 'true');
|
||||||
return apiRequest<{ jobs: BuildJob[]; total: number; page: number; limit: number }>(`/apps/builds?${params.toString()}`);
|
return apiRequest<{ jobs: BuildJob[]; total: number; page: number; limit: number }>(`/apps/builds?${params.toString()}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -176,6 +189,12 @@ export function deleteBuildJob(buildJobId: string) {
|
|||||||
return apiRequest<{ deleted: boolean }>(`/apps/builds/${buildJobId}`, { method: 'DELETE' });
|
return apiRequest<{ deleted: boolean }>(`/apps/builds/${buildJobId}`, { method: 'DELETE' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function hideReleaseCandidate(buildJobId: string) {
|
||||||
|
return apiRequest<{ buildJob: BuildJob }>(`/apps/builds/${buildJobId}/hide-release-candidate`, {
|
||||||
|
method: 'PATCH'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function listReleases(appKey?: string) {
|
export function listReleases(appKey?: string) {
|
||||||
const query = appKey ? `?appKey=${encodeURIComponent(appKey)}` : '';
|
const query = appKey ? `?appKey=${encodeURIComponent(appKey)}` : '';
|
||||||
return apiRequest<AppRelease[]>(`/apps/releases${query}`);
|
return apiRequest<AppRelease[]>(`/apps/releases${query}`);
|
||||||
@@ -198,6 +217,12 @@ export function createRelease(input: { appId: string; version: string; branch?:
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function deleteRelease(releaseId: string) {
|
||||||
|
return apiRequest<{ deleted: boolean }>(`/apps/releases/${releaseId}`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function listDeployments(appKey?: string) {
|
export function listDeployments(appKey?: string) {
|
||||||
const query = appKey ? `?appKey=${encodeURIComponent(appKey)}` : '';
|
const query = appKey ? `?appKey=${encodeURIComponent(appKey)}` : '';
|
||||||
return apiRequest<DeploymentJob[]>(`/apps/deployments${query}`);
|
return apiRequest<DeploymentJob[]>(`/apps/deployments${query}`);
|
||||||
@@ -213,7 +238,24 @@ export function listDeploymentsPage(options: { appKey?: string; page: number; li
|
|||||||
return apiRequest<{ deployments: DeploymentJob[]; total: number; page: number; limit: number; deployedReleaseIds?: string[] }>(`/apps/deployments?${params.toString()}`);
|
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;
|
||||||
|
resources?: DeploymentResourceLimits;
|
||||||
|
}) {
|
||||||
|
return apiRequest<{ deploymentJob: DeploymentJob }>('/apps/deployments', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(input)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createQuickDeployment(input: {
|
||||||
|
buildJobId: string;
|
||||||
|
environment: string;
|
||||||
|
namespace: string;
|
||||||
|
}) {
|
||||||
return apiRequest<{ deploymentJob: DeploymentJob }>('/apps/deployments', {
|
return apiRequest<{ deploymentJob: DeploymentJob }>('/apps/deployments', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify(input)
|
body: JSON.stringify(input)
|
||||||
|
|||||||
@@ -0,0 +1,234 @@
|
|||||||
|
import { Loader2, RotateCcw } from 'lucide-react';
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
import { Badge, Button, Card, CardHeader } from '../../components/ui';
|
||||||
|
import { useToast } from '../../components/toast';
|
||||||
|
import { PageTitle } from '../../layout/AppLayout';
|
||||||
|
import { cn } from '../../lib/utils';
|
||||||
|
import { LoadingModal, useMinimumLoading } from '../operations/operation-utils';
|
||||||
|
import { listCommonAppPipelineHistory, listCommonAppPipelines, type CommonAppPipeline } from './common-app-api';
|
||||||
|
import { formatDuration, formatPipelineRelativeTime, formatRepositoryName, formatShortSha, isActivePipelineStatus, pipelineStatusDotClass, pipelineStatusTone } from './common-app-utils';
|
||||||
|
|
||||||
|
const historyPageSize = 6;
|
||||||
|
|
||||||
|
export function CommonAppBuildPage() {
|
||||||
|
const { toast } = useToast();
|
||||||
|
const [pipelines, setPipelines] = useState<CommonAppPipeline[]>([]);
|
||||||
|
const [selectedRepoId, setSelectedRepoId] = useState('');
|
||||||
|
const [history, setHistory] = useState<CommonAppPipeline[]>([]);
|
||||||
|
const [historyPage, setHistoryPage] = useState(1);
|
||||||
|
const [historyTotal, setHistoryTotal] = useState<number | null>(null);
|
||||||
|
const [historyTotalPages, setHistoryTotalPages] = useState<number | null>(null);
|
||||||
|
const [hasNextHistoryPage, setHasNextHistoryPage] = useState(false);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [historyLoading, setHistoryLoading] = useState(false);
|
||||||
|
const [lastLoadedAt, setLastLoadedAt] = useState<number | null>(null);
|
||||||
|
const loadingVisible = useMinimumLoading(loading || historyLoading);
|
||||||
|
|
||||||
|
const runningCount = useMemo(
|
||||||
|
() => pipelines.filter((pipeline) => isActivePipelineStatus(pipeline.status)).length,
|
||||||
|
[pipelines]
|
||||||
|
);
|
||||||
|
const selectedPipeline = pipelines.find((pipeline) => pipeline.repoId === selectedRepoId) ?? pipelines[0];
|
||||||
|
const effectiveHistoryTotalPages = historyTotal !== null
|
||||||
|
? Math.max(1, historyTotalPages ?? Math.ceil(historyTotal / historyPageSize))
|
||||||
|
: historyTotalPages;
|
||||||
|
|
||||||
|
const refreshHistory = useCallback((repoId: string, page: number, silent = false) => {
|
||||||
|
if (!repoId) {
|
||||||
|
setHistory([]);
|
||||||
|
setHistoryTotal(null);
|
||||||
|
setHistoryTotalPages(null);
|
||||||
|
setHasNextHistoryPage(false);
|
||||||
|
return () => undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
let canceled = false;
|
||||||
|
if (!silent) setHistoryLoading(true);
|
||||||
|
|
||||||
|
listCommonAppPipelineHistory(repoId, page, historyPageSize)
|
||||||
|
.then((result) => {
|
||||||
|
if (canceled) return;
|
||||||
|
setHistory(result.pipelines ?? []);
|
||||||
|
setHistoryTotal(result.total ?? null);
|
||||||
|
setHistoryTotalPages(result.totalPages ?? null);
|
||||||
|
setHasNextHistoryPage(Boolean(result.hasNext));
|
||||||
|
})
|
||||||
|
.catch((error: Error) => {
|
||||||
|
if (canceled) return;
|
||||||
|
if (!silent) {
|
||||||
|
setHistory([]);
|
||||||
|
setHistoryTotal(null);
|
||||||
|
setHistoryTotalPages(null);
|
||||||
|
setHasNextHistoryPage(false);
|
||||||
|
toast({ type: 'error', title: '파이프라인 이력 조회 실패', message: error.message });
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (!canceled && !silent) setHistoryLoading(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
canceled = true;
|
||||||
|
};
|
||||||
|
}, [toast]);
|
||||||
|
|
||||||
|
const refresh = (silent = false) => {
|
||||||
|
if (!silent) setLoading(true);
|
||||||
|
listCommonAppPipelines()
|
||||||
|
.then((nextPipelines) => {
|
||||||
|
setPipelines(nextPipelines);
|
||||||
|
setLastLoadedAt(Date.now());
|
||||||
|
setSelectedRepoId((current) => {
|
||||||
|
if (current && nextPipelines.some((pipeline) => pipeline.repoId === current)) return current;
|
||||||
|
return nextPipelines.find((pipeline) => pipeline.repoId)?.repoId ?? '';
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch((error: Error) => {
|
||||||
|
if (!silent) toast({ type: 'error', title: '공통앱 빌드 조회 실패', message: error.message });
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (!silent) setLoading(false);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
refresh();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = window.setInterval(() => refresh(true), 5000);
|
||||||
|
return () => window.clearInterval(timer);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setHistoryPage(1);
|
||||||
|
}, [selectedRepoId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return refreshHistory(selectedRepoId, historyPage);
|
||||||
|
}, [historyPage, refreshHistory, selectedRepoId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = window.setInterval(() => {
|
||||||
|
if (selectedRepoId && historyPage === 1) refreshHistory(selectedRepoId, 1, true);
|
||||||
|
}, 5000);
|
||||||
|
|
||||||
|
return () => window.clearInterval(timer);
|
||||||
|
}, [historyPage, refreshHistory, selectedRepoId]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageTitle
|
||||||
|
eyebrow="공통앱"
|
||||||
|
title="빌드 관리"
|
||||||
|
description="공통앱 저장소의 최근 파이프라인 상태입니다."
|
||||||
|
actions={
|
||||||
|
<Button variant="outline" className="h-8 px-3 text-[13px]" onClick={() => refresh()} disabled={loading}>
|
||||||
|
<RotateCcw className={cn('size-4', loading && 'animate-spin')} />
|
||||||
|
새로고침
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="grid gap-4 xl:grid-cols-[minmax(320px,0.75fr)_minmax(0,1.25fr)]">
|
||||||
|
<Card className="min-w-0 overflow-hidden">
|
||||||
|
<CardHeader
|
||||||
|
title="모든 저장소"
|
||||||
|
description="마지막 파이프라인 생성 순으로 정렬된 저장소"
|
||||||
|
action={
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="hidden text-xs text-muted-foreground sm:inline">
|
||||||
|
{runningCount > 0 ? `진행 중 ${runningCount}개` : lastLoadedAt ? `${formatPipelineRelativeTime(lastLoadedAt)} 갱신` : '5초마다 갱신'}
|
||||||
|
</span>
|
||||||
|
<Badge tone={runningCount > 0 ? 'blue' : 'neutral'}>{pipelines.length}개</Badge>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<div className="max-h-[680px] overflow-y-auto">
|
||||||
|
{pipelines.length === 0 && !loading ? (
|
||||||
|
<div className="flex min-h-40 items-center justify-center px-5 text-sm text-muted-foreground">표시할 저장소가 없습니다.</div>
|
||||||
|
) : null}
|
||||||
|
<div className="divide-y divide-border">
|
||||||
|
{pipelines.map((pipeline) => {
|
||||||
|
const selected = pipeline.repoId === selectedRepoId;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={pipeline.id}
|
||||||
|
type="button"
|
||||||
|
className={cn(
|
||||||
|
'block w-full px-5 py-3 text-left transition-colors hover:bg-muted/20',
|
||||||
|
selected && 'bg-primary/20 shadow-[inset_4px_0_0_var(--color-primary)] hover:bg-primary/25'
|
||||||
|
)}
|
||||||
|
onClick={() => pipeline.repoId && setSelectedRepoId(pipeline.repoId)}
|
||||||
|
>
|
||||||
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
|
<span className={cn('size-2.5 shrink-0 rounded-full', pipelineStatusDotClass(pipeline.status))} />
|
||||||
|
<p className="truncate text-sm font-semibold">{formatRepositoryName(pipeline.repository)}</p>
|
||||||
|
{isActivePipelineStatus(pipeline.status) ? <Loader2 className="size-3.5 shrink-0 animate-spin text-white/90" /> : null}
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 truncate text-sm text-muted-foreground">
|
||||||
|
<span className="mr-1 font-mono text-xs">#{pipeline.number ?? '-'}</span>
|
||||||
|
{pipeline.message || '-'}
|
||||||
|
</p>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="min-w-0 overflow-hidden">
|
||||||
|
<CardHeader
|
||||||
|
title="파이프라인 이력"
|
||||||
|
description={selectedPipeline ? formatRepositoryName(selectedPipeline.repository) : '저장소를 선택하세요'}
|
||||||
|
/>
|
||||||
|
<div className="min-h-[430px] divide-y divide-border">
|
||||||
|
{history.length === 0 && !historyLoading ? (
|
||||||
|
<div className="flex min-h-40 items-center justify-center px-5 text-sm text-muted-foreground">표시할 파이프라인 이력이 없습니다.</div>
|
||||||
|
) : null}
|
||||||
|
{history.map((pipeline) => (
|
||||||
|
<div key={pipeline.id} className="px-5 py-3 transition-colors hover:bg-muted/20">
|
||||||
|
<div className="flex min-w-0 items-center justify-between gap-4">
|
||||||
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
|
<span className={cn('size-2.5 shrink-0 rounded-full', pipelineStatusDotClass(pipeline.status))} />
|
||||||
|
<span className="shrink-0 font-mono text-xs text-muted-foreground">#{pipeline.number ?? pipeline.id}</span>
|
||||||
|
<p className="truncate text-sm font-semibold">{pipeline.message || '-'}</p>
|
||||||
|
</div>
|
||||||
|
<Badge tone={pipelineStatusTone(pipeline.status)}>
|
||||||
|
<span className="inline-flex items-center gap-1.5">
|
||||||
|
{isActivePipelineStatus(pipeline.status) ? <Loader2 className="size-3 animate-spin" /> : null}
|
||||||
|
{pipeline.status || '-'}
|
||||||
|
</span>
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 flex flex-wrap items-center gap-2 pl-5 text-xs text-muted-foreground">
|
||||||
|
<span className="rounded-md bg-muted/50 px-2 py-1">{pipeline.branch || '-'}</span>
|
||||||
|
<span className="rounded-md bg-muted/50 px-2 py-1 font-mono">SHA1 {formatShortSha(pipeline.commitSha)}</span>
|
||||||
|
<span className="rounded-md bg-muted/50 px-2 py-1">소요 {formatDuration(pipeline.durationSeconds)}</span>
|
||||||
|
<span className="rounded-md bg-muted/50 px-2 py-1">{formatPipelineRelativeTime(pipeline.createdAt)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between border-t border-border px-5 py-3 text-xs text-muted-foreground">
|
||||||
|
<span>
|
||||||
|
{historyTotal !== null
|
||||||
|
? `총 ${historyTotal}건 · ${historyPage} / ${effectiveHistoryTotalPages ?? 1}`
|
||||||
|
: `현재 ${historyPage}페이지`}
|
||||||
|
</span>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant="outline" className="h-8 px-3 text-[13px]" disabled={historyLoading || historyPage <= 1} onClick={() => setHistoryPage((page) => Math.max(1, page - 1))}>
|
||||||
|
이전
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" className="h-8 px-3 text-[13px]" disabled={historyLoading || !hasNextHistoryPage} onClick={() => setHistoryPage((page) => page + 1)}>
|
||||||
|
다음
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<LoadingModal open={loadingVisible} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,422 @@
|
|||||||
|
import { Plus, RotateCcw, Server, Settings, TerminalSquare, Trash2 } from 'lucide-react';
|
||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { Badge, Button, Card, CardHeader, Modal } from '../../components/ui';
|
||||||
|
import { useToast } from '../../components/toast';
|
||||||
|
import { PageTitle } from '../../layout/AppLayout';
|
||||||
|
import { cn } from '../../lib/utils';
|
||||||
|
import type { RuntimeResource } from '../../types/runtime';
|
||||||
|
import { LoadingModal, useMinimumLoading } from '../operations/operation-utils';
|
||||||
|
import {
|
||||||
|
loadLauncherRuntimeResources,
|
||||||
|
loadLauncherComponentLogs,
|
||||||
|
loadRuntimeWorkloadEnv,
|
||||||
|
restartLauncherComponent,
|
||||||
|
saveRuntimeWorkloadEnv
|
||||||
|
} from '../launcher/launcher-api';
|
||||||
|
import { COMMON_APP_NAMESPACE, commonAppComponents, type CommonAppComponent } from './common-app-utils';
|
||||||
|
|
||||||
|
function statusTone(status?: string): 'green' | 'amber' | 'red' | 'neutral' {
|
||||||
|
if (['Running', 'Bound', 'ClusterIP', 'LoadBalancer'].includes(status ?? '')) return 'green';
|
||||||
|
if (['Pending', 'ContainerCreating'].includes(status ?? '')) return 'amber';
|
||||||
|
if (['Error', 'CrashLoopBackOff', 'ImagePullBackOff', 'Failed'].includes(status ?? '')) return 'red';
|
||||||
|
return 'neutral';
|
||||||
|
}
|
||||||
|
|
||||||
|
function apiKind(kind: string) {
|
||||||
|
return kind.toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripAnsi(value: string) {
|
||||||
|
return value.replace(/(?:\x1B|\u241B)\[[0-?]*[ -/]*[@-~]/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function findResource(resources: RuntimeResource[], kind: string, name: string) {
|
||||||
|
return resources.find((resource) => resource.kind === kind && resource.name === name);
|
||||||
|
}
|
||||||
|
|
||||||
|
function serviceResource(resources: RuntimeResource[], name?: string) {
|
||||||
|
if (!name) return undefined;
|
||||||
|
return resources.find((resource) => resource.kind === 'Service' && resource.name === name);
|
||||||
|
}
|
||||||
|
|
||||||
|
function relatedResources(resources: RuntimeResource[], component: CommonAppComponent) {
|
||||||
|
const names = new Set([component.workloadName, component.serviceName].filter(Boolean));
|
||||||
|
return resources.filter((resource) => {
|
||||||
|
if (names.has(resource.name)) return true;
|
||||||
|
return resource.name.includes(component.workloadName);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
type EnvVariable = {
|
||||||
|
key: string;
|
||||||
|
value: string;
|
||||||
|
isSecret: boolean;
|
||||||
|
editable: boolean;
|
||||||
|
source: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function CommonAppComponentsPage() {
|
||||||
|
const { toast } = useToast();
|
||||||
|
const [resources, setResources] = useState<RuntimeResource[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [logTarget, setLogTarget] = useState<CommonAppComponent | null>(null);
|
||||||
|
const [logs, setLogs] = useState('');
|
||||||
|
const [logLoading, setLogLoading] = useState(false);
|
||||||
|
const [envTarget, setEnvTarget] = useState<CommonAppComponent | null>(null);
|
||||||
|
const [restartTarget, setRestartTarget] = useState<CommonAppComponent | null>(null);
|
||||||
|
const [restartKey, setRestartKey] = useState('');
|
||||||
|
const [envVariables, setEnvVariables] = useState<EnvVariable[]>([]);
|
||||||
|
const [envLoading, setEnvLoading] = useState(false);
|
||||||
|
const [envSaving, setEnvSaving] = useState(false);
|
||||||
|
const envTableBodyRef = useRef<HTMLDivElement>(null);
|
||||||
|
const newEnvKeyInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const loadingVisible = useMinimumLoading(loading || envLoading);
|
||||||
|
|
||||||
|
const refresh = () => {
|
||||||
|
setLoading(true);
|
||||||
|
loadLauncherRuntimeResources(COMMON_APP_NAMESPACE)
|
||||||
|
.then((runtime) => setResources(runtime.resources ?? []))
|
||||||
|
.catch((error: Error) => {
|
||||||
|
toast({ type: 'error', title: '공통앱 구성요소 조회 실패', message: error.message });
|
||||||
|
})
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
refresh();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const openEnv = (component: CommonAppComponent) => {
|
||||||
|
setEnvTarget(component);
|
||||||
|
setEnvVariables([]);
|
||||||
|
setEnvLoading(true);
|
||||||
|
loadRuntimeWorkloadEnv(COMMON_APP_NAMESPACE, apiKind(component.kind), component.workloadName)
|
||||||
|
.then((result) => {
|
||||||
|
setEnvVariables([...(result.variables ?? [])].sort((left, right) => left.key.localeCompare(right.key)));
|
||||||
|
})
|
||||||
|
.catch((error: Error) => {
|
||||||
|
toast({ type: 'error', title: '설정값 조회 실패', message: error.message });
|
||||||
|
})
|
||||||
|
.finally(() => setEnvLoading(false));
|
||||||
|
};
|
||||||
|
|
||||||
|
const openLogs = (component: CommonAppComponent) => {
|
||||||
|
setLogTarget(component);
|
||||||
|
setLogs('');
|
||||||
|
setLogLoading(true);
|
||||||
|
loadLauncherComponentLogs(COMMON_APP_NAMESPACE, apiKind(component.kind), component.workloadName)
|
||||||
|
.then((result) => setLogs(stripAnsi(result.logs || '로그가 없습니다.')))
|
||||||
|
.catch((error: Error) => setLogs(`[warn] 로그 조회 실패\n${error.message}`))
|
||||||
|
.finally(() => setLogLoading(false));
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateEnvVariable = (index: number, patch: Partial<Pick<EnvVariable, 'key' | 'value' | 'isSecret'>>) => {
|
||||||
|
setEnvVariables((prev) => prev.map((variable, variableIndex) => (
|
||||||
|
variableIndex === index ? { ...variable, ...patch } : variable
|
||||||
|
)));
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeEnvVariable = (index: number) => {
|
||||||
|
setEnvVariables((prev) => prev.filter((_, variableIndex) => variableIndex !== index));
|
||||||
|
};
|
||||||
|
|
||||||
|
const addEnvVariable = () => {
|
||||||
|
setEnvVariables((prev) => [...prev, { key: '', value: '', isSecret: false, editable: true, source: 'literal' }]);
|
||||||
|
window.requestAnimationFrame(() => {
|
||||||
|
envTableBodyRef.current?.scrollTo({ top: envTableBodyRef.current.scrollHeight, behavior: 'smooth' });
|
||||||
|
newEnvKeyInputRef.current?.focus();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const restart = (component: CommonAppComponent) => {
|
||||||
|
setRestartKey(component.key);
|
||||||
|
restartLauncherComponent(COMMON_APP_NAMESPACE, apiKind(component.kind), component.workloadName)
|
||||||
|
.then(() => {
|
||||||
|
toast({ type: 'success', message: `${component.name} 재시작을 요청했습니다.` });
|
||||||
|
refresh();
|
||||||
|
})
|
||||||
|
.catch((error: Error) => {
|
||||||
|
toast({ type: 'error', title: '재시작 실패', message: error.message });
|
||||||
|
})
|
||||||
|
.finally(() => setRestartKey(''));
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveEnv = () => {
|
||||||
|
if (!envTarget) return;
|
||||||
|
setEnvSaving(true);
|
||||||
|
saveRuntimeWorkloadEnv(
|
||||||
|
COMMON_APP_NAMESPACE,
|
||||||
|
apiKind(envTarget.kind),
|
||||||
|
envTarget.workloadName,
|
||||||
|
envVariables
|
||||||
|
.filter((variable) => variable.editable)
|
||||||
|
.map((variable) => ({ key: variable.key.trim(), value: variable.value, isSecret: variable.isSecret }))
|
||||||
|
.filter((variable) => variable.key)
|
||||||
|
)
|
||||||
|
.then((result) => {
|
||||||
|
setEnvVariables(result.variables ?? []);
|
||||||
|
toast({ type: 'success', title: '설정값 저장', message: `${envTarget.name} 설정값을 반영했습니다.` });
|
||||||
|
refresh();
|
||||||
|
})
|
||||||
|
.catch((error: Error) => {
|
||||||
|
toast({ type: 'error', title: '설정값 저장 실패', message: error.message });
|
||||||
|
})
|
||||||
|
.finally(() => setEnvSaving(false));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageTitle
|
||||||
|
eyebrow="공통앱"
|
||||||
|
title="구성요소"
|
||||||
|
description="공통앱 워크로드 상태와 설정값을 관리합니다."
|
||||||
|
actions={
|
||||||
|
<Button variant="outline" className="h-8 px-3 text-[13px]" onClick={refresh} disabled={loading}>
|
||||||
|
<RotateCcw className={cn('size-4', loading && 'animate-spin')} />
|
||||||
|
새로고침
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="grid gap-4">
|
||||||
|
{commonAppComponents.map((component) => {
|
||||||
|
const workload = findResource(resources, component.kind, component.workloadName);
|
||||||
|
const service = serviceResource(resources, component.serviceName);
|
||||||
|
const details = relatedResources(resources, component);
|
||||||
|
const status = workload?.status ?? 'NotFound';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card key={component.key} className="overflow-hidden">
|
||||||
|
<CardHeader
|
||||||
|
title={component.name}
|
||||||
|
description={component.description}
|
||||||
|
action={<Badge tone={statusTone(status)}>{status}</Badge>}
|
||||||
|
/>
|
||||||
|
<div className="grid gap-5 p-5 xl:grid-cols-[minmax(220px,0.55fr)_minmax(0,1.45fr)]">
|
||||||
|
<div className="rounded-md border border-border bg-background p-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="flex size-10 items-center justify-center rounded-md bg-muted text-muted-foreground">
|
||||||
|
<Server className="size-5" />
|
||||||
|
</span>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="truncate text-sm font-semibold">{component.repository}</p>
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">{component.kind}/{component.workloadName}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 grid gap-3 text-sm">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-muted-foreground">Ready</p>
|
||||||
|
<p className="mt-1 font-medium">{workload?.ready ?? '-'}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-muted-foreground">Service</p>
|
||||||
|
<p className="mt-1 font-medium">{service ? `${service.status} · ${service.detail}` : '-'}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-muted-foreground">Image</p>
|
||||||
|
<p className="mt-1 break-all font-mono text-xs text-muted-foreground">{workload?.detail ?? '-'}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 flex flex-wrap gap-2">
|
||||||
|
<Button variant="outline" onClick={() => openLogs(component)} disabled={!workload}>
|
||||||
|
<TerminalSquare className="size-4" />
|
||||||
|
로그
|
||||||
|
</Button>
|
||||||
|
<Button onClick={() => setRestartTarget(component)} disabled={!workload || restartKey === component.key}>
|
||||||
|
<RotateCcw className={cn('size-4', restartKey === component.key && 'animate-spin')} />
|
||||||
|
재시작
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" onClick={() => openEnv(component)} disabled={!workload}>
|
||||||
|
<Settings className="size-4" />
|
||||||
|
설정값
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="min-w-0 overflow-x-auto rounded-md border border-border">
|
||||||
|
<table className="w-full min-w-[760px] whitespace-nowrap text-left text-sm">
|
||||||
|
<thead className="border-b border-border bg-muted/30 text-xs text-muted-foreground">
|
||||||
|
<tr>
|
||||||
|
<th className="px-4 py-3 font-medium">Kind</th>
|
||||||
|
<th className="px-4 py-3 font-medium">Name</th>
|
||||||
|
<th className="px-4 py-3 font-medium">Status</th>
|
||||||
|
<th className="px-4 py-3 font-medium">Ready</th>
|
||||||
|
<th className="px-4 py-3 font-medium">Age</th>
|
||||||
|
<th className="px-4 py-3 font-medium">Detail</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-border">
|
||||||
|
{details.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={6} className="px-4 py-8 text-center text-muted-foreground">관련 리소스가 없습니다.</td>
|
||||||
|
</tr>
|
||||||
|
) : null}
|
||||||
|
{details.map((resource) => (
|
||||||
|
<tr key={`${resource.kind}-${resource.name}`} className="hover:bg-muted/20">
|
||||||
|
<td className="px-4 py-3 font-semibold">{resource.kind}</td>
|
||||||
|
<td className="px-4 py-3 font-mono text-xs">{resource.name}</td>
|
||||||
|
<td className="px-4 py-3"><Badge tone={statusTone(resource.status)}>{resource.status}</Badge></td>
|
||||||
|
<td className="px-4 py-3">{resource.ready}</td>
|
||||||
|
<td className="px-4 py-3 text-muted-foreground">{resource.age}</td>
|
||||||
|
<td className="max-w-[360px] truncate px-4 py-3 font-mono text-xs text-muted-foreground">{resource.detail || '-'}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
open={Boolean(logTarget)}
|
||||||
|
title={logTarget ? `${logTarget.name} 로그` : '로그'}
|
||||||
|
onClose={() => setLogTarget(null)}
|
||||||
|
className="!max-w-5xl"
|
||||||
|
>
|
||||||
|
<pre className="max-h-[62vh] overflow-auto rounded-md bg-background p-4 font-mono text-xs leading-6 text-muted-foreground">
|
||||||
|
{logs || (logLoading ? '로그를 불러오는 중...' : '로그가 없습니다.')}
|
||||||
|
</pre>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
open={Boolean(restartTarget)}
|
||||||
|
title={restartTarget ? `${restartTarget.name} 재시작` : '구성요소 재시작'}
|
||||||
|
onClose={() => {
|
||||||
|
if (!restartTarget || restartKey !== restartTarget.key) setRestartTarget(null);
|
||||||
|
}}
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button variant="ghost" onClick={() => setRestartTarget(null)} disabled={Boolean(restartTarget && restartKey === restartTarget.key)}>
|
||||||
|
취소
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
if (!restartTarget) return;
|
||||||
|
restart(restartTarget);
|
||||||
|
setRestartTarget(null);
|
||||||
|
}}
|
||||||
|
disabled={Boolean(restartTarget && restartKey === restartTarget.key)}
|
||||||
|
>
|
||||||
|
재시작
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<p className="text-sm leading-6 text-muted-foreground">
|
||||||
|
{restartTarget?.name}을 재시작합니다. 실행 중인 요청은 잠시 영향을 받을 수 있습니다.
|
||||||
|
</p>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
open={Boolean(envTarget)}
|
||||||
|
title={envTarget ? `${envTarget.name} 설정값` : '설정값'}
|
||||||
|
onClose={() => setEnvTarget(null)}
|
||||||
|
className="!flex !h-[min(760px,calc(100vh-4rem))] !max-w-4xl !flex-col"
|
||||||
|
bodyClassName="!flex !min-h-0 !max-h-none !flex-1 !overflow-hidden"
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button variant="outline" onClick={addEnvVariable} disabled={envLoading}>
|
||||||
|
<Plus className="size-4" />
|
||||||
|
설정값 추가
|
||||||
|
</Button>
|
||||||
|
<span className="flex-1" />
|
||||||
|
<Button variant="ghost" onClick={() => setEnvTarget(null)}>닫기</Button>
|
||||||
|
<Button onClick={saveEnv} disabled={envLoading || envSaving}>
|
||||||
|
{envSaving ? '저장 중' : '저장'}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="flex min-h-0 flex-1 flex-col">
|
||||||
|
<div ref={envTableBodyRef} className="min-h-0 flex-1 overflow-y-auto rounded-md border border-border">
|
||||||
|
<table className="w-full table-fixed text-left text-sm">
|
||||||
|
<colgroup>
|
||||||
|
<col className="w-[26%]" />
|
||||||
|
<col />
|
||||||
|
<col className="w-20" />
|
||||||
|
<col className="w-20" />
|
||||||
|
<col className="w-12" />
|
||||||
|
</colgroup>
|
||||||
|
<thead className="border-b border-border bg-muted/30 text-xs text-muted-foreground">
|
||||||
|
<tr>
|
||||||
|
<th className="px-3 py-3 font-medium">Key</th>
|
||||||
|
<th className="px-3 py-3 font-medium">Value</th>
|
||||||
|
<th className="px-3 py-3 font-medium">Secret</th>
|
||||||
|
<th className="px-3 py-3 font-medium">Source</th>
|
||||||
|
<th className="px-2 py-3 font-medium"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-border">
|
||||||
|
{envLoading ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={5} className="px-4 py-8 text-center text-muted-foreground">설정값을 불러오는 중입니다.</td>
|
||||||
|
</tr>
|
||||||
|
) : null}
|
||||||
|
{!envLoading && envVariables.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={5} className="px-4 py-8 text-center text-muted-foreground">등록된 설정값이 없습니다.</td>
|
||||||
|
</tr>
|
||||||
|
) : null}
|
||||||
|
{!envLoading && envVariables.map((variable, index) => (
|
||||||
|
<tr key={`${variable.key}-${index}`}>
|
||||||
|
<td className="min-w-0 px-3 py-2.5">
|
||||||
|
<input
|
||||||
|
ref={index === envVariables.length - 1 && variable.editable ? newEnvKeyInputRef : undefined}
|
||||||
|
className="h-8 w-full min-w-0 rounded-md border border-input bg-background px-2 font-mono text-xs outline-none focus:border-ring focus:ring-1 focus:ring-ring disabled:opacity-60"
|
||||||
|
value={variable.key}
|
||||||
|
disabled={!variable.editable}
|
||||||
|
onChange={(event) => updateEnvVariable(index, { key: event.target.value })}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="min-w-0 px-3 py-2.5">
|
||||||
|
<input
|
||||||
|
className="h-8 w-full min-w-0 rounded-md border border-input bg-background px-2 font-mono text-xs outline-none focus:border-ring focus:ring-1 focus:ring-ring disabled:opacity-60"
|
||||||
|
value={variable.value}
|
||||||
|
disabled={!variable.editable}
|
||||||
|
onChange={(event) => updateEnvVariable(index, { value: event.target.value })}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2.5">
|
||||||
|
<label className="inline-flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="size-4 accent-primary"
|
||||||
|
checked={variable.isSecret}
|
||||||
|
disabled={!variable.editable}
|
||||||
|
onChange={(event) => updateEnvVariable(index, { isSecret: event.target.checked })}
|
||||||
|
/>
|
||||||
|
Secret
|
||||||
|
</label>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2.5">
|
||||||
|
<Badge tone={variable.editable ? 'blue' : 'neutral'}>{variable.source}</Badge>
|
||||||
|
</td>
|
||||||
|
<td className="px-2 py-2.5">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="size-8 px-0"
|
||||||
|
disabled={!variable.editable}
|
||||||
|
onClick={() => removeEnvVariable(index)}
|
||||||
|
aria-label="설정값 삭제"
|
||||||
|
title="설정값 삭제"
|
||||||
|
>
|
||||||
|
<Trash2 className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<LoadingModal open={loadingVisible} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,471 @@
|
|||||||
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { FileText, RotateCcw, Search, TerminalSquare } from 'lucide-react';
|
||||||
|
import { Badge, Button, Card } from '../../components/ui';
|
||||||
|
import { PageTitle } from '../../layout/AppLayout';
|
||||||
|
import { formatDateTime } from '../../lib/date-format';
|
||||||
|
import { runtimeStatusDotClass, statusDotToneClass } from '../../lib/status-dot';
|
||||||
|
import { cn } from '../../lib/utils';
|
||||||
|
import type { RuntimeEvent, RuntimePodMetric, RuntimeResource, RuntimeResourceResponse } from '../../types/runtime';
|
||||||
|
import { LoadingModal, useMinimumLoading } from '../operations/operation-utils';
|
||||||
|
import { loadRuntimeEvents, loadRuntimePodMetrics, loadRuntimeResources, loadRuntimeWorkloadLogs } from '../runtime/runtime-api';
|
||||||
|
import { COMMON_APP_NAMESPACE, commonAppComponents, type CommonAppComponent } from './common-app-utils';
|
||||||
|
|
||||||
|
function apiKind(kind: string) {
|
||||||
|
return kind.toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripAnsi(value: string) {
|
||||||
|
return value.replace(/(?:\x1B|\u241B)\[[0-?]*[ -/]*[@-~]/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function eventTone(type?: string) {
|
||||||
|
if (type === 'Normal') return 'green';
|
||||||
|
if (type === 'Warning') return 'amber';
|
||||||
|
return 'neutral';
|
||||||
|
}
|
||||||
|
|
||||||
|
const eventRanges = [
|
||||||
|
{ label: '1시간', value: '1h', milliseconds: 60 * 60 * 1000 },
|
||||||
|
{ label: '24시간', value: '24h', milliseconds: 24 * 60 * 60 * 1000 },
|
||||||
|
{ label: '전체', value: 'all', milliseconds: null }
|
||||||
|
];
|
||||||
|
|
||||||
|
function eventTime(event: RuntimeEvent) {
|
||||||
|
const value = event.lastTimestamp ?? event.firstTimestamp;
|
||||||
|
if (!value) return 0;
|
||||||
|
const time = new Date(value).getTime();
|
||||||
|
return Number.isFinite(time) ? time : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isEventInRange(event: RuntimeEvent, range: (typeof eventRanges)[number]) {
|
||||||
|
if (!range.milliseconds) return true;
|
||||||
|
const time = eventTime(event);
|
||||||
|
return time > 0 && time >= Date.now() - range.milliseconds;
|
||||||
|
}
|
||||||
|
|
||||||
|
function metricKey(namespace: string, pod: string) {
|
||||||
|
return `${namespace}/${pod}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isLoggable(resource: RuntimeResource) {
|
||||||
|
return ['Pod', 'Deployment', 'StatefulSet', 'DaemonSet', 'Job'].includes(resource.kind);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseReadyCount(ready?: string) {
|
||||||
|
const [current, desired] = (ready ?? '').split('/').map((value) => Number(value));
|
||||||
|
return {
|
||||||
|
current: Number.isFinite(current) ? current : 0,
|
||||||
|
desired: Number.isFinite(desired) ? desired : 0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasReadyReplica(resource: RuntimeResource) {
|
||||||
|
const ready = parseReadyCount(resource.ready);
|
||||||
|
return ready.current > 0 && (ready.desired === 0 || ready.current <= ready.desired);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isActiveLogTarget(resource: RuntimeResource) {
|
||||||
|
if (!isLoggable(resource)) return false;
|
||||||
|
if (resource.kind === 'Job') return resource.status === 'Running';
|
||||||
|
if (resource.kind === 'Pod') return ['Running', 'Pending', 'ContainerCreating'].includes(resource.status);
|
||||||
|
return resource.status === 'Running' && hasReadyReplica(resource);
|
||||||
|
}
|
||||||
|
|
||||||
|
function relatedToComponent(resource: RuntimeResource, component: CommonAppComponent) {
|
||||||
|
const names = [component.workloadName, component.serviceName].filter(Boolean) as string[];
|
||||||
|
return names.some((name) => resource.name === name || resource.name.includes(component.workloadName));
|
||||||
|
}
|
||||||
|
|
||||||
|
function workloadStatusDot(resource: RuntimeResource) {
|
||||||
|
if (resource.kind === 'Pod') return runtimeStatusDotClass(resource.status);
|
||||||
|
if (resource.kind === 'Job') return resource.status === 'Running' ? statusDotToneClass('green') : statusDotToneClass('neutral');
|
||||||
|
if (hasReadyReplica(resource) && resource.status === 'Running') return statusDotToneClass('green');
|
||||||
|
if (resource.status === 'Pending') return statusDotToneClass('amber');
|
||||||
|
if (['Error', 'CrashLoopBackOff', 'ImagePullBackOff', 'Failed'].includes(resource.status)) return statusDotToneClass('red');
|
||||||
|
return statusDotToneClass('neutral');
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseCpuMillicores(value?: string) {
|
||||||
|
if (!value) return 0;
|
||||||
|
if (value.endsWith('m')) return Number(value.replace('m', '')) || 0;
|
||||||
|
return (Number(value) || 0) * 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseMemoryMi(value?: string) {
|
||||||
|
if (!value) return 0;
|
||||||
|
const numeric = Number(value.replace(/[^\d.]/g, '')) || 0;
|
||||||
|
if (value.endsWith('Gi')) return numeric * 1024;
|
||||||
|
if (value.endsWith('Ki')) return numeric / 1024;
|
||||||
|
if (value.endsWith('Mi')) return numeric;
|
||||||
|
return numeric / 1024 / 1024;
|
||||||
|
}
|
||||||
|
|
||||||
|
function usagePercent(value: number, max: number) {
|
||||||
|
return Math.max(4, Math.min(100, Math.round((value / max) * 100)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function UsageBars({ metric, compact = false }: { metric?: RuntimePodMetric; compact?: boolean }) {
|
||||||
|
if (!metric) return <span className="text-xs text-muted-foreground">사용량 -</span>;
|
||||||
|
|
||||||
|
const cpuPercent = usagePercent(parseCpuMillicores(metric.cpu), 1000);
|
||||||
|
const memoryPercent = usagePercent(parseMemoryMi(metric.memory), 512);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn('grid gap-2', compact ? 'w-full grid-cols-2' : 'sm:grid-cols-2')}>
|
||||||
|
<div>
|
||||||
|
<div className="mb-1 flex justify-between text-xs text-muted-foreground">
|
||||||
|
<span>CPU</span>
|
||||||
|
<span>{metric.cpu}</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-2 overflow-hidden rounded-full bg-muted">
|
||||||
|
<div className="h-full rounded-full bg-primary" style={{ width: `${cpuPercent}%` }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="mb-1 flex justify-between text-xs text-muted-foreground">
|
||||||
|
<span>메모리</span>
|
||||||
|
<span>{metric.memory}</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-2 overflow-hidden rounded-full bg-muted">
|
||||||
|
<div className="h-full rounded-full bg-emerald-400" style={{ width: `${memoryPercent}%` }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CommonAppLogsPage() {
|
||||||
|
const [componentKey, setComponentKey] = useState(commonAppComponents[0]?.key ?? '');
|
||||||
|
const [data, setData] = useState<RuntimeResourceResponse | null>(null);
|
||||||
|
const [podMetrics, setPodMetrics] = useState<RuntimePodMetric[]>([]);
|
||||||
|
const [events, setEvents] = useState<RuntimeEvent[]>([]);
|
||||||
|
const [selectedResource, setSelectedResource] = useState<RuntimeResource | null>(null);
|
||||||
|
const [logs, setLogs] = useState('');
|
||||||
|
const [tail, setTail] = useState(100);
|
||||||
|
const [activeTab, setActiveTab] = useState<'logs' | 'events'>('logs');
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [eventRange, setEventRange] = useState('1h');
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [logLoading, setLogLoading] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const logContainerRef = useRef<HTMLPreElement | null>(null);
|
||||||
|
const loadingVisible = useMinimumLoading(loading || logLoading);
|
||||||
|
|
||||||
|
const selectedComponent = commonAppComponents.find((component) => component.key === componentKey) ?? commonAppComponents[0];
|
||||||
|
const resources = data?.resources ?? [];
|
||||||
|
const activeNamespace = data?.namespace || COMMON_APP_NAMESPACE;
|
||||||
|
const metricByPod = useMemo(() => {
|
||||||
|
const map = new Map<string, RuntimePodMetric>();
|
||||||
|
podMetrics.forEach((metric) => map.set(metricKey(metric.namespace, metric.pod), metric));
|
||||||
|
return map;
|
||||||
|
}, [podMetrics]);
|
||||||
|
const componentResources = selectedComponent ? resources.filter((resource) => relatedToComponent(resource, selectedComponent)) : [];
|
||||||
|
const loggableResources = componentResources.filter(isActiveLogTarget);
|
||||||
|
const selectedMetric = selectedResource?.kind === 'Pod' ? metricByPod.get(metricKey(activeNamespace, selectedResource.name)) : undefined;
|
||||||
|
const selectedEventRange = eventRanges.find((range) => range.value === eventRange) ?? eventRanges[0];
|
||||||
|
const filteredEvents = events.filter((event) => {
|
||||||
|
if (!isEventInRange(event, selectedEventRange)) return false;
|
||||||
|
const normalizedSearch = search.trim().toLowerCase();
|
||||||
|
const componentMatch = selectedComponent
|
||||||
|
? `${event.involvedObject ?? ''} ${event.message ?? ''}`.toLowerCase().includes(selectedComponent.workloadName.toLowerCase())
|
||||||
|
: true;
|
||||||
|
if (!componentMatch) return false;
|
||||||
|
if (!normalizedSearch) return true;
|
||||||
|
return `${event.type ?? ''} ${event.reason ?? ''} ${event.message ?? ''} ${event.involvedObject ?? ''}`.toLowerCase().includes(normalizedSearch);
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeTab !== 'logs') return;
|
||||||
|
const frame = window.requestAnimationFrame(() => {
|
||||||
|
const container = logContainerRef.current;
|
||||||
|
if (container) container.scrollTop = container.scrollHeight;
|
||||||
|
});
|
||||||
|
return () => window.cancelAnimationFrame(frame);
|
||||||
|
}, [activeTab, logs, logLoading, selectedResource?.name]);
|
||||||
|
|
||||||
|
const loadLogs = (resource: RuntimeResource, nextTail = tail) => {
|
||||||
|
setSelectedResource(resource);
|
||||||
|
setLogLoading(true);
|
||||||
|
setError('');
|
||||||
|
loadRuntimeWorkloadLogs(COMMON_APP_NAMESPACE, apiKind(resource.kind), resource.name, nextTail)
|
||||||
|
.then((result) => setLogs(result.logs ? stripAnsi(result.logs) : '로그가 없습니다.'))
|
||||||
|
.catch((logError: Error) => {
|
||||||
|
setLogs('');
|
||||||
|
setError(logError.message);
|
||||||
|
})
|
||||||
|
.finally(() => setLogLoading(false));
|
||||||
|
};
|
||||||
|
|
||||||
|
const refresh = () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
|
Promise.all([
|
||||||
|
loadRuntimeResources(COMMON_APP_NAMESPACE),
|
||||||
|
loadRuntimeEvents(COMMON_APP_NAMESPACE),
|
||||||
|
loadRuntimePodMetrics(COMMON_APP_NAMESPACE).catch(() => [])
|
||||||
|
])
|
||||||
|
.then(([nextData, nextEvents, nextMetrics]) => {
|
||||||
|
setData(nextData);
|
||||||
|
setEvents(nextEvents);
|
||||||
|
setPodMetrics(nextMetrics);
|
||||||
|
setSelectedResource(null);
|
||||||
|
setLogs('');
|
||||||
|
})
|
||||||
|
.catch((refreshError: Error) => setError(refreshError.message))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
refresh();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeTab !== 'events') return;
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
const loadEvents = () => {
|
||||||
|
loadRuntimeEvents(COMMON_APP_NAMESPACE)
|
||||||
|
.then((nextEvents) => {
|
||||||
|
if (!cancelled) setEvents(nextEvents);
|
||||||
|
})
|
||||||
|
.catch(() => undefined);
|
||||||
|
};
|
||||||
|
|
||||||
|
loadEvents();
|
||||||
|
const timer = window.setInterval(loadEvents, 10_000);
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
window.clearInterval(timer);
|
||||||
|
};
|
||||||
|
}, [activeTab]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageTitle
|
||||||
|
eyebrow="공통앱"
|
||||||
|
title="로그/이벤트"
|
||||||
|
description="공통앱 워크로드 로그와 Kubernetes 이벤트를 확인합니다."
|
||||||
|
actions={(
|
||||||
|
<Button variant="outline" className="h-8 px-3 text-[13px]" onClick={refresh} disabled={loading}>
|
||||||
|
<RotateCcw className={cn('size-4', loading && 'animate-spin')} />
|
||||||
|
새로고침
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{error ? <div className="mb-4 rounded-md border border-rose-500/30 bg-rose-950/30 px-4 py-3 text-sm text-rose-200">{error}</div> : null}
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Card className="p-5">
|
||||||
|
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-semibold">조회 기준</p>
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">공통앱 구성요소와 네임스페이스 기준으로 로그와 이벤트를 조회합니다.</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-end">
|
||||||
|
<label className="flex items-center gap-3">
|
||||||
|
<span className="shrink-0 text-xs font-medium text-muted-foreground">구성요소</span>
|
||||||
|
<select
|
||||||
|
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring sm:w-64"
|
||||||
|
value={componentKey}
|
||||||
|
onChange={(event) => {
|
||||||
|
setComponentKey(event.target.value);
|
||||||
|
setSelectedResource(null);
|
||||||
|
setLogs('');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{commonAppComponents.map((component) => <option key={component.key} value={component.key}>{component.name}</option>)}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<div className="rounded-md border border-border bg-background px-3 py-2 text-sm">
|
||||||
|
<span className="text-xs text-muted-foreground">네임스페이스</span>
|
||||||
|
<span className="ml-3 font-medium">{COMMON_APP_NAMESPACE}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div className="grid gap-4 xl:grid-cols-[360px_minmax(0,1fr)]">
|
||||||
|
<Card className="min-w-0 overflow-hidden">
|
||||||
|
<div className="flex items-start justify-between gap-4 border-b border-border px-5 py-4">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-sm font-semibold text-foreground">워크로드</h2>
|
||||||
|
<p className="mt-1 text-xs leading-5 text-muted-foreground">실행 중인 로그 대상을 선택합니다.</p>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-muted-foreground">총 {loggableResources.length}개</span>
|
||||||
|
</div>
|
||||||
|
<div className="max-h-[620px] space-y-2 overflow-y-auto p-4">
|
||||||
|
{loggableResources.length === 0 ? (
|
||||||
|
<div className="flex min-h-40 items-center justify-center text-center text-sm text-muted-foreground">
|
||||||
|
실행 중인 로그 대상이 없습니다.
|
||||||
|
</div>
|
||||||
|
) : loggableResources.map((resource) => {
|
||||||
|
const selected = selectedResource?.kind === resource.kind && selectedResource?.name === resource.name;
|
||||||
|
const metric = resource.kind === 'Pod' ? metricByPod.get(metricKey(activeNamespace, resource.name)) : undefined;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
key={`${resource.kind}-${resource.name}`}
|
||||||
|
className={cn(
|
||||||
|
'w-full rounded-md border px-3 py-3 text-left transition-colors',
|
||||||
|
selected ? 'border-border bg-primary/15 shadow-[inset_4px_0_0_var(--color-primary)]' : 'border-border bg-background/70 hover:bg-muted/20'
|
||||||
|
)}
|
||||||
|
onClick={() => loadLogs(resource)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className={cn('size-2.5 shrink-0 rounded-full', workloadStatusDot(resource))} />
|
||||||
|
<span className="truncate text-sm font-medium">{resource.name}</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 flex flex-wrap gap-x-3 gap-y-1 text-xs text-muted-foreground">
|
||||||
|
<span>{resource.kind}</span>
|
||||||
|
<span>준비 <b className="text-foreground">{resource.ready}</b></span>
|
||||||
|
<span>재시작 <b className="text-foreground">{resource.restarts ?? 0}</b></span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3">
|
||||||
|
<UsageBars metric={metric} compact />
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<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">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={cn('inline-flex h-8 items-center gap-1.5 rounded-md px-3 text-[13px] font-medium', activeTab === 'logs' ? 'bg-primary text-primary-foreground' : 'bg-muted text-muted-foreground hover:text-foreground')}
|
||||||
|
onClick={() => setActiveTab('logs')}
|
||||||
|
>
|
||||||
|
<TerminalSquare className="size-4" />
|
||||||
|
로그
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={cn('inline-flex h-8 items-center gap-1.5 rounded-md px-3 text-[13px] font-medium', activeTab === 'events' ? 'bg-primary text-primary-foreground' : 'bg-muted text-muted-foreground hover:text-foreground')}
|
||||||
|
onClick={() => setActiveTab('events')}
|
||||||
|
>
|
||||||
|
<FileText className="size-4" />
|
||||||
|
이벤트
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{activeTab === 'logs' ? (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<select
|
||||||
|
className="h-8 rounded-md border border-input bg-background px-3 text-xs text-foreground outline-none focus:ring-2 focus:ring-ring"
|
||||||
|
value={tail}
|
||||||
|
onChange={(event) => {
|
||||||
|
const nextTail = Number(event.target.value);
|
||||||
|
setTail(nextTail);
|
||||||
|
if (selectedResource) loadLogs(selectedResource, nextTail);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value={100}>최근 100줄</option>
|
||||||
|
<option value={300}>최근 300줄</option>
|
||||||
|
<option value={1000}>최근 1000줄</option>
|
||||||
|
</select>
|
||||||
|
<Button variant="outline" className="h-8 px-3 text-[13px]" disabled={!selectedResource || logLoading} onClick={() => selectedResource && loadLogs(selectedResource)}>
|
||||||
|
<RotateCcw className={cn('size-4', logLoading && 'animate-spin')} />
|
||||||
|
로그 새로고침
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex w-full flex-col gap-2 lg:w-auto lg:flex-row lg:items-center">
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{eventRanges.map((range) => (
|
||||||
|
<Button
|
||||||
|
key={range.value}
|
||||||
|
variant={eventRange === range.value ? 'secondary' : 'outline'}
|
||||||
|
className="h-8 px-3 text-[13px]"
|
||||||
|
onClick={() => setEventRange(range.value)}
|
||||||
|
>
|
||||||
|
{range.label}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="relative w-full lg:w-80">
|
||||||
|
<Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
|
<input
|
||||||
|
className="h-8 w-full rounded-md border border-input bg-background pl-9 pr-3 text-sm text-foreground outline-none placeholder:text-muted-foreground focus:ring-2 focus:ring-ring"
|
||||||
|
placeholder="이벤트 검색"
|
||||||
|
value={search}
|
||||||
|
onChange={(event) => setSearch(event.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{activeTab === 'logs' ? (
|
||||||
|
<div className="min-h-[620px] bg-background">
|
||||||
|
<div className="border-b border-border px-5 py-4">
|
||||||
|
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
대상 <b className="text-foreground">{selectedResource ? `${selectedResource.kind}/${selectedResource.name}` : '-'}</b>
|
||||||
|
</div>
|
||||||
|
{selectedResource ? (
|
||||||
|
<div className="grid gap-2 text-xs sm:grid-cols-[minmax(0,1fr)_120px_120px] lg:min-w-[620px]">
|
||||||
|
<div className="rounded-md border border-border bg-card px-3 py-2">
|
||||||
|
<UsageBars metric={selectedMetric} />
|
||||||
|
</div>
|
||||||
|
<div className="rounded-md border border-border bg-card px-3 py-2">
|
||||||
|
<p className="text-muted-foreground">Ready</p>
|
||||||
|
<p className="mt-1 font-semibold text-foreground">{selectedResource.ready}</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-md border border-border bg-card px-3 py-2">
|
||||||
|
<p className="text-muted-foreground">재시작</p>
|
||||||
|
<p className="mt-1 font-semibold text-foreground">{selectedResource.restarts ?? 0}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{selectedResource ? (
|
||||||
|
<pre ref={logContainerRef} className="max-h-[640px] overflow-auto whitespace-pre-wrap p-5 font-mono text-xs leading-6 text-muted-foreground">
|
||||||
|
{logLoading ? '로그를 불러오는 중입니다...' : logs || '로그가 없습니다.'}
|
||||||
|
</pre>
|
||||||
|
) : (
|
||||||
|
<div className="flex min-h-[520px] items-center justify-center text-center text-sm text-muted-foreground">
|
||||||
|
왼쪽에서 워크로드를 선택하면 로그가 표시됩니다.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="max-h-[680px] overflow-x-auto overflow-y-auto">
|
||||||
|
<table className="w-full min-w-[920px] text-left text-sm">
|
||||||
|
<thead className="sticky top-0 z-10 border-b border-border bg-card text-xs text-muted-foreground">
|
||||||
|
<tr>
|
||||||
|
<th className="px-5 py-3 font-medium">Type</th>
|
||||||
|
<th className="px-5 py-3 font-medium">Reason</th>
|
||||||
|
<th className="px-5 py-3 font-medium">Object</th>
|
||||||
|
<th className="px-5 py-3 font-medium">Message</th>
|
||||||
|
<th className="px-5 py-3 font-medium">Count</th>
|
||||||
|
<th className="px-5 py-3 font-medium">Last</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-border">
|
||||||
|
{filteredEvents.length === 0 ? (
|
||||||
|
<tr><td colSpan={6} className="px-5 py-14 text-center text-muted-foreground">표시할 이벤트가 없습니다.</td></tr>
|
||||||
|
) : null}
|
||||||
|
{filteredEvents.map((event, index) => (
|
||||||
|
<tr key={`${event.involvedObject}-${event.reason}-${index}`} className="hover:bg-muted/20">
|
||||||
|
<td className="px-5 py-3"><Badge tone={eventTone(event.type)}>{event.type ?? '-'}</Badge></td>
|
||||||
|
<td className="px-5 py-3 font-medium">{event.reason ?? '-'}</td>
|
||||||
|
<td className="px-5 py-3 font-mono text-xs text-muted-foreground">{event.involvedObject ?? '-'}</td>
|
||||||
|
<td className="max-w-[460px] px-5 py-3 text-muted-foreground"><span className="block truncate">{event.message ?? '-'}</span></td>
|
||||||
|
<td className="px-5 py-3">{event.count ?? '-'}</td>
|
||||||
|
<td className="px-5 py-3 text-muted-foreground">{formatDateTime(event.lastTimestamp || event.firstTimestamp)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<LoadingModal open={loadingVisible} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,433 @@
|
|||||||
|
import { FileText, RotateCcw } from 'lucide-react';
|
||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { Badge, Button, Card, CardHeader, Modal } from '../../components/ui';
|
||||||
|
import { useToast } from '../../components/toast';
|
||||||
|
import { CommitAuthor } from '../../components/commit-author';
|
||||||
|
import { PageTitle } from '../../layout/AppLayout';
|
||||||
|
import { cn } from '../../lib/utils';
|
||||||
|
import type { GiteaRepo } from '../../types/application';
|
||||||
|
import {
|
||||||
|
getGiteaCommitDiff,
|
||||||
|
listGiteaCommitsPage,
|
||||||
|
listStudioRepos,
|
||||||
|
type GiteaCommit
|
||||||
|
} from '../launcher/launcher-api';
|
||||||
|
import { LoadingModal, useMinimumLoading } from '../operations/operation-utils';
|
||||||
|
import { listCommonAppPipelines, type CommonAppPipeline } from './common-app-api';
|
||||||
|
import { commonAppComponents, formatRepositoryName } from './common-app-utils';
|
||||||
|
|
||||||
|
type CommitRange = 'week' | 'month' | 'all';
|
||||||
|
|
||||||
|
const commitRangeLabels: Record<CommitRange, string> = {
|
||||||
|
week: '일주일',
|
||||||
|
month: '한달',
|
||||||
|
all: '전체'
|
||||||
|
};
|
||||||
|
|
||||||
|
const commitPageSize = 5;
|
||||||
|
const branchOptions = ['develop', 'release', 'main'];
|
||||||
|
|
||||||
|
function repoFullName(repo: GiteaRepo) {
|
||||||
|
return repo.fullName ?? repo.full_name ?? repo.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
function repoHtmlUrl(repo?: GiteaRepo) {
|
||||||
|
return repo?.htmlUrl ?? repo?.html_url ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function splitRepository(repository: string) {
|
||||||
|
const [owner, ...repoParts] = repository.split('/');
|
||||||
|
const repo = repoParts.join('/');
|
||||||
|
return owner && repo ? { owner, repo } : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function commitAuthor(commit: GiteaCommit) {
|
||||||
|
return commit.author?.login
|
||||||
|
|| commit.author?.username
|
||||||
|
|| commit.author?.full_name
|
||||||
|
|| commit.author?.name
|
||||||
|
|| commit.commit?.author?.name
|
||||||
|
|| commit.committer?.login
|
||||||
|
|| '-';
|
||||||
|
}
|
||||||
|
|
||||||
|
function commitMessage(commit: GiteaCommit) {
|
||||||
|
return (commit.commit?.message || '').split(/\r?\n/)[0]?.trim() || '-';
|
||||||
|
}
|
||||||
|
|
||||||
|
function commitDate(commit: GiteaCommit) {
|
||||||
|
return commit.commit?.author?.date
|
||||||
|
|| commit.commit?.committer?.date
|
||||||
|
|| commit.created
|
||||||
|
|| '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function relativeTime(value?: string | null) {
|
||||||
|
if (!value) return '-';
|
||||||
|
const time = new Date(value).getTime();
|
||||||
|
if (Number.isNaN(time)) return '-';
|
||||||
|
const seconds = Math.max(1, Math.floor((Date.now() - time) / 1000));
|
||||||
|
if (seconds < 60) return '방금 전';
|
||||||
|
const minutes = Math.floor(seconds / 60);
|
||||||
|
if (minutes < 60) return `${minutes}분 전`;
|
||||||
|
const hours = Math.floor(minutes / 60);
|
||||||
|
if (hours < 24) return `${hours}시간 전`;
|
||||||
|
const days = Math.floor(hours / 24);
|
||||||
|
if (days === 1) return '어제';
|
||||||
|
if (days < 7) return `${days}일 전`;
|
||||||
|
return new Intl.DateTimeFormat('ko-KR', { month: '2-digit', day: '2-digit' }).format(new Date(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function commitRangeSince(range: CommitRange) {
|
||||||
|
if (range === 'all') return undefined;
|
||||||
|
const date = new Date();
|
||||||
|
date.setDate(date.getDate() - (range === 'week' ? 7 : 30));
|
||||||
|
return date.toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function diffLineClass(line: string) {
|
||||||
|
if (line.startsWith('+++') || line.startsWith('---')) return 'bg-[#111827] text-sky-200';
|
||||||
|
if (line.startsWith('@@')) return 'bg-[#1e1b4b] text-violet-100';
|
||||||
|
if (line.startsWith('+')) return 'bg-[#123524] text-emerald-50';
|
||||||
|
if (line.startsWith('-')) return 'bg-[#3a1418] text-rose-50';
|
||||||
|
if (line.startsWith('diff --git') || line.startsWith('index ') || line.startsWith('new file mode') || line.startsWith('deleted file mode')) {
|
||||||
|
return 'bg-[#0f172a] text-slate-200';
|
||||||
|
}
|
||||||
|
return 'text-slate-300';
|
||||||
|
}
|
||||||
|
|
||||||
|
function DiffViewer({ diff }: { diff: string }) {
|
||||||
|
const lines = diff.trim() ? diff.split(/\r?\n/) : ['표시할 변경 내역이 없습니다.'];
|
||||||
|
return (
|
||||||
|
<div className="max-h-[64vh] overflow-auto rounded-md border border-border bg-[#020617] font-mono text-xs">
|
||||||
|
{lines.map((line, index) => (
|
||||||
|
<div key={`${index}-${line}`} className={`grid grid-cols-[64px_minmax(0,1fr)] ${diffLineClass(line)}`}>
|
||||||
|
<span className="select-none border-r border-white/10 px-3 py-0.5 text-right text-slate-500">{index + 1}</span>
|
||||||
|
<span className="whitespace-pre px-3 py-0.5">{line || ' '}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CommonAppSourcePage() {
|
||||||
|
const { toast } = useToast();
|
||||||
|
const [repos, setRepos] = useState<GiteaRepo[]>([]);
|
||||||
|
const [pipelines, setPipelines] = useState<CommonAppPipeline[]>([]);
|
||||||
|
const [selectedRepository, setSelectedRepository] = useState(commonAppComponents[0]?.repository ?? '');
|
||||||
|
const [selectedBranch, setSelectedBranch] = useState('develop');
|
||||||
|
const [commitRange, setCommitRange] = useState<CommitRange>('week');
|
||||||
|
const [commitPage, setCommitPage] = useState(1);
|
||||||
|
const [commits, setCommits] = useState<GiteaCommit[]>([]);
|
||||||
|
const [totalCommits, setTotalCommits] = useState<number | null>(null);
|
||||||
|
const [hasNextCommitPage, setHasNextCommitPage] = useState(false);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [commitsLoading, setCommitsLoading] = useState(false);
|
||||||
|
const [commitsError, setCommitsError] = useState('');
|
||||||
|
const [diffTarget, setDiffTarget] = useState<GiteaCommit | null>(null);
|
||||||
|
const [diffContent, setDiffContent] = useState('');
|
||||||
|
const [diffLoading, setDiffLoading] = useState(false);
|
||||||
|
const [diffError, setDiffError] = useState('');
|
||||||
|
const loadingVisible = useMinimumLoading(loading);
|
||||||
|
|
||||||
|
const pipelineByRepo = useMemo(() => new Map(pipelines.map((pipeline) => [pipeline.repository, pipeline])), [pipelines]);
|
||||||
|
const knownCommonRepos = useMemo(() => new Set(commonAppComponents.map((component) => component.repository)), []);
|
||||||
|
const sortedRepos = useMemo(() => {
|
||||||
|
return [...repos].sort((a, b) => {
|
||||||
|
const aKnown = knownCommonRepos.has(repoFullName(a)) ? 0 : 1;
|
||||||
|
const bKnown = knownCommonRepos.has(repoFullName(b)) ? 0 : 1;
|
||||||
|
if (aKnown !== bKnown) return aKnown - bKnown;
|
||||||
|
return repoFullName(a).localeCompare(repoFullName(b));
|
||||||
|
});
|
||||||
|
}, [knownCommonRepos, repos]);
|
||||||
|
const selectedRepo = useMemo(() => repos.find((repo) => repoFullName(repo) === selectedRepository), [repos, selectedRepository]);
|
||||||
|
const selectedPipeline = pipelineByRepo.get(selectedRepository);
|
||||||
|
const repositoryParts = useMemo(() => splitRepository(selectedRepository), [selectedRepository]);
|
||||||
|
const totalCommitPages = totalCommits === null ? null : Math.max(1, Math.ceil(totalCommits / commitPageSize));
|
||||||
|
|
||||||
|
const refresh = () => {
|
||||||
|
setLoading(true);
|
||||||
|
Promise.all([
|
||||||
|
listStudioRepos(100),
|
||||||
|
listCommonAppPipelines()
|
||||||
|
])
|
||||||
|
.then(([nextRepos, nextPipelines]) => {
|
||||||
|
setRepos(nextRepos);
|
||||||
|
setPipelines(nextPipelines);
|
||||||
|
if (!nextRepos.some((repo) => repoFullName(repo) === selectedRepository)) {
|
||||||
|
setSelectedRepository(repoFullName(nextRepos.find((repo) => knownCommonRepos.has(repoFullName(repo))) ?? nextRepos[0]) || '');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((error: Error) => {
|
||||||
|
toast({ type: 'error', title: '공통앱 소스 조회 실패', message: error.message });
|
||||||
|
})
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
refresh();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setCommitPage(1);
|
||||||
|
}, [selectedRepository, selectedBranch, commitRange]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let canceled = false;
|
||||||
|
if (!repositoryParts) {
|
||||||
|
setCommits([]);
|
||||||
|
setTotalCommits(null);
|
||||||
|
setHasNextCommitPage(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setCommitsLoading(true);
|
||||||
|
setCommitsError('');
|
||||||
|
listGiteaCommitsPage(repositoryParts.owner, repositoryParts.repo, selectedBranch, {
|
||||||
|
page: commitPage,
|
||||||
|
limit: commitPageSize,
|
||||||
|
since: commitRangeSince(commitRange)
|
||||||
|
})
|
||||||
|
.then((result) => {
|
||||||
|
if (canceled) return;
|
||||||
|
setCommits(result.commits);
|
||||||
|
setTotalCommits(result.total);
|
||||||
|
setHasNextCommitPage(result.total === null ? result.commits.length === commitPageSize : commitPage < Math.max(1, Math.ceil(result.total / commitPageSize)));
|
||||||
|
})
|
||||||
|
.catch((error: Error) => {
|
||||||
|
if (canceled) return;
|
||||||
|
setCommits([]);
|
||||||
|
setTotalCommits(null);
|
||||||
|
setHasNextCommitPage(false);
|
||||||
|
setCommitsError(error.message);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (!canceled) setCommitsLoading(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
canceled = true;
|
||||||
|
};
|
||||||
|
}, [commitPage, commitRange, repositoryParts?.owner, repositoryParts?.repo, selectedBranch]);
|
||||||
|
|
||||||
|
const openCommitDiff = (commit: GiteaCommit) => {
|
||||||
|
if (!repositoryParts) return;
|
||||||
|
setDiffTarget(commit);
|
||||||
|
setDiffContent('');
|
||||||
|
setDiffError('');
|
||||||
|
setDiffLoading(true);
|
||||||
|
getGiteaCommitDiff(repositoryParts.owner, repositoryParts.repo, commit.sha)
|
||||||
|
.then((diff) => setDiffContent(diff))
|
||||||
|
.catch((error: Error) => setDiffError(error.message))
|
||||||
|
.finally(() => setDiffLoading(false));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageTitle
|
||||||
|
eyebrow="공통앱"
|
||||||
|
title="소스 관리"
|
||||||
|
description="Gitea 저장소와 브랜치별 커밋 내역을 확인합니다."
|
||||||
|
actions={
|
||||||
|
<Button variant="outline" className="h-8 px-3 text-[13px]" onClick={refresh} disabled={loading}>
|
||||||
|
<RotateCcw className={cn('size-4', loading && 'animate-spin')} />
|
||||||
|
새로고침
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="grid gap-4 xl:grid-cols-[minmax(320px,0.8fr)_minmax(0,1.2fr)]">
|
||||||
|
<Card className="min-w-0 overflow-hidden">
|
||||||
|
<CardHeader title="Gitea 저장소" description="studio 조직 저장소 기준입니다." />
|
||||||
|
<div className="max-h-[520px] overflow-y-auto">
|
||||||
|
{sortedRepos.length === 0 && !loading ? (
|
||||||
|
<div className="flex min-h-36 items-center justify-center px-5 text-sm text-muted-foreground">표시할 저장소가 없습니다.</div>
|
||||||
|
) : null}
|
||||||
|
<div className="divide-y divide-border">
|
||||||
|
{sortedRepos.map((repo) => {
|
||||||
|
const fullName = repoFullName(repo);
|
||||||
|
const selected = fullName === selectedRepository;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={fullName}
|
||||||
|
type="button"
|
||||||
|
className={cn(
|
||||||
|
'block w-full px-5 py-4 text-left transition-colors hover:bg-muted/20',
|
||||||
|
selected && 'bg-primary/20 shadow-[inset_4px_0_0_var(--color-primary)] hover:bg-primary/25'
|
||||||
|
)}
|
||||||
|
onClick={() => setSelectedRepository(fullName)}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="truncate text-sm font-semibold">{formatRepositoryName(fullName)}</p>
|
||||||
|
<p className="mt-1 truncate text-xs text-muted-foreground">{repo.private ? 'Private' : 'Public'}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="min-w-0 overflow-hidden">
|
||||||
|
<CardHeader title="소스 기준" description="커밋 내역을 확인할 저장소와 브랜치입니다." />
|
||||||
|
<div className="grid gap-4 p-5 md:grid-cols-2">
|
||||||
|
<div className="rounded-md border border-border bg-muted/20 p-4">
|
||||||
|
<p className="text-xs text-muted-foreground">저장소</p>
|
||||||
|
<p className="mt-2 truncate text-sm font-semibold">{selectedRepository ? formatRepositoryName(selectedRepository) : '-'}</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-md border border-border bg-muted/20 p-4">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<p className="text-xs text-muted-foreground">CI/CD</p>
|
||||||
|
<Badge tone={selectedPipeline ? 'green' : 'neutral'}>{selectedPipeline ? '연결됨' : '미연결'}</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 space-y-2">
|
||||||
|
<div className="flex items-center justify-between gap-3 text-sm">
|
||||||
|
<span className="text-muted-foreground">도구</span>
|
||||||
|
<span className="font-semibold text-foreground">{selectedPipeline ? 'Woodpecker CI' : '-'}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between gap-3 text-sm">
|
||||||
|
<span className="text-muted-foreground">서버</span>
|
||||||
|
<span className="min-w-0 truncate font-mono text-xs font-medium text-foreground">{selectedPipeline?.ciUrl || '-'}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-muted-foreground">조회 브랜치</p>
|
||||||
|
<select
|
||||||
|
className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm font-medium text-foreground outline-none focus:ring-2 focus:ring-ring"
|
||||||
|
value={selectedBranch}
|
||||||
|
onChange={(event) => setSelectedBranch(event.target.value)}
|
||||||
|
>
|
||||||
|
{branchOptions.map((option) => (
|
||||||
|
<option key={option} value={option}>{option}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-muted-foreground">Gitea URL</p>
|
||||||
|
<div className="mt-2 flex h-9 items-center gap-2 rounded-md border border-border bg-background px-3">
|
||||||
|
<p className="min-w-0 flex-1 truncate text-xs text-muted-foreground">{repoHtmlUrl(selectedRepo) || '-'}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card className="mt-4 min-w-0 overflow-hidden">
|
||||||
|
<CardHeader
|
||||||
|
title="커밋 목록"
|
||||||
|
description={`${selectedRepository ? formatRepositoryName(selectedRepository) : '-'} 저장소의 ${selectedBranch} 브랜치 기준 커밋 내역입니다.`}
|
||||||
|
action={
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{(Object.keys(commitRangeLabels) as CommitRange[]).map((range) => (
|
||||||
|
<Button
|
||||||
|
key={range}
|
||||||
|
variant={commitRange === range ? 'default' : 'outline'}
|
||||||
|
className="h-8 px-3 text-[13px]"
|
||||||
|
onClick={() => setCommitRange(range)}
|
||||||
|
>
|
||||||
|
{commitRangeLabels[range]}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<div className="min-h-[284px] overflow-x-auto">
|
||||||
|
<table className="w-full min-w-[760px] text-left text-sm">
|
||||||
|
<thead className="border-y border-border bg-muted/30 text-xs text-muted-foreground">
|
||||||
|
<tr>
|
||||||
|
<th className="px-5 py-3 font-medium">작성자</th>
|
||||||
|
<th className="px-5 py-3 font-medium">SHA1</th>
|
||||||
|
<th className="px-5 py-3 font-medium">메시지</th>
|
||||||
|
<th className="px-5 py-3 font-medium">날짜</th>
|
||||||
|
<th className="w-16 px-5 py-3 text-right font-medium">상세</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{commitsLoading ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={5} className="h-[240px] px-5 py-10 text-center text-sm text-muted-foreground">커밋을 불러오는 중입니다.</td>
|
||||||
|
</tr>
|
||||||
|
) : null}
|
||||||
|
{!commitsLoading && commitsError ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={5} className="h-[240px] px-5 py-10 text-center text-sm text-muted-foreground">커밋 조회 실패: {commitsError}</td>
|
||||||
|
</tr>
|
||||||
|
) : null}
|
||||||
|
{!commitsLoading && !commitsError && commits.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={5} className="h-[240px] px-5 py-10 text-center text-sm text-muted-foreground">표시할 커밋이 없습니다.</td>
|
||||||
|
</tr>
|
||||||
|
) : null}
|
||||||
|
{!commitsLoading && !commitsError ? commits.map((commit) => (
|
||||||
|
<tr key={commit.sha} className="border-b border-border last:border-0">
|
||||||
|
<td className="whitespace-nowrap px-5 py-3"><CommitAuthor commit={commit} /></td>
|
||||||
|
<td className="whitespace-nowrap px-5 py-3 font-mono text-xs text-muted-foreground">{commit.sha.slice(0, 10)}</td>
|
||||||
|
<td className="max-w-[360px] truncate px-5 py-3">{commitMessage(commit)}</td>
|
||||||
|
<td className="whitespace-nowrap px-5 py-3 text-muted-foreground">{relativeTime(commitDate(commit))}</td>
|
||||||
|
<td className="whitespace-nowrap px-5 py-3 text-right">
|
||||||
|
<Button variant="outline" className="size-8 px-0" title="내역 상세" aria-label="내역 상세" onClick={() => openCommitDiff(commit)}>
|
||||||
|
<FileText className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)) : null}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between gap-3 border-t border-border px-5 py-4 text-sm">
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{totalCommits === null ? `페이지 ${commitPage}` : `총 ${totalCommits}건 · ${commitPage} / ${totalCommitPages}`}
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button variant="outline" className="h-8 px-3 text-[13px]" disabled={commitPage <= 1 || commitsLoading} onClick={() => setCommitPage((page) => Math.max(1, page - 1))}>
|
||||||
|
이전
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="h-8 px-3 text-[13px]"
|
||||||
|
disabled={commitsLoading || (totalCommitPages !== null ? commitPage >= totalCommitPages : !hasNextCommitPage)}
|
||||||
|
onClick={() => setCommitPage((page) => totalCommitPages === null ? page + 1 : Math.min(totalCommitPages, page + 1))}
|
||||||
|
>
|
||||||
|
다음
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
open={Boolean(diffTarget)}
|
||||||
|
title="커밋 내역 상세"
|
||||||
|
onClose={() => setDiffTarget(null)}
|
||||||
|
className="!max-w-[1400px]"
|
||||||
|
bodyClassName="max-h-[70vh] px-4 py-4"
|
||||||
|
footer={<Button variant="outline" onClick={() => setDiffTarget(null)}>닫기</Button>}
|
||||||
|
>
|
||||||
|
{diffTarget ? (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="rounded-md border border-border bg-muted/20 px-4 py-3 text-sm">
|
||||||
|
<div className="flex flex-wrap items-center gap-x-3 gap-y-1">
|
||||||
|
<span className="font-mono text-xs text-muted-foreground">{diffTarget.sha.slice(0, 10)}</span>
|
||||||
|
<span className="font-medium">{commitMessage(diffTarget)}</span>
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
|
{commitAuthor(diffTarget)} · {relativeTime(commitDate(diffTarget))}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{diffLoading ? (
|
||||||
|
<div className="flex min-h-40 items-center justify-center text-sm text-muted-foreground">Diff를 불러오는 중입니다.</div>
|
||||||
|
) : diffError ? (
|
||||||
|
<div className="rounded-md border border-rose-500/30 bg-rose-500/10 px-4 py-3 text-sm text-rose-200">Diff 조회 실패: {diffError}</div>
|
||||||
|
) : (
|
||||||
|
<DiffViewer diff={diffContent} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<LoadingModal open={loadingVisible} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { apiRequest } from '../../lib/api';
|
||||||
|
|
||||||
|
export type CommonAppPipeline = {
|
||||||
|
id: string;
|
||||||
|
repoId?: string | null;
|
||||||
|
repository: string;
|
||||||
|
number?: number | null;
|
||||||
|
message: string;
|
||||||
|
branch: string;
|
||||||
|
commitSha?: string | null;
|
||||||
|
durationSeconds?: number | null;
|
||||||
|
status: string;
|
||||||
|
createdAt: number;
|
||||||
|
ciUrl?: string | null;
|
||||||
|
url?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function listCommonAppPipelines() {
|
||||||
|
return apiRequest<{ repos?: CommonAppPipeline[]; baseUrl?: string | null }>('/woodpecker/repos')
|
||||||
|
.then((result) => (result.repos ?? []).map((repo) => ({ ...repo, ciUrl: repo.ciUrl ?? result.baseUrl ?? null })));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listCommonAppPipelineHistory(repoId: string, page = 1, limit = 5) {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
page: String(page),
|
||||||
|
limit: String(limit)
|
||||||
|
});
|
||||||
|
return apiRequest<{
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
total: number | null;
|
||||||
|
totalPages: number | null;
|
||||||
|
hasNext: boolean;
|
||||||
|
pipelines: CommonAppPipeline[];
|
||||||
|
}>(`/woodpecker/repos/${encodeURIComponent(repoId)}/pipelines?${params.toString()}`);
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import { statusDotToneClass } from '../../lib/status-dot';
|
||||||
|
|
||||||
|
export const COMMON_APP_NAMESPACE = 'studio-system';
|
||||||
|
|
||||||
|
export type CommonAppComponent = {
|
||||||
|
key: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
repository: string;
|
||||||
|
kind: 'Deployment';
|
||||||
|
workloadName: string;
|
||||||
|
serviceName?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const commonAppComponents: CommonAppComponent[] = [
|
||||||
|
{
|
||||||
|
key: 'studio-admin-fe',
|
||||||
|
name: 'Admin FE',
|
||||||
|
description: '관리자 웹 콘솔 프론트엔드입니다.',
|
||||||
|
repository: 'studio/studio-admin-fe',
|
||||||
|
kind: 'Deployment',
|
||||||
|
workloadName: 'studio-admin-fe',
|
||||||
|
serviceName: 'studio-admin-fe'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'studio-admin-be',
|
||||||
|
name: 'Admin BE',
|
||||||
|
description: '관리자 웹 콘솔 API 서버입니다.',
|
||||||
|
repository: 'studio/studio-admin-be',
|
||||||
|
kind: 'Deployment',
|
||||||
|
workloadName: 'studio-admin-be',
|
||||||
|
serviceName: 'studio-admin-be'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'studio-auth-be',
|
||||||
|
name: 'Auth BE',
|
||||||
|
description: '공통 인증, 사용자, 라이센스 API 서버입니다.',
|
||||||
|
repository: 'studio/studio-auth-be',
|
||||||
|
kind: 'Deployment',
|
||||||
|
workloadName: 'studio-auth-be',
|
||||||
|
serviceName: 'studio-auth-be'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'platform-gateway',
|
||||||
|
name: 'Platform Gateway',
|
||||||
|
description: 'Headscale, Agent, 클러스터 연동용 게이트웨이입니다.',
|
||||||
|
repository: 'studio/platform-gateway',
|
||||||
|
kind: 'Deployment',
|
||||||
|
workloadName: 'platform-gateway',
|
||||||
|
serviceName: 'platform-gateway'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
export function formatPipelineTime(value?: number | null) {
|
||||||
|
if (!value) return '-';
|
||||||
|
const ms = value < 10_000_000_000 ? value * 1000 : value;
|
||||||
|
return new Date(ms).toLocaleString();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatPipelineRelativeTime(value?: number | null) {
|
||||||
|
if (!value) return '-';
|
||||||
|
const ms = value < 10_000_000_000 ? value * 1000 : value;
|
||||||
|
const seconds = Math.max(0, Math.floor((Date.now() - ms) / 1000));
|
||||||
|
if (seconds < 60) return '방금 전';
|
||||||
|
const minutes = Math.floor(seconds / 60);
|
||||||
|
if (minutes < 60) return `${minutes}분 전`;
|
||||||
|
const hours = Math.floor(minutes / 60);
|
||||||
|
if (hours < 24) return `${hours}시간 전`;
|
||||||
|
const days = Math.floor(hours / 24);
|
||||||
|
if (days < 30) return `${days}일 전`;
|
||||||
|
return new Intl.DateTimeFormat('ko-KR', { month: 'short', day: 'numeric' }).format(new Date(ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatRepositoryName(repository: string) {
|
||||||
|
return repository.replace('/', ' / ');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatShortSha(value?: string | null) {
|
||||||
|
if (!value) return '-';
|
||||||
|
return value.length > 8 ? value.slice(0, 8) : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDuration(seconds?: number | null) {
|
||||||
|
if (seconds === null || seconds === undefined || !Number.isFinite(seconds)) return '-';
|
||||||
|
if (seconds < 60) return `${seconds}초`;
|
||||||
|
const minutes = Math.floor(seconds / 60);
|
||||||
|
const remainingSeconds = seconds % 60;
|
||||||
|
if (minutes < 60) return remainingSeconds ? `${minutes}분 ${remainingSeconds}초` : `${minutes}분`;
|
||||||
|
const hours = Math.floor(minutes / 60);
|
||||||
|
const remainingMinutes = minutes % 60;
|
||||||
|
return remainingMinutes ? `${hours}시간 ${remainingMinutes}분` : `${hours}시간`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pipelineStatusTone(status?: string | null): 'green' | 'amber' | 'red' | 'blue' | 'neutral' {
|
||||||
|
const value = (status ?? '').toLowerCase();
|
||||||
|
if (['success', 'succeeded', 'passed'].includes(value)) return 'green';
|
||||||
|
if (['running', 'started'].includes(value)) return 'blue';
|
||||||
|
if (['pending', 'queued', 'blocked', 'created'].includes(value)) return 'amber';
|
||||||
|
if (['failure', 'failed', 'error', 'killed', 'declined'].includes(value)) return 'red';
|
||||||
|
return 'neutral';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pipelineStatusDotClass(status?: string | null) {
|
||||||
|
const value = (status ?? '').toLowerCase();
|
||||||
|
if (['success', 'succeeded', 'passed'].includes(value)) return statusDotToneClass('green');
|
||||||
|
if (['running', 'started'].includes(value)) return statusDotToneClass('blue');
|
||||||
|
if (['pending', 'queued', 'blocked', 'created'].includes(value)) return statusDotToneClass('amber');
|
||||||
|
if (['failure', 'failed', 'error', 'killed', 'declined'].includes(value)) return statusDotToneClass('red');
|
||||||
|
return statusDotToneClass('neutral');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isActivePipelineStatus(status?: string | null) {
|
||||||
|
const value = (status ?? '').toLowerCase();
|
||||||
|
return ['running', 'started', 'pending', 'queued', 'blocked', 'created'].includes(value);
|
||||||
|
}
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
import { Activity, Boxes, ExternalLink, FileKey, GitBranch, RefreshCw } from 'lucide-react';
|
import { Activity, Boxes, ChevronRight, FileKey } from 'lucide-react';
|
||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { Badge, Button, Card, CardHeader } from '../../components/ui';
|
import { Badge, Button, Card, CardHeader } from '../../components/ui';
|
||||||
import { PageTitle } from '../../layout/AppLayout';
|
import { PageTitle } from '../../layout/AppLayout';
|
||||||
import { apiRequest } from '../../lib/api';
|
import { apiRequest } from '../../lib/api';
|
||||||
import { cn } from '../../lib/utils';
|
|
||||||
|
|
||||||
type CommonAppPipeline = {
|
type CommonAppPipeline = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -43,6 +43,7 @@ async function loadCommonAppPipelines(): Promise<CommonAppPipeline[]> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function DashboardPage() {
|
export function DashboardPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
const [commonApps, setCommonApps] = useState<CommonAppPipeline[]>([]);
|
const [commonApps, setCommonApps] = useState<CommonAppPipeline[]>([]);
|
||||||
const [commonAppsLoading, setCommonAppsLoading] = useState(true);
|
const [commonAppsLoading, setCommonAppsLoading] = useState(true);
|
||||||
const [commonAppsFailed, setCommonAppsFailed] = useState(false);
|
const [commonAppsFailed, setCommonAppsFailed] = useState(false);
|
||||||
@@ -113,8 +114,8 @@ export function DashboardPage() {
|
|||||||
title="공통앱"
|
title="공통앱"
|
||||||
description="공통앱의 최근 빌드 상태를 확인합니다."
|
description="공통앱의 최근 빌드 상태를 확인합니다."
|
||||||
action={
|
action={
|
||||||
<Button variant="outline" className="size-8 px-0" onClick={refreshCommonApps} disabled={commonAppsLoading} title="새로고침" aria-label="새로고침">
|
<Button variant="ghost" className="size-8 px-0 text-muted-foreground hover:text-foreground" onClick={() => navigate('/common-apps/builds')} title="공통앱 빌드 관리로 이동" aria-label="공통앱 빌드 관리로 이동">
|
||||||
<RefreshCw className={cn('size-4', commonAppsLoading && 'animate-spin')} />
|
<ChevronRight className="size-4" />
|
||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -132,16 +133,10 @@ export function DashboardPage() {
|
|||||||
<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 min-w-0 items-center gap-2">
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
<GitBranch className="size-3.5 shrink-0 text-muted-foreground" />
|
|
||||||
<p className="truncate text-sm font-semibold">{pipeline.repository}</p>
|
<p className="truncate text-sm font-semibold">{pipeline.repository}</p>
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-1 truncate text-sm text-muted-foreground">{pipeline.message}</p>
|
<p className="mt-1 truncate text-sm text-muted-foreground">{pipeline.message}</p>
|
||||||
</div>
|
</div>
|
||||||
{pipeline.url ? (
|
|
||||||
<a href={pipeline.url} target="_blank" rel="noreferrer" className="shrink-0 text-muted-foreground hover:text-foreground" aria-label="Woodpecker 저장소 열기">
|
|
||||||
<ExternalLink className="size-4" />
|
|
||||||
</a>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-3 flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
<div className="mt-3 flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
||||||
<Badge tone={pipelineTone(pipeline.status)}>{pipeline.status}</Badge>
|
<Badge tone={pipelineTone(pipeline.status)}>{pipeline.status}</Badge>
|
||||||
|
|||||||
@@ -0,0 +1,374 @@
|
|||||||
|
import { FileCode2, RotateCcw } from 'lucide-react';
|
||||||
|
import { useCallback, useEffect, useMemo, useState, type ReactNode } from 'react';
|
||||||
|
import { DataTableCard, type DataTableColumn, type DataTableFilter } from '../../components/data-table';
|
||||||
|
import { Badge, Button, Card, CardHeader, Modal } from '../../components/ui';
|
||||||
|
import { PageTitle } from '../../layout/AppLayout';
|
||||||
|
import { runtimeStatusDotClass } from '../../lib/status-dot';
|
||||||
|
import { cn } from '../../lib/utils';
|
||||||
|
import type { RuntimePodMetric, RuntimeResource, RuntimeResourceResponse } from '../../types/runtime';
|
||||||
|
import { loadApps, loadRuntimePodMetrics, loadRuntimeResourceYaml, loadRuntimeResources } from '../runtime/runtime-api';
|
||||||
|
import { LoadingModal, useMinimumLoading } from '../operations/operation-utils';
|
||||||
|
|
||||||
|
const defaultNamespaces = ['studio-system', 'stdo-lms', 'stdo-ads-ide'];
|
||||||
|
|
||||||
|
type NamespaceRuntime = RuntimeResourceResponse & {
|
||||||
|
failed?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type InfrastructureData = {
|
||||||
|
namespaces: string[];
|
||||||
|
runtimes: NamespaceRuntime[];
|
||||||
|
metrics: RuntimePodMetric[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type ResourceRow = RuntimeResource & {
|
||||||
|
id: string;
|
||||||
|
namespace: string;
|
||||||
|
metric?: RuntimePodMetric;
|
||||||
|
};
|
||||||
|
|
||||||
|
function uniqueNamespaces(values: Array<string | null | undefined>) {
|
||||||
|
return [...new Set(values.filter(Boolean) as string[])].sort((a, b) => a.localeCompare(b));
|
||||||
|
}
|
||||||
|
|
||||||
|
function isWorkload(resource: RuntimeResource) {
|
||||||
|
return ['Deployment', 'StatefulSet', 'DaemonSet', 'Pod', 'Job', 'CronJob', 'ReplicaSet'].includes(resource.kind);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isNetwork(resource: RuntimeResource) {
|
||||||
|
return ['Service', 'Ingress', 'Endpoint', 'Endpoints'].includes(resource.kind);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isStorage(resource: RuntimeResource) {
|
||||||
|
return ['PersistentVolumeClaim', 'PVC', 'PersistentVolume', 'StorageClass'].includes(resource.kind);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resourceStatusTone(status: string): 'neutral' | 'green' | 'amber' | 'red' | 'blue' {
|
||||||
|
const normalized = status.toLowerCase();
|
||||||
|
if (['running', 'ready', 'active', 'bound'].some((word) => normalized.includes(word))) return 'green';
|
||||||
|
if (['pending', 'progressing', 'containercreating'].some((word) => normalized.includes(word))) return 'amber';
|
||||||
|
if (['failed', 'error', 'crash', 'unhealthy', 'notready'].some((word) => normalized.includes(word))) return 'red';
|
||||||
|
if (['completed', 'succeeded'].some((word) => normalized.includes(word))) return 'neutral';
|
||||||
|
return 'blue';
|
||||||
|
}
|
||||||
|
|
||||||
|
function metricFor(resource: ResourceRow, metrics: RuntimePodMetric[]) {
|
||||||
|
if (resource.kind !== 'Pod') return undefined;
|
||||||
|
return metrics.find((metric) => metric.namespace === resource.namespace && metric.pod === resource.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
function useInfrastructureData() {
|
||||||
|
const [data, setData] = useState<InfrastructureData>({ namespaces: defaultNamespaces, runtimes: [], metrics: [] });
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [failed, setFailed] = useState(false);
|
||||||
|
const loadingVisible = useMinimumLoading(loading);
|
||||||
|
|
||||||
|
const refresh = useCallback(() => {
|
||||||
|
setLoading(true);
|
||||||
|
Promise.all([
|
||||||
|
loadApps().catch(() => []),
|
||||||
|
loadRuntimePodMetrics().catch(() => [] as RuntimePodMetric[])
|
||||||
|
])
|
||||||
|
.then(async ([apps, metrics]) => {
|
||||||
|
const namespaces = uniqueNamespaces([...defaultNamespaces, ...apps.map((app) => app.namespace)]);
|
||||||
|
const runtimes = await Promise.all(
|
||||||
|
namespaces.map((namespace) => (
|
||||||
|
loadRuntimeResources(namespace)
|
||||||
|
.then((runtime) => runtime)
|
||||||
|
.catch(() => ({ namespace, exists: false, failed: true, summary: { pods: 0, deployments: 0, statefulSets: 0, services: 0, persistentVolumeClaims: 0 }, resources: [] }))
|
||||||
|
))
|
||||||
|
);
|
||||||
|
|
||||||
|
setData({ namespaces, runtimes, metrics });
|
||||||
|
setFailed(false);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
setFailed(true);
|
||||||
|
})
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
refresh();
|
||||||
|
}, [refresh]);
|
||||||
|
|
||||||
|
return { data, loading, loadingVisible, failed, refresh };
|
||||||
|
}
|
||||||
|
|
||||||
|
function rowsFromData(data: InfrastructureData, predicate: (resource: RuntimeResource) => boolean) {
|
||||||
|
return data.runtimes.flatMap((runtime) => (
|
||||||
|
runtime.resources
|
||||||
|
.filter(predicate)
|
||||||
|
.map((resource) => {
|
||||||
|
const row = {
|
||||||
|
...resource,
|
||||||
|
namespace: runtime.namespace,
|
||||||
|
id: `${runtime.namespace}:${resource.kind}:${resource.name}`
|
||||||
|
};
|
||||||
|
return { ...row, metric: metricFor(row, data.metrics) };
|
||||||
|
})
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
function ResourceStatus({ status }: { status: string }) {
|
||||||
|
return (
|
||||||
|
<div className="inline-flex items-center gap-2">
|
||||||
|
<span className={cn('size-2.5 shrink-0 rounded-full', runtimeStatusDotClass(status))} />
|
||||||
|
<Badge tone={resourceStatusTone(status)}>{status || '-'}</Badge>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function NamespaceFilter(rows: ResourceRow[]): DataTableFilter<ResourceRow> {
|
||||||
|
return {
|
||||||
|
key: 'namespace',
|
||||||
|
label: '네임스페이스',
|
||||||
|
options: uniqueNamespaces(rows.map((row) => row.namespace)).map((namespace) => ({ label: namespace, value: namespace })),
|
||||||
|
getValue: (row) => row.namespace
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function KindFilter(rows: ResourceRow[]): DataTableFilter<ResourceRow> {
|
||||||
|
return {
|
||||||
|
key: 'kind',
|
||||||
|
label: '종류',
|
||||||
|
options: uniqueNamespaces(rows.map((row) => row.kind)).map((kind) => ({ label: kind, value: kind })),
|
||||||
|
getValue: (row) => row.kind
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function InfraPageShell({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
children,
|
||||||
|
loading,
|
||||||
|
loadingVisible,
|
||||||
|
failed,
|
||||||
|
onRefresh
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
children: ReactNode;
|
||||||
|
loading: boolean;
|
||||||
|
loadingVisible: boolean;
|
||||||
|
failed: boolean;
|
||||||
|
onRefresh: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageTitle
|
||||||
|
eyebrow="인프라"
|
||||||
|
title={title}
|
||||||
|
description={description}
|
||||||
|
actions={
|
||||||
|
<Button variant="outline" className="h-8 px-3 text-[13px]" onClick={onRefresh} disabled={loading}>
|
||||||
|
<RotateCcw className={cn('size-4', loading && 'animate-spin')} />
|
||||||
|
새로고침
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{failed ? (
|
||||||
|
<div className="mb-4 rounded-md border border-rose-500/30 bg-rose-500/10 px-4 py-3 text-sm text-rose-200">
|
||||||
|
인프라 데이터를 불러오지 못했습니다.
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{children}
|
||||||
|
<LoadingModal open={loadingVisible} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ResourceTable({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
rows,
|
||||||
|
loading,
|
||||||
|
emptyMessage
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
rows: ResourceRow[];
|
||||||
|
loading: boolean;
|
||||||
|
emptyMessage: string;
|
||||||
|
}) {
|
||||||
|
const [yamlTarget, setYamlTarget] = useState<ResourceRow | null>(null);
|
||||||
|
const [yaml, setYaml] = useState('');
|
||||||
|
const [yamlLoading, setYamlLoading] = useState(false);
|
||||||
|
const [yamlError, setYamlError] = useState('');
|
||||||
|
|
||||||
|
const openYaml = (row: ResourceRow) => {
|
||||||
|
setYamlTarget(row);
|
||||||
|
setYaml('');
|
||||||
|
setYamlError('');
|
||||||
|
setYamlLoading(true);
|
||||||
|
loadRuntimeResourceYaml(row.namespace, row.kind, row.name)
|
||||||
|
.then((response) => setYaml(response.yaml))
|
||||||
|
.catch((error: unknown) => setYamlError(error instanceof Error ? error.message : 'YAML을 불러오지 못했습니다.'))
|
||||||
|
.finally(() => setYamlLoading(false));
|
||||||
|
};
|
||||||
|
|
||||||
|
const columns = useMemo<Array<DataTableColumn<ResourceRow>>>(
|
||||||
|
() => [
|
||||||
|
{ key: 'namespace', header: '네임스페이스', className: 'w-44 px-5 py-3', cell: (row) => <span className="font-medium">{row.namespace}</span> },
|
||||||
|
{ key: 'kind', header: '종류', className: 'w-40 px-5 py-3', cell: (row) => <Badge tone="neutral">{row.kind}</Badge> },
|
||||||
|
{ key: 'name', header: '이름', className: 'min-w-[240px] px-5 py-3', cell: (row) => <span className="font-medium">{row.name}</span> },
|
||||||
|
{ key: 'status', header: '상태', className: 'w-44 px-5 py-3', cell: (row) => <ResourceStatus status={row.status} /> },
|
||||||
|
{ key: 'ready', header: 'Ready', className: 'w-28 px-5 py-3', cell: (row) => <span className="text-muted-foreground">{row.ready || '-'}</span> },
|
||||||
|
{ key: 'restarts', header: '재시작', className: 'w-24 px-5 py-3', cell: (row) => <span className="text-muted-foreground">{row.restarts ?? '-'}</span> },
|
||||||
|
{ key: 'metric', header: '사용량', className: 'w-40 px-5 py-3', cell: (row) => <span className="text-xs text-muted-foreground">{row.metric ? `${row.metric.cpu} / ${row.metric.memory}` : '-'}</span> },
|
||||||
|
{ key: 'age', header: 'Age', className: 'w-28 px-5 py-3', cell: (row) => <span className="text-muted-foreground">{row.age || '-'}</span> },
|
||||||
|
{ key: 'detail', header: '상세', className: 'min-w-[220px] px-5 py-3', cell: (row) => <span className="text-xs text-muted-foreground">{row.detail || '-'}</span> },
|
||||||
|
{
|
||||||
|
key: 'yaml',
|
||||||
|
header: '',
|
||||||
|
className: 'w-14 px-3 py-3 text-right',
|
||||||
|
cell: (row) => (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="size-8 px-0"
|
||||||
|
title="YAML 보기"
|
||||||
|
aria-label="YAML 보기"
|
||||||
|
onClick={() => openYaml(row)}
|
||||||
|
>
|
||||||
|
<FileCode2 className="size-4" />
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const filters = useMemo(() => [NamespaceFilter(rows), KindFilter(rows)], [rows]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<DataTableCard
|
||||||
|
title={title}
|
||||||
|
description={description}
|
||||||
|
rows={rows}
|
||||||
|
columns={columns}
|
||||||
|
filters={filters}
|
||||||
|
searchPlaceholder="네임스페이스, 이름, 상태 검색"
|
||||||
|
getSearchText={(row) => `${row.namespace} ${row.kind} ${row.name} ${row.status} ${row.detail}`}
|
||||||
|
getRowKey={(row) => row.id}
|
||||||
|
pageSize={10}
|
||||||
|
minWidth={1320}
|
||||||
|
loading={loading}
|
||||||
|
emptyMessage={emptyMessage}
|
||||||
|
/>
|
||||||
|
<Modal
|
||||||
|
open={Boolean(yamlTarget)}
|
||||||
|
title={yamlTarget ? `${yamlTarget.kind}/${yamlTarget.name}` : 'YAML'}
|
||||||
|
onClose={() => setYamlTarget(null)}
|
||||||
|
className="!max-w-5xl"
|
||||||
|
>
|
||||||
|
{yamlLoading ? <p className="py-10 text-center text-sm text-muted-foreground">불러오는 중...</p> : null}
|
||||||
|
{yamlError ? <p className="py-4 text-sm text-rose-300">{yamlError}</p> : null}
|
||||||
|
{!yamlLoading && !yamlError ? (
|
||||||
|
<pre className="max-h-[65vh] overflow-auto rounded-md border border-border bg-black/30 p-4 text-xs leading-6 text-slate-200">{yaml}</pre>
|
||||||
|
) : null}
|
||||||
|
</Modal>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function InfrastructureClusterPage() {
|
||||||
|
const { data, loading, loadingVisible, failed, refresh } = useInfrastructureData();
|
||||||
|
|
||||||
|
const totals = useMemo(() => data.runtimes.reduce(
|
||||||
|
(acc, runtime) => ({
|
||||||
|
namespaces: acc.namespaces + 1,
|
||||||
|
resources: acc.resources + runtime.resources.length,
|
||||||
|
pods: acc.pods + runtime.summary.pods,
|
||||||
|
deployments: acc.deployments + runtime.summary.deployments,
|
||||||
|
services: acc.services + runtime.summary.services,
|
||||||
|
pvc: acc.pvc + runtime.summary.persistentVolumeClaims,
|
||||||
|
failed: acc.failed + (runtime.failed || !runtime.exists ? 1 : 0)
|
||||||
|
}),
|
||||||
|
{ namespaces: 0, resources: 0, pods: 0, deployments: 0, services: 0, pvc: 0, failed: 0 }
|
||||||
|
), [data.runtimes]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<InfraPageShell title="클러스터" description="네임스페이스별 런타임 리소스 요약을 조회합니다." loading={loading} loadingVisible={loadingVisible} failed={failed} onRefresh={refresh}>
|
||||||
|
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-6">
|
||||||
|
{[
|
||||||
|
['네임스페이스', totals.namespaces],
|
||||||
|
['전체 리소스', totals.resources],
|
||||||
|
['Pod', totals.pods],
|
||||||
|
['Deployment', totals.deployments],
|
||||||
|
['Service', totals.services],
|
||||||
|
['PVC', totals.pvc]
|
||||||
|
].map(([label, value]) => (
|
||||||
|
<Card key={label} className="px-5 py-4">
|
||||||
|
<p className="text-xs font-medium text-muted-foreground">{label}</p>
|
||||||
|
<p className="mt-2 text-2xl font-semibold text-foreground">{value}</p>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card className="mt-4 overflow-hidden">
|
||||||
|
<CardHeader title="네임스페이스" description="조회 가능한 네임스페이스의 리소스 구성입니다." />
|
||||||
|
<div className="divide-y divide-border">
|
||||||
|
{data.runtimes.map((runtime) => (
|
||||||
|
<div key={runtime.namespace} className="grid gap-3 px-5 py-4 text-sm md:grid-cols-[minmax(180px,1fr)_repeat(5,minmax(80px,auto))] md:items-center">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className={cn('size-2.5 rounded-full', runtime.failed || !runtime.exists ? 'bg-rose-400 shadow-[0_0_0_3px_rgba(251,113,133,0.15)]' : 'bg-emerald-400 shadow-[0_0_0_3px_rgba(52,211,153,0.15)]')} />
|
||||||
|
<p className="truncate font-semibold">{runtime.namespace}</p>
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">{runtime.failed ? '조회 실패' : runtime.exists ? '조회 가능' : '네임스페이스 없음'}</p>
|
||||||
|
</div>
|
||||||
|
<SummaryValue label="Pod" value={runtime.summary.pods} />
|
||||||
|
<SummaryValue label="Deployment" value={runtime.summary.deployments} />
|
||||||
|
<SummaryValue label="StatefulSet" value={runtime.summary.statefulSets} />
|
||||||
|
<SummaryValue label="Service" value={runtime.summary.services} />
|
||||||
|
<SummaryValue label="PVC" value={runtime.summary.persistentVolumeClaims} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</InfraPageShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SummaryValue({ label, value }: { label: string; value: number }) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-muted-foreground">{label}</p>
|
||||||
|
<p className="mt-1 font-semibold text-foreground">{value}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function InfrastructureWorkloadsPage() {
|
||||||
|
const { data, loading, loadingVisible, failed, refresh } = useInfrastructureData();
|
||||||
|
const rows = useMemo(() => rowsFromData(data, isWorkload), [data]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<InfraPageShell title="워크로드" description="Deployment, StatefulSet, Pod 등 실행 리소스를 조회합니다." loading={loading} loadingVisible={loadingVisible} failed={failed} onRefresh={refresh}>
|
||||||
|
<ResourceTable title="워크로드 목록" description="실행 상태, Ready, 재시작 횟수와 Pod 메트릭을 확인합니다." rows={rows} loading={loading} emptyMessage="표시할 워크로드가 없습니다." />
|
||||||
|
</InfraPageShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function InfrastructureNetworkPage() {
|
||||||
|
const { data, loading, loadingVisible, failed, refresh } = useInfrastructureData();
|
||||||
|
const rows = useMemo(() => rowsFromData(data, isNetwork), [data]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<InfraPageShell title="네트워크" description="Service, Ingress 등 네트워크 리소스를 조회합니다." loading={loading} loadingVisible={loadingVisible} failed={failed} onRefresh={refresh}>
|
||||||
|
<ResourceTable title="네트워크 리소스" description="서비스와 인그레스 연결 정보를 확인합니다." rows={rows} loading={loading} emptyMessage="표시할 네트워크 리소스가 없습니다." />
|
||||||
|
</InfraPageShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function InfrastructureStoragePage() {
|
||||||
|
const { data, loading, loadingVisible, failed, refresh } = useInfrastructureData();
|
||||||
|
const rows = useMemo(() => rowsFromData(data, isStorage), [data]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<InfraPageShell title="스토리지" description="PVC, PV, StorageClass 등 저장소 리소스를 조회합니다." loading={loading} loadingVisible={loadingVisible} failed={failed} onRefresh={refresh}>
|
||||||
|
<ResourceTable title="스토리지 리소스" description="영구 볼륨과 스토리지 연결 상태를 확인합니다." rows={rows} loading={loading} emptyMessage="표시할 스토리지 리소스가 없습니다." />
|
||||||
|
</InfraPageShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import { useEffect, useState } from 'react';
|
|||||||
import { Badge, Button, Card, CardHeader, Modal } from '../../components/ui';
|
import { Badge, 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 { statusDotToneClass } from '../../lib/status-dot';
|
||||||
import { LoadingModal, useMinimumLoading } from '../operations/operation-utils';
|
import { LoadingModal, useMinimumLoading } from '../operations/operation-utils';
|
||||||
import {
|
import {
|
||||||
createLauncherAppLink,
|
createLauncherAppLink,
|
||||||
@@ -29,6 +30,19 @@ type DisplayForm = {
|
|||||||
isEnabled: boolean;
|
isEnabled: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const iconOptions = [
|
||||||
|
{ value: 'code', label: 'Code' },
|
||||||
|
{ value: 'bar-chart', label: 'Bar chart' },
|
||||||
|
{ value: 'monitor', label: 'Monitor' },
|
||||||
|
{ value: 'truck', label: 'Truck' },
|
||||||
|
{ value: 'graduation', label: 'Graduation' },
|
||||||
|
{ value: 'activity', label: 'Activity' },
|
||||||
|
{ value: 'sliders', label: 'Sliders' },
|
||||||
|
{ value: 'shopping-cart', label: 'Shopping cart' },
|
||||||
|
{ value: 'map', label: 'Map' },
|
||||||
|
];
|
||||||
|
const showDevelopmentFields = import.meta.env.DEV;
|
||||||
|
|
||||||
function releaseLabel(link: LauncherAppLink) {
|
function releaseLabel(link: LauncherAppLink) {
|
||||||
return link.release?.version ?? '-';
|
return link.release?.version ?? '-';
|
||||||
}
|
}
|
||||||
@@ -60,7 +74,9 @@ export function LauncherAppsPage() {
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [workingId, setWorkingId] = useState('');
|
const [workingId, setWorkingId] = useState('');
|
||||||
const [syncing, setSyncing] = useState(false);
|
const [syncing, setSyncing] = useState(false);
|
||||||
|
const [unlinking, setUnlinking] = useState(false);
|
||||||
const [editingLink, setEditingLink] = useState<LauncherAppLink | null>(null);
|
const [editingLink, setEditingLink] = useState<LauncherAppLink | null>(null);
|
||||||
|
const [unlinkTarget, setUnlinkTarget] = useState<LauncherAppLink | null>(null);
|
||||||
const [displayForm, setDisplayForm] = useState<DisplayForm | null>(null);
|
const [displayForm, setDisplayForm] = useState<DisplayForm | null>(null);
|
||||||
const loadingVisible = useMinimumLoading(loading);
|
const loadingVisible = useMinimumLoading(loading);
|
||||||
|
|
||||||
@@ -107,17 +123,23 @@ export function LauncherAppsPage() {
|
|||||||
.finally(() => setWorkingId(''));
|
.finally(() => setWorkingId(''));
|
||||||
};
|
};
|
||||||
|
|
||||||
const unlink = (link: LauncherAppLink) => {
|
const unlink = () => {
|
||||||
setWorkingId(link.id);
|
if (!unlinkTarget || unlinking) return;
|
||||||
deleteLauncherAppLink(link.id)
|
setUnlinking(true);
|
||||||
|
setWorkingId(unlinkTarget.id);
|
||||||
|
deleteLauncherAppLink(unlinkTarget.id)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
toast({ type: 'success', message: '런처 앱 연결을 해제했습니다.' });
|
toast({ type: 'success', message: '런처 앱 연결을 해제했습니다.' });
|
||||||
|
setUnlinkTarget(null);
|
||||||
refresh();
|
refresh();
|
||||||
})
|
})
|
||||||
.catch((error: Error) => {
|
.catch((error: Error) => {
|
||||||
toast({ type: 'error', title: '연결 해제 실패', message: error.message });
|
toast({ type: 'error', title: '연결 해제 실패', message: error.message });
|
||||||
})
|
})
|
||||||
.finally(() => setWorkingId(''));
|
.finally(() => {
|
||||||
|
setWorkingId('');
|
||||||
|
setUnlinking(false);
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const syncAdminApps = () => {
|
const syncAdminApps = () => {
|
||||||
@@ -174,7 +196,7 @@ export function LauncherAppsPage() {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageTitle
|
<PageTitle
|
||||||
eyebrow="런처 운영"
|
eyebrow="런처"
|
||||||
title="앱 연결"
|
title="앱 연결"
|
||||||
description="등록된 앱 중 런처에 노출할 앱과 표시 순서를 관리합니다."
|
description="등록된 앱 중 런처에 노출할 앱과 표시 순서를 관리합니다."
|
||||||
actions={
|
actions={
|
||||||
@@ -218,7 +240,7 @@ export function LauncherAppsPage() {
|
|||||||
<td className="px-5 py-3">{link.displayOrder || index + 1}</td>
|
<td className="px-5 py-3">{link.displayOrder || index + 1}</td>
|
||||||
<td className="px-5 py-3">
|
<td className="px-5 py-3">
|
||||||
<p className="inline-flex items-center gap-2">
|
<p className="inline-flex items-center gap-2">
|
||||||
<span className={link.isEnabled ? 'size-2 rounded-full bg-emerald-400' : 'size-2 rounded-full bg-muted-foreground'} />
|
<span className={`size-2.5 shrink-0 rounded-full ${link.isEnabled ? statusDotToneClass('green') : statusDotToneClass('neutral')}`} />
|
||||||
{link.displayName || link.app.name}
|
{link.displayName || link.app.name}
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-1 text-xs text-muted-foreground">
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
@@ -240,7 +262,7 @@ export function LauncherAppsPage() {
|
|||||||
<Button variant="outline" className="size-8 px-0" aria-label={`${link.app.name} 표시 설정`} onClick={() => openSettings(link)} disabled={workingId === `${link.id}-settings`}>
|
<Button variant="outline" className="size-8 px-0" aria-label={`${link.app.name} 표시 설정`} onClick={() => openSettings(link)} disabled={workingId === `${link.id}-settings`}>
|
||||||
<MoreHorizontal className="size-4" />
|
<MoreHorizontal className="size-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="outline" className="size-8 border-rose-500/40 px-0 text-rose-300 hover:bg-rose-500/10" aria-label={`${link.app.name} 연결 해제`} onClick={() => unlink(link)} disabled={workingId === link.id}>
|
<Button variant="outline" className="size-8 border-rose-500/40 px-0 text-rose-300 hover:bg-rose-500/10" aria-label={`${link.app.name} 연결 해제`} onClick={() => setUnlinkTarget(link)} disabled={workingId === link.id}>
|
||||||
<Unlink className="size-4" />
|
<Unlink className="size-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -292,63 +314,95 @@ export function LauncherAppsPage() {
|
|||||||
>
|
>
|
||||||
{displayForm ? (
|
{displayForm ? (
|
||||||
<form className="grid gap-4" onSubmit={(event) => { event.preventDefault(); saveSettings(); }}>
|
<form className="grid gap-4" onSubmit={(event) => { event.preventDefault(); saveSettings(); }}>
|
||||||
<div className="grid gap-4 md:grid-cols-[1fr_120px_120px]">
|
|
||||||
<label className="block">
|
|
||||||
<span className="text-xs font-medium text-muted-foreground">표시명</span>
|
|
||||||
<input className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring" value={displayForm.displayName} onChange={(event) => setDisplayForm((prev) => prev ? { ...prev, displayName: event.target.value } : prev)} />
|
|
||||||
</label>
|
|
||||||
<label className="block">
|
|
||||||
<span className="text-xs font-medium text-muted-foreground">노출순서</span>
|
|
||||||
<input type="number" className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring" value={displayForm.displayOrder} onChange={(event) => setDisplayForm((prev) => prev ? { ...prev, displayOrder: event.target.value } : prev)} />
|
|
||||||
</label>
|
|
||||||
<div className="block">
|
|
||||||
<span className="text-xs font-medium text-muted-foreground">상태</span>
|
|
||||||
<label className="mt-2 inline-flex h-9 w-full items-center gap-2 rounded-md border border-border px-3 text-sm text-foreground">
|
|
||||||
<input type="checkbox" checked={displayForm.isEnabled} onChange={(event) => setDisplayForm((prev) => prev ? { ...prev, isEnabled: event.target.checked } : prev)} />
|
|
||||||
활성
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
<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">표시명 <b className="text-foreground">*</b></span>
|
||||||
<input className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring" value={displayForm.subtitle} onChange={(event) => setDisplayForm((prev) => prev ? { ...prev, subtitle: event.target.value } : prev)} />
|
<input className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring" value={displayForm.displayName} onChange={(event) => setDisplayForm((prev) => prev ? { ...prev, displayName: event.target.value } : prev)} />
|
||||||
</label>
|
|
||||||
<label className="block">
|
|
||||||
<span className="text-xs font-medium text-muted-foreground">아이콘 키</span>
|
|
||||||
<input className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring" value={displayForm.iconKey} onChange={(event) => setDisplayForm((prev) => prev ? { ...prev, iconKey: event.target.value } : prev)} />
|
|
||||||
</label>
|
</label>
|
||||||
|
<div className="grid gap-4 sm:grid-cols-[1fr_120px]">
|
||||||
|
<label className="block">
|
||||||
|
<span className="text-xs font-medium text-muted-foreground">노출순서</span>
|
||||||
|
<input type="number" className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring" value={displayForm.displayOrder} onChange={(event) => setDisplayForm((prev) => prev ? { ...prev, displayOrder: event.target.value } : prev)} />
|
||||||
|
</label>
|
||||||
|
<div className="block">
|
||||||
|
<span className="text-xs font-medium text-muted-foreground">상태</span>
|
||||||
|
<label className="mt-2 inline-flex h-9 w-full items-center gap-2 rounded-md border border-border px-3 text-sm text-foreground">
|
||||||
|
<input type="checkbox" checked={displayForm.isEnabled} onChange={(event) => setDisplayForm((prev) => prev ? { ...prev, isEnabled: event.target.checked } : prev)} />
|
||||||
|
활성
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<label className="block">
|
||||||
|
<span className="text-xs font-medium text-muted-foreground">보조 문구</span>
|
||||||
|
<textarea className="mt-2 min-h-20 w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring" value={displayForm.subtitle} onChange={(event) => setDisplayForm((prev) => prev ? { ...prev, subtitle: event.target.value } : prev)} />
|
||||||
|
</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>
|
||||||
<textarea className="mt-2 min-h-20 w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring" value={displayForm.description} onChange={(event) => setDisplayForm((prev) => prev ? { ...prev, description: event.target.value } : prev)} />
|
<textarea className="mt-2 min-h-20 w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring" value={displayForm.description} onChange={(event) => setDisplayForm((prev) => prev ? { ...prev, description: event.target.value } : prev)} />
|
||||||
</label>
|
</label>
|
||||||
|
<label className="block w-full">
|
||||||
|
<span className="text-xs font-medium text-muted-foreground">자세히 보기 URL 링크</span>
|
||||||
|
<input className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring" value={displayForm.detailUrl} onChange={(event) => setDisplayForm((prev) => prev ? { ...prev, detailUrl: event.target.value } : prev)} />
|
||||||
|
</label>
|
||||||
|
{showDevelopmentFields ? (
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<label className="block">
|
||||||
|
<span className="text-xs font-medium text-muted-foreground">실행 URL</span>
|
||||||
|
<input className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring" value={displayForm.launchUrl} onChange={(event) => setDisplayForm((prev) => prev ? { ...prev, launchUrl: event.target.value } : prev)} />
|
||||||
|
</label>
|
||||||
|
<label className="block">
|
||||||
|
<span className="text-xs font-medium text-muted-foreground">개발 앱 경로</span>
|
||||||
|
<input className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring" value={displayForm.devAppPath} onChange={(event) => setDisplayForm((prev) => prev ? { ...prev, devAppPath: event.target.value } : prev)} />
|
||||||
|
</label>
|
||||||
|
<label className="block">
|
||||||
|
<span className="text-xs font-medium text-muted-foreground">실행 파일 경로</span>
|
||||||
|
<input className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring" value={displayForm.execPath} onChange={(event) => setDisplayForm((prev) => prev ? { ...prev, execPath: event.target.value } : prev)} />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<span className="text-xs font-medium text-muted-foreground">실행 URL</span>
|
<span className="text-xs font-medium text-muted-foreground">소개 영상 경로</span>
|
||||||
<input className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring" value={displayForm.launchUrl} onChange={(event) => setDisplayForm((prev) => prev ? { ...prev, launchUrl: event.target.value } : prev)} />
|
<input className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring" value={displayForm.videoPath} onChange={(event) => setDisplayForm((prev) => prev ? { ...prev, videoPath: event.target.value } : prev)} />
|
||||||
</label>
|
</label>
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<span className="text-xs font-medium text-muted-foreground">상세 URL</span>
|
<span className="text-xs font-medium text-muted-foreground">아이콘 키</span>
|
||||||
<input className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring" value={displayForm.detailUrl} onChange={(event) => setDisplayForm((prev) => prev ? { ...prev, detailUrl: event.target.value } : prev)} />
|
<select className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring" value={iconOptions.some((option) => option.value === displayForm.iconKey) ? displayForm.iconKey : 'code'} onChange={(event) => setDisplayForm((prev) => prev ? { ...prev, iconKey: event.target.value } : prev)}>
|
||||||
</label>
|
{iconOptions.map((option) => (
|
||||||
<label className="block">
|
<option key={option.value} value={option.value}>{option.label}</option>
|
||||||
<span className="text-xs font-medium text-muted-foreground">개발 앱 경로</span>
|
))}
|
||||||
<input className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring" value={displayForm.devAppPath} onChange={(event) => setDisplayForm((prev) => prev ? { ...prev, devAppPath: event.target.value } : prev)} />
|
</select>
|
||||||
</label>
|
|
||||||
<label className="block">
|
|
||||||
<span className="text-xs font-medium text-muted-foreground">실행 파일 경로</span>
|
|
||||||
<input className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring" value={displayForm.execPath} onChange={(event) => setDisplayForm((prev) => prev ? { ...prev, execPath: event.target.value } : prev)} />
|
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<label className="block">
|
|
||||||
<span className="text-xs font-medium text-muted-foreground">소개 영상 경로</span>
|
|
||||||
<input className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring" value={displayForm.videoPath} onChange={(event) => setDisplayForm((prev) => prev ? { ...prev, videoPath: event.target.value } : prev)} />
|
|
||||||
</label>
|
|
||||||
</form>
|
</form>
|
||||||
) : null}
|
) : null}
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
open={Boolean(unlinkTarget)}
|
||||||
|
title="앱 연결 해제"
|
||||||
|
onClose={() => {
|
||||||
|
if (!unlinking) setUnlinkTarget(null);
|
||||||
|
}}
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button variant="outline" disabled={unlinking} onClick={() => setUnlinkTarget(null)}>취소</Button>
|
||||||
|
<Button className="bg-rose-600 text-white hover:bg-rose-500" disabled={unlinking} onClick={unlink}>
|
||||||
|
{unlinking ? '해제 중' : '해제'}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="space-y-3 text-sm">
|
||||||
|
<p>
|
||||||
|
<span className="font-semibold text-foreground">{unlinkTarget?.displayName || unlinkTarget?.app.name || '-'}</span>
|
||||||
|
{' '}앱의 런처 연결을 해제합니다.
|
||||||
|
</p>
|
||||||
|
<p className="text-muted-foreground">연결을 해제하면 런처 홈에 표시되지 않습니다.</p>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
<LoadingModal open={loadingVisible} />
|
<LoadingModal open={loadingVisible} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { ChevronLeft, ChevronRight, MoreHorizontal, Play, Plus, RotateCcw, Trash2 } from 'lucide-react';
|
import { ChevronLeft, ChevronRight, MoreHorizontal, Play, Plus, RotateCcw, Trash2, Upload } from 'lucide-react';
|
||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { Badge, Button, Card, CardHeader, Modal } from '../../components/ui';
|
import { Badge, Button, Card, CardHeader, Modal } from '../../components/ui';
|
||||||
import { useToast } from '../../components/toast';
|
import { useToast } from '../../components/toast';
|
||||||
@@ -7,6 +7,8 @@ import { formatDateTime } from '../../lib/date-format';
|
|||||||
import { LoadingModal, useMinimumLoading } from '../operations/operation-utils';
|
import { LoadingModal, useMinimumLoading } from '../operations/operation-utils';
|
||||||
import {
|
import {
|
||||||
createLauncherBuildJob,
|
createLauncherBuildJob,
|
||||||
|
deployLauncherBackend,
|
||||||
|
getLauncherBackendDeployment,
|
||||||
getLauncherSource,
|
getLauncherSource,
|
||||||
listLauncherBuildEnv,
|
listLauncherBuildEnv,
|
||||||
listLauncherBuildJobs,
|
listLauncherBuildJobs,
|
||||||
@@ -16,6 +18,7 @@ import {
|
|||||||
type LauncherSource,
|
type LauncherSource,
|
||||||
type LauncherBuildJob,
|
type LauncherBuildJob,
|
||||||
type LauncherBuildEnvVariable,
|
type LauncherBuildEnvVariable,
|
||||||
|
type LauncherBackendDeployment,
|
||||||
type LauncherVersion
|
type LauncherVersion
|
||||||
} from './launcher-api';
|
} from './launcher-api';
|
||||||
|
|
||||||
@@ -48,6 +51,37 @@ function jobTime(job: LauncherBuildJob) {
|
|||||||
return Number.isNaN(value) ? 0 : value;
|
return Number.isNaN(value) ? 0 : value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function BuildWorker({ job }: { job: LauncherBuildJob }) {
|
||||||
|
const [failed, setFailed] = useState(false);
|
||||||
|
const name = job.requestedByName
|
||||||
|
|| job.createdByName
|
||||||
|
|| job.requestedBy
|
||||||
|
|| job.createdBy
|
||||||
|
|| '-';
|
||||||
|
const avatarUrl = job.requestedByAvatarUrl
|
||||||
|
|| job.createdByAvatarUrl
|
||||||
|
|| '';
|
||||||
|
const initial = name.trim().charAt(0).toUpperCase() || '?';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className="inline-flex h-4 min-w-0 items-center gap-1.5 align-middle">
|
||||||
|
{name === '-' ? null : avatarUrl && !failed ? (
|
||||||
|
<img
|
||||||
|
src={avatarUrl}
|
||||||
|
alt=""
|
||||||
|
className="size-4 shrink-0 rounded-full border border-border bg-muted object-cover"
|
||||||
|
onError={() => setFailed(true)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span className="inline-flex size-4 shrink-0 items-center justify-center rounded-full border border-border bg-muted text-[9px] font-semibold leading-none text-muted-foreground">
|
||||||
|
{initial}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<b className="truncate leading-none text-foreground">{name}</b>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const launcherBuildJobPageSize = 3;
|
const launcherBuildJobPageSize = 3;
|
||||||
type BuildTarget = 'launcher-client' | 'launcher-backend';
|
type BuildTarget = 'launcher-client' | 'launcher-backend';
|
||||||
type BuildRange = 'week' | 'month' | 'all';
|
type BuildRange = 'week' | 'month' | 'all';
|
||||||
@@ -101,6 +135,9 @@ export function LauncherBuildPage() {
|
|||||||
const [buildEnv, setBuildEnv] = useState<LauncherBuildEnvVariable[]>([]);
|
const [buildEnv, setBuildEnv] = useState<LauncherBuildEnvVariable[]>([]);
|
||||||
const [buildEnvSaving, setBuildEnvSaving] = useState(false);
|
const [buildEnvSaving, setBuildEnvSaving] = useState(false);
|
||||||
const [selectedJob, setSelectedJob] = useState<LauncherBuildJob | null>(null);
|
const [selectedJob, setSelectedJob] = useState<LauncherBuildJob | null>(null);
|
||||||
|
const [quickDeployJob, setQuickDeployJob] = useState<LauncherBuildJob | null>(null);
|
||||||
|
const [backendDeployment, setBackendDeployment] = useState<LauncherBackendDeployment | null>(null);
|
||||||
|
const [quickDeploying, setQuickDeploying] = useState(false);
|
||||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
@@ -122,6 +159,13 @@ export function LauncherBuildPage() {
|
|||||||
const pagedJobs = jobs;
|
const pagedJobs = jobs;
|
||||||
const hasActiveJob = jobs.some((job) => job.status === 'PENDING' || job.status === 'RUNNING');
|
const hasActiveJob = jobs.some((job) => job.status === 'PENDING' || job.status === 'RUNNING');
|
||||||
|
|
||||||
|
const isQuickDeployable = (job: LauncherBuildJob) => (
|
||||||
|
target === 'launcher-backend'
|
||||||
|
&& job.target === 'launcher-backend'
|
||||||
|
&& job.status === 'SUCCEEDED'
|
||||||
|
&& Boolean(job.downloadUrl)
|
||||||
|
);
|
||||||
|
|
||||||
const loadBuildJobs = (nextPage = page, nextTarget = target, nextRange = range) => (
|
const loadBuildJobs = (nextPage = page, nextTarget = target, nextRange = range) => (
|
||||||
listLauncherBuildJobsPage({
|
listLauncherBuildJobsPage({
|
||||||
target: nextTarget,
|
target: nextTarget,
|
||||||
@@ -238,6 +282,31 @@ export function LauncherBuildPage() {
|
|||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const openQuickDeploy = (job: LauncherBuildJob) => {
|
||||||
|
setQuickDeployJob(job);
|
||||||
|
setBackendDeployment(null);
|
||||||
|
getLauncherBackendDeployment()
|
||||||
|
.then(setBackendDeployment)
|
||||||
|
.catch((error: Error) => {
|
||||||
|
toast({ type: 'error', title: '배포 대상 조회 실패', message: error.message });
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const quickDeploy = () => {
|
||||||
|
if (!quickDeployJob?.downloadUrl) return;
|
||||||
|
setQuickDeploying(true);
|
||||||
|
deployLauncherBackend(quickDeployJob.downloadUrl)
|
||||||
|
.then(() => {
|
||||||
|
toast({ type: 'success', message: '런처 백엔드를 배포했습니다.' });
|
||||||
|
setQuickDeployJob(null);
|
||||||
|
refresh();
|
||||||
|
})
|
||||||
|
.catch((error: Error) => {
|
||||||
|
toast({ type: 'error', title: '백엔드 배포 실패', message: error.message });
|
||||||
|
})
|
||||||
|
.finally(() => setQuickDeploying(false));
|
||||||
|
};
|
||||||
|
|
||||||
const updateBuildEnv = (index: number, patch: Partial<LauncherBuildEnvVariable>) => {
|
const updateBuildEnv = (index: number, patch: Partial<LauncherBuildEnvVariable>) => {
|
||||||
setBuildEnv((prev) => prev.map((variable, variableIndex) => (
|
setBuildEnv((prev) => prev.map((variable, variableIndex) => (
|
||||||
variableIndex === index ? { ...variable, ...patch } : variable
|
variableIndex === index ? { ...variable, ...patch } : variable
|
||||||
@@ -268,7 +337,7 @@ export function LauncherBuildPage() {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageTitle
|
<PageTitle
|
||||||
eyebrow="런처 운영"
|
eyebrow="런처"
|
||||||
title="빌드 관리"
|
title="빌드 관리"
|
||||||
description="런처 클라이언트 설치 파일과 백엔드 컨테이너 이미지를 구성요소별로 빌드합니다."
|
description="런처 클라이언트 설치 파일과 백엔드 컨테이너 이미지를 구성요소별로 빌드합니다."
|
||||||
actions={
|
actions={
|
||||||
@@ -405,12 +474,26 @@ export function LauncherBuildPage() {
|
|||||||
<div className="mt-3 flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted-foreground">
|
<div className="mt-3 flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted-foreground">
|
||||||
<span>브랜치 <b className="text-foreground">{job.branch}</b></span>
|
<span>브랜치 <b className="text-foreground">{job.branch}</b></span>
|
||||||
<span>커밋 <b className="font-mono text-foreground">{job.commitSha || '-'}</b></span>
|
<span>커밋 <b className="font-mono text-foreground">{job.commitSha || '-'}</b></span>
|
||||||
|
<span className="inline-flex h-4 min-w-0 items-center gap-1.5 align-middle">작업자 <BuildWorker job={job} /></span>
|
||||||
<span>생성 <b className="text-foreground">{formatDate(job.createdAt)}</b></span>
|
<span>생성 <b className="text-foreground">{formatDate(job.createdAt)}</b></span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Button variant="outline" className="size-8 px-0" title="로그" aria-label="로그" onClick={() => setSelectedJob(job)}>
|
<div className="flex shrink-0 items-center gap-1">
|
||||||
<MoreHorizontal className="size-4" />
|
{isQuickDeployable(job) ? (
|
||||||
</Button>
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="size-8 px-0"
|
||||||
|
title="빠른 배포"
|
||||||
|
aria-label="빠른 배포"
|
||||||
|
onClick={() => openQuickDeploy(job)}
|
||||||
|
>
|
||||||
|
<Upload className="size-4" />
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
<Button variant="outline" className="size-8 px-0" title="로그" aria-label="로그" onClick={() => setSelectedJob(job)}>
|
||||||
|
<MoreHorizontal className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-3 grid gap-3 rounded-md border border-border bg-card px-3 py-2 text-xs md:grid-cols-[minmax(0,0.8fr)_minmax(0,1.2fr)]">
|
<div className="mt-3 grid gap-3 rounded-md border border-border bg-card px-3 py-2 text-xs md:grid-cols-[minmax(0,0.8fr)_minmax(0,1.2fr)]">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
@@ -468,6 +551,51 @@ export function LauncherBuildPage() {
|
|||||||
{selectedJob?.logs || '작업을 선택하면 로그가 표시됩니다.'}
|
{selectedJob?.logs || '작업을 선택하면 로그가 표시됩니다.'}
|
||||||
</pre>
|
</pre>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
<Modal
|
||||||
|
open={Boolean(quickDeployJob)}
|
||||||
|
title="빠른 배포"
|
||||||
|
onClose={() => !quickDeploying && setQuickDeployJob(null)}
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button variant="outline" onClick={() => setQuickDeployJob(null)} disabled={quickDeploying}>취소</Button>
|
||||||
|
<Button onClick={quickDeploy} disabled={quickDeploying || !quickDeployJob?.downloadUrl}>
|
||||||
|
<Upload className="size-4" />
|
||||||
|
{quickDeploying ? '배포 중' : '배포'}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="space-y-3 text-sm">
|
||||||
|
<div className="rounded-md border border-border bg-muted/20 px-4 py-3">
|
||||||
|
<p className="font-medium">성공한 백엔드 빌드 이미지를 바로 배포합니다.</p>
|
||||||
|
<p className="mt-1 text-xs leading-5 text-muted-foreground">
|
||||||
|
현재 설정된 복제 수와 CPU, 메모리 제한은 변경하지 않습니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2 rounded-md border border-border bg-background p-4">
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<span className="text-muted-foreground">대상</span>
|
||||||
|
<span className="font-mono text-xs font-medium">
|
||||||
|
{backendDeployment ? `${backendDeployment.namespace}/deployment/${backendDeployment.deployment}` : '배포 대상 확인 중'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<span className="text-muted-foreground">빌드</span>
|
||||||
|
<span className="font-medium">{quickDeployJob?.version || '-'}</span>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-1 pt-1">
|
||||||
|
<span className="text-muted-foreground">이미지</span>
|
||||||
|
<span className="break-all font-mono text-xs font-medium">{quickDeployJob?.downloadUrl || '-'}</span>
|
||||||
|
</div>
|
||||||
|
{backendDeployment?.currentImage ? (
|
||||||
|
<div className="grid gap-1 border-t border-border pt-3">
|
||||||
|
<span className="text-muted-foreground">현재 이미지</span>
|
||||||
|
<span className="break-all font-mono text-xs text-muted-foreground">{backendDeployment.currentImage}</span>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
<Modal
|
<Modal
|
||||||
open={confirmOpen}
|
open={confirmOpen}
|
||||||
title="빌드 실행 확인"
|
title="빌드 실행 확인"
|
||||||
|
|||||||
@@ -1,341 +0,0 @@
|
|||||||
import { ChevronLeft, ChevronRight, MoreHorizontal, Plus, SlidersHorizontal, Trash2 } from 'lucide-react';
|
|
||||||
import { useEffect, useMemo, useState, type FormEvent } from 'react';
|
|
||||||
import { Badge, Button, Card, CardHeader, Modal } from '../../components/ui';
|
|
||||||
import { useToast } from '../../components/toast';
|
|
||||||
import { authApiEndpoints } from '../../data/mock';
|
|
||||||
import { PageTitle } from '../../layout/AppLayout';
|
|
||||||
import { LoadingModal, useMinimumLoading } from '../operations/operation-utils';
|
|
||||||
import {
|
|
||||||
createLauncherCommonSetting,
|
|
||||||
deleteLauncherCommonSetting,
|
|
||||||
listLauncherCommonSettings,
|
|
||||||
updateLauncherCommonSetting,
|
|
||||||
type LauncherCommonSetting
|
|
||||||
} from './launcher-api';
|
|
||||||
|
|
||||||
type FormState = {
|
|
||||||
key: string;
|
|
||||||
name: string;
|
|
||||||
type: string;
|
|
||||||
value: string;
|
|
||||||
description: string;
|
|
||||||
isActive: boolean;
|
|
||||||
isSecret: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
const emptyForm: FormState = {
|
|
||||||
key: '',
|
|
||||||
name: '',
|
|
||||||
type: 'endpoint',
|
|
||||||
value: '',
|
|
||||||
description: '',
|
|
||||||
isActive: true,
|
|
||||||
isSecret: false
|
|
||||||
};
|
|
||||||
|
|
||||||
const settingsPageSize = 10;
|
|
||||||
|
|
||||||
function maskValue(setting: LauncherCommonSetting) {
|
|
||||||
if (!setting.isSecret) return setting.value;
|
|
||||||
if (!setting.value) return '-';
|
|
||||||
return '******';
|
|
||||||
}
|
|
||||||
|
|
||||||
function toForm(setting?: LauncherCommonSetting): FormState {
|
|
||||||
if (!setting) return emptyForm;
|
|
||||||
return {
|
|
||||||
key: setting.key,
|
|
||||||
name: setting.name,
|
|
||||||
type: setting.type,
|
|
||||||
value: setting.value,
|
|
||||||
description: setting.description ?? '',
|
|
||||||
isActive: setting.isActive,
|
|
||||||
isSecret: setting.isSecret
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function LauncherCommonSettingsPage() {
|
|
||||||
const { toast } = useToast();
|
|
||||||
const [apiOpen, setApiOpen] = useState(false);
|
|
||||||
const [settings, setSettings] = useState<LauncherCommonSetting[]>([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [modalOpen, setModalOpen] = useState(false);
|
|
||||||
const [editing, setEditing] = useState<LauncherCommonSetting | null>(null);
|
|
||||||
const [form, setForm] = useState<FormState>(emptyForm);
|
|
||||||
const [saving, setSaving] = useState(false);
|
|
||||||
const [page, setPage] = useState(1);
|
|
||||||
const loadingVisible = useMinimumLoading(loading);
|
|
||||||
|
|
||||||
const totalPages = Math.max(1, Math.ceil(settings.length / settingsPageSize));
|
|
||||||
const pagedSettings = settings.slice((page - 1) * settingsPageSize, page * settingsPageSize);
|
|
||||||
const summary = useMemo(() => ({
|
|
||||||
total: settings.length,
|
|
||||||
endpoint: settings.filter((item) => item.type === 'endpoint').length,
|
|
||||||
database: settings.filter((item) => item.type === 'database').length,
|
|
||||||
active: settings.filter((item) => item.isActive).length
|
|
||||||
}), [settings]);
|
|
||||||
|
|
||||||
const refresh = () => {
|
|
||||||
setLoading(true);
|
|
||||||
listLauncherCommonSettings()
|
|
||||||
.then(setSettings)
|
|
||||||
.catch((error: Error) => {
|
|
||||||
toast({ type: 'error', title: '공통 설정 조회 실패', message: error.message });
|
|
||||||
})
|
|
||||||
.finally(() => setLoading(false));
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
refresh();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setPage((currentPage) => Math.min(currentPage, totalPages));
|
|
||||||
}, [totalPages]);
|
|
||||||
|
|
||||||
const openCreate = () => {
|
|
||||||
setEditing(null);
|
|
||||||
setForm(emptyForm);
|
|
||||||
setModalOpen(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const openEdit = (setting: LauncherCommonSetting) => {
|
|
||||||
setEditing(setting);
|
|
||||||
setForm(toForm(setting));
|
|
||||||
setModalOpen(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const submit = (event: FormEvent) => {
|
|
||||||
event.preventDefault();
|
|
||||||
setSaving(true);
|
|
||||||
const payload = {
|
|
||||||
key: form.key.trim(),
|
|
||||||
name: form.name.trim(),
|
|
||||||
type: form.type.trim(),
|
|
||||||
value: form.value.trim(),
|
|
||||||
description: form.description.trim(),
|
|
||||||
isActive: form.isActive,
|
|
||||||
isSecret: form.isSecret
|
|
||||||
};
|
|
||||||
const request = editing
|
|
||||||
? updateLauncherCommonSetting(editing.id, payload)
|
|
||||||
: createLauncherCommonSetting(payload);
|
|
||||||
|
|
||||||
request
|
|
||||||
.then(() => {
|
|
||||||
toast({ type: 'success', message: editing ? '설정을 수정했습니다.' : '설정을 추가했습니다.' });
|
|
||||||
setModalOpen(false);
|
|
||||||
refresh();
|
|
||||||
})
|
|
||||||
.catch((error: Error) => {
|
|
||||||
toast({ type: 'error', title: editing ? '설정 수정 실패' : '설정 추가 실패', message: error.message });
|
|
||||||
})
|
|
||||||
.finally(() => setSaving(false));
|
|
||||||
};
|
|
||||||
|
|
||||||
const remove = () => {
|
|
||||||
if (!editing) return;
|
|
||||||
setSaving(true);
|
|
||||||
deleteLauncherCommonSetting(editing.id)
|
|
||||||
.then(() => {
|
|
||||||
toast({ type: 'success', message: '설정을 삭제했습니다.' });
|
|
||||||
setModalOpen(false);
|
|
||||||
refresh();
|
|
||||||
})
|
|
||||||
.catch((error: Error) => {
|
|
||||||
toast({ type: 'error', title: '설정 삭제 실패', message: error.message });
|
|
||||||
})
|
|
||||||
.finally(() => setSaving(false));
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<PageTitle
|
|
||||||
eyebrow="런처 운영"
|
|
||||||
title="공통 설정"
|
|
||||||
description="런처에서 공통으로 사용하는 연결값과 기본 설정을 관리합니다."
|
|
||||||
actions={
|
|
||||||
<>
|
|
||||||
<Button variant="outline" className="h-8 px-3 text-[13px]" onClick={() => setApiOpen(true)}>
|
|
||||||
<SlidersHorizontal className="size-4" />
|
|
||||||
공통 인증 API 보기
|
|
||||||
</Button>
|
|
||||||
<Button className="h-8 px-3 text-[13px]" onClick={openCreate}>
|
|
||||||
<Plus className="size-4" />
|
|
||||||
설정 추가
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="grid gap-4 md:grid-cols-4">
|
|
||||||
{[
|
|
||||||
['설정 항목', `${summary.total}개`],
|
|
||||||
['Endpoint', `${summary.endpoint}개`],
|
|
||||||
['Database', `${summary.database}개`],
|
|
||||||
['활성 상태', `${summary.active}개`]
|
|
||||||
].map(([label, value]) => (
|
|
||||||
<Card className="p-5" key={label}>
|
|
||||||
<p className="text-sm text-muted-foreground">{label}</p>
|
|
||||||
<p className="mt-3 text-2xl font-semibold">{value}</p>
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Card className="mt-4 min-w-0 overflow-hidden">
|
|
||||||
<CardHeader title="런처 연결 설정" description="여러 기능에서 함께 쓰는 주소, 인증, 저장소 관련 값을 정리합니다." />
|
|
||||||
<div className="overflow-x-auto">
|
|
||||||
<table className="w-full min-w-[1120px] text-left text-sm">
|
|
||||||
<thead className="border-b border-border text-xs text-muted-foreground">
|
|
||||||
<tr>
|
|
||||||
<th className="w-[280px] px-5 py-3 font-medium">설정</th>
|
|
||||||
<th className="px-5 py-3 font-medium">Key</th>
|
|
||||||
<th className="px-5 py-3 font-medium">Type</th>
|
|
||||||
<th className="px-5 py-3 font-medium">Value</th>
|
|
||||||
<th className="px-5 py-3 text-right font-medium">관리</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="divide-y divide-border">
|
|
||||||
{!loading && settings.length === 0 ? (
|
|
||||||
<tr>
|
|
||||||
<td colSpan={5} className="px-5 py-12 text-center text-sm text-muted-foreground">등록된 설정이 없습니다.</td>
|
|
||||||
</tr>
|
|
||||||
) : null}
|
|
||||||
{pagedSettings.map((setting) => (
|
|
||||||
<tr className="hover:bg-muted/20" key={setting.id}>
|
|
||||||
<td className="px-5 py-3">
|
|
||||||
<p className="inline-flex items-center gap-2 font-medium">
|
|
||||||
<span className={setting.isActive ? 'size-2 rounded-full bg-emerald-400' : 'size-2 rounded-full bg-muted-foreground'} />
|
|
||||||
{setting.name}
|
|
||||||
</p>
|
|
||||||
{setting.description ? <p className="mt-1 truncate text-xs text-muted-foreground">{setting.description}</p> : null}
|
|
||||||
</td>
|
|
||||||
<td className="px-5 py-3 font-medium">{setting.key}</td>
|
|
||||||
<td className="px-5 py-3">{setting.type}</td>
|
|
||||||
<td className="px-5 py-3 text-muted-foreground">{maskValue(setting)}</td>
|
|
||||||
<td className="px-5 py-3 text-right">
|
|
||||||
<Button variant="ghost" className="size-8 px-0" aria-label={`${setting.name} 관리`} onClick={() => openEdit(setting)}>
|
|
||||||
<MoreHorizontal className="size-4" />
|
|
||||||
</Button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
<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">
|
|
||||||
{loading ? '불러오는 중' : `총 ${settings.length}건 · ${page} / ${totalPages}`}
|
|
||||||
</span>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
className="h-8 px-3 text-[13px]"
|
|
||||||
disabled={page <= 1}
|
|
||||||
onClick={() => setPage((currentPage) => Math.max(1, currentPage - 1))}
|
|
||||||
>
|
|
||||||
<ChevronLeft className="size-4" />
|
|
||||||
이전
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
className="h-8 px-3 text-[13px]"
|
|
||||||
disabled={page >= totalPages}
|
|
||||||
onClick={() => setPage((currentPage) => Math.min(totalPages, currentPage + 1))}
|
|
||||||
>
|
|
||||||
다음
|
|
||||||
<ChevronRight className="size-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Modal
|
|
||||||
open={modalOpen}
|
|
||||||
title={editing ? '설정 수정' : '설정 추가'}
|
|
||||||
onClose={() => setModalOpen(false)}
|
|
||||||
className="!max-w-2xl"
|
|
||||||
footer={
|
|
||||||
<>
|
|
||||||
{editing ? (
|
|
||||||
<Button variant="outline" className="border-rose-500/40 text-rose-300 hover:bg-rose-500/10" onClick={remove} disabled={saving}>
|
|
||||||
<Trash2 className="size-4" />
|
|
||||||
삭제
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
<Button variant="outline" onClick={() => setModalOpen(false)}>취소</Button>
|
|
||||||
<Button onClick={submit as unknown as () => void} disabled={saving || !form.key.trim() || !form.name.trim() || !form.type.trim()}>
|
|
||||||
{saving ? '저장 중' : '저장'}
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<form className="grid gap-4" onSubmit={submit}>
|
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
|
||||||
<label className="block">
|
|
||||||
<span className="text-xs font-medium text-muted-foreground">설정명</span>
|
|
||||||
<input className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring" value={form.name} onChange={(event) => setForm((prev) => ({ ...prev, name: event.target.value }))} />
|
|
||||||
</label>
|
|
||||||
<label className="block">
|
|
||||||
<span className="text-xs font-medium text-muted-foreground">Type</span>
|
|
||||||
<input className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring" value={form.type} onChange={(event) => setForm((prev) => ({ ...prev, type: event.target.value }))} />
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<label className="block">
|
|
||||||
<span className="text-xs font-medium text-muted-foreground">Key</span>
|
|
||||||
<input className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 font-mono text-sm text-foreground outline-none focus:ring-2 focus:ring-ring" value={form.key} onChange={(event) => setForm((prev) => ({ ...prev, key: event.target.value }))} />
|
|
||||||
</label>
|
|
||||||
<label className="block">
|
|
||||||
<span className="text-xs font-medium text-muted-foreground">Value</span>
|
|
||||||
<input className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 font-mono text-sm text-foreground outline-none focus:ring-2 focus:ring-ring" value={form.value} onChange={(event) => setForm((prev) => ({ ...prev, value: event.target.value }))} />
|
|
||||||
</label>
|
|
||||||
<label className="block">
|
|
||||||
<span className="text-xs font-medium text-muted-foreground">설명</span>
|
|
||||||
<textarea className="mt-2 min-h-20 w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring" value={form.description} onChange={(event) => setForm((prev) => ({ ...prev, description: event.target.value }))} />
|
|
||||||
</label>
|
|
||||||
<div className="flex flex-wrap gap-4">
|
|
||||||
<label className="inline-flex items-center gap-2 text-sm">
|
|
||||||
<input type="checkbox" checked={form.isActive} onChange={(event) => setForm((prev) => ({ ...prev, isActive: event.target.checked }))} />
|
|
||||||
활성
|
|
||||||
</label>
|
|
||||||
<label className="inline-flex items-center gap-2 text-sm">
|
|
||||||
<input type="checkbox" checked={form.isSecret} onChange={(event) => setForm((prev) => ({ ...prev, isSecret: event.target.checked }))} />
|
|
||||||
민감값
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</Modal>
|
|
||||||
|
|
||||||
<Modal open={apiOpen} title="공통 인증 API" onClose={() => setApiOpen(false)} className="!max-w-[1280px]" footer={<Button variant="ghost" onClick={() => setApiOpen(false)}>닫기</Button>}>
|
|
||||||
<div className="max-h-[70vh] overflow-x-auto overflow-y-auto">
|
|
||||||
<table className="w-full min-w-[980px] text-left text-sm">
|
|
||||||
<thead className="sticky top-0 z-10 border-b border-border bg-card text-xs text-muted-foreground">
|
|
||||||
<tr>
|
|
||||||
<th className="px-5 py-3 font-medium">그룹</th>
|
|
||||||
<th className="px-5 py-3 font-medium">Method</th>
|
|
||||||
<th className="px-5 py-3 font-medium">Path</th>
|
|
||||||
<th className="px-5 py-3 font-medium">용도</th>
|
|
||||||
<th className="px-5 py-3 font-medium">권한</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="divide-y divide-border">
|
|
||||||
{authApiEndpoints.map((endpoint) => (
|
|
||||||
<tr className="hover:bg-muted/20" key={`${endpoint.method}-${endpoint.path}`}>
|
|
||||||
<td className="px-5 py-3">{endpoint.group}</td>
|
|
||||||
<td className="px-5 py-3 font-semibold">{endpoint.method}</td>
|
|
||||||
<td className="px-5 py-3 font-medium">{endpoint.path}</td>
|
|
||||||
<td className="px-5 py-3 text-muted-foreground">{endpoint.purpose}</td>
|
|
||||||
<td className="px-5 py-3">
|
|
||||||
<Badge tone={endpoint.auth === '없음' ? 'neutral' : endpoint.auth === 'ADMIN' ? 'amber' : 'blue'}>{endpoint.auth}</Badge>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</Modal>
|
|
||||||
|
|
||||||
<LoadingModal open={loadingVisible} />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Database, FileArchive, Plus, RotateCcw, Server, Settings, TerminalSquare, Trash2 } from 'lucide-react';
|
import { Database, FileArchive, Plus, RotateCcw, Server, Settings, TerminalSquare, Trash2 } from 'lucide-react';
|
||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { Badge, Button, Card, CardHeader, Modal } from '../../components/ui';
|
import { Badge, 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';
|
||||||
@@ -64,6 +64,23 @@ function statusTone(status?: string): 'green' | 'amber' | 'red' | 'neutral' {
|
|||||||
return 'neutral';
|
return 'neutral';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildStatusText(status?: string) {
|
||||||
|
if (status === 'SUCCEEDED') return '성공';
|
||||||
|
if (status === 'FAILED') return '실패';
|
||||||
|
if (status === 'RUNNING') return '진행 중';
|
||||||
|
if (status === 'PENDING') return '대기';
|
||||||
|
if (status === 'CANCELED') return '취소';
|
||||||
|
return status ?? '빌드 이력 없음';
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildStatusTone(status?: string): 'green' | 'amber' | 'red' | 'blue' | 'neutral' {
|
||||||
|
if (status === 'SUCCEEDED') return 'green';
|
||||||
|
if (status === 'FAILED' || status === 'CANCELED') return 'red';
|
||||||
|
if (status === 'RUNNING') return 'blue';
|
||||||
|
if (status === 'PENDING') return 'amber';
|
||||||
|
return 'neutral';
|
||||||
|
}
|
||||||
|
|
||||||
function componentIcon(type: LauncherComponentDefinition['type']) {
|
function componentIcon(type: LauncherComponentDefinition['type']) {
|
||||||
if (type === 'client') return FileArchive;
|
if (type === 'client') return FileArchive;
|
||||||
if (type === 'database') return Database;
|
if (type === 'database') return Database;
|
||||||
@@ -122,9 +139,12 @@ export function LauncherComponentsPage() {
|
|||||||
const [logs, setLogs] = useState('');
|
const [logs, setLogs] = useState('');
|
||||||
const [logLoading, setLogLoading] = useState(false);
|
const [logLoading, setLogLoading] = useState(false);
|
||||||
const [envTarget, setEnvTarget] = useState<LauncherComponentDefinition | null>(null);
|
const [envTarget, setEnvTarget] = useState<LauncherComponentDefinition | null>(null);
|
||||||
const [envVariables, setEnvVariables] = useState<Array<{ key: string; value: string; editable: boolean; source: string }>>([]);
|
const [restartTarget, setRestartTarget] = useState<LauncherComponentDefinition | null>(null);
|
||||||
|
const [envVariables, setEnvVariables] = useState<Array<{ key: string; value: string; isSecret: boolean; editable: boolean; source: string }>>([]);
|
||||||
const [envLoading, setEnvLoading] = useState(false);
|
const [envLoading, setEnvLoading] = useState(false);
|
||||||
const [envSaving, setEnvSaving] = useState(false);
|
const [envSaving, setEnvSaving] = useState(false);
|
||||||
|
const envTableBodyRef = useRef<HTMLDivElement>(null);
|
||||||
|
const newEnvKeyInputRef = useRef<HTMLInputElement>(null);
|
||||||
const loadingVisible = useMinimumLoading(loading || logLoading);
|
const loadingVisible = useMinimumLoading(loading || logLoading);
|
||||||
|
|
||||||
const activeVersion = useMemo(() => versions.find((version) => version.isActive) ?? versions[0], [versions]);
|
const activeVersion = useMemo(() => versions.find((version) => version.isActive) ?? versions[0], [versions]);
|
||||||
@@ -184,15 +204,15 @@ export function LauncherComponentsPage() {
|
|||||||
setEnvLoading(true);
|
setEnvLoading(true);
|
||||||
loadRuntimeWorkloadEnv(LAUNCHER_NAMESPACE, apiKind(component.kind), component.workloadName)
|
loadRuntimeWorkloadEnv(LAUNCHER_NAMESPACE, apiKind(component.kind), component.workloadName)
|
||||||
.then((result) => {
|
.then((result) => {
|
||||||
setEnvVariables(result.variables ?? []);
|
setEnvVariables([...(result.variables ?? [])].sort((left, right) => left.key.localeCompare(right.key)));
|
||||||
})
|
})
|
||||||
.catch((error: Error) => {
|
.catch((error: Error) => {
|
||||||
toast({ type: 'error', title: '환경변수 조회 실패', message: error.message });
|
toast({ type: 'error', title: '설정값 조회 실패', message: error.message });
|
||||||
})
|
})
|
||||||
.finally(() => setEnvLoading(false));
|
.finally(() => setEnvLoading(false));
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateEnvVariable = (index: number, patch: Partial<{ key: string; value: string }>) => {
|
const updateEnvVariable = (index: number, patch: Partial<{ key: string; value: string; isSecret: boolean }>) => {
|
||||||
setEnvVariables((prev) => prev.map((variable, variableIndex) => (
|
setEnvVariables((prev) => prev.map((variable, variableIndex) => (
|
||||||
variableIndex === index ? { ...variable, ...patch } : variable
|
variableIndex === index ? { ...variable, ...patch } : variable
|
||||||
)));
|
)));
|
||||||
@@ -202,6 +222,14 @@ export function LauncherComponentsPage() {
|
|||||||
setEnvVariables((prev) => prev.filter((_, variableIndex) => variableIndex !== index));
|
setEnvVariables((prev) => prev.filter((_, variableIndex) => variableIndex !== index));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const addEnvVariable = () => {
|
||||||
|
setEnvVariables((prev) => [...prev, { key: '', value: '', isSecret: false, editable: true, source: 'literal' }]);
|
||||||
|
window.requestAnimationFrame(() => {
|
||||||
|
envTableBodyRef.current?.scrollTo({ top: envTableBodyRef.current.scrollHeight, behavior: 'smooth' });
|
||||||
|
newEnvKeyInputRef.current?.focus();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const saveEnv = () => {
|
const saveEnv = () => {
|
||||||
if (!envTarget?.kind || !envTarget.workloadName) return;
|
if (!envTarget?.kind || !envTarget.workloadName) return;
|
||||||
setEnvSaving(true);
|
setEnvSaving(true);
|
||||||
@@ -211,16 +239,16 @@ export function LauncherComponentsPage() {
|
|||||||
envTarget.workloadName,
|
envTarget.workloadName,
|
||||||
envVariables
|
envVariables
|
||||||
.filter((variable) => variable.editable)
|
.filter((variable) => variable.editable)
|
||||||
.map((variable) => ({ key: variable.key.trim(), value: variable.value }))
|
.map((variable) => ({ key: variable.key.trim(), value: variable.value, isSecret: variable.isSecret }))
|
||||||
.filter((variable) => variable.key)
|
.filter((variable) => variable.key)
|
||||||
)
|
)
|
||||||
.then((result) => {
|
.then((result) => {
|
||||||
setEnvVariables(result.variables ?? []);
|
setEnvVariables(result.variables ?? []);
|
||||||
toast({ type: 'success', title: '환경변수 저장', message: `${envTarget.name} 환경변수를 반영했습니다.` });
|
toast({ type: 'success', title: '설정값 저장', message: `${envTarget.name} 설정값을 반영했습니다.` });
|
||||||
refresh();
|
refresh();
|
||||||
})
|
})
|
||||||
.catch((error: Error) => {
|
.catch((error: Error) => {
|
||||||
toast({ type: 'error', title: '환경변수 저장 실패', message: error.message });
|
toast({ type: 'error', title: '설정값 저장 실패', message: error.message });
|
||||||
})
|
})
|
||||||
.finally(() => setEnvSaving(false));
|
.finally(() => setEnvSaving(false));
|
||||||
};
|
};
|
||||||
@@ -228,7 +256,7 @@ export function LauncherComponentsPage() {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageTitle
|
<PageTitle
|
||||||
eyebrow="런처 운영"
|
eyebrow="런처"
|
||||||
title="구성요소"
|
title="구성요소"
|
||||||
description="런처 클라이언트, 백엔드, DB의 배포 기준과 런타임 상태를 한 화면에서 확인합니다."
|
description="런처 클라이언트, 백엔드, DB의 배포 기준과 런타임 상태를 한 화면에서 확인합니다."
|
||||||
actions={
|
actions={
|
||||||
@@ -249,7 +277,9 @@ export function LauncherComponentsPage() {
|
|||||||
<Card className="p-5">
|
<Card className="p-5">
|
||||||
<p className="text-sm text-muted-foreground">최근 클라이언트 빌드</p>
|
<p className="text-sm text-muted-foreground">최근 클라이언트 빌드</p>
|
||||||
<p className="mt-3 text-2xl font-semibold">{latestBuild?.version ?? '-'}</p>
|
<p className="mt-3 text-2xl font-semibold">{latestBuild?.version ?? '-'}</p>
|
||||||
<p className="mt-1 text-xs text-muted-foreground">{latestBuild?.status ?? '빌드 이력 없음'}</p>
|
<div className="mt-2">
|
||||||
|
<Badge tone={buildStatusTone(latestBuild?.status)}>{buildStatusText(latestBuild?.status)}</Badge>
|
||||||
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
<Card className="p-5">
|
<Card className="p-5">
|
||||||
<p className="text-sm text-muted-foreground">Namespace</p>
|
<p className="text-sm text-muted-foreground">Namespace</p>
|
||||||
@@ -274,7 +304,7 @@ export function LauncherComponentsPage() {
|
|||||||
description={component.description}
|
description={component.description}
|
||||||
action={<Badge tone={isClient ? (activeVersion ? 'green' : 'neutral') : statusTone(status)}>{status}</Badge>}
|
action={<Badge tone={isClient ? (activeVersion ? 'green' : 'neutral') : statusTone(status)}>{status}</Badge>}
|
||||||
/>
|
/>
|
||||||
<div className="grid gap-5 p-5 xl:grid-cols-[minmax(260px,0.8fr)_minmax(0,1.2fr)]">
|
<div className="grid gap-5 p-5 xl:grid-cols-[minmax(220px,0.55fr)_minmax(0,1.45fr)]">
|
||||||
<div className="rounded-md border border-border bg-background p-4">
|
<div className="rounded-md border border-border bg-background p-4">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<span className="flex size-10 items-center justify-center rounded-md bg-muted text-muted-foreground">
|
<span className="flex size-10 items-center justify-center rounded-md bg-muted text-muted-foreground">
|
||||||
@@ -322,14 +352,14 @@ export function LauncherComponentsPage() {
|
|||||||
<TerminalSquare className="size-4" />
|
<TerminalSquare className="size-4" />
|
||||||
로그
|
로그
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={() => restart(component)} disabled={!workload || actionKey === component.key}>
|
<Button onClick={() => setRestartTarget(component)} disabled={!workload || actionKey === component.key}>
|
||||||
<RotateCcw className={cn('size-4', actionKey === component.key && 'animate-spin')} />
|
<RotateCcw className={cn('size-4', actionKey === component.key && 'animate-spin')} />
|
||||||
재시작
|
재시작
|
||||||
</Button>
|
</Button>
|
||||||
{component.type === 'backend' ? (
|
{component.type === 'backend' ? (
|
||||||
<Button variant="outline" onClick={() => openEnv(component)} disabled={!workload}>
|
<Button variant="outline" onClick={() => openEnv(component)} disabled={!workload}>
|
||||||
<Settings className="size-4" />
|
<Settings className="size-4" />
|
||||||
환경변수
|
설정값
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
@@ -395,12 +425,47 @@ export function LauncherComponentsPage() {
|
|||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
<Modal
|
<Modal
|
||||||
open={Boolean(envTarget)}
|
open={Boolean(restartTarget)}
|
||||||
title={envTarget ? `${envTarget.name} 환경변수` : '환경변수'}
|
title={restartTarget ? `${restartTarget.name} 재시작` : '구성요소 재시작'}
|
||||||
onClose={() => setEnvTarget(null)}
|
onClose={() => {
|
||||||
className="!max-w-4xl"
|
if (!restartTarget || actionKey !== restartTarget.key) setRestartTarget(null);
|
||||||
|
}}
|
||||||
footer={
|
footer={
|
||||||
<>
|
<>
|
||||||
|
<Button variant="ghost" onClick={() => setRestartTarget(null)} disabled={Boolean(restartTarget && actionKey === restartTarget.key)}>
|
||||||
|
취소
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
if (!restartTarget) return;
|
||||||
|
restart(restartTarget);
|
||||||
|
setRestartTarget(null);
|
||||||
|
}}
|
||||||
|
disabled={Boolean(restartTarget && actionKey === restartTarget.key)}
|
||||||
|
>
|
||||||
|
재시작
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<p className="text-sm leading-6 text-muted-foreground">
|
||||||
|
{restartTarget?.name}을 재시작합니다. 실행 중인 요청은 잠시 영향을 받을 수 있습니다.
|
||||||
|
</p>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
open={Boolean(envTarget)}
|
||||||
|
title={envTarget ? `${envTarget.name} 설정값` : '설정값'}
|
||||||
|
onClose={() => setEnvTarget(null)}
|
||||||
|
className="!flex !h-[min(760px,calc(100vh-4rem))] !max-w-4xl !flex-col"
|
||||||
|
bodyClassName="!flex !min-h-0 !max-h-none !flex-1 !overflow-hidden"
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button variant="outline" onClick={addEnvVariable} disabled={envLoading}>
|
||||||
|
<Plus className="size-4" />
|
||||||
|
설정값 추가
|
||||||
|
</Button>
|
||||||
|
<span className="flex-1" />
|
||||||
<Button variant="ghost" onClick={() => setEnvTarget(null)}>닫기</Button>
|
<Button variant="ghost" onClick={() => setEnvTarget(null)}>닫기</Button>
|
||||||
<Button onClick={saveEnv} disabled={envLoading || envSaving}>
|
<Button onClick={saveEnv} disabled={envLoading || envSaving}>
|
||||||
{envSaving ? '저장 중' : '저장'}
|
{envSaving ? '저장 중' : '저장'}
|
||||||
@@ -408,53 +473,74 @@ export function LauncherComponentsPage() {
|
|||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div className="space-y-4">
|
<div className="flex min-h-0 flex-1 flex-col gap-4">
|
||||||
<p className="text-xs leading-5 text-muted-foreground">
|
<p className="text-xs leading-5 text-muted-foreground">
|
||||||
저장하면 Kubernetes Deployment 환경변수가 변경되고 새 Pod로 롤아웃됩니다. Secret/ConfigMap에서 주입된 값은 여기서 직접 수정하지 않습니다.
|
Secret을 체크하면 Kubernetes Secret에 저장하고 secretKeyRef로 연결합니다. 체크하지 않은 값은 Deployment 환경변수로 저장합니다.
|
||||||
</p>
|
</p>
|
||||||
<div className="overflow-x-auto rounded-md border border-border">
|
<div ref={envTableBodyRef} className="min-h-0 flex-1 overflow-y-auto rounded-md border border-border">
|
||||||
<table className="w-full min-w-[720px] text-left text-sm">
|
<table className="w-full table-fixed text-left text-sm">
|
||||||
|
<colgroup>
|
||||||
|
<col className="w-[26%]" />
|
||||||
|
<col />
|
||||||
|
<col className="w-20" />
|
||||||
|
<col className="w-20" />
|
||||||
|
<col className="w-12" />
|
||||||
|
</colgroup>
|
||||||
<thead className="border-b border-border bg-muted/30 text-xs text-muted-foreground">
|
<thead className="border-b border-border bg-muted/30 text-xs text-muted-foreground">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="px-4 py-3 font-medium">Key</th>
|
<th className="px-3 py-3 font-medium">Key</th>
|
||||||
<th className="px-4 py-3 font-medium">Value</th>
|
<th className="px-3 py-3 font-medium">Value</th>
|
||||||
<th className="w-24 px-4 py-3 font-medium">Source</th>
|
<th className="px-3 py-3 font-medium">Secret</th>
|
||||||
<th className="w-16 px-4 py-3 font-medium"></th>
|
<th className="px-3 py-3 font-medium">Source</th>
|
||||||
|
<th className="px-2 py-3 font-medium"></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-border">
|
<tbody className="divide-y divide-border">
|
||||||
{envLoading ? (
|
{envLoading ? (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={4} className="px-4 py-8 text-center text-muted-foreground">환경변수를 불러오는 중입니다.</td>
|
<td colSpan={5} className="px-4 py-8 text-center text-muted-foreground">설정값을 불러오는 중입니다.</td>
|
||||||
</tr>
|
</tr>
|
||||||
) : null}
|
) : null}
|
||||||
{!envLoading && envVariables.length === 0 ? (
|
{!envLoading && envVariables.length === 0 ? (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={4} className="px-4 py-8 text-center text-muted-foreground">등록된 환경변수가 없습니다.</td>
|
<td colSpan={5} className="px-4 py-8 text-center text-muted-foreground">등록된 설정값이 없습니다.</td>
|
||||||
</tr>
|
</tr>
|
||||||
) : null}
|
) : null}
|
||||||
{!envLoading && envVariables.map((variable, index) => (
|
{!envLoading && envVariables.map((variable, index) => (
|
||||||
<tr key={`${variable.key}-${index}`}>
|
<tr key={`${variable.key}-${index}`}>
|
||||||
<td className="px-4 py-3">
|
<td className="min-w-0 px-3 py-2.5">
|
||||||
<input
|
<input
|
||||||
className="h-9 w-full rounded-md border border-input bg-background px-3 font-mono text-xs outline-none focus:ring-2 focus:ring-ring disabled:opacity-60"
|
ref={index === envVariables.length - 1 && variable.editable ? newEnvKeyInputRef : undefined}
|
||||||
|
className="h-8 w-full min-w-0 rounded-md border border-input bg-background px-2 font-mono text-xs outline-none focus:border-ring focus:ring-1 focus:ring-ring disabled:opacity-60"
|
||||||
value={variable.key}
|
value={variable.key}
|
||||||
disabled={!variable.editable}
|
disabled={!variable.editable}
|
||||||
onChange={(event) => updateEnvVariable(index, { key: event.target.value })}
|
onChange={(event) => updateEnvVariable(index, { key: event.target.value })}
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3">
|
<td className="min-w-0 px-3 py-2.5">
|
||||||
<input
|
<input
|
||||||
className="h-9 w-full rounded-md border border-input bg-background px-3 font-mono text-xs outline-none focus:ring-2 focus:ring-ring disabled:opacity-60"
|
className="h-8 w-full min-w-0 rounded-md border border-input bg-background px-2 font-mono text-xs outline-none focus:border-ring focus:ring-1 focus:ring-ring disabled:opacity-60"
|
||||||
value={variable.value}
|
value={variable.value}
|
||||||
disabled={!variable.editable}
|
disabled={!variable.editable}
|
||||||
onChange={(event) => updateEnvVariable(index, { value: event.target.value })}
|
onChange={(event) => updateEnvVariable(index, { value: event.target.value })}
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3">
|
<td className="px-3 py-2.5">
|
||||||
|
<label className="inline-flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="size-4 accent-primary"
|
||||||
|
checked={variable.isSecret}
|
||||||
|
disabled={!variable.editable}
|
||||||
|
onChange={(event) => updateEnvVariable(index, { isSecret: event.target.checked })}
|
||||||
|
/>
|
||||||
|
Secret
|
||||||
|
</label>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2.5">
|
||||||
<Badge tone={variable.editable ? 'blue' : 'neutral'}>{variable.source}</Badge>
|
<Badge tone={variable.editable ? 'blue' : 'neutral'}>{variable.source}</Badge>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3">
|
<td className="px-2 py-2.5">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="size-8 px-0"
|
className="size-8 px-0"
|
||||||
@@ -471,14 +557,6 @@ export function LauncherComponentsPage() {
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => setEnvVariables((prev) => [...prev, { key: '', value: '', editable: true, source: 'literal' }])}
|
|
||||||
disabled={envLoading}
|
|
||||||
>
|
|
||||||
<Plus className="size-4" />
|
|
||||||
환경변수 추가
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,500 @@
|
|||||||
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { FileText, RotateCcw, Search, TerminalSquare } from 'lucide-react';
|
||||||
|
import { Badge, Button, Card } from '../../components/ui';
|
||||||
|
import { PageTitle } from '../../layout/AppLayout';
|
||||||
|
import { formatDateTime } from '../../lib/date-format';
|
||||||
|
import { runtimeStatusDotClass, statusDotToneClass } from '../../lib/status-dot';
|
||||||
|
import { cn } from '../../lib/utils';
|
||||||
|
import type { RuntimeEvent, RuntimePodMetric, RuntimeResource, RuntimeResourceResponse } from '../../types/runtime';
|
||||||
|
import { LoadingModal, useMinimumLoading } from '../operations/operation-utils';
|
||||||
|
import { loadRuntimeEvents, loadRuntimePodMetrics, loadRuntimeResources, loadRuntimeWorkloadLogs } from '../runtime/runtime-api';
|
||||||
|
|
||||||
|
const LAUNCHER_NAMESPACE = 'studio-system';
|
||||||
|
|
||||||
|
type LauncherLogComponent = {
|
||||||
|
key: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
kind: 'Deployment' | 'StatefulSet';
|
||||||
|
workloadName: string;
|
||||||
|
serviceName: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const launcherLogComponents: LauncherLogComponent[] = [
|
||||||
|
{
|
||||||
|
key: 'launcher-be',
|
||||||
|
name: '런처 백엔드',
|
||||||
|
description: '로그인, 앱 목록, 업데이트, handoff API를 제공하는 서버입니다.',
|
||||||
|
kind: 'Deployment',
|
||||||
|
workloadName: 'studio-launcher-be',
|
||||||
|
serviceName: 'studio-launcher-be'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'launcher-db',
|
||||||
|
name: '런처 DB',
|
||||||
|
description: '런처 앱, 버전, 사용자 앱 권한 데이터를 저장합니다.',
|
||||||
|
kind: 'StatefulSet',
|
||||||
|
workloadName: 'studio-launcher-db',
|
||||||
|
serviceName: 'studio-launcher-db'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
function apiKind(kind: string) {
|
||||||
|
return kind.toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripAnsi(value: string) {
|
||||||
|
return value.replace(/(?:\x1B|\u241B)\[[0-?]*[ -/]*[@-~]/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function eventTone(type?: string) {
|
||||||
|
if (type === 'Normal') return 'green';
|
||||||
|
if (type === 'Warning') return 'amber';
|
||||||
|
return 'neutral';
|
||||||
|
}
|
||||||
|
|
||||||
|
const eventRanges = [
|
||||||
|
{ label: '1시간', value: '1h', milliseconds: 60 * 60 * 1000 },
|
||||||
|
{ label: '24시간', value: '24h', milliseconds: 24 * 60 * 60 * 1000 },
|
||||||
|
{ label: '전체', value: 'all', milliseconds: null }
|
||||||
|
];
|
||||||
|
|
||||||
|
function eventTime(event: RuntimeEvent) {
|
||||||
|
const value = event.lastTimestamp ?? event.firstTimestamp;
|
||||||
|
if (!value) return 0;
|
||||||
|
const time = new Date(value).getTime();
|
||||||
|
return Number.isFinite(time) ? time : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isEventInRange(event: RuntimeEvent, range: (typeof eventRanges)[number]) {
|
||||||
|
if (!range.milliseconds) return true;
|
||||||
|
const time = eventTime(event);
|
||||||
|
return time > 0 && time >= Date.now() - range.milliseconds;
|
||||||
|
}
|
||||||
|
|
||||||
|
function metricKey(namespace: string, pod: string) {
|
||||||
|
return `${namespace}/${pod}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseReadyCount(ready?: string) {
|
||||||
|
const [current, desired] = (ready ?? '').split('/').map((value) => Number(value));
|
||||||
|
return {
|
||||||
|
current: Number.isFinite(current) ? current : 0,
|
||||||
|
desired: Number.isFinite(desired) ? desired : 0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasReadyReplica(resource: RuntimeResource) {
|
||||||
|
const ready = parseReadyCount(resource.ready);
|
||||||
|
return ready.current > 0 && (ready.desired === 0 || ready.current <= ready.desired);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isLoggable(resource: RuntimeResource) {
|
||||||
|
return ['Pod', 'Deployment', 'StatefulSet', 'DaemonSet', 'Job'].includes(resource.kind);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isActiveLogTarget(resource: RuntimeResource) {
|
||||||
|
if (!isLoggable(resource)) return false;
|
||||||
|
if (resource.kind === 'Job') return resource.status === 'Running';
|
||||||
|
if (resource.kind === 'Pod') return ['Running', 'Pending', 'ContainerCreating'].includes(resource.status);
|
||||||
|
return resource.status === 'Running' && hasReadyReplica(resource);
|
||||||
|
}
|
||||||
|
|
||||||
|
function relatedToComponent(resource: RuntimeResource, component: LauncherLogComponent) {
|
||||||
|
return resource.name === component.workloadName
|
||||||
|
|| resource.name === component.serviceName
|
||||||
|
|| resource.name.includes(component.workloadName);
|
||||||
|
}
|
||||||
|
|
||||||
|
function workloadStatusDot(resource: RuntimeResource) {
|
||||||
|
if (resource.kind === 'Pod') return runtimeStatusDotClass(resource.status);
|
||||||
|
if (resource.kind === 'Job') return resource.status === 'Running' ? statusDotToneClass('green') : statusDotToneClass('neutral');
|
||||||
|
if (hasReadyReplica(resource) && resource.status === 'Running') return statusDotToneClass('green');
|
||||||
|
if (resource.status === 'Pending') return statusDotToneClass('amber');
|
||||||
|
if (['Error', 'CrashLoopBackOff', 'ImagePullBackOff', 'Failed'].includes(resource.status)) return statusDotToneClass('red');
|
||||||
|
return statusDotToneClass('neutral');
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseCpuMillicores(value?: string) {
|
||||||
|
if (!value) return 0;
|
||||||
|
if (value.endsWith('m')) return Number(value.replace('m', '')) || 0;
|
||||||
|
return (Number(value) || 0) * 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseMemoryMi(value?: string) {
|
||||||
|
if (!value) return 0;
|
||||||
|
const numeric = Number(value.replace(/[^\d.]/g, '')) || 0;
|
||||||
|
if (value.endsWith('Gi')) return numeric * 1024;
|
||||||
|
if (value.endsWith('Ki')) return numeric / 1024;
|
||||||
|
if (value.endsWith('Mi')) return numeric;
|
||||||
|
return numeric / 1024 / 1024;
|
||||||
|
}
|
||||||
|
|
||||||
|
function usagePercent(value: number, max: number) {
|
||||||
|
return Math.max(4, Math.min(100, Math.round((value / max) * 100)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function UsageBars({ metric, compact = false }: { metric?: RuntimePodMetric; compact?: boolean }) {
|
||||||
|
if (!metric) return <span className="text-xs text-muted-foreground">사용량 -</span>;
|
||||||
|
|
||||||
|
const cpuPercent = usagePercent(parseCpuMillicores(metric.cpu), 1000);
|
||||||
|
const memoryPercent = usagePercent(parseMemoryMi(metric.memory), 512);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn('grid gap-2', compact ? 'w-full grid-cols-2' : 'sm:grid-cols-2')}>
|
||||||
|
<div>
|
||||||
|
<div className="mb-1 flex justify-between text-xs text-muted-foreground">
|
||||||
|
<span>CPU</span>
|
||||||
|
<span>{metric.cpu}</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-2 overflow-hidden rounded-full bg-muted">
|
||||||
|
<div className="h-full rounded-full bg-primary" style={{ width: `${cpuPercent}%` }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="mb-1 flex justify-between text-xs text-muted-foreground">
|
||||||
|
<span>메모리</span>
|
||||||
|
<span>{metric.memory}</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-2 overflow-hidden rounded-full bg-muted">
|
||||||
|
<div className="h-full rounded-full bg-emerald-400" style={{ width: `${memoryPercent}%` }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LauncherLogsPage() {
|
||||||
|
const [componentKey, setComponentKey] = useState(launcherLogComponents[0]?.key ?? '');
|
||||||
|
const [data, setData] = useState<RuntimeResourceResponse | null>(null);
|
||||||
|
const [podMetrics, setPodMetrics] = useState<RuntimePodMetric[]>([]);
|
||||||
|
const [events, setEvents] = useState<RuntimeEvent[]>([]);
|
||||||
|
const [selectedResource, setSelectedResource] = useState<RuntimeResource | null>(null);
|
||||||
|
const [logs, setLogs] = useState('');
|
||||||
|
const [tail, setTail] = useState(100);
|
||||||
|
const [activeTab, setActiveTab] = useState<'logs' | 'events'>('logs');
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [eventRange, setEventRange] = useState('1h');
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [logLoading, setLogLoading] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const logContainerRef = useRef<HTMLPreElement | null>(null);
|
||||||
|
const loadingVisible = useMinimumLoading(loading || logLoading);
|
||||||
|
|
||||||
|
const selectedComponent = launcherLogComponents.find((component) => component.key === componentKey) ?? launcherLogComponents[0];
|
||||||
|
const resources = data?.resources ?? [];
|
||||||
|
const activeNamespace = data?.namespace || LAUNCHER_NAMESPACE;
|
||||||
|
const metricByPod = useMemo(() => {
|
||||||
|
const map = new Map<string, RuntimePodMetric>();
|
||||||
|
podMetrics.forEach((metric) => map.set(metricKey(metric.namespace, metric.pod), metric));
|
||||||
|
return map;
|
||||||
|
}, [podMetrics]);
|
||||||
|
const componentResources = selectedComponent ? resources.filter((resource) => relatedToComponent(resource, selectedComponent)) : [];
|
||||||
|
const loggableResources = componentResources.filter(isActiveLogTarget);
|
||||||
|
const selectedMetric = selectedResource?.kind === 'Pod' ? metricByPod.get(metricKey(activeNamespace, selectedResource.name)) : undefined;
|
||||||
|
const selectedEventRange = eventRanges.find((range) => range.value === eventRange) ?? eventRanges[0];
|
||||||
|
const filteredEvents = events.filter((event) => {
|
||||||
|
if (!isEventInRange(event, selectedEventRange)) return false;
|
||||||
|
const normalizedSearch = search.trim().toLowerCase();
|
||||||
|
const componentText = `${event.involvedObject ?? ''} ${event.message ?? ''}`.toLowerCase();
|
||||||
|
const componentMatch = selectedComponent ? componentText.includes(selectedComponent.workloadName.toLowerCase()) : true;
|
||||||
|
if (!componentMatch) return false;
|
||||||
|
if (!normalizedSearch) return true;
|
||||||
|
return `${event.type ?? ''} ${event.reason ?? ''} ${event.message ?? ''} ${event.involvedObject ?? ''}`.toLowerCase().includes(normalizedSearch);
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeTab !== 'logs') return;
|
||||||
|
const frame = window.requestAnimationFrame(() => {
|
||||||
|
const container = logContainerRef.current;
|
||||||
|
if (container) container.scrollTop = container.scrollHeight;
|
||||||
|
});
|
||||||
|
return () => window.cancelAnimationFrame(frame);
|
||||||
|
}, [activeTab, logs, logLoading, selectedResource?.name]);
|
||||||
|
|
||||||
|
const loadLogs = (resource: RuntimeResource, nextTail = tail) => {
|
||||||
|
setSelectedResource(resource);
|
||||||
|
setLogLoading(true);
|
||||||
|
setError('');
|
||||||
|
loadRuntimeWorkloadLogs(LAUNCHER_NAMESPACE, apiKind(resource.kind), resource.name, nextTail)
|
||||||
|
.then((result) => setLogs(result.logs ? stripAnsi(result.logs) : '로그가 없습니다.'))
|
||||||
|
.catch((logError: Error) => {
|
||||||
|
setLogs('');
|
||||||
|
setError(logError.message);
|
||||||
|
})
|
||||||
|
.finally(() => setLogLoading(false));
|
||||||
|
};
|
||||||
|
|
||||||
|
const refresh = () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
|
Promise.all([
|
||||||
|
loadRuntimeResources(LAUNCHER_NAMESPACE),
|
||||||
|
loadRuntimeEvents(LAUNCHER_NAMESPACE),
|
||||||
|
loadRuntimePodMetrics(LAUNCHER_NAMESPACE).catch(() => [])
|
||||||
|
])
|
||||||
|
.then(([nextData, nextEvents, nextMetrics]) => {
|
||||||
|
setData(nextData);
|
||||||
|
setEvents(nextEvents);
|
||||||
|
setPodMetrics(nextMetrics);
|
||||||
|
setSelectedResource(null);
|
||||||
|
setLogs('');
|
||||||
|
})
|
||||||
|
.catch((refreshError: Error) => setError(refreshError.message))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
refresh();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeTab !== 'events') return;
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
const loadEvents = () => {
|
||||||
|
loadRuntimeEvents(LAUNCHER_NAMESPACE)
|
||||||
|
.then((nextEvents) => {
|
||||||
|
if (!cancelled) setEvents(nextEvents);
|
||||||
|
})
|
||||||
|
.catch(() => undefined);
|
||||||
|
};
|
||||||
|
|
||||||
|
loadEvents();
|
||||||
|
const timer = window.setInterval(loadEvents, 10_000);
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
window.clearInterval(timer);
|
||||||
|
};
|
||||||
|
}, [activeTab]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageTitle
|
||||||
|
eyebrow="런처"
|
||||||
|
title="로그/이벤트"
|
||||||
|
description="런처 백엔드와 DB 워크로드 로그 및 Kubernetes 이벤트를 확인합니다."
|
||||||
|
actions={(
|
||||||
|
<Button variant="outline" className="h-8 px-3 text-[13px]" onClick={refresh} disabled={loading}>
|
||||||
|
<RotateCcw className={cn('size-4', loading && 'animate-spin')} />
|
||||||
|
새로고침
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{error ? <div className="mb-4 rounded-md border border-rose-500/30 bg-rose-950/30 px-4 py-3 text-sm text-rose-200">{error}</div> : null}
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Card className="p-5">
|
||||||
|
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-semibold">조회 기준</p>
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">런처 구성요소와 네임스페이스 기준으로 로그와 이벤트를 조회합니다.</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-end">
|
||||||
|
<label className="flex items-center gap-3">
|
||||||
|
<span className="shrink-0 text-xs font-medium text-muted-foreground">구성요소</span>
|
||||||
|
<select
|
||||||
|
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring sm:w-64"
|
||||||
|
value={componentKey}
|
||||||
|
onChange={(event) => {
|
||||||
|
setComponentKey(event.target.value);
|
||||||
|
setSelectedResource(null);
|
||||||
|
setLogs('');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{launcherLogComponents.map((component) => <option key={component.key} value={component.key}>{component.name}</option>)}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<div className="rounded-md border border-border bg-background px-3 py-2 text-sm">
|
||||||
|
<span className="text-xs text-muted-foreground">네임스페이스</span>
|
||||||
|
<span className="ml-3 font-medium">{LAUNCHER_NAMESPACE}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div className="grid gap-4 xl:grid-cols-[360px_minmax(0,1fr)]">
|
||||||
|
<Card className="min-w-0 overflow-hidden">
|
||||||
|
<div className="flex items-start justify-between gap-4 border-b border-border px-5 py-4">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-sm font-semibold text-foreground">워크로드</h2>
|
||||||
|
<p className="mt-1 text-xs leading-5 text-muted-foreground">실행 중인 로그 대상을 선택합니다.</p>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-muted-foreground">총 {loggableResources.length}개</span>
|
||||||
|
</div>
|
||||||
|
<div className="max-h-[620px] space-y-2 overflow-y-auto p-4">
|
||||||
|
{loggableResources.length === 0 ? (
|
||||||
|
<div className="flex min-h-40 items-center justify-center text-center text-sm text-muted-foreground">
|
||||||
|
실행 중인 로그 대상이 없습니다.
|
||||||
|
</div>
|
||||||
|
) : loggableResources.map((resource) => {
|
||||||
|
const selected = selectedResource?.kind === resource.kind && selectedResource?.name === resource.name;
|
||||||
|
const metric = resource.kind === 'Pod' ? metricByPod.get(metricKey(activeNamespace, resource.name)) : undefined;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
key={`${resource.kind}-${resource.name}`}
|
||||||
|
className={cn(
|
||||||
|
'w-full rounded-md border px-3 py-3 text-left transition-colors',
|
||||||
|
selected ? 'border-border bg-primary/15 shadow-[inset_4px_0_0_var(--color-primary)]' : 'border-border bg-background/70 hover:bg-muted/20'
|
||||||
|
)}
|
||||||
|
onClick={() => loadLogs(resource)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className={cn('size-2.5 shrink-0 rounded-full', workloadStatusDot(resource))} />
|
||||||
|
<span className="truncate text-sm font-medium">{resource.name}</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 flex flex-wrap gap-x-3 gap-y-1 text-xs text-muted-foreground">
|
||||||
|
<span>{resource.kind}</span>
|
||||||
|
<span>준비 <b className="text-foreground">{resource.ready}</b></span>
|
||||||
|
<span>재시작 <b className="text-foreground">{resource.restarts ?? 0}</b></span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3">
|
||||||
|
<UsageBars metric={metric} compact />
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<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">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={cn('inline-flex h-8 items-center gap-1.5 rounded-md px-3 text-[13px] font-medium', activeTab === 'logs' ? 'bg-primary text-primary-foreground' : 'bg-muted text-muted-foreground hover:text-foreground')}
|
||||||
|
onClick={() => setActiveTab('logs')}
|
||||||
|
>
|
||||||
|
<TerminalSquare className="size-4" />
|
||||||
|
로그
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={cn('inline-flex h-8 items-center gap-1.5 rounded-md px-3 text-[13px] font-medium', activeTab === 'events' ? 'bg-primary text-primary-foreground' : 'bg-muted text-muted-foreground hover:text-foreground')}
|
||||||
|
onClick={() => setActiveTab('events')}
|
||||||
|
>
|
||||||
|
<FileText className="size-4" />
|
||||||
|
이벤트
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{activeTab === 'logs' ? (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<select
|
||||||
|
className="h-8 rounded-md border border-input bg-background px-3 text-xs text-foreground outline-none focus:ring-2 focus:ring-ring"
|
||||||
|
value={tail}
|
||||||
|
onChange={(event) => {
|
||||||
|
const nextTail = Number(event.target.value);
|
||||||
|
setTail(nextTail);
|
||||||
|
if (selectedResource) loadLogs(selectedResource, nextTail);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value={100}>최근 100줄</option>
|
||||||
|
<option value={300}>최근 300줄</option>
|
||||||
|
<option value={1000}>최근 1000줄</option>
|
||||||
|
</select>
|
||||||
|
<Button variant="outline" className="h-8 px-3 text-[13px]" disabled={!selectedResource || logLoading} onClick={() => selectedResource && loadLogs(selectedResource)}>
|
||||||
|
<RotateCcw className={cn('size-4', logLoading && 'animate-spin')} />
|
||||||
|
로그 새로고침
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex w-full flex-col gap-2 lg:w-auto lg:flex-row lg:items-center">
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{eventRanges.map((range) => (
|
||||||
|
<Button
|
||||||
|
key={range.value}
|
||||||
|
variant={eventRange === range.value ? 'secondary' : 'outline'}
|
||||||
|
className="h-8 px-3 text-[13px]"
|
||||||
|
onClick={() => setEventRange(range.value)}
|
||||||
|
>
|
||||||
|
{range.label}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="relative w-full lg:w-80">
|
||||||
|
<Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
|
<input
|
||||||
|
className="h-8 w-full rounded-md border border-input bg-background pl-9 pr-3 text-sm text-foreground outline-none placeholder:text-muted-foreground focus:ring-2 focus:ring-ring"
|
||||||
|
placeholder="이벤트 검색"
|
||||||
|
value={search}
|
||||||
|
onChange={(event) => setSearch(event.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{activeTab === 'logs' ? (
|
||||||
|
<div className="min-h-[620px] bg-background">
|
||||||
|
<div className="border-b border-border px-5 py-4">
|
||||||
|
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
대상 <b className="text-foreground">{selectedResource ? `${selectedResource.kind}/${selectedResource.name}` : '-'}</b>
|
||||||
|
</div>
|
||||||
|
{selectedResource ? (
|
||||||
|
<div className="grid gap-2 text-xs sm:grid-cols-[minmax(0,1fr)_120px_120px] lg:min-w-[620px]">
|
||||||
|
<div className="rounded-md border border-border bg-card px-3 py-2">
|
||||||
|
<UsageBars metric={selectedMetric} />
|
||||||
|
</div>
|
||||||
|
<div className="rounded-md border border-border bg-card px-3 py-2">
|
||||||
|
<p className="text-muted-foreground">Ready</p>
|
||||||
|
<p className="mt-1 font-semibold text-foreground">{selectedResource.ready}</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-md border border-border bg-card px-3 py-2">
|
||||||
|
<p className="text-muted-foreground">재시작</p>
|
||||||
|
<p className="mt-1 font-semibold text-foreground">{selectedResource.restarts ?? 0}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{selectedResource ? (
|
||||||
|
<pre ref={logContainerRef} className="max-h-[640px] overflow-auto whitespace-pre-wrap p-5 font-mono text-xs leading-6 text-muted-foreground">
|
||||||
|
{logLoading ? '로그를 불러오는 중입니다...' : logs || '로그가 없습니다.'}
|
||||||
|
</pre>
|
||||||
|
) : (
|
||||||
|
<div className="flex min-h-[520px] items-center justify-center text-center text-sm text-muted-foreground">
|
||||||
|
왼쪽에서 워크로드를 선택하면 로그가 표시됩니다.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="max-h-[680px] overflow-x-auto overflow-y-auto">
|
||||||
|
<table className="w-full min-w-[920px] text-left text-sm">
|
||||||
|
<thead className="sticky top-0 z-10 border-b border-border bg-card text-xs text-muted-foreground">
|
||||||
|
<tr>
|
||||||
|
<th className="px-5 py-3 font-medium">Type</th>
|
||||||
|
<th className="px-5 py-3 font-medium">Reason</th>
|
||||||
|
<th className="px-5 py-3 font-medium">Object</th>
|
||||||
|
<th className="px-5 py-3 font-medium">Message</th>
|
||||||
|
<th className="px-5 py-3 font-medium">Count</th>
|
||||||
|
<th className="px-5 py-3 font-medium">Last</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-border">
|
||||||
|
{filteredEvents.length === 0 ? (
|
||||||
|
<tr><td colSpan={6} className="px-5 py-14 text-center text-muted-foreground">표시할 이벤트가 없습니다.</td></tr>
|
||||||
|
) : null}
|
||||||
|
{filteredEvents.map((event, index) => (
|
||||||
|
<tr key={`${event.involvedObject}-${event.reason}-${index}`} className="hover:bg-muted/20">
|
||||||
|
<td className="px-5 py-3"><Badge tone={eventTone(event.type)}>{event.type ?? '-'}</Badge></td>
|
||||||
|
<td className="px-5 py-3 font-medium">{event.reason ?? '-'}</td>
|
||||||
|
<td className="px-5 py-3 font-mono text-xs text-muted-foreground">{event.involvedObject ?? '-'}</td>
|
||||||
|
<td className="max-w-[460px] px-5 py-3 text-muted-foreground"><span className="block truncate">{event.message ?? '-'}</span></td>
|
||||||
|
<td className="px-5 py-3">{event.count ?? '-'}</td>
|
||||||
|
<td className="px-5 py-3 text-muted-foreground">{formatDateTime(event.lastTimestamp || event.firstTimestamp)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<LoadingModal open={loadingVisible} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import { Copy, FileText, Link2, RotateCcw } from 'lucide-react';
|
|||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { Badge, Button, Card, CardHeader, Modal } from '../../components/ui';
|
import { Badge, Button, Card, CardHeader, Modal } from '../../components/ui';
|
||||||
import { useToast } from '../../components/toast';
|
import { useToast } from '../../components/toast';
|
||||||
|
import { CommitAuthor } from '../../components/commit-author';
|
||||||
import { PageTitle } from '../../layout/AppLayout';
|
import { PageTitle } from '../../layout/AppLayout';
|
||||||
import { cn } from '../../lib/utils';
|
import { cn } from '../../lib/utils';
|
||||||
import type { GiteaRepo } from '../../types/application';
|
import type { GiteaRepo } from '../../types/application';
|
||||||
@@ -378,7 +379,7 @@ export function LauncherSourcePage() {
|
|||||||
'launcher-backend': emptyForm()
|
'launcher-backend': emptyForm()
|
||||||
});
|
});
|
||||||
const [commits, setCommits] = useState<GiteaCommit[]>([]);
|
const [commits, setCommits] = useState<GiteaCommit[]>([]);
|
||||||
const [sourceBranch, setSourceBranch] = useState('release');
|
const [sourceBranch, setSourceBranch] = useState('develop');
|
||||||
const [commitRange, setCommitRange] = useState<CommitRange>('week');
|
const [commitRange, setCommitRange] = useState<CommitRange>('week');
|
||||||
const [commitPage, setCommitPage] = useState(1);
|
const [commitPage, setCommitPage] = useState(1);
|
||||||
const [hasNextCommitPage, setHasNextCommitPage] = useState(false);
|
const [hasNextCommitPage, setHasNextCommitPage] = useState(false);
|
||||||
@@ -392,7 +393,7 @@ export function LauncherSourcePage() {
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const loadingVisible = useMinimumLoading(loading);
|
const loadingVisible = useMinimumLoading(loading);
|
||||||
const branchOptions = ['release', 'develop', 'main'];
|
const branchOptions = ['develop', 'release', 'main'];
|
||||||
|
|
||||||
const activeVersion = useMemo(() => versions.find((version) => version.isActive) ?? versions[0], [versions]);
|
const activeVersion = useMemo(() => versions.find((version) => version.isActive) ?? versions[0], [versions]);
|
||||||
const activeForm = forms[activeTarget];
|
const activeForm = forms[activeTarget];
|
||||||
@@ -537,7 +538,7 @@ export function LauncherSourcePage() {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageTitle
|
<PageTitle
|
||||||
eyebrow="런처 운영"
|
eyebrow="런처"
|
||||||
title="소스 관리"
|
title="소스 관리"
|
||||||
description="런처 클라이언트와 백엔드 소스 저장소를 구성요소별로 연결합니다."
|
description="런처 클라이언트와 백엔드 소스 저장소를 구성요소별로 연결합니다."
|
||||||
actions={
|
actions={
|
||||||
@@ -688,7 +689,7 @@ export function LauncherSourcePage() {
|
|||||||
) : null}
|
) : null}
|
||||||
{!commitsLoading && !commitsError ? commits.map((commit) => (
|
{!commitsLoading && !commitsError ? commits.map((commit) => (
|
||||||
<tr key={commit.sha} className="border-b border-border last:border-0">
|
<tr key={commit.sha} className="border-b border-border last:border-0">
|
||||||
<td className="whitespace-nowrap px-5 py-3 font-medium">{commitAuthor(commit)}</td>
|
<td className="whitespace-nowrap px-5 py-3"><CommitAuthor commit={commit} /></td>
|
||||||
<td className="whitespace-nowrap px-5 py-3 font-mono text-xs text-muted-foreground">{commit.sha.slice(0, 10)}</td>
|
<td className="whitespace-nowrap px-5 py-3 font-mono text-xs text-muted-foreground">{commit.sha.slice(0, 10)}</td>
|
||||||
<td className="max-w-[360px] truncate px-5 py-3">{commitMessage(commit)}</td>
|
<td className="max-w-[360px] truncate px-5 py-3">{commitMessage(commit)}</td>
|
||||||
<td className="whitespace-nowrap px-5 py-3 text-muted-foreground">{relativeTime(commitDate(commit))}</td>
|
<td className="whitespace-nowrap px-5 py-3 text-muted-foreground">{relativeTime(commitDate(commit))}</td>
|
||||||
@@ -709,7 +710,7 @@ export function LauncherSourcePage() {
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between gap-3 border-t border-border px-5 py-4 text-sm">
|
<div className="flex items-center justify-between gap-3 border-t border-border px-5 py-4 text-sm">
|
||||||
<span className="text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
{totalCommits === null ? `페이지 ${commitPage}` : `총 ${totalCommits}건 · ${commitPage} / ${totalCommitPages}`}
|
{totalCommits === null ? `페이지 ${commitPage}` : `총 ${totalCommits}건 · ${commitPage} / ${totalCommitPages}`}
|
||||||
</span>
|
</span>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
|||||||
@@ -36,6 +36,11 @@ export function LauncherVersionsPage() {
|
|||||||
const [deployOpen, setDeployOpen] = useState(false);
|
const [deployOpen, setDeployOpen] = useState(false);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [deploying, setDeploying] = useState(false);
|
const [deploying, setDeploying] = useState(false);
|
||||||
|
const [deployReplicas, setDeployReplicas] = useState('1');
|
||||||
|
const [deployCpuRequest, setDeployCpuRequest] = useState('100m');
|
||||||
|
const [deployCpuLimit, setDeployCpuLimit] = useState('500m');
|
||||||
|
const [deployMemoryRequest, setDeployMemoryRequest] = useState('128Mi');
|
||||||
|
const [deployMemoryLimit, setDeployMemoryLimit] = useState('256Mi');
|
||||||
const [releaseForm, setReleaseForm] = useState({
|
const [releaseForm, setReleaseForm] = useState({
|
||||||
buildJobId: '',
|
buildJobId: '',
|
||||||
version: '',
|
version: '',
|
||||||
@@ -128,7 +133,16 @@ export function LauncherVersionsPage() {
|
|||||||
const deployBackend = () => {
|
const deployBackend = () => {
|
||||||
if (!latestBackendBuild?.downloadUrl) return;
|
if (!latestBackendBuild?.downloadUrl) return;
|
||||||
setDeploying(true);
|
setDeploying(true);
|
||||||
deployLauncherBackend(latestBackendBuild.downloadUrl)
|
const requestedReplicas = Number.parseInt(deployReplicas, 10);
|
||||||
|
const resources = {
|
||||||
|
...(Number.isFinite(requestedReplicas) && requestedReplicas > 0 ? { replicas: requestedReplicas } : {}),
|
||||||
|
...(deployCpuRequest.trim() ? { cpuRequest: deployCpuRequest.trim() } : {}),
|
||||||
|
...(deployCpuLimit.trim() ? { cpuLimit: deployCpuLimit.trim() } : {}),
|
||||||
|
...(deployMemoryRequest.trim() ? { memoryRequest: deployMemoryRequest.trim() } : {}),
|
||||||
|
...(deployMemoryLimit.trim() ? { memoryLimit: deployMemoryLimit.trim() } : {})
|
||||||
|
};
|
||||||
|
|
||||||
|
deployLauncherBackend(latestBackendBuild.downloadUrl, Object.keys(resources).length ? resources : undefined)
|
||||||
.then((result) => {
|
.then((result) => {
|
||||||
setDeployResult(result);
|
setDeployResult(result);
|
||||||
toast({ type: 'success', message: '런처 백엔드를 배포했습니다.' });
|
toast({ type: 'success', message: '런처 백엔드를 배포했습니다.' });
|
||||||
@@ -141,10 +155,19 @@ export function LauncherVersionsPage() {
|
|||||||
.finally(() => setDeploying(false));
|
.finally(() => setDeploying(false));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const openBackendDeploy = () => {
|
||||||
|
setDeployReplicas('1');
|
||||||
|
setDeployCpuRequest('100m');
|
||||||
|
setDeployCpuLimit('500m');
|
||||||
|
setDeployMemoryRequest('128Mi');
|
||||||
|
setDeployMemoryLimit('256Mi');
|
||||||
|
setDeployOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageTitle
|
<PageTitle
|
||||||
eyebrow="런처 운영"
|
eyebrow="런처"
|
||||||
title="버전 관리"
|
title="버전 관리"
|
||||||
description="스튜디오 메인 런처 클라이언트 릴리즈와 백엔드 배포를 관리합니다."
|
description="스튜디오 메인 런처 클라이언트 릴리즈와 백엔드 배포를 관리합니다."
|
||||||
actions={
|
actions={
|
||||||
@@ -159,7 +182,7 @@ export function LauncherVersionsPage() {
|
|||||||
릴리즈 등록
|
릴리즈 등록
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
<Button className="h-8 px-3 text-[13px]" onClick={() => setDeployOpen(true)} disabled={!latestBackendBuild?.downloadUrl}>
|
<Button className="h-8 px-3 text-[13px]" onClick={openBackendDeploy} disabled={!latestBackendBuild?.downloadUrl}>
|
||||||
<Play className="size-4" />
|
<Play className="size-4" />
|
||||||
백엔드 배포
|
백엔드 배포
|
||||||
</Button>
|
</Button>
|
||||||
@@ -293,7 +316,7 @@ export function LauncherVersionsPage() {
|
|||||||
<p className="mt-1 text-xs leading-5 text-muted-foreground">성공한 빌드 결과의 버전과 다운로드 URL을 런처 배포 기준으로 저장합니다.</p>
|
<p className="mt-1 text-xs leading-5 text-muted-foreground">성공한 빌드 결과의 버전과 다운로드 URL을 런처 배포 기준으로 저장합니다.</p>
|
||||||
</div>
|
</div>
|
||||||
<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">빌드 결과 <b className="text-foreground">*</b></span>
|
||||||
<select
|
<select
|
||||||
className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring"
|
className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring"
|
||||||
value={releaseForm.buildJobId}
|
value={releaseForm.buildJobId}
|
||||||
@@ -309,7 +332,7 @@ export function LauncherVersionsPage() {
|
|||||||
</label>
|
</label>
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
<div>
|
<div>
|
||||||
<span className="text-xs font-medium text-muted-foreground">버전</span>
|
<span className="text-xs font-medium text-muted-foreground">버전 <b className="text-foreground">*</b></span>
|
||||||
<div className="mt-2 flex h-9 items-center rounded-md border border-border bg-muted/20 px-3 text-sm font-medium text-foreground">
|
<div className="mt-2 flex h-9 items-center rounded-md border border-border bg-muted/20 px-3 text-sm font-medium text-foreground">
|
||||||
{displayValue(releaseForm.version)}
|
{displayValue(releaseForm.version)}
|
||||||
</div>
|
</div>
|
||||||
@@ -324,7 +347,7 @@ export function LauncherVersionsPage() {
|
|||||||
<p className="mt-2 break-all font-mono text-xs">{displayValue(currentArtifact)}</p>
|
<p className="mt-2 break-all font-mono text-xs">{displayValue(currentArtifact)}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-md border border-border bg-background px-4 py-3">
|
<div className="rounded-md border border-border bg-background px-4 py-3">
|
||||||
<p className="text-xs text-muted-foreground">다운로드 URL</p>
|
<p className="text-xs text-muted-foreground">다운로드 URL <b className="text-foreground">*</b></p>
|
||||||
<p className="mt-2 break-all font-mono text-xs text-muted-foreground">{displayValue(releaseForm.downloadUrl)}</p>
|
<p className="mt-2 break-all font-mono text-xs text-muted-foreground">{displayValue(releaseForm.downloadUrl)}</p>
|
||||||
</div>
|
</div>
|
||||||
<label className="block">
|
<label className="block">
|
||||||
@@ -345,6 +368,60 @@ export function LauncherVersionsPage() {
|
|||||||
<p className="font-medium">최신 성공 빌드 이미지로 런처 백엔드를 배포합니다.</p>
|
<p className="font-medium">최신 성공 빌드 이미지로 런처 백엔드를 배포합니다.</p>
|
||||||
<p className="mt-1 text-xs leading-5 text-muted-foreground">Deployment 이미지가 교체되고 rollout 완료까지 대기합니다.</p>
|
<p className="mt-1 text-xs leading-5 text-muted-foreground">Deployment 이미지가 교체되고 rollout 완료까지 대기합니다.</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="grid gap-3 md:grid-cols-2">
|
||||||
|
<label className="block">
|
||||||
|
<span className="text-xs font-medium text-muted-foreground">Replicas</span>
|
||||||
|
<input
|
||||||
|
className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm outline-none"
|
||||||
|
value={deployReplicas}
|
||||||
|
disabled={deploying}
|
||||||
|
onChange={(event) => setDeployReplicas(event.target.value)}
|
||||||
|
placeholder="1"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="block">
|
||||||
|
<span className="text-xs font-medium text-muted-foreground">CPU 요청</span>
|
||||||
|
<input
|
||||||
|
className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm outline-none"
|
||||||
|
value={deployCpuRequest}
|
||||||
|
disabled={deploying}
|
||||||
|
onChange={(event) => setDeployCpuRequest(event.target.value)}
|
||||||
|
placeholder="예: 100m"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-3 md:grid-cols-2">
|
||||||
|
<label className="block">
|
||||||
|
<span className="text-xs font-medium text-muted-foreground">CPU 상한</span>
|
||||||
|
<input
|
||||||
|
className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm outline-none"
|
||||||
|
value={deployCpuLimit}
|
||||||
|
disabled={deploying}
|
||||||
|
onChange={(event) => setDeployCpuLimit(event.target.value)}
|
||||||
|
placeholder="예: 500m"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="block">
|
||||||
|
<span className="text-xs font-medium text-muted-foreground">메모리 요청</span>
|
||||||
|
<input
|
||||||
|
className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm outline-none"
|
||||||
|
value={deployMemoryRequest}
|
||||||
|
disabled={deploying}
|
||||||
|
onChange={(event) => setDeployMemoryRequest(event.target.value)}
|
||||||
|
placeholder="예: 256Mi"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<label className="block">
|
||||||
|
<span className="text-xs font-medium text-muted-foreground">메모리 상한</span>
|
||||||
|
<input
|
||||||
|
className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm outline-none"
|
||||||
|
value={deployMemoryLimit}
|
||||||
|
disabled={deploying}
|
||||||
|
onChange={(event) => setDeployMemoryLimit(event.target.value)}
|
||||||
|
placeholder="예: 512Mi"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
<div className="rounded-md border border-border bg-background p-4">
|
<div className="rounded-md border border-border bg-background p-4">
|
||||||
<p className="text-xs text-muted-foreground">배포 이미지</p>
|
<p className="text-xs text-muted-foreground">배포 이미지</p>
|
||||||
<p className="mt-2 break-all font-mono text-xs font-medium">{latestBackendBuild?.downloadUrl || '-'}</p>
|
<p className="mt-2 break-all font-mono text-xs font-medium">{latestBackendBuild?.downloadUrl || '-'}</p>
|
||||||
|
|||||||
@@ -29,6 +29,14 @@ export type LauncherBuildJob = {
|
|||||||
downloadUrl: string;
|
downloadUrl: string;
|
||||||
artifact: string;
|
artifact: string;
|
||||||
status: 'PENDING' | 'RUNNING' | 'SUCCEEDED' | 'FAILED' | 'CANCELED';
|
status: 'PENDING' | 'RUNNING' | 'SUCCEEDED' | 'FAILED' | 'CANCELED';
|
||||||
|
requestedBy?: string | null;
|
||||||
|
requestedByName?: string | null;
|
||||||
|
requestedByEmail?: string | null;
|
||||||
|
requestedByAvatarUrl?: string | null;
|
||||||
|
createdBy?: string | null;
|
||||||
|
createdByName?: string | null;
|
||||||
|
createdByEmail?: string | null;
|
||||||
|
createdByAvatarUrl?: string | null;
|
||||||
logs?: string | null;
|
logs?: string | null;
|
||||||
startedAt?: string | null;
|
startedAt?: string | null;
|
||||||
finishedAt?: string | null;
|
finishedAt?: string | null;
|
||||||
@@ -55,6 +63,14 @@ export type LauncherBackendDeployResult = {
|
|||||||
logs: string;
|
logs: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type DeploymentResourceLimits = {
|
||||||
|
replicas?: number;
|
||||||
|
cpuRequest?: string;
|
||||||
|
cpuLimit?: string;
|
||||||
|
memoryRequest?: string;
|
||||||
|
memoryLimit?: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type LauncherBuildEnvVariable = {
|
export type LauncherBuildEnvVariable = {
|
||||||
key: string;
|
key: string;
|
||||||
value: string;
|
value: string;
|
||||||
@@ -97,12 +113,16 @@ export type GiteaCommit = {
|
|||||||
username?: string;
|
username?: string;
|
||||||
full_name?: string;
|
full_name?: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
|
avatar_url?: string;
|
||||||
|
avatarUrl?: string;
|
||||||
} | null;
|
} | null;
|
||||||
committer?: {
|
committer?: {
|
||||||
login?: string;
|
login?: string;
|
||||||
username?: string;
|
username?: string;
|
||||||
full_name?: string;
|
full_name?: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
|
avatar_url?: string;
|
||||||
|
avatarUrl?: string;
|
||||||
} | null;
|
} | null;
|
||||||
created?: string;
|
created?: string;
|
||||||
};
|
};
|
||||||
@@ -129,19 +149,6 @@ export type LauncherAppLink = {
|
|||||||
release?: AppRelease | null;
|
release?: AppRelease | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type LauncherCommonSetting = {
|
|
||||||
id: string;
|
|
||||||
key: string;
|
|
||||||
name: string;
|
|
||||||
type: string;
|
|
||||||
value: string;
|
|
||||||
description?: string | null;
|
|
||||||
isActive: boolean;
|
|
||||||
isSecret: boolean;
|
|
||||||
createdAt: string;
|
|
||||||
updatedAt?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type LauncherUnlinkedApp = AppWithComponents & {
|
export type LauncherUnlinkedApp = AppWithComponents & {
|
||||||
releases?: AppRelease[];
|
releases?: AppRelease[];
|
||||||
};
|
};
|
||||||
@@ -282,10 +289,10 @@ export function getLauncherBackendDeployment() {
|
|||||||
return apiRequest<LauncherBackendDeployment>('/launcher/backend-deployment');
|
return apiRequest<LauncherBackendDeployment>('/launcher/backend-deployment');
|
||||||
}
|
}
|
||||||
|
|
||||||
export function deployLauncherBackend(image: string) {
|
export function deployLauncherBackend(image: string, resources?: DeploymentResourceLimits) {
|
||||||
return apiRequest<LauncherBackendDeployResult>('/launcher/backend-deployment', {
|
return apiRequest<LauncherBackendDeployResult>('/launcher/backend-deployment', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ image })
|
body: JSON.stringify({ image, resources: resources && Object.keys(resources).length ? resources : undefined })
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -380,51 +387,10 @@ export function deleteLauncherAppLink(id: string) {
|
|||||||
return apiRequest<{ deleted: boolean }>(`/launcher/app-links/${id}`, { method: 'DELETE' });
|
return apiRequest<{ deleted: boolean }>(`/launcher/app-links/${id}`, { method: 'DELETE' });
|
||||||
}
|
}
|
||||||
|
|
||||||
export function listLauncherCommonSettings() {
|
|
||||||
return apiRequest<LauncherCommonSetting[]>('/launcher/common-settings');
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createLauncherCommonSetting(input: {
|
|
||||||
key: string;
|
|
||||||
name: string;
|
|
||||||
type: string;
|
|
||||||
value: string;
|
|
||||||
description?: string;
|
|
||||||
isActive?: boolean;
|
|
||||||
isSecret?: boolean;
|
|
||||||
}) {
|
|
||||||
return apiRequest<{ setting: LauncherCommonSetting }>('/launcher/common-settings', {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify(input)
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function updateLauncherCommonSetting(
|
|
||||||
id: string,
|
|
||||||
input: Partial<{
|
|
||||||
key: string;
|
|
||||||
name: string;
|
|
||||||
type: string;
|
|
||||||
value: string;
|
|
||||||
description: string;
|
|
||||||
isActive: boolean;
|
|
||||||
isSecret: boolean;
|
|
||||||
}>
|
|
||||||
) {
|
|
||||||
return apiRequest<{ setting: LauncherCommonSetting }>(`/launcher/common-settings/${id}`, {
|
|
||||||
method: 'PATCH',
|
|
||||||
body: JSON.stringify(input)
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function deleteLauncherCommonSetting(id: string) {
|
|
||||||
return apiRequest<{ deleted: boolean }>(`/launcher/common-settings/${id}`, { method: 'DELETE' });
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
export type RuntimeWorkloadEnvVariable = {
|
export type RuntimeWorkloadEnvVariable = {
|
||||||
key: string;
|
key: string;
|
||||||
value: string;
|
value: string;
|
||||||
|
isSecret: boolean;
|
||||||
editable: boolean;
|
editable: boolean;
|
||||||
source: string;
|
source: string;
|
||||||
};
|
};
|
||||||
@@ -447,7 +413,7 @@ export function saveRuntimeWorkloadEnv(
|
|||||||
namespace: string,
|
namespace: string,
|
||||||
kind: string,
|
kind: string,
|
||||||
name: string,
|
name: string,
|
||||||
variables: Array<{ key: string; value: string }>
|
variables: Array<{ key: string; value: string; isSecret: boolean }>
|
||||||
) {
|
) {
|
||||||
return apiRequest<{
|
return apiRequest<{
|
||||||
namespace: string;
|
namespace: string;
|
||||||
|
|||||||
@@ -4,16 +4,18 @@ import { DataTableCard, type DataTableColumn, type DataTableFilter } from '../..
|
|||||||
import { useToast } from '../../components/toast';
|
import { useToast } from '../../components/toast';
|
||||||
import { Button, Modal } from '../../components/ui';
|
import { Button, Modal } from '../../components/ui';
|
||||||
import { PageTitle } from '../../layout/AppLayout';
|
import { PageTitle } from '../../layout/AppLayout';
|
||||||
import { getAdminAppPermissions, isOwnerAdmin, setAdminAppPermissions } from '../../lib/admin-app-permissions';
|
import { isOwnerAdmin, setAdminAppPermissions } from '../../lib/admin-app-permissions';
|
||||||
import { listApps } from '../applications/application-api';
|
import { listApps } from '../applications/application-api';
|
||||||
import type { AppWithComponents } from '../../types/application';
|
import type { AppWithComponents } from '../../types/application';
|
||||||
import {
|
import {
|
||||||
createAdmin,
|
createAdmin,
|
||||||
createAdminSshKey,
|
createAdminSshKey,
|
||||||
deleteAdminSshKey,
|
deleteAdminSshKey,
|
||||||
|
loadAdminAppPermissions,
|
||||||
loadAdminRoles,
|
loadAdminRoles,
|
||||||
loadAdmins,
|
loadAdminsPage,
|
||||||
resetAdminPassword,
|
resetAdminPassword,
|
||||||
|
updateAdminAppPermissions,
|
||||||
updateAdminRole,
|
updateAdminRole,
|
||||||
updateAdminStatus,
|
updateAdminStatus,
|
||||||
type AdminAccount,
|
type AdminAccount,
|
||||||
@@ -42,6 +44,10 @@ const fallbackAdminRoles: AdminRoleOption[] = [
|
|||||||
export function AdminManagementPage() {
|
export function AdminManagementPage() {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const [admins, setAdmins] = useState<AdminAccount[]>([]);
|
const [admins, setAdmins] = useState<AdminAccount[]>([]);
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
|
const [keyword, setKeyword] = useState('');
|
||||||
|
const [filterValues, setFilterValues] = useState<Record<string, string>>({});
|
||||||
const [adminRoles, setAdminRoles] = useState<AdminRoleOption[]>([]);
|
const [adminRoles, setAdminRoles] = useState<AdminRoleOption[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [failed, setFailed] = useState(false);
|
const [failed, setFailed] = useState(false);
|
||||||
@@ -53,9 +59,9 @@ export function AdminManagementPage() {
|
|||||||
const [sshTarget, setSshTarget] = useState({
|
const [sshTarget, setSshTarget] = useState({
|
||||||
admin: null as AdminAccount | null,
|
admin: null as AdminAccount | null,
|
||||||
label: '',
|
label: '',
|
||||||
giteaUsername: '',
|
|
||||||
publicKey: ''
|
publicKey: ''
|
||||||
});
|
});
|
||||||
|
const [sshDeleteTarget, setSshDeleteTarget] = useState<{ admin: AdminAccount; keyId: string } | null>(null);
|
||||||
const [adminForm, setAdminForm] = useState(emptyAdminForm);
|
const [adminForm, setAdminForm] = useState(emptyAdminForm);
|
||||||
const [saveState, setSaveState] = useState<'idle' | 'saving' | 'error'>('idle');
|
const [saveState, setSaveState] = useState<'idle' | 'saving' | 'error'>('idle');
|
||||||
const [passwordState, setPasswordState] = useState<'idle' | 'saving' | 'error'>('idle');
|
const [passwordState, setPasswordState] = useState<'idle' | 'saving' | 'error'>('idle');
|
||||||
@@ -68,13 +74,18 @@ export function AdminManagementPage() {
|
|||||||
});
|
});
|
||||||
const [permissionApps, setPermissionApps] = useState<AppWithComponents[]>([]);
|
const [permissionApps, setPermissionApps] = useState<AppWithComponents[]>([]);
|
||||||
const [permissionLoading, setPermissionLoading] = useState(false);
|
const [permissionLoading, setPermissionLoading] = useState(false);
|
||||||
|
const [permissionSaving, setPermissionSaving] = useState(false);
|
||||||
const loadingVisible = useMinimumLoading(loading);
|
const loadingVisible = useMinimumLoading(loading);
|
||||||
|
|
||||||
const refresh = () => {
|
const refresh = () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
Promise.all([loadAdmins(), loadAdminRoles().catch(() => fallbackAdminRoles)])
|
Promise.all([
|
||||||
.then(([nextAdmins, nextRoles]) => {
|
loadAdminsPage({ page, limit: 6, keyword, role: filterValues.role, state: filterValues.state }),
|
||||||
setAdmins(nextAdmins);
|
loadAdminRoles().catch(() => fallbackAdminRoles)
|
||||||
|
])
|
||||||
|
.then(([result, nextRoles]) => {
|
||||||
|
setAdmins(result.items);
|
||||||
|
setTotal(result.total);
|
||||||
setAdminRoles(nextRoles.filter((role) => role.isActive));
|
setAdminRoles(nextRoles.filter((role) => role.isActive));
|
||||||
setFailed(false);
|
setFailed(false);
|
||||||
})
|
})
|
||||||
@@ -88,7 +99,7 @@ export function AdminManagementPage() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
refresh();
|
refresh();
|
||||||
}, []);
|
}, [page, keyword, filterValues.role, filterValues.state]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!menuAdmin) return;
|
if (!menuAdmin) return;
|
||||||
@@ -107,7 +118,7 @@ export function AdminManagementPage() {
|
|||||||
}, [menuAdmin]);
|
}, [menuAdmin]);
|
||||||
|
|
||||||
const rows = useMemo<AdminRow[]>(
|
const rows = useMemo<AdminRow[]>(
|
||||||
() => admins.map((admin, index) => ({ ...admin, no: admins.length - index, state: admin.isActive ? '활성' : '비활성' })),
|
() => admins.map((admin) => ({ ...admin, no: admin.no ?? 0, state: admin.isActive ? '활성' : '비활성' })),
|
||||||
[admins]
|
[admins]
|
||||||
);
|
);
|
||||||
const roleOptions = adminRoles.length ? adminRoles : fallbackAdminRoles;
|
const roleOptions = adminRoles.length ? adminRoles : fallbackAdminRoles;
|
||||||
@@ -116,21 +127,22 @@ export function AdminManagementPage() {
|
|||||||
setMenuAdmin(null);
|
setMenuAdmin(null);
|
||||||
const account = admin.email.split('@')[0] || 'admin';
|
const account = admin.email.split('@')[0] || 'admin';
|
||||||
const random = Math.random().toString(36).slice(2, 8);
|
const random = Math.random().toString(36).slice(2, 8);
|
||||||
setSshTarget({ admin, label: `${account}-${random}-key`, giteaUsername: '', publicKey: '' });
|
setSshTarget({ admin, label: `${account}-${random}-key`, publicKey: '' });
|
||||||
setSshState('idle');
|
setSshState('idle');
|
||||||
setSshError('');
|
setSshError('');
|
||||||
};
|
};
|
||||||
|
|
||||||
const openPermissionModal = (admin: AdminAccount) => {
|
const openPermissionModal = (admin: AdminAccount) => {
|
||||||
setMenuAdmin(null);
|
setMenuAdmin(null);
|
||||||
setPermissionTarget({ admin, appIds: isOwnerAdmin(admin) ? [] : getAdminAppPermissions(admin) });
|
setPermissionTarget({ admin, appIds: [] });
|
||||||
setPermissionLoading(true);
|
setPermissionLoading(true);
|
||||||
listApps()
|
Promise.all([
|
||||||
.then((apps) => {
|
listApps(),
|
||||||
|
isOwnerAdmin(admin) ? Promise.resolve<string[]>([]) : loadAdminAppPermissions(admin.id)
|
||||||
|
])
|
||||||
|
.then(([apps, appIds]) => {
|
||||||
setPermissionApps(apps);
|
setPermissionApps(apps);
|
||||||
if (isOwnerAdmin(admin)) {
|
setPermissionTarget({ admin, appIds: isOwnerAdmin(admin) ? apps.map((app) => app.id) : appIds });
|
||||||
setPermissionTarget({ admin, appIds: apps.map((app) => app.id) });
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
setPermissionApps([]);
|
setPermissionApps([]);
|
||||||
@@ -152,15 +164,28 @@ export function AdminManagementPage() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setAdminAppPermissions(permissionTarget.admin, permissionTarget.appIds);
|
setPermissionSaving(true);
|
||||||
setPermissionTarget({ admin: null, appIds: [] });
|
updateAdminAppPermissions(permissionTarget.admin.id, permissionTarget.appIds)
|
||||||
toast({ type: 'success', title: '앱 권한 저장', message: '관리자 앱 권한이 저장되었습니다.' });
|
.then((result) => {
|
||||||
|
setAdminAppPermissions(permissionTarget.admin as AdminAccount, result.appIds ?? permissionTarget.appIds);
|
||||||
|
setPermissionTarget({ admin: null, appIds: [] });
|
||||||
|
toast({ type: 'success', title: '앱 권한 저장', message: '관리자 앱 권한이 저장되었습니다.' });
|
||||||
|
refresh();
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
toast({ type: 'error', title: '앱 권한 저장 실패', message: error instanceof Error ? error.message : '관리자 앱 권한 저장에 실패했습니다.' });
|
||||||
|
})
|
||||||
|
.finally(() => setPermissionSaving(false));
|
||||||
};
|
};
|
||||||
|
|
||||||
const submitAdmin = () => {
|
const submitAdmin = () => {
|
||||||
if (!adminForm.email.trim() || !adminForm.name.trim()) return;
|
if (!adminForm.email.trim() || !adminForm.name.trim() || adminForm.temporaryPassword.length < 8) return;
|
||||||
|
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(adminForm.email.trim())) {
|
||||||
|
toast({ type: 'error', title: '관리자 등록 실패', message: '올바른 이메일 주소를 입력해주세요.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
setSaveState('saving');
|
setSaveState('saving');
|
||||||
createAdmin(adminForm)
|
createAdmin({ ...adminForm, email: adminForm.email.trim().toLowerCase() })
|
||||||
.then(() => {
|
.then(() => {
|
||||||
setSaveState('idle');
|
setSaveState('idle');
|
||||||
setCreateOpen(false);
|
setCreateOpen(false);
|
||||||
@@ -194,12 +219,11 @@ export function AdminManagementPage() {
|
|||||||
setSshError('');
|
setSshError('');
|
||||||
createAdminSshKey(sshTarget.admin.id, {
|
createAdminSshKey(sshTarget.admin.id, {
|
||||||
label: sshTarget.label.trim() || undefined,
|
label: sshTarget.label.trim() || undefined,
|
||||||
giteaUsername: sshTarget.giteaUsername.trim() || undefined,
|
|
||||||
publicKey: sshTarget.publicKey.trim()
|
publicKey: sshTarget.publicKey.trim()
|
||||||
})
|
})
|
||||||
.then(() => {
|
.then(() => {
|
||||||
setSshState('idle');
|
setSshState('idle');
|
||||||
setSshTarget({ admin: null, label: '', giteaUsername: '', publicKey: '' });
|
setSshTarget({ admin: null, label: '', publicKey: '' });
|
||||||
toast({ type: 'success', title: 'SSH 키 등록 완료', message: '관리자 SSH 키가 등록되었습니다.' });
|
toast({ type: 'success', title: 'SSH 키 등록 완료', message: '관리자 SSH 키가 등록되었습니다.' });
|
||||||
refresh();
|
refresh();
|
||||||
})
|
})
|
||||||
@@ -210,9 +234,12 @@ export function AdminManagementPage() {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const removeSshKey = (admin: AdminAccount, keyId: string) => {
|
const removeSshKey = () => {
|
||||||
deleteAdminSshKey(admin.id, keyId)
|
if (!sshDeleteTarget) return;
|
||||||
|
|
||||||
|
deleteAdminSshKey(sshDeleteTarget.admin.id, sshDeleteTarget.keyId)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
|
setSshDeleteTarget(null);
|
||||||
toast({ type: 'success', title: 'SSH 키 삭제 완료', message: '관리자 SSH 키가 삭제되었습니다.' });
|
toast({ type: 'success', title: 'SSH 키 삭제 완료', message: '관리자 SSH 키가 삭제되었습니다.' });
|
||||||
refresh();
|
refresh();
|
||||||
})
|
})
|
||||||
@@ -221,15 +248,18 @@ export function AdminManagementPage() {
|
|||||||
|
|
||||||
const columns = useMemo<Array<DataTableColumn<AdminRow>>>(
|
const columns = useMemo<Array<DataTableColumn<AdminRow>>>(
|
||||||
() => [
|
() => [
|
||||||
{ key: 'no', header: 'No.', className: 'w-20 px-5 py-3 font-medium', cell: (row) => <span className="font-semibold">{row.no}</span> },
|
{ key: 'no', header: 'No.', className: 'w-14 px-3 py-3 text-center font-medium', cell: (row) => <span className="block text-center font-semibold">{row.no}</span> },
|
||||||
{
|
{
|
||||||
key: 'name',
|
key: 'name',
|
||||||
header: '관리자',
|
header: '관리자',
|
||||||
className: 'w-[260px] px-5 py-3 font-medium',
|
className: 'w-[260px] px-5 py-3 font-medium',
|
||||||
cell: (row) => (
|
cell: (row) => (
|
||||||
<div>
|
<div className="flex min-w-0 items-center gap-3">
|
||||||
<p className="font-semibold">{row.name}</p>
|
<AdminAvatar admin={row} />
|
||||||
<p className="mt-1 text-xs text-muted-foreground">{row.email}</p>
|
<div className="min-w-0">
|
||||||
|
<p className="truncate font-semibold">{row.name}</p>
|
||||||
|
<p className="mt-1 truncate text-xs text-muted-foreground">{row.email}</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
@@ -265,7 +295,7 @@ export function AdminManagementPage() {
|
|||||||
className="inline-flex rounded-full outline-none focus:ring-2 focus:ring-ring"
|
className="inline-flex rounded-full outline-none focus:ring-2 focus:ring-ring"
|
||||||
onClick={() => openSshModal(row)}
|
onClick={() => openSshModal(row)}
|
||||||
>
|
>
|
||||||
<StatusBadge status={`${keyCount}개`} tone={keyCount > 0 ? 'green' : 'neutral'} />
|
<StatusBadge status={keyCount > 0 ? '등록' : '미등록'} tone={keyCount > 0 ? 'green' : 'neutral'} />
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -300,10 +330,10 @@ export function AdminManagementPage() {
|
|||||||
|
|
||||||
const filters = useMemo<Array<DataTableFilter<AdminRow>>>(
|
const filters = useMemo<Array<DataTableFilter<AdminRow>>>(
|
||||||
() => [
|
() => [
|
||||||
{ key: 'role', label: '권한', options: [...new Set(rows.map((row) => row.role))].map((role) => ({ label: role, value: role })), getValue: (row) => row.role },
|
{ key: 'role', label: '권한', options: roleOptions.map((role) => ({ label: role.name, value: role.code })), getValue: (row) => row.role },
|
||||||
{ key: 'state', label: '상태', options: [...new Set(rows.map((row) => row.state))].map((state) => ({ label: state, value: state })), getValue: (row) => row.state }
|
{ key: 'state', label: '상태', options: [{ label: '활성', value: 'active' }, { label: '비활성', value: 'inactive' }], getValue: (row) => row.isActive ? 'active' : 'inactive' }
|
||||||
],
|
],
|
||||||
[rows]
|
[roleOptions]
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -336,19 +366,46 @@ export function AdminManagementPage() {
|
|||||||
minWidth={1040}
|
minWidth={1040}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
emptyMessage="등록된 관리자가 없습니다."
|
emptyMessage="등록된 관리자가 없습니다."
|
||||||
|
serverPagination={{
|
||||||
|
page,
|
||||||
|
total,
|
||||||
|
onPageChange: setPage,
|
||||||
|
onSearchChange: (value) => {
|
||||||
|
setKeyword(value);
|
||||||
|
setPage(1);
|
||||||
|
},
|
||||||
|
filterValues,
|
||||||
|
onFilterChange: (key, value) => {
|
||||||
|
setFilterValues((prev) => ({ ...prev, [key]: value }));
|
||||||
|
setPage(1);
|
||||||
|
}
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
{menuAdmin ? (() => {
|
{menuAdmin ? (() => {
|
||||||
const row = rows.find((item) => item.id === menuAdmin.id);
|
const row = rows.find((item) => item.id === menuAdmin.id);
|
||||||
if (!row) return null;
|
if (!row) return null;
|
||||||
return (
|
return (
|
||||||
<div onClick={(event) => event.stopPropagation()} className="fixed z-[70] min-w-36 overflow-hidden rounded-md border border-border bg-card py-1 text-left shadow-xl" style={{ left: menuAdmin.x, top: menuAdmin.y, transform: 'translateX(-100%)' }}>
|
<div onClick={(event) => event.stopPropagation()} className="fixed z-[70] min-w-36 overflow-hidden rounded-md border border-border bg-card py-1 text-left shadow-xl" style={{ left: menuAdmin.x, top: menuAdmin.y, transform: 'translateX(-100%)' }}>
|
||||||
<button
|
{row.sshKeys?.length ? (
|
||||||
type="button"
|
<button
|
||||||
className="block w-full px-3 py-2 text-left text-xs text-foreground hover:bg-muted"
|
type="button"
|
||||||
onClick={() => openSshModal(row)}
|
className="block w-full px-3 py-2 text-left text-xs text-rose-300 hover:bg-muted"
|
||||||
>
|
onClick={() => {
|
||||||
SSH 키 등록
|
setMenuAdmin(null);
|
||||||
</button>
|
setSshDeleteTarget({ admin: row, keyId: row.sshKeys![0].id });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
SSH 키 삭제
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="block w-full px-3 py-2 text-left text-xs text-foreground hover:bg-muted"
|
||||||
|
onClick={() => openSshModal(row)}
|
||||||
|
>
|
||||||
|
SSH 키 등록
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="block w-full px-3 py-2 text-left text-xs text-foreground hover:bg-muted"
|
className="block w-full px-3 py-2 text-left text-xs text-foreground hover:bg-muted"
|
||||||
@@ -383,26 +440,26 @@ export function AdminManagementPage() {
|
|||||||
|
|
||||||
<Modal
|
<Modal
|
||||||
open={createOpen}
|
open={createOpen}
|
||||||
title="관리자 등록"
|
title="관리자 추가"
|
||||||
onClose={() => setCreateOpen(false)}
|
onClose={() => setCreateOpen(false)}
|
||||||
footer={
|
footer={
|
||||||
<>
|
<>
|
||||||
<Button variant="outline" onClick={() => setCreateOpen(false)}>취소</Button>
|
<Button variant="outline" onClick={() => setCreateOpen(false)}>취소</Button>
|
||||||
<Button onClick={submitAdmin} disabled={saveState === 'saving'}>{saveState === 'saving' ? '저장 중' : '등록'}</Button>
|
<Button onClick={submitAdmin} disabled={saveState === 'saving' || !adminForm.email.trim() || !adminForm.name.trim() || adminForm.temporaryPassword.length < 8}>{saveState === 'saving' ? '저장 중' : '등록'}</Button>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div className="grid gap-4">
|
<div className="grid gap-4">
|
||||||
<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">이메일 <b className="text-foreground">*</b></span>
|
||||||
<input className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring" value={adminForm.email} onChange={(event) => setAdminForm((prev) => ({ ...prev, email: event.target.value }))} />
|
<input type="email" className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring" value={adminForm.email} onChange={(event) => setAdminForm((prev) => ({ ...prev, email: event.target.value }))} />
|
||||||
</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">이름 <b className="text-foreground">*</b></span>
|
||||||
<input className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring" value={adminForm.name} onChange={(event) => setAdminForm((prev) => ({ ...prev, name: event.target.value }))} />
|
<input className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring" value={adminForm.name} onChange={(event) => setAdminForm((prev) => ({ ...prev, name: event.target.value }))} />
|
||||||
</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">권한 <b className="text-foreground">*</b></span>
|
||||||
<select className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring" value={adminForm.role} onChange={(event) => setAdminForm((prev) => ({ ...prev, role: event.target.value }))}>
|
<select className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring" value={adminForm.role} onChange={(event) => setAdminForm((prev) => ({ ...prev, role: event.target.value }))}>
|
||||||
{roleOptions.map((role) => (
|
{roleOptions.map((role) => (
|
||||||
<option key={role.code} value={role.code}>
|
<option key={role.code} value={role.code}>
|
||||||
@@ -412,7 +469,7 @@ export function AdminManagementPage() {
|
|||||||
</select>
|
</select>
|
||||||
</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">임시 비밀번호 <b className="text-foreground">*</b></span>
|
||||||
<input className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring" value={adminForm.temporaryPassword} onChange={(event) => setAdminForm((prev) => ({ ...prev, temporaryPassword: event.target.value }))} />
|
<input className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring" value={adminForm.temporaryPassword} onChange={(event) => setAdminForm((prev) => ({ ...prev, temporaryPassword: event.target.value }))} />
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
@@ -438,11 +495,11 @@ export function AdminManagementPage() {
|
|||||||
<Modal
|
<Modal
|
||||||
open={Boolean(sshTarget.admin)}
|
open={Boolean(sshTarget.admin)}
|
||||||
title="SSH 키 등록"
|
title="SSH 키 등록"
|
||||||
onClose={() => setSshTarget({ admin: null, label: '', giteaUsername: '', publicKey: '' })}
|
onClose={() => setSshTarget({ admin: null, label: '', publicKey: '' })}
|
||||||
className="max-w-2xl"
|
className="max-w-2xl"
|
||||||
footer={
|
footer={
|
||||||
<>
|
<>
|
||||||
<Button variant="outline" onClick={() => setSshTarget({ admin: null, label: '', giteaUsername: '', publicKey: '' })}>취소</Button>
|
<Button variant="outline" onClick={() => setSshTarget({ admin: null, label: '', publicKey: '' })}>취소</Button>
|
||||||
<Button onClick={submitSshKey} disabled={sshState === 'saving'}>{sshState === 'saving' ? '저장 중' : '등록'}</Button>
|
<Button onClick={submitSshKey} disabled={sshState === 'saving'}>{sshState === 'saving' ? '저장 중' : '등록'}</Button>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
@@ -452,10 +509,9 @@ export function AdminManagementPage() {
|
|||||||
<span className="text-xs font-medium text-muted-foreground">키 이름</span>
|
<span className="text-xs font-medium text-muted-foreground">키 이름</span>
|
||||||
<input className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring" value={sshTarget.label} onChange={(event) => setSshTarget((prev) => ({ ...prev, label: event.target.value }))} />
|
<input className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring" value={sshTarget.label} onChange={(event) => setSshTarget((prev) => ({ ...prev, label: event.target.value }))} />
|
||||||
</label>
|
</label>
|
||||||
<label className="block">
|
<p className="rounded-md border border-border bg-muted/20 px-3 py-2 text-xs text-muted-foreground">
|
||||||
<span className="text-xs font-medium text-muted-foreground">저장소 사용자명</span>
|
저장소 사용자명은 관리자 이메일 기준으로 자동 적용됩니다.
|
||||||
<input className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring" value={sshTarget.giteaUsername} onChange={(event) => setSshTarget((prev) => ({ ...prev, giteaUsername: event.target.value }))} />
|
</p>
|
||||||
</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>
|
||||||
<textarea className="mt-2 min-h-32 w-full rounded-md border border-input bg-background px-3 py-2 font-mono text-sm text-foreground outline-none focus:ring-2 focus:ring-ring" value={sshTarget.publicKey} onChange={(event) => setSshTarget((prev) => ({ ...prev, publicKey: event.target.value }))} />
|
<textarea className="mt-2 min-h-32 w-full rounded-md border border-input bg-background px-3 py-2 font-mono text-sm text-foreground outline-none focus:ring-2 focus:ring-ring" value={sshTarget.publicKey} onChange={(event) => setSshTarget((prev) => ({ ...prev, publicKey: event.target.value }))} />
|
||||||
@@ -469,7 +525,7 @@ export function AdminManagementPage() {
|
|||||||
<p className="truncate text-sm font-semibold">{key.label ?? 'SSH Key'}</p>
|
<p className="truncate text-sm font-semibold">{key.label ?? 'SSH Key'}</p>
|
||||||
<p className="truncate font-mono text-xs text-muted-foreground">{key.fingerprint}</p>
|
<p className="truncate font-mono text-xs text-muted-foreground">{key.fingerprint}</p>
|
||||||
</div>
|
</div>
|
||||||
<Button variant="outline" className="size-8 px-0" title="삭제" aria-label="삭제" onClick={() => sshTarget.admin && removeSshKey(sshTarget.admin, key.id)}>
|
<Button variant="outline" className="size-8 px-0" title="삭제" aria-label="삭제" onClick={() => sshTarget.admin && setSshDeleteTarget({ admin: sshTarget.admin, keyId: key.id })}>
|
||||||
<Trash2 className="size-4" />
|
<Trash2 className="size-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -479,6 +535,20 @@ export function AdminManagementPage() {
|
|||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
open={Boolean(sshDeleteTarget)}
|
||||||
|
title="SSH 키 삭제"
|
||||||
|
onClose={() => setSshDeleteTarget(null)}
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button variant="outline" onClick={() => setSshDeleteTarget(null)}>취소</Button>
|
||||||
|
<Button className="border border-rose-500/40 bg-rose-500/15 text-rose-200 hover:bg-rose-500/25" onClick={removeSshKey}>삭제</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<p className="text-sm text-muted-foreground">등록된 관리자 SSH 키를 삭제합니다. Gitea에 등록된 키도 함께 삭제됩니다.</p>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
<Modal
|
<Modal
|
||||||
open={Boolean(permissionTarget.admin)}
|
open={Boolean(permissionTarget.admin)}
|
||||||
title="앱 권한"
|
title="앱 권한"
|
||||||
@@ -486,9 +556,9 @@ export function AdminManagementPage() {
|
|||||||
className="max-w-2xl"
|
className="max-w-2xl"
|
||||||
footer={
|
footer={
|
||||||
<>
|
<>
|
||||||
<Button variant="outline" onClick={() => setPermissionTarget({ admin: null, appIds: [] })}>취소</Button>
|
<Button variant="outline" onClick={() => setPermissionTarget({ admin: null, appIds: [] })} disabled={permissionSaving}>취소</Button>
|
||||||
<Button onClick={saveAppPermissions} disabled={permissionLoading || isOwnerAdmin(permissionTarget.admin)}>
|
<Button onClick={saveAppPermissions} disabled={permissionLoading || permissionSaving || isOwnerAdmin(permissionTarget.admin)}>
|
||||||
저장
|
{permissionSaving ? '저장 중' : '저장'}
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
@@ -534,3 +604,25 @@ export function AdminManagementPage() {
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function AdminAvatar({ admin }: { admin: AdminAccount }) {
|
||||||
|
const [failed, setFailed] = useState(false);
|
||||||
|
const initial = (admin.name || admin.email || '?').trim().charAt(0).toUpperCase() || '?';
|
||||||
|
|
||||||
|
if (admin.avatarUrl && !failed) {
|
||||||
|
return (
|
||||||
|
<img
|
||||||
|
src={admin.avatarUrl}
|
||||||
|
alt=""
|
||||||
|
className="size-9 shrink-0 rounded-full border border-border bg-muted object-cover"
|
||||||
|
onError={() => setFailed(true)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className="inline-flex size-9 shrink-0 items-center justify-center rounded-full border border-border bg-muted text-sm font-semibold text-muted-foreground">
|
||||||
|
{initial}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,30 +8,23 @@ import { formatDateOnly } from '../../lib/date-format';
|
|||||||
import { LoadingModal, StatusBadge, useMinimumLoading } from './operation-utils';
|
import { LoadingModal, StatusBadge, useMinimumLoading } from './operation-utils';
|
||||||
import {
|
import {
|
||||||
assignUserLicense,
|
assignUserLicense,
|
||||||
|
loadLicenseAppPermissions,
|
||||||
|
loadLicenseAssignments,
|
||||||
loadLicensePlans,
|
loadLicensePlans,
|
||||||
loadLicenseUsers,
|
loadLicenseUsers,
|
||||||
loadLauncherUserAppPermissions,
|
loadLauncherUserAppPermissions,
|
||||||
revokeUserLicense,
|
revokeUserLicense,
|
||||||
|
updateLicensePlan,
|
||||||
updateLauncherUserAppPermissions,
|
updateLauncherUserAppPermissions,
|
||||||
updateUserLicense,
|
updateUserLicense,
|
||||||
type AuthLicenseUser,
|
type AuthLicenseUser,
|
||||||
type LauncherUserAppPermission,
|
type LauncherUserAppPermission,
|
||||||
|
type LicenseAppPermission,
|
||||||
|
type LicenseAssignment,
|
||||||
type LicensePlan
|
type LicensePlan
|
||||||
} from './operations-api';
|
} from './operations-api';
|
||||||
|
|
||||||
type LicenseAssignmentRow = {
|
type LicenseAssignmentRow = LicenseAssignment;
|
||||||
id: string;
|
|
||||||
userId: string;
|
|
||||||
user: string;
|
|
||||||
name: string;
|
|
||||||
role: string;
|
|
||||||
code: string;
|
|
||||||
licenseName: string;
|
|
||||||
startsAt: string;
|
|
||||||
expiresAt?: string | null;
|
|
||||||
assignedBy?: string | null;
|
|
||||||
status: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
function today() {
|
function today() {
|
||||||
return new Date().toISOString().slice(0, 10);
|
return new Date().toISOString().slice(0, 10);
|
||||||
@@ -58,28 +51,15 @@ function toDateInputValue(value?: string | null) {
|
|||||||
return date.toISOString().slice(0, 10);
|
return date.toISOString().slice(0, 10);
|
||||||
}
|
}
|
||||||
|
|
||||||
function assignmentRows(users: AuthLicenseUser[]) {
|
|
||||||
return users.flatMap((user) =>
|
|
||||||
user.licenses.map((license) => ({
|
|
||||||
id: license.id,
|
|
||||||
userId: user.id,
|
|
||||||
user: user.email,
|
|
||||||
name: user.name,
|
|
||||||
role: user.role,
|
|
||||||
code: license.license.code,
|
|
||||||
licenseName: license.license.name,
|
|
||||||
startsAt: license.startsAt,
|
|
||||||
expiresAt: license.expiresAt,
|
|
||||||
assignedBy: license.assignedById,
|
|
||||||
status: license.status
|
|
||||||
}))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function LicensesPage() {
|
export function LicensesPage() {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const [plans, setPlans] = useState<LicensePlan[]>([]);
|
const [plans, setPlans] = useState<LicensePlan[]>([]);
|
||||||
const [users, setUsers] = useState<AuthLicenseUser[]>([]);
|
const [users, setUsers] = useState<AuthLicenseUser[]>([]);
|
||||||
|
const [rows, setRows] = useState<LicenseAssignmentRow[]>([]);
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
|
const [keyword, setKeyword] = useState('');
|
||||||
|
const [filterValues, setFilterValues] = useState<Record<string, string>>({});
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [assignOpen, setAssignOpen] = useState(false);
|
const [assignOpen, setAssignOpen] = useState(false);
|
||||||
const [editingRow, setEditingRow] = useState<LicenseAssignmentRow | null>(null);
|
const [editingRow, setEditingRow] = useState<LicenseAssignmentRow | null>(null);
|
||||||
@@ -87,8 +67,18 @@ export function LicensesPage() {
|
|||||||
const [appPermissionTarget, setAppPermissionTarget] = useState<LicenseAssignmentRow | null>(null);
|
const [appPermissionTarget, setAppPermissionTarget] = useState<LicenseAssignmentRow | null>(null);
|
||||||
const [appPermissions, setAppPermissions] = useState<LauncherUserAppPermission[]>([]);
|
const [appPermissions, setAppPermissions] = useState<LauncherUserAppPermission[]>([]);
|
||||||
const [appPermissionLoading, setAppPermissionLoading] = useState(false);
|
const [appPermissionLoading, setAppPermissionLoading] = useState(false);
|
||||||
|
const [planEditTarget, setPlanEditTarget] = useState<LicensePlan | null>(null);
|
||||||
|
const [planStatusTarget, setPlanStatusTarget] = useState<LicensePlan | null>(null);
|
||||||
|
const [licenseAppPermissions, setLicenseAppPermissions] = useState<LicenseAppPermission[]>([]);
|
||||||
|
const [licenseAppLoading, setLicenseAppLoading] = useState(false);
|
||||||
const [menuRow, setMenuRow] = useState<{ id: string; x: number; y: number } | null>(null);
|
const [menuRow, setMenuRow] = useState<{ id: string; x: number; y: number } | null>(null);
|
||||||
|
const [planMenu, setPlanMenu] = useState<{ code: string; x: number; y: number } | null>(null);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [planForm, setPlanForm] = useState({
|
||||||
|
name: '',
|
||||||
|
description: '',
|
||||||
|
defaultDurationDays: ''
|
||||||
|
});
|
||||||
const [assignForm, setAssignForm] = useState({
|
const [assignForm, setAssignForm] = useState({
|
||||||
userId: '',
|
userId: '',
|
||||||
licenseCode: '',
|
licenseCode: '',
|
||||||
@@ -98,7 +88,6 @@ export function LicensesPage() {
|
|||||||
const [userKeyword, setUserKeyword] = useState('');
|
const [userKeyword, setUserKeyword] = useState('');
|
||||||
const loadingVisible = useMinimumLoading(loading);
|
const loadingVisible = useMinimumLoading(loading);
|
||||||
|
|
||||||
const rows = useMemo(() => assignmentRows(users), [users]);
|
|
||||||
const calculateExpiresAt = (licenseCode: string, startsAt: string) => {
|
const calculateExpiresAt = (licenseCode: string, startsAt: string) => {
|
||||||
const plan = plans.find((item) => item.code === licenseCode);
|
const plan = plans.find((item) => item.code === licenseCode);
|
||||||
return addDays(startsAt, plan?.defaultDurationDays);
|
return addDays(startsAt, plan?.defaultDurationDays);
|
||||||
@@ -116,8 +105,17 @@ export function LicensesPage() {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const [nextPlans, nextUsers] = await Promise.all([loadLicensePlans(), loadLicenseUsers()]);
|
const [nextPlans, nextUsers] = await Promise.all([loadLicensePlans(), loadLicenseUsers()]);
|
||||||
|
const nextAssignments = await loadLicenseAssignments({
|
||||||
|
page,
|
||||||
|
limit: 6,
|
||||||
|
keyword,
|
||||||
|
licenseCode: filterValues.licenseName,
|
||||||
|
status: filterValues.status,
|
||||||
|
});
|
||||||
setPlans(nextPlans);
|
setPlans(nextPlans);
|
||||||
setUsers(nextUsers);
|
setUsers(nextUsers);
|
||||||
|
setRows(nextAssignments.items);
|
||||||
|
setTotal(nextAssignments.total);
|
||||||
setAssignForm((prev) => ({
|
setAssignForm((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
licenseCode: prev.licenseCode || nextPlans[0]?.code || '',
|
licenseCode: prev.licenseCode || nextPlans[0]?.code || '',
|
||||||
@@ -132,14 +130,17 @@ export function LicensesPage() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void refresh();
|
void refresh();
|
||||||
}, []);
|
}, [page, keyword, filterValues.licenseName, filterValues.status]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!menuRow) return;
|
if (!menuRow && !planMenu) return;
|
||||||
|
|
||||||
const close = () => setMenuRow(null);
|
const close = () => {
|
||||||
|
setMenuRow(null);
|
||||||
|
setPlanMenu(null);
|
||||||
|
};
|
||||||
const closeOnEscape = (event: KeyboardEvent) => {
|
const closeOnEscape = (event: KeyboardEvent) => {
|
||||||
if (event.key === 'Escape') setMenuRow(null);
|
if (event.key === 'Escape') close();
|
||||||
};
|
};
|
||||||
|
|
||||||
window.addEventListener('click', close);
|
window.addEventListener('click', close);
|
||||||
@@ -148,7 +149,7 @@ export function LicensesPage() {
|
|||||||
window.removeEventListener('click', close);
|
window.removeEventListener('click', close);
|
||||||
window.removeEventListener('keydown', closeOnEscape);
|
window.removeEventListener('keydown', closeOnEscape);
|
||||||
};
|
};
|
||||||
}, [menuRow]);
|
}, [menuRow, planMenu]);
|
||||||
|
|
||||||
const openAssignModal = () => {
|
const openAssignModal = () => {
|
||||||
const startsAt = today();
|
const startsAt = today();
|
||||||
@@ -276,8 +277,76 @@ export function LicensesPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const openPlanEditModal = async (plan: LicensePlan) => {
|
||||||
|
setPlanMenu(null);
|
||||||
|
setPlanEditTarget(plan);
|
||||||
|
setPlanForm({
|
||||||
|
name: plan.name,
|
||||||
|
description: plan.description ?? '',
|
||||||
|
defaultDurationDays: plan.defaultDurationDays ? String(plan.defaultDurationDays) : ''
|
||||||
|
});
|
||||||
|
setLicenseAppPermissions([]);
|
||||||
|
setLicenseAppLoading(true);
|
||||||
|
try {
|
||||||
|
setLicenseAppPermissions(await loadLicenseAppPermissions(plan.code));
|
||||||
|
} catch (error) {
|
||||||
|
toast({ type: 'error', message: error instanceof Error ? error.message : '라이센스 앱 설정을 불러오지 못했습니다.' });
|
||||||
|
} finally {
|
||||||
|
setLicenseAppLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const closePlanEditModal = () => {
|
||||||
|
setPlanEditTarget(null);
|
||||||
|
setLicenseAppPermissions([]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const savePlanEdit = async () => {
|
||||||
|
if (!planEditTarget || !planForm.name.trim()) return;
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const result = await updateLicensePlan(planEditTarget.code, {
|
||||||
|
name: planForm.name.trim(),
|
||||||
|
description: planForm.description.trim() || null,
|
||||||
|
defaultDurationDays: planForm.defaultDurationDays ? Number(planForm.defaultDurationDays) : null,
|
||||||
|
permissions: licenseAppPermissions.map((permission) => ({
|
||||||
|
appKey: permission.appKey,
|
||||||
|
canLaunch: permission.canLaunch
|
||||||
|
}))
|
||||||
|
});
|
||||||
|
toast({ type: 'success', message: `라이센스를 수정했습니다. ${result.updatedUserCount ?? 0}명에게 반영했습니다.` });
|
||||||
|
closePlanEditModal();
|
||||||
|
await refresh();
|
||||||
|
} catch (error) {
|
||||||
|
toast({ type: 'error', message: error instanceof Error ? error.message : '라이센스 수정에 실패했습니다.' });
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const togglePlanActive = async (plan: LicensePlan) => {
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
await updateLicensePlan(plan.code, { isActive: !plan.isActive });
|
||||||
|
toast({ type: 'success', message: plan.isActive ? '라이센스를 비활성화했습니다.' : '라이센스를 활성화했습니다.' });
|
||||||
|
setPlanStatusTarget(null);
|
||||||
|
await refresh();
|
||||||
|
} catch (error) {
|
||||||
|
toast({ type: 'error', message: error instanceof Error ? error.message : '라이센스 상태 변경에 실패했습니다.' });
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleLicenseAppLaunch = (appKey: string) => {
|
||||||
|
setLicenseAppPermissions((prev) => prev.map((permission) =>
|
||||||
|
permission.appKey === appKey ? { ...permission, canLaunch: !permission.canLaunch, canView: true } : permission
|
||||||
|
));
|
||||||
|
};
|
||||||
|
|
||||||
const columns = useMemo<Array<DataTableColumn<LicenseAssignmentRow>>>(
|
const columns = useMemo<Array<DataTableColumn<LicenseAssignmentRow>>>(
|
||||||
() => [
|
() => [
|
||||||
|
{ key: 'no', header: 'No.', className: 'w-14 px-3 py-3 text-center font-medium', cell: (row) => <span className="block text-center font-semibold">{row.no}</span> },
|
||||||
{
|
{
|
||||||
key: 'user',
|
key: 'user',
|
||||||
header: '사용자',
|
header: '사용자',
|
||||||
@@ -322,10 +391,10 @@ export function LicensesPage() {
|
|||||||
|
|
||||||
const filters = useMemo<Array<DataTableFilter<LicenseAssignmentRow>>>(
|
const filters = useMemo<Array<DataTableFilter<LicenseAssignmentRow>>>(
|
||||||
() => [
|
() => [
|
||||||
{ key: 'licenseName', label: '라이센스', options: plans.map((plan) => ({ label: plan.name, value: plan.name })), getValue: (row) => row.licenseName },
|
{ key: 'licenseName', label: '라이센스', options: plans.map((plan) => ({ label: plan.name, value: plan.code })), getValue: (row) => row.code },
|
||||||
{ key: 'status', label: '상태', options: [...new Set(rows.map((row) => row.status))].map((status) => ({ label: status, value: status })), getValue: (row) => row.status }
|
{ key: 'status', label: '상태', options: [{ label: '활성', value: 'active' }, { label: '만료', value: 'expired' }], getValue: (row) => row.status === '활성' ? 'active' : 'expired' }
|
||||||
],
|
],
|
||||||
[plans, rows]
|
[plans]
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -337,7 +406,7 @@ export function LicensesPage() {
|
|||||||
actions={
|
actions={
|
||||||
<Button className="h-8 px-3 text-[13px]" disabled={users.length === 0 || plans.length === 0} onClick={openAssignModal}>
|
<Button className="h-8 px-3 text-[13px]" disabled={users.length === 0 || plans.length === 0} onClick={openAssignModal}>
|
||||||
<Plus className="size-4" />
|
<Plus className="size-4" />
|
||||||
라이센스 부여
|
등록
|
||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -349,7 +418,22 @@ export function LicensesPage() {
|
|||||||
<p className="text-sm text-foreground">{plan.name}</p>
|
<p className="text-sm text-foreground">{plan.name}</p>
|
||||||
<p className="mt-1 text-xs text-muted-foreground">{plan.description || '-'}</p>
|
<p className="mt-1 text-xs text-muted-foreground">{plan.description || '-'}</p>
|
||||||
</div>
|
</div>
|
||||||
<StatusBadge status={plan.isActive ? '활성' : '비활성'} />
|
<div className="flex shrink-0 items-center gap-2">
|
||||||
|
{!plan.isActive ? <StatusBadge status="비활성" /> : null}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="size-8 px-0"
|
||||||
|
title="메뉴"
|
||||||
|
aria-label="메뉴"
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
const rect = event.currentTarget.getBoundingClientRect();
|
||||||
|
setPlanMenu((prev) => (prev?.code === plan.code ? null : { code: plan.code, x: rect.right, y: rect.bottom + 6 }));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MoreHorizontal className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-5 grid grid-cols-3 gap-3">
|
<div className="mt-5 grid grid-cols-3 gap-3">
|
||||||
<div>
|
<div>
|
||||||
@@ -374,9 +458,34 @@ export function LicensesPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
{planMenu ? (() => {
|
||||||
|
const plan = plans.find((item) => item.code === planMenu.code);
|
||||||
|
if (!plan) return null;
|
||||||
|
return (
|
||||||
|
<div onClick={(event) => event.stopPropagation()} className="fixed z-[70] min-w-28 overflow-hidden rounded-md border border-border bg-card py-1 text-left shadow-xl" style={{ left: planMenu.x, top: planMenu.y, transform: 'translateX(-100%)' }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="block w-full px-3 py-2 text-left text-xs text-foreground hover:bg-muted"
|
||||||
|
onClick={() => void openPlanEditModal(plan)}
|
||||||
|
>
|
||||||
|
수정
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="block w-full px-3 py-2 text-left text-xs text-foreground hover:bg-muted"
|
||||||
|
onClick={() => {
|
||||||
|
setPlanMenu(null);
|
||||||
|
setPlanStatusTarget(plan);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{plan.isActive ? '비활성' : '활성'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})() : null}
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
<DataTableCard
|
<DataTableCard
|
||||||
title="부여 이력"
|
title="목록"
|
||||||
rows={rows}
|
rows={rows}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
filters={filters}
|
filters={filters}
|
||||||
@@ -387,6 +496,20 @@ export function LicensesPage() {
|
|||||||
minWidth={980}
|
minWidth={980}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
emptyMessage="부여된 라이센스가 없습니다."
|
emptyMessage="부여된 라이센스가 없습니다."
|
||||||
|
serverPagination={{
|
||||||
|
page,
|
||||||
|
total,
|
||||||
|
onPageChange: setPage,
|
||||||
|
onSearchChange: (value) => {
|
||||||
|
setKeyword(value);
|
||||||
|
setPage(1);
|
||||||
|
},
|
||||||
|
filterValues,
|
||||||
|
onFilterChange: (key, value) => {
|
||||||
|
setFilterValues((prev) => ({ ...prev, [key]: value }));
|
||||||
|
setPage(1);
|
||||||
|
}
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -428,7 +551,7 @@ export function LicensesPage() {
|
|||||||
title={
|
title={
|
||||||
<span className="inline-flex items-center gap-2">
|
<span className="inline-flex items-center gap-2">
|
||||||
<Pencil className="size-4 text-primary" />
|
<Pencil className="size-4 text-primary" />
|
||||||
{editingRow ? '라이센스 수정' : '라이센스 부여'}
|
{editingRow ? '라이센스 수정' : '라이센스 추가'}
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
onClose={closeAssignModal}
|
onClose={closeAssignModal}
|
||||||
@@ -444,7 +567,7 @@ export function LicensesPage() {
|
|||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{editingRow ? (
|
{editingRow ? (
|
||||||
<div>
|
<div>
|
||||||
<span className="text-xs font-medium text-muted-foreground">사용자</span>
|
<span className="text-xs font-medium text-muted-foreground">사용자 <b className="text-foreground">*</b></span>
|
||||||
<div className="mt-2 rounded-md border border-border bg-background/80 px-3 py-2 text-sm">
|
<div className="mt-2 rounded-md border border-border bg-background/80 px-3 py-2 text-sm">
|
||||||
<p className="text-foreground">{editingRow.user}</p>
|
<p className="text-foreground">{editingRow.user}</p>
|
||||||
<p className="mt-0.5 text-xs text-muted-foreground">{editingRow.name || '-'}</p>
|
<p className="mt-0.5 text-xs text-muted-foreground">{editingRow.name || '-'}</p>
|
||||||
@@ -500,7 +623,7 @@ export function LicensesPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<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">라이센스 <b className="text-foreground">*</b></span>
|
||||||
<select
|
<select
|
||||||
className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring"
|
className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring"
|
||||||
value={assignForm.licenseCode}
|
value={assignForm.licenseCode}
|
||||||
@@ -518,7 +641,7 @@ export function LicensesPage() {
|
|||||||
</label>
|
</label>
|
||||||
<div className="grid gap-3 sm:grid-cols-2">
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
<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">시작일 <b className="text-foreground">*</b></span>
|
||||||
<input
|
<input
|
||||||
type="date"
|
type="date"
|
||||||
className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring"
|
className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring"
|
||||||
@@ -545,6 +668,110 @@ export function LicensesPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
<Modal
|
||||||
|
open={Boolean(planEditTarget)}
|
||||||
|
title={
|
||||||
|
<span className="inline-flex items-center gap-2">
|
||||||
|
<Pencil className="size-4 text-primary" />
|
||||||
|
라이센스 수정
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
onClose={closePlanEditModal}
|
||||||
|
className="max-w-2xl"
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button variant="outline" onClick={closePlanEditModal} disabled={saving}>취소</Button>
|
||||||
|
<Button onClick={savePlanEdit} disabled={saving || licenseAppLoading || !planForm.name.trim()}>
|
||||||
|
{saving ? '저장 중' : '저장'}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
<label className="block">
|
||||||
|
<span className="text-xs font-medium text-muted-foreground">라이센스명 <b className="text-foreground">*</b></span>
|
||||||
|
<input
|
||||||
|
className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none placeholder:text-muted-foreground focus:ring-2 focus:ring-ring"
|
||||||
|
value={planForm.name}
|
||||||
|
onChange={(event) => setPlanForm((prev) => ({ ...prev, name: event.target.value }))}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="block">
|
||||||
|
<span className="text-xs font-medium text-muted-foreground">기본 기간</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none placeholder:text-muted-foreground focus:ring-2 focus:ring-ring"
|
||||||
|
value={planForm.defaultDurationDays}
|
||||||
|
onChange={(event) => setPlanForm((prev) => ({ ...prev, defaultDurationDays: event.target.value }))}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<label className="block">
|
||||||
|
<span className="text-xs font-medium text-muted-foreground">설명</span>
|
||||||
|
<textarea
|
||||||
|
className="mt-2 min-h-20 w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground outline-none placeholder:text-muted-foreground focus:ring-2 focus:ring-ring"
|
||||||
|
value={planForm.description}
|
||||||
|
onChange={(event) => setPlanForm((prev) => ({ ...prev, description: event.target.value }))}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div>
|
||||||
|
<span className="text-xs font-medium text-muted-foreground">앱 실행 설정</span>
|
||||||
|
<div className="mt-2 max-h-64 overflow-y-auto rounded-md border border-border">
|
||||||
|
{licenseAppLoading ? (
|
||||||
|
<div className="px-4 py-8 text-center text-sm text-muted-foreground">앱 설정을 불러오는 중입니다.</div>
|
||||||
|
) : licenseAppPermissions.length === 0 ? (
|
||||||
|
<div className="px-4 py-8 text-center text-sm text-muted-foreground">런처에 연결된 앱이 없습니다.</div>
|
||||||
|
) : (
|
||||||
|
licenseAppPermissions.map((permission) => (
|
||||||
|
<div key={permission.appKey} className="flex items-center justify-between gap-4 border-b border-border px-4 py-3 last:border-b-0 hover:bg-muted/20">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="truncate text-sm text-foreground">{permission.displayName}</p>
|
||||||
|
<p className="mt-1 truncate text-xs text-muted-foreground">{permission.appKey}</p>
|
||||||
|
</div>
|
||||||
|
<label className="inline-flex shrink-0 items-center gap-2 text-xs text-muted-foreground">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="size-4 accent-primary"
|
||||||
|
checked={permission.canLaunch}
|
||||||
|
onChange={() => toggleLicenseAppLaunch(permission.appKey)}
|
||||||
|
/>
|
||||||
|
실행
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
<Modal
|
||||||
|
open={Boolean(planStatusTarget)}
|
||||||
|
title={
|
||||||
|
<span className="inline-flex items-center gap-2">
|
||||||
|
<ShieldCheck className="size-4 text-primary" />
|
||||||
|
라이센스 상태 변경
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
onClose={() => setPlanStatusTarget(null)}
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button variant="outline" onClick={() => setPlanStatusTarget(null)} disabled={saving}>취소</Button>
|
||||||
|
<Button
|
||||||
|
className={planStatusTarget?.isActive ? 'border border-amber-500/40 bg-amber-500/15 text-amber-100 hover:bg-amber-500/25' : undefined}
|
||||||
|
onClick={() => planStatusTarget ? void togglePlanActive(planStatusTarget) : undefined}
|
||||||
|
disabled={saving}
|
||||||
|
>
|
||||||
|
{saving ? '처리 중' : planStatusTarget?.isActive ? '비활성' : '활성'}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<p className="pb-6 text-sm text-foreground">
|
||||||
|
{planStatusTarget?.name ?? '라이센스'}를 {planStatusTarget?.isActive ? '비활성화' : '활성화'}할까요?
|
||||||
|
</p>
|
||||||
|
</Modal>
|
||||||
<Modal
|
<Modal
|
||||||
open={Boolean(deleteTarget)}
|
open={Boolean(deleteTarget)}
|
||||||
title={
|
title={
|
||||||
|
|||||||
@@ -0,0 +1,146 @@
|
|||||||
|
import { MoreHorizontal, Pencil, Plus, Trash2 } from 'lucide-react';
|
||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { DataTableCard, type DataTableColumn } from '../../components/data-table';
|
||||||
|
import { useToast } from '../../components/toast';
|
||||||
|
import { Button, Modal } from '../../components/ui';
|
||||||
|
import { PageTitle } from '../../layout/AppLayout';
|
||||||
|
import { formatDateTime } from '../../lib/date-format';
|
||||||
|
import { AppSelector, useApps } from '../applications/ApplicationControls';
|
||||||
|
import {
|
||||||
|
createManualDocument,
|
||||||
|
deleteManualDocument,
|
||||||
|
loadManualDocumentsPage,
|
||||||
|
type ManualDocument,
|
||||||
|
updateManualDocument,
|
||||||
|
} from './operations-api';
|
||||||
|
import { LoadingModal, StatusBadge, useMinimumLoading } from './operation-utils';
|
||||||
|
|
||||||
|
type ManualForm = Pick<ManualDocument, 'appKey' | 'title' | 'body' | 'isPublished' | 'sortOrder'>;
|
||||||
|
|
||||||
|
const emptyForm: ManualForm = { appKey: '', title: '', body: '', isPublished: true, sortOrder: 0 };
|
||||||
|
|
||||||
|
function ManualCreator({ document }: { document: ManualDocument }) {
|
||||||
|
const [failed, setFailed] = useState(false);
|
||||||
|
const name = document.createdByName || document.createdByEmail || '-';
|
||||||
|
const avatarUrl = document.createdByAvatarUrl || '';
|
||||||
|
const initial = name.trim().charAt(0).toUpperCase() || 'A';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
|
{avatarUrl && !failed ? (
|
||||||
|
<img src={avatarUrl} alt="" className="size-7 shrink-0 rounded-full border border-border bg-muted object-cover" onError={() => setFailed(true)} />
|
||||||
|
) : (
|
||||||
|
<span className="inline-flex size-7 shrink-0 items-center justify-center rounded-full border border-border bg-muted text-xs font-semibold text-muted-foreground">{initial}</span>
|
||||||
|
)}
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="truncate text-sm font-medium text-foreground">{name}</p>
|
||||||
|
<p className="mt-0.5 whitespace-nowrap text-xs text-muted-foreground">{formatDateTime(document.createdAt)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ManualsPage() {
|
||||||
|
const { toast } = useToast();
|
||||||
|
const [documents, setDocuments] = useState<ManualDocument[]>([]);
|
||||||
|
const { apps, selectedApp, selectedAppId, loading: appsLoading, selectApp } = useApps();
|
||||||
|
const selectedAppKey = selectedApp?.key ?? '';
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
|
const [keyword, setKeyword] = useState('');
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [failed, setFailed] = useState(false);
|
||||||
|
const [editing, setEditing] = useState<ManualDocument | null>(null);
|
||||||
|
const [form, setForm] = useState<ManualForm>(emptyForm);
|
||||||
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
|
const [menu, setMenu] = useState<{ id: string; x: number; y: number } | null>(null);
|
||||||
|
const [deleteTarget, setDeleteTarget] = useState<ManualDocument | null>(null);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const loadingVisible = useMinimumLoading(loading);
|
||||||
|
|
||||||
|
const refresh = () => {
|
||||||
|
if (!selectedAppKey) {
|
||||||
|
setDocuments([]);
|
||||||
|
setTotal(0);
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLoading(true);
|
||||||
|
loadManualDocumentsPage({ page, limit: 6, keyword, appKey: selectedAppKey })
|
||||||
|
.then((result) => { setDocuments(result.items); setTotal(result.total); setFailed(false); })
|
||||||
|
.catch((error: Error) => { setDocuments([]); setTotal(0); setFailed(true); toast({ type: 'error', title: '매뉴얼 조회 실패', message: error.message }); })
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => { refresh(); }, [page, keyword, selectedAppKey]);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!menu) return;
|
||||||
|
const close = () => setMenu(null);
|
||||||
|
window.addEventListener('click', close);
|
||||||
|
return () => window.removeEventListener('click', close);
|
||||||
|
}, [menu]);
|
||||||
|
|
||||||
|
const openCreate = () => { setEditing(null); setForm({ ...emptyForm, appKey: selectedAppKey }); setModalOpen(true); };
|
||||||
|
const openEdit = (document: ManualDocument) => {
|
||||||
|
setMenu(null);
|
||||||
|
setEditing(document);
|
||||||
|
setForm({ appKey: document.appKey, title: document.title, body: document.body, isPublished: document.isPublished, sortOrder: document.sortOrder });
|
||||||
|
setModalOpen(true);
|
||||||
|
};
|
||||||
|
const closeModal = () => { setEditing(null); setForm(emptyForm); setModalOpen(false); };
|
||||||
|
const submit = () => {
|
||||||
|
if (!form.appKey.trim() || !form.title.trim() || !form.body.trim()) return;
|
||||||
|
setSaving(true);
|
||||||
|
const payload = { ...form, appKey: form.appKey.trim(), title: form.title.trim(), body: form.body.trim() };
|
||||||
|
const request = editing ? updateManualDocument(editing.id, payload) : createManualDocument(payload);
|
||||||
|
request
|
||||||
|
.then(() => { toast({ type: 'success', message: editing ? '매뉴얼을 수정했습니다.' : '매뉴얼을 추가했습니다.' }); closeModal(); refresh(); })
|
||||||
|
.catch((error: Error) => toast({ type: 'error', title: editing ? '매뉴얼 수정 실패' : '매뉴얼 추가 실패', message: error.message }))
|
||||||
|
.finally(() => setSaving(false));
|
||||||
|
};
|
||||||
|
const confirmDelete = () => {
|
||||||
|
if (!deleteTarget) return;
|
||||||
|
setSaving(true);
|
||||||
|
deleteManualDocument(deleteTarget.id)
|
||||||
|
.then(() => { toast({ type: 'success', message: '매뉴얼을 삭제했습니다.' }); setDeleteTarget(null); refresh(); })
|
||||||
|
.catch((error: Error) => toast({ type: 'error', title: '매뉴얼 삭제 실패', message: error.message }))
|
||||||
|
.finally(() => setSaving(false));
|
||||||
|
};
|
||||||
|
|
||||||
|
const columns = useMemo<Array<DataTableColumn<ManualDocument>>>(() => [
|
||||||
|
{ key: 'no', header: 'No.', className: 'px-3 py-3 text-center font-medium', cell: (row) => <span className="block text-center font-semibold">{row.no}</span> },
|
||||||
|
{ key: 'title', header: '문서명', width: 220, className: 'px-5 py-3 font-medium', cell: (row) => row.title },
|
||||||
|
{ key: 'body', header: '본문', flexible: true, className: 'px-5 py-3', cell: (row) => <p className="truncate">{row.body}</p> },
|
||||||
|
{ key: 'published', header: '공개', width: 100, className: 'px-5 py-3', cell: (row) => <StatusBadge status={row.isPublished ? '공개' : '비공개'} tone={row.isPublished ? 'green' : 'neutral'} /> },
|
||||||
|
{ key: 'sortOrder', header: '순서', width: 80, className: 'px-5 py-3 text-center', cell: (row) => row.sortOrder },
|
||||||
|
{ key: 'createdAt', header: '생성', width: 190, className: 'px-5 py-3', cell: (row) => <ManualCreator document={row} /> },
|
||||||
|
{ key: 'actions', header: '', className: 'px-5 py-3', cell: (row) => <div className="relative flex justify-end"><Button variant="ghost" className="size-8 px-0" title="메뉴" aria-label="메뉴" onClick={(event) => { event.stopPropagation(); const rect = event.currentTarget.getBoundingClientRect(); setMenu((previous) => previous?.id === row.id ? null : { id: row.id, x: rect.right, y: rect.bottom + 6 }); }}><MoreHorizontal className="size-4" /></Button></div> },
|
||||||
|
], []);
|
||||||
|
|
||||||
|
return <>
|
||||||
|
<PageTitle eyebrow="운영" title="매뉴얼" description="런처 사용 매뉴얼의 Markdown 문서를 관리합니다." actions={<Button className="h-8 px-3 text-[13px]" onClick={openCreate} disabled={!selectedAppKey}><Plus className="size-4" />추가</Button>} />
|
||||||
|
{failed && <div className="mb-4 rounded-md border border-rose-500/30 bg-rose-500/10 px-4 py-3 text-sm text-rose-200">매뉴얼 데이터를 불러오지 못했습니다.</div>}
|
||||||
|
<AppSelector apps={apps} value={selectedAppId} loading={appsLoading} onChange={(appId) => { selectApp(appId); setPage(1); }} />
|
||||||
|
<DataTableCard title="목록" rows={documents} columns={columns} searchPlaceholder="문서명, 본문 검색" getSearchText={(row) => `${row.title} ${row.body}`} getRowKey={(row) => row.id} pageSize={6} minWidth={980} loading={loading} emptyMessage="등록된 매뉴얼이 없습니다." serverPagination={{ page, total, onPageChange: setPage, onSearchChange: (value) => { setKeyword(value); setPage(1); } }} />
|
||||||
|
{menu && (() => {
|
||||||
|
const document = documents.find((item) => item.id === menu.id);
|
||||||
|
if (!document) return null;
|
||||||
|
return <div onClick={(event) => event.stopPropagation()} className="fixed z-[70] min-w-28 overflow-hidden rounded-md border border-border bg-card py-1 text-left shadow-xl" style={{ left: menu.x, top: menu.y, transform: 'translateX(-100%)' }}>
|
||||||
|
<button type="button" className="block w-full px-3 py-2 text-left text-xs text-foreground hover:bg-muted" onClick={() => openEdit(document)}>수정</button>
|
||||||
|
<button type="button" className="block w-full px-3 py-2 text-left text-xs text-rose-300 hover:bg-rose-500/10" onClick={() => { setMenu(null); setDeleteTarget(document); }}>삭제</button>
|
||||||
|
</div>;
|
||||||
|
})()}
|
||||||
|
<Modal open={modalOpen} title={<span className="inline-flex items-center gap-2"><Pencil className="size-4 text-primary" />{editing ? '매뉴얼 수정' : '매뉴얼 추가'}</span>} onClose={closeModal} className="!max-w-3xl" footer={<><Button variant="outline" onClick={closeModal} disabled={saving}>취소</Button><Button onClick={submit} disabled={saving || !form.appKey.trim() || !form.title.trim() || !form.body.trim()}>{saving ? '저장 중' : '저장'}</Button></>}>
|
||||||
|
<form className="grid gap-4" onSubmit={(event) => { event.preventDefault(); submit(); }}>
|
||||||
|
<div className="grid gap-4 md:grid-cols-[minmax(0,1fr)_96px]">
|
||||||
|
<label className="block"><span className="text-xs font-medium text-muted-foreground">문서명 <b className="text-foreground">*</b></span><input className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring" value={form.title} onChange={(event) => setForm((prev) => ({ ...prev, title: event.target.value }))} /></label>
|
||||||
|
<label className="block"><span className="text-xs font-medium text-muted-foreground">순서</span><input type="number" min="0" className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring" value={form.sortOrder} onChange={(event) => setForm((prev) => ({ ...prev, sortOrder: Number(event.target.value) || 0 }))} /></label>
|
||||||
|
</div>
|
||||||
|
<label className="block"><span className="text-xs font-medium text-muted-foreground">본문 <b className="text-foreground">*</b></span><textarea className="mt-2 min-h-72 w-full rounded-md border border-input bg-background px-3 py-2 font-mono text-sm text-foreground outline-none focus:ring-2 focus:ring-ring" value={form.body} onChange={(event) => setForm((prev) => ({ ...prev, body: event.target.value }))} /></label>
|
||||||
|
<label className="inline-flex items-center gap-2 text-sm text-foreground"><input type="checkbox" checked={form.isPublished} onChange={(event) => setForm((prev) => ({ ...prev, isPublished: event.target.checked }))} />공개</label>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
<Modal open={Boolean(deleteTarget)} title={<span className="inline-flex items-center gap-2"><Trash2 className="size-4 text-rose-300" />매뉴얼 삭제</span>} onClose={() => setDeleteTarget(null)} footer={<><Button variant="outline" onClick={() => setDeleteTarget(null)} disabled={saving}>취소</Button><Button className="border border-rose-500/40 bg-rose-500/15 text-rose-200 hover:bg-rose-500/25" onClick={confirmDelete} disabled={saving}>{saving ? '삭제 중' : '삭제'}</Button></>}><p className="pb-6 text-sm text-foreground">매뉴얼 문서를 삭제할까요?</p></Modal>
|
||||||
|
<LoadingModal open={loadingVisible} />
|
||||||
|
</>;
|
||||||
|
}
|
||||||
@@ -9,7 +9,7 @@ import { listApps } from '../applications/application-api';
|
|||||||
import {
|
import {
|
||||||
createLauncherNotice,
|
createLauncherNotice,
|
||||||
deleteLauncherNotice,
|
deleteLauncherNotice,
|
||||||
loadLauncherNotices,
|
loadLauncherNoticesPage,
|
||||||
updateLauncherNotice,
|
updateLauncherNotice,
|
||||||
type LauncherNotice
|
type LauncherNotice
|
||||||
} from './operations-api';
|
} from './operations-api';
|
||||||
@@ -21,6 +21,7 @@ type NoticeForm = {
|
|||||||
title: string;
|
title: string;
|
||||||
body: string;
|
body: string;
|
||||||
linkUrl: string;
|
linkUrl: string;
|
||||||
|
exposureEnabled: boolean;
|
||||||
startsAt: string;
|
startsAt: string;
|
||||||
endsAt: string;
|
endsAt: string;
|
||||||
priority: string;
|
priority: string;
|
||||||
@@ -32,6 +33,7 @@ const emptyForm: NoticeForm = {
|
|||||||
title: '',
|
title: '',
|
||||||
body: '',
|
body: '',
|
||||||
linkUrl: '',
|
linkUrl: '',
|
||||||
|
exposureEnabled: false,
|
||||||
startsAt: '',
|
startsAt: '',
|
||||||
endsAt: '',
|
endsAt: '',
|
||||||
priority: '0',
|
priority: '0',
|
||||||
@@ -71,12 +73,41 @@ function exposureCell(row: LauncherNotice) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function NoticeCreator({ notice }: { notice: LauncherNotice }) {
|
||||||
|
const [failed, setFailed] = useState(false);
|
||||||
|
const name = notice.createdByName || notice.createdByEmail || 'admin';
|
||||||
|
const avatarUrl = notice.createdByAvatarUrl || '';
|
||||||
|
const initial = name.trim().charAt(0).toUpperCase() || 'A';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
|
{avatarUrl && !failed ? (
|
||||||
|
<img
|
||||||
|
src={avatarUrl}
|
||||||
|
alt=""
|
||||||
|
className="size-7 shrink-0 rounded-full border border-border bg-muted object-cover"
|
||||||
|
onError={() => setFailed(true)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span className="inline-flex size-7 shrink-0 items-center justify-center rounded-full border border-border bg-muted text-xs font-semibold text-muted-foreground">
|
||||||
|
{initial}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="truncate text-sm font-medium text-foreground">{name}</p>
|
||||||
|
<p className="mt-0.5 whitespace-nowrap text-xs text-muted-foreground">{formatNoticeDateTime(notice.createdAt)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function toForm(notice: LauncherNotice): NoticeForm {
|
function toForm(notice: LauncherNotice): NoticeForm {
|
||||||
return {
|
return {
|
||||||
appKey: notice.appKey,
|
appKey: notice.appKey,
|
||||||
title: notice.title,
|
title: notice.title,
|
||||||
body: notice.body,
|
body: notice.body,
|
||||||
linkUrl: notice.linkUrl ?? '',
|
linkUrl: notice.linkUrl ?? '',
|
||||||
|
exposureEnabled: Boolean(notice.startsAt || notice.endsAt),
|
||||||
startsAt: toDateTimeInputValue(notice.startsAt),
|
startsAt: toDateTimeInputValue(notice.startsAt),
|
||||||
endsAt: toDateTimeInputValue(notice.endsAt),
|
endsAt: toDateTimeInputValue(notice.endsAt),
|
||||||
priority: String(notice.priority ?? 0),
|
priority: String(notice.priority ?? 0),
|
||||||
@@ -92,6 +123,10 @@ export function NoticesPage() {
|
|||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const [notices, setNotices] = useState<LauncherNotice[]>([]);
|
const [notices, setNotices] = useState<LauncherNotice[]>([]);
|
||||||
const [apps, setApps] = useState<AppWithComponents[]>([]);
|
const [apps, setApps] = useState<AppWithComponents[]>([]);
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
|
const [keyword, setKeyword] = useState('');
|
||||||
|
const [filterValues, setFilterValues] = useState<Record<string, string>>({});
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [failed, setFailed] = useState(false);
|
const [failed, setFailed] = useState(false);
|
||||||
const [modalOpen, setModalOpen] = useState(false);
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
@@ -105,11 +140,12 @@ export function NoticesPage() {
|
|||||||
const refresh = () => {
|
const refresh = () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
Promise.all([
|
Promise.all([
|
||||||
loadLauncherNotices(),
|
loadLauncherNoticesPage({ page, limit: 6, keyword, appKey: filterValues.appKey }),
|
||||||
listApps().catch(() => [])
|
listApps().catch(() => [])
|
||||||
])
|
])
|
||||||
.then(([items, appItems]) => {
|
.then(([result, appItems]) => {
|
||||||
setNotices(items);
|
setNotices(result.items);
|
||||||
|
setTotal(result.total);
|
||||||
setApps(appItems);
|
setApps(appItems);
|
||||||
setFailed(false);
|
setFailed(false);
|
||||||
})
|
})
|
||||||
@@ -123,7 +159,7 @@ export function NoticesPage() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
refresh();
|
refresh();
|
||||||
}, []);
|
}, [page, keyword, filterValues.appKey]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!menuNotice) return;
|
if (!menuNotice) return;
|
||||||
@@ -153,7 +189,7 @@ export function NoticesPage() {
|
|||||||
const appLabel = (appKey: string) => appNameByKey.get(appKey) ?? appKey;
|
const appLabel = (appKey: string) => appNameByKey.get(appKey) ?? appKey;
|
||||||
|
|
||||||
const rows = useMemo(
|
const rows = useMemo(
|
||||||
() => notices.map((notice, index) => ({ ...notice, no: notices.length - index, appName: appLabel(notice.appKey) })),
|
() => notices.map((notice) => ({ ...notice, no: notice.no ?? 0, appName: appLabel(notice.appKey) })),
|
||||||
[appNameByKey, notices]
|
[appNameByKey, notices]
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -181,8 +217,8 @@ export function NoticesPage() {
|
|||||||
title: form.title.trim(),
|
title: form.title.trim(),
|
||||||
body: form.body.trim(),
|
body: form.body.trim(),
|
||||||
linkUrl: form.linkUrl.trim(),
|
linkUrl: form.linkUrl.trim(),
|
||||||
startsAt: toApiDateTime(form.startsAt),
|
startsAt: form.exposureEnabled ? toApiDateTime(form.startsAt) : undefined,
|
||||||
endsAt: toApiDateTime(form.endsAt),
|
endsAt: form.exposureEnabled ? toApiDateTime(form.endsAt) : undefined,
|
||||||
priority: Number(form.priority) || 0,
|
priority: Number(form.priority) || 0,
|
||||||
isActive: form.isActive
|
isActive: form.isActive
|
||||||
});
|
});
|
||||||
@@ -229,7 +265,7 @@ export function NoticesPage() {
|
|||||||
|
|
||||||
const columns = useMemo<Array<DataTableColumn<(typeof rows)[number]>>>(
|
const columns = useMemo<Array<DataTableColumn<(typeof rows)[number]>>>(
|
||||||
() => [
|
() => [
|
||||||
{ key: 'no', header: 'No.', className: 'w-20 px-5 py-3 font-medium', cell: (row) => <span className="font-semibold">{row.no}</span> },
|
{ key: 'no', header: 'No.', className: 'w-14 px-3 py-3 text-center font-medium', cell: (row) => <span className="block text-center font-semibold">{row.no}</span> },
|
||||||
{
|
{
|
||||||
key: 'title',
|
key: 'title',
|
||||||
header: '공지',
|
header: '공지',
|
||||||
@@ -243,7 +279,7 @@ 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: '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: '생성', className: 'w-48 px-5 py-3', cell: (row) => <NoticeCreator notice={row} /> },
|
||||||
{
|
{
|
||||||
key: 'actions',
|
key: 'actions',
|
||||||
header: '',
|
header: '',
|
||||||
@@ -272,9 +308,14 @@ 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: ['Launcher', ...apps.map((app) => app.key)].map((appKey) => ({ label: appLabel(appKey), value: appKey })),
|
||||||
|
getValue: (row) => row.appKey
|
||||||
|
}
|
||||||
],
|
],
|
||||||
[rows]
|
[apps, appNameByKey]
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -307,6 +348,20 @@ export function NoticesPage() {
|
|||||||
minWidth={1040}
|
minWidth={1040}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
emptyMessage="등록된 공지사항이 없습니다."
|
emptyMessage="등록된 공지사항이 없습니다."
|
||||||
|
serverPagination={{
|
||||||
|
page,
|
||||||
|
total,
|
||||||
|
onPageChange: setPage,
|
||||||
|
onSearchChange: (value) => {
|
||||||
|
setKeyword(value);
|
||||||
|
setPage(1);
|
||||||
|
},
|
||||||
|
filterValues,
|
||||||
|
onFilterChange: (key, value) => {
|
||||||
|
setFilterValues((prev) => ({ ...prev, [key]: value }));
|
||||||
|
setPage(1);
|
||||||
|
}
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{menuNotice ? (() => {
|
{menuNotice ? (() => {
|
||||||
@@ -337,7 +392,7 @@ export function NoticesPage() {
|
|||||||
title={
|
title={
|
||||||
<span className="inline-flex items-center gap-2">
|
<span className="inline-flex items-center gap-2">
|
||||||
<Pencil className="size-4 text-primary" />
|
<Pencil className="size-4 text-primary" />
|
||||||
공지사항
|
{editing ? '공지사항 수정' : '공지사항 추가'}
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
onClose={closeModal}
|
onClose={closeModal}
|
||||||
@@ -354,7 +409,7 @@ export function NoticesPage() {
|
|||||||
<form className="grid gap-4" onSubmit={(event) => { event.preventDefault(); submit(); }}>
|
<form className="grid gap-4" onSubmit={(event) => { event.preventDefault(); submit(); }}>
|
||||||
<div className="grid gap-4 md:grid-cols-[1fr_120px]">
|
<div className="grid gap-4 md:grid-cols-[1fr_120px]">
|
||||||
<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">대상 앱 <b className="text-foreground">*</b></span>
|
||||||
<select className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring" value={form.appKey} onChange={(event) => setForm((prev) => ({ ...prev, appKey: event.target.value }))}>
|
<select className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring" value={form.appKey} onChange={(event) => setForm((prev) => ({ ...prev, appKey: event.target.value }))}>
|
||||||
<option value="Launcher">Launcher</option>
|
<option value="Launcher">Launcher</option>
|
||||||
{apps.map((app) => (
|
{apps.map((app) => (
|
||||||
@@ -371,26 +426,43 @@ export function NoticesPage() {
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<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">제목 <b className="text-foreground">*</b></span>
|
||||||
<input className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring" value={form.title} onChange={(event) => setForm((prev) => ({ ...prev, title: event.target.value }))} />
|
<input className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring" value={form.title} onChange={(event) => setForm((prev) => ({ ...prev, title: event.target.value }))} />
|
||||||
</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">본문 <b className="text-foreground">*</b></span>
|
||||||
<textarea className="mt-2 min-h-24 w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring" value={form.body} onChange={(event) => setForm((prev) => ({ ...prev, body: event.target.value }))} />
|
<textarea className="mt-2 min-h-24 w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring" value={form.body} onChange={(event) => setForm((prev) => ({ ...prev, body: event.target.value }))} />
|
||||||
</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>
|
||||||
<input className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring" value={form.linkUrl} onChange={(event) => setForm((prev) => ({ ...prev, linkUrl: event.target.value }))} />
|
<input className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring" value={form.linkUrl} onChange={(event) => setForm((prev) => ({ ...prev, linkUrl: event.target.value }))} />
|
||||||
</label>
|
</label>
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
<div className="rounded-md border border-border bg-muted/20 p-3">
|
||||||
<label className="block">
|
<label className="inline-flex items-center gap-2 text-sm font-medium text-foreground">
|
||||||
<span className="text-xs font-medium text-muted-foreground">노출 시작 일시</span>
|
<input
|
||||||
<input type="datetime-local" className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring" value={form.startsAt} onChange={(event) => setForm((prev) => ({ ...prev, startsAt: event.target.value }))} />
|
type="checkbox"
|
||||||
</label>
|
checked={form.exposureEnabled}
|
||||||
<label className="block">
|
onChange={(event) => setForm((prev) => ({
|
||||||
<span className="text-xs font-medium text-muted-foreground">노출 종료 일시</span>
|
...prev,
|
||||||
<input type="datetime-local" className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring" value={form.endsAt} onChange={(event) => setForm((prev) => ({ ...prev, endsAt: event.target.value }))} />
|
exposureEnabled: event.target.checked,
|
||||||
|
startsAt: event.target.checked ? prev.startsAt : '',
|
||||||
|
endsAt: event.target.checked ? prev.endsAt : ''
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
노출기간 설정
|
||||||
</label>
|
</label>
|
||||||
|
{form.exposureEnabled ? (
|
||||||
|
<div className="mt-3 grid gap-4 md:grid-cols-2">
|
||||||
|
<label className="block">
|
||||||
|
<span className="text-xs font-medium text-muted-foreground">노출 시작 일시</span>
|
||||||
|
<input type="datetime-local" className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring" value={form.startsAt} onChange={(event) => setForm((prev) => ({ ...prev, startsAt: event.target.value }))} />
|
||||||
|
</label>
|
||||||
|
<label className="block">
|
||||||
|
<span className="text-xs font-medium text-muted-foreground">노출 종료 일시</span>
|
||||||
|
<input type="datetime-local" className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring" value={form.endsAt} onChange={(event) => setForm((prev) => ({ ...prev, endsAt: event.target.value }))} />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|||||||
@@ -8,8 +8,11 @@ import { formatDateTime } from '../../lib/date-format';
|
|||||||
import {
|
import {
|
||||||
createOrganization,
|
createOrganization,
|
||||||
deleteOrganization,
|
deleteOrganization,
|
||||||
loadOrganizations,
|
loadLicensePlans,
|
||||||
|
loadOrganizationsPage,
|
||||||
updateOrganization,
|
updateOrganization,
|
||||||
|
updateOrganizationLicense,
|
||||||
|
type LicensePlan,
|
||||||
type Organization,
|
type Organization,
|
||||||
type OrganizationType
|
type OrganizationType
|
||||||
} from './operations-api';
|
} from './operations-api';
|
||||||
@@ -33,14 +36,41 @@ function organizationTypeLabel(type: OrganizationType) {
|
|||||||
return organizationTypes.find((item) => item.value === type)?.label ?? type;
|
return organizationTypes.find((item) => item.value === type)?.label ?? type;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function OrganizationCreator({ organization }: { organization: Organization }) {
|
||||||
|
const [failed, setFailed] = useState(false);
|
||||||
|
const name = organization.createdByName || organization.createdByEmail || '-';
|
||||||
|
const avatarUrl = organization.createdByAvatarUrl || '';
|
||||||
|
const initial = name.trim().charAt(0).toUpperCase() || 'A';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
|
{avatarUrl && !failed ? (
|
||||||
|
<img src={avatarUrl} alt="" className="size-7 shrink-0 rounded-full border border-border bg-muted object-cover" onError={() => setFailed(true)} />
|
||||||
|
) : (
|
||||||
|
<span className="inline-flex size-7 shrink-0 items-center justify-center rounded-full border border-border bg-muted text-xs font-semibold text-muted-foreground">{initial}</span>
|
||||||
|
)}
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="truncate text-sm font-medium text-foreground">{name}</p>
|
||||||
|
<p className="mt-0.5 whitespace-nowrap text-xs text-muted-foreground">{formatDateTime(organization.createdAt)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function OrganizationsPage() {
|
export function OrganizationsPage() {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const [organizations, setOrganizations] = useState<Organization[]>([]);
|
const [organizations, setOrganizations] = useState<Organization[]>([]);
|
||||||
|
const [licensePlans, setLicensePlans] = useState<LicensePlan[]>([]);
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
|
const [keyword, setKeyword] = useState('');
|
||||||
|
const [filterValues, setFilterValues] = useState<Record<string, string>>({});
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [failed, setFailed] = useState(false);
|
const [failed, setFailed] = useState(false);
|
||||||
const [modalOpen, setModalOpen] = useState(false);
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
const [editing, setEditing] = useState<Organization | null>(null);
|
const [editing, setEditing] = useState<Organization | null>(null);
|
||||||
const [deleteTarget, setDeleteTarget] = useState<Organization | null>(null);
|
const [deleteTarget, setDeleteTarget] = useState<Organization | null>(null);
|
||||||
|
const [licenseTarget, setLicenseTarget] = useState<{ organization: Organization; licenseCode: string } | null>(null);
|
||||||
const [menuOrganization, setMenuOrganization] = useState<{ id: string; x: number; y: number } | null>(null);
|
const [menuOrganization, setMenuOrganization] = useState<{ id: string; x: number; y: number } | null>(null);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [form, setForm] = useState(emptyForm);
|
const [form, setForm] = useState(emptyForm);
|
||||||
@@ -48,9 +78,10 @@ export function OrganizationsPage() {
|
|||||||
|
|
||||||
const refresh = () => {
|
const refresh = () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
loadOrganizations()
|
loadOrganizationsPage({ page, limit: 10, keyword, type: filterValues.type })
|
||||||
.then((items) => {
|
.then((result) => {
|
||||||
setOrganizations(items);
|
setOrganizations(result.items);
|
||||||
|
setTotal(result.total);
|
||||||
setFailed(false);
|
setFailed(false);
|
||||||
})
|
})
|
||||||
.catch((error: Error) => {
|
.catch((error: Error) => {
|
||||||
@@ -63,7 +94,10 @@ export function OrganizationsPage() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
refresh();
|
refresh();
|
||||||
}, []);
|
loadLicensePlans()
|
||||||
|
.then(setLicensePlans)
|
||||||
|
.catch(() => setLicensePlans([]));
|
||||||
|
}, [page, keyword, filterValues.type]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!menuOrganization) return;
|
if (!menuOrganization) return;
|
||||||
@@ -82,13 +116,13 @@ export function OrganizationsPage() {
|
|||||||
}, [menuOrganization]);
|
}, [menuOrganization]);
|
||||||
|
|
||||||
const rows = useMemo(
|
const rows = useMemo(
|
||||||
() => organizations.map((organization, index) => ({ ...organization, no: organizations.length - index })),
|
() => organizations.map((organization) => ({ ...organization, no: organization.no ?? 0 })),
|
||||||
[organizations]
|
[organizations]
|
||||||
);
|
);
|
||||||
|
|
||||||
const columns = useMemo<Array<DataTableColumn<Organization>>>(
|
const columns = useMemo<Array<DataTableColumn<Organization>>>(
|
||||||
() => [
|
() => [
|
||||||
{ key: 'no', header: 'No.', className: 'w-20 px-5 py-3 font-medium', cell: (row) => <span className="font-semibold">{row.no}</span> },
|
{ key: 'no', header: 'No.', className: 'w-14 px-3 py-3 text-center font-medium', cell: (row) => <span className="block text-center font-semibold">{row.no}</span> },
|
||||||
{
|
{
|
||||||
key: 'name',
|
key: 'name',
|
||||||
header: '조직명',
|
header: '조직명',
|
||||||
@@ -101,8 +135,10 @@ export function OrganizationsPage() {
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
{ key: 'type', header: '유형', className: 'w-32 px-5 py-3', cell: (row) => <StatusBadge status={organizationTypeLabel(row.type)} tone="blue" /> },
|
{ key: 'type', header: '유형', className: 'w-32 px-5 py-3', cell: (row) => <StatusBadge status={organizationTypeLabel(row.type)} tone="blue" /> },
|
||||||
|
{ key: 'license', header: '라이센스', className: 'w-36 px-5 py-3', cell: (row) => row.defaultLicenseCode ? <StatusBadge status={row.defaultLicenseName ?? row.defaultLicenseCode} tone="green" /> : <span className="text-xs text-muted-foreground">-</span> },
|
||||||
|
{ key: 'instructorInviteCode', header: '강사 가입 코드', className: 'w-40 px-5 py-3', cell: (row) => <span className="font-mono text-sm font-semibold tracking-[0.08em] text-foreground">{row.instructorInviteCode}</span> },
|
||||||
{ key: 'domain', header: '도메인', className: 'w-56 px-5 py-3', cell: (row) => <span className="font-mono text-xs text-muted-foreground">{row.domain || '-'}</span> },
|
{ key: 'domain', header: '도메인', className: 'w-56 px-5 py-3', cell: (row) => <span className="font-mono text-xs text-muted-foreground">{row.domain || '-'}</span> },
|
||||||
{ key: 'createdAt', header: '등록일', className: 'w-40 px-5 py-3', cell: (row) => <span className="whitespace-nowrap text-muted-foreground">{formatDateTime(row.createdAt)}</span> },
|
{ key: 'createdAt', header: '생성', className: 'w-48 px-5 py-3', cell: (row) => <OrganizationCreator organization={row} /> },
|
||||||
{
|
{
|
||||||
key: 'actions',
|
key: 'actions',
|
||||||
header: '',
|
header: '',
|
||||||
@@ -220,7 +256,7 @@ export function OrganizationsPage() {
|
|||||||
actions={
|
actions={
|
||||||
<Button className="h-8 px-3 text-[13px]" onClick={openCreateModal}>
|
<Button className="h-8 px-3 text-[13px]" onClick={openCreateModal}>
|
||||||
<Plus className="size-4" />
|
<Plus className="size-4" />
|
||||||
조직 등록
|
추가
|
||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -241,6 +277,20 @@ export function OrganizationsPage() {
|
|||||||
minWidth={980}
|
minWidth={980}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
emptyMessage="표시할 조직 데이터가 없습니다."
|
emptyMessage="표시할 조직 데이터가 없습니다."
|
||||||
|
serverPagination={{
|
||||||
|
page,
|
||||||
|
total,
|
||||||
|
onPageChange: setPage,
|
||||||
|
onSearchChange: (value) => {
|
||||||
|
setKeyword(value);
|
||||||
|
setPage(1);
|
||||||
|
},
|
||||||
|
filterValues,
|
||||||
|
onFilterChange: (key, value) => {
|
||||||
|
setFilterValues((prev) => ({ ...prev, [key]: value }));
|
||||||
|
setPage(1);
|
||||||
|
}
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
{menuOrganization ? (() => {
|
{menuOrganization ? (() => {
|
||||||
const row = rows.find((item) => item.id === menuOrganization.id);
|
const row = rows.find((item) => item.id === menuOrganization.id);
|
||||||
@@ -248,6 +298,16 @@ export function OrganizationsPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div onClick={(event) => event.stopPropagation()} className="fixed z-[70] min-w-32 overflow-hidden rounded-md border border-border bg-card py-1 text-left shadow-xl" style={{ left: menuOrganization.x, top: menuOrganization.y, transform: 'translateX(-100%)' }}>
|
<div onClick={(event) => event.stopPropagation()} className="fixed z-[70] min-w-32 overflow-hidden rounded-md border border-border bg-card py-1 text-left shadow-xl" style={{ left: menuOrganization.x, top: menuOrganization.y, transform: 'translateX(-100%)' }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="block w-full px-3 py-2 text-left text-xs text-foreground hover:bg-muted"
|
||||||
|
onClick={() => {
|
||||||
|
setMenuOrganization(null);
|
||||||
|
setLicenseTarget({ organization: row, licenseCode: row.defaultLicenseCode ?? '' });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
라이센스 설정
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="block w-full px-3 py-2 text-left text-xs text-foreground hover:bg-muted"
|
className="block w-full px-3 py-2 text-left text-xs text-foreground hover:bg-muted"
|
||||||
@@ -271,9 +331,65 @@ export function OrganizationsPage() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})() : null}
|
})() : null}
|
||||||
|
<Modal
|
||||||
|
open={Boolean(licenseTarget)}
|
||||||
|
title="조직 라이센스 설정"
|
||||||
|
onClose={() => setLicenseTarget(null)}
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button variant="outline" onClick={() => setLicenseTarget(null)} disabled={saving}>취소</Button>
|
||||||
|
<Button
|
||||||
|
disabled={saving || !licenseTarget}
|
||||||
|
onClick={() => {
|
||||||
|
if (!licenseTarget) return;
|
||||||
|
setSaving(true);
|
||||||
|
updateOrganizationLicense(licenseTarget.organization.id, licenseTarget.licenseCode || null)
|
||||||
|
.then((result) => {
|
||||||
|
toast({
|
||||||
|
type: 'success',
|
||||||
|
title: '라이센스 설정 완료',
|
||||||
|
message: `${result.updatedUserCount}명의 사용자 라이센스를 갱신했습니다.`
|
||||||
|
});
|
||||||
|
setLicenseTarget(null);
|
||||||
|
refresh();
|
||||||
|
})
|
||||||
|
.catch((error: Error) => {
|
||||||
|
toast({ type: 'error', title: '라이센스 설정 실패', message: error.message });
|
||||||
|
})
|
||||||
|
.finally(() => setSaving(false));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{saving ? '저장 중' : '저장'}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{licenseTarget ? (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="rounded-md border border-border bg-background/70 px-3 py-2 text-sm">
|
||||||
|
<p className="font-semibold text-foreground">{licenseTarget.organization.name}</p>
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">저장하면 해당 조직에 소속된 사용자의 현재 라이센스가 선택한 라이센스로 갱신됩니다.</p>
|
||||||
|
</div>
|
||||||
|
<label className="block">
|
||||||
|
<span className="text-xs font-medium text-muted-foreground">라이센스</span>
|
||||||
|
<select
|
||||||
|
className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring"
|
||||||
|
value={licenseTarget.licenseCode}
|
||||||
|
onChange={(event) => setLicenseTarget((prev) => prev ? { ...prev, licenseCode: event.target.value } : prev)}
|
||||||
|
>
|
||||||
|
<option value="">없음</option>
|
||||||
|
{licensePlans.map((plan) => (
|
||||||
|
<option key={plan.code} value={plan.code}>{plan.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</Modal>
|
||||||
|
|
||||||
<Modal
|
<Modal
|
||||||
open={modalOpen}
|
open={modalOpen}
|
||||||
title={editing ? '조직 수정' : '조직 등록'}
|
title={editing ? '조직 수정' : '조직 추가'}
|
||||||
onClose={closeModal}
|
onClose={closeModal}
|
||||||
footer={
|
footer={
|
||||||
<>
|
<>
|
||||||
@@ -284,16 +400,15 @@ export function OrganizationsPage() {
|
|||||||
>
|
>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<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">조직명 <b className="text-foreground">*</b></span>
|
||||||
<input
|
<input
|
||||||
className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none placeholder:text-muted-foreground focus:ring-2 focus:ring-ring"
|
className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none placeholder:text-muted-foreground focus:ring-2 focus:ring-ring"
|
||||||
value={form.name}
|
value={form.name}
|
||||||
onChange={(event) => setForm((prev) => ({ ...prev, name: event.target.value }))}
|
onChange={(event) => setForm((prev) => ({ ...prev, name: event.target.value }))}
|
||||||
placeholder="예: 영남대학교"
|
|
||||||
/>
|
/>
|
||||||
</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">유형 <b className="text-foreground">*</b></span>
|
||||||
<select
|
<select
|
||||||
className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring"
|
className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring"
|
||||||
value={form.type}
|
value={form.type}
|
||||||
@@ -310,7 +425,6 @@ export function OrganizationsPage() {
|
|||||||
className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 font-mono text-sm text-foreground outline-none placeholder:text-muted-foreground focus:ring-2 focus:ring-ring"
|
className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 font-mono text-sm text-foreground outline-none placeholder:text-muted-foreground focus:ring-2 focus:ring-ring"
|
||||||
value={form.domain}
|
value={form.domain}
|
||||||
onChange={(event) => setForm((prev) => ({ ...prev, domain: event.target.value }))}
|
onChange={(event) => setForm((prev) => ({ ...prev, domain: event.target.value }))}
|
||||||
placeholder="yu.ac.kr"
|
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,379 @@
|
|||||||
|
import { MoreHorizontal, Pencil, Plus, Trash2 } from 'lucide-react';
|
||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { DataTableCard, type DataTableColumn, type DataTableFilter } from '../../components/data-table';
|
||||||
|
import { useToast } from '../../components/toast';
|
||||||
|
import { Button, Modal } from '../../components/ui';
|
||||||
|
import { PageTitle } from '../../layout/AppLayout';
|
||||||
|
import { formatDateTime } from '../../lib/date-format';
|
||||||
|
import {
|
||||||
|
createTerm,
|
||||||
|
deleteTerm,
|
||||||
|
loadTermsPage,
|
||||||
|
updateTerm,
|
||||||
|
type LauncherTerm
|
||||||
|
} from './operations-api';
|
||||||
|
import { LoadingModal, StatusBadge, useMinimumLoading } from './operation-utils';
|
||||||
|
|
||||||
|
type TermForm = {
|
||||||
|
key: string;
|
||||||
|
body: string;
|
||||||
|
isRequired: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const termKeyOptions = [
|
||||||
|
{ value: 'terms', label: '이용약관' },
|
||||||
|
{ value: 'privacy', label: '개인정보 처리방침' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const emptyForm: TermForm = {
|
||||||
|
key: 'terms',
|
||||||
|
body: '',
|
||||||
|
isRequired: true
|
||||||
|
};
|
||||||
|
|
||||||
|
function termKeyLabel(key: string) {
|
||||||
|
return termKeyOptions.find((option) => option.value === key)?.label ?? key;
|
||||||
|
}
|
||||||
|
|
||||||
|
function termKeyTone(key: string) {
|
||||||
|
return key === 'privacy' ? 'blue' as const : 'violet' as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
function TermCreator({ term }: { term: LauncherTerm }) {
|
||||||
|
const [failed, setFailed] = useState(false);
|
||||||
|
const name = term.createdByName || term.createdByEmail || '-';
|
||||||
|
const avatarUrl = term.createdByAvatarUrl || '';
|
||||||
|
const initial = name.trim().charAt(0).toUpperCase() || 'A';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
|
{avatarUrl && !failed ? (
|
||||||
|
<img
|
||||||
|
src={avatarUrl}
|
||||||
|
alt=""
|
||||||
|
className="size-7 shrink-0 rounded-full border border-border bg-muted object-cover"
|
||||||
|
onError={() => setFailed(true)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span className="inline-flex size-7 shrink-0 items-center justify-center rounded-full border border-border bg-muted text-xs font-semibold text-muted-foreground">
|
||||||
|
{initial}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="truncate text-sm font-medium text-foreground">{name}</p>
|
||||||
|
<p className="mt-0.5 whitespace-nowrap text-xs text-muted-foreground">{formatDateTime(term.createdAt)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toForm(term: LauncherTerm): TermForm {
|
||||||
|
return {
|
||||||
|
key: term.key,
|
||||||
|
body: term.body,
|
||||||
|
isRequired: term.isRequired
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TermsPage() {
|
||||||
|
const { toast } = useToast();
|
||||||
|
const [terms, setTerms] = useState<LauncherTerm[]>([]);
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
|
const [keyword, setKeyword] = useState('');
|
||||||
|
const [filterValues, setFilterValues] = useState<Record<string, string>>({});
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [failed, setFailed] = useState(false);
|
||||||
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
|
const [editing, setEditing] = useState<LauncherTerm | null>(null);
|
||||||
|
const [deleteTarget, setDeleteTarget] = useState<LauncherTerm | null>(null);
|
||||||
|
const [menuTerm, setMenuTerm] = useState<{ id: string; x: number; y: number } | null>(null);
|
||||||
|
const [form, setForm] = useState<TermForm>(emptyForm);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const loadingVisible = useMinimumLoading(loading);
|
||||||
|
|
||||||
|
const refresh = () => {
|
||||||
|
setLoading(true);
|
||||||
|
loadTermsPage({ page, limit: 6, keyword, key: filterValues.key })
|
||||||
|
.then((result) => {
|
||||||
|
setTerms(result.items);
|
||||||
|
setTotal(result.total);
|
||||||
|
setFailed(false);
|
||||||
|
})
|
||||||
|
.catch((error: Error) => {
|
||||||
|
setTerms([]);
|
||||||
|
setTotal(0);
|
||||||
|
setFailed(true);
|
||||||
|
toast({ type: 'error', title: '약관 조회 실패', message: error.message });
|
||||||
|
})
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
refresh();
|
||||||
|
}, [page, keyword, filterValues.key]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!menuTerm) return;
|
||||||
|
|
||||||
|
const close = () => setMenuTerm(null);
|
||||||
|
const closeOnEscape = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === 'Escape') setMenuTerm(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('click', close);
|
||||||
|
window.addEventListener('keydown', closeOnEscape);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('click', close);
|
||||||
|
window.removeEventListener('keydown', closeOnEscape);
|
||||||
|
};
|
||||||
|
}, [menuTerm]);
|
||||||
|
|
||||||
|
const rows = useMemo(
|
||||||
|
() => terms.map((term) => ({ ...term, no: term.no ?? 0, keyLabel: termKeyLabel(term.key) })),
|
||||||
|
[terms]
|
||||||
|
);
|
||||||
|
|
||||||
|
const openCreate = () => {
|
||||||
|
setEditing(null);
|
||||||
|
setForm(emptyForm);
|
||||||
|
setModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const openEdit = (term: LauncherTerm) => {
|
||||||
|
setMenuTerm(null);
|
||||||
|
setEditing(term);
|
||||||
|
setForm(toForm(term));
|
||||||
|
setModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeModal = () => {
|
||||||
|
setModalOpen(false);
|
||||||
|
setEditing(null);
|
||||||
|
setForm(emptyForm);
|
||||||
|
};
|
||||||
|
|
||||||
|
const payload = () => ({
|
||||||
|
key: form.key.trim(),
|
||||||
|
body: form.body.trim(),
|
||||||
|
isRequired: form.isRequired
|
||||||
|
});
|
||||||
|
|
||||||
|
const submit = () => {
|
||||||
|
if (!form.key.trim() || !form.body.trim()) return;
|
||||||
|
setSaving(true);
|
||||||
|
const request = editing ? updateTerm(editing.id, payload()) : createTerm(payload());
|
||||||
|
|
||||||
|
request
|
||||||
|
.then(() => {
|
||||||
|
toast({ type: 'success', message: editing ? '약관을 수정했습니다.' : '약관을 추가했습니다.' });
|
||||||
|
closeModal();
|
||||||
|
refresh();
|
||||||
|
})
|
||||||
|
.catch((error: Error) => {
|
||||||
|
toast({ type: 'error', title: editing ? '약관 수정 실패' : '약관 추가 실패', message: error.message });
|
||||||
|
})
|
||||||
|
.finally(() => setSaving(false));
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeTerm = (term: LauncherTerm) => {
|
||||||
|
setMenuTerm(null);
|
||||||
|
setDeleteTarget(term);
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmRemoveTerm = () => {
|
||||||
|
if (!deleteTarget) return;
|
||||||
|
setSaving(true);
|
||||||
|
deleteTerm(deleteTarget.id)
|
||||||
|
.then(() => {
|
||||||
|
toast({ type: 'success', message: '약관을 삭제했습니다.' });
|
||||||
|
setDeleteTarget(null);
|
||||||
|
refresh();
|
||||||
|
})
|
||||||
|
.catch((error: Error) => {
|
||||||
|
toast({ type: 'error', title: '약관 삭제 실패', message: error.message });
|
||||||
|
})
|
||||||
|
.finally(() => setSaving(false));
|
||||||
|
};
|
||||||
|
|
||||||
|
const columns = useMemo<Array<DataTableColumn<(typeof rows)[number]>>>(
|
||||||
|
() => [
|
||||||
|
{ key: 'no', header: 'No.', className: 'w-14 px-3 py-3 text-center font-medium', cell: (row) => <span className="block text-center font-semibold">{row.no}</span> },
|
||||||
|
{ key: 'key', header: '약관명', width: 160, className: 'px-5 py-3', cell: (row) => <StatusBadge status={row.keyLabel} tone={termKeyTone(row.key)} /> },
|
||||||
|
{
|
||||||
|
key: 'body',
|
||||||
|
header: '본문',
|
||||||
|
flexible: true,
|
||||||
|
className: 'px-5 py-3 font-medium',
|
||||||
|
cell: (row) => <p className="truncate">{row.body}</p>
|
||||||
|
},
|
||||||
|
{ key: 'required', header: '필수 여부', className: 'w-24 px-5 py-3', cell: (row) => <StatusBadge status={row.isRequired ? '필수' : '선택'} tone={row.isRequired ? 'amber' : 'neutral'} /> },
|
||||||
|
{ key: 'createdAt', header: '생성', className: 'w-48 px-5 py-3', cell: (row) => <TermCreator term={row} /> },
|
||||||
|
{
|
||||||
|
key: 'actions',
|
||||||
|
header: '',
|
||||||
|
className: 'w-20 px-5 py-3 font-medium',
|
||||||
|
cell: (row) => (
|
||||||
|
<div className="relative flex justify-end">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="size-8 px-0"
|
||||||
|
title="메뉴"
|
||||||
|
aria-label="메뉴"
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
const rect = event.currentTarget.getBoundingClientRect();
|
||||||
|
setMenuTerm((prev) => (prev?.id === row.id ? null : { id: row.id, x: rect.right, y: rect.bottom + 6 }));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MoreHorizontal className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const filters = useMemo<Array<DataTableFilter<(typeof rows)[number]>>>(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
key: 'key',
|
||||||
|
label: '약관명',
|
||||||
|
options: termKeyOptions,
|
||||||
|
getValue: (row) => row.key
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageTitle
|
||||||
|
eyebrow="운영"
|
||||||
|
title="약관"
|
||||||
|
description="회원가입과 서비스 이용에 필요한 약관을 관리합니다."
|
||||||
|
actions={
|
||||||
|
<Button className="h-8 px-3 text-[13px]" onClick={openCreate}>
|
||||||
|
<Plus className="size-4" />
|
||||||
|
추가
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{failed ? (
|
||||||
|
<div className="mb-4 rounded-md border border-rose-500/30 bg-rose-500/10 px-4 py-3 text-sm text-rose-200">
|
||||||
|
약관 데이터를 불러오지 못했습니다.
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<DataTableCard
|
||||||
|
title="목록"
|
||||||
|
rows={rows}
|
||||||
|
columns={columns}
|
||||||
|
filters={filters}
|
||||||
|
searchPlaceholder="약관 본문, 약관명 검색"
|
||||||
|
getSearchText={(row) => `${row.body} ${row.key} ${row.keyLabel}`}
|
||||||
|
getRowKey={(row) => row.id}
|
||||||
|
pageSize={6}
|
||||||
|
minWidth={980}
|
||||||
|
loading={loading}
|
||||||
|
emptyMessage="등록된 약관이 없습니다."
|
||||||
|
serverPagination={{
|
||||||
|
page,
|
||||||
|
total,
|
||||||
|
onPageChange: setPage,
|
||||||
|
onSearchChange: (value) => {
|
||||||
|
setKeyword(value);
|
||||||
|
setPage(1);
|
||||||
|
},
|
||||||
|
filterValues,
|
||||||
|
onFilterChange: (key, value) => {
|
||||||
|
setFilterValues((prev) => ({ ...prev, [key]: value }));
|
||||||
|
setPage(1);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{menuTerm ? (() => {
|
||||||
|
const row = rows.find((item) => item.id === menuTerm.id);
|
||||||
|
if (!row) return null;
|
||||||
|
return (
|
||||||
|
<div onClick={(event) => event.stopPropagation()} className="fixed z-[70] min-w-28 overflow-hidden rounded-md border border-border bg-card py-1 text-left shadow-xl" style={{ left: menuTerm.x, top: menuTerm.y, transform: 'translateX(-100%)' }}>
|
||||||
|
<button type="button" className="block w-full px-3 py-2 text-left text-xs text-foreground hover:bg-muted" onClick={() => openEdit(row)}>
|
||||||
|
수정
|
||||||
|
</button>
|
||||||
|
<button type="button" className="block w-full px-3 py-2 text-left text-xs text-rose-300 hover:bg-rose-500/10" onClick={() => removeTerm(row)}>
|
||||||
|
삭제
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})() : null}
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
open={modalOpen}
|
||||||
|
title={
|
||||||
|
<span className="inline-flex items-center gap-2">
|
||||||
|
<Pencil className="size-4 text-primary" />
|
||||||
|
{editing ? '약관 수정' : '약관 추가'}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
onClose={closeModal}
|
||||||
|
className="!max-w-2xl"
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button variant="outline" onClick={closeModal} disabled={saving}>취소</Button>
|
||||||
|
<Button onClick={submit} disabled={saving || !form.key.trim() || !form.body.trim()}>
|
||||||
|
{saving ? '저장 중' : '저장'}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<form className="grid gap-4" onSubmit={(event) => { event.preventDefault(); submit(); }}>
|
||||||
|
<div className="grid gap-4 md:grid-cols-[1fr_120px]">
|
||||||
|
<label className="block">
|
||||||
|
<span className="text-xs font-medium text-muted-foreground">약관명 <b className="text-foreground">*</b></span>
|
||||||
|
<select className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring" value={form.key} onChange={(event) => setForm((prev) => ({ ...prev, key: event.target.value }))}>
|
||||||
|
{termKeyOptions.map((option) => (
|
||||||
|
<option key={option.value} value={option.value}>{option.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="block">
|
||||||
|
<span className="text-xs font-medium text-muted-foreground">필수 여부</span>
|
||||||
|
<span className="mt-2 inline-flex h-9 w-full items-center gap-2 rounded-md border border-border px-3 text-sm text-foreground">
|
||||||
|
<input type="checkbox" checked={form.isRequired} onChange={(event) => setForm((prev) => ({ ...prev, isRequired: event.target.checked }))} />
|
||||||
|
필수
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<label className="block">
|
||||||
|
<span className="text-xs font-medium text-muted-foreground">본문 <b className="text-foreground">*</b></span>
|
||||||
|
<textarea className="mt-2 min-h-56 w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring" value={form.body} onChange={(event) => setForm((prev) => ({ ...prev, body: event.target.value }))} />
|
||||||
|
</label>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
open={Boolean(deleteTarget)}
|
||||||
|
title={
|
||||||
|
<span className="inline-flex items-center gap-2">
|
||||||
|
<Trash2 className="size-4 text-rose-300" />
|
||||||
|
약관 삭제
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
onClose={() => setDeleteTarget(null)}
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button variant="outline" onClick={() => setDeleteTarget(null)} disabled={saving}>취소</Button>
|
||||||
|
<Button className="border border-rose-500/40 bg-rose-500/15 text-rose-200 hover:bg-rose-500/25" onClick={confirmRemoveTerm} disabled={saving}>
|
||||||
|
{saving ? '삭제 중' : '삭제'}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<p className="pb-6 text-sm text-foreground">약관을 삭제할까요?</p>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<LoadingModal open={loadingVisible} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,24 +1,60 @@
|
|||||||
import { MoreHorizontal } from 'lucide-react';
|
import { MoreHorizontal, Plus } from 'lucide-react';
|
||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { DataTableCard, type DataTableColumn, type DataTableFilter } from '../../components/data-table';
|
import { DataTableCard, type DataTableColumn, type DataTableFilter } from '../../components/data-table';
|
||||||
import { Button, Modal } from '../../components/ui';
|
import { Button, Modal } from '../../components/ui';
|
||||||
|
import { useToast } from '../../components/toast';
|
||||||
import { PageTitle } from '../../layout/AppLayout';
|
import { PageTitle } from '../../layout/AppLayout';
|
||||||
import { loadUserSessions, lockUser, terminateUserSession, unlockUser, type UserSession } from './operations-api';
|
import { cn } from '../../lib/utils';
|
||||||
|
import {
|
||||||
|
createManagedUser,
|
||||||
|
loadLicensePlans,
|
||||||
|
loadOrganizations,
|
||||||
|
loadUserSessionsPage,
|
||||||
|
lockUser,
|
||||||
|
terminateUserSession,
|
||||||
|
unlockUser,
|
||||||
|
updateLmsInstructor,
|
||||||
|
type LicensePlan,
|
||||||
|
type Organization,
|
||||||
|
type UserSession
|
||||||
|
} from './operations-api';
|
||||||
import { formatDateTime, LoadingModal, StatusBadge, useMinimumLoading } from './operation-utils';
|
import { formatDateTime, LoadingModal, StatusBadge, useMinimumLoading } from './operation-utils';
|
||||||
|
|
||||||
|
const emptyCreateForm = {
|
||||||
|
email: '',
|
||||||
|
name: '',
|
||||||
|
password: '',
|
||||||
|
organizationId: '',
|
||||||
|
department: '',
|
||||||
|
position: '',
|
||||||
|
licenseCode: 'EDU' as 'FREE' | 'EDU' | 'PRO'
|
||||||
|
};
|
||||||
|
|
||||||
export function UserSessionsPage() {
|
export function UserSessionsPage() {
|
||||||
|
const { toast } = useToast();
|
||||||
const [sessions, setSessions] = useState<UserSession[]>([]);
|
const [sessions, setSessions] = useState<UserSession[]>([]);
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
|
const [keyword, setKeyword] = useState('');
|
||||||
|
const [filterValues, setFilterValues] = useState<Record<string, string>>({});
|
||||||
|
const [organizations, setOrganizations] = useState<Organization[]>([]);
|
||||||
|
const [licensePlans, setLicensePlans] = useState<LicensePlan[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [failed, setFailed] = useState(false);
|
const [failed, setFailed] = useState(false);
|
||||||
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
|
const [createForm, setCreateForm] = useState(emptyCreateForm);
|
||||||
|
const [createSaving, setCreateSaving] = useState(false);
|
||||||
const [menuSession, setMenuSession] = useState<{ id: string; x: number; y: number } | null>(null);
|
const [menuSession, setMenuSession] = useState<{ id: string; x: number; y: number } | null>(null);
|
||||||
const [detailSession, setDetailSession] = useState<UserSession | null>(null);
|
const [detailSession, setDetailSession] = useState<UserSession | null>(null);
|
||||||
|
const [lmsRoleSaving, setLmsRoleSaving] = useState(false);
|
||||||
const loadingVisible = useMinimumLoading(loading);
|
const loadingVisible = useMinimumLoading(loading);
|
||||||
|
|
||||||
const refresh = () => {
|
const refresh = () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
loadUserSessions()
|
loadUserSessionsPage({ page, limit: 6, keyword, state: filterValues.state })
|
||||||
.then((nextSessions) => {
|
.then((result) => {
|
||||||
setSessions(nextSessions);
|
setSessions(result.items);
|
||||||
|
setTotal(result.total);
|
||||||
setFailed(false);
|
setFailed(false);
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
@@ -30,7 +66,19 @@ export function UserSessionsPage() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
refresh();
|
refresh();
|
||||||
}, []);
|
Promise.all([
|
||||||
|
loadOrganizations().catch(() => []),
|
||||||
|
loadLicensePlans().catch(() => [])
|
||||||
|
]).then(([nextOrganizations, nextLicensePlans]) => {
|
||||||
|
setOrganizations(nextOrganizations);
|
||||||
|
setLicensePlans(nextLicensePlans);
|
||||||
|
setCreateForm((current) => ({
|
||||||
|
...current,
|
||||||
|
organizationId: current.organizationId || '',
|
||||||
|
licenseCode: (current.licenseCode || nextLicensePlans[0]?.code || 'EDU') as 'FREE' | 'EDU' | 'PRO'
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
}, [page, keyword, filterValues.state]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!menuSession) return;
|
if (!menuSession) return;
|
||||||
@@ -48,26 +96,29 @@ export function UserSessionsPage() {
|
|||||||
};
|
};
|
||||||
}, [menuSession]);
|
}, [menuSession]);
|
||||||
|
|
||||||
const rows = useMemo(() => sessions.map((session, index) => ({ ...session, no: sessions.length - index })), [sessions]);
|
const rows = sessions;
|
||||||
|
|
||||||
const columns = useMemo<Array<DataTableColumn<UserSession>>>(
|
const columns = useMemo<Array<DataTableColumn<UserSession>>>(
|
||||||
() => [
|
() => [
|
||||||
{ key: 'no', header: 'No.', className: 'w-20 px-5 py-3 font-medium', cell: (row) => <span className="font-semibold">{row.no}</span> },
|
{ key: 'no', header: 'No.', className: 'w-14 px-3 py-3 text-center font-medium', cell: (row) => <span className="block text-center font-semibold">{row.no}</span> },
|
||||||
{
|
{
|
||||||
key: 'user',
|
key: 'user',
|
||||||
header: '사용자',
|
header: '사용자',
|
||||||
className: 'w-[240px] px-5 py-3 font-medium',
|
className: 'w-[280px] px-5 py-3 font-medium',
|
||||||
cell: (row) => (
|
cell: (row) => (
|
||||||
<div className="min-w-0">
|
<div className="flex min-w-0 items-center gap-3">
|
||||||
<p className="truncate font-semibold">{row.user}</p>
|
<UserAvatar session={row} />
|
||||||
{row.email ? <p className="mt-1 truncate text-xs font-medium text-muted-foreground">{row.email}</p> : null}
|
<div className="min-w-0">
|
||||||
|
<p className="truncate font-semibold">{row.user}</p>
|
||||||
|
{row.email ? <p className="mt-1 truncate text-xs font-medium text-muted-foreground">{row.email}</p> : null}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
{ key: 'session', header: '세션', className: 'w-28 px-5 py-3', cell: (row) => <span className="font-mono text-xs text-muted-foreground">{row.session}</span> },
|
{ key: 'session', header: '세션', className: 'w-28 px-5 py-3', cell: (row) => <span className="font-mono text-xs text-muted-foreground">{row.session}</span> },
|
||||||
{ key: 'expiresAt', header: '만료', className: 'w-40 px-5 py-3', cell: (row) => <span className="whitespace-nowrap text-muted-foreground">{formatDateTime(row.expiresAt)}</span> },
|
{ key: 'expiresAt', header: '만료', className: 'w-40 px-5 py-3', cell: (row) => <span className="whitespace-nowrap text-muted-foreground">{formatDateTime(row.expiresAt)}</span> },
|
||||||
{ key: 'lastSeen', header: '최근 접속', className: 'w-40 px-5 py-3', cell: (row) => <span className="whitespace-nowrap text-muted-foreground">{formatDateTime(row.lastSeen)}</span> },
|
{ key: 'lastSeen', header: '최근 접속', className: 'w-40 px-5 py-3', cell: (row) => <span className="whitespace-nowrap text-muted-foreground">{formatDateTime(row.lastSeen)}</span> },
|
||||||
{ key: 'state', header: '상태', className: 'w-24 px-5 py-3', cell: (row) => <StatusBadge status={row.state} /> },
|
{ key: 'state', header: '상태', className: 'w-32 px-5 py-3', cell: (row) => <StatusBadge status={row.state} /> },
|
||||||
{
|
{
|
||||||
key: 'actions',
|
key: 'actions',
|
||||||
header: '',
|
header: '',
|
||||||
@@ -96,17 +147,78 @@ export function UserSessionsPage() {
|
|||||||
|
|
||||||
const filters = useMemo<Array<DataTableFilter<UserSession>>>(
|
const filters = useMemo<Array<DataTableFilter<UserSession>>>(
|
||||||
() => [
|
() => [
|
||||||
{ key: 'state', label: '상태', options: [...new Set(sessions.map((row) => row.state))].map((state) => ({ label: state, value: state })), getValue: (row) => row.state }
|
{ key: 'state', label: '상태', options: ['유효토큰', '세션 없음', '잠김', '만료', '종료'].map((state) => ({ label: state, value: state })), getValue: (row) => row.state }
|
||||||
],
|
],
|
||||||
[sessions]
|
[]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const openCreateModal = () => {
|
||||||
|
setCreateForm({
|
||||||
|
...emptyCreateForm,
|
||||||
|
organizationId: '',
|
||||||
|
licenseCode: (licensePlans[0]?.code || 'EDU') as 'FREE' | 'EDU' | 'PRO'
|
||||||
|
});
|
||||||
|
setCreateOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const submitCreateUser = () => {
|
||||||
|
if (!createForm.email.trim() || !createForm.name.trim() || createForm.password.length < 8) return;
|
||||||
|
|
||||||
|
setCreateSaving(true);
|
||||||
|
createManagedUser({
|
||||||
|
email: createForm.email.trim(),
|
||||||
|
name: createForm.name.trim(),
|
||||||
|
password: createForm.password,
|
||||||
|
organizationId: createForm.organizationId || null,
|
||||||
|
department: createForm.department.trim() || null,
|
||||||
|
position: createForm.position.trim() || null,
|
||||||
|
licenseCode: createForm.licenseCode
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
toast({ type: 'success', title: '사용자 등록', message: '사용자가 등록되었습니다.' });
|
||||||
|
setCreateOpen(false);
|
||||||
|
refresh();
|
||||||
|
})
|
||||||
|
.catch((error: Error) => {
|
||||||
|
toast({ type: 'error', title: '사용자 등록 실패', message: error.message });
|
||||||
|
})
|
||||||
|
.finally(() => setCreateSaving(false));
|
||||||
|
};
|
||||||
|
|
||||||
|
const setLmsInstructor = (isInstructor: boolean) => {
|
||||||
|
if (!detailSession) return;
|
||||||
|
|
||||||
|
setLmsRoleSaving(true);
|
||||||
|
updateLmsInstructor(detailSession.userId, isInstructor)
|
||||||
|
.then(() => {
|
||||||
|
setDetailSession((current) => current ? { ...current, isLmsInstructor: isInstructor } : current);
|
||||||
|
setSessions((current) => current.map((session) => (
|
||||||
|
session.userId === detailSession.userId ? { ...session, isLmsInstructor: isInstructor } : session
|
||||||
|
)));
|
||||||
|
toast({ type: 'success', title: 'LMS 역할 변경', message: isInstructor ? '강사 권한을 부여했습니다.' : '강사 권한을 해제했습니다.' });
|
||||||
|
})
|
||||||
|
.catch((error: Error) => {
|
||||||
|
toast({ type: 'error', title: 'LMS 역할 변경 실패', message: error.message });
|
||||||
|
})
|
||||||
|
.finally(() => setLmsRoleSaving(false));
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageTitle eyebrow="운영" title="사용자/세션" description="사용자의 유효토큰, 만료, 종료 상태를 확인하고 필요한 제어 작업을 실행합니다." />
|
<PageTitle
|
||||||
|
eyebrow="운영"
|
||||||
|
title="사용자 관리"
|
||||||
|
description="사용자의 유효토큰, 만료, 종료 상태를 확인하고 필요한 제어 작업을 실행합니다."
|
||||||
|
actions={
|
||||||
|
<Button className="h-8 px-3 text-[13px]" onClick={openCreateModal}>
|
||||||
|
<Plus className="size-4" />
|
||||||
|
추가
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
{failed ? (
|
{failed ? (
|
||||||
<div className="mb-4 rounded-md border border-rose-500/30 bg-rose-500/10 px-4 py-3 text-sm text-rose-200">
|
<div className="mb-4 rounded-md border border-rose-500/30 bg-rose-500/10 px-4 py-3 text-sm text-rose-200">
|
||||||
사용자/세션 데이터를 불러오지 못했습니다.
|
사용자 데이터를 불러오지 못했습니다.
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
<DataTableCard
|
<DataTableCard
|
||||||
@@ -120,7 +232,21 @@ export function UserSessionsPage() {
|
|||||||
pageSize={6}
|
pageSize={6}
|
||||||
minWidth={900}
|
minWidth={900}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
emptyMessage="표시할 사용자/세션 데이터가 없습니다."
|
emptyMessage="표시할 사용자가 없습니다."
|
||||||
|
serverPagination={{
|
||||||
|
page,
|
||||||
|
total,
|
||||||
|
onPageChange: setPage,
|
||||||
|
onSearchChange: (value) => {
|
||||||
|
setKeyword(value);
|
||||||
|
setPage(1);
|
||||||
|
},
|
||||||
|
filterValues,
|
||||||
|
onFilterChange: (key, value) => {
|
||||||
|
setFilterValues((prev) => ({ ...prev, [key]: value }));
|
||||||
|
setPage(1);
|
||||||
|
}
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
{menuSession ? (() => {
|
{menuSession ? (() => {
|
||||||
const row = rows.find((item) => item.id === menuSession.id);
|
const row = rows.find((item) => item.id === menuSession.id);
|
||||||
@@ -174,14 +300,29 @@ export function UserSessionsPage() {
|
|||||||
{detailSession ? (
|
{detailSession ? (
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
<div className="flex min-w-0 items-start justify-between gap-4 rounded-md border border-border bg-muted/20 px-4 py-3">
|
<div className="flex min-w-0 items-start justify-between gap-4 rounded-md border border-border bg-muted/20 px-4 py-3">
|
||||||
<div className="min-w-0">
|
<div className="flex min-w-0 items-center gap-3">
|
||||||
<p className="truncate text-sm font-semibold text-foreground">{detailSession.user}</p>
|
<UserAvatar session={detailSession} size="lg" />
|
||||||
<p className="mt-1 truncate text-xs text-muted-foreground">{detailSession.email ?? '-'}</p>
|
<div className="min-w-0">
|
||||||
|
<p className="truncate text-sm font-semibold text-foreground">{detailSession.user}</p>
|
||||||
|
<p className="mt-1 truncate text-xs text-muted-foreground">{detailSession.email ?? '-'}</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<StatusBadge status={detailSession.state} />
|
<StatusBadge status={detailSession.state} />
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-3 text-sm sm:grid-cols-2">
|
<div className="grid gap-3 text-sm sm:grid-cols-2">
|
||||||
<DetailItem label="사용자 ID" value={detailSession.userId} mono />
|
<DetailItem label="사용자 ID" value={detailSession.userId} mono />
|
||||||
|
<label className="block min-w-0">
|
||||||
|
<span className="text-xs font-medium text-muted-foreground">LMS</span>
|
||||||
|
<select
|
||||||
|
className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm font-medium text-foreground outline-none focus:ring-2 focus:ring-ring"
|
||||||
|
value={detailSession.isLmsInstructor ? 'INSTRUCTOR' : 'STUDENT'}
|
||||||
|
disabled={lmsRoleSaving}
|
||||||
|
onChange={(event) => setLmsInstructor(event.target.value === 'INSTRUCTOR')}
|
||||||
|
>
|
||||||
|
<option value="STUDENT">수강생</option>
|
||||||
|
<option value="INSTRUCTOR">강사</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
<DetailItem label="조직" value={detailSession.primaryAffiliation?.organizationName ?? '-'} />
|
<DetailItem label="조직" value={detailSession.primaryAffiliation?.organizationName ?? '-'} />
|
||||||
<DetailItem label="조직 유형" value={organizationTypeLabel(detailSession.primaryAffiliation?.organizationType)} />
|
<DetailItem label="조직 유형" value={organizationTypeLabel(detailSession.primaryAffiliation?.organizationType)} />
|
||||||
<DetailItem label="부서" value={detailSession.primaryAffiliation?.department ?? '-'} />
|
<DetailItem label="부서" value={detailSession.primaryAffiliation?.department ?? '-'} />
|
||||||
@@ -195,6 +336,96 @@ export function UserSessionsPage() {
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</Modal>
|
</Modal>
|
||||||
|
<Modal
|
||||||
|
open={createOpen}
|
||||||
|
title="사용자 추가"
|
||||||
|
onClose={() => {
|
||||||
|
if (!createSaving) setCreateOpen(false);
|
||||||
|
}}
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button variant="outline" onClick={() => setCreateOpen(false)} disabled={createSaving}>취소</Button>
|
||||||
|
<Button
|
||||||
|
onClick={submitCreateUser}
|
||||||
|
disabled={createSaving || !createForm.email.trim() || !createForm.name.trim() || createForm.password.length < 8}
|
||||||
|
>
|
||||||
|
{createSaving ? '등록 중' : '등록'}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<label className="block">
|
||||||
|
<span className="text-xs font-medium text-muted-foreground">이메일 <b className="text-foreground">*</b></span>
|
||||||
|
<input
|
||||||
|
className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring"
|
||||||
|
value={createForm.email}
|
||||||
|
onChange={(event) => setCreateForm((current) => ({ ...current, email: event.target.value }))}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="block">
|
||||||
|
<span className="text-xs font-medium text-muted-foreground">이름 <b className="text-foreground">*</b></span>
|
||||||
|
<input
|
||||||
|
className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring"
|
||||||
|
value={createForm.name}
|
||||||
|
onChange={(event) => setCreateForm((current) => ({ ...current, name: event.target.value }))}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="block">
|
||||||
|
<span className="text-xs font-medium text-muted-foreground">임시 비밀번호 <b className="text-foreground">*</b></span>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring"
|
||||||
|
value={createForm.password}
|
||||||
|
onChange={(event) => setCreateForm((current) => ({ ...current, password: event.target.value }))}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="block">
|
||||||
|
<span className="text-xs font-medium text-muted-foreground">조직</span>
|
||||||
|
<select
|
||||||
|
className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring"
|
||||||
|
value={createForm.organizationId}
|
||||||
|
onChange={(event) => setCreateForm((current) => ({ ...current, organizationId: event.target.value }))}
|
||||||
|
>
|
||||||
|
<option value="">없음</option>
|
||||||
|
{organizations.map((organization) => (
|
||||||
|
<option key={organization.id} value={organization.id}>{organization.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
<label className="block">
|
||||||
|
<span className="text-xs font-medium text-muted-foreground">부서</span>
|
||||||
|
<input
|
||||||
|
className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring"
|
||||||
|
value={createForm.department}
|
||||||
|
onChange={(event) => setCreateForm((current) => ({ ...current, department: event.target.value }))}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="block">
|
||||||
|
<span className="text-xs font-medium text-muted-foreground">직책</span>
|
||||||
|
<input
|
||||||
|
className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring"
|
||||||
|
value={createForm.position}
|
||||||
|
onChange={(event) => setCreateForm((current) => ({ ...current, position: event.target.value }))}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<label className="block">
|
||||||
|
<span className="text-xs font-medium text-muted-foreground">라이센스 <b className="text-foreground">*</b></span>
|
||||||
|
<select
|
||||||
|
className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring"
|
||||||
|
value={createForm.licenseCode}
|
||||||
|
onChange={(event) => setCreateForm((current) => ({ ...current, licenseCode: event.target.value as 'FREE' | 'EDU' | 'PRO' }))}
|
||||||
|
>
|
||||||
|
{licensePlans.map((plan) => (
|
||||||
|
<option key={plan.code} value={plan.code}>{plan.name}</option>
|
||||||
|
))}
|
||||||
|
{licensePlans.length === 0 ? <option value="EDU">EDU</option> : null}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
<LoadingModal open={loadingVisible} />
|
<LoadingModal open={loadingVisible} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
@@ -212,11 +443,36 @@ function organizationTypeLabel(type?: string | null) {
|
|||||||
return type ? labels[type] ?? type : '-';
|
return type ? labels[type] ?? type : '-';
|
||||||
}
|
}
|
||||||
|
|
||||||
function DetailItem({ label, value, mono = false }: { label: string; value: string; mono?: boolean }) {
|
function UserAvatar({ session, size = 'sm' }: { session: UserSession; size?: 'sm' | 'lg' }) {
|
||||||
|
const [failed, setFailed] = useState(false);
|
||||||
|
const initial = (session.user || session.email || '?').trim().charAt(0).toUpperCase() || '?';
|
||||||
|
const sizeClassName = size === 'lg' ? 'size-14 text-lg' : 'size-9 text-sm';
|
||||||
|
|
||||||
|
if (session.avatarUrl && !failed) {
|
||||||
|
return (
|
||||||
|
<img
|
||||||
|
src={session.avatarUrl}
|
||||||
|
alt=""
|
||||||
|
className={cn(sizeClassName, 'shrink-0 rounded-full border border-border bg-muted object-cover')}
|
||||||
|
onError={() => setFailed(true)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-w-0 rounded-md border border-border bg-background px-3 py-2">
|
<span className={cn(sizeClassName, 'inline-flex shrink-0 items-center justify-center rounded-full border border-border bg-muted font-semibold text-muted-foreground')}>
|
||||||
<p className="text-xs font-medium text-muted-foreground">{label}</p>
|
{initial}
|
||||||
<p className={`mt-1 truncate text-sm text-foreground ${mono ? 'font-mono text-xs' : 'font-medium'}`}>{value || '-'}</p>
|
</span>
|
||||||
</div>
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DetailItem({ label, value, mono = false }: { label: string; value: string; mono?: boolean }) {
|
||||||
|
return (
|
||||||
|
<label className="block min-w-0">
|
||||||
|
<span className="text-xs font-medium text-muted-foreground">{label}</span>
|
||||||
|
<div className={cn('mt-2 min-h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm font-medium text-foreground', mono && 'break-all font-mono text-xs font-normal')}>
|
||||||
|
{value || '-'}
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,10 +7,10 @@ export type BadgeTone = 'neutral' | 'green' | 'amber' | 'red' | 'blue' | 'violet
|
|||||||
|
|
||||||
export function statusTone(status?: string | null): BadgeTone {
|
export function statusTone(status?: string | null): BadgeTone {
|
||||||
if (!status) return 'neutral';
|
if (!status) return 'neutral';
|
||||||
if (['활성', '완료', '게시됨', '게시중', '실행 중', '배포됨', '성공', 'Running', 'ACTIVE'].includes(status)) return 'green';
|
if (['활성', '완료', '게시됨', '게시중', '실행 중', '배포됨', '성공', '유효토큰', 'Running', 'ACTIVE'].includes(status)) return 'green';
|
||||||
if (['주의', '예약됨', '예약', '대기', '준비중', '카나리', '만료', 'PENDING'].includes(status)) return 'amber';
|
if (['주의', '예약됨', '예약', '대기', '준비중', '카나리', '만료', 'PENDING'].includes(status)) return 'amber';
|
||||||
if (['잠김', '실패', '오프라인', '비활성', '종료', 'FAILED', 'Error', 'CrashLoopBackOff'].includes(status)) return 'red';
|
if (['잠김', '실패', '오프라인', '비활성', '종료', 'FAILED', 'Error', 'CrashLoopBackOff'].includes(status)) return 'red';
|
||||||
if (['없음'].includes(status)) return 'neutral';
|
if (['없음', '세션 없음'].includes(status)) return 'neutral';
|
||||||
if (['런처'].includes(status)) return 'violet';
|
if (['런처'].includes(status)) return 'violet';
|
||||||
return 'neutral';
|
return 'neutral';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,28 @@
|
|||||||
import { apiRequest } from '../../lib/api';
|
import { apiRequest } from '../../lib/api';
|
||||||
|
|
||||||
|
export type PageParams = {
|
||||||
|
page?: number;
|
||||||
|
limit?: number;
|
||||||
|
keyword?: string;
|
||||||
|
[key: string]: string | number | undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PagedResponse<T> = {
|
||||||
|
items: T[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
limit: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
function pageQuery(params?: PageParams) {
|
||||||
|
const searchParams = new URLSearchParams();
|
||||||
|
Object.entries(params ?? {}).forEach(([key, value]) => {
|
||||||
|
if (value !== undefined && value !== '') searchParams.set(key, String(value));
|
||||||
|
});
|
||||||
|
const query = searchParams.toString();
|
||||||
|
return query ? `?${query}` : '';
|
||||||
|
}
|
||||||
|
|
||||||
export type AdminSession = {
|
export type AdminSession = {
|
||||||
id?: string;
|
id?: string;
|
||||||
revokedAt?: string | null;
|
revokedAt?: string | null;
|
||||||
@@ -13,13 +36,16 @@ export type AdminSshKey = {
|
|||||||
|
|
||||||
export type AdminAccount = {
|
export type AdminAccount = {
|
||||||
id: string;
|
id: string;
|
||||||
|
no?: number;
|
||||||
email: string;
|
email: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
avatarUrl?: string | null;
|
||||||
role: 'OWNER' | 'ADMIN' | 'OPERATOR' | 'VIEWER' | string;
|
role: 'OWNER' | 'ADMIN' | 'OPERATOR' | 'VIEWER' | string;
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
lastLoginAt?: string | null;
|
lastLoginAt?: string | null;
|
||||||
sessions?: AdminSession[];
|
sessions?: AdminSession[];
|
||||||
sshKeys?: AdminSshKey[];
|
sshKeys?: AdminSshKey[];
|
||||||
|
appPermissions?: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type AdminRoleOption = {
|
export type AdminRoleOption = {
|
||||||
@@ -38,11 +64,13 @@ export type UserSession = {
|
|||||||
sessionId?: string | null;
|
sessionId?: string | null;
|
||||||
user: string;
|
user: string;
|
||||||
email?: string;
|
email?: string;
|
||||||
|
avatarUrl?: string | null;
|
||||||
role: string;
|
role: string;
|
||||||
session: string;
|
session: string;
|
||||||
expiresAt?: string | null;
|
expiresAt?: string | null;
|
||||||
lastSeen?: string | null;
|
lastSeen?: string | null;
|
||||||
state: string;
|
state: string;
|
||||||
|
isLmsInstructor?: boolean;
|
||||||
primaryAffiliation?: {
|
primaryAffiliation?: {
|
||||||
id: string;
|
id: string;
|
||||||
organizationId: string;
|
organizationId: string;
|
||||||
@@ -61,6 +89,13 @@ export type Organization = {
|
|||||||
name: string;
|
name: string;
|
||||||
type: OrganizationType;
|
type: OrganizationType;
|
||||||
domain?: string | null;
|
domain?: string | null;
|
||||||
|
defaultLicenseCode?: string | null;
|
||||||
|
defaultLicenseName?: string | null;
|
||||||
|
instructorInviteCode: string;
|
||||||
|
createdById?: string | null;
|
||||||
|
createdByName?: string | null;
|
||||||
|
createdByEmail?: string | null;
|
||||||
|
createdByAvatarUrl?: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -76,13 +111,61 @@ export type LauncherNotice = {
|
|||||||
endsAt?: string | null;
|
endsAt?: string | null;
|
||||||
priority: number;
|
priority: number;
|
||||||
status?: string;
|
status?: string;
|
||||||
|
createdByName?: string | null;
|
||||||
|
createdByEmail?: string | null;
|
||||||
|
createdByAvatarUrl?: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt?: string;
|
updatedAt?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type LauncherTerm = {
|
||||||
|
id: string;
|
||||||
|
no?: number;
|
||||||
|
key: string;
|
||||||
|
body: string;
|
||||||
|
isRequired: boolean;
|
||||||
|
createdById?: string | null;
|
||||||
|
createdByName?: string | null;
|
||||||
|
createdByEmail?: string | null;
|
||||||
|
createdByAvatarUrl?: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ManualDocument = {
|
||||||
|
id: string;
|
||||||
|
no?: number;
|
||||||
|
appKey: string;
|
||||||
|
title: string;
|
||||||
|
body: string;
|
||||||
|
isPublished: boolean;
|
||||||
|
sortOrder: number;
|
||||||
|
createdById?: string | null;
|
||||||
|
createdByName?: string | null;
|
||||||
|
createdByEmail?: string | null;
|
||||||
|
createdByAvatarUrl?: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function loadAdminsPage(params?: PageParams & { role?: string; state?: string }): Promise<PagedResponse<AdminAccount>> {
|
||||||
|
type RawAdminAccount = Omit<AdminAccount, 'appPermissions'> & {
|
||||||
|
appPermissions?: Array<string | { appId: string }>;
|
||||||
|
};
|
||||||
|
const response = await apiRequest<RawAdminAccount[] | { admins?: RawAdminAccount[]; total?: number; page?: number; limit?: number }>(`/admins${pageQuery(params)}`);
|
||||||
|
const admins = Array.isArray(response) ? response : response.admins ?? [];
|
||||||
|
const items = admins.map((admin) => ({
|
||||||
|
...admin,
|
||||||
|
appPermissions: (admin.appPermissions ?? []).map((permission) => (
|
||||||
|
typeof permission === 'string' ? permission : permission.appId
|
||||||
|
))
|
||||||
|
}));
|
||||||
|
if (Array.isArray(response)) return { items, total: items.length, page: params?.page ?? 1, limit: params?.limit ?? items.length };
|
||||||
|
return { items, total: response.total ?? items.length, page: response.page ?? params?.page ?? 1, limit: response.limit ?? params?.limit ?? items.length };
|
||||||
|
}
|
||||||
|
|
||||||
export async function loadAdmins() {
|
export async function loadAdmins() {
|
||||||
const response = await apiRequest<AdminAccount[] | { admins?: AdminAccount[] }>('/admins');
|
return (await loadAdminsPage({ limit: 1000 })).items;
|
||||||
return Array.isArray(response) ? response : response.admins ?? [];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function loadAdminRoles() {
|
export function loadAdminRoles() {
|
||||||
@@ -124,7 +207,7 @@ export function resetAdminPassword(adminId: string, temporaryPassword: string) {
|
|||||||
|
|
||||||
export function createAdminSshKey(
|
export function createAdminSshKey(
|
||||||
adminId: string,
|
adminId: string,
|
||||||
payload: { label?: string; giteaUsername?: string; publicKey: string }
|
payload: { label?: string; publicKey: string }
|
||||||
) {
|
) {
|
||||||
return apiRequest(`/admins/${adminId}/ssh-keys`, {
|
return apiRequest(`/admins/${adminId}/ssh-keys`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -138,13 +221,30 @@ export function deleteAdminSshKey(adminId: string, keyId: string) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function loadUserSessions() {
|
export async function loadAdminAppPermissions(adminId: string) {
|
||||||
const response = await apiRequest<{ sessions?: UserSession[] }>('/users/sessions');
|
const response = await apiRequest<{ appIds?: string[] }>(`/admins/${adminId}/app-permissions`);
|
||||||
return (response.sessions ?? []).map((session) => ({
|
return response.appIds ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateAdminAppPermissions(adminId: string, appIds: string[]) {
|
||||||
|
return apiRequest<{ appIds?: string[] }>(`/admins/${adminId}/app-permissions`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify({ appIds })
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadUserSessionsPage(params?: PageParams & { state?: string }): Promise<PagedResponse<UserSession>> {
|
||||||
|
const response = await apiRequest<{ sessions?: UserSession[]; total?: number; page?: number; limit?: number }>(`/users/sessions${pageQuery(params)}`);
|
||||||
|
const items = (response.sessions ?? []).map((session) => ({
|
||||||
...session,
|
...session,
|
||||||
session: session.sessionId ? session.session : '-',
|
session: session.sessionId ? session.session : '-',
|
||||||
state: session.state === '세션 없음' ? '없음' : session.state
|
state: session.state
|
||||||
}));
|
}));
|
||||||
|
return { items, total: response.total ?? items.length, page: response.page ?? params?.page ?? 1, limit: response.limit ?? params?.limit ?? items.length };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadUserSessions() {
|
||||||
|
return (await loadUserSessionsPage({ limit: 1000 })).items;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function lockUser(userId: string) {
|
export function lockUser(userId: string) {
|
||||||
@@ -159,15 +259,42 @@ export function unlockUser(userId: string) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function updateLmsInstructor(userId: string, isInstructor: boolean) {
|
||||||
|
return apiRequest<{ userId: string; isInstructor: boolean }>(`/users/${userId}/lms-instructor`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify({ isInstructor })
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function terminateUserSession(sessionId: string) {
|
export function terminateUserSession(sessionId: string) {
|
||||||
return apiRequest(`/users/sessions/${sessionId}`, {
|
return apiRequest(`/users/sessions/${sessionId}`, {
|
||||||
method: 'DELETE'
|
method: 'DELETE'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function createManagedUser(payload: {
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
name: string;
|
||||||
|
organizationId?: string | null;
|
||||||
|
department?: string | null;
|
||||||
|
position?: string | null;
|
||||||
|
licenseCode?: 'FREE' | 'EDU' | 'PRO';
|
||||||
|
}) {
|
||||||
|
return apiRequest<{ user: { id: string; email: string; name?: string | null } }>('/users', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadOrganizationsPage(params?: PageParams & { type?: string }): Promise<PagedResponse<Organization>> {
|
||||||
|
const response = await apiRequest<{ organizations?: Organization[]; total?: number; page?: number; limit?: number }>(`/organizations${pageQuery(params)}`);
|
||||||
|
const items = response.organizations ?? [];
|
||||||
|
return { items, total: response.total ?? items.length, page: response.page ?? params?.page ?? 1, limit: response.limit ?? params?.limit ?? items.length };
|
||||||
|
}
|
||||||
|
|
||||||
export async function loadOrganizations() {
|
export async function loadOrganizations() {
|
||||||
const response = await apiRequest<{ organizations?: Organization[] }>('/organizations');
|
return (await loadOrganizationsPage({ limit: 1000 })).items;
|
||||||
return response.organizations ?? [];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createOrganization(payload: { name: string; type: OrganizationType; domain?: string | null }) {
|
export function createOrganization(payload: { name: string; type: OrganizationType; domain?: string | null }) {
|
||||||
@@ -190,9 +317,21 @@ export function deleteOrganization(organizationId: string) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function updateOrganizationLicense(organizationId: string, licenseCode: string | null) {
|
||||||
|
return apiRequest<{ organization: Organization; updatedUserCount: number }>(`/organizations/${organizationId}/license`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify({ licenseCode })
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadLauncherNoticesPage(params?: PageParams & { appKey?: string }): Promise<PagedResponse<LauncherNotice>> {
|
||||||
|
const response = await apiRequest<{ notices?: LauncherNotice[]; total?: number; page?: number; limit?: number }>(`/notices${pageQuery(params)}`);
|
||||||
|
const items = response.notices ?? [];
|
||||||
|
return { items, total: response.total ?? items.length, page: response.page ?? params?.page ?? 1, limit: response.limit ?? params?.limit ?? items.length };
|
||||||
|
}
|
||||||
|
|
||||||
export async function loadLauncherNotices() {
|
export async function loadLauncherNotices() {
|
||||||
const response = await apiRequest<{ notices?: LauncherNotice[] }>('/notices');
|
return (await loadLauncherNoticesPage({ limit: 1000 })).items;
|
||||||
return response.notices ?? [];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createLauncherNotice(payload: {
|
export function createLauncherNotice(payload: {
|
||||||
@@ -236,6 +375,61 @@ export function deleteLauncherNotice(noticeId: string) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function loadTermsPage(params?: PageParams & { key?: string }): Promise<PagedResponse<LauncherTerm>> {
|
||||||
|
const response = await apiRequest<{ terms?: LauncherTerm[]; total?: number; page?: number; limit?: number }>(`/terms${pageQuery(params)}`);
|
||||||
|
const items = response.terms ?? [];
|
||||||
|
return { items, total: response.total ?? items.length, page: response.page ?? params?.page ?? 1, limit: response.limit ?? params?.limit ?? items.length };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createTerm(payload: {
|
||||||
|
key: string;
|
||||||
|
body: string;
|
||||||
|
isRequired?: boolean;
|
||||||
|
}) {
|
||||||
|
return apiRequest<{ term: LauncherTerm }>('/terms', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateTerm(
|
||||||
|
termId: string,
|
||||||
|
payload: Partial<{
|
||||||
|
key: string;
|
||||||
|
body: string;
|
||||||
|
isRequired: boolean;
|
||||||
|
}>
|
||||||
|
) {
|
||||||
|
return apiRequest<{ term: LauncherTerm }>(`/terms/${termId}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteTerm(termId: string) {
|
||||||
|
return apiRequest<{ ok: boolean }>(`/terms/${termId}`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadManualDocumentsPage(params?: PageParams & { appKey?: string }): Promise<PagedResponse<ManualDocument>> {
|
||||||
|
const response = await apiRequest<{ documents?: ManualDocument[]; total?: number; page?: number; limit?: number }>(`/manuals${pageQuery(params)}`);
|
||||||
|
const items = response.documents ?? [];
|
||||||
|
return { items, total: response.total ?? items.length, page: response.page ?? params?.page ?? 1, limit: response.limit ?? params?.limit ?? items.length };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createManualDocument(payload: Pick<ManualDocument, 'appKey' | 'title' | 'body' | 'isPublished' | 'sortOrder'>) {
|
||||||
|
return apiRequest<{ document: ManualDocument }>('/manuals', { method: 'POST', body: JSON.stringify(payload) });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateManualDocument(id: string, payload: Partial<Pick<ManualDocument, 'appKey' | 'title' | 'body' | 'isPublished' | 'sortOrder'>>) {
|
||||||
|
return apiRequest<{ document: ManualDocument }>(`/manuals/${id}`, { method: 'PATCH', body: JSON.stringify(payload) });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteManualDocument(id: string) {
|
||||||
|
return apiRequest<{ ok: boolean }>(`/manuals/${id}`, { method: 'DELETE' });
|
||||||
|
}
|
||||||
|
|
||||||
export type LicensePlan = {
|
export type LicensePlan = {
|
||||||
id: string;
|
id: string;
|
||||||
code: string;
|
code: string;
|
||||||
@@ -281,6 +475,19 @@ export async function loadLicensePlans() {
|
|||||||
return response.plans ?? [];
|
return response.plans ?? [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function updateLicensePlan(licenseCode: string, payload: {
|
||||||
|
name?: string;
|
||||||
|
description?: string | null;
|
||||||
|
defaultDurationDays?: number | null;
|
||||||
|
isActive?: boolean;
|
||||||
|
permissions?: Array<{ appKey: string; canLaunch: boolean }>;
|
||||||
|
}) {
|
||||||
|
return apiRequest<{ plan: LicensePlan; updatedUserCount?: number }>(`/licenses/plans/${encodeURIComponent(licenseCode)}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export async function loadLicenseUsers(params?: { keyword?: string; licenseCode?: string }) {
|
export async function loadLicenseUsers(params?: { keyword?: string; licenseCode?: string }) {
|
||||||
const searchParams = new URLSearchParams();
|
const searchParams = new URLSearchParams();
|
||||||
if (params?.keyword) searchParams.set('keyword', params.keyword);
|
if (params?.keyword) searchParams.set('keyword', params.keyword);
|
||||||
@@ -290,6 +497,27 @@ export async function loadLicenseUsers(params?: { keyword?: string; licenseCode?
|
|||||||
return response.users ?? [];
|
return response.users ?? [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function loadLicenseAssignments(params?: PageParams & { licenseCode?: string; status?: string }): Promise<PagedResponse<LicenseAssignment>> {
|
||||||
|
const response = await apiRequest<{ assignments?: LicenseAssignment[]; total?: number; page?: number; limit?: number }>(`/licenses/assignments${pageQuery(params)}`);
|
||||||
|
const items = response.assignments ?? [];
|
||||||
|
return { items, total: response.total ?? items.length, page: response.page ?? params?.page ?? 1, limit: response.limit ?? params?.limit ?? items.length };
|
||||||
|
}
|
||||||
|
|
||||||
|
export type LicenseAssignment = {
|
||||||
|
id: string;
|
||||||
|
no?: number;
|
||||||
|
userId: string;
|
||||||
|
user: string;
|
||||||
|
name: string;
|
||||||
|
role: string;
|
||||||
|
code: string;
|
||||||
|
licenseName: string;
|
||||||
|
startsAt: string;
|
||||||
|
expiresAt?: string | null;
|
||||||
|
assignedBy?: string | null;
|
||||||
|
status: string;
|
||||||
|
};
|
||||||
|
|
||||||
export function assignUserLicense(userId: string, payload: {
|
export function assignUserLicense(userId: string, payload: {
|
||||||
licenseCode: string;
|
licenseCode: string;
|
||||||
startsAt?: string;
|
startsAt?: string;
|
||||||
@@ -331,6 +559,33 @@ export type LauncherUserAppPermission = {
|
|||||||
assignedAt?: string | null;
|
assignedAt?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type LicenseAppPermission = {
|
||||||
|
appId: string;
|
||||||
|
appKey: string;
|
||||||
|
displayName: string;
|
||||||
|
description?: string | null;
|
||||||
|
sortOrder: number;
|
||||||
|
isEnabled: boolean;
|
||||||
|
policyId?: string | null;
|
||||||
|
canView: boolean;
|
||||||
|
canLaunch: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function loadLicenseAppPermissions(licenseCode: string) {
|
||||||
|
const response = await apiRequest<{ permissions?: LicenseAppPermission[] }>(`/licenses/plans/${encodeURIComponent(licenseCode)}/app-permissions`);
|
||||||
|
return response.permissions ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateLicenseAppPermissions(licenseCode: string, permissions: Array<{
|
||||||
|
appKey: string;
|
||||||
|
canLaunch: boolean;
|
||||||
|
}>) {
|
||||||
|
return apiRequest<{ permissions?: LicenseAppPermission[]; updatedUserCount?: number }>(`/licenses/plans/${encodeURIComponent(licenseCode)}/app-permissions`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify({ permissions })
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export async function loadLauncherUserAppPermissions(userId: string) {
|
export async function loadLauncherUserAppPermissions(userId: string) {
|
||||||
const response = await apiRequest<{ permissions?: LauncherUserAppPermission[] }>(`/launcher/users/${userId}/app-permissions`);
|
const response = await apiRequest<{ permissions?: LauncherUserAppPermission[] }>(`/launcher/users/${userId}/app-permissions`);
|
||||||
return response.permissions ?? [];
|
return response.permissions ?? [];
|
||||||
|
|||||||
@@ -4,8 +4,9 @@ import { Badge, Button, Card, 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 { formatDateTime } from '../../lib/date-format';
|
import { formatDateTime } from '../../lib/date-format';
|
||||||
import { getStoredSelectedAppId, setStoredSelectedAppId } from '../../lib/selected-app';
|
import { getStoredSelectedAppId, getStoredSelectedComponentId, selectedComponentChangedEventName, setStoredSelectedAppId, setStoredSelectedComponentId } from '../../lib/selected-app';
|
||||||
import type { ApiApp, RuntimeEnvProfile, RuntimeResource } from '../../types/runtime';
|
import { statusDotToneClass } from '../../lib/status-dot';
|
||||||
|
import type { ApiApp, ApiAppComponent, RuntimeEnvProfile, RuntimeEnvVariable, RuntimeIngressRule, RuntimeResource } from '../../types/runtime';
|
||||||
import { LoadingModal, useMinimumLoading } from '../operations/operation-utils';
|
import { LoadingModal, useMinimumLoading } from '../operations/operation-utils';
|
||||||
import {
|
import {
|
||||||
applyRuntimeProfile,
|
applyRuntimeProfile,
|
||||||
@@ -13,6 +14,7 @@ import {
|
|||||||
createRuntimeProfile,
|
createRuntimeProfile,
|
||||||
createRuntimeVariable,
|
createRuntimeVariable,
|
||||||
deleteRuntimeIngressRule,
|
deleteRuntimeIngressRule,
|
||||||
|
deleteRuntimeProfile,
|
||||||
deleteRuntimeVariable,
|
deleteRuntimeVariable,
|
||||||
loadApps,
|
loadApps,
|
||||||
loadRuntimeProfiles,
|
loadRuntimeProfiles,
|
||||||
@@ -40,13 +42,6 @@ function getWorkloadKind(kind: string) {
|
|||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
function statusDot(status?: string) {
|
|
||||||
if (['Running', 'Bound', 'ClusterIP', 'LoadBalancer'].includes(status ?? '')) return 'bg-emerald-400';
|
|
||||||
if (['Pending', 'ContainerCreating'].includes(status ?? '')) return 'bg-amber-400';
|
|
||||||
if (['Error', 'CrashLoopBackOff', 'ImagePullBackOff', 'Failed'].includes(status ?? '')) return 'bg-rose-400';
|
|
||||||
return 'bg-muted-foreground';
|
|
||||||
}
|
|
||||||
|
|
||||||
function isStopped(resource: RuntimeResource) {
|
function isStopped(resource: RuntimeResource) {
|
||||||
return resource.ready.endsWith('/0');
|
return resource.ready.endsWith('/0');
|
||||||
}
|
}
|
||||||
@@ -67,6 +62,13 @@ function runtimeStatusTone(resource: RuntimeResource, stopped: boolean): 'neutra
|
|||||||
return 'blue';
|
return 'blue';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function preferredComponentId(appId: string, components: ApiAppComponent[]) {
|
||||||
|
const storedComponentId = getStoredSelectedComponentId(appId);
|
||||||
|
return components.some((component) => component.id === storedComponentId)
|
||||||
|
? storedComponentId
|
||||||
|
: components[0]?.id ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
export function RuntimeConfigPage() {
|
export function RuntimeConfigPage() {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const [apps, setApps] = useState<ApiApp[]>([]);
|
const [apps, setApps] = useState<ApiApp[]>([]);
|
||||||
@@ -79,6 +81,9 @@ export function RuntimeConfigPage() {
|
|||||||
const [actionKey, setActionKey] = useState('');
|
const [actionKey, setActionKey] = useState('');
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [profileModalOpen, setProfileModalOpen] = useState(false);
|
const [profileModalOpen, setProfileModalOpen] = useState(false);
|
||||||
|
const [deleteProfileTarget, setDeleteProfileTarget] = useState<RuntimeEnvProfile | null>(null);
|
||||||
|
const [deleteVariableTarget, setDeleteVariableTarget] = useState<RuntimeEnvVariable | null>(null);
|
||||||
|
const [deleteIngressTarget, setDeleteIngressTarget] = useState<RuntimeIngressRule | null>(null);
|
||||||
const loadingVisible = useMinimumLoading(loading);
|
const loadingVisible = useMinimumLoading(loading);
|
||||||
|
|
||||||
const [profileName, setProfileName] = useState('');
|
const [profileName, setProfileName] = useState('');
|
||||||
@@ -113,6 +118,7 @@ export function RuntimeConfigPage() {
|
|||||||
profile.environment === profileEnvironment &&
|
profile.environment === profileEnvironment &&
|
||||||
profile.namespace.trim() === profileNamespace.trim()
|
profile.namespace.trim() === profileNamespace.trim()
|
||||||
));
|
));
|
||||||
|
const showDuplicateProfileWarning = Boolean(profileName.trim() && duplicateProfile);
|
||||||
|
|
||||||
const refreshResources = (targetNamespace = namespace) => {
|
const refreshResources = (targetNamespace = namespace) => {
|
||||||
if (!targetNamespace.trim()) {
|
if (!targetNamespace.trim()) {
|
||||||
@@ -156,11 +162,13 @@ export function RuntimeConfigPage() {
|
|||||||
const storedAppId = getStoredSelectedAppId();
|
const storedAppId = getStoredSelectedAppId();
|
||||||
const nextAppId = nextApps.some((app) => app.id === storedAppId) ? storedAppId : nextApps[0]?.id ?? '';
|
const nextAppId = nextApps.some((app) => app.id === storedAppId) ? storedAppId : nextApps[0]?.id ?? '';
|
||||||
const nextApp = nextApps.find((app) => app.id === nextAppId);
|
const nextApp = nextApps.find((app) => app.id === nextAppId);
|
||||||
|
const nextComponentId = preferredComponentId(nextAppId, nextApp?.components ?? []);
|
||||||
setApps(nextApps);
|
setApps(nextApps);
|
||||||
setAppId(nextAppId);
|
setAppId(nextAppId);
|
||||||
setProfileNamespace(nextApp?.namespace ?? '');
|
setProfileNamespace(nextApp?.namespace ?? '');
|
||||||
setVariableComponentId(nextApp?.components?.[0]?.id ?? '');
|
setVariableComponentId(nextComponentId);
|
||||||
setIngressComponentId(nextApp?.components?.[0]?.id ?? '');
|
setIngressComponentId(nextComponentId);
|
||||||
|
if (nextComponentId) setStoredSelectedComponentId(nextAppId, nextComponentId);
|
||||||
setIngressPort('');
|
setIngressPort('');
|
||||||
return nextAppId ? loadRuntimeProfiles(nextAppId) : Promise.resolve([]);
|
return nextAppId ? loadRuntimeProfiles(nextAppId) : Promise.resolve([]);
|
||||||
})
|
})
|
||||||
@@ -171,15 +179,38 @@ export function RuntimeConfigPage() {
|
|||||||
|
|
||||||
const changeApp = (nextAppId: string) => {
|
const changeApp = (nextAppId: string) => {
|
||||||
const nextApp = apps.find((app) => app.id === nextAppId);
|
const nextApp = apps.find((app) => app.id === nextAppId);
|
||||||
|
const nextComponentId = preferredComponentId(nextAppId, nextApp?.components ?? []);
|
||||||
setStoredSelectedAppId(nextAppId);
|
setStoredSelectedAppId(nextAppId);
|
||||||
setAppId(nextAppId);
|
setAppId(nextAppId);
|
||||||
setProfileNamespace(nextApp?.namespace ?? '');
|
setProfileNamespace(nextApp?.namespace ?? '');
|
||||||
setVariableComponentId(nextApp?.components?.[0]?.id ?? '');
|
setVariableComponentId(nextComponentId);
|
||||||
setIngressComponentId(nextApp?.components?.[0]?.id ?? '');
|
setIngressComponentId(nextComponentId);
|
||||||
|
if (nextComponentId) setStoredSelectedComponentId(nextAppId, nextComponentId);
|
||||||
setIngressPort('');
|
setIngressPort('');
|
||||||
refreshProfiles(nextAppId).catch((loadError: Error) => setError(loadError.message));
|
refreshProfiles(nextAppId).catch((loadError: Error) => setError(loadError.message));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const storedComponentId = preferredComponentId(appId, components);
|
||||||
|
if (!storedComponentId) return;
|
||||||
|
if (variableComponentId !== storedComponentId) setVariableComponentId(storedComponentId);
|
||||||
|
if (ingressComponentId !== storedComponentId) setIngressComponentId(storedComponentId);
|
||||||
|
}, [appId, components, ingressComponentId, variableComponentId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleSelectedComponentChange = (event: Event) => {
|
||||||
|
const detail = (event as CustomEvent<{ appId?: string; componentId?: string }>).detail;
|
||||||
|
if (detail?.appId !== appId || !detail.componentId) return;
|
||||||
|
if (!components.some((component) => component.id === detail.componentId)) return;
|
||||||
|
setVariableComponentId(detail.componentId);
|
||||||
|
setIngressComponentId(detail.componentId);
|
||||||
|
setIngressPort('');
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener(selectedComponentChangedEventName, handleSelectedComponentChange);
|
||||||
|
return () => window.removeEventListener(selectedComponentChangedEventName, handleSelectedComponentChange);
|
||||||
|
}, [appId, components]);
|
||||||
|
|
||||||
const addProfile = () => {
|
const addProfile = () => {
|
||||||
if (!appId || !profileName.trim() || !profileNamespace.trim()) return;
|
if (!appId || !profileName.trim() || !profileNamespace.trim()) return;
|
||||||
if (duplicateProfile) {
|
if (duplicateProfile) {
|
||||||
@@ -207,6 +238,23 @@ export function RuntimeConfigPage() {
|
|||||||
.catch((createError: Error) => setError(createError.message));
|
.catch((createError: Error) => setError(createError.message));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const removeProfile = () => {
|
||||||
|
if (!deleteProfileTarget) return;
|
||||||
|
const target = deleteProfileTarget;
|
||||||
|
setActionKey(`profile-delete-${target.id}`);
|
||||||
|
deleteRuntimeProfile(target.id)
|
||||||
|
.then(() => {
|
||||||
|
toast({ type: 'success', title: '프로필 삭제', message: `${target.name} 프로필을 삭제했습니다.` });
|
||||||
|
setDeleteProfileTarget(null);
|
||||||
|
return refreshProfiles(appId);
|
||||||
|
})
|
||||||
|
.catch((deleteError: Error) => {
|
||||||
|
setError(deleteError.message);
|
||||||
|
toast({ type: 'error', title: '프로필 삭제 실패', message: deleteError.message });
|
||||||
|
})
|
||||||
|
.finally(() => setActionKey(''));
|
||||||
|
};
|
||||||
|
|
||||||
const addVariable = () => {
|
const addVariable = () => {
|
||||||
if (!selectedProfile || !variableComponentId || !variableKey.trim() || !variableValue.trim()) return;
|
if (!selectedProfile || !variableComponentId || !variableKey.trim() || !variableValue.trim()) return;
|
||||||
const profile = selectedProfile;
|
const profile = selectedProfile;
|
||||||
@@ -339,6 +387,13 @@ export function RuntimeConfigPage() {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const openProfileModal = () => {
|
||||||
|
setProfileName('');
|
||||||
|
setProfileEnvironment('dev');
|
||||||
|
setProfileNamespace(selectedApp?.namespace ?? '');
|
||||||
|
setProfileModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageTitle
|
<PageTitle
|
||||||
@@ -346,7 +401,7 @@ export function RuntimeConfigPage() {
|
|||||||
title="실행 설정"
|
title="실행 설정"
|
||||||
description="앱 구성요소별 환경변수와 시크릿을 프로필로 묶어 배포 환경에 적용합니다."
|
description="앱 구성요소별 환경변수와 시크릿을 프로필로 묶어 배포 환경에 적용합니다."
|
||||||
actions={(
|
actions={(
|
||||||
<Button className="h-8 px-3 text-[13px]" onClick={() => setProfileModalOpen(true)} disabled={!appId}>
|
<Button className="h-8 px-3 text-[13px]" onClick={openProfileModal} disabled={!appId}>
|
||||||
<Plus className="size-4" />
|
<Plus className="size-4" />
|
||||||
프로필 생성
|
프로필 생성
|
||||||
</Button>
|
</Button>
|
||||||
@@ -386,15 +441,15 @@ export function RuntimeConfigPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
<Card className="p-5">
|
<Card className="p-5">
|
||||||
<p className="text-sm text-muted-foreground">구성요소</p>
|
<p className="text-sm text-muted-foreground">구성요소</p>
|
||||||
<p className="mt-4 text-2xl font-medium text-foreground">{components.length}개</p>
|
<p className="mt-4 text-2xl font-medium text-foreground">{components.length}</p>
|
||||||
</Card>
|
</Card>
|
||||||
<Card className="p-5">
|
<Card className="p-5">
|
||||||
<p className="text-sm text-muted-foreground">환경변수</p>
|
<p className="text-sm text-muted-foreground">환경변수</p>
|
||||||
<p className="mt-4 text-2xl font-medium text-foreground">{envCount}개</p>
|
<p className="mt-4 text-2xl font-medium text-foreground">{envCount}</p>
|
||||||
</Card>
|
</Card>
|
||||||
<Card className="p-5">
|
<Card className="p-5">
|
||||||
<p className="text-sm text-muted-foreground">시크릿</p>
|
<p className="text-sm text-muted-foreground">시크릿</p>
|
||||||
<p className="mt-4 text-2xl font-medium text-foreground">{secretCount}개</p>
|
<p className="mt-4 text-2xl font-medium text-foreground">{secretCount}</p>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -447,6 +502,19 @@ export function RuntimeConfigPage() {
|
|||||||
<span>수정 <b className="text-foreground">{formatDate(profile.updatedAt ?? profile.createdAt)}</b></span>
|
<span>수정 <b className="text-foreground">{formatDate(profile.updatedAt ?? profile.createdAt)}</b></span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="size-8 shrink-0 self-end px-0 text-rose-300 hover:text-rose-200 lg:self-center"
|
||||||
|
title="프로필 삭제"
|
||||||
|
aria-label={`${profile.name} 프로필 삭제`}
|
||||||
|
disabled={Boolean(actionKey)}
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
setDeleteProfileTarget(profile);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 className="size-4" />
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -466,9 +534,8 @@ export function RuntimeConfigPage() {
|
|||||||
<table className="w-full table-fixed text-left text-sm">
|
<table className="w-full table-fixed text-left text-sm">
|
||||||
<colgroup>
|
<colgroup>
|
||||||
<col className="w-[22%]" />
|
<col className="w-[22%]" />
|
||||||
<col className="w-[29%]" />
|
<col className="w-[30%]" />
|
||||||
<col className="w-[19%]" />
|
<col />
|
||||||
<col className="w-[24%]" />
|
|
||||||
<col className="w-20" />
|
<col className="w-20" />
|
||||||
</colgroup>
|
</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">
|
||||||
@@ -476,27 +543,25 @@ export function RuntimeConfigPage() {
|
|||||||
<th className="px-5 py-3 font-medium">구성요소</th>
|
<th className="px-5 py-3 font-medium">구성요소</th>
|
||||||
<th className="px-5 py-3 font-medium">Key</th>
|
<th className="px-5 py-3 font-medium">Key</th>
|
||||||
<th className="px-5 py-3 font-medium">Value</th>
|
<th className="px-5 py-3 font-medium">Value</th>
|
||||||
<th className="px-5 py-3 font-medium">등록일</th>
|
|
||||||
<th className="w-16 px-5 py-3 text-right font-medium">관리</th>
|
<th className="w-16 px-5 py-3 text-right font-medium">관리</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-border">
|
<tbody className="divide-y divide-border">
|
||||||
{variables.length === 0 ? (
|
{variables.length === 0 ? (
|
||||||
<tr><td colSpan={5} className="px-5 py-14 text-center text-muted-foreground">등록된 설정값이 없습니다.</td></tr>
|
<tr><td colSpan={4} className="px-5 py-14 text-center text-muted-foreground">등록된 설정값이 없습니다.</td></tr>
|
||||||
) : 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-4 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={`block size-2.5 shrink-0 rounded-full ${variable.isSecret ? statusDotToneClass('amber') : statusDotToneClass('green')}`} />
|
||||||
<span className="truncate">{variable.component?.name ?? '-'}</span>
|
<span className="truncate">{variable.component?.name ?? '-'}</span>
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="break-all px-4 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="break-all px-4 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-4 py-3 text-xs text-muted-foreground">{formatDate(variable.createdAt)}</td>
|
|
||||||
<td className="px-3 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={() => setDeleteVariableTarget(variable)} disabled={Boolean(actionKey)}>
|
||||||
<Trash2 className="size-4" />
|
<Trash2 className="size-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</td>
|
</td>
|
||||||
@@ -517,7 +582,15 @@ export function RuntimeConfigPage() {
|
|||||||
</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>
|
||||||
<select className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring" value={variableComponentId} onChange={(event) => setVariableComponentId(event.target.value)}>
|
<select
|
||||||
|
className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring"
|
||||||
|
value={variableComponentId}
|
||||||
|
onChange={(event) => {
|
||||||
|
const nextId = event.target.value;
|
||||||
|
setVariableComponentId(nextId);
|
||||||
|
setStoredSelectedComponentId(appId, nextId);
|
||||||
|
}}
|
||||||
|
>
|
||||||
{components.map((component) => <option key={component.id} value={component.id}>{component.name}</option>)}
|
{components.map((component) => <option key={component.id} value={component.id}>{component.name}</option>)}
|
||||||
{components.length === 0 ? <option value="">구성요소 없음</option> : null}
|
{components.length === 0 ? <option value="">구성요소 없음</option> : null}
|
||||||
</select>
|
</select>
|
||||||
@@ -587,7 +660,7 @@ export function RuntimeConfigPage() {
|
|||||||
<tr key={rule.id} className="hover:bg-muted/20">
|
<tr key={rule.id} className="hover:bg-muted/20">
|
||||||
<td className="px-4 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 shrink-0 rounded-full ${statusDotToneClass('blue')}`} />
|
||||||
<span className="truncate">{rule.component?.name ?? '-'}</span>
|
<span className="truncate">{rule.component?.name ?? '-'}</span>
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
@@ -596,7 +669,7 @@ export function RuntimeConfigPage() {
|
|||||||
<td className="px-4 py-3 text-muted-foreground">{rule.servicePort}</td>
|
<td className="px-4 py-3 text-muted-foreground">{rule.servicePort}</td>
|
||||||
<td className="px-4 py-3 text-muted-foreground">{rule.stripPrefix ? '제거' : '유지'}</td>
|
<td className="px-4 py-3 text-muted-foreground">{rule.stripPrefix ? '제거' : '유지'}</td>
|
||||||
<td className="px-3 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={() => setDeleteIngressTarget(rule)} disabled={Boolean(actionKey)}>
|
||||||
<Trash2 className="size-4" />
|
<Trash2 className="size-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</td>
|
</td>
|
||||||
@@ -619,6 +692,7 @@ export function RuntimeConfigPage() {
|
|||||||
onChange={(event) => {
|
onChange={(event) => {
|
||||||
const nextId = event.target.value;
|
const nextId = event.target.value;
|
||||||
setIngressComponentId(nextId);
|
setIngressComponentId(nextId);
|
||||||
|
setStoredSelectedComponentId(appId, nextId);
|
||||||
setIngressPort('');
|
setIngressPort('');
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -665,24 +739,24 @@ export function RuntimeConfigPage() {
|
|||||||
footer={(
|
footer={(
|
||||||
<>
|
<>
|
||||||
<Button variant="outline" onClick={() => setProfileModalOpen(false)}>취소</Button>
|
<Button variant="outline" onClick={() => setProfileModalOpen(false)}>취소</Button>
|
||||||
<Button disabled={!profileName.trim() || !profileNamespace.trim() || Boolean(duplicateProfile)} onClick={addProfile}>생성</Button>
|
<Button disabled={!profileName.trim() || !profileNamespace.trim() || showDuplicateProfileWarning} onClick={addProfile}>생성</Button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<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">프로필명 <b className="text-foreground">*</b></span>
|
||||||
<input className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none placeholder:text-muted-foreground focus:ring-2 focus:ring-ring" value={profileName} onChange={(event) => setProfileName(event.target.value)} placeholder="예: LMS개발" />
|
<input className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none placeholder:text-muted-foreground focus:ring-2 focus:ring-ring" value={profileName} onChange={(event) => setProfileName(event.target.value)} placeholder="예: LMS개발" />
|
||||||
</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">환경 <b className="text-foreground">*</b></span>
|
||||||
<select className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring" value={profileEnvironment} onChange={(event) => setProfileEnvironment(event.target.value)}>
|
<select className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring" value={profileEnvironment} onChange={(event) => setProfileEnvironment(event.target.value)}>
|
||||||
<option value="dev">개발</option>
|
<option value="dev">개발</option>
|
||||||
<option value="prod">운영</option>
|
<option value="prod">운영</option>
|
||||||
</select>
|
</select>
|
||||||
</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">네임스페이스 <b className="text-foreground">*</b></span>
|
||||||
<input
|
<input
|
||||||
list="runtime-namespace-options"
|
list="runtime-namespace-options"
|
||||||
className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none placeholder:text-muted-foreground focus:ring-2 focus:ring-ring"
|
className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none placeholder:text-muted-foreground focus:ring-2 focus:ring-ring"
|
||||||
@@ -694,13 +768,100 @@ export function RuntimeConfigPage() {
|
|||||||
{namespaceOptions.map((item) => <option key={item} value={item} />)}
|
{namespaceOptions.map((item) => <option key={item} value={item} />)}
|
||||||
</datalist>
|
</datalist>
|
||||||
</label>
|
</label>
|
||||||
{duplicateProfile ? (
|
{showDuplicateProfileWarning ? (
|
||||||
<p className="rounded-md border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-xs text-amber-200">
|
<p className="rounded-md border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-xs text-amber-200">
|
||||||
이미 같은 환경과 네임스페이스의 프로필이 있습니다. 기존 프로필을 선택해 설정값을 수정하세요.
|
이미 같은 환경과 네임스페이스의 프로필이 있습니다. 기존 프로필을 선택해 설정값을 수정하세요.
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
<Modal
|
||||||
|
open={Boolean(deleteProfileTarget)}
|
||||||
|
title="프로필 삭제"
|
||||||
|
onClose={() => {
|
||||||
|
if (!actionKey) setDeleteProfileTarget(null);
|
||||||
|
}}
|
||||||
|
footer={(
|
||||||
|
<>
|
||||||
|
<Button variant="outline" disabled={Boolean(actionKey)} onClick={() => setDeleteProfileTarget(null)}>취소</Button>
|
||||||
|
<Button className="bg-rose-600 text-white hover:bg-rose-500" disabled={Boolean(actionKey)} onClick={removeProfile}>
|
||||||
|
{actionKey.startsWith('profile-delete-') ? '삭제 중' : '삭제'}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="space-y-3 text-sm">
|
||||||
|
<p>
|
||||||
|
<span className="font-semibold text-foreground">{deleteProfileTarget?.name ?? '-'}</span>
|
||||||
|
{' '}프로필을 삭제합니다.
|
||||||
|
</p>
|
||||||
|
<p className="text-muted-foreground">프로필에 등록된 설정값과 접속 설정도 함께 삭제됩니다.</p>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
<Modal
|
||||||
|
open={Boolean(deleteVariableTarget)}
|
||||||
|
title="설정값 삭제"
|
||||||
|
onClose={() => {
|
||||||
|
if (!actionKey) setDeleteVariableTarget(null);
|
||||||
|
}}
|
||||||
|
footer={(
|
||||||
|
<>
|
||||||
|
<Button variant="outline" disabled={Boolean(actionKey)} onClick={() => setDeleteVariableTarget(null)}>취소</Button>
|
||||||
|
<Button
|
||||||
|
className="bg-rose-600 text-white hover:bg-rose-500"
|
||||||
|
disabled={Boolean(actionKey)}
|
||||||
|
onClick={() => {
|
||||||
|
if (!deleteVariableTarget) return;
|
||||||
|
removeVariable(deleteVariableTarget.id);
|
||||||
|
setDeleteVariableTarget(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
삭제
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="space-y-3 text-sm">
|
||||||
|
<p>
|
||||||
|
<span className="font-semibold text-foreground">{deleteVariableTarget?.key ?? '-'}</span>
|
||||||
|
{' '}설정값을 삭제합니다.
|
||||||
|
</p>
|
||||||
|
<p className="text-muted-foreground">삭제 후 클러스터 설정에 바로 반영됩니다.</p>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
<Modal
|
||||||
|
open={Boolean(deleteIngressTarget)}
|
||||||
|
title="접속 설정 삭제"
|
||||||
|
onClose={() => {
|
||||||
|
if (!actionKey) setDeleteIngressTarget(null);
|
||||||
|
}}
|
||||||
|
footer={(
|
||||||
|
<>
|
||||||
|
<Button variant="outline" disabled={Boolean(actionKey)} onClick={() => setDeleteIngressTarget(null)}>취소</Button>
|
||||||
|
<Button
|
||||||
|
className="bg-rose-600 text-white hover:bg-rose-500"
|
||||||
|
disabled={Boolean(actionKey)}
|
||||||
|
onClick={() => {
|
||||||
|
if (!deleteIngressTarget) return;
|
||||||
|
removeIngressRule(deleteIngressTarget.id);
|
||||||
|
setDeleteIngressTarget(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
삭제
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="space-y-3 text-sm">
|
||||||
|
<p>
|
||||||
|
<span className="font-semibold text-foreground">{deleteIngressTarget?.host ?? '-'}</span>
|
||||||
|
{' '}
|
||||||
|
<span className="font-semibold text-foreground">{deleteIngressTarget?.path ?? ''}</span>
|
||||||
|
{' '}접속 설정을 삭제합니다.
|
||||||
|
</p>
|
||||||
|
<p className="text-muted-foreground">삭제 후 클러스터 설정에 바로 반영됩니다.</p>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
<LoadingModal open={loadingVisible} />
|
<LoadingModal open={loadingVisible} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,86 +1,191 @@
|
|||||||
import { Activity, MoreHorizontal, Search, SlidersHorizontal } from 'lucide-react';
|
import { MoreHorizontal, RotateCcw } from 'lucide-react';
|
||||||
import { Badge, Button, Card, CardHeader } from '../../components/ui';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
import { DataTableCard, type DataTableColumn } from '../../components/data-table';
|
||||||
|
import { Badge, Button, Modal } from '../../components/ui';
|
||||||
|
import { apiRequest } from '../../lib/api';
|
||||||
import { PageTitle } from '../../layout/AppLayout';
|
import { PageTitle } from '../../layout/AppLayout';
|
||||||
import { agentDevices } from '../../data/mock';
|
import { loadPlatformDevices, type PlatformDevice } from '../agents/platform-api';
|
||||||
|
|
||||||
function getTone(status: string) {
|
type HeadscaleNode = {
|
||||||
if (['정상', '활성', '성공', '운영', 'Running'].includes(status)) return 'green';
|
id: string;
|
||||||
if (['주의', '예약됨', '대기', '준비중', '카나리', 'Pending'].includes(status)) return 'amber';
|
name: string;
|
||||||
if (['오류', '실패', '비활성', '만료', 'Error', 'Failed'].includes(status)) return 'red';
|
ip: string;
|
||||||
if (['검수', '동기화'].includes(status)) return 'blue';
|
ips?: string[];
|
||||||
return 'neutral';
|
role: string;
|
||||||
|
user: string;
|
||||||
|
routes: string;
|
||||||
|
lastSeen: string | null;
|
||||||
|
status: string;
|
||||||
|
online?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type AclRule = { id: string; action: string; sources: string[]; destinations: string[] };
|
||||||
|
type PlatformRow = HeadscaleNode & { device?: PlatformDevice; controllers: string[]; no: number };
|
||||||
|
|
||||||
|
type DeviceKind = 'agent' | 'launcher' | 'unknown';
|
||||||
|
|
||||||
|
function normalize(value?: string | null) {
|
||||||
|
return (value ?? '').toLowerCase().replace(/[^a-z0-9]/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function findDevice(node: HeadscaleNode, devices: PlatformDevice[]) {
|
||||||
|
const nodeValues = [node.name, node.user].map(normalize).filter(Boolean);
|
||||||
|
return devices.find((device) => {
|
||||||
|
const deviceValues = [device.hostname, device.tailscaleHostname, device.accountEmail].map(normalize).filter(Boolean);
|
||||||
|
return nodeValues.some((nodeValue) => deviceValues.some((deviceValue) => nodeValue.includes(deviceValue) || deviceValue.includes(nodeValue)));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function findControllers(node: HeadscaleNode, rules: AclRule[]) {
|
||||||
|
const destinations = new Set([node.ip, ...(node.ips ?? [])].filter(Boolean).map((ip) => `${ip}:*`));
|
||||||
|
return [...new Set(rules
|
||||||
|
.filter((rule) => rule.action === 'accept' && rule.destinations.some((destination) => destinations.has(destination)))
|
||||||
|
.flatMap((rule) => rule.sources))];
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDeviceKind(row: PlatformRow): DeviceKind {
|
||||||
|
if (!row.device) return 'unknown';
|
||||||
|
return row.device.agentVersion || row.device.agentLastSeenAt ? 'agent' : 'launcher';
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateTime(value?: string | null) {
|
||||||
|
if (!value) return '-';
|
||||||
|
const date = new Date(value);
|
||||||
|
return Number.isNaN(date.getTime()) ? '-' : date.toLocaleString('ko-KR', { hour12: false });
|
||||||
}
|
}
|
||||||
|
|
||||||
export function RuntimeDeviceControlPage() {
|
export function RuntimeDeviceControlPage() {
|
||||||
|
const [rows, setRows] = useState<PlatformRow[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [selected, setSelected] = useState<PlatformRow | null>(null);
|
||||||
|
|
||||||
|
const refresh = useCallback(() => {
|
||||||
|
setLoading(true);
|
||||||
|
Promise.all([
|
||||||
|
apiRequest<{ nodes?: HeadscaleNode[] }>('/tailscale/nodes'),
|
||||||
|
apiRequest<{ policy?: { acls?: AclRule[] } }>('/tailscale/acl'),
|
||||||
|
loadPlatformDevices(),
|
||||||
|
])
|
||||||
|
.then(([nodeResponse, aclResponse, devices]) => {
|
||||||
|
const rules = aclResponse.policy?.acls ?? [];
|
||||||
|
const nodes = nodeResponse.nodes ?? [];
|
||||||
|
setRows(nodes.map((node, index) => ({
|
||||||
|
...node,
|
||||||
|
no: nodes.length - index,
|
||||||
|
device: findDevice(node, devices),
|
||||||
|
controllers: findControllers(node, rules),
|
||||||
|
})));
|
||||||
|
})
|
||||||
|
.catch(() => setRows([]))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void refresh();
|
||||||
|
}, [refresh]);
|
||||||
|
|
||||||
|
const columns = useMemo<Array<DataTableColumn<PlatformRow>>>(
|
||||||
|
() => [
|
||||||
|
{ key: 'no', header: 'No.', className: 'w-14 px-3 py-3 text-center font-medium', cell: (row) => <span>{row.no}</span> },
|
||||||
|
{
|
||||||
|
key: 'platform',
|
||||||
|
header: '플랫폼',
|
||||||
|
flexible: true,
|
||||||
|
cell: (row) => (
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="truncate font-semibold" title={row.name}>{row.name}</p>
|
||||||
|
<p className="mt-1 truncate text-xs text-muted-foreground" title={row.user}>{row.user || '-'}</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'connection',
|
||||||
|
header: '연결 대상',
|
||||||
|
width: 180,
|
||||||
|
cell: (row) => <span className="font-mono text-xs text-muted-foreground">{row.ip || '-'}</span>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'deviceKind',
|
||||||
|
header: '유형',
|
||||||
|
width: 150,
|
||||||
|
cell: (row) => {
|
||||||
|
const kind = getDeviceKind(row);
|
||||||
|
if (kind === 'agent') {
|
||||||
|
return <Badge tone="green">에이전트</Badge>;
|
||||||
|
}
|
||||||
|
if (kind === 'launcher') return <Badge tone="neutral">런처 클라이언트</Badge>;
|
||||||
|
return <Badge tone="neutral">미확인</Badge>;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'controllers',
|
||||||
|
header: '제어 사용자',
|
||||||
|
width: 280,
|
||||||
|
cell: (row) => (
|
||||||
|
<span className="block truncate text-muted-foreground" title={row.controllers.length ? row.controllers.join(', ') : row.user || '-'}>
|
||||||
|
{row.controllers.length ? row.controllers.join(', ') : row.user || '-'}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{ key: 'routes', header: '연결 경로', width: 190, cell: (row) => <span className="block truncate text-muted-foreground" title={row.routes || '-'}>{row.routes || '-'}</span> },
|
||||||
|
{ key: 'status', header: '상태', width: 110, cell: (row) => <Badge tone={row.online || row.status === '활성' ? 'green' : 'neutral'}>{row.online || row.status === '활성' ? '연결됨' : '오프라인'}</Badge> },
|
||||||
|
{
|
||||||
|
key: 'actions',
|
||||||
|
header: '',
|
||||||
|
className: 'w-20 px-5 py-3 text-right font-medium',
|
||||||
|
cell: (row) => (
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button variant="ghost" className="size-8 px-0" title="상세" aria-label="상세" onClick={() => setSelected(row)}>
|
||||||
|
<MoreHorizontal className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageTitle eyebrow="Agent" title="장비 제어" description="운영 장비의 런타임 시작, 중지, 재시작, 상태 조회 명령을 관리합니다." />
|
<PageTitle
|
||||||
<Card className="min-w-0 overflow-hidden">
|
eyebrow="플랫폼 관리"
|
||||||
<CardHeader
|
title="연결 제어"
|
||||||
title="장비 목록"
|
description="Headscale 노드 연결 상태와 ACL 기반 제어 권한을 확인합니다."
|
||||||
description="Agent가 연결된 장비를 목록으로 확인하고 필요한 제어 명령을 실행합니다."
|
actions={
|
||||||
action={
|
<Button variant="outline" className="h-8 px-3 text-[13px]" onClick={refresh} disabled={loading}>
|
||||||
<div className="flex gap-2">
|
<RotateCcw className={loading ? 'size-4 animate-spin' : 'size-4'} />
|
||||||
<Button variant="outline" className="h-8 px-3 text-[13px]">
|
새로고침
|
||||||
<SlidersHorizontal className="size-4" />
|
</Button>
|
||||||
상태 필터
|
}
|
||||||
</Button>
|
/>
|
||||||
<Button className="h-8 px-3 text-[13px]">
|
<DataTableCard
|
||||||
<Activity className="size-4" />
|
title="연결 플랫폼"
|
||||||
상태 갱신
|
description="Headscale에 등록된 노드와 해당 노드에 적용된 ACL 제어 사용자를 표시합니다."
|
||||||
</Button>
|
rows={rows}
|
||||||
</div>
|
columns={columns}
|
||||||
}
|
searchPlaceholder="플랫폼, 사용자, IP, ACL 검색"
|
||||||
/>
|
getSearchText={(row) => `${row.name} ${row.user} ${row.ip} ${row.controllers.join(' ')} ${row.routes}`}
|
||||||
<div className="border-b border-border px-5 pb-4">
|
getRowKey={(row) => row.id || row.name}
|
||||||
<div className="relative max-w-md">
|
pageSize={6}
|
||||||
<Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
minWidth={1100}
|
||||||
<input
|
loading={loading}
|
||||||
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"
|
emptyMessage="Headscale에 연결된 플랫폼이 없습니다."
|
||||||
placeholder="장비명, 계정, 워크스페이스 검색"
|
/>
|
||||||
/>
|
<Modal open={Boolean(selected)} title="플랫폼 연결 상세" onClose={() => setSelected(null)}>
|
||||||
</div>
|
<dl className="grid grid-cols-[140px_minmax(0,1fr)] gap-x-4 gap-y-4 text-sm">
|
||||||
</div>
|
<dt className="text-muted-foreground">플랫폼</dt><dd className="min-w-0 break-all font-medium">{selected?.name}</dd>
|
||||||
<div className="max-h-[620px] overflow-x-auto overflow-y-auto">
|
<dt className="text-muted-foreground">Headscale 사용자</dt><dd className="min-w-0 break-all">{selected?.user || '-'}</dd>
|
||||||
<table className="w-full min-w-[1040px] text-left text-sm">
|
<dt className="text-muted-foreground">연결 IP</dt><dd className="font-mono text-xs">{selected?.ips?.join(', ') || selected?.ip || '-'}</dd>
|
||||||
<thead className="sticky top-0 z-10 border-b border-border bg-card text-xs text-muted-foreground">
|
<dt className="text-muted-foreground">ACL 제어 사용자</dt><dd className="min-w-0 break-all">{selected?.controllers.length ? selected.controllers.join(', ') : '개별 ACL 규칙 없음'}</dd>
|
||||||
<tr>
|
<dt className="text-muted-foreground">연결 경로</dt><dd className="min-w-0 break-all">{selected?.routes || '-'}</dd>
|
||||||
<th className="px-5 py-3 font-medium">장비</th>
|
<dt className="text-muted-foreground">Workspace</dt><dd className="min-w-0 break-all">{selected?.device?.workspaceRoot || '-'}</dd>
|
||||||
<th className="px-5 py-3 font-medium">계정</th>
|
<dt className="text-muted-foreground">SSH 사용자</dt><dd>{selected?.device?.sshUser || '-'}</dd>
|
||||||
<th className="px-5 py-3 font-medium">Workspace</th>
|
<dt className="text-muted-foreground">사용 유형</dt><dd>{selected?.device?.usageType || '-'}</dd>
|
||||||
<th className="px-5 py-3 font-medium">SSH 키</th>
|
<dt className="text-muted-foreground">장비 유형</dt><dd>{selected ? ({ agent: '에이전트', launcher: '런처 클라이언트', unknown: '미확인' }[getDeviceKind(selected)]) : '-'}</dd>
|
||||||
<th className="px-5 py-3 font-medium">Last Seen</th>
|
<dt className="text-muted-foreground">에이전트 버전</dt><dd>{selected?.device?.agentVersion || '-'}</dd>
|
||||||
<th className="px-5 py-3 font-medium">상태</th>
|
<dt className="text-muted-foreground">마지막 에이전트 확인</dt><dd>{formatDateTime(selected?.device?.agentLastSeenAt)}</dd>
|
||||||
<th className="px-5 py-3 text-right font-medium">명령</th>
|
</dl>
|
||||||
</tr>
|
</Modal>
|
||||||
</thead>
|
|
||||||
<tbody className="divide-y divide-border">
|
|
||||||
{agentDevices.map((device) => (
|
|
||||||
<tr key={device.name} className="hover:bg-muted/20">
|
|
||||||
<td className="px-5 py-3 font-medium">{device.name}</td>
|
|
||||||
<td className="px-5 py-3 text-muted-foreground">{device.account}</td>
|
|
||||||
<td className="px-5 py-3 text-muted-foreground">{device.workspace}</td>
|
|
||||||
<td className="px-5 py-3">{device.sshKey}</td>
|
|
||||||
<td className="px-5 py-3 text-muted-foreground">{device.lastSeen}</td>
|
|
||||||
<td className="px-5 py-3">
|
|
||||||
<Badge tone={getTone(device.status)}>{device.status}</Badge>
|
|
||||||
</td>
|
|
||||||
<td className="px-5 py-3">
|
|
||||||
<div className="flex justify-end gap-2">
|
|
||||||
<Button variant="outline" className="h-8 px-3 text-[13px]">시작</Button>
|
|
||||||
<Button variant="outline" className="h-8 px-3 text-[13px]">중지</Button>
|
|
||||||
<Button variant="outline" className="h-8 px-3 text-[13px]">재시작</Button>
|
|
||||||
<Button variant="ghost" className="size-8 px-0">
|
|
||||||
<MoreHorizontal className="size-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,24 +4,37 @@ import { Badge, Button, Card } from '../../components/ui';
|
|||||||
import { PageTitle } from '../../layout/AppLayout';
|
import { PageTitle } from '../../layout/AppLayout';
|
||||||
import { formatDateTime } from '../../lib/date-format';
|
import { formatDateTime } from '../../lib/date-format';
|
||||||
import { getStoredSelectedAppId, setStoredSelectedAppId } from '../../lib/selected-app';
|
import { getStoredSelectedAppId, setStoredSelectedAppId } from '../../lib/selected-app';
|
||||||
|
import { runtimeStatusDotClass, statusDotToneClass } from '../../lib/status-dot';
|
||||||
import { cn } from '../../lib/utils';
|
import { cn } from '../../lib/utils';
|
||||||
import type { ApiApp, RuntimeEvent, RuntimePodMetric, RuntimeResource, RuntimeResourceResponse } from '../../types/runtime';
|
import type { ApiApp, RuntimeEvent, RuntimePodMetric, RuntimeResource, RuntimeResourceResponse } from '../../types/runtime';
|
||||||
import { LoadingModal, useMinimumLoading } from '../operations/operation-utils';
|
import { LoadingModal, useMinimumLoading } from '../operations/operation-utils';
|
||||||
import { loadApps, loadRuntimeEvents, loadRuntimePodMetrics, loadRuntimeResources, loadRuntimeWorkloadLogs } from './runtime-api';
|
import { loadApps, loadRuntimeEvents, loadRuntimePodMetrics, loadRuntimeResources, loadRuntimeWorkloadLogs } from './runtime-api';
|
||||||
|
|
||||||
function statusDot(status?: string) {
|
|
||||||
if (['Running', 'Bound', 'ClusterIP', 'LoadBalancer', 'Normal'].includes(status ?? '')) return 'bg-emerald-400';
|
|
||||||
if (['Pending', 'ContainerCreating', 'Warning'].includes(status ?? '')) return 'bg-amber-400';
|
|
||||||
if (['Error', 'CrashLoopBackOff', 'ImagePullBackOff', 'Failed'].includes(status ?? '')) return 'bg-rose-400';
|
|
||||||
return 'bg-muted-foreground';
|
|
||||||
}
|
|
||||||
|
|
||||||
function eventTone(type?: string) {
|
function eventTone(type?: string) {
|
||||||
if (type === 'Normal') return 'green';
|
if (type === 'Normal') return 'green';
|
||||||
if (type === 'Warning') return 'amber';
|
if (type === 'Warning') return 'amber';
|
||||||
return 'neutral';
|
return 'neutral';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const eventRanges = [
|
||||||
|
{ label: '1시간', value: '1h', milliseconds: 60 * 60 * 1000 },
|
||||||
|
{ label: '24시간', value: '24h', milliseconds: 24 * 60 * 60 * 1000 },
|
||||||
|
{ label: '전체', value: 'all', milliseconds: null }
|
||||||
|
];
|
||||||
|
|
||||||
|
function eventTime(event: RuntimeEvent) {
|
||||||
|
const value = event.lastTimestamp ?? event.firstTimestamp;
|
||||||
|
if (!value) return 0;
|
||||||
|
const time = new Date(value).getTime();
|
||||||
|
return Number.isFinite(time) ? time : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isEventInRange(event: RuntimeEvent, range: (typeof eventRanges)[number]) {
|
||||||
|
if (!range.milliseconds) return true;
|
||||||
|
const time = eventTime(event);
|
||||||
|
return time > 0 && time >= Date.now() - range.milliseconds;
|
||||||
|
}
|
||||||
|
|
||||||
function apiKind(kind: string) {
|
function apiKind(kind: string) {
|
||||||
return kind.toLowerCase();
|
return kind.toLowerCase();
|
||||||
}
|
}
|
||||||
@@ -59,12 +72,12 @@ function isActiveLogTarget(resource: RuntimeResource) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function workloadStatusDot(resource: RuntimeResource) {
|
function workloadStatusDot(resource: RuntimeResource) {
|
||||||
if (resource.kind === 'Pod') return statusDot(resource.status);
|
if (resource.kind === 'Pod') return runtimeStatusDotClass(resource.status);
|
||||||
if (resource.kind === 'Job') return resource.status === 'Running' ? 'bg-emerald-400' : 'bg-muted-foreground';
|
if (resource.kind === 'Job') return resource.status === 'Running' ? statusDotToneClass('green') : statusDotToneClass('neutral');
|
||||||
if (hasReadyReplica(resource) && resource.status === 'Running') return 'bg-emerald-400';
|
if (hasReadyReplica(resource) && resource.status === 'Running') return statusDotToneClass('green');
|
||||||
if (resource.status === 'Pending') return 'bg-amber-400';
|
if (resource.status === 'Pending') return statusDotToneClass('amber');
|
||||||
if (['Error', 'CrashLoopBackOff', 'ImagePullBackOff', 'Failed'].includes(resource.status)) return 'bg-rose-400';
|
if (['Error', 'CrashLoopBackOff', 'ImagePullBackOff', 'Failed'].includes(resource.status)) return statusDotToneClass('red');
|
||||||
return 'bg-muted-foreground';
|
return statusDotToneClass('neutral');
|
||||||
}
|
}
|
||||||
|
|
||||||
function metricKey(namespace: string, pod: string) {
|
function metricKey(namespace: string, pod: string) {
|
||||||
@@ -134,6 +147,7 @@ export function RuntimeLogsPage() {
|
|||||||
const [tail, setTail] = useState(100);
|
const [tail, setTail] = useState(100);
|
||||||
const [activeTab, setActiveTab] = useState<'logs' | 'events'>('logs');
|
const [activeTab, setActiveTab] = useState<'logs' | 'events'>('logs');
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
|
const [eventRange, setEventRange] = useState('1h');
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [logLoading, setLogLoading] = useState(false);
|
const [logLoading, setLogLoading] = useState(false);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
@@ -153,7 +167,9 @@ export function RuntimeLogsPage() {
|
|||||||
}, [podMetrics]);
|
}, [podMetrics]);
|
||||||
const loggableResources = resources.filter(isActiveLogTarget);
|
const loggableResources = resources.filter(isActiveLogTarget);
|
||||||
const selectedMetric = selectedResource?.kind === 'Pod' ? metricByPod.get(metricKey(activeNamespace, selectedResource.name)) : undefined;
|
const selectedMetric = selectedResource?.kind === 'Pod' ? metricByPod.get(metricKey(activeNamespace, selectedResource.name)) : undefined;
|
||||||
|
const selectedEventRange = eventRanges.find((range) => range.value === eventRange) ?? eventRanges[0];
|
||||||
const filteredEvents = events.filter((event) => {
|
const filteredEvents = events.filter((event) => {
|
||||||
|
if (!isEventInRange(event, selectedEventRange)) return false;
|
||||||
const normalizedSearch = search.trim().toLowerCase();
|
const normalizedSearch = search.trim().toLowerCase();
|
||||||
if (!normalizedSearch) return true;
|
if (!normalizedSearch) return true;
|
||||||
return `${event.type ?? ''} ${event.reason ?? ''} ${event.message ?? ''} ${event.involvedObject ?? ''}`.toLowerCase().includes(normalizedSearch);
|
return `${event.type ?? ''} ${event.reason ?? ''} ${event.message ?? ''} ${event.involvedObject ?? ''}`.toLowerCase().includes(normalizedSearch);
|
||||||
@@ -223,6 +239,26 @@ export function RuntimeLogsPage() {
|
|||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeTab !== 'events' || !namespace.trim()) return;
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
const loadEvents = () => {
|
||||||
|
loadRuntimeEvents(namespace.trim())
|
||||||
|
.then((nextEvents) => {
|
||||||
|
if (!cancelled) setEvents(nextEvents);
|
||||||
|
})
|
||||||
|
.catch(() => undefined);
|
||||||
|
};
|
||||||
|
|
||||||
|
loadEvents();
|
||||||
|
const timer = window.setInterval(loadEvents, 10_000);
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
window.clearInterval(timer);
|
||||||
|
};
|
||||||
|
}, [activeTab, namespace]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageTitle
|
<PageTitle
|
||||||
@@ -311,7 +347,7 @@ export function RuntimeLogsPage() {
|
|||||||
onClick={() => loadLogs(resource)}
|
onClick={() => loadLogs(resource)}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className={cn('size-2 rounded-full', workloadStatusDot(resource))} />
|
<span className={cn('size-2.5 shrink-0 rounded-full', workloadStatusDot(resource))} />
|
||||||
<span className="truncate text-sm font-medium">{resource.name}</span>
|
<span className="truncate text-sm font-medium">{resource.name}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-2 flex flex-wrap gap-x-3 gap-y-1 text-xs text-muted-foreground">
|
<div className="mt-2 flex flex-wrap gap-x-3 gap-y-1 text-xs text-muted-foreground">
|
||||||
@@ -369,14 +405,28 @@ export function RuntimeLogsPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="relative w-full lg:w-80">
|
<div className="flex w-full flex-col gap-2 lg:w-auto lg:flex-row lg:items-center">
|
||||||
<Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
<div className="flex flex-wrap gap-2">
|
||||||
<input
|
{eventRanges.map((range) => (
|
||||||
className="h-8 w-full rounded-md border border-input bg-background pl-9 pr-3 text-sm text-foreground outline-none placeholder:text-muted-foreground focus:ring-2 focus:ring-ring"
|
<Button
|
||||||
placeholder="이벤트 검색"
|
key={range.value}
|
||||||
value={search}
|
variant={eventRange === range.value ? 'secondary' : 'outline'}
|
||||||
onChange={(event) => setSearch(event.target.value)}
|
className="h-8 px-3 text-[13px]"
|
||||||
/>
|
onClick={() => setEventRange(range.value)}
|
||||||
|
>
|
||||||
|
{range.label}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="relative w-full lg:w-80">
|
||||||
|
<Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
|
<input
|
||||||
|
className="h-8 w-full rounded-md border border-input bg-background pl-9 pr-3 text-sm text-foreground outline-none placeholder:text-muted-foreground focus:ring-2 focus:ring-ring"
|
||||||
|
placeholder="이벤트 검색"
|
||||||
|
value={search}
|
||||||
|
onChange={(event) => setSearch(event.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -18,6 +18,12 @@ export function createRuntimeProfile(input: { appId: string; name: string; envir
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function deleteRuntimeProfile(profileId: string) {
|
||||||
|
return apiRequest<{ deleted: boolean; profileId: string; name: string }>(`/apps/runtime-profiles/${profileId}`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function createRuntimeVariable(
|
export function createRuntimeVariable(
|
||||||
profileId: string,
|
profileId: string,
|
||||||
input: { componentId: string; key: string; value: string; isSecret: boolean }
|
input: { componentId: string; key: string; value: string; isSecret: boolean }
|
||||||
@@ -60,6 +66,12 @@ export function loadRuntimeResources(namespace: string) {
|
|||||||
return apiRequest<RuntimeResourceResponse>(`/apps/runtime-namespaces/${encodeURIComponent(namespace)}/resources`);
|
return apiRequest<RuntimeResourceResponse>(`/apps/runtime-namespaces/${encodeURIComponent(namespace)}/resources`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function loadRuntimeResourceYaml(namespace: string, kind: string, name: string) {
|
||||||
|
return apiRequest<{ namespace: string; kind: string; name: string; yaml: string }>(
|
||||||
|
`/apps/runtime-namespaces/${encodeURIComponent(namespace)}/resources/${encodeURIComponent(kind)}/${encodeURIComponent(name)}/yaml`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function loadRuntimePodMetrics(namespace?: string) {
|
export function loadRuntimePodMetrics(namespace?: string) {
|
||||||
const query = namespace ? `?namespace=${encodeURIComponent(namespace)}` : '';
|
const query = namespace ? `?namespace=${encodeURIComponent(namespace)}` : '';
|
||||||
return apiRequest<RuntimePodMetric[]>(`/runtime/metrics/pods${query}`);
|
return apiRequest<RuntimePodMetric[]>(`/runtime/metrics/pods${query}`);
|
||||||
|
|||||||
@@ -0,0 +1,428 @@
|
|||||||
|
import { Activity, AlertTriangle, RotateCcw, Server } from 'lucide-react';
|
||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import {
|
||||||
|
Bar,
|
||||||
|
BarChart,
|
||||||
|
CartesianGrid,
|
||||||
|
Cell,
|
||||||
|
Pie,
|
||||||
|
PieChart,
|
||||||
|
ResponsiveContainer,
|
||||||
|
Tooltip,
|
||||||
|
XAxis,
|
||||||
|
YAxis
|
||||||
|
} from 'recharts';
|
||||||
|
import { Badge, Button, Card, CardHeader } from '../../components/ui';
|
||||||
|
import { PageTitle } from '../../layout/AppLayout';
|
||||||
|
import { getStoredSelectedAppId, setStoredSelectedAppId } from '../../lib/selected-app';
|
||||||
|
import { runtimeStatusDotClass } from '../../lib/status-dot';
|
||||||
|
import { cn } from '../../lib/utils';
|
||||||
|
import type { ApiApp, RuntimeEvent, RuntimePodMetric, RuntimeResource } from '../../types/runtime';
|
||||||
|
import { loadApps, loadRuntimeEvents, loadRuntimePodMetrics, loadRuntimeResources } from '../runtime/runtime-api';
|
||||||
|
import { LoadingModal, useMinimumLoading } from '../operations/operation-utils';
|
||||||
|
|
||||||
|
type ResourceRow = RuntimeResource & {
|
||||||
|
metric?: RuntimePodMetric;
|
||||||
|
};
|
||||||
|
|
||||||
|
const chartColors = ['#34d399', '#60a5fa', '#fbbf24', '#fb7185', '#a78bfa'];
|
||||||
|
const commonAppMonitoringAppId = '__common_apps__';
|
||||||
|
const commonAppMonitoringApp: ApiApp = {
|
||||||
|
id: commonAppMonitoringAppId,
|
||||||
|
key: 'studio',
|
||||||
|
name: '공통앱',
|
||||||
|
namespace: 'studio-system',
|
||||||
|
status: 'ACTIVE'
|
||||||
|
};
|
||||||
|
const eventRanges = [
|
||||||
|
{ label: '최근 10분', value: '10m', milliseconds: 10 * 60 * 1000 },
|
||||||
|
{ label: '1시간', value: '1h', milliseconds: 60 * 60 * 1000 },
|
||||||
|
{ label: '6시간', value: '6h', milliseconds: 6 * 60 * 60 * 1000 },
|
||||||
|
{ label: '24시간', value: '24h', milliseconds: 24 * 60 * 60 * 1000 }
|
||||||
|
];
|
||||||
|
|
||||||
|
function parseCpuMillicores(value?: string) {
|
||||||
|
if (!value) return 0;
|
||||||
|
if (value.endsWith('m')) return Number(value.slice(0, -1)) || 0;
|
||||||
|
return (Number(value) || 0) * 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseMemoryMi(value?: string) {
|
||||||
|
if (!value) return 0;
|
||||||
|
const matched = value.match(/^([0-9.]+)([A-Za-z]+)?$/);
|
||||||
|
if (!matched) return 0;
|
||||||
|
const amount = Number(matched[1]);
|
||||||
|
const unit = matched[2] ?? '';
|
||||||
|
if (!Number.isFinite(amount)) return 0;
|
||||||
|
if (unit === 'Ki') return amount / 1024;
|
||||||
|
if (unit === 'Gi') return amount * 1024;
|
||||||
|
if (unit === 'Ti') return amount * 1024 * 1024;
|
||||||
|
if (unit === 'Mi' || unit === 'M') return amount;
|
||||||
|
return amount / 1024 / 1024;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isHealthy(resource: RuntimeResource) {
|
||||||
|
if (['Error', 'CrashLoopBackOff', 'Failed'].includes(resource.status)) return false;
|
||||||
|
if (!resource.ready.includes('/')) return ['Running', 'Bound', 'ClusterIP', 'LoadBalancer'].includes(resource.status);
|
||||||
|
const [ready, total] = resource.ready.split('/').map((value) => Number(value));
|
||||||
|
return Number.isFinite(ready) && Number.isFinite(total) && total > 0 && ready === total;
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusTone(status: string): 'neutral' | 'green' | 'amber' | 'red' | 'blue' {
|
||||||
|
if (isHealthy({ kind: '', name: '', status, ready: status === 'Running' ? '1/1' : '-', restarts: null, age: '', detail: '' })) return 'green';
|
||||||
|
if (['Pending', 'ContainerCreating'].includes(status)) return 'amber';
|
||||||
|
if (['Error', 'CrashLoopBackOff', 'Failed'].includes(status)) return 'red';
|
||||||
|
return 'neutral';
|
||||||
|
}
|
||||||
|
|
||||||
|
function isCompletedMigration(resource: RuntimeResource) {
|
||||||
|
const normalizedName = resource.name.toLowerCase();
|
||||||
|
const normalizedStatus = resource.status.toLowerCase();
|
||||||
|
return normalizedName.includes('migration') && ['completed', 'succeeded', 'success'].includes(normalizedStatus);
|
||||||
|
}
|
||||||
|
|
||||||
|
function eventTime(event: RuntimeEvent) {
|
||||||
|
const value = event.lastTimestamp ?? event.firstTimestamp;
|
||||||
|
if (!value) return 0;
|
||||||
|
const time = new Date(value).getTime();
|
||||||
|
return Number.isFinite(time) ? time : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MonitoringPage() {
|
||||||
|
const [apps, setApps] = useState<ApiApp[]>([]);
|
||||||
|
const [selectedAppId, setSelectedAppId] = useState(() => getStoredSelectedAppId());
|
||||||
|
const [eventRange, setEventRange] = useState('1h');
|
||||||
|
const [resources, setResources] = useState<ResourceRow[]>([]);
|
||||||
|
const [events, setEvents] = useState<RuntimeEvent[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const loadingVisible = useMinimumLoading(loading);
|
||||||
|
|
||||||
|
const selectedApp = apps.find((app) => app.id === selectedAppId) ?? apps[0];
|
||||||
|
const selectedRange = eventRanges.find((range) => range.value === eventRange) ?? eventRanges[1];
|
||||||
|
const visibleResources = useMemo(() => resources.filter((resource) => !isCompletedMigration(resource)), [resources]);
|
||||||
|
const showInitialSkeleton = loading && resources.length === 0 && events.length === 0;
|
||||||
|
const filteredEvents = useMemo(() => {
|
||||||
|
const threshold = Date.now() - selectedRange.milliseconds;
|
||||||
|
return events.filter((event) => eventTime(event) >= threshold);
|
||||||
|
}, [events, selectedRange.milliseconds]);
|
||||||
|
|
||||||
|
const refresh = () => {
|
||||||
|
setLoading(true);
|
||||||
|
loadApps()
|
||||||
|
.then(async (nextApps) => {
|
||||||
|
const appItems = [commonAppMonitoringApp, ...nextApps.filter((app) => app.namespace)];
|
||||||
|
const storedAppId = getStoredSelectedAppId();
|
||||||
|
const preferredAppId = selectedAppId || storedAppId;
|
||||||
|
const app = appItems.find((item) => item.id === preferredAppId)
|
||||||
|
?? appItems.find((item) => item.id === storedAppId)
|
||||||
|
?? appItems[0];
|
||||||
|
setApps(appItems);
|
||||||
|
setSelectedAppId(app?.id ?? '');
|
||||||
|
|
||||||
|
if (!app?.namespace) {
|
||||||
|
setResources([]);
|
||||||
|
setEvents([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [runtime, metrics, eventItems] = await Promise.all([
|
||||||
|
loadRuntimeResources(app.namespace),
|
||||||
|
loadRuntimePodMetrics(app.namespace).catch(() => []),
|
||||||
|
loadRuntimeEvents(app.namespace).catch(() => [])
|
||||||
|
]);
|
||||||
|
const metricByPod = new Map(metrics.map((metric) => [metric.pod, metric]));
|
||||||
|
setResources((runtime.resources ?? []).map((resource) => ({
|
||||||
|
...resource,
|
||||||
|
metric: resource.kind === 'Pod' ? metricByPod.get(resource.name) : undefined
|
||||||
|
})));
|
||||||
|
setEvents(eventItems);
|
||||||
|
})
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
refresh();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedApp?.namespace) return;
|
||||||
|
setLoading(true);
|
||||||
|
Promise.all([
|
||||||
|
loadRuntimeResources(selectedApp.namespace),
|
||||||
|
loadRuntimePodMetrics(selectedApp.namespace).catch(() => []),
|
||||||
|
loadRuntimeEvents(selectedApp.namespace).catch(() => [])
|
||||||
|
])
|
||||||
|
.then(([runtime, metrics, eventItems]) => {
|
||||||
|
const metricByPod = new Map(metrics.map((metric) => [metric.pod, metric]));
|
||||||
|
setResources((runtime.resources ?? []).map((resource) => ({
|
||||||
|
...resource,
|
||||||
|
metric: resource.kind === 'Pod' ? metricByPod.get(resource.name) : undefined
|
||||||
|
})));
|
||||||
|
setEvents(eventItems);
|
||||||
|
})
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, [selectedAppId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedApp?.namespace) return;
|
||||||
|
const namespace = selectedApp.namespace;
|
||||||
|
const timer = window.setInterval(() => {
|
||||||
|
Promise.all([
|
||||||
|
loadRuntimeResources(namespace),
|
||||||
|
loadRuntimePodMetrics(namespace).catch(() => []),
|
||||||
|
loadRuntimeEvents(namespace).catch(() => [])
|
||||||
|
])
|
||||||
|
.then(([runtime, metrics, eventItems]) => {
|
||||||
|
const metricByPod = new Map(metrics.map((metric) => [metric.pod, metric]));
|
||||||
|
setResources((runtime.resources ?? []).map((resource) => ({
|
||||||
|
...resource,
|
||||||
|
metric: resource.kind === 'Pod' ? metricByPod.get(resource.name) : undefined
|
||||||
|
})));
|
||||||
|
setEvents(eventItems);
|
||||||
|
})
|
||||||
|
.catch(() => undefined);
|
||||||
|
}, 10000);
|
||||||
|
|
||||||
|
return () => window.clearInterval(timer);
|
||||||
|
}, [selectedApp?.namespace]);
|
||||||
|
|
||||||
|
const summary = useMemo(() => {
|
||||||
|
const runtimeResources = visibleResources.filter((resource) => ['Pod', 'Deployment', 'StatefulSet', 'DaemonSet'].includes(resource.kind));
|
||||||
|
const healthy = runtimeResources.filter(isHealthy).length;
|
||||||
|
const warnings = filteredEvents.filter((event) => event.type === 'Warning').length;
|
||||||
|
const restarts = visibleResources.reduce((sum, resource) => sum + (resource.restarts ?? 0), 0);
|
||||||
|
return { total: runtimeResources.length, healthy, warnings, restarts };
|
||||||
|
}, [filteredEvents, visibleResources]);
|
||||||
|
|
||||||
|
const podMetrics = useMemo(() => visibleResources
|
||||||
|
.filter((resource) => resource.kind === 'Pod' && resource.metric)
|
||||||
|
.map((resource) => ({
|
||||||
|
name: resource.name.replace(`${selectedApp?.key?.toLowerCase() ?? ''}-`, ''),
|
||||||
|
cpu: parseCpuMillicores(resource.metric?.cpu),
|
||||||
|
memory: Math.round(parseMemoryMi(resource.metric?.memory))
|
||||||
|
})), [visibleResources, selectedApp?.key]);
|
||||||
|
|
||||||
|
const statusData = useMemo(() => {
|
||||||
|
const byStatus = new Map<string, number>();
|
||||||
|
visibleResources.forEach((resource) => byStatus.set(resource.status || '-', (byStatus.get(resource.status || '-') ?? 0) + 1));
|
||||||
|
return [...byStatus.entries()].map(([name, value]) => ({ name, value }));
|
||||||
|
}, [visibleResources]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageTitle
|
||||||
|
eyebrow="시스템"
|
||||||
|
title="모니터링"
|
||||||
|
description="선택한 앱 서비스의 런타임 상태, 리소스 사용량, 최근 이벤트를 확인합니다."
|
||||||
|
actions={
|
||||||
|
<Button variant="outline" className="h-8 px-3 text-[13px]" onClick={refresh} disabled={loading}>
|
||||||
|
<RotateCcw className={cn('size-4', loading && 'animate-spin')} />
|
||||||
|
새로고침
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Card className="mb-4 p-4">
|
||||||
|
<div className="grid gap-4 lg:grid-cols-[minmax(260px,420px)_1fr] lg:items-end">
|
||||||
|
<label className="block">
|
||||||
|
<span className="text-xs font-medium text-muted-foreground">앱</span>
|
||||||
|
<select
|
||||||
|
className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring"
|
||||||
|
value={selectedAppId}
|
||||||
|
onChange={(event) => {
|
||||||
|
const nextAppId = event.target.value;
|
||||||
|
setSelectedAppId(nextAppId);
|
||||||
|
if (nextAppId !== commonAppMonitoringAppId) setStoredSelectedAppId(nextAppId);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{apps.map((app) => (
|
||||||
|
<option key={app.id} value={app.id}>{app.name} · {app.namespace}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-medium text-muted-foreground">검색 기간</p>
|
||||||
|
<div className="mt-2 flex flex-wrap gap-2">
|
||||||
|
{eventRanges.map((range) => (
|
||||||
|
<Button
|
||||||
|
key={range.value}
|
||||||
|
variant={eventRange === range.value ? 'secondary' : 'outline'}
|
||||||
|
className="h-9 px-3 text-[13px]"
|
||||||
|
onClick={() => setEventRange(range.value)}
|
||||||
|
>
|
||||||
|
{range.label}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||||
|
{showInitialSkeleton ? (
|
||||||
|
Array.from({ length: 4 }).map((_, index) => <MetricCardSkeleton key={index} />)
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<MetricCard icon={Server} label="워크로드" value={`${summary.healthy}/${summary.total}`} hint="Pod/Deployment/StatefulSet 기준" tone={summary.healthy === summary.total ? 'green' : 'amber'} />
|
||||||
|
<MetricCard icon={Activity} label="Pod" value={String(visibleResources.filter((resource) => resource.kind === 'Pod').length)} hint="현재 기준" tone="blue" />
|
||||||
|
<MetricCard icon={AlertTriangle} label="Warning 이벤트" value={String(summary.warnings)} hint={`${selectedRange.label} 기준`} tone={summary.warnings > 0 ? 'amber' : 'green'} />
|
||||||
|
<MetricCard icon={RotateCcw} label="재시작" value={String(summary.restarts)} hint="현재 Pod 누적" tone={summary.restarts > 0 ? 'amber' : 'green'} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 grid gap-4 xl:grid-cols-[minmax(0,1.25fr)_minmax(360px,0.75fr)]">
|
||||||
|
<Card className="min-w-0 overflow-hidden">
|
||||||
|
<CardHeader title="Pod 리소스" description="선택한 앱 네임스페이스의 Pod CPU/Memory 사용량입니다." />
|
||||||
|
<div className="h-80 p-5">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<BarChart data={podMetrics}>
|
||||||
|
<CartesianGrid stroke="rgba(148,163,184,0.16)" vertical={false} />
|
||||||
|
<XAxis dataKey="name" tick={{ fill: '#94a3b8', fontSize: 11 }} tickLine={false} axisLine={false} />
|
||||||
|
<YAxis tick={{ fill: '#94a3b8', fontSize: 11 }} tickLine={false} axisLine={false} />
|
||||||
|
<Tooltip contentStyle={{ backgroundColor: 'hsl(224 20% 10%)', border: '1px solid hsl(222 15% 20%)', borderRadius: 8 }} />
|
||||||
|
<Bar dataKey="cpu" name="CPU(m)" fill="#60a5fa" radius={[4, 4, 0, 0]} />
|
||||||
|
<Bar dataKey="memory" name="Memory(Mi)" fill="#34d399" radius={[4, 4, 0, 0]} />
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="min-w-0 overflow-hidden">
|
||||||
|
<CardHeader title="상태 분포" description="런타임 리소스 상태별 개수입니다." />
|
||||||
|
<div className="p-5">
|
||||||
|
<div className="h-60">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<PieChart>
|
||||||
|
<Pie data={statusData} dataKey="value" nameKey="name" innerRadius={58} outerRadius={92} paddingAngle={3}>
|
||||||
|
{statusData.map((entry, index) => <Cell key={entry.name} fill={chartColors[index % chartColors.length]} />)}
|
||||||
|
</Pie>
|
||||||
|
<Tooltip contentStyle={{ backgroundColor: 'hsl(224 20% 10%)', border: '1px solid hsl(222 15% 20%)', borderRadius: 8 }} />
|
||||||
|
</PieChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 grid gap-2 sm:grid-cols-2">
|
||||||
|
{statusData.map((entry, index) => (
|
||||||
|
<div key={entry.name} className="flex items-center justify-between gap-3 rounded-md bg-muted/30 px-3 py-2 text-xs">
|
||||||
|
<span className="inline-flex min-w-0 items-center gap-2">
|
||||||
|
<span className="size-2.5 shrink-0 rounded-full" style={{ backgroundColor: chartColors[index % chartColors.length] }} />
|
||||||
|
<span className="truncate text-muted-foreground">{entry.name}</span>
|
||||||
|
</span>
|
||||||
|
<span className="font-semibold text-foreground">{entry.value}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 grid gap-4 xl:grid-cols-2">
|
||||||
|
<Card className="overflow-hidden">
|
||||||
|
<CardHeader title="리소스 상태" description="선택한 앱 네임스페이스의 주요 리소스입니다." />
|
||||||
|
<div className="max-h-[420px] divide-y divide-border overflow-y-auto">
|
||||||
|
{showInitialSkeleton ? (
|
||||||
|
<ListSkeleton rows={6} />
|
||||||
|
) : visibleResources.map((resource) => (
|
||||||
|
<div key={`${resource.kind}-${resource.name}`} className="flex items-center justify-between gap-4 px-5 py-3 text-sm">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className={cn('size-2.5 shrink-0 rounded-full', runtimeStatusDotClass(resource.status))} />
|
||||||
|
<p className="truncate font-medium">{resource.kind}/{resource.name}</p>
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">{resource.ready || '-'} · 재시작 {resource.restarts ?? 0}</p>
|
||||||
|
</div>
|
||||||
|
<Badge tone={statusTone(resource.status)}>{resource.status || '-'}</Badge>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="overflow-hidden">
|
||||||
|
<CardHeader title="이벤트 목록" description="선택한 기간의 Warning 이벤트를 우선 확인합니다." />
|
||||||
|
<div className="max-h-[420px] divide-y divide-border overflow-y-auto">
|
||||||
|
{showInitialSkeleton ? (
|
||||||
|
<ListSkeleton rows={5} />
|
||||||
|
) : filteredEvents.slice(0, 12).map((event, index) => (
|
||||||
|
<div key={`${event.involvedObject}-${event.reason}-${index}`} className="px-5 py-3 text-sm">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Badge tone={event.type === 'Warning' ? 'amber' : 'neutral'}>{event.type ?? '-'}</Badge>
|
||||||
|
<p className="truncate font-medium">{event.reason ?? '-'}</p>
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 line-clamp-2 text-xs leading-5 text-muted-foreground">{event.message ?? '-'}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{!showInitialSkeleton && !filteredEvents.length ? <div className="px-5 py-12 text-center text-sm text-muted-foreground">선택 기간에 표시할 이벤트가 없습니다.</div> : null}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<LoadingModal open={loadingVisible} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SkeletonBlock({ className }: { className: string }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'relative overflow-hidden rounded-md bg-white/15',
|
||||||
|
'before:absolute before:inset-0 before:-translate-x-full before:animate-[skeleton-shimmer_1.35s_ease-in-out_infinite]',
|
||||||
|
'before:bg-gradient-to-r before:from-transparent before:via-white/35 before:to-transparent',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MetricCardSkeleton() {
|
||||||
|
return (
|
||||||
|
<Card className="p-5">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<SkeletonBlock className="h-4 w-20" />
|
||||||
|
<SkeletonBlock className="size-4 rounded-full" />
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 flex items-end justify-between gap-3">
|
||||||
|
<SkeletonBlock className="h-9 w-24" />
|
||||||
|
<SkeletonBlock className="h-6 w-12" />
|
||||||
|
</div>
|
||||||
|
<SkeletonBlock className="mt-3 h-3 w-36" />
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ListSkeleton({ rows }: { rows: number }) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{Array.from({ length: rows }).map((_, index) => (
|
||||||
|
<div key={index} className="px-5 py-3">
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<SkeletonBlock className="size-2.5 rounded-full" />
|
||||||
|
<SkeletonBlock className="h-4 w-3/5" />
|
||||||
|
</div>
|
||||||
|
<SkeletonBlock className="mt-2 h-3 w-2/5" />
|
||||||
|
</div>
|
||||||
|
<SkeletonBlock className="h-6 w-16" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MetricCard({ icon: Icon, label, value, hint, tone }: { icon: typeof Server; label: string; value: string; hint: string; tone: 'green' | 'amber' | 'red' | 'blue' | 'neutral' }) {
|
||||||
|
return (
|
||||||
|
<Card className="p-5">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="text-sm text-muted-foreground">{label}</p>
|
||||||
|
<Icon className="size-4 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 flex items-end justify-between gap-3">
|
||||||
|
<p className="text-3xl font-semibold tracking-tight">{value}</p>
|
||||||
|
<Badge tone={tone}>{tone === 'green' ? '정상' : tone === 'amber' ? '주의' : tone === 'red' ? '장애' : '정보'}</Badge>
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 text-xs text-muted-foreground">{hint}</p>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -21,9 +21,14 @@ export type BuildJob = {
|
|||||||
branch: string;
|
branch: string;
|
||||||
commitSha?: string | null;
|
commitSha?: string | null;
|
||||||
artifact?: string | null;
|
artifact?: string | null;
|
||||||
|
artifactSha256?: string | null;
|
||||||
image?: string | null;
|
image?: string | null;
|
||||||
status: JobStatus;
|
status: JobStatus;
|
||||||
logs?: string | null;
|
logs?: string | null;
|
||||||
|
requestedBy?: string | null;
|
||||||
|
requestedByName?: string | null;
|
||||||
|
requestedByEmail?: string | null;
|
||||||
|
requestedByAvatarUrl?: string | null;
|
||||||
startedAt?: string | null;
|
startedAt?: string | null;
|
||||||
finishedAt?: string | null;
|
finishedAt?: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
@@ -40,6 +45,10 @@ export type AppRelease = {
|
|||||||
imageTags?: unknown;
|
imageTags?: unknown;
|
||||||
status: JobStatus;
|
status: JobStatus;
|
||||||
logs?: string | null;
|
logs?: string | null;
|
||||||
|
requestedBy?: string | null;
|
||||||
|
requestedByName?: string | null;
|
||||||
|
requestedByEmail?: string | null;
|
||||||
|
requestedByAvatarUrl?: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt?: string;
|
updatedAt?: string;
|
||||||
app: ApiApp;
|
app: ApiApp;
|
||||||
@@ -53,6 +62,9 @@ export type DeploymentJob = {
|
|||||||
namespace: string;
|
namespace: string;
|
||||||
status: JobStatus;
|
status: JobStatus;
|
||||||
requestedBy?: string | null;
|
requestedBy?: string | null;
|
||||||
|
requestedByName?: string | null;
|
||||||
|
requestedByEmail?: string | null;
|
||||||
|
requestedByAvatarUrl?: string | null;
|
||||||
logs?: string | null;
|
logs?: string | null;
|
||||||
startedAt?: string | null;
|
startedAt?: string | null;
|
||||||
finishedAt?: string | null;
|
finishedAt?: string | null;
|
||||||
@@ -69,6 +81,9 @@ export type RuntimeControlJob = {
|
|||||||
action: string;
|
action: string;
|
||||||
status: JobStatus;
|
status: JobStatus;
|
||||||
requestedBy?: string | null;
|
requestedBy?: string | null;
|
||||||
|
requestedByName?: string | null;
|
||||||
|
requestedByEmail?: string | null;
|
||||||
|
requestedByAvatarUrl?: string | null;
|
||||||
request?: Record<string, unknown> | null;
|
request?: Record<string, unknown> | null;
|
||||||
result?: Record<string, unknown> | null;
|
result?: Record<string, unknown> | null;
|
||||||
logs?: string | null;
|
logs?: string | null;
|
||||||
@@ -81,9 +96,13 @@ export type GiteaRepo = {
|
|||||||
id?: number;
|
id?: number;
|
||||||
name: string;
|
name: string;
|
||||||
full_name: string;
|
full_name: string;
|
||||||
|
fullName?: string;
|
||||||
html_url?: string;
|
html_url?: string;
|
||||||
|
htmlUrl?: string;
|
||||||
clone_url?: string;
|
clone_url?: string;
|
||||||
|
cloneUrl?: string;
|
||||||
ssh_url?: string;
|
ssh_url?: string;
|
||||||
|
sshUrl?: string;
|
||||||
private?: boolean;
|
private?: boolean;
|
||||||
owner?: { login: string };
|
owner?: { login: string };
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ export type ApiAppComponent = {
|
|||||||
name?: string;
|
name?: string;
|
||||||
port?: number;
|
port?: number;
|
||||||
targetPort?: number;
|
targetPort?: number;
|
||||||
|
nodePort?: number;
|
||||||
protocol?: string;
|
protocol?: string;
|
||||||
}> | null;
|
}> | null;
|
||||||
buildJobs?: Array<{
|
buildJobs?: Array<{
|
||||||
@@ -40,6 +41,7 @@ export type ApiApp = {
|
|||||||
description?: string | null;
|
description?: string | null;
|
||||||
namespace?: string | null;
|
namespace?: string | null;
|
||||||
status: string;
|
status: string;
|
||||||
|
launcherLinked?: boolean;
|
||||||
components?: ApiAppComponent[];
|
components?: ApiAppComponent[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user