frontend(config): 改善配置中心测试错误展示布局并美化错误详情
- useApi.testConfig:摘要优先 error 字段;缺省时将原始响应体放入 details,确保 Pretty Printer 可用 - Config 页面:失败时仅显示“测试失败”+ 折叠详情;新增 brace-aware pretty printer,支持 MCP/Rust/reqwest 风格错误的缩进分行显示
This commit is contained in:
parent
427776b863
commit
733bf89af5
@ -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' },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
})}
|
||||
|
||||
@ -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 {
|
||||
// 忽略解析失败,保留原始 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 {
|
||||
throw new Error(text || `HTTP ${res.status}`);
|
||||
// 无法解析为 JSON 时,仍然把原始文本作为 details 返回,保证前端可美化
|
||||
const fallback = text || `HTTP ${res.status}`;
|
||||
throw { summary: fallback, details: text || undefined };
|
||||
}
|
||||
}
|
||||
try {
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
mod api;
|
||||
mod api_test;
|
||||
mod config;
|
||||
mod error;
|
||||
mod mapping;
|
||||
|
||||
@ -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::{
|
||||
|
||||
Loading…
Reference in New Issue
Block a user