fix: 账号列表与环境解耦;网关清理收敛已消失的旧代
- 社媒账号列表页不再拉取运行环境数据:账号是资源池,环境故障 不应阻塞账号展示,409 横幅随之消失;「固定资源」「可调度」 两列移除,绑定状态去运行环境页查看。详情页保持原行为。 - docker-gateway DELETE 清理:容器与登记网络均已不存在时返回 204 而非 409,控制面 runtime_cleanup_pending 可收敛,修复 外部清理过的旧代永久卡死;容器存活而网络消失仍 fail-closed。
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
</Alert>
|
||||
) : null}
|
||||
{bindingsError ? (
|
||||
<Alert variant="destructive" className="mb-4">
|
||||
环境不可用{bindingsError.status ? `(${bindingsError.status})` : ""}
|
||||
:无法读取运行环境绑定与 readiness,状态暂时未知。
|
||||
<Button size="sm" onClick={() => refetchBindings()}>
|
||||
重试环境状态
|
||||
</Button>
|
||||
</Alert>
|
||||
) : null}
|
||||
<PageState
|
||||
pending={isPending}
|
||||
error={error}
|
||||
@@ -341,7 +316,7 @@ export function AccountList() {
|
||||
columns={[
|
||||
{
|
||||
header: "账号",
|
||||
width: "22%",
|
||||
width: "32%",
|
||||
render: (account) => (
|
||||
<div className="min-w-0">
|
||||
<Link
|
||||
@@ -358,12 +333,12 @@ export function AccountList() {
|
||||
},
|
||||
{
|
||||
header: "平台",
|
||||
width: "10%",
|
||||
width: "16%",
|
||||
render: (account) => 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 ? (
|
||||
<span className="text-sm text-muted">
|
||||
状态未知(环境数据不可用)
|
||||
</span>
|
||||
) : (
|
||||
<div className="min-w-0">
|
||||
<p className="anywhere">
|
||||
{binding?.name || "未绑定运行环境"}
|
||||
</p>
|
||||
<p className="anywhere text-xs text-muted">
|
||||
{binding?.network_exit_id || "未绑定出口"}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
header: "可调度",
|
||||
width: "20%",
|
||||
render: (account) => (
|
||||
<ReadinessPill
|
||||
readiness={accountReadiness(
|
||||
account,
|
||||
bindingByAccount.get(account.id),
|
||||
!!bindingsError,
|
||||
)}
|
||||
paused={account.runtime_status === "paused"}
|
||||
/>
|
||||
),
|
||||
},
|
||||
]}
|
||||
rows={accounts}
|
||||
rowKey={(account) => account.id}
|
||||
minWidth="760px"
|
||||
minWidth="560px"
|
||||
/>
|
||||
</PageState>
|
||||
<AccountCreateModal
|
||||
|
||||
@@ -221,6 +221,25 @@ describe("AccountList", () => {
|
||||
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", () => {
|
||||
|
||||
Reference in New Issue
Block a user