fix(login): frontend web auth error handle

This commit is contained in:
sky5454 2026-04-07 22:34:28 +08:00
parent 0809b4d416
commit 140acd5852
4 changed files with 71 additions and 34 deletions

View file

@ -49,10 +49,12 @@ export function AppHeader() {
state: gwState, state: gwState,
loading: gwLoading, loading: gwLoading,
canStart, canStart,
startReason,
restartRequired, restartRequired,
start, start,
restart, restart,
stop, stop,
error: gwError,
} = useGateway() } = useGateway()
const isRunning = gwState === "running" const isRunning = gwState === "running"
@ -196,37 +198,50 @@ export function AppHeader() {
<IconPower className="h-4 w-4 opacity-80" /> <IconPower className="h-4 w-4 opacity-80" />
</Button> </Button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent>{t("header.gateway.action.stop")}</TooltipContent> <TooltipContent>{gwError ?? t("header.gateway.action.stop")}</TooltipContent>
</Tooltip> </Tooltip>
) : ( ) : (
<Button <Tooltip delayDuration={(gwError || (!canStart && startReason)) ? 0 : 700}>
variant={ <TooltipTrigger asChild>
isStarting || isRestarting || isStopping ? "secondary" : "default" {/* Wrap in span so the tooltip still fires when the button is disabled */}
} <span
size="sm" className={!canStart && startReason ? "cursor-not-allowed" : undefined}
data-tour="gateway-button" tabIndex={!canStart && startReason ? 0 : undefined}
className={`h-8 gap-2 px-3 ${isStopped ? "bg-green-500 text-white hover:bg-green-600" : "" >
}`} <Button
onClick={handleGatewayToggle} variant={
disabled={ isStarting || isRestarting || isStopping ? "secondary" : "default"
gwLoading || isStarting || isRestarting || isStopping || !canStart }
} size="sm"
> data-tour="gateway-button"
{gwLoading || isStarting || isRestarting || isStopping ? ( className={`h-8 gap-2 px-3 ${isStopped ? "bg-green-500 text-white hover:bg-green-600" : ""
<IconLoader2 className="h-4 w-4 animate-spin opacity-70" /> } ${!canStart ? "pointer-events-none" : ""}`}
) : ( onClick={handleGatewayToggle}
<IconPlayerPlay className="h-4 w-4 opacity-80" /> disabled={
)} gwLoading || isStarting || isRestarting || isStopping || !canStart
<span className="text-xs font-semibold"> }
{isStopping >
? t("header.gateway.status.stopping") {gwLoading || isStarting || isRestarting || isStopping ? (
: isRestarting <IconLoader2 className="h-4 w-4 animate-spin opacity-70" />
? t("header.gateway.status.restarting") ) : (
: isStarting <IconPlayerPlay className="h-4 w-4 opacity-80" />
? t("header.gateway.status.starting") )}
: t("header.gateway.action.start")} <span className="text-xs font-semibold">
</span> {isStopping
</Button> ? t("header.gateway.status.stopping")
: isRestarting
? t("header.gateway.status.restarting")
: isStarting
? t("header.gateway.status.starting")
: t("header.gateway.action.start")}
</span>
</Button>
</span>
</TooltipTrigger>
{(gwError || (!canStart && startReason)) ? (
<TooltipContent>{gwError ?? startReason}</TooltipContent>
) : null}
</Tooltip>
)} )}
<Separator <Separator

View file

@ -13,8 +13,9 @@ import {
export function useGateway() { export function useGateway() {
const gateway = useAtomValue(gatewayAtom) const gateway = useAtomValue(gatewayAtom)
const { status: state, canStart, restartRequired } = gateway const { status: state, canStart, startReason, restartRequired } = gateway
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
useEffect(() => { useEffect(() => {
return subscribeGatewayPolling() return subscribeGatewayPolling()
@ -23,6 +24,7 @@ export function useGateway() {
const start = useCallback(async () => { const start = useCallback(async () => {
if (!canStart) return if (!canStart) return
setError(null)
setLoading(true) setLoading(true)
try { try {
await startGateway() await startGateway()
@ -32,6 +34,7 @@ export function useGateway() {
}) })
} catch (err) { } catch (err) {
console.error("Failed to start gateway:", err) console.error("Failed to start gateway:", err)
setError(err instanceof Error ? err.message : String(err))
} finally { } finally {
await refreshGatewayState({ force: true }) await refreshGatewayState({ force: true })
setLoading(false) setLoading(false)
@ -39,12 +42,14 @@ export function useGateway() {
}, [canStart]) }, [canStart])
const stop = useCallback(async () => { const stop = useCallback(async () => {
setError(null)
setLoading(true) setLoading(true)
beginGatewayStoppingTransition() beginGatewayStoppingTransition()
try { try {
await stopGateway() await stopGateway()
} catch (err) { } catch (err) {
console.error("Failed to stop gateway:", err) console.error("Failed to stop gateway:", err)
setError(err instanceof Error ? err.message : String(err))
cancelGatewayStoppingTransition() cancelGatewayStoppingTransition()
} finally { } finally {
await refreshGatewayState({ force: true }) await refreshGatewayState({ force: true })
@ -55,6 +60,7 @@ export function useGateway() {
const restart = useCallback(async () => { const restart = useCallback(async () => {
if (state !== "running") return if (state !== "running") return
setError(null)
setLoading(true) setLoading(true)
try { try {
await restartGateway() await restartGateway()
@ -64,11 +70,12 @@ export function useGateway() {
}) })
} catch (err) { } catch (err) {
console.error("Failed to restart gateway:", err) console.error("Failed to restart gateway:", err)
setError(err instanceof Error ? err.message : String(err))
} finally { } finally {
await refreshGatewayState({ force: true }) await refreshGatewayState({ force: true })
setLoading(false) setLoading(false)
} }
}, [state]) }, [state])
return { state, loading, canStart, restartRequired, start, stop, restart } return { state, loading, canStart, startReason, restartRequired, start, stop, restart, error }
} }

View file

@ -42,8 +42,14 @@ const RootLayout = () => {
globalThis.location.assign("/launcher-login") globalThis.location.assign("/launcher-login")
} }
}) })
.catch(() => { .catch((err: unknown) => {
// Network error or 401 — launcherFetch will handle redirect on real API calls. // For real HTTP errors (e.g. 503 when the auth store is unavailable),
// redirect to login so the user can re-authenticate rather than being
// silently stranded on the dashboard. Network failures (no response)
// are left alone — launcherFetch handles the 401 redirect on real calls.
if (err instanceof Error && /^status \d+$/.test(err.message)) {
globalThis.location.assign("/launcher-login")
}
}) })
}, [isAuthPage]) }, [isAuthPage])

View file

@ -14,6 +14,7 @@ export type GatewayState =
export interface GatewayStoreState { export interface GatewayStoreState {
status: GatewayState status: GatewayState
canStart: boolean canStart: boolean
startReason?: string
restartRequired: boolean restartRequired: boolean
} }
@ -57,6 +58,7 @@ function normalizeGatewayStoreState(
if ( if (
next.status === prev.status && next.status === prev.status &&
next.canStart === prev.canStart && next.canStart === prev.canStart &&
next.startReason === prev.startReason &&
next.restartRequired === prev.restartRequired next.restartRequired === prev.restartRequired
) { ) {
return prev return prev
@ -108,7 +110,10 @@ export function applyGatewayStatusToStore(
data: Partial< data: Partial<
Pick< Pick<
GatewayStatusResponse, GatewayStatusResponse,
"gateway_status" | "gateway_start_allowed" | "gateway_restart_required" | "gateway_status"
| "gateway_start_allowed"
| "gateway_start_reason"
| "gateway_restart_required"
> >
>, >,
) { ) {
@ -121,6 +126,10 @@ export function applyGatewayStatusToStore(
prev.status === "stopping" && data.gateway_status === "running" prev.status === "stopping" && data.gateway_status === "running"
? false ? false
: (data.gateway_start_allowed ?? prev.canStart), : (data.gateway_start_allowed ?? prev.canStart),
startReason:
prev.status === "stopping" && data.gateway_status === "running"
? prev.startReason
: (data.gateway_start_reason ?? prev.startReason),
restartRequired: restartRequired:
prev.status === "stopping" && data.gateway_status === "running" prev.status === "stopping" && data.gateway_status === "running"
? false ? false