refactor(quality): Q36 predictUtils.ts 巨型工具文件拆分 — 716L→49L barrel re-export (-93%)
提取 3 个域文件: - predict-types.ts 140L: 类型定义 + 共享常量 (METRIC_DEFS/TIME_RANGE_*) - capacity-predict.ts 194L: 容量预测 (linearRegression + extractSeries + computePredictions + capacityRecommendation + trendColorClass + buildCapacityInsightPrompt) - cost-predict.ts 434L: 云成本预测 (monthStartDate + daysRemainingInMonth + aggregateDailyCosts + sumMonthCost + costRecommendation + computeCostPredictions + buildCostInsightPrompt + 私有函数 forecastMonthEnd/detectBillingPattern/recentRunRate/topServicesByCost/budgetRiskAction) predictUtils.ts 改为 barrel re-export 保持向后兼容, 3 个消费者 (AIPredictiveAnalytics/PredictiveCostTab/PredictiveCapacityTab) import 路径无需修改。 零逻辑变更, tsc -b=0, npm build✓ (17.26s), ESLint errors=0/warnings=66/any=0, go build=0, 17/17 集成测试通过
This commit is contained in:
1 parent
4283fb93c1
commit
a2edf6aa9f
5 files changed
+809
-707
No files matched your search
@@ -507,6 +507,7 @@ services/api.ts (1324L)
|
|||||||
| Q33 | 前端巨型组件拆分(DashboardRecent 518L→25L (-95%),提取 dashboard-recent/ 7 个文件 (types 48L + shared-components(StatusDot/SeverityBadge) 35L + AlertMessagesCard 72L + CertificateExpiryCard 99L + ReleaseHistoryCard 99L + OpsOverviewCard 77L + KubernetesClusterCard 97L),主文件改为 barrel re-export 保持向后兼容,tsc -b=0,npm build✓,ESLint=0/0/0,go build=0,17/17 测试通过) | P0 | **done** |
|
| Q33 | 前端巨型组件拆分(DashboardRecent 518L→25L (-95%),提取 dashboard-recent/ 7 个文件 (types 48L + shared-components(StatusDot/SeverityBadge) 35L + AlertMessagesCard 72L + CertificateExpiryCard 99L + ReleaseHistoryCard 99L + OpsOverviewCard 77L + KubernetesClusterCard 97L),主文件改为 barrel re-export 保持向后兼容,tsc -b=0,npm build✓,ESLint=0/0/0,go build=0,17/17 测试通过) | P0 | **done** |
|
||||||
| Q34 | 前端巨型组件拆分(AIOpsAssistant 505L→288L (-43%),提取 aiops-assistant/ 5 个文件 (types 39L Message/QuickAction/defaultConfig/fmtTime + context-loaders.ts 69L loadAlerts/loadMetrics/loadReport 3 个平台数据加载纯函数 + quick-actions.tsx 17L + ChatMessage 47L 消息气泡(含 streaming/thinking/error 状态) + AssistantSidebar 76L 快捷操作+能力卡片),tsc -b=0,npm build✓,ESLint=0/0/0,go build=0,17/17 测试通过) | P0 | **done** |
|
| Q34 | 前端巨型组件拆分(AIOpsAssistant 505L→288L (-43%),提取 aiops-assistant/ 5 个文件 (types 39L Message/QuickAction/defaultConfig/fmtTime + context-loaders.ts 69L loadAlerts/loadMetrics/loadReport 3 个平台数据加载纯函数 + quick-actions.tsx 17L + ChatMessage 47L 消息气泡(含 streaming/thinking/error 状态) + AssistantSidebar 76L 快捷操作+能力卡片),tsc -b=0,npm build✓,ESLint=0/0/0,go build=0,17/17 测试通过) | P0 | **done** |
|
||||||
| Q35 | 前端巨型组件拆分(AgentCLI 521L→399L (-23%),提取 agent-cli/ 2 个文件 (event-handler.ts 149L createWsEventHandler 工厂函数封装 8 种 WS 事件分发+高危命令确认 + ChatInputArea.tsx 44L 受控输入区组件),导出 ConfirmOptions 接口供跨模块引用,tsc -b=0,npm build✓,ESLint=0/66/0,go build=0,17/17 测试通过。最后一个 ≥500L 业务组件拆分完成,剩余最大业务组件 UnifiedQueryPanel 529L(已从 682L 拆分)) | P0 | **done** |
|
| Q35 | 前端巨型组件拆分(AgentCLI 521L→399L (-23%),提取 agent-cli/ 2 个文件 (event-handler.ts 149L createWsEventHandler 工厂函数封装 8 种 WS 事件分发+高危命令确认 + ChatInputArea.tsx 44L 受控输入区组件),导出 ConfirmOptions 接口供跨模块引用,tsc -b=0,npm build✓,ESLint=0/66/0,go build=0,17/17 测试通过。最后一个 ≥500L 业务组件拆分完成,剩余最大业务组件 UnifiedQueryPanel 529L(已从 682L 拆分)) | P0 | **done** |
|
||||||
|
| Q36 | 前端巨型工具文件拆分(predictUtils.ts 716L→49L barrel re-export (-93%),提取 predict-types.ts 140L 类型+常量 + capacity-predict.ts 194L 容量预测函数(linearRegression+computePredictions+buildCapacityInsightPrompt) + cost-predict.ts 434L 云成本预测函数(computeCostPredictions+buildCostInsightPrompt),3 个消费者 import 路径无需修改,tsc -b=0,npm build✓ (17.26s),ESLint=0/66/0,go build=0,17/17 测试通过) | P0 | **done** |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,194 @@
|
|||||||
|
import type { JsonData } from '@/types/common';
|
||||||
|
import i18n from '@/i18n';
|
||||||
|
import {
|
||||||
|
METRIC_DEFS,
|
||||||
|
TIME_RANGE_LABELS,
|
||||||
|
TIME_RANGE_HOURS,
|
||||||
|
type PredictionMetric,
|
||||||
|
type CapacityForecast,
|
||||||
|
type ComputePredictionsInput,
|
||||||
|
type ComputedPredictions,
|
||||||
|
} from './predict-types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 容量预测 — 线性回归 + 指标预测 + 容量规划 + AI 解读 prompt
|
||||||
|
*
|
||||||
|
* 从 predictUtils.ts (716L) 提取。
|
||||||
|
* 所有函数均为纯函数(输入 → 输出,无副作用),便于单元测试和复用。
|
||||||
|
* // Logan.Li
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ============ 纯函数工具 ============
|
||||||
|
|
||||||
|
/** 简单最小二乘线性回归,返回斜率和 R² */
|
||||||
|
export function linearRegression(values: number[]): { slope: number; r2: number } {
|
||||||
|
const n = values.length;
|
||||||
|
if (n < 2) return { slope: 0, r2: 0 };
|
||||||
|
const xs = values.map((_, i) => i);
|
||||||
|
const meanX = xs.reduce((a, b) => a + b, 0) / n;
|
||||||
|
const meanY = values.reduce((a, b) => a + b, 0) / n;
|
||||||
|
let num = 0,
|
||||||
|
den = 0,
|
||||||
|
ssTot = 0,
|
||||||
|
ssRes = 0;
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
num += (xs[i] - meanX) * (values[i] - meanY);
|
||||||
|
den += (xs[i] - meanX) ** 2;
|
||||||
|
ssTot += (values[i] - meanY) ** 2;
|
||||||
|
}
|
||||||
|
const slope = den === 0 ? 0 : num / den;
|
||||||
|
const intercept = meanY - slope * meanX;
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
const pred = intercept + slope * xs[i];
|
||||||
|
ssRes += (values[i] - pred) ** 2;
|
||||||
|
}
|
||||||
|
const r2 = ssTot === 0 ? 1 : Math.max(0, 1 - ssRes / ssTot);
|
||||||
|
return { slope, r2 };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 从时序指标中提取按时间排序的数值序列 */
|
||||||
|
export function extractSeries(metrics: JsonData[], name: string): number[] {
|
||||||
|
return metrics
|
||||||
|
.filter((m) => m.name === name)
|
||||||
|
.sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime())
|
||||||
|
.map((m) => Number(m.value) || 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 容量规划建议 */
|
||||||
|
export function capacityRecommendation(metricKey: string, days: number): string {
|
||||||
|
const level = days <= 7 ? 'urgent' : days <= 14 ? 'warning' : 'normal';
|
||||||
|
const recs: Record<string, Record<string, string>> = {
|
||||||
|
urgent: {
|
||||||
|
cpu: i18n.t('aiops.predictutilsts.s4d0bd643'),
|
||||||
|
memory: i18n.t('aiops.predictutilsts.sb4593f3a'),
|
||||||
|
disk: i18n.t('aiops.predictutilsts.s6c25383f'),
|
||||||
|
},
|
||||||
|
warning: {
|
||||||
|
cpu: i18n.t('aiops.predictutilsts.s8ea826f4'),
|
||||||
|
memory: i18n.t('aiops.predictutilsts.sb08bd5fc'),
|
||||||
|
disk: i18n.t('aiops.predictutilsts.s4cdda5dc'),
|
||||||
|
},
|
||||||
|
normal: {
|
||||||
|
cpu: i18n.t('aiops.predictutilsts.sfa295c44'),
|
||||||
|
memory: i18n.t('aiops.predictutilsts.s7156a4c0'),
|
||||||
|
disk: i18n.t('aiops.predictutilsts.s0073f3a3'),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return recs[level]?.[metricKey] || i18n.t('aiops.predictutilsts.sfeb368e6');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ 综合预测推算 ============
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 综合预测推算:基于时序指标计算指标预测与容量规划。
|
||||||
|
* 主组件 AIPredictiveAnalytics.tsx 直接调用此函数获取展示数据。
|
||||||
|
* // Logan.Li
|
||||||
|
*/
|
||||||
|
export function computePredictions(input: ComputePredictionsInput): ComputedPredictions {
|
||||||
|
const { rawMetrics, serverStats, selectedTimeRange } = input;
|
||||||
|
const horizonHours = TIME_RANGE_HOURS[selectedTimeRange] || 24;
|
||||||
|
const timeRangeLabel = TIME_RANGE_LABELS[selectedTimeRange] || i18n.t('aiops.predictutilsts.sfc78646e');
|
||||||
|
|
||||||
|
// ---- 指标预测 ----
|
||||||
|
const predictions: PredictionMetric[] = METRIC_DEFS.map((def) => {
|
||||||
|
const series = extractSeries(rawMetrics, def.key);
|
||||||
|
const { slope, r2 } = linearRegression(series);
|
||||||
|
const avgFallback =
|
||||||
|
def.key === 'cpu' ? serverStats.avg_cpu : def.key === 'memory' ? serverStats.avg_memory : serverStats.avg_disk;
|
||||||
|
const current = series.length > 0 ? series[series.length - 1] : avgFallback || 0;
|
||||||
|
let predicted = current + slope * horizonHours;
|
||||||
|
predicted = Math.max(0, Math.min(100, predicted));
|
||||||
|
const trend: 'up' | 'down' | 'stable' = slope > 0.05 ? 'up' : slope < -0.05 ? 'down' : 'stable';
|
||||||
|
const confidence = Math.round(60 + r2 * 35);
|
||||||
|
return {
|
||||||
|
name: def.label,
|
||||||
|
current: Math.round(current),
|
||||||
|
predicted: Math.round(predicted),
|
||||||
|
unit: '%',
|
||||||
|
trend,
|
||||||
|
confidence,
|
||||||
|
timeRange: timeRangeLabel,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- 容量规划 ----
|
||||||
|
const capacityForecasts: CapacityForecast[] = METRIC_DEFS.map((def) => {
|
||||||
|
const series = extractSeries(rawMetrics, def.key);
|
||||||
|
const { slope } = linearRegression(series);
|
||||||
|
const avgFallback =
|
||||||
|
def.key === 'cpu' ? serverStats.avg_cpu : def.key === 'memory' ? serverStats.avg_memory : serverStats.avg_disk;
|
||||||
|
const currentUsage = Math.round(series.length > 0 ? series[series.length - 1] : avgFallback || 0);
|
||||||
|
const slopePerDay = slope * 24;
|
||||||
|
const predictedUsage = Math.round(Math.min(100, currentUsage + slopePerDay * 7));
|
||||||
|
const threshold = def.threshold;
|
||||||
|
let daysUntilThreshold = 999;
|
||||||
|
if (slopePerDay > 0.01) {
|
||||||
|
daysUntilThreshold = Math.round((threshold - currentUsage) / slopePerDay);
|
||||||
|
daysUntilThreshold = daysUntilThreshold < 1 ? 1 : daysUntilThreshold;
|
||||||
|
}
|
||||||
|
const resourceNames: Record<string, string> = {
|
||||||
|
cpu: '服务器CPU算力',
|
||||||
|
memory: '服务器内存',
|
||||||
|
disk: '存储磁盘空间',
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
resource: resourceNames[def.key] || def.label,
|
||||||
|
metricKey: def.key,
|
||||||
|
currentUsage,
|
||||||
|
predictedUsage,
|
||||||
|
threshold,
|
||||||
|
daysUntilThreshold,
|
||||||
|
recommendation: capacityRecommendation(def.key, daysUntilThreshold),
|
||||||
|
};
|
||||||
|
}).filter((f) => f.currentUsage > 0);
|
||||||
|
|
||||||
|
// ---- 概览统计 ----
|
||||||
|
const avgR2 = METRIC_DEFS.length > 0
|
||||||
|
? METRIC_DEFS.reduce((sum, def) => sum + linearRegression(extractSeries(rawMetrics, def.key)).r2, 0) /
|
||||||
|
METRIC_DEFS.length
|
||||||
|
: 0;
|
||||||
|
const accuracy = Math.round(78 + avgR2 * 17);
|
||||||
|
const capacityWarningCount = capacityForecasts.filter((f) => f.daysUntilThreshold <= 14).length;
|
||||||
|
|
||||||
|
return {
|
||||||
|
predictions,
|
||||||
|
capacityForecasts,
|
||||||
|
accuracy,
|
||||||
|
capacityWarningCount,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 趋势图标颜色工具,供主组件 render 用 */
|
||||||
|
export function trendColorClass(trend: string): string {
|
||||||
|
switch (trend) {
|
||||||
|
case 'up':
|
||||||
|
return 'dg-text-red';
|
||||||
|
case 'down':
|
||||||
|
return 'dg-text-green';
|
||||||
|
default:
|
||||||
|
return 'dg-text-secondary';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构建容量预测「AI 深度解读」的 prompt —— 把硬数据序列化为结构化上下文喂给 LLM。 // Logan.Li
|
||||||
|
* 纯函数,供 PredictiveCapacityTab 调 aiApi.chat 使用。无数据返回空串(调用方据此禁用按钮)。
|
||||||
|
*/
|
||||||
|
export function buildCapacityInsightPrompt(forecasts: CapacityForecast[]): string {
|
||||||
|
if (forecasts.length === 0) return '';
|
||||||
|
const lines = forecasts.map((f) => {
|
||||||
|
const risk = f.daysUntilThreshold >= 999 ? i18n.t('aiops.predictutilsts.s179fd5a9') : i18n.t('aiops.predictutilsts.daysUntilThreshold', { days: f.daysUntilThreshold });
|
||||||
|
return `- ${f.resource}:当前 ${f.currentUsage}% → 7天后预测 ${f.predictedUsage}%(阈值 ${f.threshold}%),${risk}`;
|
||||||
|
});
|
||||||
|
return [
|
||||||
|
i18n.t('aiops.predictutilsts.s3b46e5a0'),
|
||||||
|
'',
|
||||||
|
i18n.t('aiops.predictutilsts.s586bac5d'),
|
||||||
|
...lines,
|
||||||
|
'',
|
||||||
|
i18n.t('aiops.predictutilsts.sdf6ebee3'),
|
||||||
|
i18n.t('aiops.predictutilsts.s534b6893'),
|
||||||
|
i18n.t('aiops.predictutilsts.saf173e7a'),
|
||||||
|
i18n.t('aiops.predictutilsts.s4ed1d035'),
|
||||||
|
].join('\n');
|
||||||
|
}
|
||||||
@@ -0,0 +1,434 @@
|
|||||||
|
import type { JsonData } from '@/types/common';
|
||||||
|
import i18n from '@/i18n';
|
||||||
|
import { linearRegression } from './capacity-predict';
|
||||||
|
import type {
|
||||||
|
DailyCostPoint,
|
||||||
|
CostDimensionForecast,
|
||||||
|
BudgetRiskForecast,
|
||||||
|
CostPredictionSummary,
|
||||||
|
ComputedCostPredictions,
|
||||||
|
ComputeCostPredictionsInput,
|
||||||
|
} from './predict-types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 云成本预测 — 月末费用预测 + 预算触线风险 + AI 解读 prompt
|
||||||
|
*
|
||||||
|
* 从 predictUtils.ts (716L) 提取。
|
||||||
|
* 所有函数均为纯函数(输入 → 输出,无副作用),便于单元测试和复用。
|
||||||
|
* // Logan.Li
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ============ 私有常量 ============
|
||||||
|
|
||||||
|
const PROVIDER_LABELS: Record<string, string> = {
|
||||||
|
aliyun: i18n.t('aiops.predictutilsts.providerAliyun'),
|
||||||
|
tencent: i18n.t('aiops.predictutilsts.providerTencent'),
|
||||||
|
aws: 'AWS',
|
||||||
|
huawei: i18n.t('aiops.predictutilsts.providerHuawei'),
|
||||||
|
azure: 'Azure',
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============ 日期工具 ============
|
||||||
|
|
||||||
|
/** 本月第一天 YYYY-MM-DD */
|
||||||
|
export function monthStartDate(ref = new Date()): string {
|
||||||
|
return `${ref.getFullYear()}-${String(ref.getMonth() + 1).padStart(2, '0')}-01`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 本月剩余天数(不含今天) */
|
||||||
|
export function daysRemainingInMonth(ref = new Date()): number {
|
||||||
|
const lastDay = new Date(ref.getFullYear(), ref.getMonth() + 1, 0).getDate();
|
||||||
|
return Math.max(0, lastDay - ref.getDate());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ 账单聚合 ============
|
||||||
|
|
||||||
|
/** 账单明细按日聚合 */
|
||||||
|
export function aggregateDailyCosts(
|
||||||
|
records: JsonData[],
|
||||||
|
filter?: { startDate?: string; provider?: string },
|
||||||
|
): DailyCostPoint[] {
|
||||||
|
const map = new Map<string, number>();
|
||||||
|
for (const r of records) {
|
||||||
|
const date = String(r.billing_date || '');
|
||||||
|
if (!date) continue;
|
||||||
|
if (filter?.startDate && date < filter.startDate) continue;
|
||||||
|
if (filter?.provider && r.provider !== filter.provider) continue;
|
||||||
|
map.set(date, (map.get(date) || 0) + (Number(r.cost) || 0));
|
||||||
|
}
|
||||||
|
return Array.from(map.entries())
|
||||||
|
.sort((a, b) => a[0].localeCompare(b[0]))
|
||||||
|
.map(([date, cost]) => ({ date, cost }));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 本月累计费用 */
|
||||||
|
export function sumMonthCost(records: JsonData[], provider?: string): number {
|
||||||
|
const start = monthStartDate();
|
||||||
|
return records.reduce((sum, r) => {
|
||||||
|
const date = String(r.billing_date || '');
|
||||||
|
if (date < start) return sum;
|
||||||
|
if (provider && r.provider !== provider) return sum;
|
||||||
|
return sum + (Number(r.cost) || 0);
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ 私有工具 ============
|
||||||
|
|
||||||
|
function costTrendFromSlope(slope: number): 'up' | 'down' | 'stable' {
|
||||||
|
if (slope > 1) return 'up';
|
||||||
|
if (slope < -1) return 'down';
|
||||||
|
return 'stable';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 成本优化建议 */
|
||||||
|
export function costRecommendation(trend: 'up' | 'down' | 'stable', ratio: number): string {
|
||||||
|
if (trend === 'up' && ratio >= 0.85) {
|
||||||
|
return i18n.t('aiops.predictutilsts.sa7d4e3c1');
|
||||||
|
}
|
||||||
|
if (trend === 'up') {
|
||||||
|
return i18n.t('aiops.predictutilsts.sb3f5a2d8');
|
||||||
|
}
|
||||||
|
if (trend === 'down') {
|
||||||
|
return i18n.t('aiops.predictutilsts.sc1a9b4e7');
|
||||||
|
}
|
||||||
|
return i18n.t('aiops.predictutilsts.sd8c6f2a3');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 预算风险建议 */
|
||||||
|
function budgetRiskAction(metric: 'daily' | 'monthly', provider: string): string {
|
||||||
|
const scope = provider && provider !== i18n.t('aiops.predictutilsts.sg5a3d8e1') ? `${provider} ` : '';
|
||||||
|
if (metric === 'daily') {
|
||||||
|
return i18n.t('aiops.predictutilsts.se4b7a5d2', { scope });
|
||||||
|
}
|
||||||
|
return i18n.t('aiops.predictutilsts.sf2d8c6b4', { scope });
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MonthEndForecast {
|
||||||
|
currentMonth: number;
|
||||||
|
predictedMonthEnd: number;
|
||||||
|
dailyAvg: number;
|
||||||
|
recentDailyAvg: number;
|
||||||
|
billingPattern: 'steady' | 'front_loaded' | 'sparse';
|
||||||
|
trend: 'up' | 'down' | 'stable';
|
||||||
|
confidence: number;
|
||||||
|
series: DailyCostPoint[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 识别账单形态(预付费常表现为月初大额、后续近零) // Logan.Li */
|
||||||
|
function detectBillingPattern(series: DailyCostPoint[]): 'steady' | 'front_loaded' | 'sparse' {
|
||||||
|
const active = series.filter((d) => d.cost > 0);
|
||||||
|
if (active.length < 2) return 'sparse';
|
||||||
|
const total = series.reduce((s, d) => s + d.cost, 0);
|
||||||
|
const recent = series.slice(-Math.min(7, series.length));
|
||||||
|
const recentAvg = recent.reduce((s, d) => s + d.cost, 0) / recent.length;
|
||||||
|
const overallAvg = total / series.length;
|
||||||
|
if (overallAvg > 0 && recentAvg < overallAvg * 0.25) return 'front_loaded';
|
||||||
|
return 'steady';
|
||||||
|
}
|
||||||
|
|
||||||
|
function recentRunRate(series: DailyCostPoint[], days = 7): number {
|
||||||
|
const recent = series.slice(-Math.min(days, series.length));
|
||||||
|
if (recent.length === 0) return 0;
|
||||||
|
return recent.reduce((s, d) => s + d.cost, 0) / recent.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 本月 Top 服务消费 */
|
||||||
|
function topServicesByCost(records: JsonData[], limit = 5): { service: string; cost: number }[] {
|
||||||
|
const start = monthStartDate();
|
||||||
|
const map = new Map<string, number>();
|
||||||
|
records.forEach((r) => {
|
||||||
|
const date = String(r.billing_date || '');
|
||||||
|
if (date < start) return;
|
||||||
|
const svc = String(r.service || i18n.t('aiops.predictutilsts.sh7c2b9d6'));
|
||||||
|
map.set(svc, (map.get(svc) || 0) + (Number(r.cost) || 0));
|
||||||
|
});
|
||||||
|
return [...map.entries()]
|
||||||
|
.sort((a, b) => b[1] - a[1])
|
||||||
|
.slice(0, limit)
|
||||||
|
.map(([service, cost]) => ({ service, cost: Math.round(cost * 100) / 100 }));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 基于日费用序列预测月末总费用 */
|
||||||
|
function forecastMonthEnd(
|
||||||
|
records: JsonData[],
|
||||||
|
statsMonthCost: number,
|
||||||
|
fallbackTrend: DailyCostPoint[],
|
||||||
|
provider?: string,
|
||||||
|
): MonthEndForecast {
|
||||||
|
const start = monthStartDate();
|
||||||
|
const monthSeries = aggregateDailyCosts(records, { startDate: start, provider });
|
||||||
|
const series = monthSeries.length >= 2 ? monthSeries : fallbackTrend;
|
||||||
|
const costs = series.map((d) => d.cost);
|
||||||
|
const { slope, r2 } = linearRegression(costs);
|
||||||
|
const currentMonth =
|
||||||
|
monthSeries.length > 0 ? monthSeries.reduce((s, d) => s + d.cost, 0) : statsMonthCost;
|
||||||
|
const daysLeft = daysRemainingInMonth();
|
||||||
|
const dailyAvg = costs.length > 0 ? costs.reduce((a, b) => a + b, 0) / costs.length : 0;
|
||||||
|
const recentDailyAvg = recentRunRate(series);
|
||||||
|
const billingPattern = detectBillingPattern(series);
|
||||||
|
// 预付费/前高后平:用近 7 日运行费率外推,避免线性回归高估或矛盾 // Logan.Li
|
||||||
|
const extrapolation =
|
||||||
|
billingPattern === 'front_loaded' ? recentDailyAvg * daysLeft : slope * daysLeft;
|
||||||
|
const predictedMonthEnd = Math.max(0, Math.round((currentMonth + extrapolation) * 100) / 100);
|
||||||
|
let confidence = Math.round(55 + r2 * 40);
|
||||||
|
if (billingPattern === 'front_loaded') confidence = Math.max(45, confidence - 15);
|
||||||
|
if (billingPattern === 'sparse') confidence = Math.min(confidence, 50);
|
||||||
|
return {
|
||||||
|
currentMonth,
|
||||||
|
predictedMonthEnd,
|
||||||
|
dailyAvg: Math.round(dailyAvg * 100) / 100,
|
||||||
|
recentDailyAvg: Math.round(recentDailyAvg * 100) / 100,
|
||||||
|
billingPattern,
|
||||||
|
trend: costTrendFromSlope(billingPattern === 'front_loaded' ? recentDailyAvg - dailyAvg : slope),
|
||||||
|
confidence,
|
||||||
|
series: monthSeries.length > 0 ? monthSeries : fallbackTrend,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ 综合成本预测 ============
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 云成本预测:基于账单日粒度时序 + 预算预警规则,推算月末费用与触线风险。
|
||||||
|
* // Logan.Li
|
||||||
|
*/
|
||||||
|
export function computeCostPredictions(input: ComputeCostPredictionsInput): ComputedCostPredictions {
|
||||||
|
const { billingStats, billingRecords, billingAlertRules } = input;
|
||||||
|
const empty: ComputedCostPredictions = {
|
||||||
|
summary: {
|
||||||
|
monthCost: 0,
|
||||||
|
predictedMonthEnd: 0,
|
||||||
|
todayCost: 0,
|
||||||
|
yesterdayCost: 0,
|
||||||
|
dailyAvg: 0,
|
||||||
|
daysRemaining: daysRemainingInMonth(),
|
||||||
|
trend: 'stable',
|
||||||
|
confidence: 0,
|
||||||
|
monthTrend: [],
|
||||||
|
providerDist: [],
|
||||||
|
},
|
||||||
|
dimensionForecasts: [],
|
||||||
|
budgetRisks: [],
|
||||||
|
budgetRiskCount: 0,
|
||||||
|
recentDailyAvg: 0,
|
||||||
|
billingPattern: 'sparse',
|
||||||
|
topServices: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!billingStats) return empty;
|
||||||
|
|
||||||
|
const monthTrend: DailyCostPoint[] = Array.isArray(billingStats.month_trend)
|
||||||
|
? billingStats.month_trend.map((d: JsonData) => ({
|
||||||
|
date: String(d.date || ''),
|
||||||
|
cost: Number(d.cost) || 0,
|
||||||
|
}))
|
||||||
|
: [];
|
||||||
|
const providerDist: { provider: string; cost: number; percent: number }[] = Array.isArray(
|
||||||
|
billingStats.provider_dist,
|
||||||
|
)
|
||||||
|
? billingStats.provider_dist.map((p: JsonData) => ({
|
||||||
|
provider: String(p.provider || ''),
|
||||||
|
cost: Number(p.cost) || 0,
|
||||||
|
percent: Number(p.percent) || 0,
|
||||||
|
}))
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const overall = forecastMonthEnd(
|
||||||
|
billingRecords,
|
||||||
|
Number(billingStats.month_cost) || 0,
|
||||||
|
monthTrend,
|
||||||
|
);
|
||||||
|
|
||||||
|
const summary: CostPredictionSummary = {
|
||||||
|
monthCost: Number(billingStats.month_cost) || overall.currentMonth,
|
||||||
|
predictedMonthEnd: overall.predictedMonthEnd,
|
||||||
|
todayCost: Number(billingStats.today_cost) || 0,
|
||||||
|
yesterdayCost: Number(billingStats.yesterday_cost) || 0,
|
||||||
|
dailyAvg: overall.dailyAvg,
|
||||||
|
daysRemaining: daysRemainingInMonth(),
|
||||||
|
trend: overall.trend,
|
||||||
|
confidence: overall.confidence,
|
||||||
|
monthTrend: overall.series.length > 0 ? overall.series : monthTrend,
|
||||||
|
providerDist,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 全部 + 各云厂商维度预测
|
||||||
|
const dimensionForecasts: CostDimensionForecast[] = [];
|
||||||
|
const allRatio =
|
||||||
|
summary.predictedMonthEnd > 0 ? summary.monthCost / summary.predictedMonthEnd : 0;
|
||||||
|
dimensionForecasts.push({
|
||||||
|
dimension: i18n.t('aiops.predictutilsts.sa1b2c3d4'),
|
||||||
|
currentMonth: summary.monthCost,
|
||||||
|
predictedMonthEnd: summary.predictedMonthEnd,
|
||||||
|
dailyAvg: summary.dailyAvg,
|
||||||
|
trend: summary.trend,
|
||||||
|
confidence: summary.confidence,
|
||||||
|
recommendation: costRecommendation(summary.trend, allRatio),
|
||||||
|
});
|
||||||
|
|
||||||
|
const providers = new Set<string>();
|
||||||
|
providerDist.forEach((p) => providers.add(p.provider));
|
||||||
|
billingRecords.forEach((r) => {
|
||||||
|
if (r.provider) providers.add(String(r.provider));
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const provider of providers) {
|
||||||
|
if (!provider) continue;
|
||||||
|
const provForecast = forecastMonthEnd(billingRecords, 0, monthTrend, provider);
|
||||||
|
const provMonth =
|
||||||
|
provForecast.currentMonth > 0 ? provForecast.currentMonth : sumMonthCost(billingRecords, provider);
|
||||||
|
const ratio = provForecast.predictedMonthEnd > 0 ? provMonth / provForecast.predictedMonthEnd : 0;
|
||||||
|
dimensionForecasts.push({
|
||||||
|
dimension: PROVIDER_LABELS[provider] || provider,
|
||||||
|
provider,
|
||||||
|
currentMonth: provMonth,
|
||||||
|
predictedMonthEnd: provForecast.predictedMonthEnd,
|
||||||
|
dailyAvg: provForecast.dailyAvg,
|
||||||
|
trend: provForecast.trend,
|
||||||
|
confidence: provForecast.confidence,
|
||||||
|
recommendation: costRecommendation(provForecast.trend, ratio),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
dimensionForecasts.sort((a, b) => b.predictedMonthEnd - a.predictedMonthEnd);
|
||||||
|
|
||||||
|
// 预算触线风险
|
||||||
|
const budgetRisks: BudgetRiskForecast[] = [];
|
||||||
|
const activeAlerts: JsonData[] = Array.isArray(billingStats.alerts) ? billingStats.alerts : [];
|
||||||
|
|
||||||
|
for (const rule of billingAlertRules) {
|
||||||
|
if (!rule.enabled) continue;
|
||||||
|
const threshold = Number(rule.threshold) || 0;
|
||||||
|
if (threshold <= 0) continue;
|
||||||
|
|
||||||
|
const metric = (rule.metric === 'daily' ? 'daily' : 'monthly') as 'daily' | 'monthly';
|
||||||
|
const ruleProvider = String(rule.provider || '');
|
||||||
|
const providerLabel = ruleProvider ? PROVIDER_LABELS[ruleProvider] || ruleProvider : i18n.t('aiops.predictutilsts.sb2c3d4e5');
|
||||||
|
|
||||||
|
let current = 0;
|
||||||
|
let predicted = 0;
|
||||||
|
let slope = 0;
|
||||||
|
|
||||||
|
if (metric === 'daily') {
|
||||||
|
current = Number(billingStats.today_cost) || 0;
|
||||||
|
if (ruleProvider) {
|
||||||
|
const today = new Date().toISOString().slice(0, 10);
|
||||||
|
current = billingRecords
|
||||||
|
.filter((r) => r.billing_date === today && r.provider === ruleProvider)
|
||||||
|
.reduce((s, r) => s + (Number(r.cost) || 0), 0);
|
||||||
|
}
|
||||||
|
const recent = aggregateDailyCosts(billingRecords, ruleProvider ? { provider: ruleProvider } : undefined);
|
||||||
|
const costs = recent.slice(-7).map((d) => d.cost);
|
||||||
|
slope = linearRegression(costs).slope;
|
||||||
|
predicted = Math.max(0, current + slope);
|
||||||
|
} else {
|
||||||
|
const provForecast = ruleProvider
|
||||||
|
? forecastMonthEnd(billingRecords, 0, monthTrend, ruleProvider)
|
||||||
|
: overall;
|
||||||
|
current = ruleProvider ? sumMonthCost(billingRecords, ruleProvider) : summary.monthCost;
|
||||||
|
predicted = provForecast.predictedMonthEnd;
|
||||||
|
const costs = provForecast.series.map((d) => d.cost);
|
||||||
|
slope = linearRegression(costs).slope;
|
||||||
|
}
|
||||||
|
|
||||||
|
const active = activeAlerts.find((a) => a.rule_id === rule.id || a.rule_name === rule.name);
|
||||||
|
if (active) current = Number(active.current) || current;
|
||||||
|
|
||||||
|
const ratio = threshold > 0 ? predicted / threshold : 0;
|
||||||
|
let probability = Math.round(ratio * 50);
|
||||||
|
if (slope > 0.5) probability += 15;
|
||||||
|
if (current >= threshold) probability += 25;
|
||||||
|
probability = Math.max(5, Math.min(92, probability));
|
||||||
|
|
||||||
|
let timeframe = metric === 'daily' ? i18n.t('aiops.predictutilsts.sc3d4e5f6') : i18n.t('aiops.predictutilsts.sd4e5f6g7');
|
||||||
|
if (slope > 0.01 && threshold > current) {
|
||||||
|
const days = (threshold - current) / slope;
|
||||||
|
if (days < 1) timeframe = metric === 'daily' ? i18n.t('aiops.predictutilsts.se5f6g7h8') : i18n.t('aiops.predictutilsts.sf6g7h8i9');
|
||||||
|
else if (days <= 7) timeframe = i18n.t('aiops.predictutilsts.sg7h8i9j0', { days: Math.ceil(days) });
|
||||||
|
else timeframe = i18n.t('aiops.predictutilsts.sh8i9j0k1', { days: Math.ceil(days) });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (probability >= 30 || current >= threshold * 0.7) {
|
||||||
|
budgetRisks.push({
|
||||||
|
ruleName: String(rule.name || i18n.t('aiops.predictutilsts.si9j0k1l2')),
|
||||||
|
provider: providerLabel,
|
||||||
|
metric,
|
||||||
|
threshold,
|
||||||
|
current: Math.round(current * 100) / 100,
|
||||||
|
predicted: Math.round(predicted * 100) / 100,
|
||||||
|
probability,
|
||||||
|
timeframe,
|
||||||
|
suggestedAction: budgetRiskAction(metric, providerLabel),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
budgetRisks.sort((a, b) => b.probability - a.probability);
|
||||||
|
const budgetRiskCount = budgetRisks.filter((r) => r.probability >= 50).length;
|
||||||
|
|
||||||
|
return {
|
||||||
|
summary,
|
||||||
|
dimensionForecasts,
|
||||||
|
budgetRisks,
|
||||||
|
budgetRiskCount,
|
||||||
|
recentDailyAvg: overall.recentDailyAvg,
|
||||||
|
billingPattern: overall.billingPattern,
|
||||||
|
topServices: topServicesByCost(billingRecords),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构建成本预测「AI 深度解读」prompt。
|
||||||
|
* // Logan.Li
|
||||||
|
*/
|
||||||
|
export function buildCostInsightPrompt(data: ComputedCostPredictions): string {
|
||||||
|
const { summary, dimensionForecasts, budgetRisks, recentDailyAvg, billingPattern, topServices } = data;
|
||||||
|
if (summary.monthCost <= 0 && dimensionForecasts.length === 0) return '';
|
||||||
|
|
||||||
|
const patternHint =
|
||||||
|
billingPattern === 'front_loaded'
|
||||||
|
? i18n.t('aiops.predictutilsts.sj0k1l2m3')
|
||||||
|
: billingPattern === 'sparse'
|
||||||
|
? i18n.t('aiops.predictutilsts.sk1l2m3n4')
|
||||||
|
: i18n.t('aiops.predictutilsts.sl2m3n4o5');
|
||||||
|
|
||||||
|
const dimLines = dimensionForecasts.slice(0, 6).map(
|
||||||
|
(d) =>
|
||||||
|
`- ${d.dimension}:本月 ${d.currentMonth.toFixed(2)} 元 → 预测月末 ${d.predictedMonthEnd.toFixed(2)} 元(趋势 ${d.trend})`,
|
||||||
|
);
|
||||||
|
const riskLines =
|
||||||
|
budgetRisks.length > 0
|
||||||
|
? budgetRisks.slice(0, 5).map(
|
||||||
|
(r) =>
|
||||||
|
`- ${r.ruleName}(${r.provider}):当前 ${r.current} / 阈值 ${r.threshold},预测 ${r.predicted},触线概率 ${r.probability}%`,
|
||||||
|
)
|
||||||
|
: [`- ${i18n.t('aiops.predictutilsts.sm3n4o5p6')}`];
|
||||||
|
const serviceLines =
|
||||||
|
topServices.length > 0
|
||||||
|
? topServices.map((s) => `- ${s.service}:${s.cost.toFixed(2)} 元`)
|
||||||
|
: [`- ${i18n.t('aiops.predictutilsts.sn4o5p6q7')}`];
|
||||||
|
|
||||||
|
return [
|
||||||
|
i18n.t('aiops.predictutilsts.so5p6q7r8'),
|
||||||
|
'',
|
||||||
|
i18n.t('aiops.predictutilsts.sp6q7r8s9'),
|
||||||
|
`- 本月已花:${summary.monthCost.toFixed(2)} 元`,
|
||||||
|
`- 预测月末:${summary.predictedMonthEnd.toFixed(2)} 元(置信 ${summary.confidence}%)`,
|
||||||
|
`- 全月日均:${summary.dailyAvg.toFixed(2)} 元;近7日日均:${recentDailyAvg.toFixed(2)} 元;剩余 ${summary.daysRemaining} 天`,
|
||||||
|
`- 账单形态:${patternHint}`,
|
||||||
|
'',
|
||||||
|
i18n.t('aiops.predictutilsts.sq7r8s9t0'),
|
||||||
|
...serviceLines,
|
||||||
|
'',
|
||||||
|
i18n.t('aiops.predictutilsts.sr8s9t0u1'),
|
||||||
|
...dimLines,
|
||||||
|
'',
|
||||||
|
i18n.t('aiops.predictutilsts.ss9t0u1v2'),
|
||||||
|
...riskLines,
|
||||||
|
'',
|
||||||
|
i18n.t('aiops.predictutilsts.st0u1v2w3'),
|
||||||
|
i18n.t('aiops.predictutilsts.su1v2w3x4'),
|
||||||
|
i18n.t('aiops.predictutilsts.sv2w3x4y5'),
|
||||||
|
i18n.t('aiops.predictutilsts.sw3x4y5z6'),
|
||||||
|
i18n.t('aiops.predictutilsts.sx4y5z6a7'),
|
||||||
|
].join('\n');
|
||||||
|
}
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
import type { JsonData } from '@/types/common';
|
||||||
|
import i18n from '@/i18n';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AI 预测分析模块 — 共享类型定义 + 常量
|
||||||
|
*
|
||||||
|
* 从 predictUtils.ts (716L) 提取。
|
||||||
|
* // Logan.Li
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ============ 容量预测类型 ============
|
||||||
|
|
||||||
|
export interface PredictionMetric {
|
||||||
|
name: string;
|
||||||
|
current: number;
|
||||||
|
predicted: number;
|
||||||
|
unit: string;
|
||||||
|
trend: 'up' | 'down' | 'stable';
|
||||||
|
confidence: number;
|
||||||
|
timeRange: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CapacityForecast {
|
||||||
|
resource: string;
|
||||||
|
metricKey: string;
|
||||||
|
currentUsage: number;
|
||||||
|
predictedUsage: number;
|
||||||
|
threshold: number;
|
||||||
|
daysUntilThreshold: number;
|
||||||
|
recommendation: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ 云成本预测类型 ============
|
||||||
|
|
||||||
|
/** 日费用数据点 */
|
||||||
|
export interface DailyCostPoint {
|
||||||
|
date: string;
|
||||||
|
cost: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 单维度(全部 / 云厂商)成本预测 */
|
||||||
|
export interface CostDimensionForecast {
|
||||||
|
dimension: string;
|
||||||
|
provider?: string;
|
||||||
|
currentMonth: number;
|
||||||
|
predictedMonthEnd: number;
|
||||||
|
dailyAvg: number;
|
||||||
|
trend: 'up' | 'down' | 'stable';
|
||||||
|
confidence: number;
|
||||||
|
recommendation: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 预算触线风险(对接 BillingAlertRule) */
|
||||||
|
export interface BudgetRiskForecast {
|
||||||
|
ruleName: string;
|
||||||
|
provider: string;
|
||||||
|
metric: 'daily' | 'monthly';
|
||||||
|
threshold: number;
|
||||||
|
current: number;
|
||||||
|
predicted: number;
|
||||||
|
probability: number;
|
||||||
|
timeframe: string;
|
||||||
|
suggestedAction: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 成本预测汇总 */
|
||||||
|
export interface CostPredictionSummary {
|
||||||
|
monthCost: number;
|
||||||
|
predictedMonthEnd: number;
|
||||||
|
todayCost: number;
|
||||||
|
yesterdayCost: number;
|
||||||
|
dailyAvg: number;
|
||||||
|
daysRemaining: number;
|
||||||
|
trend: 'up' | 'down' | 'stable';
|
||||||
|
confidence: number;
|
||||||
|
monthTrend: DailyCostPoint[];
|
||||||
|
providerDist: { provider: string; cost: number; percent: number }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ComputedCostPredictions {
|
||||||
|
summary: CostPredictionSummary;
|
||||||
|
dimensionForecasts: CostDimensionForecast[];
|
||||||
|
budgetRisks: BudgetRiskForecast[];
|
||||||
|
budgetRiskCount: number;
|
||||||
|
/** 近 7 日日均(用于识别预付费后按量趋零) // Logan.Li */
|
||||||
|
recentDailyAvg: number;
|
||||||
|
/** 账单形态:steady 平稳 / front_loaded 月初集中 / sparse 样本不足 */
|
||||||
|
billingPattern: 'steady' | 'front_loaded' | 'sparse';
|
||||||
|
/** 本月 Top 服务消费 */
|
||||||
|
topServices: { service: string; cost: number }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ 容量预测入参/出参 ============
|
||||||
|
|
||||||
|
/** computePredictions 的输入参数 */
|
||||||
|
export interface ComputePredictionsInput {
|
||||||
|
rawMetrics: JsonData[];
|
||||||
|
serverStats?: JsonData;
|
||||||
|
selectedTimeRange: string; // '1h' | '24h' | '7d' | '30d'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** computePredictions 的聚合输出 */
|
||||||
|
export interface ComputedPredictions {
|
||||||
|
predictions: PredictionMetric[];
|
||||||
|
capacityForecasts: CapacityForecast[];
|
||||||
|
accuracy: number;
|
||||||
|
capacityWarningCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ 成本预测入参/出参 ============
|
||||||
|
|
||||||
|
/** computeCostPredictions 输入 */
|
||||||
|
export interface ComputeCostPredictionsInput {
|
||||||
|
billingStats: JsonData | null;
|
||||||
|
billingRecords: JsonData[];
|
||||||
|
billingAlertRules: JsonData[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ 共享常量 ============
|
||||||
|
|
||||||
|
/** 指标定义:后端 metric name → 展示标签 / 告警表达式前缀 / 阈值 */
|
||||||
|
export const METRIC_DEFS = [
|
||||||
|
{ key: 'cpu', label: i18n.t('aiops.predictutilsts.s7054bc34'), exprKey: 'cpu_usage', threshold: 85 },
|
||||||
|
{ key: 'memory', label: i18n.t('aiops.predictutilsts.s82c0d1b0'), exprKey: 'memory_usage', threshold: 85 },
|
||||||
|
{ key: 'disk', label: i18n.t('aiops.predictutilsts.s4e2d8f91'), exprKey: 'disk_usage', threshold: 90 },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export const TIME_RANGE_LABELS: Record<string, string> = {
|
||||||
|
'1h': i18n.t('aiops.predictutilsts.s75719bb8'),
|
||||||
|
'24h': i18n.t('aiops.predictutilsts.sfc78646e'),
|
||||||
|
'7d': i18n.t('aiops.predictutilsts.s5b5a820c'),
|
||||||
|
'30d': i18n.t('aiops.predictutilsts.s933ab0a6'),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const TIME_RANGE_HOURS: Record<string, number> = {
|
||||||
|
'1h': 1,
|
||||||
|
'24h': 24,
|
||||||
|
'7d': 168,
|
||||||
|
'30d': 720,
|
||||||
|
};
|
||||||
@@ -1,716 +1,49 @@
|
|||||||
import type { JsonData } from "@/types/common";
|
|
||||||
import i18n from '@/i18n';
|
|
||||||
/**
|
/**
|
||||||
* AI 预测分析模块的纯计算工具 — 类型定义、常量、线性回归、预测推算
|
* AI 预测分析模块 — Barrel re-export(向后兼容)
|
||||||
*
|
*
|
||||||
* 从 AIPredictiveAnalytics.tsx(670行) 提取,避免主组件超 700 行硬上限。
|
* 原 716L 文件已按域拆分为 3 个文件:
|
||||||
* 所有函数均为纯函数(输入 → 输出,无副作用),便于单元测试和复用。
|
* - predict-types.ts — 类型定义 + 共享常量 (METRIC_DEFS / TIME_RANGE_*)
|
||||||
|
* - capacity-predict.ts — 容量预测(线性回归 + computePredictions + 容量规划 + AI prompt)
|
||||||
|
* - cost-predict.ts — 云成本预测(月末预测 + 预算触线风险 + AI prompt)
|
||||||
|
*
|
||||||
|
* 现有 3 个消费者的 import 路径 (`from './predictUtils'`) 无需修改。
|
||||||
* // Logan.Li
|
* // Logan.Li
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// ============ 类型定义 ============
|
// ============ 类型 ============
|
||||||
|
export type {
|
||||||
export interface PredictionMetric {
|
PredictionMetric,
|
||||||
name: string;
|
CapacityForecast,
|
||||||
current: number;
|
DailyCostPoint,
|
||||||
predicted: number;
|
CostDimensionForecast,
|
||||||
unit: string;
|
BudgetRiskForecast,
|
||||||
trend: 'up' | 'down' | 'stable';
|
CostPredictionSummary,
|
||||||
confidence: number;
|
ComputedCostPredictions,
|
||||||
timeRange: string;
|
ComputePredictionsInput,
|
||||||
}
|
ComputedPredictions,
|
||||||
|
ComputeCostPredictionsInput,
|
||||||
export interface CapacityForecast {
|
} from './predict-types';
|
||||||
resource: string;
|
|
||||||
metricKey: string;
|
|
||||||
currentUsage: number;
|
|
||||||
predictedUsage: number;
|
|
||||||
threshold: number;
|
|
||||||
daysUntilThreshold: number;
|
|
||||||
recommendation: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 日费用数据点 */
|
|
||||||
export interface DailyCostPoint {
|
|
||||||
date: string;
|
|
||||||
cost: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 单维度(全部 / 云厂商)成本预测 */
|
|
||||||
export interface CostDimensionForecast {
|
|
||||||
dimension: string;
|
|
||||||
provider?: string;
|
|
||||||
currentMonth: number;
|
|
||||||
predictedMonthEnd: number;
|
|
||||||
dailyAvg: number;
|
|
||||||
trend: 'up' | 'down' | 'stable';
|
|
||||||
confidence: number;
|
|
||||||
recommendation: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 预算触线风险(对接 BillingAlertRule) */
|
|
||||||
export interface BudgetRiskForecast {
|
|
||||||
ruleName: string;
|
|
||||||
provider: string;
|
|
||||||
metric: 'daily' | 'monthly';
|
|
||||||
threshold: number;
|
|
||||||
current: number;
|
|
||||||
predicted: number;
|
|
||||||
probability: number;
|
|
||||||
timeframe: string;
|
|
||||||
suggestedAction: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 成本预测汇总 */
|
|
||||||
export interface CostPredictionSummary {
|
|
||||||
monthCost: number;
|
|
||||||
predictedMonthEnd: number;
|
|
||||||
todayCost: number;
|
|
||||||
yesterdayCost: number;
|
|
||||||
dailyAvg: number;
|
|
||||||
daysRemaining: number;
|
|
||||||
trend: 'up' | 'down' | 'stable';
|
|
||||||
confidence: number;
|
|
||||||
monthTrend: DailyCostPoint[];
|
|
||||||
providerDist: { provider: string; cost: number; percent: number }[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ComputedCostPredictions {
|
|
||||||
summary: CostPredictionSummary;
|
|
||||||
dimensionForecasts: CostDimensionForecast[];
|
|
||||||
budgetRisks: BudgetRiskForecast[];
|
|
||||||
budgetRiskCount: number;
|
|
||||||
/** 近 7 日日均(用于识别预付费后按量趋零) // Logan.Li */
|
|
||||||
recentDailyAvg: number;
|
|
||||||
/** 账单形态:steady 平稳 / front_loaded 月初集中 / sparse 样本不足 */
|
|
||||||
billingPattern: 'steady' | 'front_loaded' | 'sparse';
|
|
||||||
/** 本月 Top 服务消费 */
|
|
||||||
topServices: { service: string; cost: number }[];
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============ 常量 ============
|
// ============ 常量 ============
|
||||||
|
export { METRIC_DEFS, TIME_RANGE_LABELS, TIME_RANGE_HOURS } from './predict-types';
|
||||||
|
|
||||||
/** 指标定义:后端 metric name → 展示标签 / 告警表达式前缀 / 阈值 */
|
// ============ 容量预测函数 ============
|
||||||
export const METRIC_DEFS = [
|
export {
|
||||||
{ key: 'cpu', label: i18n.t('aiops.predictutilsts.s7054bc34'), exprKey: 'cpu_usage', threshold: 85 },
|
linearRegression,
|
||||||
{ key: 'memory', label: i18n.t('aiops.predictutilsts.s82c0d1b0'), exprKey: 'memory_usage', threshold: 85 },
|
extractSeries,
|
||||||
{ key: 'disk', label: i18n.t('aiops.predictutilsts.s4e2d8f91'), exprKey: 'disk_usage', threshold: 90 },
|
capacityRecommendation,
|
||||||
] as const;
|
computePredictions,
|
||||||
|
trendColorClass,
|
||||||
|
buildCapacityInsightPrompt,
|
||||||
|
} from './capacity-predict';
|
||||||
|
|
||||||
export const TIME_RANGE_LABELS: Record<string, string> = {
|
// ============ 云成本预测函数 ============
|
||||||
'1h': i18n.t('aiops.predictutilsts.s75719bb8'),
|
export {
|
||||||
'24h': i18n.t('aiops.predictutilsts.sfc78646e'),
|
monthStartDate,
|
||||||
'7d': i18n.t('aiops.predictutilsts.s5b5a820c'),
|
daysRemainingInMonth,
|
||||||
'30d': i18n.t('aiops.predictutilsts.s933ab0a6'),
|
aggregateDailyCosts,
|
||||||
};
|
sumMonthCost,
|
||||||
|
costRecommendation,
|
||||||
export const TIME_RANGE_HOURS: Record<string, number> = {
|
computeCostPredictions,
|
||||||
'1h': 1,
|
buildCostInsightPrompt,
|
||||||
'24h': 24,
|
} from './cost-predict';
|
||||||
'7d': 168,
|
|
||||||
'30d': 720,
|
|
||||||
};
|
|
||||||
|
|
||||||
// ============ 纯函数工具 ============
|
|
||||||
|
|
||||||
/** 简单最小二乘线性回归,返回斜率和 R² */
|
|
||||||
export function linearRegression(values: number[]): { slope: number; r2: number } {
|
|
||||||
const n = values.length;
|
|
||||||
if (n < 2) return { slope: 0, r2: 0 };
|
|
||||||
const xs = values.map((_, i) => i);
|
|
||||||
const meanX = xs.reduce((a, b) => a + b, 0) / n;
|
|
||||||
const meanY = values.reduce((a, b) => a + b, 0) / n;
|
|
||||||
let num = 0,
|
|
||||||
den = 0,
|
|
||||||
ssTot = 0,
|
|
||||||
ssRes = 0;
|
|
||||||
for (let i = 0; i < n; i++) {
|
|
||||||
num += (xs[i] - meanX) * (values[i] - meanY);
|
|
||||||
den += (xs[i] - meanX) ** 2;
|
|
||||||
ssTot += (values[i] - meanY) ** 2;
|
|
||||||
}
|
|
||||||
const slope = den === 0 ? 0 : num / den;
|
|
||||||
const intercept = meanY - slope * meanX;
|
|
||||||
for (let i = 0; i < n; i++) {
|
|
||||||
const pred = intercept + slope * xs[i];
|
|
||||||
ssRes += (values[i] - pred) ** 2;
|
|
||||||
}
|
|
||||||
const r2 = ssTot === 0 ? 1 : Math.max(0, 1 - ssRes / ssTot);
|
|
||||||
return { slope, r2 };
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 从时序指标中提取按时间排序的数值序列 */
|
|
||||||
export function extractSeries( metrics: JsonData[], name: string): number[] {
|
|
||||||
return metrics
|
|
||||||
.filter((m) => m.name === name)
|
|
||||||
.sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime())
|
|
||||||
.map((m) => Number(m.value) || 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 容量规划建议 */
|
|
||||||
export function capacityRecommendation(metricKey: string, days: number): string {
|
|
||||||
const level = days <= 7 ? 'urgent' : days <= 14 ? 'warning' : 'normal';
|
|
||||||
const recs: Record<string, Record<string, string>> = {
|
|
||||||
urgent: {
|
|
||||||
cpu: i18n.t('aiops.predictutilsts.s4d0bd643'),
|
|
||||||
memory: i18n.t('aiops.predictutilsts.sb4593f3a'),
|
|
||||||
disk: i18n.t('aiops.predictutilsts.s6c25383f'),
|
|
||||||
},
|
|
||||||
warning: {
|
|
||||||
cpu: i18n.t('aiops.predictutilsts.s8ea826f4'),
|
|
||||||
memory: i18n.t('aiops.predictutilsts.sb08bd5fc'),
|
|
||||||
disk: i18n.t('aiops.predictutilsts.s4cdda5dc'),
|
|
||||||
},
|
|
||||||
normal: {
|
|
||||||
cpu: i18n.t('aiops.predictutilsts.sfa295c44'),
|
|
||||||
memory: i18n.t('aiops.predictutilsts.s7156a4c0'),
|
|
||||||
disk: i18n.t('aiops.predictutilsts.s0073f3a3'),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
return recs[level]?.[metricKey] || i18n.t('aiops.predictutilsts.sfeb368e6');
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============ 综合预测推算 ============
|
|
||||||
|
|
||||||
/** computePredictions 的输入参数 */
|
|
||||||
export interface ComputePredictionsInput {
|
|
||||||
rawMetrics: JsonData[];
|
|
||||||
serverStats?: JsonData;
|
|
||||||
selectedTimeRange: string; // '1h' | '24h' | '7d' | '30d'
|
|
||||||
}
|
|
||||||
|
|
||||||
/** computePredictions 的聚合输出 */
|
|
||||||
export interface ComputedPredictions {
|
|
||||||
predictions: PredictionMetric[];
|
|
||||||
capacityForecasts: CapacityForecast[];
|
|
||||||
accuracy: number;
|
|
||||||
capacityWarningCount: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 综合预测推算:基于时序指标计算指标预测与容量规划。
|
|
||||||
* 主组件 AIPredictiveAnalytics.tsx 直接调用此函数获取展示数据。
|
|
||||||
* // Logan.Li
|
|
||||||
*/
|
|
||||||
export function computePredictions(input: ComputePredictionsInput): ComputedPredictions {
|
|
||||||
const { rawMetrics, serverStats, selectedTimeRange } = input;
|
|
||||||
const horizonHours = TIME_RANGE_HOURS[selectedTimeRange] || 24;
|
|
||||||
const timeRangeLabel = TIME_RANGE_LABELS[selectedTimeRange] || i18n.t('aiops.predictutilsts.sfc78646e');
|
|
||||||
|
|
||||||
// ---- 指标预测 ----
|
|
||||||
const predictions: PredictionMetric[] = METRIC_DEFS.map((def) => {
|
|
||||||
const series = extractSeries(rawMetrics, def.key);
|
|
||||||
const { slope, r2 } = linearRegression(series);
|
|
||||||
const avgFallback =
|
|
||||||
def.key === 'cpu' ? serverStats.avg_cpu : def.key === 'memory' ? serverStats.avg_memory : serverStats.avg_disk;
|
|
||||||
const current = series.length > 0 ? series[series.length - 1] : avgFallback || 0;
|
|
||||||
let predicted = current + slope * horizonHours;
|
|
||||||
predicted = Math.max(0, Math.min(100, predicted));
|
|
||||||
const trend: 'up' | 'down' | 'stable' = slope > 0.05 ? 'up' : slope < -0.05 ? 'down' : 'stable';
|
|
||||||
const confidence = Math.round(60 + r2 * 35);
|
|
||||||
return {
|
|
||||||
name: def.label,
|
|
||||||
current: Math.round(current),
|
|
||||||
predicted: Math.round(predicted),
|
|
||||||
unit: '%',
|
|
||||||
trend,
|
|
||||||
confidence,
|
|
||||||
timeRange: timeRangeLabel,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
// ---- 容量规划 ----
|
|
||||||
const capacityForecasts: CapacityForecast[] = METRIC_DEFS.map((def) => {
|
|
||||||
const series = extractSeries(rawMetrics, def.key);
|
|
||||||
const { slope } = linearRegression(series);
|
|
||||||
const avgFallback =
|
|
||||||
def.key === 'cpu' ? serverStats.avg_cpu : def.key === 'memory' ? serverStats.avg_memory : serverStats.avg_disk;
|
|
||||||
const currentUsage = Math.round(series.length > 0 ? series[series.length - 1] : avgFallback || 0);
|
|
||||||
const slopePerDay = slope * 24;
|
|
||||||
const predictedUsage = Math.round(Math.min(100, currentUsage + slopePerDay * 7));
|
|
||||||
const threshold = def.threshold;
|
|
||||||
let daysUntilThreshold = 999;
|
|
||||||
if (slopePerDay > 0.01) {
|
|
||||||
daysUntilThreshold = Math.round((threshold - currentUsage) / slopePerDay);
|
|
||||||
daysUntilThreshold = daysUntilThreshold < 1 ? 1 : daysUntilThreshold;
|
|
||||||
}
|
|
||||||
const resourceNames: Record<string, string> = {
|
|
||||||
cpu: '服务器CPU算力',
|
|
||||||
memory: '服务器内存',
|
|
||||||
disk: '存储磁盘空间',
|
|
||||||
};
|
|
||||||
return {
|
|
||||||
resource: resourceNames[def.key] || def.label,
|
|
||||||
metricKey: def.key,
|
|
||||||
currentUsage,
|
|
||||||
predictedUsage,
|
|
||||||
threshold,
|
|
||||||
daysUntilThreshold,
|
|
||||||
recommendation: capacityRecommendation(def.key, daysUntilThreshold),
|
|
||||||
};
|
|
||||||
}).filter((f) => f.currentUsage > 0);
|
|
||||||
|
|
||||||
// ---- 概览统计 ----
|
|
||||||
const avgR2 = METRIC_DEFS.length > 0
|
|
||||||
? METRIC_DEFS.reduce((sum, def) => sum + linearRegression(extractSeries(rawMetrics, def.key)).r2, 0) /
|
|
||||||
METRIC_DEFS.length
|
|
||||||
: 0;
|
|
||||||
const accuracy = Math.round(78 + avgR2 * 17);
|
|
||||||
const capacityWarningCount = capacityForecasts.filter((f) => f.daysUntilThreshold <= 14).length;
|
|
||||||
|
|
||||||
return {
|
|
||||||
predictions,
|
|
||||||
capacityForecasts,
|
|
||||||
accuracy,
|
|
||||||
capacityWarningCount,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 趋势图标颜色工具,供主组件 render 用 */
|
|
||||||
export function trendColorClass(trend: string): string {
|
|
||||||
switch (trend) {
|
|
||||||
case 'up':
|
|
||||||
return 'dg-text-red';
|
|
||||||
case 'down':
|
|
||||||
return 'dg-text-green';
|
|
||||||
default:
|
|
||||||
return 'dg-text-secondary';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 构建容量预测「AI 深度解读」的 prompt —— 把硬数据序列化为结构化上下文喂给 LLM。 // Logan.Li
|
|
||||||
* 纯函数,供 PredictiveCapacityTab 调 aiApi.chat 使用。无数据返回空串(调用方据此禁用按钮)。
|
|
||||||
*/
|
|
||||||
export function buildCapacityInsightPrompt(forecasts: CapacityForecast[]): string {
|
|
||||||
if (forecasts.length === 0) return '';
|
|
||||||
const lines = forecasts.map((f) => {
|
|
||||||
const risk = f.daysUntilThreshold >= 999 ? i18n.t('aiops.predictutilsts.s179fd5a9') : i18n.t('aiops.predictutilsts.daysUntilThreshold', { days: f.daysUntilThreshold });
|
|
||||||
return `- ${f.resource}:当前 ${f.currentUsage}% → 7天后预测 ${f.predictedUsage}%(阈值 ${f.threshold}%),${risk}`;
|
|
||||||
});
|
|
||||||
return [
|
|
||||||
i18n.t('aiops.predictutilsts.s3b46e5a0'),
|
|
||||||
'',
|
|
||||||
i18n.t('aiops.predictutilsts.s586bac5d'),
|
|
||||||
...lines,
|
|
||||||
'',
|
|
||||||
i18n.t('aiops.predictutilsts.sdf6ebee3'),
|
|
||||||
i18n.t('aiops.predictutilsts.s534b6893'),
|
|
||||||
i18n.t('aiops.predictutilsts.saf173e7a'),
|
|
||||||
i18n.t('aiops.predictutilsts.s4ed1d035'),
|
|
||||||
].join('\n');
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============ 云成本预测 ============
|
|
||||||
|
|
||||||
const PROVIDER_LABELS: Record<string, string> = {
|
|
||||||
aliyun: i18n.t('aiops.predictutilsts.providerAliyun'),
|
|
||||||
tencent: i18n.t('aiops.predictutilsts.providerTencent'),
|
|
||||||
aws: 'AWS',
|
|
||||||
huawei: i18n.t('aiops.predictutilsts.providerHuawei'),
|
|
||||||
azure: 'Azure',
|
|
||||||
};
|
|
||||||
|
|
||||||
/** 本月第一天 YYYY-MM-DD */
|
|
||||||
export function monthStartDate(ref = new Date()): string {
|
|
||||||
return `${ref.getFullYear()}-${String(ref.getMonth() + 1).padStart(2, '0')}-01`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 本月剩余天数(不含今天) */
|
|
||||||
export function daysRemainingInMonth(ref = new Date()): number {
|
|
||||||
const lastDay = new Date(ref.getFullYear(), ref.getMonth() + 1, 0).getDate();
|
|
||||||
return Math.max(0, lastDay - ref.getDate());
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 账单明细按日聚合 */
|
|
||||||
export function aggregateDailyCosts(
|
|
||||||
records: JsonData[],
|
|
||||||
filter?: { startDate?: string; provider?: string },
|
|
||||||
): DailyCostPoint[] {
|
|
||||||
const map = new Map<string, number>();
|
|
||||||
for (const r of records) {
|
|
||||||
const date = String(r.billing_date || '');
|
|
||||||
if (!date) continue;
|
|
||||||
if (filter?.startDate && date < filter.startDate) continue;
|
|
||||||
if (filter?.provider && r.provider !== filter.provider) continue;
|
|
||||||
map.set(date, (map.get(date) || 0) + (Number(r.cost) || 0));
|
|
||||||
}
|
|
||||||
return Array.from(map.entries())
|
|
||||||
.sort((a, b) => a[0].localeCompare(b[0]))
|
|
||||||
.map(([date, cost]) => ({ date, cost }));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 本月累计费用 */
|
|
||||||
export function sumMonthCost(records: JsonData[], provider?: string): number {
|
|
||||||
const start = monthStartDate();
|
|
||||||
return records.reduce((sum, r) => {
|
|
||||||
const date = String(r.billing_date || '');
|
|
||||||
if (date < start) return sum;
|
|
||||||
if (provider && r.provider !== provider) return sum;
|
|
||||||
return sum + (Number(r.cost) || 0);
|
|
||||||
}, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
function costTrendFromSlope(slope: number): 'up' | 'down' | 'stable' {
|
|
||||||
if (slope > 1) return 'up';
|
|
||||||
if (slope < -1) return 'down';
|
|
||||||
return 'stable';
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 成本优化建议 */
|
|
||||||
export function costRecommendation(trend: 'up' | 'down' | 'stable', ratio: number): string {
|
|
||||||
if (trend === 'up' && ratio >= 0.85) {
|
|
||||||
return i18n.t('aiops.predictutilsts.sa7d4e3c1');
|
|
||||||
}
|
|
||||||
if (trend === 'up') {
|
|
||||||
return i18n.t('aiops.predictutilsts.sb3f5a2d8');
|
|
||||||
}
|
|
||||||
if (trend === 'down') {
|
|
||||||
return i18n.t('aiops.predictutilsts.sc1a9b4e7');
|
|
||||||
}
|
|
||||||
return i18n.t('aiops.predictutilsts.sd8c6f2a3');
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 预算风险建议 */
|
|
||||||
function budgetRiskAction(metric: 'daily' | 'monthly', provider: string): string {
|
|
||||||
const scope = provider && provider !== i18n.t('aiops.predictutilsts.sg5a3d8e1') ? `${provider} ` : '';
|
|
||||||
if (metric === 'daily') {
|
|
||||||
return i18n.t('aiops.predictutilsts.se4b7a5d2', { scope });
|
|
||||||
}
|
|
||||||
return i18n.t('aiops.predictutilsts.sf2d8c6b4', { scope });
|
|
||||||
}
|
|
||||||
|
|
||||||
interface MonthEndForecast {
|
|
||||||
currentMonth: number;
|
|
||||||
predictedMonthEnd: number;
|
|
||||||
dailyAvg: number;
|
|
||||||
recentDailyAvg: number;
|
|
||||||
billingPattern: 'steady' | 'front_loaded' | 'sparse';
|
|
||||||
trend: 'up' | 'down' | 'stable';
|
|
||||||
confidence: number;
|
|
||||||
series: DailyCostPoint[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 识别账单形态(预付费常表现为月初大额、后续近零) // Logan.Li */
|
|
||||||
function detectBillingPattern(series: DailyCostPoint[]): 'steady' | 'front_loaded' | 'sparse' {
|
|
||||||
const active = series.filter((d) => d.cost > 0);
|
|
||||||
if (active.length < 2) return 'sparse';
|
|
||||||
const total = series.reduce((s, d) => s + d.cost, 0);
|
|
||||||
const recent = series.slice(-Math.min(7, series.length));
|
|
||||||
const recentAvg = recent.reduce((s, d) => s + d.cost, 0) / recent.length;
|
|
||||||
const overallAvg = total / series.length;
|
|
||||||
if (overallAvg > 0 && recentAvg < overallAvg * 0.25) return 'front_loaded';
|
|
||||||
return 'steady';
|
|
||||||
}
|
|
||||||
|
|
||||||
function recentRunRate(series: DailyCostPoint[], days = 7): number {
|
|
||||||
const recent = series.slice(-Math.min(days, series.length));
|
|
||||||
if (recent.length === 0) return 0;
|
|
||||||
return recent.reduce((s, d) => s + d.cost, 0) / recent.length;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 本月 Top 服务消费 */
|
|
||||||
function topServicesByCost(records: JsonData[], limit = 5): { service: string; cost: number }[] {
|
|
||||||
const start = monthStartDate();
|
|
||||||
const map = new Map<string, number>();
|
|
||||||
records.forEach((r) => {
|
|
||||||
const date = String(r.billing_date || '');
|
|
||||||
if (date < start) return;
|
|
||||||
const svc = String(r.service || i18n.t('aiops.predictutilsts.sh7c2b9d6'));
|
|
||||||
map.set(svc, (map.get(svc) || 0) + (Number(r.cost) || 0));
|
|
||||||
});
|
|
||||||
return [...map.entries()]
|
|
||||||
.sort((a, b) => b[1] - a[1])
|
|
||||||
.slice(0, limit)
|
|
||||||
.map(([service, cost]) => ({ service, cost: Math.round(cost * 100) / 100 }));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 基于日费用序列预测月末总费用 */
|
|
||||||
function forecastMonthEnd(
|
|
||||||
records: JsonData[],
|
|
||||||
statsMonthCost: number,
|
|
||||||
fallbackTrend: DailyCostPoint[],
|
|
||||||
provider?: string,
|
|
||||||
): MonthEndForecast {
|
|
||||||
const start = monthStartDate();
|
|
||||||
const monthSeries = aggregateDailyCosts(records, { startDate: start, provider });
|
|
||||||
const series = monthSeries.length >= 2 ? monthSeries : fallbackTrend;
|
|
||||||
const costs = series.map((d) => d.cost);
|
|
||||||
const { slope, r2 } = linearRegression(costs);
|
|
||||||
const currentMonth =
|
|
||||||
monthSeries.length > 0 ? monthSeries.reduce((s, d) => s + d.cost, 0) : statsMonthCost;
|
|
||||||
const daysLeft = daysRemainingInMonth();
|
|
||||||
const dailyAvg = costs.length > 0 ? costs.reduce((a, b) => a + b, 0) / costs.length : 0;
|
|
||||||
const recentDailyAvg = recentRunRate(series);
|
|
||||||
const billingPattern = detectBillingPattern(series);
|
|
||||||
// 预付费/前高后平:用近 7 日运行费率外推,避免线性回归高估或矛盾 // Logan.Li
|
|
||||||
const extrapolation =
|
|
||||||
billingPattern === 'front_loaded' ? recentDailyAvg * daysLeft : slope * daysLeft;
|
|
||||||
const predictedMonthEnd = Math.max(0, Math.round((currentMonth + extrapolation) * 100) / 100);
|
|
||||||
let confidence = Math.round(55 + r2 * 40);
|
|
||||||
if (billingPattern === 'front_loaded') confidence = Math.max(45, confidence - 15);
|
|
||||||
if (billingPattern === 'sparse') confidence = Math.min(confidence, 50);
|
|
||||||
return {
|
|
||||||
currentMonth,
|
|
||||||
predictedMonthEnd,
|
|
||||||
dailyAvg: Math.round(dailyAvg * 100) / 100,
|
|
||||||
recentDailyAvg: Math.round(recentDailyAvg * 100) / 100,
|
|
||||||
billingPattern,
|
|
||||||
trend: costTrendFromSlope(billingPattern === 'front_loaded' ? recentDailyAvg - dailyAvg : slope),
|
|
||||||
confidence,
|
|
||||||
series: monthSeries.length > 0 ? monthSeries : fallbackTrend,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/** computeCostPredictions 输入 */
|
|
||||||
export interface ComputeCostPredictionsInput {
|
|
||||||
billingStats: JsonData | null;
|
|
||||||
billingRecords: JsonData[];
|
|
||||||
billingAlertRules: JsonData[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 云成本预测:基于账单日粒度时序 + 预算预警规则,推算月末费用与触线风险。
|
|
||||||
* // Logan.Li
|
|
||||||
*/
|
|
||||||
export function computeCostPredictions(input: ComputeCostPredictionsInput): ComputedCostPredictions {
|
|
||||||
const { billingStats, billingRecords, billingAlertRules } = input;
|
|
||||||
const empty: ComputedCostPredictions = {
|
|
||||||
summary: {
|
|
||||||
monthCost: 0,
|
|
||||||
predictedMonthEnd: 0,
|
|
||||||
todayCost: 0,
|
|
||||||
yesterdayCost: 0,
|
|
||||||
dailyAvg: 0,
|
|
||||||
daysRemaining: daysRemainingInMonth(),
|
|
||||||
trend: 'stable',
|
|
||||||
confidence: 0,
|
|
||||||
monthTrend: [],
|
|
||||||
providerDist: [],
|
|
||||||
},
|
|
||||||
dimensionForecasts: [],
|
|
||||||
budgetRisks: [],
|
|
||||||
budgetRiskCount: 0,
|
|
||||||
recentDailyAvg: 0,
|
|
||||||
billingPattern: 'sparse',
|
|
||||||
topServices: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!billingStats) return empty;
|
|
||||||
|
|
||||||
const monthTrend: DailyCostPoint[] = Array.isArray(billingStats.month_trend)
|
|
||||||
? billingStats.month_trend.map((d: JsonData) => ({
|
|
||||||
date: String(d.date || ''),
|
|
||||||
cost: Number(d.cost) || 0,
|
|
||||||
}))
|
|
||||||
: [];
|
|
||||||
const providerDist: { provider: string; cost: number; percent: number }[] = Array.isArray(
|
|
||||||
billingStats.provider_dist,
|
|
||||||
)
|
|
||||||
? billingStats.provider_dist.map((p: JsonData) => ({
|
|
||||||
provider: String(p.provider || ''),
|
|
||||||
cost: Number(p.cost) || 0,
|
|
||||||
percent: Number(p.percent) || 0,
|
|
||||||
}))
|
|
||||||
: [];
|
|
||||||
|
|
||||||
const overall = forecastMonthEnd(
|
|
||||||
billingRecords,
|
|
||||||
Number(billingStats.month_cost) || 0,
|
|
||||||
monthTrend,
|
|
||||||
);
|
|
||||||
|
|
||||||
const summary: CostPredictionSummary = {
|
|
||||||
monthCost: Number(billingStats.month_cost) || overall.currentMonth,
|
|
||||||
predictedMonthEnd: overall.predictedMonthEnd,
|
|
||||||
todayCost: Number(billingStats.today_cost) || 0,
|
|
||||||
yesterdayCost: Number(billingStats.yesterday_cost) || 0,
|
|
||||||
dailyAvg: overall.dailyAvg,
|
|
||||||
daysRemaining: daysRemainingInMonth(),
|
|
||||||
trend: overall.trend,
|
|
||||||
confidence: overall.confidence,
|
|
||||||
monthTrend: overall.series.length > 0 ? overall.series : monthTrend,
|
|
||||||
providerDist,
|
|
||||||
};
|
|
||||||
|
|
||||||
// 全部 + 各云厂商维度预测
|
|
||||||
const dimensionForecasts: CostDimensionForecast[] = [];
|
|
||||||
const allRatio =
|
|
||||||
summary.predictedMonthEnd > 0 ? summary.monthCost / summary.predictedMonthEnd : 0;
|
|
||||||
dimensionForecasts.push({
|
|
||||||
dimension: i18n.t('aiops.predictutilsts.sa1b2c3d4'),
|
|
||||||
currentMonth: summary.monthCost,
|
|
||||||
predictedMonthEnd: summary.predictedMonthEnd,
|
|
||||||
dailyAvg: summary.dailyAvg,
|
|
||||||
trend: summary.trend,
|
|
||||||
confidence: summary.confidence,
|
|
||||||
recommendation: costRecommendation(summary.trend, allRatio),
|
|
||||||
});
|
|
||||||
|
|
||||||
const providers = new Set<string>();
|
|
||||||
providerDist.forEach((p) => providers.add(p.provider));
|
|
||||||
billingRecords.forEach((r) => {
|
|
||||||
if (r.provider) providers.add(String(r.provider));
|
|
||||||
});
|
|
||||||
|
|
||||||
for (const provider of providers) {
|
|
||||||
if (!provider) continue;
|
|
||||||
const provForecast = forecastMonthEnd(billingRecords, 0, monthTrend, provider);
|
|
||||||
const provMonth =
|
|
||||||
provForecast.currentMonth > 0 ? provForecast.currentMonth : sumMonthCost(billingRecords, provider);
|
|
||||||
const ratio = provForecast.predictedMonthEnd > 0 ? provMonth / provForecast.predictedMonthEnd : 0;
|
|
||||||
dimensionForecasts.push({
|
|
||||||
dimension: PROVIDER_LABELS[provider] || provider,
|
|
||||||
provider,
|
|
||||||
currentMonth: provMonth,
|
|
||||||
predictedMonthEnd: provForecast.predictedMonthEnd,
|
|
||||||
dailyAvg: provForecast.dailyAvg,
|
|
||||||
trend: provForecast.trend,
|
|
||||||
confidence: provForecast.confidence,
|
|
||||||
recommendation: costRecommendation(provForecast.trend, ratio),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
dimensionForecasts.sort((a, b) => b.predictedMonthEnd - a.predictedMonthEnd);
|
|
||||||
|
|
||||||
// 预算触线风险
|
|
||||||
const budgetRisks: BudgetRiskForecast[] = [];
|
|
||||||
const activeAlerts: JsonData[] = Array.isArray(billingStats.alerts) ? billingStats.alerts : [];
|
|
||||||
|
|
||||||
for (const rule of billingAlertRules) {
|
|
||||||
if (!rule.enabled) continue;
|
|
||||||
const threshold = Number(rule.threshold) || 0;
|
|
||||||
if (threshold <= 0) continue;
|
|
||||||
|
|
||||||
const metric = (rule.metric === 'daily' ? 'daily' : 'monthly') as 'daily' | 'monthly';
|
|
||||||
const ruleProvider = String(rule.provider || '');
|
|
||||||
const providerLabel = ruleProvider ? PROVIDER_LABELS[ruleProvider] || ruleProvider : i18n.t('aiops.predictutilsts.sb2c3d4e5');
|
|
||||||
|
|
||||||
let current = 0;
|
|
||||||
let predicted = 0;
|
|
||||||
let slope = 0;
|
|
||||||
|
|
||||||
if (metric === 'daily') {
|
|
||||||
current = Number(billingStats.today_cost) || 0;
|
|
||||||
if (ruleProvider) {
|
|
||||||
const today = new Date().toISOString().slice(0, 10);
|
|
||||||
current = billingRecords
|
|
||||||
.filter((r) => r.billing_date === today && r.provider === ruleProvider)
|
|
||||||
.reduce((s, r) => s + (Number(r.cost) || 0), 0);
|
|
||||||
}
|
|
||||||
const recent = aggregateDailyCosts(billingRecords, ruleProvider ? { provider: ruleProvider } : undefined);
|
|
||||||
const costs = recent.slice(-7).map((d) => d.cost);
|
|
||||||
slope = linearRegression(costs).slope;
|
|
||||||
predicted = Math.max(0, current + slope);
|
|
||||||
} else {
|
|
||||||
const provForecast = ruleProvider
|
|
||||||
? forecastMonthEnd(billingRecords, 0, monthTrend, ruleProvider)
|
|
||||||
: overall;
|
|
||||||
current = ruleProvider ? sumMonthCost(billingRecords, ruleProvider) : summary.monthCost;
|
|
||||||
predicted = provForecast.predictedMonthEnd;
|
|
||||||
const costs = provForecast.series.map((d) => d.cost);
|
|
||||||
slope = linearRegression(costs).slope;
|
|
||||||
}
|
|
||||||
|
|
||||||
const active = activeAlerts.find((a) => a.rule_id === rule.id || a.rule_name === rule.name);
|
|
||||||
if (active) current = Number(active.current) || current;
|
|
||||||
|
|
||||||
const ratio = threshold > 0 ? predicted / threshold : 0;
|
|
||||||
let probability = Math.round(ratio * 50);
|
|
||||||
if (slope > 0.5) probability += 15;
|
|
||||||
if (current >= threshold) probability += 25;
|
|
||||||
probability = Math.max(5, Math.min(92, probability));
|
|
||||||
|
|
||||||
let timeframe = metric === 'daily' ? i18n.t('aiops.predictutilsts.sc3d4e5f6') : i18n.t('aiops.predictutilsts.sd4e5f6g7');
|
|
||||||
if (slope > 0.01 && threshold > current) {
|
|
||||||
const days = (threshold - current) / slope;
|
|
||||||
if (days < 1) timeframe = metric === 'daily' ? i18n.t('aiops.predictutilsts.se5f6g7h8') : i18n.t('aiops.predictutilsts.sf6g7h8i9');
|
|
||||||
else if (days <= 7) timeframe = i18n.t('aiops.predictutilsts.sg7h8i9j0', { days: Math.ceil(days) });
|
|
||||||
else timeframe = i18n.t('aiops.predictutilsts.sh8i9j0k1', { days: Math.ceil(days) });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (probability >= 30 || current >= threshold * 0.7) {
|
|
||||||
budgetRisks.push({
|
|
||||||
ruleName: String(rule.name || i18n.t('aiops.predictutilsts.si9j0k1l2')),
|
|
||||||
provider: providerLabel,
|
|
||||||
metric,
|
|
||||||
threshold,
|
|
||||||
current: Math.round(current * 100) / 100,
|
|
||||||
predicted: Math.round(predicted * 100) / 100,
|
|
||||||
probability,
|
|
||||||
timeframe,
|
|
||||||
suggestedAction: budgetRiskAction(metric, providerLabel),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
budgetRisks.sort((a, b) => b.probability - a.probability);
|
|
||||||
const budgetRiskCount = budgetRisks.filter((r) => r.probability >= 50).length;
|
|
||||||
|
|
||||||
return {
|
|
||||||
summary,
|
|
||||||
dimensionForecasts,
|
|
||||||
budgetRisks,
|
|
||||||
budgetRiskCount,
|
|
||||||
recentDailyAvg: overall.recentDailyAvg,
|
|
||||||
billingPattern: overall.billingPattern,
|
|
||||||
topServices: topServicesByCost(billingRecords),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 构建成本预测「AI 深度解读」prompt。
|
|
||||||
* // Logan.Li
|
|
||||||
*/
|
|
||||||
export function buildCostInsightPrompt(data: ComputedCostPredictions): string {
|
|
||||||
const { summary, dimensionForecasts, budgetRisks, recentDailyAvg, billingPattern, topServices } = data;
|
|
||||||
if (summary.monthCost <= 0 && dimensionForecasts.length === 0) return '';
|
|
||||||
|
|
||||||
const patternHint =
|
|
||||||
billingPattern === 'front_loaded'
|
|
||||||
? i18n.t('aiops.predictutilsts.sj0k1l2m3')
|
|
||||||
: billingPattern === 'sparse'
|
|
||||||
? i18n.t('aiops.predictutilsts.sk1l2m3n4')
|
|
||||||
: i18n.t('aiops.predictutilsts.sl2m3n4o5');
|
|
||||||
|
|
||||||
const dimLines = dimensionForecasts.slice(0, 6).map(
|
|
||||||
(d) =>
|
|
||||||
`- ${d.dimension}:本月 ${d.currentMonth.toFixed(2)} 元 → 预测月末 ${d.predictedMonthEnd.toFixed(2)} 元(趋势 ${d.trend})`,
|
|
||||||
);
|
|
||||||
const riskLines =
|
|
||||||
budgetRisks.length > 0
|
|
||||||
? budgetRisks.slice(0, 5).map(
|
|
||||||
(r) =>
|
|
||||||
`- ${r.ruleName}(${r.provider}):当前 ${r.current} / 阈值 ${r.threshold},预测 ${r.predicted},触线概率 ${r.probability}%`,
|
|
||||||
)
|
|
||||||
: [`- ${i18n.t('aiops.predictutilsts.sm3n4o5p6')}`];
|
|
||||||
const serviceLines =
|
|
||||||
topServices.length > 0
|
|
||||||
? topServices.map((s) => `- ${s.service}:${s.cost.toFixed(2)} 元`)
|
|
||||||
: [`- ${i18n.t('aiops.predictutilsts.sn4o5p6q7')}`];
|
|
||||||
|
|
||||||
return [
|
|
||||||
i18n.t('aiops.predictutilsts.so5p6q7r8'),
|
|
||||||
'',
|
|
||||||
i18n.t('aiops.predictutilsts.sp6q7r8s9'),
|
|
||||||
`- 本月已花:${summary.monthCost.toFixed(2)} 元`,
|
|
||||||
`- 预测月末:${summary.predictedMonthEnd.toFixed(2)} 元(置信 ${summary.confidence}%)`,
|
|
||||||
`- 全月日均:${summary.dailyAvg.toFixed(2)} 元;近7日日均:${recentDailyAvg.toFixed(2)} 元;剩余 ${summary.daysRemaining} 天`,
|
|
||||||
`- 账单形态:${patternHint}`,
|
|
||||||
'',
|
|
||||||
i18n.t('aiops.predictutilsts.sq7r8s9t0'),
|
|
||||||
...serviceLines,
|
|
||||||
'',
|
|
||||||
i18n.t('aiops.predictutilsts.sr8s9t0u1'),
|
|
||||||
...dimLines,
|
|
||||||
'',
|
|
||||||
i18n.t('aiops.predictutilsts.ss9t0u1v2'),
|
|
||||||
...riskLines,
|
|
||||||
'',
|
|
||||||
i18n.t('aiops.predictutilsts.st0u1v2w3'),
|
|
||||||
i18n.t('aiops.predictutilsts.su1v2w3x4'),
|
|
||||||
i18n.t('aiops.predictutilsts.sv2w3x4y5'),
|
|
||||||
i18n.t('aiops.predictutilsts.sw3x4y5z6'),
|
|
||||||
i18n.t('aiops.predictutilsts.sx4y5z6a7'),
|
|
||||||
].join('\n');
|
|
||||||
}
|
|
||||||
Reference in new issue
Block a user