feat(gateway): stream release progress logs
This commit is contained in:
@@ -33,6 +33,7 @@ type FixtureState = {
|
||||
}>;
|
||||
runtimeRunning: boolean;
|
||||
requestBodies: Array<{ operation: string; body: unknown }>;
|
||||
gatewayLogPollCount?: number;
|
||||
capabilities?: Array<{ permission: string; scope: 'GLOBAL' | 'PROFILE'; scopes: string[] }>;
|
||||
};
|
||||
|
||||
@@ -128,6 +129,37 @@ const installFixture = async (page: Page, state: FixtureState) => {
|
||||
if (name === 'admin.releases.list') {
|
||||
return response(state.gatewayOperations);
|
||||
}
|
||||
if (name === 'admin.releases.logs') {
|
||||
const releaseOperation = state.gatewayOperations[0];
|
||||
if (!releaseOperation) throw new Error('Release operation fixture is missing');
|
||||
state.gatewayLogPollCount = (state.gatewayLogPollCount ?? 0) + 1;
|
||||
const completed = state.gatewayLogPollCount > 1;
|
||||
return response({
|
||||
operation: { ...releaseOperation, status: completed ? 'SUCCEEDED' : 'RUNNING' },
|
||||
entries: completed
|
||||
? [
|
||||
{
|
||||
cursor: '2',
|
||||
operationId: releaseOperation.id,
|
||||
level: 'OUTPUT',
|
||||
phase: 'build',
|
||||
message: 'gateway-frontend build complete',
|
||||
createdAt: '2026-08-01T02:00:02.000Z',
|
||||
},
|
||||
]
|
||||
: [
|
||||
{
|
||||
cursor: '1',
|
||||
operationId: releaseOperation.id,
|
||||
level: 'INFO',
|
||||
phase: 'build',
|
||||
message: 'Gateway 구성 요소를 빌드합니다.',
|
||||
createdAt: '2026-08-01T02:00:01.000Z',
|
||||
},
|
||||
],
|
||||
nextCursor: completed ? '2' : '1',
|
||||
});
|
||||
}
|
||||
if (name === 'admin.profiles.listScenarios') {
|
||||
return response(scenarios);
|
||||
}
|
||||
@@ -359,9 +391,23 @@ test('controls gateway deployment and rollback through the external controller q
|
||||
|
||||
await expect(page.getByText(/Gateway 배포 작업을 등록했습니다/)).toBeVisible();
|
||||
await expect(page.getByTestId('gateway-release-table')).toContainText('DEPLOY');
|
||||
await expect(page.getByTestId('gateway-release-log-panel')).toBeVisible();
|
||||
await expect(page.getByTestId('gateway-release-log')).toContainText('Gateway 구성 요소를 빌드합니다.');
|
||||
await expect(page.getByTestId('gateway-release-log')).toContainText('gateway-frontend build complete');
|
||||
await expect(page.getByTestId('gateway-release-log-status')).toContainText('SUCCEEDED');
|
||||
expect(state.gatewayLogPollCount).toBeGreaterThanOrEqual(2);
|
||||
expect(state.requestBodies.some((entry) => entry.operation === 'admin.releases.requestGatewayDeploy')).toBe(true);
|
||||
await page.screenshot({ path: testInfo.outputPath('gateway-release-desktop.png'), fullPage: true });
|
||||
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
const mobileLogGeometry = await page.getByTestId('gateway-release-log-panel').evaluate((element) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
return { x: rect.x, width: rect.width, viewportWidth: document.documentElement.clientWidth };
|
||||
});
|
||||
expect(mobileLogGeometry.x).toBeGreaterThanOrEqual(0);
|
||||
expect(mobileLogGeometry.x + mobileLogGeometry.width).toBeLessThanOrEqual(mobileLogGeometry.viewportWidth);
|
||||
await page.screenshot({ path: testInfo.outputPath('gateway-release-mobile.png'), fullPage: true });
|
||||
|
||||
state.gatewayOperations = [];
|
||||
await page.getByTestId('refresh-operations').click();
|
||||
await page.getByTestId('request-gateway-rollback').click();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue';
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue';
|
||||
|
||||
import AdminConsoleLayout from '../layouts/AdminConsoleLayout.vue';
|
||||
import { trpc } from '../utils/trpc';
|
||||
@@ -80,11 +80,26 @@ type GatewayReleaseOperation = {
|
||||
completedAt?: string;
|
||||
};
|
||||
|
||||
type GatewayReleaseLog = {
|
||||
cursor: string;
|
||||
operationId: string;
|
||||
level: 'INFO' | 'OUTPUT' | 'ERROR';
|
||||
phase: string;
|
||||
message: string;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
const profiles = ref<Profile[]>([]);
|
||||
const scenarios = ref<Scenario[]>([]);
|
||||
const operations = ref<Operation[]>([]);
|
||||
const gatewayReleaseState = ref<GatewayReleaseState | null>(null);
|
||||
const gatewayReleaseOperations = ref<GatewayReleaseOperation[]>([]);
|
||||
const selectedGatewayOperationId = ref('');
|
||||
const gatewayReleaseLogs = ref<GatewayReleaseLog[]>([]);
|
||||
const gatewayReleaseLogCursor = ref<string>();
|
||||
const gatewayReleaseLogStatus = ref('');
|
||||
const gatewayReleaseLogConnection = ref<'idle' | 'connected' | 'reconnecting'>('idle');
|
||||
const gatewayReleaseLogViewport = ref<HTMLElement>();
|
||||
const gatewayReleaseAvailable = ref(false);
|
||||
const selectedProfileName = ref(props.profileName ?? '');
|
||||
const capabilities = ref<Array<{ permission: string; scopes?: string[] }>>([]);
|
||||
@@ -95,6 +110,8 @@ const message = ref('');
|
||||
const errorMessage = ref('');
|
||||
let pollTimer: ReturnType<typeof setInterval> | undefined;
|
||||
let stateRequestInFlight = false;
|
||||
let releaseLogLoopGeneration = 0;
|
||||
let componentMounted = false;
|
||||
|
||||
const form = reactive({
|
||||
sourceMode: (props.mode === 'scenario' ? 'CURRENT' : 'BRANCH') as 'CURRENT' | 'BRANCH' | 'COMMIT',
|
||||
@@ -127,6 +144,10 @@ const gatewayForm = reactive({
|
||||
reason: '',
|
||||
});
|
||||
|
||||
const selectedGatewayOperation = computed(
|
||||
() => gatewayReleaseOperations.value.find((operation) => operation.id === selectedGatewayOperationId.value) ?? null
|
||||
);
|
||||
|
||||
const selectedProfile = computed(
|
||||
() => profiles.value.find((profile) => profile.profileName === selectedProfileName.value) ?? null
|
||||
);
|
||||
@@ -178,6 +199,8 @@ const toIso = (value: string): string | undefined => {
|
||||
};
|
||||
|
||||
const formatTime = (value?: string): string => (value ? new Date(value).toLocaleString('ko-KR') : '-');
|
||||
const formatLogTime = (value: string): string =>
|
||||
new Date(value).toLocaleTimeString('ko-KR', { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
||||
const shortSha = (value?: string): string => (value ? value.slice(0, 12) : '-');
|
||||
|
||||
const clearStatus = () => {
|
||||
@@ -200,6 +223,17 @@ const loadState = async (quiet = false) => {
|
||||
const releaseOperations = await adminClient.releases.list.query({ limit: 30 });
|
||||
gatewayReleaseState.value = state as GatewayReleaseState;
|
||||
gatewayReleaseOperations.value = releaseOperations as GatewayReleaseOperation[];
|
||||
const active = gatewayReleaseOperations.value.find((operation) =>
|
||||
['QUEUED', 'RUNNING'].includes(operation.status)
|
||||
);
|
||||
if (active && selectedGatewayOperationId.value !== active.id) {
|
||||
selectedGatewayOperationId.value = active.id;
|
||||
} else if (
|
||||
!selectedGatewayOperationId.value ||
|
||||
!gatewayReleaseOperations.value.some((operation) => operation.id === selectedGatewayOperationId.value)
|
||||
) {
|
||||
selectedGatewayOperationId.value = gatewayReleaseOperations.value[0]?.id ?? '';
|
||||
}
|
||||
gatewayReleaseAvailable.value = true;
|
||||
} else {
|
||||
const profileResult = await adminClient.profiles.list.query();
|
||||
@@ -219,6 +253,57 @@ const loadState = async (quiet = false) => {
|
||||
}
|
||||
};
|
||||
|
||||
const scrollReleaseLogToEnd = async () => {
|
||||
await nextTick();
|
||||
const viewport = gatewayReleaseLogViewport.value;
|
||||
if (viewport) viewport.scrollTop = viewport.scrollHeight;
|
||||
};
|
||||
|
||||
const pollGatewayReleaseLogs = async (operationId: string, generation: number) => {
|
||||
while (componentMounted && generation === releaseLogLoopGeneration && selectedGatewayOperationId.value === operationId) {
|
||||
try {
|
||||
const result = await adminClient.releases.logs.query({
|
||||
id: operationId,
|
||||
afterCursor: gatewayReleaseLogCursor.value,
|
||||
limit: 200,
|
||||
timeoutMs: 20_000,
|
||||
});
|
||||
if (generation !== releaseLogLoopGeneration || selectedGatewayOperationId.value !== operationId) return;
|
||||
gatewayReleaseLogConnection.value = 'connected';
|
||||
const entries = result.entries as GatewayReleaseLog[];
|
||||
if (entries.length) {
|
||||
const known = new Set(gatewayReleaseLogs.value.map((entry) => entry.cursor));
|
||||
gatewayReleaseLogs.value.push(...entries.filter((entry) => !known.has(entry.cursor)));
|
||||
gatewayReleaseLogs.value = gatewayReleaseLogs.value.slice(-1_000);
|
||||
gatewayReleaseLogCursor.value = result.nextCursor;
|
||||
await scrollReleaseLogToEnd();
|
||||
}
|
||||
const operation = result.operation as GatewayReleaseOperation;
|
||||
gatewayReleaseLogStatus.value = operation.status;
|
||||
const index = gatewayReleaseOperations.value.findIndex((entry) => entry.id === operation.id);
|
||||
if (index >= 0) gatewayReleaseOperations.value[index] = operation;
|
||||
if (['SUCCEEDED', 'FAILED', 'CANCELLED'].includes(operation.status)) return;
|
||||
} catch {
|
||||
if (generation !== releaseLogLoopGeneration || !componentMounted) return;
|
||||
gatewayReleaseLogConnection.value = 'reconnecting';
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 1_000));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const selectGatewayReleaseOperation = (operationId: string) => {
|
||||
if (selectedGatewayOperationId.value === operationId) {
|
||||
releaseLogLoopGeneration += 1;
|
||||
gatewayReleaseLogs.value = [];
|
||||
gatewayReleaseLogCursor.value = undefined;
|
||||
gatewayReleaseLogStatus.value = '';
|
||||
gatewayReleaseLogConnection.value = 'idle';
|
||||
void pollGatewayReleaseLogs(operationId, releaseLogLoopGeneration);
|
||||
return;
|
||||
}
|
||||
selectedGatewayOperationId.value = operationId;
|
||||
};
|
||||
|
||||
const requestDeploy = async () => {
|
||||
clearStatus();
|
||||
if (!selectedProfile.value || activeOperation.value || !form.sourceRef.trim() || form.sourceMode === 'CURRENT') {
|
||||
@@ -254,11 +339,12 @@ const requestGatewayDeploy = async () => {
|
||||
if (!window.confirm(`Gateway 전체를 ${gatewayForm.sourceRef.trim()} 버전으로 전환하시겠습니까?`)) return;
|
||||
submitting.value = true;
|
||||
try {
|
||||
await adminClient.releases.requestGatewayDeploy.mutate({
|
||||
const operation = await adminClient.releases.requestGatewayDeploy.mutate({
|
||||
sourceMode: gatewayForm.sourceMode,
|
||||
sourceRef: gatewayForm.sourceRef.trim(),
|
||||
reason: gatewayForm.reason.trim() || undefined,
|
||||
});
|
||||
selectedGatewayOperationId.value = operation.id;
|
||||
message.value = 'Gateway 배포 작업을 등록했습니다. 외부 release-controller가 처리합니다.';
|
||||
await loadState(true);
|
||||
} catch (error) {
|
||||
@@ -279,9 +365,10 @@ const requestGatewayRollback = async () => {
|
||||
return;
|
||||
submitting.value = true;
|
||||
try {
|
||||
await adminClient.releases.requestGatewayRollback.mutate({
|
||||
const operation = await adminClient.releases.requestGatewayRollback.mutate({
|
||||
reason: gatewayForm.reason.trim() || undefined,
|
||||
});
|
||||
selectedGatewayOperationId.value = operation.id;
|
||||
message.value = 'Gateway rollback 작업을 등록했습니다.';
|
||||
await loadState(true);
|
||||
} catch (error) {
|
||||
@@ -420,13 +507,25 @@ watch(selectedProfileName, () => {
|
||||
}
|
||||
});
|
||||
|
||||
watch(selectedGatewayOperationId, (operationId) => {
|
||||
releaseLogLoopGeneration += 1;
|
||||
gatewayReleaseLogs.value = [];
|
||||
gatewayReleaseLogCursor.value = undefined;
|
||||
gatewayReleaseLogStatus.value = '';
|
||||
gatewayReleaseLogConnection.value = operationId ? 'connected' : 'idle';
|
||||
if (operationId && componentMounted) void pollGatewayReleaseLogs(operationId, releaseLogLoopGeneration);
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
componentMounted = true;
|
||||
await loadState();
|
||||
if (props.mode === 'scenario') await loadScenarios();
|
||||
pollTimer = setInterval(() => void loadState(true), 3000);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
componentMounted = false;
|
||||
releaseLogLoopGeneration += 1;
|
||||
if (pollTimer) {
|
||||
clearInterval(pollTimer);
|
||||
}
|
||||
@@ -888,6 +987,57 @@ onBeforeUnmount(() => {
|
||||
<div v-if="gatewayReleaseState?.lastError" class="text-sm text-red-300">
|
||||
{{ gatewayReleaseState.lastError }}
|
||||
</div>
|
||||
<section
|
||||
v-if="selectedGatewayOperationId"
|
||||
class="overflow-hidden rounded border border-zinc-700 bg-zinc-950"
|
||||
data-testid="gateway-release-log-panel"
|
||||
aria-live="polite"
|
||||
>
|
||||
<div class="flex flex-wrap items-center justify-between gap-2 border-b border-zinc-800 px-4 py-3">
|
||||
<div>
|
||||
<h4 class="text-sm font-semibold text-zinc-100">실시간 릴리스 로그</h4>
|
||||
<p class="mt-1 font-mono text-[11px] text-zinc-500">
|
||||
{{ selectedGatewayOperationId }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 text-xs">
|
||||
<span
|
||||
class="h-2 w-2 rounded-full"
|
||||
:class="
|
||||
gatewayReleaseLogConnection === 'reconnecting'
|
||||
? 'animate-pulse bg-amber-400'
|
||||
: ['QUEUED', 'RUNNING'].includes(
|
||||
gatewayReleaseLogStatus || selectedGatewayOperation?.status || ''
|
||||
)
|
||||
? 'animate-pulse bg-emerald-400'
|
||||
: 'bg-zinc-500'
|
||||
"
|
||||
></span>
|
||||
<span data-testid="gateway-release-log-status">
|
||||
{{ gatewayReleaseLogStatus || selectedGatewayOperation?.status || '연결 중' }}
|
||||
<template v-if="gatewayReleaseLogConnection === 'reconnecting'"> · 재연결 중</template>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
ref="gatewayReleaseLogViewport"
|
||||
class="h-72 overflow-y-auto px-4 py-3 font-mono text-xs leading-5"
|
||||
data-testid="gateway-release-log"
|
||||
>
|
||||
<div v-if="!gatewayReleaseLogs.length" class="text-zinc-500">
|
||||
controller 로그를 기다리고 있습니다…
|
||||
</div>
|
||||
<div
|
||||
v-for="entry in gatewayReleaseLogs"
|
||||
:key="entry.cursor"
|
||||
:class="entry.level === 'ERROR' ? 'text-red-300' : entry.level === 'OUTPUT' ? 'text-zinc-300' : 'text-cyan-300'"
|
||||
>
|
||||
<span class="text-zinc-600">{{ formatLogTime(entry.createdAt) }}</span>
|
||||
<span class="ml-2 text-violet-300">[{{ entry.phase }}]</span>
|
||||
<span class="ml-2 whitespace-pre-wrap break-all">{{ entry.message }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full min-w-[760px] text-left text-xs" data-testid="gateway-release-table">
|
||||
<thead class="border-b border-zinc-700 text-zinc-500">
|
||||
@@ -898,6 +1048,7 @@ onBeforeUnmount(() => {
|
||||
<th class="p-2">소스</th>
|
||||
<th class="p-2">해석 커밋</th>
|
||||
<th class="p-2">오류</th>
|
||||
<th class="p-2">로그</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -912,6 +1063,16 @@ onBeforeUnmount(() => {
|
||||
<td class="p-2 font-mono">{{ operation.sourceRef }}</td>
|
||||
<td class="p-2 font-mono">{{ shortSha(operation.resolvedCommitSha) }}</td>
|
||||
<td class="max-w-xs p-2 text-red-300">{{ operation.error }}</td>
|
||||
<td class="p-2">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded border border-zinc-700 px-2 py-1 text-zinc-300 hover:bg-zinc-800"
|
||||
:class="operation.id === selectedGatewayOperationId ? 'border-violet-500 text-violet-200' : ''"
|
||||
@click="selectGatewayReleaseOperation(operation.id)"
|
||||
>
|
||||
보기
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
Reference in New Issue
Block a user