diff --git a/cmd/docker-gateway/main.go b/cmd/docker-gateway/main.go index 9ea8684..54c692e 100644 --- a/cmd/docker-gateway/main.go +++ b/cmd/docker-gateway/main.go @@ -714,7 +714,16 @@ func (api gateway) remove(c fiber.Ctx) error { if networkExists { err = api.removeTenantNetwork(id, input.BindingVersion, input.RuntimeID, networkGeneration, &input, containerID, exists) } else if expectedNetworkID != "" { - return writeError(c, http.StatusConflict, errGenerationConflict) + if exists { + // 容器仍在但登记的隔离网络已消失:代状态异常,fail-closed 交由人工对账。 + return writeError(c, http.StatusConflict, errGenerationConflict) + } + // 容器与登记网络均已不存在:请求指向的代在 Docker 侧已无残留,清理视为完成。 + // 否则被外部清理过的旧代会永远 409,控制面的 runtime_cleanup_pending 无法收敛。 + if !api.proxies.remove(id, input.BindingVersion, input.RuntimeID, input.NetworkID) { + return writeError(c, http.StatusConflict, errGenerationConflict) + } + return c.SendStatus(http.StatusNoContent) } if err != nil { if errors.Is(err, errGenerationConflict) { diff --git a/cmd/docker-gateway/main_test.go b/cmd/docker-gateway/main_test.go index 240ef49..308b52f 100644 --- a/cmd/docker-gateway/main_test.go +++ b/cmd/docker-gateway/main_test.go @@ -1263,6 +1263,60 @@ func TestGatewayDeleteWithoutContainerOrNetworkGenerationFailsClosed(t *testing. } } +func TestGatewayDeleteConvergesWhenGenerationAlreadyGone(t *testing.T) { + networkRequests := 0 + server := httptest.NewServer(withAliasReservations("gateway-self", func(response http.ResponseWriter, request *http.Request) { + switch { + case request.Method == http.MethodGet && request.URL.Path == "/containers/"+namePrefix+"account-a/json": + response.WriteHeader(http.StatusNotFound) + case request.Method == http.MethodGet && request.URL.Path == "/networks/network-old": + networkRequests++ + response.WriteHeader(http.StatusNotFound) + case strings.HasPrefix(request.URL.Path, "/networks/"): + t.Fatalf("network lookup leaked beyond the requested generation: %s %s", request.Method, request.URL.Path) + default: + t.Fatalf("unexpected Docker request %s %s", request.Method, request.URL.String()) + } + })) + defer server.Close() + handler := newGatewayWithSelf(dockerClient{baseURL: server.URL, client: server.Client(), slow: server.Client()}, + "creatorhub_browser", testToken, "gateway-self") + response := httptest.NewRecorder() + body := `{"binding_version":1,"runtime_id":"container-old","network_id":"network-old"}` + adaptor.FiberApp(handler).ServeHTTP(response, authed(http.MethodDelete, "/v1/browsers/account-a", strings.NewReader(body))) + if response.Code != http.StatusNoContent || networkRequests != 1 { + t.Fatalf("already-gone generation did not converge: status=%d networkRequests=%d body=%s", + response.Code, networkRequests, response.Body.String()) + } +} + +func TestGatewayDeleteFailsClosedWhenContainerOutlivesNetwork(t *testing.T) { + containerDeleted := false + server := httptest.NewServer(withAliasReservations("gateway-self", func(response http.ResponseWriter, request *http.Request) { + switch { + case request.Method == http.MethodGet && request.URL.Path == "/containers/"+namePrefix+"account-a/json": + _, _ = response.Write([]byte(`{"Id":"container-old","Config":{"Labels":{"` + managedLabel + `":"true","` + idLabel + `":"account-a","` + bindingVersionLabel + `":"1","` + networkIDLabel + `":"network-old"}}}`)) + case request.Method == http.MethodGet && request.URL.Path == "/networks/network-old": + response.WriteHeader(http.StatusNotFound) + case request.Method == http.MethodDelete && strings.HasPrefix(request.URL.Path, "/containers/"): + containerDeleted = true + response.WriteHeader(http.StatusNoContent) + default: + t.Fatalf("unexpected Docker request %s %s", request.Method, request.URL.String()) + } + })) + defer server.Close() + handler := newGatewayWithSelf(dockerClient{baseURL: server.URL, client: server.Client(), slow: server.Client()}, + "creatorhub_browser", testToken, "gateway-self") + response := httptest.NewRecorder() + body := `{"binding_version":1,"runtime_id":"container-old","network_id":"network-old"}` + adaptor.FiberApp(handler).ServeHTTP(response, authed(http.MethodDelete, "/v1/browsers/account-a", strings.NewReader(body))) + if response.Code != http.StatusConflict || containerDeleted { + t.Fatalf("container outliving its network must fail closed: status=%d containerDeleted=%v body=%s", + response.Code, containerDeleted, response.Body.String()) + } +} + func TestGatewayRejectsStaleProxyRestoreAfterReplacementGeneration(t *testing.T) { type dockerState struct { sync.Mutex diff --git a/web/src/AccountsPage.jsx b/web/src/AccountsPage.jsx index fa2d840..1d65729 100644 --- a/web/src/AccountsPage.jsx +++ b/web/src/AccountsPage.jsx @@ -1,4 +1,4 @@ -import { useMemo, useState } from "react"; +import { useState } from "react"; import { Link, useNavigate, useParams } from "react-router"; import { useDataProvider, useList, useOne } from "@refinedev/core"; import { @@ -265,22 +265,6 @@ export function AccountList() { const error = query.error; const isPending = query.isPending; const accounts = result.data ?? []; - const { result: bindingResult, query: bindingsQuery } = useList({ - resource: "browsers", - queryOptions: { retry: false }, - }); - const bindings = bindingResult.data ?? []; - const bindingsError = bindingsQuery.error; - const refetchBindings = bindingsQuery.refetch; - const bindingByAccount = useMemo( - () => - new Map( - bindings - .filter((item) => item.account_id) - .map((item) => [item.account_id, item]), - ), - [bindings], - ); useTitle("CreatorHub · 社媒账号"); async function createAccount(data) { @@ -322,15 +306,6 @@ export function AccountList() { {notice.text} ) : null} - {bindingsError ? ( - - 环境不可用{bindingsError.status ? `(${bindingsError.status})` : ""} - :无法读取运行环境绑定与 readiness,状态暂时未知。 - - - ) : null} (
platformLabel(account.platform), }, { header: "授权", - width: "10%", + width: "16%", render: (account) => account.authorization_status === "authorized" ? "已授权" @@ -371,49 +346,14 @@ export function AccountList() { }, { header: "账号状态", - width: "14%", + width: "20%", render: (account) => account.runtime_status === "active" ? "启用" : "暂停", }, - { - header: "固定资源", - width: "24%", - render: (account) => { - const binding = bindingByAccount.get(account.id); - return bindingsError ? ( - - 状态未知(环境数据不可用) - - ) : ( -
-

- {binding?.name || "未绑定运行环境"} -

-

- {binding?.network_exit_id || "未绑定出口"} -

-
- ); - }, - }, - { - header: "可调度", - width: "20%", - render: (account) => ( - - ), - }, ]} rows={accounts} rowKey={(account) => account.id} - minWidth="760px" + minWidth="560px" /> { canResume: false, }); }); + + it("renders accounts without touching runtime environments", async () => { + const dataProvider = provider({ + getList: vi.fn(({ resource }) => + resource === "accounts" + ? { data: [account], total: 1 } + : Promise.reject(httpError("environment down", 409)), + ), + }); + renderList(dataProvider); + + expect(await screen.findAllByText("店铺一号")).toBeTruthy(); + expect(screen.queryByText(/环境不可用/)).toBeNull(); + expect( + dataProvider.getList.mock.calls.filter( + ([call]) => call.resource === "browsers", + ), + ).toHaveLength(0); + }); }); describe("AccountDetail", () => {