mirror of
https://github.com/sipeed/picoclaw.git
synced 2026-06-12 18:08:54 +00:00
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:
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -63,7 +63,8 @@
|
||||
},
|
||||
"status": {
|
||||
"starting": "服务启动中...",
|
||||
"restarting": "服务重启中..."
|
||||
"restarting": "服务重启中...",
|
||||
"stopping": "服务停止中..."
|
||||
},
|
||||
"restartRequired": "切换默认模型后需要重启服务才能生效。"
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user