Skip to content

Commit

Permalink
Uses Azure API for forecast data (#36)
Browse files Browse the repository at this point in the history
* Added ability to get forecasts

* Added Unit Test for getting forecast

* Updated Version to 1.0.9

* Added New Line end of QueryEditor.tsx
  • Loading branch information
MartinBelton-gov authored Feb 1, 2024
1 parent 416aa2c commit 6d8c91e
Show file tree
Hide file tree
Showing 11 changed files with 198 additions and 25 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/dockerpublish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ env:
# github.repository as <account>/<repo>
IMAGE_NAME: ${{ github.repository }}
RELEASE_TAG: ${{ github.event.release.tag_name }}
PLUGIN_VERSION: 1.0.8
PLUGIN_VERSION: 1.0.9

jobs:
build:
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/publish-ghcr.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ env:
# github.repository as <account>/<repo>
IMAGE_NAME: ${{ github.repository }}
RELEASE_TAG: ${{ github.event.release.tag_name }}
PLUGIN_VERSION: 1.0.8
PLUGIN_VERSION: 1.0.9

jobs:
build-and-push:
Expand Down
4 changes: 2 additions & 2 deletions dfe-azurecostbackend-datasource/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion dfe-azurecostbackend-datasource/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"id": "1",
"name": "azurecost-backend",
"version": "1.0.8",
"version": "1.0.9",
"description": "Azure cost backend",
"scripts": {
"build": "webpack -c ./.config/webpack/webpack.config.ts --env production",
Expand Down
117 changes: 107 additions & 10 deletions dfe-azurecostbackend-datasource/pkg/plugin/datasource.go
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,7 @@ func (d *Datasource) QueryData(ctx context.Context, req *backend.QueryDataReques
type queryModel struct {
QueryText string `json:"queryText"`
Constant float64 `json:"constant"`
Forecast bool `json:"forecast"`
}

func (d *Datasource) query(_ context.Context, pCtx backend.PluginContext, query backend.DataQuery) backend.DataResponse {
Expand All @@ -218,6 +219,7 @@ func (d *Datasource) query(_ context.Context, pCtx backend.PluginContext, query
log.Print("Starting Datasource")
log.Println("URL:", d.config.AzureCostSubscriptionUrl)
log.Println("ResourceId:", qm.QueryText)
log.Println("Do Forecast:", qm.Forecast)

// Call the fetchToken function
token, err := fetchToken(d.config)
Expand All @@ -237,17 +239,40 @@ func (d *Datasource) query(_ context.Context, pCtx backend.PluginContext, query
end = timeRange.To.Format("2006-01-02")
}

costs, err := getCosts(token, d.config, start, end, qm.QueryText)
if err != nil {
log.Println("Error getting costs:", err)
return response
}
var datepoints []DatePoint

if qm.Forecast {

costs, err := getForecast(token, d.config, start, end, qm.QueryText)
if err != nil {
log.Println("Error getting forecast:", err)
return response
}

forecastdatepoints, err := convertCostsToDatePoint(costs)
if err != nil {
log.Println("Error getting costs:", err)
return response
}

datepoints = forecastdatepoints

} else {

costs, err := getCosts(token, d.config, start, end, qm.QueryText)
if err != nil {
log.Println("Error getting costs:", err)
return response
}

costdatepoints, err := convertCostsToDatePoint(costs)
if err != nil {
log.Println("Error getting costs:", err)
return response
}

datepoints = costdatepoints

//log.Printf("Costs: %+v", costs)
datepoints, err := convertCostsToDatePoint(costs)
if err != nil {
log.Println("Error getting costs:", err)
return response
}

log.Printf("Datapoint count: %d", len(datepoints))
Expand Down Expand Up @@ -446,6 +471,78 @@ func getCosts(token string, config Config, start string, end string, resourceid
return costResponse, nil
}

// Fetch Forecast
func getForecast(token string, config Config, start string, end string, resourceid string) (CostResponse, error) {
url := config.SubscriptionID + "/providers/Microsoft.CostManagement/forecast?api-version=2023-03-01"

if len(resourceid) > 2 {
url = config.SubscriptionID + "/resourceGroups/" + resourceid + "/providers/Microsoft.CostManagement/forecast?api-version=2023-03-01"
}

log.Println("ForecastUrl:", url)

bodyParameters := map[string]interface{}{
"type": "Usage",
"timeframe": "Custom",
"timeperiod": map[string]string{
"from": start,
"to": end,
},
"dataset": map[string]interface{}{
"granularity": "Daily",
"aggregation": map[string]interface{}{
"PreTaxCost": map[string]interface{}{
"name": "PreTaxCost",
"function": "Sum",
},
},
},
"includeActualCost": true,
"includeFreshPartialCost": true,
}

body, err := json.Marshal(bodyParameters)
if err != nil {
return CostResponse{}, fmt.Errorf("Error marshalling request body: %v", err)
}

requestURL := config.AzureCostSubscriptionUrl + url
if len(config.TokenURL) > 1 {
requestURL = config.TokenURL
}
req, err := http.NewRequest("POST", requestURL, bytes.NewBuffer(body))
if err != nil {
return CostResponse{}, fmt.Errorf("Error creating HTTP request: %v", err)
}

req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)

client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
return CostResponse{}, fmt.Errorf("Error making HTTP request: %v", err)
}
defer resp.Body.Close()

if resp.StatusCode != http.StatusOK {
return CostResponse{}, fmt.Errorf("Error fetching cost. Status code: %d", resp.StatusCode)
}

responseBody, err := io.ReadAll(resp.Body)
if err != nil {
return CostResponse{}, fmt.Errorf("Error reading response body: %v", err)
}

var costResponse CostResponse
err = json.Unmarshal(responseBody, &costResponse)
if err != nil {
return CostResponse{}, fmt.Errorf("Error unmarshalling response body: %v", err)
}

return costResponse, nil
}

// FetchCosts fetches Azure costs and returns an array of DatePoint
func convertCostsToDatePoint(costs CostResponse) ([]DatePoint, error) {
// Call the fetchToken method and handle the result
Expand Down
52 changes: 52 additions & 0 deletions dfe-azurecostbackend-datasource/pkg/plugin/datasource_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,58 @@ func TestGetCosts(t *testing.T) {
}
}

func TestGetForecast(t *testing.T) {
// Create a new instance of the Config struct
config := Config{
SubscriptionID: "your-subscription-id",
AzureCostSubscriptionUrl: "https://your-cost-management-url.com/",
}

costResponse := createMockCostResponse()
responseJSON, err := json.Marshal(costResponse)
if err != nil {
t.Errorf("Error marshalling response: %v", err)
}

// Create a new instance of the httptest.Server struct
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Check the request method and URL
if r.Method != "POST" {
t.Errorf("Expected POST request, got %s", r.Method)
}
// if r.URL.String() != "/your-subscription-id/providers/Microsoft.CostManagement/query?api-version=2023-03-01" {
// t.Errorf("Expected URL /your-subscription-id/providers/Microsoft.CostManagement/query?api-version=2023-03-01, got %s", r.URL.String())
// }

// Write a mock response
w.WriteHeader(http.StatusOK)
w.Write(responseJSON)
}))

// Close the server when the test is done
defer server.Close()

// Call the getCosts function with the mock token and config
token := "your-mock-token"
config.TokenURL = server.URL
start, end := getCurrentYearDates()
costs, err := getForecast(token, config, start, end, "resourceid")
if err != nil {
t.Errorf("Expected no error, got %v", err)
}

id := costs.ID
if (id == ""){
t.Errorf("Expected response %v, got %v", id, costs.ID)
}

// Check the response
expected := costResponse
if !reflect.DeepEqual(costs, expected) {
t.Errorf("Expected response %v, got %v", expected, costs)
}
}

func createMockCostResponse() CostResponse {
// Define the properties of the CostResponse object
properties := Properties{
Expand Down
18 changes: 14 additions & 4 deletions dfe-azurecostbackend-datasource/src/components/QueryEditor.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

21 changes: 17 additions & 4 deletions dfe-azurecostbackend-datasource/src/components/QueryEditor.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
import React, { ChangeEvent } from 'react';
import { InlineField, Input } from '@grafana/ui';
import React, { ChangeEvent, useState } from 'react';
import { InlineField, Input, Checkbox } from '@grafana/ui';
import { QueryEditorProps } from '@grafana/data';
import { DataSource } from '../datasource';
import { MyDataSourceOptions, MyQuery } from '../types';

type Props = QueryEditorProps<DataSource, MyQuery, MyDataSourceOptions>;

export function QueryEditor({ query, onChange, onRunQuery }: Props) {
const [forecast, setForecast] = useState<boolean>(query.forecast || false);

const onQueryTextChange = (event: ChangeEvent<HTMLInputElement>) => {
onChange({ ...query, queryText: event.target.value });
// executes the query
Expand All @@ -19,6 +21,14 @@ export function QueryEditor({ query, onChange, onRunQuery }: Props) {
onRunQuery();
};

const onForecastChange = () => {
const newForecast = !forecast;
setForecast(newForecast);
onChange({ ...query, forecast: newForecast });
// executes the query
onRunQuery();
};

const { queryText, constant } = query;

return (
Expand All @@ -29,10 +39,13 @@ export function QueryEditor({ query, onChange, onRunQuery }: Props) {
</InlineField>
)}

<InlineField label="Azure Reource Id" labelWidth={26} tooltip="Reource Id">
<InlineField label="Azure Resource Id" labelWidth={26} tooltip="Resource Id">
<Input onChange={onQueryTextChange} value={queryText || ''} />
</InlineField>

<InlineField label="Forecast">
<Checkbox value={forecast} onChange={onForecastChange} />
</InlineField>
</div>
);
}

2 changes: 1 addition & 1 deletion dfe-azurecostbackend-datasource/src/types.js.map

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions dfe-azurecostbackend-datasource/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { DataQuery, DataSourceJsonData } from '@grafana/schema';
export interface MyQuery extends DataQuery {
queryText?: string;
constant: number;
forecast: boolean;
}

export const DEFAULT_QUERY: Partial<MyQuery> = {
Expand Down

0 comments on commit 6d8c91e

Please sign in to comment.