diff --git a/client/app/assets/images/db-logos/newrelicgql.png b/client/app/assets/images/db-logos/newrelicgql.png new file mode 100644 index 0000000000..145df1ca08 Binary files /dev/null and b/client/app/assets/images/db-logos/newrelicgql.png differ diff --git a/redash/query_runner/newrelic.py b/redash/query_runner/newrelic.py new file mode 100644 index 0000000000..5a18fc332a --- /dev/null +++ b/redash/query_runner/newrelic.py @@ -0,0 +1,151 @@ +import json +import logging +from collections import OrderedDict +from urllib.parse import quote_from_bytes + +from redash.query_runner import * +from redash.utils import json_dumps, json_loads + + +logger = logging.getLogger(__name__) + +# TODO: make this more general and move into __init__.py +class ResultSet(object): + def __init__(self): + self.columns = OrderedDict() + self.rows = [] + + def add_row(self, row): + for key in row.keys(): + self.add_column(key) + + self.rows.append(row) + + def add_column(self, column, column_type=TYPE_STRING): + if column not in self.columns: + self.columns[column] = { + "name": column, + "type": column_type, + "friendly_name": column, + } + + def to_json(self): + return json_dumps({"rows": self.rows, "columns": list(self.columns.values())}) + + def merge(self, set): + self.rows = self.rows + set.rows + + +def pct_change(current, previous): + diff = current - previous + change = 0 + try: + if diff > 0: + change = (diff / current) * 100 + elif diff < 0: + diff = previous - current + change = -((diff / current) * 100) + except ZeroDivisionError: + return float("inf") + return float("{:.2f}".format(change)) + + +def parse_comparision(data): + nested_data = data.get("data").get("actor").get("account").get("nrql").get("results") + results = ResultSet() + data_dict = {} + for rows in nested_data: + try: + data_dict[rows["comparison"]] = rows["count"] + except (KeyError, IndexError) as err: + logger.error(f"Error adding data to dictionary, err: {err}") + + if len(data_dict) >= 1: + logger.info(f"Data Dictionary: {data_dict}") + data_dict['change'] = pct_change(data_dict.get('current'), data_dict.get('previous')) + + results.add_row(data_dict) + return results + + +def parse_count(data): + nested_data = data.get("data").get("actor").get("account").get("nrql").get("results")[0] + key_name = list(nested_data.keys())[0] + data_count = list(nested_data.values())[0] + results = ResultSet() + results.add_row({key_name: data_count}) + return results + + +class NewRelicGQL(BaseHTTPQueryRunner): + should_annotate_query = False + response_error = "NewRelic returned unexpected status code" + + @classmethod + def configuration_schema(cls): + return { + "type": "object", + "properties": { + "nr_account_id": {"type": "string", "title": "NewRelic Account ID"}, + "url": {"type": "string", "title": "API URL"}, + "token": {"type": "string", "title": "Security Token"}, + }, + "required": ["nr_account_id", "url", "token"], + "secret": ["token"], + "order": [ + "nr_account_id", + "url", + "token", + ] + } + + @classmethod + def name(cls): + return "NewRelic (GraphQL)" + + def test_connection(self): + nr_account_id = str("{}".format(self.configuration["nr_account_id"])) + qraphql_test_query = '{actor {account(id: ' + nr_account_id +') {nrql(query: "SELECT 1") {results}}}}' + testQuery = {"queryType": "count", "query": qraphql_test_query} + try: + response = self.run_query(query=json.dumps(testQuery), user="test") + except Exception as err: + logger.info(f"Raised Exception: {err}") + response = None + if response is None: + raise Exception("Failed describing objects.") + pass + + def run_query(self, query, user): + nr_url = "{}".format(self.configuration["url"]) + nr_token = "{}".format(self.configuration["token"]) + nr_account_id = "{}".format(self.configuration["nr_account_id"]) + headers = { + "Content-Type": "application/json", + "API-Key": "{}".format(nr_token), + } + + query = json_loads(query) + query_type = query.pop("queryType", "count") + nrql_query = query.pop("nrql", None) + if not nrql_query or not nr_account_id: + return None, None + + qraphql_query = '{actor {account(id: ' + nr_account_id +') {nrql(query: "' + nrql_query + '") {results}}}}' + payload = {"query":qraphql_query} + response, error = self.get_response(nr_url, http_method="post", data=json.dumps(payload), headers=headers) + + if error is not None: + return None, error + data = response.json() + + if query_type == "count": + results = parse_count(data) + + if query_type == "comparison": + results = parse_comparision(data) + + return results.to_json(), None + + +register(NewRelicGQL) diff --git a/redash/settings/__init__.py b/redash/settings/__init__.py index 4879ddb309..ef76e076ab 100644 --- a/redash/settings/__init__.py +++ b/redash/settings/__init__.py @@ -362,6 +362,7 @@ def email_server_is_configured(): "redash.query_runner.mssql_odbc", "redash.query_runner.memsql_ds", "redash.query_runner.mapd", + "redash.query_runner.newrelic", "redash.query_runner.jql", "redash.query_runner.google_analytics", "redash.query_runner.axibase_tsd", diff --git a/viz-lib/src/visualizations/counter/Editor/GeneralSettings.tsx b/viz-lib/src/visualizations/counter/Editor/GeneralSettings.tsx index 3f3155bee9..c6d6bb0dd4 100644 --- a/viz-lib/src/visualizations/counter/Editor/GeneralSettings.tsx +++ b/viz-lib/src/visualizations/counter/Editor/GeneralSettings.tsx @@ -80,6 +80,37 @@ export default function GeneralSettings({ options, data, visualizationName, onOp /> + {/* @ts-expect-error ts-migrate(2745) FIXME: This JSX tag's 'children' prop expects type 'never... Remove this comment to see the full error message */} +
+ +
+ + {/* @ts-expect-error ts-migrate(2745) FIXME: This JSX tag's 'children' prop expects type 'never... Remove this comment to see the full error message */} +
+ onOptionsChange({ percentRowNumber })} + /> +
+ {/* @ts-expect-error ts-migrate(2745) FIXME: This JSX tag's 'children' prop expects type 'never... Remove this comment to see the full error message */}
{/* @ts-expect-error ts-migrate(2745) FIXME: This JSX tag's 'children' prop expects type 'never... Remove this comment to see the full error message */} diff --git a/viz-lib/src/visualizations/counter/Renderer.tsx b/viz-lib/src/visualizations/counter/Renderer.tsx index 3c925e423e..62e3592ff0 100644 --- a/viz-lib/src/visualizations/counter/Renderer.tsx +++ b/viz-lib/src/visualizations/counter/Renderer.tsx @@ -59,18 +59,26 @@ export default function Renderer({ data, options, visualizationName }: any) { targetValueTooltip, // @ts-expect-error ts-migrate(2339) FIXME: Property 'counterLabel' does not exist on type '{}... Remove this comment to see the full error message counterLabel, + // @ts-expect-error ts-migrate(2339) FIXME: Property 'percentValue' does not exist on type '{}... Remove this comment to see the full error message + percentValue, + // @ts-expect-error ts-migrate(2339) FIXME: Property 'percentValueTooltip' does not exist on ty... Remove this comment to see the full error message + percentValueTooltip } = getCounterData(data.rows, options, visualizationName); return (
+ className={cx("counter-visualization-container", + {"trend-positive": showTrend && trendPositive, "trend-negative": showTrend && !trendPositive,} + )}> {/* @ts-expect-error ts-migrate(2322) FIXME: Type 'Dispatch>' is not assig... Remove this comment to see the full error message */}
{counterValue} + {percentValue && ( +
+ {percentValue}% +
+ )}
{targetValue && (
diff --git a/viz-lib/src/visualizations/counter/render.less b/viz-lib/src/visualizations/counter/render.less index db19ccb723..63d1cd7888 100755 --- a/viz-lib/src/visualizations/counter/render.less +++ b/viz-lib/src/visualizations/counter/render.less @@ -31,10 +31,16 @@ color: #ccc; } + .counter-visualization-percent { + font-size: 0.25em; + font-weight: bold; + } + .counter-visualization-label { font-size: 0.5em; display: block; } + } &.trend-positive .counter-visualization-value { @@ -44,4 +50,5 @@ &.trend-negative .counter-visualization-value { color: #d9534f; } + } diff --git a/viz-lib/src/visualizations/counter/utils.test.ts b/viz-lib/src/visualizations/counter/utils.test.ts index 65a7fc34f4..6105ae618f 100644 --- a/viz-lib/src/visualizations/counter/utils.test.ts +++ b/viz-lib/src/visualizations/counter/utils.test.ts @@ -16,6 +16,8 @@ describe("Visualizations -> Counter -> Utils", () => { counterLabel: "Visualisation Name", counterValue: "", targetValue: null, + percentValue: null, + percentValueTooltip: "", counterValueTooltip: "", targetValueTooltip: "", }, diff --git a/viz-lib/src/visualizations/counter/utils.ts b/viz-lib/src/visualizations/counter/utils.ts index 1f433fffe7..b64ef95f94 100644 --- a/viz-lib/src/visualizations/counter/utils.ts +++ b/viz-lib/src/visualizations/counter/utils.ts @@ -73,6 +73,7 @@ export function getCounterData(rows: any, options: any, visualizationName: any) if (rowsCount > 0 || options.countRow) { const counterColName = options.counterColName; const targetColName = options.targetColName; + const percentColName = options.percentColName; // @ts-expect-error ts-migrate(2339) FIXME: Property 'counterLabel' does not exist on type '{}... Remove this comment to see the full error message result.counterLabel = options.counterLabel || visualizationName; @@ -108,10 +109,26 @@ export function getCounterData(rows: any, options: any, visualizationName: any) result.targetValue = null; } + if (percentColName) { + const percentRowNumber = getRowNumber(options.percentRowNumber, rowsCount); + // @ts-expect-error ts-migrate(2339) FIXME: Property 'percentValue' does not exist on type '{}'... Remove this comment to see the full error message + result.percentValue = rows[percentRowNumber][percentColName]; + // @ts-expect-error ts-migrate(2339) FIXME: Property 'counterValue' does not exist on type '{}... Remove this comment to see the full error message + if (Number.isFinite(result.percentValue)) { + // @ts-expect-error ts-migrate(2339) FIXME: Property 'counterValue' does not exist on type '{}... Remove this comment to see the full error message + const percentDelta = result.percentValue; + } + } else { + // @ts-expect-error ts-migrate(2339) FIXME: Property 'targetValue' does not exist on type '{}'... Remove this comment to see the full error message + result.percentValue = null; + } + // @ts-expect-error ts-migrate(2339) FIXME: Property 'counterValueTooltip' does not exist on t... Remove this comment to see the full error message result.counterValueTooltip = formatTooltip(result.counterValue, options.tooltipFormat); // @ts-expect-error ts-migrate(2339) FIXME: Property 'targetValueTooltip' does not exist on ty... Remove this comment to see the full error message result.targetValueTooltip = formatTooltip(result.targetValue, options.tooltipFormat); + // @ts-expect-error ts-migrate(2339) FIXME: Property 'percentValueTooltip' does not exist on ty... Remove this comment to see the full error message + result.percentValueTooltip = formatTooltip(result.percentValue, options.tooltipFormat); // @ts-expect-error ts-migrate(2339) FIXME: Property 'counterValue' does not exist on type '{}... Remove this comment to see the full error message result.counterValue = formatValue(result.counterValue, options);