feat(gateway): reorganize admin console navigation
This commit is contained in:
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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)을
|
||||
따릅니다.
|
||||
@@ -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와
|
||||
|
||||
@@ -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 릴리스 영역을 사용할 수 없습니다.
|
||||
|
||||
Reference in New Issue
Block a user