관리자 설정 로딩과 부트 진단 복구
This commit is contained in:
parent
d58eb67764
commit
fb640a75ac
7 changed files with 767 additions and 100 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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) =>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue