@meyicloud/meyi-cost-server 1.4.1 → 1.5.0
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/AGENTS.md +1 -0
- package/README.md +29 -5
- package/package.json +4 -2
- package/src/controllers/cost-analysis.controller.js +28 -8
- package/src/lib/cost-analysis-schedule.js +45 -0
- package/src/plugin.js +11 -1
- package/src/repositories/cost-analysis-report.repository.js +109 -0
- package/src/routes/index.js +5 -1
- package/src/schema/cost-analysis-report.schema.js +43 -0
- package/src/services/cost-analysis-pdf.service.js +42 -0
- package/src/services/cost-analysis-report.service.js +52 -0
- package/src/workers/cost-analysis-report.worker.js +66 -0
package/AGENTS.md
CHANGED
|
@@ -76,6 +76,7 @@ flowchart LR
|
|
|
76
76
|
The plugin currently owns these route groups under `${apiBaseUri}/cost`:
|
|
77
77
|
|
|
78
78
|
- `GET /accounts`, `/data-status`, `/overview`, `/filter-options`, `/reports`, `/tags`
|
|
79
|
+
- `GET`/`PUT /analysis/schedule`, `GET /analysis/reports`, and tenant-scoped PDF downloads
|
|
79
80
|
- `GET /budgets` and `POST /budgets`
|
|
80
81
|
- `DELETE /budgets/:id`
|
|
81
82
|
- `GET /budget-alert-dismissals` and `POST /budget-alert-dismissals`
|
package/README.md
CHANGED
|
@@ -30,6 +30,9 @@ and AWS onboarding. The plugin owns cost routes and its budget-related tables.
|
|
|
30
30
|
- Explicit CUR setup, pending-data, and unavailable states
|
|
31
31
|
- Tenant-scoped budget rules
|
|
32
32
|
- User-, tenant-, month-, and status-scoped budget-alert dismissals
|
|
33
|
+
- Tenant-configurable daily, weekly, and monthly AI report schedules
|
|
34
|
+
- PostgreSQL-backed background report jobs with failure history
|
|
35
|
+
- Server-generated PDF reports available through tenant-scoped downloads
|
|
33
36
|
|
|
34
37
|
Budgets are application rules stored in PostgreSQL; they are not AWS Budgets
|
|
35
38
|
resources. The consuming application evaluates them against current cost and
|
|
@@ -94,7 +97,7 @@ For a local package installation test:
|
|
|
94
97
|
|
|
95
98
|
```bash
|
|
96
99
|
npm pack
|
|
97
|
-
npm install /path/to/meyicloud-meyi-cost-server-1.
|
|
100
|
+
npm install /path/to/meyicloud-meyi-cost-server-1.5.0.tgz
|
|
98
101
|
```
|
|
99
102
|
|
|
100
103
|
## Publish to npm
|
|
@@ -257,17 +260,37 @@ SaaS Athena resources.
|
|
|
257
260
|
|
|
258
261
|
AI analysis is optional and invokes Amazon Bedrock from the backend runtime.
|
|
259
262
|
Only aggregated cost facts are sent to the model; tenant IDs, AWS credentials,
|
|
260
|
-
resource IDs, and raw CUR rows are excluded.
|
|
263
|
+
resource IDs, and raw CUR rows are excluded. AI reports are generated by a
|
|
264
|
+
background worker, not by a browser request. Daily reports cover the previous
|
|
265
|
+
complete day, weekly reports cover the previous seven complete days, and
|
|
266
|
+
monthly reports cover the previous calendar month in the configured timezone.
|
|
267
|
+
|
|
268
|
+
The authenticated report API is:
|
|
269
|
+
|
|
270
|
+
| Method | Route | Purpose |
|
|
271
|
+
| --- | --- | --- |
|
|
272
|
+
| `GET` | `/analysis/schedule` | Read this tenant's schedule. |
|
|
273
|
+
| `PUT` | `/analysis/schedule` | Update the schedule; requires `req.user.role=admin`. |
|
|
274
|
+
| `GET` | `/analysis/reports` | List tenant report history without PDF bytes. |
|
|
275
|
+
| `GET` | `/analysis/reports/:id` | Read one tenant report. |
|
|
276
|
+
| `GET` | `/analysis/reports/:id/pdf` | Download a completed report PDF. |
|
|
277
|
+
|
|
278
|
+
Schedule and report rows live in `cost_ai_report_schedules` and
|
|
279
|
+
`cost_ai_reports`. The worker atomically claims due schedules with a lease, so
|
|
280
|
+
a restarted task can resume future work without relying on in-memory timers.
|
|
261
281
|
|
|
262
282
|
| Variable | Default | Required | Purpose |
|
|
263
283
|
| --- | --- | --- | --- |
|
|
264
284
|
| `COST_AI_ENABLED` | `false` | No | Enables the AI analysis endpoints and Bedrock client. |
|
|
265
285
|
| `COST_AI_REGION` | `AWS_REGION` or `us-east-1` | When enabled | Region used by the Bedrock Runtime client. |
|
|
266
286
|
| `COST_AI_MODEL_ID` | Global Claude Sonnet inference profile | When enabled | Bedrock model or inference-profile ID. |
|
|
267
|
-
| `COST_AI_MAX_TOKENS` | `
|
|
287
|
+
| `COST_AI_MAX_TOKENS` | `4000` | No | Maximum response tokens, clamped from 600 to 8000. |
|
|
268
288
|
| `COST_AI_CACHE_TTL_MS` | `21600000` | No | Tenant analysis cache duration; defaults to six hours. |
|
|
269
289
|
| `COST_AI_HOURLY_LIMIT` | `6` | No | Maximum non-cached generations per tenant per hour. |
|
|
270
290
|
| `COST_AI_TIMEOUT_MS` | `120000` | No | Per-request timeout for key-based providers; Bedrock uses the SDK default. |
|
|
291
|
+
| `COST_AI_REPORT_POLL_INTERVAL_MS` | `60000` | No | How often the background worker claims due schedules; minimum 10 seconds. |
|
|
292
|
+
| `COST_AI_REPORT_LEASE_MS` | `1800000` | No | Claim lease used to recover from an interrupted worker. |
|
|
293
|
+
| `COST_AI_REPORT_BATCH_SIZE` | `5` | No | Maximum schedules claimed by one worker tick; clamped from 1 to 25. |
|
|
271
294
|
|
|
272
295
|
### Choosing a provider
|
|
273
296
|
|
|
@@ -305,8 +328,9 @@ tenant, including a `source` of `tenant-configuration` or `environment`.
|
|
|
305
328
|
|
|
306
329
|
The runtime role needs `bedrock:InvokeModel` for the selected model or
|
|
307
330
|
inference profile. Analysis results are stored in the tenant-scoped
|
|
308
|
-
`cost_ai_analyses` table
|
|
309
|
-
|
|
331
|
+
`cost_ai_analyses` table and scheduled PDF jobs are stored in
|
|
332
|
+
`cost_ai_reports`. Model or permission failures mark only that scheduled report
|
|
333
|
+
as failed and do not change CUR, standard reports, onboarding, or budgets.
|
|
310
334
|
|
|
311
335
|
### CUR/Athena variables
|
|
312
336
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@meyicloud/meyi-cost-server",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.5.0",
|
|
4
4
|
"description": "Tenant-aware AWS CUR and Athena cost plugin server for MeyiConnect",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./index.js",
|
|
@@ -25,7 +25,9 @@
|
|
|
25
25
|
"@aws-sdk/client-sts": "^3.850.0",
|
|
26
26
|
"@aws-sdk/credential-providers": "^3.850.0",
|
|
27
27
|
"drizzle-orm": "^0.44.7",
|
|
28
|
-
"express": "^4.21.1"
|
|
28
|
+
"express": "^4.21.1",
|
|
29
|
+
"luxon": "^3.7.2",
|
|
30
|
+
"pdfkit": "^0.17.2"
|
|
29
31
|
},
|
|
30
32
|
"peerDependencies": {
|
|
31
33
|
"pg": ">=8"
|
|
@@ -1,8 +1,7 @@
|
|
|
1
|
-
import { dateRange } from "../lib/cost-utils.js";
|
|
2
|
-
|
|
3
1
|
export class CostAnalysisController {
|
|
4
|
-
constructor({ service }) {
|
|
2
|
+
constructor({ service, reportService }) {
|
|
5
3
|
this.service = service;
|
|
4
|
+
this.reportService = reportService;
|
|
6
5
|
}
|
|
7
6
|
|
|
8
7
|
async status(req, res, next) {
|
|
@@ -17,12 +16,33 @@ export class CostAnalysisController {
|
|
|
17
16
|
} catch (error) { return next(error); }
|
|
18
17
|
}
|
|
19
18
|
|
|
20
|
-
async
|
|
19
|
+
async schedule(req, res, next) {
|
|
20
|
+
try { return res.json(await this.reportService.getSchedule(req)); }
|
|
21
|
+
catch (error) { return next(error); }
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async updateSchedule(req, res, next) {
|
|
25
|
+
try { return res.json(await this.reportService.saveSchedule(req, req.body || {})); }
|
|
26
|
+
catch (error) { return next(error); }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async reports(req, res, next) {
|
|
30
|
+
try { return res.json({ reports: await this.reportService.list(req, req.query.limit) }); }
|
|
31
|
+
catch (error) { return next(error); }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async report(req, res, next) {
|
|
35
|
+
try { return res.json(await this.reportService.get(req, req.params.id)); }
|
|
36
|
+
catch (error) { return next(error); }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async downloadPdf(req, res, next) {
|
|
21
40
|
try {
|
|
22
|
-
const
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
41
|
+
const report = await this.reportService.pdf(req, req.params.id);
|
|
42
|
+
res.setHeader("Content-Type", "application/pdf");
|
|
43
|
+
res.setHeader("Content-Disposition", `attachment; filename="${report.filename}"`);
|
|
44
|
+
res.setHeader("Content-Length", report.data.length);
|
|
45
|
+
return res.send(report.data);
|
|
26
46
|
} catch (error) { return next(error); }
|
|
27
47
|
}
|
|
28
48
|
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { DateTime, IANAZone } from "luxon";
|
|
2
|
+
|
|
3
|
+
const FREQUENCIES = new Set(["daily", "weekly", "monthly"]);
|
|
4
|
+
|
|
5
|
+
export function validateSchedule(input = {}) {
|
|
6
|
+
const frequency = String(input.frequency || "weekly").toLowerCase();
|
|
7
|
+
const timezone = String(input.timezone || "UTC").trim();
|
|
8
|
+
const time = String(input.time || "09:00").trim();
|
|
9
|
+
if (!FREQUENCIES.has(frequency)) throw Object.assign(new Error("Frequency must be daily, weekly, or monthly"), { statusCode: 400 });
|
|
10
|
+
if (!IANAZone.isValidZone(timezone)) throw Object.assign(new Error("A valid IANA timezone is required"), { statusCode: 400 });
|
|
11
|
+
if (!/^([01]\d|2[0-3]):[0-5]\d$/.test(time)) throw Object.assign(new Error("Time must use 24-hour HH:mm format"), { statusCode: 400 });
|
|
12
|
+
const dayOfWeek = frequency === "weekly" ? Number(input.dayOfWeek ?? 1) : null;
|
|
13
|
+
const dayOfMonth = frequency === "monthly" ? Number(input.dayOfMonth ?? 1) : null;
|
|
14
|
+
if (dayOfWeek !== null && (!Number.isInteger(dayOfWeek) || dayOfWeek < 0 || dayOfWeek > 6)) throw Object.assign(new Error("Weekly day must be between 0 and 6"), { statusCode: 400 });
|
|
15
|
+
if (dayOfMonth !== null && (!Number.isInteger(dayOfMonth) || dayOfMonth < 1 || dayOfMonth > 28)) throw Object.assign(new Error("Monthly day must be between 1 and 28"), { statusCode: 400 });
|
|
16
|
+
return { enabled: Boolean(input.enabled), frequency, timezone, time, dayOfWeek, dayOfMonth };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function atTime(date, time) {
|
|
20
|
+
const [hour, minute] = time.split(":").map(Number);
|
|
21
|
+
return date.set({ hour, minute, second: 0, millisecond: 0 });
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function nextScheduleRun(schedule, after = new Date()) {
|
|
25
|
+
const local = DateTime.fromJSDate(after, { zone: schedule.timezone });
|
|
26
|
+
let candidate = atTime(local.startOf("day"), schedule.time);
|
|
27
|
+
if (schedule.frequency === "daily") {
|
|
28
|
+
if (candidate <= local) candidate = candidate.plus({ days: 1 });
|
|
29
|
+
} else if (schedule.frequency === "weekly") {
|
|
30
|
+
const targetWeekday = schedule.dayOfWeek === 0 ? 7 : schedule.dayOfWeek;
|
|
31
|
+
candidate = atTime(local.startOf("week").plus({ days: targetWeekday - 1 }), schedule.time);
|
|
32
|
+
if (candidate <= local) candidate = candidate.plus({ weeks: 1 });
|
|
33
|
+
} else {
|
|
34
|
+
candidate = atTime(local.startOf("month").set({ day: schedule.dayOfMonth }), schedule.time);
|
|
35
|
+
if (candidate <= local) candidate = atTime(candidate.plus({ months: 1 }).startOf("month").set({ day: schedule.dayOfMonth }), schedule.time);
|
|
36
|
+
}
|
|
37
|
+
return candidate.toUTC().toJSDate();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function reportRange(schedule, scheduledFor) {
|
|
41
|
+
const scheduledDay = DateTime.fromJSDate(new Date(scheduledFor), { zone: schedule.timezone }).startOf("day");
|
|
42
|
+
const end = schedule.frequency === "monthly" ? scheduledDay.startOf("month") : scheduledDay;
|
|
43
|
+
const start = schedule.frequency === "monthly" ? end.minus({ months: 1 }) : end.minus({ days: schedule.frequency === "weekly" ? 7 : 1 });
|
|
44
|
+
return { Start: start.toISODate(), End: end.toISODate() };
|
|
45
|
+
}
|
package/src/plugin.js
CHANGED
|
@@ -12,9 +12,13 @@ import { CostAnalysisController } from "./controllers/cost-analysis.controller.j
|
|
|
12
12
|
import { createCostRouter } from "./routes/index.js";
|
|
13
13
|
import { installCostBudgetSchema } from "./schema/cost-budget.schema.js";
|
|
14
14
|
import { installCostAnalysisSchema } from "./schema/cost-analysis.schema.js";
|
|
15
|
+
import { installCostAnalysisReportSchema } from "./schema/cost-analysis-report.schema.js";
|
|
15
16
|
import { CostAnalysisRepository } from "./repositories/cost-analysis.repository.js";
|
|
17
|
+
import { CostAnalysisReportRepository } from "./repositories/cost-analysis-report.repository.js";
|
|
16
18
|
import { CostAnalysisDataService } from "./services/cost-analysis-data.service.js";
|
|
17
19
|
import { CostAnalysisService } from "./services/cost-analysis.service.js";
|
|
20
|
+
import { CostAnalysisReportService } from "./services/cost-analysis-report.service.js";
|
|
21
|
+
import { CostAnalysisReportWorker } from "./workers/cost-analysis-report.worker.js";
|
|
18
22
|
import { installCurDiscoverySchema } from "./cur-discovery/schema.js";
|
|
19
23
|
import { CurDiscoveryRepository } from "./cur-discovery/cur-discovery.repository.js";
|
|
20
24
|
import { CurDiscoveryAws } from "./cur-discovery/cur-discovery.aws.js";
|
|
@@ -32,6 +36,7 @@ export function createInsightCost({ app, db, apiBaseUri = "/api/v1", logger = co
|
|
|
32
36
|
const budgetRepository = new BudgetRepository({ db, qSchema });
|
|
33
37
|
const budgetAlertRepository = new BudgetAlertRepository({ db, qSchema });
|
|
34
38
|
const costAnalysisRepository = new CostAnalysisRepository({ db, qSchema });
|
|
39
|
+
const costAnalysisReportRepository = new CostAnalysisReportRepository({ db, qSchema });
|
|
35
40
|
const budgetService = new BudgetService({ repository: budgetRepository });
|
|
36
41
|
const budgetAlertService = new BudgetAlertService({ repository: budgetAlertRepository });
|
|
37
42
|
const athenaContextService = new SaasAthenaContextService();
|
|
@@ -53,7 +58,9 @@ export function createInsightCost({ app, db, apiBaseUri = "/api/v1", logger = co
|
|
|
53
58
|
// providerResolver lets the host supply per-tenant LLM credentials (the AI
|
|
54
59
|
// Providers screen). Null keeps the environment-only behaviour.
|
|
55
60
|
const costAnalysisService = new CostAnalysisService({ contextService, dataService: costAnalysisDataService, repository: costAnalysisRepository, logger, providerResolver });
|
|
56
|
-
const
|
|
61
|
+
const costAnalysisReportService = new CostAnalysisReportService({ contextService, repository: costAnalysisReportRepository, analysisService: costAnalysisService });
|
|
62
|
+
const costAnalysisReportWorker = new CostAnalysisReportWorker({ repository: costAnalysisReportRepository, analysisService: costAnalysisService, logger });
|
|
63
|
+
const costAnalysisController = new CostAnalysisController({ service: costAnalysisService, reportService: costAnalysisReportService });
|
|
57
64
|
const router = createCostRouter({ costController, budgetController, costAnalysisController }, logger);
|
|
58
65
|
let mounted = false;
|
|
59
66
|
|
|
@@ -61,6 +68,7 @@ export function createInsightCost({ app, db, apiBaseUri = "/api/v1", logger = co
|
|
|
61
68
|
async install() {
|
|
62
69
|
await installCostBudgetSchema(db, qSchema);
|
|
63
70
|
await installCostAnalysisSchema(db, qSchema);
|
|
71
|
+
await installCostAnalysisReportSchema(db, qSchema);
|
|
64
72
|
await installCurDiscoverySchema(db, qSchema);
|
|
65
73
|
logger.log?.("[Cost] Database migration verified");
|
|
66
74
|
},
|
|
@@ -70,10 +78,12 @@ export function createInsightCost({ app, db, apiBaseUri = "/api/v1", logger = co
|
|
|
70
78
|
mounted = true;
|
|
71
79
|
}
|
|
72
80
|
discoveryWorker.start();
|
|
81
|
+
costAnalysisReportWorker.start();
|
|
73
82
|
logger.log?.(`[Cost] Routes active at ${apiBaseUri}/cost/*`);
|
|
74
83
|
},
|
|
75
84
|
async stop() {
|
|
76
85
|
discoveryWorker.stop();
|
|
86
|
+
costAnalysisReportWorker.stop();
|
|
77
87
|
},
|
|
78
88
|
router,
|
|
79
89
|
};
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { sql } from "drizzle-orm";
|
|
3
|
+
import { rows } from "../lib/cost-utils.js";
|
|
4
|
+
|
|
5
|
+
export class CostAnalysisReportRepository {
|
|
6
|
+
constructor({ db, qSchema }) {
|
|
7
|
+
this.db = db;
|
|
8
|
+
this.schedules = `${qSchema}.cost_ai_report_schedules`;
|
|
9
|
+
this.reports = `${qSchema}.cost_ai_reports`;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
async getSchedule(tenantId) {
|
|
13
|
+
return rows(await this.db.execute(sql`SELECT * FROM ${sql.raw(this.schedules)} WHERE tenant_id = ${tenantId}`))[0] || null;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
async saveSchedule(tenantId, schedule, nextRunAt) {
|
|
17
|
+
return rows(await this.db.execute(sql`
|
|
18
|
+
INSERT INTO ${sql.raw(this.schedules)}
|
|
19
|
+
(tenant_id, enabled, frequency, time_of_day, timezone, day_of_week, day_of_month, next_run_at)
|
|
20
|
+
VALUES (${tenantId}, ${schedule.enabled}, ${schedule.frequency}, ${schedule.time}, ${schedule.timezone}, ${schedule.dayOfWeek}, ${schedule.dayOfMonth}, ${nextRunAt})
|
|
21
|
+
ON CONFLICT (tenant_id) DO UPDATE SET
|
|
22
|
+
enabled = EXCLUDED.enabled, frequency = EXCLUDED.frequency,
|
|
23
|
+
time_of_day = EXCLUDED.time_of_day, timezone = EXCLUDED.timezone,
|
|
24
|
+
day_of_week = EXCLUDED.day_of_week, day_of_month = EXCLUDED.day_of_month,
|
|
25
|
+
next_run_at = EXCLUDED.next_run_at, updated_at = now()
|
|
26
|
+
RETURNING *
|
|
27
|
+
`))[0];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async claimDue(limit, leaseMs) {
|
|
31
|
+
const leaseUntil = new Date(Date.now() + leaseMs);
|
|
32
|
+
const batchSize = Math.min(Math.max(Number(limit) || 10, 1), 50);
|
|
33
|
+
return rows(await this.db.execute(sql`
|
|
34
|
+
WITH due AS (
|
|
35
|
+
SELECT tenant_id, next_run_at AS scheduled_for
|
|
36
|
+
FROM ${sql.raw(this.schedules)}
|
|
37
|
+
WHERE enabled = true AND next_run_at IS NOT NULL AND next_run_at <= now()
|
|
38
|
+
ORDER BY next_run_at
|
|
39
|
+
FOR UPDATE SKIP LOCKED
|
|
40
|
+
LIMIT ${batchSize}
|
|
41
|
+
)
|
|
42
|
+
UPDATE ${sql.raw(this.schedules)} s
|
|
43
|
+
SET next_run_at = ${leaseUntil}, updated_at = now()
|
|
44
|
+
FROM due
|
|
45
|
+
WHERE s.tenant_id = due.tenant_id
|
|
46
|
+
RETURNING s.*, due.scheduled_for
|
|
47
|
+
`));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async setNextRun(tenantId, nextRunAt, completed = false, leaseUntil = null) {
|
|
51
|
+
await this.db.execute(sql`
|
|
52
|
+
UPDATE ${sql.raw(this.schedules)}
|
|
53
|
+
SET next_run_at = ${nextRunAt}, last_run_at = CASE WHEN ${completed} THEN now() ELSE last_run_at END, updated_at = now()
|
|
54
|
+
WHERE tenant_id = ${tenantId}
|
|
55
|
+
AND (${leaseUntil}::timestamptz IS NULL OR next_run_at = ${leaseUntil})
|
|
56
|
+
`);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async createReport(schedule, range) {
|
|
60
|
+
const inserted = rows(await this.db.execute(sql`
|
|
61
|
+
INSERT INTO ${sql.raw(this.reports)}
|
|
62
|
+
(id, tenant_id, schedule_frequency, scheduled_for, period_start, period_end)
|
|
63
|
+
VALUES (${randomUUID()}, ${schedule.tenant_id}, ${schedule.frequency}, ${schedule.scheduled_for}, ${range.Start}, ${range.End})
|
|
64
|
+
ON CONFLICT (tenant_id, scheduled_for) DO NOTHING
|
|
65
|
+
RETURNING *
|
|
66
|
+
`));
|
|
67
|
+
return inserted[0] || null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async markRunning(id) {
|
|
71
|
+
return rows(await this.db.execute(sql`UPDATE ${sql.raw(this.reports)} SET status = 'RUNNING', attempt_count = attempt_count + 1, started_at = now(), error = NULL, updated_at = now() WHERE id = ${id} AND status = 'PENDING' RETURNING *`))[0] || null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async complete(id, analysis, pdf) {
|
|
75
|
+
await this.db.execute(sql`
|
|
76
|
+
UPDATE ${sql.raw(this.reports)} SET status = 'COMPLETED', model_id = ${analysis.modelId}, data_source = ${analysis.dataSource || null},
|
|
77
|
+
facts = ${JSON.stringify(analysis.facts)}::jsonb,
|
|
78
|
+
result = ${JSON.stringify({ summary: analysis.summary, findings: analysis.findings, recommendations: analysis.recommendations, limitations: analysis.limitations })}::jsonb,
|
|
79
|
+
pdf_data = ${pdf}, pdf_size = ${pdf.length}, completed_at = now(), updated_at = now()
|
|
80
|
+
WHERE id = ${id}
|
|
81
|
+
`);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async fail(id, error) {
|
|
85
|
+
await this.db.execute(sql`UPDATE ${sql.raw(this.reports)} SET status = 'FAILED', error = ${String(error?.message || error).slice(0, 2000)}, completed_at = now(), updated_at = now() WHERE id = ${id}`);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async list(tenantId, limit = 100) {
|
|
89
|
+
return rows(await this.db.execute(sql`
|
|
90
|
+
SELECT id, schedule_frequency, scheduled_for, period_start, period_end, status,
|
|
91
|
+
attempt_count, model_id, data_source, facts, result, pdf_size, error,
|
|
92
|
+
started_at, completed_at, created_at
|
|
93
|
+
FROM ${sql.raw(this.reports)} WHERE tenant_id = ${tenantId}
|
|
94
|
+
ORDER BY scheduled_for DESC LIMIT ${Math.min(Math.max(Number(limit) || 100, 1), 250)}
|
|
95
|
+
`));
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async find(tenantId, id, includePdf = false) {
|
|
99
|
+
if (includePdf) {
|
|
100
|
+
return rows(await this.db.execute(sql`SELECT * FROM ${sql.raw(this.reports)} WHERE tenant_id = ${tenantId} AND id = ${id} LIMIT 1`))[0] || null;
|
|
101
|
+
}
|
|
102
|
+
return rows(await this.db.execute(sql`
|
|
103
|
+
SELECT id, schedule_frequency, scheduled_for, period_start, period_end, status,
|
|
104
|
+
attempt_count, model_id, data_source, facts, result, pdf_size, error,
|
|
105
|
+
started_at, completed_at, created_at
|
|
106
|
+
FROM ${sql.raw(this.reports)} WHERE tenant_id = ${tenantId} AND id = ${id} LIMIT 1
|
|
107
|
+
`))[0] || null;
|
|
108
|
+
}
|
|
109
|
+
}
|
package/src/routes/index.js
CHANGED
|
@@ -11,7 +11,11 @@ export function createCostRouter({ costController, budgetController, costAnalysi
|
|
|
11
11
|
router.get("/cur-discovery", costController.curDiscovery.bind(costController));
|
|
12
12
|
router.get("/analysis/status", costAnalysisController.status.bind(costAnalysisController));
|
|
13
13
|
router.get("/analysis/latest", costAnalysisController.latest.bind(costAnalysisController));
|
|
14
|
-
router.
|
|
14
|
+
router.get("/analysis/schedule", costAnalysisController.schedule.bind(costAnalysisController));
|
|
15
|
+
router.put("/analysis/schedule", costAnalysisController.updateSchedule.bind(costAnalysisController));
|
|
16
|
+
router.get("/analysis/reports", costAnalysisController.reports.bind(costAnalysisController));
|
|
17
|
+
router.get("/analysis/reports/:id", costAnalysisController.report.bind(costAnalysisController));
|
|
18
|
+
router.get("/analysis/reports/:id/pdf", costAnalysisController.downloadPdf.bind(costAnalysisController));
|
|
15
19
|
router.get("/budgets", budgetController.list.bind(budgetController));
|
|
16
20
|
router.post("/budgets", budgetController.create.bind(budgetController));
|
|
17
21
|
router.delete("/budgets/:id", budgetController.delete.bind(budgetController));
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { sql } from "drizzle-orm";
|
|
2
|
+
|
|
3
|
+
export async function installCostAnalysisReportSchema(db, qSchema) {
|
|
4
|
+
await db.execute(sql.raw(`CREATE TABLE IF NOT EXISTS ${qSchema}.cost_ai_report_schedules (
|
|
5
|
+
tenant_id text PRIMARY KEY,
|
|
6
|
+
enabled boolean NOT NULL DEFAULT false,
|
|
7
|
+
frequency text NOT NULL DEFAULT 'weekly' CHECK (frequency IN ('daily', 'weekly', 'monthly')),
|
|
8
|
+
time_of_day time NOT NULL DEFAULT '09:00:00',
|
|
9
|
+
timezone text NOT NULL DEFAULT 'UTC',
|
|
10
|
+
day_of_week smallint CHECK (day_of_week BETWEEN 0 AND 6),
|
|
11
|
+
day_of_month smallint CHECK (day_of_month BETWEEN 1 AND 28),
|
|
12
|
+
next_run_at timestamptz,
|
|
13
|
+
last_run_at timestamptz,
|
|
14
|
+
created_at timestamptz NOT NULL DEFAULT now(),
|
|
15
|
+
updated_at timestamptz NOT NULL DEFAULT now()
|
|
16
|
+
)`));
|
|
17
|
+
await db.execute(sql.raw(`CREATE INDEX IF NOT EXISTS cost_ai_report_schedules_due_idx ON ${qSchema}.cost_ai_report_schedules (enabled, next_run_at)`));
|
|
18
|
+
|
|
19
|
+
await db.execute(sql.raw(`CREATE TABLE IF NOT EXISTS ${qSchema}.cost_ai_reports (
|
|
20
|
+
id text PRIMARY KEY,
|
|
21
|
+
tenant_id text NOT NULL,
|
|
22
|
+
schedule_frequency text NOT NULL CHECK (schedule_frequency IN ('daily', 'weekly', 'monthly')),
|
|
23
|
+
scheduled_for timestamptz NOT NULL,
|
|
24
|
+
period_start date NOT NULL,
|
|
25
|
+
period_end date NOT NULL,
|
|
26
|
+
status text NOT NULL DEFAULT 'PENDING' CHECK (status IN ('PENDING', 'RUNNING', 'COMPLETED', 'FAILED')),
|
|
27
|
+
attempt_count integer NOT NULL DEFAULT 0,
|
|
28
|
+
model_id text,
|
|
29
|
+
data_source text,
|
|
30
|
+
facts jsonb,
|
|
31
|
+
result jsonb,
|
|
32
|
+
pdf_data bytea,
|
|
33
|
+
pdf_size integer,
|
|
34
|
+
error text,
|
|
35
|
+
started_at timestamptz,
|
|
36
|
+
completed_at timestamptz,
|
|
37
|
+
created_at timestamptz NOT NULL DEFAULT now(),
|
|
38
|
+
updated_at timestamptz NOT NULL DEFAULT now(),
|
|
39
|
+
UNIQUE (tenant_id, scheduled_for)
|
|
40
|
+
)`));
|
|
41
|
+
await db.execute(sql.raw(`CREATE INDEX IF NOT EXISTS cost_ai_reports_tenant_created_idx ON ${qSchema}.cost_ai_reports (tenant_id, created_at DESC)`));
|
|
42
|
+
await db.execute(sql.raw(`CREATE INDEX IF NOT EXISTS cost_ai_reports_status_idx ON ${qSchema}.cost_ai_reports (status, created_at)`));
|
|
43
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import PDFDocument from "pdfkit";
|
|
2
|
+
|
|
3
|
+
const money = (value, currency = "USD") => new Intl.NumberFormat("en-US", { style: "currency", currency }).format(Number(value || 0));
|
|
4
|
+
|
|
5
|
+
export function createCostAnalysisPdf(analysis) {
|
|
6
|
+
return new Promise((resolve, reject) => {
|
|
7
|
+
const doc = new PDFDocument({ size: "A4", margin: 48, info: { Title: "Meyi Connect AI Cost Analysis", Author: "Meyi Connect" } });
|
|
8
|
+
const chunks = [];
|
|
9
|
+
doc.on("data", (chunk) => chunks.push(chunk));
|
|
10
|
+
doc.on("end", () => resolve(Buffer.concat(chunks)));
|
|
11
|
+
doc.on("error", reject);
|
|
12
|
+
const ensure = (height = 80) => { if (doc.y + height > doc.page.height - 48) doc.addPage(); };
|
|
13
|
+
const inclusiveEnd = new Date(`${analysis.period.End}T00:00:00Z`);
|
|
14
|
+
inclusiveEnd.setUTCDate(inclusiveEnd.getUTCDate() - 1);
|
|
15
|
+
doc.font("Helvetica-Bold").fontSize(20).fillColor("#122235").text("AI Cost Analysis");
|
|
16
|
+
doc.moveDown(0.35).font("Helvetica").fontSize(9).fillColor("#53657a").text(`Generated ${new Date(analysis.generatedAt).toUTCString()} | AWS CUR | ${analysis.period.Start} to ${inclusiveEnd.toISOString().slice(0, 10)}`);
|
|
17
|
+
doc.moveDown(1).font("Helvetica-Bold").fontSize(12).fillColor("#122235").text("Executive summary");
|
|
18
|
+
doc.moveDown(0.35).font("Helvetica").fontSize(10).fillColor("#27384a").text(analysis.summary, { lineGap: 3 });
|
|
19
|
+
doc.moveDown(1).font("Helvetica-Bold").fontSize(12).fillColor("#122235").text("Cost facts");
|
|
20
|
+
doc.moveDown(0.35).font("Helvetica").fontSize(10).fillColor("#27384a");
|
|
21
|
+
doc.text(`Period spend: ${money(analysis.facts.currentTotal, analysis.facts.currency)}`);
|
|
22
|
+
doc.text(`Previous period: ${money(analysis.facts.previousTotal, analysis.facts.currency)}`);
|
|
23
|
+
doc.text(`Change: ${money(analysis.facts.change, analysis.facts.currency)} (${analysis.facts.changePercentage == null ? "no baseline" : `${analysis.facts.changePercentage}%`})`);
|
|
24
|
+
doc.moveDown(1).font("Helvetica-Bold").fontSize(12).fillColor("#122235").text("Key findings");
|
|
25
|
+
for (const finding of analysis.findings || []) {
|
|
26
|
+
ensure();
|
|
27
|
+
doc.moveDown(0.5).font("Helvetica-Bold").fontSize(10).fillColor("#122235").text(`${String(finding.severity).toUpperCase()}: ${finding.title}`);
|
|
28
|
+
doc.font("Helvetica").fillColor("#27384a").text(finding.explanation, { lineGap: 2 });
|
|
29
|
+
doc.fontSize(9).fillColor("#53657a").text(`Evidence: ${finding.evidence}`);
|
|
30
|
+
}
|
|
31
|
+
ensure();
|
|
32
|
+
doc.moveDown(1).font("Helvetica-Bold").fontSize(12).fillColor("#122235").text("Recommended actions");
|
|
33
|
+
for (const item of [...(analysis.recommendations || [])].sort((a, b) => a.priority - b.priority)) {
|
|
34
|
+
ensure();
|
|
35
|
+
doc.moveDown(0.5).font("Helvetica-Bold").fontSize(10).fillColor("#122235").text(`${item.priority}. ${item.title}`);
|
|
36
|
+
doc.font("Helvetica").fillColor("#27384a").text(item.action, { lineGap: 2 });
|
|
37
|
+
doc.fontSize(9).fillColor("#53657a").text(item.rationale);
|
|
38
|
+
}
|
|
39
|
+
doc.moveDown(1).font("Helvetica-Oblique").fontSize(8).fillColor("#6b7785").text("AI recommendations are advisory. Review them before changing AWS resources.");
|
|
40
|
+
doc.end();
|
|
41
|
+
});
|
|
42
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { nextScheduleRun, reportRange, validateSchedule } from "../lib/cost-analysis-schedule.js";
|
|
2
|
+
|
|
3
|
+
function scheduleDto(row) {
|
|
4
|
+
if (!row) return { enabled: false, frequency: "weekly", time: "09:00", timezone: "UTC", dayOfWeek: 1, dayOfMonth: 1, nextRunAt: null, lastRunAt: null };
|
|
5
|
+
return { enabled: row.enabled, frequency: row.frequency, time: String(row.time_of_day).slice(0, 5), timezone: row.timezone, dayOfWeek: row.day_of_week, dayOfMonth: row.day_of_month, nextRunAt: row.next_run_at, lastRunAt: row.last_run_at };
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
function reportDto(row) {
|
|
9
|
+
return {
|
|
10
|
+
id: row.id, frequency: row.schedule_frequency, scheduledFor: row.scheduled_for,
|
|
11
|
+
period: { Start: String(row.period_start).slice(0, 10), End: String(row.period_end).slice(0, 10) },
|
|
12
|
+
status: row.status, attempts: Number(row.attempt_count || 0), modelId: row.model_id,
|
|
13
|
+
dataSource: row.data_source, facts: row.facts || null, ...(row.result || {}),
|
|
14
|
+
pdfReady: row.status === "COMPLETED" && Number(row.pdf_size || 0) > 0,
|
|
15
|
+
pdfSize: Number(row.pdf_size || 0), error: row.error, startedAt: row.started_at,
|
|
16
|
+
completedAt: row.completed_at, createdAt: row.created_at,
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export class CostAnalysisReportService {
|
|
21
|
+
constructor({ contextService, repository, analysisService }) {
|
|
22
|
+
this.contextService = contextService;
|
|
23
|
+
this.repository = repository;
|
|
24
|
+
this.analysisService = analysisService;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
tenant(req) { return this.contextService.tenantId(req); }
|
|
28
|
+
|
|
29
|
+
async getSchedule(req) { return scheduleDto(await this.repository.getSchedule(this.tenant(req))); }
|
|
30
|
+
|
|
31
|
+
async saveSchedule(req, input) {
|
|
32
|
+
if (String(req.user?.role || "").toLowerCase() !== "admin") throw Object.assign(new Error("Administrator access is required to change the AI report schedule"), { statusCode: 403 });
|
|
33
|
+
const schedule = validateSchedule(input);
|
|
34
|
+
const nextRunAt = schedule.enabled ? nextScheduleRun(schedule) : null;
|
|
35
|
+
return scheduleDto(await this.repository.saveSchedule(this.tenant(req), schedule, nextRunAt));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async list(req, limit) { return (await this.repository.list(this.tenant(req), limit)).map(reportDto); }
|
|
39
|
+
|
|
40
|
+
async get(req, id) {
|
|
41
|
+
const row = await this.repository.find(this.tenant(req), id);
|
|
42
|
+
if (!row) throw Object.assign(new Error("AI cost report was not found"), { statusCode: 404 });
|
|
43
|
+
return reportDto(row);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async pdf(req, id) {
|
|
47
|
+
const row = await this.repository.find(this.tenant(req), id, true);
|
|
48
|
+
if (!row) throw Object.assign(new Error("AI cost report was not found"), { statusCode: 404 });
|
|
49
|
+
if (row.status !== "COMPLETED" || !row.pdf_data) throw Object.assign(new Error("The PDF is not ready"), { statusCode: 409 });
|
|
50
|
+
return { data: Buffer.from(row.pdf_data), filename: `meyi-cost-analysis-${String(row.period_start).slice(0, 10)}-${id.slice(0, 8)}.pdf` };
|
|
51
|
+
}
|
|
52
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { nextScheduleRun, reportRange } from "../lib/cost-analysis-schedule.js";
|
|
2
|
+
import { createCostAnalysisPdf } from "../services/cost-analysis-pdf.service.js";
|
|
3
|
+
|
|
4
|
+
export class CostAnalysisReportWorker {
|
|
5
|
+
constructor({ repository, analysisService, logger = console, env = process.env, pdfGenerator = createCostAnalysisPdf }) {
|
|
6
|
+
this.repository = repository;
|
|
7
|
+
this.analysisService = analysisService;
|
|
8
|
+
this.logger = logger;
|
|
9
|
+
this.pdfGenerator = pdfGenerator;
|
|
10
|
+
this.enabled = String(env.COST_AI_ENABLED || "").toLowerCase() === "true";
|
|
11
|
+
this.pollMs = Math.max(Number(env.COST_AI_REPORT_POLL_INTERVAL_MS || 60_000), 10_000);
|
|
12
|
+
this.leaseMs = Math.max(Number(env.COST_AI_REPORT_LEASE_MS || 30 * 60_000), 60_000);
|
|
13
|
+
this.batchSize = Math.min(Math.max(Number(env.COST_AI_REPORT_BATCH_SIZE || 5), 1), 25);
|
|
14
|
+
this.running = false;
|
|
15
|
+
this.timer = null;
|
|
16
|
+
this.initialTimer = null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async run(schedule) {
|
|
20
|
+
const normalized = { ...schedule, time: String(schedule.time_of_day).slice(0, 5), dayOfWeek: schedule.day_of_week, dayOfMonth: schedule.day_of_month };
|
|
21
|
+
const range = reportRange(normalized, schedule.scheduled_for);
|
|
22
|
+
const report = await this.repository.createReport(schedule, range);
|
|
23
|
+
const nextRunAt = nextScheduleRun(normalized, new Date());
|
|
24
|
+
if (!report) return this.repository.setNextRun(schedule.tenant_id, nextRunAt, false, schedule.next_run_at);
|
|
25
|
+
const claimed = await this.repository.markRunning(report.id);
|
|
26
|
+
if (!claimed) return this.repository.setNextRun(schedule.tenant_id, nextRunAt, false, schedule.next_run_at);
|
|
27
|
+
try {
|
|
28
|
+
const req = { body: { force: true }, headers: {}, user: { tenant_id: schedule.tenant_id, tenantId: schedule.tenant_id } };
|
|
29
|
+
const analysis = await this.analysisService.analyze(req, range);
|
|
30
|
+
const pdf = await this.pdfGenerator(analysis);
|
|
31
|
+
await this.repository.complete(report.id, analysis, pdf);
|
|
32
|
+
await this.repository.setNextRun(schedule.tenant_id, nextRunAt, true, schedule.next_run_at);
|
|
33
|
+
} catch (error) {
|
|
34
|
+
await this.repository.fail(report.id, error);
|
|
35
|
+
await this.repository.setNextRun(schedule.tenant_id, nextRunAt, true, schedule.next_run_at);
|
|
36
|
+
this.logger.error?.("[Cost AI Reports] Scheduled analysis failed", { tenantId: schedule.tenant_id, reportId: report.id, message: error.message });
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async tick() {
|
|
41
|
+
if (!this.enabled || this.running) return;
|
|
42
|
+
this.running = true;
|
|
43
|
+
try {
|
|
44
|
+
const schedules = await this.repository.claimDue(this.batchSize, this.leaseMs);
|
|
45
|
+
for (const schedule of schedules) await this.run(schedule);
|
|
46
|
+
} catch (error) {
|
|
47
|
+
this.logger.error?.("[Cost AI Reports] Scheduler tick failed", error);
|
|
48
|
+
} finally { this.running = false; }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
start() {
|
|
52
|
+
if (!this.enabled || this.timer) return;
|
|
53
|
+
this.initialTimer = setTimeout(() => void this.tick(), 2_000);
|
|
54
|
+
this.initialTimer.unref?.();
|
|
55
|
+
this.timer = setInterval(() => void this.tick(), this.pollMs);
|
|
56
|
+
this.timer.unref?.();
|
|
57
|
+
this.logger.log?.(`[Cost AI Reports] Scheduler started; poll=${Math.round(this.pollMs / 1000)}s`);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
stop() {
|
|
61
|
+
if (this.initialTimer) clearTimeout(this.initialTimer);
|
|
62
|
+
if (this.timer) clearInterval(this.timer);
|
|
63
|
+
this.initialTimer = null;
|
|
64
|
+
this.timer = null;
|
|
65
|
+
}
|
|
66
|
+
}
|