HH-845 / HH-799: add phase A account resource pages (#23)
This commit is contained in:
+51
-11
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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 <Stack direction="row" spacing={1} sx={{ alignItems: 'center', color }}><Icon fontSize="small" /><Typography component="span" variant="body2" fontWeight={650}>{readiness.label}</Typography></Stack>
|
||||
}
|
||||
|
||||
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 (
|
||||
<Paper component="form" onSubmit={submit} variant="outlined" sx={{ display: 'grid', gridTemplateColumns: { xs: 'minmax(0, 1fr)', md: 'repeat(2, minmax(0, 1fr))', lg: 'repeat(3, minmax(0, 1fr))' }, gap: 2.5, p: { xs: 2.5, md: 3 }, mb: 3.5, alignItems: 'start' }}>
|
||||
<TextField required label="平台" value={form.platform} onChange={event => update('platform', event.target.value)} slotProps={{ htmlInput: { pattern: '[a-z0-9][a-z0-9-]{0,31}', 'aria-label': '平台' } }} helperText="阶段 A 使用虚拟平台标识,如 mock" />
|
||||
<TextField required label="平台账号标识" value={form.platform_account_key} onChange={event => update('platform_account_key', event.target.value)} slotProps={{ htmlInput: { 'aria-label': '平台账号标识', maxLength: 128 } }} />
|
||||
<TextField select label="授权类型" value={form.authorization_kind} onChange={event => update('authorization_kind', event.target.value)} slotProps={{ htmlInput: { 'aria-label': '授权类型' } }}>
|
||||
<MenuItem value="owned">自有账号</MenuItem><MenuItem value="authorized">已获授权</MenuItem>
|
||||
</TextField>
|
||||
<TextField required label="凭据引用 ID" value={form.credential_id} onChange={event => update('credential_id', event.target.value)} slotProps={{ htmlInput: { 'aria-label': '凭据引用 ID', maxLength: 128 } }} />
|
||||
<TextField select label="凭据引用来源" value={form.credential_provider} onChange={event => update('credential_provider', event.target.value)} slotProps={{ htmlInput: { 'aria-label': '凭据引用来源' } }}>
|
||||
<MenuItem value="os_keyring">系统密钥环</MenuItem><MenuItem value="secret_manager">Secret Manager</MenuItem>
|
||||
</TextField>
|
||||
<TextField required label="凭据引用键" value={form.credential_key} onChange={event => update('credential_key', event.target.value)} slotProps={{ htmlInput: { 'aria-label': '凭据引用键', maxLength: 127 } }} helperText="仅填写引用路径,不填写密码、Cookie 或 token" />
|
||||
<Button type="submit" variant="contained" size="large" disabled={!valid || busy} sx={{ minHeight: 52, gridColumn: { md: '1 / -1', lg: 'auto' } }}>{busy ? '创建中…' : '创建账号'}</Button>
|
||||
</Paper>
|
||||
)
|
||||
}
|
||||
|
||||
function AccountTable({ accounts, bindings, bindingsError }) {
|
||||
return (
|
||||
<TableContainer component={Paper} variant="outlined" sx={{ display: { xs: 'none', md: 'block' } }}>
|
||||
<Table sx={{ tableLayout: 'fixed' }}>
|
||||
<TableHead><TableRow><TableCell width="24%">账号</TableCell><TableCell width="16%">授权</TableCell><TableCell width="26%">资源就绪度</TableCell><TableCell width="22%">固定资源</TableCell><TableCell width="12%">操作</TableCell></TableRow></TableHead>
|
||||
<TableBody>{accounts.map(account => {
|
||||
const binding = bindings.get(account.id)
|
||||
return <TableRow key={account.id}><TableCell><Typography fontWeight={700} sx={wrapAnywhere}>{account.platform_account_key}</Typography><Typography variant="caption" color="text.secondary" sx={wrapAnywhere}>{account.id} · {account.platform}</Typography></TableCell><TableCell>{account.authorization_status === 'authorized' ? '已授权' : '已撤销'} · {account.runtime_status === 'active' ? '启用' : '暂停'}</TableCell><TableCell><AccountState account={account} binding={binding} bindingError={bindingsError} /></TableCell><TableCell><Typography variant="body2" sx={wrapAnywhere}>{bindingsError ? '状态未知' : (binding?.name || '—')}</Typography><Typography variant="caption" color="text.secondary" sx={wrapAnywhere}>{bindingsError ? '环境数据不可用' : (binding?.network_exit_id || '未绑定出口')}</Typography></TableCell><TableCell><Button component={RouterLink} to={`/accounts/${account.id}`} size="small">查看</Button></TableCell></TableRow>
|
||||
})}</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
)
|
||||
}
|
||||
|
||||
function AccountCards({ accounts, bindings, bindingsError }) {
|
||||
return <Stack spacing={2} sx={{ display: { xs: 'flex', md: 'none' } }}>{accounts.map(account => {
|
||||
const binding = bindings.get(account.id)
|
||||
return <Paper key={account.id} variant="outlined" sx={{ p: 2.5, minWidth: 0 }}><Stack spacing={1.5} sx={{ minWidth: 0 }}><Box sx={{ minWidth: 0 }}><Typography fontWeight={700} sx={wrapAnywhere}>{account.platform_account_key}</Typography><Typography variant="caption" color="text.secondary" sx={wrapAnywhere}>{account.id} · {account.platform}</Typography></Box><Typography variant="body2">授权:{account.authorization_status === 'authorized' ? '已授权' : '已撤销'} · 账号:{account.runtime_status === 'active' ? '启用' : '暂停'}</Typography><AccountState account={account} binding={binding} bindingError={bindingsError} /><Typography variant="body2" sx={wrapAnywhere}>{bindingsError ? '运行环境与固定出口:状态未知' : `运行环境:${binding?.name || '未绑定'} · 固定出口:${binding?.network_exit_id || '未绑定'}`}</Typography><Button component={RouterLink} to={`/accounts/${account.id}`} variant="outlined">查看账号</Button></Stack></Paper>
|
||||
})}</Stack>
|
||||
}
|
||||
|
||||
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 (
|
||||
<>
|
||||
<Box component="header" sx={{ mb: 4 }}><Stack direction="row" spacing={1.5} sx={{ alignItems: 'center' }}><AccountCircleOutlined color="primary" fontSize="large" /><Typography variant="h1">账号池</Typography></Stack><Typography color="text.secondary" sx={{ mt: 1 }}>管理账号授权、暂停状态与固定运行资源</Typography></Box>
|
||||
<AccountForm busy={busy} onSubmit={createAccount} />
|
||||
{message ? <Alert severity={message.severity} aria-live="polite" sx={{ mb: 2.5 }}>{message.text}</Alert> : null}
|
||||
{error ? <Alert severity="error" sx={{ mb: 2.5 }}>{actionError(error, '账号列表冲突')}</Alert> : null}
|
||||
{browsersError ? <Alert severity="error" action={<Button color="inherit" size="small" onClick={() => refetchBrowsers()}>重试环境状态</Button>} sx={{ mb: 2.5 }}>{environmentError(browsersError)}</Alert> : null}
|
||||
{accounts.length > 0 ? <TextField label="筛选账号" size="small" value={filter} onChange={event => setFilter(event.target.value)} sx={{ mb: 2.5, width: { xs: '100%', sm: 320 } }} /> : null}
|
||||
{isPending ? <Box sx={{ display: 'grid', placeItems: 'center', minHeight: 220 }}><CircularProgress aria-label="正在加载账号" /></Box> : null}
|
||||
{!isPending && accounts.length === 0 ? <Paper variant="outlined" sx={{ py: 7, px: 2, textAlign: 'center', color: 'text.secondary' }}>暂无账号。创建账号后,再前往运行环境绑定健康网络出口。</Paper> : null}
|
||||
{!isPending && accounts.length > 0 && visible.length === 0 ? <Paper variant="outlined" sx={{ py: 6, textAlign: 'center' }}><Typography color="text.secondary">没有符合筛选条件的账号</Typography><Button onClick={() => setFilter('')} sx={{ mt: 1 }}>清除筛选</Button></Paper> : null}
|
||||
{!isPending && visible.length > 0 ? <><AccountTable accounts={visible} bindings={bindings} bindingsError={browsersError} /><AccountCards accounts={visible} bindings={bindings} bindingsError={browsersError} /></> : 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 <Box sx={{ display: 'grid', placeItems: 'center', minHeight: 300 }}><CircularProgress aria-label="正在加载账号详情" /></Box>
|
||||
if (error || !account) return <Alert severity="error">{error?.message || '账号不存在'}</Alert>
|
||||
return (
|
||||
<>
|
||||
<Box component="header" sx={{ mb: 4, minWidth: 0 }}><Button component={RouterLink} to="/accounts" sx={{ mb: 1 }}>← 返回账号池</Button><Typography variant="h1" sx={wrapAnywhere}>{account.platform_account_key}</Typography><Typography color="text.secondary" sx={{ ...wrapAnywhere, mt: 1 }}>{account.id} · {account.platform}</Typography></Box>
|
||||
{message ? <Alert severity={message.severity} aria-live="polite" sx={{ mb: 2.5 }}>{message.text}</Alert> : null}
|
||||
{browsersError ? <Alert severity="error" action={<Button color="inherit" size="small" onClick={() => refetchBrowsers()}>重试环境状态</Button>} sx={{ mb: 2.5 }}>{environmentError(browsersError)}</Alert> : null}
|
||||
<Alert severity="warning" icon={<PauseCircleOutlined />} sx={{ mb: 2.5 }}>暂停会把待领取任务置为 hold;恢复账号只恢复账号可用性,不会自动恢复既有 hold。</Alert>
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: 'minmax(0, 1fr)', lg: 'repeat(2, minmax(0, 1fr))' }, gap: 2.5 }}>
|
||||
<Paper variant="outlined" sx={{ p: 3, minWidth: 0 }}><Stack spacing={2} sx={{ minWidth: 0 }}><Typography variant="h6">账号状态</Typography><AccountState account={account} binding={binding} bindingError={browsersError} /><Typography>授权:{account.authorization_status === 'authorized' ? '已授权' : '已撤销'}({account.authorization_kind})</Typography><Typography>运行:{account.runtime_status === 'active' ? '启用' : '暂停'} · 版本 {account.version}</Typography><Typography sx={wrapAnywhere}>凭据引用:{account.credential_reference?.id} · {account.credential_reference?.provider}</Typography><Stack direction={{ xs: 'column', sm: 'row' }} spacing={1.5}><Button variant="outlined" color="warning" disabled={busy || account.runtime_status === 'paused'} onClick={() => act('pause')}>暂停账号</Button><Button variant="contained" disabled={busy || !readiness.canResume} onClick={() => act('resume')}>恢复账号</Button></Stack></Stack></Paper>
|
||||
<Paper variant="outlined" sx={{ p: 3, minWidth: 0 }}><Stack spacing={2} sx={{ minWidth: 0 }}><Typography variant="h6">固定资源</Typography>{browsersError ? <Typography color="text.secondary">运行环境、固定出口与 readiness 状态未知;重试成功后再执行依赖资源状态的操作。</Typography> : binding ? <><Typography sx={wrapAnywhere}>运行环境:{binding.name}({binding.alias})</Typography><Typography sx={wrapAnywhere}>固定出口:{binding.network_exit_id || '未绑定'} · {binding.network_exit_health || '未知状态'}</Typography><Typography>绑定版本:{binding.binding_version}</Typography><Typography sx={wrapAnywhere}>不可调度原因:{binding.schedule_block_reason ? (reasonLabels[binding.schedule_block_reason] || binding.schedule_block_reason) : '无'}</Typography><Button component={RouterLink} to="/browsers" variant="outlined">查看运行环境</Button></> : <><Typography color="text.secondary">尚未绑定运行环境与固定出口,因此不能恢复或排队。</Typography><Button component={RouterLink} to="/browsers" variant="contained">绑定运行环境与出口</Button></>}</Stack></Paper>
|
||||
</Box>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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(<MemoryRouter><CoreAdminContext dataProvider={dataProvider}><AccountList /></CoreAdminContext></MemoryRouter>)
|
||||
}
|
||||
|
||||
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(<MemoryRouter initialEntries={['/accounts/account-a']}><CoreAdminContext dataProvider={dataProvider}><Routes><Route path="/accounts/:id" element={<AccountDetail />} /></Routes></CoreAdminContext></MemoryRouter>)
|
||||
|
||||
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(<MemoryRouter initialEntries={['/accounts/account-a']}><CoreAdminContext dataProvider={dataProvider}><Routes><Route path="/accounts/:id" element={<AccountDetail />} /></Routes></CoreAdminContext></MemoryRouter>)
|
||||
|
||||
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))
|
||||
})
|
||||
})
|
||||
+27
-7
@@ -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 <Stack direction="row" spacing={0.75} sx={{ alignItems: 'center', color: ready ? 'success.main' : 'warning.main' }}>{ready ? <CheckCircleOutlined fontSize="small" /> : <ReportProblemOutlined fontSize="small" />}<Typography variant="caption" fontWeight={650}>{ready ? '可调度' : (scheduleReasonLabels[reason] || reason || '不可调度')}</Typography></Stack>
|
||||
}
|
||||
|
||||
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 (
|
||||
<TableContainer component={Paper} variant="outlined" sx={{ borderRadius: 1, display: { xs: 'none', md: 'block' } }}>
|
||||
<Table sx={{ tableLayout: 'fixed', '& th, & td': { py: 2.25 } }}>
|
||||
<TableHead><TableRow><TableCell width="18%">环境</TableCell><TableCell width="11%">状态</TableCell><TableCell width="13%">镜像版本</TableCell><TableCell width="22%">CDP</TableCell><TableCell width="36%">操作</TableCell></TableRow></TableHead>
|
||||
<TableHead><TableRow><TableCell width="19%">环境</TableCell><TableCell width="12%">状态</TableCell><TableCell width="23%">固定绑定</TableCell><TableCell width="16%">CDP</TableCell><TableCell width="30%">操作</TableCell></TableRow></TableHead>
|
||||
<TableBody>
|
||||
{runtimes.map(runtime => (
|
||||
<TableRow key={runtime.alias} sx={{ '&:last-child td': { borderBottom: 0 } }}>
|
||||
<TableCell><Typography fontWeight={650}>{runtime.name}</Typography><Typography variant="caption" color="text.secondary">{runtime.alias} · seed {runtime.fingerprint?.seed}</Typography></TableCell>
|
||||
<TableCell><Status state={runtime.state} /></TableCell>
|
||||
<TableCell><Typography variant="body2">{runtime.image_version}</Typography></TableCell>
|
||||
<TableCell><Typography variant="body2">{runtime.account_id || '未绑定账号'}</Typography><Typography variant="caption" color="text.secondary">{runtime.network_exit_id || '未绑定出口'} · {runtime.image_version}</Typography><BindingReadiness runtime={runtime} /></TableCell>
|
||||
<TableCell>{runtime.endpoint ? <Copyable value={runtime.endpoint} /> : <Typography variant="body2" color="text.secondary">—</Typography>}</TableCell>
|
||||
<TableCell><RuntimeActions runtime={runtime} busy={busy === runtime.alias} onAction={onAction} /></TableCell>
|
||||
</TableRow>
|
||||
@@ -292,7 +310,7 @@ function RuntimeTable({ runtimes, busy, onAction }) {
|
||||
}
|
||||
|
||||
function RuntimeCards({ runtimes, busy, onAction }) {
|
||||
return <Stack spacing={2} sx={{ display: { xs: 'flex', md: 'none' } }}>{runtimes.map(runtime => <Paper key={runtime.alias} variant="outlined" sx={{ p: 2.5 }}><Stack spacing={1.5}><Box><Typography fontWeight={700}>{runtime.name}</Typography><Typography variant="caption" color="text.secondary">{runtime.alias} · {runtime.image_version} · seed {runtime.fingerprint?.seed}</Typography></Box><Status state={runtime.state} />{runtime.endpoint ? <Copyable value={runtime.endpoint} /> : null}<RuntimeActions runtime={runtime} busy={busy === runtime.alias} onAction={onAction} /></Stack></Paper>)}</Stack>
|
||||
return <Stack spacing={2} sx={{ display: { xs: 'flex', md: 'none' } }}>{runtimes.map(runtime => <Paper key={runtime.alias} variant="outlined" sx={{ p: 2.5 }}><Stack spacing={1.5}><Box><Typography fontWeight={700}>{runtime.name}</Typography><Typography variant="caption" color="text.secondary">{runtime.alias} · {runtime.image_version} · seed {runtime.fingerprint?.seed}</Typography></Box><Status state={runtime.state} /><Typography variant="body2">账号:{runtime.account_id || '未绑定'} · 固定出口:{runtime.network_exit_id || '未绑定'}</Typography><BindingReadiness runtime={runtime} />{runtime.endpoint ? <Copyable value={runtime.endpoint} /> : null}<RuntimeActions runtime={runtime} busy={busy === runtime.alias} onAction={onAction} /></Stack></Paper>)}</Stack>
|
||||
}
|
||||
|
||||
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('')
|
||||
}
|
||||
|
||||
@@ -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(<CoreAdminContext dataProvider={dataProvider}><BrowserList /></CoreAdminContext>)
|
||||
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(<CoreAdminContext dataProvider={dataProvider}><BrowserList /></CoreAdminContext>)
|
||||
|
||||
@@ -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 <Stack direction="row" spacing={1} sx={{ alignItems: 'center', color }}><Icon fontSize="small" /><Box><Typography component="span" variant="body2" fontWeight={650}>{label}</Typography>{known ? null : <Typography variant="caption" display="block" color="text.secondary">{exit.health_status}</Typography>}</Box></Stack>
|
||||
}
|
||||
|
||||
function ExitActions({ exit, busy, onAction }) {
|
||||
return <Stack direction="row" spacing={1} useFlexGap sx={{ flexWrap: 'wrap' }}><Button size="small" variant="outlined" startIcon={<RefreshOutlined />} disabled={busy || exit.health_status === 'disabled'} onClick={() => onAction(exit, 'check')}>检测</Button><Button size="small" variant="outlined" color="error" startIcon={<BlockOutlined />} disabled={busy || exit.health_status === 'disabled'} onClick={() => onAction(exit, 'disable')}>停用</Button></Stack>
|
||||
}
|
||||
|
||||
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 (
|
||||
<Paper component="form" onSubmit={submit} variant="outlined" sx={{ display: 'grid', gridTemplateColumns: { xs: 'minmax(0, 1fr)', md: 'repeat(2, minmax(0, 1fr))', lg: 'repeat(3, minmax(0, 1fr))' }, gap: 2.5, p: { xs: 2.5, md: 3 }, mb: 3.5, alignItems: 'start' }}>
|
||||
<TextField select label="协议" value={form.protocol} onChange={event => update('protocol', event.target.value)} slotProps={{ htmlInput: { 'aria-label': '出口协议' } }}>{['http', 'https', 'socks4', 'socks5'].map(protocol => <MenuItem key={protocol} value={protocol}>{protocol}</MenuItem>)}</TextField>
|
||||
<TextField required label="主机" value={form.host} onChange={event => update('host', event.target.value)} slotProps={{ htmlInput: { 'aria-label': '出口主机', maxLength: 253 } }} />
|
||||
<TextField required type="number" label="端口" value={form.port} onChange={event => update('port', event.target.value)} slotProps={{ htmlInput: { 'aria-label': '出口端口', min: 1, max: 65535 } }} />
|
||||
<TextField label="凭据引用 ID(可选)" value={form.credential_reference_id} onChange={event => update('credential_reference_id', event.target.value)} slotProps={{ htmlInput: { 'aria-label': '出口凭据引用 ID', maxLength: 128 } }} helperText="只填写已保存的引用,不填写认证秘密" />
|
||||
<TextField label="预期公网 IP(可选)" value={form.expected_public_ip} onChange={event => update('expected_public_ip', event.target.value)} slotProps={{ htmlInput: { 'aria-label': '预期公网 IP' } }} />
|
||||
<TextField label="预期地区(可选)" value={form.expected_region} onChange={event => update('expected_region', event.target.value)} slotProps={{ htmlInput: { 'aria-label': '预期地区', maxLength: 64 } }} />
|
||||
<Button type="submit" variant="contained" size="large" disabled={!valid || busy} sx={{ minHeight: 52, gridColumn: { md: '1 / -1', lg: 'auto' } }}>{busy ? '创建中…' : '创建网络出口'}</Button>
|
||||
</Paper>
|
||||
)
|
||||
}
|
||||
|
||||
function ExitTable({ exits, bindings, bindingsError, busy, onAction }) {
|
||||
return <TableContainer component={Paper} variant="outlined" sx={{ display: { xs: 'none', md: 'block' } }}><Table sx={{ tableLayout: 'fixed' }}><TableHead><TableRow><TableCell width="24%">出口</TableCell><TableCell width="16%">健康</TableCell><TableCell width="22%">观测</TableCell><TableCell width="20%">绑定账号</TableCell><TableCell width="18%">操作</TableCell></TableRow></TableHead><TableBody>{exits.map(exit => <TableRow key={exit.id}><TableCell><Typography fontWeight={700} sx={wrapAnywhere}>{exit.protocol}://{exit.host}:{exit.port}</Typography><Typography variant="caption" color="text.secondary" sx={wrapAnywhere}>{exit.id} · 凭据 {exit.credential_reference?.id || '无'}</Typography></TableCell><TableCell><ExitHealth exit={exit} /></TableCell><TableCell><Typography variant="body2">{exit.observed_public_ip || '尚无观测'}</Typography><Typography variant="caption" color="text.secondary">{exit.last_checked_at ? new Date(exit.last_checked_at).toLocaleString('zh-CN') : '未检测'}</Typography></TableCell><TableCell><Typography variant="body2" sx={wrapAnywhere}>{bindingsError ? '状态未知' : (bindings.get(exit.id)?.join('、') || '未绑定')}</Typography></TableCell><TableCell><ExitActions exit={exit} busy={busy === exit.id} onAction={onAction} /></TableCell></TableRow>)}</TableBody></Table></TableContainer>
|
||||
}
|
||||
|
||||
function ExitCards({ exits, bindings, bindingsError, busy, onAction }) {
|
||||
return <Stack spacing={2} sx={{ display: { xs: 'flex', md: 'none' } }}>{exits.map(exit => <Paper key={exit.id} variant="outlined" sx={{ p: 2.5, minWidth: 0 }}><Stack spacing={1.5} sx={{ minWidth: 0 }}><Box sx={{ minWidth: 0 }}><Typography fontWeight={700} sx={wrapAnywhere}>{exit.protocol}://{exit.host}:{exit.port}</Typography><Typography variant="caption" color="text.secondary" sx={wrapAnywhere}>{exit.id} · 凭据 {exit.credential_reference?.id || '无'}</Typography></Box><ExitHealth exit={exit} /><Typography variant="body2">观测公网 IP:{exit.observed_public_ip || '尚无观测'}</Typography><Typography variant="body2">最近检测:{exit.last_checked_at ? new Date(exit.last_checked_at).toLocaleString('zh-CN') : '未检测'}</Typography><Typography variant="body2" sx={wrapAnywhere}>绑定账号:{bindingsError ? '状态未知' : (bindings.get(exit.id)?.join('、') || '未绑定')}</Typography><ExitActions exit={exit} busy={busy === exit.id} onAction={onAction} /></Stack></Paper>)}</Stack>
|
||||
}
|
||||
|
||||
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 (
|
||||
<>
|
||||
<Box component="header" sx={{ mb: 4 }}><Stack direction="row" spacing={1.5} sx={{ alignItems: 'center' }}><PublicOutlined color="primary" fontSize="large" /><Typography variant="h1">网络出口</Typography></Stack><Typography color="text.secondary" sx={{ mt: 1 }}>维护固定出口、健康观测与账号绑定</Typography></Box>
|
||||
<ExitForm busy={busy === 'create'} onSubmit={createExit} />
|
||||
{message ? <Alert severity={message.severity} aria-live="polite" sx={{ mb: 2.5 }}>{message.text}</Alert> : null}
|
||||
{error ? <Alert severity="error" sx={{ mb: 2.5 }}>{error.message}</Alert> : null}
|
||||
{browsersError ? <Alert severity="error" action={<Button color="inherit" size="small" onClick={() => refetchBrowsers()}>重试环境状态</Button>} sx={{ mb: 2.5 }}>{environmentError(browsersError)}</Alert> : null}
|
||||
{exits.length > 0 ? <TextField label="筛选网络出口" size="small" value={filter} onChange={event => setFilter(event.target.value)} sx={{ mb: 2.5, width: { xs: '100%', sm: 320 } }} /> : null}
|
||||
{isPending ? <Box sx={{ display: 'grid', placeItems: 'center', minHeight: 220 }}><CircularProgress aria-label="正在加载网络出口" /></Box> : null}
|
||||
{!isPending && exits.length === 0 ? <Paper variant="outlined" sx={{ py: 7, px: 2, textAlign: 'center', color: 'text.secondary' }}>暂无网络出口。创建并检测健康后,才能绑定运行环境。</Paper> : null}
|
||||
{!isPending && exits.length > 0 && visible.length === 0 ? <Paper variant="outlined" sx={{ py: 6, textAlign: 'center' }}><Typography color="text.secondary">没有符合筛选条件的网络出口</Typography><Button onClick={() => setFilter('')} sx={{ mt: 1 }}>清除筛选</Button></Paper> : null}
|
||||
{!isPending && visible.length > 0 ? <><ExitTable exits={visible} bindings={bindings} bindingsError={browsersError} busy={busy} onAction={act} /><ExitCards exits={visible} bindings={bindings} bindingsError={browsersError} busy={busy} onAction={act} /></> : null}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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(<CoreAdminContext dataProvider={provider()}><NetworkExitList /></CoreAdminContext>)
|
||||
expect(await screen.findByText(/创建并检测健康后/)).toBeTruthy()
|
||||
})
|
||||
|
||||
it('shows only credential references and runs an explicit health check', async () => {
|
||||
const dataProvider = provider([networkExit])
|
||||
render(<CoreAdminContext dataProvider={dataProvider}><NetworkExitList /></CoreAdminContext>)
|
||||
|
||||
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(<CoreAdminContext dataProvider={dataProvider}><NetworkExitList /></CoreAdminContext>)
|
||||
|
||||
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))
|
||||
})
|
||||
})
|
||||
+15
-2
@@ -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' })
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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' })
|
||||
})
|
||||
})
|
||||
|
||||
+17
-5
@@ -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 }) {
|
||||
<WidgetsOutlined sx={{ color: '#1680ff', fontSize: 30 }} />
|
||||
{collapsed ? null : <Typography variant="h6" sx={{ color: '#fff', fontWeight: 760 }}>CreatorHub</Typography>}
|
||||
</Box>
|
||||
<Box component="nav" aria-label="主导航" sx={{ px: 1.5, pt: 4 }}>
|
||||
<Box component="nav" aria-label="主导航" sx={{ px: 1.5, pt: 2 }}>
|
||||
{collapsed ? null : <Typography variant="overline" sx={{ display: 'block', px: 1.25, mb: 0.75, color: '#7f94b2', fontWeight: 700 }}>运营</Typography>}
|
||||
<ListItemButton component={NavLink} to="/accounts" aria-label="账号池" onClick={onNavigate} sx={{ minHeight: 52, mb: 1.25, borderRadius: 1, color: '#a9b7cc', justifyContent: collapsed ? 'center' : 'flex-start', '&.active': { color: '#fff', bgcolor: '#0866ef' }, '&:hover': { bgcolor: '#0b2a54' }, '&.active:hover': { bgcolor: '#0866ef' } }}>
|
||||
<ListItemIcon sx={{ minWidth: collapsed ? 0 : 42, color: 'inherit' }}><AccountCircleOutlined /></ListItemIcon>
|
||||
{collapsed ? null : <ListItemText primary="账号池" />}
|
||||
</ListItemButton>
|
||||
<ListItemButton disabled aria-label="审计记录" sx={{ minHeight: 52, mb: 2, borderRadius: 1, color: '#a9b7cc', justifyContent: collapsed ? 'center' : 'flex-start', '&.Mui-disabled': { opacity: 0.58 } }}>
|
||||
<ListItemIcon sx={{ minWidth: collapsed ? 0 : 42, color: 'inherit' }}><HistoryOutlined /></ListItemIcon>
|
||||
{collapsed ? null : <ListItemText primary="审计记录" />}
|
||||
</ListItemButton>
|
||||
{collapsed ? null : <Typography variant="overline" sx={{ display: 'block', px: 1.25, mb: 0.75, color: '#7f94b2', fontWeight: 700 }}>资源</Typography>}
|
||||
<ListItemButton component={NavLink} to="/browsers" aria-label="运行环境" onClick={onNavigate} sx={{ minHeight: 52, mb: 1.25, borderRadius: 1, color: '#a9b7cc', justifyContent: collapsed ? 'center' : 'flex-start', '&.active': { color: '#fff', bgcolor: '#0866ef' }, '&:hover': { bgcolor: '#0b2a54' }, '&.active:hover': { bgcolor: '#0866ef' } }}>
|
||||
<ListItemIcon sx={{ minWidth: collapsed ? 0 : 42, color: 'inherit' }}><DnsOutlined /></ListItemIcon>
|
||||
{collapsed ? null : <ListItemText primary="运行环境" />}
|
||||
</ListItemButton>
|
||||
<ListItemButton component={NavLink} to="/network-exits" aria-label="网络出口" onClick={onNavigate} sx={{ minHeight: 52, mb: 1.25, borderRadius: 1, color: '#a9b7cc', justifyContent: collapsed ? 'center' : 'flex-start', '&.active': { color: '#fff', bgcolor: '#0866ef' }, '&:hover': { bgcolor: '#0b2a54' }, '&.active:hover': { bgcolor: '#0866ef' } }}>
|
||||
<ListItemIcon sx={{ minWidth: collapsed ? 0 : 42, color: 'inherit' }}><PublicOutlined /></ListItemIcon>
|
||||
{collapsed ? null : <ListItemText primary="网络出口" />}
|
||||
</ListItemButton>
|
||||
<ListItemButton component={NavLink} to="/browser-images" aria-label="镜像版本" onClick={onNavigate} sx={{ minHeight: 52, mb: 1.25, borderRadius: 1, color: '#a9b7cc', justifyContent: collapsed ? 'center' : 'flex-start', '&.active': { color: '#fff', bgcolor: '#0866ef' }, '&:hover': { bgcolor: '#0b2a54' }, '&.active:hover': { bgcolor: '#0866ef' } }}>
|
||||
<ListItemIcon sx={{ minWidth: collapsed ? 0 : 42, color: 'inherit' }}><Inventory2Outlined /></ListItemIcon>
|
||||
{collapsed ? null : <ListItemText primary="镜像版本" />}
|
||||
@@ -45,10 +61,6 @@ export function CreatorHubMenu({ collapsed, onNavigate }) {
|
||||
<ListItemIcon sx={{ minWidth: collapsed ? 0 : 42, color: 'inherit' }}><HubOutlined /></ListItemIcon>
|
||||
{collapsed ? null : <ListItemText primary="网关管理" />}
|
||||
</ListItemButton>
|
||||
<ListItemButton disabled aria-label="审计记录" sx={{ minHeight: 52, borderRadius: 1, color: '#a9b7cc', justifyContent: collapsed ? 'center' : 'flex-start', '&.Mui-disabled': { opacity: 0.58 } }}>
|
||||
<ListItemIcon sx={{ minWidth: collapsed ? 0 : 42, color: 'inherit' }}><HistoryOutlined /></ListItemIcon>
|
||||
{collapsed ? null : <ListItemText primary="审计记录" />}
|
||||
</ListItemButton>
|
||||
</Box>
|
||||
</>
|
||||
)
|
||||
|
||||
+7
-1
@@ -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(
|
||||
<ThemeProvider theme={theme}>
|
||||
<CssBaseline />
|
||||
<CoreAdmin dataProvider={dataProvider} layout={CreatorHubLayout} title="CreatorHub" disableTelemetry>
|
||||
<Resource name="accounts" list={AccountList} options={{ label: '账号池' }} />
|
||||
<Resource name="network-exits" list={NetworkExitList} options={{ label: '网络出口' }} />
|
||||
<Resource name="browsers" list={BrowserList} options={{ label: '运行环境' }} />
|
||||
<Resource name="browser-images" list={BrowserImageList} options={{ label: '镜像版本' }} />
|
||||
<Resource name="gateways" list={GatewayList} options={{ label: '网关管理' }} />
|
||||
<CustomRoutes><Route path="/accounts/:id" element={<AccountDetail />} /></CustomRoutes>
|
||||
</CoreAdmin>
|
||||
</ThemeProvider>
|
||||
</StrictMode>,
|
||||
|
||||
+1
-1
@@ -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 } },
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user