feat: add git reference support for scenario previews; implement git ref handling in admin router and frontend

This commit is contained in:
2026-01-17 14:27:11 +00:00
parent 07ea17dacf
commit 70c7c0b039
7 changed files with 367 additions and 37 deletions
@@ -30,6 +30,7 @@ export interface GatewayAdminActionRecord {
} | null; } | null;
openAt?: string | null; openAt?: string | null;
preopenAt?: string | null; preopenAt?: string | null;
gitRef?: string | null;
}; };
} }
+1
View File
@@ -31,6 +31,7 @@
"@sammo-ts/common": "workspace:*", "@sammo-ts/common": "workspace:*",
"@sammo-ts/game-engine": "workspace:*", "@sammo-ts/game-engine": "workspace:*",
"@sammo-ts/infra": "workspace:*", "@sammo-ts/infra": "workspace:*",
"@sammo-ts/logic": "workspace:*",
"@trpc/server": "^11.8.1", "@trpc/server": "^11.8.1",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"es-toolkit": "^1.43.0", "es-toolkit": "^1.43.0",
+31 -4
View File
@@ -4,7 +4,7 @@ import { TRPCError } from '@trpc/server';
import { z } from 'zod'; import { z } from 'zod';
import { procedure, router } from './trpc.js'; import { procedure, router } from './trpc.js';
import { listScenarioPreviews } from './scenario/scenarioCatalog.js'; import { listScenarioPreviews, resolveGitCommitSha } from './scenario/scenarioCatalog.js';
import type { UserSanctions, UserServerRestriction } from './auth/userRepository.js'; import type { UserSanctions, UserServerRestriction } from './auth/userRepository.js';
import type { AdminAuthContext } from './adminAuth.js'; import type { AdminAuthContext } from './adminAuth.js';
import type { GatewayApiContext } from './context.js'; import type { GatewayApiContext } from './context.js';
@@ -221,6 +221,7 @@ const zInstallOptions = z.object({
autorunUser: zInstallAutorun.nullable().optional(), autorunUser: zInstallAutorun.nullable().optional(),
openAt: z.string().datetime().optional(), openAt: z.string().datetime().optional(),
preopenAt: z.string().datetime().optional(), preopenAt: z.string().datetime().optional(),
gitRef: z.string().min(1).max(128).optional(),
}); });
type SanctionsPatch = z.infer<typeof zSanctionsPatch>; type SanctionsPatch = z.infer<typeof zSanctionsPatch>;
@@ -506,9 +507,18 @@ export const adminRouter = router({
}, },
})); }));
}), }),
listScenarios: profileAdminProcedure.query(async () => { listScenarios: profileAdminProcedure
return listScenarioPreviews(); .input(
}), z
.object({
gitRef: z.string().min(1).max(128).optional(),
})
.optional()
)
.query(async ({ input }) => {
const gitRef = input?.gitRef?.trim();
return listScenarioPreviews({ gitRef: gitRef || null });
}),
upsert: profileAdminProcedure upsert: profileAdminProcedure
.input( .input(
z.object({ z.object({
@@ -669,6 +679,22 @@ export const adminRouter = router({
} }
} }
let resolvedCommitSha: string | null = null;
const gitRef = input.install.gitRef?.trim();
if (gitRef) {
try {
resolvedCommitSha = await resolveGitCommitSha(gitRef);
} catch (error) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'git ref is invalid or unavailable.',
});
}
await ctx.profiles.updateBuildStatus(profile.profileName, profile.buildStatus, {
commitSha: resolvedCommitSha,
});
}
const scheduledAt = openAt ? (preopenAt ?? openAt).toISOString() : null; const scheduledAt = openAt ? (preopenAt ?? openAt).toISOString() : null;
const action = scheduledAt ? 'RESET_SCHEDULED' : 'RESET_NOW'; const action = scheduledAt ? 'RESET_SCHEDULED' : 'RESET_NOW';
const meta = readMetaObject(profile.meta); const meta = readMetaObject(profile.meta);
@@ -685,6 +711,7 @@ export const adminRouter = router({
...input.install, ...input.install,
openAt: input.install.openAt ?? null, openAt: input.install.openAt ?? null,
preopenAt: input.install.preopenAt ?? null, preopenAt: input.install.preopenAt ?? null,
gitRef: gitRef ?? null,
autorunUser: autorunUser autorunUser: autorunUser
? { ? {
limitMinutes: autorunUser.limitMinutes, limitMinutes: autorunUser.limitMinutes,
@@ -96,6 +96,7 @@ interface GatewayAdminActionRecord {
} | null; } | null;
openAt?: string | null; openAt?: string | null;
preopenAt?: string | null; preopenAt?: string | null;
gitRef?: string | null;
}; };
} }
@@ -619,12 +620,17 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
}); });
return { status: 'FAILED', detail: 'build failed' }; return { status: 'FAILED', detail: 'build failed' };
} }
const workspace = await this.workspaceManager.prepare(commitSha);
const resourceRoot = path.join(workspace.root, 'resources');
await seedScenarioToDatabase({ await seedScenarioToDatabase({
databaseUrl: seedInfo.databaseUrl, databaseUrl: seedInfo.databaseUrl,
scenarioId: seedInfo.scenarioId, scenarioId: seedInfo.scenarioId,
tickSeconds: seedInfo.tickSeconds, tickSeconds: seedInfo.tickSeconds,
now: seedTime, now: seedTime,
installOptions: installOptions ?? undefined, installOptions: installOptions ?? undefined,
scenarioOptions: { scenarioRoot: path.join(resourceRoot, 'scenario') },
mapOptions: { mapRoot: path.join(resourceRoot, 'map') },
unitSetOptions: { unitSetRoot: path.join(resourceRoot, 'unitset') },
}); });
await this.repository.updateBuildStatus(profile.profileName, 'SUCCEEDED', { await this.repository.updateBuildStatus(profile.profileName, 'SUCCEEDED', {
completedAt, completedAt,
+175 -8
View File
@@ -1,7 +1,10 @@
import fs from 'node:fs/promises'; import fs from 'node:fs/promises';
import path from 'node:path'; import path from 'node:path';
import { spawn } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import { loadScenarioDefinitionById, resolveScenarioDefaultsPath } from '@sammo-ts/game-engine'; import { loadScenarioDefinitionById, resolveScenarioDefaultsPath } from '@sammo-ts/game-engine';
import { parseScenarioDefaults, parseScenarioDefinition, type ScenarioDefaults } from '@sammo-ts/logic';
export interface ScenarioNationPreview { export interface ScenarioNationPreview {
id: number; id: number;
@@ -25,14 +28,89 @@ export interface ScenarioPreview {
const SCENARIO_FILE_PATTERN = /^scenario_(\d+)\.json$/i; const SCENARIO_FILE_PATTERN = /^scenario_(\d+)\.json$/i;
const CACHE_TTL_MS = 5 * 60 * 1000; const CACHE_TTL_MS = 5 * 60 * 1000;
const DEFAULT_CACHE_KEY = 'local';
const GIT_REF_PATTERN = /^[0-9A-Za-z._/-]+$/;
const SCENARIO_ROOT = path.join('resources', 'scenario');
let cachedPreviews: { loadedAt: number; data: ScenarioPreview[] } | null = null; const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const REPO_ROOT = path.resolve(__dirname, '..', '..', '..', '..');
const previewCache = new Map<string, { loadedAt: number; data: ScenarioPreview[] }>();
const defaultsCache = new Map<string, ScenarioDefaults>();
const resolveScenarioRoot = (): string => { const resolveScenarioRoot = (): string => {
const defaultsPath = resolveScenarioDefaultsPath(); const defaultsPath = resolveScenarioDefaultsPath();
return path.dirname(defaultsPath); return path.dirname(defaultsPath);
}; };
const runGit = (args: string[]): Promise<{ ok: boolean; output: string }> =>
new Promise((resolve) => {
const child = spawn('git', args, {
cwd: REPO_ROOT,
stdio: ['ignore', 'pipe', 'pipe'],
});
let output = '';
child.stdout.on('data', (chunk) => {
output += chunk.toString();
});
child.stderr.on('data', (chunk) => {
output += chunk.toString();
});
child.on('close', (code) => {
resolve({ ok: code === 0, output });
});
});
const normalizeGitRef = (value?: string | null): string | null => {
if (!value) {
return null;
}
const trimmed = value.trim();
if (!trimmed) {
return null;
}
if (trimmed.startsWith('-') || trimmed.includes('..') || !GIT_REF_PATTERN.test(trimmed)) {
return null;
}
return trimmed;
};
export const resolveGitCommitSha = async (gitRef: string): Promise<string> => {
const normalized = normalizeGitRef(gitRef);
if (!normalized) {
throw new Error('git ref is invalid.');
}
const resolveCommit = async (): Promise<{ ok: boolean; output: string }> =>
runGit(['rev-parse', '--verify', `${normalized}^{commit}`]);
let result = await resolveCommit();
if (!result.ok) {
await runGit(['fetch', '--all', '--tags']);
result = await resolveCommit();
}
if (!result.ok) {
throw new Error('git ref not found.');
}
const commit = result.output.trim().split('\n')[0];
if (!commit) {
throw new Error('git ref did not resolve to a commit.');
}
return commit;
};
const readGitFile = async (commitSha: string, relativePath: string): Promise<string> => {
const result = await runGit(['show', `${commitSha}:${relativePath}`]);
if (!result.ok) {
throw new Error(`Failed to read ${relativePath} from ${commitSha}.`);
}
return result.output;
};
const readGitJson = async (commitSha: string, relativePath: string): Promise<unknown> => {
const raw = await readGitFile(commitSha, relativePath);
return JSON.parse(raw) as unknown;
};
const listScenarioIds = async (): Promise<number[]> => { const listScenarioIds = async (): Promise<number[]> => {
const root = resolveScenarioRoot(); const root = resolveScenarioRoot();
const entries = await fs.readdir(root, { withFileTypes: true }); const entries = await fs.readdir(root, { withFileTypes: true });
@@ -53,6 +131,30 @@ const listScenarioIds = async (): Promise<number[]> => {
return ids.sort((a, b) => a - b); return ids.sort((a, b) => a - b);
}; };
const listScenarioIdsFromGit = async (commitSha: string): Promise<number[]> => {
const result = await runGit(['ls-tree', '-r', '--name-only', commitSha, SCENARIO_ROOT]);
if (!result.ok) {
throw new Error('Failed to list scenarios from git.');
}
const ids: number[] = [];
result.output
.split('\n')
.map((line) => line.trim())
.filter(Boolean)
.forEach((entry) => {
const fileName = path.basename(entry);
const match = SCENARIO_FILE_PATTERN.exec(fileName);
if (!match) {
return;
}
const id = Number(match[1]);
if (Number.isFinite(id)) {
ids.push(id);
}
});
return ids.sort((a, b) => a - b);
};
const buildNationIdResolver = (nations: Array<{ id: number; name: string }>): ((value: number | string | null) => number | null) => { const buildNationIdResolver = (nations: Array<{ id: number; name: string }>): ((value: number | string | null) => number | null) => {
const byName = new Map(nations.map((nation) => [nation.name, nation.id])); const byName = new Map(nations.map((nation) => [nation.name, nation.id]));
return (value) => { return (value) => {
@@ -111,15 +213,80 @@ const buildScenarioPreview = async (scenarioId: number): Promise<ScenarioPreview
}; };
}; };
export const listScenarioPreviews = async (): Promise<ScenarioPreview[]> => { const loadScenarioDefaultsFromGit = async (commitSha: string): Promise<ScenarioDefaults> => {
if (cachedPreviews && Date.now() - cachedPreviews.loadedAt < CACHE_TTL_MS) { const cached = defaultsCache.get(commitSha);
return cachedPreviews.data; if (cached) {
return cached;
} }
const ids = await listScenarioIds(); const rawDefaults = await readGitJson(commitSha, path.join(SCENARIO_ROOT, 'default.json'));
const previews = await Promise.all(ids.map((id) => buildScenarioPreview(id))); const parsed = parseScenarioDefaults(rawDefaults);
cachedPreviews = { defaultsCache.set(commitSha, parsed);
return parsed;
};
const buildScenarioPreviewFromGit = async (commitSha: string, scenarioId: number): Promise<ScenarioPreview> => {
const defaults = await loadScenarioDefaultsFromGit(commitSha);
const rawScenario = await readGitJson(commitSha, path.join(SCENARIO_ROOT, `scenario_${scenarioId}.json`));
const scenario = parseScenarioDefinition(rawScenario, defaults);
const resolveNationId = buildNationIdResolver(scenario.nations);
const baseCounts = new Map(scenario.nations.map((nation) => [nation.id, 0]));
const generalCounts = countGeneralsByNation(scenario.generals, resolveNationId);
const generalExCounts = countGeneralsByNation(scenario.generalsEx, resolveNationId);
const generalNeutralCounts = countGeneralsByNation(scenario.generalsNeutral, resolveNationId);
const nations = scenario.nations.map((nation) => ({
id: nation.id,
name: nation.name,
color: nation.color,
cities: nation.cities,
generals: generalCounts.get(nation.id) ?? baseCounts.get(nation.id) ?? 0,
generalsEx: generalExCounts.get(nation.id) ?? baseCounts.get(nation.id) ?? 0,
generalsNeutral: generalNeutralCounts.get(nation.id) ?? baseCounts.get(nation.id) ?? 0,
}));
return {
id: scenarioId,
title: scenario.title,
year: scenario.startYear ?? null,
npcCount: scenario.generals.length,
npcExCount: scenario.generalsEx.length,
npcNeutralCount: scenario.generalsNeutral.length,
nations,
};
};
export const listScenarioPreviews = async (options?: { gitRef?: string | null }): Promise<ScenarioPreview[]> => {
const rawGitRef = options?.gitRef ?? null;
const gitRef = normalizeGitRef(rawGitRef);
if (typeof rawGitRef === 'string' && rawGitRef.trim() && !gitRef) {
throw new Error('git ref is invalid.');
}
if (!gitRef) {
const cached = previewCache.get(DEFAULT_CACHE_KEY);
if (cached && Date.now() - cached.loadedAt < CACHE_TTL_MS) {
return cached.data;
}
const ids = await listScenarioIds();
const previews = await Promise.all(ids.map((id) => buildScenarioPreview(id)));
previewCache.set(DEFAULT_CACHE_KEY, {
loadedAt: Date.now(),
data: previews,
});
return previews;
}
const commitSha = await resolveGitCommitSha(gitRef);
const cacheKey = commitSha;
const cached = previewCache.get(cacheKey);
if (cached && Date.now() - cached.loadedAt < CACHE_TTL_MS) {
return cached.data;
}
const ids = await listScenarioIdsFromGit(commitSha);
const previews = await Promise.all(ids.map((id) => buildScenarioPreviewFromGit(commitSha, id)));
previewCache.set(cacheKey, {
loadedAt: Date.now(), loadedAt: Date.now(),
data: previews, data: previews,
}; });
return previews; return previews;
}; };
@@ -0,0 +1,18 @@
import { describe, expect, it } from 'vitest';
import { listScenarioPreviews, resolveGitCommitSha } from '../src/scenario/scenarioCatalog.js';
describe('scenarioCatalog git ref support', () => {
it('resolves HEAD to a commit hash', async () => {
const commitSha = await resolveGitCommitSha('HEAD');
expect(commitSha).toMatch(/^[0-9a-f]{40}$/i);
});
it('loads scenario previews from a git ref', async () => {
const previews = await listScenarioPreviews({ gitRef: 'HEAD' });
expect(previews.length).toBeGreaterThan(0);
const ids = previews.map((scenario) => scenario.id);
const sorted = [...ids].sort((a, b) => a - b);
expect(ids).toEqual(sorted);
});
});
+135 -25
View File
@@ -87,6 +87,12 @@ type ScenarioPreview = {
nations: ScenarioNationPreview[]; nations: ScenarioNationPreview[];
}; };
type ScenarioCatalogState = {
scenarios: ScenarioPreview[];
loading: boolean;
status: string;
};
type InstallFormState = { type InstallFormState = {
scenarioId: number; scenarioId: number;
turnTermMinutes: number; turnTermMinutes: number;
@@ -102,6 +108,7 @@ type InstallFormState = {
autorunUserOptions: Record<string, boolean>; autorunUserOptions: Record<string, boolean>;
openAt: string; openAt: string;
preopenAt: string; preopenAt: string;
gitRef: string;
reason: string; reason: string;
}; };
@@ -169,7 +176,7 @@ type AdminClient = {
query: () => Promise<AdminProfile[]>; query: () => Promise<AdminProfile[]>;
}; };
listScenarios: { listScenarios: {
query: () => Promise<ScenarioPreview[]>; query: (input?: { gitRef?: string }) => Promise<ScenarioPreview[]>;
}; };
updateMeta: { updateMeta: {
mutate: (input: { mutate: (input: {
@@ -202,6 +209,7 @@ type AdminClient = {
} | null; } | null;
openAt?: string; openAt?: string;
preopenAt?: string; preopenAt?: string;
gitRef?: string;
}; };
reason?: string; reason?: string;
}) => Promise<{ ok: boolean; action?: unknown }>; }) => Promise<{ ok: boolean; action?: unknown }>;
@@ -267,9 +275,7 @@ const profileActions = ref<
> >
>({}); >({});
const profileActionStatus = ref<Record<string, string>>({}); const profileActionStatus = ref<Record<string, string>>({});
const scenarios = ref<ScenarioPreview[]>([]); const scenarioCatalogs = ref<Record<string, ScenarioCatalogState>>({});
const scenariosLoading = ref(false);
const scenariosStatus = ref('');
const profileInstalls = ref<Record<string, InstallFormState>>({}); const profileInstalls = ref<Record<string, InstallFormState>>({});
const profileInstallStatus = ref<Record<string, string>>({}); const profileInstallStatus = ref<Record<string, string>>({});
@@ -397,6 +403,21 @@ const buildAutorunOptionMap = (options?: string[]): Record<string, boolean> => {
return map; return map;
}; };
const normalizeGitRefInput = (value: string): string => value.trim();
const getScenarioCatalogKey = (gitRef: string): string => normalizeGitRefInput(gitRef);
const getScenarioCatalogStateByRef = (gitRef: string): ScenarioCatalogState => {
const key = getScenarioCatalogKey(gitRef);
return (
scenarioCatalogs.value[key] ?? {
scenarios: [],
loading: false,
status: '',
}
);
};
const ensureProfileInstallBuffers = (profile: AdminProfile) => { const ensureProfileInstallBuffers = (profile: AdminProfile) => {
if (profileInstalls.value[profile.profileName]) { if (profileInstalls.value[profile.profileName]) {
return; return;
@@ -409,6 +430,9 @@ const ensureProfileInstallBuffers = (profile: AdminProfile) => {
: undefined; : undefined;
const scenarioId = Number(profile.scenario); const scenarioId = Number(profile.scenario);
const installGitRef = readString(install.gitRef, '');
const buildCommitRef = typeof profile.buildCommitSha === 'string' ? profile.buildCommitSha : '';
profileInstalls.value[profile.profileName] = { profileInstalls.value[profile.profileName] = {
scenarioId: Number.isFinite(scenarioId) ? scenarioId : readNumber(install.scenarioId, 0), scenarioId: Number.isFinite(scenarioId) ? scenarioId : readNumber(install.scenarioId, 0),
turnTermMinutes: readNumber(install.turnTermMinutes, 60), turnTermMinutes: readNumber(install.turnTermMinutes, 60),
@@ -424,22 +448,23 @@ const ensureProfileInstallBuffers = (profile: AdminProfile) => {
autorunUserOptions: buildAutorunOptionMap(autorunOptionsRaw), autorunUserOptions: buildAutorunOptionMap(autorunOptionsRaw),
openAt: toLocalInputValue(install.openAt), openAt: toLocalInputValue(install.openAt),
preopenAt: toLocalInputValue(install.preopenAt), preopenAt: toLocalInputValue(install.preopenAt),
gitRef: installGitRef || buildCommitRef,
reason: '', reason: '',
}; };
}; };
const scenarioMap = computed(() => { const buildScenarioMap = (items: ScenarioPreview[]): Map<number, ScenarioPreview> => {
const map = new Map<number, ScenarioPreview>(); const map = new Map<number, ScenarioPreview>();
scenarios.value.forEach((scenario) => { items.forEach((scenario) => {
map.set(scenario.id, scenario); map.set(scenario.id, scenario);
}); });
return map; return map;
}); };
const scenarioGroups = computed(() => { const buildScenarioGroups = (items: ScenarioPreview[]): Record<string, ScenarioPreview[]> => {
const pattern = /【(.*?)[0-9\-_.a-zA-Z]*】/; const pattern = /【(.*?)[0-9\-_.a-zA-Z]*】/;
const groups: Record<string, ScenarioPreview[]> = {}; const groups: Record<string, ScenarioPreview[]> = {};
for (const scenario of scenarios.value) { for (const scenario of items) {
const match = pattern.exec(scenario.title); const match = pattern.exec(scenario.title);
const category = match?.[1] ?? '기타'; const category = match?.[1] ?? '기타';
if (!groups[category]) { if (!groups[category]) {
@@ -448,14 +473,41 @@ const scenarioGroups = computed(() => {
groups[category].push(scenario); groups[category].push(scenario);
} }
return groups; return groups;
}); };
const getScenarioPreview = (profileName: string): ScenarioPreview | null => { const getScenarioPreview = (profileName: string): ScenarioPreview | null => {
const install = profileInstalls.value[profileName]; const install = profileInstalls.value[profileName];
if (!install) { if (!install) {
return null; return null;
} }
return scenarioMap.value.get(install.scenarioId) ?? null; const catalog = getScenarioCatalogStateByRef(install.gitRef);
const map = buildScenarioMap(catalog.scenarios);
return map.get(install.scenarioId) ?? null;
};
const getScenarioGroups = (profileName: string): Record<string, ScenarioPreview[]> => {
const install = profileInstalls.value[profileName];
if (!install) {
return {};
}
const catalog = getScenarioCatalogStateByRef(install.gitRef);
return buildScenarioGroups(catalog.scenarios);
};
const getScenarioLoading = (profileName: string): boolean => {
const install = profileInstalls.value[profileName];
if (!install) {
return false;
}
return getScenarioCatalogStateByRef(install.gitRef).loading;
};
const getScenarioStatus = (profileName: string): string => {
const install = profileInstalls.value[profileName];
if (!install) {
return '';
}
return getScenarioCatalogStateByRef(install.gitRef).status;
}; };
const loadProfiles = async () => { const loadProfiles = async () => {
@@ -467,6 +519,15 @@ const loadProfiles = async () => {
ensureProfileInstallBuffers(profile); ensureProfileInstallBuffers(profile);
}); });
profiles.value = result; profiles.value = result;
const refs = new Set<string>();
refs.add('');
result.forEach((profile) => {
const install = profileInstalls.value[profile.profileName];
if (install?.gitRef) {
refs.add(normalizeGitRefInput(install.gitRef));
}
});
await Promise.all(Array.from(refs).map((gitRef) => loadScenarioCatalog(gitRef)));
} catch (error) { } catch (error) {
profileActionStatus.value = { profileActionStatus.value = {
...profileActionStatus.value, ...profileActionStatus.value,
@@ -477,19 +538,47 @@ const loadProfiles = async () => {
} }
}; };
const loadScenarios = async () => { const loadScenarioCatalog = async (gitRef: string) => {
scenariosLoading.value = true; const key = getScenarioCatalogKey(gitRef);
scenariosStatus.value = ''; const previous = scenarioCatalogs.value[key];
scenarioCatalogs.value = {
...scenarioCatalogs.value,
[key]: {
scenarios: previous?.scenarios ?? [],
loading: true,
status: '',
},
};
try { try {
const result = await adminClient.profiles.listScenarios.query(); const result = await adminClient.profiles.listScenarios.query(key ? { gitRef: key } : undefined);
scenarios.value = result; scenarioCatalogs.value = {
...scenarioCatalogs.value,
[key]: {
scenarios: result,
loading: false,
status: '',
},
};
} catch (error) { } catch (error) {
scenariosStatus.value = '시나리오 목록을 불러오지 못했습니다.'; scenarioCatalogs.value = {
} finally { ...scenarioCatalogs.value,
scenariosLoading.value = false; [key]: {
scenarios: previous?.scenarios ?? [],
loading: false,
status: '시나리오 목록을 불러오지 못했습니다.',
},
};
} }
}; };
const loadScenariosForProfile = async (profileName: string) => {
const install = profileInstalls.value[profileName];
if (!install) {
return;
}
await loadScenarioCatalog(install.gitRef);
};
const updateProfileMeta = async (profileName: string) => { const updateProfileMeta = async (profileName: string) => {
const edit = profileEdits.value[profileName]; const edit = profileEdits.value[profileName];
if (!edit) { if (!edit) {
@@ -581,6 +670,7 @@ const requestInstall = async (profileName: string) => {
return; return;
} }
try { try {
const gitRef = normalizeGitRefInput(install.gitRef);
await adminClient.profiles.install.mutate({ await adminClient.profiles.install.mutate({
profileName, profileName,
install: { install: {
@@ -597,6 +687,7 @@ const requestInstall = async (profileName: string) => {
autorunUser, autorunUser,
openAt: openAt ? openAt.toISOString() : undefined, openAt: openAt ? openAt.toISOString() : undefined,
preopenAt: preopenAt ? preopenAt.toISOString() : undefined, preopenAt: preopenAt ? preopenAt.toISOString() : undefined,
gitRef: gitRef ? gitRef : undefined,
}, },
reason: install.reason.trim() || undefined, reason: install.reason.trim() || undefined,
}); });
@@ -820,7 +911,6 @@ const forceDeleteUser = async () => {
onMounted(() => { onMounted(() => {
void loadNotice(); void loadNotice();
void loadProfiles(); void loadProfiles();
void loadScenarios();
}); });
</script> </script>
@@ -1259,15 +1349,35 @@ onMounted(() => {
</div> </div>
<div class="grid lg:grid-cols-2 gap-4"> <div class="grid lg:grid-cols-2 gap-4">
<div class="space-y-3"> <div class="space-y-3">
<div class="space-y-1">
<label class="text-xs text-zinc-400">Git ref (선택)</label>
<div class="flex gap-2">
<input
v-model="profileInstalls[profile.profileName].gitRef"
type="text"
class="flex-1 bg-zinc-950 border border-zinc-700 rounded px-3 py-2 text-sm text-white"
placeholder="main / v1.0.0 / abc123"
/>
<button
class="bg-zinc-700 hover:bg-zinc-600 text-white text-sm px-3 py-2 rounded"
:disabled="getScenarioLoading(profile.profileName)"
@click="loadScenariosForProfile(profile.profileName)"
>
불러오기
</button>
</div>
<div class="text-xs text-zinc-500">비워두면 현재 저장소 기준으로 불러옵니다.</div>
</div>
<div class="space-y-1"> <div class="space-y-1">
<label class="text-xs text-zinc-400">시나리오 선택</label> <label class="text-xs text-zinc-400">시나리오 선택</label>
<select <select
v-model.number="profileInstalls[profile.profileName].scenarioId" v-model.number="profileInstalls[profile.profileName].scenarioId"
class="w-full bg-zinc-950 border border-zinc-700 rounded px-3 py-2 text-sm text-white" class="w-full bg-zinc-950 border border-zinc-700 rounded px-3 py-2 text-sm text-white"
:disabled="scenariosLoading" :disabled="getScenarioLoading(profile.profileName)"
> >
<option v-if="scenariosLoading" disabled>불러오는 ...</option> <option v-if="getScenarioLoading(profile.profileName)" disabled>불러오는 ...</option>
<template v-for="(items, group) in scenarioGroups" :key="group"> <template v-for="(items, group) in getScenarioGroups(profile.profileName)" :key="group">
<optgroup :label="group"> <optgroup :label="group">
<option v-for="scenario in items" :key="scenario.id" :value="scenario.id"> <option v-for="scenario in items" :key="scenario.id" :value="scenario.id">
{{ scenario.title }} {{ scenario.title }}
@@ -1275,8 +1385,8 @@ onMounted(() => {
</optgroup> </optgroup>
</template> </template>
</select> </select>
<div v-if="scenariosStatus" class="text-xs text-red-400"> <div v-if="getScenarioStatus(profile.profileName)" class="text-xs text-red-400">
{{ scenariosStatus }} {{ getScenarioStatus(profile.profileName) }}
</div> </div>
</div> </div>