Commit ae09ccbf by Andrej Ocenas Committed by GitHub

Trace UI demo (#20297)

* Add integration with Jeager
Add Jaeger datasource and modify derived fields in loki to allow for opening a trace in Jager in separate split.
Modifies build so that this branch docker images are pushed to docker hub
Add a traceui dir with docker-compose and provision files for demoing.:wq

* Enable docker logger plugin to send logs to loki

* Add placeholder zipkin datasource

* Fixed rebase issues, added enhanceDataFrame to non-legacy code path

* Trace selector for jaeger query field

* Fix logs default mode for Loki

* Fix loading jaeger query field services on split

* Updated grafana image in traceui/compose file

* Fix prettier error

* Hide behind feature flag, clean up unused code.

* Fix tests

* Fix tests

* Cleanup code and review feedback

* Remove traceui directory

* Remove circle build changes

* Fix feature toggles object

* Fix merge issues

* Fix some null errors

* Fix test after strict null changes

* Review feedback fixes

* Fix toggle name

Co-authored-by: David Kaltschmidt <david.kaltschmidt@gmail.com>
parent b6f73e35
......@@ -26,7 +26,11 @@ export namespace dateMath {
* @param roundUp See parseDateMath function.
* @param timezone Only string 'utc' is acceptable here, for anything else, local timezone is used.
*/
export function parse(text: string | DateTime | Date, roundUp?: boolean, timezone?: TimeZone): DateTime | undefined {
export function parse(
text?: string | DateTime | Date | null,
roundUp?: boolean,
timezone?: TimeZone
): DateTime | undefined {
if (!text) {
return undefined;
}
......
......@@ -27,6 +27,9 @@ export interface DataLink {
// 1: If exists, handle click directly
// Not saved in JSON/DTO
onClick?: (event: DataLinkClickEvent) => void;
// At the moment this is used for derived fields for metadata about internal linking.
meta?: any;
}
export type LinkTarget = '_blank' | '_self';
......
......@@ -115,6 +115,7 @@ export interface DataSourcePluginMeta<T extends KeyValue = {}> extends PluginMet
logs?: boolean;
annotations?: boolean;
alerting?: boolean;
tracing?: boolean;
mixed?: boolean;
hasQueryHelp?: boolean;
category?: string;
......@@ -316,6 +317,7 @@ export enum DataSourceStatus {
export enum ExploreMode {
Logs = 'Logs',
Metrics = 'Metrics',
Tracing = 'Tracing',
}
export interface ExploreQueryFieldProps<
......
......@@ -19,6 +19,7 @@ interface FeatureToggles {
newEdit: boolean;
meta: boolean;
newVariables: boolean;
tracingIntegration: boolean;
}
interface LicenseInfo {
......@@ -71,6 +72,7 @@ export class GrafanaBootConfig {
newEdit: false,
meta: false,
newVariables: false,
tracingIntegration: false,
};
licenseInfo: LicenseInfo = {} as LicenseInfo;
phantomJSRenderer = false;
......
......@@ -24,7 +24,7 @@ import { LogDetailsRow } from './LogDetailsRow';
type FieldDef = {
key: string;
value: string;
links?: string[];
links?: Array<LinkModel<Field>>;
fieldIndex?: number;
};
......@@ -99,7 +99,7 @@ class UnThemedLogDetails extends PureComponent<Props> {
return {
key: field.name,
value: field.values.get(row.rowIndex).toString(),
links: links.map(link => link.href),
links: links,
fieldIndex: field.index,
};
})
......
import React, { PureComponent } from 'react';
import { css, cx } from 'emotion';
import { LogLabelStatsModel, GrafanaTheme } from '@grafana/data';
import { Field, LinkModel, LogLabelStatsModel, GrafanaTheme } from '@grafana/data';
import { Themeable } from '../../types/theme';
import { withTheme } from '../../themes/index';
......@@ -9,6 +9,7 @@ import { stylesFactory } from '../../themes/stylesFactory';
//Components
import { LogLabelStats } from './LogLabelStats';
import { LinkButton } from '../Button/Button';
export interface Props extends Themeable {
parsedValue: string;
......@@ -16,7 +17,7 @@ export interface Props extends Themeable {
isLabel?: boolean;
onClickFilterLabel?: (key: string, value: string) => void;
onClickFilterOutLabel?: (key: string, value: string) => void;
links?: string[];
links?: Array<LinkModel<Field>>;
getStats: () => LogLabelStatsModel[] | null;
}
......@@ -122,11 +123,27 @@ class UnThemedLogDetailsRow extends PureComponent<Props, State> {
{links &&
links.map(link => {
return (
<span key={link}>
&nbsp;
<a href={link} target={'_blank'}>
<i className={'fa fa-external-link'} />
</a>
<span key={link.href}>
<>
&nbsp;
<LinkButton
variant={'transparent'}
size={'sm'}
icon={cx('fa', link.onClick ? 'fa-list' : 'fa-external-link')}
href={link.href}
target={'_blank'}
onClick={
link.onClick
? event => {
if (!(event.ctrlKey || event.metaKey || event.shiftKey) && link.onClick) {
event.preventDefault();
link.onClick(event);
}
}
: undefined
}
/>
</>
</span>
);
})}
......
......@@ -92,7 +92,11 @@ func pluginScenario(desc string, t *testing.T, fn func()) {
_, err := sec.NewKey("path", "testdata/test-app")
So(err, ShouldBeNil)
pm := &PluginManager{}
pm := &PluginManager{
Cfg: &setting.Cfg{
FeatureToggles: map[string]bool{},
},
}
err = pm.Init()
So(err, ShouldBeNil)
......
......@@ -18,7 +18,11 @@ func TestPluginDashboards(t *testing.T) {
_, err := sec.NewKey("path", "testdata/test-app")
So(err, ShouldBeNil)
pm := &PluginManager{}
pm := &PluginManager{
Cfg: &setting.Cfg{
FeatureToggles: map[string]bool{},
},
}
err = pm.Init()
So(err, ShouldBeNil)
......
......@@ -23,6 +23,7 @@ type DataSourcePlugin struct {
Explore bool `json:"explore"`
Table bool `json:"tables"`
Logs bool `json:"logs"`
Tracing bool `json:"tracing"`
QueryOptions map[string]bool `json:"queryOptions,omitempty"`
BuiltIn bool `json:"builtIn,omitempty"`
Mixed bool `json:"mixed,omitempty"`
......
......@@ -42,10 +42,12 @@ type PluginScanner struct {
pluginPath string
errors []error
backendPluginManager backendplugin.Manager
cfg *setting.Cfg
}
type PluginManager struct {
BackendPluginManager backendplugin.Manager `inject:""`
Cfg *setting.Cfg `inject:""`
log log.Logger
}
......@@ -164,6 +166,7 @@ func (pm *PluginManager) scan(pluginDir string) error {
scanner := &PluginScanner{
pluginPath: pluginDir,
backendPluginManager: pm.BackendPluginManager,
cfg: pm.Cfg,
}
if err := util.Walk(pluginDir, true, true, scanner.walker); err != nil {
......@@ -213,6 +216,14 @@ func (scanner *PluginScanner) walker(currentPath string, f os.FileInfo, err erro
return nil
}
if !scanner.cfg.FeatureToggles["tracingIntegration"] {
// Do not load tracing datasources if
prefix := path.Join(setting.StaticRootPath, "app/plugins/datasource")
if strings.Contains(currentPath, path.Join(prefix, "jaeger")) || strings.Contains(currentPath, path.Join(prefix, "zipkin")) {
return nil
}
}
if f.Name() == "plugin.json" {
err := scanner.loadPluginJson(currentPath)
if err != nil {
......
......@@ -15,7 +15,11 @@ func TestPluginScans(t *testing.T) {
setting.StaticRootPath, _ = filepath.Abs("../../public/")
setting.Raw = ini.Empty()
pm := &PluginManager{}
pm := &PluginManager{
Cfg: &setting.Cfg{
FeatureToggles: map[string]bool{},
},
}
err := pm.Init()
So(err, ShouldBeNil)
......@@ -34,7 +38,11 @@ func TestPluginScans(t *testing.T) {
_, err = sec.NewKey("path", "testdata/test-app")
So(err, ShouldBeNil)
pm := &PluginManager{}
pm := &PluginManager{
Cfg: &setting.Cfg{
FeatureToggles: map[string]bool{},
},
}
err = pm.Init()
So(err, ShouldBeNil)
......
......@@ -281,6 +281,7 @@ type Cfg struct {
ApiKeyMaxSecondsToLive int64
// Use to enable new features which may still be in alpha/beta stage.
FeatureToggles map[string]bool
}
......
......@@ -66,17 +66,17 @@ export interface GetExploreUrlArguments {
datasourceSrv: DataSourceSrv;
timeSrv: TimeSrv;
}
export async function getExploreUrl(args: GetExploreUrlArguments) {
export async function getExploreUrl(args: GetExploreUrlArguments): Promise<string | undefined> {
const { panel, panelTargets, panelDatasource, datasourceSrv, timeSrv } = args;
let exploreDatasource = panelDatasource;
let exploreTargets: DataQuery[] = panelTargets;
let url: string;
let url: string | undefined;
// Mixed datasources need to choose only one datasource
if (panelDatasource.meta.id === 'mixed' && exploreTargets) {
if (panelDatasource.meta?.id === 'mixed' && exploreTargets) {
// Find first explore datasource among targets
for (const t of exploreTargets) {
const datasource = await datasourceSrv.get(t.datasource);
const datasource = await datasourceSrv.get(t.datasource || undefined);
if (datasource) {
exploreDatasource = datasource;
exploreTargets = panelTargets.filter(t => t.datasource === datasource.name);
......@@ -183,7 +183,7 @@ enum ParseUiStateIndex {
Strategy = 3,
}
export const safeParseJson = (text: string) => {
export const safeParseJson = (text?: string): any | undefined => {
if (!text) {
return;
}
......@@ -365,7 +365,7 @@ export function clearHistory(datasourceId: string) {
}
export const getQueryKeys = (queries: DataQuery[], datasourceInstance: DataSourceApi): string[] => {
const queryKeys = queries.reduce((newQueryKeys, query, index) => {
const queryKeys = queries.reduce<string[]>((newQueryKeys, query, index) => {
const primaryKey = datasourceInstance && datasourceInstance.name ? datasourceInstance.name : query.key;
return newQueryKeys.concat(`${primaryKey}-${index}`);
}, []);
......@@ -381,7 +381,7 @@ export const getTimeRange = (timeZone: TimeZone, rawRange: RawTimeRange): TimeRa
};
};
const parseRawTime = (value: any): TimeFragment => {
const parseRawTime = (value: any): TimeFragment | null => {
if (value === null) {
return null;
}
......@@ -442,7 +442,7 @@ export const getValueWithRefId = (value?: any): any => {
return undefined;
};
export const getFirstQueryErrorWithoutRefId = (errors?: DataQueryError[]) => {
export const getFirstQueryErrorWithoutRefId = (errors?: DataQueryError[]): DataQueryError | undefined => {
if (!errors) {
return undefined;
}
......@@ -530,7 +530,7 @@ export const stopQueryState = (querySubscription: Unsubscribable) => {
}
};
export function getIntervals(range: TimeRange, lowLimit: string, resolution: number): IntervalValues {
export function getIntervals(range: TimeRange, lowLimit: string, resolution?: number): IntervalValues {
if (!resolution) {
return { interval: '1s', intervalMs: 1000 };
}
......@@ -542,7 +542,7 @@ export function deduplicateLogRowsById(rows: LogRowModel[]) {
return _.uniqBy(rows, 'uid');
}
export const getFirstNonQueryRowSpecificError = (queryErrors?: DataQueryError[]) => {
export const getFirstNonQueryRowSpecificError = (queryErrors?: DataQueryError[]): DataQueryError | undefined => {
const refId = getValueWithRefId(queryErrors);
return refId ? null : getFirstQueryErrorWithoutRefId(queryErrors);
return refId ? undefined : getFirstQueryErrorWithoutRefId(queryErrors);
};
......@@ -88,7 +88,7 @@ export const parseBody = (options: BackendSrvRequest, isAppJson: boolean) => {
return isAppJson ? JSON.stringify(options.data) : new URLSearchParams(options.data);
};
function serializeParams(data: Record<string, any>): string {
export function serializeParams(data: Record<string, any>): string {
return Object.keys(data)
.map(key => {
const value = data[key];
......
import _ from 'lodash';
import { DataQuery } from '@grafana/data';
export const getNextRefIdChar = (queries: DataQuery[]): string => {
export const getNextRefIdChar = (queries: DataQuery[]): string | undefined => {
const letters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
return _.find(letters, refId => {
......
import { DataSourcePluginMeta, PluginType } from '@grafana/data';
import { DataSourcePluginCategory } from 'app/types';
import { config } from '@grafana/runtime';
export function buildCategories(plugins: DataSourcePluginMeta[]): DataSourcePluginCategory[] {
const categories: DataSourcePluginCategory[] = [
{ id: 'tsdb', title: 'Time series databases', plugins: [] },
{ id: 'logging', title: 'Logging & document databases', plugins: [] },
config.featureToggles.tracingIntegration ? { id: 'tracing', title: 'Distributed tracing', plugins: [] } : null,
{ id: 'sql', title: 'SQL', plugins: [] },
{ id: 'cloud', title: 'Cloud', plugins: [] },
{ id: 'enterprise', title: 'Enterprise plugins', plugins: [] },
{ id: 'other', title: 'Others', plugins: [] },
];
].filter(item => item);
const categoryIndex: Record<string, DataSourcePluginCategory> = {};
const pluginIndex: Record<string, DataSourcePluginMeta> = {};
......@@ -66,6 +68,7 @@ function sortPlugins(plugins: DataSourcePluginMeta[]) {
graphite: 95,
loki: 90,
mysql: 80,
jaeger: 100,
postgres: 79,
gcloud: -1,
};
......
......@@ -148,13 +148,12 @@ describe('Explore', () => {
it('should filter out a query-row-specific error when looking for non-query-row-specific errors', async () => {
const queryErrors = setupErrors(true);
const queryError = getFirstNonQueryRowSpecificError(queryErrors);
expect(queryError).toBeNull();
expect(queryError).toBeUndefined();
});
it('should not filter out a generic error when looking for non-query-row-specific errors', async () => {
const queryErrors = setupErrors();
const queryError = getFirstNonQueryRowSpecificError(queryErrors);
expect(queryError).not.toBeNull();
expect(queryError).toEqual({
message: 'Error message',
status: '400',
......
......@@ -20,33 +20,33 @@ import {
changeSize,
initializeExplore,
modifyQueries,
refreshExplore,
scanStart,
setQueries,
refreshExplore,
updateTimeRange,
toggleGraph,
addQueryRow,
updateTimeRange,
} from './state/actions';
// Types
import {
AbsoluteTimeRange,
DataQuery,
DataSourceApi,
GraphSeriesXY,
PanelData,
RawTimeRange,
TimeRange,
GraphSeriesXY,
TimeZone,
AbsoluteTimeRange,
LoadingState,
ExploreMode,
} from '@grafana/data';
import { ExploreItemState, ExploreUrlState, ExploreId, ExploreUpdateState, ExploreUIState } from 'app/types/explore';
import { ExploreId, ExploreItemState, ExploreUIState, ExploreUpdateState, ExploreUrlState } from 'app/types/explore';
import { StoreState } from 'app/types';
import {
ensureQueries,
DEFAULT_RANGE,
DEFAULT_UI_STATE,
ensureQueries,
getTimeRangeFromUrl,
getTimeRange,
lastUsedDatasourceKeyForOrgId,
......@@ -70,6 +70,18 @@ const getStyles = stylesFactory(() => {
button: css`
margin: 1em 4px 0 0;
`,
// Utility class for iframe parents so that we can show iframe content with reasonable height instead of squished
// or some random explicit height.
fullHeight: css`
label: fullHeight;
height: 100%;
`,
iframe: css`
label: iframe;
border: none;
width: 100%;
height: 100%;
`,
};
});
......@@ -328,14 +340,14 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
</button>
</div>
<ErrorContainer queryError={queryError} />
<AutoSizer onResize={this.onResize} disableHeight>
<AutoSizer className={styles.fullHeight} onResize={this.onResize} disableHeight>
{({ width }) => {
if (width === 0) {
return null;
}
return (
<main className={`m-t-2 ${styles.logsMain}`} style={{ width }}>
<main className={cx('m-t-2', styles.logsMain, styles.fullHeight)} style={{ width }}>
<ErrorBoundaryAlert>
{showStartPage && StartPage && (
<div className={'grafana-info-box grafana-info-box--max-lg'}>
......@@ -379,6 +391,18 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
onStopScanning={this.onStopScanning}
/>
)}
{mode === ExploreMode.Tracing && (
<div className={styles.fullHeight}>
{queryResponse &&
!!queryResponse.series.length &&
queryResponse.series[0].fields[0].values.get(0) && (
<iframe
className={styles.iframe}
src={queryResponse.series[0].fields[0].values.get(0)}
/>
)}
</div>
)}
</>
)}
{showRichHistory && (
......@@ -448,7 +472,7 @@ function mapStateToProps(state: StoreState, { exploreId }: ExploreProps): Partia
newMode = supportedModes[0];
}
} else {
newMode = [ExploreMode.Metrics, ExploreMode.Logs].includes(urlMode) ? urlMode : undefined;
newMode = [ExploreMode.Metrics, ExploreMode.Logs, ExploreMode.Tracing].includes(urlMode) ? urlMode : undefined;
}
const initialUI = ui || DEFAULT_UI_STATE;
......
......@@ -366,7 +366,7 @@ const mapStateToProps = (state: StoreState, { exploreId }: OwnProps): StateProps
containerWidth,
} = exploreItem;
const hasLiveOption = datasourceInstance?.meta?.streaming && mode === ExploreMode.Logs;
const hasLiveOption = !!(datasourceInstance?.meta?.streaming && mode === ExploreMode.Logs);
return {
datasourceMissing,
......
......@@ -14,12 +14,13 @@ import {
TimeRange,
LogsMetaItem,
GraphSeriesXY,
Field,
} from '@grafana/data';
import { ExploreId, ExploreItemState } from 'app/types/explore';
import { StoreState } from 'app/types';
import { changeDedupStrategy, updateTimeRange } from './state/actions';
import { changeDedupStrategy, updateTimeRange, splitOpen } from './state/actions';
import { toggleLogLevelAction } from 'app/features/explore/state/actionTypes';
import { deduplicatedRowsSelector } from 'app/features/explore/state/selectors';
import { getTimeZone } from '../profile/state/selectors';
......@@ -57,6 +58,7 @@ interface LogsContainerProps {
syncedTimes: boolean;
absoluteRange: AbsoluteTimeRange;
isPaused: boolean;
splitOpen: typeof splitOpen;
}
export class LogsContainer extends PureComponent<LogsContainerProps> {
......@@ -87,6 +89,30 @@ export class LogsContainer extends PureComponent<LogsContainerProps> {
return [];
};
/**
* Get links from the filed of a dataframe that was given to as and in addition check if there is associated
* metadata with datasource in which case we will add onClick to open the link in new split window. This assumes
* that we just supply datasource name and field value and Explore split window will know how to render that
* appropriately. This is for example used for transition from log with traceId to trace datasource to show that
* trace.
* @param field
* @param rowIndex
*/
getFieldLinks = (field: Field, rowIndex: number) => {
const data = getLinksFromLogsField(field, rowIndex);
return data.map(d => {
if (d.link.meta?.datasourceName) {
return {
...d.linkModel,
onClick: () => {
this.props.splitOpen(d.link.meta.datasourceName, field.values.get(rowIndex));
},
};
}
return d.linkModel;
});
};
render() {
const {
loading,
......@@ -149,7 +175,7 @@ export class LogsContainer extends PureComponent<LogsContainerProps> {
scanRange={range.raw}
width={width}
getRowContext={this.getLogRowContext}
getFieldLinks={getLinksFromLogsField}
getFieldLinks={this.getFieldLinks}
/>
</Collapse>
</LogsCrossFadeTransition>
......@@ -199,6 +225,7 @@ const mapDispatchToProps = {
changeDedupStrategy,
toggleLogLevelAction,
updateTimeRange,
splitOpen,
};
export default hot(module)(connect(mapStateToProps, mapDispatchToProps)(LogsContainer));
......@@ -46,7 +46,7 @@ export class TableContainer extends PureComponent<TableContainerProps> {
return (
<Collapse label="Table" loading={loading} collapsible isOpen={showingTable} onToggle={this.onClickTableButton}>
{hasTableResult ? (
<Table data={tableResult} width={tableWidth} height={height} onCellClick={onClickCell} />
<Table data={tableResult!} width={tableWidth} height={height} onCellClick={onClickCell} />
) : (
<MetaInfoText metaItems={[{ value: '0 series returned' }]} />
)}
......
......@@ -5,9 +5,9 @@ import { connect } from 'react-redux';
import { StoreState } from 'app/types';
import { ExploreId } from 'app/types/explore';
import Explore from './Explore';
import { CustomScrollbar, ErrorBoundaryAlert } from '@grafana/ui';
import { resetExploreAction } from './state/actionTypes';
import Explore from './Explore';
interface WrapperProps {
split: boolean;
......@@ -25,7 +25,7 @@ export class Wrapper extends Component<WrapperProps> {
return (
<div className="page-scrollbar-wrapper">
<CustomScrollbar autoHeightMin={'100%'} autoHeightMax={''} className="custom-scrollbar--page">
<div className="explore-wrapper">
<div style={{ height: '100%' }} className="explore-wrapper">
<ErrorBoundaryAlert style="page">
<Explore exploreId={ExploreId.left} />
</ErrorBoundaryAlert>
......
......@@ -123,7 +123,7 @@ export function addQueryRow(exploreId: ExploreId, index: number): ThunkResult<vo
*/
export function changeDatasource(exploreId: ExploreId, datasource: string): ThunkResult<void> {
return async (dispatch, getState) => {
let newDataSourceInstance: DataSourceApi = null;
let newDataSourceInstance: DataSourceApi;
if (!datasource) {
newDataSourceInstance = await getDatasourceSrv().get();
......@@ -317,7 +317,7 @@ export const loadDatasourceReady = (
instance: DataSourceApi,
orgId: number
): PayloadAction<LoadDatasourceReadyPayload> => {
const historyKey = `grafana.explore.history.${instance.meta.id}`;
const historyKey = `grafana.explore.history.${instance.meta?.id}`;
const history = store.getObject(historyKey, []);
// Save last-used datasource
......@@ -340,7 +340,7 @@ export const loadDatasourceReady = (
export const importQueries = (
exploreId: ExploreId,
queries: DataQuery[],
sourceDataSource: DataSourceApi,
sourceDataSource: DataSourceApi | undefined,
targetDataSource: DataSourceApi
): ThunkResult<void> => {
return async dispatch => {
......@@ -352,7 +352,7 @@ export const importQueries = (
let importedQueries = queries;
// Check if queries can be imported from previously selected datasource
if (sourceDataSource.meta.id === targetDataSource.meta.id) {
if (sourceDataSource.meta?.id === targetDataSource.meta?.id) {
// Keep same queries if same type of datasource
importedQueries = [...queries];
} else if (targetDataSource.importQueries) {
......@@ -701,18 +701,31 @@ export function splitClose(itemId: ExploreId): ThunkResult<void> {
* The right state is automatically initialized.
* The copy keeps all query modifications but wipes the query results.
*/
export function splitOpen(): ThunkResult<void> {
return (dispatch, getState) => {
export function splitOpen(dataSourceName?: string, query?: string): ThunkResult<void> {
return async (dispatch, getState) => {
// Clone left state to become the right state
const leftState = getState().explore[ExploreId.left];
const queryState = getState().location.query[ExploreId.left] as string;
const urlState = parseUrlState(queryState);
const itemState: ExploreItemState = {
const leftState: ExploreItemState = getState().explore[ExploreId.left];
const rightState: ExploreItemState = {
...leftState,
queries: leftState.queries.slice(),
urlState,
};
dispatch(splitOpenAction({ itemState }));
const queryState = getState().location.query[ExploreId.left] as string;
const urlState = parseUrlState(queryState);
rightState.queries = leftState.queries.slice();
rightState.urlState = urlState;
dispatch(splitOpenAction({ itemState: rightState }));
if (dataSourceName && query) {
// This is hardcoded for Jaeger right now
const queries = [
{
query,
refId: 'A',
} as DataQuery,
];
await dispatch(changeDatasource(ExploreId.right, dataSourceName));
await dispatch(setQueriesAction({ exploreId: ExploreId.right, queries }));
}
dispatch(stateSave());
};
}
......@@ -757,7 +770,8 @@ const togglePanelActionCreator = (
}
dispatch(actionCreator({ exploreId }));
dispatch(updateExploreUIState(exploreId, uiFragmentStateUpdate));
// The switch further up is exhaustive so uiFragmentStateUpdate should definitely be initialized
dispatch(updateExploreUIState(exploreId, uiFragmentStateUpdate!));
if (shouldRunQueries) {
dispatch(runQueries(exploreId));
......
......@@ -599,6 +599,7 @@ export const updateChildRefreshState = (
const getModesForDatasource = (dataSource: DataSourceApi, currentMode: ExploreMode): [ExploreMode[], ExploreMode] => {
const supportsGraph = dataSource.meta.metrics;
const supportsLogs = dataSource.meta.logs;
const supportsTracing = dataSource.meta.tracing;
let mode = currentMode || ExploreMode.Metrics;
const supportedModes: ExploreMode[] = [];
......@@ -611,13 +612,17 @@ const getModesForDatasource = (dataSource: DataSourceApi, currentMode: ExploreMo
supportedModes.push(ExploreMode.Logs);
}
if (supportsTracing) {
supportedModes.push(ExploreMode.Tracing);
}
if (supportedModes.length === 1) {
mode = supportedModes[0];
}
// HACK: Used to set Loki's default explore mode to Logs mode.
// A better solution would be to introduce a "default" or "preferred" mode to the datasource config
if (dataSource.meta.name === 'Loki' && !currentMode) {
if (dataSource.meta.name === 'Loki' && (!currentMode || supportedModes.indexOf(currentMode) === -1)) {
mode = ExploreMode.Logs;
}
......
......@@ -54,8 +54,8 @@ describe('getLinksFromLogsField', () => {
};
const links = getLinksFromLogsField(field, 2);
expect(links.length).toBe(2);
expect(links[0].href).toBe('http://domain.com/3');
expect(links[1].href).toBe('http://anotherdomain.sk/3');
expect(links[0].linkModel.href).toBe('http://domain.com/3');
expect(links[1].linkModel.href).toBe('http://anotherdomain.sk/3');
});
it('handles zero links', () => {
......
......@@ -10,6 +10,7 @@ import {
LinkModel,
formattedValueToString,
DisplayValue,
DataLink,
} from '@grafana/data';
import { getLinkSrv } from './link_srv';
import { getFieldDisplayValuesProxy } from './fieldDisplayValuesProxy';
......@@ -143,7 +144,10 @@ export const getPanelLinksSupplier = (value: PanelModel): LinkModelSupplier<Pane
};
};
export const getLinksFromLogsField = (field: Field, rowIndex: number): Array<LinkModel<Field>> => {
export const getLinksFromLogsField = (
field: Field,
rowIndex: number
): Array<{ linkModel: LinkModel<Field>; link: DataLink }> => {
const scopedVars: any = {};
scopedVars['__value'] = {
value: {
......@@ -153,6 +157,11 @@ export const getLinksFromLogsField = (field: Field, rowIndex: number): Array<Lin
};
return field.config.links
? field.config.links.map(link => getLinkSrv().getDataLinkUIModel(link, scopedVars, field))
? field.config.links.map(link => {
return {
link,
linkModel: getLinkSrv().getDataLinkUIModel(link, scopedVars, field),
};
})
: [];
};
......@@ -13,6 +13,8 @@ const grafanaPlugin = async () =>
const influxdbPlugin = async () =>
await import(/* webpackChunkName: "influxdbPlugin" */ 'app/plugins/datasource/influxdb/module');
const lokiPlugin = async () => await import(/* webpackChunkName: "lokiPlugin" */ 'app/plugins/datasource/loki/module');
const jaegerPlugin = async () =>
await import(/* webpackChunkName: "jaegerPlugin" */ 'app/plugins/datasource/jaeger/module');
const mixedPlugin = async () =>
await import(/* webpackChunkName: "mixedPlugin" */ 'app/plugins/datasource/mixed/module');
const mysqlPlugin = async () =>
......@@ -64,6 +66,7 @@ const builtInPlugins: any = {
'app/plugins/datasource/grafana/module': grafanaPlugin,
'app/plugins/datasource/influxdb/module': influxdbPlugin,
'app/plugins/datasource/loki/module': lokiPlugin,
'app/plugins/datasource/jaeger/module': jaegerPlugin,
'app/plugins/datasource/mixed/module': mixedPlugin,
'app/plugins/datasource/mysql/module': mysqlPlugin,
'app/plugins/datasource/postgres/module': postgresPlugin,
......
import React from 'react';
import { DataSourcePluginOptionsEditorProps } from '@grafana/data';
import { DataSourceHttpSettings } from '@grafana/ui';
export type Props = DataSourcePluginOptionsEditorProps;
export const ConfigEditor: React.FC<Props> = ({ options, onOptionsChange }) => {
return (
<>
<DataSourceHttpSettings
defaultUrl={'http://localhost:16686'}
dataSourceConfig={options}
showAccessOptions={true}
onChange={onOptionsChange}
/>
</>
);
};
import React from 'react';
import { JaegerDatasource, JaegerQuery } from './datasource';
import { ButtonCascader, CascaderOption } from '@grafana/ui';
import { ExploreQueryFieldProps } from '@grafana/data';
const ALL_OPERATIONS_KEY = '__ALL__';
const NO_TRACES_KEY = '__NO_TRACES__';
type Props = ExploreQueryFieldProps<JaegerDatasource, JaegerQuery>;
interface State {
serviceOptions: CascaderOption[];
}
function getLabelFromTrace(trace: any): string {
const firstSpan = trace.spans && trace.spans[0];
if (firstSpan) {
return `${firstSpan.operationName} [${firstSpan.duration} ms]`;
}
return trace.traceID;
}
export class JaegerQueryField extends React.PureComponent<Props, State> {
constructor(props: Props, context: React.Context<any>) {
super(props, context);
this.state = {
serviceOptions: [],
};
}
componentDidMount() {
this.getServices();
}
async getServices() {
const url = '/api/services';
const { datasource } = this.props;
try {
const res = await datasource.metadataRequest(url);
if (res) {
const services = res as string[];
const serviceOptions: CascaderOption[] = services.sort().map(service => ({
label: service,
value: service,
isLeaf: false,
}));
this.setState({ serviceOptions });
}
} catch (error) {
console.error(error);
}
}
onLoadOptions = async (selectedOptions: CascaderOption[]) => {
const service = selectedOptions[0].value;
if (selectedOptions.length === 1) {
// Load operations
const operations: string[] = await this.findOperations(service);
const allOperationsOption: CascaderOption = {
label: '[ALL]',
value: ALL_OPERATIONS_KEY,
};
const operationOptions: CascaderOption[] = [
allOperationsOption,
...operations.sort().map(operation => ({
label: operation,
value: operation,
isLeaf: false,
})),
];
this.setState(state => {
const serviceOptions = state.serviceOptions.map(serviceOption => {
if (serviceOption.value === service) {
return {
...serviceOption,
children: operationOptions,
};
}
return serviceOption;
});
return { serviceOptions };
});
} else if (selectedOptions.length === 2) {
// Load traces
const operationValue = selectedOptions[1].value;
const operation = operationValue === ALL_OPERATIONS_KEY ? '' : operationValue;
const traces: any[] = await this.findTraces(service, operation);
let traceOptions: CascaderOption[] = traces.map(trace => ({
label: getLabelFromTrace(trace),
value: trace.traceID,
}));
if (traceOptions.length === 0) {
traceOptions = [
{
label: '[No traces in time range]',
value: NO_TRACES_KEY,
},
];
}
this.setState(state => {
// Place new traces into the correct service/operation sub-tree
const serviceOptions = state.serviceOptions.map(serviceOption => {
if (serviceOption.value === service) {
const operationOptions = serviceOption.children.map(operationOption => {
if (operationOption.value === operationValue) {
return {
...operationOption,
children: traceOptions,
};
}
return operationOption;
});
return {
...serviceOption,
children: operationOptions,
};
}
return serviceOption;
});
return { serviceOptions };
});
}
};
findOperations = async (service: string) => {
const { datasource } = this.props;
const url = `/api/services/${service}/operations`;
try {
return await datasource.metadataRequest(url);
} catch (error) {
console.error(error);
}
return [];
};
findTraces = async (service: string, operation?: string) => {
const { datasource } = this.props;
const { start, end } = datasource.getTimeRange();
const traceSearch = {
start,
end,
service,
operation,
limit: 10,
lookback: '1h',
maxDuration: '',
minDuration: '',
};
const url = '/api/traces';
try {
return await datasource.metadataRequest(url, traceSearch);
} catch (error) {
console.error(error);
}
return [];
};
onSelectTrace = (values: string[], selectedOptions: CascaderOption[]) => {
const { query, onChange, onRunQuery } = this.props;
if (selectedOptions.length === 3) {
const traceID = selectedOptions[2].value;
onChange({ ...query, query: traceID });
onRunQuery();
}
};
render() {
const { query, onChange } = this.props;
const { serviceOptions } = this.state;
return (
<>
<div className="gf-form-inline gf-form-inline--nowrap">
<div className="gf-form flex-shrink-0">
<ButtonCascader options={serviceOptions} onChange={this.onSelectTrace} loadData={this.onLoadOptions}>
Traces
</ButtonCascader>
</div>
<div className="gf-form gf-form--grow flex-shrink-1">
<div className={'slate-query-field__wrapper'}>
<div className="slate-query-field">
<input
style={{ width: '100%' }}
value={query.query || ''}
onChange={e =>
onChange({
...query,
query: e.currentTarget.value,
})
}
/>
</div>
</div>
</div>
</div>
</>
);
}
}
export default JaegerQueryField;
import {
dateMath,
DateTime,
MutableDataFrame,
DataSourceApi,
DataSourceInstanceSettings,
DataQueryRequest,
DataQueryResponse,
DataQuery,
} from '@grafana/data';
import { getBackendSrv } from '@grafana/runtime';
import { getTimeSrv } from 'app/features/dashboard/services/TimeSrv';
import { DatasourceRequestOptions } from 'app/core/services/backend_srv';
import { serializeParams } from '../../../core/utils/fetch';
import { Observable, from, of } from 'rxjs';
export type JaegerQuery = {
query: string;
} & DataQuery;
export class JaegerDatasource extends DataSourceApi<JaegerQuery> {
constructor(private instanceSettings: DataSourceInstanceSettings) {
super(instanceSettings);
}
_request(apiUrl: string, data?: any, options?: DatasourceRequestOptions): Observable<Record<string, any>> {
// Hack for proxying metadata requests
const baseUrl = `/api/datasources/proxy/${this.instanceSettings.id}`;
const params = data ? serializeParams(data) : '';
const url = `${baseUrl}${apiUrl}${params.length ? `?${params}` : ''}`;
const req = {
...options,
url,
};
return from(getBackendSrv().datasourceRequest(req));
}
async metadataRequest(url: string, params?: Record<string, any>) {
const res = await this._request(url, params, { silent: true }).toPromise();
return res.data.data;
}
query(options: DataQueryRequest<JaegerQuery>): Observable<DataQueryResponse> {
//http://localhost:16686/search?end=1573338717880000&limit=20&lookback=6h&maxDuration&minDuration&service=app&start=1573317117880000
const url =
options.targets.length && options.targets[0].query
? `${this.instanceSettings.url}/trace/${options.targets[0].query}?uiEmbed=v0`
: '';
return of({
data: [
new MutableDataFrame({
fields: [
{
name: 'url',
values: [url],
},
],
}),
],
});
}
async testDatasource(): Promise<any> {
return true;
}
getTime(date: string | DateTime, roundUp: boolean) {
if (typeof date === 'string') {
date = dateMath.parse(date, roundUp);
}
return date.valueOf() * 1000;
}
getTimeRange(): { start: number; end: number } {
const range = getTimeSrv().timeRange();
return {
start: this.getTime(range.from, false),
end: this.getTime(range.to, true),
};
}
}
import { DataSourcePlugin } from '@grafana/data';
import { JaegerDatasource } from './datasource';
import { JaegerQueryField } from './QueryField';
import { ConfigEditor } from './ConfigEditor';
export const plugin = new DataSourcePlugin(JaegerDatasource)
.setConfigEditor(ConfigEditor)
.setExploreQueryField(JaegerQueryField);
{
"type": "datasource",
"name": "Jaeger",
"id": "jaeger",
"category": "tracing",
"metrics": false,
"alerting": false,
"annotations": false,
"logs": false,
"streaming": false,
"tracing": true,
"info": {
"description": "Open source, end-to-end distributed tracing",
"author": {
"name": "Grafana Project",
"url": "https://grafana.com"
},
"logos": {
"small": "img/jaeger_logo.svg",
"large": "img/jaeger_logo.svg"
},
"links": [
{
"name": "Learn more",
"url": "https://www.jaegertracing.io"
},
{
"name": "GitHub Project",
"url": "https://github.com/jaegertracing/jaeger"
}
]
}
}
......@@ -4,7 +4,7 @@ import cx from 'classnames';
import { FormField } from '@grafana/ui';
import { DerivedFieldConfig } from '../types';
import { getLinksFromLogsField } from '../../../../features/panel/panellinks/linkSuppliers';
import { ArrayVector, FieldType } from '@grafana/data';
import { ArrayVector, Field, FieldType, LinkModel } from '@grafana/data';
type Props = {
derivedFields: DerivedFieldConfig[];
......@@ -90,7 +90,7 @@ function makeDebugFields(derivedFields: DerivedFieldConfig[], debugText: string)
try {
const testMatch = debugText.match(field.matcherRegex);
const value = testMatch && testMatch[1];
let link;
let link: LinkModel<Field>;
if (field.url && value) {
link = getLinksFromLogsField(
......@@ -103,7 +103,7 @@ function makeDebugFields(derivedFields: DerivedFieldConfig[], debugText: string)
},
},
0
)[0];
)[0].linkModel;
}
return {
......
import React from 'react';
import React, { useState } from 'react';
import { css } from 'emotion';
import { Button, FormField, DataLinkInput, stylesFactory } from '@grafana/ui';
import { Button, FormField, DataLinkInput, stylesFactory, Switch } from '@grafana/ui';
import { VariableSuggestion } from '@grafana/data';
import { DataSourceSelectItem } from '@grafana/data';
import { DerivedFieldConfig } from '../types';
import DataSourcePicker from 'app/core/components/Select/DataSourcePicker';
import { getDatasourceSrv } from 'app/features/plugins/datasource_srv';
import { config } from 'app/core/config';
const getStyles = stylesFactory(() => ({
firstRow: css`
row: css`
display: flex;
align-items: baseline;
`,
......@@ -27,6 +32,7 @@ type Props = {
export const DerivedField = (props: Props) => {
const { value, onChange, onDelete, suggestions, className } = props;
const styles = getStyles();
const [hasIntenalLink, setHasInternalLink] = useState(!!value.datasourceName);
const handleChange = (field: keyof typeof value) => (event: React.ChangeEvent<HTMLInputElement>) => {
onChange({
......@@ -37,7 +43,7 @@ export const DerivedField = (props: Props) => {
return (
<div className={className}>
<div className={styles.firstRow}>
<div className={styles.row}>
<FormField
className={styles.nameField}
labelWidth={5}
......@@ -93,6 +99,64 @@ export const DerivedField = (props: Props) => {
width: 100%;
`}
/>
{config.featureToggles.tracingIntegration && (
<div className={styles.row}>
<Switch
label="Internal link"
checked={hasIntenalLink}
onChange={() => {
if (hasIntenalLink) {
onChange({
...value,
datasourceName: undefined,
});
}
setHasInternalLink(!hasIntenalLink);
}}
/>
{hasIntenalLink && (
<DataSourceSection
onChange={datasourceName => {
onChange({
...value,
datasourceName,
});
}}
datasourceName={value.datasourceName}
/>
)}
</div>
)}
</div>
);
};
type DataSourceSectionProps = {
datasourceName?: string;
onChange: (name: string) => void;
};
const DataSourceSection = (props: DataSourceSectionProps) => {
const { datasourceName, onChange } = props;
const datasources: DataSourceSelectItem[] = getDatasourceSrv()
.getExternal()
.map(
(ds: any) =>
({
value: ds.name,
name: ds.name,
meta: ds.meta,
} as DataSourceSelectItem)
);
const selectedDatasource = datasourceName && datasources.find(d => d.name === datasourceName);
return (
<DataSourcePicker
onChange={newValue => {
onChange(newValue.name);
}}
datasources={datasources}
current={selectedDatasource}
/>
);
};
......@@ -51,6 +51,7 @@ import {
} from './types';
import { LegacyTarget, LiveStreams } from './live_streams';
import LanguageProvider from './language_provider';
import { serializeParams } from '../../../core/utils/fetch';
export type RangeQueryOptions = Pick<DataQueryRequest<LokiQuery>, 'range' | 'intervalMs' | 'maxDataPoints' | 'reverse'>;
export const DEFAULT_MAX_LINES = 1000;
......@@ -68,12 +69,6 @@ const DEFAULT_QUERY_PARAMS: Partial<LokiLegacyQueryRequest> = {
query: '',
};
function serializeParams(data: Record<string, any>) {
return Object.keys(data)
.map(k => `${encodeURIComponent(k)}=${encodeURIComponent(data[k])}`)
.join('&');
}
interface LokiContextQueryOptions {
direction?: 'BACKWARD' | 'FORWARD';
limit?: number;
......
......@@ -395,11 +395,16 @@ export const enhanceDataFrame = (dataFrame: DataFrame, config: LokiOptions | nul
const fields = derivedFields.reduce((acc, field) => {
const config: FieldConfig = {};
if (field.url) {
if (field.url || field.datasourceName) {
config.links = [
{
url: field.url,
title: '',
meta: field.datasourceName
? {
datasourceName: field.datasourceName,
}
: undefined,
},
];
}
......
......@@ -127,6 +127,7 @@ export type DerivedFieldConfig = {
matcherRegex: string;
name: string;
url?: string;
datasourceName?: string;
};
export interface TransformerOptions {
......
import React from 'react';
import { DataSourcePluginOptionsEditorProps } from '@grafana/data';
import { DataSourceHttpSettings } from '@grafana/ui';
export type Props = DataSourcePluginOptionsEditorProps;
export const ConfigEditor: React.FC<Props> = ({ options, onOptionsChange }) => {
return (
<DataSourceHttpSettings
defaultUrl={'http://localhost:3100'}
dataSourceConfig={options}
showAccessOptions={true}
onChange={onOptionsChange}
/>
);
};
import React from 'react';
import { ZipkinDatasource, ZipkinQuery } from './datasource';
import { ExploreQueryFieldProps } from '@grafana/data';
type Props = ExploreQueryFieldProps<ZipkinDatasource, ZipkinQuery>;
export const QueryField = (props: Props) => (
<div className={'slate-query-field__wrapper'}>
<div className="slate-query-field">
<input
style={{ width: '100%' }}
value={props.query.query || ''}
onChange={e =>
props.onChange({
...props.query,
query: e.currentTarget.value,
})
}
/>
</div>
</div>
);
import {
MutableDataFrame,
DataSourceApi,
DataSourceInstanceSettings,
DataQueryRequest,
DataQueryResponse,
DataQuery,
} from '@grafana/data';
import { Observable, of } from 'rxjs';
export type ZipkinQuery = {
query: string;
} & DataQuery;
export class ZipkinDatasource extends DataSourceApi<ZipkinQuery> {
constructor(instanceSettings: DataSourceInstanceSettings) {
super(instanceSettings);
}
query(options: DataQueryRequest<ZipkinQuery>): Observable<DataQueryResponse> {
return of({
data: [
new MutableDataFrame({
fields: [
{
name: 'url',
values: [],
},
],
}),
],
});
}
async testDatasource(): Promise<any> {
return true;
}
}
import { DataSourcePlugin } from '@grafana/data';
import { ZipkinDatasource } from './datasource';
import { QueryField } from './QueryField';
import { ConfigEditor } from './ConfigEditor';
export const plugin = new DataSourcePlugin(ZipkinDatasource)
.setConfigEditor(ConfigEditor)
.setExploreQueryField(QueryField);
{
"type": "datasource",
"name": "Zipkin",
"id": "zipkin",
"category": "tracing",
"metrics": false,
"alerting": false,
"annotations": false,
"logs": false,
"streaming": false,
"tracing": true,
"info": {
"description": "Placeholder for the distributed tracing system.",
"author": {
"name": "Grafana Project",
"url": "https://grafana.com"
},
"logos": {
"small": "img/zipkin-logo.svg",
"large": "img/zipkin-logo.svg"
},
"links": [
{
"name": "Learn more",
"url": "https://zipkin.io"
}
]
}
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment