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

View File

@ -105,6 +105,25 @@ button:disabled {
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 {
color: #a8b0c1;
}