Merge remote-tracking branch 'origin/main' into perf/realtime-dashboard-delta-20260811

This commit is contained in:
2026-08-11 12:38:08 +00:00
22 changed files with 571 additions and 84 deletions
@@ -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<string, string>): 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, string>): 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<string, string>
): 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);
@@ -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<string, string>
env?: Record<string, string>,
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 };
+57 -1
View File
@@ -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 () => {
+13 -8
View File
@@ -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);
});
@@ -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);
@@ -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);
});
@@ -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(), {
+1 -1
View File
@@ -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(() => ({
@@ -42,6 +42,7 @@ type FixtureState = {
profileNavigationResolved?: boolean;
scenarioFailuresRemaining?: number;
resetDefaults?: Record<string, unknown>;
updateMetaFails?: boolean;
};
const profile = (runtimeRunning: boolean, resetDefaults?: Record<string, unknown>) => ({
@@ -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);
+2
View File
@@ -1,9 +1,11 @@
<script setup lang="ts">
import { RouterView } from 'vue-router';
import ToastViewport from './components/ToastViewport.vue';
</script>
<template>
<RouterView />
<ToastViewport />
</template>
<style>
@@ -0,0 +1,183 @@
<script setup lang="ts">
import { useToast, type ToastKind } from '../composables/useToast';
const { toasts, dismiss } = useToast();
const titleFor = (kind: ToastKind): string => {
if (kind === 'success') return '완료';
if (kind === 'error') return '처리 실패';
return '안내';
};
const iconFor = (kind: ToastKind): string => {
if (kind === 'success') return '✓';
if (kind === 'error') return '!';
return 'i';
};
</script>
<template>
<Teleport to="body">
<div class="toast-viewport" aria-label="작업 알림">
<TransitionGroup name="toast" tag="div" class="toast-stack">
<article
v-for="toast in toasts"
:key="toast.id"
class="toast-card"
:class="`toast-card--${toast.kind}`"
:role="toast.kind === 'error' ? 'alert' : 'status'"
:aria-live="toast.kind === 'error' ? 'assertive' : 'polite'"
data-testid="action-toast"
:data-toast-kind="toast.kind"
>
<span class="toast-icon" aria-hidden="true">{{ iconFor(toast.kind) }}</span>
<span class="toast-copy">
<strong>{{ titleFor(toast.kind) }}</strong>
<span>{{ toast.message }}</span>
</span>
<button type="button" class="toast-close" aria-label="알림 닫기" @click="dismiss(toast.id)">
×
</button>
</article>
</TransitionGroup>
</div>
</Teleport>
</template>
<style scoped>
.toast-viewport {
position: fixed;
z-index: 1000;
top: max(1rem, env(safe-area-inset-top));
right: max(1rem, env(safe-area-inset-right));
width: min(25rem, calc(100vw - 2rem));
pointer-events: none;
}
.toast-stack {
display: grid;
gap: 0.625rem;
}
.toast-card {
display: grid;
grid-template-columns: 1.75rem minmax(0, 1fr) 2rem;
gap: 0.75rem;
align-items: start;
padding: 0.875rem;
color: #f4f4f5;
background: rgb(24 24 27 / 96%);
border: 1px solid #52525b;
border-left-width: 4px;
border-radius: 0.625rem;
box-shadow: 0 14px 38px rgb(0 0 0 / 45%);
pointer-events: auto;
backdrop-filter: blur(8px);
}
.toast-card--success {
border-left-color: #34d399;
}
.toast-card--error {
border-left-color: #fb7185;
}
.toast-card--info {
border-left-color: #60a5fa;
}
.toast-icon {
display: grid;
place-items: center;
width: 1.75rem;
height: 1.75rem;
font-weight: 800;
color: #09090b;
background: #a1a1aa;
border-radius: 999px;
}
.toast-card--success .toast-icon {
background: #34d399;
}
.toast-card--error .toast-icon {
background: #fb7185;
}
.toast-card--info .toast-icon {
background: #60a5fa;
}
.toast-copy {
display: grid;
gap: 0.15rem;
min-width: 0;
font-size: 0.875rem;
line-height: 1.4;
overflow-wrap: anywhere;
}
.toast-copy strong {
color: #fff;
font-size: 0.75rem;
letter-spacing: 0.04em;
}
.toast-close {
width: 2rem;
height: 2rem;
margin: -0.35rem -0.35rem 0 0;
color: #d4d4d8;
font-size: 1.35rem;
line-height: 1;
border-radius: 0.35rem;
cursor: pointer;
}
.toast-close:hover,
.toast-close:focus-visible {
color: #fff;
background: #3f3f46;
outline: 2px solid #a1a1aa;
outline-offset: 1px;
}
.toast-enter-active,
.toast-leave-active,
.toast-move {
transition:
transform 180ms ease,
opacity 180ms ease;
}
.toast-enter-from,
.toast-leave-to {
opacity: 0;
transform: translateX(1rem);
}
@media (max-width: 640px) {
.toast-viewport {
top: auto;
right: max(0.75rem, env(safe-area-inset-right));
bottom: max(0.75rem, env(safe-area-inset-bottom));
left: max(0.75rem, env(safe-area-inset-left));
width: auto;
}
.toast-enter-from,
.toast-leave-to {
transform: translateY(0.75rem);
}
}
@media (prefers-reduced-motion: reduce) {
.toast-enter-active,
.toast-leave-active,
.toast-move {
transition: none;
}
}
</style>
@@ -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<Toast[]>([]);
const dismissTimers = new Map<number, ReturnType<typeof setTimeout>>();
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,
});
@@ -1,7 +1,8 @@
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue';
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { useRouter } from 'vue-router';
import { useToast } from '../composables/useToast';
import DefaultLayout from '../layouts/DefaultLayout.vue';
import { createGameTrpc } from '../utils/gameTrpc';
import { trpc } from '../utils/trpc';
@@ -22,6 +23,10 @@ const loading = ref(true);
const busy = ref(false);
const errorMessage = ref('');
const successMessage = ref('');
const { success: showSuccessToast, error: showErrorToast } = useToast();
watch(successMessage, (value) => value && showSuccessToast(value), { flush: 'sync' });
watch(errorMessage, (value) => value && showErrorToast(value), { flush: 'sync' });
const currentPassword = ref('');
const newPassword = ref('');
const newPasswordConfirm = ref('');
+47 -6
View File
@@ -1,7 +1,8 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import { computed, onMounted, ref, watch } from 'vue';
import ServerProfileTabs from '../components/ServerProfileTabs.vue';
import AdminConsoleLayout from '../layouts/AdminConsoleLayout.vue';
import { useToast } from '../composables/useToast';
import {
normalizeProfileResetDefaults,
type ProfileResetDefaults,
@@ -513,6 +514,45 @@ const userHistory = ref<AdminAuditEvent[]>([]);
const globalAuditHistory = ref<AdminAuditEvent[]>([]);
const globalAuditStatus = ref('');
const { feedback: showFeedbackToast } = useToast();
const actionFeedback = [
noticeStatus,
userError,
kakaoGraceStatus,
specialAccessStatus,
passwordStatus,
rolesStatus,
banStatus,
profileIconStatus,
restrictionStatus,
forceDeleteStatus,
];
watch(
actionFeedback,
(current, previous) => {
current.forEach((message, index) => {
if (message && message !== previous[index]) showFeedbackToast(message);
});
},
{ flush: 'sync' }
);
const setLocalAccountFeedback = (message: string): void => {
localAccountStatus.value = message;
showFeedbackToast(message);
};
watch(
profileActionStatus,
(current, previous) => {
Object.entries(current).forEach(([profileName, message]) => {
if (message && message !== previous[profileName]) showFeedbackToast(message);
});
},
{ flush: 'sync' }
);
const hasUser = computed(() => Boolean(userResult.value));
const loadLocalAccountStatus = async () => {
@@ -723,9 +763,10 @@ const updateProfileMeta = async (profileName: string) => {
);
}
} catch (error) {
const detail = error instanceof Error ? error.message : '';
profileActionStatus.value = {
...profileActionStatus.value,
[profileName]: '메타 저장 실패',
[profileName]: detail ? `메타 저장 실패: ${detail}` : '메타 저장 실패',
};
}
};
@@ -1183,14 +1224,14 @@ const createLocalAccount = async () => {
localAccountStatus.value = '';
localAccountResult.value = '';
if (!localAccountEnabled.value) {
localAccountStatus.value = 'ENV 설정이 비활성화 상태입니다.';
setLocalAccountFeedback('ENV 설정이 비활성화 상태입니다.');
return;
}
const username = localAccountForm.value.username.trim();
const password = localAccountForm.value.password.trim();
const displayName = localAccountForm.value.displayName.trim();
if (!username || !password) {
localAccountStatus.value = '아이디와 비밀번호를 입력하세요.';
setLocalAccountFeedback('아이디와 비밀번호를 입력하세요.');
return;
}
localAccountLoading.value = true;
@@ -1201,7 +1242,7 @@ const createLocalAccount = async () => {
displayName: displayName || undefined,
});
localAccountResult.value = `생성됨: ${result.user.username} (${result.user.id})`;
localAccountStatus.value = '로컬 계정 생성 완료';
setLocalAccountFeedback('로컬 계정 생성 완료');
localAccountForm.value = {
username: result.user.username,
password: '',
@@ -1211,7 +1252,7 @@ const createLocalAccount = async () => {
userLookupValue.value = result.user.username;
await Promise.all([lookupUser(), loadUserDirectory()]);
} catch (error) {
localAccountStatus.value = '로컬 계정 생성 실패';
setLocalAccountFeedback('로컬 계정 생성 실패');
} finally {
localAccountLoading.value = false;
}
+7 -3
View File
@@ -5,6 +5,7 @@ import type { inferRouterOutputs } from '@trpc/server';
import type { AppRouter } from '@sammo-ts/gateway-api';
import DefaultLayout from '../layouts/DefaultLayout.vue';
import MapPreview from '../components/MapPreview.vue';
import { useToast } from '../composables/useToast';
import { trpc } from '../utils/trpc';
import { createGameTrpc } from '../utils/gameTrpc';
import type { GameRouter } from '../utils/gameTrpc';
@@ -34,6 +35,9 @@ const selectedMapProfileName = ref<string | null>(null);
const entryLoading = ref<Record<string, boolean>>({});
const logoutLoading = ref(false);
const logoutError = ref('');
const { error: showErrorToast } = useToast();
watch(logoutError, (value) => value && showErrorToast(value), { flush: 'sync' });
const canAccessAdmin = computed(
() =>
me.value?.roles.some(
@@ -207,7 +211,7 @@ const handleKakaoVerification = async (): Promise<void> => {
});
window.location.assign(result.authUrl);
} catch (error) {
alert(error instanceof Error ? error.message : '카카오 인증을 시작하지 못했습니다.');
showErrorToast(error instanceof Error ? error.message : '카카오 인증을 시작하지 못했습니다.');
}
};
@@ -245,13 +249,13 @@ const handleEnter = async (profile: LobbyProfile, targetPath: string) => {
});
const url = resolveGameUrl(targetPath, issued.profile, issued.gameToken);
if (!url) {
alert('게임 프론트엔드 주소가 설정되지 않았습니다.');
showErrorToast('게임 프론트엔드 주소가 설정되지 않았습니다.');
return;
}
window.location.href = url;
} catch (e) {
console.error('Failed to issue game session', e);
alert(e instanceof Error ? e.message : '게임 서버 접속에 실패했습니다.');
showErrorToast(e instanceof Error ? e.message : '게임 서버 접속에 실패했습니다.');
} finally {
entryLoading.value[profile.profileName] = false;
}
@@ -2,6 +2,7 @@
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue';
import ServerProfileTabs from '../components/ServerProfileTabs.vue';
import { useToast } from '../composables/useToast';
import AdminConsoleLayout from '../layouts/AdminConsoleLayout.vue';
import {
normalizeProfileResetDefaults,
@@ -95,6 +96,10 @@ const catalogAttempted = ref(false);
const submitting = ref(false);
const message = ref('');
const errorMessage = ref('');
const { success: showSuccessToast, error: showErrorToast } = useToast();
watch(message, (value) => value && showSuccessToast(value), { flush: 'sync' });
watch(errorMessage, (value) => value && showErrorToast(value), { flush: 'sync' });
const resetDefaultsSource = ref<'SYSTEM' | 'PROFILE'>('SYSTEM');
let pollTimer: ReturnType<typeof setInterval> | undefined;
let stateRequestInFlight = false;
+7 -7
View File
@@ -29,6 +29,12 @@ 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 기준으로 해석합니다.
- `RELEASE_TURBO_CONCURRENCY`: Turbo worker 수입니다. 기본값 1은 실행 중인 game/Gateway
process와 4 GiB runtime을 공유하는 production cold build의 OOM을 피합니다. 더 큰
격리 build host에서만 측정 후 2 이상으로 올립니다.
비밀값은 Git에서 제외된 환경 파일 또는 process 환경으로 전달해 주세요.
`VITE_*`에는 공개 URL만 넣어 주세요.
@@ -46,13 +52,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=1 --ui=stream
pnpm --filter @sammo-ts/infra prisma:migrate:deploy:gateway
pnpm --filter @sammo-ts/release-controller start
```
@@ -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);
+2 -7
View File
@@ -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),
];
};
@@ -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'),
+14 -1
View File
@@ -44,6 +44,17 @@ 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를
사용합니다. 실행 중인 game/Gateway process와 4 GiB runtime을 공유하는 기본
동시성은 1이며, 더 큰 격리 build host에서는 `RELEASE_TURBO_CONCURRENCY`를 측정 후
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 +124,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 +148,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
+5 -2
View File
@@ -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
},