관리자 흰 화면 진단 보강
This commit is contained in:
parent
e9efaa8031
commit
287029f2a2
6 changed files with 307 additions and 25 deletions
|
|
@ -15,7 +15,7 @@
|
|||
보호 라우트는 AuthContext 기반 간단 가드. 과설계 금지.
|
||||
===================================================================== */
|
||||
|
||||
import { useEffect, type ReactNode } from "react";
|
||||
import { Component, useEffect, type ErrorInfo, type ReactNode } from "react";
|
||||
import { BrowserRouter, Navigate, Route, Routes, useLocation } from "react-router-dom";
|
||||
import { AuthProvider, canAccessRole, initialPathForUser, useAuth, roleHomePath, type AuthUser, type Role } from "./lib/auth";
|
||||
|
||||
|
|
@ -141,6 +141,150 @@ function ScrollToTopOnPathChange() {
|
|||
return null;
|
||||
}
|
||||
|
||||
function runtimeAssetLabel(): string {
|
||||
if (typeof document === "undefined") return "unknown";
|
||||
const script = Array.from(document.scripts)
|
||||
.map((item) => item.getAttribute("src") ?? "")
|
||||
.find((src) => src.includes("/assets/index-") && src.endsWith(".js"));
|
||||
if (!script) return "unknown";
|
||||
return script.split("/").pop() ?? script;
|
||||
}
|
||||
|
||||
function RouteErrorFallback({
|
||||
error,
|
||||
errorInfo,
|
||||
}: {
|
||||
error: Error;
|
||||
errorInfo: ErrorInfo | null;
|
||||
}) {
|
||||
return (
|
||||
<main
|
||||
role="alert"
|
||||
style={{
|
||||
minHeight: "100vh",
|
||||
padding: 32,
|
||||
background: "var(--bg-app)",
|
||||
color: "var(--text-strong)",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
maxWidth: 920,
|
||||
display: "grid",
|
||||
gap: 16,
|
||||
padding: 20,
|
||||
border: "1px solid var(--hair)",
|
||||
borderRadius: "var(--radius)",
|
||||
background: "var(--bg-surface)",
|
||||
boxShadow: "var(--shadow-sm)",
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<span
|
||||
style={{
|
||||
color: "var(--text-muted)",
|
||||
fontSize: 12,
|
||||
fontWeight: 700,
|
||||
}}
|
||||
>
|
||||
화면 진단
|
||||
</span>
|
||||
<h1 style={{ margin: "6px 0 0", fontSize: "var(--fs-h2)" }}>
|
||||
화면을 표시하지 못했습니다
|
||||
</h1>
|
||||
<p style={{ margin: "6px 0 0", color: "var(--text-body)", lineHeight: 1.55 }}>
|
||||
React 라우트 렌더 중 예외가 발생했습니다. 빈 화면 대신 원인 정보를 표시합니다.
|
||||
</p>
|
||||
</div>
|
||||
<dl
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "160px minmax(0, 1fr)",
|
||||
gap: 0,
|
||||
margin: 0,
|
||||
border: "1px solid var(--hair)",
|
||||
borderRadius: "var(--radius)",
|
||||
overflow: "hidden",
|
||||
fontSize: 13,
|
||||
}}
|
||||
>
|
||||
{[
|
||||
["path", typeof window === "undefined" ? "unknown" : window.location.pathname],
|
||||
["error", error.message || error.name],
|
||||
["asset", runtimeAssetLabel()],
|
||||
["componentStack", errorInfo?.componentStack?.trim() || "not captured"],
|
||||
].map(([key, value]) => (
|
||||
<div key={key} style={{ display: "contents" }}>
|
||||
<dt
|
||||
style={{
|
||||
padding: "9px 11px",
|
||||
borderBottom: "1px solid var(--hair)",
|
||||
background: "var(--bg-surface-2)",
|
||||
color: "var(--text-muted)",
|
||||
fontWeight: 700,
|
||||
}}
|
||||
>
|
||||
{key}
|
||||
</dt>
|
||||
<dd
|
||||
style={{
|
||||
minWidth: 0,
|
||||
margin: 0,
|
||||
padding: "9px 11px",
|
||||
borderBottom: "1px solid var(--hair)",
|
||||
color: "var(--text-body)",
|
||||
overflowWrap: "anywhere",
|
||||
whiteSpace: "pre-wrap",
|
||||
}}
|
||||
>
|
||||
{value}
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
class RouteErrorBoundary extends Component<
|
||||
{ resetKey: string; children: ReactNode },
|
||||
{ error: Error | null; errorInfo: ErrorInfo | null }
|
||||
> {
|
||||
state = { error: null, errorInfo: null };
|
||||
|
||||
static getDerivedStateFromError(error: Error) {
|
||||
return { error, errorInfo: null };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
||||
this.setState({ errorInfo });
|
||||
console.error("[route-render-error]", error, errorInfo.componentStack);
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps: { resetKey: string; children: ReactNode }) {
|
||||
if (prevProps.resetKey !== this.props.resetKey && this.state.error) {
|
||||
this.setState({ error: null, errorInfo: null });
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.error) {
|
||||
return <RouteErrorFallback error={this.state.error} errorInfo={this.state.errorInfo} />;
|
||||
}
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
function AppRoutesWithBoundary() {
|
||||
const { pathname } = useLocation();
|
||||
return (
|
||||
<RouteErrorBoundary resetKey={pathname}>
|
||||
<AppRoutes />
|
||||
</RouteErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
function AppRoutes() {
|
||||
return (
|
||||
<PendingApprovalGate>
|
||||
|
|
@ -311,7 +455,7 @@ export default function App() {
|
|||
<BrowserRouter>
|
||||
<AuthProvider>
|
||||
<ScrollToTopOnPathChange />
|
||||
<AppRoutes />
|
||||
<AppRoutesWithBoundary />
|
||||
</AuthProvider>
|
||||
</BrowserRouter>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
]]
|
||||
: []),
|
||||
]}
|
||||
/>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue