frontend(config): 改善配置中心测试错误展示布局并美化错误详情

- useApi.testConfig:摘要优先 error 字段;缺省时将原始响应体放入 details,确保 Pretty Printer 可用
- Config 页面:失败时仅显示“测试失败”+ 折叠详情;新增 brace-aware pretty printer,支持 MCP/Rust/reqwest 风格错误的缩进分行显示
This commit is contained in:
Lv, Qi 2025-11-18 20:12:09 +08:00
parent 427776b863
commit 733bf89af5
6 changed files with 338 additions and 14 deletions

View File

@ -6,7 +6,40 @@ export async function POST(req: NextRequest) {
if (!BACKEND_BASE) {
return new Response('BACKEND_INTERNAL_URL/NEXT_PUBLIC_BACKEND_URL 未配置', { status: 500 });
}
// 新后端暂无统一 /config/test先返回未实现
const body = await req.text().catch(() => '');
return Response.json({ success: false, message: 'config/test 未实现', echo: body }, { status: 501 });
try {
const body = await req.json();
const { type, data } = body;
if (!type || !data) {
return new Response('请求体必须包含 type 和 data', { status: 400 });
}
// 将请求转发到 API Gateway
const targetUrl = `${BACKEND_BASE.replace(/\/$/, '')}/configs/test`;
const backendRes = await fetch(targetUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ type, ...data }), // 转发时将 data 字段展开
});
const backendResBody = await backendRes.text();
return new Response(backendResBody, {
status: backendRes.status,
headers: {
'Content-Type': 'application/json',
},
});
} catch (error: any) {
console.error('配置测试代理失败:', error);
return new Response(JSON.stringify({ success: false, message: error.message || '代理请求时发生未知错误' }), {
status: 500,
headers: { 'Content-Type': 'application/json' },
});
}
}

View File

@ -26,6 +26,143 @@ import type {
} from '@/types';
import { useDataSourcesConfig, updateDataSourcesConfig, useAnalysisTemplateSets, updateAnalysisTemplateSets } from '@/hooks/useApi';
// ---- Helpers: pretty print nested JSON as YAML-like (braceless, unquoted) ----
const MAX_REPARSE_DEPTH = 2;
function tryParseJson(input: string): unknown {
try {
return JSON.parse(input);
} catch {
return input;
}
}
function parsePossiblyNestedJson(raw: string): unknown {
let current: unknown = raw;
for (let i = 0; i < MAX_REPARSE_DEPTH; i++) {
if (typeof current === 'string') {
const trimmed = current.trim();
if ((trimmed.startsWith('{') && trimmed.endsWith('}')) || (trimmed.startsWith('[') && trimmed.endsWith(']'))) {
current = tryParseJson(trimmed);
continue;
}
}
break;
}
return current;
}
function indentLines(text: string, indent: string): string {
return text.split('\n').map((line) => indent + line).join('\n');
}
function looksLikeStructuredText(s: string): boolean {
const t = s.trim();
// 具有成对的大括号,或常见的 Rust/reqwest 错误标识,判定为可结构化的调试输出
return (t.includes('{') && t.includes('}')) || t.includes('DynamicTransportError') || t.includes('reqwest::Error');
}
function prettyFormatByBraces(input: string): string {
let out = '';
let indentLevel = 0;
const indentUnit = ' ';
let inString = false;
let stringQuote = '';
for (let i = 0; i < input.length; i++) {
const ch = input[i]!;
const prev = i > 0 ? input[i - 1]! : '';
if ((ch === '"' || ch === "'") && prev !== '\\') {
if (!inString) {
inString = true;
stringQuote = ch;
} else if (stringQuote === ch) {
inString = false;
}
out += ch;
continue;
}
if (inString) {
out += ch;
continue;
}
if (ch === '{' || ch === '[' || ch === '(') {
indentLevel += 1;
out += ch + '\n' + indentUnit.repeat(indentLevel);
continue;
}
if (ch === '}' || ch === ']' || ch === ')') {
indentLevel = Math.max(0, indentLevel - 1);
out += '\n' + indentUnit.repeat(indentLevel) + ch;
continue;
}
if (ch === ',') {
out += ch + '\n' + indentUnit.repeat(indentLevel);
continue;
}
if (ch === ':') {
out += ch + ' ';
continue;
}
out += ch;
}
return out;
}
function toPseudoYaml(value: unknown, indent: string = ''): string {
const nextIndent = indent + ' ';
if (value === null || value === undefined) {
return `${indent}null`;
}
const t = typeof value;
if (t === 'string' || t === 'number' || t === 'boolean') {
const s = String(value);
const shouldPretty = looksLikeStructuredText(s) || s.includes('\n');
if (shouldPretty) {
const pretty = looksLikeStructuredText(s) ? prettyFormatByBraces(s) : s;
return `${indent}|-\n${indentLines(pretty, nextIndent)}`;
}
return `${indent}${s}`;
}
if (Array.isArray(value)) {
if (value.length === 0) return `${indent}[]`;
return value.map((item) => {
const rendered = toPseudoYaml(item, nextIndent);
const lines = rendered.split('\n');
if (lines.length === 1) {
return `${indent}- ${lines[0].trimStart()}`;
}
return `${indent}- ${lines[0].trimStart()}\n${lines.slice(1).map((l) => indent + ' ' + l.trimStart()).join('\n')}`;
}).join('\n');
}
if (typeof value === 'object') {
const obj = value as Record<string, unknown>;
const keys = Object.keys(obj);
if (keys.length === 0) return `${indent}{}`;
return keys.map((k) => {
const rendered = toPseudoYaml(obj[k], nextIndent);
const lines = rendered.split('\n');
if (lines.length === 1) {
return `${indent}${k}: ${lines[0].trimStart()}`;
}
return `${indent}${k}:\n${lines.map((l) => l).join('\n')}`;
}).join('\n');
}
// fallback
return `${indent}${String(value)}`;
}
function formatDetailsToYaml(details: string): string {
const parsed = parsePossiblyNestedJson(details);
if (typeof parsed === 'string') {
// 尝试再次解析(有些场景内层 message 也是 JSON 串)
const again = parsePossiblyNestedJson(parsed);
if (typeof again === 'string') {
return toPseudoYaml(again);
}
return toPseudoYaml(again);
}
return toPseudoYaml(parsed);
}
const defaultUrls: Partial<Record<DataSourceProvider, string>> = {
tushare: 'http://api.tushare.pro',
finnhub: 'https://finnhub.io/api/v1',
alphavantage: 'https://mcp.alphavantage.co/mcp',
};
export default function ConfigPage() {
// 从 Zustand store 获取全局状态
const { config, loading, error, setConfig } = useConfigStore();
@ -47,7 +184,7 @@ export default function ConfigPage() {
// 分析配置保存状态(状态定义在下方统一维护)
// 测试结果状态
const [testResults, setTestResults] = useState<Record<string, { success: boolean; message: string } | null>>({});
const [testResults, setTestResults] = useState<Record<string, { success: boolean; summary: string; details?: string } | null>>({});
// 保存状态
const [saving, setSaving] = useState(false);
@ -368,8 +505,22 @@ export default function ConfigPage() {
try {
if (initialDataSources) {
await updateDataSourcesConfig(localDataSources);
await mutateDataSources(localDataSources, false);
// Create a deep copy to avoid mutating the local state directly
const finalDataSources = JSON.parse(JSON.stringify(localDataSources));
for (const key in finalDataSources) {
const providerKey = key as DataSourceProvider;
const source = finalDataSources[providerKey];
// If the URL is empty or null, and there is a default URL, use it
if (source && (source.api_url === null || source.api_url.trim() === '') && defaultUrls[providerKey]) {
source.api_url = defaultUrls[providerKey];
}
}
await updateDataSourcesConfig(finalDataSources);
// After saving, mutate the local SWR cache with the final data
// and also update the component's local state to reflect the change.
await mutateDataSources(finalDataSources, false);
setLocalDataSources(finalDataSources);
}
setSaveMessage('保存成功!');
} catch (e: any) {
@ -383,11 +534,18 @@ export default function ConfigPage() {
const handleTest = async (type: string, data: any) => {
try {
const result = await testConfig(type, data);
setTestResults(prev => ({ ...prev, [type]: result }));
const success = !!result?.success;
const summary = typeof result?.message === 'string' && result.message.trim().length > 0
? result.message
: (success ? '测试成功' : '测试失败');
setTestResults(prev => ({ ...prev, [type]: { success, summary } }));
} catch (e: any) {
// 结构化错误对象:{ summary, details? }
const summary: string = (e && typeof e === 'object' && 'summary' in e) ? String(e.summary) : (e?.message || '未知错误');
const details: string | undefined = (e && typeof e === 'object' && 'details' in e) ? (e.details ? String(e.details) : undefined) : undefined;
setTestResults(prev => ({
...prev,
[type]: { success: false, message: e.message }
[type]: { success: false, summary, details }
}));
}
};
@ -402,6 +560,11 @@ export default function ConfigPage() {
handleTest('finnhub', { api_key: cfg?.api_key, api_url: cfg?.api_url, enabled: cfg?.enabled });
};
const handleTestAlphaVantage = () => {
const cfg = localDataSources['alphavantage'];
handleTest('alphavantage', { api_key: cfg?.api_key, api_url: cfg?.api_url, enabled: cfg?.enabled });
};
const handleReset = () => {
if (initialDataSources) setLocalDataSources(initialDataSources);
setTestResults({});
@ -1058,7 +1221,8 @@ export default function ConfigPage() {
[providerKey]: { ...item, api_url: v, provider: providerKey },
}));
}}
placeholder={providerKey === 'tushare' ? 'https://api.tushare.pro' : 'https://...'}
placeholder={defaultUrls[providerKey] ?? 'https://...'}
disabled={providerKey === 'yfinance'}
/>
</div>
</div>
@ -1069,7 +1233,33 @@ export default function ConfigPage() {
{providerKey === 'finnhub' && (
<Button variant="outline" onClick={handleTestFinnhub}> Finnhub</Button>
)}
{providerKey === 'alphavantage' && (
<Button variant="outline" onClick={handleTestAlphaVantage}> AlphaVantage</Button>
)}
</div>
{testResults[providerKey] ? (() => {
const r = testResults[providerKey]!;
if (r.success) {
return (
<div className="text-sm text-green-600">
{r.summary}
</div>
);
}
return (
<div className="text-sm text-red-600">
<div className="font-medium"></div>
{r.details ? (
<details className="mt-2">
<summary className="cursor-pointer text-red-700 underline">YAML</summary>
<pre className="mt-2 p-2 rounded bg-red-50 text-red-700 whitespace-pre-wrap break-words">
{formatDetailsToYaml(r.details)}
</pre>
</details>
) : null}
</div>
);
})() : null}
</div>
);
})}

View File

@ -294,9 +294,41 @@ export async function testConfig(type: string, data: unknown) {
if (!res.ok) {
try {
const err = JSON.parse(text);
throw new Error(err?.message || text);
// 优先从标准字段中提取错误信息;同时分离 details
let message: string = err?.error || err?.message || '';
let detailsStr = '';
if (err?.details !== undefined) {
if (typeof err.details === 'string') {
detailsStr = err.details;
// details 可能是被 JSON 序列化的字符串,尝试解析一次以便还原内部结构
try {
const parsed = JSON.parse(detailsStr);
if (typeof parsed === 'string') {
detailsStr = parsed;
} else if (parsed && typeof parsed === 'object' && 'message' in parsed) {
detailsStr = String((parsed as any).message);
} else {
detailsStr = JSON.stringify(parsed);
}
} catch {
throw new Error(text || `HTTP ${res.status}`);
// 忽略解析失败,保留原始 details 字符串
}
} else {
try {
detailsStr = JSON.stringify(err.details);
} catch {
detailsStr = String(err.details);
}
}
}
const summary = message || `HTTP ${res.status}`;
const detailsOut = (detailsStr && detailsStr.length > 0) ? detailsStr : (text || undefined);
// 抛出结构化错误对象供调用方精确展示details 始终尽量携带原始响应体,便于前端美化)
throw { summary, details: detailsOut };
} catch {
// 无法解析为 JSON 时,仍然把原始文本作为 details 返回,保证前端可美化
const fallback = text || `HTTP ${res.status}`;
throw { summary: fallback, details: text || undefined };
}
}
try {

View File

@ -2,16 +2,18 @@ use std::collections::HashMap;
use axum::{
extract::State,
response::Json,
routing::get,
routing::{get, post},
Router,
};
use common_contracts::observability::{HealthStatus, ServiceStatus, TaskProgress};
use crate::state::{AppState, ServiceOperationalStatus};
use crate::api_test;
pub fn create_router(app_state: AppState) -> Router {
Router::new()
.route("/health", get(health_check))
.route("/tasks", get(get_current_tasks))
.route("/test", post(api_test::test_connection))
.with_state(app_state)
}

View File

@ -1,4 +1,5 @@
mod api;
mod api_test;
mod config;
mod error;
mod mapping;

View File

@ -66,6 +66,7 @@ fn create_v1_router() -> Router<AppState> {
"/configs/data_sources",
get(get_data_sources_config).put(update_data_sources_config),
)
.route("/configs/test", post(test_data_source_config))
// --- New Discover Routes ---
.route("/discover-models/{provider_id}", get(discover_models))
.route("/discover-models", post(discover_models_preview))
@ -208,6 +209,71 @@ async fn get_task_progress(
}
// --- New Config Test Handler ---
#[derive(Deserialize, Debug)]
struct TestConfigRequest {
r#type: String,
#[serde(flatten)]
data: serde_json::Value,
}
/// [POST /v1/configs/test]
/// Forwards a configuration test request to the appropriate downstream service.
async fn test_data_source_config(
State(state): State<AppState>,
Json(payload): Json<TestConfigRequest>,
) -> Result<impl IntoResponse> {
info!("test_data_source_config: type={}", payload.r#type);
let target_service_url = match payload.r#type.as_str() {
"tushare" => state.config.provider_services.iter().find(|s| s.contains("tushare")),
"finnhub" => state.config.provider_services.iter().find(|s| s.contains("finnhub")),
"alphavantage" => state.config.provider_services.iter().find(|s| s.contains("alphavantage")),
_ => {
return Ok((
StatusCode::BAD_REQUEST,
Json(serde_json::json!({ "error": "Unsupported config type" })),
).into_response());
}
};
if let Some(base_url) = target_service_url {
let client = reqwest::Client::new();
let target_url = format!("{}/test", base_url.trim_end_matches('/'));
info!("Forwarding test request for '{}' to {}", payload.r#type, target_url);
let response = client
.post(&target_url)
.json(&payload.data)
.send()
.await?;
if !response.status().is_success() {
let status = response.status();
let error_text = response.text().await?;
warn!("Downstream test for '{}' failed: status={} body={}", payload.r#type, status, error_text);
return Ok((
StatusCode::from_u16(status.as_u16()).unwrap_or(StatusCode::BAD_GATEWAY),
Json(serde_json::json!({
"error": "Downstream service returned an error",
"details": error_text,
})),
).into_response());
}
let response_json: serde_json::Value = response.json().await?;
Ok((StatusCode::OK, Json(response_json)).into_response())
} else {
warn!("No downstream service found for config type: {}", payload.r#type);
Ok((
StatusCode::NOT_IMPLEMENTED,
Json(serde_json::json!({ "error": "No downstream service configured for this type" })),
).into_response())
}
}
// --- Config API Handlers (Proxy to data-persistence-service) ---
use common_contracts::config_models::{