From 4ab7bb820d0367fc6cf36d04ac11c475934279e9 Mon Sep 17 00:00:00 2001 From: npc0-hue Date: Mon, 7 Sep 2026 19:49:20 +0800 Subject: [PATCH] Reduce server console polling load --- platform/api/resource_handlers.go | 15 ++++++++++ platform/api/resource_handlers_test.go | 4 +++ platform/api/routes.md | 1 + platform/domain/resources.go | 1 + platform/repo/resources.go | 17 ++++++++++- platform_web/api/client.test.ts | 14 ++++++++- platform_web/api/client.ts | 38 +++++++++++++++++++++---- platform_web/pages/ServerDetailPage.tsx | 15 ++++------ platform_web/pages/ServersPage.tsx | 21 ++++++++------ platform_web/stores/session.ts | 9 ++++++ 10 files changed, 110 insertions(+), 25 deletions(-) diff --git a/platform/api/resource_handlers.go b/platform/api/resource_handlers.go index 50cbc58..fc6402d 100644 --- a/platform/api/resource_handlers.go +++ b/platform/api/resource_handlers.go @@ -2441,6 +2441,7 @@ func (h *coreHandlers) jobs(w http.ResponseWriter, r *http.Request) { ServerInstanceID: r.URL.Query().Get("serverInstanceId"), RunEndpointID: r.URL.Query().Get("runEndpointId"), State: domain.JobState(r.URL.Query().Get("state")), + States: parseJobStates(r.URL.Query().Get("states")), } var jobs []domain.Job var err error @@ -2471,6 +2472,20 @@ func (h *coreHandlers) jobs(w http.ResponseWriter, r *http.Request) { } } +func parseJobStates(raw string) []domain.JobState { + if strings.TrimSpace(raw) == "" { + return nil + } + states := []domain.JobState{} + for _, part := range strings.Split(raw, ",") { + state := domain.JobState(strings.TrimSpace(part)) + if state != "" { + states = append(states, state) + } + } + return states +} + // jobCancel godoc // @Summary Request job cancellation // @Description Records a cancellation request for an accepted or running job; run observes it through the job cancel poll route. diff --git a/platform/api/resource_handlers_test.go b/platform/api/resource_handlers_test.go index 20d094b..e1d8df1 100644 --- a/platform/api/resource_handlers_test.go +++ b/platform/api/resource_handlers_test.go @@ -86,6 +86,10 @@ func TestCoreAPICreateListDetailWorkflows(t *testing.T) { getJSON[dto.JobResponse](t, router, "/api/v1/jobs/job-1") jobs := getJSON[dto.JobListResponse](t, router, "/api/v1/jobs?serverInstanceId=server-1&runEndpointId=run-local&state=queued") assertListCount(t, jobs.Count, 1) + jobs = getJSON[dto.JobListResponse](t, router, "/api/v1/jobs?serverInstanceId=server-1&states=queued,running,failed") + assertListCount(t, jobs.Count, 1) + jobs = getJSON[dto.JobListResponse](t, router, "/api/v1/jobs?serverInstanceId=server-1&states=running,failed") + assertListCount(t, jobs.Count, 0) artifactResponse := postJSON[dto.ArtifactResponse](t, router, "/api/v1/artifacts", dto.ArtifactCreateRequest{ ID: "artifact-1", diff --git a/platform/api/routes.md b/platform/api/routes.md index 86e506e..1a08e04 100644 --- a/platform/api/routes.md +++ b/platform/api/routes.md @@ -38,6 +38,7 @@ Plugin-owned data is an independent, server-scoped plugin store. It is not a pro - `GET /api/v1/metrics/server-instances` - `GET /api/v1/run/endpoints?status=online` - `GET /api/v1/jobs?serverInstanceId=server-1&runEndpointId=run-local&state=queued` +- `GET /api/v1/jobs?serverInstanceId=server-1&states=queued,running,failed` - `GET /api/v1/artifacts?ownerKind=job&ownerId=job-1&state=uploading` - `GET /api/v1/log-streams?serverInstanceId=server-1&streamKey=stdout` diff --git a/platform/domain/resources.go b/platform/domain/resources.go index e815fa2..dec5d25 100644 --- a/platform/domain/resources.go +++ b/platform/domain/resources.go @@ -1596,6 +1596,7 @@ type JobFilter struct { ServerInstanceID string RunEndpointID string State JobState + States []JobState } type ArtifactFilter struct { diff --git a/platform/repo/resources.go b/platform/repo/resources.go index 4bda2fe..777163d 100644 --- a/platform/repo/resources.go +++ b/platform/repo/resources.go @@ -544,7 +544,22 @@ func matchRunEndpoint(endpoint domain.RunEndpoint, filter domain.RunEndpointFilt func matchJob(job domain.Job, filter domain.JobFilter) bool { return (filter.ServerInstanceID == "" || job.ServerInstanceID == filter.ServerInstanceID) && (filter.RunEndpointID == "" || job.RunEndpointID == filter.RunEndpointID) && - (filter.State == "" || job.State == filter.State) + matchJobState(job.State, filter) +} + +func matchJobState(state domain.JobState, filter domain.JobFilter) bool { + if filter.State != "" && state != filter.State { + return false + } + if len(filter.States) == 0 { + return true + } + for _, candidate := range filter.States { + if state == candidate { + return true + } + } + return false } func matchArtifact(artifact domain.Artifact, filter domain.ArtifactFilter) bool { diff --git a/platform_web/api/client.test.ts b/platform_web/api/client.test.ts index d170b92..0db4123 100644 --- a/platform_web/api/client.test.ts +++ b/platform_web/api/client.test.ts @@ -311,12 +311,21 @@ describe("PlatformApiClient AI providers", () => { if (url.endsWith("/api/v1/run/endpoints")) { return jsonResponse({ items: [endpoint], count: 1 }); } + if (url.endsWith("/api/v1/run/endpoints?status=online")) { + return jsonResponse({ items: [endpoint], count: 1 }); + } if (url.endsWith("/api/v1/jobs")) { return jsonResponse({ items: [job], count: 1 }); } if (url.endsWith("/api/v1/jobs?serverInstanceId=server-1")) { return jsonResponse({ items: [job], count: 1 }); } + if (url.endsWith("/api/v1/jobs?states=queued%2Crunning%2Cfailed")) { + return jsonResponse({ items: [job], count: 1 }); + } + if (url.endsWith("/api/v1/jobs?serverInstanceId=server-1&states=queued%2Crunning%2Cfailed")) { + return jsonResponse({ items: [job], count: 1 }); + } if (url.endsWith("/api/v1/artifacts?ownerKind=job&ownerId=job-1&state=available")) { return jsonResponse({ items: [artifact], count: 1 }); } @@ -571,8 +580,11 @@ describe("PlatformApiClient AI providers", () => { await expect(client.uploadServerFile(server.id, { directoryKey: "configs", file: new File(["server.name=Example\n"], "server.properties", { type: "text/plain" }), idempotencyKey: "idem-file-upload" })).resolves.toMatchObject({ inputRef: "artifact://artifact-upload-1", job: { capability: "files.write" } }); await expect(client.prepareServerFileDownload(server.id, { key: "config/server.properties", idempotencyKey: "idem-file-download" })).resolves.toMatchObject({ status: "ready", filename: "server.properties", content: "server.name=Example\n" }); await expect(client.listRunEndpoints()).resolves.toMatchObject({ count: 1 }); + await expect(client.listRunEndpoints({ status: "online" })).resolves.toMatchObject({ count: 1 }); await expect(client.listJobs()).resolves.toMatchObject({ count: 1 }); await expect(client.listJobs(server.id)).resolves.toMatchObject({ count: 1 }); + await expect(client.listJobs(undefined, { states: ["queued", "running", "failed"] })).resolves.toMatchObject({ count: 1 }); + await expect(client.listJobs(server.id, { states: ["queued", "running", "failed"] })).resolves.toMatchObject({ count: 1 }); await expect(client.listArtifacts({ ownerKind: "job", ownerId: job.id, state: "available" })).resolves.toMatchObject({ count: 1, items: [{ id: artifact.id }] }); await expect(client.openArtifactDownload(artifact.id)).resolves.toMatchObject({ downloadUrl: "/api/v1/artifacts/artifact-1/content", rangeSupported: true }); await expect(client.readArtifactContent(artifact.id, 0, 8)).resolves.toMatchObject({ contentLength: 8, contentRange: "bytes 0-7/18", checksum: artifact.checksum }); @@ -613,7 +625,7 @@ describe("PlatformApiClient AI providers", () => { client.invokeAI({ requestId: "ai-1", serverInstanceId: server.id, purpose: "config.suggest", prompt: "Tune PVP safely", currentConfig: "server.name=Example Survival #1\n" }) ).resolves.toMatchObject({ status: "ok", usage: { mocked: true }, configRecommendation: { diffSummary: "review required" } }); - expect(fetchMock).toHaveBeenCalledTimes(43); + expect(fetchMock).toHaveBeenCalledTimes(46); }); it("normalizes server file workspace null arrays from older platform responses", async () => { diff --git a/platform_web/api/client.ts b/platform_web/api/client.ts index d3a8c72..456a187 100644 --- a/platform_web/api/client.ts +++ b/platform_web/api/client.ts @@ -37,6 +37,7 @@ import type { JobCreateRequest, JobListResponse, JobResponse, + JobState, LlmConfigSuggestionRequest, LlmConfigSuggestionResponse, LogStreamCursorRequest, @@ -59,6 +60,7 @@ import type { RunDistributionGenerateRequest, RunDistributionResponse, RunEndpointListResponse, + RunEndpointStatus, RunUpdateJobResponse, RunUpdateJobListResponse, RunUpdateRequest, @@ -142,6 +144,16 @@ export interface PlatformEventStream { close(): void; } +interface RunEndpointListFilter { + status?: RunEndpointStatus; +} + +interface JobListFilter { + runEndpointId?: string; + state?: JobState; + states?: JobState[]; +} + export class PlatformApiClient { constructor(private readonly baseUrl = "/api/v1", private readonly sessionTokenProvider: () => string | null = () => platformApiSessionToken) {} @@ -241,13 +253,12 @@ export class PlatformApiClient { }); } - async listRunEndpoints(): Promise { - return this.request("/run/endpoints"); + async listRunEndpoints(filter: RunEndpointListFilter = {}): Promise { + return this.request(`/run/endpoints${runEndpointQuery(filter)}`); } - async listJobs(serverInstanceId?: string): Promise { - const params = serverInstanceId ? `?serverInstanceId=${encodeURIComponent(serverInstanceId)}` : ""; - return this.request(`/jobs${params}`); + async listJobs(serverInstanceId?: string, filter: JobListFilter = {}): Promise { + return this.request(`/jobs${jobListQuery(serverInstanceId, filter)}`); } async listArtifacts(filter: ArtifactFilterRequest = {}): Promise { @@ -901,6 +912,23 @@ function artifactQuery(filter: ArtifactFilterRequest): string { return query ? `?${query}` : ""; } +function runEndpointQuery(filter: RunEndpointListFilter): string { + const params = new URLSearchParams(); + if (filter.status) params.set("status", filter.status); + const query = params.toString(); + return query ? `?${query}` : ""; +} + +function jobListQuery(serverInstanceId: string | undefined, filter: JobListFilter): string { + const params = new URLSearchParams(); + if (serverInstanceId) params.set("serverInstanceId", serverInstanceId); + if (filter.runEndpointId) params.set("runEndpointId", filter.runEndpointId); + if (filter.state) params.set("state", filter.state); + if (filter.states?.length) params.set("states", filter.states.join(",")); + const query = params.toString(); + return query ? `?${query}` : ""; +} + function serverFileListQuery(request: Partial): string { const params = new URLSearchParams(); if (request.directoryKey) params.set("directoryKey", request.directoryKey); diff --git a/platform_web/pages/ServerDetailPage.tsx b/platform_web/pages/ServerDetailPage.tsx index d51d8c9..cb075c2 100644 --- a/platform_web/pages/ServerDetailPage.tsx +++ b/platform_web/pages/ServerDetailPage.tsx @@ -38,7 +38,7 @@ import { PluginPageHostPage } from "./PluginPageHostPage"; type LoadState = { status: "loading" } | { status: "error"; reason: string } | { status: "ready"; data: T }; -const serverDetailRefreshMs = 5000; +const serverDetailRefreshMs = 15000; export function ServerDetailPage(props: PageComponentProps) { const { session, params, operations, onNavigate } = props; @@ -46,7 +46,6 @@ export function ServerDetailPage(props: PageComponentProps) { const [section, setSection] = useState("manage"); const [instance, setInstance] = useState>({ status: "loading" }); const [plugins, setPlugins] = useState([]); - const [jobs, setJobs] = useState([]); const [runEndpoint, setRunEndpoint] = useState(); const [deployment, setDeployment] = useState>({ status: "loading" }); const [confirm, setConfirm] = useState Promise }>(null); @@ -61,19 +60,17 @@ export function ServerDetailPage(props: PageComponentProps) { } setInstance({ status: "loading" }); try { - const [detail, pluginResponse, jobResponse, deploymentResponse, endpointResponse] = await Promise.all([ + const [detail, pluginResponse, deploymentResponse, endpointResponse] = await Promise.all([ platformApiClient.getServerInstance(serverId), platformApiClient.listGamePlugins(), - platformApiClient.listJobs(serverId), platformApiClient .getServerDeployment(serverId) .then((data): LoadState => ({ status: "ready", data })) .catch((error): LoadState => ({ status: "error", reason: error instanceof Error ? error.message : "部署定义加载失败" })), - platformApiClient.listRunEndpoints().catch(() => ({ items: [], count: 0 })) + platformApiClient.listRunEndpoints({ status: "online" }).catch(() => ({ items: [], count: 0 })) ]); setInstance({ status: "ready", data: detail }); setPlugins(pluginResponse.items); - setJobs(jobResponse.items); setDeployment(deploymentResponse); setRunEndpoint(endpointResponse.items.find((endpoint) => endpoint.id === detail.runEndpointId)); } catch (error) { @@ -90,13 +87,11 @@ export function ServerDetailPage(props: PageComponentProps) { const refreshOperationalState = useCallback(async () => { if (!serverId) return; try { - const [detail, jobResponse, endpointResponse] = await Promise.all([ + const [detail, endpointResponse] = await Promise.all([ platformApiClient.getServerInstance(serverId), - platformApiClient.listJobs(serverId), - platformApiClient.listRunEndpoints() + platformApiClient.listRunEndpoints({ status: "online" }) ]); setInstance({ status: "ready", data: detail }); - setJobs(jobResponse.items); setRunEndpoint(endpointResponse.items.find((endpoint) => endpoint.id === detail.runEndpointId)); } catch { setRunEndpoint(undefined); diff --git a/platform_web/pages/ServersPage.tsx b/platform_web/pages/ServersPage.tsx index bcd9be2..9bc79a6 100644 --- a/platform_web/pages/ServersPage.tsx +++ b/platform_web/pages/ServersPage.tsx @@ -62,9 +62,10 @@ const statusFilters: Array<{ id: ServerStatusFilter; label: string }> = [ { id: "attention", label: "需关注" } ]; -const serverListRefreshMs = 5000; +const serverListRefreshMs = 15000; const serverMetricFreshMs = 30000; const serverForceDeleteConfirmation = "FORCE DELETE"; +const serverOperationalJobStates: JobResponse["state"][] = ["queued", "accepted", "running", "retrying", "failed"]; export function ServersPage({ session, operations, onNavigate }: PageComponentProps) { const [listState, setListState] = useState("loading"); @@ -93,16 +94,16 @@ export function ServersPage({ session, operations, onNavigate }: PageComponentPr if (showLoading) setListState("loading"); try { const [pluginResponse, endpointResponse, instanceResponse, jobResponse] = await Promise.all([ - platformApiClient.listGamePlugins(), - platformApiClient.listRunEndpoints(), + showLoading ? platformApiClient.listGamePlugins() : Promise.resolve(undefined), + platformApiClient.listRunEndpoints({ status: "online" }), platformApiClient.listServerInstances(), - platformApiClient.listJobs() + platformApiClient.listJobs(undefined, { states: serverOperationalJobStates }) ]); - setPlugins(pluginResponse.items); + if (pluginResponse) setPlugins(pluginResponse.items); setEndpoints(endpointResponse.items); setInstances(instanceResponse.items); setJobs(jobResponse.items); - if (showLoading) setForm((current) => { + if (showLoading && pluginResponse) setForm((current) => { const plugin = pluginResponse.items.find((item) => item.id === current.pluginId) ?? pluginResponse.items[0]; return { ...current, @@ -144,10 +145,14 @@ export function ServersPage({ session, operations, onNavigate }: PageComponentPr useEffect(() => { const timer = window.setInterval(() => { void refreshList(false); - void refreshMetrics(); }, serverListRefreshMs); return () => window.clearInterval(timer); - }, [refreshList, refreshMetrics]); + }, [refreshList]); + + useEffect(() => { + const timer = window.setInterval(() => void refreshMetrics(), serverMetricFreshMs); + return () => window.clearInterval(timer); + }, [refreshMetrics]); const cards = useMemo( () => diff --git a/platform_web/stores/session.ts b/platform_web/stores/session.ts index a961dc6..559ea0f 100644 --- a/platform_web/stores/session.ts +++ b/platform_web/stores/session.ts @@ -17,6 +17,7 @@ import { defaultThemeBackgroundId, defaultThemePaletteId, loadThemeState } from const sessionTokenStorageKey = "platform-web.session.apiToken"; const localFallbackEnabled = readWebRuntimeEnv().enableLocalAuthFallback; +let currentUserLoadPromise: Promise | null = null; export const localFallbackUser: CurrentUserView = { id: "local-server-operator", @@ -57,6 +58,14 @@ export interface SessionState { } export async function loadCurrentUser(): Promise { + if (currentUserLoadPromise) return currentUserLoadPromise; + currentUserLoadPromise = loadCurrentUserOnce().finally(() => { + currentUserLoadPromise = null; + }); + return currentUserLoadPromise; +} + +async function loadCurrentUserOnce(): Promise { const storedToken = readStoredSessionToken(); setPlatformApiSessionToken(storedToken); try {