mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 09:20:22 +08:00
feat(data): 废弃 usage 表 HTTP/结算列,迁移至 settlement_snapshots 与 http_audits
- 新增迁移 20260413030000:标记 billing_status、finalized_at、request_headers 等列为 DEPRECATED - 更新 baseline_v2.sql 同步废弃注释,BASELINE_V2_CUTOFF_VERSION 升至 20260413030000 - usage/sql.rs:inline body 阈值归零,强制所有 body 走 blob 存储;upsert 时清空 legacy header/output_price 列 - 查询层优先读 usage_settlement_snapshots 的 billing_status、finalized_at、output_price_per_1m - runtime.rs:stale usage 处理同步写入 usage_settlement_snapshots;SELECT FOR UPDATE 改为 FOR UPDATE OF usage - 前端:PerformanceAnalysis 页面重构为实时面板,新增 prometheus 工具函数与 monitoring API
This commit is contained in:
37
frontend/src/utils/__tests__/prometheus.spec.ts
Normal file
37
frontend/src/utils/__tests__/prometheus.spec.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
findMetricValueNumber,
|
||||
parsePrometheusSamples,
|
||||
sumMetricValues,
|
||||
} from '../prometheus'
|
||||
|
||||
describe('parsePrometheusSamples', () => {
|
||||
it('parses labeled samples and finds gate metrics by suffix name', () => {
|
||||
const samples = parsePrometheusSamples(`
|
||||
# HELP aether_gateway_concurrency_in_flight Current number of in-flight operations.
|
||||
# TYPE aether_gateway_concurrency_in_flight gauge
|
||||
aether_gateway_concurrency_in_flight{gate="gateway_requests"} 7
|
||||
aether_gateway_concurrency_rejected_total{gate="gateway_requests"} 12
|
||||
`)
|
||||
|
||||
expect(
|
||||
findMetricValueNumber(samples, 'concurrency_in_flight', {
|
||||
gate: 'gateway_requests',
|
||||
})
|
||||
).toBe(7)
|
||||
expect(
|
||||
findMetricValueNumber(samples, 'concurrency_rejected_total', {
|
||||
gate: 'gateway_requests',
|
||||
})
|
||||
).toBe(12)
|
||||
})
|
||||
|
||||
it('sums fallback counters across labeled samples', () => {
|
||||
const samples = parsePrometheusSamples(`
|
||||
decision_remote_total{route_kind="chat",reason="local_decision_miss"} 2
|
||||
decision_remote_total{route_kind="responses",reason="remote_decision_miss"} 3
|
||||
`)
|
||||
|
||||
expect(sumMetricValues(samples, 'decision_remote_total')).toBe(5)
|
||||
})
|
||||
})
|
||||
106
frontend/src/utils/prometheus.ts
Normal file
106
frontend/src/utils/prometheus.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
export interface PrometheusSample {
|
||||
name: string
|
||||
labels: Record<string, string>
|
||||
value: string
|
||||
}
|
||||
|
||||
export function parsePrometheusSamples(text: string): PrometheusSample[] {
|
||||
return text
|
||||
.split(/\r?\n/)
|
||||
.map(line => line.trim())
|
||||
.filter(line => line.length > 0 && !line.startsWith('#'))
|
||||
.map(parsePrometheusLine)
|
||||
.filter((sample): sample is PrometheusSample => sample !== null)
|
||||
}
|
||||
|
||||
export function findMetricValueNumber(
|
||||
samples: PrometheusSample[],
|
||||
metricName: string,
|
||||
labels: Record<string, string> = {}
|
||||
): number | null {
|
||||
const sample = samples.find(item =>
|
||||
metricNameMatches(item.name, metricName) && labelsMatch(item.labels, labels)
|
||||
)
|
||||
|
||||
if (!sample) {
|
||||
return null
|
||||
}
|
||||
|
||||
const value = Number(sample.value)
|
||||
return Number.isFinite(value) ? value : null
|
||||
}
|
||||
|
||||
export function sumMetricValues(
|
||||
samples: PrometheusSample[],
|
||||
metricName: string
|
||||
): number {
|
||||
return samples.reduce((total, sample) => {
|
||||
if (!metricNameMatches(sample.name, metricName)) {
|
||||
return total
|
||||
}
|
||||
|
||||
const value = Number(sample.value)
|
||||
return Number.isFinite(value) ? total + value : total
|
||||
}, 0)
|
||||
}
|
||||
|
||||
function metricNameMatches(actual: string, expected: string): boolean {
|
||||
return actual === expected || actual.split('_').pop() === expected || actual.endsWith(`_${expected}`)
|
||||
}
|
||||
|
||||
function labelsMatch(
|
||||
actual: Record<string, string>,
|
||||
expected: Record<string, string>
|
||||
): boolean {
|
||||
return Object.entries(expected).every(([key, value]) => actual[key] === value)
|
||||
}
|
||||
|
||||
function parsePrometheusLine(line: string): PrometheusSample | null {
|
||||
const separatorIndex = line.lastIndexOf(' ')
|
||||
if (separatorIndex === -1) {
|
||||
return null
|
||||
}
|
||||
|
||||
const metric = line.slice(0, separatorIndex).trim()
|
||||
const value = line.slice(separatorIndex + 1).trim()
|
||||
if (!metric || !value) {
|
||||
return null
|
||||
}
|
||||
|
||||
const labelStart = metric.indexOf('{')
|
||||
if (labelStart === -1 || !metric.endsWith('}')) {
|
||||
return {
|
||||
name: metric,
|
||||
labels: {},
|
||||
value,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
name: metric.slice(0, labelStart),
|
||||
labels: parseLabels(metric.slice(labelStart + 1, -1)),
|
||||
value,
|
||||
}
|
||||
}
|
||||
|
||||
function parseLabels(raw: string): Record<string, string> {
|
||||
const labels: Record<string, string> = {}
|
||||
const pattern = /([^=,\s]+)="((?:\\.|[^"])*)"/g
|
||||
|
||||
for (const match of raw.matchAll(pattern)) {
|
||||
const [, key, value] = match
|
||||
if (!key) {
|
||||
continue
|
||||
}
|
||||
labels[key] = unescapePrometheusLabel(value ?? '')
|
||||
}
|
||||
|
||||
return labels
|
||||
}
|
||||
|
||||
function unescapePrometheusLabel(value: string): string {
|
||||
return value
|
||||
.replace(/\\"/g, '"')
|
||||
.replace(/\\n/g, '\n')
|
||||
.replace(/\\\\/g, '\\')
|
||||
}
|
||||
Reference in New Issue
Block a user