feat: add admin scenario operations
This commit is contained in:
@@ -4,10 +4,18 @@ import path from 'node:path';
|
|||||||
import { TRPCError } from '@trpc/server';
|
import { TRPCError } from '@trpc/server';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { createGamePostgresConnector, resolvePostgresConfigFromEnv } from '@sammo-ts/infra';
|
import {
|
||||||
|
createGamePostgresConnector,
|
||||||
|
resolvePostgresConfigFromEnv,
|
||||||
|
type GatewayPrisma,
|
||||||
|
} from '@sammo-ts/infra';
|
||||||
|
|
||||||
import { procedure, router } from './trpc.js';
|
import { procedure, router } from './trpc.js';
|
||||||
import { listScenarioPreviews, resolveGitCommitSha } from './scenario/scenarioCatalog.js';
|
import {
|
||||||
|
listScenarioPreviews,
|
||||||
|
resolveGitBranchCommitSha,
|
||||||
|
resolveGitCommitSha,
|
||||||
|
} from './scenario/scenarioCatalog.js';
|
||||||
import type { UserSanctions, UserServerRestriction } from './auth/userRepository.js';
|
import type { UserSanctions, UserServerRestriction } from './auth/userRepository.js';
|
||||||
import { toPublicUser } from './auth/userRepository.js';
|
import { toPublicUser } from './auth/userRepository.js';
|
||||||
import type { AdminAuthContext } from './adminAuth.js';
|
import type { AdminAuthContext } from './adminAuth.js';
|
||||||
@@ -241,6 +249,8 @@ const zInstallAutorun = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const isAllowedTurnTerm = (value: number): boolean => TURN_TERM_MINUTES.some((term) => term === value);
|
const isAllowedTurnTerm = (value: number): boolean => TURN_TERM_MINUTES.some((term) => term === value);
|
||||||
|
const isUniqueConstraintError = (error: unknown): boolean =>
|
||||||
|
Boolean(error && typeof error === 'object' && 'code' in error && error.code === 'P2002');
|
||||||
|
|
||||||
const zInstallOptions = z.object({
|
const zInstallOptions = z.object({
|
||||||
scenarioId: z.number().int().min(0),
|
scenarioId: z.number().int().min(0),
|
||||||
@@ -260,6 +270,8 @@ const zInstallOptions = z.object({
|
|||||||
preopenAt: z.string().datetime().optional(),
|
preopenAt: z.string().datetime().optional(),
|
||||||
gitRef: z.string().min(1).max(128).optional(),
|
gitRef: z.string().min(1).max(128).optional(),
|
||||||
});
|
});
|
||||||
|
const zOperationInstallOptions = zInstallOptions.omit({ gitRef: true });
|
||||||
|
const zSourceMode = z.enum(['BRANCH', 'COMMIT']);
|
||||||
|
|
||||||
type SanctionsPatch = z.infer<typeof zSanctionsPatch>;
|
type SanctionsPatch = z.infer<typeof zSanctionsPatch>;
|
||||||
|
|
||||||
@@ -578,6 +590,237 @@ export const adminRouter = router({
|
|||||||
return { ok: true };
|
return { ok: true };
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
|
operations: router({
|
||||||
|
list: adminProcedure
|
||||||
|
.input(
|
||||||
|
z
|
||||||
|
.object({
|
||||||
|
profileName: z.string().min(1).optional(),
|
||||||
|
limit: z.number().int().min(1).max(200).optional(),
|
||||||
|
})
|
||||||
|
.optional()
|
||||||
|
)
|
||||||
|
.query(async ({ ctx, input }) => {
|
||||||
|
const adminAuth = requireAdminAuth(ctx);
|
||||||
|
if (input?.profileName) {
|
||||||
|
assertPermission(adminAuth, ROLE_ADMIN_PROFILES, input.profileName);
|
||||||
|
return ctx.profiles.listOperations({
|
||||||
|
profileName: input.profileName,
|
||||||
|
limit: input.limit,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (hasScopedPermission(adminAuth, ROLE_ADMIN_PROFILES)) {
|
||||||
|
return ctx.profiles.listOperations({ limit: input?.limit });
|
||||||
|
}
|
||||||
|
const profiles = await ctx.profiles.listProfiles();
|
||||||
|
const allowed = profiles.filter((profile) =>
|
||||||
|
hasScopedPermission(adminAuth, ROLE_ADMIN_PROFILES, profile.profileName)
|
||||||
|
);
|
||||||
|
const operations = (
|
||||||
|
await Promise.all(
|
||||||
|
allowed.map((profile) =>
|
||||||
|
ctx.profiles.listOperations({
|
||||||
|
profileName: profile.profileName,
|
||||||
|
limit: input?.limit,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.flat()
|
||||||
|
.sort((left, right) => right.createdAt.localeCompare(left.createdAt));
|
||||||
|
return operations.slice(0, input?.limit ?? 50);
|
||||||
|
}),
|
||||||
|
requestReset: adminProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
profileName: z.string().min(1),
|
||||||
|
sourceMode: zSourceMode,
|
||||||
|
sourceRef: z.string().min(1).max(128),
|
||||||
|
install: zOperationInstallOptions,
|
||||||
|
scheduledAt: z.string().datetime().optional(),
|
||||||
|
reason: z.string().max(200).optional(),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const adminAuth = requireAdminAuth(ctx);
|
||||||
|
assertPermission(adminAuth, ROLE_ADMIN_PROFILES, input.profileName);
|
||||||
|
const profile = await ctx.profiles.getProfile(input.profileName);
|
||||||
|
if (!profile) {
|
||||||
|
throw new TRPCError({ code: 'NOT_FOUND', message: 'Profile not found.' });
|
||||||
|
}
|
||||||
|
if (input.scheduledAt && new Date(input.scheduledAt).getTime() <= Date.now()) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'BAD_REQUEST',
|
||||||
|
message: 'scheduledAt must be in the future.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const scheduledAt = input.scheduledAt ? new Date(input.scheduledAt) : null;
|
||||||
|
const openAt = input.install.openAt ? new Date(input.install.openAt) : null;
|
||||||
|
const preopenAt = input.install.preopenAt ? new Date(input.install.preopenAt) : null;
|
||||||
|
if (preopenAt && !openAt) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'BAD_REQUEST',
|
||||||
|
message: 'openAt is required when preopenAt is set.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (preopenAt && openAt && preopenAt.getTime() >= openAt.getTime()) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'BAD_REQUEST',
|
||||||
|
message: 'preopenAt must be earlier than openAt.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (openAt && openAt.getTime() <= (scheduledAt?.getTime() ?? Date.now())) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'BAD_REQUEST',
|
||||||
|
message: 'openAt must be later than the reset start.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (preopenAt && scheduledAt && preopenAt.getTime() < scheduledAt.getTime()) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'BAD_REQUEST',
|
||||||
|
message: 'preopenAt cannot be earlier than scheduledAt.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const autorunUser = input.install.autorunUser;
|
||||||
|
if (
|
||||||
|
autorunUser &&
|
||||||
|
((autorunUser.limitMinutes <= 0 && autorunUser.options.length > 0) ||
|
||||||
|
(autorunUser.limitMinutes > 0 && autorunUser.options.length === 0))
|
||||||
|
) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'BAD_REQUEST',
|
||||||
|
message: 'autorunUser minutes and options must be configured together.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let sourceRef = input.sourceRef.trim();
|
||||||
|
try {
|
||||||
|
const resolved =
|
||||||
|
input.sourceMode === 'BRANCH'
|
||||||
|
? await resolveGitBranchCommitSha(sourceRef)
|
||||||
|
: await resolveGitCommitSha(sourceRef);
|
||||||
|
if (input.sourceMode === 'COMMIT') {
|
||||||
|
sourceRef = resolved;
|
||||||
|
}
|
||||||
|
const scenarios = await listScenarioPreviews({ gitRef: resolved });
|
||||||
|
if (!scenarios.some((scenario) => scenario.id === input.install.scenarioId)) {
|
||||||
|
throw new Error('Scenario not found at source.');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'BAD_REQUEST',
|
||||||
|
message:
|
||||||
|
input.sourceMode === 'BRANCH'
|
||||||
|
? 'Branch is invalid or does not contain the scenario.'
|
||||||
|
: 'Commit is invalid or does not contain the scenario.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const operation = await ctx.profiles.createOperation({
|
||||||
|
profileName: input.profileName,
|
||||||
|
type: 'RESET',
|
||||||
|
sourceMode: input.sourceMode,
|
||||||
|
sourceRef,
|
||||||
|
payload: { install: input.install } as GatewayPrisma.JsonObject,
|
||||||
|
reason: input.reason,
|
||||||
|
requestedBy: adminAuth.user.id,
|
||||||
|
scheduledAt: input.scheduledAt,
|
||||||
|
});
|
||||||
|
return operation;
|
||||||
|
} catch (error) {
|
||||||
|
if (!isUniqueConstraintError(error)) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'CONFLICT',
|
||||||
|
message: 'This profile already has a queued or running operation.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
requestRuntime: adminProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
profileName: z.string().min(1),
|
||||||
|
action: z.enum(['START', 'STOP']),
|
||||||
|
reason: z.string().max(200).optional(),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const adminAuth = requireAdminAuth(ctx);
|
||||||
|
assertPermission(adminAuth, ROLE_ADMIN_PROFILES, input.profileName);
|
||||||
|
const profile = await ctx.profiles.getProfile(input.profileName);
|
||||||
|
if (!profile) {
|
||||||
|
throw new TRPCError({ code: 'NOT_FOUND', message: 'Profile not found.' });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const operation = await ctx.profiles.createOperation({
|
||||||
|
profileName: input.profileName,
|
||||||
|
type: input.action,
|
||||||
|
reason: input.reason,
|
||||||
|
requestedBy: adminAuth.user.id,
|
||||||
|
});
|
||||||
|
return operation;
|
||||||
|
} catch (error) {
|
||||||
|
if (!isUniqueConstraintError(error)) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'CONFLICT',
|
||||||
|
message: 'This profile already has a queued or running operation.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
cancel: adminProcedure
|
||||||
|
.input(z.object({ id: z.string().uuid() }))
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const adminAuth = requireAdminAuth(ctx);
|
||||||
|
const previous = await ctx.profiles.getOperation(input.id);
|
||||||
|
if (!previous) {
|
||||||
|
throw new TRPCError({ code: 'NOT_FOUND', message: 'Operation not found.' });
|
||||||
|
}
|
||||||
|
assertPermission(adminAuth, ROLE_ADMIN_PROFILES, previous.profileName);
|
||||||
|
const cancelled = await ctx.profiles.cancelOperation(input.id);
|
||||||
|
if (!cancelled) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'CONFLICT',
|
||||||
|
message: 'Only queued operations can be cancelled.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return { ok: true };
|
||||||
|
}),
|
||||||
|
retry: adminProcedure
|
||||||
|
.input(z.object({ id: z.string().uuid() }))
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const adminAuth = requireAdminAuth(ctx);
|
||||||
|
const previous = await ctx.profiles.getOperation(input.id);
|
||||||
|
if (!previous) {
|
||||||
|
throw new TRPCError({ code: 'NOT_FOUND', message: 'Operation not found.' });
|
||||||
|
}
|
||||||
|
assertPermission(adminAuth, ROLE_ADMIN_PROFILES, previous.profileName);
|
||||||
|
try {
|
||||||
|
const operation = await ctx.profiles.retryOperation(input.id, adminAuth.user.id);
|
||||||
|
if (!operation) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'CONFLICT',
|
||||||
|
message: 'Only failed or cancelled operations can be retried.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return operation;
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof TRPCError) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
if (!isUniqueConstraintError(error)) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'CONFLICT',
|
||||||
|
message: 'This profile already has a queued or running operation.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
}),
|
||||||
profiles: router({
|
profiles: router({
|
||||||
list: adminProcedure.query(async ({ ctx }) => {
|
list: adminProcedure.query(async ({ ctx }) => {
|
||||||
const profiles = await ctx.profiles.listProfiles();
|
const profiles = await ctx.profiles.listProfiles();
|
||||||
@@ -599,12 +842,20 @@ export const adminRouter = router({
|
|||||||
z
|
z
|
||||||
.object({
|
.object({
|
||||||
gitRef: z.string().min(1).max(128).optional(),
|
gitRef: z.string().min(1).max(128).optional(),
|
||||||
|
sourceMode: zSourceMode.optional(),
|
||||||
})
|
})
|
||||||
.optional()
|
.optional()
|
||||||
)
|
)
|
||||||
.query(async ({ input }) => {
|
.query(async ({ input }) => {
|
||||||
const gitRef = input?.gitRef?.trim();
|
const gitRef = input?.gitRef?.trim();
|
||||||
return listScenarioPreviews({ gitRef: gitRef || null });
|
if (!gitRef) {
|
||||||
|
return listScenarioPreviews();
|
||||||
|
}
|
||||||
|
const resolved =
|
||||||
|
input?.sourceMode === 'BRANCH'
|
||||||
|
? await resolveGitBranchCommitSha(gitRef)
|
||||||
|
: await resolveGitCommitSha(gitRef);
|
||||||
|
return listScenarioPreviews({ gitRef: resolved });
|
||||||
}),
|
}),
|
||||||
upsert: profileAdminProcedure
|
upsert: profileAdminProcedure
|
||||||
.input(
|
.input(
|
||||||
|
|||||||
@@ -7,7 +7,12 @@ import { isRecord } from '@sammo-ts/common';
|
|||||||
|
|
||||||
import type { BuildCommand, BuildRunner } from './buildRunner.js';
|
import type { BuildCommand, BuildRunner } from './buildRunner.js';
|
||||||
import type { ProcessManager } from './processManager.js';
|
import type { ProcessManager } from './processManager.js';
|
||||||
import type { GatewayProfileRecord, GatewayProfileRepository, GatewayProfileStatus } from './profileRepository.js';
|
import type {
|
||||||
|
GatewayOperationRecord,
|
||||||
|
GatewayProfileRecord,
|
||||||
|
GatewayProfileRepository,
|
||||||
|
GatewayProfileStatus,
|
||||||
|
} from './profileRepository.js';
|
||||||
import type { GitWorkspaceManager } from './workspaceManager.js';
|
import type { GitWorkspaceManager } from './workspaceManager.js';
|
||||||
import { seedProfileDatabase, type AdminSeedUser } from './seedProfileDatabase.js';
|
import { seedProfileDatabase, type AdminSeedUser } from './seedProfileDatabase.js';
|
||||||
|
|
||||||
@@ -46,6 +51,7 @@ export interface GatewayOrchestratorHandle {
|
|||||||
reconcileNow(): Promise<void>;
|
reconcileNow(): Promise<void>;
|
||||||
runScheduleNow(): Promise<void>;
|
runScheduleNow(): Promise<void>;
|
||||||
runBuildQueueNow(): Promise<void>;
|
runBuildQueueNow(): Promise<void>;
|
||||||
|
runOperationsNow(): Promise<void>;
|
||||||
cleanupStaleWorkspaces(): Promise<{
|
cleanupStaleWorkspaces(): Promise<{
|
||||||
removed: string[];
|
removed: string[];
|
||||||
skipped: string[];
|
skipped: string[];
|
||||||
@@ -376,6 +382,7 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
|
|||||||
private scheduleInFlight = false;
|
private scheduleInFlight = false;
|
||||||
private buildInFlight = false;
|
private buildInFlight = false;
|
||||||
private adminActionInFlight = false;
|
private adminActionInFlight = false;
|
||||||
|
private operationInFlight = false;
|
||||||
private readonly resetInFlight = new Set<string>();
|
private readonly resetInFlight = new Set<string>();
|
||||||
|
|
||||||
constructor(options: GatewayOrchestratorOptions) {
|
constructor(options: GatewayOrchestratorOptions) {
|
||||||
@@ -393,11 +400,15 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
|
|||||||
|
|
||||||
start(): void {
|
start(): void {
|
||||||
void this.reconcileNow();
|
void this.reconcileNow();
|
||||||
|
void this.runOperationsNow();
|
||||||
void this.runAdminActionsNow();
|
void this.runAdminActionsNow();
|
||||||
this.reconcileTimer = setInterval(() => void this.reconcileNow(), this.reconcileIntervalMs);
|
this.reconcileTimer = setInterval(() => void this.reconcileNow(), this.reconcileIntervalMs);
|
||||||
this.scheduleTimer = setInterval(() => void this.runScheduleNow(), this.scheduleIntervalMs);
|
this.scheduleTimer = setInterval(() => void this.runScheduleNow(), this.scheduleIntervalMs);
|
||||||
this.buildTimer = setInterval(() => void this.runBuildQueueNow(), this.buildIntervalMs);
|
this.buildTimer = setInterval(() => void this.runBuildQueueNow(), this.buildIntervalMs);
|
||||||
this.adminActionTimer = setInterval(() => void this.runAdminActionsNow(), this.adminActionIntervalMs);
|
this.adminActionTimer = setInterval(() => {
|
||||||
|
void this.runOperationsNow();
|
||||||
|
void this.runAdminActionsNow();
|
||||||
|
}, this.adminActionIntervalMs);
|
||||||
}
|
}
|
||||||
|
|
||||||
async stop(): Promise<void> {
|
async stop(): Promise<void> {
|
||||||
@@ -548,6 +559,83 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async runOperationsNow(): Promise<void> {
|
||||||
|
if (this.operationInFlight || this.buildInFlight) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.operationInFlight = true;
|
||||||
|
try {
|
||||||
|
const operation = await this.repository.claimNextOperation(this.now());
|
||||||
|
if (!operation) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await this.handleOperation(operation);
|
||||||
|
} finally {
|
||||||
|
this.operationInFlight = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleOperation(operation: GatewayOperationRecord): Promise<void> {
|
||||||
|
const profile = await this.repository.getProfile(operation.profileName);
|
||||||
|
if (!profile) {
|
||||||
|
await this.repository.completeOperation(operation.id, 'FAILED', {
|
||||||
|
error: 'Profile not found.',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (operation.type === 'START') {
|
||||||
|
const updated = await this.repository.updateStatus(profile.profileName, 'RUNNING', {
|
||||||
|
preopenAt: null,
|
||||||
|
openAt: null,
|
||||||
|
scheduledStartAt: null,
|
||||||
|
});
|
||||||
|
const started = await this.startProfile(updated ?? profile);
|
||||||
|
if (!started) {
|
||||||
|
throw new Error('Failed to start profile processes.');
|
||||||
|
}
|
||||||
|
await this.repository.completeOperation(operation.id, 'SUCCEEDED', { error: null });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (operation.type === 'STOP') {
|
||||||
|
await this.repository.updateStatus(profile.profileName, 'STOPPED');
|
||||||
|
await this.stopProfile(profile);
|
||||||
|
await this.repository.completeOperation(operation.id, 'SUCCEEDED', { error: null });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!operation.sourceMode || !operation.sourceRef) {
|
||||||
|
throw new Error('Reset source mode and ref are required.');
|
||||||
|
}
|
||||||
|
const commitSha = await this.workspaceManager.resolveCommit(operation.sourceMode, operation.sourceRef);
|
||||||
|
const payload = normalizeMeta(operation.payload);
|
||||||
|
const install = isRecord(payload.install) ? payload.install : {};
|
||||||
|
const resetAction: GatewayAdminActionRecord = {
|
||||||
|
action: operation.scheduledAt ? 'RESET_SCHEDULED' : 'RESET_NOW',
|
||||||
|
requestedAt: operation.createdAt,
|
||||||
|
scheduledAt: operation.scheduledAt ?? null,
|
||||||
|
reason: operation.reason ?? null,
|
||||||
|
install,
|
||||||
|
};
|
||||||
|
const result = await this.handleResetAction(profile, resetAction, commitSha);
|
||||||
|
if (result.status === 'REQUESTED') {
|
||||||
|
const retryAt = new Date(this.now().getTime() + this.adminActionIntervalMs).toISOString();
|
||||||
|
await this.repository.requeueOperation(operation.id, result.detail, retryAt);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (result.status !== 'APPLIED') {
|
||||||
|
throw new Error(result.detail ?? 'Reset failed.');
|
||||||
|
}
|
||||||
|
await this.repository.completeOperation(operation.id, 'SUCCEEDED', {
|
||||||
|
resolvedCommitSha: commitSha,
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const detail = error instanceof Error ? error.message : String(error);
|
||||||
|
await this.repository.completeOperation(operation.id, 'FAILED', { error: detail });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async runAdminActionsNow(): Promise<void> {
|
private async runAdminActionsNow(): Promise<void> {
|
||||||
if (this.adminActionInFlight) {
|
if (this.adminActionInFlight) {
|
||||||
return;
|
return;
|
||||||
@@ -632,7 +720,8 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
|
|||||||
|
|
||||||
private async handleResetAction(
|
private async handleResetAction(
|
||||||
profile: GatewayProfileRecord,
|
profile: GatewayProfileRecord,
|
||||||
action: GatewayAdminActionRecord
|
action: GatewayAdminActionRecord,
|
||||||
|
commitShaOverride?: string
|
||||||
): Promise<GatewayAdminActionResult> {
|
): Promise<GatewayAdminActionResult> {
|
||||||
// 리셋 요청을 빌드+재기동 흐름으로 처리한다.
|
// 리셋 요청을 빌드+재기동 흐름으로 처리한다.
|
||||||
if (this.resetInFlight.has(profile.profileName)) {
|
if (this.resetInFlight.has(profile.profileName)) {
|
||||||
@@ -651,7 +740,7 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const commitSha = profile.buildCommitSha;
|
const commitSha = commitShaOverride ?? profile.buildCommitSha;
|
||||||
if (!commitSha) {
|
if (!commitSha) {
|
||||||
return { status: 'FAILED', detail: 'buildCommitSha is missing' };
|
return { status: 'FAILED', detail: 'buildCommitSha is missing' };
|
||||||
}
|
}
|
||||||
@@ -734,7 +823,10 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
|
|||||||
scheduledStartAt: action.scheduledAt ?? null,
|
scheduledStartAt: action.scheduledAt ?? null,
|
||||||
});
|
});
|
||||||
const builtProfile = (await this.repository.getProfile(profile.profileName)) ?? activeProfile;
|
const builtProfile = (await this.repository.getProfile(profile.profileName)) ?? activeProfile;
|
||||||
await this.startProfile(builtProfile);
|
const started = await this.startProfile(builtProfile);
|
||||||
|
if (!started) {
|
||||||
|
return { status: 'FAILED', detail: 'reset completed but profile processes failed to start' };
|
||||||
|
}
|
||||||
return { status: 'APPLIED', detail: 'reset completed via rebuild' };
|
return { status: 'APPLIED', detail: 'reset completed via rebuild' };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const detail = error instanceof Error ? error.message : String(error);
|
const detail = error instanceof Error ? error.message : String(error);
|
||||||
@@ -852,32 +944,39 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
|
|||||||
return cutoff;
|
return cutoff;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async startProfile(profile: GatewayProfileRecord): Promise<void> {
|
private async startProfile(profile: GatewayProfileRecord): Promise<boolean> {
|
||||||
const definitions = buildProcessDefinitions(profile, this.processConfig);
|
const definitions = buildProcessDefinitions(profile, this.processConfig);
|
||||||
try {
|
try {
|
||||||
await this.processManager.start(definitions.api);
|
await this.processManager.start(definitions.api);
|
||||||
await this.processManager.start(definitions.daemon);
|
await this.processManager.start(definitions.daemon);
|
||||||
await this.repository.updateLastError(profile.profileName, null);
|
await this.repository.updateLastError(profile.profileName, null);
|
||||||
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await this.repository.updateLastError(
|
await this.repository.updateLastError(
|
||||||
profile.profileName,
|
profile.profileName,
|
||||||
error instanceof Error ? error.message : 'Failed to start processes.'
|
error instanceof Error ? error.message : 'Failed to start processes.'
|
||||||
);
|
);
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async stopProfile(profile: GatewayProfileRecord): Promise<void> {
|
private async stopProfile(profile: GatewayProfileRecord): Promise<void> {
|
||||||
const apiName = buildProcessName(profile.profileName, 'api');
|
const apiName = buildProcessName(profile.profileName, 'api');
|
||||||
const daemonName = buildProcessName(profile.profileName, 'daemon');
|
const daemonName = buildProcessName(profile.profileName, 'daemon');
|
||||||
try {
|
const failures: string[] = [];
|
||||||
await this.processManager.stop(apiName);
|
for (const name of [apiName, daemonName]) {
|
||||||
} catch {
|
try {
|
||||||
await this.processManager.delete(apiName);
|
await this.processManager.stop(name);
|
||||||
|
} catch {
|
||||||
|
try {
|
||||||
|
await this.processManager.delete(name);
|
||||||
|
} catch (error) {
|
||||||
|
failures.push(`${name}: ${error instanceof Error ? error.message : String(error)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
try {
|
if (failures.length > 0) {
|
||||||
await this.processManager.stop(daemonName);
|
throw new Error(`Failed to stop profile processes: ${failures.join('; ')}`);
|
||||||
} catch {
|
|
||||||
await this.processManager.delete(daemonName);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,45 @@ export type GatewayProfileStatus = (typeof GATEWAY_PROFILE_STATUSES)[number];
|
|||||||
export const GATEWAY_BUILD_STATUSES = ['IDLE', 'QUEUED', 'RUNNING', 'FAILED', 'SUCCEEDED'] as const;
|
export const GATEWAY_BUILD_STATUSES = ['IDLE', 'QUEUED', 'RUNNING', 'FAILED', 'SUCCEEDED'] as const;
|
||||||
export type GatewayBuildStatus = (typeof GATEWAY_BUILD_STATUSES)[number];
|
export type GatewayBuildStatus = (typeof GATEWAY_BUILD_STATUSES)[number];
|
||||||
|
|
||||||
|
export const GATEWAY_OPERATION_TYPES = ['RESET', 'START', 'STOP'] as const;
|
||||||
|
export type GatewayOperationType = (typeof GATEWAY_OPERATION_TYPES)[number];
|
||||||
|
|
||||||
|
export const GATEWAY_OPERATION_STATUSES = ['QUEUED', 'RUNNING', 'SUCCEEDED', 'FAILED', 'CANCELLED'] as const;
|
||||||
|
export type GatewayOperationStatus = (typeof GATEWAY_OPERATION_STATUSES)[number];
|
||||||
|
|
||||||
|
export const GATEWAY_SOURCE_MODES = ['BRANCH', 'COMMIT'] as const;
|
||||||
|
export type GatewaySourceMode = (typeof GATEWAY_SOURCE_MODES)[number];
|
||||||
|
|
||||||
|
export interface GatewayOperationRecord {
|
||||||
|
id: string;
|
||||||
|
profileName: string;
|
||||||
|
type: GatewayOperationType;
|
||||||
|
status: GatewayOperationStatus;
|
||||||
|
sourceMode?: GatewaySourceMode;
|
||||||
|
sourceRef?: string;
|
||||||
|
resolvedCommitSha?: string;
|
||||||
|
payload: GatewayPrisma.JsonObject;
|
||||||
|
reason?: string;
|
||||||
|
requestedBy: string;
|
||||||
|
scheduledAt?: string;
|
||||||
|
startedAt?: string;
|
||||||
|
completedAt?: string;
|
||||||
|
error?: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GatewayOperationCreateInput {
|
||||||
|
profileName: string;
|
||||||
|
type: GatewayOperationType;
|
||||||
|
sourceMode?: GatewaySourceMode;
|
||||||
|
sourceRef?: string;
|
||||||
|
payload?: GatewayPrisma.JsonObject;
|
||||||
|
reason?: string;
|
||||||
|
requestedBy: string;
|
||||||
|
scheduledAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface GatewayProfileRecord {
|
export interface GatewayProfileRecord {
|
||||||
profileName: string;
|
profileName: string;
|
||||||
profile: string;
|
profile: string;
|
||||||
@@ -82,6 +121,18 @@ export interface GatewayProfileRepository {
|
|||||||
updateLastError(profileName: string, lastError: string | null): Promise<void>;
|
updateLastError(profileName: string, lastError: string | null): Promise<void>;
|
||||||
updateWorkspaceUsage(profileName: string, workspace: string, lastUsedAt: string): Promise<void>;
|
updateWorkspaceUsage(profileName: string, workspace: string, lastUsedAt: string): Promise<void>;
|
||||||
clearWorkspaceUsage(profileNames: string[]): Promise<void>;
|
clearWorkspaceUsage(profileNames: string[]): Promise<void>;
|
||||||
|
listOperations(options?: { profileName?: string; limit?: number }): Promise<GatewayOperationRecord[]>;
|
||||||
|
getOperation(id: string): Promise<GatewayOperationRecord | null>;
|
||||||
|
createOperation(input: GatewayOperationCreateInput): Promise<GatewayOperationRecord>;
|
||||||
|
claimNextOperation(now: Date): Promise<GatewayOperationRecord | null>;
|
||||||
|
completeOperation(
|
||||||
|
id: string,
|
||||||
|
status: Extract<GatewayOperationStatus, 'SUCCEEDED' | 'FAILED'>,
|
||||||
|
fields?: { resolvedCommitSha?: string | null; error?: string | null }
|
||||||
|
): Promise<GatewayOperationRecord>;
|
||||||
|
requeueOperation(id: string, detail?: string, retryAt?: string): Promise<GatewayOperationRecord>;
|
||||||
|
cancelOperation(id: string): Promise<boolean>;
|
||||||
|
retryOperation(id: string, requestedBy: string): Promise<GatewayOperationRecord | null>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const toIso = (value: Date | null): string | undefined => (value ? value.toISOString() : undefined);
|
const toIso = (value: Date | null): string | undefined => (value ? value.toISOString() : undefined);
|
||||||
@@ -109,6 +160,25 @@ type GatewayProfileRow = {
|
|||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type GatewayOperationRow = {
|
||||||
|
id: string;
|
||||||
|
profileName: string;
|
||||||
|
type: GatewayOperationType;
|
||||||
|
status: GatewayOperationStatus;
|
||||||
|
sourceMode: GatewaySourceMode | null;
|
||||||
|
sourceRef: string | null;
|
||||||
|
resolvedCommitSha: string | null;
|
||||||
|
payload: GatewayPrisma.JsonValue;
|
||||||
|
reason: string | null;
|
||||||
|
requestedBy: string;
|
||||||
|
scheduledAt: Date | null;
|
||||||
|
startedAt: Date | null;
|
||||||
|
completedAt: Date | null;
|
||||||
|
error: string | null;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
};
|
||||||
|
|
||||||
const mapProfile = (row: GatewayProfileRow): GatewayProfileRecord => ({
|
const mapProfile = (row: GatewayProfileRow): GatewayProfileRecord => ({
|
||||||
profileName: row.profileName,
|
profileName: row.profileName,
|
||||||
profile: row.profile,
|
profile: row.profile,
|
||||||
@@ -134,6 +204,25 @@ const mapProfile = (row: GatewayProfileRow): GatewayProfileRecord => ({
|
|||||||
|
|
||||||
const buildProfileName = (profile: string, scenario: string): string => `${profile}:${scenario}`;
|
const buildProfileName = (profile: string, scenario: string): string => `${profile}:${scenario}`;
|
||||||
|
|
||||||
|
const mapOperation = (row: GatewayOperationRow): GatewayOperationRecord => ({
|
||||||
|
id: row.id,
|
||||||
|
profileName: row.profileName,
|
||||||
|
type: row.type,
|
||||||
|
status: row.status,
|
||||||
|
sourceMode: row.sourceMode ?? undefined,
|
||||||
|
sourceRef: row.sourceRef ?? undefined,
|
||||||
|
resolvedCommitSha: row.resolvedCommitSha ?? undefined,
|
||||||
|
payload: (row.payload ?? {}) as GatewayPrisma.JsonObject,
|
||||||
|
reason: row.reason ?? undefined,
|
||||||
|
requestedBy: row.requestedBy,
|
||||||
|
scheduledAt: toIso(row.scheduledAt),
|
||||||
|
startedAt: toIso(row.startedAt),
|
||||||
|
completedAt: toIso(row.completedAt),
|
||||||
|
error: row.error ?? undefined,
|
||||||
|
createdAt: row.createdAt.toISOString(),
|
||||||
|
updatedAt: row.updatedAt.toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
export const createGatewayProfileRepository = (prisma: GatewayPrismaClient): GatewayProfileRepository => ({
|
export const createGatewayProfileRepository = (prisma: GatewayPrismaClient): GatewayProfileRepository => ({
|
||||||
async listProfiles(): Promise<GatewayProfileRecord[]> {
|
async listProfiles(): Promise<GatewayProfileRecord[]> {
|
||||||
const rows = await prisma.gatewayProfile.findMany({
|
const rows = await prisma.gatewayProfile.findMany({
|
||||||
@@ -327,4 +416,111 @@ export const createGatewayProfileRepository = (prisma: GatewayPrismaClient): Gat
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
async listOperations(options?: { profileName?: string; limit?: number }): Promise<GatewayOperationRecord[]> {
|
||||||
|
const rows = await prisma.gatewayOperation.findMany({
|
||||||
|
where: options?.profileName ? { profileName: options.profileName } : undefined,
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
take: Math.min(Math.max(options?.limit ?? 50, 1), 200),
|
||||||
|
});
|
||||||
|
return rows.map(mapOperation);
|
||||||
|
},
|
||||||
|
async getOperation(id: string): Promise<GatewayOperationRecord | null> {
|
||||||
|
const row = await prisma.gatewayOperation.findUnique({ where: { id } });
|
||||||
|
return row ? mapOperation(row) : null;
|
||||||
|
},
|
||||||
|
async createOperation(input: GatewayOperationCreateInput): Promise<GatewayOperationRecord> {
|
||||||
|
const row = await prisma.gatewayOperation.create({
|
||||||
|
data: {
|
||||||
|
profileName: input.profileName,
|
||||||
|
type: input.type,
|
||||||
|
sourceMode: input.sourceMode,
|
||||||
|
sourceRef: input.sourceRef,
|
||||||
|
payload: (input.payload ?? {}) as GatewayPrisma.JsonObject,
|
||||||
|
reason: input.reason,
|
||||||
|
requestedBy: input.requestedBy,
|
||||||
|
scheduledAt: input.scheduledAt ? new Date(input.scheduledAt) : null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return mapOperation(row);
|
||||||
|
},
|
||||||
|
async claimNextOperation(now: Date): Promise<GatewayOperationRecord | null> {
|
||||||
|
const row = await prisma.$transaction(async (tx) => {
|
||||||
|
const candidate = await tx.gatewayOperation.findFirst({
|
||||||
|
where: {
|
||||||
|
status: 'QUEUED',
|
||||||
|
OR: [{ scheduledAt: null }, { scheduledAt: { lte: now } }],
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: 'asc' },
|
||||||
|
});
|
||||||
|
if (!candidate) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const claimed = await tx.gatewayOperation.updateMany({
|
||||||
|
where: { id: candidate.id, status: 'QUEUED' },
|
||||||
|
data: { status: 'RUNNING', startedAt: now, error: null },
|
||||||
|
});
|
||||||
|
if (claimed.count !== 1) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return tx.gatewayOperation.findUnique({ where: { id: candidate.id } });
|
||||||
|
});
|
||||||
|
return row ? mapOperation(row) : null;
|
||||||
|
},
|
||||||
|
async completeOperation(
|
||||||
|
id: string,
|
||||||
|
status: Extract<GatewayOperationStatus, 'SUCCEEDED' | 'FAILED'>,
|
||||||
|
fields?: { resolvedCommitSha?: string | null; error?: string | null }
|
||||||
|
): Promise<GatewayOperationRecord> {
|
||||||
|
const row = await prisma.gatewayOperation.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
status,
|
||||||
|
completedAt: new Date(),
|
||||||
|
resolvedCommitSha:
|
||||||
|
fields?.resolvedCommitSha === undefined ? undefined : fields.resolvedCommitSha,
|
||||||
|
error: fields?.error === undefined ? undefined : fields.error,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return mapOperation(row);
|
||||||
|
},
|
||||||
|
async requeueOperation(id: string, detail?: string, retryAt?: string): Promise<GatewayOperationRecord> {
|
||||||
|
const row = await prisma.gatewayOperation.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
status: 'QUEUED',
|
||||||
|
startedAt: null,
|
||||||
|
error: detail,
|
||||||
|
scheduledAt: retryAt ? new Date(retryAt) : undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return mapOperation(row);
|
||||||
|
},
|
||||||
|
async cancelOperation(id: string): Promise<boolean> {
|
||||||
|
const result = await prisma.gatewayOperation.updateMany({
|
||||||
|
where: { id, status: 'QUEUED' },
|
||||||
|
data: { status: 'CANCELLED', completedAt: new Date() },
|
||||||
|
});
|
||||||
|
return result.count === 1;
|
||||||
|
},
|
||||||
|
async retryOperation(id: string, requestedBy: string): Promise<GatewayOperationRecord | null> {
|
||||||
|
const row = await prisma.$transaction(async (tx) => {
|
||||||
|
const previous = await tx.gatewayOperation.findUnique({ where: { id } });
|
||||||
|
if (!previous || (previous.status !== 'FAILED' && previous.status !== 'CANCELLED')) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return tx.gatewayOperation.create({
|
||||||
|
data: {
|
||||||
|
profileName: previous.profileName,
|
||||||
|
type: previous.type,
|
||||||
|
sourceMode: previous.sourceMode,
|
||||||
|
sourceRef: previous.sourceRef,
|
||||||
|
payload: previous.payload as GatewayPrisma.JsonObject,
|
||||||
|
reason: previous.reason,
|
||||||
|
requestedBy,
|
||||||
|
scheduledAt: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return row ? mapOperation(row) : null;
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -40,6 +40,15 @@ const ensureDir = (dir: string): void => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const hasInstallMarker = (dir: string): boolean => fs.existsSync(path.join(dir, 'node_modules', '.pnpm'));
|
const hasInstallMarker = (dir: string): boolean => fs.existsSync(path.join(dir, 'node_modules', '.pnpm'));
|
||||||
|
const GIT_REF_PATTERN = /^[0-9A-Za-z._/-]+$/;
|
||||||
|
|
||||||
|
const assertGitRef = (value: string): string => {
|
||||||
|
const ref = value.trim();
|
||||||
|
if (!ref || ref.startsWith('-') || ref.includes('..') || !GIT_REF_PATTERN.test(ref)) {
|
||||||
|
throw new Error('Invalid git ref.');
|
||||||
|
}
|
||||||
|
return ref;
|
||||||
|
};
|
||||||
|
|
||||||
export class GitWorkspaceManager {
|
export class GitWorkspaceManager {
|
||||||
private readonly repoRoot: string;
|
private readonly repoRoot: string;
|
||||||
@@ -52,6 +61,28 @@ export class GitWorkspaceManager {
|
|||||||
this.baseEnv = options.baseEnv;
|
this.baseEnv = options.baseEnv;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async resolveCommit(sourceMode: 'BRANCH' | 'COMMIT', sourceRef: string): Promise<string> {
|
||||||
|
const ref = assertGitRef(sourceRef);
|
||||||
|
if (sourceMode === 'BRANCH') {
|
||||||
|
const fetched = await runGit(['fetch', '--all', '--prune'], this.repoRoot, this.baseEnv);
|
||||||
|
if (!fetched.ok) {
|
||||||
|
throw new Error(fetched.output || 'Failed to fetch git branches.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const candidates =
|
||||||
|
sourceMode === 'BRANCH'
|
||||||
|
? [`refs/remotes/origin/${ref}^{commit}`, `refs/heads/${ref}^{commit}`]
|
||||||
|
: [`${ref}^{commit}`];
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
const result = await runGit(['rev-parse', '--verify', candidate], this.repoRoot, this.baseEnv);
|
||||||
|
const commitSha = result.output.trim().split('\n')[0];
|
||||||
|
if (result.ok && /^[0-9a-f]{40}$/i.test(commitSha)) {
|
||||||
|
return commitSha;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error(`${sourceMode === 'BRANCH' ? 'Branch' : 'Commit'} not found.`);
|
||||||
|
}
|
||||||
|
|
||||||
async prepare(commitSha: string): Promise<WorkspaceInfo> {
|
async prepare(commitSha: string): Promise<WorkspaceInfo> {
|
||||||
const workspacePath = path.join(this.worktreeRoot, commitSha);
|
const workspacePath = path.join(this.worktreeRoot, commitSha);
|
||||||
ensureDir(this.worktreeRoot);
|
ensureDir(this.worktreeRoot);
|
||||||
|
|||||||
@@ -98,6 +98,22 @@ export const resolveGitCommitSha = async (gitRef: string): Promise<string> => {
|
|||||||
return commit;
|
return commit;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const resolveGitBranchCommitSha = async (branch: string): Promise<string> => {
|
||||||
|
const normalized = normalizeGitRef(branch);
|
||||||
|
if (!normalized) {
|
||||||
|
throw new Error('git branch is invalid.');
|
||||||
|
}
|
||||||
|
await runGit(['fetch', '--all', '--prune']);
|
||||||
|
for (const candidate of [`refs/remotes/origin/${normalized}`, `refs/heads/${normalized}`]) {
|
||||||
|
const result = await runGit(['rev-parse', '--verify', `${candidate}^{commit}`]);
|
||||||
|
const commit = result.output.trim().split('\n')[0];
|
||||||
|
if (result.ok && /^[0-9a-f]{40}$/i.test(commit)) {
|
||||||
|
return commit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error('git branch not found.');
|
||||||
|
};
|
||||||
|
|
||||||
const readGitFile = async (commitSha: string, relativePath: string): Promise<string> => {
|
const readGitFile = async (commitSha: string, relativePath: string): Promise<string> => {
|
||||||
const result = await runGit(['show', `${commitSha}:${relativePath}`]);
|
const result = await runGit(['show', `${commitSha}:${relativePath}`]);
|
||||||
if (!result.ok) {
|
if (!result.ok) {
|
||||||
|
|||||||
@@ -0,0 +1,140 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import type { GatewayPrismaClient } from '@sammo-ts/infra';
|
||||||
|
|
||||||
|
import { InMemoryGatewaySessionService } from '../src/auth/inMemorySessionService.js';
|
||||||
|
import { createInMemoryUserRepository } from '../src/auth/inMemoryUserRepository.js';
|
||||||
|
import type { GatewayOperationCreateInput, GatewayProfileRepository } from '../src/orchestrator/profileRepository.js';
|
||||||
|
import { createGatewayApiContext } from '../src/context.js';
|
||||||
|
import { InMemoryProfileStatusService } from '../src/lobby/profileStatusService.js';
|
||||||
|
import { appRouter } from '../src/router.js';
|
||||||
|
|
||||||
|
const buildCaller = async (createOperation: GatewayProfileRepository['createOperation']) => {
|
||||||
|
const users = createInMemoryUserRepository();
|
||||||
|
const admin = await users.createUser({
|
||||||
|
username: 'admin',
|
||||||
|
password: 'secretpass',
|
||||||
|
displayName: 'Admin',
|
||||||
|
});
|
||||||
|
await users.updateRoles(admin.id, ['superuser']);
|
||||||
|
const sessions = new InMemoryGatewaySessionService({
|
||||||
|
sessionTtlSeconds: 600,
|
||||||
|
gameSessionTtlSeconds: 600,
|
||||||
|
});
|
||||||
|
const session = await sessions.createSession({ ...admin, roles: ['superuser'] });
|
||||||
|
const createdInputs: GatewayOperationCreateInput[] = [];
|
||||||
|
const profile = {
|
||||||
|
profileName: 'che:2',
|
||||||
|
profile: 'che',
|
||||||
|
scenario: '2',
|
||||||
|
apiPort: 15003,
|
||||||
|
status: 'STOPPED' as const,
|
||||||
|
buildStatus: 'SUCCEEDED' as const,
|
||||||
|
meta: {},
|
||||||
|
createdAt: '2026-07-25T00:00:00.000Z',
|
||||||
|
updatedAt: '2026-07-25T00:00:00.000Z',
|
||||||
|
};
|
||||||
|
const profiles: GatewayProfileRepository = {
|
||||||
|
listProfiles: async () => [profile],
|
||||||
|
getProfile: async () => profile,
|
||||||
|
upsertProfile: async () => profile,
|
||||||
|
updateScenario: async () => profile,
|
||||||
|
updateStatus: async () => profile,
|
||||||
|
updateBuildStatus: async () => profile,
|
||||||
|
updateMeta: async () => profile,
|
||||||
|
listReservedToStart: async () => [],
|
||||||
|
findQueuedBuild: async () => null,
|
||||||
|
updateLastError: async () => {},
|
||||||
|
updateWorkspaceUsage: async () => {},
|
||||||
|
clearWorkspaceUsage: async () => {},
|
||||||
|
listOperations: async () => [],
|
||||||
|
getOperation: async () => null,
|
||||||
|
createOperation: async (input) => {
|
||||||
|
createdInputs.push(input);
|
||||||
|
return createOperation(input);
|
||||||
|
},
|
||||||
|
claimNextOperation: async () => null,
|
||||||
|
completeOperation: async () => {
|
||||||
|
throw new Error('not used');
|
||||||
|
},
|
||||||
|
requeueOperation: async () => {
|
||||||
|
throw new Error('not used');
|
||||||
|
},
|
||||||
|
cancelOperation: async () => false,
|
||||||
|
retryOperation: async () => null,
|
||||||
|
};
|
||||||
|
const caller = appRouter.createCaller(
|
||||||
|
createGatewayApiContext({
|
||||||
|
users,
|
||||||
|
sessions,
|
||||||
|
flushPublisher: { publishUserFlush: async () => {} },
|
||||||
|
gameTokenSecret: 'test-secret',
|
||||||
|
gameSessionTtlSeconds: 600,
|
||||||
|
kakaoClient: {} as never,
|
||||||
|
oauthSessions: {} as never,
|
||||||
|
publicBaseUrl: 'http://localhost',
|
||||||
|
adminLocalAccountEnabled: false,
|
||||||
|
profiles,
|
||||||
|
orchestrator: {
|
||||||
|
start: () => {},
|
||||||
|
stop: async () => {},
|
||||||
|
reconcileNow: async () => {},
|
||||||
|
runScheduleNow: async () => {},
|
||||||
|
runBuildQueueNow: async () => {},
|
||||||
|
runOperationsNow: async () => {},
|
||||||
|
cleanupStaleWorkspaces: async () => ({ removed: [], skipped: [] }),
|
||||||
|
listRuntimeStates: async () => [],
|
||||||
|
},
|
||||||
|
profileStatus: new InMemoryProfileStatusService(),
|
||||||
|
requestHeaders: { 'x-session-token': session.sessionToken },
|
||||||
|
prisma: {
|
||||||
|
appUser: {
|
||||||
|
findFirst: async () => ({ id: admin.id }),
|
||||||
|
},
|
||||||
|
} as unknown as GatewayPrismaClient,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
return { caller, createdInputs };
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('admin operation API', () => {
|
||||||
|
it('queues a start operation with the authenticated requester', async () => {
|
||||||
|
const operation = {
|
||||||
|
id: '11111111-1111-4111-8111-111111111111',
|
||||||
|
profileName: 'che:2',
|
||||||
|
type: 'START' as const,
|
||||||
|
status: 'QUEUED' as const,
|
||||||
|
payload: {},
|
||||||
|
requestedBy: 'admin-id',
|
||||||
|
createdAt: '2026-07-25T00:00:00.000Z',
|
||||||
|
updatedAt: '2026-07-25T00:00:00.000Z',
|
||||||
|
};
|
||||||
|
const harness = await buildCaller(async () => operation);
|
||||||
|
|
||||||
|
const result = await harness.caller.admin.operations.requestRuntime({
|
||||||
|
profileName: 'che:2',
|
||||||
|
action: 'START',
|
||||||
|
reason: 'maintenance complete',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.type).toBe('START');
|
||||||
|
expect(harness.createdInputs[0]).toMatchObject({
|
||||||
|
profileName: 'che:2',
|
||||||
|
type: 'START',
|
||||||
|
reason: 'maintenance complete',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports an active-operation uniqueness conflict', async () => {
|
||||||
|
const harness = await buildCaller(async () => {
|
||||||
|
throw { code: 'P2002' };
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
harness.caller.admin.operations.requestRuntime({
|
||||||
|
profileName: 'che:2',
|
||||||
|
action: 'STOP',
|
||||||
|
})
|
||||||
|
).rejects.toMatchObject({ code: 'CONFLICT' });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -59,6 +59,20 @@ const buildCaller = () => {
|
|||||||
updateLastError: async () => {},
|
updateLastError: async () => {},
|
||||||
updateWorkspaceUsage: async () => {},
|
updateWorkspaceUsage: async () => {},
|
||||||
clearWorkspaceUsage: async () => {},
|
clearWorkspaceUsage: async () => {},
|
||||||
|
listOperations: async () => [],
|
||||||
|
getOperation: async () => null,
|
||||||
|
createOperation: async () => {
|
||||||
|
throw new Error('not implemented');
|
||||||
|
},
|
||||||
|
claimNextOperation: async () => null,
|
||||||
|
completeOperation: async () => {
|
||||||
|
throw new Error('not implemented');
|
||||||
|
},
|
||||||
|
requeueOperation: async () => {
|
||||||
|
throw new Error('not implemented');
|
||||||
|
},
|
||||||
|
cancelOperation: async () => false,
|
||||||
|
retryOperation: async () => null,
|
||||||
};
|
};
|
||||||
const orchestrator = {
|
const orchestrator = {
|
||||||
start: () => {},
|
start: () => {},
|
||||||
@@ -66,6 +80,7 @@ const buildCaller = () => {
|
|||||||
reconcileNow: async () => {},
|
reconcileNow: async () => {},
|
||||||
runScheduleNow: async () => {},
|
runScheduleNow: async () => {},
|
||||||
runBuildQueueNow: async () => {},
|
runBuildQueueNow: async () => {},
|
||||||
|
runOperationsNow: async () => {},
|
||||||
cleanupStaleWorkspaces: async () => ({
|
cleanupStaleWorkspaces: async () => ({
|
||||||
removed: [],
|
removed: [],
|
||||||
skipped: [],
|
skipped: [],
|
||||||
|
|||||||
@@ -0,0 +1,165 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { GatewayOrchestrator } from '../src/orchestrator/gatewayOrchestrator.js';
|
||||||
|
import type { ProcessDefinition, ProcessManager } from '../src/orchestrator/processManager.js';
|
||||||
|
import type {
|
||||||
|
GatewayOperationRecord,
|
||||||
|
GatewayOperationStatus,
|
||||||
|
GatewayProfileRecord,
|
||||||
|
GatewayProfileRepository,
|
||||||
|
} from '../src/orchestrator/profileRepository.js';
|
||||||
|
import { GitWorkspaceManager } from '../src/orchestrator/workspaceManager.js';
|
||||||
|
|
||||||
|
const profile: GatewayProfileRecord = {
|
||||||
|
profileName: 'che:2',
|
||||||
|
profile: 'che',
|
||||||
|
scenario: '2',
|
||||||
|
apiPort: 15003,
|
||||||
|
status: 'STOPPED',
|
||||||
|
buildStatus: 'SUCCEEDED',
|
||||||
|
buildCommitSha: '0123456789abcdef0123456789abcdef01234567',
|
||||||
|
buildWorkspace: '/srv/sammo/worktrees/0123456789abcdef',
|
||||||
|
meta: {},
|
||||||
|
createdAt: '2026-07-25T00:00:00.000Z',
|
||||||
|
updatedAt: '2026-07-25T00:00:00.000Z',
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildOperation = (type: 'START' | 'STOP'): GatewayOperationRecord => ({
|
||||||
|
id: '11111111-1111-4111-8111-111111111111',
|
||||||
|
profileName: profile.profileName,
|
||||||
|
type,
|
||||||
|
status: 'RUNNING',
|
||||||
|
payload: {},
|
||||||
|
requestedBy: 'admin',
|
||||||
|
createdAt: '2026-07-25T01:00:00.000Z',
|
||||||
|
startedAt: '2026-07-25T01:00:00.000Z',
|
||||||
|
updatedAt: '2026-07-25T01:00:00.000Z',
|
||||||
|
});
|
||||||
|
|
||||||
|
const createHarness = (operation: GatewayOperationRecord, failStart = false, failStop = false) => {
|
||||||
|
let nextOperation: GatewayOperationRecord | null = operation;
|
||||||
|
const statuses: string[] = [];
|
||||||
|
const completions: GatewayOperationStatus[] = [];
|
||||||
|
const started: ProcessDefinition[] = [];
|
||||||
|
const stopped: string[] = [];
|
||||||
|
const deleted: string[] = [];
|
||||||
|
|
||||||
|
const repository: GatewayProfileRepository = {
|
||||||
|
listProfiles: async () => [profile],
|
||||||
|
getProfile: async () => profile,
|
||||||
|
upsertProfile: async () => profile,
|
||||||
|
updateScenario: async () => profile,
|
||||||
|
updateStatus: async (_profileName, status) => {
|
||||||
|
statuses.push(status);
|
||||||
|
return { ...profile, status };
|
||||||
|
},
|
||||||
|
updateBuildStatus: async () => profile,
|
||||||
|
updateMeta: async () => profile,
|
||||||
|
listReservedToStart: async () => [],
|
||||||
|
findQueuedBuild: async () => null,
|
||||||
|
updateLastError: async () => {},
|
||||||
|
updateWorkspaceUsage: async () => {},
|
||||||
|
clearWorkspaceUsage: async () => {},
|
||||||
|
listOperations: async () => [],
|
||||||
|
getOperation: async () => operation,
|
||||||
|
createOperation: async () => operation,
|
||||||
|
claimNextOperation: async () => {
|
||||||
|
const result = nextOperation;
|
||||||
|
nextOperation = null;
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
completeOperation: async (_id, status) => {
|
||||||
|
completions.push(status);
|
||||||
|
return { ...operation, status };
|
||||||
|
},
|
||||||
|
requeueOperation: async () => ({ ...operation, status: 'QUEUED' }),
|
||||||
|
cancelOperation: async () => false,
|
||||||
|
retryOperation: async () => null,
|
||||||
|
};
|
||||||
|
const processManager: ProcessManager = {
|
||||||
|
list: async () => [],
|
||||||
|
start: async (definition) => {
|
||||||
|
if (failStart) {
|
||||||
|
throw new Error('pm2 unavailable');
|
||||||
|
}
|
||||||
|
started.push(definition);
|
||||||
|
},
|
||||||
|
stop: async (name) => {
|
||||||
|
stopped.push(name);
|
||||||
|
if (failStop) {
|
||||||
|
throw new Error('pm2 stop failed');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
delete: async (name) => {
|
||||||
|
deleted.push(name);
|
||||||
|
if (failStop) {
|
||||||
|
throw new Error('pm2 delete failed');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const orchestrator = new GatewayOrchestrator({
|
||||||
|
repository,
|
||||||
|
processManager,
|
||||||
|
buildRunner: {
|
||||||
|
run: async () => ({ ok: true, exitCode: 0, output: '' }),
|
||||||
|
},
|
||||||
|
workspaceManager: new GitWorkspaceManager({
|
||||||
|
repoRoot: '/tmp/not-used',
|
||||||
|
worktreeRoot: '/tmp/not-used-worktrees',
|
||||||
|
}),
|
||||||
|
processConfig: {
|
||||||
|
workspaceRoot: '/srv/sammo',
|
||||||
|
redisKeyPrefix: 'sammo:test',
|
||||||
|
gameTokenSecret: 'test-secret',
|
||||||
|
},
|
||||||
|
reconcileIntervalMs: 60_000,
|
||||||
|
scheduleIntervalMs: 60_000,
|
||||||
|
buildIntervalMs: 60_000,
|
||||||
|
adminActionIntervalMs: 60_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { orchestrator, statuses, completions, started, stopped, deleted };
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('GatewayOrchestrator first-class operations', () => {
|
||||||
|
it('starts both profile processes and records success', async () => {
|
||||||
|
const harness = createHarness(buildOperation('START'));
|
||||||
|
|
||||||
|
await harness.orchestrator.runOperationsNow();
|
||||||
|
|
||||||
|
expect(harness.statuses).toEqual(['RUNNING']);
|
||||||
|
expect(harness.started.map((definition) => definition.name)).toEqual([
|
||||||
|
'sammo:che:2:game-api',
|
||||||
|
'sammo:che:2:turn-daemon',
|
||||||
|
]);
|
||||||
|
expect(harness.completions).toEqual(['SUCCEEDED']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stops both profile processes and records success', async () => {
|
||||||
|
const harness = createHarness(buildOperation('STOP'));
|
||||||
|
|
||||||
|
await harness.orchestrator.runOperationsNow();
|
||||||
|
|
||||||
|
expect(harness.statuses).toEqual(['STOPPED']);
|
||||||
|
expect(harness.stopped).toEqual(['sammo:che:2:game-api', 'sammo:che:2:turn-daemon']);
|
||||||
|
expect(harness.completions).toEqual(['SUCCEEDED']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('records a failed start instead of reporting a false success', async () => {
|
||||||
|
const harness = createHarness(buildOperation('START'), true);
|
||||||
|
|
||||||
|
await harness.orchestrator.runOperationsNow();
|
||||||
|
|
||||||
|
expect(harness.completions).toEqual(['FAILED']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('attempts to stop both roles before reporting a partial PM2 failure', async () => {
|
||||||
|
const harness = createHarness(buildOperation('STOP'), false, true);
|
||||||
|
|
||||||
|
await harness.orchestrator.runOperationsNow();
|
||||||
|
|
||||||
|
expect(harness.stopped).toEqual(['sammo:che:2:game-api', 'sammo:che:2:turn-daemon']);
|
||||||
|
expect(harness.deleted).toEqual(['sammo:che:2:game-api', 'sammo:che:2:turn-daemon']);
|
||||||
|
expect(harness.completions).toEqual(['FAILED']);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import { execFileSync } from 'node:child_process';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
import { afterEach, describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { GitWorkspaceManager } from '../src/orchestrator/workspaceManager.js';
|
||||||
|
|
||||||
|
const temporaryRoots: string[] = [];
|
||||||
|
|
||||||
|
const git = (cwd: string, ...args: string[]): string =>
|
||||||
|
execFileSync('git', args, {
|
||||||
|
cwd,
|
||||||
|
encoding: 'utf8',
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
GIT_AUTHOR_NAME: 'Sammo Test',
|
||||||
|
GIT_AUTHOR_EMAIL: 'sammo-test@example.invalid',
|
||||||
|
GIT_COMMITTER_NAME: 'Sammo Test',
|
||||||
|
GIT_COMMITTER_EMAIL: 'sammo-test@example.invalid',
|
||||||
|
},
|
||||||
|
}).trim();
|
||||||
|
|
||||||
|
const createRepositoryFixture = (): {
|
||||||
|
source: string;
|
||||||
|
checkout: string;
|
||||||
|
worktrees: string;
|
||||||
|
firstCommit: string;
|
||||||
|
} => {
|
||||||
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'sammo-workspace-manager-'));
|
||||||
|
temporaryRoots.push(root);
|
||||||
|
const remote = path.join(root, 'remote.git');
|
||||||
|
const source = path.join(root, 'source');
|
||||||
|
const checkout = path.join(root, 'checkout');
|
||||||
|
const worktrees = path.join(root, 'worktrees');
|
||||||
|
|
||||||
|
fs.mkdirSync(source);
|
||||||
|
git(root, 'init', '--bare', remote);
|
||||||
|
git(source, 'init', '-b', 'main');
|
||||||
|
fs.writeFileSync(path.join(source, 'version.txt'), 'first\n');
|
||||||
|
git(source, 'add', 'version.txt');
|
||||||
|
git(source, 'commit', '-m', 'first');
|
||||||
|
const firstCommit = git(source, 'rev-parse', 'HEAD');
|
||||||
|
git(source, 'remote', 'add', 'origin', remote);
|
||||||
|
git(source, 'push', '-u', 'origin', 'main');
|
||||||
|
git(root, 'clone', '--branch', 'main', remote, checkout);
|
||||||
|
return { source, checkout, worktrees, firstCommit };
|
||||||
|
};
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
for (const root of temporaryRoots.splice(0)) {
|
||||||
|
fs.rmSync(root, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GitWorkspaceManager source resolution', () => {
|
||||||
|
it('keeps COMMIT pinned while BRANCH follows the latest remote head', async () => {
|
||||||
|
const fixture = createRepositoryFixture();
|
||||||
|
const manager = new GitWorkspaceManager({
|
||||||
|
repoRoot: fixture.checkout,
|
||||||
|
worktreeRoot: fixture.worktrees,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(await manager.resolveCommit('COMMIT', fixture.firstCommit)).toBe(fixture.firstCommit);
|
||||||
|
expect(await manager.resolveCommit('BRANCH', 'main')).toBe(fixture.firstCommit);
|
||||||
|
|
||||||
|
fs.writeFileSync(path.join(fixture.source, 'version.txt'), 'second\n');
|
||||||
|
git(fixture.source, 'add', 'version.txt');
|
||||||
|
git(fixture.source, 'commit', '-m', 'second');
|
||||||
|
const secondCommit = git(fixture.source, 'rev-parse', 'HEAD');
|
||||||
|
git(fixture.source, 'push', 'origin', 'main');
|
||||||
|
|
||||||
|
expect(await manager.resolveCommit('COMMIT', fixture.firstCommit)).toBe(fixture.firstCommit);
|
||||||
|
expect(await manager.resolveCommit('BRANCH', 'main')).toBe(secondCommit);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects option-like and range refs', async () => {
|
||||||
|
const fixture = createRepositoryFixture();
|
||||||
|
const manager = new GitWorkspaceManager({
|
||||||
|
repoRoot: fixture.checkout,
|
||||||
|
worktreeRoot: fixture.worktrees,
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(manager.resolveCommit('BRANCH', '--upload-pack=bad')).rejects.toThrow('Invalid git ref');
|
||||||
|
await expect(manager.resolveCommit('COMMIT', 'HEAD..main')).rejects.toThrow('Invalid git ref');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { dirname, resolve } from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { defineConfig, devices } from '@playwright/test';
|
||||||
|
|
||||||
|
const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../../..');
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
testDir: '.',
|
||||||
|
testMatch: 'server-operations.spec.ts',
|
||||||
|
fullyParallel: false,
|
||||||
|
workers: 1,
|
||||||
|
timeout: 30_000,
|
||||||
|
expect: {
|
||||||
|
timeout: 5_000,
|
||||||
|
},
|
||||||
|
reporter: [['list']],
|
||||||
|
outputDir: resolve(repositoryRoot, 'test-results/server-operations'),
|
||||||
|
use: {
|
||||||
|
baseURL: 'http://127.0.0.1:15130/gateway/',
|
||||||
|
...devices['Desktop Chrome'],
|
||||||
|
deviceScaleFactor: 1,
|
||||||
|
colorScheme: 'dark',
|
||||||
|
trace: 'retain-on-failure',
|
||||||
|
screenshot: 'only-on-failure',
|
||||||
|
},
|
||||||
|
webServer: {
|
||||||
|
command:
|
||||||
|
'VITE_APP_BASE_PATH=/gateway pnpm --filter @sammo-ts/gateway-frontend preview --host 127.0.0.1 --port 15130',
|
||||||
|
cwd: repositoryRoot,
|
||||||
|
url: 'http://127.0.0.1:15130/gateway/',
|
||||||
|
reuseExistingServer: false,
|
||||||
|
timeout: 120_000,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,223 @@
|
|||||||
|
import { expect, test, type Page, type Route } from '@playwright/test';
|
||||||
|
import { writeFile } from 'node:fs/promises';
|
||||||
|
|
||||||
|
type OperationStatus = 'QUEUED' | 'RUNNING' | 'SUCCEEDED' | 'FAILED' | 'CANCELLED';
|
||||||
|
type Operation = {
|
||||||
|
id: string;
|
||||||
|
profileName: string;
|
||||||
|
type: 'RESET' | 'START' | 'STOP';
|
||||||
|
status: OperationStatus;
|
||||||
|
sourceMode?: 'BRANCH' | 'COMMIT';
|
||||||
|
sourceRef?: string;
|
||||||
|
resolvedCommitSha?: string;
|
||||||
|
payload: Record<string, unknown>;
|
||||||
|
requestedBy: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type FixtureState = {
|
||||||
|
operations: Operation[];
|
||||||
|
runtimeRunning: boolean;
|
||||||
|
requestBodies: Array<{ operation: string; body: unknown }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const profile = (runtimeRunning: boolean) => ({
|
||||||
|
profileName: 'che:2',
|
||||||
|
profile: 'che',
|
||||||
|
scenario: '2',
|
||||||
|
apiPort: 15003,
|
||||||
|
status: runtimeRunning ? 'RUNNING' : 'STOPPED',
|
||||||
|
buildStatus: 'SUCCEEDED',
|
||||||
|
buildCommitSha: '0123456789abcdef0123456789abcdef01234567',
|
||||||
|
buildWorkspace: '/srv/sammo/worktrees/0123456789abcdef0123456789abcdef01234567',
|
||||||
|
meta: {},
|
||||||
|
createdAt: '2026-07-25T00:00:00.000Z',
|
||||||
|
updatedAt: '2026-07-25T00:00:00.000Z',
|
||||||
|
runtime: {
|
||||||
|
profileName: 'che:2',
|
||||||
|
apiRunning: runtimeRunning,
|
||||||
|
daemonRunning: runtimeRunning,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const scenarios = [
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
title: '【테스트】황건의 난',
|
||||||
|
year: 184,
|
||||||
|
npcCount: 42,
|
||||||
|
npcExCount: 0,
|
||||||
|
npcNeutralCount: 0,
|
||||||
|
nations: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 5,
|
||||||
|
title: '【테스트】군웅할거',
|
||||||
|
year: 190,
|
||||||
|
npcCount: 55,
|
||||||
|
npcExCount: 0,
|
||||||
|
npcNeutralCount: 0,
|
||||||
|
nations: [],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const response = (data: unknown) => ({ result: { data } });
|
||||||
|
const operationNames = (route: Route): string[] => {
|
||||||
|
const url = new URL(route.request().url());
|
||||||
|
return decodeURIComponent(url.pathname.slice(url.pathname.lastIndexOf('/trpc/') + 6)).split(',');
|
||||||
|
};
|
||||||
|
|
||||||
|
const installFixture = async (page: Page, state: FixtureState) => {
|
||||||
|
await page.addInitScript(() => {
|
||||||
|
window.localStorage.setItem('sammo-session-token', 'playwright-admin-session');
|
||||||
|
});
|
||||||
|
await page.route('**/gateway/api/trpc/**', async (route) => {
|
||||||
|
const names = operationNames(route);
|
||||||
|
const body = route.request().postDataJSON() as unknown;
|
||||||
|
const results = names.map((name) => {
|
||||||
|
if (route.request().method() === 'POST') {
|
||||||
|
state.requestBodies.push({ operation: name, body });
|
||||||
|
}
|
||||||
|
if (name === 'admin.profiles.list') {
|
||||||
|
return response([profile(state.runtimeRunning)]);
|
||||||
|
}
|
||||||
|
if (name === 'admin.operations.list') {
|
||||||
|
return response(state.operations);
|
||||||
|
}
|
||||||
|
if (name === 'admin.profiles.listScenarios') {
|
||||||
|
return response(scenarios);
|
||||||
|
}
|
||||||
|
if (name === 'admin.operations.requestReset') {
|
||||||
|
const operation: Operation = {
|
||||||
|
id: '11111111-1111-4111-8111-111111111111',
|
||||||
|
profileName: 'che:2',
|
||||||
|
type: 'RESET',
|
||||||
|
status: 'QUEUED',
|
||||||
|
sourceMode: 'COMMIT',
|
||||||
|
sourceRef: '0123456789abcdef0123456789abcdef01234567',
|
||||||
|
payload: {},
|
||||||
|
requestedBy: 'admin',
|
||||||
|
createdAt: '2026-07-25T02:00:00.000Z',
|
||||||
|
updatedAt: '2026-07-25T02:00:00.000Z',
|
||||||
|
};
|
||||||
|
state.operations = [operation];
|
||||||
|
return response(operation);
|
||||||
|
}
|
||||||
|
if (name === 'admin.operations.requestRuntime') {
|
||||||
|
const serialized = JSON.stringify(body);
|
||||||
|
const type = serialized.includes('"STOP"') ? 'STOP' : 'START';
|
||||||
|
state.runtimeRunning = type === 'START';
|
||||||
|
const operation: Operation = {
|
||||||
|
id:
|
||||||
|
type === 'START'
|
||||||
|
? '22222222-2222-4222-8222-222222222222'
|
||||||
|
: '33333333-3333-4333-8333-333333333333',
|
||||||
|
profileName: 'che:2',
|
||||||
|
type,
|
||||||
|
status: 'SUCCEEDED',
|
||||||
|
payload: {},
|
||||||
|
requestedBy: 'admin',
|
||||||
|
createdAt: '2026-07-25T03:00:00.000Z',
|
||||||
|
updatedAt: '2026-07-25T03:00:00.000Z',
|
||||||
|
};
|
||||||
|
state.operations = [operation];
|
||||||
|
return response(operation);
|
||||||
|
}
|
||||||
|
throw new Error(`Unhandled tRPC operation: ${name}`);
|
||||||
|
});
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify(results),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
test('separates branch and commit semantics and submits a reset from the dedicated page', async ({
|
||||||
|
page,
|
||||||
|
}, testInfo) => {
|
||||||
|
const state: FixtureState = { operations: [], runtimeRunning: false, requestBodies: [] };
|
||||||
|
await installFixture(page, state);
|
||||||
|
page.on('dialog', (dialog) => dialog.accept());
|
||||||
|
|
||||||
|
await page.goto('admin/server-operations');
|
||||||
|
await expect(page.getByTestId('server-operations-page')).toBeVisible();
|
||||||
|
await expect(page).toHaveURL(/\/gateway\/admin\/server-operations$/);
|
||||||
|
await expect(page.getByTestId('source-help')).toContainText('실제로 시작될 때');
|
||||||
|
await expect(page.getByTestId('scenario-select')).toHaveValue('2');
|
||||||
|
|
||||||
|
const desktopGeometry = await page.getByTestId('server-operations-page').locator('section').first().evaluate((section) => {
|
||||||
|
const children = Array.from(section.children).map((child) => {
|
||||||
|
const rect = child.getBoundingClientRect();
|
||||||
|
return { x: rect.x, y: rect.y, width: rect.width, height: rect.height };
|
||||||
|
});
|
||||||
|
return children;
|
||||||
|
});
|
||||||
|
expect(desktopGeometry).toHaveLength(2);
|
||||||
|
expect(desktopGeometry[1]!.x).toBeGreaterThan(desktopGeometry[0]!.x);
|
||||||
|
const sourceInput = page.getByTestId('source-ref');
|
||||||
|
await sourceInput.focus();
|
||||||
|
const focusedInputStyle = await sourceInput.evaluate((element) => {
|
||||||
|
const style = getComputedStyle(element);
|
||||||
|
return {
|
||||||
|
borderColor: style.borderColor,
|
||||||
|
backgroundColor: style.backgroundColor,
|
||||||
|
color: style.color,
|
||||||
|
fontSize: style.fontSize,
|
||||||
|
lineHeight: style.lineHeight,
|
||||||
|
outlineStyle: style.outlineStyle,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
await writeFile(
|
||||||
|
testInfo.outputPath('layout-metrics.json'),
|
||||||
|
JSON.stringify({ desktopGeometry, focusedInputStyle }, null, 2)
|
||||||
|
);
|
||||||
|
await page.screenshot({ path: testInfo.outputPath('desktop-operations.png'), fullPage: true });
|
||||||
|
|
||||||
|
await page.getByTestId('source-commit').check();
|
||||||
|
await expect(page.getByTestId('source-help')).toContainText('전체 SHA로 고정');
|
||||||
|
await page.getByTestId('source-ref').fill('0123456789abcdef0123456789abcdef01234567');
|
||||||
|
await page.getByTestId('load-scenarios').click();
|
||||||
|
await page.getByTestId('scenario-select').selectOption('5');
|
||||||
|
await page.getByTestId('request-reset').hover();
|
||||||
|
await page.getByTestId('request-reset').click();
|
||||||
|
|
||||||
|
await expect(page.getByText('초기화 작업을 시작했습니다.')).toBeVisible();
|
||||||
|
await expect(page.getByTestId('operations-table')).toContainText('RESET');
|
||||||
|
const resetRequest = state.requestBodies.find((entry) => entry.operation === 'admin.operations.requestReset');
|
||||||
|
expect(JSON.stringify(resetRequest?.body)).toContain('"sourceMode":"COMMIT"');
|
||||||
|
expect(JSON.stringify(resetRequest?.body)).toContain('0123456789abcdef0123456789abcdef01234567');
|
||||||
|
expect(JSON.stringify(resetRequest?.body)).toContain('"scenarioId":5');
|
||||||
|
|
||||||
|
await page.setViewportSize({ width: 390, height: 844 });
|
||||||
|
const mobileGeometry = await page.getByTestId('server-operations-page').locator('section').first().evaluate((section) => {
|
||||||
|
const children = Array.from(section.children).map((child) => {
|
||||||
|
const rect = child.getBoundingClientRect();
|
||||||
|
return { x: rect.x, y: rect.y, width: rect.width };
|
||||||
|
});
|
||||||
|
return children;
|
||||||
|
});
|
||||||
|
expect(mobileGeometry[1]!.y).toBeGreaterThan(mobileGeometry[0]!.y);
|
||||||
|
expect(mobileGeometry[0]!.width).toBeLessThanOrEqual(390);
|
||||||
|
await page.screenshot({ path: testInfo.outputPath('mobile-operations.png'), fullPage: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('starts and stops both runtime roles through the operation controls', async ({ page }) => {
|
||||||
|
const state: FixtureState = { operations: [], runtimeRunning: false, requestBodies: [] };
|
||||||
|
await installFixture(page, state);
|
||||||
|
page.on('dialog', (dialog) => dialog.accept());
|
||||||
|
|
||||||
|
await page.goto('admin/server-operations');
|
||||||
|
await page.getByTestId('start-server').click();
|
||||||
|
await expect(page.getByText('시작 작업을 요청했습니다.')).toBeVisible();
|
||||||
|
await expect(page.getByText('RUNNING', { exact: true }).first()).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByTestId('stop-server').click();
|
||||||
|
await expect(page.getByText('정지 작업을 요청했습니다.')).toBeVisible();
|
||||||
|
await expect(page.getByText('STOPPED', { exact: true }).first()).toBeVisible();
|
||||||
|
|
||||||
|
const serializedRequests = state.requestBodies.map((entry) => JSON.stringify(entry.body)).join('\n');
|
||||||
|
expect(serializedRequests).toContain('"action":"START"');
|
||||||
|
expect(serializedRequests).toContain('"action":"STOP"');
|
||||||
|
});
|
||||||
@@ -5,6 +5,7 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
"test:e2e:operations": "VITE_APP_BASE_PATH=/gateway VITE_GATEWAY_API_URL=/gateway/api/trpc pnpm build && playwright test --config e2e/playwright.config.mjs",
|
||||||
"build": "vue-tsc && vite build",
|
"build": "vue-tsc && vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { createRouter, createWebHistory } from 'vue-router';
|
|||||||
import HomeView from '../views/HomeView.vue';
|
import HomeView from '../views/HomeView.vue';
|
||||||
import LobbyView from '../views/LobbyView.vue';
|
import LobbyView from '../views/LobbyView.vue';
|
||||||
import AdminView from '../views/AdminView.vue';
|
import AdminView from '../views/AdminView.vue';
|
||||||
|
import ServerOperationsView from '../views/ServerOperationsView.vue';
|
||||||
|
|
||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
history: createWebHistory(import.meta.env.BASE_URL),
|
history: createWebHistory(import.meta.env.BASE_URL),
|
||||||
@@ -21,6 +22,11 @@ const router = createRouter({
|
|||||||
name: 'admin',
|
name: 'admin',
|
||||||
component: AdminView,
|
component: AdminView,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/admin/server-operations',
|
||||||
|
name: 'server-operations',
|
||||||
|
component: ServerOperationsView,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1012,9 +1012,17 @@ onMounted(() => {
|
|||||||
<template>
|
<template>
|
||||||
<DefaultLayout>
|
<DefaultLayout>
|
||||||
<div class="max-w-6xl mx-auto px-4 py-10 space-y-10">
|
<div class="max-w-6xl mx-auto px-4 py-10 space-y-10">
|
||||||
<div class="space-y-2">
|
<div class="flex flex-col gap-4 md:flex-row md:items-end md:justify-between">
|
||||||
<h2 class="text-3xl font-bold text-white">관리자 콘솔</h2>
|
<div class="space-y-2">
|
||||||
<p class="text-sm text-zinc-400">유저 관리와 서버 운영 제어를 위한 관리자 전용 대시보드입니다.</p>
|
<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>
|
</div>
|
||||||
|
|
||||||
<section class="bg-zinc-900 border border-zinc-800 rounded-lg p-5 space-y-3">
|
<section class="bg-zinc-900 border border-zinc-800 rounded-lg p-5 space-y-3">
|
||||||
|
|||||||
@@ -0,0 +1,623 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue';
|
||||||
|
|
||||||
|
import DefaultLayout from '../layouts/DefaultLayout.vue';
|
||||||
|
import { trpc } from '../utils/trpc';
|
||||||
|
|
||||||
|
const adminClient = trpc.admin;
|
||||||
|
|
||||||
|
type Profile = {
|
||||||
|
profileName: string;
|
||||||
|
profile: string;
|
||||||
|
scenario: string;
|
||||||
|
status: string;
|
||||||
|
buildStatus: string;
|
||||||
|
buildCommitSha?: string;
|
||||||
|
buildWorkspace?: string;
|
||||||
|
buildError?: string;
|
||||||
|
lastError?: string;
|
||||||
|
runtime: { apiRunning: boolean; daemonRunning: boolean };
|
||||||
|
};
|
||||||
|
|
||||||
|
type Scenario = {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
year: number | null;
|
||||||
|
npcCount: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Operation = {
|
||||||
|
id: string;
|
||||||
|
profileName: string;
|
||||||
|
type: 'RESET' | 'START' | 'STOP';
|
||||||
|
status: 'QUEUED' | 'RUNNING' | 'SUCCEEDED' | 'FAILED' | 'CANCELLED';
|
||||||
|
sourceMode?: 'BRANCH' | 'COMMIT';
|
||||||
|
sourceRef?: string;
|
||||||
|
resolvedCommitSha?: string;
|
||||||
|
reason?: string;
|
||||||
|
requestedBy: string;
|
||||||
|
scheduledAt?: string;
|
||||||
|
startedAt?: string;
|
||||||
|
completedAt?: string;
|
||||||
|
error?: string;
|
||||||
|
createdAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const profiles = ref<Profile[]>([]);
|
||||||
|
const scenarios = ref<Scenario[]>([]);
|
||||||
|
const operations = ref<Operation[]>([]);
|
||||||
|
const selectedProfileName = ref('');
|
||||||
|
const loading = ref(false);
|
||||||
|
const catalogLoading = ref(false);
|
||||||
|
const submitting = ref(false);
|
||||||
|
const message = ref('');
|
||||||
|
const errorMessage = ref('');
|
||||||
|
let pollTimer: ReturnType<typeof setInterval> | undefined;
|
||||||
|
let stateRequestInFlight = false;
|
||||||
|
|
||||||
|
const form = reactive({
|
||||||
|
sourceMode: 'BRANCH' as 'BRANCH' | 'COMMIT',
|
||||||
|
sourceRef: 'main',
|
||||||
|
scenarioId: 0,
|
||||||
|
turnTermMinutes: 60,
|
||||||
|
sync: true,
|
||||||
|
fiction: 1,
|
||||||
|
extend: true,
|
||||||
|
blockGeneralCreate: 0,
|
||||||
|
npcMode: 0,
|
||||||
|
showImgLevel: 3,
|
||||||
|
tournamentTrig: true,
|
||||||
|
joinMode: 'full' as 'full' | 'onlyRandom',
|
||||||
|
autorunEnabled: false,
|
||||||
|
autorunUserMinutes: 1440,
|
||||||
|
autorunDevelop: true,
|
||||||
|
autorunWarp: true,
|
||||||
|
autorunRecruit: true,
|
||||||
|
autorunTrain: true,
|
||||||
|
autorunBattle: true,
|
||||||
|
openAt: '',
|
||||||
|
preopenAt: '',
|
||||||
|
scheduledAt: '',
|
||||||
|
reason: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
const selectedProfile = computed(
|
||||||
|
() => profiles.value.find((profile) => profile.profileName === selectedProfileName.value) ?? null
|
||||||
|
);
|
||||||
|
|
||||||
|
const activeOperation = computed(
|
||||||
|
() =>
|
||||||
|
operations.value.find(
|
||||||
|
(operation) =>
|
||||||
|
operation.profileName === selectedProfileName.value &&
|
||||||
|
(operation.status === 'QUEUED' || operation.status === 'RUNNING')
|
||||||
|
) ?? null
|
||||||
|
);
|
||||||
|
|
||||||
|
const sourceHelp = computed(() =>
|
||||||
|
form.sourceMode === 'BRANCH'
|
||||||
|
? '작업이 실제로 시작될 때 원격 브랜치를 다시 fetch하여 최신 커밋을 사용합니다.'
|
||||||
|
: '요청 시 커밋을 전체 SHA로 고정하므로 이후 브랜치가 이동해도 결과가 바뀌지 않습니다.'
|
||||||
|
);
|
||||||
|
|
||||||
|
const toIso = (value: string): string | undefined => {
|
||||||
|
if (!value) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const parsed = new Date(value);
|
||||||
|
return Number.isNaN(parsed.getTime()) ? undefined : parsed.toISOString();
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatTime = (value?: string): string => (value ? new Date(value).toLocaleString('ko-KR') : '-');
|
||||||
|
const shortSha = (value?: string): string => (value ? value.slice(0, 12) : '-');
|
||||||
|
|
||||||
|
const clearStatus = () => {
|
||||||
|
message.value = '';
|
||||||
|
errorMessage.value = '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadState = async (quiet = false) => {
|
||||||
|
if (stateRequestInFlight) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
stateRequestInFlight = true;
|
||||||
|
if (!quiet) {
|
||||||
|
loading.value = true;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const profileResult = await adminClient.profiles.list.query();
|
||||||
|
const operationResult = await adminClient.operations.list.query({ limit: 100 });
|
||||||
|
profiles.value = profileResult as Profile[];
|
||||||
|
operations.value = operationResult as Operation[];
|
||||||
|
if (!selectedProfileName.value && profiles.value.length > 0) {
|
||||||
|
selectedProfileName.value = profiles.value[0].profileName;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
errorMessage.value = error instanceof Error ? error.message : '운영 상태를 불러오지 못했습니다.';
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
stateRequestInFlight = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadScenarios = async () => {
|
||||||
|
clearStatus();
|
||||||
|
if (!form.sourceRef.trim()) {
|
||||||
|
errorMessage.value = '브랜치 또는 커밋을 입력해주세요.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
catalogLoading.value = true;
|
||||||
|
try {
|
||||||
|
const result = await adminClient.profiles.listScenarios.query({
|
||||||
|
gitRef: form.sourceRef.trim(),
|
||||||
|
sourceMode: form.sourceMode,
|
||||||
|
});
|
||||||
|
scenarios.value = result as Scenario[];
|
||||||
|
if (!scenarios.value.some((scenario) => scenario.id === form.scenarioId)) {
|
||||||
|
const profileScenario = Number(selectedProfile.value?.scenario);
|
||||||
|
form.scenarioId =
|
||||||
|
scenarios.value.find((scenario) => scenario.id === profileScenario)?.id ?? scenarios.value[0]?.id ?? 0;
|
||||||
|
}
|
||||||
|
message.value = `${scenarios.value.length}개 시나리오를 확인했습니다.`;
|
||||||
|
} catch (error) {
|
||||||
|
scenarios.value = [];
|
||||||
|
errorMessage.value = error instanceof Error ? error.message : '소스에서 시나리오를 읽지 못했습니다.';
|
||||||
|
} finally {
|
||||||
|
catalogLoading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const requestRuntime = async (action: 'START' | 'STOP') => {
|
||||||
|
clearStatus();
|
||||||
|
if (!selectedProfile.value || activeOperation.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const label = action === 'START' ? '시작' : '정지';
|
||||||
|
if (!window.confirm(`${selectedProfile.value.profileName} 서버를 ${label}하시겠습니까?`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
submitting.value = true;
|
||||||
|
try {
|
||||||
|
await adminClient.operations.requestRuntime.mutate({
|
||||||
|
profileName: selectedProfile.value.profileName,
|
||||||
|
action,
|
||||||
|
reason: form.reason.trim() || undefined,
|
||||||
|
});
|
||||||
|
message.value = `${label} 작업을 요청했습니다.`;
|
||||||
|
await loadState(true);
|
||||||
|
} catch (error) {
|
||||||
|
errorMessage.value = error instanceof Error ? error.message : `${label} 요청에 실패했습니다.`;
|
||||||
|
} finally {
|
||||||
|
submitting.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectedAutorunOptions = (): Array<
|
||||||
|
'develop' | 'warp' | 'recruit' | 'train' | 'battle'
|
||||||
|
> => {
|
||||||
|
const options: Array<'develop' | 'warp' | 'recruit' | 'train' | 'battle'> = [];
|
||||||
|
if (form.autorunDevelop) options.push('develop');
|
||||||
|
if (form.autorunWarp) options.push('warp');
|
||||||
|
if (form.autorunRecruit) options.push('recruit');
|
||||||
|
if (form.autorunTrain) options.push('train');
|
||||||
|
if (form.autorunBattle) options.push('battle');
|
||||||
|
return options;
|
||||||
|
};
|
||||||
|
|
||||||
|
const requestReset = async () => {
|
||||||
|
clearStatus();
|
||||||
|
if (!selectedProfile.value || activeOperation.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!form.sourceRef.trim() || !form.scenarioId) {
|
||||||
|
errorMessage.value = '소스와 시나리오를 먼저 선택해주세요.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const sourceLabel = form.sourceMode === 'BRANCH' ? '브랜치' : '커밋';
|
||||||
|
if (
|
||||||
|
!window.confirm(
|
||||||
|
`${selectedProfile.value.profileName}의 게임 DB를 초기화합니다.\n${sourceLabel}: ${form.sourceRef}\n시나리오: ${form.scenarioId}`
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
submitting.value = true;
|
||||||
|
try {
|
||||||
|
await adminClient.operations.requestReset.mutate({
|
||||||
|
profileName: selectedProfile.value.profileName,
|
||||||
|
sourceMode: form.sourceMode,
|
||||||
|
sourceRef: form.sourceRef.trim(),
|
||||||
|
scheduledAt: toIso(form.scheduledAt),
|
||||||
|
reason: form.reason.trim() || undefined,
|
||||||
|
install: {
|
||||||
|
scenarioId: form.scenarioId,
|
||||||
|
turnTermMinutes: form.turnTermMinutes,
|
||||||
|
sync: form.sync,
|
||||||
|
fiction: form.fiction,
|
||||||
|
extend: form.extend,
|
||||||
|
blockGeneralCreate: form.blockGeneralCreate,
|
||||||
|
npcMode: form.npcMode,
|
||||||
|
showImgLevel: form.showImgLevel,
|
||||||
|
tournamentTrig: form.tournamentTrig,
|
||||||
|
joinMode: form.joinMode,
|
||||||
|
autorunUser: form.autorunEnabled
|
||||||
|
? {
|
||||||
|
limitMinutes: form.autorunUserMinutes,
|
||||||
|
options: selectedAutorunOptions(),
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
openAt: toIso(form.openAt),
|
||||||
|
preopenAt: toIso(form.preopenAt),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
message.value = form.scheduledAt ? '예약 초기화 작업을 등록했습니다.' : '초기화 작업을 시작했습니다.';
|
||||||
|
await loadState(true);
|
||||||
|
} catch (error) {
|
||||||
|
errorMessage.value = error instanceof Error ? error.message : '초기화 요청에 실패했습니다.';
|
||||||
|
} finally {
|
||||||
|
submitting.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const cancelOperation = async (operation: Operation) => {
|
||||||
|
clearStatus();
|
||||||
|
if (!window.confirm('대기 중인 작업을 취소하시겠습니까?')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await adminClient.operations.cancel.mutate({ id: operation.id });
|
||||||
|
message.value = '작업을 취소했습니다.';
|
||||||
|
await loadState(true);
|
||||||
|
} catch (error) {
|
||||||
|
errorMessage.value = error instanceof Error ? error.message : '작업 취소에 실패했습니다.';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const retryOperation = async (operation: Operation) => {
|
||||||
|
clearStatus();
|
||||||
|
if (!window.confirm('같은 입력으로 작업을 다시 실행하시겠습니까?')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await adminClient.operations.retry.mutate({ id: operation.id });
|
||||||
|
message.value = '재시도 작업을 등록했습니다.';
|
||||||
|
await loadState(true);
|
||||||
|
} catch (error) {
|
||||||
|
errorMessage.value = error instanceof Error ? error.message : '작업 재시도에 실패했습니다.';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
watch(selectedProfileName, () => {
|
||||||
|
const scenarioId = Number(selectedProfile.value?.scenario);
|
||||||
|
if (Number.isFinite(scenarioId)) {
|
||||||
|
form.scenarioId = scenarioId;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await loadState();
|
||||||
|
await loadScenarios();
|
||||||
|
pollTimer = setInterval(() => void loadState(true), 3000);
|
||||||
|
});
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
if (pollTimer) {
|
||||||
|
clearInterval(pollTimer);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</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>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
<div v-if="message" class="rounded border border-emerald-800 bg-emerald-950/40 px-4 py-3 text-sm text-emerald-200">
|
||||||
|
{{ message }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="grid gap-4 lg:grid-cols-[1.1fr_1.9fr]">
|
||||||
|
<div class="rounded-lg border border-zinc-800 bg-zinc-900 p-5 space-y-4">
|
||||||
|
<div>
|
||||||
|
<label class="text-xs text-zinc-400" for="profile-select">운영 프로필</label>
|
||||||
|
<select
|
||||||
|
id="profile-select"
|
||||||
|
v-model="selectedProfileName"
|
||||||
|
class="mt-2 w-full rounded border border-zinc-700 bg-zinc-950 px-3 py-2 text-white"
|
||||||
|
data-testid="profile-select"
|
||||||
|
>
|
||||||
|
<option v-for="profile in profiles" :key="profile.profileName" :value="profile.profileName">
|
||||||
|
{{ profile.profileName }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="selectedProfile" class="grid grid-cols-2 gap-3 text-sm">
|
||||||
|
<div class="rounded bg-zinc-950 p-3">
|
||||||
|
<div class="text-xs text-zinc-500">목표 상태</div>
|
||||||
|
<div class="mt-1 font-semibold">{{ selectedProfile.status }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="rounded bg-zinc-950 p-3">
|
||||||
|
<div class="text-xs text-zinc-500">빌드</div>
|
||||||
|
<div class="mt-1 font-semibold">{{ selectedProfile.buildStatus }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="rounded bg-zinc-950 p-3">
|
||||||
|
<div class="text-xs text-zinc-500">Game API</div>
|
||||||
|
<div :class="selectedProfile.runtime.apiRunning ? 'text-emerald-400' : 'text-zinc-500'">
|
||||||
|
{{ selectedProfile.runtime.apiRunning ? 'RUNNING' : 'STOPPED' }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="rounded bg-zinc-950 p-3">
|
||||||
|
<div class="text-xs text-zinc-500">Turn daemon</div>
|
||||||
|
<div :class="selectedProfile.runtime.daemonRunning ? 'text-emerald-400' : 'text-zinc-500'">
|
||||||
|
{{ selectedProfile.runtime.daemonRunning ? 'RUNNING' : 'STOPPED' }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="selectedProfile" class="space-y-1 text-xs text-zinc-500">
|
||||||
|
<div>현재 커밋: <span class="font-mono text-zinc-300">{{ shortSha(selectedProfile.buildCommitSha) }}</span></div>
|
||||||
|
<div class="break-all">worktree: {{ selectedProfile.buildWorkspace ?? '기본 workspace' }}</div>
|
||||||
|
<div v-if="selectedProfile.buildError" class="text-red-400">{{ selectedProfile.buildError }}</div>
|
||||||
|
<div v-if="selectedProfile.lastError" class="text-red-400">{{ selectedProfile.lastError }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-2 gap-2">
|
||||||
|
<button
|
||||||
|
class="rounded bg-emerald-700 px-3 py-2 font-semibold text-white hover:bg-emerald-600 disabled:opacity-40"
|
||||||
|
:disabled="submitting || Boolean(activeOperation)"
|
||||||
|
data-testid="start-server"
|
||||||
|
@click="requestRuntime('START')"
|
||||||
|
>
|
||||||
|
서버 시작
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="rounded bg-red-800 px-3 py-2 font-semibold text-white hover:bg-red-700 disabled:opacity-40"
|
||||||
|
:disabled="submitting || Boolean(activeOperation)"
|
||||||
|
data-testid="stop-server"
|
||||||
|
@click="requestRuntime('STOP')"
|
||||||
|
>
|
||||||
|
서버 정지
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form class="rounded-lg border border-zinc-800 bg-zinc-900 p-5 space-y-5" @submit.prevent="requestReset">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h3 class="text-lg font-semibold">시나리오 초기화</h3>
|
||||||
|
<span v-if="activeOperation" class="rounded-full bg-amber-500/15 px-3 py-1 text-xs text-amber-300">
|
||||||
|
{{ activeOperation.type }} · {{ activeOperation.status }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<fieldset class="space-y-2">
|
||||||
|
<legend class="text-xs text-zinc-400">소스 종류</legend>
|
||||||
|
<div class="flex gap-5">
|
||||||
|
<label class="flex items-center gap-2">
|
||||||
|
<input v-model="form.sourceMode" type="radio" value="BRANCH" data-testid="source-branch" />
|
||||||
|
브랜치
|
||||||
|
</label>
|
||||||
|
<label class="flex items-center gap-2">
|
||||||
|
<input v-model="form.sourceMode" type="radio" value="COMMIT" data-testid="source-commit" />
|
||||||
|
커밋
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-amber-200/80" data-testid="source-help">{{ sourceHelp }}</p>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<div class="grid gap-3 md:grid-cols-[1fr_auto]">
|
||||||
|
<input
|
||||||
|
v-model="form.sourceRef"
|
||||||
|
class="rounded border border-zinc-700 bg-zinc-950 px-3 py-2 font-mono text-sm text-white"
|
||||||
|
:placeholder="form.sourceMode === 'BRANCH' ? '예: main 또는 release/season-12' : '예: 40자리 commit SHA'"
|
||||||
|
data-testid="source-ref"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded border border-zinc-600 bg-zinc-800 px-4 py-2 text-sm hover:bg-zinc-700 disabled:opacity-50"
|
||||||
|
:disabled="catalogLoading"
|
||||||
|
data-testid="load-scenarios"
|
||||||
|
@click="loadScenarios"
|
||||||
|
>
|
||||||
|
시나리오 확인
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-4 md:grid-cols-2">
|
||||||
|
<label class="text-xs text-zinc-400">
|
||||||
|
시나리오
|
||||||
|
<select
|
||||||
|
v-model.number="form.scenarioId"
|
||||||
|
class="mt-1 w-full rounded border border-zinc-700 bg-zinc-950 px-3 py-2 text-sm text-white"
|
||||||
|
data-testid="scenario-select"
|
||||||
|
>
|
||||||
|
<option v-for="scenario in scenarios" :key="scenario.id" :value="scenario.id">
|
||||||
|
{{ scenario.id }} · {{ scenario.title }} (NPC {{ scenario.npcCount }})
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="text-xs text-zinc-400">
|
||||||
|
턴 간격
|
||||||
|
<select
|
||||||
|
v-model.number="form.turnTermMinutes"
|
||||||
|
class="mt-1 w-full rounded border border-zinc-700 bg-zinc-950 px-3 py-2 text-sm text-white"
|
||||||
|
>
|
||||||
|
<option v-for="minutes in [1, 2, 5, 10, 20, 30, 60, 120]" :key="minutes" :value="minutes">
|
||||||
|
{{ minutes }}분
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<details class="rounded border border-zinc-800 bg-zinc-950/50 p-4">
|
||||||
|
<summary class="cursor-pointer text-sm font-semibold">고급 시나리오 옵션</summary>
|
||||||
|
<div class="mt-4 grid gap-4 md:grid-cols-2 text-sm">
|
||||||
|
<label>동기화
|
||||||
|
<select v-model="form.sync" class="ml-2 rounded bg-zinc-900 px-2 py-1">
|
||||||
|
<option :value="true">사용</option><option :value="false">미사용</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>가상 장수
|
||||||
|
<select v-model.number="form.fiction" class="ml-2 rounded bg-zinc-900 px-2 py-1">
|
||||||
|
<option :value="1">허용</option><option :value="0">금지</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>연장
|
||||||
|
<select v-model="form.extend" class="ml-2 rounded bg-zinc-900 px-2 py-1">
|
||||||
|
<option :value="true">사용</option><option :value="false">미사용</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>가입 방식
|
||||||
|
<select v-model="form.joinMode" class="ml-2 rounded bg-zinc-900 px-2 py-1">
|
||||||
|
<option value="full">전체</option><option value="onlyRandom">랜덤만</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>장수 생성 제한
|
||||||
|
<select v-model.number="form.blockGeneralCreate" class="ml-2 rounded bg-zinc-900 px-2 py-1">
|
||||||
|
<option :value="0">없음</option><option :value="1">제한</option><option :value="2">차단</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>NPC 모드
|
||||||
|
<select v-model.number="form.npcMode" class="ml-2 rounded bg-zinc-900 px-2 py-1">
|
||||||
|
<option :value="0">기본</option><option :value="1">확장</option><option :value="2">전체</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>이미지 표시
|
||||||
|
<select v-model.number="form.showImgLevel" class="ml-2 rounded bg-zinc-900 px-2 py-1">
|
||||||
|
<option v-for="level in [0, 1, 2, 3]" :key="level" :value="level">{{ level }}</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="flex items-center gap-2">
|
||||||
|
<input v-model="form.tournamentTrig" type="checkbox" /> 토너먼트 사용
|
||||||
|
</label>
|
||||||
|
<label class="flex items-center gap-2">
|
||||||
|
<input v-model="form.autorunEnabled" type="checkbox" /> 유저 자동턴
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
v-if="form.autorunEnabled"
|
||||||
|
v-model.number="form.autorunUserMinutes"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="43200"
|
||||||
|
class="rounded border border-zinc-700 bg-zinc-900 px-2 py-1"
|
||||||
|
aria-label="자동턴 제한 분"
|
||||||
|
/>
|
||||||
|
<div v-if="form.autorunEnabled" class="md:col-span-2 flex flex-wrap gap-3 text-xs">
|
||||||
|
<label><input v-model="form.autorunDevelop" type="checkbox" /> 내정</label>
|
||||||
|
<label><input v-model="form.autorunWarp" type="checkbox" /> 이동</label>
|
||||||
|
<label><input v-model="form.autorunRecruit" type="checkbox" /> 징병</label>
|
||||||
|
<label><input v-model="form.autorunTrain" type="checkbox" /> 훈련</label>
|
||||||
|
<label><input v-model="form.autorunBattle" type="checkbox" /> 전투</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<div class="grid gap-4 md:grid-cols-3">
|
||||||
|
<label class="text-xs text-zinc-400">작업 예약
|
||||||
|
<input v-model="form.scheduledAt" type="datetime-local" class="mt-1 w-full rounded border border-zinc-700 bg-zinc-950 px-3 py-2 text-white" />
|
||||||
|
</label>
|
||||||
|
<label class="text-xs text-zinc-400">가오픈
|
||||||
|
<input v-model="form.preopenAt" type="datetime-local" class="mt-1 w-full rounded border border-zinc-700 bg-zinc-950 px-3 py-2 text-white" />
|
||||||
|
</label>
|
||||||
|
<label class="text-xs text-zinc-400">정식 오픈
|
||||||
|
<input v-model="form.openAt" type="datetime-local" class="mt-1 w-full rounded border border-zinc-700 bg-zinc-950 px-3 py-2 text-white" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<input
|
||||||
|
v-model="form.reason"
|
||||||
|
class="w-full rounded border border-zinc-700 bg-zinc-950 px-3 py-2 text-sm text-white"
|
||||||
|
placeholder="작업 사유 또는 운영 메모"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="w-full rounded bg-amber-500 px-4 py-3 font-bold text-black hover:bg-amber-400 disabled:cursor-not-allowed disabled:opacity-40"
|
||||||
|
:disabled="submitting || Boolean(activeOperation) || !form.scenarioId"
|
||||||
|
data-testid="request-reset"
|
||||||
|
>
|
||||||
|
{{ form.scheduledAt ? '초기화 예약' : '초기화 시작' }}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="rounded-lg border border-zinc-800 bg-zinc-900 p-5">
|
||||||
|
<div class="mb-4 flex items-center justify-between">
|
||||||
|
<h3 class="text-lg font-semibold">작업 이력</h3>
|
||||||
|
<span class="text-xs text-zinc-500">3초마다 상태 갱신</span>
|
||||||
|
</div>
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="w-full min-w-[920px] text-left text-sm" data-testid="operations-table">
|
||||||
|
<thead class="border-b border-zinc-700 text-xs text-zinc-500">
|
||||||
|
<tr>
|
||||||
|
<th class="p-2">요청/예약</th><th class="p-2">프로필</th><th class="p-2">작업</th>
|
||||||
|
<th class="p-2">상태</th><th class="p-2">소스</th><th class="p-2">해석 커밋</th>
|
||||||
|
<th class="p-2">요청자/사유</th><th class="p-2">완료/오류</th><th class="p-2"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="operation in operations" :key="operation.id" class="border-b border-zinc-800 align-top">
|
||||||
|
<td class="p-2 text-xs">
|
||||||
|
{{ formatTime(operation.createdAt) }}
|
||||||
|
<div v-if="operation.scheduledAt" class="mt-1 text-amber-300">
|
||||||
|
예약 {{ formatTime(operation.scheduledAt) }}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="p-2">{{ operation.profileName }}</td>
|
||||||
|
<td class="p-2">{{ operation.type }}</td>
|
||||||
|
<td class="p-2 font-semibold">{{ operation.status }}</td>
|
||||||
|
<td class="p-2 font-mono text-xs">{{ operation.sourceMode ?? '-' }}<br />{{ operation.sourceRef ?? '' }}</td>
|
||||||
|
<td class="p-2 font-mono text-xs">{{ shortSha(operation.resolvedCommitSha) }}</td>
|
||||||
|
<td class="max-w-xs p-2 text-xs">
|
||||||
|
<div class="font-mono">{{ operation.requestedBy }}</div>
|
||||||
|
<div v-if="operation.reason" class="mt-1 text-zinc-400">{{ operation.reason }}</div>
|
||||||
|
</td>
|
||||||
|
<td class="max-w-xs p-2 text-xs">
|
||||||
|
{{ formatTime(operation.completedAt) }}
|
||||||
|
<div v-if="operation.error" class="mt-1 text-red-400">{{ operation.error }}</div>
|
||||||
|
</td>
|
||||||
|
<td class="p-2">
|
||||||
|
<button
|
||||||
|
v-if="operation.status === 'QUEUED'"
|
||||||
|
class="rounded border border-red-800 px-2 py-1 text-xs text-red-300 hover:bg-red-950"
|
||||||
|
@click="cancelOperation(operation)"
|
||||||
|
>
|
||||||
|
취소
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-else-if="operation.status === 'FAILED' || operation.status === 'CANCELLED'"
|
||||||
|
class="rounded border border-amber-700 px-2 py-1 text-xs text-amber-300 hover:bg-amber-950"
|
||||||
|
@click="retryOperation(operation)"
|
||||||
|
>
|
||||||
|
재시도
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-if="operations.length === 0">
|
||||||
|
<td colspan="9" class="p-6 text-center text-zinc-500">작업 이력이 없습니다.</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</DefaultLayout>
|
||||||
|
</template>
|
||||||
@@ -68,7 +68,8 @@
|
|||||||
"src/**/*.d.ts",
|
"src/**/*.d.ts",
|
||||||
"src/**/*.tsx",
|
"src/**/*.tsx",
|
||||||
"src/**/*.vue",
|
"src/**/*.vue",
|
||||||
"test/**/*.ts"
|
"test/**/*.ts",
|
||||||
|
"e2e/**/*.ts"
|
||||||
],
|
],
|
||||||
"references": [
|
"references": [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -80,6 +80,23 @@ Gateway runs a lightweight cron loop (setInterval) that:
|
|||||||
|
|
||||||
### Build Workflow (Admin)
|
### Build Workflow (Admin)
|
||||||
|
|
||||||
|
- 관리자는 gateway의 `/admin/server-operations` 전용 화면에서 profile별
|
||||||
|
runtime, build, worktree, 오류와 작업 이력을 확인하고 시작·정지·초기화를
|
||||||
|
요청한다.
|
||||||
|
- 운영 요청은 `gateway_operation` 행으로 기록된다. 같은 profile에는
|
||||||
|
`QUEUED` 또는 `RUNNING` 작업이 하나만 존재할 수 있으며, 상태는
|
||||||
|
`QUEUED -> RUNNING -> SUCCEEDED|FAILED` 또는 실행 전 `CANCELLED`로
|
||||||
|
전이된다. 실패·취소 작업은 같은 입력으로 재시도할 수 있다.
|
||||||
|
- `BRANCH` 소스는 요청 시 유효성을 검사하지만 branch 이름을 보존하고,
|
||||||
|
실행 직전에 원격을 다시 fetch하여 최신 commit을 확정한다. `COMMIT`
|
||||||
|
소스는 요청 시 full SHA로 정규화하여 이후 branch 이동과 무관하게
|
||||||
|
동일 commit을 사용한다. 실제 사용한 SHA는 작업의
|
||||||
|
`resolvedCommitSha`와 profile의 `buildCommitSha`에 기록한다.
|
||||||
|
- 초기화 작업은 profile 프로세스를 정지한 뒤 선택 commit worktree에서
|
||||||
|
build하고, 같은 worktree의 scenario/map/unitset으로 profile DB를 seed한
|
||||||
|
후 `PREOPEN` 또는 `RUNNING`으로 전환하여 API와 daemon을 함께 시작한다.
|
||||||
|
대기 중 작업은 취소할 수 있으나 DB 변경이 시작된 `RUNNING` 작업은
|
||||||
|
중간 취소하지 않는다.
|
||||||
- Admin triggers a build request for a profile.
|
- Admin triggers a build request for a profile.
|
||||||
- Gateway queues a build job with `(profileName, commitSha)` and prepares a
|
- Gateway queues a build job with `(profileName, commitSha)` and prepares a
|
||||||
per-commit workspace (`/.worktrees/{commitSha}` by default).
|
per-commit workspace (`/.worktrees/{commitSha}` by default).
|
||||||
@@ -96,6 +113,11 @@ Gateway runs a lightweight cron loop (setInterval) that:
|
|||||||
worktrees. A profile without `buildWorkspace` intentionally falls back to the
|
worktrees. A profile without `buildWorkspace` intentionally falls back to the
|
||||||
main workspace; this is the `hwe`/main compatibility path.
|
main workspace; this is the `hwe`/main compatibility path.
|
||||||
|
|
||||||
|
`GatewayProfile.meta.adminActions` polling is retained only for requests
|
||||||
|
created by the older admin page. New operations must use `gateway_operation`
|
||||||
|
so concurrency, audit history, cancellation, retry, source intent, and the
|
||||||
|
resolved commit remain first-class data.
|
||||||
|
|
||||||
## Current Implementation Status
|
## Current Implementation Status
|
||||||
|
|
||||||
- Turn daemon lifecycle + in-memory state live in `app/game-engine` with DB flush hooks.
|
- Turn daemon lifecycle + in-memory state live in `app/game-engine` with DB flush hooks.
|
||||||
|
|||||||
@@ -31,6 +31,25 @@ enum GatewayBuildStatus {
|
|||||||
SUCCEEDED
|
SUCCEEDED
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum GatewayOperationType {
|
||||||
|
RESET
|
||||||
|
START
|
||||||
|
STOP
|
||||||
|
}
|
||||||
|
|
||||||
|
enum GatewayOperationStatus {
|
||||||
|
QUEUED
|
||||||
|
RUNNING
|
||||||
|
SUCCEEDED
|
||||||
|
FAILED
|
||||||
|
CANCELLED
|
||||||
|
}
|
||||||
|
|
||||||
|
enum GatewaySourceMode {
|
||||||
|
BRANCH
|
||||||
|
COMMIT
|
||||||
|
}
|
||||||
|
|
||||||
model AppUser {
|
model AppUser {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
loginId String @unique @map("login_id")
|
loginId String @unique @map("login_id")
|
||||||
@@ -71,11 +90,36 @@ model GatewayProfile {
|
|||||||
meta Json @default(dbgenerated("'{}'::jsonb"))
|
meta Json @default(dbgenerated("'{}'::jsonb"))
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
updatedAt DateTime @updatedAt @map("updated_at")
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
operations GatewayOperation[]
|
||||||
|
|
||||||
@@unique([profile, scenario])
|
@@unique([profile, scenario])
|
||||||
@@map("gateway_profile")
|
@@map("gateway_profile")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model GatewayOperation {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
profileName String @map("profile_name")
|
||||||
|
profile GatewayProfile @relation(fields: [profileName], references: [profileName], onDelete: Cascade)
|
||||||
|
type GatewayOperationType
|
||||||
|
status GatewayOperationStatus @default(QUEUED)
|
||||||
|
sourceMode GatewaySourceMode? @map("source_mode")
|
||||||
|
sourceRef String? @map("source_ref")
|
||||||
|
resolvedCommitSha String? @map("resolved_commit_sha")
|
||||||
|
payload Json @default(dbgenerated("'{}'::jsonb"))
|
||||||
|
reason String?
|
||||||
|
requestedBy String @map("requested_by")
|
||||||
|
scheduledAt DateTime? @map("scheduled_at")
|
||||||
|
startedAt DateTime? @map("started_at")
|
||||||
|
completedAt DateTime? @map("completed_at")
|
||||||
|
error String?
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
|
@@index([status, scheduledAt, createdAt])
|
||||||
|
@@index([profileName, createdAt])
|
||||||
|
@@map("gateway_operation")
|
||||||
|
}
|
||||||
|
|
||||||
model SystemSetting {
|
model SystemSetting {
|
||||||
id Int @id @default(1) @map("no")
|
id Int @id @default(1) @map("no")
|
||||||
notice String @default("") @map("notice")
|
notice String @default("") @map("notice")
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
CREATE TYPE "GatewayOperationType" AS ENUM ('RESET', 'START', 'STOP');
|
||||||
|
CREATE TYPE "GatewayOperationStatus" AS ENUM ('QUEUED', 'RUNNING', 'SUCCEEDED', 'FAILED', 'CANCELLED');
|
||||||
|
CREATE TYPE "GatewaySourceMode" AS ENUM ('BRANCH', 'COMMIT');
|
||||||
|
|
||||||
|
CREATE TABLE "gateway_operation" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"profile_name" TEXT NOT NULL,
|
||||||
|
"type" "GatewayOperationType" NOT NULL,
|
||||||
|
"status" "GatewayOperationStatus" NOT NULL DEFAULT 'QUEUED',
|
||||||
|
"source_mode" "GatewaySourceMode",
|
||||||
|
"source_ref" TEXT,
|
||||||
|
"resolved_commit_sha" TEXT,
|
||||||
|
"payload" JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
"reason" TEXT,
|
||||||
|
"requested_by" TEXT NOT NULL,
|
||||||
|
"scheduled_at" TIMESTAMP(3),
|
||||||
|
"started_at" TIMESTAMP(3),
|
||||||
|
"completed_at" TIMESTAMP(3),
|
||||||
|
"error" TEXT,
|
||||||
|
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "gateway_operation_pkey" PRIMARY KEY ("id"),
|
||||||
|
CONSTRAINT "gateway_operation_profile_name_fkey"
|
||||||
|
FOREIGN KEY ("profile_name") REFERENCES "gateway_profile"("profile_name")
|
||||||
|
ON DELETE CASCADE ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX "gateway_operation_status_scheduled_at_created_at_idx"
|
||||||
|
ON "gateway_operation"("status", "scheduled_at", "created_at");
|
||||||
|
CREATE INDEX "gateway_operation_profile_name_created_at_idx"
|
||||||
|
ON "gateway_operation"("profile_name", "created_at");
|
||||||
|
CREATE UNIQUE INDEX "gateway_operation_one_active_per_profile_idx"
|
||||||
|
ON "gateway_operation"("profile_name")
|
||||||
|
WHERE "status" IN ('QUEUED', 'RUNNING');
|
||||||
Reference in New Issue
Block a user