관리자 설정 로딩과 부트 진단 복구

This commit is contained in:
Yun Chan 2026-09-22 23:06:54 +09:00
parent d58eb67764
commit fb640a75ac
7 changed files with 767 additions and 100 deletions

View file

@ -66,6 +66,8 @@ const Settings = lazy(() => import("./pages/Settings"));
function BootScreen() {
return (
<div
role="status"
data-vignette-boot-loading="true"
style={{
minHeight: "100dvh",
display: "grid",

View file

@ -616,6 +616,7 @@ export type AdminUptimeResponse = ApiSchema<"AdminUptimeResponse">;
export type AdminSupportTicket = ApiSchema<"AdminSupportTicketResponse">;
export type AdminTicketsResponse = ApiSchema<"AdminTicketsResponse">;
export type AdminTicketPatchRequest = ApiSchema<"AdminTicketPatch">;
type AdminReadOptions = Pick<RequestOptions, "signal">;
export type AdminTicketFilters = {
status?: AdminSupportTicket["status"] | "";
category?: AdminSupportTicket["category"] | "";
@ -628,8 +629,9 @@ export type AdminTicketFilters = {
};
export const adminApi = {
health: () => api.get<AdminHealthResponse>("/admin/health"),
usage: (windowDays = 7) => api.get<AdminUsageResponse>(`/admin/usage?window_days=${windowDays}`),
health: (options?: AdminReadOptions) => api.get<AdminHealthResponse>("/admin/health", options),
usage: (windowDays = 7, options?: AdminReadOptions) =>
api.get<AdminUsageResponse>(`/admin/usage?window_days=${windowDays}`, options),
uptime: (windowHours = 24) =>
api.get<AdminUptimeResponse>(`/admin/uptime?window_hours=${windowHours}`),
tickets: (filters: AdminTicketFilters = {}) => {
@ -828,14 +830,19 @@ export type AdminProviderOAuthStartResponse =
ApiSchema<"AdminProviderOAuthStartResponse">;
export const adminEngineApi = {
get: () => api.get<AdminEngineConfigResponse>("/admin/engine-config"),
capabilities: (engineMode: string, force = false, engineUrl?: string) => {
get: (options?: AdminReadOptions) =>
api.get<AdminEngineConfigResponse>("/admin/engine-config", options),
capabilities: (
engineMode: string,
force = false,
engineUrl?: string,
options?: AdminReadOptions,
) => {
const query = new URLSearchParams({ engine_mode: engineMode, force: String(force) });
if (engineUrl?.trim()) query.set("engine_url", engineUrl.trim());
return (
api.get<EngineCapabilitiesResponse>(
`/admin/engine-capabilities?${query.toString()}`,
)
return api.get<EngineCapabilitiesResponse>(
`/admin/engine-capabilities?${query.toString()}`,
options,
);
},
update: (body: AdminEngineConfigPatchRequest) =>

View file

@ -188,11 +188,33 @@ function healthStatusLabel(status?: string): string {
return "확인 불가";
}
const ADMIN_READ_TIMEOUT_MS = 15_000;
const CAPABILITIES_READ_TIMEOUT_MS = 25_000;
class AdminReadTimeoutError extends Error {
constructor(label: string) {
super(`${label} 조회 시간이 초과되었습니다. 잠시 후 다시 시도해 주세요.`);
this.name = "AdminReadTimeoutError";
}
}
function adminReadError(cause: unknown, label: string): string {
if (cause instanceof AdminReadTimeoutError) return cause.message;
if (cause instanceof Error) return cause.message;
return `${label}을 불러오지 못했습니다. 잠시 후 다시 시도해 주세요.`;
}
function capabilitiesScope(engineMode: string, engineUrl: string): string {
return `${engineMode}\u0000${engineUrl.trim()}`;
}
export default function AdminAi() {
const [windowDays, setWindowDays] = useState<(typeof WINDOW_OPTIONS)[number]>(30);
const [usage, setUsage] = useState<AdminUsageResponse | null>(null);
const [health, setHealth] = useState<AdminHealthResponse | null>(null);
const [engine, setEngine] = useState<AdminEngineConfigResponse | null>(null);
const [engineLoading, setEngineLoading] = useState(true);
const [engineError, setEngineError] = useState<string | null>(null);
const [capabilities, setCapabilities] = useState<EngineCapabilitiesResponse | null>(null);
const [capabilitiesLoading, setCapabilitiesLoading] = useState(false);
const [capabilitiesError, setCapabilitiesError] = useState<string | null>(null);
@ -200,100 +222,257 @@ export default function AdminAi() {
const [saving, setSaving] = useState(false);
const [saved, setSaved] = useState(false);
const [error, setError] = useState<string | null>(null);
const initialLoad = useRef(true);
const mounted = useRef(false);
const loadGeneration = useRef(0);
const displayRequestId = useRef(0);
const usageRequestId = useRef(0);
const healthRequestId = useRef(0);
const capabilityRequestId = useRef(0);
const engineDraftRevision = useRef(0);
const loadedEngineRevision = useRef(0);
const loadedCapabilitiesScope = useRef<string | null>(null);
const activeControllers = useRef(new Set<AbortController>());
const loadRef = useRef<() => Promise<void>>(async () => undefined);
const loadedWindowDays = useRef(windowDays);
useEffect(() => {
mounted.current = true;
return () => {
mounted.current = false;
loadGeneration.current += 1;
capabilityRequestId.current += 1;
for (const controller of activeControllers.current) controller.abort();
activeControllers.current.clear();
};
}, []);
const runTimedRead = useCallback(
async <T,>(
timeoutMs: number,
label: string,
request: (signal: AbortSignal) => Promise<T>,
): Promise<T> => {
const controller = new AbortController();
activeControllers.current.add(controller);
let timedOut = false;
const timeout = window.setTimeout(() => {
timedOut = true;
controller.abort();
}, timeoutMs);
try {
return await request(controller.signal);
} catch (cause) {
if (timedOut) throw new AdminReadTimeoutError(label);
throw cause;
} finally {
window.clearTimeout(timeout);
activeControllers.current.delete(controller);
}
},
[],
);
const updateEngineDraft = useCallback(
(updater: (current: AdminEngineConfigResponse) => AdminEngineConfigResponse): number => {
const revision = ++engineDraftRevision.current;
setEngine((current) => (current ? updater(current) : current));
return revision;
},
[],
);
const loadCapabilities = useCallback(
async (
engineMode: string,
engineUrl: string,
options: { force?: boolean; applyDefault?: boolean } = {},
options: { force?: boolean; applyDefault?: boolean; draftRevision?: number } = {},
) => {
const requestId = ++capabilityRequestId.current;
const draftRevision = options.draftRevision ?? engineDraftRevision.current;
const scope = capabilitiesScope(engineMode, engineUrl);
setCapabilitiesLoading(true);
setCapabilitiesError(null);
try {
const next = await adminEngineApi.capabilities(
engineMode,
options.force ?? false,
engineUrl,
const next = await runTimedRead(
CAPABILITIES_READ_TIMEOUT_MS,
"모델 목록",
(signal) =>
adminEngineApi.capabilities(engineMode, options.force ?? false, engineUrl, { signal }),
);
if (requestId !== capabilityRequestId.current) return;
if (
!mounted.current ||
requestId !== capabilityRequestId.current ||
draftRevision !== engineDraftRevision.current
) {
return;
}
setCapabilities(next);
loadedCapabilitiesScope.current = scope;
if (options.applyDefault && next.available && next.default_model) {
setEngine((current) => {
if (!current || current.engine_mode !== engineMode) return current;
return {
...current,
model: next.default_model ?? "",
reasoning_effort: next.default_reasoning_effort,
reasoning_effort: next.default_reasoning_effort ?? null,
};
});
}
} catch (cause) {
if (requestId !== capabilityRequestId.current) return;
setCapabilities(null);
setCapabilitiesError(
cause instanceof Error ? cause.message : "모델 목록을 불러오지 못했습니다.",
);
if (
!mounted.current ||
requestId !== capabilityRequestId.current ||
draftRevision !== engineDraftRevision.current
) {
return;
}
setCapabilitiesError(adminReadError(cause, "모델 목록"));
} finally {
if (requestId === capabilityRequestId.current) setCapabilitiesLoading(false);
if (mounted.current && requestId === capabilityRequestId.current) {
setCapabilitiesLoading(false);
}
}
},
[],
[runTimedRead],
);
const loadUsage = useCallback(
async (days: number): Promise<{ requestId: number; error: string | null }> => {
const requestId = ++usageRequestId.current;
try {
const next = await runTimedRead(
ADMIN_READ_TIMEOUT_MS,
"AI 사용량",
(signal) => adminApi.usage(days, { signal }),
);
if (!mounted.current || requestId !== usageRequestId.current) {
return { requestId, error: null };
}
setUsage(next);
return { requestId, error: null };
} catch (cause) {
if (!mounted.current || requestId !== usageRequestId.current) {
return { requestId, error: null };
}
return { requestId, error: adminReadError(cause, "AI 사용량") };
}
},
[runTimedRead],
);
const loadHealth = useCallback(
async (): Promise<{ requestId: number; error: string | null }> => {
const requestId = ++healthRequestId.current;
try {
const next = await runTimedRead(
ADMIN_READ_TIMEOUT_MS,
"엔진 상태",
(signal) => adminApi.health({ signal }),
);
if (!mounted.current || requestId !== healthRequestId.current) {
return { requestId, error: null };
}
setHealth(next);
return { requestId, error: null };
} catch (cause) {
if (!mounted.current || requestId !== healthRequestId.current) {
return { requestId, error: null };
}
return { requestId, error: adminReadError(cause, "엔진 상태") };
}
},
[runTimedRead],
);
const load = useCallback(async () => {
const generation = ++loadGeneration.current;
const displayRequest = ++displayRequestId.current;
const draftRevision = engineDraftRevision.current;
capabilityRequestId.current += 1;
setCapabilitiesLoading(false);
setLoading(true);
setError(null);
try {
const [usageResult, healthResult, engineResult] = await Promise.allSettled([
adminApi.usage(windowDays),
adminApi.health(),
adminEngineApi.get(),
]);
if (usageResult.status === "fulfilled") setUsage(usageResult.value);
if (healthResult.status === "fulfilled") setHealth(healthResult.value);
if (engineResult.status === "fulfilled") {
setEngine(engineResult.value);
await loadCapabilities(engineResult.value.engine_mode, engineResult.value.engine_url);
}
const failures = [usageResult, healthResult, engineResult]
.filter((result): result is PromiseRejectedResult => result.status === "rejected")
.map((result) =>
result.reason instanceof Error ? result.reason.message : "일부 운영 정보를 불러오지 못했습니다.",
);
if (failures.length > 0) setError([...new Set(failures)].join(" · "));
} finally {
setLoading(false);
}
}, [loadCapabilities, windowDays]);
setEngineLoading(true);
setEngineError(null);
useEffect(() => {
if (initialLoad.current) {
initialLoad.current = false;
void load();
return;
}
let active = true;
setLoading(true);
setError(null);
void adminApi.usage(windowDays)
.then((nextUsage) => {
if (active) setUsage(nextUsage);
const usagePromise = loadUsage(windowDays);
const healthPromise = loadHealth();
void runTimedRead(
ADMIN_READ_TIMEOUT_MS,
"AI 엔진 설정",
(signal) => adminEngineApi.get({ signal }),
)
.then((next) => {
if (
!mounted.current ||
generation !== loadGeneration.current ||
draftRevision !== engineDraftRevision.current ||
draftRevision !== loadedEngineRevision.current
) {
return;
}
const scope = capabilitiesScope(next.engine_mode, next.engine_url);
if (loadedCapabilitiesScope.current !== scope) {
setCapabilities(null);
setCapabilitiesError(null);
}
setEngine(next);
loadedEngineRevision.current = draftRevision;
setEngineError(null);
void loadCapabilities(next.engine_mode, next.engine_url, { draftRevision });
})
.catch((cause) => {
if (active) {
setError(cause instanceof Error ? cause.message : "AI 사용량을 불러오지 못했습니다.");
}
if (!mounted.current || generation !== loadGeneration.current) return;
setEngineError(adminReadError(cause, "AI 엔진 설정"));
})
.finally(() => {
if (active) setLoading(false);
if (mounted.current && generation === loadGeneration.current) setEngineLoading(false);
});
return () => {
active = false;
};
}, [windowDays]);
const [usageResult, healthResult] = await Promise.all([usagePromise, healthPromise]);
if (
!mounted.current ||
generation !== loadGeneration.current ||
displayRequest !== displayRequestId.current ||
usageResult.requestId !== usageRequestId.current ||
healthResult.requestId !== healthRequestId.current
) {
return;
}
const errors = [usageResult.error, healthResult.error].filter((item): item is string => Boolean(item));
setError(errors.length ? [...new Set(errors)].join(" · ") : null);
setLoading(false);
}, [loadCapabilities, loadHealth, loadUsage, runTimedRead, windowDays]);
useEffect(() => {
loadRef.current = load;
}, [load]);
useEffect(() => {
const timer = window.setTimeout(() => {
void loadRef.current();
}, 0);
return () => window.clearTimeout(timer);
}, []);
useEffect(() => {
if (windowDays === loadedWindowDays.current) return;
loadedWindowDays.current = windowDays;
const displayRequest = ++displayRequestId.current;
setLoading(true);
setError(null);
void loadUsage(windowDays).then((usageResult) => {
if (
!mounted.current ||
displayRequest !== displayRequestId.current ||
usageResult.requestId !== usageRequestId.current
) {
return;
}
setError(usageResult.error);
setLoading(false);
});
}, [loadUsage, windowDays]);
const saveEngine = async () => {
if (!engine || !engine.engine_url.trim() || !engine.model.trim()) return;
@ -307,9 +486,11 @@ export default function AdminAi() {
model: engine.model.trim(),
reasoning_effort: engine.reasoning_effort,
});
setEngine(next);
const savedRevision = updateEngineDraft(() => next);
loadedEngineRevision.current = savedRevision;
setSaved(true);
setHealth(await adminApi.health().catch(() => health));
const healthResult = await loadHealth();
if (healthResult.error) setError(healthResult.error);
window.setTimeout(() => setSaved(false), 2400);
} catch (cause) {
setError(cause instanceof Error ? cause.message : "AI 운영 설정을 저장하지 못했습니다.");
@ -317,7 +498,6 @@ export default function AdminAi() {
setSaving(false);
}
};
const usageCostBasis = readCostBasis(usage);
const budgetCostBasis = readCostBasis(usage?.budget, usageCostBasis);
const daily = usage?.daily_cost ?? [];
@ -710,6 +890,14 @@ export default function AdminAi() {
{healthStatusLabel(engineService?.status)}
</span>
</div>
{engineError ? (
<div className="aic-capability is-unavailable" role="alert">
<div>
<b>엔진 설정을 확인할 수 없음</b>
<span>{engineError}</span>
</div>
</div>
) : null}
{engine ? (
<>
<Field
@ -726,15 +914,17 @@ export default function AdminAi() {
const engineMode = event.target
.value as AdminEngineConfigResponse["engine_mode"];
setCapabilities(null);
setEngine({
...engine,
loadedCapabilitiesScope.current = null;
const draftRevision = updateEngineDraft((current) => ({
...current,
engine_mode: engineMode,
model: "",
reasoning_effort: null,
});
}));
void loadCapabilities(engineMode, engine.engine_url, {
force: true,
applyDefault: true,
draftRevision,
});
}}
>
@ -750,8 +940,12 @@ export default function AdminAi() {
id="aic-engine-url"
value={engine.engine_url}
onChange={(event) => {
setEngine({ ...engine, engine_url: event.target.value });
updateEngineDraft((current) => ({
...current,
engine_url: event.target.value,
}));
setCapabilities(null);
loadedCapabilitiesScope.current = null;
setCapabilitiesError(
"연결 주소가 바뀌었습니다. 새 주소의 모델 목록을 확인하세요.",
);
@ -771,11 +965,11 @@ export default function AdminAi() {
onChange={(event) => {
const model = event.target.value;
const option = capabilityModels.find((item) => item.id === model);
setEngine({
...engine,
updateEngineDraft((current) => ({
...current,
model,
reasoning_effort: option?.default_reasoning_effort ?? null,
});
}));
}}
aria-label="AI 기본 모델"
disabled={capabilitiesLoading || !capabilities?.available}
@ -805,11 +999,11 @@ export default function AdminAi() {
aria-label="AI 추론 강도"
disabled={capabilitiesLoading || !selectedModel}
onChange={(event) =>
setEngine({
...engine,
updateEngineDraft((current) => ({
...current,
reasoning_effort: (event.target.value ||
null) as AdminEngineConfigResponse["reasoning_effort"],
})
}))
}
>
{selectedEfforts.length ? null : (
@ -858,6 +1052,7 @@ export default function AdminAi() {
void loadCapabilities(engine.engine_mode, engine.engine_url, {
force: true,
applyDefault: !selectedModel,
draftRevision: engineDraftRevision.current,
})
}
disabled={capabilitiesLoading}
@ -889,7 +1084,11 @@ export default function AdminAi() {
</div>
</>
) : (
<div className="aic-empty">현재 AI 엔진 설정을 불러오는 중입니다.</div>
<div className="aic-empty">
{engineLoading
? "현재 AI 엔진 설정을 불러오는 중입니다."
: "현재 AI 엔진 설정을 불러오지 못했습니다. 새로고침 후 다시 시도해 주세요."}
</div>
)}
</section>