feat(gateway): reorganize admin console navigation

This commit is contained in:
2026-08-08 13:36:37 +00:00
parent d99e18f334
commit 4d46afa6a5
16 changed files with 804 additions and 922 deletions
+2 -1
View File
@@ -224,4 +224,5 @@ commit-worktree build 경로에서 구성합니다.
보존합니다. Gateway API·frontend·orchestrator는 외부 release-controller가
함께 전환합니다. 설치와 CLI self-upgrade 절차는
[`app/release-controller/README.md`](app/release-controller/README.md)를 확인해
주세요.
주세요. 관리자 화면의 메뉴와 책임 분리는
[`docs/admin-console.md`](docs/admin-console.md)에 정리되어 있습니다.
@@ -134,14 +134,13 @@ const installFixture = async (page: Page) => {
test('operates OAuth grace and scheduled deletion with reasoned audit history', async ({ page }, testInfo) => {
const mutations = await installFixture(page);
page.on('dialog', (dialog) => dialog.accept());
await page.goto('admin');
await page.goto('admin/users');
await page.getByPlaceholder('검색 값 입력').fill('target');
await page.getByRole('button', { name: '조회', exact: true }).click();
await expect(page.getByText('Kakao 인증: 미완료')).toBeVisible();
await expect(page.getByRole('cell', { name: 'che:default' })).toBeVisible();
await expect(page.getByText('SUCCEEDED · admin.users.updateSanctions').first()).toBeVisible();
await expect(page.getByRole('heading', { name: '전체 관리자 감사 원장' })).toBeVisible();
await page.screenshot({ path: testInfo.outputPath('gateway-admin-account-controls-desktop.png'), fullPage: true });
const deletionButton = page.getByRole('button', { name: '보존 기간 후 탈퇴 예약', exact: true });
const baseDeleteColor = await deletionButton.evaluate((button) => getComputedStyle(button).backgroundColor);
@@ -163,6 +162,11 @@ test('operates OAuth grace and scheduled deletion with reasoned audit history',
expect(mutations.some(({ operation }) => operation === 'admin.users.updateKakaoGrace')).toBe(true);
expect(mutations.some(({ operation }) => operation === 'admin.users.scheduleDeletion')).toBe(true);
await page.getByRole('link', { name: '감사 로그' }).click();
await expect(page).toHaveURL(/\/gateway\/admin\/audit$/);
await expect(page.getByRole('heading', { name: '전체 관리자 감사 원장' })).toBeVisible();
await expect(page.getByText('SUCCEEDED · admin.users.updateKakaoGrace').first()).toBeVisible();
await page.setViewportSize({ width: 390, height: 844 });
const geometry = await page
.getByRole('heading', { name: '전체 관리자 감사 원장' })
@@ -173,6 +177,9 @@ test('operates OAuth grace and scheduled deletion with reasoned audit history',
});
expect(geometry.left).toBeGreaterThanOrEqual(0);
expect(geometry.right).toBeLessThanOrEqual(geometry.viewportWidth);
await writeFile(testInfo.outputPath('gateway-admin-account-controls-mobile-geometry.json'), JSON.stringify(geometry));
await writeFile(
testInfo.outputPath('gateway-admin-account-controls-mobile-geometry.json'),
JSON.stringify(geometry)
);
await page.screenshot({ path: testInfo.outputPath('gateway-admin-account-controls-mobile.png'), fullPage: true });
});
@@ -211,8 +211,8 @@ const installFixture = async (
test('reports clock-shift acceptance separately from actual application', async ({ page }) => {
const fixture = await installFixture(page, { deferRequest: true, pendingProfileReads: 1 });
await page.goto('admin');
await expect(page.getByRole('heading', { name: '관리자 콘솔' })).toBeVisible();
await page.goto('admin/servers');
await expect(page.getByRole('heading', { name: '서버 관리' })).toBeVisible();
const duration = page.locator('input[type="number"][min="1"][max="1440"]');
const accelerate = page.getByRole('button', { name: '가속', exact: true });
@@ -249,7 +249,7 @@ test('blocks another clock shift while any recent action is pending', async ({ p
}),
],
});
await page.goto('admin');
await page.goto('admin/servers');
await page.locator('input[type="number"][min="1"][max="1440"]').fill('15');
await expect(page.getByRole('button', { name: '가속', exact: true })).toBeDisabled();
@@ -264,7 +264,7 @@ test('renders a failed terminal outcome without calling it applied', async ({ pa
}),
],
});
await page.goto('admin');
await page.goto('admin/servers');
const failed = page.getByText('FAILED · ACCELERATE 15분');
await expect(failed).toBeVisible();
@@ -282,7 +282,7 @@ test('renders an ignored terminal outcome without calling it applied', async ({
}),
],
});
await page.goto('admin');
await page.goto('admin/servers');
const ignored = page.getByText('IGNORED · DELAY 15분');
await expect(ignored).toBeVisible();
@@ -291,32 +291,20 @@ test('renders an ignored terminal outcome without calling it applied', async ({
await expect(page.getByText(/적용됨|요청 완료/)).toHaveCount(0);
});
test('keeps profile installation disabled while its queued operation is active', async ({ page }, testInfo) => {
const fixture = await installFixture(page, { deferInstall: true });
page.on('dialog', (dialog) => dialog.accept());
await page.goto('admin');
test('directs profile deployment to the centralized version page', async ({ page }) => {
await installFixture(page);
await page.goto('admin/servers');
const installButton = page.getByRole('button', { name: '설치 적용' });
const click = installButton.click();
await expect.poll(() => fixture.requestBodies.length).toBe(1);
await expect(page.getByRole('button', { name: '등록 중…' })).toBeDisabled();
fixture.releaseInstall();
await click;
const operationLink = page.getByRole('link', { name: /77777777-7777-4777-8777-777777777777 상태 보기/ });
await expect(operationLink).toBeVisible();
await expect(page.getByRole('button', { name: '설치 작업 진행 중' })).toBeDisabled();
const releaseLink = page.getByRole('link', { name: '버전 업데이트 열기' });
await expect(releaseLink).toBeVisible();
await expect(releaseLink).toHaveAttribute('href', '/gateway/admin/releases');
await expect(page.getByRole('button', { name: '설치 적용' })).toHaveCount(0);
await page.setViewportSize({ width: 390, height: 844 });
const linkGeometry = await operationLink.evaluate((element) => {
const linkGeometry = await releaseLink.evaluate((element) => {
const rect = element.getBoundingClientRect();
return { left: rect.left, right: rect.right, viewportWidth: window.innerWidth };
});
expect(linkGeometry.left).toBeGreaterThanOrEqual(0);
expect(linkGeometry.right).toBeLessThanOrEqual(linkGeometry.viewportWidth);
await page.screenshot({ path: testInfo.outputPath('admin-install-active-mobile.png'), fullPage: true });
await operationLink.click();
await expect(page).toHaveURL(/\/gateway\/admin\/server-operations\?operationId=77777777/);
await expect(page.getByTestId('operations-table')).toContainText('77777777-7777-4777-8777-777777777777');
});
@@ -27,7 +27,7 @@ const login = async (page: Page, username: string, password: string): Promise<vo
const hweRow = (page: Page) => page.locator('tbody tr').filter({ hasText: /^hwe섭/ });
const resetScenario = async (page: Page, scenarioId: string, sourceCommit: string): Promise<void> => {
await page.goto('/gateway/admin/server-operations');
await page.goto('/gateway/admin/releases');
await page.getByTestId('profile-select').selectOption('hwe:2');
await page.getByTestId('source-commit').check();
await page.getByTestId('source-ref').fill(sourceCommit);
@@ -85,8 +85,8 @@ test('admin resets and opens hwe, then two users create generals and reach main'
await login(page, adminUsername, await readPassword('admin'));
await page.getByRole('link', { name: '관리자 페이지' }).click();
await expect(page).toHaveURL(/\/gateway\/admin$/);
await page.getByRole('link', { name: '서버 배포 · 시나리오 초기화' }).click();
await expect(page).toHaveURL(/\/gateway\/admin\/server-operations$/);
await page.getByRole('link', { name: '버전 업데이트' }).first().click();
await expect(page).toHaveURL(/\/gateway\/admin\/releases$/);
await page.getByTestId('profile-select').selectOption(profileKey);
const profileStatus = page.getByTestId('selected-profile-status');
@@ -1,4 +1,5 @@
import { expect, test, type Page, type Route } from '@playwright/test';
import { writeFile } from 'node:fs/promises';
const response = (data: unknown) => ({ result: { data } });
@@ -53,7 +54,7 @@ const installGatewayFixture = async (page: Page, roles: string[]) => {
});
};
test('bootstrap superuser can navigate from the lobby to server operations', async ({ page }) => {
test('bootstrap superuser can navigate the administrator workspace from the lobby', async ({ page }, testInfo) => {
await installGatewayFixture(page, ['superuser']);
await page.goto('lobby');
@@ -62,9 +63,39 @@ test('bootstrap superuser can navigate from the lobby to server operations', asy
await adminLink.click();
await expect(page).toHaveURL(/\/gateway\/admin$/);
await expect(page.getByRole('heading', { name: '관리자 콘솔' })).toBeVisible();
await page.getByRole('link', { name: '서버 배포 · 시나리오 초기화' }).click();
await expect(page).toHaveURL(/\/gateway\/admin\/server-operations$/);
await expect(page.getByRole('heading', { name: '운영 개요' })).toBeVisible();
const navigation = page.getByRole('navigation', { name: '관리자 메뉴' });
await expect(navigation).toBeVisible();
const userLink = navigation.getByRole('link', { name: '사용자 관리' });
const baseColor = await userLink.evaluate((element) => getComputedStyle(element).color);
await userLink.hover();
await expect.poll(() => userLink.evaluate((element) => getComputedStyle(element).color)).not.toBe(baseColor);
await page.screenshot({ path: testInfo.outputPath('admin-overview-desktop.png'), fullPage: true });
await page.setViewportSize({ width: 390, height: 844 });
await expect(navigation).toBeHidden();
await page.getByRole('button', { name: '관리자 메뉴' }).click();
await expect(navigation).toBeVisible();
const geometry = await navigation.evaluate((element) => {
const rect = element.getBoundingClientRect();
return { left: rect.left, right: rect.right, top: rect.top, viewportWidth: window.innerWidth };
});
expect(geometry.left).toBeGreaterThanOrEqual(0);
expect(geometry.right).toBeLessThanOrEqual(geometry.viewportWidth);
expect(geometry.top).toBeGreaterThanOrEqual(0);
await writeFile(testInfo.outputPath('admin-overview-mobile-geometry.json'), JSON.stringify(geometry));
await page.screenshot({ path: testInfo.outputPath('admin-overview-mobile-menu.png'), fullPage: true });
await navigation.getByRole('link', { name: '버전 업데이트' }).click();
await expect(page).toHaveURL(/\/gateway\/admin\/releases$/);
await expect(page.getByRole('heading', { name: '버전 업데이트' })).toBeVisible();
});
test('legacy server operations URL keeps query parameters and redirects to releases', async ({ page }) => {
await installGatewayFixture(page, ['superuser']);
await page.goto('admin/server-operations?operationId=legacy-operation');
await expect(page).toHaveURL(/\/gateway\/admin\/releases\?operationId=legacy-operation$/);
});
test('scoped administrators see the same navigation while ordinary users do not', async ({ browser }) => {
@@ -218,9 +218,9 @@ test('separates branch and commit semantics and submits a reset from the dedicat
await installFixture(page, state);
page.on('dialog', (dialog) => dialog.accept());
await page.goto('admin/server-operations');
await page.goto('admin/releases');
await expect(page.getByTestId('server-operations-page')).toBeVisible();
await expect(page).toHaveURL(/\/gateway\/admin\/server-operations$/);
await expect(page).toHaveURL(/\/gateway\/admin\/releases$/);
await expect(page.getByTestId('source-help')).toContainText('실제로 시작될 때');
await expect(page.getByTestId('scenario-select')).toHaveValue('2');
@@ -293,7 +293,7 @@ test('starts and stops all runtime roles through the operation controls', async
await installFixture(page, state);
page.on('dialog', (dialog) => dialog.accept());
await page.goto('admin/server-operations');
await page.goto('admin/releases');
await page.getByTestId('start-server').click();
await expect(page.getByText('시작 작업을 요청했습니다.')).toBeVisible();
await expect(page.getByText('RUNNING', { exact: true }).first()).toBeVisible();
@@ -312,7 +312,7 @@ test('separates DB-preserving profile deployment from DB reset', async ({ page }
await installFixture(page, state);
page.on('dialog', (dialog) => dialog.accept());
await page.goto('admin/server-operations');
await page.goto('admin/releases');
await expect(page.getByText('Game frontend')).toBeVisible();
await page.getByTestId('request-deploy').click();
@@ -327,7 +327,7 @@ test('controls gateway deployment and rollback through the external controller q
await installFixture(page, state);
page.on('dialog', (dialog) => dialog.accept());
await page.goto('admin/server-operations');
await page.goto('admin/releases');
const panel = page.getByTestId('gateway-release-panel');
await expect(panel).toBeVisible();
await expect(panel).toContainText('aaaaaaaaaaaa');
@@ -375,7 +375,7 @@ test('renders a failed reset, retries it as a new operation, and reaches success
await installFixture(page, state);
page.on('dialog', (dialog) => dialog.accept());
await page.goto('admin/server-operations');
await page.goto('admin/releases');
await expect(page.getByText('FAILED', { exact: true })).toBeVisible();
await expect(page.getByRole('cell', { name: 'fedcba987654', exact: true })).toBeVisible();
const failure = page.getByText(longError);
@@ -0,0 +1,345 @@
<script setup lang="ts">
import { ref } from 'vue';
import DefaultLayout from './DefaultLayout.vue';
defineProps<{
title: string;
description: string;
eyebrow?: string;
}>();
const menuOpen = ref(false);
const navigation = [
{
label: '관리',
items: [
{ to: '/admin', label: '운영 개요', icon: '⌂', exact: true },
{ to: '/admin/users', label: '사용자 관리', icon: '人', exact: false },
],
},
{
label: '서비스 운영',
items: [
{ to: '/admin/servers', label: '서버 관리', icon: '◫', exact: false },
{ to: '/admin/releases', label: '버전 업데이트', icon: '↥', exact: false },
],
},
{
label: '시스템',
items: [
{ to: '/admin/system', label: '공지 · 접속', icon: '⚙', exact: false },
{ to: '/admin/audit', label: '감사 로그', icon: '≡', exact: false },
],
},
] as const;
</script>
<template>
<DefaultLayout>
<div class="admin-shell">
<button
class="admin-menu-button"
type="button"
:aria-expanded="menuOpen"
aria-controls="admin-navigation"
@click="menuOpen = !menuOpen"
>
<span>관리자 메뉴</span>
<span aria-hidden="true">{{ menuOpen ? '닫기' : '열기' }}</span>
</button>
<aside id="admin-navigation" class="admin-sidebar" :class="{ open: menuOpen }">
<div class="admin-identity">
<span class="admin-mark" aria-hidden="true"></span>
<div>
<strong>관리자 콘솔</strong>
<span>Gateway control</span>
</div>
</div>
<nav aria-label="관리자 메뉴">
<section v-for="group in navigation" :key="group.label" class="admin-nav-group">
<h2>{{ group.label }}</h2>
<RouterLink
v-for="item in group.items"
:key="item.to"
:to="item.to"
class="admin-nav-link"
:active-class="item.exact ? '' : 'active'"
:exact-active-class="item.exact ? 'active' : ''"
@click="menuOpen = false"
>
<span class="admin-nav-icon" aria-hidden="true">{{ item.icon }}</span>
<span>{{ item.label }}</span>
</RouterLink>
</section>
</nav>
<RouterLink class="admin-exit" to="/lobby"> 로비로 돌아가기</RouterLink>
</aside>
<main class="admin-content">
<header class="admin-page-header">
<div>
<p>{{ eyebrow ?? 'Admin console' }}</p>
<h1>{{ title }}</h1>
<div class="admin-description">{{ description }}</div>
</div>
<div v-if="$slots.actions" class="admin-header-actions">
<slot name="actions" />
</div>
</header>
<slot />
</main>
</div>
</DefaultLayout>
</template>
<style scoped>
.admin-shell {
display: grid;
width: min(1480px, 100%);
min-height: calc(100vh - 56px);
margin: 0 auto;
grid-template-columns: 244px minmax(0, 1fr);
padding-top: 56px;
background: #09090b;
}
:deep(.gateway-navbar) {
position: absolute;
}
.admin-sidebar {
position: sticky;
top: 56px;
height: calc(100vh - 56px);
border-right: 1px solid #27272a;
background: #111113;
padding: 24px 16px;
}
.admin-identity {
display: flex;
align-items: center;
gap: 11px;
margin-bottom: 28px;
padding: 0 8px;
}
.admin-identity strong,
.admin-identity span {
display: block;
}
.admin-identity strong {
color: #fafafa;
font-size: 15px;
}
.admin-identity div > span {
margin-top: 2px;
color: #71717a;
font-size: 10px;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.admin-mark {
display: grid;
width: 34px;
height: 34px;
flex: 0 0 34px;
place-items: center;
border: 1px solid #a16207;
border-radius: 8px;
background: #422006;
color: #facc15;
font-weight: 800;
}
.admin-nav-group {
margin: 0 0 24px;
}
.admin-nav-group h2 {
margin: 0 8px 7px;
color: #71717a;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.14em;
text-transform: uppercase;
}
.admin-nav-link {
display: flex;
min-height: 40px;
align-items: center;
gap: 10px;
margin: 2px 0;
border: 1px solid transparent;
border-radius: 7px;
padding: 8px 10px;
color: #a1a1aa;
font-size: 13px;
text-decoration: none;
transition:
border-color 120ms ease,
background-color 120ms ease,
color 120ms ease;
}
.admin-nav-link:hover,
.admin-nav-link:focus-visible {
border-color: #3f3f46;
background: #1c1c1f;
color: #fafafa;
outline: none;
}
.admin-nav-link.active {
border-color: #713f12;
background: #2d1b08;
color: #fde68a;
}
.admin-nav-icon {
width: 20px;
color: #d4d4d8;
font-family: sans-serif;
font-size: 15px;
text-align: center;
}
.admin-exit {
position: absolute;
right: 24px;
bottom: 24px;
left: 24px;
border-top: 1px solid #27272a;
padding-top: 17px;
color: #71717a;
font-size: 12px;
text-decoration: none;
}
.admin-exit:hover,
.admin-exit:focus-visible {
color: #e4e4e7;
}
.admin-content {
min-width: 0;
padding: 30px clamp(20px, 3vw, 44px) 56px;
}
.admin-page-header {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 24px;
margin-bottom: 28px;
border-bottom: 1px solid #27272a;
padding-bottom: 22px;
}
.admin-page-header p {
margin: 0 0 7px;
color: #ca8a04;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.2em;
text-transform: uppercase;
}
.admin-page-header h1 {
margin: 0;
color: #fafafa;
font-size: clamp(25px, 3vw, 34px);
line-height: 1.2;
}
.admin-description {
margin-top: 8px;
color: #a1a1aa;
font-size: 13px;
line-height: 1.6;
}
.admin-header-actions {
flex: 0 0 auto;
}
.admin-menu-button {
display: none;
}
@media (max-width: 860px) {
.admin-shell {
display: block;
padding-top: 72px;
}
.admin-menu-button {
position: absolute;
z-index: 20;
top: 72px;
right: 16px;
left: 16px;
display: flex;
min-height: 44px;
align-items: center;
justify-content: space-between;
border: 1px solid #3f3f46;
border-radius: 8px;
background: #18181b;
padding: 10px 14px;
color: #f4f4f5;
font-size: 13px;
}
.admin-sidebar {
position: absolute;
z-index: 19;
top: 124px;
right: 16px;
left: 16px;
display: none;
width: auto;
height: auto;
border: 1px solid #3f3f46;
border-radius: 10px;
box-shadow: 0 20px 45px rgb(0 0 0 / 55%);
padding: 18px 14px 14px;
}
.admin-sidebar.open {
display: block;
}
.admin-identity {
margin-bottom: 18px;
}
.admin-nav-group {
margin-bottom: 14px;
}
.admin-exit {
position: static;
display: block;
margin: 16px 8px 0;
}
.admin-content {
padding: 76px 16px 40px;
}
.admin-page-header {
align-items: flex-start;
flex-direction: column;
gap: 14px;
}
}
</style>
+31 -2
View File
@@ -1,6 +1,7 @@
import { createRouter, createWebHistory } from 'vue-router';
import HomeView from '../views/HomeView.vue';
import LobbyView from '../views/LobbyView.vue';
import AdminOverviewView from '../views/AdminOverviewView.vue';
import AdminView from '../views/AdminView.vue';
import ServerOperationsView from '../views/ServerOperationsView.vue';
import AccountView from '../views/AccountView.vue';
@@ -28,12 +29,40 @@ const router = createRouter({
{
path: '/admin',
name: 'admin',
component: AdminOverviewView,
},
{
path: '/admin/users',
name: 'admin-users',
component: AdminView,
props: { section: 'users' },
},
{
path: '/admin/servers',
name: 'admin-servers',
component: AdminView,
props: { section: 'servers' },
},
{
path: '/admin/system',
name: 'admin-system',
component: AdminView,
props: { section: 'system' },
},
{
path: '/admin/audit',
name: 'admin-audit',
component: AdminView,
props: { section: 'audit' },
},
{
path: '/admin/releases',
name: 'admin-releases',
component: ServerOperationsView,
},
{
path: '/admin/server-operations',
name: 'server-operations',
component: ServerOperationsView,
redirect: (to) => ({ path: '/admin/releases', query: to.query }),
},
{
path: '/account',
@@ -0,0 +1,198 @@
<script setup lang="ts">
import AdminConsoleLayout from '../layouts/AdminConsoleLayout.vue';
const sections = [
{
to: '/admin/users',
eyebrow: 'Accounts',
title: '사용자 관리',
description: '계정 조회와 생성, 인증 유예, 권한, 제재, 복구 및 탈퇴 예약을 관리합니다.',
tone: 'blue',
},
{
to: '/admin/servers',
eyebrow: 'Profiles',
title: '서버 관리',
description: '프로필별 공개 정보, 계정 정책, 실행 상태와 게임 운영 동작을 관리합니다.',
tone: 'emerald',
},
{
to: '/admin/releases',
eyebrow: 'Releases',
title: '버전 업데이트',
description: '프로필 DB 유지·초기화 배포와 Gateway 릴리스, rollback 및 작업 이력을 확인합니다.',
tone: 'violet',
},
{
to: '/admin/system',
eyebrow: 'System',
title: '공지 · 접속',
description: '로비 공지와 관리자 세션 연결 상태처럼 Gateway 공통 설정을 관리합니다.',
tone: 'amber',
},
{
to: '/admin/audit',
eyebrow: 'Audit',
title: '감사 로그',
description: '누가 어떤 관리자 조치를 수행했는지 결과와 대상, 사유를 추적합니다.',
tone: 'zinc',
},
] as const;
</script>
<template>
<AdminConsoleLayout
title="운영 개요"
description="관리 대상을 먼저 선택하세요. 위험도가 높은 변경과 배포 이력은 각 영역에서 분리해 확인할 수 있습니다."
eyebrow="Admin workspace"
>
<section class="overview-guide" aria-label="관리 원칙">
<div>
<strong>작업 영역이 분리되었습니다.</strong>
<p>계정 변경, 서버 설정, 버전 배포가 화면에 섞이지 않도록 책임별로 나누었습니다.</p>
</div>
<span>권한 검사는 기존 서버 정책을 그대로 따릅니다.</span>
</section>
<section class="overview-grid" aria-label="관리 기능">
<RouterLink
v-for="section in sections"
:key="section.to"
:to="section.to"
class="overview-card"
:class="`tone-${section.tone}`"
>
<span>{{ section.eyebrow }}</span>
<h2>{{ section.title }}</h2>
<p>{{ section.description }}</p>
<strong>열기 </strong>
</RouterLink>
</section>
</AdminConsoleLayout>
</template>
<style scoped>
.overview-guide {
display: flex;
align-items: center;
justify-content: space-between;
gap: 20px;
margin-bottom: 22px;
border: 1px solid #3f3f46;
border-radius: 10px;
background: #18181b;
padding: 18px 20px;
}
.overview-guide strong {
color: #f4f4f5;
font-size: 14px;
}
.overview-guide p,
.overview-guide span {
margin: 4px 0 0;
color: #a1a1aa;
font-size: 12px;
line-height: 1.5;
}
.overview-guide > span {
flex: 0 0 auto;
margin: 0;
color: #fbbf24;
}
.overview-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
}
.overview-card {
min-height: 190px;
border: 1px solid #27272a;
border-radius: 10px;
background: #111113;
padding: 22px;
color: #fafafa;
text-decoration: none;
transition:
border-color 140ms ease,
transform 140ms ease,
background-color 140ms ease;
}
.overview-card:hover,
.overview-card:focus-visible {
border-color: var(--accent);
background: #18181b;
outline: none;
transform: translateY(-2px);
}
.overview-card > span {
color: var(--accent);
font-size: 10px;
font-weight: 700;
letter-spacing: 0.18em;
text-transform: uppercase;
}
.overview-card h2 {
margin: 15px 0 9px;
font-size: 19px;
}
.overview-card p {
min-height: 58px;
margin: 0 0 20px;
color: #a1a1aa;
font-size: 12px;
line-height: 1.65;
}
.overview-card strong {
color: #d4d4d8;
font-size: 12px;
}
.tone-blue {
--accent: #60a5fa;
}
.tone-emerald {
--accent: #34d399;
}
.tone-violet {
--accent: #a78bfa;
}
.tone-amber {
--accent: #fbbf24;
}
.tone-zinc {
--accent: #a1a1aa;
}
@media (max-width: 700px) {
.overview-guide {
align-items: flex-start;
flex-direction: column;
}
.overview-grid {
grid-template-columns: 1fr;
}
.overview-card {
min-height: 0;
}
.overview-card p {
min-height: 0;
}
}
</style>
+96 -856
View File
@@ -1,8 +1,39 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import DefaultLayout from '../layouts/DefaultLayout.vue';
import AdminConsoleLayout from '../layouts/AdminConsoleLayout.vue';
import { trpc } from '../utils/trpc';
type AdminSection = 'users' | 'servers' | 'system' | 'audit';
const props = defineProps<{
section: AdminSection;
}>();
const pageMeta: Record<AdminSection, { title: string; description: string; eyebrow: string }> = {
users: {
title: '사용자 관리',
description: '계정 조회와 생성, 인증 유예, 권한, 제재, 복구 및 탈퇴 예약을 한 사용자 단위로 관리합니다.',
eyebrow: 'Accounts and access',
},
servers: {
title: '서버 관리',
description: '프로필별 공개 정보와 계정 정책, 게임 실행 상태 및 운영 동작을 관리합니다.',
eyebrow: 'Profile operations',
},
system: {
title: '공지 · 접속',
description: 'Gateway 공통 공지와 관리자 세션 연결 상태를 관리합니다.',
eyebrow: 'Gateway settings',
},
audit: {
title: '감사 로그',
description: '관리자 조치의 실행 결과, 대상과 사유를 시간순으로 확인합니다.',
eyebrow: 'Audit trail',
},
};
const currentPage = computed(() => pageMeta[props.section]);
type AdminUserSanctions = {
bannedUntil?: string;
mutedUntil?: string;
@@ -129,51 +160,6 @@ type AdminProfile = {
}>;
};
type ScenarioNationPreview = {
id: number;
name: string;
color: string;
cities: string[];
generals: number;
generalsEx: number;
generalsNeutral: number;
};
type ScenarioPreview = {
id: number;
title: string;
year: number | null;
npcCount: number;
npcExCount: number;
npcNeutralCount: number;
nations: ScenarioNationPreview[];
};
type ScenarioCatalogState = {
scenarios: ScenarioPreview[];
loading: boolean;
status: string;
};
type InstallFormState = {
scenarioId: number;
turnTermMinutes: number;
sync: boolean;
fiction: number;
extend: boolean;
blockGeneralCreate: number;
npcMode: number;
showImgLevel: number;
tournamentTrig: boolean;
joinMode: 'full' | 'onlyRandom';
autorunUserMinutes: number;
autorunUserOptions: Record<string, boolean>;
openAt: string;
preopenAt: string;
gitRef: string;
reason: string;
};
type AdminAction =
'RESUME' | 'PAUSE' | 'STOP' | 'ACCELERATE' | 'DELAY' | 'RESET_NOW' | 'RESET_SCHEDULED' | 'OPEN_SURVEY' | 'SHUTDOWN';
@@ -270,9 +256,6 @@ type AdminClient = {
list: {
query: () => Promise<AdminProfile[]>;
};
listScenarios: {
query: (input?: { gitRef?: string }) => Promise<ScenarioPreview[]>;
};
updateMeta: {
mutate: (input: {
profileName: string;
@@ -288,31 +271,6 @@ type AdminClient = {
reason: string;
}) => Promise<AdminProfile | null>;
};
install: {
mutate: (input: {
profileName: string;
install: {
scenarioId: number;
turnTermMinutes: number;
sync: boolean;
fiction: number;
extend: boolean;
blockGeneralCreate: number;
npcMode: number;
showImgLevel: number;
tournamentTrig: boolean;
joinMode: 'full' | 'onlyRandom';
autorunUser?: {
limitMinutes: number;
options: string[];
} | null;
openAt?: string;
preopenAt?: string;
gitRef?: string;
};
reason?: string;
}) => Promise<{ ok: boolean; operationId: string; action?: unknown }>;
};
requestAction: {
mutate: (input: {
profileName: string;
@@ -379,11 +337,6 @@ const profileActions = ref<
>({});
const profileActionStatus = ref<Record<string, string>>({});
const profileActionSubmitting = ref<Record<string, boolean>>({});
const scenarioCatalogs = ref<Record<string, ScenarioCatalogState>>({});
const profileInstalls = ref<Record<string, InstallFormState>>({});
const profileInstallStatus = ref<Record<string, string>>({});
const profileInstallSubmitting = ref<Record<string, boolean>>({});
const profileInstallOperationId = ref<Record<string, string>>({});
const runtimeActionPending = (profile: AdminProfile): boolean => {
return profile.runtimeActions.some((action) => action.status === 'REQUESTED' || action.status === 'PARTIAL');
@@ -408,18 +361,6 @@ const isRuntimeActionTerminal = (status: AdminProfile['runtimeActions'][number][
const formatRuntimeActionTime = (value: string | null): string =>
value ? new Date(value).toLocaleString('ko-KR') : '';
const autorunOptionLabels = [
{ key: 'develop', label: '내정' },
{ key: 'warp', label: '순간이동' },
{ key: 'recruit', label: '징병' },
{ key: 'recruit_high', label: '모병' },
{ key: 'train', label: '훈사' },
{ key: 'battle', label: '출병' },
{ key: 'chief', label: '기본 사령턴' },
] as const;
const turnTermOptions = [120, 60, 30, 20, 10, 5, 2, 1] as const;
const userLookupMode = ref<'username' | 'id' | 'email'>('username');
const userLookupValue = ref('');
const userLoading = ref(false);
@@ -544,144 +485,13 @@ const ensureProfileBuffers = (profile: AdminProfile) => {
}
};
const pad2 = (value: number): string => String(value).padStart(2, '0');
const formatLocalInput = (date: Date): string =>
`${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}T${pad2(date.getHours())}:${pad2(
const toLocalInputValue = (value: string): string => {
const date = new Date(value);
if (Number.isNaN(date.getTime())) return '';
const pad = (part: number): string => String(part).padStart(2, '0');
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(
date.getMinutes()
)}`;
const toLocalInputValue = (value: unknown): string => {
if (typeof value !== 'string') {
return '';
}
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) {
return '';
}
return formatLocalInput(parsed);
};
const readNumber = (value: unknown, fallback: number): number =>
typeof value === 'number' && Number.isFinite(value) ? value : fallback;
const readBoolean = (value: unknown, fallback: boolean): boolean => (typeof value === 'boolean' ? value : fallback);
const readString = (value: unknown, fallback: string): string => (typeof value === 'string' ? value : fallback);
const buildAutorunOptionMap = (options?: string[]): Record<string, boolean> => {
const map: Record<string, boolean> = {};
autorunOptionLabels.forEach(({ key }) => {
map[key] = options ? options.includes(key) : true;
});
return map;
};
const normalizeGitRefInput = (value: string): string => value.trim();
const getScenarioCatalogKey = (gitRef: string): string => normalizeGitRefInput(gitRef);
const getScenarioCatalogStateByRef = (gitRef: string): ScenarioCatalogState => {
const key = getScenarioCatalogKey(gitRef);
return (
scenarioCatalogs.value[key] ?? {
scenarios: [],
loading: false,
status: '',
}
);
};
const ensureProfileInstallBuffers = (profile: AdminProfile) => {
if (profileInstalls.value[profile.profileName]) {
return;
}
const meta = (profile.meta ?? {}) as Record<string, unknown>;
const install = (meta.install ?? {}) as Record<string, unknown>;
const autorunUser = (install.autorunUser ?? {}) as Record<string, unknown>;
const autorunOptionsRaw = Array.isArray(autorunUser.options)
? autorunUser.options.filter((option): option is string => typeof option === 'string')
: undefined;
const scenarioId = Number(profile.scenario);
const installGitRef = readString(install.gitRef, '');
const buildCommitRef = typeof profile.buildCommitSha === 'string' ? profile.buildCommitSha : '';
profileInstalls.value[profile.profileName] = {
scenarioId: Number.isFinite(scenarioId) ? scenarioId : readNumber(install.scenarioId, 0),
turnTermMinutes: readNumber(install.turnTermMinutes, 60),
sync: readBoolean(install.sync, true),
fiction: readNumber(install.fiction, 1),
extend: readBoolean(install.extend, true),
blockGeneralCreate: readNumber(install.blockGeneralCreate, 0),
npcMode: readNumber(install.npcMode, 0),
showImgLevel: readNumber(install.showImgLevel, 3),
tournamentTrig: readBoolean(install.tournamentTrig, true),
joinMode: readString(install.joinMode, 'full') === 'onlyRandom' ? 'onlyRandom' : 'full',
autorunUserMinutes: readNumber(autorunUser.limitMinutes, 1440),
autorunUserOptions: buildAutorunOptionMap(autorunOptionsRaw),
openAt: toLocalInputValue(install.openAt),
preopenAt: toLocalInputValue(install.preopenAt),
gitRef: installGitRef || buildCommitRef,
reason: '',
};
};
const buildScenarioMap = (items: ScenarioPreview[]): Map<number, ScenarioPreview> => {
const map = new Map<number, ScenarioPreview>();
items.forEach((scenario) => {
map.set(scenario.id, scenario);
});
return map;
};
const buildScenarioGroups = (items: ScenarioPreview[]): Record<string, ScenarioPreview[]> => {
const pattern = /【(.*?)[0-9\-_.a-zA-Z]*】/;
const groups: Record<string, ScenarioPreview[]> = {};
for (const scenario of items) {
const match = pattern.exec(scenario.title);
const category = match?.[1] ?? '기타';
if (!groups[category]) {
groups[category] = [];
}
groups[category].push(scenario);
}
return groups;
};
const getScenarioPreview = (profileName: string): ScenarioPreview | null => {
const install = profileInstalls.value[profileName];
if (!install) {
return null;
}
const catalog = getScenarioCatalogStateByRef(install.gitRef);
const map = buildScenarioMap(catalog.scenarios);
return map.get(install.scenarioId) ?? null;
};
const getScenarioGroups = (profileName: string): Record<string, ScenarioPreview[]> => {
const install = profileInstalls.value[profileName];
if (!install) {
return {};
}
const catalog = getScenarioCatalogStateByRef(install.gitRef);
return buildScenarioGroups(catalog.scenarios);
};
const getScenarioLoading = (profileName: string): boolean => {
const install = profileInstalls.value[profileName];
if (!install) {
return false;
}
return getScenarioCatalogStateByRef(install.gitRef).loading;
};
const getScenarioStatus = (profileName: string): string => {
const install = profileInstalls.value[profileName];
if (!install) {
return '';
}
return getScenarioCatalogStateByRef(install.gitRef).status;
};
const loadProfiles = async () => {
@@ -690,7 +500,6 @@ const loadProfiles = async () => {
const result = await adminClient.profiles.list.query();
result.forEach((profile) => {
ensureProfileBuffers(profile);
ensureProfileInstallBuffers(profile);
const latest = profile.runtimeActions[0];
if (latest && isRuntimeActionTerminal(latest.status)) {
profileActionStatus.value = {
@@ -700,15 +509,6 @@ const loadProfiles = async () => {
}
});
profiles.value = result;
const refs = new Set<string>();
refs.add('');
result.forEach((profile) => {
const install = profileInstalls.value[profile.profileName];
if (install?.gitRef) {
refs.add(normalizeGitRefInput(install.gitRef));
}
});
await Promise.all(Array.from(refs).map((gitRef) => loadScenarioCatalog(gitRef)));
} catch (error) {
profileActionStatus.value = {
...profileActionStatus.value,
@@ -736,7 +536,6 @@ const refreshRuntimeActionUntilTerminal = async (profileName: string, actionId:
const result = await adminClient.profiles.list.query();
result.forEach((profile) => {
ensureProfileBuffers(profile);
ensureProfileInstallBuffers(profile);
});
profiles.value = result;
} catch {
@@ -745,50 +544,6 @@ const refreshRuntimeActionUntilTerminal = async (profileName: string, actionId:
}
};
const loadScenarioCatalog = async (gitRef: string) => {
const key = getScenarioCatalogKey(gitRef);
const previous = scenarioCatalogs.value[key];
scenarioCatalogs.value = {
...scenarioCatalogs.value,
[key]: {
scenarios: previous?.scenarios ?? [],
loading: true,
status: '',
},
};
try {
const result = await adminClient.profiles.listScenarios.query(key ? { gitRef: key } : undefined);
scenarioCatalogs.value = {
...scenarioCatalogs.value,
[key]: {
scenarios: result,
loading: false,
status: '',
},
};
} catch (error) {
scenarioCatalogs.value = {
...scenarioCatalogs.value,
[key]: {
scenarios: previous?.scenarios ?? [],
loading: false,
status: '시나리오 목록을 불러오지 못했습니다.',
},
};
}
};
const loadScenariosForProfile = async (profileName: string) => {
const install = profileInstalls.value[profileName];
if (!install) {
return;
}
if (profileInstallSubmitting.value[profileName]) {
return;
}
await loadScenarioCatalog(install.gitRef);
};
const updateProfileMeta = async (profileName: string) => {
const edit = profileEdits.value[profileName];
if (!edit) {
@@ -907,79 +662,6 @@ const requestProfileAction = async (profileName: string, action: AdminAction) =>
}
};
const requestInstall = async (profileName: string) => {
const install = profileInstalls.value[profileName];
if (!install) {
return;
}
const options = Object.entries(install.autorunUserOptions)
.filter(([, enabled]) => enabled)
.map(([key]) => key);
const autorunUser =
install.autorunUserMinutes > 0 && options.length
? {
limitMinutes: install.autorunUserMinutes,
options,
}
: null;
const openAt = install.openAt ? new Date(install.openAt) : null;
if (openAt && Number.isNaN(openAt.getTime())) {
profileInstallStatus.value = {
...profileInstallStatus.value,
[profileName]: '오픈 시간이 올바르지 않습니다.',
};
return;
}
const preopenAt = install.preopenAt ? new Date(install.preopenAt) : null;
if (preopenAt && Number.isNaN(preopenAt.getTime())) {
profileInstallStatus.value = {
...profileInstallStatus.value,
[profileName]: '가오픈 시간이 올바르지 않습니다.',
};
return;
}
try {
profileInstallSubmitting.value = { ...profileInstallSubmitting.value, [profileName]: true };
const gitRef = normalizeGitRefInput(install.gitRef);
const result = await adminClient.profiles.install.mutate({
profileName,
install: {
scenarioId: install.scenarioId,
turnTermMinutes: install.turnTermMinutes,
sync: install.sync,
fiction: install.fiction,
extend: install.extend,
blockGeneralCreate: install.blockGeneralCreate,
npcMode: install.npcMode,
showImgLevel: install.showImgLevel,
tournamentTrig: install.tournamentTrig,
joinMode: install.joinMode,
autorunUser,
openAt: openAt ? openAt.toISOString() : undefined,
preopenAt: preopenAt ? preopenAt.toISOString() : undefined,
gitRef: gitRef ? gitRef : undefined,
},
reason: install.reason.trim() || undefined,
});
profileInstallStatus.value = {
...profileInstallStatus.value,
[profileName]: openAt ? '설치 작업을 예약했습니다.' : '설치 작업을 등록했습니다.',
};
profileInstallOperationId.value = {
...profileInstallOperationId.value,
[profileName]: result.operationId,
};
await loadProfiles();
} catch (error) {
profileInstallStatus.value = {
...profileInstallStatus.value,
[profileName]: error instanceof Error ? `설치 요청 실패: ${error.message}` : '설치 요청 실패',
};
} finally {
profileInstallSubmitting.value = { ...profileInstallSubmitting.value, [profileName]: false };
}
};
const lookupUser = async () => {
userLoading.value = true;
userError.value = '';
@@ -1335,30 +1017,29 @@ const createLocalAccount = async () => {
};
onMounted(() => {
void loadCapabilities();
void loadLocalAccountStatus();
void loadNotice();
void loadProfiles();
if (props.section === 'users' || props.section === 'audit') {
void loadCapabilities();
}
if (props.section === 'users') {
void loadLocalAccountStatus();
}
if (props.section === 'system') {
void loadNotice();
}
if (props.section === 'servers') {
void loadProfiles();
}
});
</script>
<template>
<DefaultLayout>
<div class="max-w-6xl mx-auto px-4 py-10 space-y-10">
<div class="flex flex-col gap-4 md:flex-row md:items-end md:justify-between">
<div class="space-y-2">
<h2 class="text-3xl font-bold text-white">관리자 콘솔</h2>
<p class="text-sm text-zinc-400">유저 관리와 서버 운영 제어를 위한 관리자 전용 대시보드입니다.</p>
</div>
<RouterLink
to="/admin/server-operations"
class="rounded bg-amber-500 px-4 py-2 text-center text-sm font-bold text-black hover:bg-amber-400"
>
서버 배포 · 시나리오 초기화
</RouterLink>
</div>
<section class="bg-zinc-900 border border-zinc-800 rounded-lg p-5 space-y-3">
<AdminConsoleLayout
:title="currentPage.title"
:description="currentPage.description"
:eyebrow="currentPage.eyebrow"
>
<div class="space-y-8">
<section v-if="section === 'system'" class="bg-zinc-900 border border-zinc-800 rounded-lg p-5 space-y-3">
<div class="flex items-center justify-between">
<h3 class="text-lg font-semibold">관리자 세션 토큰</h3>
<span class="text-xs text-zinc-500">{{ sessionTokenStatus }}</span>
@@ -1379,9 +1060,9 @@ onMounted(() => {
</div>
</section>
<div class="grid lg:grid-cols-2 gap-8">
<section class="min-w-0 space-y-6">
<div class="bg-zinc-900 border border-zinc-800 rounded-lg p-5 space-y-4">
<div class="space-y-8">
<section v-if="section === 'users'" class="grid min-w-0 items-start gap-6 xl:grid-cols-2">
<div class="bg-zinc-900 border border-zinc-800 rounded-lg p-5 space-y-4 xl:col-span-2">
<h3 class="text-lg font-semibold">유저 관리</h3>
<form class="space-y-3" @submit.prevent="lookupUser">
<div class="flex flex-col md:flex-row gap-2">
@@ -1437,7 +1118,7 @@ onMounted(() => {
</div>
</div>
<div class="bg-zinc-900 border border-amber-800/70 rounded-lg p-5 space-y-3">
<div class="bg-zinc-900 border border-amber-800/70 rounded-lg p-5 space-y-3 xl:col-span-2">
<h4 class="text-base font-semibold">민감 조치 공통 사유</h4>
<input
v-model="userActionReason"
@@ -1513,7 +1194,7 @@ onMounted(() => {
<div class="text-xs text-zinc-500">{{ passwordStatus }}</div>
</div>
<div class="bg-zinc-900 border border-zinc-800 rounded-lg p-5 space-y-4">
<div class="bg-zinc-900 border border-zinc-800 rounded-lg p-5 space-y-4 xl:col-span-2">
<h4 class="text-base font-semibold">특수 권한 부여</h4>
<div class="grid gap-2 md:grid-cols-[1fr_1fr_auto]">
<select
@@ -1800,9 +1481,11 @@ onMounted(() => {
</div>
</section>
<section class="min-w-0 space-y-6">
<section v-if="section !== 'users'" class="min-w-0 space-y-6">
<div
v-if="capabilities.some((entry) => entry.permission === 'admin.audit.read')"
v-if="
section === 'audit' && capabilities.some((entry) => entry.permission === 'admin.audit.read')
"
class="bg-zinc-900 border border-zinc-800 rounded-lg p-5 space-y-4"
>
<div class="flex items-center justify-between gap-3">
@@ -1846,7 +1529,20 @@ onMounted(() => {
</div>
</div>
<div class="bg-zinc-900 border border-zinc-800 rounded-lg p-5 space-y-4">
<div
v-if="
section === 'audit' &&
!capabilities.some((entry) => entry.permission === 'admin.audit.read')
"
class="rounded-lg border border-zinc-800 bg-zinc-900 p-5 text-sm text-zinc-400"
>
감사 로그를 조회할 권한이 없습니다.
</div>
<div
v-if="section === 'system'"
class="bg-zinc-900 border border-zinc-800 rounded-lg p-5 space-y-4"
>
<div class="flex justify-between items-center">
<h3 class="text-lg font-semibold">서버 공지</h3>
<span class="text-xs text-zinc-500">{{ noticeStatus }}</span>
@@ -1874,7 +1570,7 @@ onMounted(() => {
</div>
</div>
<div class="space-y-4">
<div v-if="section === 'servers'" class="space-y-4">
<div class="flex items-center justify-between">
<h3 class="text-lg font-semibold">서버별 관리</h3>
<button
@@ -2137,478 +1833,22 @@ onMounted(() => {
</div>
</div>
<div
v-if="profileInstalls[profile.profileName]"
class="border-t border-zinc-800 pt-4 space-y-3"
>
<div class="flex items-center justify-between">
<h4 class="text-sm font-semibold">설치/리셋</h4>
<div class="text-right text-xs text-zinc-500">
<div>{{ profileInstallStatus[profile.profileName] }}</div>
<RouterLink
v-if="profileInstallOperationId[profile.profileName]"
:to="{
path: '/admin/server-operations',
query: { operationId: profileInstallOperationId[profile.profileName] },
}"
class="block break-all text-amber-400 underline hover:text-amber-300"
>
작업 {{ profileInstallOperationId[profile.profileName] }} 상태 보기
</RouterLink>
</div>
</div>
<div class="grid lg:grid-cols-2 gap-4">
<div class="space-y-3">
<div class="space-y-1">
<label class="text-xs text-zinc-400">Git ref (선택)</label>
<div class="flex gap-2">
<input
v-model="profileInstalls[profile.profileName].gitRef"
type="text"
class="flex-1 bg-zinc-950 border border-zinc-700 rounded px-3 py-2 text-sm text-white"
placeholder="main / v1.0.0 / abc123"
/>
<button
class="bg-zinc-700 hover:bg-zinc-600 text-white text-sm px-3 py-2 rounded"
:disabled="getScenarioLoading(profile.profileName)"
@click="loadScenariosForProfile(profile.profileName)"
>
불러오기
</button>
</div>
<div class="text-xs text-zinc-500">
비워두면 현재 저장소 기준으로 불러옵니다.
</div>
</div>
<div class="space-y-1">
<label class="text-xs text-zinc-400">시나리오 선택</label>
<select
v-model.number="profileInstalls[profile.profileName].scenarioId"
class="w-full bg-zinc-950 border border-zinc-700 rounded px-3 py-2 text-sm text-white"
:disabled="getScenarioLoading(profile.profileName)"
>
<option v-if="getScenarioLoading(profile.profileName)" disabled>
불러오는 중...
</option>
<template
v-for="(items, group) in getScenarioGroups(profile.profileName)"
:key="group"
>
<optgroup :label="group">
<option
v-for="scenario in items"
:key="scenario.id"
:value="scenario.id"
>
{{ scenario.title }}
</option>
</optgroup>
</template>
</select>
<div
v-if="getScenarioStatus(profile.profileName)"
class="text-xs text-red-400"
>
{{ getScenarioStatus(profile.profileName) }}
</div>
</div>
<div class="grid grid-cols-2 gap-2">
<div class="space-y-1">
<label class="text-xs text-zinc-400">턴 시간(분)</label>
<select
v-model.number="
profileInstalls[profile.profileName].turnTermMinutes
"
class="w-full bg-zinc-950 border border-zinc-700 rounded px-3 py-2 text-sm text-white"
>
<option v-for="term in turnTermOptions" :key="term" :value="term">
{{ term }}
</option>
</select>
</div>
<div class="space-y-1">
<label class="text-xs text-zinc-400">시간 동기화</label>
<div class="flex gap-2">
<label class="flex items-center gap-1 text-xs text-zinc-300">
<input
v-model="profileInstalls[profile.profileName].sync"
class="accent-yellow-500"
type="radio"
:value="true"
/>
Y
</label>
<label class="flex items-center gap-1 text-xs text-zinc-300">
<input
v-model="profileInstalls[profile.profileName].sync"
class="accent-yellow-500"
type="radio"
:value="false"
/>
N
</label>
</div>
</div>
</div>
<div class="space-y-1">
<label class="text-xs text-zinc-400">NPC 상성</label>
<div class="flex gap-2">
<label class="flex items-center gap-1 text-xs text-zinc-300">
<input
v-model.number="profileInstalls[profile.profileName].fiction"
class="accent-yellow-500"
type="radio"
:value="0"
/>
연의
</label>
<label class="flex items-center gap-1 text-xs text-zinc-300">
<input
v-model.number="profileInstalls[profile.profileName].fiction"
class="accent-yellow-500"
type="radio"
:value="1"
/>
가상
</label>
</div>
</div>
<div class="grid grid-cols-2 gap-2">
<div class="space-y-1">
<label class="text-xs text-zinc-400">확장 NPC</label>
<div class="flex gap-2">
<label class="flex items-center gap-1 text-xs text-zinc-300">
<input
v-model="profileInstalls[profile.profileName].extend"
class="accent-yellow-500"
type="radio"
:value="true"
/>
포함
</label>
<label class="flex items-center gap-1 text-xs text-zinc-300">
<input
v-model="profileInstalls[profile.profileName].extend"
class="accent-yellow-500"
type="radio"
:value="false"
/>
미포함
</label>
</div>
</div>
<div class="space-y-1">
<label class="text-xs text-zinc-400">임관 모드</label>
<div class="flex gap-2">
<label class="flex items-center gap-1 text-xs text-zinc-300">
<input
v-model="profileInstalls[profile.profileName].joinMode"
class="accent-yellow-500"
type="radio"
value="full"
/>
일반
</label>
<label class="flex items-center gap-1 text-xs text-zinc-300">
<input
v-model="profileInstalls[profile.profileName].joinMode"
class="accent-yellow-500"
type="radio"
value="onlyRandom"
/>
랜덤 임관
</label>
</div>
</div>
</div>
<div class="space-y-1">
<label class="text-xs text-zinc-400">장수 임의 생성</label>
<div class="flex gap-2 flex-wrap">
<label class="flex items-center gap-1 text-xs text-zinc-300">
<input
v-model.number="
profileInstalls[profile.profileName].blockGeneralCreate
"
class="accent-yellow-500"
type="radio"
:value="0"
/>
가능
</label>
<label class="flex items-center gap-1 text-xs text-zinc-300">
<input
v-model.number="
profileInstalls[profile.profileName].blockGeneralCreate
"
class="accent-yellow-500"
type="radio"
:value="2"
/>
장수명 무작위
</label>
<label class="flex items-center gap-1 text-xs text-zinc-300">
<input
v-model.number="
profileInstalls[profile.profileName].blockGeneralCreate
"
class="accent-yellow-500"
type="radio"
:value="1"
/>
불가
</label>
</div>
</div>
<div class="space-y-1">
<label class="text-xs text-zinc-400">NPC 빙의</label>
<div class="flex gap-2 flex-wrap">
<label class="flex items-center gap-1 text-xs text-zinc-300">
<input
v-model.number="profileInstalls[profile.profileName].npcMode"
class="accent-yellow-500"
type="radio"
:value="1"
/>
가능
</label>
<label class="flex items-center gap-1 text-xs text-zinc-300">
<input
v-model.number="profileInstalls[profile.profileName].npcMode"
class="accent-yellow-500"
type="radio"
:value="0"
/>
불가
</label>
<label class="flex items-center gap-1 text-xs text-zinc-300">
<input
v-model.number="profileInstalls[profile.profileName].npcMode"
class="accent-yellow-500"
type="radio"
:value="2"
/>
선택 생성 가능
</label>
</div>
</div>
</div>
<div class="space-y-3">
<div class="space-y-1">
<label class="text-xs text-zinc-400">이미지 표기</label>
<div class="flex gap-2 flex-wrap">
<label class="flex items-center gap-1 text-xs text-zinc-300">
<input
v-model.number="
profileInstalls[profile.profileName].showImgLevel
"
class="accent-yellow-500"
type="radio"
:value="0"
/>
안함
</label>
<label class="flex items-center gap-1 text-xs text-zinc-300">
<input
v-model.number="
profileInstalls[profile.profileName].showImgLevel
"
class="accent-yellow-500"
type="radio"
:value="1"
/>
전콘
</label>
<label class="flex items-center gap-1 text-xs text-zinc-300">
<input
v-model.number="
profileInstalls[profile.profileName].showImgLevel
"
class="accent-yellow-500"
type="radio"
:value="2"
/>
전콘, 병종
</label>
<label class="flex items-center gap-1 text-xs text-zinc-300">
<input
v-model.number="
profileInstalls[profile.profileName].showImgLevel
"
class="accent-yellow-500"
type="radio"
:value="3"
/>
전콘, 병종, NPC
</label>
</div>
</div>
<div class="space-y-1">
<label class="text-xs text-zinc-400">토너먼트 자동 시작</label>
<div class="flex gap-2">
<label class="flex items-center gap-1 text-xs text-zinc-300">
<input
v-model="profileInstalls[profile.profileName].tournamentTrig"
class="accent-yellow-500"
type="radio"
:value="false"
/>
수동
</label>
<label class="flex items-center gap-1 text-xs text-zinc-300">
<input
v-model="profileInstalls[profile.profileName].tournamentTrig"
class="accent-yellow-500"
type="radio"
:value="true"
/>
자동
</label>
</div>
</div>
<div class="space-y-1">
<label class="text-xs text-zinc-400">휴식 턴 자동 행동</label>
<div class="flex flex-wrap gap-2">
<label
v-for="option in autorunOptionLabels"
:key="option.key"
class="flex items-center gap-1 text-xs text-zinc-300"
>
<input
v-model="
profileInstalls[profile.profileName].autorunUserOptions[
option.key
]
"
class="accent-yellow-500"
type="checkbox"
/>
{{ option.label }}
</label>
</div>
</div>
<div class="space-y-1">
<label class="text-xs text-zinc-400">유효 시간(분)</label>
<select
v-model.number="profileInstalls[profile.profileName].autorunUserMinutes"
class="w-full bg-zinc-950 border border-zinc-700 rounded px-3 py-2 text-sm text-white"
>
<option :value="0">꺼짐</option>
<option :value="43200">항상</option>
<option :value="10">10분</option>
<option :value="20">20분</option>
<option :value="30">30분</option>
<option :value="60">1시간</option>
<option :value="120">2시간</option>
<option :value="180">3시간</option>
<option :value="240">4시간</option>
<option :value="360">6시간</option>
<option :value="480">8시간</option>
<option :value="600">10시간</option>
<option :value="720">12시간</option>
<option :value="1440">24시간</option>
<option :value="2160">36시간</option>
<option :value="2880">48시간</option>
<option :value="3600">60시간</option>
<option :value="4320">72시간</option>
</select>
</div>
<div class="grid grid-cols-2 gap-2">
<div class="space-y-1">
<label class="text-xs text-zinc-400">오픈 예약</label>
<input
v-model="profileInstalls[profile.profileName].openAt"
type="datetime-local"
class="w-full bg-zinc-950 border border-zinc-700 rounded px-3 py-2 text-sm text-white"
/>
</div>
<div class="space-y-1">
<label class="text-xs text-zinc-400">가오픈 예약</label>
<input
v-model="profileInstalls[profile.profileName].preopenAt"
type="datetime-local"
class="w-full bg-zinc-950 border border-zinc-700 rounded px-3 py-2 text-sm text-white"
/>
</div>
</div>
<div class="space-y-1">
<label class="text-xs text-zinc-400">설치 메모</label>
<input
v-model="profileInstalls[profile.profileName].reason"
type="text"
class="w-full bg-zinc-950 border border-zinc-700 rounded px-3 py-2 text-sm text-white"
placeholder="사유/메모"
/>
</div>
<button
class="bg-emerald-600 hover:bg-emerald-500 disabled:cursor-not-allowed disabled:opacity-50 text-black font-semibold px-4 py-2 rounded w-full"
:disabled="
profileInstallSubmitting[profile.profileName] ||
Boolean(profile.activeOperation)
"
@click="requestInstall(profile.profileName)"
>
{{
profileInstallSubmitting[profile.profileName]
? '등록 중…'
: profile.activeOperation
? '설치 작업 진행 중'
: '설치 적용'
}}
</button>
<div
v-if="getScenarioPreview(profile.profileName)"
class="bg-zinc-950 border border-zinc-800 rounded p-3 text-xs text-zinc-300 space-y-2"
>
<div class="font-semibold text-zinc-200">
{{ getScenarioPreview(profile.profileName)?.title }}
</div>
<div>
시작 연도: {{ getScenarioPreview(profile.profileName)?.year ?? '-' }}년
</div>
<div>
NPC: {{ getScenarioPreview(profile.profileName)?.npcCount }}명
<span v-if="getScenarioPreview(profile.profileName)?.npcExCount"
>+{{ getScenarioPreview(profile.profileName)?.npcExCount }}명</span
>
<span v-if="getScenarioPreview(profile.profileName)?.npcNeutralCount">
/ 중립
{{ getScenarioPreview(profile.profileName)?.npcNeutralCount }}명
</span>
</div>
<div class="space-y-1">
<div class="text-zinc-400">국가</div>
<div class="space-y-1">
<div
v-for="nation in getScenarioPreview(profile.profileName)
?.nations ?? []"
:key="nation.id"
class="text-[11px]"
>
<span :style="{ color: nation.color }">{{ nation.name }}</span>
{{ nation.generals }}명
<span v-if="nation.generalsEx">(+{{ nation.generalsEx }})</span>
<span class="text-zinc-500"
>· {{ nation.cities.join(', ') }}</span
>
</div>
</div>
</div>
</div>
<div class="border-t border-zinc-800 pt-4">
<div
class="flex flex-col gap-3 rounded border border-violet-900/70 bg-violet-950/20 p-4 md:flex-row md:items-center md:justify-between"
>
<div>
<h4 class="text-sm font-semibold text-violet-200">배포와 시나리오 초기화</h4>
<p class="mt-1 text-xs text-zinc-500">
버전 선택, DB 유지 배포와 초기화 작업은 버전 업데이트에서 관리합니다.
</p>
</div>
<RouterLink
to="/admin/releases"
class="rounded border border-violet-700 px-3 py-2 text-center text-xs font-semibold text-violet-200 hover:bg-violet-950"
>
버전 업데이트 열기
</RouterLink>
</div>
</div>
</div>
@@ -2619,5 +1859,5 @@ onMounted(() => {
</section>
</div>
</div>
</DefaultLayout>
</AdminConsoleLayout>
</template>
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue';
import DefaultLayout from '../layouts/DefaultLayout.vue';
import AdminConsoleLayout from '../layouts/AdminConsoleLayout.vue';
import { trpc } from '../utils/trpc';
const adminClient = trpc.admin;
@@ -427,26 +427,23 @@ onBeforeUnmount(() => {
</script>
<template>
<DefaultLayout>
<div class="max-w-7xl mx-auto px-4 py-8 space-y-6" data-testid="server-operations-page">
<header class="flex flex-col gap-3 md:flex-row md:items-end md:justify-between">
<div>
<p class="text-xs uppercase tracking-[0.25em] text-amber-400">Operations console</p>
<h2 class="text-2xl font-bold text-white">서버 배포 · 시나리오 초기화</h2>
<p class="mt-2 text-sm text-zinc-400">
실행 소스, 초기화 옵션, 프로세스 상태와 작업 이력을 화면에서 관리합니다.
</p>
</div>
<button
class="rounded border border-zinc-700 bg-zinc-900 px-4 py-2 text-sm hover:border-zinc-500 disabled:opacity-50"
:disabled="loading"
data-testid="refresh-operations"
@click="loadState()"
>
상태 새로고침
</button>
</header>
<AdminConsoleLayout
title="버전 업데이트"
description="프로필 DB 유지·초기화 배포와 Gateway 릴리스, rollback 및 작업 이력을 관리합니다."
eyebrow="Release operations"
>
<template #actions>
<button
class="rounded border border-zinc-700 bg-zinc-900 px-4 py-2 text-sm hover:border-zinc-500 disabled:opacity-50"
:disabled="loading"
data-testid="refresh-operations"
@click="loadState()"
>
상태 새로고침
</button>
</template>
<div class="space-y-6" data-testid="server-operations-page">
<div v-if="errorMessage" class="rounded border border-red-800 bg-red-950/50 px-4 py-3 text-sm text-red-200">
{{ errorMessage }}
</div>
@@ -973,5 +970,5 @@ onBeforeUnmount(() => {
</div>
</section>
</div>
</DefaultLayout>
</AdminConsoleLayout>
</template>
+2
View File
@@ -13,6 +13,7 @@ export default defineConfig({
{ text: '개발자', link: '/developer/' },
{ text: '플레이어', link: '/user/' },
{ text: '아키텍처', link: '/architecture/overview' },
{ text: '관리자 콘솔', link: '/admin-console' },
{ text: '릴리스 운영', link: '/release-operations' },
],
sidebar: {
@@ -23,6 +24,7 @@ export default defineConfig({
{ text: '시작하기', link: '/developer/' },
{ text: '아키텍처 개요', link: '/architecture/overview' },
{ text: '런타임 아키텍처', link: '/architecture/runtime' },
{ text: '관리자 콘솔', link: '/admin-console' },
{ text: '릴리스 운영 매뉴얼', link: '/release-operations' },
{ text: '요청·턴·저장 흐름', link: '/developer/request-turn-persistence' },
{ text: '도메인 로직과 핵심 클래스', link: '/developer/domain-and-classes' },
+38
View File
@@ -0,0 +1,38 @@
# 관리자 콘솔
Gateway 관리자 콘솔은 `/gateway/admin`에서 시작합니다. 공개 로비에서 관리자
역할 또는 `admin.*` 범위 권한이 확인된 사용자에게만 진입 링크를 표시하며,
실제 조회와 변경 권한은 각 Gateway API가 다시 검사합니다.
## 화면 구성
좌측 메뉴는 관리 책임을 다음과 같이 분리합니다.
| 메뉴 | 경로 | 책임 |
| ------------- | ------------------------- | ------------------------------------------------------------------------------ |
| 운영 개요 | `/gateway/admin` | 관리 영역 안내와 빠른 진입 |
| 사용자 관리 | `/gateway/admin/users` | 계정 조회·생성, 권한, OAuth 유예, 제재, 아이콘 복구, 탈퇴 예약과 사용자별 이력 |
| 서버 관리 | `/gateway/admin/servers` | profile 공개 정보, 계정 정책, 실행 상태와 게임 운영 동작 |
| 버전 업데이트 | `/gateway/admin/releases` | profile DB 유지·초기화 배포, Gateway 릴리스·rollback과 작업 이력 |
| 공지 · 접속 | `/gateway/admin/system` | 로비 공지와 관리자 세션 연결 |
| 감사 로그 | `/gateway/admin/audit` | 관리자 조치 결과, 대상과 사유 조회 |
기존 `/gateway/admin/server-operations` 링크는 query string을 보존한 채
`/gateway/admin/releases`로 이동합니다. 즐겨찾기와 이전 운영 보고서의 링크를
즉시 깨뜨리지 않기 위한 호환 경로입니다.
## 운영 경계
- 사용자 페이지는 사용자 한 명에 대한 계정 변경과 사용자별 감사 이력만
표시합니다. 전체 감사 원장은 감사 로그에서 별도로 조회합니다.
- 서버 관리는 현재 실행 상태와 게임 운영 정책을 다룹니다. Git source 선택,
migration, 시나리오 초기화와 rollback은 버전 업데이트에서 수행합니다.
- `DEPLOY`는 현재 game DB를 유지하고 migration/build를 적용합니다. `RESET`
현재 시즌 데이터를 새 시나리오로 교체하며 장기 보존 자료를 유지합니다.
- Gateway 릴리스는 profile 작업과 다른 전역 `admin.releases.manage` 권한을
사용하며 외부 release-controller가 실행합니다.
- 브라우저의 메뉴 노출은 편의 기능입니다. 권한 판단의 기준은 서버가 인증
session에서 해석한 capability입니다.
상세 배포·복구 절차는 [릴리스 운영 매뉴얼](./release-operations.md)을
따릅니다.
+5
View File
@@ -15,6 +15,9 @@ hero:
- theme: alt
text: 릴리스 운영
link: /release-operations
- theme: alt
text: 관리자 콘솔
link: /admin-console
features:
- title: 시스템 구조
@@ -32,6 +35,8 @@ features:
[시간과 턴](./user/time-and-turns.md)과
[커맨드 목록](./user/command-catalog.generated.md)을 확인해 주세요. Profile과
Gateway 배포는 [릴리스 운영 매뉴얼](./release-operations.md)을 따라 주세요.
관리자 화면의 메뉴와 권한·운영 경계는
[관리자 콘솔](./admin-console.md)에서 확인할 수 있습니다.
게임 진행 시각과 운영 벽시계의 경계는
[게임 시계](./architecture/game-clock.md)에 설명합니다.
[패키지와 파일 경계](./architecture/package-boundaries.md)는 source import와
+2 -1
View File
@@ -12,7 +12,8 @@ Gateway 전체는 별도 release-controller가 처리합니다.
| Gateway API·frontend·orchestrator | Gateway 관리자 화면 | 외부 release-controller | `GatewayReleaseOperation`, `GatewayReleaseState` |
| release-controller 자체 | 별도 CLI process | self-upgrade CLI | PM2 `sammo:release-controller` |
관리자 화면은 `/gateway/admin/server-operations`입니다. Profile 작업에는 해당
관리자 화면은 `/gateway/admin/releases`입니다. 이전 경로
`/gateway/admin/server-operations`는 호환성을 위해 새 화면으로 이동합니다. Profile 작업에는 해당
profile의 `admin.profiles.manage` 권한이 필요합니다. Gateway 전체 릴리스에는
profile 범위 권한과 별개인 전역 `admin.releases.manage` 권한이 필요합니다.
일반 사용자와 권한이 없는 관리자는 Gateway 릴리스 영역을 사용할 수 없습니다.