Skip to content
This repository was archived by the owner on Dec 24, 2024. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
109 changes: 48 additions & 61 deletions src/components/ConfigEditor.tsx
Original file line number Diff line number Diff line change
@@ -1,67 +1,54 @@
import React, { ChangeEvent } from 'react';
import { InlineField, Input, SecretInput } from '@grafana/ui';
import React from 'react';
import { DataSourceHttpSettings, Field, FieldSet, InlineField, Input, SecretInput, SecretTextArea } from '@grafana/ui';
import type { NullifyDataSourceOptions } from '../types';
import { DataSourcePluginOptionsEditorProps } from '@grafana/data';
import { MyDataSourceOptions, MySecureJsonData } from '../types';
import { useChangeOptions } from '../useChangeOptions';
import { useChangeSecureOptions } from '../useChangeSecureOptions';
import { useResetSecureOptions } from '../useResetSecureOptions';

interface Props extends DataSourcePluginOptionsEditorProps<MyDataSourceOptions> {}
interface Props extends DataSourcePluginOptionsEditorProps<NullifyDataSourceOptions> {}

export function ConfigEditor(props: Props) {
const { onOptionsChange, options } = props;
const onPathChange = (event: ChangeEvent<HTMLInputElement>) => {
const jsonData = {
...options.jsonData,
path: event.target.value,
};
onOptionsChange({ ...options, jsonData });
};

// Secure field (only sent to the backend)
const onAPIKeyChange = (event: ChangeEvent<HTMLInputElement>) => {
onOptionsChange({
...options,
secureJsonData: {
apiKey: event.target.value,
},
});
};

const onResetAPIKey = () => {
onOptionsChange({
...options,
secureJsonFields: {
...options.secureJsonFields,
apiKey: false,
},
secureJsonData: {
...options.secureJsonData,
apiKey: '',
},
});
};

const { jsonData, secureJsonFields } = options;
const secureJsonData = (options.secureJsonData || {}) as MySecureJsonData;
export const ConfigEditor: React.FC<Props> = (props: any) => {
const { jsonData, secureJsonData, secureJsonFields } = props.options;
const onUrlChange = useChangeOptions(props, 'apiHostUrl');
const onGithubOwnerIdChange = useChangeOptions(props, 'githubOwnerId');
const onApiKeyChange = useChangeSecureOptions(props, 'apiKey');
const onResetApiKey = useResetSecureOptions(props, 'apiKey');

return (
<div className="gf-form-group">
<InlineField label="Path" labelWidth={12}>
<Input
onChange={onPathChange}
value={jsonData.path || ''}
placeholder="json field returned to frontend"
width={40}
/>
</InlineField>
<InlineField label="API Key" labelWidth={12}>
<SecretInput
isConfigured={(secureJsonFields && secureJsonFields.apiKey) as boolean}
value={secureJsonData.apiKey || ''}
placeholder="secure json field (backend only)"
width={40}
onReset={onResetAPIKey}
onChange={onAPIKeyChange}
/>
</InlineField>
</div>
<>
<FieldSet label="Nullify API Settings">
<Field
label="Nullify API Host URL"
description="URL endpoint host name for the Nullify API. E.g. https://api.YOUR_COMPANY_NAME.nullify.ai"
>
<Input
onChange={onUrlChange}
placeholder="https://api.YOUR_COMPANY_NAME.nullify.ai"
value={jsonData?.apiHostUrl ?? ''}
/>
</Field>
<Field
label="GitHub Owner ID"
description="Globally unique GitHub ID for individual/organization accounts. ID available at: https://api.github.com/users/YOUR_GITHUB_USERNAME"
>
<Input onChange={onGithubOwnerIdChange} placeholder="1234" value={jsonData?.githubOwnerId ?? ''} />
</Field>
<Field
label="Nullify API Key"
description="API key to access Nullify API endpoints. This key is a securely stored secret and used by the backend only. API Docs: https://docs.nullify.ai/api-reference/nullify-api"
>
<SecretTextArea
isConfigured={Boolean(secureJsonFields.apiKey)}
value={secureJsonData?.apiKey || ''}
placeholder="eyJ..."
cols={200}
rows={10}
onReset={onResetApiKey}
onChange={onApiKeyChange}
/>
</Field>
</FieldSet>
</>
);
}
};
43 changes: 19 additions & 24 deletions src/components/QueryEditor.tsx
Original file line number Diff line number Diff line change
@@ -1,32 +1,27 @@
import React, { ChangeEvent } from 'react';
import { InlineField, Input } from '@grafana/ui';
import defaults from 'lodash/defaults';

import React, { ChangeEvent, PureComponent } from 'react';
import { HorizontalGroup } from '@grafana/ui';
import { QueryEditorProps } from '@grafana/data';
import { DataSource } from '../datasource';
import { MyDataSourceOptions, MyQuery } from '../types';
import { NullifySastDataSource } from '../datasource';
import { defaultQuery, NullifyDataSourceOptions, MyQuery } from '../types';

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

export function QueryEditor({ query, onChange, onRunQuery }: Props) {
const onQueryTextChange = (event: ChangeEvent<HTMLInputElement>) => {
export class QueryEditor extends PureComponent<Props> {
onQueryTextChange = (event: ChangeEvent<HTMLInputElement>) => {
const { onChange, query } = this.props;
onChange({ ...query, queryText: event.target.value });
};

const onConstantChange = (event: ChangeEvent<HTMLInputElement>) => {
onChange({ ...query, constant: parseFloat(event.target.value) });
// executes the query
onRunQuery();
};

const { queryText, constant } = query;
render() {
const query = defaults(this.props.query, defaultQuery);
const { queryText } = query;

return (
<div className="gf-form">
<InlineField label="Constant">
<Input onChange={onConstantChange} value={constant} width={8} type="number" step="0.1" />
</InlineField>
<InlineField label="Query Text" labelWidth={16} tooltip="Not used yet">
<Input onChange={onQueryTextChange} value={queryText || ''} />
</InlineField>
</div>
);
return (
<HorizontalGroup>
Queries are not currently supported.
</HorizontalGroup>
);
}
}
154 changes: 116 additions & 38 deletions src/datasource.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,61 +7,139 @@ import {
MutableDataFrame,
} from '@grafana/data';

import { defaultQuery, MyDataSourceOptions, MyQuery } from './types';
import _ from 'lodash';
import defaults from 'lodash/defaults';
import { getBackendSrv, isFetchError } from '@grafana/runtime';
import { lastValueFrom } from 'rxjs';

export class DataSource extends DataSourceApi<MyQuery, MyDataSourceOptions> {
constructor(instanceSettings: DataSourceInstanceSettings<MyDataSourceOptions>) {
import { defaultQuery, MyQuery, NullifyDataSourceOptions, NullifySastSummaryApiResponse } from './types';


const prepend_severity_idx = (severity: string) => {
severity = severity.toUpperCase();
switch (severity) {
case 'CRITICAL':
return `S1 - ${severity}`;
case 'HIGH':
return `S2 - ${severity}`;
case 'MEDIUM':
return `S3 - ${severity}`;
case 'LOW':
return `S4 - ${severity}`;
default:
return severity;
}
};

export class NullifySastDataSource extends DataSourceApi<MyQuery, NullifyDataSourceOptions> {
instanceUrl?: string;
apiHostUrl: string;
githubOwnerId: number;

constructor(instanceSettings: DataSourceInstanceSettings<NullifyDataSourceOptions>) {
super(instanceSettings);
this.instanceUrl = instanceSettings.url;
this.githubOwnerId = instanceSettings.jsonData.githubOwnerId!;
this.apiHostUrl = instanceSettings.jsonData.apiHostUrl;
}

async query(options: DataQueryRequest<MyQuery>): Promise<DataQueryResponse> {
const { range } = options;
const from = range!.from.valueOf();
const to = range!.to.valueOf();

// Return a constant for each query.
const data = options.targets.map((target) => {
const promises = options.targets.map(async (target) => {
const query = defaults(target, defaultQuery);
const frame = new MutableDataFrame({
const response = await this.request();

const datapoints: NullifySastSummaryApiResponse = response.data as unknown as NullifySastSummaryApiResponse;
if (datapoints === undefined || !('vulnerabilities' in datapoints)) {
throw new Error('Remote endpoint reponse does not contain "events" property.');
}

let ids: string[] = [];
let formatted_severities: string[] = [];
let severities: string[] = [];
let languages: string[] = [];
let filePaths: string[] = [];
let isAutoFixables: boolean[] = [];
let isAllowlisteds: boolean[] = [];
let latests: boolean[] = [];
for (const vuln of datapoints.vulnerabilities) {
ids.push(vuln.id);
formatted_severities.push(prepend_severity_idx(vuln.severity));
severities.push(vuln.severity);
languages.push(vuln.language);
filePaths.push(vuln.filePath);
isAutoFixables.push(vuln.isAutoFixable);
isAllowlisteds.push(vuln.isAllowlisted);
latests.push(vuln.latest);
}

return new MutableDataFrame({
refId: query.refId,
fields: [
{ name: 'time', type: FieldType.time },
{ name: 'value', type: FieldType.number },
{ name: 'id', type: FieldType.string, values: ids },
{ name: 'formatted_severity', type: FieldType.string, values: formatted_severities },
{ name: 'severity', type: FieldType.string, values: severities },
{ name: 'language', type: FieldType.string, values: languages },
{ name: 'filePath', type: FieldType.string, values: filePaths },
{ name: 'isAutoFixable', type: FieldType.boolean, values: isAutoFixables },
{ name: 'isAllowlisted', type: FieldType.boolean, values: isAllowlisteds },
{ name: 'latest', type: FieldType.boolean, values: latests },
],
});
// duration of the time range, in milliseconds.
const duration = to - from;
});

// step determines how close in time (ms) the points will be to each other.
const step = duration / 1000;
return Promise.all(promises).then((data) => ({ data }));
}

for (let t = 0; t < duration; t += step) {
frame.add({ time: from + t, value: Math.sin((2 * Math.PI * t) / duration) });
}
return frame;

// return new MutableDataFrame({
// refId: target.refId,
// fields: [
// { name: 'Time', values: [from, to], type: FieldType.time },
// {
// name: 'Value',
// values: [target.constant, target.constant],
// type: FieldType.number,
// },
// ],
// });
// TODO(jqphu): only one path is supported now, sca/events
async request() {
const response = getBackendSrv().fetch<NullifySastSummaryApiResponse>({
url: `${this.instanceUrl}/grafana_proxy/sast/summary?githubOwnerId=${this.githubOwnerId}`,
});

return { data };
return await lastValueFrom(response);
}

filterQuery(query: MyQuery): boolean {
if (query.hide) {
return false;
}
return true;
}

/**
* Checks whether we can connect to the API.
*/
async testDatasource() {
// Implement a health check for your data source.
return {
status: 'success',
message: 'Success',
};
const defaultErrorMessage = 'Cannot connect to API';
console.log('Starting test');

try {
const response = await this.request();
if (response.status === 200) {
return {
status: 'success',
message: 'Success',
};
} else {
return {
status: 'error',
message: response.statusText ? response.statusText : defaultErrorMessage,
};
}
} catch (err) {
let message = '';
if (_.isString(err)) {
message = err;
} else if (isFetchError(err)) {
message = 'Fetch error: ' + (err.statusText ? err.statusText : defaultErrorMessage);
if (err.data && err.data.error && err.data.error.code) {
message += ': ' + err.data.error.code + '. ' + err.data.error.message;
}
}
return {
status: 'error',
message,
};
}
}
}
6 changes: 3 additions & 3 deletions src/module.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { DataSourcePlugin } from '@grafana/data';
import { DataSource } from './datasource';
import { NullifySastDataSource } from './datasource';
import { ConfigEditor } from './components/ConfigEditor';
import { QueryEditor } from './components/QueryEditor';
import { MyQuery, MyDataSourceOptions } from './types';
import { MyQuery, NullifyDataSourceOptions } from './types';

export const plugin = new DataSourcePlugin<DataSource, MyQuery, MyDataSourceOptions>(DataSource)
export const plugin = new DataSourcePlugin<NullifySastDataSource, MyQuery, NullifyDataSourceOptions>(NullifySastDataSource)
.setConfigEditor(ConfigEditor)
.setQueryEditor(QueryEditor);
12 changes: 12 additions & 0 deletions src/plugin.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,18 @@
"version": "%VERSION%",
"updated": "%TODAY%"
},
"routes": [
{
"path": "grafana_proxy",
"url": "{{ .JsonData.apiHostUrl }}",
"headers": [
{
"name": "Authorization",
"content": "Bearer {{ .SecureJsonData.apiKey }}"
}
]
}
],
"dependencies": {
"grafanaDependency": ">=10.3.1",
"plugins": []
Expand Down
Loading