feat: 관리자 일괄 업데이트 화면을 추가한다

권한 있는 Gateway와 서버를 체크박스로 선택하고 진행 상태와 실패 재시도를 desktop 및 mobile에서 제공한다.
This commit is contained in:
2026-08-25 09:19:20 +00:00
parent 9613b482c1
commit 7fa14b2b20
7 changed files with 900 additions and 20 deletions
@@ -66,6 +66,23 @@ type FixtureState = {
scenarioFailuresRemaining?: number;
resetDefaults?: Record<string, unknown>;
updateMetaFails?: boolean;
bulkBatches?: Array<{
id: string;
sourceMode: 'BRANCH' | 'COMMIT';
sourceRef: string;
resolvedCommitSha: string;
requestedBy: string;
createdAt: string;
status: OperationStatus;
targets: Array<{
kind: 'GATEWAY' | 'PROFILE';
order: number;
label: string;
profileName?: string;
operationId: string;
status: OperationStatus;
}>;
}>;
};
const profile = (runtimeRunning: boolean, resetDefaults?: Record<string, unknown>) => ({
@@ -220,6 +237,66 @@ const installFixture = async (page: Page, state: FixtureState) => {
]
);
}
if (name === 'admin.bulkReleases.targets') {
return response({
gateway: true,
profiles: [
{
profileName: 'che:default',
displayName: '체',
status: 'RUNNING',
currentScenario: '2',
buildCommitSha: '0123456789abcdef0123456789abcdef01234567',
activeOperation: null,
},
{
profileName: 'hwe:default',
displayName: '환상',
status: 'RUNNING',
currentScenario: '1010',
buildCommitSha: 'fedcba9876543210fedcba9876543210fedcba98',
activeOperation: {
id: '99999999-9999-4999-8999-999999999999',
type: 'DEPLOY',
status: 'RUNNING',
},
},
],
});
}
if (name === 'admin.bulkReleases.list') {
return response(state.bulkBatches ?? []);
}
if (name === 'admin.bulkReleases.request') {
const batch = {
id: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa',
sourceMode: 'BRANCH' as const,
sourceRef: 'main',
resolvedCommitSha: '1234567890abcdef1234567890abcdef12345678',
requestedBy: 'admin',
createdAt: '2026-08-25T01:00:00.000Z',
status: 'QUEUED' as const,
targets: [
{
kind: 'GATEWAY' as const,
order: 0,
label: 'Gateway',
operationId: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb',
status: 'QUEUED' as const,
},
{
kind: 'PROFILE' as const,
order: 1,
label: '체',
profileName: 'che:default',
operationId: 'cccccccc-cccc-4ccc-8ccc-cccccccccccc',
status: 'QUEUED' as const,
},
],
};
state.bulkBatches = [batch];
return response({ id: batch.id, resolvedCommitSha: batch.resolvedCommitSha, targetCount: 2 });
}
if (name === 'admin.operations.list') {
return response(state.operations);
}
@@ -530,6 +607,91 @@ const deferred = () => {
return { promise, resolve };
};
test('selects authorized Gateway and profile targets and registers one pinned bulk update', async ({
page,
}, testInfo) => {
const state: FixtureState = {
operations: [],
gatewayOperations: [],
runtimeRunning: true,
requestBodies: [],
};
await installFixture(page, state);
page.on('dialog', (dialog) => dialog.accept());
await page.goto('admin/releases/batch');
await expect(page.getByRole('heading', { name: '일괄 업데이트', level: 1 })).toBeVisible();
await expect(page.getByTestId('bulk-target-gateway')).toBeEnabled();
await expect(page.getByTestId('bulk-target-che:default')).toBeEnabled();
await expect(page.getByTestId('bulk-target-hwe:default')).toBeDisabled();
await page.getByTestId('bulk-target-gateway').check();
await page.getByTestId('bulk-target-che:default').check();
await page.getByTestId('submit-bulk-release').click();
await expect(page.getByText('1234567890ab', { exact: true })).toBeVisible();
await expect(page.getByText('Gateway', { exact: true }).last()).toBeVisible();
await expect(page.getByText('체', { exact: true }).last()).toBeVisible();
const request = state.requestBodies.find((entry) => entry.operation === 'admin.bulkReleases.request');
expect(JSON.stringify(request?.body)).toContain('che:default');
expect(JSON.stringify(request?.body)).toContain('includeGateway');
const formBox = await page.getByTestId('bulk-release-form').boundingBox();
expect(formBox?.width ?? 0).toBeGreaterThan(700);
await page.screenshot({ path: testInfo.outputPath('bulk-release-desktop.png'), fullPage: true });
});
test('keeps bulk target selection and progress readable on a 390px mobile viewport', async ({ page }, testInfo) => {
await page.setViewportSize({ width: 390, height: 844 });
const state: FixtureState = {
operations: [],
gatewayOperations: [],
runtimeRunning: true,
requestBodies: [],
bulkBatches: [
{
id: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa',
sourceMode: 'COMMIT',
sourceRef: '1234567890abcdef1234567890abcdef12345678',
resolvedCommitSha: '1234567890abcdef1234567890abcdef12345678',
requestedBy: 'admin',
createdAt: '2026-08-25T01:00:00.000Z',
status: 'FAILED',
targets: [
{
kind: 'GATEWAY',
order: 0,
label: 'Gateway',
operationId: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb',
status: 'SUCCEEDED',
},
{
kind: 'PROFILE',
order: 1,
label: '체',
profileName: 'che:default',
operationId: 'cccccccc-cccc-4ccc-8ccc-cccccccccccc',
status: 'FAILED',
},
],
},
],
};
await installFixture(page, state);
await page.goto('admin/releases/batch');
await page.getByText('1234567890ab', { exact: true }).click();
await expect(page.getByRole('button', { name: '재시도' })).toBeVisible();
const metrics = await page.evaluate(() => ({
scrollWidth: document.documentElement.scrollWidth,
clientWidth: document.documentElement.clientWidth,
checkboxSize: getComputedStyle(document.querySelector<HTMLInputElement>('[data-testid="bulk-target-gateway"]')!)
.width,
}));
expect(metrics.scrollWidth).toBeLessThanOrEqual(metrics.clientWidth);
expect(Number.parseFloat(metrics.checkboxSize)).toBeGreaterThanOrEqual(18);
await page.screenshot({ path: testInfo.outputPath('bulk-release-mobile.png'), fullPage: true });
});
test('separates branch and commit semantics and submits a reset from the dedicated page', async ({
page,
}, testInfo) => {
@@ -80,6 +80,14 @@ const navigation = computed(() => [
{
label: 'Gateway',
items: [
{
to: '/admin/releases/batch',
label: '일괄 업데이트',
icon: '⇈',
exact: false,
visible: hasCapability('admin.releases.manage') || hasCapability('admin.profiles.deploy'),
child: false,
},
{
to: '/admin/releases',
label: 'Gateway 릴리스',
+6
View File
@@ -6,6 +6,7 @@ const OpenSuggestionView = () => import('../views/OpenSuggestionView.vue');
const AdminOverviewView = () => import('../views/AdminOverviewView.vue');
const AdminView = () => import('../views/AdminView.vue');
const ServerOperationsView = () => import('../views/ServerOperationsView.vue');
const BulkReleaseView = () => import('../views/BulkReleaseView.vue');
const AccountView = () => import('../views/AccountView.vue');
const OAuthCallbackView = () => import('../views/OAuthCallbackView.vue');
const SignupView = () => import('../views/SignupView.vue');
@@ -86,6 +87,11 @@ const router = createRouter({
component: AdminView,
props: { section: 'audit' },
},
{
path: '/admin/releases/batch',
name: 'admin-bulk-releases',
component: BulkReleaseView,
},
{
path: '/admin/releases',
name: 'admin-releases',
@@ -35,6 +35,14 @@ const sections = computed(
tone: 'emerald',
visible: isRootAdmin.value || profileCount.value > 0,
},
{
to: '/admin/releases/batch',
eyebrow: 'Release batch',
title: '일괄 업데이트',
description: 'Gateway와 권한 있는 서버를 동일한 고정 커밋으로 순차 업데이트합니다.',
tone: 'blue',
visible: hasCapability('admin.releases.manage') || hasCapability('admin.profiles.deploy'),
},
{
to: '/admin/releases',
eyebrow: 'Releases',
+17 -8
View File
@@ -2384,15 +2384,24 @@ onMounted(() => {
</div>
<div v-if="section === 'servers'" class="space-y-4">
<div class="flex items-center justify-between">
<div class="flex flex-wrap items-center justify-between gap-2">
<h3 class="text-lg font-semibold">서버별 관리</h3>
<button
class="bg-zinc-700 hover:bg-zinc-600 text-white text-sm px-3 py-1.5 rounded"
:disabled="profilesLoading"
@click="loadProfiles"
>
새로고침
</button>
<div class="flex items-center gap-2">
<RouterLink
v-if="hasCapability('admin.profiles.deploy')"
to="/admin/releases/batch"
class="rounded bg-sky-700 px-3 py-1.5 text-sm font-semibold text-white hover:bg-sky-600"
>
일괄 업데이트
</RouterLink>
<button
class="bg-zinc-700 hover:bg-zinc-600 text-white text-sm px-3 py-1.5 rounded"
:disabled="profilesLoading"
@click="loadProfiles"
>
새로고침
</button>
</div>
</div>
<div
v-for="profile in visibleProfiles"
@@ -0,0 +1,680 @@
<script setup lang="ts">
import { formatServerDateTime } from '@sammo-ts/common/time/ServerDateTime';
import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue';
import AdminConsoleLayout from '../layouts/AdminConsoleLayout.vue';
import { useToast } from '../composables/useToast';
import { trpc } from '../utils/trpc';
type OperationStatus = 'QUEUED' | 'RUNNING' | 'SUCCEEDED' | 'FAILED' | 'CANCELLED';
type BulkTarget = {
kind: 'GATEWAY' | 'PROFILE';
order: number;
label: string;
profileName?: string;
operationId: string;
status: OperationStatus;
error?: string;
startedAt?: string;
completedAt?: string;
};
type BulkRelease = {
id: string;
sourceMode: 'BRANCH' | 'COMMIT';
sourceRef: string;
resolvedCommitSha: string;
reason?: string;
requestedBy: string;
createdAt: string;
status: OperationStatus;
targets: BulkTarget[];
};
type AvailableProfile = {
profileName: string;
displayName: string;
status: string;
currentScenario: string | null;
buildCommitSha?: string;
activeOperation?: { id: string; type: string; status: OperationStatus } | null;
scheduledResetAt?: string;
};
const adminClient = trpc.admin as unknown as {
bulkReleases: {
targets: { query: () => Promise<{ gateway: boolean; profiles: AvailableProfile[] }> };
list: { query: (input: { limit: number }) => Promise<BulkRelease[]> };
request: {
mutate: (input: {
includeGateway: boolean;
profileNames: string[];
sourceMode: 'BRANCH' | 'COMMIT';
sourceRef: string;
reason?: string;
}) => Promise<{ id: string; resolvedCommitSha: string; targetCount: number }>;
};
};
operations: { retry: { mutate: (input: { id: string }) => Promise<unknown> } };
releases: { retry: { mutate: (input: { id: string }) => Promise<unknown> } };
};
const form = reactive({
sourceMode: 'BRANCH' as 'BRANCH' | 'COMMIT',
sourceRef: 'main',
reason: '',
});
const gatewayAvailable = ref(false);
const includeGateway = ref(false);
const profiles = ref<AvailableProfile[]>([]);
const selectedProfileNames = ref<string[]>([]);
const batches = ref<BulkRelease[]>([]);
const loading = ref(false);
const submitting = ref(false);
const errorMessage = ref('');
const expandedBatchId = ref('');
const { success: showSuccessToast, error: showErrorToast } = useToast();
let pollTimer: ReturnType<typeof setInterval> | undefined;
const selectableProfiles = computed(() => profiles.value.filter((profile) => !profile.activeOperation));
const selectedCount = computed(() => selectedProfileNames.value.length + (includeGateway.value ? 1 : 0));
const allProfilesSelected = computed(
() =>
selectableProfiles.value.length > 0 &&
selectableProfiles.value.every((profile) => selectedProfileNames.value.includes(profile.profileName))
);
const hasActiveBatch = computed(() =>
batches.value.some((batch) => batch.status === 'QUEUED' || batch.status === 'RUNNING')
);
const statusLabel = (status: OperationStatus): string =>
({
QUEUED: '대기 중',
RUNNING: '진행 중',
SUCCEEDED: '완료',
FAILED: '실패',
CANCELLED: '중단됨',
})[status];
const shortSha = (value?: string): string => value?.slice(0, 12) ?? '-';
const toggleAllProfiles = () => {
selectedProfileNames.value = allProfilesSelected.value
? []
: selectableProfiles.value.map((profile) => profile.profileName);
};
const loadTargets = async () => {
const result = await adminClient.bulkReleases.targets.query();
gatewayAvailable.value = result.gateway;
profiles.value = result.profiles;
if (!gatewayAvailable.value) includeGateway.value = false;
const availableNames = new Set(selectableProfiles.value.map((profile) => profile.profileName));
selectedProfileNames.value = selectedProfileNames.value.filter((profileName) => availableNames.has(profileName));
};
const loadBatches = async () => {
batches.value = await adminClient.bulkReleases.list.query({ limit: 20 });
};
const loadState = async () => {
if (loading.value) return;
loading.value = true;
try {
await Promise.all([loadTargets(), loadBatches()]);
errorMessage.value = '';
} catch (error) {
errorMessage.value = error instanceof Error ? error.message : '일괄 업데이트 정보를 불러오지 못했습니다.';
} finally {
loading.value = false;
}
};
const submit = async () => {
errorMessage.value = '';
const sourceRef = form.sourceRef.trim();
if (!selectedCount.value || !sourceRef) return;
const labels = [
...(includeGateway.value ? ['Gateway'] : []),
...profiles.value
.filter((profile) => selectedProfileNames.value.includes(profile.profileName))
.map((profile) => profile.displayName),
];
if (
!window.confirm(
`${labels.join(' · ')}을(를) ${sourceRef}의 동일 커밋으로 순차 업데이트하시겠습니까? 각 profile의 게임 DB는 유지됩니다.`
)
) {
return;
}
submitting.value = true;
try {
const result = await adminClient.bulkReleases.request.mutate({
includeGateway: includeGateway.value,
profileNames: selectedProfileNames.value,
sourceMode: form.sourceMode,
sourceRef,
reason: form.reason.trim() || undefined,
});
includeGateway.value = false;
selectedProfileNames.value = [];
expandedBatchId.value = result.id;
showSuccessToast(
`${result.targetCount}개 대상의 일괄 업데이트를 ${shortSha(result.resolvedCommitSha)}로 등록했습니다.`
);
await loadState();
} catch (error) {
errorMessage.value = error instanceof Error ? error.message : '일괄 업데이트 등록에 실패했습니다.';
showErrorToast(errorMessage.value);
} finally {
submitting.value = false;
}
};
const retryTarget = async (target: BulkTarget) => {
if (!window.confirm(`${target.label} 작업을 일괄 업데이트의 고정 커밋으로 다시 실행하시겠습니까?`)) return;
try {
if (target.kind === 'GATEWAY') {
await adminClient.releases.retry.mutate({ id: target.operationId });
} else {
await adminClient.operations.retry.mutate({ id: target.operationId });
}
showSuccessToast(`${target.label} 재시도를 등록했습니다.`);
await loadState();
} catch (error) {
showErrorToast(error instanceof Error ? error.message : '재시도 등록에 실패했습니다.');
}
};
onMounted(async () => {
await loadState();
pollTimer = setInterval(() => void loadState(), 2_000);
});
onBeforeUnmount(() => {
if (pollTimer) clearInterval(pollTimer);
});
</script>
<template>
<AdminConsoleLayout
title="일괄 업데이트"
description="Gateway와 권한이 있는 서버를 하나의 고정 커밋으로 순차 업데이트합니다."
eyebrow="Release batch"
>
<div class="space-y-6">
<section class="batch-panel space-y-5" data-testid="bulk-release-form">
<div class="batch-heading">
<div>
<h2> 일괄 업데이트</h2>
<p>Gateway를 먼저 처리하고, 선택한 서버는 표시 순서대로 DB 유지 배포합니다.</p>
</div>
<button type="button" class="secondary-button" :disabled="loading" @click="loadState">
새로고침
</button>
</div>
<div class="source-grid">
<label>
<span>소스 종류</span>
<select v-model="form.sourceMode" data-testid="bulk-source-mode">
<option value="BRANCH">브랜치</option>
<option value="COMMIT">커밋</option>
</select>
</label>
<label>
<span>브랜치 또는 전체 commit SHA</span>
<input v-model="form.sourceRef" class="font-mono" data-testid="bulk-source-ref" />
</label>
<label>
<span>작업 사유</span>
<input v-model="form.reason" maxlength="200" placeholder="운영 메모" />
</label>
</div>
<fieldset class="target-fieldset">
<legend>업데이트 대상</legend>
<label v-if="gatewayAvailable" class="target-row gateway-target">
<input v-model="includeGateway" type="checkbox" data-testid="bulk-target-gateway" />
<span class="target-copy">
<strong>Gateway</strong>
<small>API · frontend · orchestrator</small>
</span>
<span class="target-badge">먼저 실행</span>
</label>
<div class="target-toolbar">
<span>서버 {{ selectableProfiles.length }}</span>
<button
type="button"
class="text-button"
:disabled="!selectableProfiles.length"
@click="toggleAllProfiles"
>
{{ allProfilesSelected ? '서버 선택 해제' : '권한 있는 서버 전체 선택' }}
</button>
</div>
<label
v-for="profile in profiles"
:key="profile.profileName"
class="target-row"
:class="{ blocked: Boolean(profile.activeOperation) }"
>
<input
v-model="selectedProfileNames"
type="checkbox"
:value="profile.profileName"
:disabled="Boolean(profile.activeOperation)"
:data-testid="`bulk-target-${profile.profileName}`"
/>
<span class="target-copy">
<strong>{{ profile.displayName }}</strong>
<small>현재 {{ shortSha(profile.buildCommitSha) }} · {{ profile.status }}</small>
</span>
<span v-if="profile.activeOperation" class="target-badge warning">
{{ statusLabel(profile.activeOperation.status) }} 작업 있음
</span>
<span v-else-if="profile.scheduledResetAt" class="target-badge warning">초기화 예약 유지</span>
<span v-else-if="profile.currentScenario === null" class="target-badge warning"
>시나리오 미설정</span
>
<span v-else class="target-badge">DB 유지</span>
</label>
</fieldset>
<div v-if="errorMessage" class="error-box" role="alert">{{ errorMessage }}</div>
<div class="submit-row">
<p>
선택 {{ selectedCount }} · branch도 등록 하나의 commit SHA로 고정됩니다. 실패한 대상 뒤의
작업은 재시도 전까지 대기합니다.
</p>
<button
type="button"
class="primary-button"
:disabled="submitting || !selectedCount || !form.sourceRef.trim() || hasActiveBatch"
data-testid="submit-bulk-release"
@click="submit"
>
{{ submitting ? '등록 중…' : `선택 ${selectedCount}개 일괄 업데이트` }}
</button>
</div>
<p v-if="hasActiveBatch" class="active-notice">
진행 중인 일괄 업데이트가 끝난 묶음을 등록할 있습니다.
</p>
</section>
<section class="space-y-3" aria-labelledby="bulk-history-title">
<div class="batch-heading">
<div>
<h2 id="bulk-history-title">일괄 업데이트 이력</h2>
<p>묶음은 원자적 rollback이 아니며, 성공한 대상은 그대로 유지됩니다.</p>
</div>
</div>
<div v-if="!batches.length" class="empty-state">등록된 일괄 업데이트가 없습니다.</div>
<article v-for="batch in batches" :key="batch.id" class="batch-history" :data-status="batch.status">
<button
type="button"
class="batch-summary"
:aria-expanded="expandedBatchId === batch.id"
:aria-controls="`bulk-release-${batch.id}`"
@click="expandedBatchId = expandedBatchId === batch.id ? '' : batch.id"
>
<span>
<strong>{{ shortSha(batch.resolvedCommitSha) }}</strong>
<small
>{{ batch.sourceMode }} {{ batch.sourceRef }} ·
{{ formatServerDateTime(batch.createdAt) }}</small
>
</span>
<span class="status-pill" :data-status="batch.status">{{ statusLabel(batch.status) }}</span>
</button>
<div v-if="expandedBatchId === batch.id" :id="`bulk-release-${batch.id}`" class="batch-details">
<p v-if="batch.reason" class="batch-reason">사유: {{ batch.reason }}</p>
<ol class="target-progress">
<li v-for="target in batch.targets" :key="target.operationId">
<span class="target-order">{{ target.order + 1 }}</span>
<span class="target-progress-copy">
<strong>{{ target.label }}</strong>
<small v-if="target.error">{{ target.error }}</small>
<small v-else-if="target.completedAt"
>완료 {{ formatServerDateTime(target.completedAt) }}</small
>
<small v-else-if="target.startedAt"
>시작 {{ formatServerDateTime(target.startedAt) }}</small
>
<small v-else> 작업 완료 대기</small>
</span>
<span class="status-pill" :data-status="target.status">{{
statusLabel(target.status)
}}</span>
<button
v-if="target.status === 'FAILED' || target.status === 'CANCELLED'"
type="button"
class="secondary-button compact"
@click="retryTarget(target)"
>
재시도
</button>
<RouterLink
v-else
class="detail-link"
:to="
target.kind === 'GATEWAY'
? '/admin/releases'
: `/admin/servers/${encodeURIComponent(target.profileName ?? '')}/version`
"
>
상세
</RouterLink>
</li>
</ol>
</div>
</article>
</section>
</div>
</AdminConsoleLayout>
</template>
<style scoped>
.batch-panel,
.batch-history,
.empty-state {
border: 1px solid #27272a;
border-radius: 10px;
background: #111113;
}
.batch-panel {
padding: 20px;
}
.batch-heading,
.submit-row,
.target-toolbar,
.batch-summary,
.target-row,
.target-progress li {
display: flex;
align-items: center;
justify-content: space-between;
gap: 14px;
}
.batch-heading h2 {
margin: 0;
font-size: 18px;
}
.batch-heading p,
.submit-row p {
margin: 5px 0 0;
color: #a1a1aa;
font-size: 12px;
line-height: 1.6;
}
.source-grid {
display: grid;
grid-template-columns: minmax(130px, 0.6fr) minmax(220px, 1.4fr) minmax(200px, 1fr);
gap: 12px;
}
.source-grid label {
display: grid;
gap: 6px;
color: #a1a1aa;
font-size: 12px;
}
.source-grid input,
.source-grid select {
min-width: 0;
border: 1px solid #3f3f46;
border-radius: 6px;
background: #09090b;
padding: 10px 12px;
color: #fafafa;
}
.target-fieldset {
border: 0;
padding: 0;
}
.target-fieldset legend {
margin-bottom: 10px;
color: #d4d4d8;
font-size: 13px;
font-weight: 700;
}
.target-row {
min-height: 58px;
border: 1px solid #27272a;
border-radius: 7px;
background: #09090b;
padding: 10px 12px;
cursor: pointer;
}
.target-row + .target-row {
margin-top: 8px;
}
.target-row.gateway-target {
margin-bottom: 14px;
border-color: #5b21b6;
background: #1e1234;
}
.target-row.blocked {
cursor: not-allowed;
opacity: 0.62;
}
.target-row input {
width: 18px;
height: 18px;
flex: 0 0 auto;
accent-color: #0ea5e9;
}
.target-copy,
.target-progress-copy,
.batch-summary > span:first-child {
display: grid;
min-width: 0;
flex: 1;
gap: 3px;
}
.target-copy small,
.target-progress-copy small,
.batch-summary small {
overflow-wrap: anywhere;
color: #71717a;
font-size: 11px;
}
.target-toolbar {
margin: 0 0 8px;
color: #a1a1aa;
font-size: 12px;
}
.target-badge,
.status-pill {
flex: 0 0 auto;
border: 1px solid #3f3f46;
border-radius: 999px;
padding: 4px 8px;
color: #d4d4d8;
font-size: 10px;
}
.target-badge.warning {
border-color: #92400e;
color: #fcd34d;
}
.primary-button,
.secondary-button,
.text-button {
border: 0;
border-radius: 6px;
font-weight: 700;
cursor: pointer;
}
.primary-button {
min-width: 220px;
background: #0369a1;
padding: 11px 16px;
color: white;
}
.secondary-button {
background: #3f3f46;
padding: 8px 12px;
color: white;
}
.secondary-button.compact {
padding: 6px 9px;
font-size: 11px;
}
.text-button {
background: transparent;
padding: 4px;
color: #7dd3fc;
}
button:disabled {
cursor: not-allowed;
opacity: 0.45;
}
.error-box,
.active-notice {
border: 1px solid #7f1d1d;
border-radius: 6px;
background: #450a0a66;
padding: 10px 12px;
color: #fecaca;
font-size: 12px;
}
.active-notice {
margin: 0;
border-color: #854d0e;
background: #42200666;
color: #fde68a;
}
.empty-state {
padding: 28px;
color: #71717a;
text-align: center;
}
.batch-summary {
width: 100%;
border: 0;
background: transparent;
padding: 15px 16px;
color: #fafafa;
text-align: left;
cursor: pointer;
}
.batch-details {
border-top: 1px solid #27272a;
padding: 14px 16px 16px;
}
.batch-reason {
margin: 0 0 12px;
color: #a1a1aa;
font-size: 12px;
}
.target-progress {
display: grid;
gap: 8px;
margin: 0;
padding: 0;
list-style: none;
}
.target-progress li {
border-radius: 6px;
background: #09090b;
padding: 10px;
}
.target-order {
display: grid;
width: 24px;
height: 24px;
flex: 0 0 auto;
place-items: center;
border-radius: 50%;
background: #27272a;
color: #d4d4d8;
font-size: 11px;
}
.status-pill[data-status='RUNNING'] {
border-color: #047857;
color: #6ee7b7;
}
.status-pill[data-status='SUCCEEDED'] {
border-color: #155e75;
color: #67e8f9;
}
.status-pill[data-status='FAILED'] {
border-color: #991b1b;
color: #fca5a5;
}
.status-pill[data-status='QUEUED'] {
border-color: #92400e;
color: #fcd34d;
}
.detail-link {
color: #7dd3fc;
font-size: 11px;
font-weight: 700;
text-decoration: none;
}
@media (max-width: 760px) {
.source-grid {
grid-template-columns: 1fr;
}
.batch-heading,
.submit-row {
align-items: stretch;
flex-direction: column;
}
.primary-button {
width: 100%;
min-width: 0;
}
.target-progress li {
display: grid;
grid-template-columns: auto 1fr auto;
}
.target-progress li .secondary-button,
.target-progress li .detail-link {
grid-column: 2 / -1;
justify-self: start;
}
}
</style>
+19 -12
View File
@@ -8,18 +8,19 @@ Gateway 관리자 콘솔은 `/gateway/admin`에서 시작합니다. 공개 로
좌측 메뉴는 관리 책임을 다음과 같이 분리합니다.
| 메뉴 | 경로 | 책임 |
| --------------- | ---------------------------------------------- | ------------------------------------------------------------------------- |
| 운영 개요 | `/gateway/admin` | 현재 권한으로 접근할 수 있는 관리 영역 안내 |
| 사용자 관리 | `/gateway/admin/users` | 계정 식별자·Kakao 교체, 권한, 특수 접근·제재, 아이콘 복구와 탈퇴 예약 |
| 서버 관리 | `/gateway/admin/servers` | 접근 가능한 profile 목록 |
| 서버 상태·설정 | `/gateway/admin/servers/:profileName` | 해당 profile의 공개 정보, 계정 정책, 실행 상태와 게임 운영 동작 |
| 버전 업데이트 | `/gateway/admin/servers/:profileName/version` | 현 DB를 보존하는 profile 코드·migration 배포 |
| 시나리오 초기화 | `/gateway/admin/servers/:profileName/scenario` | 서버 지정 branch 최신 또는 고정 commit으로 현 시즌 DB와 시나리오 교체 |
| 게임 취소 | `/gateway/admin/servers/:profileName/cancel` | 잘못 연 게임을 닫고 기록·유산 포인트를 취소 정책에 따라 원자적으로 정산 |
| Gateway 릴리스 | `/gateway/admin/releases` | Gateway control plane 배포와 rollback |
| 공지 · 접속 | `/gateway/admin/system` | 로비 공지와 관리자 세션 연결 |
| 감사 로그 | `/gateway/admin/audit` | 관리자 조치 결과, 대상과 사유 조회 |
| 메뉴 | 경로 | 책임 |
| --------------- | ---------------------------------------------- | ----------------------------------------------------------------------- |
| 운영 개요 | `/gateway/admin` | 현재 권한으로 접근할 수 있는 관리 영역 안내 |
| 사용자 관리 | `/gateway/admin/users` | 계정 식별자·Kakao 교체, 권한, 특수 접근·제재, 아이콘 복구와 탈퇴 예약 |
| 서버 관리 | `/gateway/admin/servers` | 접근 가능한 profile 목록 |
| 서버 상태·설정 | `/gateway/admin/servers/:profileName` | 해당 profile의 공개 정보, 계정 정책, 실행 상태와 게임 운영 동작 |
| 버전 업데이트 | `/gateway/admin/servers/:profileName/version` | 현 DB를 보존하는 profile 코드·migration 배포 |
| 시나리오 초기화 | `/gateway/admin/servers/:profileName/scenario` | 서버 지정 branch 최신 또는 고정 commit으로 현 시즌 DB와 시나리오 교체 |
| 게임 취소 | `/gateway/admin/servers/:profileName/cancel` | 잘못 연 게임을 닫고 기록·유산 포인트를 취소 정책에 따라 원자적으로 정산 |
| 일괄 업데이트 | `/gateway/admin/releases/batch` | Gateway와 권한 있는 profile을 하나의 고정 commit으로 순차 DB 보존 배포 |
| Gateway 릴리스 | `/gateway/admin/releases` | Gateway control plane 배포와 rollback |
| 공지 · 접속 | `/gateway/admin/system` | 로비 공지와 관리자 세션 연결 |
| 감사 로그 | `/gateway/admin/audit` | 관리자 조치 결과, 대상과 사유 조회 |
기존 `/gateway/admin/server-operations` 링크는 query string을 보존한 채
`/gateway/admin/servers`로 이동합니다. 즐겨찾기와 이전 운영 보고서의 링크를
@@ -106,6 +107,12 @@ Gateway 관리자 콘솔은 `/gateway/admin`에서 시작합니다. 공개 로
사용하며 외부 release-controller가 실행합니다. 선택한 릴리스 작업의 단계와
명령 출력을 관리자 화면이 long polling으로 이어 받아 표시하며, 완료된 이력의
로그도 다시 열 수 있습니다.
- 일괄 업데이트는 `admin.releases.manage`가 있는 경우에만 Gateway를, 각
`admin.profiles.deploy:<name>` 범위 안의 profile만 선택 대상으로 표시합니다.
브랜치는 묶음 등록 시 서버에서 한 번 full commit SHA로 해석하며 Gateway를 먼저,
profile은 관리자 목록 순서대로 실행합니다. 앞 대상이 실패하거나 중단되면 뒤 대상은
`QUEUED`로 유지되고, 실패 대상을 같은 고정 commit으로 재시도하면 이어서 실행합니다.
묶음은 원자적 rollback이 아니므로 이미 성공한 대상은 그대로 유지합니다.
- 브라우저의 메뉴 노출은 편의 기능입니다. 권한 판단의 기준은 서버가 인증
session에서 해석한 capability입니다.