Skip to content
Open
2 changes: 1 addition & 1 deletion heatmapchart/src/components/HeatMapTooltip.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ export function generateTooltipHTML({

return `
<div>
<div style="${tooltipHeader.styles}">${formattedDate} ${formattedTime}</div>
<div style="${tooltipHeader.styles}">${formattedDate} - ${formattedTime}</div>
<div style="${tooltipContentStyles.styles}">
<div style="${labelStyles.styles}">
${marker}
Expand Down
9 changes: 9 additions & 0 deletions prometheus/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,15 @@
},
"name": "PrometheusExplorer"
}
},
{
"kind": "Annotation",
"spec": {
"display": {
"name": "Prometheus PromQL Annotation"
},
"name": "PrometheusPromQLAnnotation"
}
}
]
}
Expand Down
1 change: 1 addition & 0 deletions prometheus/rsbuild.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ export default createConfigForPlugin({
'./PrometheusLabelNamesVariable': './src/plugins/PrometheusLabelNamesVariable.tsx',
'./PrometheusPromQLVariable': './src/plugins/PrometheusPromQLVariable.tsx',
'./PrometheusExplorer': './src/explore/PrometheusExplorer.tsx',
'./PrometheusPromQLAnnotation': './src/annotations/PrometheusPromQLAnnotation.tsx',
},
shared: {
react: { requiredVersion: '18.2.0', singleton: true },
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
// Copyright The Perses Authors
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package model

import (
"strings"
promDs "github.com/perses/plugins/prometheus/schemas/datasource:model"
)

kind: "PrometheusPromQLAnnotation"
spec: close({
promDs.#selector
expr: strings.MinRunes(1)
title?: string
legend?: string
tags?: [string]
})
29 changes: 29 additions & 0 deletions prometheus/src/annotations/PrometheusPromQLAnnotation.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
// Copyright The Perses Authors
// Licensed under the Apache License, Version 2.0 (the \"License\");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an \"AS IS\" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

import { AnnotationPlugin, AnnotationQueryPluginDependencies, parseVariables } from '@perses-dev/plugin-system';
import { PrometheusPromQLAnnotationOptions } from '../plugins';
import { PrometheusPromQLAnnotationOptionEditor } from './PrometheusPromQLAnnotationOptionEditor';
import { getAnnotationData } from './get-annotation-data';

export const PrometheusPromQLAnnotation: AnnotationPlugin<PrometheusPromQLAnnotationOptions> = {
getAnnotationData: getAnnotationData,
dependsOn: (spec: PrometheusPromQLAnnotationOptions): AnnotationQueryPluginDependencies => {
const queryVariables = parseVariables(spec.expr);
return {
variables: [...queryVariables],
};
},
OptionsEditorComponent: PrometheusPromQLAnnotationOptionEditor,
createInitialOptions: () => ({ expr: '' }),
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
// Copyright The Perses Authors
// Licensed under the Apache License, Version 2.0 (the \"License\");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an \"AS IS\" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

import { ReactElement } from 'react';
import { useId } from '@perses-dev/components';
import { produce } from 'immer';
import { Autocomplete, FormControl, Stack, TextField } from '@mui/material';
import {
DatasourceSelect,
DatasourceSelectProps,
DatasourceSelectValue,
OptionsEditorProps,
useDatasourceClient,
useDatasourceSelectValueToSelector,
} from '@perses-dev/plugin-system';
import {
DEFAULT_PROM,
isDefaultPromSelector,
isPrometheusDatasourceSelector,
PROM_DATASOURCE_KIND,
PrometheusClient,
PrometheusDatasourceSelector,
} from '../model';

import { PromQLEditor } from '../components';

export interface PrometheusAnnotationsQuerySpec {
expr: string;
datasource?: DatasourceSelectValue<PrometheusDatasourceSelector>;
title?: string;
legend?: string;
tags?: string[];
}

export type PrometheusAnnotationsQueryEditorProps = OptionsEditorProps<PrometheusAnnotationsQuerySpec>;

export function PrometheusPromQLAnnotationOptionEditor(props: PrometheusAnnotationsQueryEditorProps): ReactElement {
const {
onChange,
value,
value: { expr, datasource, title, legend, tags },
isReadonly,
} = props;

const datasourceSelectValue = datasource ?? DEFAULT_PROM;

const datasourceSelectLabelID = useId('prom-datasource-label'); // for panels with multiple queries, this component is rendered multiple times on the same page

const selectedDatasource = useDatasourceSelectValueToSelector(
datasourceSelectValue,
PROM_DATASOURCE_KIND
) as PrometheusDatasourceSelector;

const { data: client } = useDatasourceClient<PrometheusClient>(selectedDatasource);
const promURL = client?.options.datasourceUrl;

const handleDatasourceChange: DatasourceSelectProps['onChange'] = (next) => {
if (isPrometheusDatasourceSelector(next)) {
/* Good to know: The usage of onchange here causes an immediate spec update which eventually updates the panel
This was probably intentional to allow for quick switching between datasources.
Could have been triggered only with Run Query button as well.
*/
onChange(
produce(value, (draft) => {
// If they're using the default, just omit the datasource prop (i.e. set to undefined)
const nextDatasource = isDefaultPromSelector(next) ? undefined : next;
draft.datasource = nextDatasource;
})
);
return;
}

throw new Error('Got unexpected non-Prometheus datasource selector');
};

const handleExprChange = (next: string): void => {
onChange(
produce(value, (draft) => {
draft.expr = next;
})
);
};

const handleTitleChange = (next: string): void => {
onChange(
produce(value, (draft) => {
draft.title = next || undefined;
})
);
};

const handleLegendChange = (next: string): void => {
onChange(
produce(value, (draft) => {
draft.legend = next || undefined;
})
);
};

const handleTagsChange = (next: string[]): void => {
onChange(
produce(value, (draft) => {
draft.tags = next.length > 0 ? next : undefined;
})
);
};

return (
<Stack spacing={2}>
<FormControl margin="dense" fullWidth={false}>
<DatasourceSelect
datasourcePluginKind={PROM_DATASOURCE_KIND}
value={datasourceSelectValue}
onChange={handleDatasourceChange}
labelId={datasourceSelectLabelID}
label="Prometheus Datasource"
notched
readOnly={isReadonly}
/>
</FormControl>
<PromQLEditor
completeConfig={{ remote: { url: promURL } }}
value={expr}
datasource={selectedDatasource}
onChange={handleExprChange}
isReadOnly={isReadonly}
treeViewMetadata={undefined}
/>
<TextField
fullWidth
label="Title"
placeholder="Example: 'Deployment {{service}}'"
helperText="Title displayed in the annotation tooltip. Use {{label_name}} to interpolate label values."
value={title ?? ''}
onChange={(e) => handleTitleChange(e.target.value)}
slotProps={{
inputLabel: { shrink: isReadonly ? true : undefined },
input: { readOnly: isReadonly },
}}
/>
<TextField
fullWidth
label="Legend"
placeholder="Example: '{{instance}}' will generate annotation legends like 'webserver-123'..."
helperText="Text displayed below the title in the annotation tooltip. Use {{label_name}} to interpolate label values."
value={legend ?? ''}
onChange={(e) => handleLegendChange(e.target.value)}
slotProps={{
inputLabel: { shrink: isReadonly ? true : undefined },
input: { readOnly: isReadonly },
}}
/>
<Autocomplete
multiple
freeSolo
options={[]}
value={tags ?? []}
onChange={(_, next) => handleTagsChange(next as string[])}
readOnly={isReadonly}
renderInput={(params) => (
<TextField
{...params}
label="Tags"
placeholder="Add label names..."
helperText="Label names to display as tags in the annotation tooltip. Leave empty to show all labels. Press Enter to add."
/>
)}
/>
</Stack>
);
}
110 changes: 110 additions & 0 deletions prometheus/src/annotations/get-annotation-data.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
// Copyright The Perses Authors
// Licensed under the Apache License, Version 2.0 (the \"License\");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an \"AS IS\" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

import { AnnotationData, DatasourceSpec, parseDurationString } from '@perses-dev/spec';
import { AnnotationContext, datasourceSelectValueToSelector, replaceVariables } from '@perses-dev/plugin-system';
import { milliseconds } from 'date-fns';
import { DEFAULT_SCRAPE_INTERVAL, PrometheusDatasourceSpec, PrometheusPromQLAnnotationOptions } from '../plugins';
import { DEFAULT_PROM, getPrometheusTimeRange, getRangeStep, PROM_DATASOURCE_KIND, PrometheusClient } from '../model';
import { formatSeriesName } from '../utils';
import { interpolateDatasourceProxyParams } from '../plugins/interpolation';

export const getAnnotationData = async (
spec: PrometheusPromQLAnnotationOptions,
context: AnnotationContext,
abortSignal?: AbortSignal
): Promise<AnnotationData[]> => {
if (!spec.expr) {
return [];
}

const listDatasourceSelectItems = await context.datasourceStore.listDatasourceSelectItems(PROM_DATASOURCE_KIND);

const datasourceSelector =
datasourceSelectValueToSelector(
spec.datasource ?? DEFAULT_PROM,
context.variableState,
listDatasourceSelectItems
) ?? DEFAULT_PROM;

const client: PrometheusClient = await context.datasourceStore.getDatasourceClient(datasourceSelector);

const datasource = (await context.datasourceStore.getDatasource(
datasourceSelector
)) as DatasourceSpec<PrometheusDatasourceSpec>;
const interpolatedOptions = interpolateDatasourceProxyParams(datasource, context.variableState);

const datasourceScrapeInterval = Math.trunc(
milliseconds(parseDurationString(datasource.plugin.spec.scrapeInterval ?? DEFAULT_SCRAPE_INTERVAL)) / 1000
);

const timeRange = getPrometheusTimeRange(context.absoluteTimeRange);

const step = getRangeStep(timeRange, datasourceScrapeInterval);

const utcOffsetSec = new Date().getTimezoneOffset() * 60;

const alignedStart = Math.floor((timeRange.start + utcOffsetSec) / step) * step - utcOffsetSec;
let alignedEnd = Math.floor((timeRange.end + utcOffsetSec) / step) * step - utcOffsetSec;

/* Ensure end is always greater than start:
If the step is greater than equal to the diff of end and start,
both start, and end will eventually be rounded to the same value,
Consequently, the time range will be zero, which does not return any valid value
*/
if (alignedStart === alignedEnd) {
alignedEnd = alignedStart + step;
console.warn(`Step (${step}) was larger than the time range! end of time range was set accordingly.`);
}

const { data } = await client.rangeQuery(
{
query: replaceVariables(spec.expr, context.variableState),
start: alignedStart,
end: alignedEnd,
step: step,
},
{ ...interpolatedOptions, signal: abortSignal }
);

const result: AnnotationData[] = [];
for (const series of data?.result ?? []) {
const start = series.values[0]?.[0];
const end = series.values[series.values.length - 1]?.[0];

if (start !== undefined && end !== undefined) {
const labels = series.metric ?? {};
const title = spec.title ? formatSeriesName(spec.title, labels) : undefined;
const legend = spec.legend ? formatSeriesName(spec.legend, labels) : undefined;
// If spec.tags is provided, only expose the selected label names as tags.
// Otherwise, expose all labels.
const tags =
spec.tags && spec.tags.length > 0
? spec.tags.reduce<Record<string, string>>((acc, name) => {
const v = labels[name];
if (v !== undefined) acc[name] = v;
return acc;
}, {})
: labels;
result.push({
start: start * 1000,
end: end * 1000,
title: title,
legend: legend,
tags: tags,
});
}
}

return result;
};
14 changes: 14 additions & 0 deletions prometheus/src/annotations/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
// Copyright The Perses Authors
// Licensed under the Apache License, Version 2.0 (the \"License\");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an \"AS IS\" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

export * from './PrometheusPromQLAnnotation';
Loading
Loading