diff --git a/.github/workflows/dockerpublish.yml b/.github/workflows/dockerpublish.yml index 5c2f816..0982560 100644 --- a/.github/workflows/dockerpublish.yml +++ b/.github/workflows/dockerpublish.yml @@ -16,7 +16,7 @@ env: # github.repository as / IMAGE_NAME: ${{ github.repository }} RELEASE_TAG: ${{ github.event.release.tag_name }} - PLUGIN_VERSION: 1.0.8 + PLUGIN_VERSION: 1.0.9 jobs: build: diff --git a/.github/workflows/publish-ghcr.yml b/.github/workflows/publish-ghcr.yml index 731bad6..f6bbcbf 100644 --- a/.github/workflows/publish-ghcr.yml +++ b/.github/workflows/publish-ghcr.yml @@ -11,7 +11,7 @@ env: # github.repository as / 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: diff --git a/dfe-azurecostbackend-datasource/package-lock.json b/dfe-azurecostbackend-datasource/package-lock.json index 9d89447..24a6b3b 100644 --- a/dfe-azurecostbackend-datasource/package-lock.json +++ b/dfe-azurecostbackend-datasource/package-lock.json @@ -1,12 +1,12 @@ { "name": "azurecost-backend", - "version": "1.0.0", + "version": "1.0.8", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "azurecost-backend", - "version": "1.0.0", + "version": "1.0.8", "license": "Apache-2.0", "dependencies": { "@emotion/css": "^11.1.3", diff --git a/dfe-azurecostbackend-datasource/package.json b/dfe-azurecostbackend-datasource/package.json index 49517da..c9612cf 100644 --- a/dfe-azurecostbackend-datasource/package.json +++ b/dfe-azurecostbackend-datasource/package.json @@ -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", diff --git a/dfe-azurecostbackend-datasource/pkg/plugin/datasource.go b/dfe-azurecostbackend-datasource/pkg/plugin/datasource.go index 83e4dbc..c650bc7 100644 --- a/dfe-azurecostbackend-datasource/pkg/plugin/datasource.go +++ b/dfe-azurecostbackend-datasource/pkg/plugin/datasource.go @@ -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 { @@ -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) @@ -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)) @@ -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 diff --git a/dfe-azurecostbackend-datasource/pkg/plugin/datasource_test.go b/dfe-azurecostbackend-datasource/pkg/plugin/datasource_test.go index 3f0f3bd..aa12fee 100644 --- a/dfe-azurecostbackend-datasource/pkg/plugin/datasource_test.go +++ b/dfe-azurecostbackend-datasource/pkg/plugin/datasource_test.go @@ -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{ diff --git a/dfe-azurecostbackend-datasource/src/components/QueryEditor.js b/dfe-azurecostbackend-datasource/src/components/QueryEditor.js index f3b833f..96d2835 100644 --- a/dfe-azurecostbackend-datasource/src/components/QueryEditor.js +++ b/dfe-azurecostbackend-datasource/src/components/QueryEditor.js @@ -1,6 +1,7 @@ -import React from 'react'; -import { InlineField, Input } from '@grafana/ui'; +import React, { useState } from 'react'; +import { InlineField, Input, Checkbox } from '@grafana/ui'; export function QueryEditor({ query, onChange, onRunQuery }) { + const [forecast, setForecast] = useState(query.forecast || false); const onQueryTextChange = (event) => { onChange(Object.assign(Object.assign({}, query), { queryText: event.target.value })); // executes the query @@ -11,11 +12,20 @@ export function QueryEditor({ query, onChange, onRunQuery }) { // executes the query onRunQuery(); }; + const onForecastChange = () => { + const newForecast = !forecast; + setForecast(newForecast); + onChange(Object.assign(Object.assign({}, query), { forecast: newForecast })); + // executes the query + onRunQuery(); + }; const { queryText, constant } = query; return (React.createElement("div", { className: "gf-form" }, false && (React.createElement(InlineField, { label: "Constant" }, React.createElement(Input, { onChange: onConstantChange, value: constant, width: 8, type: "number", step: "0.1" }))), - React.createElement(InlineField, { label: "Azure Reource Id", labelWidth: 26, tooltip: "Reource Id" }, - React.createElement(Input, { onChange: onQueryTextChange, value: queryText || '' })))); + React.createElement(InlineField, { label: "Azure Resource Id", labelWidth: 26, tooltip: "Resource Id" }, + React.createElement(Input, { onChange: onQueryTextChange, value: queryText || '' })), + React.createElement(InlineField, { label: "Forecast" }, + React.createElement(Checkbox, { value: forecast, onChange: onForecastChange })))); } //# sourceMappingURL=QueryEditor.js.map diff --git a/dfe-azurecostbackend-datasource/src/components/QueryEditor.js.map b/dfe-azurecostbackend-datasource/src/components/QueryEditor.js.map index f353eb5..94bea3f 100644 --- a/dfe-azurecostbackend-datasource/src/components/QueryEditor.js.map +++ b/dfe-azurecostbackend-datasource/src/components/QueryEditor.js.map @@ -1 +1 @@ -{"version":3,"file":"QueryEditor.js","sourceRoot":"","sources":["QueryEditor.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAsB,MAAM,OAAO,CAAC;AAC3C,OAAO,EAAE,WAAW,EAAE,KAAK,EAAE,MAAM,aAAa,CAAC;AAOjD,MAAM,UAAU,WAAW,CAAC,EAAE,KAAK,EAAE,QAAQ,EAAE,UAAU,EAAS;IAChE,MAAM,iBAAiB,GAAG,CAAC,KAAoC,EAAE,EAAE;QACjE,QAAQ,iCAAM,KAAK,KAAE,SAAS,EAAE,KAAK,CAAC,MAAM,CAAC,KAAK,IAAG,CAAC;QACtD,qBAAqB;QACrB,UAAU,EAAE,CAAC;IACf,CAAC,CAAC;IAEF,MAAM,gBAAgB,GAAG,CAAC,KAAoC,EAAE,EAAE;QAChE,QAAQ,iCAAM,KAAK,KAAE,QAAQ,EAAE,UAAU,CAAC,KAAK,CAAC,MAAM,CAAC,KAAK,CAAC,IAAG,CAAC;QACjE,qBAAqB;QACrB,UAAU,EAAE,CAAC;IACf,CAAC,CAAC;IAEF,MAAM,EAAE,SAAS,EAAE,QAAQ,EAAE,GAAG,KAAK,CAAC;IAEtC,OAAO,CACL,6BAAK,SAAS,EAAC,SAAS;QACrB,KAAK,IAAI,CACR,oBAAC,WAAW,IAAC,KAAK,EAAC,UAAU;YAC3B,oBAAC,KAAK,IAAC,QAAQ,EAAE,gBAAgB,EAAE,KAAK,EAAE,QAAQ,EAAE,KAAK,EAAE,CAAC,EAAE,IAAI,EAAC,QAAQ,EAAC,IAAI,EAAC,KAAK,GAAG,CAC7E,CACf;QAED,oBAAC,WAAW,IAAC,KAAK,EAAC,kBAAkB,EAAC,UAAU,EAAE,EAAE,EAAE,OAAO,EAAC,YAAY;YACxE,oBAAC,KAAK,IAAC,QAAQ,EAAE,iBAAiB,EAAE,KAAK,EAAE,SAAS,IAAI,EAAE,GAAI,CAClD,CACV,CACP,CAAC;AACJ,CAAC"} \ No newline at end of file +{"version":3,"file":"QueryEditor.js","sourceRoot":"","sources":["QueryEditor.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,EAAe,QAAQ,EAAE,MAAM,OAAO,CAAC;AACrD,OAAO,EAAE,WAAW,EAAE,KAAK,EAAE,QAAQ,EAAE,MAAM,aAAa,CAAC;AAO3D,MAAM,UAAU,WAAW,CAAC,EAAE,KAAK,EAAE,QAAQ,EAAE,UAAU,EAAS;IAChE,MAAM,CAAC,QAAQ,EAAE,WAAW,CAAC,GAAG,QAAQ,CAAU,KAAK,CAAC,QAAQ,IAAI,KAAK,CAAC,CAAC;IAE3E,MAAM,iBAAiB,GAAG,CAAC,KAAoC,EAAE,EAAE;QACjE,QAAQ,iCAAM,KAAK,KAAE,SAAS,EAAE,KAAK,CAAC,MAAM,CAAC,KAAK,IAAG,CAAC;QACtD,qBAAqB;QACrB,UAAU,EAAE,CAAC;IACf,CAAC,CAAC;IAEF,MAAM,gBAAgB,GAAG,CAAC,KAAoC,EAAE,EAAE;QAChE,QAAQ,iCAAM,KAAK,KAAE,QAAQ,EAAE,UAAU,CAAC,KAAK,CAAC,MAAM,CAAC,KAAK,CAAC,IAAG,CAAC;QACjE,qBAAqB;QACrB,UAAU,EAAE,CAAC;IACf,CAAC,CAAC;IAEF,MAAM,gBAAgB,GAAG,GAAG,EAAE;QAC5B,MAAM,WAAW,GAAG,CAAC,QAAQ,CAAC;QAC9B,WAAW,CAAC,WAAW,CAAC,CAAC;QACzB,QAAQ,iCAAM,KAAK,KAAE,QAAQ,EAAE,WAAW,IAAG,CAAC;QAC9C,qBAAqB;QACrB,UAAU,EAAE,CAAC;IACf,CAAC,CAAC;IAEF,MAAM,EAAE,SAAS,EAAE,QAAQ,EAAE,GAAG,KAAK,CAAC;IAEtC,OAAO,CACL,6BAAK,SAAS,EAAC,SAAS;QACrB,KAAK,IAAI,CACR,oBAAC,WAAW,IAAC,KAAK,EAAC,UAAU;YAC3B,oBAAC,KAAK,IAAC,QAAQ,EAAE,gBAAgB,EAAE,KAAK,EAAE,QAAQ,EAAE,KAAK,EAAE,CAAC,EAAE,IAAI,EAAC,QAAQ,EAAC,IAAI,EAAC,KAAK,GAAG,CAC7E,CACf;QAED,oBAAC,WAAW,IAAC,KAAK,EAAC,mBAAmB,EAAC,UAAU,EAAE,EAAE,EAAE,OAAO,EAAC,aAAa;YAC1E,oBAAC,KAAK,IAAC,QAAQ,EAAE,iBAAiB,EAAE,KAAK,EAAE,SAAS,IAAI,EAAE,GAAI,CAClD;QAEd,oBAAC,WAAW,IAAC,KAAK,EAAC,UAAU;YAC3B,oBAAC,QAAQ,IAAC,KAAK,EAAE,QAAQ,EAAE,QAAQ,EAAE,gBAAgB,GAAI,CAC7C,CACV,CACP,CAAC;AACJ,CAAC"} \ No newline at end of file diff --git a/dfe-azurecostbackend-datasource/src/components/QueryEditor.tsx b/dfe-azurecostbackend-datasource/src/components/QueryEditor.tsx index 90307bb..6b5e3b1 100644 --- a/dfe-azurecostbackend-datasource/src/components/QueryEditor.tsx +++ b/dfe-azurecostbackend-datasource/src/components/QueryEditor.tsx @@ -1,5 +1,5 @@ -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'; @@ -7,6 +7,8 @@ import { MyDataSourceOptions, MyQuery } from '../types'; type Props = QueryEditorProps; export function QueryEditor({ query, onChange, onRunQuery }: Props) { + const [forecast, setForecast] = useState(query.forecast || false); + const onQueryTextChange = (event: ChangeEvent) => { onChange({ ...query, queryText: event.target.value }); // executes the query @@ -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 ( @@ -29,10 +39,13 @@ export function QueryEditor({ query, onChange, onRunQuery }: Props) { )} - + + + + + ); } - diff --git a/dfe-azurecostbackend-datasource/src/types.js.map b/dfe-azurecostbackend-datasource/src/types.js.map index 8f607e2..d4176f9 100644 --- a/dfe-azurecostbackend-datasource/src/types.js.map +++ b/dfe-azurecostbackend-datasource/src/types.js.map @@ -1 +1 @@ -{"version":3,"file":"types.js","sourceRoot":"","sources":["types.ts"],"names":[],"mappings":"AAOA,MAAM,CAAC,MAAM,aAAa,GAAqB;IAC7C,QAAQ,EAAE,GAAG;CACd,CAAC"} \ No newline at end of file +{"version":3,"file":"types.js","sourceRoot":"","sources":["types.ts"],"names":[],"mappings":"AAQA,MAAM,CAAC,MAAM,aAAa,GAAqB;IAC7C,QAAQ,EAAE,GAAG;CACd,CAAC"} \ No newline at end of file diff --git a/dfe-azurecostbackend-datasource/src/types.ts b/dfe-azurecostbackend-datasource/src/types.ts index c161fa8..3762f59 100644 --- a/dfe-azurecostbackend-datasource/src/types.ts +++ b/dfe-azurecostbackend-datasource/src/types.ts @@ -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 = {