관리자 흰 화면 진단 보강

This commit is contained in:
Yun Chan 2026-07-03 22:50:14 +09:00
parent e9efaa8031
commit 287029f2a2
6 changed files with 307 additions and 25 deletions

View file

@ -451,6 +451,55 @@ function cohortIdsField(
return normalized;
}
function numberRecordField(
source: Record<string, unknown>,
field: string,
warnings: AdminDataWarning[],
label: string,
): Record<string, number> {
const value = source[field];
if (value == null) {
warnings.push({
key: `${label}.${field}`,
message: `${label} 응답에서 ${field} 값이 비어 있어 빈 집계로 표시합니다.`,
detail: `${field}=null`,
});
return {};
}
if (typeof value !== "object" || Array.isArray(value)) {
warnings.push({
key: `${label}.${field}`,
message: `${label} 응답에서 ${field} 타입이 잘못되어 빈 집계로 표시합니다.`,
detail: `${field}=${typeof value}`,
});
return {};
}
const entries = Object.entries(value as Record<string, unknown>);
const normalized: Record<string, number> = {};
for (const [key, raw] of entries) {
if (typeof raw === "number" && Number.isFinite(raw)) {
normalized[key] = raw;
continue;
}
const parsed = typeof raw === "string" ? Number(raw) : Number.NaN;
if (Number.isFinite(parsed)) {
normalized[key] = parsed;
warnings.push({
key: `${label}.${field}.${key}`,
message: `${label} 응답에서 ${field}.${key}가 문자열이라 숫자로 변환했습니다.`,
detail: `${field}.${key}=string`,
});
continue;
}
warnings.push({
key: `${label}.${field}.${key}`,
message: `${label} 응답에서 ${field}.${key} 숫자 값이 없어 제외했습니다.`,
detail: `${field}.${key}=${raw == null ? "null" : typeof raw}`,
});
}
return normalized;
}
function roleField(
source: Record<string, unknown>,
warnings: AdminDataWarning[],
@ -539,6 +588,52 @@ function normalizeAdminUsersResponse(rawResponse: AdminUsersResponse): {
};
}
function normalizeAdminTicketsResponse(rawResponse: AdminTicketsResponse): {
response: AdminTicketsResponse;
warnings: AdminDataWarning[];
} {
const warnings: AdminDataWarning[] = [];
const root = asRecord(rawResponse);
const source: AdminTicketsResponse["source"] = root.source === "database" ? "database" : "unavailable";
const durable = typeof root.durable === "boolean" ? root.durable : source === "database";
const rawTickets = Array.isArray(root.tickets) ? root.tickets : [];
if (!Array.isArray(root.tickets)) {
warnings.push({
key: "admin.tickets",
message: "/admin/tickets 응답에서 tickets 배열이 없어 빈 목록으로 표시합니다.",
detail: `tickets=${root.tickets == null ? "null" : typeof root.tickets}`,
});
}
const summaryRoot = asRecord(root.summary);
if (!root.summary || typeof root.summary !== "object" || Array.isArray(root.summary)) {
warnings.push({
key: "admin.tickets.summary",
message: "/admin/tickets 응답에서 summary 객체가 없어 기본 집계로 표시합니다.",
detail: `summary=${root.summary == null ? "null" : typeof root.summary}`,
});
}
return {
response: {
source,
durable,
generated_at: numberField(root, "generated_at", 0, warnings, "admin.tickets"),
tickets: rawTickets as AdminSupportTicket[],
summary: {
total: numberField(summaryRoot, "total", rawTickets.length, warnings, "admin.tickets.summary"),
open_count: numberField(summaryRoot, "open_count", 0, warnings, "admin.tickets.summary"),
high_priority_count: numberField(summaryRoot, "high_priority_count", 0, warnings, "admin.tickets.summary"),
stale_count: numberField(summaryRoot, "stale_count", 0, warnings, "admin.tickets.summary"),
by_category: numberRecordField(summaryRoot, "by_category", warnings, "admin.tickets.summary"),
by_priority: numberRecordField(summaryRoot, "by_priority", warnings, "admin.tickets.summary"),
by_status: numberRecordField(summaryRoot, "by_status", warnings, "admin.tickets.summary"),
},
},
warnings,
};
}
function isOnline(seconds?: number | null): boolean {
if (!seconds || seconds <= 0) return false;
return Date.now() / 1000 - seconds < 15 * 60;
@ -774,6 +869,7 @@ export default function Admin({ section = "overview" }: AdminProps) {
const [tickets, setTickets] = useState<AdminTicketsResponse | null>(null);
const [ticketsLoading, setTicketsLoading] = useState(true);
const [ticketsError, setTicketsError] = useState<string | null>(null);
const [ticketsDiagnostics, setTicketsDiagnostics] = useState<AdminDataWarning[]>([]);
const [updatingTicketId, setUpdatingTicketId] = useState<string | null>(null);
const [ticketStatusFilter, setTicketStatusFilter] = useState<TicketStatusFilter>("all");
const [ticketCategoryFilter, setTicketCategoryFilter] = useState<TicketCategoryFilter>("all");
@ -867,9 +963,13 @@ export default function Admin({ section = "overview" }: AdminProps) {
search: ticketSearch,
windowDays: 30,
};
setTickets(await adminApi.tickets(filters));
const next = await adminApi.tickets(filters);
const { response: normalized, warnings } = normalizeAdminTicketsResponse(next);
setTickets(normalized);
setTicketsDiagnostics(warnings);
} catch (err) {
setTicketsError(err instanceof Error ? err.message : "운영 티켓을 불러오지 못했습니다.");
setTicketsDiagnostics([]);
} finally {
setTicketsLoading(false);
}
@ -1199,20 +1299,23 @@ export default function Admin({ section = "overview" }: AdminProps) {
const hasUserSearch = userSearch.trim().length > 0;
const visibleUsers = filteredUsers.slice(0, MAX_RENDERED_USERS);
const renderAdminDiagnostics = () =>
usersDiagnostics.length > 0 ? (
[...usersDiagnostics, ...ticketsDiagnostics].length > 0 ? (
<AdminDiagnosticPanel
title="관리자 데이터 진단"
body="/admin/users 응답 일부가 화면 계약과 달라 기본값으로 보정했습니다. 빈 화면 대신 어떤 필드가 문제인지 표시합니다."
body="관리자 API 응답 일부가 화면 계약과 달라 기본값으로 보정했습니다. 빈 화면 대신 어떤 필드가 문제인지 표시합니다."
details={[
["section", section],
["path", typeof window === "undefined" ? "unknown" : window.location.pathname],
["asset", runtimeAssetLabel()],
...usersDiagnostics.slice(0, 8).map((warning): [string, string] => [
...[...usersDiagnostics, ...ticketsDiagnostics].slice(0, 8).map((warning): [string, string] => [
warning.key,
`${warning.message} (${warning.detail})`,
]),
...(usersDiagnostics.length > 8
? [["more", `${usersDiagnostics.length - 8}개 추가 진단 생략`] as [string, string]]
...(usersDiagnostics.length + ticketsDiagnostics.length > 8
? [["more", `${usersDiagnostics.length + ticketsDiagnostics.length - 8}개 추가 진단 생략`] as [
string,
string,
]]
: []),
]}
/>