From 3e938e17d6beca420a7c928f1c81d97cd58e8753 Mon Sep 17 00:00:00 2001 From: hided62 Date: Tue, 11 Aug 2026 11:59:18 +0000 Subject: [PATCH 1/3] perf: share Turbo cache across release worktrees --- .../src/orchestrator/buildRunner.ts | 30 ++++++++++++ .../src/orchestrator/gatewayOrchestrator.ts | 39 +++++++-------- app/gateway-api/test/buildRunner.test.ts | 49 ++++++++++++++++++- app/gateway-api/test/orchestratorPlan.test.ts | 21 +++++--- app/release-controller/README.md | 11 ++--- .../src/releaseController.ts | 21 +++++--- app/release-controller/src/selfUpgrade.ts | 9 +--- .../test/releaseController.test.ts | 18 +++++-- docs/release-operations.md | 13 ++++- turbo.json | 7 ++- 10 files changed, 159 insertions(+), 59 deletions(-) diff --git a/app/gateway-api/src/orchestrator/buildRunner.ts b/app/gateway-api/src/orchestrator/buildRunner.ts index 56c3cebf..c936832d 100644 --- a/app/gateway-api/src/orchestrator/buildRunner.ts +++ b/app/gateway-api/src/orchestrator/buildRunner.ts @@ -1,4 +1,5 @@ import { spawn } from 'node:child_process'; +import path from 'node:path'; export interface BuildCommand { command: string; @@ -25,6 +26,35 @@ export interface BuildRunner { } export const MAX_BUILD_OUTPUT_CHARS = 64 * 1024; +export const RELEASE_TURBO_CONCURRENCY = 2; + +export const resolveReleaseTurboCacheDir = (cacheAnchorRoot: string, env?: Record): string => { + const configured = env?.TURBO_CACHE_DIR?.trim(); + if (!configured) return path.join(path.resolve(cacheAnchorRoot), '.turbo', 'release-cache'); + return path.isAbsolute(configured) ? configured : path.resolve(cacheAnchorRoot, configured); +}; + +export const buildTurboReleaseCommand = ( + workspaceRoot: string, + cacheAnchorRoot: string, + packageNames: string[], + env?: Record +): BuildCommand => ({ + command: 'pnpm', + args: [ + 'exec', + 'turbo', + 'run', + 'build', + ...packageNames.map((packageName) => `--filter=${packageName}`), + `--cache-dir=${resolveReleaseTurboCacheDir(cacheAnchorRoot, env)}`, + `--concurrency=${RELEASE_TURBO_CONCURRENCY}`, + '--ui=stream', + '--output-logs=new-only', + ], + cwd: workspaceRoot, + env, +}); const appendOutputTail = (current: string, chunk: unknown): string => `${current}${String(chunk)}`.slice(-MAX_BUILD_OUTPUT_CHARS); diff --git a/app/gateway-api/src/orchestrator/gatewayOrchestrator.ts b/app/gateway-api/src/orchestrator/gatewayOrchestrator.ts index fc0055e5..3f72c3ee 100644 --- a/app/gateway-api/src/orchestrator/gatewayOrchestrator.ts +++ b/app/gateway-api/src/orchestrator/gatewayOrchestrator.ts @@ -12,7 +12,7 @@ import { } from '@sammo-ts/infra'; import { isRecord } from '@sammo-ts/common'; -import type { BuildCommand, BuildRunner } from './buildRunner.js'; +import { buildTurboReleaseCommand, type BuildCommand, type BuildRunner } from './buildRunner.js'; import { sanitizeManagedProcessEnv, type ProcessManager } from './processManager.js'; import type { GatewayClaimedProfileUpdate, @@ -494,7 +494,8 @@ export const buildProfileFrontendCommands = ( export const buildWorkspaceCommands = ( workspaceRoot: string, needsInstall: boolean, - env?: Record + env?: Record, + cacheAnchorRoot: string = workspaceRoot ): BuildCommand[] => { const commands: BuildCommand[] = []; if (needsInstall) { @@ -505,23 +506,9 @@ export const buildWorkspaceCommands = ( env, }); } - const buildSteps: Array<[filter: string, script: string]> = [ - ['@sammo-ts/common', 'build'], - ['@sammo-ts/infra', 'prisma:generate'], - ['@sammo-ts/infra', 'build'], - ['@sammo-ts/logic', 'build'], - ['@sammo-ts/game-api', 'build'], - ['@sammo-ts/game-engine', 'build'], - ['@sammo-ts/gateway-api', 'build'], - ]; - for (const [filter, script] of buildSteps) { - commands.push({ - command: 'pnpm', - args: ['--filter', filter, script], - cwd: workspaceRoot, - env, - }); - } + commands.push( + buildTurboReleaseCommand(workspaceRoot, cacheAnchorRoot, ['@sammo-ts/game-api', '@sammo-ts/gateway-api'], env) + ); return commands; }; @@ -1061,7 +1048,12 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle { const manifest = await readReleaseManifest(workspace.root); assertReleaseComponents(manifest, ['game-api', 'game-engine', 'game-frontend']); const commands = [ - ...buildWorkspaceCommands(workspace.root, workspace.needsInstall, this.processConfig.baseEnv), + ...buildWorkspaceCommands( + workspace.root, + workspace.needsInstall, + this.processConfig.baseEnv, + this.processConfig.workspaceRoot + ), ...buildProfileFrontendCommands(workspace.root, profile, this.processConfig.baseEnv), ]; const result = await this.buildRunner.run(commands); @@ -1552,7 +1544,12 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle { }> { const workspace = await this.workspaceManager.prepare(commitSha); const commands = [ - ...buildWorkspaceCommands(workspace.root, workspace.needsInstall, this.processConfig.baseEnv), + ...buildWorkspaceCommands( + workspace.root, + workspace.needsInstall, + this.processConfig.baseEnv, + this.processConfig.workspaceRoot + ), ...(profile ? buildProfileFrontendCommands(workspace.root, profile, this.processConfig.baseEnv) : []), ]; return { result: await this.buildRunner.run(commands), workspace }; diff --git a/app/gateway-api/test/buildRunner.test.ts b/app/gateway-api/test/buildRunner.test.ts index 134561c6..b0ec6cb7 100644 --- a/app/gateway-api/test/buildRunner.test.ts +++ b/app/gateway-api/test/buildRunner.test.ts @@ -2,7 +2,54 @@ import path from 'node:path'; import { describe, expect, it } from 'vitest'; -import { MAX_BUILD_OUTPUT_CHARS, PnpmBuildRunner } from '../src/orchestrator/buildRunner.js'; +import { + buildTurboReleaseCommand, + MAX_BUILD_OUTPUT_CHARS, + PnpmBuildRunner, + resolveReleaseTurboCacheDir, +} from '../src/orchestrator/buildRunner.js'; + +describe('Turbo release build plan', () => { + it('anchors the default cache outside commit worktrees and allows an operator override', () => { + expect(resolveReleaseTurboCacheDir('/srv/core/repository')).toBe('/srv/core/repository/.turbo/release-cache'); + expect( + resolveReleaseTurboCacheDir('/srv/core/repository', { + TURBO_CACHE_DIR: '/srv/core/cache/turbo', + }) + ).toBe('/srv/core/cache/turbo'); + expect( + resolveReleaseTurboCacheDir('/srv/core/repository', { + TURBO_CACHE_DIR: '.cache/turbo', + }) + ).toBe('/srv/core/repository/.cache/turbo'); + }); + + it('uses a bounded streaming Turbo build for the selected packages', () => { + expect( + buildTurboReleaseCommand( + '/srv/core/profile-worktrees/commit', + '/srv/core/repository', + ['@sammo-ts/game-api'], + { NODE_ENV: 'production' } + ) + ).toEqual({ + command: 'pnpm', + args: [ + 'exec', + 'turbo', + 'run', + 'build', + '--filter=@sammo-ts/game-api', + '--cache-dir=/srv/core/repository/.turbo/release-cache', + '--concurrency=2', + '--ui=stream', + '--output-logs=new-only', + ], + cwd: '/srv/core/profile-worktrees/commit', + env: { NODE_ENV: 'production' }, + }); + }); +}); describe('PnpmBuildRunner', () => { it('returns a failed result when a command cannot be spawned', async () => { diff --git a/app/gateway-api/test/orchestratorPlan.test.ts b/app/gateway-api/test/orchestratorPlan.test.ts index 4d739886..3c5609a2 100644 --- a/app/gateway-api/test/orchestratorPlan.test.ts +++ b/app/gateway-api/test/orchestratorPlan.test.ts @@ -225,17 +225,22 @@ describe('sanitizeManagedProcessEnv', () => { describe('buildWorkspaceCommands', () => { it('installs and builds runtime dependencies before the profile processes', () => { const workspaceRoot = '/srv/sammo/worktrees/0123456789abcdef'; - const commands = buildWorkspaceCommands(workspaceRoot, true); + const commands = buildWorkspaceCommands(workspaceRoot, true, undefined, '/srv/sammo/controller'); expect(commands.map(({ args }) => args)).toEqual([ ['install', '--frozen-lockfile'], - ['--filter', '@sammo-ts/common', 'build'], - ['--filter', '@sammo-ts/infra', 'prisma:generate'], - ['--filter', '@sammo-ts/infra', 'build'], - ['--filter', '@sammo-ts/logic', 'build'], - ['--filter', '@sammo-ts/game-api', 'build'], - ['--filter', '@sammo-ts/game-engine', 'build'], - ['--filter', '@sammo-ts/gateway-api', 'build'], + [ + 'exec', + 'turbo', + 'run', + 'build', + '--filter=@sammo-ts/game-api', + '--filter=@sammo-ts/gateway-api', + '--cache-dir=/srv/sammo/controller/.turbo/release-cache', + '--concurrency=2', + '--ui=stream', + '--output-logs=new-only', + ], ]); expect(commands.every(({ cwd }) => cwd === workspaceRoot)).toBe(true); }); diff --git a/app/release-controller/README.md b/app/release-controller/README.md index 05d895c2..33523f5b 100644 --- a/app/release-controller/README.md +++ b/app/release-controller/README.md @@ -29,6 +29,9 @@ Gateway process 환경에 전달하지 않습니다. 이 값이 frontend 정의 frontend build 계약입니다. - `RELEASE_CONTROLLER_POLL_MS`, `RELEASE_CONTROLLER_READINESS_TIMEOUT_MS`: queue poll과 준비 제한 시간입니다. +- `TURBO_CACHE_DIR`: 선택 사항인 공유 local cache 경로입니다. 없으면 원래 + `RELEASE_CONTROLLER_WORKSPACE_ROOT/.turbo/release-cache`를 사용합니다. 상대 경로는 + 원래 workspace 기준으로 해석합니다. 비밀값은 Git에서 제외된 환경 파일 또는 process 환경으로 전달해 주세요. `VITE_*`에는 공개 URL만 넣어 주세요. @@ -46,13 +49,7 @@ DEPLOY의 rollback이 frontend build가 없는 controller worktree를 이전 Gat ```sh pnpm install --frozen-lockfile -pnpm --filter @sammo-ts/infra prisma:generate -pnpm --filter @sammo-ts/common build -pnpm --filter @sammo-ts/infra build -pnpm --filter @sammo-ts/logic build -pnpm --filter @sammo-ts/game-engine build -pnpm --filter @sammo-ts/gateway-api build -pnpm --filter @sammo-ts/release-controller build +pnpm exec turbo run build --filter=@sammo-ts/release-controller --concurrency=2 --ui=stream pnpm --filter @sammo-ts/infra prisma:migrate:deploy:gateway pnpm --filter @sammo-ts/release-controller start ``` diff --git a/app/release-controller/src/releaseController.ts b/app/release-controller/src/releaseController.ts index 879a3b5c..5461da2c 100644 --- a/app/release-controller/src/releaseController.ts +++ b/app/release-controller/src/releaseController.ts @@ -4,6 +4,7 @@ import { stripVTControlCharacters } from 'node:util'; import { assertReleaseComponents, + buildTurboReleaseCommand, type BuildCommand, type BuildProgressEvent, type BuildRunner, @@ -38,13 +39,12 @@ export const buildGatewayReleaseCommands = ( }; return [ ...(needsInstall ? [{ command: 'pnpm', args: ['install', '--frozen-lockfile'], cwd: workspaceRoot, env }] : []), - { command: 'pnpm', args: ['--filter', '@sammo-ts/common', 'build'], cwd: workspaceRoot, env }, - { command: 'pnpm', args: ['--filter', '@sammo-ts/infra', 'prisma:generate'], cwd: workspaceRoot, env }, - { command: 'pnpm', args: ['--filter', '@sammo-ts/infra', 'build'], cwd: workspaceRoot, env }, - { command: 'pnpm', args: ['--filter', '@sammo-ts/logic', 'build'], cwd: workspaceRoot, env }, - { command: 'pnpm', args: ['--filter', '@sammo-ts/game-engine', 'build'], cwd: workspaceRoot, env }, - { command: 'pnpm', args: ['--filter', '@sammo-ts/gateway-api', 'build'], cwd: workspaceRoot, env }, - { command: 'pnpm', args: ['--filter', '@sammo-ts/gateway-frontend', 'build'], cwd: workspaceRoot, env }, + buildTurboReleaseCommand( + workspaceRoot, + config.workspaceRoot, + ['@sammo-ts/gateway-api', '@sammo-ts/gateway-frontend'], + env + ), ]; }; @@ -244,7 +244,12 @@ export class GatewayReleaseController { await this.startDefinitions(buildGatewayProcessDefinitions(workspace.root, this.config), operation.id); await this.waitForReadiness(operation.id); } catch (error) { - await this.appendLog(operation.id, 'rollback', '새 Gateway 시작에 실패하여 이전 process를 복구합니다.', 'ERROR'); + await this.appendLog( + operation.id, + 'rollback', + '새 Gateway 시작에 실패하여 이전 process를 복구합니다.', + 'ERROR' + ); await this.stopManagedProcesses(operation.id); if (previousDefinitions.length) { await this.startDefinitions(previousDefinitions, operation.id); diff --git a/app/release-controller/src/selfUpgrade.ts b/app/release-controller/src/selfUpgrade.ts index 9f5cdfe8..8fd87c2b 100644 --- a/app/release-controller/src/selfUpgrade.ts +++ b/app/release-controller/src/selfUpgrade.ts @@ -2,6 +2,7 @@ import path from 'node:path'; import { assertReleaseComponents, + buildTurboReleaseCommand, type BuildCommand, type BuildRunner, type GitWorkspaceManager, @@ -24,13 +25,7 @@ export const buildReleaseControllerCommands = ( const env = sanitizeManagedProcessEnv(config.baseEnv); return [ ...(needsInstall ? [{ command: 'pnpm', args: ['install', '--frozen-lockfile'], cwd: workspaceRoot, env }] : []), - { command: 'pnpm', args: ['--filter', '@sammo-ts/common', 'build'], cwd: workspaceRoot, env }, - { command: 'pnpm', args: ['--filter', '@sammo-ts/infra', 'prisma:generate'], cwd: workspaceRoot, env }, - { command: 'pnpm', args: ['--filter', '@sammo-ts/infra', 'build'], cwd: workspaceRoot, env }, - { command: 'pnpm', args: ['--filter', '@sammo-ts/logic', 'build'], cwd: workspaceRoot, env }, - { command: 'pnpm', args: ['--filter', '@sammo-ts/game-engine', 'build'], cwd: workspaceRoot, env }, - { command: 'pnpm', args: ['--filter', '@sammo-ts/gateway-api', 'build'], cwd: workspaceRoot, env }, - { command: 'pnpm', args: ['--filter', '@sammo-ts/release-controller', 'build'], cwd: workspaceRoot, env }, + buildTurboReleaseCommand(workspaceRoot, config.workspaceRoot, ['@sammo-ts/release-controller'], env), ]; }; diff --git a/app/release-controller/test/releaseController.test.ts b/app/release-controller/test/releaseController.test.ts index 1f9fd3f7..213dc704 100644 --- a/app/release-controller/test/releaseController.test.ts +++ b/app/release-controller/test/releaseController.test.ts @@ -209,6 +209,8 @@ describe('GatewayReleaseController', () => { expect(commandGroups).toHaveLength(2); expect(commandGroups[0]?.[0]).toBe('install --frozen-lockfile'); + expect(commandGroups[0]?.[1]).toContain('turbo run build'); + expect(commandGroups[0]?.[1]).toContain('--cache-dir=/srv/sammo/controller/.turbo/release-cache'); expect(commandGroups[1]).toEqual(['--filter @sammo-ts/infra prisma:migrate:deploy:gateway']); expect([...running.keys()].sort()).toEqual([...gatewayNames].sort()); expect(harness.published).toEqual([ @@ -216,7 +218,16 @@ describe('GatewayReleaseController', () => { ]); expect(harness.completions).toEqual(['SUCCEEDED']); expect(harness.logs.map((entry) => entry.phase)).toEqual( - expect.arrayContaining(['claim', 'resolve', 'workspace', 'build', 'migration', 'switch', 'readiness', 'publish']) + expect.arrayContaining([ + 'claim', + 'resolve', + 'workspace', + 'build', + 'migration', + 'switch', + 'readiness', + 'publish', + ]) ); }); @@ -275,8 +286,7 @@ describe('GatewayReleaseController', () => { await onProgress?.({ type: 'OUTPUT', stream: 'stdout', - message: - 'bootstrap-secret-value postgresql://operator:visible-password@db.invalid/sammo', + message: 'bootstrap-secret-value postgresql://operator:visible-password@db.invalid/sammo', }); return { ok: true, exitCode: 0, output: '' }; }, @@ -405,7 +415,7 @@ describe('upgradeReleaseController', () => { ).resolves.toEqual({ commitSha: SHA, workspace }); expect(commandGroups).toHaveLength(2); - expect(commandGroups[0]?.at(-1)).toBe('--filter @sammo-ts/release-controller build'); + expect(commandGroups[0]?.at(-1)).toContain('turbo run build --filter=@sammo-ts/release-controller'); expect(starts.at(-1)).toMatchObject({ name: 'sammo:release-controller', cwd: path.join(workspace, 'app', 'release-controller'), diff --git a/docs/release-operations.md b/docs/release-operations.md index 7bd570fd..83348466 100644 --- a/docs/release-operations.md +++ b/docs/release-operations.md @@ -44,6 +44,15 @@ profile 범위 권한과 별개인 전역 `admin.releases.manage` 권한이 필 - Root와 server package의 `tsdown`은 0.22.14 계열로 통일합니다. Docker runtime의 Node heap/Rayon 상한을 상속한 동일 toolchain으로 초기 Gateway와 profile worktree를 빌드하여 구형 Rolldown의 과도한 native thread 생성을 피합니다. +- Profile, Gateway와 controller self-upgrade의 server package build는 Turbo DAG를 + 동시성 2로 실행합니다. 기본 local cache는 원래 Core checkout의 + `.turbo/release-cache`이므로 commit별 worktree가 달라도 재사용됩니다. 별도 + persistent 경로가 필요하면 controller/orchestrator 환경에 `TURBO_CACHE_DIR`을 + 설정합니다. Cache는 재생성 가능한 build artifact이며 DB/Redis backup이 아닙니다. +- `NODE_ENV`와 Vite가 추론한 `VITE_*`는 build hash에 포함됩니다. 따라서 base path나 + API URL이 다른 frontend artifact를 cache hit로 잘못 복원하지 않습니다. + `NODE_OPTIONS`와 `RAYON_NUM_THREADS`는 출력에는 영향을 주지 않는 resource 제한으로 + build child에 전달됩니다. - migration 이후 이전 애플리케이션으로 돌아갈 때 schema 하위 호환성이 유지됩니다. @@ -113,7 +122,7 @@ Gateway는 자기 process를 직접 교체하지 않습니다. 관리자 화면 1. Source ref를 commit SHA로 고정하고 commit worktree를 준비합니다. 2. Release manifest의 protocol, component와 migration head를 검증합니다. -3. Gateway API와 frontend를 빌드하고 gateway migration을 적용합니다. +3. Gateway API와 frontend를 공유 Turbo cache로 빌드하고 gateway migration을 적용합니다. 4. `sammo:gateway-api`, `sammo:gateway-frontend`, `sammo:gateway-orchestrator`를 새 worktree definition으로 전환합니다. 5. Gateway API `/healthz`, `/gateway/`와 세 PM2 process의 `online` 상태를 @@ -137,6 +146,8 @@ Gateway 전체에는 활성 릴리스 작업을 동시에 하나만 둘 수 있 commit 해석, worktree 준비, build 명령 출력, migration, process 전환, readiness와 rollback 진행을 커서 순서대로 이어 붙입니다. 완료된 작업의 로그도 같은 이력에서 다시 열 수 있으며 화면은 최근 1,000줄을 유지합니다. +Build 로그의 `cache hit`/`cache miss`와 마지막 `Cached: N cached, M total`은 실제 +이번 릴리스의 cache 사용 여부를 나타냅니다. 로그 원본은 Gateway DB의 `GatewayReleaseLog`에 작업별로 저장되고 작업 삭제 시 함께 제거됩니다. Controller는 ANSI 제어 문자를 제거하고 secret·token·password diff --git a/turbo.json b/turbo.json index b791d8e2..f83d3229 100644 --- a/turbo.json +++ b/turbo.json @@ -1,17 +1,20 @@ { "$schema": "https://turbo.build/schema.json", "ui": "tui", + "globalPassThroughEnv": ["NODE_OPTIONS", "RAYON_NUM_THREADS"], "tasks": { "build": { "dependsOn": ["^build", "typecheck"], + "env": ["NODE_ENV"], + "inputs": ["$TURBO_DEFAULT$", ".env*"], "outputs": ["dist/**"] }, "typecheck": { - "dependsOn": ["^typecheck", "prisma:generate"], + "dependsOn": ["^build", "prisma:generate"], "cache": true }, "prisma:generate": { - "inputs": ["prisma/*.prisma"], + "inputs": ["$TURBO_DEFAULT$"], "outputs": ["node_modules/.prisma/client/**", "prisma/generated/**"], "cache": true }, From 0cff7f681f2f2e5b2c40dc737aa0a7a7b3a5863f Mon Sep 17 00:00:00 2001 From: hided62 Date: Tue, 11 Aug 2026 12:02:27 +0000 Subject: [PATCH 2/3] feat(gateway): show action result toasts --- .../e2e/admin-account-controls.spec.ts | 6 +- .../e2e/admin-runtime-actions.spec.ts | 2 +- .../e2e/hwe-lifecycle.spec.ts | 2 +- app/gateway-frontend/e2e/logout.spec.ts | 2 +- .../e2e/server-operations.spec.ts | 82 +++++++- app/gateway-frontend/src/App.vue | 2 + .../src/components/ToastViewport.vue | 183 ++++++++++++++++++ .../src/composables/useToast.ts | 59 ++++++ .../src/views/AccountView.vue | 7 +- app/gateway-frontend/src/views/AdminView.vue | 53 ++++- app/gateway-frontend/src/views/LobbyView.vue | 10 +- .../src/views/ServerOperationsView.vue | 5 + 12 files changed, 388 insertions(+), 25 deletions(-) create mode 100644 app/gateway-frontend/src/components/ToastViewport.vue create mode 100644 app/gateway-frontend/src/composables/useToast.ts diff --git a/app/gateway-frontend/e2e/admin-account-controls.spec.ts b/app/gateway-frontend/e2e/admin-account-controls.spec.ts index f48732ae..29ae81df 100644 --- a/app/gateway-frontend/e2e/admin-account-controls.spec.ts +++ b/app/gateway-frontend/e2e/admin-account-controls.spec.ts @@ -224,7 +224,7 @@ test('operates OAuth grace and scheduled deletion with reasoned audit history', await page.getByPlaceholder('che 또는 che:2 (쉼표 구분, 비우면 전체)').fill('che'); await page.getByPlaceholder('권한·제재·복구·탈퇴 조치 사유 (필수)').fill('휴대폰 분실 임시 복구'); await page.getByRole('button', { name: '특수 접근 부여', exact: true }).click(); - await expect(page.getByText('특수 접근 자격을 부여했습니다.')).toBeVisible(); + await expect(page.getByText('특수 접근 자격을 부여했습니다.').first()).toBeVisible(); await expect(page.getByText(/RECOVERY · che/)).toBeVisible(); await page.screenshot({ path: testInfo.outputPath('gateway-admin-special-access-granted.png'), fullPage: true }); @@ -232,7 +232,7 @@ test('operates OAuth grace and scheduled deletion with reasoned audit history', await page.getByPlaceholder('권한·제재·복구·탈퇴 조치 사유 (필수)').fill('본인 확인 처리 중'); await gracePanel.locator('input[type="datetime-local"]').fill('2026-08-20T00:00'); await page.getByRole('button', { name: '유예 연장', exact: true }).click(); - await expect(page.getByText('OAuth 유예 연장 완료')).toBeVisible(); + await expect(page.getByText('OAuth 유예 연장 완료').first()).toBeVisible(); await page.getByRole('button', { name: /탈퇴 · 이력/ }).click(); await expect(page.getByText('SUCCEEDED · admin.users.updateKakaoGrace').first()).toBeVisible(); @@ -246,7 +246,7 @@ test('operates OAuth grace and scheduled deletion with reasoned audit history', await page.getByPlaceholder('권한·제재·복구·탈퇴 조치 사유 (필수)').fill('탈퇴 요청 접수'); await page.getByLabel('탈퇴 전 보존 일수').fill('30'); await deletionButton.click(); - await expect(page.getByText(/탈퇴 예약 완료/)).toBeVisible(); + await expect(page.getByText(/탈퇴 예약 완료/).first()).toBeVisible(); expect(mutations.some(({ operation }) => operation === 'admin.users.updateKakaoGrace')).toBe(true); expect(mutations.some(({ operation }) => operation === 'admin.users.grantSpecialAccess')).toBe(true); expect(mutations.some(({ operation }) => operation === 'admin.users.scheduleDeletion')).toBe(true); diff --git a/app/gateway-frontend/e2e/admin-runtime-actions.spec.ts b/app/gateway-frontend/e2e/admin-runtime-actions.spec.ts index 5a5b2ec7..bab4440d 100644 --- a/app/gateway-frontend/e2e/admin-runtime-actions.spec.ts +++ b/app/gateway-frontend/e2e/admin-runtime-actions.spec.ts @@ -311,7 +311,7 @@ test('renders a failed terminal outcome without calling it applied', async ({ pa const failed = page.getByText('FAILED · ACCELERATE 15분'); await expect(failed).toBeVisible(); - await expect(page.getByText('DB 시간 조정 실패')).toBeVisible(); + await expect(page.getByText('DB 시간 조정 실패').first()).toBeVisible(); expect(await failed.evaluate((element) => getComputedStyle(element).color)).toBe('oklch(0.704 0.191 22.216)'); await expect(page.getByText(/적용됨|요청 완료/)).toHaveCount(0); }); diff --git a/app/gateway-frontend/e2e/hwe-lifecycle.spec.ts b/app/gateway-frontend/e2e/hwe-lifecycle.spec.ts index e5482c2d..48cb8287 100644 --- a/app/gateway-frontend/e2e/hwe-lifecycle.spec.ts +++ b/app/gateway-frontend/e2e/hwe-lifecycle.spec.ts @@ -98,7 +98,7 @@ test('admin resets and opens hwe, then two users create generals and reach main' const latestOperation = page.getByTestId('operations-table').locator('tbody tr').first(); const previousLatestOperation = await latestOperation.textContent(); await page.getByTestId('request-reset').click(); - await expect(page.getByText('초기화 작업을 등록했습니다.')).toBeVisible(); + await expect(page.getByText('초기화 작업을 등록했습니다.').first()).toBeVisible(); await expect .poll(() => latestOperation.textContent(), { diff --git a/app/gateway-frontend/e2e/logout.spec.ts b/app/gateway-frontend/e2e/logout.spec.ts index e9e0bd37..a865c732 100644 --- a/app/gateway-frontend/e2e/logout.spec.ts +++ b/app/gateway-frontend/e2e/logout.spec.ts @@ -118,7 +118,7 @@ test('keeps the lobby and every token when server logout fails', async ({ page } await page.locator('#btn_logout').click(); await expect(page).toHaveURL(/\/gateway\/lobby$/); - await expect(page.getByRole('alert')).toContainText('로그아웃 서버가 응답하지 않습니다.'); + await expect(page.getByTestId('action-toast')).toContainText('로그아웃 서버가 응답하지 않습니다.'); await expect .poll(() => page.evaluate(() => ({ diff --git a/app/gateway-frontend/e2e/server-operations.spec.ts b/app/gateway-frontend/e2e/server-operations.spec.ts index 4c84be74..a57b9d1a 100644 --- a/app/gateway-frontend/e2e/server-operations.spec.ts +++ b/app/gateway-frontend/e2e/server-operations.spec.ts @@ -42,6 +42,7 @@ type FixtureState = { profileNavigationResolved?: boolean; scenarioFailuresRemaining?: number; resetDefaults?: Record; + updateMetaFails?: boolean; }; const profile = (runtimeRunning: boolean, resetDefaults?: Record) => ({ @@ -131,6 +132,10 @@ const installFixture = async (page: Page, state: FixtureState) => { await route.abort('failed'); return; } + if (names.includes('admin.profiles.updateMeta') && state.updateMetaFails) { + await route.abort('failed'); + return; + } const results = names.map((name) => { if (route.request().method() === 'POST') { state.requestBodies.push({ operation: name, body }); @@ -408,7 +413,7 @@ test('separates branch and commit semantics and submits a reset from the dedicat await page.getByTestId('request-reset').hover(); await page.getByTestId('request-reset').click(); - await expect(page.getByText('초기화 작업을 등록했습니다.')).toBeVisible(); + await expect(page.getByText('초기화 작업을 등록했습니다.').first()).toBeVisible(); await expect(page.getByTestId('operations-table')).toContainText('RESET'); const resetRequest = state.requestBodies.find((entry) => entry.operation === 'admin.operations.requestReset'); expect(JSON.stringify(resetRequest?.body)).toContain('"sourceMode":"COMMIT"'); @@ -457,7 +462,7 @@ test('separates DB-preserving profile deployment from DB reset', async ({ page } ); await page.getByTestId('request-deploy').click(); - await expect(page.getByText('DB 보존 배포 작업을 등록했습니다.')).toBeVisible(); + await expect(page.getByText('DB 보존 배포 작업을 등록했습니다.').first()).toBeVisible(); await expect(page.getByTestId('operations-table')).toContainText('DEPLOY'); expect(state.requestBodies.some((entry) => entry.operation === 'admin.operations.requestDeploy')).toBe(true); expect(state.requestBodies.some((entry) => entry.operation === 'admin.operations.requestReset')).toBe(false); @@ -505,7 +510,7 @@ test('loads server metadata defaults into the reset form and submits them', asyn expect(request).toContain('"options":["develop","train"]'); }); -test('edits server reset defaults through profile metadata settings', async ({ page }) => { +test('edits server reset defaults through profile metadata settings', async ({ page }, testInfo) => { const state: FixtureState = { operations: [], gatewayOperations: [], runtimeRunning: true, requestBodies: [] }; await installFixture(page, state); @@ -513,10 +518,47 @@ test('edits server reset defaults through profile metadata settings', async ({ p await page.getByText('서버 리셋 기본 옵션').click(); await page.getByTestId('meta-reset-turn-term').selectOption('10'); await page.getByTestId('meta-reset-npc-mode').selectOption('2'); + + await page.getByRole('button', { name: '메타 저장' }).click(); + const validationToast = page.getByTestId('action-toast').filter({ hasText: '변경 사유를 입력하세요.' }); + await expect(validationToast).toHaveAttribute('data-toast-kind', 'error'); + await expect(validationToast).toHaveAttribute('role', 'alert'); + await page.getByPlaceholder('변경 사유 (필수)').fill('set reset defaults'); await page.getByRole('button', { name: '메타 저장' }).click(); - await expect(page.getByText('메타 저장 완료')).toBeVisible(); + await expect(page.getByText('메타 저장 완료').first()).toBeVisible(); + const successToast = page.getByTestId('action-toast').filter({ hasText: '메타 저장 완료' }); + await expect(successToast).toHaveAttribute('data-toast-kind', 'success'); + await expect(successToast).toHaveAttribute('role', 'status'); + const toastGeometry = await successToast.evaluate((element) => { + const rect = element.getBoundingClientRect(); + const viewport = element.parentElement?.parentElement; + return { + right: Math.round(window.innerWidth - rect.right), + width: Math.round(rect.width), + viewportPosition: viewport ? getComputedStyle(viewport).position : '', + }; + }); + expect(toastGeometry.right).toBeGreaterThanOrEqual(0); + expect(toastGeometry.width).toBeGreaterThan(250); + expect(toastGeometry.viewportPosition).toBe('fixed'); + await page.screenshot({ path: testInfo.outputPath('meta-save-toast-desktop.png'), fullPage: true }); + + await page.setViewportSize({ width: 390, height: 844 }); + const mobileToastGeometry = await successToast.evaluate((element) => { + const rect = element.getBoundingClientRect(); + return { + left: Math.round(rect.left), + right: Math.round(window.innerWidth - rect.right), + bottom: Math.round(window.innerHeight - rect.bottom), + }; + }); + expect(mobileToastGeometry.left).toBeGreaterThanOrEqual(0); + expect(mobileToastGeometry.right).toBeGreaterThanOrEqual(0); + expect(mobileToastGeometry.bottom).toBeGreaterThanOrEqual(0); + expect(mobileToastGeometry.bottom).toBeLessThanOrEqual(20); + await page.screenshot({ path: testInfo.outputPath('meta-save-toast-mobile.png'), fullPage: true }); const request = JSON.stringify( state.requestBodies.find((entry) => entry.operation === 'admin.profiles.updateMeta')?.body ); @@ -525,13 +567,35 @@ test('edits server reset defaults through profile metadata settings', async ({ p expect(request).toContain('"npcMode":2'); }); +test('shows a dismissible error toast when profile metadata persistence fails', async ({ page }, testInfo) => { + const state: FixtureState = { + operations: [], + gatewayOperations: [], + runtimeRunning: true, + requestBodies: [], + updateMetaFails: true, + }; + await installFixture(page, state); + + await page.goto('admin/servers/che%3A2'); + await page.getByPlaceholder('변경 사유 (필수)').fill('exercise persistence error'); + await page.getByRole('button', { name: '메타 저장' }).click(); + + const errorToast = page.getByTestId('action-toast').filter({ hasText: '메타 저장 실패' }); + await expect(errorToast).toBeVisible(); + await expect(errorToast).toHaveAttribute('data-toast-kind', 'error'); + await page.screenshot({ path: testInfo.outputPath('meta-save-toast-error.png'), fullPage: true }); + await errorToast.getByRole('button', { name: '알림 닫기' }).click(); + await expect(errorToast).toHaveCount(0); +}); + test('renders the fixed-profile version form without waiting for the server list', async ({ page }) => { const state: FixtureState = { operations: [], gatewayOperations: [], runtimeRunning: true, requestBodies: [], - profileNavigationDelayMs: 1500, + profileNavigationDelayMs: 3000, profileNavigationResolved: false, }; await installFixture(page, state); @@ -595,7 +659,7 @@ test('scenario-only operator resets the current version without Git or Gateway c await expect(page.getByTestId('source-commit')).toHaveCount(0); await expect(page.getByRole('link', { name: 'Gateway 릴리스' })).toHaveCount(0); await page.getByTestId('request-reset').click(); - await expect(page.getByText('초기화 작업을 등록했습니다.')).toBeVisible(); + await expect(page.getByText('초기화 작업을 등록했습니다.').first()).toBeVisible(); await expect .poll(() => state.requestBodies.some((entry) => entry.operation === 'admin.operations.requestReset')) .toBe(true); @@ -618,7 +682,7 @@ test('controls gateway deployment and rollback through the external controller q await page.getByTestId('gateway-source-ref').fill('release/2026-08'); await page.getByTestId('request-gateway-deploy').click(); - await expect(page.getByText(/Gateway 배포 작업을 등록했습니다/)).toBeVisible(); + await expect(page.getByText(/Gateway 배포 작업을 등록했습니다/).first()).toBeVisible(); await expect(page.getByTestId('gateway-release-table')).toContainText('DEPLOY'); await expect(page.getByTestId('gateway-release-log-panel')).toBeVisible(); await expect(page.getByTestId('gateway-release-log')).toContainText('Gateway 구성 요소를 빌드합니다.'); @@ -640,7 +704,7 @@ test('controls gateway deployment and rollback through the external controller q state.gatewayOperations = []; await page.getByTestId('refresh-operations').click(); await page.getByTestId('request-gateway-rollback').click(); - await expect(page.getByText('Gateway rollback 작업을 등록했습니다.')).toBeVisible(); + await expect(page.getByText('Gateway rollback 작업을 등록했습니다.').first()).toBeVisible(); expect(state.requestBodies.some((entry) => entry.operation === 'admin.releases.requestGatewayRollback')).toBe(true); }); @@ -709,7 +773,7 @@ test('renders a failed reset, retries it as a new operation, and reaches success expect(await failure.evaluate((element) => getComputedStyle(element).color)).toBe('oklch(0.704 0.191 22.216)'); await page.getByRole('button', { name: '재시도' }).click(); - await expect(page.getByText('재시도 작업을 등록했습니다.')).toBeVisible(); + await expect(page.getByText('재시도 작업을 등록했습니다.').first()).toBeVisible(); await expect(page.getByText('FAILED', { exact: true })).toBeVisible(); await expect(page.getByText('QUEUED', { exact: true })).toBeVisible(); await expect(page.getByTestId('operations-table').locator('tbody tr')).toHaveCount(2); diff --git a/app/gateway-frontend/src/App.vue b/app/gateway-frontend/src/App.vue index 9df054f5..1a6dcf33 100644 --- a/app/gateway-frontend/src/App.vue +++ b/app/gateway-frontend/src/App.vue @@ -1,9 +1,11 @@ diff --git a/app/gateway-frontend/src/composables/useToast.ts b/app/gateway-frontend/src/composables/useToast.ts new file mode 100644 index 00000000..cd558a24 --- /dev/null +++ b/app/gateway-frontend/src/composables/useToast.ts @@ -0,0 +1,59 @@ +import { readonly, ref } from 'vue'; + +export type ToastKind = 'success' | 'error' | 'info'; + +export type Toast = { + id: number; + kind: ToastKind; + message: string; +}; + +const visibleToasts = ref([]); +const dismissTimers = new Map>(); +let nextToastId = 1; + +const dismiss = (id: number): void => { + const timer = dismissTimers.get(id); + if (timer) clearTimeout(timer); + dismissTimers.delete(id); + visibleToasts.value = visibleToasts.value.filter((toast) => toast.id !== id); +}; + +const show = (message: string, kind: ToastKind = 'info', durationMs = 5_000): number => { + const normalizedMessage = message.trim(); + if (!normalizedMessage) return -1; + + const duplicate = visibleToasts.value.find( + (toast) => toast.message === normalizedMessage && toast.kind === kind + ); + if (duplicate) { + dismiss(duplicate.id); + } + + const id = nextToastId++; + visibleToasts.value = [...visibleToasts.value.slice(-3), { id, kind, message: normalizedMessage }]; + if (durationMs > 0) { + dismissTimers.set(id, setTimeout(() => dismiss(id), durationMs)); + } + return id; +}; + +const feedback = (message: string): number => { + if (/실패|오류|못했|필요|입력|선택|유효|일치하지|비활성화|없습니다|해야 합니다/.test(message)) { + return show(message, 'error'); + } + if (/완료|성공|저장|등록|적용|변경|해제|부여|생성|철회|예약/.test(message)) { + return show(message, 'success'); + } + return show(message, 'info'); +}; + +export const useToast = () => ({ + toasts: readonly(visibleToasts), + show, + success: (message: string, durationMs?: number) => show(message, 'success', durationMs), + error: (message: string, durationMs?: number) => show(message, 'error', durationMs), + info: (message: string, durationMs?: number) => show(message, 'info', durationMs), + feedback, + dismiss, +}); diff --git a/app/gateway-frontend/src/views/AccountView.vue b/app/gateway-frontend/src/views/AccountView.vue index 318c5826..b6519969 100644 --- a/app/gateway-frontend/src/views/AccountView.vue +++ b/app/gateway-frontend/src/views/AccountView.vue @@ -1,7 +1,8 @@