import { css } from '@emotion/css'; import { useRef, useCallback, useEffect, useMemo, useState } from 'react'; import { useAsync } from 'react-use'; import { dateTime, GrafanaTheme2, LogRowModel, renderMarkdown, SelectableValue } from '@grafana/data'; import { RawQuery } from '@grafana/plugin-ui'; import { reportInteraction } from '@grafana/runtime'; import { Alert, Button, Collapse, Icon, InlineField, InlineFieldRow, InlineSwitch, Label, MultiSelect, RenderUserContentAsHTML, Spinner, Tooltip, useStyles2, } from '@grafana/ui'; import { LogContextProvider, LOKI_LOG_CONTEXT_PRESERVED_LABELS, PreservedLabels, SHOULD_INCLUDE_PIPELINE_OPERATIONS, } from '../LogContextProvider'; import { escapeLabelValueInSelector } from '../languageUtils'; import { lokiGrammar } from '../syntax'; import { ContextFilter, LokiQuery } from '../types'; export interface LokiContextUiProps { logContextProvider: LogContextProvider; row: LogRowModel; updateFilter: (value: ContextFilter[]) => void; onClose: () => void; origQuery?: LokiQuery; runContextQuery?: () => void; } function getStyles(theme: GrafanaTheme2) { return { labels: css({ display: 'flex', gap: theme.spacing(0.5), }), wrapper: css({ display: 'flex', flexDirection: 'column', flex: 1, gap: theme.spacing(0.5), position: 'relative', }), textWrapper: css({ display: 'flex', alignItems: 'center', }), hidden: css({ visibility: 'hidden', }), label: css({ maxWidth: '100%', '&:first-of-type': { marginBottom: theme.spacing(2), }, '&:not(:first-of-type)': { margin: theme.spacing(2, 0), }, }), rawQueryContainer: css({ textAlign: 'start', lineBreak: 'anywhere', marginTop: theme.spacing(-0.25), marginRight: theme.spacing(6), minHeight: theme.spacing(4), }), ui: css({ backgroundColor: theme.colors.background.secondary, padding: theme.spacing(2), }), notification: css({ position: 'absolute', zIndex: theme.zIndex.portal, top: 0, right: 0, }), rawQuery: css({ display: 'inline', }), queryDescription: css({ marginLeft: theme.spacing(0.5), }), iconButton: css({ position: 'absolute', top: theme.spacing(1), right: theme.spacing(1), zIndex: theme.zIndex.navbarFixed, }), operationsToggle: css({ margin: theme.spacing(1, 0, -1, 0), '& > div': { margin: 0, '& > label': { padding: 0, }, }, }), }; } export const IS_LOKI_LOG_CONTEXT_UI_OPEN = 'isLogContextQueryUiOpen'; export function LokiContextUi(props: LokiContextUiProps) { const { row, logContextProvider, updateFilter, onClose, origQuery, runContextQuery } = props; const styles = useStyles2(getStyles); const [contextFilters, setContextFilters] = useState([]); const [showPreservedFiltersAppliedNotification, setShowPreservedFiltersAppliedNotification] = useState(false); const [initialized, setInitialized] = useState(false); const [loading, setLoading] = useState(false); const [isOpen, setIsOpen] = useState(window.localStorage.getItem(IS_LOKI_LOG_CONTEXT_UI_OPEN) === 'true'); const [includePipelineOperations, setIncludePipelineOperations] = useState( window.localStorage.getItem(SHOULD_INCLUDE_PIPELINE_OPERATIONS) === 'true' ); const timerHandle = useRef(); const previousInitialized = useRef(false); const previousContextFilters = useRef([]); const isInitialState = useMemo(() => { // Initial query has all regular labels enabled and all parsed labels disabled if (initialized && contextFilters.some((filter) => filter.nonIndexed === filter.enabled)) { return false; } // if we include pipeline operations, we also want to enable the revert button if (includePipelineOperations && logContextProvider.queryContainsValidPipelineStages(origQuery)) { return false; } return true; }, [contextFilters, includePipelineOperations, initialized, logContextProvider, origQuery]); useEffect(() => { if (!initialized) { return; } // don't trigger if we initialized, this will be the same query anyways. if (!previousInitialized.current) { previousInitialized.current = initialized; return; } if (contextFilters.filter(({ enabled, nonIndexed }) => enabled && !nonIndexed).length === 0) { setContextFilters(previousContextFilters.current); return; } previousContextFilters.current = structuredClone(contextFilters); if (timerHandle.current) { clearTimeout(timerHandle.current); } setLoading(true); timerHandle.current = window.setTimeout(() => { updateFilter(contextFilters.filter(({ enabled }) => enabled)); // We are storing the removed labels and selected extracted labels in local storage so we can // preselect the labels in the UI in the next log context view. const preservedLabels: PreservedLabels = { removedLabels: [], selectedExtractedLabels: [], }; contextFilters.forEach(({ enabled, nonIndexed, label }) => { // We only want to store real labels that were removed from the initial query if (!enabled && !nonIndexed) { preservedLabels.removedLabels.push(label); } // Or extracted labels that were added to the initial query if (enabled && nonIndexed) { preservedLabels.selectedExtractedLabels.push(label); } }); window.localStorage.setItem(LOKI_LOG_CONTEXT_PRESERVED_LABELS, JSON.stringify(preservedLabels)); setLoading(false); }, 1500); return () => { clearTimeout(timerHandle.current); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [contextFilters, initialized]); useEffect(() => { return () => { clearTimeout(timerHandle.current); onClose(); }; }, [onClose]); useAsync(async () => { setLoading(true); const initContextFilters = await logContextProvider.getInitContextFilters(row, origQuery, { from: dateTime(row.timeEpochMs), to: dateTime(row.timeEpochMs), raw: { from: dateTime(row.timeEpochMs), to: dateTime(row.timeEpochMs) }, }); setContextFilters(initContextFilters.contextFilters); setShowPreservedFiltersAppliedNotification(initContextFilters.preservedFiltersApplied); setInitialized(true); setLoading(false); }); // To hide previousContextFiltersApplied notification after 2 seconds useEffect(() => { if (showPreservedFiltersAppliedNotification) { setTimeout(() => { setShowPreservedFiltersAppliedNotification(false); }, 2000); } }, [showPreservedFiltersAppliedNotification]); useEffect(() => { reportInteraction('grafana_explore_logs_loki_log_context_loaded', { logRowUid: row.uid, type: 'load', }); return () => { reportInteraction('grafana_explore_logs_loki_log_context_loaded', { logRowUid: row.uid, type: 'unload', }); }; }, [row.uid]); const realLabels = contextFilters.filter(({ nonIndexed }) => !nonIndexed); const realLabelsEnabled = realLabels.filter(({ enabled }) => enabled); const parsedLabels = contextFilters.filter(({ nonIndexed }) => nonIndexed); const parsedLabelsEnabled = parsedLabels.filter(({ enabled }) => enabled); const contextFilterToSelectFilter = useCallback((contextFilter: ContextFilter): SelectableValue => { return { label: `${contextFilter.label}="${escapeLabelValueInSelector(contextFilter.value)}"`, value: contextFilter.label, }; }, []); // If there's any nonIndexed labels, that includes structured metadata and parsed labels, we show the nonIndexed labels input const showNonIndexedLabels = parsedLabels.length > 0; let queryExpr = logContextProvider.prepareExpression( contextFilters.filter(({ enabled }) => enabled), origQuery ); return (
{showPreservedFiltersAppliedNotification && ( )}
{ window.localStorage.setItem(IS_LOKI_LOG_CONTEXT_UI_OPEN, (!isOpen).toString()); setIsOpen((isOpen) => !isOpen); reportInteraction('grafana_explore_logs_loki_log_context_toggled', { logRowUid: row.uid, action: !isOpen ? 'open' : 'close', }); }} label={
{initialized ? ( <> ) : ( )}
} >
{ if (actionMeta.action === 'select-option') { reportInteraction('grafana_explore_logs_loki_log_context_filtered', { logRowUid: row.uid, type: 'label', action: 'select', }); } if (actionMeta.action === 'remove-value') { reportInteraction('grafana_explore_logs_loki_log_context_filtered', { logRowUid: row.uid, type: 'label', action: 'remove', }); } return setContextFilters( contextFilters.map((filter) => { if (filter.nonIndexed) { return filter; } filter.enabled = keys.some((key) => key.value === filter.label); return filter; }) ); }} /> {showNonIndexedLabels && ( <> { if (actionMeta.action === 'select-option') { reportInteraction('grafana_explore_logs_loki_log_context_filtered', { logRowUid: row.uid, type: 'parsed_label', action: 'select', }); } if (actionMeta.action === 'remove-value') { reportInteraction('grafana_explore_logs_loki_log_context_filtered', { logRowUid: row.uid, type: 'parsed_label', action: 'remove', }); } setContextFilters( contextFilters.map((filter) => { if (!filter.nonIndexed) { return filter; } filter.enabled = keys.some((key) => key.value === filter.label); return filter; }) ); }} /> )} {logContextProvider.queryContainsValidPipelineStages(origQuery) && ( } > { reportInteraction('grafana_explore_logs_loki_log_context_pipeline_toggled', { logRowUid: row.uid, action: e.currentTarget.checked ? 'enable' : 'disable', }); window.localStorage.setItem(SHOULD_INCLUDE_PIPELINE_OPERATIONS, e.currentTarget.checked.toString()); setIncludePipelineOperations(e.currentTarget.checked); if (runContextQuery) { runContextQuery(); } }} /> )}
); }