Enhance server check functionality and UI updates. Added new state management for server checks, including recovery mode and transition scheduling. Improved error handling and introduced new styles for server check notifications and tool indicators.

This commit is contained in:
이정수 2026-01-30 10:24:26 +09:00
parent 4b28133a1a
commit dc13526360
2 changed files with 274 additions and 69 deletions

View File

@ -27,6 +27,8 @@ type GitPathsCheck = {
const HEALTHCHECK_INTERVAL_MS = APP_CONFIG.healthcheckIntervalMs; const HEALTHCHECK_INTERVAL_MS = APP_CONFIG.healthcheckIntervalMs;
const HEALTHCHECK_ENDPOINT_FALLBACK = SERVER_HEALTHCHECK_URL; const HEALTHCHECK_ENDPOINT_FALLBACK = SERVER_HEALTHCHECK_URL;
const MIN_SERVER_CHECK_SCREEN_MS = 1500;
const IS_DEV = import.meta.env.DEV;
const App = () => { const App = () => {
const [screen, setScreen] = useState<Screen>("serverCheck"); const [screen, setScreen] = useState<Screen>("serverCheck");
@ -37,6 +39,10 @@ const App = () => {
const [serverCheckedUrl, setServerCheckedUrl] = useState<string | undefined>(); const [serverCheckedUrl, setServerCheckedUrl] = useState<string | undefined>();
const [serverCheckInProgress, setServerCheckInProgress] = useState(false); const [serverCheckInProgress, setServerCheckInProgress] = useState(false);
const [serverCheckedAt, setServerCheckedAt] = useState<number | undefined>(); const [serverCheckedAt, setServerCheckedAt] = useState<number | undefined>();
const [serverCheckStartedAt, setServerCheckStartedAt] = useState(Date.now());
const [serverRecoveryActive, setServerRecoveryActive] = useState(false);
const [skipToolsCheck, setSkipToolsCheck] = useState(false);
const [devProceedReady, setDevProceedReady] = useState(false);
const [gitCheckInProgress, setGitCheckInProgress] = useState(false); const [gitCheckInProgress, setGitCheckInProgress] = useState(false);
const [gitCheckedAt, setGitCheckedAt] = useState<number | undefined>(); const [gitCheckedAt, setGitCheckedAt] = useState<number | undefined>();
const [gitCheckResult, setGitCheckResult] = useState<GitToolsCheck | undefined>(); const [gitCheckResult, setGitCheckResult] = useState<GitToolsCheck | undefined>();
@ -62,6 +68,7 @@ const App = () => {
const healthCheckInFlightRef = useRef(false); const healthCheckInFlightRef = useRef(false);
const gitCheckInFlightRef = useRef(false); const gitCheckInFlightRef = useRef(false);
const gitCheckedRef = useRef(false); const gitCheckedRef = useRef(false);
const transitionTimeoutRef = useRef<number | undefined>();
const runGitCheck = useCallback(async (force = false) => { const runGitCheck = useCallback(async (force = false) => {
if (force) { if (force) {
@ -69,7 +76,7 @@ const App = () => {
} }
if (gitCheckInFlightRef.current || (!force && gitCheckedRef.current)) { if (gitCheckInFlightRef.current || (!force && gitCheckedRef.current)) {
return; return gitCheckResult;
} }
gitCheckInFlightRef.current = true; gitCheckInFlightRef.current = true;
@ -84,78 +91,193 @@ const App = () => {
setGitCheckResult(result); setGitCheckResult(result);
setGitCheckedAt(result.checkedAt ?? Date.now()); setGitCheckedAt(result.checkedAt ?? Date.now());
gitCheckedRef.current = true; gitCheckedRef.current = true;
return result;
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : String(error); const message = error instanceof Error ? error.message : String(error);
setGitCheckResult({ const fallback = {
git: { ok: false, command: "git", error: message }, git: { ok: false, command: "git", error: message },
lfs: { ok: false, command: "git lfs", error: message }, lfs: { ok: false, command: "git lfs", error: message },
checkedAt: Date.now() checkedAt: Date.now()
}); };
setGitCheckResult(fallback);
setGitCheckedAt(Date.now()); setGitCheckedAt(Date.now());
gitCheckedRef.current = true; gitCheckedRef.current = true;
return fallback;
} finally { } finally {
gitCheckInFlightRef.current = false; gitCheckInFlightRef.current = false;
setGitCheckInProgress(false); setGitCheckInProgress(false);
} }
}, [gitCheckResult]);
const clearTransitionTimeout = useCallback(() => {
if (transitionTimeoutRef.current) {
window.clearTimeout(transitionTimeoutRef.current);
transitionTimeoutRef.current = undefined;
}
}, []); }, []);
const runHealthCheck = useCallback(async () => { const scheduleScreenTransition = useCallback(
if (healthCheckInFlightRef.current) { (nextScreen: Screen) => {
return; clearTransitionTimeout();
} const elapsedMs = Date.now() - serverCheckStartedAt;
const remainingMs = Math.max(0, MIN_SERVER_CHECK_SCREEN_MS - elapsedMs);
healthCheckInFlightRef.current = true; if (remainingMs > 0) {
setServerCheckInProgress(true); transitionTimeoutRef.current = window.setTimeout(() => {
setServerError(undefined); setScreen(nextScreen);
}, remainingMs);
try { return;
if (!window.sptLauncher?.checkServerHealth) {
throw new Error("preload(preload) 미로딩: window.sptLauncher가 없습니다.");
} }
const result = await window.sptLauncher.checkServerHealth(); setScreen(nextScreen);
setServerHealthy(result.ok); },
setServerStatusCode(result.status); [clearTransitionTimeout, serverCheckStartedAt]
setServerLatencyMs(result.latencyMs); );
setServerError(result.error);
setServerCheckedUrl(result.url ?? HEALTHCHECK_ENDPOINT_FALLBACK);
setServerCheckedAt(Date.now());
if (screen === "serverCheck" && result.ok) { const enterRecoveryMode = useCallback(
if (!gitCheckedRef.current) { (reason: string) => {
await runGitCheck(); setServerRecoveryActive(true);
}
setScreen(hasSession ? "main" : "login");
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
setServerHealthy(false); setServerHealthy(false);
setServerStatusCode(undefined); setServerStatusCode(undefined);
setServerLatencyMs(undefined); setServerLatencyMs(undefined);
setServerError(message); setServerError(reason);
setServerCheckedUrl(undefined); setServerCheckedUrl(undefined);
setServerCheckedAt(Date.now()); setServerCheckedAt(Date.now());
} finally { setHasSession(false);
healthCheckInFlightRef.current = false; setProfile(undefined);
setServerCheckInProgress(false); setSkipToolsCheck(false);
setDevProceedReady(false);
setScreen("serverCheck");
clearTransitionTimeout();
},
[clearTransitionTimeout]
);
const isTimeoutError = (error?: string) => {
if (!error) {
return false;
} }
}, [screen, hasSession]); return error.toLowerCase().includes("timeout");
};
const runHealthCheck = useCallback(
async (trigger: "initial" | "manual" | "recovery" = "initial") => {
if (healthCheckInFlightRef.current) {
return;
}
healthCheckInFlightRef.current = true;
setServerCheckInProgress(true);
setServerError(undefined);
let isHealthy = false;
let failureReason = "healthcheck_failed";
try {
if (!window.sptLauncher?.checkServerHealth) {
throw new Error("preload(preload) 미로딩: window.sptLauncher가 없습니다.");
}
const result = await window.sptLauncher.checkServerHealth();
isHealthy = result.ok;
if (!result.ok) {
failureReason = result.error ?? "healthcheck_failed";
}
setServerHealthy(result.ok);
setServerStatusCode(result.status);
setServerLatencyMs(result.latencyMs);
setServerError(result.error);
setServerCheckedUrl(result.url ?? HEALTHCHECK_ENDPOINT_FALLBACK);
setServerCheckedAt(Date.now());
if (screen === "serverCheck" && result.ok) {
const gitResult = !gitCheckedRef.current ? await runGitCheck() : gitCheckResult;
const toolsReady = Boolean(gitResult?.git.ok && gitResult?.lfs.ok);
if (serverRecoveryActive) {
setServerRecoveryActive(false);
if (IS_DEV) {
setDevProceedReady(true);
} else {
scheduleScreenTransition("login");
}
return;
}
if (!toolsReady && gitResult && !skipToolsCheck) {
return;
}
if (IS_DEV) {
setDevProceedReady(true);
return;
}
scheduleScreenTransition(hasSession ? "main" : "login");
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
failureReason = message;
setServerHealthy(false);
setServerStatusCode(undefined);
setServerLatencyMs(undefined);
setServerError(message);
setServerCheckedUrl(undefined);
setServerCheckedAt(Date.now());
} finally {
healthCheckInFlightRef.current = false;
setServerCheckInProgress(false);
}
if (trigger === "manual" && !isHealthy) {
enterRecoveryMode(failureReason);
}
},
[
screen,
hasSession,
serverRecoveryActive,
runGitCheck,
gitCheckResult,
scheduleScreenTransition,
enterRecoveryMode,
skipToolsCheck
]
);
const handleManualHealthCheck = () => {
void runHealthCheck("manual");
};
useEffect(() => { useEffect(() => {
if (screen !== "serverCheck") { if (screen !== "serverCheck") {
return; return;
} }
void runHealthCheck(); void runHealthCheck(serverRecoveryActive ? "recovery" : "initial");
}, [screen, runHealthCheck]); }, [screen, serverRecoveryActive, runHealthCheck]);
useEffect(() => { useEffect(() => {
if (screen !== "serverCheck") {
return;
}
setServerCheckStartedAt(Date.now());
setDevProceedReady(false);
}, [screen]);
useEffect(() => {
if (!serverRecoveryActive) {
return;
}
const interval = window.setInterval(() => { const interval = window.setInterval(() => {
void runHealthCheck(); void runHealthCheck("recovery");
}, HEALTHCHECK_INTERVAL_MS); }, HEALTHCHECK_INTERVAL_MS);
return () => window.clearInterval(interval); return () => window.clearInterval(interval);
}, [runHealthCheck]); }, [serverRecoveryActive, runHealthCheck]);
useEffect(() => {
return () => clearTransitionTimeout();
}, [clearTransitionTimeout]);
const serverStatusLabel = useMemo(() => { const serverStatusLabel = useMemo(() => {
return serverHealthy ? "정상" : "불가"; return serverHealthy ? "정상" : "불가";
@ -311,6 +433,10 @@ const App = () => {
username: trimmedId username: trimmedId
}); });
if (!result.ok) { if (!result.ok) {
if (isTimeoutError(result.error)) {
enterRecoveryMode("timeout");
return;
}
window.alert(result.error ?? "프로필 조회에 실패했습니다."); window.alert(result.error ?? "프로필 조회에 실패했습니다.");
return; return;
} }
@ -325,6 +451,10 @@ const App = () => {
setScreen("main"); setScreen("main");
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : String(error); const message = error instanceof Error ? error.message : String(error);
if (isTimeoutError(message)) {
enterRecoveryMode("timeout");
return;
}
window.alert(message); window.alert(message);
} finally { } finally {
setProfileLoading(false); setProfileLoading(false);
@ -363,6 +493,10 @@ const App = () => {
try { try {
const result = await window.sptLauncher.downloadProfile({ username }); const result = await window.sptLauncher.downloadProfile({ username });
if (!result.ok) { if (!result.ok) {
if (isTimeoutError(result.error)) {
enterRecoveryMode("timeout");
return;
}
window.alert(result.error ?? "프로필 다운로드에 실패했습니다."); window.alert(result.error ?? "프로필 다운로드에 실패했습니다.");
return; return;
} }
@ -370,6 +504,10 @@ const App = () => {
window.alert(`프로필을 다운로드했습니다: ${result.filePath ?? "-"}`); window.alert(`프로필을 다운로드했습니다: ${result.filePath ?? "-"}`);
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : String(error); const message = error instanceof Error ? error.message : String(error);
if (isTimeoutError(message)) {
enterRecoveryMode("timeout");
return;
}
window.alert(message); window.alert(message);
} finally { } finally {
setProfileActionInProgress(false); setProfileActionInProgress(false);
@ -397,6 +535,10 @@ const App = () => {
try { try {
const result = await window.sptLauncher.resetProfile({ username }); const result = await window.sptLauncher.resetProfile({ username });
if (!result.ok) { if (!result.ok) {
if (isTimeoutError(result.error)) {
enterRecoveryMode("timeout");
return;
}
window.alert(result.error ?? "프로필 리셋에 실패했습니다."); window.alert(result.error ?? "프로필 리셋에 실패했습니다.");
return; return;
} }
@ -404,6 +546,10 @@ const App = () => {
window.alert("프로필 리셋 요청이 완료되었습니다."); window.alert("프로필 리셋 요청이 완료되었습니다.");
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : String(error); const message = error instanceof Error ? error.message : String(error);
if (isTimeoutError(message)) {
enterRecoveryMode("timeout");
return;
}
window.alert(message); window.alert(message);
} finally { } finally {
setProfileActionInProgress(false); setProfileActionInProgress(false);
@ -442,43 +588,73 @@ const App = () => {
</header> </header>
{screen === "serverCheck" && ( {screen === "serverCheck" && (
<section className="card hero"> <section className="card hero server-check">
<h2> </h2> <h2> </h2>
<p className={`status ${serverCheckInProgress ? "idle" : serverHealthy ? "ok" : "blocked"}`}> <p className={`status ${serverCheckInProgress ? "idle" : serverHealthy ? "ok" : "blocked"}`}>
{serverCheckInProgress ? "체크 중..." : serverHealthy ? "정상" : "불가"} {serverCheckInProgress ? "체크 중..." : serverHealthy ? "정상" : "불가"}
</p> </p>
<p className="helper"> <div className="notice">
{serverHealthy <div className="tool-led">
? "서버 상태 확인 완료. 로그인 화면으로 이동합니다." <span className="tool-label"></span>
: "서버 연결이 불가합니다. 10초마다 자동으로 재시도합니다."} <span className={`led ${serverStatusLedClass}`} aria-label="서버 상태" />
</p> {serverRecoveryActive && <span className="helper"> </span>}
<p className="notice"> </div>
{serverCheckedUrl ? `엔드포인트: ${serverCheckedUrl}` : "엔드포인트: "} <div className="tool-led">
{serverStatusCode ? ` · HTTP ${serverStatusCode}` : ""} <span className="tool-label">Git</span>
{typeof serverLatencyMs === "number" ? ` · ${serverLatencyMs}ms` : ""} <span className={`led ${gitLedClass}`} aria-label="Git 상태" />
{serverError ? ` · ${serverError}` : ""} </div>
{serverCheckedAt ? ` · ${new Date(serverCheckedAt).toLocaleTimeString()}` : ""} <div className="tool-led">
</p> <span className="tool-label">Git LFS</span>
<span className={`led ${lfsLedClass}`} aria-label="Git LFS 상태" />
</div>
</div>
{!serverCheckInProgress && !serverHealthy && ( {!serverCheckInProgress && !serverHealthy && (
<> <button type="button" onClick={handleManualHealthCheck}>
<button type="button" onClick={() => void runHealthCheck()}>
</button>
</button>
</>
)} )}
{serverHealthy && ( {serverHealthy && gitCheckResult && !(gitCheckResult.git.ok && gitCheckResult.lfs.ok) && (
<div className="notice"> <div className="notice">
<div className="tool-led"> <strong> </strong>
<span className="tool-label">Git</span> <div className="helper">
<span className={`led ${gitLedClass}`} aria-label="Git 상태" /> Git/Git LFS . (Windows) .
</div> </div>
<div className="tool-led"> <div className="modal-actions">
<span className="tool-label">Git LFS</span> <button type="button" onClick={handleInstallTools} disabled={installInProgress}>
<span className={`led ${lfsLedClass}`} aria-label="Git LFS 상태" /> {installInProgress ? "설치 중..." : "자동 설치"}
</button>
<button type="button" className="ghost" onClick={handleRecheckTools} disabled={gitCheckInProgress}>
{gitCheckInProgress ? "점검 중..." : "재확인"}
</button>
<button
type="button"
className="ghost"
onClick={() => {
setSkipToolsCheck(true);
scheduleScreenTransition(IS_DEV ? "login" : hasSession ? "main" : "login");
}}
>
</button>
</div> </div>
{gitCheckedAt ? <div className="helper">{new Date(gitCheckedAt).toLocaleTimeString()}</div> : null}
</div> </div>
)} )}
{IS_DEV &&
serverHealthy &&
(skipToolsCheck ||
Boolean(gitCheckResult?.git.ok && gitCheckResult?.lfs.ok)) && (
<button
type="button"
className="ghost"
onClick={() => {
setDevProceedReady(false);
scheduleScreenTransition("login");
}}
disabled={!devProceedReady}
>
</button>
)}
</section> </section>
)} )}
@ -641,7 +817,9 @@ const App = () => {
{screen !== "serverCheck" && ( {screen !== "serverCheck" && (
<nav className="status-bar"> <nav className="status-bar">
<div className="status-item"> <div className="status-item">
<span className="label"> </span> <button type="button" className="status-button" onClick={handleManualHealthCheck}>
</button>
<div className="status-item-right"> <div className="status-item-right">
<span className={`led ${serverStatusLedClass}`} aria-label="서버 상태" /> <span className={`led ${serverStatusLedClass}`} aria-label="서버 상태" />
</div> </div>
@ -691,13 +869,21 @@ const App = () => {
<button type="button" onClick={handleRecheckTools} disabled={gitCheckInProgress}> <button type="button" onClick={handleRecheckTools} disabled={gitCheckInProgress}>
{gitCheckInProgress ? "점검 중..." : "재확인"} {gitCheckInProgress ? "점검 중..." : "재확인"}
</button> </button>
<button type="button" className="ghost" onClick={handleInstallTools} disabled={installInProgress}> <button type="button" className="ghost" onClick={handleInstallTools} disabled={installInProgress}>
{installInProgress ? "설치 중..." : "자동 설치"} {installInProgress ? "설치 중..." : "자동 설치"}
</button> </button>
<button type="button" className="ghost" onClick={handleFetchGitPaths} disabled={gitPathsLoading}> <button type="button" className="ghost" onClick={handleFetchGitPaths} disabled={gitPathsLoading}>
{gitPathsLoading ? "확인 중..." : "경로 확인"} {gitPathsLoading ? "확인 중..." : "경로 확인"}
</button> </button>
</div> </div>
{gitCheckResult && !(gitCheckResult.git.ok && gitCheckResult.lfs.ok) && (
<div className="notice">
<strong> </strong>
<div className="modal-help">
Git/Git LFS가 . (Windows) .
</div>
</div>
)}
{installMessage && <div className="notice">{installMessage}</div>} {installMessage && <div className="notice">{installMessage}</div>}
{gitPathsResult && ( {gitPathsResult && (
<div className="notice"> <div className="notice">

View File

@ -105,6 +105,25 @@ button:disabled {
margin: 0 auto; margin: 0 auto;
} }
.server-check .notice {
display: grid;
gap: 10px;
line-height: 1.1;
}
.server-check .tool-led {
gap: 10px;
}
.server-check .tool-label {
font-size: 16px;
}
.server-check .led {
width: 12px;
height: 12px;
}
.helper { .helper {
color: #a8b0c1; color: #a8b0c1;
} }