@meyicloud/meyi-cost-server 1.7.0 → 1.7.1

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.7.0",
3
+ "version": "1.7.1",
4
4
  "description": "Tenant-aware AWS CUR and Athena cost plugin server for MeyiConnect",
5
5
  "type": "module",
6
6
  "main": "./index.js",
@@ -36,6 +36,11 @@ export class CostAnalysisController {
36
36
  catch (error) { return next(error); }
37
37
  }
38
38
 
39
+ async removeReport(req, res, next) {
40
+ try { return res.json(await this.reportService.remove(req, req.params.id)); }
41
+ catch (error) { return next(error); }
42
+ }
43
+
39
44
  async downloadPdf(req, res, next) {
40
45
  try {
41
46
  const report = await this.reportService.pdf(req, req.params.id);
@@ -71,8 +71,14 @@ export class CostAnalysisReportRepository {
71
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
72
  }
73
73
 
74
- async setTaskArn(id, taskArn) {
75
- await this.db.execute(sql`UPDATE ${sql.raw(this.reports)} SET ecs_task_arn = ${taskArn}, updated_at = now() WHERE id = ${id}`);
74
+ async setTaskStarted(id, { taskArn, provider, modelId, source, artifactBucket, markdownKey, pdfKey }) {
75
+ await this.db.execute(sql`
76
+ UPDATE ${sql.raw(this.reports)}
77
+ SET ecs_task_arn = ${taskArn}, provider_name = ${provider}, model_id = ${modelId},
78
+ provider_source = ${source}, artifact_bucket = ${artifactBucket},
79
+ markdown_key = ${markdownKey}, pdf_key = ${pdfKey}, updated_at = now()
80
+ WHERE id = ${id}
81
+ `);
76
82
  }
77
83
 
78
84
  async completeExternal(id, result) {
@@ -125,4 +131,12 @@ export class CostAnalysisReportRepository {
125
131
  FROM ${sql.raw(this.reports)} WHERE tenant_id = ${tenantId} AND id = ${id} LIMIT 1
126
132
  `))[0] || null;
127
133
  }
134
+
135
+ async remove(tenantId, id) {
136
+ return rows(await this.db.execute(sql`
137
+ DELETE FROM ${sql.raw(this.reports)}
138
+ WHERE tenant_id = ${tenantId} AND id = ${id}
139
+ RETURNING id
140
+ `))[0] || null;
141
+ }
128
142
  }
@@ -15,6 +15,7 @@ export function createCostRouter({ costController, budgetController, costAnalysi
15
15
  router.put("/analysis/schedule", costAnalysisController.updateSchedule.bind(costAnalysisController));
16
16
  router.get("/analysis/reports", costAnalysisController.reports.bind(costAnalysisController));
17
17
  router.get("/analysis/reports/:id", costAnalysisController.report.bind(costAnalysisController));
18
+ router.delete("/analysis/reports/:id", costAnalysisController.removeReport.bind(costAnalysisController));
18
19
  router.get("/analysis/reports/:id/pdf", costAnalysisController.downloadPdf.bind(costAnalysisController));
19
20
  router.get("/analysis/reports/:id/markdown", costAnalysisController.downloadMarkdown.bind(costAnalysisController));
20
21
  router.get("/budgets", budgetController.list.bind(budgetController));
@@ -218,7 +218,13 @@ export class CostAnalyserService {
218
218
  throw Object.assign(new Error(`Unable to start cost analyser: ${reason}`), { name: "CostAnalyserLaunchError", statusCode: 502 });
219
219
  }
220
220
  const taskArn = launched.tasks[0].taskArn;
221
- await onStarted?.(taskArn);
221
+ await onStarted?.({
222
+ taskArn,
223
+ ...this.statusSummary(provider),
224
+ artifactBucket: this.reportBucket,
225
+ markdownKey: `${prefix}/report.md`,
226
+ pdfKey: `${prefix}/report.pdf`,
227
+ });
222
228
  const deadline = Date.now() + this.timeoutMs;
223
229
  while (Date.now() < deadline) {
224
230
  const response = await this.ecs.send(new DescribeTasksCommand({ cluster: this.cluster, tasks: [taskArn] }));
@@ -49,6 +49,25 @@ export class CostAnalysisReportService {
49
49
  return reportDto(row);
50
50
  }
51
51
 
52
+ async remove(req, id) {
53
+ if (String(req.user?.role || "").toLowerCase() !== "admin") {
54
+ throw Object.assign(new Error("Administrator access is required to remove AI report history"), { statusCode: 403 });
55
+ }
56
+ const tenantId = this.tenant(req);
57
+ const row = await this.repository.find(tenantId, id, true);
58
+ if (!row) throw Object.assign(new Error("AI cost report was not found"), { statusCode: 404 });
59
+ if (row.status === "PENDING" || row.status === "RUNNING") {
60
+ throw Object.assign(new Error("A running AI cost report cannot be removed"), { statusCode: 409 });
61
+ }
62
+ const artifactKey = row.markdown_key || row.pdf_key;
63
+ if (row.artifact_bucket && artifactKey) {
64
+ const slash = artifactKey.lastIndexOf("/");
65
+ await this.artifactService.removePrefix(row.artifact_bucket, slash >= 0 ? artifactKey.slice(0, slash + 1) : artifactKey);
66
+ }
67
+ await this.repository.remove(tenantId, id);
68
+ return { success: true };
69
+ }
70
+
52
71
  async pdf(req, id) {
53
72
  const row = await this.repository.find(this.tenant(req), id, true);
54
73
  if (!row) throw Object.assign(new Error("AI cost report was not found"), { statusCode: 404 });
@@ -1,4 +1,4 @@
1
- import { GetObjectCommand, S3Client } from "@aws-sdk/client-s3";
1
+ import { DeleteObjectsCommand, GetObjectCommand, ListObjectsV2Command, S3Client } from "@aws-sdk/client-s3";
2
2
 
3
3
  async function bytes(body) {
4
4
  if (!body) return Buffer.alloc(0);
@@ -18,5 +18,21 @@ export class CostReportArtifactService {
18
18
  const response = await this.client.send(new GetObjectCommand({ Bucket: bucket, Key: key }));
19
19
  return bytes(response.Body);
20
20
  }
21
- }
22
21
 
22
+ async removePrefix(bucket, prefix) {
23
+ if (!bucket || !prefix) return;
24
+ let continuationToken;
25
+ do {
26
+ const page = await this.client.send(new ListObjectsV2Command({
27
+ Bucket: bucket,
28
+ Prefix: prefix,
29
+ ContinuationToken: continuationToken,
30
+ }));
31
+ const objects = (page.Contents || []).map(({ Key }) => ({ Key })).filter(({ Key }) => Boolean(Key));
32
+ if (objects.length) {
33
+ await this.client.send(new DeleteObjectsCommand({ Bucket: bucket, Delete: { Objects: objects, Quiet: true } }));
34
+ }
35
+ continuationToken = page.IsTruncated ? page.NextContinuationToken : undefined;
36
+ } while (continuationToken);
37
+ }
38
+ }
@@ -27,7 +27,7 @@ export class CostAnalysisReportWorker {
27
27
  reportId: report.id,
28
28
  range,
29
29
  frequency: schedule.frequency,
30
- onStarted: (taskArn) => this.repository.setTaskArn(report.id, taskArn),
30
+ onStarted: (metadata) => this.repository.setTaskStarted(report.id, metadata),
31
31
  });
32
32
  await this.repository.completeExternal(report.id, result);
33
33
  await this.repository.setNextRun(schedule.tenant_id, nextRunAt, true, schedule.next_run_at);