diff --git a/cmd/control-plane/hub.go b/cmd/control-plane/hub.go index 5fc91b6..48237ae 100644 --- a/cmd/control-plane/hub.go +++ b/cmd/control-plane/hub.go @@ -295,14 +295,40 @@ type containerStatus struct { type envView struct { hub.Env - State string `json:"state"` - Status string `json:"status"` - ContainerID string `json:"container_id"` - Endpoint string `json:"endpoint"` - NetworkExitID string `json:"network_exit_id"` - BindingVersion int64 `json:"binding_version"` - RecoveryRequired bool `json:"recovery_required"` - CleanupPending bool `json:"cleanup_pending"` + State string `json:"state"` + Status string `json:"status"` + ContainerID string `json:"container_id"` + Endpoint string `json:"endpoint"` + AccountID string `json:"account_id"` + AccountStatus string `json:"account_status"` + AuthorizationStatus string `json:"authorization_status"` + NetworkExitID string `json:"network_exit_id"` + NetworkExitHealth string `json:"network_exit_health"` + BindingVersion int64 `json:"binding_version"` + RuntimeInstanceID string `json:"runtime_instance_id"` + ScheduleStatus string `json:"schedule_status"` + ScheduleBlockReason string `json:"schedule_block_reason,omitempty"` + RecoveryRequired bool `json:"recovery_required"` + CleanupPending bool `json:"cleanup_pending"` +} + +func environmentScheduleReadiness(environment hub.EnvironmentContext) (string, string) { + switch { + case environment.AuthorizationStatus != "authorized": + return "blocked", "account_revoked" + case environment.AccountStatus != "active": + return "blocked", "account_paused" + case environment.Exit.ID == "": + return "blocked", "network_exit_missing" + case environment.Exit.HealthStatus != "healthy": + return "blocked", "network_exit_unhealthy" + case environment.RuntimeCleanupPending: + return "blocked", "runtime_stop_pending" + case environment.RuntimeInstanceID == "": + return "blocked", "runtime_missing" + default: + return "ready", "" + } } func containerMatchesBinding(container containerStatus, environment hub.EnvironmentContext) bool { @@ -363,6 +389,7 @@ func registerHubWithNetwork(app *fiber.App, store hubStore, probe networkExitPro app.Post("/api/browsers/:alias/:action", serialized(browserAction(store, probe, resolve))) app.Delete("/api/browsers/:alias", serialized(deleteBrowser(store))) app.Get("/api/network-exits", listNetworkExits(store)) + app.Get("/api/network-exits/:id", getNetworkExit(store)) app.Post("/api/network-exits", serialized(createNetworkExit(store))) app.Post("/api/network-exits/:id/check", serialized(checkNetworkExit(store, probe))) app.Post("/api/network-exits/:id/disable", serialized(disableNetworkExit(store, probe, resolve))) @@ -460,6 +487,16 @@ func listNetworkExits(store hubStore) fiber.Handler { } } +func getNetworkExit(store hubStore) fiber.Handler { + return func(c fiber.Ctx) error { + exit, err := store.GetNetworkExit(c.Context(), c.Params("id")) + if err != nil { + return hubError(c, err) + } + return c.JSON(exit) + } +} + func createNetworkExit(store hubStore) fiber.Handler { return func(c fiber.Ctx) error { var input struct { @@ -552,11 +589,14 @@ func listBrowsers(store hubStore, probe networkExitProbe, resolve func(hub.Netwo } views := make([]envView, 0, len(envs)) for _, env := range envs { - view := envView{Env: env, State: "missing", Status: "网关上不存在容器"} + view := envView{Env: env, State: "missing", Status: "网关上不存在容器", ScheduleStatus: "blocked", ScheduleBlockReason: "binding_missing", RecoveryRequired: true} if environment, contextErr := store.GetEnvironmentContext(c.Context(), env.Alias); contextErr == nil { - view.NetworkExitID, view.BindingVersion = environment.Exit.ID, environment.BindingVersion + view.AccountID, view.AccountStatus, view.AuthorizationStatus = environment.AccountID, environment.AccountStatus, environment.AuthorizationStatus + view.NetworkExitID, view.NetworkExitHealth, view.BindingVersion = environment.Exit.ID, environment.Exit.HealthStatus, environment.BindingVersion + view.RuntimeInstanceID = environment.RuntimeInstanceID + view.ScheduleStatus, view.ScheduleBlockReason = environmentScheduleReadiness(environment) view.CleanupPending = environment.RuntimeCleanupPending - view.RecoveryRequired = environment.Exit.ID == "" || environment.RuntimeCleanupPending + view.RecoveryRequired = environment.Exit.ID == "" || environment.Exit.HealthStatus != "healthy" || environment.RuntimeCleanupPending } else if !errors.Is(contextErr, hub.ErrNotFound) { return hubError(c, contextErr) } diff --git a/cmd/control-plane/hub_test.go b/cmd/control-plane/hub_test.go index 43133ca..dc887e8 100644 --- a/cmd/control-plane/hub_test.go +++ b/cmd/control-plane/hub_test.go @@ -64,6 +64,29 @@ func newMemoryStore() *memoryStore { } } +func TestResumeBlockReasonIsStable(t *testing.T) { + account := phasea.Account{AuthorizationStatus: "authorized"} + healthy := hub.EnvironmentContext{Exit: hub.NetworkExit{ID: "exit-a", HealthStatus: "healthy"}} + for name, test := range map[string]struct { + account phasea.Account + env hub.EnvironmentContext + found bool + want string + }{ + "revoked": {phasea.Account{AuthorizationStatus: "revoked"}, healthy, true, "account_revoked"}, + "missing binding": {account, hub.EnvironmentContext{}, false, "binding_missing"}, + "unhealthy exit": {account, hub.EnvironmentContext{Exit: hub.NetworkExit{ID: "exit-a", HealthStatus: "unhealthy"}}, true, "network_exit_unhealthy"}, + "cleanup pending": {account, hub.EnvironmentContext{Exit: healthy.Exit, RuntimeCleanupPending: true}, true, "runtime_stop_pending"}, + "runtime active": {account, hub.EnvironmentContext{Exit: healthy.Exit, RuntimeInstanceID: "runtime-a"}, true, "runtime_active"}, + } { + t.Run(name, func(t *testing.T) { + if got := resumeBlockReason(test.account, test.env, test.found); got != test.want { + t.Fatalf("got %q, want %q", got, test.want) + } + }) + } +} + func (s *memoryStore) CreateGateway(_ context.Context, _, _, _ string) (hub.Gateway, error) { return hub.Gateway{}, nil } @@ -1286,6 +1309,9 @@ func TestListBrowsersMergesLiveGatewayState(t *testing.T) { if byAlias["account-a"].State != "running" || byAlias["account-a"].ContainerID != "id-1" { t.Fatalf("running container state must be merged: %#v", byAlias["account-a"]) } + if view := byAlias["account-a"]; view.AccountID != "account-a" || view.NetworkExitHealth != "healthy" || view.ScheduleStatus != "ready" || view.ScheduleBlockReason != "" { + t.Fatalf("binding readiness must be exposed without credentials: %#v", view) + } if byAlias["account-b"].State != "missing" { t.Fatalf("env without container must report missing: %#v", byAlias["account-b"]) } @@ -4165,6 +4191,10 @@ func TestNetworkExitRoutesAreStrictAndSecretFree(t *testing.T) { if created.Code != http.StatusCreated || strings.Contains(created.Body.String(), "raw-value") { t.Fatalf("unexpected secret-bearing network exit response: status=%d body=%s", created.Code, created.Body.String()) } + detail := do(app, http.MethodGet, "/api/network-exits/exit-created", "") + if detail.Code != http.StatusOK || !strings.Contains(detail.Body.String(), `"credential_reference":{"id":"credential-a"`) || strings.Contains(detail.Body.String(), "raw-value") { + t.Fatalf("network exit detail must expose only the credential reference: status=%d body=%s", detail.Code, detail.Body.String()) + } checked := do(app, http.MethodPost, "/api/network-exits/exit-created/check", "") if checked.Code != http.StatusOK || !strings.Contains(checked.Body.String(), `"health_status":"healthy"`) { t.Fatalf("network exit check did not record health: status=%d body=%s", checked.Code, checked.Body.String()) diff --git a/cmd/control-plane/phasea.go b/cmd/control-plane/phasea.go index 41f4242..6fb1ad2 100644 --- a/cmd/control-plane/phasea.go +++ b/cmd/control-plane/phasea.go @@ -6,6 +6,7 @@ import ( "errors" "io" + "git.ipao.vip/rogee/creator-hub/internal/hub" "git.ipao.vip/rogee/creator-hub/internal/phasea" "github.com/gofiber/fiber/v3" ) @@ -28,8 +29,12 @@ func registerPhaseA(app *fiber.App, store *phasea.Store, runtimeStore runtimeSto if err := decodePhaseA(c, &input); err != nil { return phaseAError(c, err) } + accountID := input.ID + if accountID == "" { + accountID = phasea.NewAccountID() + } account := phasea.Account{ - ID: input.ID, Platform: input.Platform, PlatformAccountKey: input.PlatformAccountKey, + ID: accountID, Platform: input.Platform, PlatformAccountKey: input.PlatformAccountKey, AuthorizationKind: input.AuthorizationKind, CredentialKey: input.CredentialReference.Key, CredentialReference: phasea.CredentialReference{ID: input.CredentialReference.ID, Provider: input.CredentialReference.Provider}, AuthorizationStatus: "authorized", RuntimeStatus: "paused", Version: 1, @@ -74,6 +79,9 @@ func registerPhaseA(app *fiber.App, store *phasea.Store, runtimeStore runtimeSto runtimeOperations.Lock() defer runtimeOperations.Unlock() if err := store.ResumeAccount(c.Context(), c.Params("id")); err != nil { + if errors.Is(err, phasea.ErrConflict) { + return accountResumeConflict(c, store, runtimeStore, c.Params("id")) + } return phaseAError(c, err) } return c.SendStatus(fiber.StatusNoContent) @@ -162,6 +170,44 @@ func registerPhaseA(app *fiber.App, store *phasea.Store, runtimeStore runtimeSto }) } +func resumeBlockReason(account phasea.Account, environment hub.EnvironmentContext, bindingFound bool) string { + switch { + case account.AuthorizationStatus != "authorized": + return "account_revoked" + case !bindingFound: + return "binding_missing" + case environment.Exit.ID == "": + return "network_exit_missing" + case environment.Exit.HealthStatus != "healthy": + return "network_exit_unhealthy" + case environment.RuntimeCleanupPending: + return "runtime_stop_pending" + case environment.RuntimeInstanceID != "": + return "runtime_active" + default: + return "account_conflict" + } +} + +func accountResumeConflict(c fiber.Ctx, store *phasea.Store, runtimeStore runtimeStopStore, accountID string) error { + account, err := store.GetAccount(c.Context(), accountID) + if err != nil { + return phaseAError(c, err) + } + var environment hub.EnvironmentContext + bindingFound := false + if runtimeStore != nil { + environment, err = runtimeStore.GetEnvironmentContextForAccount(c.Context(), accountID) + bindingFound = err == nil + if err != nil && !errors.Is(err, hub.ErrNotFound) { + return phaseAError(c, err) + } + } + return c.Status(fiber.StatusConflict).JSON(map[string]string{ + "error": phasea.ErrConflict.Error(), "reason_code": resumeBlockReason(account, environment, bindingFound), "readiness": "blocked", + }) +} + func decodePhaseA(c fiber.Ctx, destination any) error { decoder := json.NewDecoder(bytes.NewReader(c.Body())) decoder.DisallowUnknownFields() diff --git a/internal/phasea/store.go b/internal/phasea/store.go index 0ab385e..8e69788 100644 --- a/internal/phasea/store.go +++ b/internal/phasea/store.go @@ -844,6 +844,8 @@ func newID() string { return hex.EncodeToString(value[:]) } +func NewAccountID() string { return "account-" + newID()[:24] } + func commit(tx *sql.Tx) error { if err := tx.Commit(); err != nil { return errors.New("commit transaction") diff --git a/internal/phasea/store_test.go b/internal/phasea/store_test.go index 79eba80..22c3014 100644 --- a/internal/phasea/store_test.go +++ b/internal/phasea/store_test.go @@ -14,6 +14,13 @@ import ( "time" ) +func TestNewAccountIDIsStoreValidAndUnique(t *testing.T) { + first, second := NewAccountID(), NewAccountID() + if first == second || !idPattern.MatchString(first) || !idPattern.MatchString(second) { + t.Fatalf("invalid generated account ids: %q %q", first, second) + } +} + func TestValidationRejectsInvalidInputsBeforePersistence(t *testing.T) { store := &Store{} valid := Account{ diff --git a/web/src/AccountList.jsx b/web/src/AccountList.jsx new file mode 100644 index 0000000..8939934 --- /dev/null +++ b/web/src/AccountList.jsx @@ -0,0 +1,224 @@ +import { useEffect, useMemo, useState } from 'react' +import { useDataProvider, useGetList, useGetOne } from 'ra-core' +import { Link as RouterLink, useParams } from 'react-router-dom' +import { + Alert, + Box, + Button, + CircularProgress, + MenuItem, + Paper, + Stack, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + TextField, + Typography, +} from '@mui/material' +import AccountCircleOutlined from '@mui/icons-material/AccountCircleOutlined' +import CheckCircleOutlined from '@mui/icons-material/CheckCircleOutlined' +import PauseCircleOutlined from '@mui/icons-material/PauseCircleOutlined' +import ReportProblemOutlined from '@mui/icons-material/ReportProblemOutlined' + +const emptyAccount = { + platform: '', + platform_account_key: '', + authorization_kind: 'owned', + credential_id: '', + credential_provider: 'os_keyring', + credential_key: '', +} + +const wrapAnywhere = { overflowWrap: 'anywhere', minWidth: 0 } + +const reasonLabels = { + account_revoked: '授权已撤销', + account_paused: '账号已暂停', + binding_missing: '未绑定运行环境', + network_exit_missing: '未绑定固定出口', + network_exit_unhealthy: '固定出口不健康', + runtime_stop_pending: '运行环境停止结果待确认', + runtime_missing: '运行环境未启动', + runtime_active: '仍有活动运行实例', + account_conflict: '账号状态已变化,请刷新后重试', + environment_unavailable: '环境状态未知', +} + +function actionError(error, conflictMessage) { + if (error?.status === 409) return `冲突(409):${reasonLabels[error.body?.reason_code] || conflictMessage}` + if (error?.status === 503) return `环境不可用(503):${error.message}` + return error?.message || '操作失败' +} + +function environmentError(error) { + return `环境不可用${error?.status ? `(${error.status})` : ''}:无法读取运行环境绑定与 readiness,状态暂时未知。` +} + +export function accountReadiness(account, binding, bindingError = false) { + if (account.authorization_status !== 'authorized') return { label: '授权已撤销', reason: 'account_revoked', canResume: false, ready: false } + if (bindingError) return { label: '环境状态未知', reason: 'environment_unavailable', canResume: false, ready: false } + if (!binding) return { label: '未绑定运行环境', reason: 'binding_missing', canResume: false, ready: false } + const resourceReason = binding.cleanup_pending + ? 'runtime_stop_pending' + : binding.network_exit_health !== 'healthy' + ? (binding.network_exit_id ? 'network_exit_unhealthy' : 'network_exit_missing') + : '' + if (resourceReason) return { label: reasonLabels[resourceReason], reason: resourceReason, canResume: false, ready: false } + if (account.runtime_status === 'paused') { + const canResume = !binding.runtime_instance_id + return { label: canResume ? '资源就绪,可恢复' : '等待运行环境停止', reason: canResume ? '' : 'runtime_stop_pending', canResume, ready: false } + } + const reason = binding.schedule_block_reason || '' + return { label: reason ? (reasonLabels[reason] || `不可调度:${reason}`) : '可调度', reason, canResume: false, ready: !reason } +} + +function AccountState({ account, binding, bindingError }) { + const readiness = accountReadiness(account, binding, bindingError) + const Icon = readiness.ready ? CheckCircleOutlined : account.runtime_status === 'paused' && readiness.canResume ? PauseCircleOutlined : ReportProblemOutlined + const color = readiness.ready ? 'success.main' : readiness.canResume ? 'warning.main' : 'error.main' + return {readiness.label} +} + +function AccountForm({ busy, onSubmit }) { + const [form, setForm] = useState(emptyAccount) + const update = (key, value) => setForm(current => ({ ...current, [key]: value })) + const valid = form.platform.trim() && form.platform_account_key.trim() && form.credential_id.trim() && form.credential_key.trim() + + async function submit(event) { + event.preventDefault() + if (!valid) return + const created = await onSubmit({ + platform: form.platform.trim(), + platform_account_key: form.platform_account_key.trim(), + authorization_kind: form.authorization_kind, + credential_reference: { + id: form.credential_id.trim(), + provider: form.credential_provider, + key: form.credential_key.trim(), + }, + }) + if (created) setForm(emptyAccount) + } + + return ( + + update('platform', event.target.value)} slotProps={{ htmlInput: { pattern: '[a-z0-9][a-z0-9-]{0,31}', 'aria-label': '平台' } }} helperText="阶段 A 使用虚拟平台标识,如 mock" /> + update('platform_account_key', event.target.value)} slotProps={{ htmlInput: { 'aria-label': '平台账号标识', maxLength: 128 } }} /> + update('authorization_kind', event.target.value)} slotProps={{ htmlInput: { 'aria-label': '授权类型' } }}> + 自有账号已获授权 + + update('credential_id', event.target.value)} slotProps={{ htmlInput: { 'aria-label': '凭据引用 ID', maxLength: 128 } }} /> + update('credential_provider', event.target.value)} slotProps={{ htmlInput: { 'aria-label': '凭据引用来源' } }}> + 系统密钥环Secret Manager + + update('credential_key', event.target.value)} slotProps={{ htmlInput: { 'aria-label': '凭据引用键', maxLength: 127 } }} helperText="仅填写引用路径,不填写密码、Cookie 或 token" /> + + + ) +} + +function AccountTable({ accounts, bindings, bindingsError }) { + return ( + + + 账号授权资源就绪度固定资源操作 + {accounts.map(account => { + const binding = bindings.get(account.id) + return {account.platform_account_key}{account.id} · {account.platform}{account.authorization_status === 'authorized' ? '已授权' : '已撤销'} · {account.runtime_status === 'active' ? '启用' : '暂停'}{bindingsError ? '状态未知' : (binding?.name || '—')}{bindingsError ? '环境数据不可用' : (binding?.network_exit_id || '未绑定出口')} + })} +
+
+ ) +} + +function AccountCards({ accounts, bindings, bindingsError }) { + return {accounts.map(account => { + const binding = bindings.get(account.id) + return {account.platform_account_key}{account.id} · {account.platform}授权:{account.authorization_status === 'authorized' ? '已授权' : '已撤销'} · 账号:{account.runtime_status === 'active' ? '启用' : '暂停'}{bindingsError ? '运行环境与固定出口:状态未知' : `运行环境:${binding?.name || '未绑定'} · 固定出口:${binding?.network_exit_id || '未绑定'}`} + })} +} + +export function AccountList() { + const dataProvider = useDataProvider() + const [busy, setBusy] = useState(false) + const [message, setMessage] = useState(null) + const [filter, setFilter] = useState('') + const { data: accounts = [], error, isPending, refetch } = useGetList('accounts') + const { data: browsers = [], error: browsersError, refetch: refetchBrowsers } = useGetList('browsers', undefined, { retry: false }) + const bindings = useMemo(() => new Map(browsers.filter(browser => browser.account_id).map(browser => [browser.account_id, browser])), [browsers]) + const needle = filter.trim().toLowerCase() + const visible = needle ? accounts.filter(account => `${account.platform} ${account.platform_account_key} ${account.id}`.toLowerCase().includes(needle)) : accounts + + useEffect(() => { document.title = 'CreatorHub · 账号池' }, []) + + async function createAccount(data) { + setBusy(true); setMessage(null) + try { + await dataProvider.create('accounts', { data }) + await refetch() + setMessage({ severity: 'success', text: '账号已创建;绑定健康出口和运行环境后方可恢复。' }) + return true + } catch (reason) { + setMessage({ severity: 'error', text: actionError(reason, '平台账号标识或凭据引用已存在;表单内容已保留。') }) + return false + } finally { setBusy(false) } + } + + return ( + <> + 账号池管理账号授权、暂停状态与固定运行资源 + + {message ? {message.text} : null} + {error ? {actionError(error, '账号列表冲突')} : null} + {browsersError ? refetchBrowsers()}>重试环境状态} sx={{ mb: 2.5 }}>{environmentError(browsersError)} : null} + {accounts.length > 0 ? setFilter(event.target.value)} sx={{ mb: 2.5, width: { xs: '100%', sm: 320 } }} /> : null} + {isPending ? : null} + {!isPending && accounts.length === 0 ? 暂无账号。创建账号后,再前往运行环境绑定健康网络出口。 : null} + {!isPending && accounts.length > 0 && visible.length === 0 ? 没有符合筛选条件的账号 : null} + {!isPending && visible.length > 0 ? <> : null} + + ) +} + +export function AccountDetail() { + const { id } = useParams() + const dataProvider = useDataProvider() + const [busy, setBusy] = useState(false) + const [message, setMessage] = useState(null) + const { data: account, error, isPending, refetch } = useGetOne('accounts', { id }) + const { data: browsers = [], error: browsersError, refetch: refetchBrowsers } = useGetList('browsers', undefined, { retry: false }) + const binding = browsersError ? undefined : browsers.find(browser => browser.account_id === id) + const readiness = account ? accountReadiness(account, binding, browsersError) : null + + useEffect(() => { document.title = 'CreatorHub · 账号详情' }, []) + + async function act(action) { + if (action === 'pause' && !window.confirm(`暂停账号 ${account.platform_account_key}?待领取任务将进入 hold,恢复账号不会自动恢复既有 hold。`)) return + setBusy(true); setMessage(null) + try { + await dataProvider.accountAction(account.id, action) + await Promise.all([refetch(), refetchBrowsers()]) + setMessage({ severity: 'success', text: action === 'pause' ? '账号已暂停;既有 hold 任务需后续逐条核验。' : '账号已恢复;既有 hold 任务没有自动恢复。' }) + } catch (reason) { + setMessage({ severity: 'error', text: actionError(reason, readiness?.label || '账号当前资源不满足恢复条件。') }) + } finally { setBusy(false) } + } + + if (isPending) return + if (error || !account) return {error?.message || '账号不存在'} + return ( + <> + {account.platform_account_key}{account.id} · {account.platform} + {message ? {message.text} : null} + {browsersError ? refetchBrowsers()}>重试环境状态} sx={{ mb: 2.5 }}>{environmentError(browsersError)} : null} + } sx={{ mb: 2.5 }}>暂停会把待领取任务置为 hold;恢复账号只恢复账号可用性,不会自动恢复既有 hold。 + + 账号状态授权:{account.authorization_status === 'authorized' ? '已授权' : '已撤销'}({account.authorization_kind})运行:{account.runtime_status === 'active' ? '启用' : '暂停'} · 版本 {account.version}凭据引用:{account.credential_reference?.id} · {account.credential_reference?.provider} + 固定资源{browsersError ? 运行环境、固定出口与 readiness 状态未知;重试成功后再执行依赖资源状态的操作。 : binding ? <>运行环境:{binding.name}({binding.alias})固定出口:{binding.network_exit_id || '未绑定'} · {binding.network_exit_health || '未知状态'}绑定版本:{binding.binding_version}不可调度原因:{binding.schedule_block_reason ? (reasonLabels[binding.schedule_block_reason] || binding.schedule_block_reason) : '无'} : <>尚未绑定运行环境与固定出口,因此不能恢复或排队。} + + + ) +} diff --git a/web/src/AccountList.test.jsx b/web/src/AccountList.test.jsx new file mode 100644 index 0000000..b27c503 --- /dev/null +++ b/web/src/AccountList.test.jsx @@ -0,0 +1,85 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { CoreAdminContext, HttpError } from 'ra-core' +import { MemoryRouter, Route, Routes } from 'react-router-dom' +import { AccountDetail, AccountList, accountReadiness } from './AccountList' + +afterEach(() => { cleanup(); vi.restoreAllMocks() }) + +const account = { id: 'account-a', platform: 'mock', platform_account_key: 'shop-a', authorization_kind: 'owned', authorization_status: 'authorized', runtime_status: 'paused', version: 1, credential_reference: { id: 'credential-a', provider: 'os_keyring' } } +const binding = { id: 'env-a', alias: 'env-a', name: '店铺环境', account_id: 'account-a', network_exit_id: 'exit-a', network_exit_health: 'healthy', binding_version: 1, schedule_status: 'blocked', schedule_block_reason: 'account_paused' } + +function provider(overrides = {}) { + return { + getList: vi.fn(resource => Promise.resolve(resource === 'accounts' ? { data: [account], total: 1 } : resource === 'browsers' ? { data: [binding], total: 1 } : { data: [], total: 0 })), + getOne: vi.fn().mockResolvedValue({ data: account }), + create: vi.fn().mockResolvedValue({ data: account }), + accountAction: vi.fn().mockResolvedValue(undefined), + getMany: vi.fn(), getManyReference: vi.fn(), update: vi.fn(), updateMany: vi.fn(), delete: vi.fn(), deleteMany: vi.fn(), + ...overrides, + } +} + +function renderList(dataProvider) { + return render() +} + +describe('AccountList', () => { + it('keeps account input after a 409 and never submits a secret or client id', async () => { + const dataProvider = provider({ create: vi.fn().mockRejectedValue(new HttpError('conflict', 409, { reason_code: 'duplicate_platform_account' })) }) + renderList(dataProvider) + await screen.findAllByText('shop-a') + + fireEvent.change(screen.getByRole('textbox', { name: '平台' }), { target: { value: 'mock' } }) + fireEvent.change(screen.getByRole('textbox', { name: '平台账号标识' }), { target: { value: 'shop-new' } }) + fireEvent.change(screen.getByRole('textbox', { name: '凭据引用 ID' }), { target: { value: 'credential-new' } }) + fireEvent.change(screen.getByRole('textbox', { name: '凭据引用键' }), { target: { value: 'creatorhub/shop-new' } }) + fireEvent.click(screen.getByRole('button', { name: '创建账号' })) + + await waitFor(() => expect(dataProvider.create).toHaveBeenCalledWith('accounts', { data: { + platform: 'mock', platform_account_key: 'shop-new', authorization_kind: 'owned', + credential_reference: { id: 'credential-new', provider: 'os_keyring', key: 'creatorhub/shop-new' }, + } })) + expect((await screen.findByRole('alert')).textContent).toContain('冲突(409)') + expect(screen.getByRole('textbox', { name: '平台账号标识' }).value).toBe('shop-new') + expect(screen.queryByLabelText(/密码|Cookie|token/i)).toBeNull() + }) + + it('distinguishes resumable bindings from missing resources', () => { + expect(accountReadiness(account, binding)).toMatchObject({ label: '资源就绪,可恢复', canResume: true }) + expect(accountReadiness(account, undefined)).toMatchObject({ label: '未绑定运行环境', canResume: false }) + expect(accountReadiness(account, undefined, true)).toMatchObject({ label: '环境状态未知', canResume: false }) + }) +}) + +describe('AccountDetail', () => { + it('pauses explicitly and explains that hold tasks stay held', async () => { + vi.spyOn(window, 'confirm').mockReturnValue(true) + const active = { ...account, runtime_status: 'active' } + const dataProvider = provider({ getOne: vi.fn().mockResolvedValue({ data: active }) }) + render(} />) + + fireEvent.click(await screen.findByRole('button', { name: '暂停账号' })) + await waitFor(() => expect(dataProvider.accountAction).toHaveBeenCalledWith('account-a', 'pause')) + expect((await screen.findAllByText(/不会自动恢复既有 hold/)).length).toBeGreaterThan(0) + }) + + it('keeps environment-dependent actions fail-safe when browsers return 502', async () => { + const active = { ...account, runtime_status: 'active' } + const dataProvider = provider({ + getOne: vi.fn().mockResolvedValue({ data: active }), + getList: vi.fn(resource => resource === 'browsers' + ? Promise.reject(new HttpError('bad gateway', 502)) + : Promise.resolve({ data: [], total: 0 })), + }) + render(} />) + + expect(await screen.findByText(/环境不可用(502)/)).toBeTruthy() + expect(screen.getByText(/运行环境、固定出口与 readiness 状态未知/)).toBeTruthy() + expect(screen.queryByText(/尚未绑定运行环境与固定出口/)).toBeNull() + expect(screen.getByRole('button', { name: '恢复账号' }).disabled).toBe(true) + expect(screen.getByRole('button', { name: '暂停账号' }).disabled).toBe(false) + fireEvent.click(screen.getByRole('button', { name: '重试环境状态' })) + await waitFor(() => expect(dataProvider.getList.mock.calls.filter(([resource]) => resource === 'browsers').length).toBeGreaterThan(1)) + }) +}) diff --git a/web/src/BrowserList.jsx b/web/src/BrowserList.jsx index 2883675..09844d1 100644 --- a/web/src/BrowserList.jsx +++ b/web/src/BrowserList.jsx @@ -27,9 +27,11 @@ import { Typography, } from '@mui/material' import ContentCopyOutlined from '@mui/icons-material/ContentCopyOutlined' +import CheckCircleOutlined from '@mui/icons-material/CheckCircleOutlined' import DeleteOutlined from '@mui/icons-material/DeleteOutlined' import ExpandMoreOutlined from '@mui/icons-material/ExpandMoreOutlined' import PlayArrowOutlined from '@mui/icons-material/PlayArrowOutlined' +import ReportProblemOutlined from '@mui/icons-material/ReportProblemOutlined' import SecurityOutlined from '@mui/icons-material/SecurityOutlined' import StopOutlined from '@mui/icons-material/StopOutlined' import UpgradeOutlined from '@mui/icons-material/UpgradeOutlined' @@ -48,6 +50,16 @@ const statusLabels = { missing: '未部署', } +const scheduleReasonLabels = { + account_revoked: '授权已撤销', + account_paused: '账号已暂停', + binding_missing: '绑定缺失', + network_exit_missing: '固定出口缺失', + network_exit_unhealthy: '固定出口不健康', + runtime_stop_pending: '停止结果待确认', + runtime_missing: '运行实例缺失', +} + const emptyForm = { name: '', alias: '', @@ -148,10 +160,10 @@ function CreateForm({ gateways, images, accounts, networkExits, onSubmit, busy } form.image_version !== '' && form.account_id !== '' && form.network_exit_id !== '' && Number.isInteger(seedNumber) && seedNumber >= 1 && seedNumber <= 2147483647 - const submit = event => { + const submit = async event => { event.preventDefault() if (!valid) return - onSubmit({ + const created = await onSubmit({ alias: form.alias, name: form.name.trim(), gateway: form.gateway, @@ -160,7 +172,7 @@ function CreateForm({ gateways, images, accounts, networkExits, onSubmit, busy } network_exit_id: form.network_exit_id, fingerprint: buildFingerprint(form), }) - setForm(current => ({ ...emptyForm, gateway: current.gateway, image_version: current.image_version, account_id: current.account_id, network_exit_id: current.network_exit_id })) + if (created) setForm(current => ({ ...emptyForm, gateway: current.gateway, image_version: current.image_version, account_id: current.account_id, network_exit_id: current.network_exit_id })) } const label = (htmlFor, text, required = false) => ( @@ -245,6 +257,12 @@ function CreateForm({ gateways, images, accounts, networkExits, onSubmit, busy } ) } +function BindingReadiness({ runtime }) { + const ready = runtime.schedule_status === 'ready' + const reason = runtime.schedule_block_reason + return {ready ? : }{ready ? '可调度' : (scheduleReasonLabels[reason] || reason || '不可调度')} +} + function UpgradeDialog({ target, images, busy, onClose, onConfirm }) { const [version, setVersion] = useState('') const enabledImages = images.filter(image => image.enabled) @@ -274,13 +292,13 @@ function RuntimeTable({ runtimes, busy, onAction }) { return ( - 环境状态镜像版本CDP操作 + 环境状态固定绑定CDP操作 {runtimes.map(runtime => ( {runtime.name}{runtime.alias} · seed {runtime.fingerprint?.seed} - {runtime.image_version} + {runtime.account_id || '未绑定账号'}{runtime.network_exit_id || '未绑定出口'} · {runtime.image_version} {runtime.endpoint ? : —} @@ -292,7 +310,7 @@ function RuntimeTable({ runtimes, busy, onAction }) { } function RuntimeCards({ runtimes, busy, onAction }) { - return {runtimes.map(runtime => {runtime.name}{runtime.alias} · {runtime.image_version} · seed {runtime.fingerprint?.seed}{runtime.endpoint ? : null})} + return {runtimes.map(runtime => {runtime.name}{runtime.alias} · {runtime.image_version} · seed {runtime.fingerprint?.seed}账号:{runtime.account_id || '未绑定'} · 固定出口:{runtime.network_exit_id || '未绑定'}{runtime.endpoint ? : null})} } export function BrowserList() { @@ -316,8 +334,10 @@ export function BrowserList() { try { await dataProvider.create('browsers', { data }) await refetch() + return true } catch (reason) { - setLocalError(reason.message) + setLocalError(reason.status === 409 ? '冲突(409):账号或运行环境已有活动绑定;表单内容已保留。' : reason.status === 503 ? `环境不可用(503):${reason.message}` : reason.message) + return false } finally { setBusy('') } diff --git a/web/src/BrowserList.test.jsx b/web/src/BrowserList.test.jsx index 0c42e69..16db3e4 100644 --- a/web/src/BrowserList.test.jsx +++ b/web/src/BrowserList.test.jsx @@ -1,13 +1,13 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react' -import { CoreAdminContext } from 'ra-core' +import { CoreAdminContext, HttpError } from 'ra-core' import { BrowserList } from './BrowserList' import { theme } from './theme' afterEach(cleanup) const runtimes = [ - { id: 'account-a', alias: 'account-a', name: '店铺一号', state: 'running', status: 'Up', image_version: '148.0.0.1', fingerprint: { seed: 1000 }, endpoint: 'http://account-a:9222' }, + { id: 'account-a', alias: 'account-a', name: '店铺一号', state: 'running', status: 'Up', image_version: '148.0.0.1', fingerprint: { seed: 1000 }, endpoint: 'http://account-a:9222', account_id: 'social-a', network_exit_id: 'exit-1', network_exit_health: 'healthy', schedule_status: 'ready' }, { id: 'account-b', alias: 'account-b', name: '店铺二号', state: 'exited', status: 'Exited', image_version: '148.0.0.1', fingerprint: { seed: 2000 }, endpoint: 'http://account-b:9222' }, ] const gateways = [{ id: 'gw-1', name: 'gw-1', endpoint: 'http://gw:8081', token: 't' }] @@ -103,6 +103,19 @@ describe('BrowserList', () => { expect(screen.queryByLabelText('禁用非代理 UDP')).toBeNull() }) + it('keeps binding input after a 409 conflict', async () => { + const dataProvider = provider({ create: vi.fn().mockRejectedValue(new HttpError('conflict', 409)) }) + render() + await screen.findAllByText('店铺一号') + + fireEvent.change(screen.getByRole('textbox', { name: /环境名称/ }), { target: { value: '冲突环境' } }) + fireEvent.change(screen.getByRole('textbox', { name: /别名/ }), { target: { value: 'conflict-env' } }) + fireEvent.click(screen.getByRole('button', { name: '创建环境' })) + + expect((await screen.findByRole('alert')).textContent).toContain('冲突(409)') + expect(screen.getByRole('textbox', { name: /环境名称/ }).value).toBe('冲突环境') + }) + it('upgrades an env through the version dialog', async () => { const dataProvider = provider() render() diff --git a/web/src/NetworkExitList.jsx b/web/src/NetworkExitList.jsx new file mode 100644 index 0000000..f0eb2d6 --- /dev/null +++ b/web/src/NetworkExitList.jsx @@ -0,0 +1,152 @@ +import { useEffect, useMemo, useState } from 'react' +import { useDataProvider, useGetList } from 'ra-core' +import { + Alert, + Box, + Button, + CircularProgress, + MenuItem, + Paper, + Stack, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + TextField, + Typography, +} from '@mui/material' +import BlockOutlined from '@mui/icons-material/BlockOutlined' +import CheckCircleOutlined from '@mui/icons-material/CheckCircleOutlined' +import ErrorOutlined from '@mui/icons-material/ErrorOutlined' +import PublicOutlined from '@mui/icons-material/PublicOutlined' +import RefreshOutlined from '@mui/icons-material/RefreshOutlined' + +const emptyExit = { protocol: 'socks5', host: '', port: '', credential_reference_id: '', expected_public_ip: '', expected_region: '' } +const healthLabels = { unchecked: '未检测', healthy: '健康', unhealthy: '不健康', disabled: '已停用' } +const wrapAnywhere = { overflowWrap: 'anywhere', minWidth: 0 } + +function actionError(error, conflictMessage) { + if (error?.status === 409) return `冲突(409):${conflictMessage}` + if (error?.status === 503) return `环境不可用(503):${error.message}` + return error?.message || '操作失败' +} + +function environmentError(error) { + return `环境不可用${error?.status ? `(${error.status})` : ''}:无法读取账号绑定,状态暂时未知。` +} + +function ExitHealth({ exit }) { + const known = Object.hasOwn(healthLabels, exit.health_status) + const label = known ? healthLabels[exit.health_status] : '未知状态' + const Icon = exit.health_status === 'healthy' ? CheckCircleOutlined : exit.health_status === 'disabled' ? BlockOutlined : ErrorOutlined + const color = exit.health_status === 'healthy' ? 'success.main' : exit.health_status === 'unchecked' ? 'warning.main' : 'error.main' + return {label}{known ? null : {exit.health_status}} +} + +function ExitActions({ exit, busy, onAction }) { + return +} + +function ExitForm({ busy, onSubmit }) { + const [form, setForm] = useState(emptyExit) + const update = (key, value) => setForm(current => ({ ...current, [key]: value })) + const port = Number(form.port) + const valid = form.host.trim() && Number.isInteger(port) && port > 0 && port <= 65535 + + async function submit(event) { + event.preventDefault() + if (!valid) return + const created = await onSubmit({ + protocol: form.protocol, + host: form.host.trim(), + port, + credential_reference: { id: form.credential_reference_id.trim() }, + expected_public_ip: form.expected_public_ip.trim(), + expected_region: form.expected_region.trim(), + }) + if (created) setForm(emptyExit) + } + + return ( + + update('protocol', event.target.value)} slotProps={{ htmlInput: { 'aria-label': '出口协议' } }}>{['http', 'https', 'socks4', 'socks5'].map(protocol => {protocol})} + update('host', event.target.value)} slotProps={{ htmlInput: { 'aria-label': '出口主机', maxLength: 253 } }} /> + update('port', event.target.value)} slotProps={{ htmlInput: { 'aria-label': '出口端口', min: 1, max: 65535 } }} /> + update('credential_reference_id', event.target.value)} slotProps={{ htmlInput: { 'aria-label': '出口凭据引用 ID', maxLength: 128 } }} helperText="只填写已保存的引用,不填写认证秘密" /> + update('expected_public_ip', event.target.value)} slotProps={{ htmlInput: { 'aria-label': '预期公网 IP' } }} /> + update('expected_region', event.target.value)} slotProps={{ htmlInput: { 'aria-label': '预期地区', maxLength: 64 } }} /> + + + ) +} + +function ExitTable({ exits, bindings, bindingsError, busy, onAction }) { + return
出口健康观测绑定账号操作{exits.map(exit => {exit.protocol}://{exit.host}:{exit.port}{exit.id} · 凭据 {exit.credential_reference?.id || '无'}{exit.observed_public_ip || '尚无观测'}{exit.last_checked_at ? new Date(exit.last_checked_at).toLocaleString('zh-CN') : '未检测'}{bindingsError ? '状态未知' : (bindings.get(exit.id)?.join('、') || '未绑定')})}
+} + +function ExitCards({ exits, bindings, bindingsError, busy, onAction }) { + return {exits.map(exit => {exit.protocol}://{exit.host}:{exit.port}{exit.id} · 凭据 {exit.credential_reference?.id || '无'}观测公网 IP:{exit.observed_public_ip || '尚无观测'}最近检测:{exit.last_checked_at ? new Date(exit.last_checked_at).toLocaleString('zh-CN') : '未检测'}绑定账号:{bindingsError ? '状态未知' : (bindings.get(exit.id)?.join('、') || '未绑定')})} +} + +export function NetworkExitList() { + const dataProvider = useDataProvider() + const [busy, setBusy] = useState('') + const [message, setMessage] = useState(null) + const [filter, setFilter] = useState('') + const { data: exits = [], error, isPending, refetch } = useGetList('network-exits') + const { data: browsers = [], error: browsersError, refetch: refetchBrowsers } = useGetList('browsers', undefined, { retry: false }) + const bindings = useMemo(() => { + const result = new Map() + for (const browser of browsers) { + if (!browser.network_exit_id || !browser.account_id) continue + result.set(browser.network_exit_id, [...(result.get(browser.network_exit_id) || []), browser.account_id]) + } + return result + }, [browsers]) + const needle = filter.trim().toLowerCase() + const visible = needle ? exits.filter(exit => `${exit.id} ${exit.protocol} ${exit.host} ${exit.observed_public_ip || ''}`.toLowerCase().includes(needle)) : exits + + useEffect(() => { document.title = 'CreatorHub · 网络出口' }, []) + + async function createExit(data) { + setBusy('create'); setMessage(null) + try { + await dataProvider.create('network-exits', { data }) + await refetch() + setMessage({ severity: 'success', text: '网络出口已创建;检测通过后才可用于账号绑定。' }) + return true + } catch (reason) { + setMessage({ severity: 'error', text: actionError(reason, '出口或凭据引用与现有资源冲突;表单内容已保留。') }) + return false + } finally { setBusy('') } + } + + async function act(exit, action) { + if (action === 'disable' && !window.confirm(`停用网络出口 ${exit.id}?绑定账号将暂停,排队任务进入 hold。`)) return + setBusy(exit.id); setMessage(null) + try { + await dataProvider.networkExitAction(exit.id, action) + await Promise.all([refetch(), refetchBrowsers()]) + setMessage({ severity: 'success', text: action === 'check' ? '出口检测完成。' : '出口已停用;绑定账号不会自动恢复。' }) + } catch (reason) { + setMessage({ severity: 'error', text: actionError(reason, action === 'check' ? '已停用的出口不能检测。' : '出口当前不能停用。') }) + } finally { setBusy('') } + } + + return ( + <> + 网络出口维护固定出口、健康观测与账号绑定 + + {message ? {message.text} : null} + {error ? {error.message} : null} + {browsersError ? refetchBrowsers()}>重试环境状态} sx={{ mb: 2.5 }}>{environmentError(browsersError)} : null} + {exits.length > 0 ? setFilter(event.target.value)} sx={{ mb: 2.5, width: { xs: '100%', sm: 320 } }} /> : null} + {isPending ? : null} + {!isPending && exits.length === 0 ? 暂无网络出口。创建并检测健康后,才能绑定运行环境。 : null} + {!isPending && exits.length > 0 && visible.length === 0 ? 没有符合筛选条件的网络出口 : null} + {!isPending && visible.length > 0 ? <> : null} + + ) +} diff --git a/web/src/NetworkExitList.test.jsx b/web/src/NetworkExitList.test.jsx new file mode 100644 index 0000000..ec96680 --- /dev/null +++ b/web/src/NetworkExitList.test.jsx @@ -0,0 +1,52 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { CoreAdminContext, HttpError } from 'ra-core' +import { NetworkExitList } from './NetworkExitList' + +afterEach(() => { cleanup(); vi.restoreAllMocks() }) + +const networkExit = { id: 'exit-a', protocol: 'socks5', host: 'proxy.example', port: 1080, health_status: 'healthy', observed_public_ip: '203.0.113.1', credential_reference: { id: 'credential-a', provider: 'os_keyring' } } + +function provider(exits = [], overrides = {}) { + return { + getList: vi.fn(resource => Promise.resolve(resource === 'network-exits' ? { data: exits, total: exits.length } : { data: [], total: 0 })), + create: vi.fn().mockResolvedValue({ data: networkExit }), + networkExitAction: vi.fn().mockResolvedValue(networkExit), + getOne: vi.fn(), getMany: vi.fn(), getManyReference: vi.fn(), update: vi.fn(), updateMany: vi.fn(), delete: vi.fn(), deleteMany: vi.fn(), + ...overrides, + } +} + +describe('NetworkExitList', () => { + it('shows the ordered empty-state instruction', async () => { + render() + expect(await screen.findByText(/创建并检测健康后/)).toBeTruthy() + }) + + it('shows only credential references and runs an explicit health check', async () => { + const dataProvider = provider([networkExit]) + render() + + expect((await screen.findAllByText(/凭据 credential-a/)).length).toBeGreaterThan(0) + expect(screen.queryByText(/password|token|raw-value/i)).toBeNull() + fireEvent.click(screen.getAllByRole('button', { name: '检测' })[0]) + await waitFor(() => expect(dataProvider.networkExitAction).toHaveBeenCalledWith('exit-a', 'check')) + }) + + it('shows unknown bindings and retries when browsers return 502', async () => { + const dataProvider = provider([networkExit], { + getList: vi.fn(resource => resource === 'browsers' + ? Promise.reject(new HttpError('bad gateway', 502)) + : Promise.resolve({ data: [networkExit], total: 1 })), + }) + render() + + expect(await screen.findByText(/环境不可用(502)/)).toBeTruthy() + expect((await screen.findAllByText(/绑定账号:状态未知|^状态未知$/)).length).toBeGreaterThan(0) + expect(screen.queryByText(/绑定账号:未绑定/)).toBeNull() + expect(screen.queryByText(/^未绑定$/)).toBeNull() + expect(screen.getAllByRole('button', { name: '检测' })[0].disabled).toBe(false) + fireEvent.click(screen.getByRole('button', { name: '重试环境状态' })) + await waitFor(() => expect(dataProvider.getList.mock.calls.filter(([resource]) => resource === 'browsers').length).toBeGreaterThan(1)) + }) +}) diff --git a/web/src/dataProvider.js b/web/src/dataProvider.js index 8ff39cb..63d743e 100644 --- a/web/src/dataProvider.js +++ b/web/src/dataProvider.js @@ -36,7 +36,7 @@ export const dataProvider = { const path = resourcePaths[resource] if (!path) return unsupported(resource, 'create') const created = await request(path, jsonOptions('POST', data)) - return { data: { ...data, ...created, id: created?.alias ?? created?.version ?? created?.name ?? data.alias } } + return { data: { ...data, ...created, id: created?.id ?? created?.alias ?? created?.version ?? created?.name ?? data.id ?? data.alias } } }, async update(resource, { id, data }) { const path = resourcePaths[resource] @@ -52,7 +52,12 @@ export const dataProvider = { await request(`${path}/${encodeURIComponent(id)}`, { method: 'DELETE' }) return { data: { id } } }, - getOne: resource => unsupported(resource, 'getOne'), + async getOne(resource, { id }) { + const path = resourcePaths[resource] + if (!path || (resource !== 'accounts' && resource !== 'network-exits')) return unsupported(resource, 'getOne') + const record = await request(`${path}/${encodeURIComponent(id)}`) + return { data: { ...record, id: record.id ?? id } } + }, getMany: resource => unsupported(resource, 'getMany'), getManyReference: resource => unsupported(resource, 'getManyReference'), updateMany: resource => unsupported(resource, 'updateMany'), @@ -72,4 +77,12 @@ export const dataProvider = { if (!target) throw new Error(`未知运行环境操作: ${action}`) await request(target[0], { method: target[1] }) }, + async accountAction(id, action) { + if (action !== 'pause' && action !== 'resume') throw new Error(`未知账号操作: ${action}`) + await request(`/phase-a/accounts/${encodeURIComponent(id)}/${action}`, { method: 'POST' }) + }, + async networkExitAction(id, action) { + if (action !== 'check' && action !== 'disable') throw new Error(`未知网络出口操作: ${action}`) + return request(`/network-exits/${encodeURIComponent(id)}/${action}`, { method: 'POST' }) + }, } diff --git a/web/src/dataProvider.test.js b/web/src/dataProvider.test.js index 3a22f7f..ef31b55 100644 --- a/web/src/dataProvider.test.js +++ b/web/src/dataProvider.test.js @@ -26,6 +26,26 @@ describe('dataProvider', () => { expect(fetch).toHaveBeenCalledWith(path, undefined) }) + it.each([ + ['accounts', 'account-a', '/api/phase-a/accounts/account-a'], + ['network-exits', 'exit/one', '/api/network-exits/exit%2Fone'], + ])('loads %s detail through its stable API path', async (resource, id, path) => { + const fetch = vi.fn().mockResolvedValue(new Response(JSON.stringify({ id }), { status: 200 })) + vi.stubGlobal('fetch', fetch) + + await expect(dataProvider.getOne(resource, { id })).resolves.toEqual({ data: { id } }) + expect(fetch).toHaveBeenCalledWith(path, undefined) + }) + + it('creates an account without a client-generated technical id', async () => { + const fetch = vi.fn().mockResolvedValue(new Response('{"id":"account-generated"}', { status: 201 })) + vi.stubGlobal('fetch', fetch) + const data = { platform: 'mock', platform_account_key: 'shop-a' } + + await expect(dataProvider.create('accounts', { data })).resolves.toEqual({ data: { ...data, id: 'account-generated' } }) + expect(fetch).toHaveBeenCalledWith('/api/phase-a/accounts', expect.objectContaining({ method: 'POST' })) + }) + it.each([ ['start', '/api/browsers/account-a/start', 'POST'], ['stop', '/api/browsers/account-a/stop', 'POST'], @@ -43,4 +63,23 @@ describe('dataProvider', () => { await expect(dataProvider.getList('browsers')).rejects.toMatchObject({ message: '网关不可用', status: 502 }) }) + + it.each([409, 503])('preserves structured action errors for status %s', async status => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(JSON.stringify({ error: 'blocked', reason_code: 'network_exit_unhealthy' }), { status }))) + + await expect(dataProvider.accountAction('account-a', 'resume')).rejects.toMatchObject({ + message: 'blocked', status, body: { error: 'blocked', reason_code: 'network_exit_unhealthy' }, + }) + }) + + it.each([ + ['pause', '/api/phase-a/accounts/account-a/pause'], + ['resume', '/api/phase-a/accounts/account-a/resume'], + ])('sends explicit account action %s', async (action, path) => { + const fetch = vi.fn().mockResolvedValue(new Response(null, { status: 204 })) + vi.stubGlobal('fetch', fetch) + + await dataProvider.accountAction('account-a', action) + expect(fetch).toHaveBeenCalledWith(path, { method: 'POST' }) + }) }) diff --git a/web/src/layout.jsx b/web/src/layout.jsx index 12fea15..d0738ef 100644 --- a/web/src/layout.jsx +++ b/web/src/layout.jsx @@ -14,11 +14,13 @@ import { } from '@mui/material' import ChevronLeft from '@mui/icons-material/ChevronLeft' import ChevronRight from '@mui/icons-material/ChevronRight' +import AccountCircleOutlined from '@mui/icons-material/AccountCircleOutlined' import DnsOutlined from '@mui/icons-material/DnsOutlined' import HistoryOutlined from '@mui/icons-material/HistoryOutlined' import HubOutlined from '@mui/icons-material/HubOutlined' import Inventory2Outlined from '@mui/icons-material/Inventory2Outlined' import MenuIcon from '@mui/icons-material/Menu' +import PublicOutlined from '@mui/icons-material/PublicOutlined' import WidgetsOutlined from '@mui/icons-material/WidgetsOutlined' import { useTheme } from '@mui/material/styles' @@ -32,11 +34,25 @@ export function CreatorHubMenu({ collapsed, onNavigate }) { {collapsed ? null : CreatorHub} - + + {collapsed ? null : 运营} + + + {collapsed ? null : } + + + + {collapsed ? null : } + + {collapsed ? null : 资源} {collapsed ? null : } + + + {collapsed ? null : } + {collapsed ? null : } @@ -45,10 +61,6 @@ export function CreatorHubMenu({ collapsed, onNavigate }) { {collapsed ? null : } - - - {collapsed ? null : } - ) diff --git a/web/src/main.jsx b/web/src/main.jsx index 69cb527..281a897 100644 --- a/web/src/main.jsx +++ b/web/src/main.jsx @@ -1,13 +1,16 @@ import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' // react-admin 5.15.1 publishes an incomplete MUI barrel; ra-core is its supported headless entry point. -import { CoreAdmin, Resource } from 'ra-core' +import { CoreAdmin, CustomRoutes, Resource } from 'ra-core' import { CssBaseline, ThemeProvider } from '@mui/material' +import { Route } from 'react-router-dom' +import { AccountDetail, AccountList } from './AccountList' import { BrowserImageList } from './BrowserImageList' import { BrowserList } from './BrowserList' import { dataProvider } from './dataProvider' import { GatewayList } from './GatewayList' import { CreatorHubLayout } from './layout' +import { NetworkExitList } from './NetworkExitList' import { theme } from './theme' import './styles.css' @@ -16,9 +19,12 @@ createRoot(document.getElementById('root')).render( + + + } /> , diff --git a/web/src/theme.js b/web/src/theme.js index dd91f65..17f4ea3 100644 --- a/web/src/theme.js +++ b/web/src/theme.js @@ -21,7 +21,7 @@ export const theme = createTheme({ components: { MuiButton: { defaultProps: { disableElevation: true }, - styleOverrides: { root: { borderRadius: 7, minHeight: 40 } }, + styleOverrides: { root: { borderRadius: 7, minHeight: 44 } }, }, MuiOutlinedInput: { styleOverrides: { root: { borderRadius: 7 } }, diff --git a/web/tests/responsive.e2e.js b/web/tests/responsive.e2e.js index e28c11b..e1b5839 100644 --- a/web/tests/responsive.e2e.js +++ b/web/tests/responsive.e2e.js @@ -19,7 +19,7 @@ test('keeps the create form inside a 900px viewport', async ({ page }) => { await page.route('**/api/phase-a/accounts', route => route.fulfill({ json: [] })) await page.route('**/api/network-exits', route => route.fulfill({ json: [] })) await page.setViewportSize({ width: 900, height: 800 }) - await page.goto('/') + await page.goto('/#/browsers') await expect(page.getByRole('button', { name: '收起导航' }).first()).toBeVisible() const createButton = page.getByRole('button', { name: '创建环境' }) @@ -39,7 +39,7 @@ test('shows the CDP endpoint only in the active branch at 900px and 599px', asyn const endpoint = page.getByText('http://account-a:9222') await page.setViewportSize({ width: 900, height: 800 }) - await page.goto('/') + await page.goto('/#/browsers') await expect(page.getByRole('cell', { name: 'http://account-a:9222' })).toBeVisible() await expect(endpoint.filter({ visible: true })).toHaveCount(1) @@ -47,3 +47,39 @@ test('shows the CDP endpoint only in the active branch at 900px and 599px', asyn await expect(page.getByRole('cell', { name: 'http://account-a:9222' })).toBeHidden() await expect(endpoint.filter({ visible: true })).toHaveCount(1) }) + +test('keeps account and network-exit pages inside 599px, 900px and 1280px', async ({ page }) => { + const accountKey = 'a'.repeat(128) + const credentialID = 'c'.repeat(128) + const hostname = [63, 63, 63, 61].map(length => 'h'.repeat(length)).join('.') + const account = { id: 'account-a', platform: 'mock', platform_account_key: accountKey, authorization_kind: 'owned', authorization_status: 'authorized', runtime_status: 'paused', version: 1, credential_reference: { id: credentialID, provider: 'os_keyring' } } + const binding = { alias: 'env-a', name: '店铺环境', account_id: 'account-a', network_exit_id: 'exit-a', network_exit_health: 'healthy', binding_version: 1, schedule_status: 'blocked', schedule_block_reason: 'account_paused' } + const networkExit = { id: 'exit-a', protocol: 'socks5', host: hostname, port: 1080, health_status: 'healthy', credential_reference: { id: credentialID, provider: 'os_keyring' } } + await page.route('**/api/phase-a/accounts', route => route.fulfill({ json: [account] })) + await page.route('**/api/phase-a/accounts/account-a', route => route.fulfill({ json: account })) + await page.route('**/api/browsers', route => route.fulfill({ json: [binding] })) + await page.route('**/api/network-exits', route => route.fulfill({ json: [networkExit] })) + + for (const path of ['/#/accounts', '/#/accounts/account-a', '/#/network-exits']) { + for (const width of [599, 900, 1280]) { + await page.setViewportSize({ width, height: 900 }) + await page.goto(path) + await expect(page.getByRole('heading', { level: 1 })).toBeVisible() + expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(width) + } + } +}) + +test('opens account detail at the phase A route', async ({ page }) => { + const account = { id: 'account-a', platform: 'mock', platform_account_key: 'shop-a', authorization_kind: 'owned', authorization_status: 'authorized', runtime_status: 'paused', version: 1, credential_reference: { id: 'credential-a', provider: 'os_keyring' } } + await page.route('**/api/phase-a/accounts', route => route.fulfill({ json: [account] })) + await page.route('**/api/phase-a/accounts/account-a', route => route.fulfill({ json: account })) + await page.route('**/api/browsers', route => route.fulfill({ json: [] })) + await page.setViewportSize({ width: 599, height: 900 }) + await page.goto('/#/accounts') + + await page.getByRole('link', { name: '查看账号' }).click() + await expect(page).toHaveURL(/#\/accounts\/account-a$/) + await expect(page.getByRole('heading', { level: 1, name: 'shop-a' })).toBeVisible() + expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(599) +})