@meyicloud/meyi-cost-server 1.8.2 → 1.8.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@meyicloud/meyi-cost-server",
3
- "version": "1.8.2",
3
+ "version": "1.8.3",
4
4
  "description": "Tenant-aware AWS CUR and Athena cost plugin server for MeyiConnect",
5
5
  "type": "module",
6
6
  "main": "./index.js",
@@ -3,6 +3,9 @@ import {
3
3
  getCurOverview,
4
4
  getCurReport,
5
5
  getCurTags,
6
+ mergeCurFilterOptions,
7
+ mergeCurOverview,
8
+ mergeCurReports,
6
9
  } from "../services/cur.service.js";
7
10
  import { dateRange, iso, listParam } from "../lib/cost-utils.js";
8
11
 
@@ -18,9 +21,10 @@ export class CostController {
18
21
  try {
19
22
  const ctx = await this.contextService.resolve(req);
20
23
  const names = new Map(ctx.accounts.map((item) => [item.id, item.name]));
21
- const report = await this.curProvider.run(ctx, (client, config) => getCurReport({
24
+ const reports = await this.curProvider.run(ctx, (client, config) => getCurReport({
22
25
  client, config, tenant: ctx.tenant, range: dateRange(req.query), groupBy: "account", accountNames: names,
23
26
  }));
27
+ const report = mergeCurReports(reports);
24
28
  return res.json({
25
29
  accounts: report.summary.map((item) => ({ id: item.accountId || item.key, name: item.label, region: "global", status: "active" })),
26
30
  dataSource: "aws-cur-athena",
@@ -37,7 +41,7 @@ export class CostController {
37
41
  const trendStart = trendMode === "monthly" ? new Date(`${range.End}T00:00:00Z`) : new Date(`${range.Start}T00:00:00Z`);
38
42
  if (trendMode === "monthly") trendStart.setUTCMonth(0, 1);
39
43
  const names = new Map(ctx.accounts.map((item) => [item.id, item.name]));
40
- const result = await this.curProvider.run(ctx, (client, config) => getCurOverview({
44
+ const results = await this.curProvider.run(ctx, (client, config) => getCurOverview({
41
45
  client,
42
46
  config,
43
47
  tenant: ctx.tenant,
@@ -46,6 +50,7 @@ export class CostController {
46
50
  trendMode,
47
51
  accountNames: names,
48
52
  }));
53
+ const result = mergeCurOverview(results);
49
54
  return res.json(result);
50
55
  } catch (error) { return next(error); }
51
56
  }
@@ -54,7 +59,7 @@ export class CostController {
54
59
  try {
55
60
  const ctx = await this.contextService.resolve(req);
56
61
  const names = new Map(ctx.accounts.map((item) => [item.id, item.name]));
57
- const result = await this.curProvider.run(ctx, (client, config) => getCurFilterOptions({
62
+ const results = await this.curProvider.run(ctx, (client, config) => getCurFilterOptions({
58
63
  client,
59
64
  config,
60
65
  tenant: ctx.tenant,
@@ -62,6 +67,7 @@ export class CostController {
62
67
  tagKey: String(req.query.tagKey || "").trim(),
63
68
  accountNames: names,
64
69
  }));
70
+ const result = mergeCurFilterOptions(results);
65
71
  return res.json({ ...result, tenantId: ctx.tenant, updatedAt: new Date().toISOString() });
66
72
  } catch (error) { return next(error); }
67
73
  }
@@ -81,7 +87,7 @@ export class CostController {
81
87
  tagKey: String(req.query.filterTagKey || "").trim(),
82
88
  tagValues: listParam(req.query.tagValues),
83
89
  };
84
- const result = await this.curProvider.run(ctx, (client, config) => getCurReport({
90
+ const results = await this.curProvider.run(ctx, (client, config) => getCurReport({
85
91
  client,
86
92
  config,
87
93
  tenant: ctx.tenant,
@@ -93,6 +99,7 @@ export class CostController {
93
99
  filters,
94
100
  includeBreakdown: true,
95
101
  }));
102
+ const result = mergeCurReports(results);
96
103
  return res.json({ ...result, tenantId: ctx.tenant, updatedAt: new Date().toISOString() });
97
104
  } catch (error) { return next(error); }
98
105
  }
@@ -100,7 +107,8 @@ export class CostController {
100
107
  async tags(req, res, next) {
101
108
  try {
102
109
  const ctx = await this.contextService.resolve(req);
103
- const tags = await this.curProvider.run(ctx, (client, config) => getCurTags({ client, config, tenant: ctx.tenant }));
110
+ const tagSets = await this.curProvider.run(ctx, (client, config) => getCurTags({ client, config, tenant: ctx.tenant }));
111
+ const tags = [...new Set(tagSets.flat())].sort();
104
112
  return res.json({ tags, dataSource: "aws-cur-athena", accessMode: "cur-readonly" });
105
113
  } catch (error) { return next(error); }
106
114
  }
@@ -1,5 +1,5 @@
1
- export class CurDataStatus {
2
- constructor({ configured, required = true, ready = false, state, action = null, credentialMode = "saas-runtime", ingestionMode = "central", sourceConfigured = false, lastDataAt = null, recordCount = 0, message = null, discovery = null }) {
1
+ export class CurDataStatus {
2
+ constructor({ configured, required = true, ready = false, state, action = null, credentialMode = "saas-runtime", ingestionMode = "central", sourceConfigured = false, lastDataAt = null, recordCount = 0, message = null, discovery = null, sources = [], managementAccountId = null, connectionId = null }) {
3
3
  this.configured = Boolean(configured);
4
4
  this.required = Boolean(required);
5
5
  this.ready = Boolean(ready);
@@ -11,7 +11,10 @@ export class CurDataStatus {
11
11
  this.lastDataAt = lastDataAt;
12
12
  this.recordCount = Number(recordCount || 0);
13
13
  this.message = message;
14
- this.discovery = discovery ? Object.freeze({ ...discovery }) : null;
14
+ this.discovery = discovery ? Object.freeze({ ...discovery }) : null;
15
+ this.sources = Object.freeze(sources.map((source) => Object.freeze({ ...source })));
16
+ this.managementAccountId = managementAccountId;
17
+ this.connectionId = connectionId;
15
18
  Object.freeze(this);
16
19
  }
17
20
  }
@@ -6,10 +6,16 @@ const normalizeAccount = (account = {}) => Object.freeze({
6
6
  });
7
7
 
8
8
  export class CustomerAwsContext {
9
- constructor({ tenant, accounts = [], meta = {} }) {
10
- this.tenant = String(tenant || "default").trim() || "default";
11
- this.accounts = Object.freeze(accounts.map(normalizeAccount).filter((account) => account.id));
9
+ constructor({ tenant, accounts = [], meta = {}, sources = [] }) {
10
+ this.tenant = String(tenant || "default").trim() || "default";
11
+ this.accounts = Object.freeze(accounts.map(normalizeAccount).filter((account) => account.id));
12
12
  this.meta = Object.freeze({ ...meta });
13
- Object.freeze(this);
14
- }
15
- }
13
+ this.sources = Object.freeze(sources.map((source) => Object.freeze({
14
+ managementAccountId: String(source.managementAccountId || "").trim(),
15
+ connectionId: String(source.connectionId || "").trim(),
16
+ accounts: Object.freeze((source.accounts || []).map(normalizeAccount).filter((account) => account.id)),
17
+ meta: Object.freeze({ ...(source.meta || {}) }),
18
+ })).filter((source) => source.connectionId));
19
+ Object.freeze(this);
20
+ }
21
+ }
@@ -11,7 +11,7 @@ export class AwsOnboardingRepository {
11
11
  this.curDiscoveryTable = `"${schema}".cost_cur_discovery_jobs`;
12
12
  }
13
13
 
14
- async findCostAccess(tenant) {
14
+ async findCostAccess(tenant, managementAccountId = undefined) {
15
15
  const relations = rows(await this.db.execute(sql`
16
16
  SELECT
17
17
  to_regclass(${`${this.schema}.aws_connections`}) AS connections_table,
@@ -22,115 +22,103 @@ export class AwsOnboardingRepository {
22
22
 
23
23
  if (!relations.connections_table) return { accounts: [], meta: {} };
24
24
 
25
- const connection = rows(await this.db.execute(sql`
26
- SELECT
25
+ const connections = rows(await this.db.execute(sql`
26
+ SELECT
27
27
  connection_id,
28
28
  role_arn,
29
29
  external_id,
30
30
  management_account_id
31
31
  FROM ${sql.raw(this.connectionsTable)}
32
32
  WHERE tenant_id::text = ${tenant}
33
- AND status = 'CONNECTED'
34
- AND plugins ? 'Cost'
35
- AND COALESCE(verification_checks->>'costAccess', 'false') = 'true'
36
- ORDER BY connected_at DESC NULLS LAST, created_at DESC
37
- LIMIT 1
38
- `))[0];
39
-
40
- if (!connection) return { accounts: [], meta: {} };
41
-
42
- const curConfig = relations.cur_config_table ? rows(await this.db.execute(sql`
43
- SELECT export_arn, bucket, prefix, region, tenant_partition, status, last_data_at
44
- FROM ${sql.raw(this.curConfigTable)}
45
- WHERE tenant_id::text = ${tenant}
46
- AND connection_id = ${connection.connection_id}
47
- LIMIT 1
48
- `))[0] || {} : {};
49
-
50
- const curDiscovery = relations.cur_discovery_table ? rows(await this.db.execute(sql`
51
- SELECT id, status, attempt_count, glue_database, glue_table, table_location,
52
- cur_s3_uri, last_data_at, last_error, last_started_at, last_finished_at, next_run_at
53
- FROM ${sql.raw(this.curDiscoveryTable)}
54
- WHERE tenant_id = ${tenant}
55
- AND connection_id = ${connection.connection_id}
56
- LIMIT 1
57
- `))[0] || {} : {};
58
-
59
- const accounts = relations.accounts_table ? rows(await this.db.execute(sql`
60
- SELECT account_id, account_name, status
61
- FROM ${sql.raw(this.accountsTable)}
62
- WHERE tenant_id::text = ${tenant}
63
- AND connection_id = ${connection.connection_id}
64
- AND LOWER(status) = 'active'
65
- ORDER BY account_name
66
- `)) : [];
67
-
68
- return {
69
- accounts: accounts.map((item) => ({
70
- id: item.account_id,
71
- name: item.account_name || item.account_id,
72
- region: "global",
73
- status: item.status,
74
- })),
75
- meta: {
76
- payerRoleArn: connection.role_arn,
33
+ AND status = 'CONNECTED'
34
+ AND plugins ? 'Cost'
35
+ AND COALESCE(verification_checks->>'costAccess', 'false') = 'true'
36
+ AND (${managementAccountId || null}::text IS NULL OR management_account_id = ${managementAccountId || null})
37
+ ORDER BY connected_at DESC NULLS LAST, created_at DESC
38
+ `));
39
+
40
+ if (!connections.length) return { accounts: [], meta: {}, sources: [] };
41
+
42
+ const connectionIds = connections.map((connection) => connection.connection_id);
43
+ const curConfigs = relations.cur_config_table ? rows(await this.db.execute(sql`
44
+ SELECT export_arn, bucket, prefix, region, tenant_partition, status, last_data_at
45
+ , connection_id
46
+ FROM ${sql.raw(this.curConfigTable)}
47
+ WHERE tenant_id::text = ${tenant} AND connection_id = ANY(${connectionIds})
48
+ `)) : [];
49
+
50
+ const discoveries = relations.cur_discovery_table ? rows(await this.db.execute(sql`
51
+ SELECT id, status, attempt_count, glue_database, glue_table, table_location,
52
+ cur_s3_uri, last_data_at, last_error, last_started_at, last_finished_at, next_run_at, connection_id
53
+ FROM ${sql.raw(this.curDiscoveryTable)}
54
+ WHERE tenant_id = ${tenant} AND connection_id = ANY(${connectionIds})
55
+ `)) : [];
56
+
57
+ const allAccounts = relations.accounts_table ? rows(await this.db.execute(sql`
58
+ SELECT connection_id, account_id, account_name, status
59
+ FROM ${sql.raw(this.accountsTable)}
60
+ WHERE tenant_id::text = ${tenant}
61
+ AND connection_id = ANY(${connectionIds})
62
+ AND LOWER(status) = 'active'
63
+ ORDER BY account_name
64
+ `)) : [];
65
+
66
+ const configByConnection = new Map(curConfigs.map((item) => [item.connection_id, item]));
67
+ const discoveryByConnection = new Map(discoveries.map((item) => [item.connection_id, item]));
68
+ const sources = connections.map((connection) => {
69
+ const curConfig = configByConnection.get(connection.connection_id) || {};
70
+ const curDiscovery = discoveryByConnection.get(connection.connection_id) || {};
71
+ const accounts = allAccounts.filter((account) => account.connection_id === connection.connection_id).map((item) => ({
72
+ id: item.account_id, name: item.account_name || item.account_id, region: "global", status: item.status,
73
+ }));
74
+ return {
77
75
  managementAccountId: connection.management_account_id,
78
- externalId: connection.external_id,
79
- connectionId: connection.connection_id,
80
- curExportArn: curConfig.export_arn,
81
- curSourceBucket: curConfig.bucket,
82
- curSourcePrefix: curConfig.prefix,
83
- curSourceRegion: curConfig.region,
84
- curTenantPartition: curConfig.tenant_partition || tenant,
85
- curIngestionMode: "central",
86
- curStatus: curConfig.status,
87
- curLastDataAt: curConfig.last_data_at,
88
- curDiscoveredTable: curDiscovery.status === "READY" ? curDiscovery.glue_table : undefined,
89
- curDiscovery: curDiscovery.id ? {
90
- jobId: String(curDiscovery.id),
91
- status: curDiscovery.status,
92
- attemptCount: Number(curDiscovery.attempt_count || 0),
93
- database: curDiscovery.glue_database,
94
- table: curDiscovery.glue_table,
95
- tableLocation: curDiscovery.table_location,
96
- curS3Uri: curDiscovery.cur_s3_uri,
97
- lastDataAt: curDiscovery.last_data_at,
98
- lastError: curDiscovery.last_error,
99
- lastStartedAt: curDiscovery.last_started_at,
100
- lastFinishedAt: curDiscovery.last_finished_at,
101
- nextRunAt: curDiscovery.next_run_at,
102
- } : null,
103
- },
104
- };
105
- }
76
+ connectionId: connection.connection_id,
77
+ accounts,
78
+ meta: {
79
+ payerRoleArn: connection.role_arn, managementAccountId: connection.management_account_id,
80
+ externalId: connection.external_id, connectionId: connection.connection_id,
81
+ curExportArn: curConfig.export_arn, curSourceBucket: curConfig.bucket, curSourcePrefix: curConfig.prefix,
82
+ curSourceRegion: curConfig.region, curTenantPartition: curConfig.tenant_partition || tenant,
83
+ curIngestionMode: "central", curStatus: curConfig.status, curLastDataAt: curConfig.last_data_at,
84
+ curDiscoveredTable: curDiscovery.status === "READY" ? curDiscovery.glue_table : undefined,
85
+ curDiscovery: curDiscovery.id ? {
86
+ jobId: String(curDiscovery.id), status: curDiscovery.status, attemptCount: Number(curDiscovery.attempt_count || 0),
87
+ database: curDiscovery.glue_database, table: curDiscovery.glue_table, tableLocation: curDiscovery.table_location,
88
+ curS3Uri: curDiscovery.cur_s3_uri, lastDataAt: curDiscovery.last_data_at, lastError: curDiscovery.last_error,
89
+ lastStartedAt: curDiscovery.last_started_at, lastFinishedAt: curDiscovery.last_finished_at, nextRunAt: curDiscovery.next_run_at,
90
+ } : null,
91
+ },
92
+ };
93
+ });
94
+
95
+ return {
96
+ accounts: sources.flatMap((source) => source.accounts),
97
+ meta: sources[0].meta,
98
+ sources,
99
+ };
100
+ }
106
101
 
107
- async updateCurStatus(tenant, status) {
102
+ async updateCurStatus(tenant, status) {
108
103
  const relation = rows(await this.db.execute(sql`
109
104
  SELECT to_regclass(${`${this.schema}.cost_cur_config`}) AS cur_config_table
110
105
  `))[0];
111
106
  if (!relation?.cur_config_table) return;
112
- const curStatus = status.ready
113
- ? "READY"
114
- : status.state === "pending_data"
115
- ? "WAITING_FOR_DATA"
116
- : status.state === "unavailable"
117
- ? "FAILED"
118
- : "PROVISIONING";
119
- await this.db.execute(sql`
120
- UPDATE ${sql.raw(this.curConfigTable)}
121
- SET status = ${curStatus},
122
- last_data_at = ${status.lastDataAt || null},
123
- verified_at = now(),
124
- updated_at = now()
125
- WHERE connection_id = (
126
- SELECT connection_id
127
- FROM ${sql.raw(this.connectionsTable)}
128
- WHERE tenant_id::text = ${tenant}
129
- AND status = 'CONNECTED'
130
- AND plugins ? 'Cost'
131
- ORDER BY connected_at DESC NULLS LAST, created_at DESC
132
- LIMIT 1
133
- )
134
- `);
135
- }
136
- }
107
+ const sources = status.sources?.length ? status.sources : [];
108
+ for (const source of sources) {
109
+ if (!source.connectionId) continue;
110
+ const curStatus = source.ready
111
+ ? "READY"
112
+ : source.state === "pending_data"
113
+ ? "WAITING_FOR_DATA"
114
+ : source.state === "unavailable"
115
+ ? "FAILED"
116
+ : "PROVISIONING";
117
+ await this.db.execute(sql`
118
+ UPDATE ${sql.raw(this.curConfigTable)}
119
+ SET status = ${curStatus}, last_data_at = ${source.lastDataAt || null}, verified_at = now(), updated_at = now()
120
+ WHERE tenant_id::text = ${tenant} AND connection_id = ${source.connectionId}
121
+ `);
122
+ }
123
+ }
124
+ }
@@ -1,20 +1,32 @@
1
- import { CurDataStatus } from "../models/cur-data-status.model.js";
2
- import { getCurDataStatus } from "./cur.service.js";
3
-
1
+ import { CurDataStatus } from "../models/cur-data-status.model.js";
2
+ import { getCurDataStatus } from "./cur.service.js";
3
+
4
4
  export class CurProviderService {
5
- constructor({ athenaContextService, logger = console, env = process.env } = {}) {
6
- this.athenaContextService = athenaContextService;
7
- this.logger = logger;
8
- this.statusCache = new Map();
9
- this.statusCacheMs = Math.max(Number(env.COST_CUR_STATUS_CACHE_MS || 300000), 0);
10
- }
11
-
5
+ constructor({ athenaContextService, logger = console, env = process.env } = {}) {
6
+ this.athenaContextService = athenaContextService;
7
+ this.logger = logger;
8
+ this.statusCache = new Map();
9
+ this.statusCacheMs = Math.max(Number(env.COST_CUR_STATUS_CACHE_MS || 300000), 0);
10
+ }
11
+
12
+ sourceContexts(customerContext) {
13
+ const sources = customerContext.sources?.length ? customerContext.sources : [{
14
+ managementAccountId: customerContext.meta?.managementAccountId || "",
15
+ connectionId: customerContext.meta?.connectionId || "",
16
+ accounts: customerContext.accounts,
17
+ meta: customerContext.meta,
18
+ }];
19
+ return sources.map((source) => ({
20
+ source,
21
+ context: this.athenaContextService.resolve({ tenant: customerContext.tenant, accounts: source.accounts, meta: source.meta }),
22
+ }));
23
+ }
24
+
12
25
  async run(customerContext, action) {
13
- const context = this.athenaContextService.resolve(customerContext);
14
26
  const readiness = await this.status(customerContext);
15
27
  if (!readiness.ready) throw this.notReadyError(readiness);
16
28
  try {
17
- return await action(context.client, context.config);
29
+ return await Promise.all(this.sourceContexts(customerContext).map(({ source, context }) => action(context.client, context.config, source)));
18
30
  } catch (error) {
19
31
  this.logger.error?.("[Cost] CUR/Athena query failed", error);
20
32
  const unavailable = new Error("CUR is configured, but its Athena data could not be queried. Review the CUR discovery status, Glue table, Athena output location, and IAM permissions.");
@@ -36,60 +48,66 @@ export class CurProviderService {
36
48
  error.dataStatus = readiness;
37
49
  return error;
38
50
  }
39
-
40
- async status(customerContext) {
41
- const context = this.athenaContextService.resolve(customerContext);
51
+
52
+ async statusForSource(tenant, source, context) {
53
+ const base = { managementAccountId: source.managementAccountId, connectionId: source.connectionId };
42
54
  if (!context.config.enabled) {
43
- const discovery = customerContext.meta?.curDiscovery || null;
55
+ const discovery = source.meta?.curDiscovery || null;
44
56
  const sourceConfigured = context.ingestion.sourceConfigured;
45
57
  return new CurDataStatus({
46
- configured: false,
47
- required: true,
48
- credentialMode: context.credentialMode,
49
- ingestionMode: context.ingestion.mode,
50
- sourceConfigured,
58
+ ...base, configured: false, required: true, credentialMode: context.credentialMode,
59
+ ingestionMode: context.ingestion.mode, sourceConfigured,
51
60
  state: discovery?.status ? String(discovery.status).toLowerCase() : "not_configured",
52
- action: discovery || sourceConfigured ? "review_cur_discovery" : "connect_source",
53
- discovery,
61
+ action: discovery || sourceConfigured ? "review_cur_discovery" : "connect_source", discovery,
54
62
  message: context.config.disabledReason || (discovery || sourceConfigured
55
63
  ? "CUR discovery has not produced a queryable Glue table yet. Review its status and resolve any reported error."
56
64
  : "CUR is not configured for this tenant. Connect an AWS cost source and deploy the CUR setup."),
57
- });
58
- }
59
- const cacheKey = `${customerContext.tenant}:${context.config.tenantPartition}`;
60
- const cached = this.statusCache.get(cacheKey);
61
- if (cached && Date.now() - cached.createdAt < this.statusCacheMs) return cached.status;
62
- try {
63
- const result = await getCurDataStatus({ client: context.client, config: context.config, tenant: customerContext.tenant });
64
- const status = new CurDataStatus({
65
- configured: true,
66
- required: true,
67
- ready: result.recordCount > 0,
68
- state: result.recordCount > 0 ? "ready" : "pending_data",
69
- action: result.recordCount > 0 ? null : "review_cur_discovery",
70
- credentialMode: context.credentialMode,
71
- ingestionMode: context.ingestion.mode,
72
- sourceConfigured: context.ingestion.sourceConfigured,
73
- lastDataAt: result.lastDataAt,
74
- recordCount: result.recordCount,
75
- message: result.recordCount > 0 ? null : "CUR is configured, but no report rows are available for this tenant yet. Verify that AWS delivered report files and that discovery completed for the tenant partition.",
76
- discovery: customerContext.meta?.curDiscovery || null,
77
- });
78
- this.statusCache.set(cacheKey, { createdAt: Date.now(), status });
79
- return status;
80
- } catch (error) {
65
+ });
66
+ }
67
+ try {
68
+ const result = await getCurDataStatus({ client: context.client, config: context.config, tenant });
69
+ return new CurDataStatus({
70
+ ...base, configured: true, required: true, ready: result.recordCount > 0,
71
+ state: result.recordCount > 0 ? "ready" : "pending_data", action: result.recordCount > 0 ? null : "review_cur_discovery",
72
+ credentialMode: context.credentialMode, ingestionMode: context.ingestion.mode,
73
+ sourceConfigured: context.ingestion.sourceConfigured, lastDataAt: result.lastDataAt, recordCount: result.recordCount,
74
+ message: result.recordCount > 0 ? null : "CUR is configured, but no report rows are available for this management account yet.",
75
+ discovery: source.meta?.curDiscovery || null,
76
+ });
77
+ } catch (error) {
81
78
  this.logger.warn?.("[Cost] CUR readiness check failed", error.message);
82
79
  return new CurDataStatus({
83
- configured: true,
84
- required: true,
85
- state: "unavailable",
86
- action: "review_cur_discovery",
87
- credentialMode: context.credentialMode,
88
- ingestionMode: context.ingestion.mode,
89
- sourceConfigured: context.ingestion.sourceConfigured,
80
+ ...base, configured: true, required: true, state: "unavailable", action: "review_cur_discovery",
81
+ credentialMode: context.credentialMode, ingestionMode: context.ingestion.mode,
82
+ sourceConfigured: context.ingestion.sourceConfigured,
90
83
  message: "CUR is configured, but its Athena data is unavailable. Review the discovery error, Glue table, Athena output location, and IAM permissions.",
91
- discovery: customerContext.meta?.curDiscovery || null,
92
- });
93
- }
94
- }
95
- }
84
+ discovery: source.meta?.curDiscovery || null,
85
+ });
86
+ }
87
+ }
88
+
89
+ async status(customerContext) {
90
+ const contexts = this.sourceContexts(customerContext);
91
+ if (!contexts.length) return new CurDataStatus({ configured: false, required: true, state: "not_configured", action: "connect_source", message: "CUR is not configured for this tenant. Connect an AWS cost source and deploy the CUR setup." });
92
+ const cacheKey = `${customerContext.tenant}:${contexts.map(({ source }) => source.connectionId).sort().join(",")}`;
93
+ const cached = this.statusCache.get(cacheKey);
94
+ if (cached && Date.now() - cached.createdAt < this.statusCacheMs) return cached.status;
95
+ const statuses = await Promise.all(contexts.map(({ source, context }) => this.statusForSource(customerContext.tenant, source, context)));
96
+ const ready = statuses.every((status) => status.ready);
97
+ const status = new CurDataStatus({
98
+ configured: statuses.some((item) => item.configured), required: true, ready,
99
+ state: ready ? "ready" : statuses.some((item) => item.state === "unavailable") ? "unavailable" : statuses.some((item) => item.state === "not_configured") ? "not_configured" : "pending_data",
100
+ action: ready ? null : statuses.some((item) => item.action === "connect_source") ? "connect_source" : "review_cur_discovery",
101
+ credentialMode: statuses[0]?.credentialMode || "saas-runtime-identity", ingestionMode: "central",
102
+ sourceConfigured: statuses.some((item) => item.sourceConfigured),
103
+ lastDataAt: statuses.map((item) => item.lastDataAt).filter(Boolean).sort().at(-1) || null,
104
+ recordCount: statuses.reduce((total, item) => total + item.recordCount, 0),
105
+ message: ready ? null : statuses.length === 1
106
+ ? statuses[0].message
107
+ : "One or more selected management-account CUR sources are not ready. Review Cost Sources and CUR Discovery before viewing aggregated costs.",
108
+ sources: statuses.map((item) => ({ managementAccountId: item.managementAccountId, connectionId: item.connectionId, state: item.state, ready: item.ready, lastDataAt: item.lastDataAt, recordCount: item.recordCount, message: item.message })),
109
+ });
110
+ this.statusCache.set(cacheKey, { createdAt: Date.now(), status });
111
+ return status;
112
+ }
113
+ }
@@ -312,7 +312,7 @@ export async function getCurTags({ client, config, tenant }) {
312
312
  return rows.map((row) => String(row.tag_key || "").replace(/^user:/, "")).filter(Boolean);
313
313
  }
314
314
 
315
- export async function getCurFilterOptions({ client, config, tenant, range, tagKey, accountNames = new Map() }) {
315
+ export async function getCurFilterOptions({ client, config, tenant, range, tagKey, accountNames = new Map() }) {
316
316
  const distinct = async (column) => execute(client, config, `
317
317
  SELECT DISTINCT CAST("${column}" AS VARCHAR) AS value
318
318
  FROM ${table(config)}
@@ -342,7 +342,7 @@ export async function getCurFilterOptions({ client, config, tenant, range, tagKe
342
342
  `)).map((row) => row.value).filter(Boolean);
343
343
  }
344
344
  const options = (items, type) => items.map((item) => ({ value: item.value, label: type === "account" ? accountNames.get(item.value) || item.value : item.value }));
345
- return {
345
+ return {
346
346
  services: options(services),
347
347
  regions: options(regions),
348
348
  accounts: options(accounts, "account"),
@@ -350,5 +350,91 @@ export async function getCurFilterOptions({ client, config, tenant, range, tagKe
350
350
  tagValues,
351
351
  dataSource: "aws-cur-athena",
352
352
  accessMode: "cur-readonly",
353
- };
354
- }
353
+ };
354
+ }
355
+
356
+ function mergeRelations(items = []) {
357
+ const totals = new Map();
358
+ for (const item of items) totals.set(item.key, { ...item, amount: (totals.get(item.key)?.amount || 0) + amount(item.amount) });
359
+ return [...totals.values()].map((item) => ({ ...item, amount: round(item.amount) })).sort((a, b) => b.amount - a.amount);
360
+ }
361
+
362
+ function mergeSummary(items = []) {
363
+ const totals = new Map();
364
+ for (const item of items) {
365
+ const key = item.key || item.accountId || item.label;
366
+ const current = totals.get(key) || { ...item, key, amount: 0, accounts: [], regions: [] };
367
+ current.amount += amount(item.amount);
368
+ current.accounts = mergeRelations([...current.accounts, ...(item.accounts || [])]);
369
+ current.regions = mergeRelations([...current.regions, ...(item.regions || [])]);
370
+ totals.set(key, current);
371
+ }
372
+ const total = [...totals.values()].reduce((sum, item) => sum + item.amount, 0);
373
+ return [...totals.values()].map((item) => ({ ...item, amount: round(item.amount), percentage: total ? round(item.amount / total * 100) : 0 }))
374
+ .sort((a, b) => b.amount - a.amount);
375
+ }
376
+
377
+ export function mergeCurReports(reports = []) {
378
+ const first = reports[0];
379
+ if (!first) return null;
380
+ const timeline = new Map();
381
+ for (const report of reports) for (const period of report.timeline || []) {
382
+ const current = timeline.get(period.start) || { start: period.start, end: period.end, groups: [] };
383
+ current.groups.push(...(period.groups || []));
384
+ timeline.set(period.start, current);
385
+ }
386
+ const mergedTimeline = [...timeline.values()].map((period) => {
387
+ const groups = mergeSummary(period.groups);
388
+ return { ...period, groups, total: round(groups.reduce((sum, item) => sum + item.amount, 0)) };
389
+ }).sort((a, b) => a.start.localeCompare(b.start));
390
+ const summary = mergeSummary(reports.flatMap((report) => report.summary || []));
391
+ return {
392
+ ...first,
393
+ totalCost: round(reports.reduce((sum, report) => sum + amount(report.totalCost), 0)),
394
+ count: summary.length,
395
+ summary,
396
+ timeline: mergedTimeline,
397
+ message: reports.map((report) => report.message).filter(Boolean).join(" ") || undefined,
398
+ };
399
+ }
400
+
401
+ export function mergeCurOverview(overviews = []) {
402
+ const first = overviews[0];
403
+ if (!first) return null;
404
+ const accounts = new Map();
405
+ for (const overview of overviews) for (const account of overview.accounts || []) accounts.set(account.id, account);
406
+ const trends = new Map();
407
+ for (const overview of overviews) for (const trend of overview.trends || []) {
408
+ trends.set(trend.month, { ...trend, amount: (trends.get(trend.month)?.amount || 0) + amount(trend.amount) });
409
+ }
410
+ const grossCost = round(overviews.reduce((sum, overview) => sum + amount(overview.grossCost), 0));
411
+ const netCost = round(overviews.reduce((sum, overview) => sum + amount(overview.netCost), 0));
412
+ return {
413
+ ...first,
414
+ accounts: [...accounts.values()],
415
+ totalCost: grossCost,
416
+ grossCost,
417
+ netCost,
418
+ creditsAndAdjustments: round(netCost - grossCost),
419
+ activeAccounts: accounts.size,
420
+ activeResources: overviews.reduce((sum, overview) => sum + Number(overview.activeResources || 0), 0),
421
+ trends: [...trends.values()].map((trend) => ({ ...trend, amount: round(trend.amount) })).sort((a, b) => a.month.localeCompare(b.month)),
422
+ topServices: mergeSummary(overviews.flatMap((overview) => overview.topServices || [])).slice(0, 10),
423
+ topAccounts: mergeSummary(overviews.flatMap((overview) => overview.topAccounts || [])).slice(0, 10),
424
+ topRegions: mergeSummary(overviews.flatMap((overview) => overview.topRegions || [])).slice(0, 10),
425
+ };
426
+ }
427
+
428
+ export function mergeCurFilterOptions(options = []) {
429
+ const mergeOptions = (key) => {
430
+ const merged = new Map();
431
+ for (const option of options) for (const item of option[key] || []) merged.set(item.value, item);
432
+ return [...merged.values()].sort((a, b) => a.label.localeCompare(b.label));
433
+ };
434
+ return {
435
+ services: mergeOptions("services"), regions: mergeOptions("regions"), accounts: mergeOptions("accounts"),
436
+ tagKeys: [...new Set(options.flatMap((option) => option.tagKeys || []))].sort(),
437
+ tagValues: [...new Set(options.flatMap((option) => option.tagValues || []))].sort(),
438
+ dataSource: "aws-cur-athena", accessMode: "cur-readonly",
439
+ };
440
+ }
@@ -10,13 +10,19 @@ export class CustomerAwsContextService {
10
10
  return String(req.user?.tenant_id || req.user?.tenantId || req.headers["x-tenant-id"] || this.defaultTenant).trim() || this.defaultTenant;
11
11
  }
12
12
 
13
- async resolve(req) {
14
- const tenant = this.tenantId(req);
15
- const stored = await this.repository.findCostAccess(tenant);
13
+ async resolve(req) {
14
+ const tenant = this.tenantId(req);
15
+ const managementAccountId = String(req.query?.managementAccountId || "").trim();
16
+ if (managementAccountId && !/^\d{12}$/.test(managementAccountId)) {
17
+ const error = new Error("managementAccountId must contain exactly 12 digits");
18
+ error.statusCode = 400;
19
+ throw error;
20
+ }
21
+ const stored = await this.repository.findCostAccess(tenant, managementAccountId || undefined);
16
22
  const accounts = stored.accounts;
17
23
  const meta = stored.meta;
18
- return new CustomerAwsContext({ tenant, accounts, meta });
19
- }
24
+ return new CustomerAwsContext({ tenant, accounts, meta, sources: stored.sources || [] });
25
+ }
20
26
 
21
27
  async recordCurStatus(tenant, status) {
22
28
  if (typeof this.repository.updateCurStatus === "function") {