From 28b77a5ff5471d5d8bcf639d62074ad416def86a Mon Sep 17 00:00:00 2001 From: kdletters Date: Fri, 1 May 2026 00:36:42 +0800 Subject: [PATCH] Fix admin SQL count parsing for local SpacetimeDB --- .env.local | 11 +- apps/admin-web/index.html | 12 + apps/admin-web/package.json | 24 + apps/admin-web/src/api/adminApiClient.ts | 222 ++++++ apps/admin-web/src/api/adminApiTypes.ts | 135 ++++ apps/admin-web/src/app/AdminApp.tsx | 163 ++++ apps/admin-web/src/app/AdminShell.tsx | 108 +++ apps/admin-web/src/app/adminRoutes.ts | 29 + apps/admin-web/src/auth/adminAuthStore.ts | 34 + apps/admin-web/src/main.tsx | 18 + .../src/pages/AdminDebugHttpPage.tsx | 214 ++++++ apps/admin-web/src/pages/AdminLoginPage.tsx | 83 ++ .../admin-web/src/pages/AdminOverviewPage.tsx | 171 +++++ .../src/pages/AdminRedeemCodePage.tsx | 275 +++++++ apps/admin-web/src/pages/pageUtils.ts | 33 + apps/admin-web/src/styles/admin.css | 661 ++++++++++++++++ apps/admin-web/src/vite-env.d.ts | 9 + apps/admin-web/tsconfig.json | 19 + apps/admin-web/vite.config.ts | 43 ++ docs/README.md | 4 +- docs/prd/ADMIN_WEB_CONSOLE_PRD_2026-04-30.md | 153 ++++ ...ADMIN_CONSOLE_SERVICE_DESIGN_2026-04-23.md | 57 +- ...B_CONSOLE_TECHNICAL_SOLUTION_2026-04-30.md | 400 ++++++++++ docs/technical/README.md | 5 +- .../RUST_API_SERVER_ROUTE_INDEX_2026-04-22.md | 21 +- package.json | 4 + server-rs/crates/api-server/src/admin.rs | 712 +++++------------- server-rs/crates/api-server/src/app.rs | 23 +- spacetime.json | 2 +- 29 files changed, 3064 insertions(+), 581 deletions(-) create mode 100644 apps/admin-web/index.html create mode 100644 apps/admin-web/package.json create mode 100644 apps/admin-web/src/api/adminApiClient.ts create mode 100644 apps/admin-web/src/api/adminApiTypes.ts create mode 100644 apps/admin-web/src/app/AdminApp.tsx create mode 100644 apps/admin-web/src/app/AdminShell.tsx create mode 100644 apps/admin-web/src/app/adminRoutes.ts create mode 100644 apps/admin-web/src/auth/adminAuthStore.ts create mode 100644 apps/admin-web/src/main.tsx create mode 100644 apps/admin-web/src/pages/AdminDebugHttpPage.tsx create mode 100644 apps/admin-web/src/pages/AdminLoginPage.tsx create mode 100644 apps/admin-web/src/pages/AdminOverviewPage.tsx create mode 100644 apps/admin-web/src/pages/AdminRedeemCodePage.tsx create mode 100644 apps/admin-web/src/pages/pageUtils.ts create mode 100644 apps/admin-web/src/styles/admin.css create mode 100644 apps/admin-web/src/vite-env.d.ts create mode 100644 apps/admin-web/tsconfig.json create mode 100644 apps/admin-web/vite.config.ts create mode 100644 docs/prd/ADMIN_WEB_CONSOLE_PRD_2026-04-30.md create mode 100644 docs/technical/ADMIN_WEB_CONSOLE_TECHNICAL_SOLUTION_2026-04-30.md diff --git a/.env.local b/.env.local index 95fecc04..ef534f5b 100644 --- a/.env.local +++ b/.env.local @@ -50,6 +50,15 @@ ALIYUN_OSS_ACCESS_KEY_SECRET="XblWGE6CO1WLnSBdMRVpL6lut4GSoS" RUST_SERVER_TARGET="http://127.0.0.1:3100" GENARRATIVE_API_TARGET="http://127.0.0.1:3100" +GENARRATIVE_SPACETIME_SERVER_URL="http://127.0.0.1:3101" +GENARRATIVE_SPACETIME_DATABASE="xushi-p4wfr" +GENARRATIVE_SPACETIME_TOKEN="" + GENARRATIVE_SPACETIME_MAINCLOUD_SERVER_URL="https://maincloud.spacetimedb.com" GENARRATIVE_SPACETIME_MAINCLOUD_DATABASE="xushi-p4wfr" -GENARRATIVE_SPACETIME_MAINCLOUD_TOKEN="eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiIwMUtQRzVXQlhTTjVCRDAyTjBNSlNONFJCNyIsImlzcyI6Imh0dHBzOi8vYXV0aC5zcGFjZXRpbWVkYi5jb20iLCJhdWQiOiJzcGFjZXRpbWVkYiIsImlhdCI6MTc3NjUxMjAyMiwiZXhwIjoxODM5NTg0MDIyfQ.UguSQDajalekrqs9oiUqLZiWjWK7VTgMQfdLVOhBQZpKX0VYUhNMSok9oBMJ4X655_NxV5TUUXZ4ON4HSJZrMMPc9aZyhS1b3i36vqI_zMPwLrAgfb1MqY5o0wNFl6Y0m0UQ3nsu7ZYxmxxgzF4My7So0Pv75QfXFS3-Uq1-QyO7lCxxgQ6vySbP_PEr7FZJsdPkNvAfP7mTaUh0yaV6SI7jXBsZ_mfdcWtElCNuvR9J3hvfAbx1qyeTgCJtgH4kNhiEOEIAYEFMEQkXd4rdLszmEgtlFubYZPsbMgqeZKx73feU6eGxlYhyPiRHF4AdosIfk3x2MAm_WzOd3efXDQ" +GENARRATIVE_SPACETIME_MAINCLOUD_TOKEN="eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiIwMUsyN05YUjBaQkRUVEVCNlFQQjFXNzU2MiIsImlzcyI6Imh0dHBzOi8vYXV0aC5zcGFjZXRpbWVkYi5jb20iLCJhdWQiOiJzcGFjZXRpbWVkYiIsImlhdCI6MTc3NzU1NTQ1NiwiZXhwIjoxODQwNjI3NDU2fQ.iy5qN-3lGPQnkya-wsABtqEgRk1VM2XGxTfxuLV5-eTMfX8cR20sWSx7pnoZcLEwYOkz6cEOb4krhMJmTeBax9Z114o_iwISau3wjjHbeKL9or-039zfYfKb3TtJo3_DZaJSu-ECcMZNl4P1zLmtoRSwl-_AMET4sGzPw0_qR-e49_QGDJz1EEhr7aphybl1xCejCebM8XiJjaRz48vL7-lkwBl90uP-0h7Xx8ToTT2h1egmlcYAvaJalVLHIQqzyYxPUT_Zw9TW7VYExZLhJWdGpQzEm0aXZ2fbch9qVrKpZP2xQ9YjppuLxUFFJeQwhmFf6yc67s6J7LqNvL2-ZA" + +# admin +GENARRATIVE_ADMIN_USERNAME=admin +GENARRATIVE_ADMIN_PASSWORD=123456 +ADMIN_API_TARGET=http://127.0.0.1:8082 \ No newline at end of file diff --git a/apps/admin-web/index.html b/apps/admin-web/index.html new file mode 100644 index 00000000..1af47ad5 --- /dev/null +++ b/apps/admin-web/index.html @@ -0,0 +1,12 @@ + + + + + + 陶泥后台 + + +
+ + + diff --git a/apps/admin-web/package.json b/apps/admin-web/package.json new file mode 100644 index 00000000..7704e562 --- /dev/null +++ b/apps/admin-web/package.json @@ -0,0 +1,24 @@ +{ + "name": "@genarrative/admin-web", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite --host 127.0.0.1", + "build": "tsc --noEmit && vite build", + "typecheck": "tsc --noEmit", + "preview": "vite preview --host 127.0.0.1" + }, + "dependencies": { + "@vitejs/plugin-react": "^5.0.4", + "lucide-react": "^0.546.0", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "vite": "^6.2.0" + }, + "devDependencies": { + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "typescript": "~5.8.2" + } +} diff --git a/apps/admin-web/src/api/adminApiClient.ts b/apps/admin-web/src/api/adminApiClient.ts new file mode 100644 index 00000000..3caeaa39 --- /dev/null +++ b/apps/admin-web/src/api/adminApiClient.ts @@ -0,0 +1,222 @@ +import type { + AdminDebugHttpRequest, + AdminDebugHttpResponse, + AdminDisableProfileRedeemCodeRequest, + AdminLoginResponse, + AdminMeResponse, + AdminOverviewResponse, + AdminUpsertProfileRedeemCodeRequest, + ApiErrorEnvelope, + ApiMeta, + ApiSuccessEnvelope, + ProfileRedeemCodeAdminResponse, +} from './adminApiTypes'; + +const API_RESPONSE_ENVELOPE_HEADER = 'x-genarrative-response-envelope'; +const ADMIN_API_BASE_URL = normalizeBaseUrl( + import.meta.env.VITE_ADMIN_API_BASE_URL ?? '', +); + +interface AdminRequestOptions { + method?: string; + token?: string; + body?: unknown; + headers?: Record; + signal?: AbortSignal; +} + +export class AdminApiError extends Error { + status: number; + code: string; + details: Record | null; + meta: ApiMeta | null; + responseText: string; + + constructor(params: { + message: string; + status: number; + code?: string; + details?: Record | null; + meta?: ApiMeta | null; + responseText?: string; + }) { + super(params.message); + this.name = 'AdminApiError'; + this.status = params.status; + this.code = params.code ?? 'ADMIN_API_ERROR'; + this.details = params.details ?? null; + this.meta = params.meta ?? null; + this.responseText = params.responseText ?? ''; + } +} + +export function isAdminApiError(error: unknown): error is AdminApiError { + return error instanceof AdminApiError; +} + +export function formatAdminApiError(error: unknown) { + if (isAdminApiError(error)) { + return error.message; + } + + if (error instanceof Error && error.message.trim()) { + return error.message; + } + + return '请求失败'; +} + +export async function request( + path: string, + options: AdminRequestOptions = {}, +): Promise { + const method = options.method ?? 'GET'; + const headers: Record = { + Accept: 'application/json', + [API_RESPONSE_ENVELOPE_HEADER]: 'v1', + ...(options.headers ?? {}), + }; + + const token = options.token?.trim(); + if (token) { + headers.Authorization = `Bearer ${token}`; + } + + const init: RequestInit = { + method, + headers, + signal: options.signal, + }; + + if (typeof options.body !== 'undefined') { + headers['Content-Type'] = 'application/json'; + init.body = JSON.stringify(options.body); + } + + const response = await fetch(buildRequestUrl(path), init); + const responseText = await response.text(); + const payload = parseJsonResponse(responseText); + + if (!response.ok) { + throw buildAdminApiError(response, payload, responseText); + } + + return unwrapSuccessPayload(payload); +} + +export function loginAdmin(username: string, password: string) { + return request('/admin/api/login', { + method: 'POST', + body: {username, password}, + }); +} + +export function getAdminMe(token: string) { + return request('/admin/api/me', {token}); +} + +export function getAdminOverview(token: string) { + return request('/admin/api/overview', {token}); +} + +export function debugAdminHttp(token: string, payload: AdminDebugHttpRequest) { + return request('/admin/api/debug/http', { + method: 'POST', + token, + body: payload, + }); +} + +export function upsertProfileRedeemCode( + token: string, + payload: AdminUpsertProfileRedeemCodeRequest, +) { + return request( + '/admin/api/profile/redeem-codes', + { + method: 'POST', + token, + body: payload, + }, + ); +} + +export function disableProfileRedeemCode( + token: string, + payload: AdminDisableProfileRedeemCodeRequest, +) { + return request( + '/admin/api/profile/redeem-codes/disable', + { + method: 'POST', + token, + body: payload, + }, + ); +} + +function normalizeBaseUrl(value: string) { + return value.trim().replace(/\/+$/, ''); +} + +function buildRequestUrl(path: string) { + const normalizedPath = path.startsWith('/') ? path : `/${path}`; + return `${ADMIN_API_BASE_URL}${normalizedPath}`; +} + +function parseJsonResponse(responseText: string): unknown { + if (!responseText.trim()) { + return null; + } + + try { + return JSON.parse(responseText) as unknown; + } catch { + return null; + } +} + +function unwrapSuccessPayload(payload: unknown): T { + if (isRecord(payload) && 'data' in payload) { + return (payload as ApiSuccessEnvelope).data as T; + } + + return payload as T; +} + +function buildAdminApiError( + response: Response, + payload: unknown, + responseText: string, +) { + const envelope = isRecord(payload) ? (payload as ApiErrorEnvelope) : null; + const errorPayload = envelope?.error; + const details = isRecord(errorPayload?.details) + ? errorPayload.details + : null; + const detailsMessage = + typeof details?.message === 'string' ? details.message.trim() : ''; + const payloadMessage = + typeof errorPayload?.message === 'string' ? errorPayload.message.trim() : ''; + const topLevelMessage = + typeof envelope?.message === 'string' ? envelope.message.trim() : ''; + const message = + detailsMessage || + payloadMessage || + topLevelMessage || + response.statusText || + `HTTP ${response.status}`; + + return new AdminApiError({ + message, + status: response.status, + code: errorPayload?.code, + details, + meta: envelope?.meta ?? null, + responseText, + }); +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} diff --git a/apps/admin-web/src/api/adminApiTypes.ts b/apps/admin-web/src/api/adminApiTypes.ts new file mode 100644 index 00000000..8eaa5f23 --- /dev/null +++ b/apps/admin-web/src/api/adminApiTypes.ts @@ -0,0 +1,135 @@ +export interface ApiMeta { + apiVersion?: string; + requestId?: string; + routeVersion?: string; + operation?: string; + latencyMs?: number; + timestamp?: string; +} + +export interface ApiErrorPayload { + code?: string; + message?: string; + details?: { + message?: string; + [key: string]: unknown; + } | null; +} + +export interface ApiSuccessEnvelope { + ok?: boolean; + data?: T; + error?: ApiErrorPayload | null; + meta?: ApiMeta; +} + +export interface ApiErrorEnvelope { + ok?: boolean; + data?: unknown; + error?: ApiErrorPayload; + meta?: ApiMeta; + message?: string; +} + +export interface AdminSessionPayload { + subject: string; + username: string; + displayName: string; + roles: string[]; + issuedAt: string; + expiresAt: string; +} + +export interface AdminLoginResponse { + token: string; + admin: AdminSessionPayload; +} + +export interface AdminMeResponse { + admin: AdminSessionPayload; +} + +export interface AdminOverviewResponse { + service: AdminServiceOverviewPayload; + database: AdminDatabaseOverviewPayload; +} + +export interface AdminServiceOverviewPayload { + bindHost: string; + bindPort: number; + jwtIssuer: string; + adminEnabled: boolean; + spacetimeServerUrl: string; + spacetimeDatabase: string; +} + +export interface AdminDatabaseOverviewPayload { + databaseIdentity: string | null; + ownerIdentity: string | null; + hostType: string | null; + schemaTableNames: string[]; + tableStats: AdminDatabaseTableStatPayload[]; + fetchErrors: string[]; +} + +export interface AdminDatabaseTableStatPayload { + tableName: string; + rowCount: number | null; + errorMessage: string | null; +} + +export interface AdminDebugHeaderInput { + name: string; + value: string; +} + +export type AdminDebugHttpMethod = + | 'GET' + | 'POST' + | 'PUT' + | 'PATCH' + | 'DELETE'; + +export interface AdminDebugHttpRequest { + method: AdminDebugHttpMethod; + path: string; + headers?: AdminDebugHeaderInput[]; + body?: string; +} + +export interface AdminDebugHttpResponse { + status: number; + statusText: string; + headers: AdminDebugHeaderInput[]; + bodyText: string; + bodyJson: unknown | null; +} + +export type ProfileRedeemCodeMode = 'public' | 'unique' | 'private'; + +export interface AdminUpsertProfileRedeemCodeRequest { + code: string; + mode: ProfileRedeemCodeMode; + rewardPoints: number; + maxUses: number; + enabled: boolean; + allowedUserIds: string[]; + allowedPublicUserCodes: string[]; +} + +export interface AdminDisableProfileRedeemCodeRequest { + code: string; +} + +export interface ProfileRedeemCodeAdminResponse { + code: string; + mode: ProfileRedeemCodeMode; + rewardPoints: number; + maxUses: number; + globalUsedCount: number; + enabled: boolean; + allowedUserIds: string[]; + createdBy: string; + createdAt: string; + updatedAt: string; +} diff --git a/apps/admin-web/src/app/AdminApp.tsx b/apps/admin-web/src/app/AdminApp.tsx new file mode 100644 index 00000000..bb4e0876 --- /dev/null +++ b/apps/admin-web/src/app/AdminApp.tsx @@ -0,0 +1,163 @@ +import {useCallback, useEffect, useState} from 'react'; + +import { + formatAdminApiError, + getAdminMe, + isAdminApiError, + loginAdmin, +} from '../api/adminApiClient'; +import type { + AdminSessionPayload, + ProfileRedeemCodeAdminResponse, +} from '../api/adminApiTypes'; +import { + clearStoredAdminToken, + getStoredAdminToken, + setStoredAdminToken, +} from '../auth/adminAuthStore'; +import {AdminDebugHttpPage} from '../pages/AdminDebugHttpPage'; +import {AdminLoginPage} from '../pages/AdminLoginPage'; +import {AdminOverviewPage} from '../pages/AdminOverviewPage'; +import {AdminRedeemCodePage} from '../pages/AdminRedeemCodePage'; +import {AdminShell} from './AdminShell'; +import type {AdminRouteId} from './adminRoutes'; +import {resolveAdminRoute, routeHash} from './adminRoutes'; + +type SessionStatus = 'checking' | 'guest' | 'authenticated'; + +export function AdminApp() { + const [status, setStatus] = useState('checking'); + const [admin, setAdmin] = useState(null); + const [token, setToken] = useState(''); + const [routeId, setRouteId] = useState(() => + resolveAdminRoute(window.location.hash), + ); + const [loginNotice, setLoginNotice] = useState(''); + // 兑换码页会随页签切换卸载,最近操作记录需要放在会话层保留。 + const [redeemResult, setRedeemResult] = + useState(null); + + const clearSession = useCallback((message = '') => { + clearStoredAdminToken(); + setToken(''); + setAdmin(null); + setRedeemResult(null); + setStatus('guest'); + setLoginNotice(message); + }, []); + + useEffect(() => { + let isMounted = true; + const storedToken = getStoredAdminToken(); + + if (!storedToken) { + setStatus('guest'); + return; + } + + void getAdminMe(storedToken) + .then((response) => { + if (!isMounted) { + return; + } + + setToken(storedToken); + setAdmin(response.admin); + setStatus('authenticated'); + }) + .catch((error: unknown) => { + if (!isMounted) { + return; + } + + clearStoredAdminToken(); + setToken(''); + setAdmin(null); + setStatus('guest'); + setLoginNotice( + isAdminApiError(error) && error.status === 401 + ? '登录状态已失效' + : formatAdminApiError(error), + ); + }); + + return () => { + isMounted = false; + }; + }, []); + + useEffect(() => { + const handleHashChange = () => { + setRouteId(resolveAdminRoute(window.location.hash)); + }; + + window.addEventListener('hashchange', handleHashChange); + return () => window.removeEventListener('hashchange', handleHashChange); + }, []); + + const handleRouteChange = useCallback((nextRouteId: AdminRouteId) => { + setRouteId(nextRouteId); + const nextHash = routeHash(nextRouteId); + if (window.location.hash !== nextHash) { + window.location.hash = nextHash; + } + }, []); + + const handleLogin = useCallback(async (username: string, password: string) => { + const response = await loginAdmin(username, password); + setStoredAdminToken(response.token); + setToken(response.token); + setAdmin(response.admin); + setRedeemResult(null); + setLoginNotice(''); + setStatus('authenticated'); + }, []); + + const handleUnauthorized = useCallback( + (message = '登录状态已失效') => { + clearSession(message); + }, + [clearSession], + ); + + const handleLogout = useCallback(() => { + clearSession(''); + }, [clearSession]); + + if (status === 'checking') { + return ( +
+
+ 正在校验会话 +
+ ); + } + + if (status === 'guest' || !admin || !token) { + return ; + } + + return ( + + {routeId === 'overview' ? ( + + ) : null} + {routeId === 'debug' ? ( + + ) : null} + {routeId === 'redeem' ? ( + + ) : null} + + ); +} diff --git a/apps/admin-web/src/app/AdminShell.tsx b/apps/admin-web/src/app/AdminShell.tsx new file mode 100644 index 00000000..0edb0eb4 --- /dev/null +++ b/apps/admin-web/src/app/AdminShell.tsx @@ -0,0 +1,108 @@ +import { + Bug, + LayoutDashboard, + LogOut, + ShieldCheck, + TicketPercent, +} from 'lucide-react'; +import type {ReactNode} from 'react'; + +import type {AdminSessionPayload} from '../api/adminApiTypes'; +import type {AdminRouteId} from './adminRoutes'; +import {adminRoutes} from './adminRoutes'; + +interface AdminShellProps { + admin: AdminSessionPayload; + routeId: AdminRouteId; + children: ReactNode; + onRouteChange: (routeId: AdminRouteId) => void; + onLogout: () => void; +} + +const routeIcons = { + overview: LayoutDashboard, + debug: Bug, + redeem: TicketPercent, +} satisfies Record; + +export function AdminShell({ + admin, + routeId, + children, + onRouteChange, + onLogout, +}: AdminShellProps) { + return ( +
+ + +
+
+
+ {admin.displayName || admin.username} + {admin.roles.join(' / ')} +
+ +
+ +
{children}
+
+ + +
+ ); +} diff --git a/apps/admin-web/src/app/adminRoutes.ts b/apps/admin-web/src/app/adminRoutes.ts new file mode 100644 index 00000000..b8651dd1 --- /dev/null +++ b/apps/admin-web/src/app/adminRoutes.ts @@ -0,0 +1,29 @@ +export type AdminRouteId = 'overview' | 'debug' | 'redeem'; + +export interface AdminRouteDefinition { + id: AdminRouteId; + label: string; + hash: string; +} + +export const adminRoutes: AdminRouteDefinition[] = [ + {id: 'overview', label: '总览', hash: '#overview'}, + {id: 'debug', label: 'API 调试', hash: '#debug'}, + {id: 'redeem', label: '兑换码', hash: '#redeem'}, +]; + +export function resolveAdminRoute(hash: string): AdminRouteId { + const normalizedHash = hash.trim().toLowerCase(); + return ( + adminRoutes.find((route) => route.hash === normalizedHash)?.id ?? + 'overview' + ); +} + +export function routeHash(routeId: AdminRouteId) { + return ( + adminRoutes.find((route) => route.id === routeId)?.hash ?? + adminRoutes[0]?.hash ?? + '#overview' + ); +} diff --git a/apps/admin-web/src/auth/adminAuthStore.ts b/apps/admin-web/src/auth/adminAuthStore.ts new file mode 100644 index 00000000..f7e02d4d --- /dev/null +++ b/apps/admin-web/src/auth/adminAuthStore.ts @@ -0,0 +1,34 @@ +export const ADMIN_TOKEN_STORAGE_KEY = 'genarrative_admin_token'; + +// 管理员 token 与玩家 token 分开保存,避免后台请求误复用玩家登录态。 +export function getStoredAdminToken() { + if (!canUseLocalStorage()) { + return ''; + } + + return window.localStorage.getItem(ADMIN_TOKEN_STORAGE_KEY)?.trim() || ''; +} + +export function setStoredAdminToken(token: string) { + if (!canUseLocalStorage()) { + return; + } + + const nextToken = token.trim(); + if (nextToken) { + window.localStorage.setItem(ADMIN_TOKEN_STORAGE_KEY, nextToken); + return; + } + + window.localStorage.removeItem(ADMIN_TOKEN_STORAGE_KEY); +} + +export function clearStoredAdminToken() { + setStoredAdminToken(''); +} + +function canUseLocalStorage() { + return ( + typeof window !== 'undefined' && typeof window.localStorage !== 'undefined' + ); +} diff --git a/apps/admin-web/src/main.tsx b/apps/admin-web/src/main.tsx new file mode 100644 index 00000000..339dc360 --- /dev/null +++ b/apps/admin-web/src/main.tsx @@ -0,0 +1,18 @@ +import './styles/admin.css'; + +import {StrictMode} from 'react'; +import {createRoot} from 'react-dom/client'; + +import {AdminApp} from './app/AdminApp'; + +const rootElement = document.getElementById('root'); + +if (!rootElement) { + throw new Error('Missing #root container'); +} + +createRoot(rootElement).render( + + + , +); diff --git a/apps/admin-web/src/pages/AdminDebugHttpPage.tsx b/apps/admin-web/src/pages/AdminDebugHttpPage.tsx new file mode 100644 index 00000000..a5ac8ad1 --- /dev/null +++ b/apps/admin-web/src/pages/AdminDebugHttpPage.tsx @@ -0,0 +1,214 @@ +import {Plus, Send, Trash2} from 'lucide-react'; +import {FormEvent, useMemo, useState} from 'react'; + +import {debugAdminHttp} from '../api/adminApiClient'; +import type { + AdminDebugHeaderInput, + AdminDebugHttpMethod, + AdminDebugHttpResponse, +} from '../api/adminApiTypes'; +import {formatUnknownJson, handlePageError} from './pageUtils'; + +interface AdminDebugHttpPageProps { + token: string; + onUnauthorized: (message?: string) => void; +} + +const httpMethods: AdminDebugHttpMethod[] = [ + 'GET', + 'POST', + 'PUT', + 'PATCH', + 'DELETE', +]; + +export function AdminDebugHttpPage({ + token, + onUnauthorized, +}: AdminDebugHttpPageProps) { + const [method, setMethod] = useState('GET'); + const [path, setPath] = useState('/healthz'); + const [body, setBody] = useState(''); + const [headers, setHeaders] = useState([]); + const [result, setResult] = useState(null); + const [errorMessage, setErrorMessage] = useState(''); + const [isSubmitting, setIsSubmitting] = useState(false); + + const jsonPreview = useMemo( + () => formatUnknownJson(result?.bodyJson), + [result?.bodyJson], + ); + + async function handleSubmit(event: FormEvent) { + event.preventDefault(); + if (isSubmitting) { + return; + } + + setErrorMessage(''); + setIsSubmitting(true); + try { + const response = await debugAdminHttp(token, { + method, + path: path.trim(), + headers: headers.filter((header) => header.name.trim()), + body, + }); + setResult(response); + } catch (error: unknown) { + handlePageError(error, onUnauthorized, setErrorMessage); + } finally { + setIsSubmitting(false); + } + } + + return ( +
+
+
+

API 调试

+

受控同源请求

+
+
+ +
+
+
+ + +
+ +
+
+ Headers + +
+
+ {headers.map((header, index) => ( +
+ + setHeaders((current) => + current.map((item, itemIndex) => + itemIndex === index + ? {...item, name: event.target.value} + : item, + ), + ) + } + /> + + setHeaders((current) => + current.map((item, itemIndex) => + itemIndex === index + ? {...item, value: event.target.value} + : item, + ), + ) + } + /> + +
+ ))} +
+
+ + - -
- -
-
-
-
- - -
-
-
-

数据库概览

-

读取当前服务配置和 SpacetimeDB 数据库真相。

-
-
-
- -
-
-
-
-
-
-
- -
-
-

调试结果

-

返回状态、响应头和内容预览。

-
-
-
-
尚未执行调试请求。
-
-
-
-
- - - - - -"#; - #[cfg(test)] mod tests { - use super::{build_body_preview, build_debug_base_url, normalize_debug_path, trim_preview}; + use super::{ + build_body_preview, build_debug_base_url, build_spacetime_schema_url, normalize_debug_path, + parse_spacetime_sql_count_response, trim_preview, + }; use axum::{http::StatusCode, response::IntoResponse}; + use serde_json::json; #[test] fn normalize_debug_path_rejects_absolute_url() { @@ -1161,6 +712,91 @@ mod tests { assert_eq!(trim_preview(&text).chars().count(), 4000); } + #[test] + fn build_spacetime_schema_url_includes_required_version_query() { + let url = build_spacetime_schema_url("http://127.0.0.1:3101", "xushi-p4wfr"); + + assert_eq!( + url, + "http://127.0.0.1:3101/v1/database/xushi-p4wfr/schema?version=9" + ); + } + + #[test] + fn parse_spacetime_sql_count_response_accepts_statement_array_rows() { + let payload = json!([ + { + "schema": { + "elements": [ + { + "name": { + "some": "row_count" + }, + "algebraic_type": { + "U64": [] + } + } + ] + }, + "rows": [[7]], + "total_duration_micros": 116, + "stats": { + "rows_inserted": 0, + "rows_deleted": 0, + "rows_updated": 0 + } + } + ]); + + let count = + parse_spacetime_sql_count_response(payload).expect("statement array should parse"); + + assert_eq!(count, 7); + } + + #[test] + fn parse_spacetime_sql_count_response_uses_schema_column_index() { + let payload = json!([ + { + "schema": { + "elements": [ + { + "name": { + "some": "table_name" + } + }, + { + "name": { + "some": "row_count" + } + } + ] + }, + "rows": [["runtime_setting", "12"]] + } + ]); + + let count = + parse_spacetime_sql_count_response(payload).expect("schema column index should parse"); + + assert_eq!(count, 12); + } + + #[test] + fn parse_spacetime_sql_count_response_keeps_object_row_compatibility() { + let payload = json!({ + "rows": [ + { + "row_count": "3" + } + ] + }); + + let count = parse_spacetime_sql_count_response(payload).expect("object row should parse"); + + assert_eq!(count, 3); + } + #[test] fn build_body_preview_handles_utf8() { let preview = build_body_preview("后台测试".as_bytes()); diff --git a/server-rs/crates/api-server/src/app.rs b/server-rs/crates/api-server/src/app.rs index db4e12e3..e2d5d6be 100644 --- a/server-rs/crates/api-server/src/app.rs +++ b/server-rs/crates/api-server/src/app.rs @@ -13,10 +13,7 @@ use tower_http::{ use tracing::{Level, Span, error, info, info_span, warn}; use crate::{ - admin::{ - admin_console_page, admin_debug_http, admin_login, admin_me, admin_overview, - require_admin_auth, - }, + admin::{admin_debug_http, admin_login, admin_me, admin_overview, require_admin_auth}, ai_tasks::{ append_ai_text_chunk, attach_ai_result_reference, cancel_ai_task, complete_ai_stage, complete_ai_task, create_ai_task, fail_ai_task, start_ai_task, start_ai_task_stage, @@ -131,7 +128,6 @@ pub fn build_router(state: AppState) -> Router { let slow_request_threshold_ms = state.config.slow_request_threshold_ms; Router::new() - .route("/admin", get(admin_console_page)) .route("/admin/api/login", post(admin_login)) .route( "/admin/api/me", @@ -3166,6 +3162,23 @@ mod tests { ); } + #[tokio::test] + async fn admin_page_route_is_not_mounted() { + let app = build_router(AppState::new(AppConfig::default()).expect("state should build")); + + let response = app + .oneshot( + Request::builder() + .uri("/admin") + .body(Body::empty()) + .expect("admin page request should build"), + ) + .await + .expect("admin page request should succeed"); + + assert_eq!(response.status(), StatusCode::NOT_FOUND); + } + #[tokio::test] async fn admin_login_returns_token_when_configured() { let mut config = AppConfig::default(); diff --git a/spacetime.json b/spacetime.json index fc6b20a0..cb013ee4 100644 --- a/spacetime.json +++ b/spacetime.json @@ -1,4 +1,4 @@ { - "server": "http://127.0.0.1:3101", + "server": "local", "module-path": "./server-rs/crates/spacetime-module" }