@meyicloud/meyi-cost-server 1.8.2 → 1.8.4
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 +1 -1
- package/src/controllers/cost.controller.js +13 -5
- package/src/models/cur-data-status.model.js +6 -3
- package/src/models/customer-aws-context.model.js +12 -6
- package/src/repositories/aws-onboarding.repository.js +92 -101
- package/src/services/cur-provider.service.js +77 -59
- package/src/services/cur.service.js +90 -4
- package/src/services/customer-aws-context.service.js +11 -5
package/package.json
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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(
|
|
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,106 @@ export class AwsOnboardingRepository {
|
|
|
22
22
|
|
|
23
23
|
if (!relations.connections_table) return { accounts: [], meta: {} };
|
|
24
24
|
|
|
25
|
-
const
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
`))
|
|
39
|
-
|
|
40
|
-
if (!
|
|
41
|
-
|
|
42
|
-
const
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
AND
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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
|
+
// Bind every connection ID independently. Passing an untyped JavaScript array
|
|
44
|
+
// to PostgreSQL ANY() fails for the varchar connection_id column.
|
|
45
|
+
const connectionIdList = sql.join(connectionIds.map((connectionId) => sql`${connectionId}`), sql`, `);
|
|
46
|
+
const curConfigs = relations.cur_config_table ? rows(await this.db.execute(sql`
|
|
47
|
+
SELECT export_arn, bucket, prefix, region, tenant_partition, status, last_data_at
|
|
48
|
+
, connection_id
|
|
49
|
+
FROM ${sql.raw(this.curConfigTable)}
|
|
50
|
+
WHERE tenant_id::text = ${tenant} AND connection_id IN (${connectionIdList})
|
|
51
|
+
`)) : [];
|
|
52
|
+
|
|
53
|
+
const discoveries = relations.cur_discovery_table ? rows(await this.db.execute(sql`
|
|
54
|
+
SELECT id, status, attempt_count, glue_database, glue_table, table_location,
|
|
55
|
+
cur_s3_uri, last_data_at, last_error, last_started_at, last_finished_at, next_run_at, connection_id
|
|
56
|
+
FROM ${sql.raw(this.curDiscoveryTable)}
|
|
57
|
+
WHERE tenant_id = ${tenant} AND connection_id IN (${connectionIdList})
|
|
58
|
+
`)) : [];
|
|
59
|
+
|
|
60
|
+
const allAccounts = relations.accounts_table ? rows(await this.db.execute(sql`
|
|
61
|
+
SELECT connection_id, account_id, account_name, status
|
|
62
|
+
FROM ${sql.raw(this.accountsTable)}
|
|
63
|
+
WHERE tenant_id::text = ${tenant}
|
|
64
|
+
AND connection_id IN (${connectionIdList})
|
|
65
|
+
AND LOWER(status) = 'active'
|
|
66
|
+
ORDER BY account_name
|
|
67
|
+
`)) : [];
|
|
68
|
+
|
|
69
|
+
const configByConnection = new Map(curConfigs.map((item) => [item.connection_id, item]));
|
|
70
|
+
const discoveryByConnection = new Map(discoveries.map((item) => [item.connection_id, item]));
|
|
71
|
+
const sources = connections.map((connection) => {
|
|
72
|
+
const curConfig = configByConnection.get(connection.connection_id) || {};
|
|
73
|
+
const curDiscovery = discoveryByConnection.get(connection.connection_id) || {};
|
|
74
|
+
const accounts = allAccounts.filter((account) => account.connection_id === connection.connection_id).map((item) => ({
|
|
75
|
+
id: item.account_id, name: item.account_name || item.account_id, region: "global", status: item.status,
|
|
76
|
+
}));
|
|
77
|
+
return {
|
|
77
78
|
managementAccountId: connection.management_account_id,
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
},
|
|
104
|
-
};
|
|
105
|
-
}
|
|
79
|
+
connectionId: connection.connection_id,
|
|
80
|
+
accounts,
|
|
81
|
+
meta: {
|
|
82
|
+
payerRoleArn: connection.role_arn, managementAccountId: connection.management_account_id,
|
|
83
|
+
externalId: connection.external_id, connectionId: connection.connection_id,
|
|
84
|
+
curExportArn: curConfig.export_arn, curSourceBucket: curConfig.bucket, curSourcePrefix: curConfig.prefix,
|
|
85
|
+
curSourceRegion: curConfig.region, curTenantPartition: curConfig.tenant_partition || tenant,
|
|
86
|
+
curIngestionMode: "central", curStatus: curConfig.status, curLastDataAt: curConfig.last_data_at,
|
|
87
|
+
curDiscoveredTable: curDiscovery.status === "READY" ? curDiscovery.glue_table : undefined,
|
|
88
|
+
curDiscovery: curDiscovery.id ? {
|
|
89
|
+
jobId: String(curDiscovery.id), status: curDiscovery.status, attemptCount: Number(curDiscovery.attempt_count || 0),
|
|
90
|
+
database: curDiscovery.glue_database, table: curDiscovery.glue_table, tableLocation: curDiscovery.table_location,
|
|
91
|
+
curS3Uri: curDiscovery.cur_s3_uri, lastDataAt: curDiscovery.last_data_at, lastError: curDiscovery.last_error,
|
|
92
|
+
lastStartedAt: curDiscovery.last_started_at, lastFinishedAt: curDiscovery.last_finished_at, nextRunAt: curDiscovery.next_run_at,
|
|
93
|
+
} : null,
|
|
94
|
+
},
|
|
95
|
+
};
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
return {
|
|
99
|
+
accounts: sources.flatMap((source) => source.accounts),
|
|
100
|
+
meta: sources[0].meta,
|
|
101
|
+
sources,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
106
104
|
|
|
107
|
-
async updateCurStatus(tenant, status) {
|
|
105
|
+
async updateCurStatus(tenant, status) {
|
|
108
106
|
const relation = rows(await this.db.execute(sql`
|
|
109
107
|
SELECT to_regclass(${`${this.schema}.cost_cur_config`}) AS cur_config_table
|
|
110
108
|
`))[0];
|
|
111
109
|
if (!relation?.cur_config_table) return;
|
|
112
|
-
const
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
AND plugins ? 'Cost'
|
|
131
|
-
ORDER BY connected_at DESC NULLS LAST, created_at DESC
|
|
132
|
-
LIMIT 1
|
|
133
|
-
)
|
|
134
|
-
`);
|
|
135
|
-
}
|
|
136
|
-
}
|
|
110
|
+
const sources = status.sources?.length ? status.sources : [];
|
|
111
|
+
for (const source of sources) {
|
|
112
|
+
if (!source.connectionId) continue;
|
|
113
|
+
const curStatus = source.ready
|
|
114
|
+
? "READY"
|
|
115
|
+
: source.state === "pending_data"
|
|
116
|
+
? "WAITING_FOR_DATA"
|
|
117
|
+
: source.state === "unavailable"
|
|
118
|
+
? "FAILED"
|
|
119
|
+
: "PROVISIONING";
|
|
120
|
+
await this.db.execute(sql`
|
|
121
|
+
UPDATE ${sql.raw(this.curConfigTable)}
|
|
122
|
+
SET status = ${curStatus}, last_data_at = ${source.lastDataAt || null}, verified_at = now(), updated_at = now()
|
|
123
|
+
WHERE tenant_id::text = ${tenant} AND connection_id = ${source.connectionId}
|
|
124
|
+
`);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
@@ -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
|
|
41
|
-
const
|
|
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 =
|
|
55
|
+
const discovery = source.meta?.curDiscovery || null;
|
|
44
56
|
const sourceConfigured = context.ingestion.sourceConfigured;
|
|
45
57
|
return new CurDataStatus({
|
|
46
|
-
configured: false,
|
|
47
|
-
|
|
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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
|
|
85
|
-
|
|
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:
|
|
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
|
|
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") {
|