desktop/src/app/services/azure-cost-management/azure-cost-management.service.ts (199 lines of code) (raw):
import { Injectable } from "@angular/core";
import { ServerError } from "@batch-flask/core";
import { TimeRange } from "@batch-flask/ui";
import { log } from "@batch-flask/utils";
import { ArmBatchAccount, ArmSubscription } from "app/models";
import { ArmResourceUtils } from "app/utils";
import { DateTime } from "luxon";
import { Observable } from "rxjs";
import { map, share, switchMap, take, tap } from "rxjs/operators";
import { AzureHttpService } from "../azure-http.service";
import { BatchAccountService } from "../batch-account";
import { AzureCostQuery, CostManagementDimensions, QueryResult } from "./azure-cost-mangement-api.model";
export interface AzureCostEntry {
preTaxCost: number;
date: Date;
}
export interface BatchAccountCost {
// Sum of all the prices for the given period
totalForPeriod: number;
// Currency
currency: string;
// Costs per pool
pools: StringMap<BatchPoolCost>;
}
export interface BatchPoolCost {
// Sum of all the prices for the given period
totalForPeriod: number;
// Costs per pool
costs: AzureCostEntry[];
}
function costManagementUrl(scope: string) {
return `${scope}/providers/Microsoft.CostManagement`;
}
@Injectable({ providedIn: "root" })
export class AzureCostManagementService {
constructor(private accountService: BatchAccountService, private azure: AzureHttpService) { }
public getCost(timeRange: TimeRange): Observable<BatchAccountCost> {
return this.accountService.currentAccount.pipe(
take(1),
tap((account) => {
if (!(account instanceof ArmBatchAccount)) {
throw new ServerError({
code: "LocalBatchAccount",
message: "Cannot get quotas for a local batch account",
status: 406,
});
}
}),
switchMap((account: ArmBatchAccount) => this.getCostFor(account.subscription, account.id, timeRange)),
);
}
public getCostFor(
subscription: ArmSubscription, accountId: string, timeRange: TimeRange,
): Observable<BatchAccountCost> {
const subId = ArmResourceUtils.getSubscriptionIdFromResourceId(accountId);
const resourceGroup = ArmResourceUtils.getResourceGroupFromResourceId(accountId);
const scope = `/subscriptions/${subId}/resourceGroups/${resourceGroup}`;
const url = `${costManagementUrl(scope)}/query`;
const payload = this._buildQuery(timeRange);
return this.azure.post<QueryResult>(subscription, url, payload).pipe(
map(x => this._processQueryResponse(accountId, x)),
share(),
);
}
private _processQueryResponse(accountId: string, response: QueryResult): BatchAccountCost {
const columnIndexes = {
cost: null,
date: null,
resourceId: null,
currency: null,
};
for (const [index, column] of response.properties.columns.entries()) {
switch (column.name) {
case "PreTaxCost":
columnIndexes.cost = index;
break;
case "UsageDate":
columnIndexes.date = index;
break;
case CostManagementDimensions.ResourceId:
columnIndexes.resourceId = index;
break;
case "Currency":
columnIndexes.currency = index;
break;
}
}
// Check we found all the columns
for (const [key, index] of Object.entries(columnIndexes)) {
if (index === null) {
log.error(`Failed to retrieve column index for ${key}`, response.properties.columns);
return {
totalForPeriod: 0,
currency: "n/a",
pools: {},
};
}
}
const rows = response.properties.rows.filter((row) => {
// Filter empty meters
return row[columnIndexes.resourceId]
|| row[columnIndexes.cost] !== 0;
}).map((row) => {
return {
preTaxCost: row[columnIndexes.cost],
date: row[columnIndexes.date],
currency: row[columnIndexes.currency],
resourceId: row[columnIndexes.resourceId],
};
}).filter(entry => entry.resourceId.toLowerCase().startsWith(accountId.toLowerCase()));
return this._buildResponseFromRows(rows);
}
private _buildResponseFromRows(rows: Array<{
date: number, preTaxCost: number, currency: string, resourceId: string,
}>): BatchAccountCost {
let currency: string | null = null;
let total = 0;
const poolMap: StringMap<{ [key: number]: AzureCostEntry }> = {};
const days = new Set<number>();
for (const row of rows) {
const poolId = ArmResourceUtils.getAccountNameFromResourceId(row.resourceId);
if (!(poolId in poolMap)) {
poolMap[poolId] = {};
}
if (!days.has(row.date)) {
days.add(row.date);
}
poolMap[poolId][row.date] = {
preTaxCost: row.preTaxCost,
date: this._parseDate(row.date),
};
total += row.preTaxCost;
if (currency == null && row.currency) {
currency = row.currency;
}
}
for (const map of Object.values(poolMap)) {
for (const day of days) {
if (!(day in map)) {
map[day] = {
preTaxCost: 0,
date: this._parseDate(day),
};
}
}
}
const result: StringMap<BatchPoolCost> = {};
for (const [poolId, map] of Object.entries(poolMap)) {
const costs = Object.values(map);
result[poolId] = {
totalForPeriod: costs.reduce((t, c) => t + c.preTaxCost, 0),
costs: costs.sortBy(x => x.date),
};
}
return {
totalForPeriod: total,
currency: currency || "",
pools: result,
};
}
private _parseDate(date: number) {
return DateTime.fromFormat(date.toString(), "yyyyLLdd").toJSDate();
}
private _buildQuery(timeRange: TimeRange, resourceIds: string[] = []): AzureCostQuery {
return {
type: "Usage",
timeframe: "Custom",
timePeriod: {
from: timeRange.start.toISOString(),
to: timeRange.end.toISOString(),
},
dataSet: {
granularity: "Daily",
aggregation: {
totalCost: {
name: "PreTaxCost",
function: "Sum",
},
},
sorting: [
{
direction: "ascending",
name: "UsageDate",
},
],
grouping: [
{
type: "Dimension",
name: CostManagementDimensions.ResourceId,
},
],
filter: {
Dimensions: {
Name: "ResourceType",
Operator: "In",
Values: [
"Microsoft.Batch/batchaccounts",
"Microsoft.Batch/batchaccounts/batchpools",
"Microsoft.Batch/batchaccounts/pools",
],
},
},
},
};
}
}