diff --git a/app/gateway-frontend/e2e/server-operations.spec.ts b/app/gateway-frontend/e2e/server-operations.spec.ts index b57ed750..3ae70edb 100644 --- a/app/gateway-frontend/e2e/server-operations.spec.ts +++ b/app/gateway-frontend/e2e/server-operations.spec.ts @@ -66,6 +66,23 @@ type FixtureState = { scenarioFailuresRemaining?: number; resetDefaults?: Record; updateMetaFails?: boolean; + bulkBatches?: Array<{ + id: string; + sourceMode: 'BRANCH' | 'COMMIT'; + sourceRef: string; + resolvedCommitSha: string; + requestedBy: string; + createdAt: string; + status: OperationStatus; + targets: Array<{ + kind: 'GATEWAY' | 'PROFILE'; + order: number; + label: string; + profileName?: string; + operationId: string; + status: OperationStatus; + }>; + }>; }; const profile = (runtimeRunning: boolean, resetDefaults?: Record) => ({ @@ -220,6 +237,66 @@ const installFixture = async (page: Page, state: FixtureState) => { ] ); } + if (name === 'admin.bulkReleases.targets') { + return response({ + gateway: true, + profiles: [ + { + profileName: 'che:default', + displayName: '체', + status: 'RUNNING', + currentScenario: '2', + buildCommitSha: '0123456789abcdef0123456789abcdef01234567', + activeOperation: null, + }, + { + profileName: 'hwe:default', + displayName: '환상', + status: 'RUNNING', + currentScenario: '1010', + buildCommitSha: 'fedcba9876543210fedcba9876543210fedcba98', + activeOperation: { + id: '99999999-9999-4999-8999-999999999999', + type: 'DEPLOY', + status: 'RUNNING', + }, + }, + ], + }); + } + if (name === 'admin.bulkReleases.list') { + return response(state.bulkBatches ?? []); + } + if (name === 'admin.bulkReleases.request') { + const batch = { + id: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', + sourceMode: 'BRANCH' as const, + sourceRef: 'main', + resolvedCommitSha: '1234567890abcdef1234567890abcdef12345678', + requestedBy: 'admin', + createdAt: '2026-08-25T01:00:00.000Z', + status: 'QUEUED' as const, + targets: [ + { + kind: 'GATEWAY' as const, + order: 0, + label: 'Gateway', + operationId: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', + status: 'QUEUED' as const, + }, + { + kind: 'PROFILE' as const, + order: 1, + label: '체', + profileName: 'che:default', + operationId: 'cccccccc-cccc-4ccc-8ccc-cccccccccccc', + status: 'QUEUED' as const, + }, + ], + }; + state.bulkBatches = [batch]; + return response({ id: batch.id, resolvedCommitSha: batch.resolvedCommitSha, targetCount: 2 }); + } if (name === 'admin.operations.list') { return response(state.operations); } @@ -530,6 +607,91 @@ const deferred = () => { return { promise, resolve }; }; +test('selects authorized Gateway and profile targets and registers one pinned bulk update', async ({ + page, +}, testInfo) => { + const state: FixtureState = { + operations: [], + gatewayOperations: [], + runtimeRunning: true, + requestBodies: [], + }; + await installFixture(page, state); + page.on('dialog', (dialog) => dialog.accept()); + + await page.goto('admin/releases/batch'); + await expect(page.getByRole('heading', { name: '일괄 업데이트', level: 1 })).toBeVisible(); + await expect(page.getByTestId('bulk-target-gateway')).toBeEnabled(); + await expect(page.getByTestId('bulk-target-che:default')).toBeEnabled(); + await expect(page.getByTestId('bulk-target-hwe:default')).toBeDisabled(); + await page.getByTestId('bulk-target-gateway').check(); + await page.getByTestId('bulk-target-che:default').check(); + await page.getByTestId('submit-bulk-release').click(); + + await expect(page.getByText('1234567890ab', { exact: true })).toBeVisible(); + await expect(page.getByText('Gateway', { exact: true }).last()).toBeVisible(); + await expect(page.getByText('체', { exact: true }).last()).toBeVisible(); + const request = state.requestBodies.find((entry) => entry.operation === 'admin.bulkReleases.request'); + expect(JSON.stringify(request?.body)).toContain('che:default'); + expect(JSON.stringify(request?.body)).toContain('includeGateway'); + + const formBox = await page.getByTestId('bulk-release-form').boundingBox(); + expect(formBox?.width ?? 0).toBeGreaterThan(700); + await page.screenshot({ path: testInfo.outputPath('bulk-release-desktop.png'), fullPage: true }); +}); + +test('keeps bulk target selection and progress readable on a 390px mobile viewport', async ({ page }, testInfo) => { + await page.setViewportSize({ width: 390, height: 844 }); + const state: FixtureState = { + operations: [], + gatewayOperations: [], + runtimeRunning: true, + requestBodies: [], + bulkBatches: [ + { + id: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', + sourceMode: 'COMMIT', + sourceRef: '1234567890abcdef1234567890abcdef12345678', + resolvedCommitSha: '1234567890abcdef1234567890abcdef12345678', + requestedBy: 'admin', + createdAt: '2026-08-25T01:00:00.000Z', + status: 'FAILED', + targets: [ + { + kind: 'GATEWAY', + order: 0, + label: 'Gateway', + operationId: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', + status: 'SUCCEEDED', + }, + { + kind: 'PROFILE', + order: 1, + label: '체', + profileName: 'che:default', + operationId: 'cccccccc-cccc-4ccc-8ccc-cccccccccccc', + status: 'FAILED', + }, + ], + }, + ], + }; + await installFixture(page, state); + await page.goto('admin/releases/batch'); + await page.getByText('1234567890ab', { exact: true }).click(); + await expect(page.getByRole('button', { name: '재시도' })).toBeVisible(); + + const metrics = await page.evaluate(() => ({ + scrollWidth: document.documentElement.scrollWidth, + clientWidth: document.documentElement.clientWidth, + checkboxSize: getComputedStyle(document.querySelector('[data-testid="bulk-target-gateway"]')!) + .width, + })); + expect(metrics.scrollWidth).toBeLessThanOrEqual(metrics.clientWidth); + expect(Number.parseFloat(metrics.checkboxSize)).toBeGreaterThanOrEqual(18); + await page.screenshot({ path: testInfo.outputPath('bulk-release-mobile.png'), fullPage: true }); +}); + test('separates branch and commit semantics and submits a reset from the dedicated page', async ({ page, }, testInfo) => { diff --git a/app/gateway-frontend/src/layouts/AdminConsoleLayout.vue b/app/gateway-frontend/src/layouts/AdminConsoleLayout.vue index 1e05104e..01615a26 100644 --- a/app/gateway-frontend/src/layouts/AdminConsoleLayout.vue +++ b/app/gateway-frontend/src/layouts/AdminConsoleLayout.vue @@ -80,6 +80,14 @@ const navigation = computed(() => [ { label: 'Gateway', items: [ + { + to: '/admin/releases/batch', + label: '일괄 업데이트', + icon: '⇈', + exact: false, + visible: hasCapability('admin.releases.manage') || hasCapability('admin.profiles.deploy'), + child: false, + }, { to: '/admin/releases', label: 'Gateway 릴리스', diff --git a/app/gateway-frontend/src/router/index.ts b/app/gateway-frontend/src/router/index.ts index c3f03239..10c81ba2 100644 --- a/app/gateway-frontend/src/router/index.ts +++ b/app/gateway-frontend/src/router/index.ts @@ -6,6 +6,7 @@ const OpenSuggestionView = () => import('../views/OpenSuggestionView.vue'); const AdminOverviewView = () => import('../views/AdminOverviewView.vue'); const AdminView = () => import('../views/AdminView.vue'); const ServerOperationsView = () => import('../views/ServerOperationsView.vue'); +const BulkReleaseView = () => import('../views/BulkReleaseView.vue'); const AccountView = () => import('../views/AccountView.vue'); const OAuthCallbackView = () => import('../views/OAuthCallbackView.vue'); const SignupView = () => import('../views/SignupView.vue'); @@ -86,6 +87,11 @@ const router = createRouter({ component: AdminView, props: { section: 'audit' }, }, + { + path: '/admin/releases/batch', + name: 'admin-bulk-releases', + component: BulkReleaseView, + }, { path: '/admin/releases', name: 'admin-releases', diff --git a/app/gateway-frontend/src/views/AdminOverviewView.vue b/app/gateway-frontend/src/views/AdminOverviewView.vue index 9460ad9e..3351fe71 100644 --- a/app/gateway-frontend/src/views/AdminOverviewView.vue +++ b/app/gateway-frontend/src/views/AdminOverviewView.vue @@ -35,6 +35,14 @@ const sections = computed( tone: 'emerald', visible: isRootAdmin.value || profileCount.value > 0, }, + { + to: '/admin/releases/batch', + eyebrow: 'Release batch', + title: '일괄 업데이트', + description: 'Gateway와 권한 있는 서버를 동일한 고정 커밋으로 순차 업데이트합니다.', + tone: 'blue', + visible: hasCapability('admin.releases.manage') || hasCapability('admin.profiles.deploy'), + }, { to: '/admin/releases', eyebrow: 'Releases', diff --git a/app/gateway-frontend/src/views/AdminView.vue b/app/gateway-frontend/src/views/AdminView.vue index 27b13bb8..d5b10923 100644 --- a/app/gateway-frontend/src/views/AdminView.vue +++ b/app/gateway-frontend/src/views/AdminView.vue @@ -2384,15 +2384,24 @@ onMounted(() => {
-
+

서버별 관리

- +
+ + 일괄 업데이트 + + +
+import { formatServerDateTime } from '@sammo-ts/common/time/ServerDateTime'; +import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue'; + +import AdminConsoleLayout from '../layouts/AdminConsoleLayout.vue'; +import { useToast } from '../composables/useToast'; +import { trpc } from '../utils/trpc'; + +type OperationStatus = 'QUEUED' | 'RUNNING' | 'SUCCEEDED' | 'FAILED' | 'CANCELLED'; +type BulkTarget = { + kind: 'GATEWAY' | 'PROFILE'; + order: number; + label: string; + profileName?: string; + operationId: string; + status: OperationStatus; + error?: string; + startedAt?: string; + completedAt?: string; +}; +type BulkRelease = { + id: string; + sourceMode: 'BRANCH' | 'COMMIT'; + sourceRef: string; + resolvedCommitSha: string; + reason?: string; + requestedBy: string; + createdAt: string; + status: OperationStatus; + targets: BulkTarget[]; +}; +type AvailableProfile = { + profileName: string; + displayName: string; + status: string; + currentScenario: string | null; + buildCommitSha?: string; + activeOperation?: { id: string; type: string; status: OperationStatus } | null; + scheduledResetAt?: string; +}; + +const adminClient = trpc.admin as unknown as { + bulkReleases: { + targets: { query: () => Promise<{ gateway: boolean; profiles: AvailableProfile[] }> }; + list: { query: (input: { limit: number }) => Promise }; + request: { + mutate: (input: { + includeGateway: boolean; + profileNames: string[]; + sourceMode: 'BRANCH' | 'COMMIT'; + sourceRef: string; + reason?: string; + }) => Promise<{ id: string; resolvedCommitSha: string; targetCount: number }>; + }; + }; + operations: { retry: { mutate: (input: { id: string }) => Promise } }; + releases: { retry: { mutate: (input: { id: string }) => Promise } }; +}; + +const form = reactive({ + sourceMode: 'BRANCH' as 'BRANCH' | 'COMMIT', + sourceRef: 'main', + reason: '', +}); +const gatewayAvailable = ref(false); +const includeGateway = ref(false); +const profiles = ref([]); +const selectedProfileNames = ref([]); +const batches = ref([]); +const loading = ref(false); +const submitting = ref(false); +const errorMessage = ref(''); +const expandedBatchId = ref(''); +const { success: showSuccessToast, error: showErrorToast } = useToast(); +let pollTimer: ReturnType | undefined; + +const selectableProfiles = computed(() => profiles.value.filter((profile) => !profile.activeOperation)); +const selectedCount = computed(() => selectedProfileNames.value.length + (includeGateway.value ? 1 : 0)); +const allProfilesSelected = computed( + () => + selectableProfiles.value.length > 0 && + selectableProfiles.value.every((profile) => selectedProfileNames.value.includes(profile.profileName)) +); +const hasActiveBatch = computed(() => + batches.value.some((batch) => batch.status === 'QUEUED' || batch.status === 'RUNNING') +); + +const statusLabel = (status: OperationStatus): string => + ({ + QUEUED: '대기 중', + RUNNING: '진행 중', + SUCCEEDED: '완료', + FAILED: '실패', + CANCELLED: '중단됨', + })[status]; + +const shortSha = (value?: string): string => value?.slice(0, 12) ?? '-'; + +const toggleAllProfiles = () => { + selectedProfileNames.value = allProfilesSelected.value + ? [] + : selectableProfiles.value.map((profile) => profile.profileName); +}; + +const loadTargets = async () => { + const result = await adminClient.bulkReleases.targets.query(); + gatewayAvailable.value = result.gateway; + profiles.value = result.profiles; + if (!gatewayAvailable.value) includeGateway.value = false; + const availableNames = new Set(selectableProfiles.value.map((profile) => profile.profileName)); + selectedProfileNames.value = selectedProfileNames.value.filter((profileName) => availableNames.has(profileName)); +}; + +const loadBatches = async () => { + batches.value = await adminClient.bulkReleases.list.query({ limit: 20 }); +}; + +const loadState = async () => { + if (loading.value) return; + loading.value = true; + try { + await Promise.all([loadTargets(), loadBatches()]); + errorMessage.value = ''; + } catch (error) { + errorMessage.value = error instanceof Error ? error.message : '일괄 업데이트 정보를 불러오지 못했습니다.'; + } finally { + loading.value = false; + } +}; + +const submit = async () => { + errorMessage.value = ''; + const sourceRef = form.sourceRef.trim(); + if (!selectedCount.value || !sourceRef) return; + const labels = [ + ...(includeGateway.value ? ['Gateway'] : []), + ...profiles.value + .filter((profile) => selectedProfileNames.value.includes(profile.profileName)) + .map((profile) => profile.displayName), + ]; + if ( + !window.confirm( + `${labels.join(' · ')}을(를) ${sourceRef}의 동일 커밋으로 순차 업데이트하시겠습니까? 각 profile의 게임 DB는 유지됩니다.` + ) + ) { + return; + } + submitting.value = true; + try { + const result = await adminClient.bulkReleases.request.mutate({ + includeGateway: includeGateway.value, + profileNames: selectedProfileNames.value, + sourceMode: form.sourceMode, + sourceRef, + reason: form.reason.trim() || undefined, + }); + includeGateway.value = false; + selectedProfileNames.value = []; + expandedBatchId.value = result.id; + showSuccessToast( + `${result.targetCount}개 대상의 일괄 업데이트를 ${shortSha(result.resolvedCommitSha)}로 등록했습니다.` + ); + await loadState(); + } catch (error) { + errorMessage.value = error instanceof Error ? error.message : '일괄 업데이트 등록에 실패했습니다.'; + showErrorToast(errorMessage.value); + } finally { + submitting.value = false; + } +}; + +const retryTarget = async (target: BulkTarget) => { + if (!window.confirm(`${target.label} 작업을 일괄 업데이트의 고정 커밋으로 다시 실행하시겠습니까?`)) return; + try { + if (target.kind === 'GATEWAY') { + await adminClient.releases.retry.mutate({ id: target.operationId }); + } else { + await adminClient.operations.retry.mutate({ id: target.operationId }); + } + showSuccessToast(`${target.label} 재시도를 등록했습니다.`); + await loadState(); + } catch (error) { + showErrorToast(error instanceof Error ? error.message : '재시도 등록에 실패했습니다.'); + } +}; + +onMounted(async () => { + await loadState(); + pollTimer = setInterval(() => void loadState(), 2_000); +}); + +onBeforeUnmount(() => { + if (pollTimer) clearInterval(pollTimer); +}); + + + + + diff --git a/docs/admin-console.md b/docs/admin-console.md index 9fd82d1a..de89dfd0 100644 --- a/docs/admin-console.md +++ b/docs/admin-console.md @@ -8,18 +8,19 @@ Gateway 관리자 콘솔은 `/gateway/admin`에서 시작합니다. 공개 로 좌측 메뉴는 관리 책임을 다음과 같이 분리합니다. -| 메뉴 | 경로 | 책임 | -| --------------- | ---------------------------------------------- | ------------------------------------------------------------------------- | -| 운영 개요 | `/gateway/admin` | 현재 권한으로 접근할 수 있는 관리 영역 안내 | -| 사용자 관리 | `/gateway/admin/users` | 계정 식별자·Kakao 교체, 권한, 특수 접근·제재, 아이콘 복구와 탈퇴 예약 | -| 서버 관리 | `/gateway/admin/servers` | 접근 가능한 profile 목록 | -| 서버 상태·설정 | `/gateway/admin/servers/:profileName` | 해당 profile의 공개 정보, 계정 정책, 실행 상태와 게임 운영 동작 | -| 버전 업데이트 | `/gateway/admin/servers/:profileName/version` | 현 DB를 보존하는 profile 코드·migration 배포 | -| 시나리오 초기화 | `/gateway/admin/servers/:profileName/scenario` | 서버 지정 branch 최신 또는 고정 commit으로 현 시즌 DB와 시나리오 교체 | -| 게임 취소 | `/gateway/admin/servers/:profileName/cancel` | 잘못 연 게임을 닫고 기록·유산 포인트를 취소 정책에 따라 원자적으로 정산 | -| Gateway 릴리스 | `/gateway/admin/releases` | Gateway control plane 배포와 rollback | -| 공지 · 접속 | `/gateway/admin/system` | 로비 공지와 관리자 세션 연결 | -| 감사 로그 | `/gateway/admin/audit` | 관리자 조치 결과, 대상과 사유 조회 | +| 메뉴 | 경로 | 책임 | +| --------------- | ---------------------------------------------- | ----------------------------------------------------------------------- | +| 운영 개요 | `/gateway/admin` | 현재 권한으로 접근할 수 있는 관리 영역 안내 | +| 사용자 관리 | `/gateway/admin/users` | 계정 식별자·Kakao 교체, 권한, 특수 접근·제재, 아이콘 복구와 탈퇴 예약 | +| 서버 관리 | `/gateway/admin/servers` | 접근 가능한 profile 목록 | +| 서버 상태·설정 | `/gateway/admin/servers/:profileName` | 해당 profile의 공개 정보, 계정 정책, 실행 상태와 게임 운영 동작 | +| 버전 업데이트 | `/gateway/admin/servers/:profileName/version` | 현 DB를 보존하는 profile 코드·migration 배포 | +| 시나리오 초기화 | `/gateway/admin/servers/:profileName/scenario` | 서버 지정 branch 최신 또는 고정 commit으로 현 시즌 DB와 시나리오 교체 | +| 게임 취소 | `/gateway/admin/servers/:profileName/cancel` | 잘못 연 게임을 닫고 기록·유산 포인트를 취소 정책에 따라 원자적으로 정산 | +| 일괄 업데이트 | `/gateway/admin/releases/batch` | Gateway와 권한 있는 profile을 하나의 고정 commit으로 순차 DB 보존 배포 | +| Gateway 릴리스 | `/gateway/admin/releases` | Gateway control plane 배포와 rollback | +| 공지 · 접속 | `/gateway/admin/system` | 로비 공지와 관리자 세션 연결 | +| 감사 로그 | `/gateway/admin/audit` | 관리자 조치 결과, 대상과 사유 조회 | 기존 `/gateway/admin/server-operations` 링크는 query string을 보존한 채 `/gateway/admin/servers`로 이동합니다. 즐겨찾기와 이전 운영 보고서의 링크를 @@ -106,6 +107,12 @@ Gateway 관리자 콘솔은 `/gateway/admin`에서 시작합니다. 공개 로 사용하며 외부 release-controller가 실행합니다. 선택한 릴리스 작업의 단계와 명령 출력을 관리자 화면이 long polling으로 이어 받아 표시하며, 완료된 이력의 로그도 다시 열 수 있습니다. +- 일괄 업데이트는 `admin.releases.manage`가 있는 경우에만 Gateway를, 각 + `admin.profiles.deploy:` 범위 안의 profile만 선택 대상으로 표시합니다. + 브랜치는 묶음 등록 시 서버에서 한 번 full commit SHA로 해석하며 Gateway를 먼저, + profile은 관리자 목록 순서대로 실행합니다. 앞 대상이 실패하거나 중단되면 뒤 대상은 + `QUEUED`로 유지되고, 실패 대상을 같은 고정 commit으로 재시도하면 이어서 실행합니다. + 묶음은 원자적 rollback이 아니므로 이미 성공한 대상은 그대로 유지합니다. - 브라우저의 메뉴 노출은 편의 기능입니다. 권한 판단의 기준은 서버가 인증 session에서 해석한 capability입니다.