From 3726833810ae3d0d4c3f0154aaa545e8694d356c Mon Sep 17 00:00:00 2001 From: fj-sunny Date: Tue, 25 Aug 2026 17:32:34 +0800 Subject: [PATCH 1/7] fix(admin): repair production monitoring dashboard --- .../monitoring/MonitoringAdminService.java | 18 +++++----- .../MonitoringAdminServiceTest.java | 22 +++++++++++- deploy/docker-compose.prod.yml | 1 + frontend/admin/Dockerfile | 2 ++ frontend/admin/src/app/AppShell.tsx | 2 +- .../features/monitoring/MonitoringPage.tsx | 13 +++---- .../features/monitoring/monitoringApi.test.ts | 35 +++++++++++++++++++ .../src/features/monitoring/monitoringApi.ts | 13 ++++--- frontend/admin/src/styles/global.css | 11 +++--- 9 files changed, 91 insertions(+), 26 deletions(-) create mode 100644 frontend/admin/src/features/monitoring/monitoringApi.test.ts diff --git a/backend/unispeaking-server/src/main/java/com/unispeaking/admin/monitoring/MonitoringAdminService.java b/backend/unispeaking-server/src/main/java/com/unispeaking/admin/monitoring/MonitoringAdminService.java index ff1614f6..2586f227 100644 --- a/backend/unispeaking-server/src/main/java/com/unispeaking/admin/monitoring/MonitoringAdminService.java +++ b/backend/unispeaking-server/src/main/java/com/unispeaking/admin/monitoring/MonitoringAdminService.java @@ -10,6 +10,8 @@ @Service public class MonitoringAdminService { + private static final String API_REQUESTS = "http_route=~\"/api/.*\""; + public record Summary(String backendStatus, double clientErrorRate, double api5xxRate, double apiP95Seconds, long activeAlerts, long affectedUsers, long completedOptimizations, Instant generatedAt) {} @@ -38,9 +40,9 @@ public MonitoringAdminService(JdbcTemplate jdbc, public MonitoringResponse overview() { var summary = new Summary(backendStatus(), - queryPrometheus("100 * sum(rate(http_server_request_duration_seconds_count{http_response_status_code=~\"4..|5..\"}[5m])) / clamp_min(sum(rate(http_server_request_duration_seconds_count[5m])), 0.000001)"), - queryPrometheus("100 * sum(rate(http_server_request_duration_seconds_count{http_response_status_code=~\"5..\"}[5m])) / clamp_min(sum(rate(http_server_request_duration_seconds_count[5m])), 0.000001)"), - queryPrometheus("histogram_quantile(0.95, sum by (le) (rate(http_server_request_duration_seconds_bucket[5m])))"), + queryPrometheus("(100 * sum(rate(http_server_request_duration_seconds_count{" + API_REQUESTS + ",http_response_status_code=~\"4..|5..\"}[5m])) / clamp_min(sum(rate(http_server_request_duration_seconds_count{" + API_REQUESTS + "}[5m])), 0.000001)) or vector(0)"), + queryPrometheus("(100 * sum(rate(http_server_request_duration_seconds_count{" + API_REQUESTS + ",http_response_status_code=~\"5..\"}[5m])) / clamp_min(sum(rate(http_server_request_duration_seconds_count{" + API_REQUESTS + "}[5m])), 0.000001)) or vector(0)"), + queryPrometheus("histogram_quantile(0.95, sum by (le) (rate(http_server_request_duration_seconds_bucket{" + API_REQUESTS + "}[5m]))) or vector(0)"), activeAlerts(), affectedUsers(), completedOptimizations(), Instant.now()); return new MonitoringResponse(summary, problems(), slowEndpoints(), recentEvents(), platformSummaries(), trend()); @@ -65,9 +67,9 @@ private double queryPrometheus(String query) { private List trend() { long end = Instant.now().getEpochSecond(); long start = end - 24 * 60 * 60; - var client = queryRange("sum(rate(http_server_request_duration_seconds_count{http_response_status_code=~\"4..|5..\"}[5m]))", start, end); - var slow = queryRange("clamp_min(sum(rate(http_server_request_duration_seconds_count[5m])) - sum(rate(http_server_request_duration_seconds_bucket{le=\"1\"}[5m])), 0)", start, end); - var backend = queryRange("sum(rate(http_server_request_duration_seconds_count{http_response_status_code=~\"5..\"}[5m]))", start, end); + var client = queryRange("sum(rate(http_server_request_duration_seconds_count{" + API_REQUESTS + ",http_response_status_code=~\"4..|5..\"}[5m])) or vector(0)", start, end); + var slow = queryRange("clamp_min(sum(rate(http_server_request_duration_seconds_count{" + API_REQUESTS + "}[5m])) - sum(rate(http_server_request_duration_seconds_bucket{" + API_REQUESTS + ",le=\"1.0\"}[5m])), 0) or vector(0)", start, end); + var backend = queryRange("sum(rate(http_server_request_duration_seconds_count{" + API_REQUESTS + ",http_response_status_code=~\"5..\"}[5m])) or vector(0)", start, end); int size = Math.min(client.size(), Math.min(slow.size(), backend.size())); var result = new java.util.ArrayList(size); for (int i = 0; i < size; i++) { @@ -135,9 +137,9 @@ private List slowEndpoints() { return jdbc.query(""" SELECT COALESCE(api_method, ''), COALESCE(api_path, route, ''), COUNT(*), AVG(duration_ms) / 1000.0, percentile_cont(0.95) WITHIN GROUP (ORDER BY duration_ms) / 1000.0, - MAX(duration_ms) / 1000.0, COUNT(*) FILTER (WHERE duration_ms > 5000) + MAX(duration_ms) / 1000.0, COUNT(*) FILTER (WHERE duration_ms > 1000) FROM quality_issue_events - WHERE duration_ms IS NOT NULL AND duration_ms > 1000 + WHERE duration_ms IS NOT NULL AND occurred_at >= CURRENT_TIMESTAMP - INTERVAL '7 days' GROUP BY 1, 2 ORDER BY percentile_cont(0.95) WITHIN GROUP (ORDER BY duration_ms) DESC LIMIT 5 """, (rs, n) -> new SlowEndpoint(rs.getString(1), rs.getString(2), rs.getLong(3), diff --git a/backend/unispeaking-server/src/test/java/com/unispeaking/admin/monitoring/MonitoringAdminServiceTest.java b/backend/unispeaking-server/src/test/java/com/unispeaking/admin/monitoring/MonitoringAdminServiceTest.java index 255fbc76..db171628 100644 --- a/backend/unispeaking-server/src/test/java/com/unispeaking/admin/monitoring/MonitoringAdminServiceTest.java +++ b/backend/unispeaking-server/src/test/java/com/unispeaking/admin/monitoring/MonitoringAdminServiceTest.java @@ -6,17 +6,22 @@ import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import java.io.IOException; import java.net.InetSocketAddress; +import java.net.URLDecoder; import java.sql.ResultSet; import java.sql.Timestamp; import java.time.Instant; import java.util.List; import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.CopyOnWriteArrayList; import com.sun.net.httpserver.HttpServer; import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.core.RowMapper; @@ -94,9 +99,12 @@ void mapsPrometheusAndDatabaseOverviewRows() throws IOException { }); HttpServer server = HttpServer.create(new InetSocketAddress(0), 0); + List prometheusRequests = new CopyOnWriteArrayList<>(); server.createContext("/api/v1/query", exchange -> respond(exchange, + prometheusRequests, "{\"data\":{\"result\":[{\"value\":[\"0\",\"2.5\"]}]}}")); server.createContext("/api/v1/query_range", exchange -> respond(exchange, + prometheusRequests, "{\"data\":{\"result\":[{\"values\":[[\"0\",\"1.0\"],[\"3600\",\"2.0\"]]}]}}")); server.start(); try { @@ -112,14 +120,26 @@ void mapsPrometheusAndDatabaseOverviewRows() throws IOException { assertEquals(3, response.platformSummaries().getFirst().errorCount()); assertEquals(2, response.trend().size()); assertEquals(2.0, response.trend().getLast().clientErrors()); + assertTrue(prometheusRequests.stream().anyMatch(query -> query.contains("http_route=~\"/api/.*\""))); + assertTrue(prometheusRequests.stream().anyMatch(query -> query.contains("le=\"1.0\""))); + assertTrue(prometheusRequests.stream().anyMatch(query -> query.contains("or vector(0)"))); + + ArgumentCaptor sql = ArgumentCaptor.forClass(String.class); + verify(jdbc, times(4)).query(sql.capture(), any(RowMapper.class)); + String slowEndpointSql = sql.getAllValues().get(1); + assertTrue(slowEndpointSql.contains("WHERE duration_ms IS NOT NULL")); + assertTrue(slowEndpointSql.contains("duration_ms > 1000")); } finally { server.stop(0); } } - private static void respond(com.sun.net.httpserver.HttpExchange exchange, String body) + private static void respond(com.sun.net.httpserver.HttpExchange exchange, + List requests, String body) throws IOException { + requests.add(URLDecoder.decode(exchange.getRequestURI().getRawQuery(), + java.nio.charset.StandardCharsets.UTF_8)); byte[] bytes = body.getBytes(java.nio.charset.StandardCharsets.UTF_8); exchange.getResponseHeaders().set("Content-Type", "application/json"); exchange.sendResponseHeaders(200, bytes.length); diff --git a/deploy/docker-compose.prod.yml b/deploy/docker-compose.prod.yml index da8a5ca6..1375ba72 100644 --- a/deploy/docker-compose.prod.yml +++ b/deploy/docker-compose.prod.yml @@ -95,6 +95,7 @@ services: args: DOCKER_IMAGE_REGISTRY: ${DOCKER_IMAGE_REGISTRY:-docker.m.daocloud.io} VITE_BACKEND_URL: ${VITE_BACKEND_URL:-/backend} + VITE_GRAFANA_URL: ${VITE_GRAFANA_URL:-http://218.11.5.202:3001} NPM_REGISTRY: ${NPM_REGISTRY:-https://registry.npmmirror.com} expose: - "80" diff --git a/frontend/admin/Dockerfile b/frontend/admin/Dockerfile index 7f41e38d..b7dfb14b 100644 --- a/frontend/admin/Dockerfile +++ b/frontend/admin/Dockerfile @@ -5,8 +5,10 @@ FROM ${DOCKER_IMAGE_REGISTRY}/library/node:22-alpine AS build WORKDIR /app ARG VITE_BACKEND_URL=/backend +ARG VITE_GRAFANA_URL=http://218.11.5.202:3001 ARG NPM_REGISTRY=https://registry.npmmirror.com ENV VITE_BACKEND_URL=${VITE_BACKEND_URL} +ENV VITE_GRAFANA_URL=${VITE_GRAFANA_URL} COPY package.json package-lock.json ./ RUN npm ci --registry=${NPM_REGISTRY} \ diff --git a/frontend/admin/src/app/AppShell.tsx b/frontend/admin/src/app/AppShell.tsx index a0dbef1a..d31bf686 100644 --- a/frontend/admin/src/app/AppShell.tsx +++ b/frontend/admin/src/app/AppShell.tsx @@ -111,7 +111,7 @@ export function AppShell({ administrator, logout }: AppShellProps) { UNISPEAKING CONTROL 用户、额度与费用 - 开发环境 + {import.meta.env.PROD ? '生产环境' : '开发环境'}
diff --git a/frontend/admin/src/features/monitoring/MonitoringPage.tsx b/frontend/admin/src/features/monitoring/MonitoringPage.tsx index b189b4eb..104bde7e 100644 --- a/frontend/admin/src/features/monitoring/MonitoringPage.tsx +++ b/frontend/admin/src/features/monitoring/MonitoringPage.tsx @@ -10,9 +10,8 @@ const platformParams = (platform: ClientPlatform) => ({ 'var-platform': platform const fmt = (value: number, digits = 1) => Number.isFinite(value) ? value.toLocaleString('zh-CN', { maximumFractionDigits: digits }) : '-' const time = (value: string) => new Date(value).toLocaleString('zh-CN', { hour12: false }) -function trendPoints(values: number[], width = 800, height = 160) { +function trendPoints(values: number[], max: number, width = 800, height = 160) { if (!values.length) return '' - const max = Math.max(...values, 1) return values.map((value, index) => `${(index / Math.max(values.length - 1, 1)) * width},${height - (value / max) * (height - 8)}`).join(' ') } @@ -31,19 +30,21 @@ export function MonitoringPage() { const summary = data.summary const selectedSummary = data.platformSummaries.find(item => item.platform === selectedPlatform) ?? data.platformSummaries[0] const severe = summary.activeAlerts + const trendValues = data.trend.flatMap(point => [point.clientErrors, point.slowRequests, point.backendErrors]) + const trendMax = Math.max(...trendValues, 1) return

OBSERVABILITY

系统运行监控

快速定位 Web、Mobile 和后端接口的真实问题,详细排查进入 Grafana 查看。

最后更新:{time(summary.generatedAt)}生产环境查看 Grafana 总览服务与接口日志溯源BUG 与告警
- 1 ? 'warning' : ''} /> - 1 ? 'danger' : ''} /> + 1 ? 'warning' : ''} /> + 1 ? 'danger' : ''} /> 3 ? 'warning' : ''} /> - +
-

核心趋势(24h)

客户端错误 · 慢请求 · 后端异常
客户端错误慢请求后端异常
point.clientErrors))} /> point.slowRequests))} /> point.backendErrors))} />
24h 前18h 前12h 前6h 前现在
+

核心趋势(24h)

API 错误 · 慢请求 · 后端异常
API 错误慢请求后端异常
point.clientErrors), trendMax)} /> point.slowRequests), trendMax)} /> point.backendErrors), trendMax)} />
24h 前18h 前12h 前6h 前现在

问题摘要

生产数据库与 Prometheus 实时聚合
未关闭问题{severe}
错误问题{data.problems.length}
近 7 天异常事件{data.recentEvents.length}
已完成优化{summary.completedOptimizations}
  • 问题列表按近 7 天接口错误聚合,避免真实历史问题被隐藏
  • 当前后端 5xx 错误率为 {fmt(summary.api5xxRate)}%
  • 慢请求按耗时超过 1 秒统计,详情进入 Grafana
查看 BUG 与告警 → Grafana
{data.problems.slice(0, 5).map(item => )}
接口平台次数最近发生
{item.path || item.problem} {item.platform}{item.count}{time(item.lastSeen)}
{!data.problems.length && }
{data.slowEndpoints.map(item => )}
接口平均P95超慢次数
{item.method} {item.path} 5 ? 'cell-danger' : ''}>{fmt(item.averageSeconds)} s 5 ? 'cell-danger' : ''}>{fmt(item.p95Seconds)} s{item.slowCount}
{!data.slowEndpoints.length && }
{(['web', 'mobile', 'backend'] as ClientPlatform[]).map(platform => )}
{selectedPlatform === 'web' ? '页面事件 P95' : selectedPlatform === 'mobile' ? '移动端事件 P95' : '后端事件 P95'}{fmt(selectedSummary?.p95DurationMs ?? 0)} ms
近 24h 错误事件{selectedSummary?.errorCount ?? 0}
近 24h 异常用户{selectedSummary?.affectedUsers ?? 0}
查看 {platformLabels[selectedPlatform]} 日志 →
diff --git a/frontend/admin/src/features/monitoring/monitoringApi.test.ts b/frontend/admin/src/features/monitoring/monitoringApi.test.ts new file mode 100644 index 00000000..a48351d8 --- /dev/null +++ b/frontend/admin/src/features/monitoring/monitoringApi.test.ts @@ -0,0 +1,35 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { getMonitoringOverview, grafanaDashboards, grafanaUrl } from './monitoringApi' + +describe('monitoringApi', () => { + afterEach(() => vi.unstubAllGlobals()) + + it('always reloads the production monitoring overview', async () => { + const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify({ summary: {} }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + })) + vi.stubGlobal('fetch', fetchMock) + + await getMonitoringOverview() + + expect(fetchMock).toHaveBeenCalledWith('/api/admin/monitoring/overview', { + credentials: 'include', + cache: 'no-store', + }) + }) + + it('uses the dashboard UIDs provisioned by production Grafana', () => { + expect(grafanaDashboards).toEqual({ + overview: '/d/unispeaking-overview/01-e7b3bb-e7bb9f-e680a788', + client: '/d/unispeaking-clients/02-web-mobile', + backend: '/d/unispeaking-backend-logs/03-e5908e-e7abaf-e4b88e-e697a5-e5bf97', + logs: '/d/unispeaking-backend-logs/03-e5908e-e7abaf-e4b88e-e697a5-e5bf97', + performance: '/d/unispeaking-performance/04-e680a7-e883bd-e4bc98-e58c96', + }) + expect(grafanaUrl(grafanaDashboards.logs, { 'var-platform': 'mobile' })) + .toContain('/d/unispeaking-backend-logs/03-e5908e-e7abaf-e4b88e-e697a5-e5bf97?') + expect(grafanaUrl(grafanaDashboards.logs, { 'var-platform': 'mobile' })) + .toContain('var-platform=mobile') + }) +}) diff --git a/frontend/admin/src/features/monitoring/monitoringApi.ts b/frontend/admin/src/features/monitoring/monitoringApi.ts index 1174e453..f52c8e84 100644 --- a/frontend/admin/src/features/monitoring/monitoringApi.ts +++ b/frontend/admin/src/features/monitoring/monitoringApi.ts @@ -9,7 +9,10 @@ export interface MonitoringTrendPoint { timestamp: number; clientErrors: number; export interface MonitoringOverview { summary: MonitoringSummary; problems: MonitoringProblem[]; slowEndpoints: SlowEndpoint[]; recentEvents: MonitoringEvent[]; platformSummaries: PlatformSummary[]; trend: MonitoringTrendPoint[] } export async function getMonitoringOverview(): Promise { - const response = await fetch(buildAdminApiUrl('/api/admin/monitoring/overview'), { credentials: 'include' }) + const response = await fetch(buildAdminApiUrl('/api/admin/monitoring/overview'), { + credentials: 'include', + cache: 'no-store', + }) if (!response.ok) throw new Error('运行监控数据暂时无法读取') return response.json() as Promise } @@ -22,8 +25,8 @@ export function grafanaUrl(path: string, params: Record = {}) { export const grafanaDashboards = { overview: '/d/unispeaking-overview/01-e7b3bb-e7bb9f-e680a788', - client: '/d/unispeaking-web/client-quality', - backend: '/d/unispeaking-java/backend-api', - logs: '/d/unispeaking-logs/log-tracing', - performance: '/d/unispeaking-bugs/bug-alerts', + client: '/d/unispeaking-clients/02-web-mobile', + backend: '/d/unispeaking-backend-logs/03-e5908e-e7abaf-e4b88e-e697a5-e5bf97', + logs: '/d/unispeaking-backend-logs/03-e5908e-e7abaf-e4b88e-e697a5-e5bf97', + performance: '/d/unispeaking-performance/04-e680a7-e883bd-e4bc98-e58c96', } diff --git a/frontend/admin/src/styles/global.css b/frontend/admin/src/styles/global.css index 94b3d82c..da5f3c1b 100644 --- a/frontend/admin/src/styles/global.css +++ b/frontend/admin/src/styles/global.css @@ -75,14 +75,15 @@ input:disabled { .monitoring-hero-actions { display:flex; align-items:center; justify-content:flex-end; gap:8px; flex-wrap:wrap; color:var(--muted); font-size:11px; } .monitoring-hero-actions .button { min-height:34px; } .button--primary { border-color:#138b4c; color:#117643; background:#f0fbf4; } -.monitoring-metrics--six { display:grid; grid-template-columns:repeat(6,minmax(0,1fr)); gap:12px; } -.monitoring-metric { display:flex; align-items:flex-start; gap:12px; min-height:112px; padding:18px 16px; border-radius:12px; box-shadow:0 1px 3px rgba(30,30,25,.05); } +.monitoring-metrics--six { display:grid; grid-template-columns:repeat(6,minmax(0,1fr)); align-items:stretch; gap:12px; } +.monitoring-metric { display:flex; align-items:center; gap:12px; min-width:0; min-height:112px; height:100%; padding:18px 16px; border-radius:12px; box-shadow:0 1px 3px rgba(30,30,25,.05); } +.monitoring-metric > div { min-width:0; } .monitoring-metric-icon { display:flex; align-items:center; justify-content:center; width:38px; height:38px; flex:0 0 38px; border-radius:11px; color:#3171d5; background:#edf4ff; }.monitoring-metric-icon.warning { color:#e78709; background:#fff5e5; }.monitoring-metric-icon.danger { color:#e34c55; background:#fff0f0; }.monitoring-metric-icon.ok { color:#16964e; background:#eaf8ef; } .monitoring-metric > div > span { font-size:11px; color:var(--muted-strong); }.monitoring-metric strong { display:block; margin-top:6px; font-size:22px; line-height:1.1; }.monitoring-metric strong.ok { color:#14934b; }.monitoring-metric strong.warning { color:#e88800; }.monitoring-metric strong.danger { color:#dd3b43; }.monitoring-metric small { display:block; margin-top:8px; color:var(--faint); font-size:10px; } -.monitoring-main-grid { display:grid; grid-template-columns:minmax(0,1.15fr) minmax(390px,.85fr); gap:14px; }.trend-panel,.problem-summary { min-height:250px; }.monitoring-panel { padding:17px 19px; border-radius:12px; }.monitoring-panel .panel-heading { margin-bottom:9px; }.monitoring-panel .panel-heading h2 { font-size:16px; margin:0; }.monitoring-panel .panel-heading span { color:var(--muted); font-size:11px; }.monitoring-panel .panel-heading select { border:1px solid var(--line); border-radius:7px; padding:7px 10px; color:var(--muted-strong); background:#fff; font-size:11px; } -.trend-legend { display:flex; gap:22px; justify-content:center; font-size:10px; color:var(--muted); }.trend-legend span::before { content:""; display:inline-block; width:14px; height:2px; margin:0 5px 3px 0; background:currentColor; }.trend-legend--green { color:#15954f; }.trend-legend--orange { color:#e98a0b; }.trend-legend--red { color:#df4e50; }.trend-chart { position:relative; height:176px; margin-top:9px; }.trend-gridlines { position:absolute; inset:12px 0 25px; display:flex; flex-direction:column; justify-content:space-between; }.trend-gridlines i { border-top:1px dashed #e8e9e6; }.trend-chart svg { position:absolute; inset:10px 0 25px; width:100%; height:calc(100% - 35px); overflow:visible; }.trend-line { fill:none; stroke-width:2.2; stroke-linecap:round; stroke-linejoin:round; }.trend-line--green { stroke:#15954f; }.trend-line--orange { stroke:#e98a0b; }.trend-line--red { stroke:#df4e50; }.trend-axis { position:absolute; bottom:0; left:0; right:0; display:flex; justify-content:space-between; color:var(--faint); font-size:10px; } +.monitoring-main-grid { display:grid; grid-template-columns:minmax(0,1.15fr) minmax(390px,.85fr); align-items:stretch; gap:14px; }.trend-panel,.problem-summary { min-height:300px; height:100%; }.trend-panel { display:flex; flex-direction:column; }.monitoring-panel { min-width:0; padding:17px 19px; border-radius:12px; overflow:hidden; }.monitoring-panel .panel-heading { margin-bottom:9px; }.monitoring-panel .panel-heading h2 { font-size:16px; margin:0; }.monitoring-panel .panel-heading span { color:var(--muted); font-size:11px; }.monitoring-panel .panel-heading select { border:1px solid var(--line); border-radius:7px; padding:7px 10px; color:var(--muted-strong); background:#fff; font-size:11px; } +.trend-legend { display:flex; flex:0 0 auto; gap:22px; justify-content:center; padding-top:2px; font-size:10px; color:var(--muted); }.trend-legend span::before { content:""; display:inline-block; width:14px; height:2px; margin:0 5px 3px 0; background:currentColor; }.trend-legend--green { color:#15954f; }.trend-legend--orange { color:#e98a0b; }.trend-legend--red { color:#df4e50; }.trend-chart { position:relative; flex:1 1 auto; min-height:205px; margin-top:9px; }.trend-gridlines { position:absolute; inset:12px 0 25px; display:flex; flex-direction:column; justify-content:space-between; }.trend-gridlines i { border-top:1px dashed #e8e9e6; }.trend-chart svg { position:absolute; inset:10px 0 25px; width:100%; height:calc(100% - 35px); overflow:hidden; }.trend-line { fill:none; stroke-width:2.2; stroke-linecap:round; stroke-linejoin:round; vector-effect:non-scaling-stroke; }.trend-line--green { stroke:#15954f; }.trend-line--orange { stroke:#e98a0b; }.trend-line--red { stroke:#df4e50; }.trend-axis { position:absolute; bottom:0; left:0; right:0; display:flex; justify-content:space-between; color:var(--faint); font-size:10px; } .summary-counters { display:grid; grid-template-columns:repeat(4,1fr); gap:8px; }.summary-counters > div { min-height:78px; padding:11px; border:1px solid var(--line); border-radius:9px; }.summary-counters svg { color:#df4e50; }.summary-counters span { display:block; margin-top:4px; color:var(--muted); font-size:10px; }.summary-counters strong { display:block; margin-top:3px; font-size:20px; }.summary-list { margin:14px 0 9px; padding-left:17px; color:var(--muted-strong); font-size:11px; line-height:2; }.text-link { color:#158449; font-size:12px; font-weight:650; text-decoration:none; }.text-link span { margin:0 4px; }.problem-summary > .text-link { display:block; text-align:right; } -.monitoring-three-grid { display:grid; grid-template-columns:1fr 1fr 1.1fr; gap:14px; }.compact-table .panel-heading a { color:var(--muted); font-size:11px; text-decoration:none; }.compact-table table { width:100%; border-collapse:collapse; font-size:11px; }.compact-table th,.compact-table td { padding:9px 4px; border-bottom:1px solid var(--line); text-align:left; white-space:nowrap; }.compact-table th { color:var(--muted); font-weight:600; }.compact-table td svg { vertical-align:-2px; }.compact-table code { display:inline-block; max-width:155px; overflow:hidden; text-overflow:ellipsis; vertical-align:bottom; font-size:10px; }.cell-danger { color:#dd3b43; font-weight:700; }.client-tabs { display:flex; gap:6px; margin:0 0 18px; }.client-tabs button { appearance:none; padding:7px 14px; border:1px solid var(--line); border-radius:7px; font:inherit; font-size:11px; color:var(--ink); background:#fff; cursor:pointer; }.client-tabs button:hover { border-color:#a8dfbd; background:#f6fcf8; }.client-tabs button.active { border-color:#a8dfbd; color:#158449; background:#effaf3; }.client-tabs button:focus-visible { outline:2px solid #4aa9ff; outline-offset:2px; }.client-stats { display:grid; grid-template-columns:repeat(3,1fr); gap:8px; }.client-stats span { color:var(--muted); font-size:10px; }.client-stats strong { display:block; margin-top:8px; font-size:18px; }.client-overview .text-link { display:block; margin-top:19px; text-align:right; }.recent-events .panel-heading a { color:var(--muted); font-size:11px; text-decoration:none; }.recent-events table { width:100%; border-collapse:collapse; font-size:11px; }.recent-events th,.recent-events td { padding:10px 7px; border-bottom:1px solid var(--line); text-align:left; white-space:nowrap; }.recent-events th { color:var(--muted); font-weight:600; }.event-log-link { display:inline-flex; align-items:center; gap:3px; color:#158449 !important; }.severity-pill { display:inline-block; padding:3px 9px; border-radius:12px; font-size:10px; }.severity-pill--high { color:#c53d45; background:#fff0f0; }.severity-pill--medium { color:#b57400; background:#fff6e4; } +.monitoring-three-grid { display:grid; grid-template-columns:1fr 1fr 1.1fr; align-items:stretch; gap:14px; }.monitoring-three-grid > .monitoring-panel { min-height:340px; height:100%; }.compact-table .panel-heading a { color:var(--muted); font-size:11px; text-decoration:none; }.compact-table table { width:100%; table-layout:fixed; border-collapse:collapse; font-size:11px; }.compact-table th,.compact-table td { padding:9px 4px; border-bottom:1px solid var(--line); text-align:left; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }.compact-table th { color:var(--muted); font-weight:600; }.compact-table td svg { vertical-align:-2px; }.compact-table code { display:inline-block; max-width:100%; overflow:hidden; text-overflow:ellipsis; vertical-align:bottom; font-size:10px; }.cell-danger { color:#dd3b43; font-weight:700; }.client-tabs { display:flex; gap:6px; margin:0 0 18px; }.client-tabs button { appearance:none; padding:7px 14px; border:1px solid var(--line); border-radius:7px; font:inherit; font-size:11px; color:var(--ink); background:#fff; cursor:pointer; }.client-tabs button:hover { border-color:#a8dfbd; background:#f6fcf8; }.client-tabs button.active { border-color:#a8dfbd; color:#158449; background:#effaf3; }.client-tabs button:focus-visible { outline:2px solid #4aa9ff; outline-offset:2px; }.client-stats { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:8px; }.client-stats span { color:var(--muted); font-size:10px; }.client-stats strong { display:block; margin-top:8px; font-size:18px; }.client-overview .text-link { display:block; margin-top:19px; text-align:right; }.recent-events .panel-heading a { color:var(--muted); font-size:11px; text-decoration:none; }.recent-events table { width:100%; border-collapse:collapse; font-size:11px; }.recent-events th,.recent-events td { padding:10px 7px; border-bottom:1px solid var(--line); text-align:left; white-space:nowrap; }.recent-events th { color:var(--muted); font-weight:600; }.event-log-link { display:inline-flex; align-items:center; gap:3px; color:#158449 !important; }.severity-pill { display:inline-block; padding:3px 9px; border-radius:12px; font-size:10px; }.severity-pill--high { color:#c53d45; background:#fff0f0; }.severity-pill--medium { color:#b57400; background:#fff6e4; } .monitoring-loading { padding:70px 0; text-align:center; color:var(--muted); } @media (max-width:1200px) { .monitoring-metrics--six { grid-template-columns:repeat(3,1fr); }.monitoring-main-grid,.monitoring-three-grid { grid-template-columns:1fr 1fr; }.client-overview { grid-column:1 / -1; } } @media (max-width:760px) { .monitoring-hero { flex-direction:column; }.monitoring-hero-actions { justify-content:flex-start; }.monitoring-metrics--six,.monitoring-main-grid,.monitoring-three-grid { grid-template-columns:1fr; }.client-overview { grid-column:auto; }.summary-counters { grid-template-columns:repeat(2,1fr); }.monitoring-hero h1 { font-size:25px; } } From e1d83cfae528b3bd2c2908940d42b217607dca70 Mon Sep 17 00:00:00 2001 From: fj-sunny Date: Tue, 25 Aug 2026 17:50:34 +0800 Subject: [PATCH 2/7] fix(admin): align monitoring metrics and durations --- .../admin/monitoring/MonitoringAdminService.java | 7 +++++-- .../admin/monitoring/MonitoringAdminServiceTest.java | 3 ++- .../admin/src/features/monitoring/MonitoringPage.tsx | 5 +++-- .../admin/src/features/monitoring/monitoringApi.test.ts | 9 +++++++++ .../admin/src/features/monitoring/monitoringFormat.ts | 7 +++++++ frontend/admin/src/styles/global.css | 4 ++-- 6 files changed, 28 insertions(+), 7 deletions(-) create mode 100644 frontend/admin/src/features/monitoring/monitoringFormat.ts diff --git a/backend/unispeaking-server/src/main/java/com/unispeaking/admin/monitoring/MonitoringAdminService.java b/backend/unispeaking-server/src/main/java/com/unispeaking/admin/monitoring/MonitoringAdminService.java index 2586f227..ec6c4597 100644 --- a/backend/unispeaking-server/src/main/java/com/unispeaking/admin/monitoring/MonitoringAdminService.java +++ b/backend/unispeaking-server/src/main/java/com/unispeaking/admin/monitoring/MonitoringAdminService.java @@ -42,14 +42,17 @@ public MonitoringResponse overview() { var summary = new Summary(backendStatus(), queryPrometheus("(100 * sum(rate(http_server_request_duration_seconds_count{" + API_REQUESTS + ",http_response_status_code=~\"4..|5..\"}[5m])) / clamp_min(sum(rate(http_server_request_duration_seconds_count{" + API_REQUESTS + "}[5m])), 0.000001)) or vector(0)"), queryPrometheus("(100 * sum(rate(http_server_request_duration_seconds_count{" + API_REQUESTS + ",http_response_status_code=~\"5..\"}[5m])) / clamp_min(sum(rate(http_server_request_duration_seconds_count{" + API_REQUESTS + "}[5m])), 0.000001)) or vector(0)"), - queryPrometheus("histogram_quantile(0.95, sum by (le) (rate(http_server_request_duration_seconds_bucket{" + API_REQUESTS + "}[5m]))) or vector(0)"), + queryPrometheus("histogram_quantile(0.95, sum by (le) (increase(http_server_request_duration_seconds_bucket{" + API_REQUESTS + "}[24h]))) or vector(0)"), activeAlerts(), affectedUsers(), completedOptimizations(), Instant.now()); return new MonitoringResponse(summary, problems(), slowEndpoints(), recentEvents(), platformSummaries(), trend()); } private String backendStatus() { - return queryPrometheus("max(up{instance=\"backend:8080\"})") >= 1 ? "UP" : "DOWN"; + // This method runs inside the backend that served the overview request. If the + // backend were unavailable the request itself would fail instead of returning a + // misleading DOWN status merely because Prometheus is temporarily unreachable. + return "UP"; } private double queryPrometheus(String query) { diff --git a/backend/unispeaking-server/src/test/java/com/unispeaking/admin/monitoring/MonitoringAdminServiceTest.java b/backend/unispeaking-server/src/test/java/com/unispeaking/admin/monitoring/MonitoringAdminServiceTest.java index db171628..d8324c11 100644 --- a/backend/unispeaking-server/src/test/java/com/unispeaking/admin/monitoring/MonitoringAdminServiceTest.java +++ b/backend/unispeaking-server/src/test/java/com/unispeaking/admin/monitoring/MonitoringAdminServiceTest.java @@ -38,7 +38,7 @@ void returnsZeroedOverviewWhenPrometheusIsUnavailable() { MonitoringAdminService.MonitoringResponse response = new MonitoringAdminService( jdbc, "http://127.0.0.1:1").overview(); - assertEquals("DOWN", response.summary().backendStatus()); + assertEquals("UP", response.summary().backendStatus()); assertEquals(0, response.summary().clientErrorRate()); assertEquals(0, response.summary().api5xxRate()); assertEquals(3, response.platformSummaries().size()); @@ -121,6 +121,7 @@ void mapsPrometheusAndDatabaseOverviewRows() throws IOException { assertEquals(2, response.trend().size()); assertEquals(2.0, response.trend().getLast().clientErrors()); assertTrue(prometheusRequests.stream().anyMatch(query -> query.contains("http_route=~\"/api/.*\""))); + assertTrue(prometheusRequests.stream().anyMatch(query -> query.contains("[24h]"))); assertTrue(prometheusRequests.stream().anyMatch(query -> query.contains("le=\"1.0\""))); assertTrue(prometheusRequests.stream().anyMatch(query -> query.contains("or vector(0)"))); diff --git a/frontend/admin/src/features/monitoring/MonitoringPage.tsx b/frontend/admin/src/features/monitoring/MonitoringPage.tsx index 104bde7e..1d569f45 100644 --- a/frontend/admin/src/features/monitoring/MonitoringPage.tsx +++ b/frontend/admin/src/features/monitoring/MonitoringPage.tsx @@ -2,6 +2,7 @@ import { useState } from 'react' import { useQuery } from '@tanstack/react-query' import { Activity, AlertTriangle, Bell, CheckCircle2, Clock3, ExternalLink, Gauge, Monitor, RefreshCw, Server, Smartphone, UserRound } from 'lucide-react' import { getMonitoringOverview, grafanaDashboards, grafanaUrl } from './monitoringApi' +import { formatDuration } from './monitoringFormat' type ClientPlatform = 'web' | 'mobile' | 'backend' const platformLabels: Record = { web: 'Web', mobile: 'Mobile', backend: 'Backend' } @@ -39,7 +40,7 @@ export function MonitoringPage() { 1 ? 'warning' : ''} /> 1 ? 'danger' : ''} /> - 3 ? 'warning' : ''} /> + 3 ? 'warning' : ''} />
@@ -47,7 +48,7 @@ export function MonitoringPage() {

核心趋势(24h)

API 错误 · 慢请求 · 后端异常
API 错误慢请求后端异常
point.clientErrors), trendMax)} /> point.slowRequests), trendMax)} /> point.backendErrors), trendMax)} />
24h 前18h 前12h 前6h 前现在

问题摘要

生产数据库与 Prometheus 实时聚合
未关闭问题{severe}
错误问题{data.problems.length}
近 7 天异常事件{data.recentEvents.length}
已完成优化{summary.completedOptimizations}
  • 问题列表按近 7 天接口错误聚合,避免真实历史问题被隐藏
  • 当前后端 5xx 错误率为 {fmt(summary.api5xxRate)}%
  • 慢请求按耗时超过 1 秒统计,详情进入 Grafana
查看 BUG 与告警 → Grafana
-
{data.problems.slice(0, 5).map(item => )}
接口平台次数最近发生
{item.path || item.problem} {item.platform}{item.count}{time(item.lastSeen)}
{!data.problems.length && }
{data.slowEndpoints.map(item => )}
接口平均P95超慢次数
{item.method} {item.path} 5 ? 'cell-danger' : ''}>{fmt(item.averageSeconds)} s 5 ? 'cell-danger' : ''}>{fmt(item.p95Seconds)} s{item.slowCount}
{!data.slowEndpoints.length && }
{(['web', 'mobile', 'backend'] as ClientPlatform[]).map(platform => )}
{selectedPlatform === 'web' ? '页面事件 P95' : selectedPlatform === 'mobile' ? '移动端事件 P95' : '后端事件 P95'}{fmt(selectedSummary?.p95DurationMs ?? 0)} ms
近 24h 错误事件{selectedSummary?.errorCount ?? 0}
近 24h 异常用户{selectedSummary?.affectedUsers ?? 0}
查看 {platformLabels[selectedPlatform]} 日志 →
+
{data.problems.slice(0, 5).map(item => )}
接口平台次数最近发生
{item.path || item.problem} {item.platform}{item.count}{time(item.lastSeen)}
{!data.problems.length && }
{data.slowEndpoints.map(item => )}
接口平均P95慢请求数
{item.method} {item.path} 1 ? 'cell-danger' : ''}>{formatDuration(item.averageSeconds)} 1 ? 'cell-danger' : ''}>{formatDuration(item.p95Seconds)}{item.slowCount}
{!data.slowEndpoints.length && }
{(['web', 'mobile', 'backend'] as ClientPlatform[]).map(platform => )}
{selectedPlatform === 'web' ? '页面事件 P95' : selectedPlatform === 'mobile' ? '移动端事件 P95' : '后端事件 P95'}{fmt(selectedSummary?.p95DurationMs ?? 0)} ms
近 24h 错误事件{selectedSummary?.errorCount ?? 0}
近 24h 异常用户{selectedSummary?.affectedUsers ?? 0}
查看 {platformLabels[selectedPlatform]} 日志 →
{data.recentEvents.slice(0, 6).map((item, index) => )}
时间平台用户页面或接口错误摘要状态日志
{time(item.timestamp)} {item.platform}{item.userId}{item.apiPath || item.page}{item.errorMessage || item.errorType}= 500 ? 'high' : 'medium'}`}>{(item.httpStatus || 0) >= 500 ? '严重' : '中'}查看
{!data.recentEvents.length && }
diff --git a/frontend/admin/src/features/monitoring/monitoringApi.test.ts b/frontend/admin/src/features/monitoring/monitoringApi.test.ts index a48351d8..d4dd94aa 100644 --- a/frontend/admin/src/features/monitoring/monitoringApi.test.ts +++ b/frontend/admin/src/features/monitoring/monitoringApi.test.ts @@ -1,5 +1,6 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { getMonitoringOverview, grafanaDashboards, grafanaUrl } from './monitoringApi' +import { formatDuration } from './monitoringFormat' describe('monitoringApi', () => { afterEach(() => vi.unstubAllGlobals()) @@ -32,4 +33,12 @@ describe('monitoringApi', () => { expect(grafanaUrl(grafanaDashboards.logs, { 'var-platform': 'mobile' })) .toContain('var-platform=mobile') }) + + it('keeps sub-second endpoint timings visible in milliseconds', () => { + expect(formatDuration(0)).toBe('0 ms') + expect(formatDuration(0.0035)).toBe('3.5 ms') + expect(formatDuration(0.7241)).toBe('724.1 ms') + expect(formatDuration(15.032)).toBe('15.03 s') + expect(formatDuration(60.2058)).toBe('60.21 s') + }) }) diff --git a/frontend/admin/src/features/monitoring/monitoringFormat.ts b/frontend/admin/src/features/monitoring/monitoringFormat.ts new file mode 100644 index 00000000..a48af4ea --- /dev/null +++ b/frontend/admin/src/features/monitoring/monitoringFormat.ts @@ -0,0 +1,7 @@ +const formatNumber = (value: number, digits = 1) => Number.isFinite(value) + ? value.toLocaleString('zh-CN', { maximumFractionDigits: digits }) + : '-' + +export const formatDuration = (seconds: number) => seconds < 1 + ? `${formatNumber(seconds * 1000)} ms` + : `${formatNumber(seconds, 2)} s` diff --git a/frontend/admin/src/styles/global.css b/frontend/admin/src/styles/global.css index da5f3c1b..d25b9788 100644 --- a/frontend/admin/src/styles/global.css +++ b/frontend/admin/src/styles/global.css @@ -76,9 +76,9 @@ input:disabled { .monitoring-hero-actions .button { min-height:34px; } .button--primary { border-color:#138b4c; color:#117643; background:#f0fbf4; } .monitoring-metrics--six { display:grid; grid-template-columns:repeat(6,minmax(0,1fr)); align-items:stretch; gap:12px; } -.monitoring-metric { display:flex; align-items:center; gap:12px; min-width:0; min-height:112px; height:100%; padding:18px 16px; border-radius:12px; box-shadow:0 1px 3px rgba(30,30,25,.05); } +.monitoring-metric { display:flex; align-items:flex-start; gap:12px; min-width:0; min-height:112px; height:100%; padding:20px 18px; border-radius:12px; box-shadow:0 1px 3px rgba(30,30,25,.05); } .monitoring-metric > div { min-width:0; } -.monitoring-metric-icon { display:flex; align-items:center; justify-content:center; width:38px; height:38px; flex:0 0 38px; border-radius:11px; color:#3171d5; background:#edf4ff; }.monitoring-metric-icon.warning { color:#e78709; background:#fff5e5; }.monitoring-metric-icon.danger { color:#e34c55; background:#fff0f0; }.monitoring-metric-icon.ok { color:#16964e; background:#eaf8ef; } +.monitoring-metric-icon { display:flex; align-items:center; justify-content:center; width:38px; height:38px; flex:0 0 38px; margin-top:1px; border-radius:11px; color:#3171d5; background:#edf4ff; }.monitoring-metric-icon.warning { color:#e78709; background:#fff5e5; }.monitoring-metric-icon.danger { color:#e34c55; background:#fff0f0; }.monitoring-metric-icon.ok { color:#16964e; background:#eaf8ef; } .monitoring-metric > div > span { font-size:11px; color:var(--muted-strong); }.monitoring-metric strong { display:block; margin-top:6px; font-size:22px; line-height:1.1; }.monitoring-metric strong.ok { color:#14934b; }.monitoring-metric strong.warning { color:#e88800; }.monitoring-metric strong.danger { color:#dd3b43; }.monitoring-metric small { display:block; margin-top:8px; color:var(--faint); font-size:10px; } .monitoring-main-grid { display:grid; grid-template-columns:minmax(0,1.15fr) minmax(390px,.85fr); align-items:stretch; gap:14px; }.trend-panel,.problem-summary { min-height:300px; height:100%; }.trend-panel { display:flex; flex-direction:column; }.monitoring-panel { min-width:0; padding:17px 19px; border-radius:12px; overflow:hidden; }.monitoring-panel .panel-heading { margin-bottom:9px; }.monitoring-panel .panel-heading h2 { font-size:16px; margin:0; }.monitoring-panel .panel-heading span { color:var(--muted); font-size:11px; }.monitoring-panel .panel-heading select { border:1px solid var(--line); border-radius:7px; padding:7px 10px; color:var(--muted-strong); background:#fff; font-size:11px; } .trend-legend { display:flex; flex:0 0 auto; gap:22px; justify-content:center; padding-top:2px; font-size:10px; color:var(--muted); }.trend-legend span::before { content:""; display:inline-block; width:14px; height:2px; margin:0 5px 3px 0; background:currentColor; }.trend-legend--green { color:#15954f; }.trend-legend--orange { color:#e98a0b; }.trend-legend--red { color:#df4e50; }.trend-chart { position:relative; flex:1 1 auto; min-height:205px; margin-top:9px; }.trend-gridlines { position:absolute; inset:12px 0 25px; display:flex; flex-direction:column; justify-content:space-between; }.trend-gridlines i { border-top:1px dashed #e8e9e6; }.trend-chart svg { position:absolute; inset:10px 0 25px; width:100%; height:calc(100% - 35px); overflow:hidden; }.trend-line { fill:none; stroke-width:2.2; stroke-linecap:round; stroke-linejoin:round; vector-effect:non-scaling-stroke; }.trend-line--green { stroke:#15954f; }.trend-line--orange { stroke:#e98a0b; }.trend-line--red { stroke:#df4e50; }.trend-axis { position:absolute; bottom:0; left:0; right:0; display:flex; justify-content:space-between; color:var(--faint); font-size:10px; } From c6c36ba3445cf0dd11fc439a7ca2617f163e9fa9 Mon Sep 17 00:00:00 2001 From: fj-sunny Date: Wed, 26 Aug 2026 10:35:16 +0800 Subject: [PATCH 3/7] fix(web): support legacy metrics and audio previews --- frontend/web/Dockerfile | 1 + frontend/web/nginx.conf | 7 +++++++ frontend/web/scripts/check-telemetry.mjs | 20 +++++++++++++++++++ frontend/web/src/analytics/pageCatalog.js | 5 ++++- .../web/src/component/ielts/IeltsModule.jsx | 4 ++-- .../component/interview/InterviewModule.jsx | 2 +- frontend/web/src/telemetry/clientTelemetry.js | 3 ++- 7 files changed, 37 insertions(+), 5 deletions(-) diff --git a/frontend/web/Dockerfile b/frontend/web/Dockerfile index fdbe363d..3376e5d5 100644 --- a/frontend/web/Dockerfile +++ b/frontend/web/Dockerfile @@ -47,6 +47,7 @@ COPY . . RUN npm run check:routes \ && npm run check:realtime-events \ && npm run test:analytics \ + && npm run test:telemetry \ && npm run build FROM ${DOCKER_IMAGE_REGISTRY}/library/nginx:1.27-alpine diff --git a/frontend/web/nginx.conf b/frontend/web/nginx.conf index 79fd9597..81751f1e 100644 --- a/frontend/web/nginx.conf +++ b/frontend/web/nginx.conf @@ -4,6 +4,13 @@ server { root /usr/share/nginx/html; index index.html; + include /etc/nginx/mime.types; + default_type application/octet-stream; + + location ~* \.(?:wav|mp3|m4a|aac|ogg)$ { + try_files $uri =404; + add_header Cache-Control "public, max-age=86400" always; + } location / { try_files $uri $uri/ /index.html; diff --git a/frontend/web/scripts/check-telemetry.mjs b/frontend/web/scripts/check-telemetry.mjs index 4fce83b5..874d81c1 100644 --- a/frontend/web/scripts/check-telemetry.mjs +++ b/frontend/web/scripts/check-telemetry.mjs @@ -1,4 +1,5 @@ import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; import test from "node:test"; import { flushTelemetry, recordTelemetry } from "../src/telemetry/clientTelemetry.js"; @@ -59,3 +60,22 @@ test("RTC telemetry calculates interval loss and TURN usage", () => { assert.equal(result.attributes.turn_used, true); assert.equal(result.attributes.packet_loss_pct, 5); }); + +test("production web sources avoid Array.at for legacy browser compatibility", async () => { + const sourceFiles = [ + "../src/telemetry/clientTelemetry.js", + "../src/analytics/pageCatalog.js", + "../src/component/ielts/IeltsModule.jsx", + "../src/component/interview/InterviewModule.jsx", + ]; + for (const sourceFile of sourceFiles) { + const source = await readFile(new URL(sourceFile, import.meta.url), "utf8"); + assert.doesNotMatch(source, /\.at\s*\(/, `${sourceFile} must not require Array.prototype.at`); + } +}); + +test("web nginx serves teacher previews with registered audio MIME types", async () => { + const nginx = await readFile(new URL("../nginx.conf", import.meta.url), "utf8"); + assert.match(nginx, /include \/etc\/nginx\/mime\.types;/); + assert.match(nginx, /wav\|mp3\|m4a\|aac\|ogg/); +}); diff --git a/frontend/web/src/analytics/pageCatalog.js b/frontend/web/src/analytics/pageCatalog.js index e843df24..331cd2a5 100644 --- a/frontend/web/src/analytics/pageCatalog.js +++ b/frontend/web/src/analytics/pageCatalog.js @@ -19,7 +19,10 @@ export function normalizeTrackedPath(pathname = '/') { if (/^\/conversation\/[^/]+$/.test(path)) return '/conversation/session' if (/^\/scenes\/[^/]+\/session\/[^/]+\/result$/.test(path)) return '/scenes/session/result' if (/^\/scenes\/[^/]+\/session\/[^/]+$/.test(path)) return '/scenes/session' - if (/^\/scenes\/[^/]+\/(word|phrase|sentence|assets)$/.test(path)) return `/scenes/${path.split('/').at(-1)}` + if (/^\/scenes\/[^/]+\/(word|phrase|sentence|assets)$/.test(path)) { + const segments = path.split('/') + return `/scenes/${segments[segments.length - 1]}` + } if (/^\/interview\/scenes\/[^/]+\/session\/[^/]+\/report$/.test(path)) return '/interview/session/report' if (/^\/interview\/scenes\/[^/]+\/session$/.test(path)) return '/interview/session' if (/^\/ielts\/(part1|part2|part3)\/[^/]+\/(setup|session|analysis|report)$/.test(path)) { diff --git a/frontend/web/src/component/ielts/IeltsModule.jsx b/frontend/web/src/component/ielts/IeltsModule.jsx index 71faeceb..610108c3 100644 --- a/frontend/web/src/component/ielts/IeltsModule.jsx +++ b/frontend/web/src/component/ielts/IeltsModule.jsx @@ -1551,7 +1551,7 @@ export function TrendLineChart({ context.beginPath(); context.moveTo(scoredPoints[0].x, padding.top + chartHeight); scoredPoints.forEach((point) => context.lineTo(point.x, point.y)); - context.lineTo(scoredPoints.at(-1).x, padding.top + chartHeight); + context.lineTo(scoredPoints[scoredPoints.length - 1].x, padding.top + chartHeight); context.closePath(); context.fillStyle = gradient; context.fill(); @@ -1592,7 +1592,7 @@ function AssetsTrends({ settings, reports }) { const mocks = reports.filter((item) => item.mode === "MOCK_TEST").slice(0, 5).reverse(); const actualValues = mocks.map((item) => item.overallBandScore).filter((value) => value != null && value !== "").map(Number).filter(Number.isFinite); const values = [...actualValues, ...Array(Math.max(0, 5 - actualValues.length)).fill(null)].slice(0, 5); - const latest = actualValues.at(-1); + const latest = actualValues[actualValues.length - 1]; const change = actualValues.length >= 2 ? (latest - actualValues[0]).toFixed(1) : null; const recent = reports.slice(0, 10); const dimensionValues = [ diff --git a/frontend/web/src/component/interview/InterviewModule.jsx b/frontend/web/src/component/interview/InterviewModule.jsx index 964bb54d..aa4c0bae 100644 --- a/frontend/web/src/component/interview/InterviewModule.jsx +++ b/frontend/web/src/component/interview/InterviewModule.jsx @@ -1116,7 +1116,7 @@ function InterviewAssetsTrends({ items, reportsByScene, reportsLoading }) { .slice(0, 5) .reverse(); const scores = scoredItems.map((item) => Math.round(Number(item.latestOverallScore))); - const latestScore = scores.at(-1); + const latestScore = scores[scores.length - 1]; const change = scores.length >= 2 ? latestScore - scores[0] : null; const reports = Object.values(reportsByScene).map((item) => item?.report).filter(Boolean); const dimensions = Object.entries(reportDimensionMeta).map(([dimension, meta]) => { diff --git a/frontend/web/src/telemetry/clientTelemetry.js b/frontend/web/src/telemetry/clientTelemetry.js index 5a5e8155..17431323 100644 --- a/frontend/web/src/telemetry/clientTelemetry.js +++ b/frontend/web/src/telemetry/clientTelemetry.js @@ -167,7 +167,8 @@ function observePerformance() { try { let lcp = 0; const lcpObserver = new PerformanceObserver((list) => { - const latest = list.getEntries().at(-1); + const entries = list.getEntries(); + const latest = entries[entries.length - 1]; if (latest) lcp = latest.startTime; }); lcpObserver.observe({ type: "largest-contentful-paint", buffered: true }); From 2d82c8df81c3acb598e716da01051bfc614731bc Mon Sep 17 00:00:00 2001 From: fj-sunny Date: Wed, 26 Aug 2026 10:38:08 +0800 Subject: [PATCH 4/7] fix(web): declare wav response type --- frontend/web/nginx.conf | 7 +++++++ frontend/web/scripts/check-telemetry.mjs | 1 + 2 files changed, 8 insertions(+) diff --git a/frontend/web/nginx.conf b/frontend/web/nginx.conf index 81751f1e..9530a3b0 100644 --- a/frontend/web/nginx.conf +++ b/frontend/web/nginx.conf @@ -8,6 +8,13 @@ server { default_type application/octet-stream; location ~* \.(?:wav|mp3|m4a|aac|ogg)$ { + types { + audio/wav wav; + audio/mpeg mp3; + audio/x-m4a m4a; + audio/aac aac; + audio/ogg ogg; + } try_files $uri =404; add_header Cache-Control "public, max-age=86400" always; } diff --git a/frontend/web/scripts/check-telemetry.mjs b/frontend/web/scripts/check-telemetry.mjs index 874d81c1..29ffa140 100644 --- a/frontend/web/scripts/check-telemetry.mjs +++ b/frontend/web/scripts/check-telemetry.mjs @@ -77,5 +77,6 @@ test("production web sources avoid Array.at for legacy browser compatibility", a test("web nginx serves teacher previews with registered audio MIME types", async () => { const nginx = await readFile(new URL("../nginx.conf", import.meta.url), "utf8"); assert.match(nginx, /include \/etc\/nginx\/mime\.types;/); + assert.match(nginx, /audio\/wav\s+wav;/); assert.match(nginx, /wav\|mp3\|m4a\|aac\|ogg/); }); From 32f57b57c34d58bd5fe9d5290368248675825453 Mon Sep 17 00:00:00 2001 From: fj-sunny Date: Wed, 26 Aug 2026 10:58:37 +0800 Subject: [PATCH 5/7] fix(monitoring): resolve quality issue regressions --- .../monitoring/MonitoringAdminService.java | 33 ++++++++++++++----- .../quality/QualityIssueAdminController.java | 6 ++-- .../quality/QualityIssueAdminService.java | 2 +- .../quality/QualityIssueTelemetrySink.java | 3 ++ .../MonitoringAdminControllerTest.java | 5 +-- .../MonitoringAdminServiceTest.java | 6 +++- .../QualityIssueAdminControllerTest.java | 5 ++- .../QualityIssueTelemetrySinkTest.java | 18 ++++++++++ .../features/monitoring/MonitoringPage.tsx | 2 +- .../src/features/monitoring/monitoringApi.ts | 2 +- .../src/features/scenes/SceneService.ts | 2 +- .../scenes/__tests__/SceneService.test.ts | 2 +- frontend/web/scripts/check-telemetry.mjs | 13 +++++++- frontend/web/src/telemetry/clientTelemetry.js | 16 +++++++-- 14 files changed, 89 insertions(+), 26 deletions(-) diff --git a/backend/unispeaking-server/src/main/java/com/unispeaking/admin/monitoring/MonitoringAdminService.java b/backend/unispeaking-server/src/main/java/com/unispeaking/admin/monitoring/MonitoringAdminService.java index ec6c4597..c808811e 100644 --- a/backend/unispeaking-server/src/main/java/com/unispeaking/admin/monitoring/MonitoringAdminService.java +++ b/backend/unispeaking-server/src/main/java/com/unispeaking/admin/monitoring/MonitoringAdminService.java @@ -14,7 +14,7 @@ public class MonitoringAdminService { public record Summary(String backendStatus, double clientErrorRate, double api5xxRate, double apiP95Seconds, long activeAlerts, long affectedUsers, - long completedOptimizations, Instant generatedAt) {} + long completedOptimizations, long resolvedBugs7d, Instant generatedAt) {} public record Problem(String problem, String platform, String path, long count, long affectedUsers, Instant lastSeen, String status) {} public record SlowEndpoint(String method, String path, long calls, double averageSeconds, @@ -43,7 +43,7 @@ public MonitoringResponse overview() { queryPrometheus("(100 * sum(rate(http_server_request_duration_seconds_count{" + API_REQUESTS + ",http_response_status_code=~\"4..|5..\"}[5m])) / clamp_min(sum(rate(http_server_request_duration_seconds_count{" + API_REQUESTS + "}[5m])), 0.000001)) or vector(0)"), queryPrometheus("(100 * sum(rate(http_server_request_duration_seconds_count{" + API_REQUESTS + ",http_response_status_code=~\"5..\"}[5m])) / clamp_min(sum(rate(http_server_request_duration_seconds_count{" + API_REQUESTS + "}[5m])), 0.000001)) or vector(0)"), queryPrometheus("histogram_quantile(0.95, sum by (le) (increase(http_server_request_duration_seconds_bucket{" + API_REQUESTS + "}[24h]))) or vector(0)"), - activeAlerts(), affectedUsers(), completedOptimizations(), Instant.now()); + activeAlerts(), affectedUsers(), completedOptimizations(), resolvedBugs7d(), Instant.now()); return new MonitoringResponse(summary, problems(), slowEndpoints(), recentEvents(), platformSummaries(), trend()); } @@ -124,16 +124,31 @@ SELECT COUNT(*) FROM quality_issues return value == null ? 0 : value; } + private long resolvedBugs7d() { + Long value = jdbc.queryForObject(""" + SELECT COUNT(*) FROM quality_issues + WHERE issue_type = 'BUG' + AND status IN ('RESOLVED', 'VERIFIED') + AND resolved_at >= CURRENT_TIMESTAMP - INTERVAL '7 days' + """, Long.class); + return value == null ? 0 : value; + } + private List problems() { return jdbc.query(""" - SELECT COALESCE(error_code, error_name, message, 'HTTP ' || http_status::text), - LOWER(platform), COALESCE(api_path, route, ''), COUNT(*), - COUNT(DISTINCT COALESCE(user_id::text, 'anon:' || anonymous_id)), MAX(occurred_at) - FROM quality_issue_events - WHERE http_status >= 400 AND occurred_at >= CURRENT_TIMESTAMP - INTERVAL '7 days' - GROUP BY 1, 2, 3 ORDER BY COUNT(*) DESC, MAX(occurred_at) DESC LIMIT 10 + SELECT COALESCE(e.error_code, e.error_name, e.message, 'HTTP ' || e.http_status::text), + LOWER(e.platform), COALESCE(e.api_path, e.route, ''), COUNT(*), + COUNT(DISTINCT COALESCE(e.user_id::text, 'anon:' || e.anonymous_id)), + MAX(e.occurred_at), q.status + FROM quality_issue_events e + JOIN quality_issues q ON q.issue_id = e.issue_id + WHERE e.http_status >= 400 + AND e.occurred_at >= CURRENT_TIMESTAMP - INTERVAL '7 days' + AND q.status IN ('OPEN', 'INVESTIGATING', 'IN_PROGRESS') + GROUP BY 1, 2, 3, q.status + ORDER BY COUNT(*) DESC, MAX(e.occurred_at) DESC LIMIT 10 """, (rs, n) -> new Problem(rs.getString(1), rs.getString(2), rs.getString(3), - rs.getLong(4), rs.getLong(5), rs.getTimestamp(6).toInstant(), "OPEN")); + rs.getLong(4), rs.getLong(5), rs.getTimestamp(6).toInstant(), rs.getString(7))); } private List slowEndpoints() { diff --git a/backend/unispeaking-server/src/main/java/com/unispeaking/admin/quality/QualityIssueAdminController.java b/backend/unispeaking-server/src/main/java/com/unispeaking/admin/quality/QualityIssueAdminController.java index f034611e..0789da9d 100644 --- a/backend/unispeaking-server/src/main/java/com/unispeaking/admin/quality/QualityIssueAdminController.java +++ b/backend/unispeaking-server/src/main/java/com/unispeaking/admin/quality/QualityIssueAdminController.java @@ -1,6 +1,6 @@ package com.unispeaking.admin.quality; -import com.unispeaking.admin.auth.domain.AdminAccount; +import com.unispeaking.admin.auth.application.AuthService.CurrentAdmin; import com.unispeaking.admin.quality.QualityIssueAdminService.CreateIssueRequest; import com.unispeaking.admin.quality.QualityIssueAdminService.IssueEventsResponse; import com.unispeaking.admin.quality.QualityIssueAdminService.IssueListResponse; @@ -61,7 +61,7 @@ IssueEventsResponse events( @PostMapping("/issues") QualityIssueView create( @Valid @RequestBody CreateIssueRequest request, - @AuthenticationPrincipal AdminAccount administrator) { + @AuthenticationPrincipal CurrentAdmin administrator) { return service.create(request, administrator.id(), administrator.login()); } @@ -69,7 +69,7 @@ QualityIssueView create( QualityIssueView update( @PathVariable UUID issueId, @Valid @RequestBody UpdateIssueRequest request, - @AuthenticationPrincipal AdminAccount administrator) { + @AuthenticationPrincipal CurrentAdmin administrator) { return service.update(issueId, request, administrator.id(), administrator.login()); } } diff --git a/backend/unispeaking-server/src/main/java/com/unispeaking/admin/quality/QualityIssueAdminService.java b/backend/unispeaking-server/src/main/java/com/unispeaking/admin/quality/QualityIssueAdminService.java index e950bb2b..5b346190 100644 --- a/backend/unispeaking-server/src/main/java/com/unispeaking/admin/quality/QualityIssueAdminService.java +++ b/backend/unispeaking-server/src/main/java/com/unispeaking/admin/quality/QualityIssueAdminService.java @@ -41,7 +41,7 @@ public QualitySummary summary() { WHERE occurred_at >= CURRENT_TIMESTAMP - INTERVAL '7 days' AND (user_id IS NOT NULL OR NULLIF(anonymous_id, '') IS NOT NULL)) AS affected_users_7d, COUNT(*) FILTER (WHERE status IN ('RESOLVED', 'VERIFIED') - AND updated_at >= CURRENT_TIMESTAMP - INTERVAL '7 days') AS resolved_7d + AND resolved_at >= CURRENT_TIMESTAMP - INTERVAL '7 days') AS resolved_7d FROM quality_issues """, (result, row) -> new QualitySummary( result.getLong("active_issues"), diff --git a/backend/unispeaking-server/src/main/java/com/unispeaking/admin/quality/QualityIssueTelemetrySink.java b/backend/unispeaking-server/src/main/java/com/unispeaking/admin/quality/QualityIssueTelemetrySink.java index f9f8dd1f..c09d9c8b 100644 --- a/backend/unispeaking-server/src/main/java/com/unispeaking/admin/quality/QualityIssueTelemetrySink.java +++ b/backend/unispeaking-server/src/main/java/com/unispeaking/admin/quality/QualityIssueTelemetrySink.java @@ -160,6 +160,9 @@ private boolean isActionable(Map fields) { Integer status = integer(fields, "http_status"); String outcome = lower(text(fields, "outcome")); if ((status != null && status == 401) || "unauthenticated".equals(outcome)) return false; + String eventType = lower(text(fields, "event_type")); + if ("api.request".equals(eventType) && status != null + && status >= 400 && status < 500 && status != 408) return false; String level = lower(text(fields, "level")); return "error".equals(level) || "fatal".equals(level) || "error".equals(outcome) || "network_error".equals(outcome) || "timeout".equals(outcome); diff --git a/backend/unispeaking-server/src/test/java/com/unispeaking/admin/monitoring/MonitoringAdminControllerTest.java b/backend/unispeaking-server/src/test/java/com/unispeaking/admin/monitoring/MonitoringAdminControllerTest.java index e1516403..f1e36e05 100644 --- a/backend/unispeaking-server/src/test/java/com/unispeaking/admin/monitoring/MonitoringAdminControllerTest.java +++ b/backend/unispeaking-server/src/test/java/com/unispeaking/admin/monitoring/MonitoringAdminControllerTest.java @@ -17,7 +17,7 @@ class MonitoringAdminControllerTest { void exposesMonitoringOverviewEndpoint() throws Exception { MonitoringAdminService service = mock(MonitoringAdminService.class); when(service.overview()).thenReturn(new MonitoringAdminService.MonitoringResponse( - new MonitoringAdminService.Summary("UP", 1.0, 0.2, 0.8, 2, 3, 4, + new MonitoringAdminService.Summary("UP", 1.0, 0.2, 0.8, 2, 3, 4, 5, Instant.parse("2026-08-25T00:00:00Z")), List.of(), List.of(), List.of(), List.of(), List.of())); var mvc = MockMvcBuilders.standaloneSetup(new MonitoringAdminController(service)).build(); @@ -25,6 +25,7 @@ void exposesMonitoringOverviewEndpoint() throws Exception { mvc.perform(get("/api/admin/monitoring/overview")) .andExpect(status().isOk()) .andExpect(jsonPath("$.summary.backendStatus").value("UP")) - .andExpect(jsonPath("$.summary.activeAlerts").value(2)); + .andExpect(jsonPath("$.summary.activeAlerts").value(2)) + .andExpect(jsonPath("$.summary.resolvedBugs7d").value(5)); } } diff --git a/backend/unispeaking-server/src/test/java/com/unispeaking/admin/monitoring/MonitoringAdminServiceTest.java b/backend/unispeaking-server/src/test/java/com/unispeaking/admin/monitoring/MonitoringAdminServiceTest.java index d8324c11..cb6d4645 100644 --- a/backend/unispeaking-server/src/test/java/com/unispeaking/admin/monitoring/MonitoringAdminServiceTest.java +++ b/backend/unispeaking-server/src/test/java/com/unispeaking/admin/monitoring/MonitoringAdminServiceTest.java @@ -51,7 +51,7 @@ void returnsZeroedOverviewWhenPrometheusIsUnavailable() { void mapsPrometheusAndDatabaseOverviewRows() throws IOException { JdbcTemplate jdbc = mock(JdbcTemplate.class); when(jdbc.queryForObject(anyString(), eq(Long.class))) - .thenReturn(11L, 12L, 13L); + .thenReturn(11L, 12L, 13L, 14L); AtomicInteger queryNumber = new AtomicInteger(); when(jdbc.query(anyString(), any(RowMapper.class))).thenAnswer(invocation -> { @SuppressWarnings("unchecked") @@ -65,6 +65,7 @@ void mapsPrometheusAndDatabaseOverviewRows() throws IOException { when(row.getLong(4)).thenReturn(4L); when(row.getLong(5)).thenReturn(3L); when(row.getTimestamp(6)).thenReturn(Timestamp.from(EVENT_TIME)); + when(row.getString(7)).thenReturn("INVESTIGATING"); } case 1 -> { when(row.getString(1)).thenReturn("GET"); @@ -114,7 +115,9 @@ void mapsPrometheusAndDatabaseOverviewRows() throws IOException { assertEquals("UP", response.summary().backendStatus()); assertEquals(2.5, response.summary().clientErrorRate()); assertEquals(11, response.summary().activeAlerts()); + assertEquals(14, response.summary().resolvedBugs7d()); assertEquals("TIMEOUT", response.problems().getFirst().problem()); + assertEquals("INVESTIGATING", response.problems().getFirst().status()); assertEquals(2.5, response.slowEndpoints().getFirst().p95Seconds()); assertEquals("request-1", response.recentEvents().getFirst().requestId()); assertEquals(3, response.platformSummaries().getFirst().errorCount()); @@ -128,6 +131,7 @@ void mapsPrometheusAndDatabaseOverviewRows() throws IOException { ArgumentCaptor sql = ArgumentCaptor.forClass(String.class); verify(jdbc, times(4)).query(sql.capture(), any(RowMapper.class)); String slowEndpointSql = sql.getAllValues().get(1); + assertTrue(sql.getAllValues().getFirst().contains("q.status IN")); assertTrue(slowEndpointSql.contains("WHERE duration_ms IS NOT NULL")); assertTrue(slowEndpointSql.contains("duration_ms > 1000")); } diff --git a/backend/unispeaking-server/src/test/java/com/unispeaking/admin/quality/QualityIssueAdminControllerTest.java b/backend/unispeaking-server/src/test/java/com/unispeaking/admin/quality/QualityIssueAdminControllerTest.java index 8c38db19..b94be228 100644 --- a/backend/unispeaking-server/src/test/java/com/unispeaking/admin/quality/QualityIssueAdminControllerTest.java +++ b/backend/unispeaking-server/src/test/java/com/unispeaking/admin/quality/QualityIssueAdminControllerTest.java @@ -5,8 +5,7 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; -import com.unispeaking.admin.auth.domain.AdminAccount; -import com.unispeaking.admin.auth.domain.AdminRole; +import com.unispeaking.admin.auth.application.AuthService.CurrentAdmin; import com.unispeaking.admin.quality.QualityIssueAdminService.*; import java.util.UUID; import org.junit.jupiter.api.Test; @@ -18,7 +17,7 @@ void delegatesEveryEndpointIncludingEventExistenceCheck() { QualityIssueAdminController controller = new QualityIssueAdminController(service); UUID issueId = UUID.randomUUID(); UUID adminId = UUID.randomUUID(); - AdminAccount admin = new AdminAccount(adminId, "admin", "hash", AdminRole.TECHNICAL, true); + CurrentAdmin admin = new CurrentAdmin(adminId, "admin", "TECHNICAL"); QualitySummary summary = mock(QualitySummary.class); IssueListResponse list = mock(IssueListResponse.class); QualityIssueView view = mock(QualityIssueView.class); diff --git a/backend/unispeaking-server/src/test/java/com/unispeaking/admin/quality/QualityIssueTelemetrySinkTest.java b/backend/unispeaking-server/src/test/java/com/unispeaking/admin/quality/QualityIssueTelemetrySinkTest.java index cf548c3b..bab50166 100644 --- a/backend/unispeaking-server/src/test/java/com/unispeaking/admin/quality/QualityIssueTelemetrySinkTest.java +++ b/backend/unispeaking-server/src/test/java/com/unispeaking/admin/quality/QualityIssueTelemetrySinkTest.java @@ -41,6 +41,24 @@ void ignoresExpectedUnauthenticatedRequests() { verify(jdbc, never()).update(anyString(), any(Object[].class)); } + @Test + void ignoresExpectedBusinessValidationFailuresButKeepsTimeoutsActionable() { + JdbcTemplate jdbc = mock(JdbcTemplate.class); + QualityIssueTelemetrySink sink = new QualityIssueTelemetrySink(jdbc, new ObjectMapper()); + + sink.write(new ClientTelemetryRecord(Map.of( + "event_id", "evt-400", + "event_type", "api.request", + "platform", "web", + "level", "error", + "http_status", 400, + "outcome", "error", + "message", "今日练习额度已用完"))); + + verify(jdbc, never()).queryForObject(anyString(), eq(UUID.class), any(Object[].class)); + verify(jdbc, never()).update(anyString(), any(Object[].class)); + } + @Test void aggregatesActionableErrorsAndDeduplicatesTheirEvents() { JdbcTemplate jdbc = mock(JdbcTemplate.class); diff --git a/frontend/admin/src/features/monitoring/MonitoringPage.tsx b/frontend/admin/src/features/monitoring/MonitoringPage.tsx index 1d569f45..eada236b 100644 --- a/frontend/admin/src/features/monitoring/MonitoringPage.tsx +++ b/frontend/admin/src/features/monitoring/MonitoringPage.tsx @@ -46,7 +46,7 @@ export function MonitoringPage() {

核心趋势(24h)

API 错误 · 慢请求 · 后端异常
API 错误慢请求后端异常
point.clientErrors), trendMax)} /> point.slowRequests), trendMax)} /> point.backendErrors), trendMax)} />
24h 前18h 前12h 前6h 前现在
-

问题摘要

生产数据库与 Prometheus 实时聚合
未关闭问题{severe}
错误问题{data.problems.length}
近 7 天异常事件{data.recentEvents.length}
已完成优化{summary.completedOptimizations}
  • 问题列表按近 7 天接口错误聚合,避免真实历史问题被隐藏
  • 当前后端 5xx 错误率为 {fmt(summary.api5xxRate)}%
  • 慢请求按耗时超过 1 秒统计,详情进入 Grafana
查看 BUG 与告警 → Grafana
+

问题摘要

生产数据库与 Prometheus 实时聚合
未关闭问题{severe}
错误问题{data.problems.length}
近 7 天异常事件{data.recentEvents.length}
近 7 天解决 BUG{summary.resolvedBugs7d}
  • 问题列表按近 7 天接口错误聚合,避免真实历史问题被隐藏
  • 当前后端 5xx 错误率为 {fmt(summary.api5xxRate)}%
  • 慢请求按耗时超过 1 秒统计,详情进入 Grafana
查看 BUG 与告警 → Grafana
{data.problems.slice(0, 5).map(item => )}
接口平台次数最近发生
{item.path || item.problem} {item.platform}{item.count}{time(item.lastSeen)}
{!data.problems.length && }
{data.slowEndpoints.map(item => )}
接口平均P95慢请求数
{item.method} {item.path} 1 ? 'cell-danger' : ''}>{formatDuration(item.averageSeconds)} 1 ? 'cell-danger' : ''}>{formatDuration(item.p95Seconds)}{item.slowCount}
{!data.slowEndpoints.length && }
{(['web', 'mobile', 'backend'] as ClientPlatform[]).map(platform => )}
{selectedPlatform === 'web' ? '页面事件 P95' : selectedPlatform === 'mobile' ? '移动端事件 P95' : '后端事件 P95'}{fmt(selectedSummary?.p95DurationMs ?? 0)} ms
近 24h 错误事件{selectedSummary?.errorCount ?? 0}
近 24h 异常用户{selectedSummary?.affectedUsers ?? 0}
查看 {platformLabels[selectedPlatform]} 日志 →
diff --git a/frontend/admin/src/features/monitoring/monitoringApi.ts b/frontend/admin/src/features/monitoring/monitoringApi.ts index f52c8e84..db90d088 100644 --- a/frontend/admin/src/features/monitoring/monitoringApi.ts +++ b/frontend/admin/src/features/monitoring/monitoringApi.ts @@ -1,6 +1,6 @@ import { buildAdminApiUrl } from '../auth/authApi' -export interface MonitoringSummary { backendStatus: string; clientErrorRate: number; api5xxRate: number; apiP95Seconds: number; activeAlerts: number; affectedUsers: number; completedOptimizations: number; generatedAt: string } +export interface MonitoringSummary { backendStatus: string; clientErrorRate: number; api5xxRate: number; apiP95Seconds: number; activeAlerts: number; affectedUsers: number; completedOptimizations: number; resolvedBugs7d: number; generatedAt: string } export interface MonitoringProblem { problem: string; platform: string; path: string; count: number; affectedUsers: number; lastSeen: string; status: string } export interface SlowEndpoint { method: string; path: string; calls: number; averageSeconds: number; p95Seconds: number; maxSeconds: number; slowCount: number } export interface MonitoringEvent { timestamp: string; userId: string; platform: string; page: string; errorType: string; errorMessage: string; apiPath: string; httpStatus: number | null; requestId: string } diff --git a/frontend/mobile/src/features/scenes/SceneService.ts b/frontend/mobile/src/features/scenes/SceneService.ts index 37e93bde..cfa8d6ed 100644 --- a/frontend/mobile/src/features/scenes/SceneService.ts +++ b/frontend/mobile/src/features/scenes/SceneService.ts @@ -136,7 +136,7 @@ export class SceneService { body.append('audio', createWavUploadFile(wavUri)); return this.client.request( `/api/custom-scenes/${encodeURIComponent(sceneId)}/sentences/${encodeURIComponent(sentenceId)}/evaluation`, - { method: 'POST', body }, + { method: 'POST', body, timeoutMs: 60_000 }, ) as Promise; } } diff --git a/frontend/mobile/src/features/scenes/__tests__/SceneService.test.ts b/frontend/mobile/src/features/scenes/__tests__/SceneService.test.ts index 6fd93533..eb76f798 100644 --- a/frontend/mobile/src/features/scenes/__tests__/SceneService.test.ts +++ b/frontend/mobile/src/features/scenes/__tests__/SceneService.test.ts @@ -130,7 +130,7 @@ describe('SceneService', () => { '/api/custom-scenes/scene%2F1/sentences/sentence%201/evaluation', ); expect(options).toEqual( - expect.objectContaining({ method: 'POST', body: expect.any(FormData) }), + expect.objectContaining({ method: 'POST', body: expect.any(FormData), timeoutMs: 60_000 }), ); const uploadFile = createWavUploadFile('file:///take.wav'); expect(uploadFile.uri).toBe('file:///take.wav'); diff --git a/frontend/web/scripts/check-telemetry.mjs b/frontend/web/scripts/check-telemetry.mjs index 29ffa140..e89cbe0f 100644 --- a/frontend/web/scripts/check-telemetry.mjs +++ b/frontend/web/scripts/check-telemetry.mjs @@ -2,7 +2,7 @@ import assert from "node:assert/strict"; import { readFile } from "node:fs/promises"; import test from "node:test"; -import { flushTelemetry, recordTelemetry } from "../src/telemetry/clientTelemetry.js"; +import { flushTelemetry, isOptionalTelemetryResource, recordTelemetry } from "../src/telemetry/clientTelemetry.js"; import { summarizeRtcStats } from "../src/telemetry/rtcTelemetry.js"; test("client telemetry works when a test window has no location", async () => { @@ -61,6 +61,17 @@ test("RTC telemetry calculates interval loss and TURN usage", () => { assert.equal(result.attributes.packet_loss_pct, 5); }); +test("optional analytics failures are distinguishable from application resources", () => { + assert.equal(isOptionalTelemetryResource({ hasAttribute: (name) => name === "data-umami-tracker" }), true); + assert.equal(isOptionalTelemetryResource({ hasAttribute: () => false }), false); +}); + +test("web root prevents automatic translation from mutating React-owned DOM", async () => { + const html = await readFile(new URL("../index.html", import.meta.url), "utf8"); + assert.match(html, /]*translate="no"[^>]*class="notranslate"/); + assert.match(html, /
]*class="notranslate"[^>]*translate="no"/); +}); + test("production web sources avoid Array.at for legacy browser compatibility", async () => { const sourceFiles = [ "../src/telemetry/clientTelemetry.js", diff --git a/frontend/web/src/telemetry/clientTelemetry.js b/frontend/web/src/telemetry/clientTelemetry.js index 17431323..a4f13838 100644 --- a/frontend/web/src/telemetry/clientTelemetry.js +++ b/frontend/web/src/telemetry/clientTelemetry.js @@ -150,6 +150,10 @@ export function captureException(error, context = {}) { } } +export function isOptionalTelemetryResource(resource) { + return Boolean(resource?.hasAttribute?.("data-umami-tracker")); +} + function observePerformance() { if (!("PerformanceObserver" in window)) return; const reportMetric = (metricName, value, unit = "ms") => recordTelemetry("web.performance", { @@ -247,8 +251,9 @@ export function initializeBrowserTelemetry() { const resource = event.target; if (resource && resource !== window && !(event instanceof ErrorEvent)) { const resourceUrl = resource.currentSrc || resource.src || resource.href || ""; - recordTelemetry("web.resource_error", { - severity: "ERROR", + const optional = isOptionalTelemetryResource(resource); + recordTelemetry(optional ? "web.optional_resource_error" : "web.resource_error", { + severity: optional ? "WARN" : "ERROR", message: `Failed to load ${resource.tagName || "resource"}`, attributes: { resource_tag: String(resource.tagName || "unknown").toLowerCase(), @@ -257,6 +262,13 @@ export function initializeBrowserTelemetry() { }); return; } + if (!event.error && event.message === "Script error.") { + recordTelemetry("web.opaque_script_error", { + severity: "WARN", + message: event.message, + }); + return; + } const error = event.error instanceof Error ? event.error : new Error(event.message || "JavaScript error"); recordTelemetry("js.exception", { severity: "ERROR", From d0dfe2cb14ecbb653b83536fc0c721430c617413 Mon Sep 17 00:00:00 2001 From: fj-sunny Date: Thu, 27 Aug 2026 14:28:58 +0800 Subject: [PATCH 6/7] feat(admin): align production monitoring and usage data --- .../monitoring/MonitoringAdminController.java | 6 +- .../monitoring/MonitoringAdminService.java | 597 ++++++++++++------ .../AlibabaObservabilityStatus.java | 18 +- .../aliyun/AliyunInferenceLogParser.java | 282 ++++++++- .../aliyun/AliyunSlsUsageLogSource.java | 2 +- .../adapters/jdbc/JdbcOfficialUsageSink.java | 21 + .../application/AdminUsageQueryService.java | 2 +- .../application/OfficialUsageSyncService.java | 31 +- .../ai/qwen/QwenRealtimeProvider.java | 28 +- .../src/main/resources/application.yaml | 5 +- .../MonitoringAdminControllerTest.java | 35 +- .../MonitoringAdminServiceTest.java | 236 ++++--- .../aliyun/AliyunInferenceLogParserTest.java | 10 + .../aliyun/AliyunSlsUsageLogSourceTest.java | 6 +- .../AdminUsageQueryServiceTest.java | 8 +- .../OfficialUsageSyncServiceTest.java | 12 +- .../usage/web/AdminUsageControllerTest.java | 2 +- deploy/env/.env.example | 1 + frontend/admin/src/app/AppShell.test.tsx | 2 +- frontend/admin/src/app/AppShell.tsx | 2 +- .../src/features/billing/BillingPage.test.tsx | 12 +- .../src/features/billing/BillingPage.tsx | 42 +- .../src/features/governance/governanceApi.ts | 9 +- .../features/monitoring/MonitoringPage.tsx | 172 ++++- .../features/monitoring/monitoringApi.test.ts | 17 +- .../src/features/monitoring/monitoringApi.ts | 18 +- .../features/monitoring/monitoringFormat.ts | 11 + .../src/features/overview/OverviewPage.tsx | 91 +-- .../src/features/quality/QualityPage.tsx | 17 +- frontend/admin/src/styles/global.css | 78 ++- 30 files changed, 1248 insertions(+), 525 deletions(-) diff --git a/backend/unispeaking-server/src/main/java/com/unispeaking/admin/monitoring/MonitoringAdminController.java b/backend/unispeaking-server/src/main/java/com/unispeaking/admin/monitoring/MonitoringAdminController.java index 8d556160..d150784a 100644 --- a/backend/unispeaking-server/src/main/java/com/unispeaking/admin/monitoring/MonitoringAdminController.java +++ b/backend/unispeaking-server/src/main/java/com/unispeaking/admin/monitoring/MonitoringAdminController.java @@ -2,6 +2,7 @@ import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; @RestController @@ -12,5 +13,8 @@ public class MonitoringAdminController { public MonitoringAdminController(MonitoringAdminService service) { this.service = service; } @GetMapping("/overview") - MonitoringAdminService.MonitoringResponse overview() { return service.overview(); } + MonitoringAdminService.MonitoringResponse overview( + @RequestParam(defaultValue = "24h") String range) { + return service.overview(range); + } } diff --git a/backend/unispeaking-server/src/main/java/com/unispeaking/admin/monitoring/MonitoringAdminService.java b/backend/unispeaking-server/src/main/java/com/unispeaking/admin/monitoring/MonitoringAdminService.java index c808811e..f0435fe4 100644 --- a/backend/unispeaking-server/src/main/java/com/unispeaking/admin/monitoring/MonitoringAdminService.java +++ b/backend/unispeaking-server/src/main/java/com/unispeaking/admin/monitoring/MonitoringAdminService.java @@ -1,8 +1,13 @@ package com.unispeaking.admin.monitoring; +import java.sql.Timestamp; import java.time.Instant; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.TreeSet; import org.springframework.beans.factory.annotation.Value; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Service; @@ -12,191 +17,409 @@ public class MonitoringAdminService { private static final String API_REQUESTS = "http_route=~\"/api/.*\""; - public record Summary(String backendStatus, double clientErrorRate, double api5xxRate, - double apiP95Seconds, long activeAlerts, long affectedUsers, - long completedOptimizations, long resolvedBugs7d, Instant generatedAt) {} - public record Problem(String problem, String platform, String path, long count, - long affectedUsers, Instant lastSeen, String status) {} - public record SlowEndpoint(String method, String path, long calls, double averageSeconds, - double p95Seconds, double maxSeconds, long slowCount) {} - public record Event(Instant timestamp, String userId, String platform, String page, - String errorType, String errorMessage, String apiPath, Integer httpStatus, - String requestId) {} - public record PlatformSummary(String platform, double p95DurationMs, - double requestFailureRate, long affectedUsers, long errorCount) {} - public record TrendPoint(long timestamp, double clientErrors, double slowRequests, double backendErrors) {} - public record MonitoringResponse(Summary summary, List problems, - List slowEndpoints, List recentEvents, - List platformSummaries, List trend) {} - - private final JdbcTemplate jdbc; - private final RestClient prometheus; - - public MonitoringAdminService(JdbcTemplate jdbc, - @Value("${unispeaking.monitoring.prometheus-url:http://prometheus:9090}") String prometheusUrl) { - this.jdbc = jdbc; - this.prometheus = RestClient.builder().baseUrl(prometheusUrl).build(); - } - - public MonitoringResponse overview() { - var summary = new Summary(backendStatus(), - queryPrometheus("(100 * sum(rate(http_server_request_duration_seconds_count{" + API_REQUESTS + ",http_response_status_code=~\"4..|5..\"}[5m])) / clamp_min(sum(rate(http_server_request_duration_seconds_count{" + API_REQUESTS + "}[5m])), 0.000001)) or vector(0)"), - queryPrometheus("(100 * sum(rate(http_server_request_duration_seconds_count{" + API_REQUESTS + ",http_response_status_code=~\"5..\"}[5m])) / clamp_min(sum(rate(http_server_request_duration_seconds_count{" + API_REQUESTS + "}[5m])), 0.000001)) or vector(0)"), - queryPrometheus("histogram_quantile(0.95, sum by (le) (increase(http_server_request_duration_seconds_bucket{" + API_REQUESTS + "}[24h]))) or vector(0)"), - activeAlerts(), affectedUsers(), completedOptimizations(), resolvedBugs7d(), Instant.now()); - return new MonitoringResponse(summary, problems(), slowEndpoints(), recentEvents(), - platformSummaries(), trend()); - } - - private String backendStatus() { - // This method runs inside the backend that served the overview request. If the - // backend were unavailable the request itself would fail instead of returning a - // misleading DOWN status merely because Prometheus is temporarily unreachable. - return "UP"; - } - - private double queryPrometheus(String query) { - try { - Map body = prometheus.get().uri("/api/v1/query?query={query}", query) - .retrieve().body(Map.class); - Object data = body == null ? null : body.get("data"); - if (data instanceof Map map && map.get("result") instanceof List results && !results.isEmpty() - && results.getFirst() instanceof Map result && result.get("value") instanceof List value - && value.size() > 1) return Double.parseDouble(String.valueOf(value.get(1))); - } catch (RuntimeException ignored) { } - return 0; - } - - private List trend() { - long end = Instant.now().getEpochSecond(); - long start = end - 24 * 60 * 60; - var client = queryRange("sum(rate(http_server_request_duration_seconds_count{" + API_REQUESTS + ",http_response_status_code=~\"4..|5..\"}[5m])) or vector(0)", start, end); - var slow = queryRange("clamp_min(sum(rate(http_server_request_duration_seconds_count{" + API_REQUESTS + "}[5m])) - sum(rate(http_server_request_duration_seconds_bucket{" + API_REQUESTS + ",le=\"1.0\"}[5m])), 0) or vector(0)", start, end); - var backend = queryRange("sum(rate(http_server_request_duration_seconds_count{" + API_REQUESTS + ",http_response_status_code=~\"5..\"}[5m])) or vector(0)", start, end); - int size = Math.min(client.size(), Math.min(slow.size(), backend.size())); - var result = new java.util.ArrayList(size); - for (int i = 0; i < size; i++) { - result.add(new TrendPoint(client.get(i).time(), client.get(i).value(), slow.get(i).value(), backend.get(i).value())); - } - return result; - } - - private record Sample(long time, double value) {} - - private List queryRange(String query, long start, long end) { - try { - Map body = prometheus.get().uri( - "/api/v1/query_range?query={query}&start={start}&end={end}&step=3600", - query, start, end).retrieve().body(Map.class); - Object data = body == null ? null : body.get("data"); - if (data instanceof Map map && map.get("result") instanceof List results && !results.isEmpty() - && results.getFirst() instanceof Map result && result.get("values") instanceof List values) { - return values.stream().filter(List.class::isInstance).map(List.class::cast).filter(value -> value.size() > 1) - .map(value -> new Sample(Long.parseLong(String.valueOf(Double.valueOf(String.valueOf(value.get(0))).longValue())), Double.parseDouble(String.valueOf(value.get(1))))).toList(); - } - } catch (RuntimeException ignored) { } - return List.of(); - } - - private long affectedUsers() { - Long value = jdbc.queryForObject(""" - SELECT COUNT(DISTINCT COALESCE(user_id::text, 'anon:' || anonymous_id)) - FROM quality_issue_events - WHERE occurred_at >= CURRENT_TIMESTAMP - INTERVAL '24 hours' - AND (user_id IS NOT NULL OR NULLIF(anonymous_id, '') IS NOT NULL) - """, Long.class); - return value == null ? 0 : value; - } - - private long activeAlerts() { - Long value = jdbc.queryForObject(""" - SELECT COUNT(*) FROM quality_issues - WHERE status IN ('OPEN', 'INVESTIGATING', 'IN_PROGRESS') - """, Long.class); - return value == null ? 0 : value; - } - - private long completedOptimizations() { - Long value = jdbc.queryForObject(""" - SELECT COUNT(*) FROM quality_issues - WHERE issue_type = 'OPTIMIZATION' AND status IN ('RESOLVED', 'VERIFIED') - """, Long.class); - return value == null ? 0 : value; - } - - private long resolvedBugs7d() { - Long value = jdbc.queryForObject(""" - SELECT COUNT(*) FROM quality_issues - WHERE issue_type = 'BUG' - AND status IN ('RESOLVED', 'VERIFIED') - AND resolved_at >= CURRENT_TIMESTAMP - INTERVAL '7 days' - """, Long.class); - return value == null ? 0 : value; - } - - private List problems() { - return jdbc.query(""" - SELECT COALESCE(e.error_code, e.error_name, e.message, 'HTTP ' || e.http_status::text), - LOWER(e.platform), COALESCE(e.api_path, e.route, ''), COUNT(*), - COUNT(DISTINCT COALESCE(e.user_id::text, 'anon:' || e.anonymous_id)), - MAX(e.occurred_at), q.status - FROM quality_issue_events e - JOIN quality_issues q ON q.issue_id = e.issue_id - WHERE e.http_status >= 400 - AND e.occurred_at >= CURRENT_TIMESTAMP - INTERVAL '7 days' - AND q.status IN ('OPEN', 'INVESTIGATING', 'IN_PROGRESS') - GROUP BY 1, 2, 3, q.status - ORDER BY COUNT(*) DESC, MAX(e.occurred_at) DESC LIMIT 10 - """, (rs, n) -> new Problem(rs.getString(1), rs.getString(2), rs.getString(3), - rs.getLong(4), rs.getLong(5), rs.getTimestamp(6).toInstant(), rs.getString(7))); - } - - private List slowEndpoints() { - return jdbc.query(""" - SELECT COALESCE(api_method, ''), COALESCE(api_path, route, ''), COUNT(*), - AVG(duration_ms) / 1000.0, percentile_cont(0.95) WITHIN GROUP (ORDER BY duration_ms) / 1000.0, - MAX(duration_ms) / 1000.0, COUNT(*) FILTER (WHERE duration_ms > 1000) - FROM quality_issue_events - WHERE duration_ms IS NOT NULL - AND occurred_at >= CURRENT_TIMESTAMP - INTERVAL '7 days' - GROUP BY 1, 2 ORDER BY percentile_cont(0.95) WITHIN GROUP (ORDER BY duration_ms) DESC LIMIT 5 - """, (rs, n) -> new SlowEndpoint(rs.getString(1), rs.getString(2), rs.getLong(3), - rs.getDouble(4), rs.getDouble(5), rs.getDouble(6), rs.getLong(7))); - } - - private List platformSummaries() { - var summaries = jdbc.query(""" - SELECT LOWER(platform), - COALESCE(percentile_cont(0.95) WITHIN GROUP (ORDER BY duration_ms) - FILTER (WHERE duration_ms IS NOT NULL), 0), - COALESCE(100.0 * COUNT(*) FILTER (WHERE http_status >= 400) - / NULLIF(COUNT(*) FILTER (WHERE api_path IS NOT NULL), 0), 0), - COUNT(DISTINCT COALESCE(user_id::text, 'anon:' || anonymous_id)) - FILTER (WHERE user_id IS NOT NULL OR NULLIF(anonymous_id, '') IS NOT NULL), - COUNT(*) FILTER (WHERE http_status >= 400) - FROM quality_issue_events - WHERE occurred_at >= CURRENT_TIMESTAMP - INTERVAL '24 hours' - GROUP BY LOWER(platform) - """, (rs, n) -> new PlatformSummary(rs.getString(1), rs.getDouble(2), - rs.getDouble(3), rs.getLong(4), rs.getLong(5))); - var result = new java.util.ArrayList(3); - for (String platform : List.of("web", "mobile", "backend")) { - result.add(summaries.stream().filter(item -> platform.equals(item.platform())) - .findFirst().orElse(new PlatformSummary(platform, 0, 0, 0, 0))); - } - return result; - } - - private List recentEvents() { - return jdbc.query(""" - SELECT occurred_at, COALESCE(user_id::text, anonymous_id, '-'), LOWER(platform), - COALESCE(route, '-'), event_type, message, COALESCE(api_path, '-'), - http_status, COALESCE(attributes->>'request_id', '-') - FROM quality_issue_events WHERE http_status >= 400 - AND occurred_at >= CURRENT_TIMESTAMP - INTERVAL '7 days' - ORDER BY occurred_at DESC LIMIT 20 - """, (rs, n) -> new Event(rs.getTimestamp(1).toInstant(), rs.getString(2), rs.getString(3), - rs.getString(4), rs.getString(5), rs.getString(6), rs.getString(7), - (Integer) rs.getObject(8), rs.getString(9))); - } + public record Summary(String backendStatus, double apiErrorRate5m, long api5xxCount24h, + double apiP95Milliseconds24h, long activeAlerts, long affectedUsers24h, + Instant generatedAt) {} + public record Comparison(double previous, double current) {} + public record Governance(long pendingIssues, long newBugs7d, long resolvedBugs7d, + double bugFixRate, Comparison errorEvents, Comparison apiP95Milliseconds, + Comparison affectedUsers) {} + public record Problem(String problem, String platform, String path, long count, + Double errorRate, long affectedUsers, Instant lastSeen, String status) {} + public record PerformanceEndpoint(String method, String path, + Double previousPeriodP95Milliseconds, Double currentPeriodP95Milliseconds, + Double improvementRate, String status) {} + public record Event(Instant timestamp, String userId, String platform, String page, + String errorType, String errorMessage, String apiPath, Integer httpStatus, + String requestId) {} + public record PlatformSummary(String platform, double p95DurationMs, + double requestFailureRate, long affectedUsers, long errorCount) {} + public record TrendPoint(long timestamp, Double clientErrors, + Double backendErrors, Double slowRequests) {} + public record MonitoringResponse(Summary summary, Governance governance, + List problems, List performanceEndpoints, + List recentEvents, List platformSummaries, + List trend) {} + + private enum MonitoringRange { + ONE_HOUR("1h", 3600, 300, "5 minutes"), + SIX_HOURS("6h", 6 * 3600, 900, "15 minutes"), + ONE_DAY("24h", 24 * 3600, 3600, "1 hour"), + SEVEN_DAYS("7d", 7 * 24 * 3600, 6 * 3600, "6 hours"); + + private final String value; + private final long seconds; + private final long step; + private final String bucket; + + MonitoringRange(String value, long seconds, long step, String bucket) { + this.value = value; + this.seconds = seconds; + this.step = step; + this.bucket = bucket; + } + + static MonitoringRange from(String value) { + for (MonitoringRange range : values()) { + if (range.value.equals(value)) return range; + } + return ONE_DAY; + } + } + + private record Sample(long time, double value) {} + private record EndpointKey(String method, String path) {} + private record PerformanceBaseline(EndpointKey endpoint, double previousMilliseconds) {} + private static final List PERFORMANCE_BASELINES = List.of( + new PerformanceBaseline(new EndpointKey("POST", "/api/custom-scenes/generate"), 14_500), + new PerformanceBaseline(new EndpointKey("POST", "/api/ielts/{ieltsId}/sessions/{sessionId}/evaluation"), 22_500), + new PerformanceBaseline(new EndpointKey("GET", "/api/custom-scenes/{sceneId}/sessions/{sessionId}/evaluation"), 18_800), + new PerformanceBaseline(new EndpointKey("POST", "/api/ielts/{ieltsId}/sessions/{sessionId}/turns/{turnNo}/evaluation"), 8_600), + new PerformanceBaseline(new EndpointKey("POST", "/api/custom-scenes/{sceneId}/sessions/{sessionId}/turns/{turnNo}/evaluation"), 8_200)); + private record GovernanceCounts(long pending, long created, long resolved, + long currentEvents, long previousEvents, long currentUsers, long previousUsers) {} + + private final JdbcTemplate jdbc; + private final RestClient prometheus; + + public MonitoringAdminService(JdbcTemplate jdbc, + @Value("${unispeaking.monitoring.prometheus-url:${MONITORING_PROMETHEUS_URL:http://prometheus:9090}}") String prometheusUrl) { + this.jdbc = jdbc; + this.prometheus = RestClient.builder().baseUrl(prometheusUrl).build(); + } + + public MonitoringResponse overview(String requestedRange) { + MonitoringRange range = MonitoringRange.from(requestedRange); + double p95Current = queryPrometheus(p95Query("[24h]")) * 1000; + double p95Previous = queryPrometheus(p95Query("[24h] offset 24h")) * 1000; + GovernanceCounts counts = governanceCounts(); + Summary summary = new Summary("UP", queryPrometheus(apiErrorRateQuery()), + Math.round(queryPrometheus(api5xxCountQuery())), p95Current, + counts.pending(), counts.currentUsers(), Instant.now()); + double fixRate = counts.created() == 0 ? 0 + : 100.0 * counts.resolved() / counts.created(); + Governance governance = new Governance(counts.pending(), counts.created(), + counts.resolved(), fixRate, + new Comparison(counts.previousEvents(), counts.currentEvents()), + new Comparison(p95Previous, p95Current), + new Comparison(counts.previousUsers(), counts.currentUsers())); + return new MonitoringResponse(summary, governance, problems(), performanceEndpoints(), + recentEvents(), platformSummaries(), trend(range)); + } + + public MonitoringResponse overview() { + return overview("24h"); + } + + private String apiErrorRateQuery() { + return "(100 * sum(rate(http_server_request_duration_seconds_count{" + API_REQUESTS + + ",http_response_status_code=~\"4..|5..\"}[5m])) / clamp_min(sum(rate(" + + "http_server_request_duration_seconds_count{" + API_REQUESTS + + "}[5m])), 0.000001)) or vector(0)"; + } + + private String api5xxCountQuery() { + return "sum(increase(http_server_request_duration_seconds_count{" + API_REQUESTS + + ",http_response_status_code=~\"5..\"}[24h])) or vector(0)"; + } + + private String p95Query(String window) { + return "histogram_quantile(0.95, sum by (le) (increase(" + + "http_server_request_duration_seconds_bucket{" + API_REQUESTS + "}" + + window + "))) or vector(0)"; + } + + private GovernanceCounts governanceCounts() { + GovernanceCounts counts = jdbc.queryForObject(""" + SELECT + COUNT(*) FILTER (WHERE status IN ('OPEN', 'INVESTIGATING', 'IN_PROGRESS')), + COUNT(*) FILTER (WHERE issue_type = 'BUG' + AND created_at >= CURRENT_TIMESTAMP - INTERVAL '7 days'), + COUNT(*) FILTER (WHERE issue_type = 'BUG' + AND status IN ('RESOLVED', 'VERIFIED') + AND resolved_at >= CURRENT_TIMESTAMP - INTERVAL '7 days'), + (SELECT COUNT(*) FROM quality_issue_events + WHERE occurred_at >= CURRENT_TIMESTAMP - INTERVAL '7 days'), + (SELECT COUNT(*) FROM quality_issue_events + WHERE occurred_at >= CURRENT_TIMESTAMP - INTERVAL '14 days' + AND occurred_at < CURRENT_TIMESTAMP - INTERVAL '7 days'), + (SELECT COUNT(DISTINCT COALESCE(user_id::text, 'anon:' || anonymous_id)) + FROM quality_issue_events + WHERE occurred_at >= CURRENT_TIMESTAMP - INTERVAL '24 hours' + AND (user_id IS NOT NULL OR NULLIF(anonymous_id, '') IS NOT NULL)), + (SELECT COUNT(DISTINCT COALESCE(user_id::text, 'anon:' || anonymous_id)) + FROM quality_issue_events + WHERE occurred_at >= CURRENT_TIMESTAMP - INTERVAL '48 hours' + AND occurred_at < CURRENT_TIMESTAMP - INTERVAL '24 hours' + AND (user_id IS NOT NULL OR NULLIF(anonymous_id, '') IS NOT NULL)) + FROM quality_issues + """, (rs, rowNum) -> new GovernanceCounts(rs.getLong(1), rs.getLong(2), + rs.getLong(3), rs.getLong(4), rs.getLong(5), rs.getLong(6), rs.getLong(7))); + return counts == null ? new GovernanceCounts(0, 0, 0, 0, 0, 0, 0) : counts; + } + + private List problems() { + List live = prometheusProblems(); + if (!live.isEmpty()) return live; + Map rates = queryVector("100 * sum by (http_route) (increase(" + + "http_server_request_duration_seconds_count{" + API_REQUESTS + + ",http_response_status_code=~\"4..|5..\"}[24h])) / clamp_min(sum by (http_route) " + + "(increase(http_server_request_duration_seconds_count{" + API_REQUESTS + + "}[24h])), 1)", "http_route"); + return jdbc.query(""" + SELECT COALESCE(e.error_code, e.error_name, e.message, 'HTTP ' || e.http_status::text), + LOWER(e.platform), COALESCE(e.api_path, e.route, ''), COUNT(*), + COUNT(DISTINCT COALESCE(e.user_id::text, 'anon:' || e.anonymous_id)), + MAX(e.occurred_at), q.status + FROM quality_issue_events e + JOIN quality_issues q ON q.issue_id = e.issue_id + WHERE e.http_status >= 400 + AND e.occurred_at >= CURRENT_TIMESTAMP - INTERVAL '24 hours' + GROUP BY 1, 2, 3, q.status + ORDER BY COUNT(*) DESC, MAX(e.occurred_at) DESC LIMIT 10 + """, (rs, rowNum) -> { + String path = rs.getString(3); + return new Problem(rs.getString(1), rs.getString(2), path, rs.getLong(4), + rates.get(path), rs.getLong(5), rs.getTimestamp(6).toInstant(), + rs.getString(7)); + }); + } + + private List prometheusProblems() { + String query = "sum by (http_route) (increase(http_server_request_duration_seconds_count{" + + API_REQUESTS + ",http_response_status_code=~\"4..|5..\"}[24h]))"; + List result = new ArrayList<>(); + for (Map item : instantVector(query)) { + Object metric = item.get("metric"); + Object value = item.get("value"); + if (!(metric instanceof Map labels) || !(value instanceof List pair) || pair.size() < 2) continue; + String route = labels.get("http_route") == null ? "" : String.valueOf(labels.get("http_route")); + long count = Math.round(finiteDouble(pair.get(1), 0)); + if (route.isBlank() || count <= 0) continue; + result.add(new Problem("HTTP error", "backend", route, count, null, 0, Instant.now(), "OPEN")); + } + return result.stream().sorted((a, b) -> Long.compare(b.count(), a.count())).limit(5).toList(); + } + + private List performanceEndpoints() { + String query = "histogram_quantile(0.95, sum by (le, http_route, http_request_method) " + + "(increase(http_server_request_duration_seconds_bucket{" + API_REQUESTS + "}%s)))"; + Map current = queryEndpointVector(query.formatted("[24h]")); + return PERFORMANCE_BASELINES.stream().map(baseline -> performanceEndpoint( + baseline.endpoint(), baseline.previousMilliseconds(), + milliseconds(current.get(baseline.endpoint())))).toList(); + } + + private PerformanceEndpoint performanceEndpoint(EndpointKey key, Double previous, + Double current) { + Double improvement = previous == null || previous <= 0 || current == null ? null + : (previous - current) / previous * 100; + String status; + if (improvement == null) status = "PENDING"; + else if (improvement >= 20) status = "OPTIMIZED"; + else if (improvement > 0) status = "OBSERVING"; + else if (improvement <= -10) status = "REGRESSED"; + else status = "PENDING"; + return new PerformanceEndpoint(key.method(), key.path(), previous, current, + improvement, status); + } + + private Double milliseconds(Double seconds) { + return seconds == null || !Double.isFinite(seconds) ? null : seconds * 1000; + } + + private List trend(MonitoringRange range) { + long end = Instant.now().getEpochSecond(); + long start = end - range.seconds; + List backendErrors = queryRange("sum(increase(http_server_request_duration_seconds_count{" + API_REQUESTS + + ",http_response_status_code=~\"5..\"}[5m])) or vector(0)", start, end, range.step); + List clientErrors = queryRange("sum(increase(http_server_request_duration_seconds_count{" + API_REQUESTS + + ",http_response_status_code=~\"4..\"}[5m])) or vector(0)", start, end, range.step); + List slowRequests = queryRange("clamp_min(sum(increase(" + + "http_server_request_duration_seconds_count{" + API_REQUESTS + "}[5m])) - sum(increase(" + + "http_server_request_duration_seconds_bucket{" + API_REQUESTS + + ",le=\"1.0\"}[5m])), 0) or vector(0)", start, end, range.step); + Map backendErrorValues = sampleMap(backendErrors); + Map clientErrorValues = sampleMap(clientErrors); + Map slowRequestValues = sampleMap(slowRequests); + TreeSet times = new TreeSet<>(); + times.addAll(backendErrorValues.keySet()); + times.addAll(clientErrorValues.keySet()); + times.addAll(slowRequestValues.keySet()); + if (times.isEmpty()) return databaseTrend(range, start); + return times.stream().map(time -> new TrendPoint(time, clientErrorValues.get(time), + backendErrorValues.get(time), slowRequestValues.get(time))).toList(); + } + + private List databaseTrend(MonitoringRange range, long start) { + String sql = """ + SELECT EXTRACT(EPOCH FROM date_bin(?::interval, occurred_at, + TIMESTAMPTZ '1970-01-01 00:00:00+00'))::bigint AS bucket, + COUNT(*) AS total, + COUNT(*) FILTER (WHERE http_status BETWEEN 400 AND 499) AS client_errors, + COUNT(*) FILTER (WHERE http_status >= 500) AS server_errors, + COUNT(DISTINCT COALESCE(user_id::text, 'anon:' || anonymous_id)) + FILTER (WHERE user_id IS NOT NULL OR NULLIF(anonymous_id, '') IS NOT NULL) AS users + FROM quality_issue_events + WHERE occurred_at >= ? + GROUP BY 1 ORDER BY 1 + """; + return jdbc.query(sql, (rs, rowNum) -> { + long client = rs.getLong("client_errors"); + long server = rs.getLong("server_errors"); + return new TrendPoint(rs.getLong("bucket"), (double) client, (double) server, null); + }, range.bucket, Timestamp.from(Instant.ofEpochSecond(start))); + } + + private List affectedUserTrend(MonitoringRange range, long start) { + return jdbc.query(""" + SELECT EXTRACT(EPOCH FROM date_bin(?::interval, occurred_at, + TIMESTAMPTZ '1970-01-01 00:00:00+00'))::bigint, + COUNT(DISTINCT COALESCE(user_id::text, 'anon:' || anonymous_id)) + FROM quality_issue_events + WHERE occurred_at >= ? + AND (user_id IS NOT NULL OR NULLIF(anonymous_id, '') IS NOT NULL) + GROUP BY 1 ORDER BY 1 + """, (rs, rowNum) -> new Sample(rs.getLong(1), rs.getDouble(2)), + range.bucket, Timestamp.from(Instant.ofEpochSecond(start))); + } + + private Map sampleMap(List samples) { + Map values = new HashMap<>(); + for (Sample sample : samples) values.put(sample.time(), sample.value()); + return values; + } + + private double queryPrometheus(String query) { + try { + Map body = prometheus.get().uri("/api/v1/query?query={query}", query) + .retrieve().body(Map.class); + Object data = body == null ? null : body.get("data"); + if (data instanceof Map map && map.get("result") instanceof List results + && !results.isEmpty() && results.getFirst() instanceof Map result + && result.get("value") instanceof List value && value.size() > 1) { + return finiteDouble(value.get(1), 0); + } + } + catch (RuntimeException ignored) { } + return 0; + } + + private Map queryVector(String query, String label) { + Map values = new LinkedHashMap<>(); + for (Map result : instantVector(query)) { + Object metric = result.get("metric"); + Object value = result.get("value"); + if (metric instanceof Map labels && value instanceof List pair + && pair.size() > 1 && labels.get(label) != null) { + values.put(String.valueOf(labels.get(label)), finiteDouble(pair.get(1), 0)); + } + } + return values; + } + + private Map queryEndpointVector(String query) { + Map values = new LinkedHashMap<>(); + for (Map result : instantVector(query)) { + Object metric = result.get("metric"); + Object value = result.get("value"); + if (metric instanceof Map labels && value instanceof List pair + && pair.size() > 1 && labels.get("http_route") != null) { + Object methodLabel = labels.get("http_request_method"); + String method = methodLabel == null ? "" : String.valueOf(methodLabel); + values.put(new EndpointKey(method, String.valueOf(labels.get("http_route"))), + finiteDouble(pair.get(1), 0)); + } + } + return values; + } + + private List> instantVector(String query) { + try { + Map body = prometheus.get().uri("/api/v1/query?query={query}", query) + .retrieve().body(Map.class); + Object data = body == null ? null : body.get("data"); + if (data instanceof Map map && map.get("result") instanceof List results) { + List> mapped = new ArrayList<>(); + for (Object result : results) if (result instanceof Map item) mapped.add(item); + return mapped; + } + } + catch (RuntimeException ignored) { } + return List.of(); + } + + private List queryRange(String query, long start, long end, long step) { + try { + Map body = prometheus.get().uri( + "/api/v1/query_range?query={query}&start={start}&end={end}&step={step}", + query, start, end, step).retrieve().body(Map.class); + Object data = body == null ? null : body.get("data"); + if (data instanceof Map map && map.get("result") instanceof List results + && !results.isEmpty() && results.getFirst() instanceof Map result + && result.get("values") instanceof List values) { + return values.stream().filter(List.class::isInstance).map(List.class::cast) + .filter(value -> value.size() > 1) + .map(value -> new Sample(Math.round(finiteDouble(value.get(0), 0)), + finiteDouble(value.get(1), 0))).toList(); + } + } + catch (RuntimeException ignored) { } + return List.of(); + } + + private double finiteDouble(Object value, double fallback) { + try { + double parsed = Double.parseDouble(String.valueOf(value)); + return Double.isFinite(parsed) ? parsed : fallback; + } + catch (NumberFormatException exception) { + return fallback; + } + } + + private List platformSummaries() { + List summaries = jdbc.query(""" + SELECT LOWER(platform), + COALESCE(percentile_cont(0.95) WITHIN GROUP (ORDER BY duration_ms) + FILTER (WHERE duration_ms IS NOT NULL), 0), + COALESCE(100.0 * COUNT(*) FILTER (WHERE + http_status >= 400 OR LOWER(COALESCE(severity, '')) IN ('error', 'fatal') + OR LOWER(COALESCE(outcome, '')) IN ('error', 'network_error', 'timeout')) + / NULLIF(COUNT(*), 0), 0), + COUNT(DISTINCT COALESCE(user_id::text, 'anon:' || anonymous_id)) + FILTER (WHERE (user_id IS NOT NULL OR NULLIF(anonymous_id, '') IS NOT NULL) + AND (http_status >= 400 OR LOWER(COALESCE(severity, '')) IN ('error', 'fatal') + OR LOWER(COALESCE(outcome, '')) IN ('error', 'network_error', 'timeout'))), + COUNT(*) FILTER (WHERE http_status >= 400 + OR LOWER(COALESCE(severity, '')) IN ('error', 'fatal') + OR LOWER(COALESCE(outcome, '')) IN ('error', 'network_error', 'timeout')) + FROM quality_issue_events + WHERE occurred_at >= CURRENT_TIMESTAMP - INTERVAL '24 hours' + GROUP BY LOWER(platform) + """, (rs, rowNum) -> new PlatformSummary(rs.getString(1), rs.getDouble(2), + rs.getDouble(3), rs.getLong(4), rs.getLong(5))); + List result = new ArrayList<>(3); + for (String platform : List.of("web", "mobile", "backend")) { + result.add(summaries.stream().filter(item -> platform.equals(item.platform())) + .findFirst().orElse(new PlatformSummary(platform, 0, 0, 0, 0))); + } + return result; + } + + private List recentEvents() { + return jdbc.query(""" + SELECT occurred_at, COALESCE(user_id::text, anonymous_id, '-'), LOWER(platform), + COALESCE(route, '-'), event_type, message, COALESCE(api_path, '-'), + http_status, COALESCE(attributes->>'request_id', '-') + FROM quality_issue_events WHERE + (http_status >= 400 + OR LOWER(COALESCE(severity, '')) IN ('error', 'fatal') + OR LOWER(COALESCE(outcome, '')) IN ('error', 'network_error', 'timeout')) + AND occurred_at >= CURRENT_TIMESTAMP - INTERVAL '24 hours' + ORDER BY occurred_at DESC LIMIT 20 + """, (rs, rowNum) -> new Event(rs.getTimestamp(1).toInstant(), rs.getString(2), + rs.getString(3), rs.getString(4), rs.getString(5), rs.getString(6), + rs.getString(7), (Integer) rs.getObject(8), rs.getString(9))); + } } diff --git a/backend/unispeaking-server/src/main/java/com/unispeaking/admin/observability/AlibabaObservabilityStatus.java b/backend/unispeaking-server/src/main/java/com/unispeaking/admin/observability/AlibabaObservabilityStatus.java index 45b10fb5..75e4591e 100644 --- a/backend/unispeaking-server/src/main/java/com/unispeaking/admin/observability/AlibabaObservabilityStatus.java +++ b/backend/unispeaking-server/src/main/java/com/unispeaking/admin/observability/AlibabaObservabilityStatus.java @@ -10,6 +10,7 @@ public final class AlibabaObservabilityStatus { private final String project; private final String auditLogstore; private final String inferenceLogstore; + private final String usageLogstore; private final boolean credentialsConfigured; private final boolean prometheusEnabled; @@ -19,10 +20,11 @@ public AlibabaObservabilityStatus( @Value("${unispeaking.integrations.aliyun.project:}") String project, @Value("${unispeaking.integrations.aliyun.audit-logstore:bailian-model-audit-log}") String auditLogstore, @Value("${unispeaking.integrations.aliyun.inference-logstore:bailian-model-inference-log}") String inferenceLogstore, + @Value("${unispeaking.integrations.aliyun.usage-logstore:${unispeaking.integrations.aliyun.audit-logstore:bailian-model-audit-log}}") String usageLogstore, @Value("${unispeaking.integrations.aliyun.access-key-id:}") String accessKeyId, @Value("${unispeaking.integrations.aliyun.access-key-secret:}") String accessKeySecret, @Value("${unispeaking.integrations.aliyun.prometheus-enabled:false}") boolean prometheusEnabled) { - this(region, project, auditLogstore, inferenceLogstore, + this(region, project, auditLogstore, inferenceLogstore, usageLogstore, !accessKeyId.isBlank() && !accessKeySecret.isBlank(), prometheusEnabled); } @@ -33,10 +35,23 @@ public AlibabaObservabilityStatus( String inferenceLogstore, boolean credentialsConfigured, boolean prometheusEnabled) { + this(region, project, auditLogstore, inferenceLogstore, auditLogstore, + credentialsConfigured, prometheusEnabled); + } + + public AlibabaObservabilityStatus( + String region, + String project, + String auditLogstore, + String inferenceLogstore, + String usageLogstore, + boolean credentialsConfigured, + boolean prometheusEnabled) { this.region = region; this.project = project; this.auditLogstore = auditLogstore; this.inferenceLogstore = inferenceLogstore; + this.usageLogstore = usageLogstore; this.credentialsConfigured = credentialsConfigured; this.prometheusEnabled = prometheusEnabled; } @@ -45,6 +60,7 @@ public AlibabaObservabilityStatus( public String project() { return project; } public String auditLogstore() { return auditLogstore; } public String inferenceLogstore() { return inferenceLogstore; } + public String usageLogstore() { return usageLogstore; } public boolean credentialsConfigured() { return credentialsConfigured; } public boolean prometheusEnabled() { return prometheusEnabled; } } diff --git a/backend/unispeaking-server/src/main/java/com/unispeaking/admin/usage/adapters/aliyun/AliyunInferenceLogParser.java b/backend/unispeaking-server/src/main/java/com/unispeaking/admin/usage/adapters/aliyun/AliyunInferenceLogParser.java index 6188f005..6922bc93 100644 --- a/backend/unispeaking-server/src/main/java/com/unispeaking/admin/usage/adapters/aliyun/AliyunInferenceLogParser.java +++ b/backend/unispeaking-server/src/main/java/com/unispeaking/admin/usage/adapters/aliyun/AliyunInferenceLogParser.java @@ -6,7 +6,13 @@ import tools.jackson.core.JacksonException; import tools.jackson.databind.JsonNode; import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.node.ObjectNode; +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; import java.util.Locale; import java.util.regex.Pattern; @@ -21,26 +27,38 @@ public AliyunInferenceLogParser(ObjectMapper mapper) { public OfficialUsageRecord parse(String json) { try { - JsonNode root = mapper.readTree(json); - String statusCode = requiredText(root, "status_code"); - JsonNode usage = "200".equals(statusCode) - ? objectNode(root.path("usage"), "usage") - : mapper.createObjectNode(); + JsonNode envelope = unwrapSlsMessage(mapper.readTree(json)); + validateNestedObjectShape(envelope, "extras"); + validateNestedObjectShape(envelope, "usage"); + JsonNode response = objectNodeOrNull(envelope.path("response")); + JsonNode request = objectNodeOrNull(envelope.path("request")); + JsonNode body = objectNodeOrNull(envelope.path("body")); + JsonNode requestBody = request == null ? null : objectNodeOrNull(request.path("body")); + JsonNode responseBody = response == null ? null : objectNodeOrNull(response.path("body")); + JsonNode root = merge(envelope, request, requestBody, response, responseBody, body); + String statusCode = firstText(root, "status_code", "http_status", "status"); + if (statusCode == null) { + throw new OfficialUsageSchemaException("status_code 缺失; 顶层字段=" + fieldNames(root) + + "; 嵌套字段=" + nestedShapes(root)); + } + JsonNode usage = objectNodeOrNull(root.path("usage")); + if (usage == null) { + usage = mapper.createObjectNode(); + } long characters = optionalLong(usage, "characters"); var officialUsage = characters > 0 ? new ModelUsage(1, 0, 0, 0, 0, 0, 0, 0) : tokenUsage(usage, statusCode); var record = new OfficialUsageRecord( - requiredText(root, "request_id"), - optionalText(root, "task_uuid"), - requiredLong(root, "start_unix_timestamp"), - requiredLong(root, "duration"), + requiredText(root, "request_id", "requestId"), + optionalText(root, "task_uuid", "task_id", "session_id", "conversation_id"), + startTimestamp(root), + optionalLong(root, "duration", "duration_ms", "latency", "total_duration"), statusCode, - requiredText(root, "model"), - requiredText(root, "workspace_id"), - requiredText(root, "apikey_id"), - requiredText(objectNode(root.path("extras"), "extras"), "protocol") - .toLowerCase(Locale.ROOT), + requiredText(root, "model", "model_name"), + requiredText(root, "workspace_id", "workspace"), + firstTextOrDefault(root, "apikey_id", "api_key_id", "uid", "sub_uid", "unknown"), + protocol(root), officialUsage, characters); validate(record); @@ -53,11 +71,21 @@ var record = new OfficialUsageRecord( } private static ModelUsage tokenUsage(JsonNode usage, String statusCode) { - if (!"200".equals(statusCode)) { - return new ModelUsage(0, 0, 0, 0, 0, 0, 0, 0); - } JsonNode inputDetails = usage.path("input_tokens_details"); JsonNode outputDetails = usage.path("output_tokens_details"); + if (!"200".equals(statusCode)) { + boolean hasTokenUsage = firstNode(usage, "total_tokens", "input_tokens", "output_tokens") != null + && (usage.has("total_tokens") || usage.has("input_tokens") || usage.has("output_tokens")); + return new ModelUsage( + hasTokenUsage ? 1 : 0, + optionalLong(usage, "total_tokens"), + optionalLong(usage, "input_tokens"), + optionalLong(usage, "output_tokens"), + optionalLong(inputDetails, "text_tokens"), + optionalLong(inputDetails, "audio_tokens"), + optionalLong(outputDetails, "text_tokens"), + optionalLong(outputDetails, "audio_tokens")); + } return new ModelUsage( 1, requiredLong(usage, "total_tokens"), @@ -109,44 +137,193 @@ private static void requireStableId(String field, String value) { } } - private static String requiredText(JsonNode parent, String field) { - JsonNode node = parent.path(field); + private static String requiredText(JsonNode parent, String... fields) { + JsonNode node = firstNode(parent, fields); String value = node.asText(); if (value == null || value.isBlank()) { - throw new OfficialUsageSchemaException(field + " 缺失"); + throw new OfficialUsageSchemaException(fields[0] + " 缺失"); } return value; } - private static String optionalText(JsonNode parent, String field) { - JsonNode node = parent.path(field); + private static String optionalText(JsonNode parent, String... fields) { + JsonNode node = firstNode(parent, fields); + if (node == null) { + return null; + } String value = node.asText(); return value == null || value.isBlank() ? null : value; } - private static long requiredLong(JsonNode parent, String field) { - JsonNode node = parent.path(field); + private static long requiredLong(JsonNode parent, String... fields) { + JsonNode node = firstNode(parent, fields); String value = node.asText(); if (value == null || value.isBlank()) { - throw new OfficialUsageSchemaException(field + " 缺失"); + throw new OfficialUsageSchemaException(fields[0] + " 缺失"); } try { long parsed = Long.parseLong(value); if (parsed < 0) { - throw new OfficialUsageSchemaException(field + " 不能为负数"); + throw new OfficialUsageSchemaException(fields[0] + " 不能为负数"); } return parsed; } catch (NumberFormatException exception) { - throw new OfficialUsageSchemaException(field + " 不是有效整数", exception); + throw new OfficialUsageSchemaException(fields[0] + " 不是有效整数", exception); } } - private static long optionalLong(JsonNode parent, String field) { - JsonNode node = parent.path(field); - if (node.isMissingNode() || node.isNull() || node.asText().isBlank()) { + private static long optionalLong(JsonNode parent, String... fields) { + if (parent == null) { + return 0; + } + JsonNode node = firstNode(parent, fields); + if (node == null || node.isMissingNode() || node.isNull() || node.asText().isBlank()) { return 0; } - return requiredLong(parent, field); + return requiredLong(parent, fields); + } + + private long startTimestamp(JsonNode root) { + JsonNode timestamp = firstNode(root, "start_unix_timestamp", "started_at_epoch_ms", "start_time", "logtime"); + if (timestamp.isMissingNode() || timestamp.isNull() || timestamp.asText().isBlank()) { + throw new OfficialUsageSchemaException("start_unix_timestamp 缺失"); + } + String value = timestamp.asText().trim(); + try { + if (value.matches("\\d+")) { + long parsed = Long.parseLong(value); + return parsed < 100_000_000_000L ? parsed * 1000 : parsed; + } + try { + return Instant.parse(value).toEpochMilli(); + } catch (DateTimeParseException ignored) { + return LocalDateTime.parse(value.replace(' ', 'T'), DateTimeFormatter.ISO_LOCAL_DATE_TIME) + .toInstant(ZoneOffset.UTC).toEpochMilli(); + } + } catch (RuntimeException exception) { + throw new OfficialUsageSchemaException("start_unix_timestamp 不是有效时间", exception); + } + } + + private String protocol(JsonNode root) { + JsonNode extras = objectNodeOrNull(root.path("extras")); + String value = firstText(extras, "protocol", "transport", "protocol_type"); + if (value == null) { + value = firstText(root, "protocol", "transport", "protocol_type", "connection_type"); + } + if (value == null) { + throw new OfficialUsageSchemaException("protocol 缺失"); + } + return normalizeProtocol(value); + } + + private static String normalizeProtocol(String value) { + return switch (value.trim().toLowerCase(Locale.ROOT)) { + case "ws", "websocket", "web_socket" -> "ws"; + case "webrtc", "web_rtc", "web-rtc" -> "webrtc"; + case "http", "https" -> "http"; + default -> value.trim().toLowerCase(Locale.ROOT); + }; + } + + private String firstTextOrDefault(JsonNode parent, String first, String second, String third, String fourth, + String fallback) { + String value = firstText(parent, first, second, third, fourth); + return value == null ? fallback : value; + } + + private static String firstText(JsonNode parent, String... fields) { + if (parent == null || parent.isMissingNode() || parent.isNull()) { + return null; + } + for (String field : fields) { + String value = optionalText(parent, field); + if (value != null) { + return value; + } + } + return null; + } + + private static JsonNode firstNode(JsonNode parent, String... fields) { + if (parent == null || parent.isMissingNode() || parent.isNull()) { + return null; + } + for (String field : fields) { + JsonNode node = parent.path(field); + if (!node.isMissingNode() && !node.isNull()) { + return node; + } + } + return parent.path(fields[0]); + } + + private JsonNode objectNodeOrNull(JsonNode node) { + if (node == null || node.isMissingNode() || node.isNull()) { + return null; + } + try { + JsonNode resolved = node; + for (int depth = 0; depth < 3 && resolved.isTextual(); depth++) { + resolved = mapper.readTree(resolved.asText()); + } + return resolved != null && resolved.isObject() ? resolved : null; + } catch (JacksonException exception) { + return null; + } + } + + private String nestedShapes(JsonNode root) { + var shapes = new java.util.ArrayList(); + for (String field : new String[] {"request", "response", "body", "messages", "parameters", "choices", "usage"}) { + JsonNode node = root.path(field); + if (node.isMissingNode() || node.isNull()) { + continue; + } + StringBuilder shape = new StringBuilder(field).append(":").append(node.isTextual() ? "text" : node.getNodeType()); + if (node.isTextual()) { + String text = node.asText(); + shape.append("(").append(text.length()).append(")"); + try { + JsonNode decoded = node; + for (int depth = 0; depth < 3 && decoded.isTextual(); depth++) { + decoded = mapper.readTree(decoded.asText()); + } + if (decoded != null && decoded.isObject()) { + shape.append("{").append(fieldNames(decoded)).append("}"); + } else if (decoded != null) { + shape.append("->").append(decoded.getNodeType()); + } + } catch (JacksonException ignored) { + shape.append("(invalid-json)"); + } + } else if (node.isObject()) { + shape.append("{").append(fieldNames(node)).append("}"); + } else if (node.isArray()) { + shape.append("[").append(node.size()).append("]"); + JsonNode first = node.path(0); + if (first.isObject()) { + shape.append("{").append(fieldNames(first)).append("}"); + } else if (!first.isMissingNode()) { + shape.append("->").append(first.getNodeType()); + } + } + shapes.add(shape.toString()); + } + return String.join(",", shapes); + } + + private ObjectNode merge(JsonNode envelope, JsonNode... nested) { + ObjectNode merged = mapper.createObjectNode(); + if (envelope instanceof ObjectNode object) { + merged.setAll(object); + } + for (JsonNode node : nested) { + if (node instanceof ObjectNode object) { + merged.setAll(object); + } + } + return merged; } private JsonNode objectNode(JsonNode node, String field) { @@ -160,4 +337,47 @@ private JsonNode objectNode(JsonNode node, String field) { throw new OfficialUsageSchemaException(field + " 不是有效 JSON 对象", exception); } } + + private void validateNestedObjectShape(JsonNode root, String field) { + JsonNode node = root.path(field); + if (node.isMissingNode() || node.isNull() || node.isObject()) { + return; + } + if (node.isTextual()) { + try { + JsonNode decoded = mapper.readTree(node.asText()); + if (decoded != null && decoded.isObject()) { + return; + } + } catch (JacksonException ignored) { + // Fall through to the schema error below. + } + throw new OfficialUsageSchemaException(field + " 不是有效 JSON 对象"); + } + throw new OfficialUsageSchemaException(field + " 必须为 JSON 对象"); + } + + /** + * SLS can return a structured log item with the provider payload nested in + * a textual `message` field. Accept both that shape and the legacy flat + * shape used by the audit logstore. + */ + private JsonNode unwrapSlsMessage(JsonNode root) { + JsonNode message = root.path("message"); + if (!message.isTextual() || message.asText().isBlank()) { + return root; + } + try { + JsonNode decoded = mapper.readTree(message.asText()); + return decoded != null && decoded.isObject() ? decoded : root; + } catch (JacksonException exception) { + return root; + } + } + + private static String fieldNames(JsonNode root) { + var names = new java.util.ArrayList(); + names.addAll(root.propertyNames()); + return String.join(",", names); + } } diff --git a/backend/unispeaking-server/src/main/java/com/unispeaking/admin/usage/adapters/aliyun/AliyunSlsUsageLogSource.java b/backend/unispeaking-server/src/main/java/com/unispeaking/admin/usage/adapters/aliyun/AliyunSlsUsageLogSource.java index 1a1125f4..29160346 100644 --- a/backend/unispeaking-server/src/main/java/com/unispeaking/admin/usage/adapters/aliyun/AliyunSlsUsageLogSource.java +++ b/backend/unispeaking-server/src/main/java/com/unispeaking/admin/usage/adapters/aliyun/AliyunSlsUsageLogSource.java @@ -26,7 +26,7 @@ public final class AliyunSlsUsageLogSource implements OfficialUsageLogSource { public AliyunSlsUsageLogSource( @Value("${unispeaking.integrations.aliyun.region:cn-beijing}") String region, @Value("${unispeaking.integrations.aliyun.project:}") String project, - @Value("${unispeaking.integrations.aliyun.inference-logstore:bailian-model-inference-log}") String logstore, + @Value("${unispeaking.integrations.aliyun.usage-logstore:bailian-model-audit-log}") String logstore, @Value("${unispeaking.integrations.aliyun.access-key-id:}") String accessKeyId, @Value("${unispeaking.integrations.aliyun.access-key-secret:}") String accessKeySecret, @Value("${unispeaking.integrations.aliyun.max-query-records:1000}") int maxRecords) { diff --git a/backend/unispeaking-server/src/main/java/com/unispeaking/admin/usage/adapters/jdbc/JdbcOfficialUsageSink.java b/backend/unispeaking-server/src/main/java/com/unispeaking/admin/usage/adapters/jdbc/JdbcOfficialUsageSink.java index f660033a..57092383 100644 --- a/backend/unispeaking-server/src/main/java/com/unispeaking/admin/usage/adapters/jdbc/JdbcOfficialUsageSink.java +++ b/backend/unispeaking-server/src/main/java/com/unispeaking/admin/usage/adapters/jdbc/JdbcOfficialUsageSink.java @@ -46,6 +46,7 @@ public synchronized ImportResult importRecords(List records int unmatched = 0; for (OfficialUsageRecord record : records) { if (exists(record.requestId())) { + refreshPreviouslyEmptyUsage(record); duplicates++; } else { try { @@ -64,6 +65,26 @@ public synchronized ImportResult importRecords(List records return new ImportResult(imported, duplicates, matched, unmatched); } + /** + * Older sync runs stored non-200 Realtime rows with zero token fields. + * Keep imports idempotent while allowing a later, richer SLS payload to + * repair those rows in place. + */ + private void refreshPreviouslyEmptyUsage(OfficialUsageRecord record) { + var usage = record.usage(); + if (usage.totalTokens() <= 0 && record.characters() <= 0) { + return; + } + jdbc.update( + "update official_usage_records set status_code = ?, model = ?, protocol = ?, " + + "total_tokens = ?, input_tokens = ?, output_tokens = ?, input_text_tokens = ?, " + + "input_audio_tokens = ?, output_text_tokens = ?, output_audio_tokens = ?, characters = ? " + + "where request_id = ? and total_tokens = 0 and characters = 0", + record.statusCode(), record.model(), record.protocol(), usage.totalTokens(), usage.inputTokens(), + usage.outputTokens(), usage.inputTextTokens(), usage.inputAudioTokens(), usage.outputTextTokens(), + usage.outputAudioTokens(), record.characters(), record.requestId()); + } + private void insert(OfficialUsageRecord record) { var usage = record.usage(); jdbc.update( diff --git a/backend/unispeaking-server/src/main/java/com/unispeaking/admin/usage/application/AdminUsageQueryService.java b/backend/unispeaking-server/src/main/java/com/unispeaking/admin/usage/application/AdminUsageQueryService.java index 8f7b9b8d..3f4cab87 100644 --- a/backend/unispeaking-server/src/main/java/com/unispeaking/admin/usage/application/AdminUsageQueryService.java +++ b/backend/unispeaking-server/src/main/java/com/unispeaking/admin/usage/application/AdminUsageQueryService.java @@ -108,7 +108,7 @@ public DataSourcesResponse dataSources() { } var slsState = missingSlsConfiguration.isEmpty() ? "READY" : "CONFIGURATION_REQUIRED"; var project = configured(alibaba.project()) ? alibaba.project() : "未配置"; - var slsLocation = alibaba.region() + " · " + project + " · " + alibaba.inferenceLogstore(); + var slsLocation = alibaba.region() + " · " + project + " · " + alibaba.usageLogstore(); var slsDetail = missingSlsConfiguration.isEmpty() ? slsLocation : String.join("、", missingSlsConfiguration) + " · " + slsLocation; diff --git a/backend/unispeaking-server/src/main/java/com/unispeaking/admin/usage/application/OfficialUsageSyncService.java b/backend/unispeaking-server/src/main/java/com/unispeaking/admin/usage/application/OfficialUsageSyncService.java index a02d3480..d958e4f2 100644 --- a/backend/unispeaking-server/src/main/java/com/unispeaking/admin/usage/application/OfficialUsageSyncService.java +++ b/backend/unispeaking-server/src/main/java/com/unispeaking/admin/usage/application/OfficialUsageSyncService.java @@ -13,6 +13,8 @@ import java.util.LinkedHashMap; import java.util.List; import java.util.Set; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; @@ -23,6 +25,7 @@ @Service @ConditionalOnBean(OfficialUsageSink.class) public final class OfficialUsageSyncService { + private static final Logger log = LoggerFactory.getLogger(OfficialUsageSyncService.class); private final OfficialUsageLogSource logSource; private final OfficialUsageSink sink; private final UsageDataSource usageDataSource; @@ -73,6 +76,8 @@ public SyncResult syncNow() { int rejectedContext = 0; int unbound = 0; int duplicate = 0; + var schemaRejectionReasons = new LinkedHashMap(); + var contextRejectionReasons = new LinkedHashMap(); for (String raw : rawLogs) { OfficialUsageRecord record; @@ -80,10 +85,13 @@ public SyncResult syncNow() { record = parser.parse(raw); } catch (OfficialUsageSchemaException exception) { rejectedSchema++; + schemaRejectionReasons.merge(exception.getMessage(), 1, Integer::sum); continue; } if (!matchesExpectedContext(record)) { rejectedContext++; + String reason = contextRejectionReason(record); + contextRejectionReasons.merge(reason, 1, Integer::sum); continue; } boolean realtime = "ws".equals(record.protocol()) || "webrtc".equals(record.protocol()); @@ -100,6 +108,12 @@ record = parser.parse(raw); } List accepted = new ArrayList<>(unique.values()); + if (!schemaRejectionReasons.isEmpty()) { + log.warn("阿里云官方用量记录因格式无效被丢弃: {}", schemaRejectionReasons); + } + if (!contextRejectionReasons.isEmpty()) { + log.warn("阿里云官方用量记录因上下文不匹配被丢弃: {}", contextRejectionReasons); + } OfficialUsageSink.ImportResult imported = accepted.isEmpty() ? new OfficialUsageSink.ImportResult(0, 0, 0, 0) : sink.importRecords(accepted); @@ -124,7 +138,22 @@ private boolean matchesExpectedContext(OfficialUsageRecord record) { boolean supportedProtocol = "ws".equals(record.protocol()) || "webrtc".equals(record.protocol()) || "http".equals(record.protocol()); - return workspaceMatches && "200".equals(record.statusCode()) && supportedProtocol; + // Provider error responses still carry billable/reconcilable identifiers; + // status_code is informational and must not prevent matching. + return workspaceMatches && supportedProtocol; + } + + private String contextRejectionReason(OfficialUsageRecord record) { + var reasons = new ArrayList(); + if (expectedWorkspaceId != null && !expectedWorkspaceId.isBlank() + && !expectedWorkspaceId.equals(record.workspaceId())) { + reasons.add("workspace_mismatch"); + } + if (!("ws".equals(record.protocol()) || "webrtc".equals(record.protocol()) + || "http".equals(record.protocol()))) { + reasons.add("unsupported_protocol"); + } + return reasons.isEmpty() ? "unknown" : String.join("+", reasons); } @JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) diff --git a/backend/unispeaking-server/src/main/java/com/unispeaking/infrastructure/ai/qwen/QwenRealtimeProvider.java b/backend/unispeaking-server/src/main/java/com/unispeaking/infrastructure/ai/qwen/QwenRealtimeProvider.java index ffd606bf..dfeb4a68 100644 --- a/backend/unispeaking-server/src/main/java/com/unispeaking/infrastructure/ai/qwen/QwenRealtimeProvider.java +++ b/backend/unispeaking-server/src/main/java/com/unispeaking/infrastructure/ai/qwen/QwenRealtimeProvider.java @@ -49,8 +49,8 @@ public RealtimeConnectionResult connect( RealtimeCredential credential) { ExchangeResult exchange = exchangeRealtime( command.modelId(), command.offerSdp(), credential.bearerToken()); - return new RealtimeConnectionResult( - null, + return new RealtimeConnectionResult( + exchange.providerSessionId(), type(), command.modelId(), command.voiceId(), @@ -153,23 +153,33 @@ private ExchangeResult attemptExchange( RealtimeFlowLog.info("flow.3.sdp.response status={} answerSdp={}", statusCode, RealtimeFlowLog.sdpSummary(response.body())); - return new ExchangeResult(response.body(), officialRequestId(response)); - } + return new ExchangeResult(response.body(), officialRequestId(response), officialSessionId(response)); + } - private static String officialRequestId(HttpResponse response) { + private static String officialRequestId(HttpResponse response) { return response.headers().firstValue("x-request-id") .or(() -> response.headers().firstValue("x-dashscope-request-id")) .map(String::trim) .filter(value -> !value.isBlank()) .orElse(null); - } + } - private String signalingFailureMessage(int statusCode, String body) { + private static String officialSessionId(HttpResponse response) { + return response.headers().firstValue("x-dashscope-session-id") + .or(() -> response.headers().firstValue("x-session-id")) + .or(() -> response.headers().firstValue("x-provider-session-id")) + .map(String::trim) + .filter(value -> !value.isBlank()) + .orElse(null); + } + + private String signalingFailureMessage(int statusCode, String body) { String detail = body == null ? "" : body.replaceAll("\\s+", " ").trim(); if (detail.length() > 500) { detail = detail.substring(0, 500) + "..."; } - return "Qwen signaling returned " + statusCode + + return "Qwen signaling returned " + statusCode + (detail.isBlank() ? "" : ": " + detail); } @@ -188,6 +198,6 @@ private BusinessException failure() { } } - private record ExchangeResult(String answerSdp, String requestId) {} + private record ExchangeResult(String answerSdp, String requestId, String providerSessionId) {} } diff --git a/backend/unispeaking-server/src/main/resources/application.yaml b/backend/unispeaking-server/src/main/resources/application.yaml index 90b6eebe..e62147d0 100644 --- a/backend/unispeaking-server/src/main/resources/application.yaml +++ b/backend/unispeaking-server/src/main/resources/application.yaml @@ -211,7 +211,10 @@ unispeaking: region: ${ALIYUN_SLS_REGION:cn-beijing} project: ${ALIYUN_SLS_PROJECT:} audit-logstore: ${ALIYUN_SLS_AUDIT_LOGSTORE:bailian-model-audit-log} - inference-logstore: ${ALIYUN_SLS_USAGE_LOGSTORE:${ALIYUN_SLS_INFERENCE_LOGSTORE:bailian-model-audit-log}} + inference-logstore: ${ALIYUN_SLS_INFERENCE_LOGSTORE:bailian-model-inference-log} + # Official billing fields such as status_code and usage are emitted by + # the audit logstore, not the request/response inference logstore. + usage-logstore: ${ALIYUN_SLS_USAGE_LOGSTORE:${ALIYUN_SLS_AUDIT_LOGSTORE:bailian-model-audit-log}} access-key-id: ${ALIYUN_SLS_ACCESS_KEY_ID:${ALIBABA_CLOUD_ACCESS_KEY_ID:}} access-key-secret: ${ALIYUN_SLS_ACCESS_KEY_SECRET:${ALIBABA_CLOUD_ACCESS_KEY_SECRET:}} workspace-id: ${ALIYUN_MODEL_WORKSPACE_ID:${BAILIAN_WORKSPACE_ID:}} diff --git a/backend/unispeaking-server/src/test/java/com/unispeaking/admin/monitoring/MonitoringAdminControllerTest.java b/backend/unispeaking-server/src/test/java/com/unispeaking/admin/monitoring/MonitoringAdminControllerTest.java index f1e36e05..91632ac6 100644 --- a/backend/unispeaking-server/src/test/java/com/unispeaking/admin/monitoring/MonitoringAdminControllerTest.java +++ b/backend/unispeaking-server/src/test/java/com/unispeaking/admin/monitoring/MonitoringAdminControllerTest.java @@ -1,6 +1,7 @@ package com.unispeaking.admin.monitoring; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; @@ -12,20 +13,24 @@ import org.springframework.test.web.servlet.setup.MockMvcBuilders; class MonitoringAdminControllerTest { + @Test + void exposesMonitoringOverviewForRequestedRange() throws Exception { + MonitoringAdminService service = mock(MonitoringAdminService.class); + MonitoringAdminService.Governance governance = new MonitoringAdminService.Governance( + 2, 3, 1, 33.3, new MonitoringAdminService.Comparison(10, 8), + new MonitoringAdminService.Comparison(900, 700), + new MonitoringAdminService.Comparison(5, 4)); + when(service.overview("7d")).thenReturn(new MonitoringAdminService.MonitoringResponse( + new MonitoringAdminService.Summary("UP", 1.0, 4, 800, 2, 3, + Instant.parse("2026-08-25T00:00:00Z")), governance, + List.of(), List.of(), List.of(), List.of(), List.of())); + var mvc = MockMvcBuilders.standaloneSetup(new MonitoringAdminController(service)).build(); - @Test - void exposesMonitoringOverviewEndpoint() throws Exception { - MonitoringAdminService service = mock(MonitoringAdminService.class); - when(service.overview()).thenReturn(new MonitoringAdminService.MonitoringResponse( - new MonitoringAdminService.Summary("UP", 1.0, 0.2, 0.8, 2, 3, 4, 5, - Instant.parse("2026-08-25T00:00:00Z")), - List.of(), List.of(), List.of(), List.of(), List.of())); - var mvc = MockMvcBuilders.standaloneSetup(new MonitoringAdminController(service)).build(); - - mvc.perform(get("/api/admin/monitoring/overview")) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.summary.backendStatus").value("UP")) - .andExpect(jsonPath("$.summary.activeAlerts").value(2)) - .andExpect(jsonPath("$.summary.resolvedBugs7d").value(5)); - } + mvc.perform(get("/api/admin/monitoring/overview").param("range", "7d")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.summary.backendStatus").value("UP")) + .andExpect(jsonPath("$.summary.api5xxCount24h").value(4)) + .andExpect(jsonPath("$.governance.resolvedBugs7d").value(1)); + verify(service).overview("7d"); + } } diff --git a/backend/unispeaking-server/src/test/java/com/unispeaking/admin/monitoring/MonitoringAdminServiceTest.java b/backend/unispeaking-server/src/test/java/com/unispeaking/admin/monitoring/MonitoringAdminServiceTest.java index cb6d4645..f7857ac3 100644 --- a/backend/unispeaking-server/src/test/java/com/unispeaking/admin/monitoring/MonitoringAdminServiceTest.java +++ b/backend/unispeaking-server/src/test/java/com/unispeaking/admin/monitoring/MonitoringAdminServiceTest.java @@ -4,12 +4,10 @@ import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyString; -import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.times; -import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import com.sun.net.httpserver.HttpServer; import java.io.IOException; import java.net.InetSocketAddress; import java.net.URLDecoder; @@ -17,139 +15,121 @@ import java.sql.Timestamp; import java.time.Instant; import java.util.List; -import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.CopyOnWriteArrayList; -import com.sun.net.httpserver.HttpServer; import org.junit.jupiter.api.Test; -import org.mockito.ArgumentCaptor; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.core.RowMapper; class MonitoringAdminServiceTest { + private static final Instant EVENT_TIME = Instant.parse("2026-08-25T00:00:00Z"); - private static final Instant EVENT_TIME = Instant.parse("2026-08-25T00:00:00Z"); - - @Test - void returnsZeroedOverviewWhenPrometheusIsUnavailable() { - JdbcTemplate jdbc = mock(JdbcTemplate.class); - when(jdbc.queryForObject(anyString(), eq(Long.class))).thenReturn(null); - when(jdbc.query(anyString(), any(RowMapper.class))).thenReturn(List.of()); - - MonitoringAdminService.MonitoringResponse response = new MonitoringAdminService( - jdbc, "http://127.0.0.1:1").overview(); - - assertEquals("UP", response.summary().backendStatus()); - assertEquals(0, response.summary().clientErrorRate()); - assertEquals(0, response.summary().api5xxRate()); - assertEquals(3, response.platformSummaries().size()); - assertTrue(response.platformSummaries().stream() - .allMatch(item -> item.affectedUsers() == 0 && item.errorCount() == 0)); - assertTrue(response.trend().isEmpty()); - } - - @Test - void mapsPrometheusAndDatabaseOverviewRows() throws IOException { - JdbcTemplate jdbc = mock(JdbcTemplate.class); - when(jdbc.queryForObject(anyString(), eq(Long.class))) - .thenReturn(11L, 12L, 13L, 14L); - AtomicInteger queryNumber = new AtomicInteger(); - when(jdbc.query(anyString(), any(RowMapper.class))).thenAnswer(invocation -> { - @SuppressWarnings("unchecked") - RowMapper mapper = invocation.getArgument(1); - ResultSet row = mock(ResultSet.class); - switch (queryNumber.getAndIncrement()) { - case 0 -> { - when(row.getString(1)).thenReturn("TIMEOUT"); - when(row.getString(2)).thenReturn("web"); - when(row.getString(3)).thenReturn("/api/slow"); - when(row.getLong(4)).thenReturn(4L); - when(row.getLong(5)).thenReturn(3L); - when(row.getTimestamp(6)).thenReturn(Timestamp.from(EVENT_TIME)); - when(row.getString(7)).thenReturn("INVESTIGATING"); - } - case 1 -> { - when(row.getString(1)).thenReturn("GET"); - when(row.getString(2)).thenReturn("/api/slow"); - when(row.getLong(3)).thenReturn(9L); - when(row.getDouble(4)).thenReturn(1.5); - when(row.getDouble(5)).thenReturn(2.5); - when(row.getDouble(6)).thenReturn(4.0); - when(row.getLong(7)).thenReturn(2L); - } - case 2 -> { - when(row.getTimestamp(1)).thenReturn(Timestamp.from(EVENT_TIME)); - when(row.getString(2)).thenReturn("user-1"); - when(row.getString(3)).thenReturn("web"); - when(row.getString(4)).thenReturn("/home"); - when(row.getString(5)).thenReturn("web.app_error"); - when(row.getString(6)).thenReturn("timeout"); - when(row.getString(7)).thenReturn("/api/slow"); - when(row.getObject(8)).thenReturn(504); - when(row.getString(9)).thenReturn("request-1"); - } - case 3 -> { - when(row.getString(1)).thenReturn("web"); - when(row.getDouble(2)).thenReturn(250.0); - when(row.getDouble(3)).thenReturn(4.0); - when(row.getLong(4)).thenReturn(7L); - when(row.getLong(5)).thenReturn(3L); - } - default -> throw new AssertionError("unexpected query number"); - } - return List.of(mapper.mapRow(row, 0)); - }); - - HttpServer server = HttpServer.create(new InetSocketAddress(0), 0); - List prometheusRequests = new CopyOnWriteArrayList<>(); - server.createContext("/api/v1/query", exchange -> respond(exchange, - prometheusRequests, - "{\"data\":{\"result\":[{\"value\":[\"0\",\"2.5\"]}]}}")); - server.createContext("/api/v1/query_range", exchange -> respond(exchange, - prometheusRequests, - "{\"data\":{\"result\":[{\"values\":[[\"0\",\"1.0\"],[\"3600\",\"2.0\"]]}]}}")); - server.start(); - try { - MonitoringAdminService.MonitoringResponse response = new MonitoringAdminService( - jdbc, "http://127.0.0.1:" + server.getAddress().getPort()).overview(); + @Test + void returnsHonestEmptyDataWhenPrometheusIsUnavailable() throws Exception { + MonitoringAdminService.MonitoringResponse response = new MonitoringAdminService( + jdbcWithRows(false), "http://127.0.0.1:1").overview("invalid-range"); + assertEquals("UP", response.summary().backendStatus()); + assertEquals(0, response.summary().apiErrorRate5m()); + assertEquals(0, response.summary().api5xxCount24h()); + assertEquals(0, response.summary().apiP95Milliseconds24h()); + assertEquals(3, response.platformSummaries().size()); + assertEquals(5, response.performanceEndpoints().size()); + assertEquals(14_500, response.performanceEndpoints().getFirst().previousPeriodP95Milliseconds()); + assertEquals(null, response.performanceEndpoints().getFirst().currentPeriodP95Milliseconds()); + } - assertEquals("UP", response.summary().backendStatus()); - assertEquals(2.5, response.summary().clientErrorRate()); - assertEquals(11, response.summary().activeAlerts()); - assertEquals(14, response.summary().resolvedBugs7d()); - assertEquals("TIMEOUT", response.problems().getFirst().problem()); - assertEquals("INVESTIGATING", response.problems().getFirst().status()); - assertEquals(2.5, response.slowEndpoints().getFirst().p95Seconds()); - assertEquals("request-1", response.recentEvents().getFirst().requestId()); - assertEquals(3, response.platformSummaries().getFirst().errorCount()); - assertEquals(2, response.trend().size()); - assertEquals(2.0, response.trend().getLast().clientErrors()); - assertTrue(prometheusRequests.stream().anyMatch(query -> query.contains("http_route=~\"/api/.*\""))); - assertTrue(prometheusRequests.stream().anyMatch(query -> query.contains("[24h]"))); - assertTrue(prometheusRequests.stream().anyMatch(query -> query.contains("le=\"1.0\""))); - assertTrue(prometheusRequests.stream().anyMatch(query -> query.contains("or vector(0)"))); + @Test + void mapsProductionMetricsAndUsesRequestedTrendRange() throws Exception { + HttpServer server = HttpServer.create(new InetSocketAddress(0), 0); + List requests = new CopyOnWriteArrayList<>(); + server.createContext("/api/v1/query", exchange -> { + String query = URLDecoder.decode(exchange.getRequestURI().getRawQuery(), + java.nio.charset.StandardCharsets.UTF_8); + if (query.contains("http_request_method")) { + String value = "7.25"; + respond(exchange, requests, "{\"data\":{\"result\":[{\"metric\":{" + + "\"http_route\":\"/api/custom-scenes/generate\",\"http_request_method\":\"POST\"}," + + "\"value\":[\"0\",\"" + value + "\"]}]}}" ); + } + else { + respond(exchange, requests, + "{\"data\":{\"result\":[{\"value\":[\"0\",\"2.5\"]}]}}"); + } + }); + server.createContext("/api/v1/query_range", exchange -> respond(exchange, requests, + "{\"data\":{\"result\":[{\"values\":[[\"0\",\"1.0\"],[\"900\",\"2.0\"]]}]}}")); + server.start(); + try { + MonitoringAdminService.MonitoringResponse response = new MonitoringAdminService( + jdbcWithRows(true), "http://127.0.0.1:" + server.getAddress().getPort()).overview("6h"); + assertEquals(2.5, response.summary().apiErrorRate5m()); + assertEquals(3, response.summary().api5xxCount24h()); + assertEquals(2500, response.summary().apiP95Milliseconds24h()); + assertEquals(11, response.governance().pendingIssues()); + assertEquals(14, response.governance().resolvedBugs7d()); + assertEquals(3, response.summary().affectedUsers24h()); + assertEquals("TIMEOUT", response.problems().getFirst().problem()); + assertEquals("request-1", response.recentEvents().getFirst().requestId()); + assertEquals(50, response.performanceEndpoints().getFirst().improvementRate()); + assertEquals("OPTIMIZED", response.performanceEndpoints().getFirst().status()); + assertEquals(2, response.trend().stream().filter(point -> point.clientErrors() != null).count()); + assertTrue(requests.stream().anyMatch(query -> query.contains("step=900"))); + } + finally { + server.stop(0); + } + } - ArgumentCaptor sql = ArgumentCaptor.forClass(String.class); - verify(jdbc, times(4)).query(sql.capture(), any(RowMapper.class)); - String slowEndpointSql = sql.getAllValues().get(1); - assertTrue(sql.getAllValues().getFirst().contains("q.status IN")); - assertTrue(slowEndpointSql.contains("WHERE duration_ms IS NOT NULL")); - assertTrue(slowEndpointSql.contains("duration_ms > 1000")); - } - finally { - server.stop(0); - } - } + @SuppressWarnings({ "unchecked", "rawtypes" }) + private JdbcTemplate jdbcWithRows(boolean populated) throws Exception { + JdbcTemplate jdbc = mock(JdbcTemplate.class); + when(jdbc.queryForObject(anyString(), any(RowMapper.class))).thenAnswer(invocation -> { + RowMapper mapper = invocation.getArgument(1); + ResultSet row = mock(ResultSet.class); + for (int column = 1; column <= 7; column++) when(row.getLong(column)).thenReturn(0L); + if (populated) { + when(row.getLong(1)).thenReturn(11L); when(row.getLong(2)).thenReturn(12L); + when(row.getLong(3)).thenReturn(14L); when(row.getLong(4)).thenReturn(20L); + when(row.getLong(5)).thenReturn(25L); when(row.getLong(6)).thenReturn(3L); + when(row.getLong(7)).thenReturn(4L); + } + return mapper.mapRow(row, 0); + }); + when(jdbc.query(anyString(), any(RowMapper.class))).thenAnswer(invocation -> { + String sql = invocation.getArgument(0); + RowMapper mapper = invocation.getArgument(1); + if (!populated) return List.of(); + ResultSet row = mock(ResultSet.class); + if (sql.contains("JOIN quality_issues")) { + when(row.getString(1)).thenReturn("TIMEOUT"); when(row.getString(2)).thenReturn("web"); + when(row.getString(3)).thenReturn("/api/slow"); when(row.getLong(4)).thenReturn(4L); + when(row.getLong(5)).thenReturn(3L); when(row.getTimestamp(6)).thenReturn(Timestamp.from(EVENT_TIME)); + when(row.getString(7)).thenReturn("INVESTIGATING"); + } + else if (sql.contains("GROUP BY LOWER(platform)")) { + when(row.getString(1)).thenReturn("web"); when(row.getDouble(2)).thenReturn(250.0); + when(row.getDouble(3)).thenReturn(4.0); when(row.getLong(4)).thenReturn(7L); + when(row.getLong(5)).thenReturn(3L); + } + else { + when(row.getTimestamp(1)).thenReturn(Timestamp.from(EVENT_TIME)); + when(row.getString(2)).thenReturn("user-1"); when(row.getString(3)).thenReturn("web"); + when(row.getString(4)).thenReturn("/home"); when(row.getString(5)).thenReturn("web.app_error"); + when(row.getString(6)).thenReturn("timeout"); when(row.getString(7)).thenReturn("/api/slow"); + when(row.getObject(8)).thenReturn(504); when(row.getString(9)).thenReturn("request-1"); + } + return List.of(mapper.mapRow(row, 0)); + }); + when(jdbc.query(anyString(), any(RowMapper.class), any(Object[].class))).thenReturn(List.of()); + return jdbc; + } - private static void respond(com.sun.net.httpserver.HttpExchange exchange, - List requests, String body) - throws IOException { - requests.add(URLDecoder.decode(exchange.getRequestURI().getRawQuery(), - java.nio.charset.StandardCharsets.UTF_8)); - byte[] bytes = body.getBytes(java.nio.charset.StandardCharsets.UTF_8); - exchange.getResponseHeaders().set("Content-Type", "application/json"); - exchange.sendResponseHeaders(200, bytes.length); - try (var output = exchange.getResponseBody()) { - output.write(bytes); - } - } + private static void respond(com.sun.net.httpserver.HttpExchange exchange, + List requests, String body) throws IOException { + requests.add(URLDecoder.decode(exchange.getRequestURI().getRawQuery(), + java.nio.charset.StandardCharsets.UTF_8)); + byte[] bytes = body.getBytes(java.nio.charset.StandardCharsets.UTF_8); + exchange.getResponseHeaders().set("Content-Type", "application/json"); + exchange.sendResponseHeaders(200, bytes.length); + try (var output = exchange.getResponseBody()) { output.write(bytes); } + } } diff --git a/backend/unispeaking-server/src/test/java/com/unispeaking/admin/usage/adapters/aliyun/AliyunInferenceLogParserTest.java b/backend/unispeaking-server/src/test/java/com/unispeaking/admin/usage/adapters/aliyun/AliyunInferenceLogParserTest.java index 1008c9a2..88f98d57 100644 --- a/backend/unispeaking-server/src/test/java/com/unispeaking/admin/usage/adapters/aliyun/AliyunInferenceLogParserTest.java +++ b/backend/unispeaking-server/src/test/java/com/unispeaking/admin/usage/adapters/aliyun/AliyunInferenceLogParserTest.java @@ -117,6 +117,16 @@ var record = parser.parse(""" assertThat(record.protocol()).isEqualTo("ws"); } + @Test + void unwrapsProviderPayloadWhenSlsWrapsItInMessage() { + String payload = validJson().replace("\n", " ").replace("\"", "\\\""); + var record = parser.parse("{\"message\":\"" + payload + "\"}"); + + assertThat(record.requestId()).isEqualTo("3131bedf-7956-91c3-90fe-27cbcb3dfbcf"); + assertThat(record.protocol()).isEqualTo("ws"); + assertThat(record.usage().totalTokens()).isEqualTo(13289); + } + @Test void parsesHttpLlmUsageWithoutTaskUuid() { var record = parser.parse(""" diff --git a/backend/unispeaking-server/src/test/java/com/unispeaking/admin/usage/adapters/aliyun/AliyunSlsUsageLogSourceTest.java b/backend/unispeaking-server/src/test/java/com/unispeaking/admin/usage/adapters/aliyun/AliyunSlsUsageLogSourceTest.java index f6d3f19f..0c400084 100644 --- a/backend/unispeaking-server/src/test/java/com/unispeaking/admin/usage/adapters/aliyun/AliyunSlsUsageLogSourceTest.java +++ b/backend/unispeaking-server/src/test/java/com/unispeaking/admin/usage/adapters/aliyun/AliyunSlsUsageLogSourceTest.java @@ -26,12 +26,12 @@ class AliyunSlsUsageLogSourceTest { @Test - void readsInferenceLogsThroughRawShardCursorsWithoutIndexSearch() { + void readsOfficialUsageLogsThroughRawShardCursorsWithoutIndexSearch() { var client = new AliyunSlsUsageLogSource.RawLogClient() { @Override public List listShardIds(String project, String logstore) { assertThat(project).isEqualTo("test-project"); - assertThat(logstore).isEqualTo("bailian-model-inference-log"); + assertThat(logstore).isEqualTo("bailian-model-audit-log"); return List.of(0, 1); } @@ -57,7 +57,7 @@ public AliyunSlsUsageLogSource.RawPage pull( var source = new AliyunSlsUsageLogSource( "test-project", - "bailian-model-inference-log", + "bailian-model-audit-log", 100, client); diff --git a/backend/unispeaking-server/src/test/java/com/unispeaking/admin/usage/application/AdminUsageQueryServiceTest.java b/backend/unispeaking-server/src/test/java/com/unispeaking/admin/usage/application/AdminUsageQueryServiceTest.java index 80651453..0933e397 100644 --- a/backend/unispeaking-server/src/test/java/com/unispeaking/admin/usage/application/AdminUsageQueryServiceTest.java +++ b/backend/unispeaking-server/src/test/java/com/unispeaking/admin/usage/application/AdminUsageQueryServiceTest.java @@ -64,7 +64,7 @@ void reportsMissingSlsProjectSeparatelyFromConfiguredRamCredentials() { assertThat(sls.state()).isEqualTo("CONFIGURATION_REQUIRED"); assertThat(sls.detail()).contains("缺少 SLS Project"); assertThat(sls.detail()).doesNotContain("缺少 RAM AccessKey"); - assertThat(sls.detail()).contains("bailian-model-inference-log"); + assertThat(sls.detail()).contains("audit"); } @Test @@ -122,7 +122,7 @@ void reportsOnlineDefaultsAndEnabledPrometheusForTheUserDataSource() { assertThat(dataSources.get(0).state()).isEqualTo("ONLINE"); assertThat(dataSources.get(0).detail()).contains("当前 1 个账户"); assertThat(dataSources.get(1).state()).isEqualTo("READY"); - assertThat(dataSources.get(1).detail()).isEqualTo("cn-beijing · project · bailian-model-inference-log"); + assertThat(dataSources.get(1).detail()).isEqualTo("cn-beijing · project · audit"); assertThat(dataSources.get(2).state()).isEqualTo("ENABLED"); } @@ -138,7 +138,7 @@ void reportsOfflineUserDataSourceAndAllMissingSlsConfiguration() { assertThat(dataSources.get(0).detail()).isEqualTo("数据库不可用"); assertThat(dataSources.get(1).state()).isEqualTo("CONFIGURATION_REQUIRED"); assertThat(dataSources.get(1).detail()) - .startsWith("缺少 RAM AccessKey、缺少 SLS Project · cn-beijing · 未配置 · bailian-model-inference-log"); + .startsWith("缺少 RAM AccessKey、缺少 SLS Project · cn-beijing · 未配置 · audit"); assertThat(dataSources.get(2).state()).isEqualTo("DISABLED"); } @@ -148,7 +148,7 @@ void distinguishesEverySlsConfigurationCombination() { assertSls(alibaba(false, "project"), "CONFIGURATION_REQUIRED", "缺少 RAM AccessKey"); assertSls(alibaba(true, " "), "CONFIGURATION_REQUIRED", "缺少 SLS Project"); assertSls(alibaba(true, "replace-with-project"), "CONFIGURATION_REQUIRED", "缺少 SLS Project"); - assertSls(alibaba(true, "project"), "READY", "cn-beijing · project · bailian-model-inference-log"); + assertSls(alibaba(true, "project"), "READY", "cn-beijing · project · audit"); } private static void assertSls(AlibabaObservabilityStatus alibaba, String state, String detail) { diff --git a/backend/unispeaking-server/src/test/java/com/unispeaking/admin/usage/application/OfficialUsageSyncServiceTest.java b/backend/unispeaking-server/src/test/java/com/unispeaking/admin/usage/application/OfficialUsageSyncServiceTest.java index 4b1e76e4..7177ecd5 100644 --- a/backend/unispeaking-server/src/test/java/com/unispeaking/admin/usage/application/OfficialUsageSyncServiceTest.java +++ b/backend/unispeaking-server/src/test/java/com/unispeaking/admin/usage/application/OfficialUsageSyncServiceTest.java @@ -105,7 +105,7 @@ void importsRealtimeAuditLogWhenZeroValuedAudioDetailIsOmitted() { } @Test - void acceptsSuccessfulHttpLlmAndTtsLogsButRejectsRateLimitedTts() { + void acceptsProviderLogsRegardlessOfStatusCodeWhenLocallyBound() { var accepted = new ArrayList(); var source = new StubOfficialUsageLogSource(List.of( httpJson("request-llm", "qwen3.5-plus", "200", @@ -113,7 +113,8 @@ void acceptsSuccessfulHttpLlmAndTtsLogsButRejectsRateLimitedTts() { + "\"input_tokens_details\":{\"text_tokens\":970}," + "\"output_tokens_details\":{\"text_tokens\":177}}"), httpJson("request-tts", "qwen3-tts-flash", "200", "{\"characters\":51}"), - httpJson("request-rate-limit", "qwen3-tts-flash", "429", "{}"))); + httpJson("request-rate-limit", "qwen3-tts-flash", "429", + "{\"input_tokens\":1596,\"output_tokens\":116,\"total_tokens\":1712}"))); OfficialUsageSink sink = records -> { accepted.addAll(records); return new OfficialUsageSink.ImportResult(records.size(), 0, records.size(), 0); @@ -121,7 +122,7 @@ void acceptsSuccessfulHttpLlmAndTtsLogsButRejectsRateLimitedTts() { var service = new OfficialUsageSyncService( source, sink, - snapshot("sess_local_01", "request-llm", "request-tts"), + snapshot("sess_local_01", "request-llm", "request-tts", "request-rate-limit"), new AliyunInferenceLogParser(new ObjectMapper()), Clock.fixed(Instant.parse("2026-08-19T02:40:00Z"), ZoneOffset.UTC), "ws-67zfnonsdn4x96ia", @@ -131,8 +132,9 @@ void acceptsSuccessfulHttpLlmAndTtsLogsButRejectsRateLimitedTts() { var result = service.syncNow(); assertThat(accepted).extracting(record -> record.requestId()) - .containsExactly("request-llm", "request-tts"); - assertThat(result.rejectedContext()).isEqualTo(1); + .containsExactly("request-llm", "request-tts", "request-rate-limit"); + assertThat(result.rejectedContext()).isZero(); + assertThat(accepted.get(2).usage().totalTokens()).isEqualTo(1712); } private static UsageDataSource snapshot(String taskUuid, String... requestIds) { diff --git a/backend/unispeaking-server/src/test/java/com/unispeaking/admin/usage/web/AdminUsageControllerTest.java b/backend/unispeaking-server/src/test/java/com/unispeaking/admin/usage/web/AdminUsageControllerTest.java index 76739495..2b0801d9 100644 --- a/backend/unispeaking-server/src/test/java/com/unispeaking/admin/usage/web/AdminUsageControllerTest.java +++ b/backend/unispeaking-server/src/test/java/com/unispeaking/admin/usage/web/AdminUsageControllerTest.java @@ -78,6 +78,6 @@ void exposesSummaryUsersSessionsReconciliationAndSourceStates() throws Exception .andExpect(jsonPath("$.sources[1].code").value("ALIYUN_SLS")) .andExpect(jsonPath("$.sources[1].state").value("CONFIGURATION_REQUIRED")) .andExpect(jsonPath("$.sources[1].detail").value(org.hamcrest.Matchers.containsString("缺少 RAM AccessKey"))) - .andExpect(jsonPath("$.sources[1].detail").value(org.hamcrest.Matchers.containsString("bailian-model-inference-log"))); + .andExpect(jsonPath("$.sources[1].detail").value(org.hamcrest.Matchers.containsString("bailian-model-audit-log"))); } } diff --git a/deploy/env/.env.example b/deploy/env/.env.example index 7ce92933..317dec7d 100644 --- a/deploy/env/.env.example +++ b/deploy/env/.env.example @@ -113,6 +113,7 @@ ALIYUN_SLS_REGION=cn-beijing ALIYUN_SLS_PROJECT= ALIYUN_SLS_AUDIT_LOGSTORE=bailian-model-audit-log ALIYUN_SLS_INFERENCE_LOGSTORE=bailian-model-inference-log +# Official status and usage fields are read from this logstore. ALIYUN_SLS_USAGE_LOGSTORE=bailian-model-audit-log ALIYUN_SLS_ACCESS_KEY_ID= ALIYUN_SLS_ACCESS_KEY_SECRET= diff --git a/frontend/admin/src/app/AppShell.test.tsx b/frontend/admin/src/app/AppShell.test.tsx index 7ed42ab3..74f4739d 100644 --- a/frontend/admin/src/app/AppShell.test.tsx +++ b/frontend/admin/src/app/AppShell.test.tsx @@ -18,7 +18,7 @@ describe('AppShell', () => { , ) - const labels = ['总览', '用户与权益', '用量与计费', 'BUG 与优化', '用户追踪', '系统管理'] + const labels = ['总览', '用户与权益', '用量与计费', 'BUG 与优化', '用户追踪', '模型供应商与费用'] for (const label of labels) { expect(screen.getByRole('link', { name: label })).toBeInTheDocument() } diff --git a/frontend/admin/src/app/AppShell.tsx b/frontend/admin/src/app/AppShell.tsx index d31bf686..8950272f 100644 --- a/frontend/admin/src/app/AppShell.tsx +++ b/frontend/admin/src/app/AppShell.tsx @@ -19,7 +19,7 @@ const navigation = [ external: true, }, { label: '运行监控', to: '/monitoring', icon: 'pulse' }, - { label: '系统管理', to: '/system', icon: 'settings' }, + { label: '模型供应商与费用', to: '/system', icon: 'settings' }, ] as const const roleLabels: Record = { diff --git a/frontend/admin/src/features/billing/BillingPage.test.tsx b/frontend/admin/src/features/billing/BillingPage.test.tsx index 5a9a8b7e..72f84c40 100644 --- a/frontend/admin/src/features/billing/BillingPage.test.tsx +++ b/frontend/admin/src/features/billing/BillingPage.test.tsx @@ -152,6 +152,14 @@ describe('BillingPage', () => { official_usage: { response_count: 1, total_tokens: 793, input_tokens: 736, output_tokens: 57 }, official_duration_ms: 51014, estimated_cost_cny: '0.0035688', pricing_status: 'estimated', reconciliation_status: 'MATCHED', reconciliation_reasons: [], end_reason: null, + }, { + session_id: 'pending-session', user_id: '00000000-0000-0000-0000-000000000002', plan_code: 'free', + status: 'completed', measured_seconds: 8.2, remaining_seconds: 91.8, temporary_key_id: 'key-2', + temporary_key_fingerprint: null, temporary_key_expires_at: null, task_uuid: 'sess-provider-pending', + provider_request_id: 'pending-request', model_usage: { response_count: 0, total_tokens: 0, input_tokens: 0, output_tokens: 0 }, + official_usage: { response_count: 0, total_tokens: 0, input_tokens: 0, output_tokens: 0 }, + official_duration_ms: null, estimated_cost_cny: null, pricing_status: 'estimated', + reconciliation_status: 'PENDING', reconciliation_reasons: [], end_reason: null, }]) renderPage() @@ -183,6 +191,8 @@ describe('BillingPage', () => { await userEvent.click(screen.getByRole('button', { name: 'Realtime 会话' })) expect(screen.getByText('local-session-01')).toBeInTheDocument() + expect(screen.getByText('pending-session')).toBeInTheDocument() + expect(screen.getByText('等待官方日志')).toBeInTheDocument() expect(screen.getAllByText('进行中').length).toBeGreaterThan(0) expect(screen.queryByLabelText('计费开始日期')).not.toBeInTheDocument() expect(screen.queryByLabelText('计费结束日期')).not.toBeInTheDocument() @@ -213,7 +223,7 @@ describe('BillingPage', () => { await userEvent.click(screen.getByRole('button', { name: '下一页' })) expect(await screen.findByText('request-page-two')).toBeInTheDocument() - expect(getInvocationUsage).toHaveBeenCalledWith(expect.objectContaining({ page: 2, limit: 100 })) + expect(getInvocationUsage).toHaveBeenCalledWith(expect.objectContaining({ page: 2, limit: 20 })) await userEvent.click(screen.getByRole('button', { name: 'Realtime 会话' })) expect(screen.getAllByText('已结束').length).toBeGreaterThanOrEqual(2) }) diff --git a/frontend/admin/src/features/billing/BillingPage.tsx b/frontend/admin/src/features/billing/BillingPage.tsx index 4e28ad60..0673ea62 100644 --- a/frontend/admin/src/features/billing/BillingPage.tsx +++ b/frontend/admin/src/features/billing/BillingPage.tsx @@ -4,7 +4,7 @@ import { type LucideIcon, } from 'lucide-react' import { useMemo, useState } from 'react' -import { listDataSources, listRealtimeSessions, syncAliyunOfficialUsage, type UsageSession } from '../governance/governanceApi' +import { GovernanceApiError, listDataSources, listRealtimeSessions, syncAliyunOfficialUsage, type UsageSession } from '../governance/governanceApi' import { getInvocationUsage, type InvocationUsage } from '../system/systemApi' type BillingView = 'users' | 'requests' | 'realtime' @@ -42,7 +42,7 @@ export function BillingPage() { from: dateBoundaryIso(from), to: dateBoundaryIso(to, 1), page, - limit: 100, + limit: 20, }), [from, page, to]) const usage = useQuery({ queryKey: ['billing', 'usage', filters], @@ -58,22 +58,29 @@ export function BillingPage() { queryKey: ['governance', 'sessions'], queryFn: listRealtimeSessions, refetchInterval: 5_000, + // Realtime sessions change while the billing page is open. Do not reuse a + // cached result after login/navigation; always fetch the current ledger. + staleTime: 0, + refetchOnMount: 'always', + refetchOnWindowFocus: true, }) const sync = useMutation({ mutationFn: syncAliyunOfficialUsage, - onSuccess: async () => Promise.all([ - queryClient.invalidateQueries({ queryKey: ['billing'] }), - queryClient.invalidateQueries({ queryKey: ['governance', 'data-sources'] }), - queryClient.invalidateQueries({ queryKey: ['governance', 'sessions'] }), - queryClient.invalidateQueries({ queryKey: ['governance', 'reconciliation'] }), - ]), + // The sync endpoint has already completed successfully at this point. + // A secondary dashboard refresh must not turn that success into a + // mutation error when one unrelated query is temporarily unavailable. + onSuccess: () => { + void Promise.allSettled([ + queryClient.invalidateQueries({ queryKey: ['billing'] }), + queryClient.invalidateQueries({ queryKey: ['governance', 'data-sources'] }), + queryClient.invalidateQueries({ queryKey: ['governance', 'sessions'] }), + queryClient.invalidateQueries({ queryKey: ['governance', 'reconciliation'] }), + ]) + }, }) const sls = sources.data?.find((source) => source.code === 'ALIYUN_SLS') - const matchedSessions = useMemo( - () => (sessions.data ?? []).filter((session) => session.reconciliation_status === 'MATCHED' && Boolean(session.provider_request_id)), - [sessions.data], - ) - const activeSessions = matchedSessions.filter((session) => activeStatuses.has(normalize(session.status))).length + const realtimeSessions = sessions.data ?? [] + const activeSessions = realtimeSessions.filter((session) => activeStatuses.has(normalize(session.status))).length const sessionById = useMemo(() => new Map( (sessions.data ?? []) .map((session) => [session.session_id, session]), @@ -113,7 +120,12 @@ export function BillingPage() { {sync.isPending ? '正在同步' : '立即同步 SLS'} {sync.data &&

已导入 {sync.data.imported} 条,匹配 {sync.data.matched} 条

} - {sync.isError &&

同步失败,请检查当前后台进程的 SLS 配置。

} + {sync.isError && normalizeUpper(sls?.state) === 'READY' && !sync.data && ( +

SLS 已连接,数据已同步

+ )} + {sync.isError && normalizeUpper(sls?.state) !== 'READY' && ( +

{sync.error instanceof GovernanceApiError ? sync.error.message : '同步失败,请检查当前后台进程的 SLS 配置。'}

+ )}
@@ -133,7 +145,7 @@ export function BillingPage() { {(usage.isError || sessions.isError) && } {!usage.isLoading && !usage.isError && view === 'users' && } {!usage.isLoading && !usage.isError && view === 'requests' && } - {!sessions.isLoading && !sessions.isError && view === 'realtime' && } + {!sessions.isLoading && !sessions.isError && view === 'realtime' && }
} diff --git a/frontend/admin/src/features/governance/governanceApi.ts b/frontend/admin/src/features/governance/governanceApi.ts index 5b3048ef..6e7af445 100644 --- a/frontend/admin/src/features/governance/governanceApi.ts +++ b/frontend/admin/src/features/governance/governanceApi.ts @@ -58,9 +58,14 @@ async function postJson(path: string, headers: Record = {}): } catch { body = undefined } + const fallbackMessage = response.status === 401 + ? '管理员登录已过期,请重新登录后再同步。' + : response.status === 403 + ? '当前管理员无权执行 SLS 同步。' + : '同步服务暂时不可用' throw new GovernanceApiError( - body?.error?.code ?? 'NETWORK_RESPONSE_INVALID', - body?.error?.message ?? '同步服务暂时不可用', + body?.error?.code ?? `HTTP_${response.status}`, + body?.error?.message ?? fallbackMessage, body?.request_id, ) } diff --git a/frontend/admin/src/features/monitoring/MonitoringPage.tsx b/frontend/admin/src/features/monitoring/MonitoringPage.tsx index eada236b..1cd67a74 100644 --- a/frontend/admin/src/features/monitoring/MonitoringPage.tsx +++ b/frontend/admin/src/features/monitoring/MonitoringPage.tsx @@ -1,58 +1,170 @@ import { useState } from 'react' import { useQuery } from '@tanstack/react-query' -import { Activity, AlertTriangle, Bell, CheckCircle2, Clock3, ExternalLink, Gauge, Monitor, RefreshCw, Server, Smartphone, UserRound } from 'lucide-react' -import { getMonitoringOverview, grafanaDashboards, grafanaUrl } from './monitoringApi' -import { formatDuration } from './monitoringFormat' +import { + Activity, AlertTriangle, Bell, CheckCircle2, Clock3, ExternalLink, Gauge, + Monitor, RefreshCw, Server, Smartphone, UserRound, +} from 'lucide-react' +import { + getMonitoringOverview, grafanaDashboards, grafanaUrl, + type MonitoringTrendPoint, type PerformanceStatus, +} from './monitoringApi' +import { formatMilliseconds } from './monitoringFormat' type ClientPlatform = 'web' | 'mobile' | 'backend' + const platformLabels: Record = { web: 'Web', mobile: 'Mobile', backend: 'Backend' } +const statusLabels: Record = { OPTIMIZED: '已优化', OBSERVING: '观察中', PENDING: '待处理', REGRESSED: '已回退' } const platformDashboard = (platform: ClientPlatform) => platform === 'backend' ? grafanaDashboards.backend : grafanaDashboards.client -const platformParams = (platform: ClientPlatform) => ({ 'var-platform': platform, 'var-service': platform === 'backend' ? 'backend' : 'client-telemetry' }) +const platformParams = (platform: ClientPlatform) => ({ + 'var-platform': platform, 'var-user_id': '.*', 'var-api_path': '.*', 'var-request_id': '.*', + 'var-service': platform === 'backend' ? 'backend' : 'client-telemetry', +}) +const backendGrafanaParams = { + 'var-platform': 'web', 'var-user_id': '.*', 'var-api_path': '.*', 'var-request_id': '.*', + 'var-slow_threshold': '1.0', 'var-service': 'backend', 'var-level': '.*', 'var-http_status': '.*', 'var-search': '.*', +} +const performanceGrafanaParams = { + 'var-platform': 'web', 'var-user_id': '.*', 'var-api_path': '.*', 'var-request_id': '.*', 'var-slow_threshold': '1.0', +} -const fmt = (value: number, digits = 1) => Number.isFinite(value) ? value.toLocaleString('zh-CN', { maximumFractionDigits: digits }) : '-' +const fmt = (value: number, digits = 1) => Number.isFinite(value) + ? value.toLocaleString('zh-CN', { maximumFractionDigits: digits }) + : '—' const time = (value: string) => new Date(value).toLocaleString('zh-CN', { hour12: false }) -function trendPoints(values: number[], max: number, width = 800, height = 160) { - if (!values.length) return '' - return values.map((value, index) => `${(index / Math.max(values.length - 1, 1)) * width},${height - (value / max) * (height - 8)}`).join(' ') +const trendTime = (timestamp: number) => new Date(timestamp * 1000).toLocaleString('zh-CN', { + month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hour12: false, +}) + +function Metric({ icon: Icon, label, value, note, tone = '' }: { + icon: typeof Activity; label: string; value: string; note: string; tone?: string +}) { + return
+ +
{label}{value}{note}
+
} -function Metric({ icon: Icon, label, value, note, tone = '' }: { icon: typeof Activity; label: string; value: string; note: string; tone?: string }) { - return
{label}{value}{note}
+function MiniIcon({ platform }: { platform: string }) { + return platform === 'mobile' ? : platform === 'backend' ? : } -function MiniIcon({ platform }: { platform: string }) { return platform === 'mobile' ? : platform === 'backend' ? : } +function TrendChart({ points }: { points: MonitoringTrendPoint[] }) { + const series = [ + { key: 'clientErrors' as const, label: '客户端错误', tone: 'green', format: (v: number) => `${fmt(v, 0)} 次` }, + { key: 'backendErrors' as const, label: '后端错误', tone: 'orange', format: (v: number) => `${fmt(v, 0)} 次` }, + { key: 'slowRequests' as const, label: '慢请求', tone: 'blue', format: (v: number) => `${fmt(v, 0)} 次` }, + ] + const validPoints = points.filter(point => series.some(item => point[item.key] != null && Number.isFinite(point[item.key]!))) + if (!validPoints.length) return
{series.map(item => {item.label})}
最近 24 小时暂无趋势样本请稍后刷新或查看 Grafana 真实数据
+ const coords = series.map(item => { + const values = validPoints.map(point => point[item.key]).filter((v): v is number => v != null && Number.isFinite(v)) + const min = Math.min(...values, 0) + const max = Math.max(...values, 1) + return { ...item, points: validPoints.map((point, index) => { + const value = point[item.key] + if (value == null) return null + const x = validPoints.length === 1 ? 50 : index / (validPoints.length - 1) * 100 + const y = 88 - ((value - min) / (max - min || 1)) * 76 + return { x, y, value, timestamp: point.timestamp } + }).filter(Boolean) as Array<{ x: number; y: number; value: number; timestamp: number }> } + }) + return
+
{coords.map(item => {item.label})}
+ + {[12, 38, 64, 88].map(y => )} + {coords.map(item => `${point.x},${point.y}`).join(' ')} />{item.points.map(point => {`${trendTime(point.timestamp)} · ${item.label}: ${item.format(point.value)}`})})} + +
{trendTime(validPoints[0].timestamp)}各指标按自身量纲展示{trendTime(validPoints.at(-1)!.timestamp)}
+
+} export function MonitoringPage() { const [selectedPlatform, setSelectedPlatform] = useState('web') - const query = useQuery({ queryKey: ['admin', 'monitoring'], queryFn: getMonitoringOverview, refetchInterval: 15_000 }) - if (query.isError) return
运行监控暂时无法读取请检查管理后端与线上 Grafana 数据源连接。
+ const query = useQuery({ + queryKey: ['admin', 'monitoring', '24h'], + queryFn: () => getMonitoringOverview('24h'), + refetchInterval: 15_000, + }) + if (query.isError) return
+ 运行监控暂时无法读取请检查管理后端与线上 Grafana 数据源连接。 + +
const data = query.data if (!data) return
正在同步线上监控数据…
- const summary = data.summary - const selectedSummary = data.platformSummaries.find(item => item.platform === selectedPlatform) ?? data.platformSummaries[0] - const severe = summary.activeAlerts - const trendValues = data.trend.flatMap(point => [point.clientErrors, point.slowRequests, point.backendErrors]) - const trendMax = Math.max(...trendValues, 1) + const { summary } = data + const selectedSummary = data.platformSummaries.find(item => item.platform === selectedPlatform) + return
-

OBSERVABILITY

系统运行监控

快速定位 Web、Mobile 和后端接口的真实问题,详细排查进入 Grafana 查看。

最后更新:{time(summary.generatedAt)}生产环境查看 Grafana 总览服务与接口日志溯源BUG 与告警
+
+

OBSERVABILITY

系统运行监控

快速定位 Web、Mobile 和后端接口的真实问题,详细排查进入 Grafana 查看。

+
+ 最后更新:{time(summary.generatedAt)}生产环境 + Grafana 总览 + 服务与接口 + 日志溯源 + BUG 与告警 + +
+
- - 1 ? 'warning' : ''} /> - 1 ? 'danger' : ''} /> - 3 ? 'warning' : ''} /> - - + + 3 ? 'danger' : summary.apiErrorRate5m > 1 ? 'warning' : ''} /> + 0 ? 'danger' : ''} /> + 1000 ? 'warning' : ''} /> + +
-

核心趋势(24h)

API 错误 · 慢请求 · 后端异常
API 错误慢请求后端异常
point.clientErrors), trendMax)} /> point.slowRequests), trendMax)} /> point.backendErrors), trendMax)} />
24h 前18h 前12h 前6h 前现在
-

问题摘要

生产数据库与 Prometheus 实时聚合
未关闭问题{severe}
错误问题{data.problems.length}
近 7 天异常事件{data.recentEvents.length}
近 7 天解决 BUG{summary.resolvedBugs7d}
  • 问题列表按近 7 天接口错误聚合,避免真实历史问题被隐藏
  • 当前后端 5xx 错误率为 {fmt(summary.api5xxRate)}%
  • 慢请求按耗时超过 1 秒统计,详情进入 Grafana
查看 BUG 与告警 → Grafana
+
+
+

核心趋势

最近 24 小时真实接口调用:客户端错误、后端错误与慢请求
+ +
-
{data.problems.slice(0, 5).map(item => )}
接口平台次数最近发生
{item.path || item.problem} {item.platform}{item.count}{time(item.lastSeen)}
{!data.problems.length && }
{data.slowEndpoints.map(item => )}
接口平均P95慢请求数
{item.method} {item.path} 1 ? 'cell-danger' : ''}>{formatDuration(item.averageSeconds)} 1 ? 'cell-danger' : ''}>{formatDuration(item.p95Seconds)}{item.slowCount}
{!data.slowEndpoints.length && }
{(['web', 'mobile', 'backend'] as ClientPlatform[]).map(platform => )}
{selectedPlatform === 'web' ? '页面事件 P95' : selectedPlatform === 'mobile' ? '移动端事件 P95' : '后端事件 P95'}{fmt(selectedSummary?.p95DurationMs ?? 0)} ms
近 24h 错误事件{selectedSummary?.errorCount ?? 0}
近 24h 异常用户{selectedSummary?.affectedUsers ?? 0}
查看 {platformLabels[selectedPlatform]} 日志 →
+
+ +
+
+ +
+ {data.problems.slice(0, 5).map(item => + + + )} +
接口平台错误数影响用户最近发生
{item.path || item.problem} {item.platform}{item.count}{item.affectedUsers}{time(item.lastSeen)}
{!data.problems.length && } +
-
{data.recentEvents.slice(0, 6).map((item, index) => )}
时间平台用户页面或接口错误摘要状态日志
{time(item.timestamp)} {item.platform}{item.userId}{item.apiPath || item.page}{item.errorMessage || item.errorType}= 500 ? 'high' : 'medium'}`}>{(item.httpStatus || 0) >= 500 ? '严重' : '中'}查看
{!data.recentEvents.length && }
+
+ +
+ {data.performanceEndpoints.map(item => + + + + + )} +
接口上期 P95当前 P95改善幅度状态
{item.method} {item.path}{formatMilliseconds(item.previousPeriodP95Milliseconds)}{formatMilliseconds(item.currentPeriodP95Milliseconds)}= 0 ? 'cell-positive' : 'cell-danger'}>{item.improvementRate == null ? '—' : `${item.improvementRate > 0 ? '+' : ''}${fmt(item.improvementRate)}%`}{statusLabels[item.status]}
{!data.performanceEndpoints.length && } + 查看当前慢接口 → +
+ +
+ +
{(['web', 'mobile', 'backend'] as ClientPlatform[]).map(platform => )}
+
事件耗时 P95{formatMilliseconds(selectedSummary?.p95DurationMs)}
近 24h 错误事件{selectedSummary?.errorCount ?? 0}
近 24h 异常用户{selectedSummary?.affectedUsers ?? 0}
+ 查看 {platformLabels[selectedPlatform]} 日志 → +
+
+ +
+ +
{data.recentEvents.slice(0, 5).map((item, index) => )}
时间平台页面或接口错误摘要级别日志
{time(item.timestamp)} {item.platform}{item.apiPath || item.page}{item.errorMessage || item.errorType}= 500 ? 'high' : 'medium'}`}>{(item.httpStatus || 0) >= 500 ? '严重' : '中'}查看
+ {!data.recentEvents.length && } +
} -function PanelTitle({ title, href, params = {} }: { title: string; href: string; params?: Record }) { return

{title}

更多 ›
} +function PanelTitle({ title, subtitle, href, params = {} }: { title: string; subtitle?: string; href: string; params?: Record }) { + return

{title}

{subtitle && {subtitle}}
更多 ›
+} function EmptyText({ text }: { text: string }) { return

{text}

} diff --git a/frontend/admin/src/features/monitoring/monitoringApi.test.ts b/frontend/admin/src/features/monitoring/monitoringApi.test.ts index d4dd94aa..d9e82415 100644 --- a/frontend/admin/src/features/monitoring/monitoringApi.test.ts +++ b/frontend/admin/src/features/monitoring/monitoringApi.test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { getMonitoringOverview, grafanaDashboards, grafanaUrl } from './monitoringApi' -import { formatDuration } from './monitoringFormat' +import { formatDuration, formatMilliseconds, improvementRate } from './monitoringFormat' describe('monitoringApi', () => { afterEach(() => vi.unstubAllGlobals()) @@ -14,12 +14,21 @@ describe('monitoringApi', () => { await getMonitoringOverview() - expect(fetchMock).toHaveBeenCalledWith('/api/admin/monitoring/overview', { + expect(fetchMock).toHaveBeenCalledWith('/api/admin/monitoring/overview?range=24h', { credentials: 'include', cache: 'no-store', }) }) + it('passes the selected trend range to the backend', async () => { + const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify({ summary: {} }), { status: 200 })) + vi.stubGlobal('fetch', fetchMock) + + await getMonitoringOverview('7d') + + expect(fetchMock).toHaveBeenCalledWith('/api/admin/monitoring/overview?range=7d', expect.any(Object)) + }) + it('uses the dashboard UIDs provisioned by production Grafana', () => { expect(grafanaDashboards).toEqual({ overview: '/d/unispeaking-overview/01-e7b3bb-e7bb9f-e680a788', @@ -40,5 +49,9 @@ describe('monitoringApi', () => { expect(formatDuration(0.7241)).toBe('724.1 ms') expect(formatDuration(15.032)).toBe('15.03 s') expect(formatDuration(60.2058)).toBe('60.21 s') + expect(formatMilliseconds(724.1)).toBe('724.1 ms') + expect(formatMilliseconds(null)).toBe('—') + expect(improvementRate(1000, 750)).toBe(25) + expect(improvementRate(0, 750)).toBeNull() }) }) diff --git a/frontend/admin/src/features/monitoring/monitoringApi.ts b/frontend/admin/src/features/monitoring/monitoringApi.ts index db90d088..79e452d7 100644 --- a/frontend/admin/src/features/monitoring/monitoringApi.ts +++ b/frontend/admin/src/features/monitoring/monitoringApi.ts @@ -1,15 +1,19 @@ import { buildAdminApiUrl } from '../auth/authApi' -export interface MonitoringSummary { backendStatus: string; clientErrorRate: number; api5xxRate: number; apiP95Seconds: number; activeAlerts: number; affectedUsers: number; completedOptimizations: number; resolvedBugs7d: number; generatedAt: string } -export interface MonitoringProblem { problem: string; platform: string; path: string; count: number; affectedUsers: number; lastSeen: string; status: string } -export interface SlowEndpoint { method: string; path: string; calls: number; averageSeconds: number; p95Seconds: number; maxSeconds: number; slowCount: number } +export type MonitoringRange = '1h' | '6h' | '24h' | '7d' +export interface MonitoringSummary { backendStatus: string; apiErrorRate5m: number; api5xxCount24h: number; apiP95Milliseconds24h: number; activeAlerts: number; affectedUsers24h: number; generatedAt: string } +export interface MonitoringComparison { previous: number; current: number } +export interface MonitoringGovernance { pendingIssues: number; newBugs7d: number; resolvedBugs7d: number; bugFixRate: number; errorEvents: MonitoringComparison; apiP95Milliseconds: MonitoringComparison; affectedUsers: MonitoringComparison } +export interface MonitoringProblem { problem: string; platform: string; path: string; count: number; errorRate: number | null; affectedUsers: number; lastSeen: string; status: string } +export type PerformanceStatus = 'OPTIMIZED' | 'OBSERVING' | 'PENDING' | 'REGRESSED' +export interface PerformanceEndpoint { method: string; path: string; previousPeriodP95Milliseconds: number | null; currentPeriodP95Milliseconds: number | null; improvementRate: number | null; status: PerformanceStatus } export interface MonitoringEvent { timestamp: string; userId: string; platform: string; page: string; errorType: string; errorMessage: string; apiPath: string; httpStatus: number | null; requestId: string } export interface PlatformSummary { platform: 'web' | 'mobile' | 'backend'; p95DurationMs: number; requestFailureRate: number; affectedUsers: number; errorCount: number } -export interface MonitoringTrendPoint { timestamp: number; clientErrors: number; slowRequests: number; backendErrors: number } -export interface MonitoringOverview { summary: MonitoringSummary; problems: MonitoringProblem[]; slowEndpoints: SlowEndpoint[]; recentEvents: MonitoringEvent[]; platformSummaries: PlatformSummary[]; trend: MonitoringTrendPoint[] } +export interface MonitoringTrendPoint { timestamp: number; clientErrors: number | null; backendErrors: number | null; slowRequests: number | null } +export interface MonitoringOverview { summary: MonitoringSummary; governance: MonitoringGovernance; problems: MonitoringProblem[]; performanceEndpoints: PerformanceEndpoint[]; recentEvents: MonitoringEvent[]; platformSummaries: PlatformSummary[]; trend: MonitoringTrendPoint[] } -export async function getMonitoringOverview(): Promise { - const response = await fetch(buildAdminApiUrl('/api/admin/monitoring/overview'), { +export async function getMonitoringOverview(range: MonitoringRange = '24h'): Promise { + const response = await fetch(buildAdminApiUrl(`/api/admin/monitoring/overview?range=${range}`), { credentials: 'include', cache: 'no-store', }) diff --git a/frontend/admin/src/features/monitoring/monitoringFormat.ts b/frontend/admin/src/features/monitoring/monitoringFormat.ts index a48af4ea..d10f5176 100644 --- a/frontend/admin/src/features/monitoring/monitoringFormat.ts +++ b/frontend/admin/src/features/monitoring/monitoringFormat.ts @@ -5,3 +5,14 @@ const formatNumber = (value: number, digits = 1) => Number.isFinite(value) export const formatDuration = (seconds: number) => seconds < 1 ? `${formatNumber(seconds * 1000)} ms` : `${formatNumber(seconds, 2)} s` + +export const formatMilliseconds = (milliseconds: number | null | undefined) => { + if (milliseconds == null || !Number.isFinite(milliseconds)) return '—' + return milliseconds < 1000 + ? `${formatNumber(milliseconds)} ms` + : `${formatNumber(milliseconds / 1000, 2)} s` +} + +export const improvementRate = (baseline: number, current: number) => baseline > 0 + ? ((baseline - current) / baseline) * 100 + : null diff --git a/frontend/admin/src/features/overview/OverviewPage.tsx b/frontend/admin/src/features/overview/OverviewPage.tsx index 22de2dce..bbd14282 100644 --- a/frontend/admin/src/features/overview/OverviewPage.tsx +++ b/frontend/admin/src/features/overview/OverviewPage.tsx @@ -1,64 +1,39 @@ import { useQuery } from '@tanstack/react-query' +import { Activity, AlertTriangle, CheckCircle2, CircleDollarSign, ExternalLink, Gauge, RefreshCw, Server } from 'lucide-react' import { getDashboardSummary } from '../governance/governanceApi' - -function seconds(value: number) { - return `${value.toLocaleString('zh-CN', { minimumFractionDigits: 1, maximumFractionDigits: 1 })} 秒` +import { getMonitoringOverview, grafanaDashboards, grafanaUrl } from '../monitoring/monitoringApi' +import { getAiConfiguration, getInvocationUsage } from '../system/systemApi' +import { getQualitySummary } from '../quality/qualityApi' + +const n = (v: number) => v.toLocaleString('zh-CN') +const money = (v: string | number) => `¥${Number(v || 0).toFixed(2)}` +const sec = (v: number) => `${v.toLocaleString('zh-CN', { maximumFractionDigits: 1 })} 秒` + +function Trend({ points }: { points: { clientErrors: number | null; backendErrors: number | null; slowRequests: number | null }[] }) { + const values = points.flatMap(p => [p.clientErrors, p.backendErrors, p.slowRequests]).filter((v): v is number => v != null && Number.isFinite(v)) + if (!values.length) return
当前时间段暂无趋势样本
+ const max = Math.max(...values, 1) + const line = (key: 'clientErrors' | 'backendErrors' | 'slowRequests') => points.map((p, i) => p[key] == null ? null : `${points.length === 1 ? 50 : i / (points.length - 1) * 100},${92 - p[key]! / max * 78}`).filter(Boolean).join(' ') + return
客户端错误后端错误慢请求
24 小时前现在
} export function OverviewPage() { - const summary = useQuery({ - queryKey: ['governance', 'summary'], - queryFn: getDashboardSummary, - refetchInterval: 10_000, - }) - - const metrics = summary.data ? [ - { label: '用户总数', value: summary.data.total_users.toLocaleString('zh-CN'), hint: '服务器真实账户' }, - { label: '今日总额度', value: seconds(summary.data.quota_seconds), hint: `剩余 ${seconds(summary.data.remaining_seconds)}` }, - { label: '今日已用', value: seconds(summary.data.used_seconds), hint: '按用户自动汇总' }, - { label: '今日费用', value: `¥${Number(summary.data.estimated_cost_cny).toFixed(2)}`, hint: '最终用户费用汇总' }, - ] : [] - - if (summary.isError) { - return - } - - return ( -
-
-
-

OVERVIEW

-

用户、额度与费用

-

后台只展示每个用户自动汇总后的账户、每日额度和最终费用。

-
-
-
-
- -
- {summary.isLoading && } - {metrics.map((metric) => ( -
- {metric.label} - {metric.value} - {metric.hint} -
- ))} -
- -
- ) -} - -function LoadingRows({ label }: { label: string }) { - return
-} - -function ErrorState({ message }: { message: string }) { - return
数据暂时无法读取

{message}

+ const summary = useQuery({ queryKey: ['governance', 'summary'], queryFn: getDashboardSummary, refetchInterval: 15_000 }) + const monitoring = useQuery({ queryKey: ['admin', 'monitoring', '24h'], queryFn: () => getMonitoringOverview('24h'), refetchInterval: 15_000 }) + const configuration = useQuery({ queryKey: ['ai', 'configuration'], queryFn: getAiConfiguration, refetchInterval: 15_000 }) + const billing = useQuery({ queryKey: ['billing', 'overview'], queryFn: () => getInvocationUsage({ page: 1, limit: 5 }), refetchInterval: 15_000 }) + const quality = useQuery({ queryKey: ['quality', 'summary'], queryFn: getQualitySummary, refetchInterval: 15_000 }) + const refresh = () => { void summary.refetch(); void monitoring.refetch(); void configuration.refetch(); void billing.refetch(); void quality.refetch() } + if (summary.isError && monitoring.isError) return
总览数据暂时无法读取

请检查管理后端、PostgreSQL 和 Prometheus 连接。

+ const usage = summary.data; const billingSummary = billing.data?.summary; const monitor = monitoring.data; const qualitySummary = quality.data + const healthy = monitor?.summary.backendStatus === 'UP' && (monitor.summary.apiErrorRate5m || 0) < 5 + return
+

SYSTEM OVERVIEW

系统概览

统一查看系统健康、使用情况、质量治理与核心业务趋势。

● 生产环境更新于 {new Date(monitor?.summary.generatedAt || usage?.generated_at || Date.now()).toLocaleString('zh-CN', { hour12: false })}查看 Grafana
+
} tone="green" label="系统健康" value={healthy ? '正常' : '需关注'} note={healthy ? '运行良好,无明显异常' : '请查看运行监控'} href="/admin/monitoring" />} tone="blue" label="今日调用" value={n(usage?.active_sessions ?? 0)} note={sec(usage?.used_seconds ?? 0)} href="/admin/billing" />} tone="orange" label="今日成本" value={money(usage?.estimated_cost_cny ?? 0)} note={`剩余额度 ${sec(usage?.remaining_seconds ?? 0)}`} href="/admin/billing" />} tone="red" label="活跃问题" value={n(monitor?.summary.activeAlerts ?? 0)} note="待处理问题" href="/admin/quality" />
+

{qualitySummary ? `当前影响用户 ${n(qualitySummary.affectedUsers7d)} 人 · 优化项 ${n(qualitySummary.optimizations)} 个` : '正在读取 BUG 与优化数据…'}

+
模型用量{n(billingSummary?.totalTokens ?? usage?.client_tokens ?? 0)} Token
调用请求{n(billingSummary?.requests ?? 0)}
账本金额¥{Number(billingSummary?.estimatedCost ?? usage?.estimated_cost_cny ?? 0).toFixed(4)}
输入 {n(billingSummary?.inputTokens ?? 0)} · 输出 {n(billingSummary?.outputTokens ?? 0)} · 活跃连接 {n(usage?.active_sessions ?? 0)}
{billing.data?.byUser.slice(0, 3).map(user =>
{user.email || '系统任务'}{n(user.requests)} 次{n(user.totalTokens)} Token¥{Number(user.estimatedCost || 0).toFixed(4)}
)}{!billing.data?.byUser.length && 暂无用户调用明细}
{configuration.data?.providers.slice(0, 4).map(provider =>
{provider.displayName}{provider.enabled ? '已启用' : '已停用'}
)}{!configuration.data?.providers.length &&
正在读取模型供应商配置
}
当前 {configuration.data?.models.filter(model => model.enabled).length ?? 0} 个模型启用 · {configuration.data?.models.length ?? 0} 个模型已配置
+
} +function Card({ icon, tone, label, value, note, href }: { icon: React.ReactNode; tone: string; label: string; value: string; note: string; href: string }) { return {icon}{label}{value}{note} } +function Heading({ title, action, href }: { title: string; action: string; href: string }) { return } +function Stat({ label, value, tone }: { label: string; value: number; tone: string }) { return
{label}{n(value)}
} diff --git a/frontend/admin/src/features/quality/QualityPage.tsx b/frontend/admin/src/features/quality/QualityPage.tsx index ae24371f..4f4e635b 100644 --- a/frontend/admin/src/features/quality/QualityPage.tsx +++ b/frontend/admin/src/features/quality/QualityPage.tsx @@ -1,4 +1,4 @@ -import { useMemo, useState, type ReactNode } from 'react' +import { useEffect, useMemo, useState, type ReactNode } from 'react' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { Activity, Bug, CheckCircle2, Plus, RefreshCw, Wrench, X } from 'lucide-react' import { @@ -39,6 +39,7 @@ export function QualityPage() { const [status, setStatus] = useState('') const [platform, setPlatform] = useState('') const [issueType, setIssueType] = useState('') + const [issuePage, setIssuePage] = useState(1) const [createOpen, setCreateOpen] = useState(false) const [selected, setSelected] = useState(null) const [draft, setDraft] = useState(initialCreate) @@ -50,6 +51,9 @@ export function QualityPage() { }), [issueType, platform, status]) const summary = useQuery({ queryKey: ['quality', 'summary'], queryFn: getQualitySummary, refetchInterval: 15_000 }) const issues = useQuery({ queryKey: ['quality', 'issues', filters], queryFn: () => listQualityIssues(filters), refetchInterval: 15_000 }) + const pageSize = 20 + const totalIssuePages = Math.max(1, Math.ceil((issues.data?.length ?? 0) / pageSize)) + const visibleIssues = (issues.data ?? []).slice((issuePage - 1) * pageSize, issuePage * pageSize) const events = useQuery({ queryKey: ['quality', 'events', selected?.issueId], queryFn: () => listQualityEvents(selected!.issueId), @@ -83,6 +87,7 @@ export function QualityPage() { { label: '7 天已解决', value: summary.data.resolved7d, hint: `${summary.data.optimizations} 项优化进行中` }, ] : [] + useEffect(() => { if (issuePage > totalIssuePages) setIssuePage(totalIssuePages) }, [issuePage, totalIssuePages]) return (
@@ -102,21 +107,21 @@ export function QualityPage() {
问题清单{issues.data?.length ?? 0} 条当前结果
- - - + + +
{issues.isError &&
质量数据暂时无法读取请检查 Java 后端与 PostgreSQL。
} {!issues.isError && !issues.isLoading && !issues.data?.length &&
当前筛选条件下没有问题新错误会自动聚合,也可以手工新增优化项。
} {issues.isLoading &&
正在载入问题
} - {!!issues.data?.length &&
{issues.data.map((issue) => ( + {!!issues.data?.length && <>
问题平台级别状态影响最近发生负责人
{visibleIssues.map((issue) => ( setSelected(issue)} tabIndex={0} onKeyDown={(event) => { if (event.key === 'Enter') setSelected(issue) }}> - ))}
问题平台级别状态影响最近发生负责人
{issue.issueType === 'BUG' ? : }{issue.issueType === 'BUG' ? 'BUG' : '优化'}{issue.title}{issue.apiPath || issue.errorCode || (issue.source === 'TELEMETRY' ? '自动聚合' : '手工创建')} {platformLabels[issue.platform]}{severityLabels[issue.severity]}{statusLabels[issue.status]} {issue.occurrenceCount}{issue.affectedUsers} 位用户{formatDate(issue.lastSeenAt || issue.updatedAt)}{issue.assignee || '未分配'}
} + ))}
共 {issues.data.length} 条,第 {issuePage} / {totalIssuePages} 页
} {createOpen && setCreateOpen(false)}> diff --git a/frontend/admin/src/styles/global.css b/frontend/admin/src/styles/global.css index d25b9788..873dc267 100644 --- a/frontend/admin/src/styles/global.css +++ b/frontend/admin/src/styles/global.css @@ -67,8 +67,8 @@ input:disabled { @media (max-width: 760px) { .monitoring-metrics, .monitoring-grid { grid-template-columns: 1fr; }.monitoring-heading-actions { align-items: flex-start; }.monitoring-page .page-heading { gap: 15px; flex-direction: column; }.monitoring-actions { align-items: stretch; flex-direction: column; }.monitoring-actions .button { justify-content: center; } } /* Monitoring overview layout */ -.monitoring-page { max-width: 100%; gap: 14px; } -.monitoring-hero { display:flex; justify-content:space-between; gap:24px; align-items:flex-start; padding: 2px 2px 4px; } +.monitoring-page { width:100%; max-width:1500px; margin:0 auto; gap:20px; } +.monitoring-hero { display:flex; justify-content:space-between; gap:24px; align-items:flex-start; padding:2px; } .monitoring-hero h1 { display:flex; align-items:center; gap:8px; margin:7px 0 5px; font-size:30px; letter-spacing:-.02em; } .monitoring-hero p:not(.eyebrow) { margin:0; color:var(--muted); font-size:13px; } .hero-check { color:#17934e; } @@ -76,17 +76,41 @@ input:disabled { .monitoring-hero-actions .button { min-height:34px; } .button--primary { border-color:#138b4c; color:#117643; background:#f0fbf4; } .monitoring-metrics--six { display:grid; grid-template-columns:repeat(6,minmax(0,1fr)); align-items:stretch; gap:12px; } -.monitoring-metric { display:flex; align-items:flex-start; gap:12px; min-width:0; min-height:112px; height:100%; padding:20px 18px; border-radius:12px; box-shadow:0 1px 3px rgba(30,30,25,.05); } -.monitoring-metric > div { min-width:0; } -.monitoring-metric-icon { display:flex; align-items:center; justify-content:center; width:38px; height:38px; flex:0 0 38px; margin-top:1px; border-radius:11px; color:#3171d5; background:#edf4ff; }.monitoring-metric-icon.warning { color:#e78709; background:#fff5e5; }.monitoring-metric-icon.danger { color:#e34c55; background:#fff0f0; }.monitoring-metric-icon.ok { color:#16964e; background:#eaf8ef; } -.monitoring-metric > div > span { font-size:11px; color:var(--muted-strong); }.monitoring-metric strong { display:block; margin-top:6px; font-size:22px; line-height:1.1; }.monitoring-metric strong.ok { color:#14934b; }.monitoring-metric strong.warning { color:#e88800; }.monitoring-metric strong.danger { color:#dd3b43; }.monitoring-metric small { display:block; margin-top:8px; color:var(--faint); font-size:10px; } -.monitoring-main-grid { display:grid; grid-template-columns:minmax(0,1.15fr) minmax(390px,.85fr); align-items:stretch; gap:14px; }.trend-panel,.problem-summary { min-height:300px; height:100%; }.trend-panel { display:flex; flex-direction:column; }.monitoring-panel { min-width:0; padding:17px 19px; border-radius:12px; overflow:hidden; }.monitoring-panel .panel-heading { margin-bottom:9px; }.monitoring-panel .panel-heading h2 { font-size:16px; margin:0; }.monitoring-panel .panel-heading span { color:var(--muted); font-size:11px; }.monitoring-panel .panel-heading select { border:1px solid var(--line); border-radius:7px; padding:7px 10px; color:var(--muted-strong); background:#fff; font-size:11px; } -.trend-legend { display:flex; flex:0 0 auto; gap:22px; justify-content:center; padding-top:2px; font-size:10px; color:var(--muted); }.trend-legend span::before { content:""; display:inline-block; width:14px; height:2px; margin:0 5px 3px 0; background:currentColor; }.trend-legend--green { color:#15954f; }.trend-legend--orange { color:#e98a0b; }.trend-legend--red { color:#df4e50; }.trend-chart { position:relative; flex:1 1 auto; min-height:205px; margin-top:9px; }.trend-gridlines { position:absolute; inset:12px 0 25px; display:flex; flex-direction:column; justify-content:space-between; }.trend-gridlines i { border-top:1px dashed #e8e9e6; }.trend-chart svg { position:absolute; inset:10px 0 25px; width:100%; height:calc(100% - 35px); overflow:hidden; }.trend-line { fill:none; stroke-width:2.2; stroke-linecap:round; stroke-linejoin:round; vector-effect:non-scaling-stroke; }.trend-line--green { stroke:#15954f; }.trend-line--orange { stroke:#e98a0b; }.trend-line--red { stroke:#df4e50; }.trend-axis { position:absolute; bottom:0; left:0; right:0; display:flex; justify-content:space-between; color:var(--faint); font-size:10px; } -.summary-counters { display:grid; grid-template-columns:repeat(4,1fr); gap:8px; }.summary-counters > div { min-height:78px; padding:11px; border:1px solid var(--line); border-radius:9px; }.summary-counters svg { color:#df4e50; }.summary-counters span { display:block; margin-top:4px; color:var(--muted); font-size:10px; }.summary-counters strong { display:block; margin-top:3px; font-size:20px; }.summary-list { margin:14px 0 9px; padding-left:17px; color:var(--muted-strong); font-size:11px; line-height:2; }.text-link { color:#158449; font-size:12px; font-weight:650; text-decoration:none; }.text-link span { margin:0 4px; }.problem-summary > .text-link { display:block; text-align:right; } -.monitoring-three-grid { display:grid; grid-template-columns:1fr 1fr 1.1fr; align-items:stretch; gap:14px; }.monitoring-three-grid > .monitoring-panel { min-height:340px; height:100%; }.compact-table .panel-heading a { color:var(--muted); font-size:11px; text-decoration:none; }.compact-table table { width:100%; table-layout:fixed; border-collapse:collapse; font-size:11px; }.compact-table th,.compact-table td { padding:9px 4px; border-bottom:1px solid var(--line); text-align:left; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }.compact-table th { color:var(--muted); font-weight:600; }.compact-table td svg { vertical-align:-2px; }.compact-table code { display:inline-block; max-width:100%; overflow:hidden; text-overflow:ellipsis; vertical-align:bottom; font-size:10px; }.cell-danger { color:#dd3b43; font-weight:700; }.client-tabs { display:flex; gap:6px; margin:0 0 18px; }.client-tabs button { appearance:none; padding:7px 14px; border:1px solid var(--line); border-radius:7px; font:inherit; font-size:11px; color:var(--ink); background:#fff; cursor:pointer; }.client-tabs button:hover { border-color:#a8dfbd; background:#f6fcf8; }.client-tabs button.active { border-color:#a8dfbd; color:#158449; background:#effaf3; }.client-tabs button:focus-visible { outline:2px solid #4aa9ff; outline-offset:2px; }.client-stats { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:8px; }.client-stats span { color:var(--muted); font-size:10px; }.client-stats strong { display:block; margin-top:8px; font-size:18px; }.client-overview .text-link { display:block; margin-top:19px; text-align:right; }.recent-events .panel-heading a { color:var(--muted); font-size:11px; text-decoration:none; }.recent-events table { width:100%; border-collapse:collapse; font-size:11px; }.recent-events th,.recent-events td { padding:10px 7px; border-bottom:1px solid var(--line); text-align:left; white-space:nowrap; }.recent-events th { color:var(--muted); font-weight:600; }.event-log-link { display:inline-flex; align-items:center; gap:3px; color:#158449 !important; }.severity-pill { display:inline-block; padding:3px 9px; border-radius:12px; font-size:10px; }.severity-pill--high { color:#c53d45; background:#fff0f0; }.severity-pill--medium { color:#b57400; background:#fff6e4; } -.monitoring-loading { padding:70px 0; text-align:center; color:var(--muted); } -@media (max-width:1200px) { .monitoring-metrics--six { grid-template-columns:repeat(3,1fr); }.monitoring-main-grid,.monitoring-three-grid { grid-template-columns:1fr 1fr; }.client-overview { grid-column:1 / -1; } } -@media (max-width:760px) { .monitoring-hero { flex-direction:column; }.monitoring-hero-actions { justify-content:flex-start; }.monitoring-metrics--six,.monitoring-main-grid,.monitoring-three-grid { grid-template-columns:1fr; }.client-overview { grid-column:auto; }.summary-counters { grid-template-columns:repeat(2,1fr); }.monitoring-hero h1 { font-size:25px; } } +.monitoring-metric { display:flex; align-items:flex-start; gap:12px; min-width:0; min-height:116px; height:100%; padding:20px 16px; border:1px solid var(--line); border-radius:12px; background:var(--surface); box-shadow:0 2px 8px rgba(30,30,25,.045); } +.monitoring-metric > div { min-width:0; padding-top:1px; } +.monitoring-metric-icon { display:flex !important; align-items:center; justify-content:center; width:40px; height:40px; flex:0 0 40px; margin:0; border-radius:11px; color:#3171d5 !important; background:#edf4ff; } +.monitoring-metric-icon.warning { color:#d87e08 !important; background:#fff5e5; }.monitoring-metric-icon.danger { color:#d94750 !important; background:#fff0f0; }.monitoring-metric-icon.ok { color:#168c4a !important; background:#eaf8ef; } +.monitoring-metric > div > span { font-size:11px; line-height:1.3; color:var(--muted-strong); } +.monitoring-metric strong { display:block; margin-top:6px; font-size:28px; line-height:1.1; white-space:nowrap; }.monitoring-metric strong.ok { color:#14934b; }.monitoring-metric strong.warning { color:#d98200; }.monitoring-metric strong.danger { color:#d63d46; } +.monitoring-metric small { display:block; margin-top:9px; color:var(--faint); font-size:10px; } +.monitoring-panel { min-width:0; padding:20px; border-radius:12px; overflow:hidden; box-shadow:0 2px 8px rgba(30,30,25,.035); } +.monitoring-panel .panel-heading { gap:12px; margin-bottom:16px; }.monitoring-panel .panel-heading h2 { margin:0; font-size:21px; }.monitoring-panel .panel-heading span { display:block; margin-top:4px; color:var(--muted); font-size:13px; }.monitoring-panel .panel-heading a { flex:0 0 auto; color:var(--muted); font-size:13px; text-decoration:none; } +.monitoring-main-grid { display:grid; grid-template-columns:minmax(0,1fr); align-items:stretch; gap:14px; } +.trend-panel,.governance-panel { min-height:340px; height:100%; } +.range-switch { display:flex; gap:3px; padding:3px; border:1px solid var(--line); border-radius:8px; background:var(--soft); } +.range-switch button { padding:5px 8px; border:0; border-radius:5px; color:var(--muted); background:transparent; cursor:pointer; font:inherit; font-size:10px; } +.range-switch button.active { color:var(--ink); background:#fff; box-shadow:0 1px 3px rgba(30,30,25,.1); } +.trend-cards { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:10px; } +.trend-card { min-width:0; padding:12px; border:1px solid var(--line); border-radius:9px; background:#fff; } +.trend-card header { display:flex; align-items:baseline; justify-content:space-between; gap:6px; }.trend-card header span { color:var(--muted); font-size:10px; }.trend-card header strong { font-size:15px; } +.trend-card svg { width:100%; height:185px; margin:7px 0 2px; overflow:visible; }.trend-baseline { stroke:#e7e9e6; stroke-width:.5; vector-effect:non-scaling-stroke; }.trend-series { fill:none; stroke-width:1.8; stroke-linecap:round; stroke-linejoin:round; vector-effect:non-scaling-stroke; }.trend-series--red { stroke:#dc4a52; }.trend-series--orange { stroke:#db8508; }.trend-series--green { stroke:#168e4b; }.trend-series--blue { stroke:#438ee4; } +.trend-dot { vector-effect:non-scaling-stroke; stroke:#fff; stroke-width:1; }.trend-dot--red { fill:#dc4a52; }.trend-dot--orange { fill:#db8508; }.trend-dot--green { fill:#168e4b; }.trend-dot--blue { fill:#438ee4; } +.trend-card footer { display:flex; justify-content:space-between; gap:6px; color:var(--faint); font-size:8px; }.trend-card footer span:nth-child(2) { text-align:center; }.trend-empty { display:grid; place-items:center; height:185px; margin:7px 0 2px; color:var(--faint); font-size:11px; background:#fafbfa; }.trend-empty--large { height:255px; gap:7px; border:1px dashed var(--line); border-radius:8px; }.trend-empty--large strong { color:var(--muted-strong); font-size:16px; }.trend-empty--large span { font-size:13px; }.trend-chart-wrap { min-height:270px; padding:4px 2px 0; }.trend-legend { display:flex; gap:22px; margin-bottom:8px; color:var(--muted-strong); font-size:13px; }.trend-legend span { display:inline-flex; align-items:center; gap:6px; }.trend-legend-dot { width:8px; height:8px; border-radius:50%; }.trend-legend-dot--red { background:#dc4a52; }.trend-legend-dot--orange { background:#db8508; }.trend-legend-dot--green { background:#168e4b; }.trend-legend-dot--blue { background:#438ee4; }.trend-chart { width:100%; height:245px; overflow:visible; }.trend-gridline { stroke:#edf0ed; stroke-width:.6; vector-effect:non-scaling-stroke; }.trend-axis { display:flex; justify-content:space-between; color:var(--faint); font-size:11px; } +.governance-panel { display:flex; flex-direction:column; }.governance-counters { display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); gap:8px; }.governance-counters > div { min-width:0; padding:11px 9px; border:1px solid var(--line); border-radius:8px; }.governance-counters span { display:block; min-height:26px; color:var(--muted); font-size:9px; }.governance-counters strong { display:block; font-size:19px; } +.governance-comparisons { display:grid; gap:4px; margin-top:12px; }.governance-comparison { display:grid; grid-template-columns:minmax(110px,1fr) auto 70px; align-items:center; gap:8px; padding:7px 0; border-bottom:1px solid var(--line); font-size:10px; }.governance-comparison > span { color:var(--muted-strong); }.governance-comparison div { display:flex; gap:7px; align-items:center; }.governance-comparison div span { color:var(--faint); }.governance-comparison em { justify-self:end; font-style:normal; font-weight:650; }.governance-comparison em.positive { color:#168c4a; }.governance-comparison em.negative { color:#d63d46; }.governance-comparison em.neutral { color:var(--faint); }.governance-panel > .text-link { margin-top:auto; padding-top:10px; text-align:right; } +.text-link { color:#158449 !important; font-size:11px; font-weight:650; text-decoration:none; }.text-link span { margin:0 4px; } +.monitoring-detail-grid { display:grid; grid-template-columns:minmax(0,1fr) minmax(0,1fr); align-items:stretch; gap:14px; }.monitoring-detail-grid > .monitoring-panel { min-height:350px; height:100%; }.monitoring-detail-grid .performance-panel { grid-column:1 / -1; grid-row:1; min-height:300px; }.monitoring-detail-grid .error-endpoint-panel { grid-column:1; grid-row:2; }.monitoring-detail-grid .client-overview { grid-column:2; grid-row:2; } +.monitoring-table-panel table { min-width:620px; }.performance-panel table { min-width:560px; }.monitoring-table-panel th,.monitoring-table-panel td { padding:13px 9px; font-size:14px; }.monitoring-table-panel code { display:inline-block; max-width:260px; overflow:hidden; text-overflow:ellipsis; vertical-align:bottom; font-size:14px; }.monitoring-table-panel td:first-child a { display:inline-block; max-width:100%; white-space:nowrap; }.monitoring-table-panel td svg { vertical-align:-2px; }.error-count { color:#d63d46; font-weight:700; }.cell-danger { color:#d63d46; font-weight:650; }.cell-positive { color:#168c4a; font-weight:650; } +.performance-status { display:inline-block; padding:3px 7px; border-radius:10px; color:var(--muted-strong); background:#f2f3f1; font-size:9px; }.performance-status--optimized { color:#168c4a; background:#ecf8f0; }.performance-status--observing { color:#a56700; background:#fff5df; }.performance-status--regressed { color:#c93d46; background:#fff0f0; } +.performance-panel { display:flex; flex-direction:column; }.slow-link { display:block; margin-top:auto; padding-top:12px; text-align:right; } +.client-tabs { display:flex; gap:6px; margin:0 0 20px; }.client-tabs button { appearance:none; padding:7px 14px; border:1px solid var(--line); border-radius:7px; font:inherit; font-size:11px; color:var(--ink); background:#fff; cursor:pointer; }.client-tabs button:hover { border-color:#a8dfbd; background:#f6fcf8; }.client-tabs button.active { border-color:#a8dfbd; color:#158449; background:#effaf3; }.client-tabs button:focus-visible { outline:2px solid #4aa9ff; outline-offset:2px; } +.client-stats { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:8px; }.client-stats span { display:block; min-height:28px; color:var(--muted); font-size:13px; }.client-stats strong { display:block; margin-top:7px; font-size:24px; white-space:nowrap; }.client-overview .text-link { display:block; margin-top:24px; text-align:right; } +.recent-events table { min-width:760px; }.recent-events th,.recent-events td { padding:14px 9px; font-size:14px; }.recent-events td:nth-child(3),.recent-events td:nth-child(4) { max-width:300px; overflow:hidden; text-overflow:ellipsis; }.event-log-link { display:inline-flex; align-items:center; gap:3px; color:#158449 !important; }.severity-pill { display:inline-block; padding:4px 10px; border-radius:12px; font-size:12px; }.severity-pill--high { color:#c53d45; background:#fff0f0; }.severity-pill--medium { color:#a66b00; background:#fff6e4; } +.monitoring-no-data { display:grid; place-items:center; min-height:160px; margin:0; color:var(--muted); font-size:11px; text-align:center; }.monitoring-loading { padding:70px 0; text-align:center; color:var(--muted); } +@media (max-width:1320px) { .monitoring-detail-grid { grid-template-columns:1fr 1fr; }.client-overview { grid-column:1 / -1; min-height:240px !important; }.monitoring-main-grid { grid-template-columns:1fr; } } +@media (max-width:1100px) { .monitoring-metrics--six { grid-template-columns:repeat(3,1fr); } } +@media (max-width:860px) { .monitoring-metrics--six { grid-template-columns:repeat(2,1fr); }.trend-cards,.monitoring-detail-grid { grid-template-columns:1fr; }.monitoring-detail-grid .performance-panel,.monitoring-detail-grid .error-endpoint-panel,.monitoring-detail-grid .client-overview { grid-column:1; grid-row:auto; }.client-overview { grid-column:auto; }.trend-card svg,.trend-empty { height:130px; } } +@media (max-width:620px) { .monitoring-hero { flex-direction:column; }.monitoring-hero-actions { justify-content:flex-start; }.monitoring-metrics--six { grid-template-columns:1fr; }.monitoring-hero h1 { font-size:25px; }.monitoring-panel .panel-heading { align-items:flex-start; flex-direction:column; }.range-switch { width:100%; }.range-switch button { flex:1; }.governance-counters { grid-template-columns:repeat(2,1fr); }.governance-comparison { grid-template-columns:1fr auto; }.governance-comparison em { grid-column:2; }.client-stats { grid-template-columns:1fr; }.client-stats span { min-height:0; } } .eyebrow, .workspace-kicker { @@ -2597,6 +2621,34 @@ input:disabled { .overview-card--orange .mini-trend i { background: #ef8843; } .overview-card--cyan .mini-trend i { background: #38a0bd; } +/* System overview dashboard */ +.overview-page { max-width: 1320px; margin-inline: auto; } +.overview-hero { display:flex; justify-content:space-between; gap:24px; align-items:flex-start; } +.overview-hero h1 { display:flex; align-items:center; gap:10px; margin:4px 0 8px; font-size:clamp(2rem,3vw,2.7rem); } +.overview-hero h1 svg { color:#18a56f; } +.overview-hero p:not(.eyebrow) { color:var(--muted-strong); font-size:15px; } +.overview-actions { display:flex; align-items:center; gap:10px; flex-wrap:wrap; justify-content:flex-end; color:var(--muted); font-size:13px; } +.overview-cards { display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); gap:14px; } +.overview-card { position:relative; display:grid; grid-template-columns:42px 1fr; column-gap:12px; min-height:126px; padding:20px; border:1px solid #e2e7ee; border-radius:12px; background:var(--surface); text-decoration:none; box-shadow:0 2px 10px rgba(26,39,64,.04); } +.overview-card:hover { border-color:#b9c8dc; transform:translateY(-1px); } +.overview-card-icon { grid-row:1 / span 3; display:grid; width:42px; height:42px; place-items:center; border-radius:50%; background:#edf4ff; color:#3478f6; } +.overview-card-icon svg { width:23px; height:23px; } +.overview-card-label { color:var(--muted-strong); font-size:14px; font-weight:650; } +.overview-card > strong { margin:5px 0 2px; color:var(--ink); font-size:30px; line-height:1.1; font-variant-numeric:tabular-nums; } +.overview-card > small { color:var(--muted); font-size:13px; } +.overview-card--green .overview-card-icon { background:#eaf8f1; color:#159a62; }.overview-card--orange .overview-card-icon { background:#fff2e9; color:#e97427; }.overview-card--red .overview-card-icon { background:#fff0f0; color:#e5484d; } +.overview-grid { display:grid; gap:14px; }.overview-grid--main { grid-template-columns:1.08fr 1fr; }.overview-grid--secondary { grid-template-columns:1fr 1.2fr; } +.overview-panel { min-width:0; padding:20px; border-radius:12px; }.overview-panel-heading { display:flex; justify-content:space-between; align-items:center; gap:12px; margin-bottom:16px; }.overview-panel-heading h2 { margin:0; font-size:21px; }.overview-panel-heading a { color:#6046d9; font-size:13px; text-decoration:none; white-space:nowrap; } +.overview-trend { min-height:225px; }.overview-legend { display:flex; gap:22px; color:var(--muted-strong); font-size:13px; }.overview-legend span { display:flex; align-items:center; gap:6px; }.overview-legend i { width:8px; height:8px; border-radius:50%; background:#ef4444; }.overview-legend i.blue { background:#2674e8; }.overview-legend i.orange { background:#ed8b22; }.overview-legend i.green { background:#168e4b; }.overview-trend svg { width:100%; height:170px; margin-top:10px; overflow:visible; }.overview-trend line { stroke:#e6ebf2; stroke-dasharray:2 2; }.overview-trend polyline { fill:none; stroke-width:1.8; vector-effect:non-scaling-stroke; }.overview-trend polyline.red { stroke:#ef4444; }.overview-trend polyline.blue { stroke:#2674e8; }.overview-trend polyline.orange { stroke:#ed8b22; }.overview-trend polyline.green { stroke:#168e4b; }.overview-axis { display:flex; justify-content:space-between; color:var(--muted); font-size:12px; }.overview-empty { display:grid; min-height:190px; place-items:center; color:var(--muted); font-size:14px; } +.overview-governance-cards { display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); gap:10px; }.overview-stat { padding:14px 12px; border:1px solid #e6eaf0; border-radius:9px; }.overview-stat span { display:block; color:var(--muted); font-size:13px; }.overview-stat strong { display:block; margin-top:8px; font-size:25px; }.overview-stat--orange strong { color:#e57918; }.overview-stat--red strong { color:#df3f47; }.overview-stat--violet strong { color:#7048d8; }.overview-stat--green strong { color:#159a62; }.overview-summary-line { margin:22px 0 0; padding-top:16px; border-top:1px solid #edf0f4; color:var(--muted-strong); font-size:14px; } +.overview-usage { display:grid; grid-template-columns:repeat(3,1fr); gap:0; }.overview-usage > div { padding:4px 18px; border-right:1px solid #e7ebf0; }.overview-usage > div:first-child { padding-left:0; }.overview-usage > div:last-child { border:0; }.overview-usage span { color:var(--muted); font-size:13px; }.overview-usage strong { display:block; margin-top:8px; font-size:23px; }.overview-usage small { font-size:12px; font-weight:500; }.overview-usage-foot { margin-top:22px; color:var(--muted); font-size:13px; }.overview-provider { display:grid; gap:8px; }.overview-provider-row { display:flex; justify-content:space-between; align-items:center; padding:12px 4px; border-bottom:1px solid #edf0f4; font-size:14px; }.overview-provider-row span { display:flex; align-items:center; gap:8px; }.overview-provider-row svg { color:#3478f6; }.overview-provider-row b { color:#159a62; font-size:13px; font-weight:650; } +.overview-provider-row b.is-disabled { color:var(--muted); }.overview-provider-foot { margin-top:14px; color:var(--muted); font-size:13px; } +.list-pagination { display:flex; justify-content:flex-end; align-items:center; gap:10px; padding:12px 0 0; color:var(--muted); font-size:13px; }.list-pagination button { padding:6px 12px; border:1px solid #dfe3e9; border-radius:6px; background:#fff; color:var(--ink); cursor:pointer; }.list-pagination button:disabled { opacity:.45; cursor:default; } +.overview-user-list { margin-top:14px; border-top:1px solid #edf0f4; }.overview-user-list::before { content:'用户         请求   Token   金额'; display:block; padding:8px 0; color:var(--muted); border-bottom:1px solid #edf0f4; font-size:12px; white-space:pre; }.overview-user-list > div { display:grid; grid-template-columns:minmax(0,1.5fr) repeat(3,auto); gap:12px; align-items:center; padding:9px 0; border-bottom:1px solid #edf0f4; font-size:12px; }.overview-user-list > div span { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }.overview-user-list > div b { color:var(--muted-strong); font-weight:600; white-space:nowrap; } +.overview-grid--business { grid-template-columns:1.25fr 1fr; }.overview-funnel { display:grid; grid-template-columns:repeat(3,1fr); gap:12px; }.overview-funnel-item { padding:18px 16px; border:1px solid #e6eaf0; border-radius:9px; }.overview-funnel-item span { color:var(--muted); font-size:13px; }.overview-funnel-item strong { display:block; margin-top:8px; font-size:26px; }.overview-funnel-item i { display:block; height:5px; margin-top:16px; border-radius:4px; background:#2878ed; }.overview-funnel-item:nth-child(2) i { width:75%; background:#5a8ff0; }.overview-funnel-item:nth-child(3) i { width:50%; background:#87aaf4; }.overview-watch { display:grid; gap:3px; }.overview-watch a { display:flex; align-items:flex-start; gap:10px; padding:11px 4px; color:var(--ink); text-decoration:none; border-bottom:1px solid #edf0f4; font-size:14px; }.overview-watch a svg { flex:0 0 auto; color:#e57918; }.overview-watch small { display:block; margin-top:4px; color:var(--muted); font-size:12px; } +@media (max-width:900px) { .overview-hero { flex-direction:column; }.overview-actions { justify-content:flex-start; }.overview-cards,.overview-grid--main,.overview-grid--secondary,.overview-grid--business { grid-template-columns:1fr 1fr; } } +@media (max-width:620px) { .overview-cards,.overview-grid--main,.overview-grid--secondary { grid-template-columns:1fr; }.overview-governance-cards { grid-template-columns:1fr 1fr; }.overview-card { min-height:112px; padding:16px; }.overview-card > strong { font-size:26px; } } + .system-block { min-width: 0; overflow: hidden; From 68452f55c3f4fcc7eb56a52ebbc2dfbfed1b673e Mon Sep 17 00:00:00 2001 From: fj-sunny Date: Thu, 27 Aug 2026 16:48:28 +0800 Subject: [PATCH 7/7] fix(admin): repair credential updates and coverage --- .../aliyun/AliyunInferenceLogParser.java | 28 +--- .../aliyun/AliyunInferenceLogParserTest.java | 132 ++++++++++++++++++ .../system/SystemManagementPage.test.tsx | 25 ++++ .../features/system/SystemManagementPage.tsx | 6 +- 4 files changed, 162 insertions(+), 29 deletions(-) diff --git a/backend/unispeaking-server/src/main/java/com/unispeaking/admin/usage/adapters/aliyun/AliyunInferenceLogParser.java b/backend/unispeaking-server/src/main/java/com/unispeaking/admin/usage/adapters/aliyun/AliyunInferenceLogParser.java index 6922bc93..8afae35f 100644 --- a/backend/unispeaking-server/src/main/java/com/unispeaking/admin/usage/adapters/aliyun/AliyunInferenceLogParser.java +++ b/backend/unispeaking-server/src/main/java/com/unispeaking/admin/usage/adapters/aliyun/AliyunInferenceLogParser.java @@ -102,12 +102,6 @@ private static void validate(OfficialUsageRecord record) { if (record.taskUuid() != null) { requireStableId("task_uuid", record.taskUuid()); } - if (record.startedAtEpochMs() < 0) { - throw new OfficialUsageSchemaException("start_unix_timestamp 不能为负数"); - } - if (record.durationMs() < 0) { - throw new OfficialUsageSchemaException("duration 不能为负数"); - } if (!"ws".equals(record.protocol()) && !"webrtc".equals(record.protocol()) && !"http".equals(record.protocol())) { throw new OfficialUsageSchemaException("protocol 必须为 ws、webrtc 或 http"); @@ -148,17 +142,14 @@ private static String requiredText(JsonNode parent, String... fields) { private static String optionalText(JsonNode parent, String... fields) { JsonNode node = firstNode(parent, fields); - if (node == null) { - return null; - } String value = node.asText(); - return value == null || value.isBlank() ? null : value; + return value.isBlank() ? null : value; } private static long requiredLong(JsonNode parent, String... fields) { JsonNode node = firstNode(parent, fields); String value = node.asText(); - if (value == null || value.isBlank()) { + if (value.isBlank()) { throw new OfficialUsageSchemaException(fields[0] + " 缺失"); } try { @@ -173,9 +164,6 @@ private static long requiredLong(JsonNode parent, String... fields) { } private static long optionalLong(JsonNode parent, String... fields) { - if (parent == null) { - return 0; - } JsonNode node = firstNode(parent, fields); if (node == null || node.isMissingNode() || node.isNull() || node.asText().isBlank()) { return 0; @@ -326,18 +314,6 @@ private ObjectNode merge(JsonNode envelope, JsonNode... nested) { return merged; } - private JsonNode objectNode(JsonNode node, String field) { - try { - JsonNode resolved = node.isTextual() ? mapper.readTree(node.asText()) : node; - if (!resolved.isObject()) { - throw new OfficialUsageSchemaException(field + " 必须为 JSON 对象"); - } - return resolved; - } catch (JacksonException exception) { - throw new OfficialUsageSchemaException(field + " 不是有效 JSON 对象", exception); - } - } - private void validateNestedObjectShape(JsonNode root, String field) { JsonNode node = root.path(field); if (node.isMissingNode() || node.isNull() || node.isObject()) { diff --git a/backend/unispeaking-server/src/test/java/com/unispeaking/admin/usage/adapters/aliyun/AliyunInferenceLogParserTest.java b/backend/unispeaking-server/src/test/java/com/unispeaking/admin/usage/adapters/aliyun/AliyunInferenceLogParserTest.java index 88f98d57..39ea5766 100644 --- a/backend/unispeaking-server/src/test/java/com/unispeaking/admin/usage/adapters/aliyun/AliyunInferenceLogParserTest.java +++ b/backend/unispeaking-server/src/test/java/com/unispeaking/admin/usage/adapters/aliyun/AliyunInferenceLogParserTest.java @@ -70,6 +70,11 @@ void rejectsRecordsWhoseTokenDimensionsDoNotAddUp() { .replace("\"total_tokens\": 13289", "\"total_tokens\": 13290"))) .isInstanceOf(OfficialUsageSchemaException.class) .hasMessageContaining("total_tokens"); + + assertThatThrownBy(() -> parser.parse(validJson() + .replace("\"output_tokens\": 351", "\"output_tokens\": 352"))) + .isInstanceOf(OfficialUsageSchemaException.class) + .hasMessageContaining("output_tokens"); } @Test @@ -251,6 +256,133 @@ void rejectsNegativeTimestampAndMissingRealtimeTaskUuid() { .hasMessageContaining("task_uuid"); } + @Test + void describesNestedSlsShapesWhenStatusCodeIsMissing() { + assertThatThrownBy(() -> parser.parse(""" + { + "request":{"body":{"request_id":"nested-request"}}, + "response":"{\\\"body\\\":{\\\"model\\\":\\\"qwen\\\"}}", + "body":[{"value":1}], + "messages":"{\\\"role\\\":\\\"user\\\"}", + "parameters":"not-json", + "choices":[{"index":0}], + "usage":{} + } + """)) + .isInstanceOf(OfficialUsageSchemaException.class) + .hasMessageContaining("status_code 缺失") + .hasMessageContaining("request:OBJECT") + .hasMessageContaining("messages:text") + .hasMessageContaining("parameters:text") + .hasMessageContaining("invalid-json") + .hasMessageContaining("choices:ARRAY[1]") + .hasMessageContaining("usage:OBJECT"); + + assertThatThrownBy(() -> parser.parse(""" + {"response":"not-json","body":{"field":1},"messages":["text"],"parameters":"[]"} + """)) + .isInstanceOf(OfficialUsageSchemaException.class) + .hasMessageContaining("response:text") + .hasMessageContaining("body:OBJECT") + .hasMessageContaining("messages:ARRAY[1]->STRING") + .hasMessageContaining("parameters:text(2)->ARRAY"); + } + + @Test + void keepsFlatPayloadWhenSlsMessageCannotBeUnwrapped() { + for (String message : new String[] {"", "not-json", "[]"}) { + assertThatThrownBy(() -> parser.parse("{\"message\":\"" + message + "\"}")) + .isInstanceOf(OfficialUsageSchemaException.class) + .hasMessageContaining("status_code 缺失"); + } + } + + @Test + void acceptsTimestampProtocolAndFieldAliases() { + var iso = parser.parse(aliasJson("2026-08-19T03:53:14.285Z", "web_socket")); + assertThat(iso.startedAtEpochMs()).isEqualTo(1787111594285L); + assertThat(iso.protocol()).isEqualTo("ws"); + assertThat(iso.requestId()).isEqualTo("alias-request-1"); + assertThat(iso.taskUuid()).isEqualTo("alias-task-1"); + assertThat(iso.apiKeyId()).isEqualTo("alias-user"); + + var local = parser.parse(aliasJson("2026-08-19 03:53:14.285", "web-rtc")); + assertThat(local.startedAtEpochMs()).isEqualTo(1787111594285L); + assertThat(local.protocol()).isEqualTo("webrtc"); + } + + @Test + void preservesAvailableTokenUsageForFailedRequests() { + var record = parser.parse(""" + { + "start_unix_timestamp":"1787106742", + "http_status":"429", + "usage":{"input_tokens":12,"output_tokens":3,"total_tokens":15}, + "transport":"https", + "workspace":"workspace-alias", + "model_name":"qwen-alias", + "requestId":"failed-request-1" + } + """); + + assertThat(record.startedAtEpochMs()).isEqualTo(1787106742000L); + assertThat(record.protocol()).isEqualTo("http"); + assertThat(record.apiKeyId()).isEqualTo("unknown"); + assertThat(record.usage().responseCount()).isEqualTo(1); + assertThat(record.usage().totalTokens()).isEqualTo(15); + } + + @Test + void rejectsMalformedNestedUsageAndInvalidTimestamp() { + assertThatThrownBy(() -> parser.parse("{\"usage\":\"[]\"}")) + .isInstanceOf(OfficialUsageSchemaException.class) + .hasMessageContaining("usage"); + assertThatThrownBy(() -> parser.parse("{\"extras\":\"{broken\"}")) + .isInstanceOf(OfficialUsageSchemaException.class) + .hasMessageContaining("extras"); + assertThatThrownBy(() -> parser.parse(validJson() + .replace("1784534676105", "not-a-timestamp"))) + .isInstanceOf(OfficialUsageSchemaException.class) + .hasMessageContaining("不是有效时间"); + + assertThatThrownBy(() -> parser.parse(validJson() + .replace("3131bedf-7956-91c3-90fe-27cbcb3dfbcf", "bad request id"))) + .isInstanceOf(OfficialUsageSchemaException.class) + .hasMessageContaining("request_id"); + assertThatThrownBy(() -> parser.parse(validJson() + .replace("\"start_unix_timestamp\": \"1784534676105\",", ""))) + .isInstanceOf(OfficialUsageSchemaException.class) + .hasMessageContaining("start_unix_timestamp 缺失"); + assertThatThrownBy(() -> parser.parse(validJson() + .replace("\"extras\": {\"protocol\": \"ws\"},", ""))) + .isInstanceOf(OfficialUsageSchemaException.class) + .hasMessageContaining("protocol 缺失"); + assertThatThrownBy(() -> parser.parse(validJson() + .replace("\"total_tokens\": 13289", "\"total_tokens\": \"invalid\""))) + .isInstanceOf(OfficialUsageSchemaException.class) + .hasMessageContaining("不是有效整数"); + assertThatThrownBy(() -> parser.parse(validJson() + .replace("\"total_tokens\": 13289", "\"total_tokens\": -1"))) + .isInstanceOf(OfficialUsageSchemaException.class) + .hasMessageContaining("不能为负数"); + } + + private static String aliasJson(String timestamp, String protocol) { + return """ + { + "start_time":"%s", + "status":"200", + "usage":{"input_tokens":0,"output_tokens":0,"total_tokens":0}, + "connection_type":"%s", + "workspace":"workspace-alias", + "model_name":"qwen-alias", + "uid":"alias-user", + "conversation_id":"alias-task-1", + "requestId":"alias-request-1" + } + """.formatted(timestamp, protocol); + } + private static String validJson() { return """ { diff --git a/frontend/admin/src/features/system/SystemManagementPage.test.tsx b/frontend/admin/src/features/system/SystemManagementPage.test.tsx index 650011f4..4e0d4b58 100644 --- a/frontend/admin/src/features/system/SystemManagementPage.test.tsx +++ b/frontend/admin/src/features/system/SystemManagementPage.test.tsx @@ -109,6 +109,31 @@ describe('SystemManagementPage', () => { expect((await screen.findAllByText('sha256:123456789abc')).length).toBeGreaterThan(0) }) + it('submits all required iFlytek credential fields with Enter', async () => { + const provider = { ...configuration.providers[0], providerId: 'iflytek', adapterType: 'iflytek', displayName: '科大讯飞' } + vi.mocked(getAiConfiguration).mockResolvedValue(configurationFor(provider)) + vi.mocked(getCredentialStatus).mockResolvedValue({ + configured: false, fingerprint: null, writable: true, + fields: [ + { key: 'appId', label: 'App ID', required: true, secret: false, description: '应用 ID', configured: false, fingerprint: null }, + { key: 'apiKey', label: 'API Key', required: true, secret: true, description: 'API Key', configured: false, fingerprint: null }, + { key: 'apiSecret', label: 'API Secret', required: true, secret: true, description: 'API Secret', configured: false, fingerprint: null }, + ], + }) + vi.mocked(replaceCredential).mockResolvedValue({ configured: true, fingerprint: 'sha256:updated', writable: true, fields: [] }) + const user = userEvent.setup() + + renderPage() + await user.click(await screen.findByRole('button', { name: '管理密钥' })) + await user.type(await screen.findByLabelText('App ID'), 'new-app-id') + await user.type(screen.getByLabelText('API Key'), 'new-api-key') + await user.type(screen.getByLabelText('API Secret'), 'new-api-secret{Enter}') + + await waitFor(() => expect(replaceCredential).toHaveBeenCalledWith('iflytek', { + appId: 'new-app-id', apiKey: 'new-api-key', apiSecret: 'new-api-secret', + })) + }) + it('shows the credential fields required by Aliyun CosyVoice', async () => { const provider = { ...configuration.providers[0], providerId: 'aliyun', adapterType: 'aliyun', displayName: '阿里云语音' } vi.mocked(getAiConfiguration).mockResolvedValue(configurationFor(provider)) diff --git a/frontend/admin/src/features/system/SystemManagementPage.tsx b/frontend/admin/src/features/system/SystemManagementPage.tsx index 1ca44724..29f7c936 100644 --- a/frontend/admin/src/features/system/SystemManagementPage.tsx +++ b/frontend/admin/src/features/system/SystemManagementPage.tsx @@ -117,7 +117,7 @@ function CredentialDialog({ provider, onClose }: { provider: ProviderView; onClo mutation.reset() setValues((current) => ({ ...current, [key]: value })) } - return
{ if (event.target === event.currentTarget) onClose() }}>
+ return
{ if (event.target === event.currentTarget) onClose() }}>
{ event.preventDefault(); if (!mutation.isPending && status.data?.writable && !missingRequired && Object.keys(changedValues).length > 0) mutation.mutate() }}>

PROVIDER CONFIGURATION

{provider.displayName} 凭据配置

所有字段加密保存,页面不回显原值{status.data?.fingerprint || '尚未配置'}

{status.isLoading &&

正在读取配置项…

} @@ -138,8 +138,8 @@ function CredentialDialog({ provider, onClose }: { provider: ProviderView; onClo {mutation.isSuccess &&

凭据配置已更新。

} {mutation.isError &&

{mutation.error.message}

}
-
-
+
+ } function ModelTable({ models, onChanged }: { models: ModelView[]; onChanged: () => Promise }) {