feat: add web gateway hot reload and polling state sync (#1684)

* feat(gateway): support hot reload and empty startup

- extract gateway runtime into pkg/gateway
- add gateway.hot_reload config with default and example values
- allow starting the gateway without a default model via --allow-empty
- stop treating missing enabled channels as a startup error
- update related tests

* feat: replace gateway SSE updates with polling-based state sync

- remove gateway SSE broadcasting and event endpoint
- add polling-based gateway status refresh with stopping state handling
- detect when gateway restart is required after default model changes
- resolve gateway health and websocket proxy targets from configured host
- update gateway UI labels and add backend/frontend test coverage
This commit is contained in:
wenjie
2026-03-17 18:46:00 +08:00
committed by GitHub
parent 11207186c8
commit 8a44410e37
24 changed files with 700 additions and 543 deletions
+23 -11
View File
@@ -56,14 +56,20 @@ export function AppHeader() {
const isRunning = gwState === "running"
const isStarting = gwState === "starting"
const isRestarting = gwState === "restarting"
const isStopping = gwState === "stopping"
const isStopped = gwState === "stopped" || gwState === "unknown"
const showNotConnectedHint =
!isRestarting && canStart && (gwState === "stopped" || gwState === "error")
!isRestarting &&
!isStopping &&
canStart &&
(gwState === "stopped" || gwState === "error")
const [showStopDialog, setShowStopDialog] = React.useState(false)
const handleGatewayToggle = () => {
if (gwLoading || isRestarting || (!isRunning && !canStart)) return
if (gwLoading || isRestarting || isStopping || (!isRunning && !canStart)) {
return
}
if (isRunning) {
setShowStopDialog(true)
} else {
@@ -137,7 +143,7 @@ export function AppHeader() {
size="icon-sm"
className="bg-amber-500/15 text-amber-700 hover:bg-amber-500/25 hover:text-amber-800 dark:text-amber-300 dark:hover:bg-amber-500/25"
onClick={handleGatewayRestart}
disabled={gwLoading || isRestarting || !canStart}
disabled={gwLoading || isRestarting || isStopping || !canStart}
aria-label={t("header.gateway.action.restart")}
>
<IconRefresh className="size-4" />
@@ -168,25 +174,31 @@ export function AppHeader() {
</Tooltip>
) : (
<Button
variant={isStarting || isRestarting ? "secondary" : "default"}
variant={
isStarting || isRestarting || isStopping ? "secondary" : "default"
}
size="sm"
className={`h-8 gap-2 px-3 ${
isStopped ? "bg-green-500 text-white hover:bg-green-600" : ""
}`}
onClick={handleGatewayToggle}
disabled={gwLoading || isStarting || isRestarting || !canStart}
disabled={
gwLoading || isStarting || isRestarting || isStopping || !canStart
}
>
{gwLoading || isStarting || isRestarting ? (
{gwLoading || isStarting || isRestarting || isStopping ? (
<IconLoader2 className="h-4 w-4 animate-spin opacity-70" />
) : (
<IconPlayerPlay className="h-4 w-4 opacity-80" />
)}
<span className="text-xs font-semibold">
{isRestarting
? t("header.gateway.status.restarting")
: isStarting
? t("header.gateway.status.starting")
: t("header.gateway.action.start")}
{isStopping
? t("header.gateway.status.stopping")
: isRestarting
? t("header.gateway.status.restarting")
: isStarting
? t("header.gateway.status.starting")
: t("header.gateway.action.start")}
</span>
</Button>
)}
+3 -1
View File
@@ -37,7 +37,9 @@ export function useGatewayLogs() {
const fetchLogs = async () => {
if (
!mounted ||
!["running", "starting", "restarting"].includes(gateway.status)
!["running", "starting", "restarting", "stopping"].includes(
gateway.status,
)
) {
if (mounted) {
timeout = setTimeout(fetchLogs, 1000)
+30 -108
View File
@@ -1,83 +1,24 @@
import { useAtomValue } from "jotai"
import { useCallback, useEffect, useState } from "react"
import { restartGateway, startGateway, stopGateway } from "@/api/gateway"
import {
type GatewayStatusResponse,
getGatewayStatus,
restartGateway,
startGateway,
stopGateway,
} from "@/api/gateway"
import {
applyGatewayStatusToStore,
beginGatewayStoppingTransition,
cancelGatewayStoppingTransition,
gatewayAtom,
refreshGatewayState,
subscribeGatewayPolling,
updateGatewayStore,
} from "@/store"
// Global variable to ensure we only have one SSE connection
let sseInitialized = false
export function useGateway() {
const gateway = useAtomValue(gatewayAtom)
const { status: state, canStart, restartRequired } = gateway
const [loading, setLoading] = useState(false)
const applyGatewayStatus = useCallback((data: GatewayStatusResponse) => {
applyGatewayStatusToStore(data)
}, [])
// Initialize global SSE connection once
useEffect(() => {
if (sseInitialized) return
sseInitialized = true
getGatewayStatus()
.then((data) => applyGatewayStatus(data))
.catch(() => {
updateGatewayStore({
status: "unknown",
canStart: true,
restartRequired: false,
})
})
const statusPoll = window.setInterval(() => {
getGatewayStatus()
.then((data) => applyGatewayStatus(data))
.catch(() => {
// ignore polling errors
})
}, 5000)
// Subscribe to SSE for real-time updates globally
const es = new EventSource("/api/gateway/events")
es.onmessage = (event) => {
try {
const data = JSON.parse(event.data)
if (
data.gateway_status ||
typeof data.gateway_start_allowed === "boolean"
) {
applyGatewayStatus(data)
}
} catch {
// ignore
}
}
es.onerror = () => {
// EventSource will auto-reconnect. Preserve the last known gateway
// status so transient SSE disconnects do not suppress chat websocket
// reconnects while polling catches up.
}
return () => {
window.clearInterval(statusPoll)
es.close()
sseInitialized = false
}
}, [applyGatewayStatus])
return subscribeGatewayPolling()
}, [])
const start = useCallback(async () => {
if (!canStart) return
@@ -85,33 +26,28 @@ export function useGateway() {
setLoading(true)
try {
await startGateway()
// SSE will push the real state changes, but set optimistic state
updateGatewayStore({ status: "starting" })
} catch (err) {
console.error("Failed to start gateway:", err)
try {
const status = await getGatewayStatus()
applyGatewayStatus(status)
} catch {
updateGatewayStore({ status: "unknown" })
}
} finally {
setLoading(false)
}
}, [applyGatewayStatus, canStart])
const stop = useCallback(async () => {
setLoading(true)
try {
await stopGateway()
updateGatewayStore({
status: "stopped",
canStart: true,
status: "starting",
restartRequired: false,
})
} catch (err) {
console.error("Failed to stop gateway:", err)
console.error("Failed to start gateway:", err)
} finally {
await refreshGatewayState({ force: true })
setLoading(false)
}
}, [canStart])
const stop = useCallback(async () => {
setLoading(true)
beginGatewayStoppingTransition()
try {
await stopGateway()
} catch (err) {
console.error("Failed to stop gateway:", err)
cancelGatewayStoppingTransition()
} finally {
await refreshGatewayState({ force: true })
setLoading(false)
}
}, [])
@@ -119,34 +55,20 @@ export function useGateway() {
const restart = useCallback(async () => {
if (state !== "running") return
const previousState = state
const previousCanStart = canStart
const previousRestartRequired = restartRequired
setLoading(true)
updateGatewayStore({
status: "restarting",
restartRequired: false,
})
try {
await restartGateway()
updateGatewayStore({
status: "restarting",
restartRequired: false,
})
} catch (err) {
console.error("Failed to restart gateway:", err)
try {
const status = await getGatewayStatus()
applyGatewayStatus(status)
} catch {
updateGatewayStore({
status: previousState,
canStart: previousCanStart,
restartRequired: previousRestartRequired,
})
}
} finally {
await refreshGatewayState({ force: true })
setLoading(false)
}
}, [applyGatewayStatus, canStart, restartRequired, state])
}, [state])
return { state, loading, canStart, restartRequired, start, stop, restart }
}
+2 -1
View File
@@ -63,7 +63,8 @@
},
"status": {
"starting": "Starting Gateway...",
"restarting": "Restarting Gateway..."
"restarting": "Restarting Gateway...",
"stopping": "Stopping Gateway..."
},
"restartRequired": "Model changes require a gateway restart to take effect."
}
+2 -1
View File
@@ -63,7 +63,8 @@
},
"status": {
"starting": "服务启动中...",
"restarting": "服务重启中..."
"restarting": "服务重启中...",
"stopping": "服务停止中..."
},
"restartRequired": "切换默认模型后需要重启服务才能生效。"
}
+132 -12
View File
@@ -6,6 +6,7 @@ export type GatewayState =
| "running"
| "starting"
| "restarting"
| "stopping"
| "stopped"
| "error"
| "unknown"
@@ -24,9 +25,29 @@ const DEFAULT_GATEWAY_STATE: GatewayStoreState = {
restartRequired: false,
}
const GATEWAY_POLL_INTERVAL_MS = 2000
const GATEWAY_TRANSIENT_POLL_INTERVAL_MS = 1000
const GATEWAY_STOPPING_TIMEOUT_MS = 5000
interface RefreshGatewayStateOptions {
force?: boolean
}
// Global atom for gateway state
export const gatewayAtom = atom<GatewayStoreState>(DEFAULT_GATEWAY_STATE)
let gatewayPollingSubscribers = 0
let gatewayPollingTimer: ReturnType<typeof setTimeout> | null = null
let gatewayPollingRequest: Promise<void> | null = null
let gatewayStoppingTimer: ReturnType<typeof setTimeout> | null = null
function clearGatewayStoppingTimeout() {
if (gatewayStoppingTimer !== null) {
clearTimeout(gatewayStoppingTimer)
gatewayStoppingTimer = null
}
}
function normalizeGatewayStoreState(
prev: GatewayStoreState,
patch: GatewayStorePatch,
@@ -49,10 +70,38 @@ export function updateGatewayStore(
| GatewayStorePatch
| ((prev: GatewayStoreState) => GatewayStorePatch | GatewayStoreState),
) {
getDefaultStore().set(gatewayAtom, (prev) => {
const store = getDefaultStore()
store.set(gatewayAtom, (prev) => {
const nextPatch = typeof patch === "function" ? patch(prev) : patch
return normalizeGatewayStoreState(prev, nextPatch)
})
const nextState = store.get(gatewayAtom)
if (nextState?.status !== "stopping") {
clearGatewayStoppingTimeout()
}
}
export function beginGatewayStoppingTransition() {
clearGatewayStoppingTimeout()
updateGatewayStore({
status: "stopping",
canStart: false,
restartRequired: false,
})
gatewayStoppingTimer = setTimeout(() => {
gatewayStoppingTimer = null
updateGatewayStore((prev) =>
prev.status === "stopping" ? { status: "running" } : prev,
)
void refreshGatewayState({ force: true })
}, GATEWAY_STOPPING_TIMEOUT_MS)
}
export function cancelGatewayStoppingTransition() {
clearGatewayStoppingTimeout()
updateGatewayStore((prev) =>
prev.status === "stopping" ? { status: "running" } : prev,
)
}
export function applyGatewayStatusToStore(
@@ -64,21 +113,92 @@ export function applyGatewayStatusToStore(
>,
) {
updateGatewayStore((prev) => ({
status: data.gateway_status ?? prev.status,
canStart: data.gateway_start_allowed ?? prev.canStart,
restartRequired:
data.gateway_restart_required ??
(data.gateway_status && data.gateway_status !== "running"
status:
prev.status === "stopping" && data.gateway_status === "running"
? "stopping"
: (data.gateway_status ?? prev.status),
canStart:
prev.status === "stopping" && data.gateway_status === "running"
? false
: prev.restartRequired),
: (data.gateway_start_allowed ?? prev.canStart),
restartRequired:
prev.status === "stopping" && data.gateway_status === "running"
? false
: (data.gateway_restart_required ?? prev.restartRequired),
}))
}
export async function refreshGatewayState() {
function nextGatewayPollInterval() {
const status = getDefaultStore().get(gatewayAtom).status
if (
status === "starting" ||
status === "restarting" ||
status === "stopping"
) {
return GATEWAY_TRANSIENT_POLL_INTERVAL_MS
}
return GATEWAY_POLL_INTERVAL_MS
}
function scheduleGatewayPoll(delay = nextGatewayPollInterval()) {
if (gatewayPollingSubscribers === 0) {
return
}
if (gatewayPollingTimer !== null) {
clearTimeout(gatewayPollingTimer)
}
gatewayPollingTimer = setTimeout(() => {
gatewayPollingTimer = null
void refreshGatewayState()
}, delay)
}
export async function refreshGatewayState(
options: RefreshGatewayStateOptions = {},
) {
if (gatewayPollingRequest) {
await gatewayPollingRequest
if (options.force) {
return refreshGatewayState()
}
return
}
gatewayPollingRequest = (async () => {
try {
const status = await getGatewayStatus()
applyGatewayStatusToStore(status)
} catch {
// Preserve the last known state when a poll fails.
} finally {
gatewayPollingRequest = null
scheduleGatewayPoll()
}
})()
try {
const status = await getGatewayStatus()
applyGatewayStatusToStore(status)
} catch {
updateGatewayStore(DEFAULT_GATEWAY_STATE)
await gatewayPollingRequest
} finally {
if (gatewayPollingSubscribers === 0 && gatewayPollingTimer !== null) {
clearTimeout(gatewayPollingTimer)
gatewayPollingTimer = null
}
}
}
export function subscribeGatewayPolling() {
gatewayPollingSubscribers += 1
if (gatewayPollingSubscribers === 1) {
void refreshGatewayState()
}
return () => {
gatewayPollingSubscribers = Math.max(0, gatewayPollingSubscribers - 1)
if (gatewayPollingSubscribers === 0 && gatewayPollingTimer !== null) {
clearTimeout(gatewayPollingTimer)
gatewayPollingTimer = null
}
}
}