mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 01:10:23 +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:
@@ -49,7 +49,7 @@ cp .env.example .env
|
|||||||
docker compose pull && docker compose up -d
|
docker compose pull && docker compose up -d
|
||||||
|
|
||||||
# 4. 如果后续版本包含 schema 变更,再显式执行数据库迁移
|
# 4. 如果后续版本包含 schema 变更,再显式执行数据库迁移
|
||||||
docker compose run --rm app aether-gateway --migrate
|
docker compose run --rm app --migrate
|
||||||
|
|
||||||
# 5. 升级前备份 (可选)
|
# 5. 升级前备份 (可选)
|
||||||
docker compose exec postgres pg_dump -U postgres aether | gzip > backup_$(date +%Y%m%d_%H%M%S).sql.gz
|
docker compose exec postgres pg_dump -U postgres aether | gzip > backup_$(date +%Y%m%d_%H%M%S).sql.gz
|
||||||
|
|||||||
@@ -184,16 +184,18 @@ WHERE ledgers.billing_date = $1
|
|||||||
"#;
|
"#;
|
||||||
const SELECT_STALE_PENDING_USAGE_BATCH_SQL: &str = r#"
|
const SELECT_STALE_PENDING_USAGE_BATCH_SQL: &str = r#"
|
||||||
SELECT
|
SELECT
|
||||||
id,
|
usage.id,
|
||||||
request_id,
|
usage.request_id,
|
||||||
status,
|
usage.status,
|
||||||
billing_status
|
COALESCE(usage_settlement_snapshots.billing_status, usage.billing_status) AS billing_status
|
||||||
FROM usage
|
FROM usage
|
||||||
WHERE status = ANY($1)
|
LEFT JOIN usage_settlement_snapshots
|
||||||
AND created_at < $2
|
ON usage_settlement_snapshots.request_id = usage.request_id
|
||||||
ORDER BY created_at ASC, id ASC
|
WHERE usage.status = ANY($1)
|
||||||
|
AND usage.created_at < $2
|
||||||
|
ORDER BY usage.created_at ASC, usage.id ASC
|
||||||
LIMIT $3
|
LIMIT $3
|
||||||
FOR UPDATE SKIP LOCKED
|
FOR UPDATE OF usage SKIP LOCKED
|
||||||
"#;
|
"#;
|
||||||
const SELECT_COMPLETED_PENDING_REQUEST_IDS_SQL: &str = r#"
|
const SELECT_COMPLETED_PENDING_REQUEST_IDS_SQL: &str = r#"
|
||||||
SELECT DISTINCT request_id
|
SELECT DISTINCT request_id
|
||||||
@@ -222,17 +224,35 @@ SET status = 'failed',
|
|||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
"#;
|
"#;
|
||||||
const UPDATE_FAILED_VOID_STALE_USAGE_SQL: &str = r#"
|
const UPDATE_FAILED_VOID_STALE_USAGE_SQL: &str = r#"
|
||||||
UPDATE usage
|
WITH updated_usage AS (
|
||||||
SET status = 'failed',
|
UPDATE usage
|
||||||
status_code = 504,
|
SET status = 'failed',
|
||||||
error_message = $2,
|
status_code = 504,
|
||||||
billing_status = 'void',
|
error_message = $2,
|
||||||
finalized_at = $3,
|
billing_status = 'void',
|
||||||
total_cost_usd = 0,
|
finalized_at = $3,
|
||||||
request_cost_usd = 0,
|
total_cost_usd = 0,
|
||||||
actual_total_cost_usd = 0,
|
request_cost_usd = 0,
|
||||||
actual_request_cost_usd = 0
|
actual_total_cost_usd = 0,
|
||||||
WHERE id = $1
|
actual_request_cost_usd = 0
|
||||||
|
WHERE id = $1
|
||||||
|
RETURNING request_id
|
||||||
|
)
|
||||||
|
INSERT INTO usage_settlement_snapshots (
|
||||||
|
request_id,
|
||||||
|
billing_status,
|
||||||
|
finalized_at
|
||||||
|
)
|
||||||
|
SELECT request_id, 'void', $3
|
||||||
|
FROM updated_usage
|
||||||
|
ON CONFLICT (request_id)
|
||||||
|
DO UPDATE SET
|
||||||
|
billing_status = EXCLUDED.billing_status,
|
||||||
|
finalized_at = COALESCE(
|
||||||
|
usage_settlement_snapshots.finalized_at,
|
||||||
|
EXCLUDED.finalized_at
|
||||||
|
),
|
||||||
|
updated_at = NOW()
|
||||||
"#;
|
"#;
|
||||||
const UPDATE_RECOVERED_STREAMING_CANDIDATES_SQL: &str = r#"
|
const UPDATE_RECOVERED_STREAMING_CANDIDATES_SQL: &str = r#"
|
||||||
UPDATE request_candidates
|
UPDATE request_candidates
|
||||||
|
|||||||
@@ -29,7 +29,8 @@ use super::{
|
|||||||
usage_cleanup_window, wallet_daily_usage_aggregation_target, AppState, DbMaintenanceRunSummary,
|
usage_cleanup_window, wallet_daily_usage_aggregation_target, AppState, DbMaintenanceRunSummary,
|
||||||
FailedPendingUsageRow, GatewayDataState, ProxyUpgradeRolloutProbeConfig, StalePendingUsageRow,
|
FailedPendingUsageRow, GatewayDataState, ProxyUpgradeRolloutProbeConfig, StalePendingUsageRow,
|
||||||
UsageCleanupSettings, DELETE_STALE_WALLET_DAILY_USAGE_LEDGERS_SQL,
|
UsageCleanupSettings, DELETE_STALE_WALLET_DAILY_USAGE_LEDGERS_SQL,
|
||||||
SELECT_WALLET_DAILY_USAGE_AGGREGATION_ROWS_SQL, USAGE_CLEANUP_HOUR, USAGE_CLEANUP_MINUTE,
|
SELECT_STALE_PENDING_USAGE_BATCH_SQL, SELECT_WALLET_DAILY_USAGE_AGGREGATION_ROWS_SQL,
|
||||||
|
UPDATE_FAILED_VOID_STALE_USAGE_SQL, USAGE_CLEANUP_HOUR, USAGE_CLEANUP_MINUTE,
|
||||||
WALLET_DAILY_USAGE_AGGREGATION_HOUR, WALLET_DAILY_USAGE_AGGREGATION_MINUTE,
|
WALLET_DAILY_USAGE_AGGREGATION_HOUR, WALLET_DAILY_USAGE_AGGREGATION_MINUTE,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -90,6 +91,15 @@ fn wallet_daily_usage_queries_use_settlement_snapshots_for_wallet_identity() {
|
|||||||
.contains("usage_settlement_snapshots.wallet_id = ledgers.wallet_id"));
|
.contains("usage_settlement_snapshots.wallet_id = ledgers.wallet_id"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pending_cleanup_queries_use_settlement_snapshots_for_billing_authority() {
|
||||||
|
assert!(SELECT_STALE_PENDING_USAGE_BATCH_SQL.contains("LEFT JOIN usage_settlement_snapshots"));
|
||||||
|
assert!(SELECT_STALE_PENDING_USAGE_BATCH_SQL
|
||||||
|
.contains("COALESCE(usage_settlement_snapshots.billing_status, usage.billing_status)"));
|
||||||
|
assert!(UPDATE_FAILED_VOID_STALE_USAGE_SQL.contains("INSERT INTO usage_settlement_snapshots"));
|
||||||
|
assert!(UPDATE_FAILED_VOID_STALE_USAGE_SQL.contains("billing_status = EXCLUDED.billing_status"));
|
||||||
|
}
|
||||||
|
|
||||||
fn sample_connected_proxy_node(
|
fn sample_connected_proxy_node(
|
||||||
node_id: &str,
|
node_id: &str,
|
||||||
heartbeat_interval: i32,
|
heartbeat_interval: i32,
|
||||||
|
|||||||
@@ -4738,3 +4738,31 @@ COMMENT ON COLUMN public.usage.username IS
|
|||||||
'DEPRECATED: display cache only. Prefer join-time lookup from user/auth records. Legacy compatibility only.';
|
'DEPRECATED: display cache only. Prefer join-time lookup from user/auth records. Legacy compatibility only.';
|
||||||
COMMENT ON COLUMN public.usage.api_key_name IS
|
COMMENT ON COLUMN public.usage.api_key_name IS
|
||||||
'DEPRECATED: display cache only. Prefer join-time lookup from API key records. Legacy compatibility only.';
|
'DEPRECATED: display cache only. Prefer join-time lookup from API key records. Legacy compatibility only.';
|
||||||
|
COMMENT ON COLUMN public.usage.billing_status IS
|
||||||
|
'DEPRECATED: authoritative owner moved to public.usage_settlement_snapshots.billing_status. Compatibility/index mirror only; do not write new values.';
|
||||||
|
COMMENT ON COLUMN public.usage.finalized_at IS
|
||||||
|
'DEPRECATED: authoritative owner moved to public.usage_settlement_snapshots.finalized_at. Compatibility/index mirror only; do not write new values.';
|
||||||
|
COMMENT ON COLUMN public.usage.request_headers IS
|
||||||
|
'DEPRECATED: HTTP audit owner moved to public.usage_http_audits.request_headers. Legacy compatibility only; do not write new values.';
|
||||||
|
COMMENT ON COLUMN public.usage.provider_request_headers IS
|
||||||
|
'DEPRECATED: HTTP audit owner moved to public.usage_http_audits.provider_request_headers. Legacy compatibility only; do not write new values.';
|
||||||
|
COMMENT ON COLUMN public.usage.response_headers IS
|
||||||
|
'DEPRECATED: HTTP audit owner moved to public.usage_http_audits.response_headers. Legacy compatibility only; do not write new values.';
|
||||||
|
COMMENT ON COLUMN public.usage.client_response_headers IS
|
||||||
|
'DEPRECATED: HTTP audit owner moved to public.usage_http_audits.client_response_headers. Legacy compatibility only; do not write new values.';
|
||||||
|
COMMENT ON COLUMN public.usage.request_body IS
|
||||||
|
'DEPRECATED: HTTP body owner moved to public.usage_body_blobs plus public.usage_http_audits.request_body_ref. Legacy compatibility only; do not write new values.';
|
||||||
|
COMMENT ON COLUMN public.usage.request_body_compressed IS
|
||||||
|
'DEPRECATED: HTTP body owner moved to public.usage_body_blobs plus public.usage_http_audits.request_body_ref. Legacy compatibility only; do not write new values.';
|
||||||
|
COMMENT ON COLUMN public.usage.provider_request_body IS
|
||||||
|
'DEPRECATED: HTTP body owner moved to public.usage_body_blobs plus public.usage_http_audits.provider_request_body_ref. Legacy compatibility only; do not write new values.';
|
||||||
|
COMMENT ON COLUMN public.usage.provider_request_body_compressed IS
|
||||||
|
'DEPRECATED: HTTP body owner moved to public.usage_body_blobs plus public.usage_http_audits.provider_request_body_ref. Legacy compatibility only; do not write new values.';
|
||||||
|
COMMENT ON COLUMN public.usage.response_body IS
|
||||||
|
'DEPRECATED: HTTP body owner moved to public.usage_body_blobs plus public.usage_http_audits.response_body_ref. Legacy compatibility only; do not write new values.';
|
||||||
|
COMMENT ON COLUMN public.usage.response_body_compressed IS
|
||||||
|
'DEPRECATED: HTTP body owner moved to public.usage_body_blobs plus public.usage_http_audits.response_body_ref. Legacy compatibility only; do not write new values.';
|
||||||
|
COMMENT ON COLUMN public.usage.client_response_body IS
|
||||||
|
'DEPRECATED: HTTP body owner moved to public.usage_body_blobs plus public.usage_http_audits.client_response_body_ref. Legacy compatibility only; do not write new values.';
|
||||||
|
COMMENT ON COLUMN public.usage.client_response_body_compressed IS
|
||||||
|
'DEPRECATED: HTTP body owner moved to public.usage_body_blobs plus public.usage_http_audits.client_response_body_ref. Legacy compatibility only; do not write new values.';
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
COMMENT ON COLUMN public.usage.billing_status IS
|
||||||
|
'DEPRECATED: authoritative owner moved to public.usage_settlement_snapshots.billing_status. Compatibility/index mirror only; do not write new values.';
|
||||||
|
COMMENT ON COLUMN public.usage.finalized_at IS
|
||||||
|
'DEPRECATED: authoritative owner moved to public.usage_settlement_snapshots.finalized_at. Compatibility/index mirror only; do not write new values.';
|
||||||
|
COMMENT ON COLUMN public.usage.request_headers IS
|
||||||
|
'DEPRECATED: HTTP audit owner moved to public.usage_http_audits.request_headers. Legacy compatibility only; do not write new values.';
|
||||||
|
COMMENT ON COLUMN public.usage.provider_request_headers IS
|
||||||
|
'DEPRECATED: HTTP audit owner moved to public.usage_http_audits.provider_request_headers. Legacy compatibility only; do not write new values.';
|
||||||
|
COMMENT ON COLUMN public.usage.response_headers IS
|
||||||
|
'DEPRECATED: HTTP audit owner moved to public.usage_http_audits.response_headers. Legacy compatibility only; do not write new values.';
|
||||||
|
COMMENT ON COLUMN public.usage.client_response_headers IS
|
||||||
|
'DEPRECATED: HTTP audit owner moved to public.usage_http_audits.client_response_headers. Legacy compatibility only; do not write new values.';
|
||||||
|
COMMENT ON COLUMN public.usage.request_body IS
|
||||||
|
'DEPRECATED: HTTP body owner moved to public.usage_body_blobs plus public.usage_http_audits.request_body_ref. Legacy compatibility only; do not write new values.';
|
||||||
|
COMMENT ON COLUMN public.usage.request_body_compressed IS
|
||||||
|
'DEPRECATED: HTTP body owner moved to public.usage_body_blobs plus public.usage_http_audits.request_body_ref. Legacy compatibility only; do not write new values.';
|
||||||
|
COMMENT ON COLUMN public.usage.provider_request_body IS
|
||||||
|
'DEPRECATED: HTTP body owner moved to public.usage_body_blobs plus public.usage_http_audits.provider_request_body_ref. Legacy compatibility only; do not write new values.';
|
||||||
|
COMMENT ON COLUMN public.usage.provider_request_body_compressed IS
|
||||||
|
'DEPRECATED: HTTP body owner moved to public.usage_body_blobs plus public.usage_http_audits.provider_request_body_ref. Legacy compatibility only; do not write new values.';
|
||||||
|
COMMENT ON COLUMN public.usage.response_body IS
|
||||||
|
'DEPRECATED: HTTP body owner moved to public.usage_body_blobs plus public.usage_http_audits.response_body_ref. Legacy compatibility only; do not write new values.';
|
||||||
|
COMMENT ON COLUMN public.usage.response_body_compressed IS
|
||||||
|
'DEPRECATED: HTTP body owner moved to public.usage_body_blobs plus public.usage_http_audits.response_body_ref. Legacy compatibility only; do not write new values.';
|
||||||
|
COMMENT ON COLUMN public.usage.client_response_body IS
|
||||||
|
'DEPRECATED: HTTP body owner moved to public.usage_body_blobs plus public.usage_http_audits.client_response_body_ref. Legacy compatibility only; do not write new values.';
|
||||||
|
COMMENT ON COLUMN public.usage.client_response_body_compressed IS
|
||||||
|
'DEPRECATED: HTTP body owner moved to public.usage_body_blobs plus public.usage_http_audits.client_response_body_ref. Legacy compatibility only; do not write new values.';
|
||||||
@@ -8,7 +8,7 @@ use tracing::{error, info, warn};
|
|||||||
|
|
||||||
static MIGRATOR: Migrator = sqlx::migrate!("./migrations");
|
static MIGRATOR: Migrator = sqlx::migrate!("./migrations");
|
||||||
static BASELINE_V2_SQL: &str = include_str!("../bootstrap/20260413020000_baseline_v2.sql");
|
static BASELINE_V2_SQL: &str = include_str!("../bootstrap/20260413020000_baseline_v2.sql");
|
||||||
const BASELINE_V2_CUTOFF_VERSION: i64 = 20260413020000;
|
const BASELINE_V2_CUTOFF_VERSION: i64 = 20260413030000;
|
||||||
const MIGRATIONS_TABLE_EXISTS_SQL: &str =
|
const MIGRATIONS_TABLE_EXISTS_SQL: &str =
|
||||||
"SELECT to_regclass('public._sqlx_migrations') IS NOT NULL";
|
"SELECT to_regclass('public._sqlx_migrations') IS NOT NULL";
|
||||||
const EMPTY_DATABASE_USER_TABLE_COUNT_SQL: &str = r#"
|
const EMPTY_DATABASE_USER_TABLE_COUNT_SQL: &str = r#"
|
||||||
@@ -404,6 +404,7 @@ mod tests {
|
|||||||
20260406000000,
|
20260406000000,
|
||||||
20260410000000,
|
20260410000000,
|
||||||
20260413020000,
|
20260413020000,
|
||||||
|
20260413030000,
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -425,27 +426,47 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn deprecation_migration_and_baseline_mark_legacy_usage_columns() {
|
fn deprecation_migration_and_baseline_mark_legacy_usage_columns() {
|
||||||
let migration = MIGRATOR
|
let settlement_migration = MIGRATOR
|
||||||
.iter()
|
.iter()
|
||||||
.find(|migration| migration.version == 20260413020000)
|
.find(|migration| migration.version == 20260413020000)
|
||||||
.expect("deprecation migration should be embedded");
|
.expect("deprecation migration should be embedded");
|
||||||
|
let http_migration = MIGRATOR
|
||||||
|
.iter()
|
||||||
|
.find(|migration| migration.version == 20260413030000)
|
||||||
|
.expect("http/body deprecation migration should be embedded");
|
||||||
|
|
||||||
assert!(migration
|
assert!(settlement_migration
|
||||||
.sql
|
.sql
|
||||||
.contains("COMMENT ON COLUMN public.usage.output_price_per_1m"));
|
.contains("COMMENT ON COLUMN public.usage.output_price_per_1m"));
|
||||||
assert!(migration
|
assert!(settlement_migration
|
||||||
.sql
|
.sql
|
||||||
.contains("COMMENT ON COLUMN public.usage.wallet_id"));
|
.contains("COMMENT ON COLUMN public.usage.wallet_id"));
|
||||||
assert!(migration
|
assert!(settlement_migration
|
||||||
.sql
|
.sql
|
||||||
.contains("COMMENT ON COLUMN public.usage.username"));
|
.contains("COMMENT ON COLUMN public.usage.username"));
|
||||||
assert!(migration
|
assert!(settlement_migration
|
||||||
.sql
|
.sql
|
||||||
.contains("COMMENT ON COLUMN public.usage.api_key_name"));
|
.contains("COMMENT ON COLUMN public.usage.api_key_name"));
|
||||||
|
assert!(http_migration
|
||||||
|
.sql
|
||||||
|
.contains("COMMENT ON COLUMN public.usage.request_headers"));
|
||||||
|
assert!(http_migration
|
||||||
|
.sql
|
||||||
|
.contains("COMMENT ON COLUMN public.usage.request_body"));
|
||||||
|
assert!(http_migration
|
||||||
|
.sql
|
||||||
|
.contains("COMMENT ON COLUMN public.usage.billing_status"));
|
||||||
|
assert!(http_migration
|
||||||
|
.sql
|
||||||
|
.contains("COMMENT ON COLUMN public.usage.finalized_at"));
|
||||||
assert!(BASELINE_V2_SQL.contains("COMMENT ON COLUMN public.usage.output_price_per_1m"));
|
assert!(BASELINE_V2_SQL.contains("COMMENT ON COLUMN public.usage.output_price_per_1m"));
|
||||||
assert!(BASELINE_V2_SQL.contains("COMMENT ON COLUMN public.usage.wallet_id"));
|
assert!(BASELINE_V2_SQL.contains("COMMENT ON COLUMN public.usage.wallet_id"));
|
||||||
assert!(BASELINE_V2_SQL.contains("COMMENT ON COLUMN public.usage.username"));
|
assert!(BASELINE_V2_SQL.contains("COMMENT ON COLUMN public.usage.username"));
|
||||||
assert!(BASELINE_V2_SQL.contains("COMMENT ON COLUMN public.usage.api_key_name"));
|
assert!(BASELINE_V2_SQL.contains("COMMENT ON COLUMN public.usage.api_key_name"));
|
||||||
|
assert!(BASELINE_V2_SQL.contains("COMMENT ON COLUMN public.usage.request_headers"));
|
||||||
|
assert!(BASELINE_V2_SQL.contains("COMMENT ON COLUMN public.usage.request_body"));
|
||||||
|
assert!(BASELINE_V2_SQL.contains("COMMENT ON COLUMN public.usage.billing_status"));
|
||||||
|
assert!(BASELINE_V2_SQL.contains("COMMENT ON COLUMN public.usage.finalized_at"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -472,7 +493,10 @@ mod tests {
|
|||||||
.map(|migration| migration.version)
|
.map(|migration| migration.version)
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
assert_eq!(pending_versions, vec![20260410000000, 20260413020000]);
|
assert_eq!(
|
||||||
|
pending_versions,
|
||||||
|
vec![20260410000000, 20260413020000, 20260413030000]
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ const FIND_USAGE_FOR_SETTLEMENT_SQL: &str = r#"
|
|||||||
SELECT
|
SELECT
|
||||||
usage_record.request_id,
|
usage_record.request_id,
|
||||||
COALESCE(usage_settlement_snapshots.wallet_id, usage_record.wallet_id) AS wallet_id,
|
COALESCE(usage_settlement_snapshots.wallet_id, usage_record.wallet_id) AS wallet_id,
|
||||||
usage_record.billing_status,
|
COALESCE(usage_settlement_snapshots.billing_status, usage_record.billing_status) AS billing_status,
|
||||||
COALESCE(
|
COALESCE(
|
||||||
CAST(usage_settlement_snapshots.wallet_balance_before AS DOUBLE PRECISION),
|
CAST(usage_settlement_snapshots.wallet_balance_before AS DOUBLE PRECISION),
|
||||||
CAST(usage_record.wallet_balance_before AS DOUBLE PRECISION)
|
CAST(usage_record.wallet_balance_before AS DOUBLE PRECISION)
|
||||||
@@ -369,6 +369,7 @@ RETURNING CAST(monthly_used_usd AS DOUBLE PRECISION) AS monthly_used_usd
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
sync_usage_settlement_snapshot(&mut **tx, &settlement).await?;
|
||||||
sqlx::query(FINALIZE_USAGE_BILLING_SQL)
|
sqlx::query(FINALIZE_USAGE_BILLING_SQL)
|
||||||
.bind(&input.request_id)
|
.bind(&input.request_id)
|
||||||
.bind(final_billing_status)
|
.bind(final_billing_status)
|
||||||
@@ -376,7 +377,6 @@ RETURNING CAST(monthly_used_usd AS DOUBLE PRECISION) AS monthly_used_usd
|
|||||||
.execute(&mut **tx)
|
.execute(&mut **tx)
|
||||||
.await
|
.await
|
||||||
.map_postgres_err()?;
|
.map_postgres_err()?;
|
||||||
sync_usage_settlement_snapshot(&mut **tx, &settlement).await?;
|
|
||||||
|
|
||||||
Ok(Some(settlement))
|
Ok(Some(settlement))
|
||||||
})
|
})
|
||||||
@@ -397,7 +397,9 @@ mod tests {
|
|||||||
assert!(
|
assert!(
|
||||||
super::FIND_USAGE_FOR_SETTLEMENT_SQL.contains("LEFT JOIN usage_settlement_snapshots")
|
super::FIND_USAGE_FOR_SETTLEMENT_SQL.contains("LEFT JOIN usage_settlement_snapshots")
|
||||||
);
|
);
|
||||||
assert!(super::FIND_USAGE_FOR_SETTLEMENT_SQL.contains("COALESCE("));
|
assert!(super::FIND_USAGE_FOR_SETTLEMENT_SQL.contains(
|
||||||
|
"COALESCE(usage_settlement_snapshots.billing_status, usage_record.billing_status)"
|
||||||
|
));
|
||||||
assert!(super::FIND_USAGE_FOR_SETTLEMENT_SQL.contains("FOR UPDATE OF usage_record"));
|
assert!(super::FIND_USAGE_FOR_SETTLEMENT_SQL.contains("FOR UPDATE OF usage_record"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,9 @@ use super::{
|
|||||||
use crate::postgres::PostgresTransactionRunner;
|
use crate::postgres::PostgresTransactionRunner;
|
||||||
use crate::{error::SqlxResultExt, DataLayerError};
|
use crate::{error::SqlxResultExt, DataLayerError};
|
||||||
|
|
||||||
const MAX_INLINE_USAGE_BODY_BYTES: usize = 16 * 1024;
|
// Legacy inline body columns on public.usage are deprecated. Keep the threshold at zero so
|
||||||
|
// newly captured bodies always spill to usage_body_blobs and resolve through usage_http_audits.
|
||||||
|
const MAX_INLINE_USAGE_BODY_BYTES: usize = 0;
|
||||||
const FIND_USAGE_BODY_BLOB_BY_REF_SQL: &str =
|
const FIND_USAGE_BODY_BLOB_BY_REF_SQL: &str =
|
||||||
r#"SELECT payload_gzip FROM usage_body_blobs WHERE body_ref = $1 LIMIT 1"#;
|
r#"SELECT payload_gzip FROM usage_body_blobs WHERE body_ref = $1 LIMIT 1"#;
|
||||||
const UPSERT_USAGE_BODY_BLOB_SQL: &str = r#"
|
const UPSERT_USAGE_BODY_BLOB_SQL: &str = r#"
|
||||||
@@ -258,7 +260,10 @@ SELECT
|
|||||||
COALESCE("usage".cache_read_input_tokens, 0) AS cache_read_input_tokens,
|
COALESCE("usage".cache_read_input_tokens, 0) AS cache_read_input_tokens,
|
||||||
COALESCE(CAST("usage".cache_creation_cost_usd AS DOUBLE PRECISION), 0) AS cache_creation_cost_usd,
|
COALESCE(CAST("usage".cache_creation_cost_usd AS DOUBLE PRECISION), 0) AS cache_creation_cost_usd,
|
||||||
COALESCE(CAST("usage".cache_read_cost_usd AS DOUBLE PRECISION), 0) AS cache_read_cost_usd,
|
COALESCE(CAST("usage".cache_read_cost_usd AS DOUBLE PRECISION), 0) AS cache_read_cost_usd,
|
||||||
CAST("usage".output_price_per_1m AS DOUBLE PRECISION) AS output_price_per_1m,
|
COALESCE(
|
||||||
|
CAST(usage_settlement_snapshots.output_price_per_1m AS DOUBLE PRECISION),
|
||||||
|
CAST("usage".output_price_per_1m AS DOUBLE PRECISION)
|
||||||
|
) AS output_price_per_1m,
|
||||||
COALESCE(CAST("usage".total_cost_usd AS DOUBLE PRECISION), 0) AS total_cost_usd,
|
COALESCE(CAST("usage".total_cost_usd AS DOUBLE PRECISION), 0) AS total_cost_usd,
|
||||||
COALESCE(CAST("usage".actual_total_cost_usd AS DOUBLE PRECISION), 0) AS actual_total_cost_usd,
|
COALESCE(CAST("usage".actual_total_cost_usd AS DOUBLE PRECISION), 0) AS actual_total_cost_usd,
|
||||||
"usage".status_code,
|
"usage".status_code,
|
||||||
@@ -310,9 +315,19 @@ SELECT
|
|||||||
CAST(usage_settlement_snapshots.price_per_request AS DOUBLE PRECISION) AS settlement_price_per_request,
|
CAST(usage_settlement_snapshots.price_per_request AS DOUBLE PRECISION) AS settlement_price_per_request,
|
||||||
CAST(EXTRACT(EPOCH FROM "usage".created_at) AS BIGINT) AS created_at_unix_ms,
|
CAST(EXTRACT(EPOCH FROM "usage".created_at) AS BIGINT) AS created_at_unix_ms,
|
||||||
CAST(
|
CAST(
|
||||||
EXTRACT(EPOCH FROM COALESCE("usage".finalized_at, "usage".created_at)) AS BIGINT
|
EXTRACT(
|
||||||
|
EPOCH FROM COALESCE(
|
||||||
|
usage_settlement_snapshots.finalized_at,
|
||||||
|
"usage".finalized_at,
|
||||||
|
"usage".created_at
|
||||||
|
)
|
||||||
|
) AS BIGINT
|
||||||
) AS updated_at_unix_secs,
|
) AS updated_at_unix_secs,
|
||||||
CAST(EXTRACT(EPOCH FROM "usage".finalized_at) AS BIGINT) AS finalized_at_unix_secs
|
CAST(
|
||||||
|
EXTRACT(
|
||||||
|
EPOCH FROM COALESCE(usage_settlement_snapshots.finalized_at, "usage".finalized_at)
|
||||||
|
) AS BIGINT
|
||||||
|
) AS finalized_at_unix_secs
|
||||||
FROM "usage"
|
FROM "usage"
|
||||||
LEFT JOIN usage_http_audits
|
LEFT JOIN usage_http_audits
|
||||||
ON usage_http_audits.request_id = "usage".request_id
|
ON usage_http_audits.request_id = "usage".request_id
|
||||||
@@ -356,7 +371,10 @@ SELECT
|
|||||||
COALESCE("usage".cache_read_input_tokens, 0) AS cache_read_input_tokens,
|
COALESCE("usage".cache_read_input_tokens, 0) AS cache_read_input_tokens,
|
||||||
COALESCE(CAST("usage".cache_creation_cost_usd AS DOUBLE PRECISION), 0) AS cache_creation_cost_usd,
|
COALESCE(CAST("usage".cache_creation_cost_usd AS DOUBLE PRECISION), 0) AS cache_creation_cost_usd,
|
||||||
COALESCE(CAST("usage".cache_read_cost_usd AS DOUBLE PRECISION), 0) AS cache_read_cost_usd,
|
COALESCE(CAST("usage".cache_read_cost_usd AS DOUBLE PRECISION), 0) AS cache_read_cost_usd,
|
||||||
CAST("usage".output_price_per_1m AS DOUBLE PRECISION) AS output_price_per_1m,
|
COALESCE(
|
||||||
|
CAST(usage_settlement_snapshots.output_price_per_1m AS DOUBLE PRECISION),
|
||||||
|
CAST("usage".output_price_per_1m AS DOUBLE PRECISION)
|
||||||
|
) AS output_price_per_1m,
|
||||||
COALESCE(CAST("usage".total_cost_usd AS DOUBLE PRECISION), 0) AS total_cost_usd,
|
COALESCE(CAST("usage".total_cost_usd AS DOUBLE PRECISION), 0) AS total_cost_usd,
|
||||||
COALESCE(CAST("usage".actual_total_cost_usd AS DOUBLE PRECISION), 0) AS actual_total_cost_usd,
|
COALESCE(CAST("usage".actual_total_cost_usd AS DOUBLE PRECISION), 0) AS actual_total_cost_usd,
|
||||||
"usage".status_code,
|
"usage".status_code,
|
||||||
@@ -408,9 +426,19 @@ SELECT
|
|||||||
CAST(usage_settlement_snapshots.price_per_request AS DOUBLE PRECISION) AS settlement_price_per_request,
|
CAST(usage_settlement_snapshots.price_per_request AS DOUBLE PRECISION) AS settlement_price_per_request,
|
||||||
CAST(EXTRACT(EPOCH FROM "usage".created_at) AS BIGINT) AS created_at_unix_ms,
|
CAST(EXTRACT(EPOCH FROM "usage".created_at) AS BIGINT) AS created_at_unix_ms,
|
||||||
CAST(
|
CAST(
|
||||||
EXTRACT(EPOCH FROM COALESCE("usage".finalized_at, "usage".created_at)) AS BIGINT
|
EXTRACT(
|
||||||
|
EPOCH FROM COALESCE(
|
||||||
|
usage_settlement_snapshots.finalized_at,
|
||||||
|
"usage".finalized_at,
|
||||||
|
"usage".created_at
|
||||||
|
)
|
||||||
|
) AS BIGINT
|
||||||
) AS updated_at_unix_secs,
|
) AS updated_at_unix_secs,
|
||||||
CAST(EXTRACT(EPOCH FROM "usage".finalized_at) AS BIGINT) AS finalized_at_unix_secs
|
CAST(
|
||||||
|
EXTRACT(
|
||||||
|
EPOCH FROM COALESCE(usage_settlement_snapshots.finalized_at, "usage".finalized_at)
|
||||||
|
) AS BIGINT
|
||||||
|
) AS finalized_at_unix_secs
|
||||||
FROM "usage"
|
FROM "usage"
|
||||||
LEFT JOIN usage_http_audits
|
LEFT JOIN usage_http_audits
|
||||||
ON usage_http_audits.request_id = "usage".request_id
|
ON usage_http_audits.request_id = "usage".request_id
|
||||||
@@ -505,7 +533,10 @@ SELECT
|
|||||||
COALESCE("usage".cache_read_input_tokens, 0) AS cache_read_input_tokens,
|
COALESCE("usage".cache_read_input_tokens, 0) AS cache_read_input_tokens,
|
||||||
COALESCE(CAST("usage".cache_creation_cost_usd AS DOUBLE PRECISION), 0) AS cache_creation_cost_usd,
|
COALESCE(CAST("usage".cache_creation_cost_usd AS DOUBLE PRECISION), 0) AS cache_creation_cost_usd,
|
||||||
COALESCE(CAST("usage".cache_read_cost_usd AS DOUBLE PRECISION), 0) AS cache_read_cost_usd,
|
COALESCE(CAST("usage".cache_read_cost_usd AS DOUBLE PRECISION), 0) AS cache_read_cost_usd,
|
||||||
CAST("usage".output_price_per_1m AS DOUBLE PRECISION) AS output_price_per_1m,
|
COALESCE(
|
||||||
|
CAST(usage_settlement_snapshots.output_price_per_1m AS DOUBLE PRECISION),
|
||||||
|
CAST("usage".output_price_per_1m AS DOUBLE PRECISION)
|
||||||
|
) AS output_price_per_1m,
|
||||||
COALESCE(CAST("usage".total_cost_usd AS DOUBLE PRECISION), 0) AS total_cost_usd,
|
COALESCE(CAST("usage".total_cost_usd AS DOUBLE PRECISION), 0) AS total_cost_usd,
|
||||||
COALESCE(CAST("usage".actual_total_cost_usd AS DOUBLE PRECISION), 0) AS actual_total_cost_usd,
|
COALESCE(CAST("usage".actual_total_cost_usd AS DOUBLE PRECISION), 0) AS actual_total_cost_usd,
|
||||||
"usage".status_code,
|
"usage".status_code,
|
||||||
@@ -551,9 +582,19 @@ SELECT
|
|||||||
CAST(usage_settlement_snapshots.price_per_request AS DOUBLE PRECISION) AS settlement_price_per_request,
|
CAST(usage_settlement_snapshots.price_per_request AS DOUBLE PRECISION) AS settlement_price_per_request,
|
||||||
CAST(EXTRACT(EPOCH FROM "usage".created_at) AS BIGINT) AS created_at_unix_ms,
|
CAST(EXTRACT(EPOCH FROM "usage".created_at) AS BIGINT) AS created_at_unix_ms,
|
||||||
CAST(
|
CAST(
|
||||||
EXTRACT(EPOCH FROM COALESCE("usage".finalized_at, "usage".created_at)) AS BIGINT
|
EXTRACT(
|
||||||
|
EPOCH FROM COALESCE(
|
||||||
|
usage_settlement_snapshots.finalized_at,
|
||||||
|
"usage".finalized_at,
|
||||||
|
"usage".created_at
|
||||||
|
)
|
||||||
|
) AS BIGINT
|
||||||
) AS updated_at_unix_secs,
|
) AS updated_at_unix_secs,
|
||||||
CAST(EXTRACT(EPOCH FROM "usage".finalized_at) AS BIGINT) AS finalized_at_unix_secs
|
CAST(
|
||||||
|
EXTRACT(
|
||||||
|
EPOCH FROM COALESCE(usage_settlement_snapshots.finalized_at, "usage".finalized_at)
|
||||||
|
) AS BIGINT
|
||||||
|
) AS finalized_at_unix_secs
|
||||||
FROM "usage"
|
FROM "usage"
|
||||||
LEFT JOIN usage_settlement_snapshots
|
LEFT JOIN usage_settlement_snapshots
|
||||||
ON usage_settlement_snapshots.request_id = "usage".request_id
|
ON usage_settlement_snapshots.request_id = "usage".request_id
|
||||||
@@ -591,7 +632,10 @@ SELECT
|
|||||||
COALESCE("usage".cache_read_input_tokens, 0) AS cache_read_input_tokens,
|
COALESCE("usage".cache_read_input_tokens, 0) AS cache_read_input_tokens,
|
||||||
COALESCE(CAST("usage".cache_creation_cost_usd AS DOUBLE PRECISION), 0) AS cache_creation_cost_usd,
|
COALESCE(CAST("usage".cache_creation_cost_usd AS DOUBLE PRECISION), 0) AS cache_creation_cost_usd,
|
||||||
COALESCE(CAST("usage".cache_read_cost_usd AS DOUBLE PRECISION), 0) AS cache_read_cost_usd,
|
COALESCE(CAST("usage".cache_read_cost_usd AS DOUBLE PRECISION), 0) AS cache_read_cost_usd,
|
||||||
CAST("usage".output_price_per_1m AS DOUBLE PRECISION) AS output_price_per_1m,
|
COALESCE(
|
||||||
|
CAST(usage_settlement_snapshots.output_price_per_1m AS DOUBLE PRECISION),
|
||||||
|
CAST("usage".output_price_per_1m AS DOUBLE PRECISION)
|
||||||
|
) AS output_price_per_1m,
|
||||||
COALESCE(CAST("usage".total_cost_usd AS DOUBLE PRECISION), 0) AS total_cost_usd,
|
COALESCE(CAST("usage".total_cost_usd AS DOUBLE PRECISION), 0) AS total_cost_usd,
|
||||||
COALESCE(CAST("usage".actual_total_cost_usd AS DOUBLE PRECISION), 0) AS actual_total_cost_usd,
|
COALESCE(CAST("usage".actual_total_cost_usd AS DOUBLE PRECISION), 0) AS actual_total_cost_usd,
|
||||||
"usage".status_code,
|
"usage".status_code,
|
||||||
@@ -637,9 +681,19 @@ SELECT
|
|||||||
CAST(usage_settlement_snapshots.price_per_request AS DOUBLE PRECISION) AS settlement_price_per_request,
|
CAST(usage_settlement_snapshots.price_per_request AS DOUBLE PRECISION) AS settlement_price_per_request,
|
||||||
CAST(EXTRACT(EPOCH FROM "usage".created_at) AS BIGINT) AS created_at_unix_ms,
|
CAST(EXTRACT(EPOCH FROM "usage".created_at) AS BIGINT) AS created_at_unix_ms,
|
||||||
CAST(
|
CAST(
|
||||||
EXTRACT(EPOCH FROM COALESCE("usage".finalized_at, "usage".created_at)) AS BIGINT
|
EXTRACT(
|
||||||
|
EPOCH FROM COALESCE(
|
||||||
|
usage_settlement_snapshots.finalized_at,
|
||||||
|
"usage".finalized_at,
|
||||||
|
"usage".created_at
|
||||||
|
)
|
||||||
|
) AS BIGINT
|
||||||
) AS updated_at_unix_secs,
|
) AS updated_at_unix_secs,
|
||||||
CAST(EXTRACT(EPOCH FROM "usage".finalized_at) AS BIGINT) AS finalized_at_unix_secs
|
CAST(
|
||||||
|
EXTRACT(
|
||||||
|
EPOCH FROM COALESCE(usage_settlement_snapshots.finalized_at, "usage".finalized_at)
|
||||||
|
) AS BIGINT
|
||||||
|
) AS finalized_at_unix_secs
|
||||||
FROM "usage"
|
FROM "usage"
|
||||||
LEFT JOIN usage_settlement_snapshots
|
LEFT JOIN usage_settlement_snapshots
|
||||||
ON usage_settlement_snapshots.request_id = "usage".request_id
|
ON usage_settlement_snapshots.request_id = "usage".request_id
|
||||||
@@ -792,7 +846,7 @@ DO UPDATE SET
|
|||||||
cache_read_input_tokens = CASE WHEN "usage".billing_status = 'pending' THEN COALESCE(EXCLUDED.cache_read_input_tokens, "usage".cache_read_input_tokens) ELSE "usage".cache_read_input_tokens END,
|
cache_read_input_tokens = CASE WHEN "usage".billing_status = 'pending' THEN COALESCE(EXCLUDED.cache_read_input_tokens, "usage".cache_read_input_tokens) ELSE "usage".cache_read_input_tokens END,
|
||||||
cache_creation_cost_usd = CASE WHEN "usage".billing_status = 'pending' THEN COALESCE(EXCLUDED.cache_creation_cost_usd, "usage".cache_creation_cost_usd) ELSE "usage".cache_creation_cost_usd END,
|
cache_creation_cost_usd = CASE WHEN "usage".billing_status = 'pending' THEN COALESCE(EXCLUDED.cache_creation_cost_usd, "usage".cache_creation_cost_usd) ELSE "usage".cache_creation_cost_usd END,
|
||||||
cache_read_cost_usd = CASE WHEN "usage".billing_status = 'pending' THEN COALESCE(EXCLUDED.cache_read_cost_usd, "usage".cache_read_cost_usd) ELSE "usage".cache_read_cost_usd END,
|
cache_read_cost_usd = CASE WHEN "usage".billing_status = 'pending' THEN COALESCE(EXCLUDED.cache_read_cost_usd, "usage".cache_read_cost_usd) ELSE "usage".cache_read_cost_usd END,
|
||||||
output_price_per_1m = "usage".output_price_per_1m,
|
output_price_per_1m = NULL,
|
||||||
total_cost_usd = CASE WHEN "usage".billing_status = 'pending' THEN COALESCE(EXCLUDED.total_cost_usd, "usage".total_cost_usd) ELSE "usage".total_cost_usd END,
|
total_cost_usd = CASE WHEN "usage".billing_status = 'pending' THEN COALESCE(EXCLUDED.total_cost_usd, "usage".total_cost_usd) ELSE "usage".total_cost_usd END,
|
||||||
actual_total_cost_usd = CASE WHEN "usage".billing_status = 'pending' THEN COALESCE(EXCLUDED.actual_total_cost_usd, "usage".actual_total_cost_usd) ELSE "usage".actual_total_cost_usd END,
|
actual_total_cost_usd = CASE WHEN "usage".billing_status = 'pending' THEN COALESCE(EXCLUDED.actual_total_cost_usd, "usage".actual_total_cost_usd) ELSE "usage".actual_total_cost_usd END,
|
||||||
status_code = CASE WHEN "usage".billing_status = 'pending' THEN CASE
|
status_code = CASE WHEN "usage".billing_status = 'pending' THEN CASE
|
||||||
@@ -811,7 +865,7 @@ DO UPDATE SET
|
|||||||
first_byte_time_ms = CASE WHEN "usage".billing_status = 'pending' THEN COALESCE(EXCLUDED.first_byte_time_ms, "usage".first_byte_time_ms) ELSE "usage".first_byte_time_ms END,
|
first_byte_time_ms = CASE WHEN "usage".billing_status = 'pending' THEN COALESCE(EXCLUDED.first_byte_time_ms, "usage".first_byte_time_ms) ELSE "usage".first_byte_time_ms END,
|
||||||
status = CASE WHEN "usage".billing_status = 'pending' THEN EXCLUDED.status ELSE "usage".status END,
|
status = CASE WHEN "usage".billing_status = 'pending' THEN EXCLUDED.status ELSE "usage".status END,
|
||||||
billing_status = CASE WHEN "usage".billing_status = 'pending' THEN EXCLUDED.billing_status ELSE "usage".billing_status END,
|
billing_status = CASE WHEN "usage".billing_status = 'pending' THEN EXCLUDED.billing_status ELSE "usage".billing_status END,
|
||||||
request_headers = CASE WHEN "usage".billing_status = 'pending' THEN COALESCE(EXCLUDED.request_headers, "usage".request_headers) ELSE "usage".request_headers END,
|
request_headers = NULL,
|
||||||
request_body = CASE WHEN "usage".billing_status = 'pending' THEN CASE
|
request_body = CASE WHEN "usage".billing_status = 'pending' THEN CASE
|
||||||
WHEN EXCLUDED.request_body_compressed IS NOT NULL OR $56 THEN NULL
|
WHEN EXCLUDED.request_body_compressed IS NOT NULL OR $56 THEN NULL
|
||||||
ELSE COALESCE(EXCLUDED.request_body, "usage".request_body)
|
ELSE COALESCE(EXCLUDED.request_body, "usage".request_body)
|
||||||
@@ -820,7 +874,7 @@ DO UPDATE SET
|
|||||||
WHEN EXCLUDED.request_body IS NOT NULL OR $56 THEN NULL
|
WHEN EXCLUDED.request_body IS NOT NULL OR $56 THEN NULL
|
||||||
ELSE COALESCE(EXCLUDED.request_body_compressed, "usage".request_body_compressed)
|
ELSE COALESCE(EXCLUDED.request_body_compressed, "usage".request_body_compressed)
|
||||||
END ELSE "usage".request_body_compressed END,
|
END ELSE "usage".request_body_compressed END,
|
||||||
provider_request_headers = CASE WHEN "usage".billing_status = 'pending' THEN COALESCE(EXCLUDED.provider_request_headers, "usage".provider_request_headers) ELSE "usage".provider_request_headers END,
|
provider_request_headers = NULL,
|
||||||
provider_request_body = CASE WHEN "usage".billing_status = 'pending' THEN CASE
|
provider_request_body = CASE WHEN "usage".billing_status = 'pending' THEN CASE
|
||||||
WHEN EXCLUDED.provider_request_body_compressed IS NOT NULL OR $57 THEN NULL
|
WHEN EXCLUDED.provider_request_body_compressed IS NOT NULL OR $57 THEN NULL
|
||||||
ELSE COALESCE(EXCLUDED.provider_request_body, "usage".provider_request_body)
|
ELSE COALESCE(EXCLUDED.provider_request_body, "usage".provider_request_body)
|
||||||
@@ -829,7 +883,7 @@ DO UPDATE SET
|
|||||||
WHEN EXCLUDED.provider_request_body IS NOT NULL OR $57 THEN NULL
|
WHEN EXCLUDED.provider_request_body IS NOT NULL OR $57 THEN NULL
|
||||||
ELSE COALESCE(EXCLUDED.provider_request_body_compressed, "usage".provider_request_body_compressed)
|
ELSE COALESCE(EXCLUDED.provider_request_body_compressed, "usage".provider_request_body_compressed)
|
||||||
END ELSE "usage".provider_request_body_compressed END,
|
END ELSE "usage".provider_request_body_compressed END,
|
||||||
response_headers = CASE WHEN "usage".billing_status = 'pending' THEN COALESCE(EXCLUDED.response_headers, "usage".response_headers) ELSE "usage".response_headers END,
|
response_headers = NULL,
|
||||||
response_body = CASE WHEN "usage".billing_status = 'pending' THEN CASE
|
response_body = CASE WHEN "usage".billing_status = 'pending' THEN CASE
|
||||||
WHEN EXCLUDED.response_body_compressed IS NOT NULL OR $58 THEN NULL
|
WHEN EXCLUDED.response_body_compressed IS NOT NULL OR $58 THEN NULL
|
||||||
ELSE COALESCE(EXCLUDED.response_body, "usage".response_body)
|
ELSE COALESCE(EXCLUDED.response_body, "usage".response_body)
|
||||||
@@ -838,7 +892,7 @@ DO UPDATE SET
|
|||||||
WHEN EXCLUDED.response_body IS NOT NULL OR $58 THEN NULL
|
WHEN EXCLUDED.response_body IS NOT NULL OR $58 THEN NULL
|
||||||
ELSE COALESCE(EXCLUDED.response_body_compressed, "usage".response_body_compressed)
|
ELSE COALESCE(EXCLUDED.response_body_compressed, "usage".response_body_compressed)
|
||||||
END ELSE "usage".response_body_compressed END,
|
END ELSE "usage".response_body_compressed END,
|
||||||
client_response_headers = CASE WHEN "usage".billing_status = 'pending' THEN COALESCE(EXCLUDED.client_response_headers, "usage".client_response_headers) ELSE "usage".client_response_headers END,
|
client_response_headers = NULL,
|
||||||
client_response_body = CASE WHEN "usage".billing_status = 'pending' THEN CASE
|
client_response_body = CASE WHEN "usage".billing_status = 'pending' THEN CASE
|
||||||
WHEN EXCLUDED.client_response_body_compressed IS NOT NULL OR $59 THEN NULL
|
WHEN EXCLUDED.client_response_body_compressed IS NOT NULL OR $59 THEN NULL
|
||||||
ELSE COALESCE(EXCLUDED.client_response_body, "usage".client_response_body)
|
ELSE COALESCE(EXCLUDED.client_response_body, "usage".client_response_body)
|
||||||
@@ -1550,6 +1604,7 @@ impl SqlxUsageReadRepository {
|
|||||||
stored.execution_path = routing_snapshot.execution_path.clone();
|
stored.execution_path = routing_snapshot.execution_path.clone();
|
||||||
stored.local_execution_runtime_miss_reason =
|
stored.local_execution_runtime_miss_reason =
|
||||||
routing_snapshot.local_execution_runtime_miss_reason.clone();
|
routing_snapshot.local_execution_runtime_miss_reason.clone();
|
||||||
|
stored.output_price_per_1m = settlement_pricing_snapshot.output_price_per_1m;
|
||||||
stored.request_metadata = request_metadata_value;
|
stored.request_metadata = request_metadata_value;
|
||||||
Ok(stored)
|
Ok(stored)
|
||||||
}) as BoxFuture<'_, Result<StoredRequestUsageAudit, DataLayerError>>
|
}) as BoxFuture<'_, Result<StoredRequestUsageAudit, DataLayerError>>
|
||||||
@@ -1910,7 +1965,8 @@ struct UsageSettlementPricingSnapshot {
|
|||||||
|
|
||||||
impl UsageSettlementPricingSnapshot {
|
impl UsageSettlementPricingSnapshot {
|
||||||
fn any_present(&self) -> bool {
|
fn any_present(&self) -> bool {
|
||||||
self.billing_snapshot_schema_version.is_some()
|
self.billing_status.is_some()
|
||||||
|
|| self.billing_snapshot_schema_version.is_some()
|
||||||
|| self.billing_snapshot_status.is_some()
|
|| self.billing_snapshot_status.is_some()
|
||||||
|| self.rate_multiplier.is_some()
|
|| self.rate_multiplier.is_some()
|
||||||
|| self.is_free_tier.is_some()
|
|| self.is_free_tier.is_some()
|
||||||
@@ -2907,11 +2963,10 @@ mod tests {
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
assert!(sql.contains(
|
assert!(sql.contains(
|
||||||
"CAST(\"usage\".output_price_per_1m AS DOUBLE PRECISION) AS output_price_per_1m"
|
"CAST(usage_settlement_snapshots.output_price_per_1m AS DOUBLE PRECISION)"
|
||||||
));
|
));
|
||||||
assert!(sql.contains("EXTRACT(EPOCH FROM \"usage\".created_at)"));
|
assert!(sql.contains("EXTRACT(EPOCH FROM \"usage\".created_at)"));
|
||||||
assert!(sql.contains("COALESCE(\"usage\".finalized_at, \"usage\".created_at)"));
|
assert!(sql.contains("usage_settlement_snapshots.finalized_at"));
|
||||||
assert!(sql.contains("EXTRACT(EPOCH FROM \"usage\".finalized_at)"));
|
|
||||||
assert!(!sql.contains("CAST(EXTRACT(EPOCH FROM created_at) AS BIGINT)"));
|
assert!(!sql.contains("CAST(EXTRACT(EPOCH FROM created_at) AS BIGINT)"));
|
||||||
assert!(!sql.contains("CAST(output_price_per_1m AS DOUBLE PRECISION)"));
|
assert!(!sql.contains("CAST(output_price_per_1m AS DOUBLE PRECISION)"));
|
||||||
}
|
}
|
||||||
@@ -2955,17 +3010,11 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn usage_sql_keeps_list_output_price_reads_legacy_only() {
|
fn usage_sql_reads_list_output_price_from_settlement_snapshots_before_legacy_usage_column() {
|
||||||
assert!(super::LIST_USAGE_AUDITS_PREFIX.contains(
|
assert!(super::LIST_USAGE_AUDITS_PREFIX
|
||||||
"CAST(\"usage\".output_price_per_1m AS DOUBLE PRECISION) AS output_price_per_1m"
|
.contains("CAST(usage_settlement_snapshots.output_price_per_1m AS DOUBLE PRECISION)"));
|
||||||
));
|
assert!(super::LIST_RECENT_USAGE_AUDITS_PREFIX
|
||||||
assert!(super::LIST_RECENT_USAGE_AUDITS_PREFIX.contains(
|
.contains("CAST(usage_settlement_snapshots.output_price_per_1m AS DOUBLE PRECISION)"));
|
||||||
"CAST(\"usage\".output_price_per_1m AS DOUBLE PRECISION) AS output_price_per_1m"
|
|
||||||
));
|
|
||||||
assert!(!super::LIST_USAGE_AUDITS_PREFIX
|
|
||||||
.contains("COALESCE(\n usage_settlement_snapshots.output_price_per_1m,"));
|
|
||||||
assert!(!super::LIST_RECENT_USAGE_AUDITS_PREFIX
|
|
||||||
.contains("COALESCE(\n usage_settlement_snapshots.output_price_per_1m,"));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -3007,11 +3056,19 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn usage_sql_preserves_legacy_output_price_column_on_upsert() {
|
fn usage_sql_clears_legacy_output_price_column_on_upsert() {
|
||||||
assert!(super::UPSERT_SQL.contains("output_price_per_1m = \"usage\".output_price_per_1m"));
|
assert!(super::UPSERT_SQL.contains("output_price_per_1m = NULL"));
|
||||||
assert!(include_str!("sql.rs").contains(".bind(None::<f64>)"));
|
assert!(include_str!("sql.rs").contains(".bind(None::<f64>)"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn usage_sql_clears_legacy_header_columns_on_upsert() {
|
||||||
|
assert!(super::UPSERT_SQL.contains("request_headers = NULL"));
|
||||||
|
assert!(super::UPSERT_SQL.contains("provider_request_headers = NULL"));
|
||||||
|
assert!(super::UPSERT_SQL.contains("response_headers = NULL"));
|
||||||
|
assert!(super::UPSERT_SQL.contains("client_response_headers = NULL"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn usage_sql_detached_body_flags_clear_inline_and_compressed_columns() {
|
fn usage_sql_detached_body_flags_clear_inline_and_compressed_columns() {
|
||||||
assert!(super::UPSERT_SQL
|
assert!(super::UPSERT_SQL
|
||||||
@@ -3040,14 +3097,18 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn prepare_usage_body_storage_keeps_small_payloads_inline() {
|
fn prepare_usage_body_storage_detaches_small_payloads_into_blob_storage() {
|
||||||
let payload = json!({"message": "hello"});
|
let payload = json!({"message": "hello"});
|
||||||
let storage = prepare_usage_body_storage(Some(&payload)).expect("storage should serialize");
|
let storage = prepare_usage_body_storage(Some(&payload)).expect("storage should serialize");
|
||||||
|
|
||||||
assert!(storage.detached_blob_bytes.is_none());
|
assert!(storage.inline_json.is_none());
|
||||||
|
let compressed = storage
|
||||||
|
.detached_blob_bytes
|
||||||
|
.as_deref()
|
||||||
|
.expect("small payload should now be ref-backed");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
storage.inline_json.as_deref(),
|
inflate_usage_json_value(compressed).expect("payload should inflate"),
|
||||||
Some(payload.to_string().as_str())
|
payload
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3748,6 +3809,16 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn usage_settlement_pricing_snapshot_with_billing_status_only_is_still_persisted() {
|
||||||
|
let snapshot = UsageSettlementPricingSnapshot {
|
||||||
|
billing_status: Some("pending".to_string()),
|
||||||
|
..UsageSettlementPricingSnapshot::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
assert!(snapshot.any_present());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn attach_usage_settlement_pricing_snapshot_metadata_adds_missing_values_without_overwriting() {
|
fn attach_usage_settlement_pricing_snapshot_metadata_adds_missing_values_without_overwriting() {
|
||||||
let metadata = attach_usage_settlement_pricing_snapshot_metadata(
|
let metadata = attach_usage_settlement_pricing_snapshot_metadata(
|
||||||
|
|||||||
@@ -183,6 +183,18 @@ export interface RequestDetail {
|
|||||||
has_response_body?: boolean
|
has_response_body?: boolean
|
||||||
has_client_response_body?: boolean
|
has_client_response_body?: boolean
|
||||||
metadata?: Record<string, unknown>
|
metadata?: Record<string, unknown>
|
||||||
|
settlement?: {
|
||||||
|
billing_snapshot?: Record<string, unknown>
|
||||||
|
billing_snapshot_schema_version?: string
|
||||||
|
billing_snapshot_status?: string
|
||||||
|
rate_multiplier?: number
|
||||||
|
is_free_tier?: boolean
|
||||||
|
input_price_per_1m?: number
|
||||||
|
output_price_per_1m?: number
|
||||||
|
cache_creation_price_per_1m?: number
|
||||||
|
cache_read_price_per_1m?: number
|
||||||
|
price_per_request?: number
|
||||||
|
} | null
|
||||||
// 阶梯计费信息
|
// 阶梯计费信息
|
||||||
tiered_pricing?: {
|
tiered_pricing?: {
|
||||||
total_input_context: number // 总输入上下文 (input + cache_read)
|
total_input_context: number // 总输入上下文 (input + cache_read)
|
||||||
|
|||||||
208
frontend/src/api/monitoring.ts
Normal file
208
frontend/src/api/monitoring.ts
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
import apiClient from './client'
|
||||||
|
import {
|
||||||
|
findMetricValueNumber,
|
||||||
|
parsePrometheusSamples,
|
||||||
|
sumMetricValues,
|
||||||
|
} from '@/utils/prometheus'
|
||||||
|
|
||||||
|
export interface AdminMonitoringSystemStatus {
|
||||||
|
timestamp: string
|
||||||
|
users: {
|
||||||
|
total: number
|
||||||
|
active: number
|
||||||
|
}
|
||||||
|
providers: {
|
||||||
|
total: number
|
||||||
|
active: number
|
||||||
|
}
|
||||||
|
api_keys: {
|
||||||
|
total: number
|
||||||
|
active: number
|
||||||
|
}
|
||||||
|
today_stats: {
|
||||||
|
requests: number
|
||||||
|
tokens: number
|
||||||
|
cost_usd: string
|
||||||
|
}
|
||||||
|
tunnel: {
|
||||||
|
proxy_connections: number
|
||||||
|
nodes: number
|
||||||
|
active_streams: number
|
||||||
|
}
|
||||||
|
internal_gateway: {
|
||||||
|
status: string
|
||||||
|
path_prefixes: string[]
|
||||||
|
}
|
||||||
|
recent_errors: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminMonitoringCircuitBreakerSummary {
|
||||||
|
state: string
|
||||||
|
provider_id?: string
|
||||||
|
provider_name?: string | null
|
||||||
|
key_name?: string | null
|
||||||
|
health_score?: number
|
||||||
|
consecutive_failures?: number
|
||||||
|
last_failure_at?: string | null
|
||||||
|
open_formats?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminMonitoringErrorStatistics {
|
||||||
|
total_errors: number
|
||||||
|
active_keys: number
|
||||||
|
degraded_keys: number
|
||||||
|
unhealthy_keys: number
|
||||||
|
open_circuit_breakers: number
|
||||||
|
circuit_breakers: Record<string, AdminMonitoringCircuitBreakerSummary>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminMonitoringRecentError {
|
||||||
|
error_id: string
|
||||||
|
error_type: string
|
||||||
|
operation: string
|
||||||
|
timestamp: string | null
|
||||||
|
context: {
|
||||||
|
request_id?: string | null
|
||||||
|
provider_id?: string | null
|
||||||
|
provider_name?: string | null
|
||||||
|
model?: string | null
|
||||||
|
api_format?: string | null
|
||||||
|
status_code?: number | null
|
||||||
|
error_message?: string | null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminMonitoringResilienceStatus {
|
||||||
|
timestamp: string
|
||||||
|
health_score: number
|
||||||
|
status: 'healthy' | 'degraded' | 'critical' | string
|
||||||
|
error_statistics: AdminMonitoringErrorStatistics
|
||||||
|
recent_errors: AdminMonitoringRecentError[]
|
||||||
|
recommendations: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminMonitoringCircuitHistoryItem {
|
||||||
|
event: string
|
||||||
|
key_id: string
|
||||||
|
provider_id: string
|
||||||
|
provider_name?: string | null
|
||||||
|
key_name?: string | null
|
||||||
|
api_format?: string | null
|
||||||
|
reason?: string | null
|
||||||
|
recovery_seconds?: number | null
|
||||||
|
timestamp?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminMonitoringCircuitHistoryResponse {
|
||||||
|
items: AdminMonitoringCircuitHistoryItem[]
|
||||||
|
count: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GatewayGateMetrics {
|
||||||
|
inFlight: number | null
|
||||||
|
availablePermits: number | null
|
||||||
|
highWatermark: number | null
|
||||||
|
rejectedTotal: number | null
|
||||||
|
unavailable: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GatewayFallbackMetricSummary {
|
||||||
|
name: string
|
||||||
|
label: string
|
||||||
|
total: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GatewayMetricsSummary {
|
||||||
|
serviceUp: number | null
|
||||||
|
local: GatewayGateMetrics
|
||||||
|
distributed: GatewayGateMetrics
|
||||||
|
tunnel: {
|
||||||
|
proxyConnections: number | null
|
||||||
|
nodes: number | null
|
||||||
|
activeStreams: number | null
|
||||||
|
}
|
||||||
|
fallbackTotal: number
|
||||||
|
fallbacks: GatewayFallbackMetricSummary[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const FALLBACK_METRICS: Array<{ name: string; label: string }> = [
|
||||||
|
{ name: 'decision_remote_total', label: '远端决策回退' },
|
||||||
|
{ name: 'plan_fallback_total', label: 'Plan 回退' },
|
||||||
|
{ name: 'control_execute_fallback_total', label: '控制执行回退' },
|
||||||
|
{ name: 'remote_execute_emergency_total', label: '紧急远端执行' },
|
||||||
|
{ name: 'local_execution_runtime_miss_total', label: '本地运行时缺失' },
|
||||||
|
]
|
||||||
|
|
||||||
|
function buildGateMetrics(
|
||||||
|
samples: ReturnType<typeof parsePrometheusSamples>,
|
||||||
|
gate: string
|
||||||
|
): GatewayGateMetrics {
|
||||||
|
return {
|
||||||
|
inFlight: findMetricValueNumber(samples, 'concurrency_in_flight', { gate }),
|
||||||
|
availablePermits: findMetricValueNumber(samples, 'concurrency_available_permits', { gate }),
|
||||||
|
highWatermark: findMetricValueNumber(samples, 'concurrency_high_watermark', { gate }),
|
||||||
|
rejectedTotal: findMetricValueNumber(samples, 'concurrency_rejected_total', { gate }),
|
||||||
|
unavailable: findMetricValueNumber(samples, 'concurrency_unavailable', { gate }) === 1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildGatewayMetricsSummary(text: string): GatewayMetricsSummary {
|
||||||
|
const samples = parsePrometheusSamples(text)
|
||||||
|
const fallbacks = FALLBACK_METRICS.map(item => ({
|
||||||
|
...item,
|
||||||
|
total: sumMetricValues(samples, item.name),
|
||||||
|
}))
|
||||||
|
|
||||||
|
return {
|
||||||
|
serviceUp: findMetricValueNumber(samples, 'service_up', { service: 'aether-gateway' }),
|
||||||
|
local: buildGateMetrics(samples, 'gateway_requests'),
|
||||||
|
distributed: buildGateMetrics(samples, 'gateway_requests_distributed'),
|
||||||
|
tunnel: {
|
||||||
|
proxyConnections: findMetricValueNumber(samples, 'tunnel_proxy_connections'),
|
||||||
|
nodes: findMetricValueNumber(samples, 'tunnel_nodes'),
|
||||||
|
activeStreams: findMetricValueNumber(samples, 'tunnel_active_streams'),
|
||||||
|
},
|
||||||
|
fallbackTotal: fallbacks.reduce((total, item) => total + item.total, 0),
|
||||||
|
fallbacks,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchGatewayMetricsText(): Promise<string> {
|
||||||
|
const response = await apiClient.get<string>('/_gateway/metrics', {
|
||||||
|
responseType: 'text',
|
||||||
|
transformResponse: [(data: string) => data],
|
||||||
|
})
|
||||||
|
return typeof response.data === 'string' ? response.data : String(response.data ?? '')
|
||||||
|
}
|
||||||
|
|
||||||
|
export const monitoringApi = {
|
||||||
|
async getSystemStatus(): Promise<AdminMonitoringSystemStatus> {
|
||||||
|
const response = await apiClient.get<AdminMonitoringSystemStatus>(
|
||||||
|
'/api/admin/monitoring/system-status'
|
||||||
|
)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
async getResilienceStatus(): Promise<AdminMonitoringResilienceStatus> {
|
||||||
|
const response = await apiClient.get<AdminMonitoringResilienceStatus>(
|
||||||
|
'/api/admin/monitoring/resilience-status'
|
||||||
|
)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
async getCircuitHistory(limit = 10): Promise<AdminMonitoringCircuitHistoryResponse> {
|
||||||
|
const response = await apiClient.get<AdminMonitoringCircuitHistoryResponse>(
|
||||||
|
'/api/admin/monitoring/resilience/circuit-history',
|
||||||
|
{ params: { limit } }
|
||||||
|
)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
async getGatewayMetricsText(): Promise<string> {
|
||||||
|
return fetchGatewayMetricsText()
|
||||||
|
},
|
||||||
|
|
||||||
|
async getGatewayMetricsSummary(): Promise<GatewayMetricsSummary> {
|
||||||
|
return buildGatewayMetricsSummary(await fetchGatewayMetricsText())
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -855,8 +855,13 @@ const traceRequestMetadata = computed<Record<string, unknown> | null>(() => {
|
|||||||
return meta as Record<string, unknown>
|
return meta as Record<string, unknown>
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const settlementInfo = computed<JsonRecord | null>(() =>
|
||||||
|
asRecord(detail.value?.settlement ?? null),
|
||||||
|
)
|
||||||
|
|
||||||
const billingSnapshot = computed<JsonRecord | null>(() =>
|
const billingSnapshot = computed<JsonRecord | null>(() =>
|
||||||
asRecord(traceRequestMetadata.value?.billing_snapshot),
|
asRecord(settlementInfo.value?.billing_snapshot)
|
||||||
|
?? asRecord(traceRequestMetadata.value?.billing_snapshot),
|
||||||
)
|
)
|
||||||
|
|
||||||
const billingResolvedVariables = computed<JsonRecord | null>(() =>
|
const billingResolvedVariables = computed<JsonRecord | null>(() =>
|
||||||
|
|||||||
@@ -3016,6 +3016,194 @@ mockHandlers['GET /api/admin/stats/errors/distribution'] = async () => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
mockHandlers['GET /api/admin/monitoring/system-status'] = async () => {
|
||||||
|
await delay()
|
||||||
|
requireAdmin()
|
||||||
|
return createMockResponse({
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
users: {
|
||||||
|
total: 124,
|
||||||
|
active: 111
|
||||||
|
},
|
||||||
|
providers: {
|
||||||
|
total: 18,
|
||||||
|
active: 15
|
||||||
|
},
|
||||||
|
api_keys: {
|
||||||
|
total: 263,
|
||||||
|
active: 241
|
||||||
|
},
|
||||||
|
today_stats: {
|
||||||
|
requests: 12483,
|
||||||
|
tokens: 48751234,
|
||||||
|
cost_usd: '$182.4631'
|
||||||
|
},
|
||||||
|
tunnel: {
|
||||||
|
proxy_connections: 28,
|
||||||
|
nodes: 6,
|
||||||
|
active_streams: 164
|
||||||
|
},
|
||||||
|
internal_gateway: {
|
||||||
|
status: 'rust_native_control_plane',
|
||||||
|
path_prefixes: ['/api/', '/v1/', '/v1beta/', '/_gateway/']
|
||||||
|
},
|
||||||
|
recent_errors: 9
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
mockHandlers['GET /api/admin/monitoring/resilience-status'] = async () => {
|
||||||
|
await delay()
|
||||||
|
requireAdmin()
|
||||||
|
return createMockResponse({
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
health_score: 86,
|
||||||
|
status: 'healthy',
|
||||||
|
error_statistics: {
|
||||||
|
total_errors: 14,
|
||||||
|
active_keys: 24,
|
||||||
|
degraded_keys: 3,
|
||||||
|
unhealthy_keys: 1,
|
||||||
|
open_circuit_breakers: 1,
|
||||||
|
circuit_breakers: {
|
||||||
|
'provider-key-1': {
|
||||||
|
state: 'open',
|
||||||
|
provider_id: 'provider-openai',
|
||||||
|
provider_name: 'OpenAI',
|
||||||
|
key_name: 'prod-key-a',
|
||||||
|
health_score: 0.42,
|
||||||
|
consecutive_failures: 4,
|
||||||
|
last_failure_at: new Date(Date.now() - 8 * 60 * 1000).toISOString(),
|
||||||
|
open_formats: ['openai:chat']
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
recent_errors: [
|
||||||
|
{
|
||||||
|
error_id: 'usage-request-1',
|
||||||
|
error_type: 'timeout',
|
||||||
|
operation: 'OpenAI:openai:chat',
|
||||||
|
timestamp: new Date(Date.now() - 3 * 60 * 1000).toISOString(),
|
||||||
|
context: {
|
||||||
|
request_id: 'req-live-001',
|
||||||
|
provider_id: 'provider-openai',
|
||||||
|
provider_name: 'OpenAI',
|
||||||
|
model: 'gpt-5',
|
||||||
|
api_format: 'openai:chat',
|
||||||
|
status_code: 504,
|
||||||
|
error_message: '上游响应超时,等待首字节超过阈值'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
error_id: 'usage-request-2',
|
||||||
|
error_type: 'server_error',
|
||||||
|
operation: 'Anthropic:claude:chat',
|
||||||
|
timestamp: new Date(Date.now() - 11 * 60 * 1000).toISOString(),
|
||||||
|
context: {
|
||||||
|
request_id: 'req-live-002',
|
||||||
|
provider_id: 'provider-anthropic',
|
||||||
|
provider_name: 'Anthropic',
|
||||||
|
model: 'claude-sonnet-4-5',
|
||||||
|
api_format: 'claude:chat',
|
||||||
|
status_code: 502,
|
||||||
|
error_message: '上游返回 502 Bad Gateway'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
recommendations: [
|
||||||
|
'以下服务熔断器已打开:OpenAI/prod-key-a',
|
||||||
|
'建议检查最近的 timeout 与 5xx 错误峰值',
|
||||||
|
'当前整体健康度可接受,但需要关注单 Key 退化'
|
||||||
|
]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
mockHandlers['GET /api/admin/monitoring/resilience/circuit-history'] = async () => {
|
||||||
|
await delay()
|
||||||
|
requireAdmin()
|
||||||
|
return createMockResponse({
|
||||||
|
count: 2,
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
event: 'opened',
|
||||||
|
key_id: 'provider-key-1',
|
||||||
|
provider_id: 'provider-openai',
|
||||||
|
provider_name: 'OpenAI',
|
||||||
|
key_name: 'prod-key-a',
|
||||||
|
api_format: 'openai:chat',
|
||||||
|
reason: '错误率过高',
|
||||||
|
recovery_seconds: 300,
|
||||||
|
timestamp: new Date(Date.now() - 10 * 60 * 1000).toISOString()
|
||||||
|
},
|
||||||
|
{
|
||||||
|
event: 'half_open',
|
||||||
|
key_id: 'provider-key-7',
|
||||||
|
provider_id: 'provider-gemini',
|
||||||
|
provider_name: 'Gemini',
|
||||||
|
key_name: 'gemini-burst',
|
||||||
|
api_format: 'gemini:chat',
|
||||||
|
reason: '正在探测恢复',
|
||||||
|
recovery_seconds: 120,
|
||||||
|
timestamp: new Date(Date.now() - 22 * 60 * 1000).toISOString()
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
mockHandlers['GET /_gateway/metrics'] = async () => {
|
||||||
|
await delay(60)
|
||||||
|
requireAdmin()
|
||||||
|
return createMockResponse(`# HELP aether_gateway_service_up Whether the service process is currently up.
|
||||||
|
# TYPE aether_gateway_service_up gauge
|
||||||
|
aether_gateway_service_up{service="aether-gateway"} 1
|
||||||
|
# HELP aether_gateway_concurrency_in_flight Current number of in-flight operations guarded by the concurrency gate.
|
||||||
|
# TYPE aether_gateway_concurrency_in_flight gauge
|
||||||
|
aether_gateway_concurrency_in_flight{gate="gateway_requests"} 82
|
||||||
|
# HELP aether_gateway_concurrency_available_permits Currently available permits for the concurrency gate.
|
||||||
|
# TYPE aether_gateway_concurrency_available_permits gauge
|
||||||
|
aether_gateway_concurrency_available_permits{gate="gateway_requests"} 174
|
||||||
|
# HELP aether_gateway_concurrency_high_watermark Highest observed in-flight count for the concurrency gate.
|
||||||
|
# TYPE aether_gateway_concurrency_high_watermark gauge
|
||||||
|
aether_gateway_concurrency_high_watermark{gate="gateway_requests"} 121
|
||||||
|
# HELP aether_gateway_concurrency_rejected_total Number of operations rejected by the concurrency gate.
|
||||||
|
# TYPE aether_gateway_concurrency_rejected_total counter
|
||||||
|
aether_gateway_concurrency_rejected_total{gate="gateway_requests"} 6
|
||||||
|
# HELP aether_gateway_concurrency_in_flight Current number of in-flight operations guarded by the concurrency gate.
|
||||||
|
# TYPE aether_gateway_concurrency_in_flight gauge
|
||||||
|
aether_gateway_concurrency_in_flight{gate="gateway_requests_distributed"} 94
|
||||||
|
# HELP aether_gateway_concurrency_available_permits Currently available permits for the concurrency gate.
|
||||||
|
# TYPE aether_gateway_concurrency_available_permits gauge
|
||||||
|
aether_gateway_concurrency_available_permits{gate="gateway_requests_distributed"} 418
|
||||||
|
# HELP aether_gateway_concurrency_high_watermark Highest observed in-flight count for the concurrency gate.
|
||||||
|
# TYPE aether_gateway_concurrency_high_watermark gauge
|
||||||
|
aether_gateway_concurrency_high_watermark{gate="gateway_requests_distributed"} 137
|
||||||
|
# HELP aether_gateway_concurrency_rejected_total Number of operations rejected by the concurrency gate.
|
||||||
|
# TYPE aether_gateway_concurrency_rejected_total counter
|
||||||
|
aether_gateway_concurrency_rejected_total{gate="gateway_requests_distributed"} 11
|
||||||
|
# HELP aether_gateway_tunnel_proxy_connections Current number of connected proxy sockets.
|
||||||
|
# TYPE aether_gateway_tunnel_proxy_connections gauge
|
||||||
|
aether_gateway_tunnel_proxy_connections 28
|
||||||
|
# HELP aether_gateway_tunnel_nodes Current number of connected logical nodes.
|
||||||
|
# TYPE aether_gateway_tunnel_nodes gauge
|
||||||
|
aether_gateway_tunnel_nodes 6
|
||||||
|
# HELP aether_gateway_tunnel_active_streams Current number of active local relay streams.
|
||||||
|
# TYPE aether_gateway_tunnel_active_streams gauge
|
||||||
|
aether_gateway_tunnel_active_streams 164
|
||||||
|
# HELP aether_gateway_decision_remote_total Number of requests that fell back to Python decision endpoints.
|
||||||
|
# TYPE aether_gateway_decision_remote_total counter
|
||||||
|
aether_gateway_decision_remote_total{route_kind="chat",reason="local_decision_miss"} 4
|
||||||
|
aether_gateway_decision_remote_total{route_kind="responses",reason="remote_decision_miss"} 2
|
||||||
|
# HELP aether_gateway_plan_fallback_total Number of requests that fell back to Python plan endpoints.
|
||||||
|
# TYPE aether_gateway_plan_fallback_total counter
|
||||||
|
aether_gateway_plan_fallback_total{route_kind="chat",reason="scheduler_decision_unsupported"} 3
|
||||||
|
# HELP aether_gateway_control_execute_fallback_total Number of requests that fell back to Python control execution.
|
||||||
|
# TYPE aether_gateway_control_execute_fallback_total counter
|
||||||
|
aether_gateway_control_execute_fallback_total{route_kind="chat",reason="control_execute_emergency"} 1
|
||||||
|
# HELP aether_gateway_remote_execute_emergency_total Number of requests that used remote emergency execution fallback.
|
||||||
|
# TYPE aether_gateway_remote_execute_emergency_total counter
|
||||||
|
aether_gateway_remote_execute_emergency_total{route_kind="chat",reason="control_execute_emergency"} 2
|
||||||
|
`)
|
||||||
|
}
|
||||||
|
|
||||||
mockHandlers['GET /api/admin/stats/comparison'] = async () => {
|
mockHandlers['GET /api/admin/stats/comparison'] = async () => {
|
||||||
await delay()
|
await delay()
|
||||||
requireAdmin()
|
requireAdmin()
|
||||||
|
|||||||
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, '\\')
|
||||||
|
}
|
||||||
@@ -1,18 +1,478 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="space-y-6 px-4 sm:px-6 lg:px-0">
|
<div class="space-y-6 px-4 sm:px-6 lg:px-0">
|
||||||
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
|
<div class="flex flex-col gap-3 xl:flex-row xl:items-center xl:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 class="text-lg font-semibold">
|
<h1 class="text-lg font-semibold">
|
||||||
性能分析
|
性能分析
|
||||||
</h1>
|
</h1>
|
||||||
<p class="text-xs text-muted-foreground">
|
<p class="text-xs text-muted-foreground">
|
||||||
延迟分布与错误统计
|
实时性能监控与历史延迟趋势
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<TimeRangePicker v-model="timeRange" />
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<Badge variant="outline">
|
||||||
|
实时 10s 刷新
|
||||||
|
</Badge>
|
||||||
|
<span class="text-xs text-muted-foreground">
|
||||||
|
上次更新 {{ liveLastUpdatedLabel }}
|
||||||
|
</span>
|
||||||
|
<RefreshButton
|
||||||
|
:loading="isRefreshing"
|
||||||
|
title="刷新实时与历史性能数据"
|
||||||
|
@click="handleManualRefresh"
|
||||||
|
/>
|
||||||
|
<TimeRangePicker v-model="timeRange" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
<Card class="overflow-hidden">
|
||||||
|
<div class="border-b border-border/70 bg-muted/20 px-4 py-3 sm:px-5">
|
||||||
|
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-sm font-semibold">
|
||||||
|
实时性能面板
|
||||||
|
</h2>
|
||||||
|
<p class="text-xs text-muted-foreground">
|
||||||
|
聚合系统状态、并发门、Tunnel 与 fallback 指标
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<Badge :variant="healthStatusVariant">
|
||||||
|
{{ healthStatusText }}
|
||||||
|
</Badge>
|
||||||
|
<Badge variant="outline">
|
||||||
|
{{ metricsAvailabilityText }}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="p-4 sm:p-5">
|
||||||
|
<div
|
||||||
|
v-if="liveLoading && !liveReady"
|
||||||
|
class="py-6"
|
||||||
|
>
|
||||||
|
<LoadingState message="加载实时性能数据中" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-else-if="!liveReady"
|
||||||
|
class="rounded-xl border border-dashed border-border/70 bg-muted/15 px-4 py-6 text-sm text-muted-foreground"
|
||||||
|
>
|
||||||
|
实时性能数据暂不可用,请稍后重试。
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="space-y-4"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-if="liveLoadError"
|
||||||
|
class="rounded-lg border border-yellow-300/70 bg-yellow-50/80 px-3 py-2 text-xs text-yellow-900 dark:border-yellow-900/60 dark:bg-yellow-950/30 dark:text-yellow-100"
|
||||||
|
>
|
||||||
|
{{ liveLoadError }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-6">
|
||||||
|
<div
|
||||||
|
v-for="card in liveSummaryCards"
|
||||||
|
:key="card.title"
|
||||||
|
class="rounded-xl border border-border/70 bg-card/70 px-4 py-3"
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between gap-3">
|
||||||
|
<span class="text-xs text-muted-foreground">{{ card.title }}</span>
|
||||||
|
<component
|
||||||
|
:is="card.icon"
|
||||||
|
class="h-4 w-4"
|
||||||
|
:class="card.iconClass"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="mt-3 text-2xl font-semibold tracking-tight">
|
||||||
|
{{ card.value }}
|
||||||
|
</div>
|
||||||
|
<div class="mt-2 text-xs text-muted-foreground">
|
||||||
|
{{ card.hint }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 gap-4 xl:grid-cols-3">
|
||||||
|
<div class="grid grid-cols-1 gap-4 lg:grid-cols-2 xl:col-span-2">
|
||||||
|
<section class="rounded-xl border border-border/70 bg-card/60 p-4">
|
||||||
|
<div class="flex items-center justify-between gap-3">
|
||||||
|
<h3 class="text-sm font-semibold">
|
||||||
|
本地并发门
|
||||||
|
</h3>
|
||||||
|
<Badge variant="outline">
|
||||||
|
gateway_requests
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div class="mt-4 grid grid-cols-2 gap-3 text-sm">
|
||||||
|
<div>
|
||||||
|
<div class="text-xs text-muted-foreground">
|
||||||
|
In Flight
|
||||||
|
</div>
|
||||||
|
<div class="mt-1 text-lg font-semibold">
|
||||||
|
{{ formatMetricNumber(gatewayMetrics?.local.inFlight) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="text-xs text-muted-foreground">
|
||||||
|
Available
|
||||||
|
</div>
|
||||||
|
<div class="mt-1 text-lg font-semibold">
|
||||||
|
{{ formatMetricNumber(gatewayMetrics?.local.availablePermits) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="text-xs text-muted-foreground">
|
||||||
|
High Watermark
|
||||||
|
</div>
|
||||||
|
<div class="mt-1 text-lg font-semibold">
|
||||||
|
{{ formatMetricNumber(gatewayMetrics?.local.highWatermark) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="text-xs text-muted-foreground">
|
||||||
|
Rejected Total
|
||||||
|
</div>
|
||||||
|
<div class="mt-1 text-lg font-semibold">
|
||||||
|
{{ formatMetricNumber(gatewayMetrics?.local.rejectedTotal) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="rounded-xl border border-border/70 bg-card/60 p-4">
|
||||||
|
<div class="flex items-center justify-between gap-3">
|
||||||
|
<h3 class="text-sm font-semibold">
|
||||||
|
分布式并发门
|
||||||
|
</h3>
|
||||||
|
<Badge :variant="distributedGateVariant">
|
||||||
|
{{ distributedGateText }}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div class="mt-4 grid grid-cols-2 gap-3 text-sm">
|
||||||
|
<div>
|
||||||
|
<div class="text-xs text-muted-foreground">
|
||||||
|
In Flight
|
||||||
|
</div>
|
||||||
|
<div class="mt-1 text-lg font-semibold">
|
||||||
|
{{ formatMetricNumber(gatewayMetrics?.distributed.inFlight) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="text-xs text-muted-foreground">
|
||||||
|
Available
|
||||||
|
</div>
|
||||||
|
<div class="mt-1 text-lg font-semibold">
|
||||||
|
{{ formatMetricNumber(gatewayMetrics?.distributed.availablePermits) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="text-xs text-muted-foreground">
|
||||||
|
High Watermark
|
||||||
|
</div>
|
||||||
|
<div class="mt-1 text-lg font-semibold">
|
||||||
|
{{ formatMetricNumber(gatewayMetrics?.distributed.highWatermark) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="text-xs text-muted-foreground">
|
||||||
|
Rejected Total
|
||||||
|
</div>
|
||||||
|
<div class="mt-1 text-lg font-semibold">
|
||||||
|
{{ formatMetricNumber(gatewayMetrics?.distributed.rejectedTotal) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p
|
||||||
|
v-if="gatewayMetrics?.distributed.unavailable"
|
||||||
|
class="mt-3 text-xs text-yellow-700 dark:text-yellow-300"
|
||||||
|
>
|
||||||
|
Redis 分布式并发快照当前不可用,需要检查 gate 后端连接。
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="rounded-xl border border-border/70 bg-card/60 p-4">
|
||||||
|
<div class="flex items-center justify-between gap-3">
|
||||||
|
<h3 class="text-sm font-semibold">
|
||||||
|
Tunnel / 代理
|
||||||
|
</h3>
|
||||||
|
<Badge variant="outline">
|
||||||
|
实时连接
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div class="mt-4 grid grid-cols-2 gap-3 text-sm">
|
||||||
|
<div>
|
||||||
|
<div class="text-xs text-muted-foreground">
|
||||||
|
Nodes
|
||||||
|
</div>
|
||||||
|
<div class="mt-1 text-lg font-semibold">
|
||||||
|
{{ formatMetricNumber(currentTunnelNodes) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="text-xs text-muted-foreground">
|
||||||
|
Proxy Connections
|
||||||
|
</div>
|
||||||
|
<div class="mt-1 text-lg font-semibold">
|
||||||
|
{{ formatMetricNumber(currentProxyConnections) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="text-xs text-muted-foreground">
|
||||||
|
Active Streams
|
||||||
|
</div>
|
||||||
|
<div class="mt-1 text-lg font-semibold">
|
||||||
|
{{ formatMetricNumber(currentActiveStreams) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="text-xs text-muted-foreground">
|
||||||
|
Service Up
|
||||||
|
</div>
|
||||||
|
<div class="mt-1 text-lg font-semibold">
|
||||||
|
{{ gatewayMetrics?.serviceUp === 1 ? '在线' : '未知' }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="rounded-xl border border-border/70 bg-card/60 p-4">
|
||||||
|
<div class="flex items-center justify-between gap-3">
|
||||||
|
<h3 class="text-sm font-semibold">
|
||||||
|
今日请求概况
|
||||||
|
</h3>
|
||||||
|
<Badge variant="outline">
|
||||||
|
{{ systemStatus?.internal_gateway.status || 'gateway' }}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div class="mt-4 grid grid-cols-2 gap-3 text-sm">
|
||||||
|
<div>
|
||||||
|
<div class="text-xs text-muted-foreground">
|
||||||
|
Requests
|
||||||
|
</div>
|
||||||
|
<div class="mt-1 text-lg font-semibold">
|
||||||
|
{{ formatMetricNumber(systemStatus?.today_stats.requests) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="text-xs text-muted-foreground">
|
||||||
|
Tokens
|
||||||
|
</div>
|
||||||
|
<div class="mt-1 text-lg font-semibold">
|
||||||
|
{{ formatTokens(systemStatus?.today_stats.tokens) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="text-xs text-muted-foreground">
|
||||||
|
Cost
|
||||||
|
</div>
|
||||||
|
<div class="mt-1 text-lg font-semibold">
|
||||||
|
{{ systemStatus?.today_stats.cost_usd || '-' }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="text-xs text-muted-foreground">
|
||||||
|
Active Providers / Keys
|
||||||
|
</div>
|
||||||
|
<div class="mt-1 text-lg font-semibold">
|
||||||
|
{{ providerAndKeySummary }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="rounded-xl border border-border/70 bg-card/60 p-4">
|
||||||
|
<div class="flex items-center justify-between gap-3">
|
||||||
|
<h3 class="text-sm font-semibold">
|
||||||
|
Fallback 统计
|
||||||
|
</h3>
|
||||||
|
<span class="text-xs text-muted-foreground">
|
||||||
|
总计 {{ formatMetricNumber(gatewayMetrics?.fallbackTotal) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="!fallbackRows.length"
|
||||||
|
class="mt-4 rounded-lg border border-dashed border-border/70 px-3 py-4 text-sm text-muted-foreground"
|
||||||
|
>
|
||||||
|
当前没有记录到 fallback 计数。
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="mt-4 space-y-3"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-for="item in fallbackRows"
|
||||||
|
:key="item.name"
|
||||||
|
class="rounded-lg border border-border/60 bg-background/50 px-3 py-3"
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between gap-3">
|
||||||
|
<span class="text-sm font-medium">{{ item.label }}</span>
|
||||||
|
<span class="text-sm font-semibold">{{ formatMetricNumber(item.total) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="mt-2 h-2 overflow-hidden rounded-full bg-muted">
|
||||||
|
<div
|
||||||
|
class="h-full rounded-full bg-primary/80"
|
||||||
|
:style="{ width: `${Math.max(item.ratio, 8)}%` }"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 gap-4 xl:grid-cols-2">
|
||||||
|
<section class="rounded-xl border border-border/70 bg-card/60 p-4">
|
||||||
|
<div class="flex items-center justify-between gap-3">
|
||||||
|
<h3 class="text-sm font-semibold">
|
||||||
|
最近错误
|
||||||
|
</h3>
|
||||||
|
<span class="text-xs text-muted-foreground">
|
||||||
|
{{ formatMetricNumber(resilienceStatus?.error_statistics.total_errors) }} / 24h
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="!recentErrors.length"
|
||||||
|
class="mt-4 rounded-lg border border-dashed border-border/70 px-3 py-4 text-sm text-muted-foreground"
|
||||||
|
>
|
||||||
|
当前没有最近错误。
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="mt-4 space-y-3"
|
||||||
|
>
|
||||||
|
<article
|
||||||
|
v-for="item in recentErrors"
|
||||||
|
:key="item.error_id"
|
||||||
|
class="rounded-lg border border-border/60 bg-background/50 px-3 py-3"
|
||||||
|
>
|
||||||
|
<div class="flex items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<div class="text-sm font-medium">
|
||||||
|
{{ item.error_type }}
|
||||||
|
</div>
|
||||||
|
<div class="mt-1 text-xs text-muted-foreground">
|
||||||
|
{{ item.operation }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span class="shrink-0 text-xs text-muted-foreground">
|
||||||
|
{{ formatDate(item.timestamp) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-2 flex flex-wrap gap-2">
|
||||||
|
<Badge variant="outline">
|
||||||
|
HTTP {{ item.context.status_code ?? '-' }}
|
||||||
|
</Badge>
|
||||||
|
<Badge variant="outline">
|
||||||
|
{{ item.context.provider_name || item.context.provider_id || '未知 Provider' }}
|
||||||
|
</Badge>
|
||||||
|
<Badge variant="outline">
|
||||||
|
{{ item.context.api_format || item.context.model || '未知格式' }}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p
|
||||||
|
v-if="item.context.error_message"
|
||||||
|
class="mt-2 break-words text-xs text-muted-foreground"
|
||||||
|
>
|
||||||
|
{{ item.context.error_message }}
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="rounded-xl border border-border/70 bg-card/60 p-4">
|
||||||
|
<div class="flex items-center justify-between gap-3">
|
||||||
|
<h3 class="text-sm font-semibold">
|
||||||
|
熔断历史与建议
|
||||||
|
</h3>
|
||||||
|
<span class="text-xs text-muted-foreground">
|
||||||
|
开路 {{ formatMetricNumber(resilienceStatus?.error_statistics.open_circuit_breakers) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="!circuitHistory.length"
|
||||||
|
class="mt-4 rounded-lg border border-dashed border-border/70 px-3 py-4 text-sm text-muted-foreground"
|
||||||
|
>
|
||||||
|
当前没有熔断事件。
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="mt-4 space-y-3"
|
||||||
|
>
|
||||||
|
<article
|
||||||
|
v-for="item in circuitHistory"
|
||||||
|
:key="`${item.key_id}-${item.api_format}-${item.timestamp}`"
|
||||||
|
class="rounded-lg border border-border/60 bg-background/50 px-3 py-3"
|
||||||
|
>
|
||||||
|
<div class="flex items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<span class="text-sm font-medium">
|
||||||
|
{{ item.provider_name || item.provider_id }}
|
||||||
|
</span>
|
||||||
|
<Badge :variant="item.event === 'opened' ? 'destructive' : 'warning'">
|
||||||
|
{{ item.event === 'opened' ? '已打开' : '半开' }}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div class="mt-1 text-xs text-muted-foreground">
|
||||||
|
{{ item.key_name || item.key_id }} · {{ item.api_format || '未知格式' }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span class="shrink-0 text-xs text-muted-foreground">
|
||||||
|
{{ formatDate(item.timestamp) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-2 text-xs text-muted-foreground">
|
||||||
|
原因:{{ item.reason || '未提供' }}
|
||||||
|
</div>
|
||||||
|
<div class="mt-1 text-xs text-muted-foreground">
|
||||||
|
恢复窗口:{{ item.recovery_seconds != null ? `${item.recovery_seconds}s` : '-' }}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4 border-t border-border/70 pt-4">
|
||||||
|
<h4 class="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||||
|
建议
|
||||||
|
</h4>
|
||||||
|
<ul
|
||||||
|
v-if="resilienceRecommendations.length"
|
||||||
|
class="mt-3 space-y-2 text-sm"
|
||||||
|
>
|
||||||
|
<li
|
||||||
|
v-for="item in resilienceRecommendations"
|
||||||
|
:key="item"
|
||||||
|
class="rounded-lg border border-border/60 bg-background/50 px-3 py-2"
|
||||||
|
>
|
||||||
|
{{ item }}
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="mt-3 text-sm text-muted-foreground"
|
||||||
|
>
|
||||||
|
当前没有额外运维建议。
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||||
<Card class="p-4">
|
<Card class="p-4">
|
||||||
<PercentileChart
|
<PercentileChart
|
||||||
title="响应延迟百分位"
|
title="响应延迟百分位"
|
||||||
@@ -31,7 +491,7 @@
|
|||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
<div class="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||||
<Card class="p-4">
|
<Card class="p-4">
|
||||||
<ErrorDistributionChart
|
<ErrorDistributionChart
|
||||||
title="错误分布"
|
title="错误分布"
|
||||||
@@ -39,7 +499,7 @@
|
|||||||
:loading="errorLoading"
|
:loading="errorLoading"
|
||||||
/>
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
<Card class="p-4 space-y-3">
|
<Card class="space-y-3 p-4">
|
||||||
<h3 class="text-sm font-semibold">
|
<h3 class="text-sm font-semibold">
|
||||||
错误趋势
|
错误趋势
|
||||||
</h3>
|
</h3>
|
||||||
@@ -58,7 +518,7 @@
|
|||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card class="p-4 space-y-3">
|
<Card class="space-y-3 p-4">
|
||||||
<h3 class="text-sm font-semibold">
|
<h3 class="text-sm font-semibold">
|
||||||
提供商健康度
|
提供商健康度
|
||||||
</h3>
|
</h3>
|
||||||
@@ -69,39 +529,68 @@
|
|||||||
<LoadingState />
|
<LoadingState />
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-else
|
v-else-if="providerStatus.length"
|
||||||
class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3 text-sm"
|
class="grid grid-cols-1 gap-3 text-sm sm:grid-cols-2 lg:grid-cols-3"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
v-for="provider in providerStatus"
|
v-for="provider in providerStatus"
|
||||||
:key="provider.name"
|
:key="provider.name"
|
||||||
class="p-3 border rounded-lg"
|
class="rounded-lg border p-3"
|
||||||
>
|
>
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<span class="font-medium">{{ provider.name }}</span>
|
<span class="font-medium">{{ provider.name }}</span>
|
||||||
<span class="text-xs text-muted-foreground">{{ provider.requests }} 请求</span>
|
<span class="text-xs text-muted-foreground">{{ provider.requests }} 请求</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-xs text-muted-foreground mt-1">
|
<div class="mt-1 text-xs text-muted-foreground">
|
||||||
状态: {{ provider.status }}
|
状态: {{ provider.status }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="rounded-lg border border-dashed border-border/70 px-3 py-4 text-sm text-muted-foreground"
|
||||||
|
>
|
||||||
|
当前没有提供商状态数据。
|
||||||
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
|
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||||
import Card from '@/components/ui/card.vue'
|
import {
|
||||||
import { LoadingState, TimeRangePicker } from '@/components/common'
|
Activity,
|
||||||
import { ErrorDistributionChart, PercentileChart } from '@/components/stats'
|
AlertTriangle,
|
||||||
import LineChart from '@/components/charts/LineChart.vue'
|
Cable,
|
||||||
|
GitBranch,
|
||||||
|
ShieldCheck,
|
||||||
|
Workflow,
|
||||||
|
} from 'lucide-vue-next'
|
||||||
import { adminApi, type ErrorDistributionResponse, type PercentileItem } from '@/api/admin'
|
import { adminApi, type ErrorDistributionResponse, type PercentileItem } from '@/api/admin'
|
||||||
import { dashboardApi, type ProviderStatus } from '@/api/dashboard'
|
import { dashboardApi, type ProviderStatus } from '@/api/dashboard'
|
||||||
|
import {
|
||||||
|
monitoringApi,
|
||||||
|
type AdminMonitoringCircuitHistoryItem,
|
||||||
|
type AdminMonitoringResilienceStatus,
|
||||||
|
type AdminMonitoringSystemStatus,
|
||||||
|
type GatewayMetricsSummary,
|
||||||
|
} from '@/api/monitoring'
|
||||||
|
import LineChart from '@/components/charts/LineChart.vue'
|
||||||
|
import { LoadingState, TimeRangePicker } from '@/components/common'
|
||||||
|
import { ErrorDistributionChart, PercentileChart } from '@/components/stats'
|
||||||
|
import Badge from '@/components/ui/badge.vue'
|
||||||
|
import Card from '@/components/ui/card.vue'
|
||||||
|
import RefreshButton from '@/components/ui/refresh-button.vue'
|
||||||
|
import { useToast } from '@/composables/useToast'
|
||||||
import { getDateRangeFromPeriod } from '@/features/usage/composables'
|
import { getDateRangeFromPeriod } from '@/features/usage/composables'
|
||||||
import type { DateRangeParams } from '@/features/usage/types'
|
import type { DateRangeParams } from '@/features/usage/types'
|
||||||
|
import { formatDate, formatNumber, formatTokens } from '@/utils/format'
|
||||||
|
import { log } from '@/utils/logger'
|
||||||
|
|
||||||
|
const LIVE_REFRESH_INTERVAL_MS = 10_000
|
||||||
|
|
||||||
const timeRange = ref<DateRangeParams>(getDateRangeFromPeriod('last30days'))
|
const timeRange = ref<DateRangeParams>(getDateRangeFromPeriod('last30days'))
|
||||||
|
const { error: showError } = useToast()
|
||||||
|
|
||||||
const percentiles = ref<PercentileItem[]>([])
|
const percentiles = ref<PercentileItem[]>([])
|
||||||
const percentileLoading = ref(false)
|
const percentileLoading = ref(false)
|
||||||
@@ -112,12 +601,25 @@ const errorLoading = ref(false)
|
|||||||
|
|
||||||
const providerStatus = ref<ProviderStatus[]>([])
|
const providerStatus = ref<ProviderStatus[]>([])
|
||||||
const providerLoading = ref(false)
|
const providerLoading = ref(false)
|
||||||
|
|
||||||
|
const systemStatus = ref<AdminMonitoringSystemStatus | null>(null)
|
||||||
|
const resilienceStatus = ref<AdminMonitoringResilienceStatus | null>(null)
|
||||||
|
const circuitHistory = ref<AdminMonitoringCircuitHistoryItem[]>([])
|
||||||
|
const gatewayMetrics = ref<GatewayMetricsSummary | null>(null)
|
||||||
|
const liveLoading = ref(false)
|
||||||
|
const liveRefreshing = ref(false)
|
||||||
|
const liveReady = ref(false)
|
||||||
|
const liveLoadError = ref<string | null>(null)
|
||||||
|
const liveLastUpdatedAt = ref<string | null>(null)
|
||||||
|
|
||||||
let percentilesRequestId = 0
|
let percentilesRequestId = 0
|
||||||
let errorsRequestId = 0
|
let errorsRequestId = 0
|
||||||
let providersRequestId = 0
|
let providersRequestId = 0
|
||||||
|
let liveRequestId = 0
|
||||||
let loadAllPromise: Promise<void> | null = null
|
let loadAllPromise: Promise<void> | null = null
|
||||||
let hasPendingLoadAll = false
|
let hasPendingLoadAll = false
|
||||||
let loadAllDebounceTimer: ReturnType<typeof setTimeout> | null = null
|
let loadAllDebounceTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
let liveRefreshTimer: ReturnType<typeof setInterval> | null = null
|
||||||
|
|
||||||
function buildTimeRangeParams() {
|
function buildTimeRangeParams() {
|
||||||
return {
|
return {
|
||||||
@@ -129,6 +631,18 @@ function buildTimeRangeParams() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatMetricNumber(value: number | null | undefined): string {
|
||||||
|
if (value == null || Number.isNaN(value)) {
|
||||||
|
return '-'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Number.isInteger(value)) {
|
||||||
|
return value.toFixed(2)
|
||||||
|
}
|
||||||
|
|
||||||
|
return formatNumber(value)
|
||||||
|
}
|
||||||
|
|
||||||
async function loadPercentiles() {
|
async function loadPercentiles() {
|
||||||
const requestId = ++percentilesRequestId
|
const requestId = ++percentilesRequestId
|
||||||
percentileLoading.value = true
|
percentileLoading.value = true
|
||||||
@@ -136,6 +650,10 @@ async function loadPercentiles() {
|
|||||||
const data = await adminApi.getPercentiles(buildTimeRangeParams())
|
const data = await adminApi.getPercentiles(buildTimeRangeParams())
|
||||||
if (requestId !== percentilesRequestId) return
|
if (requestId !== percentilesRequestId) return
|
||||||
percentiles.value = data
|
percentiles.value = data
|
||||||
|
} catch (error) {
|
||||||
|
if (requestId !== percentilesRequestId) return
|
||||||
|
percentiles.value = []
|
||||||
|
log.error('加载延迟百分位失败', error)
|
||||||
} finally {
|
} finally {
|
||||||
if (requestId === percentilesRequestId) {
|
if (requestId === percentilesRequestId) {
|
||||||
percentileLoading.value = false
|
percentileLoading.value = false
|
||||||
@@ -151,6 +669,11 @@ async function loadErrors() {
|
|||||||
if (requestId !== errorsRequestId) return
|
if (requestId !== errorsRequestId) return
|
||||||
errorDistribution.value = response.distribution
|
errorDistribution.value = response.distribution
|
||||||
errorTrend.value = response.trend
|
errorTrend.value = response.trend
|
||||||
|
} catch (error) {
|
||||||
|
if (requestId !== errorsRequestId) return
|
||||||
|
errorDistribution.value = []
|
||||||
|
errorTrend.value = []
|
||||||
|
log.error('加载错误分布失败', error)
|
||||||
} finally {
|
} finally {
|
||||||
if (requestId === errorsRequestId) {
|
if (requestId === errorsRequestId) {
|
||||||
errorLoading.value = false
|
errorLoading.value = false
|
||||||
@@ -165,6 +688,10 @@ async function loadProviders() {
|
|||||||
const data = await dashboardApi.getProviderStatus()
|
const data = await dashboardApi.getProviderStatus()
|
||||||
if (requestId !== providersRequestId) return
|
if (requestId !== providersRequestId) return
|
||||||
providerStatus.value = data
|
providerStatus.value = data
|
||||||
|
} catch (error) {
|
||||||
|
if (requestId !== providersRequestId) return
|
||||||
|
providerStatus.value = []
|
||||||
|
log.error('加载提供商状态失败', error)
|
||||||
} finally {
|
} finally {
|
||||||
if (requestId === providersRequestId) {
|
if (requestId === providersRequestId) {
|
||||||
providerLoading.value = false
|
providerLoading.value = false
|
||||||
@@ -172,6 +699,82 @@ async function loadProviders() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadLiveData(options: { silent?: boolean } = {}) {
|
||||||
|
const requestId = ++liveRequestId
|
||||||
|
const initialLoad = !liveReady.value
|
||||||
|
|
||||||
|
if (initialLoad) {
|
||||||
|
liveLoading.value = true
|
||||||
|
} else {
|
||||||
|
liveRefreshing.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const results = await Promise.allSettled([
|
||||||
|
monitoringApi.getSystemStatus(),
|
||||||
|
monitoringApi.getResilienceStatus(),
|
||||||
|
monitoringApi.getCircuitHistory(8),
|
||||||
|
monitoringApi.getGatewayMetricsSummary(),
|
||||||
|
])
|
||||||
|
|
||||||
|
if (requestId !== liveRequestId) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const failedScopes: string[] = []
|
||||||
|
let successCount = 0
|
||||||
|
|
||||||
|
const [systemResult, resilienceResult, circuitResult, metricsResult] = results
|
||||||
|
|
||||||
|
if (systemResult.status === 'fulfilled') {
|
||||||
|
systemStatus.value = systemResult.value
|
||||||
|
successCount += 1
|
||||||
|
} else {
|
||||||
|
failedScopes.push('系统状态')
|
||||||
|
log.error('加载系统状态失败', systemResult.reason)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resilienceResult.status === 'fulfilled') {
|
||||||
|
resilienceStatus.value = resilienceResult.value
|
||||||
|
successCount += 1
|
||||||
|
} else {
|
||||||
|
failedScopes.push('韧性状态')
|
||||||
|
log.error('加载韧性状态失败', resilienceResult.reason)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (circuitResult.status === 'fulfilled') {
|
||||||
|
circuitHistory.value = circuitResult.value.items
|
||||||
|
successCount += 1
|
||||||
|
} else {
|
||||||
|
failedScopes.push('熔断历史')
|
||||||
|
log.error('加载熔断历史失败', circuitResult.reason)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (metricsResult.status === 'fulfilled') {
|
||||||
|
gatewayMetrics.value = metricsResult.value
|
||||||
|
successCount += 1
|
||||||
|
} else {
|
||||||
|
failedScopes.push('网关指标')
|
||||||
|
log.error('加载网关指标失败', metricsResult.reason)
|
||||||
|
}
|
||||||
|
|
||||||
|
liveReady.value = successCount > 0
|
||||||
|
if (successCount > 0) {
|
||||||
|
liveLastUpdatedAt.value = new Date().toISOString()
|
||||||
|
}
|
||||||
|
liveLoadError.value = failedScopes.length
|
||||||
|
? `部分实时数据加载失败:${failedScopes.join('、')}`
|
||||||
|
: null
|
||||||
|
|
||||||
|
if (failedScopes.length && !options.silent) {
|
||||||
|
showError(liveLoadError.value ?? '实时性能数据加载失败')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (requestId === liveRequestId) {
|
||||||
|
liveLoading.value = false
|
||||||
|
liveRefreshing.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const errorTrendChartData = computed(() => ({
|
const errorTrendChartData = computed(() => ({
|
||||||
labels: errorTrend.value.map(item => item.date),
|
labels: errorTrend.value.map(item => item.date),
|
||||||
datasets: [
|
datasets: [
|
||||||
@@ -185,11 +788,147 @@ const errorTrendChartData = computed(() => ({
|
|||||||
]
|
]
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
const recentErrors = computed(() => resilienceStatus.value?.recent_errors ?? [])
|
||||||
|
const resilienceRecommendations = computed(() => resilienceStatus.value?.recommendations ?? [])
|
||||||
|
|
||||||
|
const healthStatusVariant = computed<'success' | 'warning' | 'destructive' | 'outline'>(() => {
|
||||||
|
switch (resilienceStatus.value?.status) {
|
||||||
|
case 'healthy':
|
||||||
|
return 'success'
|
||||||
|
case 'degraded':
|
||||||
|
return 'warning'
|
||||||
|
case 'critical':
|
||||||
|
return 'destructive'
|
||||||
|
default:
|
||||||
|
return 'outline'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const healthStatusText = computed(() => {
|
||||||
|
if (!resilienceStatus.value) {
|
||||||
|
return '健康状态未知'
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusMap: Record<string, string> = {
|
||||||
|
healthy: '系统健康',
|
||||||
|
degraded: '系统降级',
|
||||||
|
critical: '系统告警',
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${statusMap[resilienceStatus.value.status] ?? resilienceStatus.value.status} · ${resilienceStatus.value.health_score}/100`
|
||||||
|
})
|
||||||
|
|
||||||
|
const metricsAvailabilityText = computed(() => (
|
||||||
|
gatewayMetrics.value ? 'Prometheus 在线' : 'Prometheus 暂不可达'
|
||||||
|
))
|
||||||
|
|
||||||
|
const distributedGateVariant = computed<'warning' | 'outline'>(() => (
|
||||||
|
gatewayMetrics.value?.distributed.unavailable ? 'warning' : 'outline'
|
||||||
|
))
|
||||||
|
|
||||||
|
const distributedGateText = computed(() => (
|
||||||
|
gatewayMetrics.value?.distributed.unavailable ? '不可用' : '在线'
|
||||||
|
))
|
||||||
|
|
||||||
|
const liveLastUpdatedLabel = computed(() => (
|
||||||
|
liveLastUpdatedAt.value ? formatDate(liveLastUpdatedAt.value) : '尚未刷新'
|
||||||
|
))
|
||||||
|
|
||||||
|
const currentActiveStreams = computed(() => (
|
||||||
|
gatewayMetrics.value?.tunnel.activeStreams ?? systemStatus.value?.tunnel.active_streams ?? null
|
||||||
|
))
|
||||||
|
|
||||||
|
const currentProxyConnections = computed(() => (
|
||||||
|
gatewayMetrics.value?.tunnel.proxyConnections ?? systemStatus.value?.tunnel.proxy_connections ?? null
|
||||||
|
))
|
||||||
|
|
||||||
|
const currentTunnelNodes = computed(() => (
|
||||||
|
gatewayMetrics.value?.tunnel.nodes ?? systemStatus.value?.tunnel.nodes ?? null
|
||||||
|
))
|
||||||
|
|
||||||
|
const providerAndKeySummary = computed(() => {
|
||||||
|
if (!systemStatus.value) {
|
||||||
|
return '-'
|
||||||
|
}
|
||||||
|
return `${systemStatus.value.providers.active}/${systemStatus.value.providers.total} · ${systemStatus.value.api_keys.active}/${systemStatus.value.api_keys.total}`
|
||||||
|
})
|
||||||
|
|
||||||
|
const fallbackRows = computed(() => {
|
||||||
|
const items = gatewayMetrics.value?.fallbacks ?? []
|
||||||
|
const maxValue = Math.max(...items.map(item => item.total), 0)
|
||||||
|
|
||||||
|
return items
|
||||||
|
.filter(item => item.total > 0)
|
||||||
|
.sort((left, right) => right.total - left.total)
|
||||||
|
.map(item => ({
|
||||||
|
...item,
|
||||||
|
ratio: maxValue > 0 ? item.total / maxValue * 100 : 0,
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
const liveSummaryCards = computed(() => [
|
||||||
|
{
|
||||||
|
title: '系统健康',
|
||||||
|
value: resilienceStatus.value ? `${resilienceStatus.value.health_score}/100` : '-',
|
||||||
|
hint: `${healthStatusText.value} · 开路 ${formatMetricNumber(resilienceStatus.value?.error_statistics.open_circuit_breakers)}`,
|
||||||
|
icon: ShieldCheck,
|
||||||
|
iconClass: 'text-emerald-500',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '最近 1 小时错误',
|
||||||
|
value: formatMetricNumber(systemStatus.value?.recent_errors),
|
||||||
|
hint: `24h 总错误 ${formatMetricNumber(resilienceStatus.value?.error_statistics.total_errors)}`,
|
||||||
|
icon: AlertTriangle,
|
||||||
|
iconClass: 'text-yellow-500',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '当前活跃流',
|
||||||
|
value: formatMetricNumber(currentActiveStreams.value),
|
||||||
|
hint: `代理连接 ${formatMetricNumber(currentProxyConnections.value)}`,
|
||||||
|
icon: Cable,
|
||||||
|
iconClass: 'text-sky-500',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '本地 In Flight',
|
||||||
|
value: formatMetricNumber(gatewayMetrics.value?.local.inFlight),
|
||||||
|
hint: `剩余 permit ${formatMetricNumber(gatewayMetrics.value?.local.availablePermits)}`,
|
||||||
|
icon: Activity,
|
||||||
|
iconClass: 'text-blue-500',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '分布式 In Flight',
|
||||||
|
value: gatewayMetrics.value?.distributed.unavailable
|
||||||
|
? '不可用'
|
||||||
|
: formatMetricNumber(gatewayMetrics.value?.distributed.inFlight),
|
||||||
|
hint: gatewayMetrics.value?.distributed.unavailable
|
||||||
|
? '检查 Redis gate 状态'
|
||||||
|
: `剩余 permit ${formatMetricNumber(gatewayMetrics.value?.distributed.availablePermits)}`,
|
||||||
|
icon: Workflow,
|
||||||
|
iconClass: 'text-violet-500',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Fallback 累计',
|
||||||
|
value: formatMetricNumber(gatewayMetrics.value?.fallbackTotal),
|
||||||
|
hint: `今日请求 ${formatMetricNumber(systemStatus.value?.today_stats.requests)}`,
|
||||||
|
icon: GitBranch,
|
||||||
|
iconClass: 'text-rose-500',
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
|
const isRefreshing = computed(() => (
|
||||||
|
liveLoading.value ||
|
||||||
|
liveRefreshing.value ||
|
||||||
|
percentileLoading.value ||
|
||||||
|
errorLoading.value ||
|
||||||
|
providerLoading.value
|
||||||
|
))
|
||||||
|
|
||||||
async function loadAll() {
|
async function loadAll() {
|
||||||
if (loadAllPromise) {
|
if (loadAllPromise) {
|
||||||
hasPendingLoadAll = true
|
hasPendingLoadAll = true
|
||||||
return loadAllPromise
|
return loadAllPromise
|
||||||
}
|
}
|
||||||
|
|
||||||
loadAllPromise = Promise.all([loadPercentiles(), loadErrors(), loadProviders()])
|
loadAllPromise = Promise.all([loadPercentiles(), loadErrors(), loadProviders()])
|
||||||
.then(() => undefined)
|
.then(() => undefined)
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
@@ -199,13 +938,19 @@ async function loadAll() {
|
|||||||
void loadAll()
|
void loadAll()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
return loadAllPromise
|
return loadAllPromise
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleManualRefresh() {
|
||||||
|
await Promise.allSettled([loadLiveData(), loadAll()])
|
||||||
|
}
|
||||||
|
|
||||||
function scheduleLoadAll() {
|
function scheduleLoadAll() {
|
||||||
if (loadAllDebounceTimer) {
|
if (loadAllDebounceTimer) {
|
||||||
clearTimeout(loadAllDebounceTimer)
|
clearTimeout(loadAllDebounceTimer)
|
||||||
}
|
}
|
||||||
|
|
||||||
loadAllDebounceTimer = setTimeout(() => {
|
loadAllDebounceTimer = setTimeout(() => {
|
||||||
loadAllDebounceTimer = null
|
loadAllDebounceTimer = null
|
||||||
void loadAll()
|
void loadAll()
|
||||||
@@ -215,7 +960,11 @@ function scheduleLoadAll() {
|
|||||||
watch(timeRange, scheduleLoadAll, { deep: true })
|
watch(timeRange, scheduleLoadAll, { deep: true })
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
|
void loadLiveData()
|
||||||
void loadAll()
|
void loadAll()
|
||||||
|
liveRefreshTimer = setInterval(() => {
|
||||||
|
void loadLiveData({ silent: true })
|
||||||
|
}, LIVE_REFRESH_INTERVAL_MS)
|
||||||
})
|
})
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
@@ -223,10 +972,17 @@ onUnmounted(() => {
|
|||||||
clearTimeout(loadAllDebounceTimer)
|
clearTimeout(loadAllDebounceTimer)
|
||||||
loadAllDebounceTimer = null
|
loadAllDebounceTimer = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (liveRefreshTimer) {
|
||||||
|
clearInterval(liveRefreshTimer)
|
||||||
|
liveRefreshTimer = null
|
||||||
|
}
|
||||||
|
|
||||||
hasPendingLoadAll = false
|
hasPendingLoadAll = false
|
||||||
loadAllPromise = null
|
loadAllPromise = null
|
||||||
percentilesRequestId += 1
|
percentilesRequestId += 1
|
||||||
errorsRequestId += 1
|
errorsRequestId += 1
|
||||||
providersRequestId += 1
|
providersRequestId += 1
|
||||||
|
liveRequestId += 1
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -73,6 +73,11 @@ export default defineConfig(({ mode }) => {
|
|||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
secure: false,
|
secure: false,
|
||||||
},
|
},
|
||||||
|
'/_gateway/': {
|
||||||
|
target: gatewayTarget,
|
||||||
|
changeOrigin: true,
|
||||||
|
secure: false,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
preview: {
|
preview: {
|
||||||
|
|||||||
Reference in New Issue
Block a user