diff --git a/app/gateway-api/src/orchestrator/buildRunner.ts b/app/gateway-api/src/orchestrator/buildRunner.ts index 56c3cebf..43159c5b 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,45 @@ export interface BuildRunner { } export const MAX_BUILD_OUTPUT_CHARS = 64 * 1024; +export const DEFAULT_RELEASE_TURBO_CONCURRENCY = 1; + +export const resolveReleaseTurboConcurrency = (env?: Record): number => { + const configured = env?.RELEASE_TURBO_CONCURRENCY?.trim(); + if (!configured) return DEFAULT_RELEASE_TURBO_CONCURRENCY; + const parsed = Number(configured); + if (!Number.isInteger(parsed) || parsed <= 0) { + throw new Error('RELEASE_TURBO_CONCURRENCY must be a positive integer.'); + } + return parsed; +}; + +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=${resolveReleaseTurboConcurrency(env)}`, + '--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..8094e4db 100644 --- a/app/gateway-api/test/buildRunner.test.ts +++ b/app/gateway-api/test/buildRunner.test.ts @@ -2,7 +2,63 @@ 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, + resolveReleaseTurboConcurrency, +} 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('defaults to one worker for bounded runtimes and accepts a larger-host override', () => { + expect(resolveReleaseTurboConcurrency()).toBe(1); + expect(resolveReleaseTurboConcurrency({ RELEASE_TURBO_CONCURRENCY: '2' })).toBe(2); + expect(() => resolveReleaseTurboConcurrency({ RELEASE_TURBO_CONCURRENCY: '0' })).toThrow( + 'RELEASE_TURBO_CONCURRENCY must be a positive integer.' + ); + }); + + 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=1', + '--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..1116ad75 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=1', + '--ui=stream', + '--output-logs=new-only', + ], ]); expect(commands.every(({ cwd }) => cwd === workspaceRoot)).toBe(true); }); 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 @@