add runtime log views and standardize status dots
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/push/woodpecker Pipeline was successful
This commit is contained in:
@@ -10,7 +10,7 @@ The visible menu labels and button labels in the application are Korean. Keep th
|
|||||||
- Unless the user explicitly allows otherwise, navigate and operate only inside the `플랫폼` menu group.
|
- 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.
|
- 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.
|
- 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, break links, or decommission resources. This includes deletion, removal, cleanup, 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.
|
- 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.
|
- 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.
|
- 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`.
|
- 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`.
|
||||||
@@ -18,30 +18,35 @@ The visible menu labels and button labels in the application are Korean. Keep th
|
|||||||
- 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.
|
- 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.
|
- 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 `전체`.
|
- 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.
|
- The `...` icon in lists is for detail/log viewing. Do not confuse it with stop, retry, or delete icons.
|
||||||
|
|
||||||
## Main Menu Map
|
## Main Menu Map
|
||||||
|
|
||||||
- `대시보드`: Shows current platform status, common app status, and license usage.
|
- `대시보드`: 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.
|
- `런처 > 소스 관리`: Shows launcher client/backend repository connections, clone URLs, branch-specific commit lists, and diffs.
|
||||||
- `런처 > 빌드 관리`: Lists and runs launcher build jobs.
|
- `런처 > 빌드 관리`: Lists and runs launcher build jobs.
|
||||||
- `런처 > 버전 관리`: Manages launcher versions and downloadable artifacts.
|
- `런처 > 버전 관리`: Manages launcher versions and downloadable artifacts.
|
||||||
- `런처 > 구성요소`: Shows launcher workload and service components.
|
- `런처 > 구성요소`: Shows launcher workload and service components.
|
||||||
- `런처 > 공통 설정`: Shows launcher common configuration.
|
- `런처 > 로그/이벤트`: 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.
|
- `어플리케이션 > 앱 관리`: 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.
|
- `어플리케이션 > 앱 관리` 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.
|
- `어플리케이션 > 저장소 관리`: 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.
|
- `어플리케이션 > 소스 관리`: 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`.
|
- `어플리케이션 > 빌드 관리`: Lists and runs build jobs, normally from `develop`.
|
||||||
- `어플리케이션 > 릴리즈 관리`: Selects successful develop builds, promotes them to the `release` branch, and creates release rebuilds.
|
- `어플리케이션 > 릴리즈 관리`: 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.
|
- `어플리케이션 > 배포 관리`: 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/delete actions save immediately.
|
- `어플리케이션 > 실행 설정`: Manages runtime environment variables and Secret values. Add actions save immediately. Delete actions require a confirmation modal.
|
||||||
- `어플리케이션 > 마이그레이션`: Runs database migration jobs and checks logs.
|
- `어플리케이션 > 마이그레이션`: Runs database migration jobs and checks logs.
|
||||||
- `어플리케이션 > 로그/이벤트`: Shows Kubernetes resources and events by namespace.
|
- `어플리케이션 > 로그/이벤트`: Shows Kubernetes resources and events by namespace.
|
||||||
|
|
||||||
The following menus are reference-only unless the user explicitly allows entering them:
|
The following menus are reference-only unless the user explicitly allows entering them:
|
||||||
|
|
||||||
- `운영 > 관리자 관리`: Admin accounts, permissions, SSH keys, and admin status.
|
- `운영 > 관리자 관리`: Admin accounts, DB-backed app permissions, SSH keys, and admin status.
|
||||||
- `운영 > 조직 관리`: Organization creation, edit, and deletion. Deletion hides the organization through deactivation.
|
- `운영 > 조직 관리`: Organization creation, edit, and deletion. Deletion hides the organization through deactivation.
|
||||||
- `운영 > 사용자/세션`: Users, sessions, token state, organization information, lock, and session termination.
|
- `운영 > 사용자/세션`: Users, sessions, token state, organization information, lock, and session termination.
|
||||||
- `운영 > 라이센스`: License plans and user license assignment.
|
- `운영 > 라이센스`: License plans and user license assignment.
|
||||||
@@ -58,6 +63,17 @@ The following menus are reference-only unless the user explicitly allows enterin
|
|||||||
- `새로고침`, period filters, and page navigation are lookup actions, not state changes.
|
- `새로고침`, 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.
|
- 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
|
||||||
|
|
||||||
|
Use `공통앱` only for Studio platform repositories and workloads such as admin FE, admin BE, auth BE, and platform gateway.
|
||||||
|
|
||||||
|
- `공통앱 > 소스 관리` 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
|
## Repository Creation And Connection Flow
|
||||||
|
|
||||||
Use this flow when a repository is missing or a new component must be connected.
|
Use this flow when a repository is missing or a new component must be connected.
|
||||||
@@ -121,11 +137,10 @@ When the user asks to set up and deploy the launcher, follow this order.
|
|||||||
|
|
||||||
1. In `런처 > 구성요소`, register required launcher components.
|
1. In `런처 > 구성요소`, register required launcher components.
|
||||||
2. In `런처 > 소스 관리`, connect launcher components to repositories.
|
2. In `런처 > 소스 관리`, connect launcher components to repositories.
|
||||||
3. In `런처 > 공통 설정`, register launcher common settings.
|
3. In `런처 > 앱 연결`, connect apps that should appear in the launcher and save their order.
|
||||||
4. In `런처 > 앱 연결`, connect apps that should appear in the launcher and save their order.
|
4. In `런처 > 빌드 관리`, run required launcher build jobs.
|
||||||
5. In `런처 > 빌드 관리`, run required launcher build jobs.
|
5. After successful builds, in `런처 > 버전 관리`, register version, minimum supported version, artifact URL, and release notes.
|
||||||
6. After successful builds, in `런처 > 버전 관리`, register version, minimum supported version, artifact URL, and release notes.
|
6. Activate the new launcher version only if needed and confirmed.
|
||||||
7. Activate the new launcher version only if needed and confirmed.
|
|
||||||
|
|
||||||
Forbidden launcher actions:
|
Forbidden launcher actions:
|
||||||
|
|
||||||
@@ -133,16 +148,16 @@ Forbidden launcher actions:
|
|||||||
- Do not activate a version before a successful build artifact exists.
|
- Do not activate a version before a successful build artifact exists.
|
||||||
- Do not change app connection order unless the user explicitly asks.
|
- Do not change app connection order unless the user explicitly asks.
|
||||||
- Do not execute deletion, removal, decommission, or unlink actions.
|
- Do not execute deletion, removal, decommission, or unlink actions.
|
||||||
|
- `런처 > 앱 연결` unlink opens an `앱 연결 해제` confirmation modal. PageAgent must not confirm unlink.
|
||||||
|
|
||||||
## Launcher Version Update Flow
|
## Launcher Version Update Flow
|
||||||
|
|
||||||
For an existing launcher version update, follow this order.
|
For an existing launcher version update, follow this order.
|
||||||
|
|
||||||
1. If common settings must change, update `런처 > 공통 설정` first.
|
1. If launcher app exposure or order changes, update `런처 > 앱 연결`. Do not change app links unless the user explicitly asks.
|
||||||
2. 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 `런처 > 빌드 관리`.
|
||||||
3. Run the new required build in `런처 > 빌드 관리`.
|
3. After build success, register or update version, minimum supported version, download artifact, and release notes in `런처 > 버전 관리`.
|
||||||
4. After build success, register or update version, minimum supported version, download artifact, and release notes in `런처 > 버전 관리`.
|
4. Activate the new version only if needed and confirmed.
|
||||||
5. Activate the new version only if needed and confirmed.
|
|
||||||
|
|
||||||
Forbidden launcher update actions:
|
Forbidden launcher update actions:
|
||||||
|
|
||||||
@@ -150,6 +165,7 @@ Forbidden launcher update actions:
|
|||||||
- Do not change minimum supported version without user request or policy confirmation.
|
- Do not change minimum supported version without user request or policy confirmation.
|
||||||
- Do not change app connection order without user request.
|
- Do not change app connection order without user request.
|
||||||
- Do not execute deletion, removal, decommission, or unlink actions.
|
- Do not execute deletion, removal, decommission, or unlink actions.
|
||||||
|
- `런처 > 앱 연결` unlink opens an `앱 연결 해제` confirmation modal. PageAgent must not confirm unlink.
|
||||||
|
|
||||||
## App Release Flow
|
## App Release Flow
|
||||||
|
|
||||||
@@ -181,6 +197,7 @@ Build list handling:
|
|||||||
- The footer shows `총 N건 · current page / total pages`.
|
- The footer shows `총 N건 · current page / total pages`.
|
||||||
- If the target job is missing, widen to `한달`, then `전체`, and use `이전`/`다음`.
|
- If the target job is missing, widen to `한달`, then `전체`, and use `이전`/`다음`.
|
||||||
- Handle `중지`, `재실행`, and `삭제` only if the user explicitly asks. PageAgent must not execute `삭제`.
|
- 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
|
## App Release Creation Details
|
||||||
|
|
||||||
@@ -199,6 +216,7 @@ When the user says "create release", "version up", or "make a release for produc
|
|||||||
11. If release status is `대기` or `실행 중`, wait.
|
11. If release status is `대기` or `실행 중`, wait.
|
||||||
12. Open release logs with the row's right-side `...` button.
|
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.
|
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:
|
Release internals:
|
||||||
|
|
||||||
@@ -255,6 +273,7 @@ Deployment list handling:
|
|||||||
- The list shows 3 items per page and displays `총 N건 · current page / total pages`.
|
- 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.
|
- 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 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
|
## Runtime Configuration Change Flow
|
||||||
|
|
||||||
@@ -266,7 +285,8 @@ For environment variable or Secret additions/edits, follow this order.
|
|||||||
4. If the value is secret, select the Secret option.
|
4. If the value is secret, select the Secret option.
|
||||||
5. Click `설정값 추가`. This saves immediately. Do not expect a separate apply button.
|
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.
|
6. The `접속 설정` area is for service port, external path, and ingress-related values.
|
||||||
7. Do not expect automatic rollout after saving settings. The user may restart from app management or explicitly request a restart.
|
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:
|
Common runtime settings:
|
||||||
|
|
||||||
@@ -316,6 +336,17 @@ Launcher forbidden actions:
|
|||||||
- Do not run builds without repository connections.
|
- Do not run builds without repository connections.
|
||||||
- Do not activate a version without a successful build artifact.
|
- 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
|
## 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.
|
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.
|
||||||
@@ -328,6 +359,14 @@ If the user asks to delete, remove, or decommission an app, PageAgent must not e
|
|||||||
|
|
||||||
By default, only explain `저장소 연결 정보 삭제`, `실행 설정 삭제`, and `Kubernetes 워크로드 중지`. Do not select `Kubernetes 리소스 삭제` or `PVC/데이터 볼륨 삭제` on your own.
|
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
|
## Component Registration Rules
|
||||||
|
|
||||||
- FE/BE/Worker with source code should connect to repositories and be managed as build targets.
|
- FE/BE/Worker with source code should connect to repositories and be managed as build targets.
|
||||||
|
|||||||
+21
-4
@@ -36,9 +36,11 @@ import { LauncherBuildPage } from './pages/launcher/LauncherBuildPage';
|
|||||||
import { LauncherSourcePage } from './pages/launcher/LauncherSourcePage';
|
import { LauncherSourcePage } from './pages/launcher/LauncherSourcePage';
|
||||||
import { LauncherAppsPage as LauncherAppsApiPage } from './pages/launcher/LauncherAppsPage';
|
import { LauncherAppsPage as LauncherAppsApiPage } from './pages/launcher/LauncherAppsPage';
|
||||||
import { LauncherComponentsPage } from './pages/launcher/LauncherComponentsPage';
|
import { LauncherComponentsPage } from './pages/launcher/LauncherComponentsPage';
|
||||||
|
import { LauncherLogsPage } from './pages/launcher/LauncherLogsPage';
|
||||||
import { LauncherVersionsPage as LauncherVersionsApiPage } from './pages/launcher/LauncherVersionsPage';
|
import { LauncherVersionsPage as LauncherVersionsApiPage } from './pages/launcher/LauncherVersionsPage';
|
||||||
import { CommonAppBuildPage } from './pages/common-apps/CommonAppBuildPage';
|
import { CommonAppBuildPage } from './pages/common-apps/CommonAppBuildPage';
|
||||||
import { CommonAppComponentsPage } from './pages/common-apps/CommonAppComponentsPage';
|
import { CommonAppComponentsPage } from './pages/common-apps/CommonAppComponentsPage';
|
||||||
|
import { CommonAppLogsPage } from './pages/common-apps/CommonAppLogsPage';
|
||||||
import { CommonAppSourcePage } from './pages/common-apps/CommonAppSourcePage';
|
import { CommonAppSourcePage } from './pages/common-apps/CommonAppSourcePage';
|
||||||
import { LoadingModal, useMinimumLoading } from './pages/operations/operation-utils';
|
import { LoadingModal, useMinimumLoading } from './pages/operations/operation-utils';
|
||||||
import {
|
import {
|
||||||
@@ -60,10 +62,12 @@ type ViewKey =
|
|||||||
| 'commonSources'
|
| 'commonSources'
|
||||||
| 'commonBuilds'
|
| 'commonBuilds'
|
||||||
| 'commonComponents'
|
| 'commonComponents'
|
||||||
|
| 'commonLogs'
|
||||||
| 'launcherSources'
|
| 'launcherSources'
|
||||||
| 'launcherBuilds'
|
| 'launcherBuilds'
|
||||||
| 'launcherVersions'
|
| 'launcherVersions'
|
||||||
| 'launcherComponents'
|
| 'launcherComponents'
|
||||||
|
| 'launcherLogs'
|
||||||
| 'launcherApps'
|
| 'launcherApps'
|
||||||
| 'notices'
|
| 'notices'
|
||||||
| 'appManagement'
|
| 'appManagement'
|
||||||
@@ -130,10 +134,12 @@ const pathByView: Record<ViewKey, string> = {
|
|||||||
commonSources: '/common-apps/sources',
|
commonSources: '/common-apps/sources',
|
||||||
commonBuilds: '/common-apps/builds',
|
commonBuilds: '/common-apps/builds',
|
||||||
commonComponents: '/common-apps/components',
|
commonComponents: '/common-apps/components',
|
||||||
|
commonLogs: '/common-apps/logs',
|
||||||
launcherSources: '/launcher/sources',
|
launcherSources: '/launcher/sources',
|
||||||
launcherBuilds: '/launcher/builds',
|
launcherBuilds: '/launcher/builds',
|
||||||
launcherVersions: '/launcher/versions',
|
launcherVersions: '/launcher/versions',
|
||||||
launcherComponents: '/launcher/components',
|
launcherComponents: '/launcher/components',
|
||||||
|
launcherLogs: '/launcher/logs',
|
||||||
launcherApps: '/launcher/apps',
|
launcherApps: '/launcher/apps',
|
||||||
notices: '/operations/notices',
|
notices: '/operations/notices',
|
||||||
appManagement: '/applications/apps',
|
appManagement: '/applications/apps',
|
||||||
@@ -916,7 +922,7 @@ function MetricCards({
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: '진행 중 작업',
|
label: '진행 중 작업',
|
||||||
value: loading ? '-' : `${activeWorkTotal}건`,
|
value: loading ? '-' : `${activeWorkTotal}`,
|
||||||
delta: `빌드 ${activeWork.builds} · 릴리즈 ${activeWork.releases} · 배포 ${activeWork.deployments}`,
|
delta: `빌드 ${activeWork.builds} · 릴리즈 ${activeWork.releases} · 배포 ${activeWork.deployments}`,
|
||||||
hint: '대기/실행 중 작업 기준',
|
hint: '대기/실행 중 작업 기준',
|
||||||
tone: activeWorkTotal > 0 ? 'amber' as const : 'green' as const
|
tone: activeWorkTotal > 0 ? 'amber' as const : 'green' as const
|
||||||
@@ -1032,10 +1038,14 @@ function LauncherPanel({ versions, loading, onOpen }: { versions: ApiLauncherVer
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function CommonAppsPanel({ pipelines, loading }: { pipelines: CommonAppPipeline[]; loading: boolean }) {
|
function CommonAppsPanel({ pipelines, loading, onOpen }: { pipelines: CommonAppPipeline[]; loading: boolean; onOpen: () => void }) {
|
||||||
return (
|
return (
|
||||||
<Card className="min-w-0 overflow-hidden">
|
<Card className="min-w-0 overflow-hidden">
|
||||||
<CardHeader title="공통앱" description="공통앱의 최근 빌드 상태를 확인합니다." />
|
<CardHeader
|
||||||
|
title="공통앱"
|
||||||
|
description="공통앱의 최근 빌드 상태를 확인합니다."
|
||||||
|
action={<DashboardLinkButton onClick={onOpen} label="공통앱 빌드 관리로 이동" />}
|
||||||
|
/>
|
||||||
<div className="max-h-[420px] overflow-y-auto">
|
<div className="max-h-[420px] overflow-y-auto">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="space-y-3 p-5">
|
<div className="space-y-3 p-5">
|
||||||
@@ -1369,12 +1379,14 @@ function NoticePanel({ notices: dashboardNotices, loading, onOpen }: { notices:
|
|||||||
|
|
||||||
function DashboardPage({
|
function DashboardPage({
|
||||||
onLauncherVersionsOpen,
|
onLauncherVersionsOpen,
|
||||||
|
onCommonBuildsOpen,
|
||||||
onAppManagementOpen,
|
onAppManagementOpen,
|
||||||
onRuntimeLogsOpen,
|
onRuntimeLogsOpen,
|
||||||
onNoticesOpen,
|
onNoticesOpen,
|
||||||
onUserSessionsOpen
|
onUserSessionsOpen
|
||||||
}: {
|
}: {
|
||||||
onLauncherVersionsOpen: () => void;
|
onLauncherVersionsOpen: () => void;
|
||||||
|
onCommonBuildsOpen: () => void;
|
||||||
onAppManagementOpen: () => void;
|
onAppManagementOpen: () => void;
|
||||||
onRuntimeLogsOpen: () => void;
|
onRuntimeLogsOpen: () => void;
|
||||||
onNoticesOpen: () => void;
|
onNoticesOpen: () => void;
|
||||||
@@ -1477,7 +1489,7 @@ function DashboardPage({
|
|||||||
|
|
||||||
<div className="mt-4 grid gap-4 xl:grid-cols-2">
|
<div className="mt-4 grid gap-4 xl:grid-cols-2">
|
||||||
<LauncherPanel versions={launcherRows} loading={dashboardLoading} onOpen={onLauncherVersionsOpen} />
|
<LauncherPanel versions={launcherRows} loading={dashboardLoading} onOpen={onLauncherVersionsOpen} />
|
||||||
<CommonAppsPanel pipelines={commonAppPipelines} loading={dashboardLoading} />
|
<CommonAppsPanel pipelines={commonAppPipelines} loading={dashboardLoading} onOpen={onCommonBuildsOpen} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
@@ -1556,10 +1568,14 @@ function App() {
|
|||||||
return <CommonAppBuildPage />;
|
return <CommonAppBuildPage />;
|
||||||
case 'commonComponents':
|
case 'commonComponents':
|
||||||
return <CommonAppComponentsPage />;
|
return <CommonAppComponentsPage />;
|
||||||
|
case 'commonLogs':
|
||||||
|
return <CommonAppLogsPage />;
|
||||||
case 'launcherVersions':
|
case 'launcherVersions':
|
||||||
return <LauncherVersionsApiPage />;
|
return <LauncherVersionsApiPage />;
|
||||||
case 'launcherComponents':
|
case 'launcherComponents':
|
||||||
return <LauncherComponentsPage />;
|
return <LauncherComponentsPage />;
|
||||||
|
case 'launcherLogs':
|
||||||
|
return <LauncherLogsPage />;
|
||||||
case 'launcherSources':
|
case 'launcherSources':
|
||||||
return <LauncherSourcePage />;
|
return <LauncherSourcePage />;
|
||||||
case 'launcherBuilds':
|
case 'launcherBuilds':
|
||||||
@@ -1623,6 +1639,7 @@ function App() {
|
|||||||
return (
|
return (
|
||||||
<DashboardPage
|
<DashboardPage
|
||||||
onLauncherVersionsOpen={() => changeView('launcherVersions')}
|
onLauncherVersionsOpen={() => changeView('launcherVersions')}
|
||||||
|
onCommonBuildsOpen={() => changeView('commonBuilds')}
|
||||||
onAppManagementOpen={() => changeView('appManagement')}
|
onAppManagementOpen={() => changeView('appManagement')}
|
||||||
onRuntimeLogsOpen={() => changeView('runtimeLogs')}
|
onRuntimeLogsOpen={() => changeView('runtimeLogs')}
|
||||||
onNoticesOpen={() => changeView('notices')}
|
onNoticesOpen={() => changeView('notices')}
|
||||||
|
|||||||
+3
-1
@@ -24,7 +24,8 @@ export const navGroups = [
|
|||||||
children: [
|
children: [
|
||||||
{ label: '소스 관리', view: 'commonSources' },
|
{ label: '소스 관리', view: 'commonSources' },
|
||||||
{ label: '빌드 관리', view: 'commonBuilds' },
|
{ label: '빌드 관리', view: 'commonBuilds' },
|
||||||
{ label: '구성요소', view: 'commonComponents' }
|
{ label: '구성요소', view: 'commonComponents' },
|
||||||
|
{ label: '로그/이벤트', view: 'commonLogs' }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -35,6 +36,7 @@ export const navGroups = [
|
|||||||
{ label: '빌드 관리', view: 'launcherBuilds' },
|
{ label: '빌드 관리', view: 'launcherBuilds' },
|
||||||
{ label: '버전 관리' },
|
{ label: '버전 관리' },
|
||||||
{ label: '구성요소', view: 'launcherComponents' },
|
{ label: '구성요소', view: 'launcherComponents' },
|
||||||
|
{ label: '로그/이벤트', view: 'launcherLogs' },
|
||||||
{ label: '앱 연결' }
|
{ label: '앱 연결' }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ export const navGroups: NavGroup[] = [
|
|||||||
{ 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/logs' },
|
||||||
{ label: '앱 연결', path: '/launcher/apps' }
|
{ label: '앱 연결', path: '/launcher/apps' }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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');
|
||||||
|
}
|
||||||
|
|
||||||
@@ -5,6 +5,7 @@ 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 } 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';
|
||||||
@@ -237,13 +238,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) => {
|
||||||
@@ -1012,7 +1006,7 @@ 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}
|
{running ? <Badge tone="green">실행 중</Badge> : null}
|
||||||
</span>
|
</span>
|
||||||
@@ -1095,7 +1089,7 @@ 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>
|
<Badge tone={runtimeStatusTone(resource, stopped)}>{runtimeStatusLabel(resource, stopped)}</Badge>
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -0,0 +1,415 @@
|
|||||||
|
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';
|
||||||
|
}
|
||||||
|
|
||||||
|
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 [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 filteredEvents = events.filter((event) => {
|
||||||
|
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();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
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="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>
|
||||||
|
|
||||||
|
{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} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { statusDotToneClass } from '../../lib/status-dot';
|
||||||
|
|
||||||
export const COMMON_APP_NAMESPACE = 'studio-system';
|
export const COMMON_APP_NAMESPACE = 'studio-system';
|
||||||
|
|
||||||
export type CommonAppComponent = {
|
export type CommonAppComponent = {
|
||||||
@@ -84,11 +86,11 @@ export function pipelineStatusTone(status?: string | null): 'green' | 'amber' |
|
|||||||
|
|
||||||
export function pipelineStatusDotClass(status?: string | null) {
|
export function pipelineStatusDotClass(status?: string | null) {
|
||||||
const value = (status ?? '').toLowerCase();
|
const value = (status ?? '').toLowerCase();
|
||||||
if (['success', 'succeeded', 'passed'].includes(value)) return 'bg-emerald-400 shadow-[0_0_0_3px_rgba(52,211,153,0.15)]';
|
if (['success', 'succeeded', 'passed'].includes(value)) return statusDotToneClass('green');
|
||||||
if (['running', 'started'].includes(value)) return 'bg-sky-400 shadow-[0_0_0_3px_rgba(56,189,248,0.15)]';
|
if (['running', 'started'].includes(value)) return statusDotToneClass('blue');
|
||||||
if (['pending', 'queued', 'blocked', 'created'].includes(value)) return 'bg-amber-400 shadow-[0_0_0_3px_rgba(251,191,36,0.15)]';
|
if (['pending', 'queued', 'blocked', 'created'].includes(value)) return statusDotToneClass('amber');
|
||||||
if (['failure', 'failed', 'error', 'killed', 'declined'].includes(value)) return 'bg-rose-400 shadow-[0_0_0_3px_rgba(251,113,133,0.15)]';
|
if (['failure', 'failed', 'error', 'killed', 'declined'].includes(value)) return statusDotToneClass('red');
|
||||||
return 'bg-muted-foreground/60';
|
return statusDotToneClass('neutral');
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isActivePipelineStatus(status?: string | null) {
|
export function isActivePipelineStatus(status?: string | null) {
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { Activity, Boxes, FileKey, 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>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -226,7 +227,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">
|
||||||
|
|||||||
@@ -0,0 +1,444 @@
|
|||||||
|
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';
|
||||||
|
}
|
||||||
|
|
||||||
|
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 [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 filteredEvents = events.filter((event) => {
|
||||||
|
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();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
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="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>
|
||||||
|
|
||||||
|
{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} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ 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, getStoredSelectedComponentId, selectedComponentChangedEventName, setStoredSelectedAppId, setStoredSelectedComponentId } from '../../lib/selected-app';
|
import { getStoredSelectedAppId, getStoredSelectedComponentId, selectedComponentChangedEventName, setStoredSelectedAppId, setStoredSelectedComponentId } from '../../lib/selected-app';
|
||||||
|
import { statusDotToneClass } from '../../lib/status-dot';
|
||||||
import type { ApiApp, ApiAppComponent, RuntimeEnvProfile, RuntimeEnvVariable, RuntimeIngressRule, RuntimeResource } from '../../types/runtime';
|
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 {
|
||||||
@@ -40,13 +41,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');
|
||||||
}
|
}
|
||||||
@@ -520,7 +514,7 @@ export function RuntimeConfigPage() {
|
|||||||
<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>
|
||||||
@@ -626,7 +620,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>
|
||||||
|
|||||||
@@ -4,18 +4,12 @@ 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';
|
||||||
@@ -59,12 +53,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) {
|
||||||
@@ -311,7 +305,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">
|
||||||
|
|||||||
Reference in New Issue
Block a user