Compare commits

...

7 Commits

Author SHA1 Message Date
Galen
b059f5ce32 chore: wip 2026-01-14 16:39:20 -06:00
Galen
3a39e927a2 chore: wip - support onCellFilterAdded 2026-01-14 10:27:26 -06:00
Galen
857f9b3d26 chore: stubbing ad-hoc-filter changes 2026-01-13 16:01:37 -06:00
Galen
a3f9ad5c0d chore: pull in table options 2026-01-13 15:23:16 -06:00
Galen
c16ab6dd21 chore: PoC wip 2026-01-07 22:24:02 -06:00
Galen
b87da74c1e chore: render transformed default displayed fields 2026-01-07 17:00:08 -06:00
Galen
3914147c86 chore: init - create hello world panel plugin 2026-01-07 15:27:05 -06:00
18 changed files with 1158 additions and 59 deletions

View File

@@ -0,0 +1,59 @@
// Code generated - EDITING IS FUTILE. DO NOT EDIT.
//
// Generated by:
// public/app/plugins/gen.go
// Using jennies:
// TSTypesJenny
// PluginTsTypesJenny
//
// Run 'make gen-cue' from repository root to regenerate.
export const pluginVersion = "12.4.0-pre";
export interface Options {
buildLinkToLogLine?: unknown;
controlsStorageKey?: string;
/**
* isFilterLabelActive?: _
* onClickFilterString?: _
* onClickFilterOutString?: _
* onClickShowField?: _
* onClickHideField?: _
* onLogOptionsChange?: _
* logRowMenuIconsBefore?: _
* logRowMenuIconsAfter?: _
* logLineMenuCustomItems?: _
* onNewLogsReceived?: _
*/
displayedFields?: Array<string>;
/**
* wrapLogMessage: bool
* prettifyLogMessage: bool
* enableLogDetails: bool
* syntaxHighlighting?: bool
* sortOrder: common.LogsSortOrder
* dedupStrategy: common.LogsDedupStrategy
* enableInfiniteScrolling?: bool
* noInteractions?: bool
* showLogAttributes?: bool
* fontSize?: "default" | "small" @cuetsy(kind="enum", memberNames="default|small")
* detailsMode?: "inline" | "sidebar" @cuetsy(kind="enum", memberNames="inline|sidebar")
* timestampResolution?: "ms" | "ns" @cuetsy(kind="enum", memberNames="ms|ns")
* TODO: figure out how to define callbacks
*/
onClickFilterLabel?: unknown;
onClickFilterOutLabel?: unknown;
setDisplayedFields?: unknown;
/**
* showLabels: bool
* showCommonLabels: bool
* showFieldSelector?: bool
* showTime: bool
* showLogContextToggle: bool
*/
showControls?: boolean;
}
export const defaultOptions: Partial<Options> = {
displayedFields: [],
};

View File

@@ -248,6 +248,16 @@ func GetComposableKinds() ([]ComposableKind, error) {
CueFile: logsCue,
})
logstableCue, err := loadCueFileWithCommon(root, filepath.Join(root, "./public/app/plugins/panel/logstable/panelcfg.cue"))
if err != nil {
return nil, err
}
kinds = append(kinds, ComposableKind{
Name: "logstable",
Filename: "panelcfg.cue",
CueFile: logstableCue,
})
newsCue, err := loadCueFileWithCommon(root, filepath.Join(root, "./public/app/plugins/panel/news/panelcfg.cue"))
if err != nil {
return nil, err

View File

@@ -1,38 +1,40 @@
import { css, cx } from '@emotion/css';
import { capitalize, groupBy } from 'lodash';
import { useCallback, useEffect, useState, useRef, useMemo } from 'react';
import * as React from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { usePrevious, useUnmount } from 'react-use';
import {
SplitOpen,
LogRowModel,
LogsMetaItem,
DataFrame,
AbsoluteTimeRange,
GrafanaTheme2,
LoadingState,
TimeZone,
RawTimeRange,
DataQueryResponse,
LogRowContextOptions,
EventBus,
ExplorePanelsState,
TimeRange,
LogsDedupStrategy,
LogsSortOrder,
CoreApp,
LogsDedupDescription,
rangeUtil,
ExploreLogsPanelState,
DataFrame,
DataHoverClearEvent,
DataHoverEvent,
serializeStateToUrlParam,
urlUtil,
DataQueryResponse,
EventBus,
ExploreLogsPanelState,
ExplorePanelsState,
FieldConfigSource,
GrafanaTheme2,
LoadingState,
LogLevel,
LogRowContextOptions,
LogRowModel,
LogsDedupDescription,
LogsDedupStrategy,
LogsMetaItem,
LogsSortOrder,
PanelData,
rangeUtil,
RawTimeRange,
serializeStateToUrlParam,
shallowCompare,
SplitOpen,
TimeRange,
TimeZone,
urlUtil,
} from '@grafana/data';
import { Trans, t } from '@grafana/i18n';
import { t, Trans } from '@grafana/i18n';
import { config, reportInteraction } from '@grafana/runtime';
import { DataQuery, DataTopic } from '@grafana/schema';
import {
@@ -47,6 +49,7 @@ import {
Themeable2,
withTheme2,
} from '@grafana/ui';
import { replaceVariables } from '@grafana-plugins/loki/querybuilder/parsingUtils';
import store from 'app/core/store';
import { createAndCopyShortLink, getLogsPermalinkRange } from 'app/core/utils/shortLinks';
import { ControlledLogRows } from 'app/features/logs/components/ControlledLogRows';
@@ -56,15 +59,17 @@ import { LogRowContextModal } from 'app/features/logs/components/log-context/Log
import { LogLineContext } from 'app/features/logs/components/panel/LogLineContext';
import { LogList, LogListOptions } from 'app/features/logs/components/panel/LogList';
import { isDedupStrategy, isLogsSortOrder } from 'app/features/logs/components/panel/LogListContext';
import { LogLevelColor, dedupLogRows } from 'app/features/logs/logsModel';
import { dedupLogRows, LogLevelColor } from 'app/features/logs/logsModel';
import { getLogLevelFromKey, getLogLevelInfo } from 'app/features/logs/utils';
import { LokiQueryDirection } from 'app/plugins/datasource/loki/dataquery.gen';
import { isLokiQuery } from 'app/plugins/datasource/loki/queryUtils';
import { GetFieldLinksFn } from 'app/plugins/panel/logs/types';
import { Options } from 'app/plugins/panel/logstable/panelcfg.gen';
import { getState } from 'app/store/store';
import { ExploreItemState } from 'app/types/explore';
import { useDispatch } from 'app/types/store';
import { LogsTable } from '../../../plugins/panel/logstable/LogsTable';
import {
contentOutlineTrackPinAdded,
contentOutlineTrackPinClicked,
@@ -80,7 +85,7 @@ import { changeQueries, runQueries } from '../state/query';
import { LogsFeedback } from './LogsFeedback';
import { LogsMetaRow } from './LogsMetaRow';
import LogsNavigation from './LogsNavigation';
import { LogsTableWrap, getLogsTableHeight } from './LogsTableWrap';
import { getLogsTableHeight, LogsTableWrap } from './LogsTableWrap';
import { LogsVolumePanelList } from './LogsVolumePanelList';
import { SETTING_KEY_ROOT, SETTINGS_KEYS, visualisationTypeKey } from './utils/logs';
import { getExploreBaseUrl } from './utils/url';
@@ -127,8 +132,9 @@ interface Props extends Themeable2 {
range: TimeRange;
onClickFilterString?: (value: string, refId?: string) => void;
onClickFilterOutString?: (value: string, refId?: string) => void;
loadMoreLogs?(range: AbsoluteTimeRange): void;
onPinLineCallback?: () => void;
loadMoreLogs?(range: AbsoluteTimeRange): void;
}
export type LogsVisualisationType = 'table' | 'logs';
@@ -764,6 +770,14 @@ const UnthemedLogs: React.FunctionComponent<Props> = (props: Props) => {
setFilterLevels(levels.map((level) => getLogLevelFromKey(level)));
}, []);
// @todo ff
const enableNewLogsTable = true;
const panelData: PanelData = {
state: loading ? LoadingState.Loading : LoadingState.Done,
series: props.logsFrames ?? [],
timeRange: props.range,
};
return (
<>
{(!config.featureToggles.newLogsPanel || !config.featureToggles.newLogContext) && getRowContext && contextRow && (
@@ -989,24 +1003,55 @@ const UnthemedLogs: React.FunctionComponent<Props> = (props: Props) => {
<div className={cx(styles.logsSection, visualisationType === 'table' ? styles.logsTable : undefined)}>
{!config.featureToggles.logsPanelControls && visualisationType === 'table' && hasData && (
<div className={styles.logRows} data-testid="logRowsTable">
{/* Width should be full width minus logs navigation and padding */}
<LogsTableWrap
logsSortOrder={logsSortOrder}
range={props.range}
splitOpen={splitOpen}
timeZone={timeZone}
width={width - 80}
logsFrames={props.logsFrames ?? []}
onClickFilterLabel={onClickFilterLabel}
onClickFilterOutLabel={onClickFilterOutLabel}
panelState={panelState?.logs}
updatePanelState={updatePanelState}
datasourceType={props.datasourceType}
displayedFields={displayedFields}
exploreId={props.exploreId}
absoluteRange={props.absoluteRange}
logRows={props.logRows}
/>
{/* @todo add flag*/}
{enableNewLogsTable && (
<LogsTable
id={0}
data={panelData}
timeRange={props.range}
timeZone={timeZone}
options={{}}
transparent={false}
width={width - 80}
height={tableHeight}
fieldConfig={{
defaults: {},
overrides: [],
}}
renderCounter={0}
title={''}
eventBus={props.eventBus}
onOptionsChange={function (options: Options): void {
console.log('onOptionsChange NOT IMP:', options);
// throw new Error('Function not implemented.');
}}
onFieldConfigChange={function (config: FieldConfigSource): void {
console.log('onFieldConfigChange NOT IMP:', config);
}}
replaceVariables={replaceVariables}
onChangeTimeRange={onChangeTime}
/>
)}
{!enableNewLogsTable && (
<LogsTableWrap
logsSortOrder={logsSortOrder}
range={props.range}
splitOpen={splitOpen}
timeZone={timeZone}
width={width - 80}
logsFrames={props.logsFrames ?? []}
onClickFilterLabel={onClickFilterLabel}
onClickFilterOutLabel={onClickFilterOutLabel}
panelState={panelState?.logs}
updatePanelState={updatePanelState}
datasourceType={props.datasourceType}
displayedFields={displayedFields}
exploreId={props.exploreId}
absoluteRange={props.absoluteRange}
logRows={props.logRows}
/>
)}
</div>
)}
{(!config.featureToggles.newLogsPanel || visualisationType === 'table') &&
@@ -1014,6 +1059,10 @@ const UnthemedLogs: React.FunctionComponent<Props> = (props: Props) => {
hasData && (
<div className={styles.controlledLogRowsWrapper} data-testid="logRows">
<ControlledLogRows
fieldConfig={{
defaults: {},
overrides: [],
}}
ref={logsContainerRef}
logsTableFrames={props.logsFrames}
width={width}

View File

@@ -367,8 +367,7 @@ const isFieldFilterable = (field: Field, bodyName: string, timeName: string) =>
return true;
};
// TODO: explore if `logsFrame.ts` can help us with getting the right fields
// TODO Why is typeInfo not defined on the Field interface?
// @todo move to logsFrame
export function getLogsExtractFields(dataFrame: DataFrame) {
return dataFrame.fields
.filter((field: Field & { typeInfo?: { frame: string } }) => {

View File

@@ -28,6 +28,11 @@ interface Props extends CustomCellRendererProps {
index?: number;
}
/**
* @deprecated
* @param props
* @constructor
*/
export function LogsTableActionButtons(props: Props) {
const { exploreId, absoluteRange, logRows, rowIndex, panelState, displayedFields, logsFrame, frame } = props;

View File

@@ -1,5 +1,5 @@
import { css } from '@emotion/css';
import { useEffect, useMemo, useRef, forwardRef, useImperativeHandle, useCallback } from 'react';
import { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef } from 'react';
import {
AbsoluteTimeRange,
@@ -7,17 +7,23 @@ import {
DataFrame,
EventBusSrv,
ExploreLogsPanelState,
FieldConfigSource,
LoadingState,
LogLevel,
LogRowModel,
LogsMetaItem,
LogsSortOrder,
PanelData,
SplitOpen,
TimeRange,
} from '@grafana/data';
import { getAppEvents, getTemplateSrv } from '@grafana/runtime';
import { PanelContextProvider } from '@grafana/ui';
import { Options } from 'app/plugins/panel/logstable/panelcfg.gen';
import { LogsTable } from '../../../plugins/panel/logstable/LogsTable';
import { LogsVisualisationType } from '../../explore/Logs/Logs';
import { ControlledLogsTable } from './ControlledLogsTable';
import { InfiniteScroll } from './InfiniteScroll';
import { LogRows, Props } from './LogRows';
import { LogListOptions } from './panel/LogList';
@@ -25,6 +31,12 @@ import { LogListContextProvider, useLogListContext } from './panel/LogListContex
import { LogListControls } from './panel/LogListControls';
import { ScrollToLogsEvent } from './panel/virtualization';
// @todo
export const FILTER_FOR_OPERATOR = '=';
export const FILTER_OUT_OPERATOR = '!=';
export type AdHocFilterOperator = typeof FILTER_FOR_OPERATOR | typeof FILTER_OUT_OPERATOR;
export type AdHocFilterItem = { key: string; value: string; operator: AdHocFilterOperator };
export interface ControlledLogRowsProps extends Omit<Props, 'scrollElement'> {
loading: boolean;
logsMeta?: LogsMetaItem[];
@@ -33,6 +45,7 @@ export interface ControlledLogRowsProps extends Omit<Props, 'scrollElement'> {
onLogOptionsChange?: (option: LogListOptions, value: string | boolean | string[]) => void;
range: TimeRange;
filterLevels?: LogLevel[];
fieldConfig: FieldConfigSource;
/** Props added for Table **/
visualisationType: LogsVisualisationType;
@@ -74,10 +87,35 @@ export const ControlledLogRows = forwardRef<HTMLDivElement | null, ControlledLog
prettifyLogMessage,
onLogOptionsChange,
wrapLogMessage,
fieldConfig,
...rest
}: ControlledLogRowsProps,
ref
) => {
const dataFrames = rest.logsTableFrames ?? [];
const panelData: PanelData = {
state: rest.loading ? LoadingState.Loading : LoadingState.Done,
series: dataFrames,
timeRange: rest.timeRange,
};
const eventBus = getAppEvents();
const onCellFilterAdded = (filter: AdHocFilterItem) => {
const { value, key, operator } = filter;
const { onClickFilterLabel, onClickFilterOutLabel } = rest;
if (!onClickFilterLabel || !onClickFilterOutLabel) {
return;
}
if (operator === FILTER_FOR_OPERATOR) {
onClickFilterLabel(key, value, dataFrames[0]);
}
if (operator === FILTER_OUT_OPERATOR) {
onClickFilterOutLabel(key, value, dataFrames[0]);
}
};
return (
<LogListContextProvider
app={rest.app || CoreApp.Unknown}
@@ -97,9 +135,46 @@ export const ControlledLogRows = forwardRef<HTMLDivElement | null, ControlledLog
wrapLogMessage={wrapLogMessage}
>
{rest.visualisationType === 'logs' && (
<LogRowsComponent ref={ref} {...rest} deduplicatedRows={deduplicatedRows} />
<LogRowsComponent
ref={ref}
{...rest}
deduplicatedRows={deduplicatedRows}
fieldConfig={{ defaults: {}, overrides: [] }}
/>
)}
{rest.visualisationType === 'table' && rest.updatePanelState && (
<PanelContextProvider
value={{
eventsScope: 'explore',
eventBus: eventBus ?? new EventBusSrv(),
onAddAdHocFilter: onCellFilterAdded,
}}
>
<LogsTable
id={0}
width={rest.width ?? 0}
data={panelData}
options={{}}
transparent={false}
height={800}
fieldConfig={fieldConfig}
renderCounter={0}
title={''}
eventBus={eventBus}
onOptionsChange={function (options: Options): void {
console.log('onOptionsChange not implemented');
}}
onFieldConfigChange={function (config: FieldConfigSource): void {
console.log('onFieldConfigChange not implemented');
}}
replaceVariables={getTemplateSrv().replace.bind(getTemplateSrv())}
onChangeTimeRange={function (timeRange: AbsoluteTimeRange): void {
console.log('onChangeTimeRange not implemented');
}}
{...rest}
/>
</PanelContextProvider>
)}
{rest.visualisationType === 'table' && rest.updatePanelState && <ControlledLogsTable {...rest} />}
</LogListContextProvider>
);
}

View File

@@ -10,6 +10,7 @@ import {
DataFrame,
LogRowContextOptions,
TimeRange,
FieldConfigSource,
} from '@grafana/data';
import { Trans, t } from '@grafana/i18n';
import { DataQuery } from '@grafana/schema';
@@ -73,6 +74,7 @@ export interface Props {
logRowMenuIconsAfter?: ReactNode[];
scrollElement: HTMLDivElement | null;
renderPreview?: boolean;
fieldConfig?: FieldConfigSource;
}
export type PopoverStateType = {

View File

@@ -29,11 +29,11 @@ function getField(cache: FieldCache, name: string, fieldType: FieldType): FieldW
return field.type === fieldType ? field : undefined;
}
const DATAPLANE_TIMESTAMP_NAME = 'timestamp';
const DATAPLANE_BODY_NAME = 'body';
const DATAPLANE_SEVERITY_NAME = 'severity';
const DATAPLANE_ID_NAME = 'id';
const DATAPLANE_LABELS_NAME = 'labels';
export const LOGS_DATAPLANE_TIMESTAMP_NAME = 'timestamp';
export const LOGS_DATAPLANE_BODY_NAME = 'body';
const LOGS_DATAPLANE_SEVERITY_NAME = 'severity';
const LOGS_DATAPLANE_ID_NAME = 'id';
const LOGS_DATAPLANE_LABELS_NAME = 'labels';
// NOTE: this is a hot fn, we need to avoid allocating new objects here
export function logFrameLabelsToLabels(logFrameLabels: LogFrameLabels): Labels {
@@ -66,17 +66,17 @@ export function logFrameLabelsToLabels(logFrameLabels: LogFrameLabels): Labels {
export function parseDataplaneLogsFrame(frame: DataFrame): LogsFrame | null {
const cache = new FieldCache(frame);
const timestampField = getField(cache, DATAPLANE_TIMESTAMP_NAME, FieldType.time);
const bodyField = getField(cache, DATAPLANE_BODY_NAME, FieldType.string);
const timestampField = getField(cache, LOGS_DATAPLANE_TIMESTAMP_NAME, FieldType.time);
const bodyField = getField(cache, LOGS_DATAPLANE_BODY_NAME, FieldType.string);
// these two are mandatory
if (timestampField === undefined || bodyField === undefined) {
return null;
}
const severityField = getField(cache, DATAPLANE_SEVERITY_NAME, FieldType.string) ?? null;
const idField = getField(cache, DATAPLANE_ID_NAME, FieldType.string) ?? null;
const labelsField = getField(cache, DATAPLANE_LABELS_NAME, FieldType.other) ?? null;
const severityField = getField(cache, LOGS_DATAPLANE_SEVERITY_NAME, FieldType.string) ?? null;
const idField = getField(cache, LOGS_DATAPLANE_ID_NAME, FieldType.string) ?? null;
const labelsField = getField(cache, LOGS_DATAPLANE_LABELS_NAME, FieldType.other) ?? null;
const labels = labelsField === null ? null : labelsField.values;

View File

@@ -42,6 +42,8 @@ const histogramPanel = async () =>
await import(/* webpackChunkName: "histogramPanel" */ 'app/plugins/panel/histogram/module');
const livePanel = async () => await import(/* webpackChunkName: "livePanel" */ 'app/plugins/panel/live/module');
const logsPanel = async () => await import(/* webpackChunkName: "logsPanel" */ 'app/plugins/panel/logs/module');
const logsTablePanel = async () =>
await import(/* webpackChunkName: "logsPanel" */ 'app/plugins/panel/logstable/module');
const newsPanel = async () => await import(/* webpackChunkName: "newsPanel" */ 'app/plugins/panel/news/module');
const pieChartPanel = async () =>
await import(/* webpackChunkName: "pieChartPanel" */ 'app/plugins/panel/piechart/module');
@@ -108,6 +110,7 @@ const builtInPlugins: Record<string, System.Module | (() => Promise<System.Modul
'core:plugin/bargauge': barGaugePanel,
'core:plugin/barchart': barChartPanel,
'core:plugin/logs': logsPanel,
'core:plugin/logstable': logsTablePanel,
'core:plugin/traces': tracesPanel,
'core:plugin/welcome': welcomeBanner,
'core:plugin/nodeGraph': nodeGraph,

View File

@@ -653,6 +653,7 @@ export const LogsPanel = ({
sortOrder={sortOrder}
>
<LogRows
fieldConfig={fieldConfig}
scrollElement={scrollElement}
scrollIntoView={scrollIntoView}
permalinkedRowId={getLogsPanelState()?.logs?.id ?? undefined}
@@ -705,6 +706,7 @@ export const LogsPanel = ({
ref={(scrollElement: HTMLDivElement | null) => {
setScrollElement(scrollElement);
}}
fieldConfig={fieldConfig}
visualisationType="logs"
loading={infiniteScrolling}
loadMoreLogs={enableInfiniteScrolling ? loadMoreLogs : undefined}

View File

@@ -0,0 +1,41 @@
import { css } from '@emotion/css';
import { GrafanaTheme2 } from '@grafana/data';
import { CustomCellRendererProps, useStyles2 } from '@grafana/ui';
import { LogsFrame } from '../../../features/logs/logsFrame';
import { LogsNGTableRowActionButtons } from './LogsNGTableRowActionButtons';
import { BuildLinkToLogLine } from './types';
export function LogsTableCustomCellRenderer(props: {
cellProps: CustomCellRendererProps;
logsFrame: LogsFrame;
buildLinkToLog?: BuildLinkToLogLine;
}) {
const styles = useStyles2(getStyles);
return (
<>
<LogsNGTableRowActionButtons
{...props.cellProps}
buildLinkToLog={props.buildLinkToLog ?? buildLinkToLog}
logsFrame={props.logsFrame}
/>
<span className={styles.firstColumnCell}>
{props.cellProps.field.display?.(props.cellProps.value).text ?? String(props.cellProps.value)}
</span>
</>
);
}
const buildLinkToLog: BuildLinkToLogLine = (logsFrame, rowIndex, field) => {
return '@todo';
};
const getStyles = (theme: GrafanaTheme2) => {
return {
firstColumnCell: css({
paddingLeft: theme.spacing(7),
}),
};
};

View File

@@ -0,0 +1,132 @@
import { css } from '@emotion/css';
import { memoize } from 'lodash';
import { useState } from 'react';
import { DataFrame, GrafanaTheme2 } from '@grafana/data';
import { t } from '@grafana/i18n';
import { ClipboardButton, CustomCellRendererProps, IconButton, Modal, useTheme2 } from '@grafana/ui';
import { LogsFrame } from 'app/features/logs/logsFrame';
import { BuildLinkToLogLine } from './types';
interface Props extends CustomCellRendererProps {
logsFrame: LogsFrame;
buildLinkToLog?: BuildLinkToLogLine;
}
/**
* Logs row actions buttons
* @todo use new inspector and default to code mode
* @param props
* @constructor
*/
export function LogsNGTableRowActionButtons(props: Props) {
const { rowIndex, logsFrame, field, frame, buildLinkToLog } = props;
const theme = useTheme2();
const [isInspecting, setIsInspecting] = useState(false);
const styles = getStyles(theme);
const handleViewClick = () => {
setIsInspecting(true);
};
return (
<>
<div className={styles.container}>
<div className={styles.buttonWrapper}>
<IconButton
className={styles.inspectButton}
tooltip={t('explore.logs-table.action-buttons.view-log-line', 'View log line')}
variant="secondary"
aria-label={t('explore.logs-table.action-buttons.view-log-line', 'View log line')}
tooltipPlacement="top"
size="md"
name="eye"
onClick={handleViewClick}
tabIndex={0}
/>
</div>
{buildLinkToLog && (
<div className={styles.buttonWrapper}>
<ClipboardButton
className={styles.clipboardButton}
icon="share-alt"
variant="secondary"
fill="text"
size="md"
tooltip={t('explore.logs-table.action-buttons.copy-link', 'Copy link to log line')}
tooltipPlacement="top"
tabIndex={0}
aria-label={t('explore.logs-table.action-buttons.copy-link', 'Copy link to log line')}
getText={() => buildLinkToLog(logsFrame, rowIndex, field)}
/>
</div>
)}
</div>
{isInspecting && (
<Modal
onDismiss={() => setIsInspecting(false)}
isOpen={true}
title={t('explore.logs-table.action-buttons.inspect-value', 'Inspect value')}
>
<pre>{getLineValue(logsFrame, frame, rowIndex)}</pre>
<Modal.ButtonRow>
<ClipboardButton icon="copy" getText={() => getLineValue(logsFrame, frame, rowIndex)}>
{t('explore.logs-table.action-buttons.copy-to-clipboard', 'Copy to Clipboard')}
</ClipboardButton>
</Modal.ButtonRow>
</Modal>
)}
</>
);
}
const getLineValue = memoize((logsFrame: LogsFrame, frame: DataFrame, rowIndex: number) => {
const bodyFieldName = logsFrame?.bodyField?.name;
const bodyField = bodyFieldName
? frame.fields.find((field) => field.name === bodyFieldName)
: frame.fields.find((field) => field.type === 'string');
return bodyField?.values[rowIndex];
});
export const getStyles = (theme: GrafanaTheme2) => ({
container: css({
background: theme.colors.background.secondary,
boxShadow: theme.shadows.z2,
display: 'flex',
flexDirection: 'row',
height: '100%',
left: 0,
top: 0,
padding: `0 ${theme.spacing(0.5)}`,
position: 'absolute',
zIndex: 1,
}),
buttonWrapper: css({
height: '100%',
'& button svg': {
marginRight: 'auto',
},
'&:hover': {
color: theme.colors.text.link,
},
padding: theme.spacing(0, 1),
display: 'flex',
alignItems: 'center',
}),
inspectButton: css({
borderRadius: theme.shape.radius.default,
display: 'inline-flex',
margin: 0,
overflow: 'hidden',
verticalAlign: 'middle',
cursor: 'pointer',
}),
clipboardButton: css({
height: 30,
lineHeight: '1',
padding: 0,
width: '20px',
cursor: 'pointer',
}),
});

View File

@@ -0,0 +1,323 @@
import { css } from '@emotion/css';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { lastValueFrom } from 'rxjs';
import {
applyFieldOverrides,
DataFrame,
Field,
FieldConfigSource,
GrafanaTheme2,
PanelProps,
transformDataFrame,
useDataLinksContext,
} from '@grafana/data';
import { getTemplateSrv } from '@grafana/runtime';
import { CustomCellRendererProps, TableCellDisplayMode, useStyles2 } from '@grafana/ui';
import { config } from '../../../core/config';
import { getLogsExtractFields } from '../../../features/explore/Logs/LogsTable';
import { FieldNameMetaStore } from '../../../features/explore/Logs/LogsTableWrap';
import { LogsTableFieldSelector } from '../../../features/logs/components/fieldSelector/FieldSelector';
import {
LOGS_DATAPLANE_BODY_NAME,
LOGS_DATAPLANE_TIMESTAMP_NAME,
LogsFrame,
parseLogsFrame,
} from '../../../features/logs/logsFrame';
import { isSetDisplayedFields } from '../logs/types';
import { TablePanel } from '../table/TablePanel';
import type { Options as TableOptions } from '../table/panelcfg.gen';
import { LogsTableCustomCellRenderer } from './CustomCellRenderer';
import type { Options as LogsTableOptions } from './panelcfg.gen';
import { isBuildLinkToLogLine, isOnLogsTableOptionsChange, OnLogsTableOptionsChange } from './types';
interface LogsTablePanelProps extends PanelProps<LogsTableOptions> {
frameIndex?: number;
showHeader?: boolean;
}
// Defaults
const DEFAULT_SIDEBAR_WIDTH = 200;
export const LogsTable = ({
data,
width,
height,
timeRange,
fieldConfig,
options,
eventBus,
frameIndex = 0,
showHeader = true, // @todo not pulling from panel settings
onOptionsChange,
onFieldConfigChange,
replaceVariables,
onChangeTimeRange,
title,
transparent,
timeZone,
id,
renderCounter,
}: LogsTablePanelProps) => {
// Variables
const unTransformedDataFrame = data.series[frameIndex];
// Hooks
const logsFrame: LogsFrame | null = useMemo(() => parseLogsFrame(unTransformedDataFrame), [unTransformedDataFrame]);
const defaultDisplayedFields = useMemo(
() => [
logsFrame?.timeField.name ?? LOGS_DATAPLANE_TIMESTAMP_NAME,
logsFrame?.bodyField.name ?? LOGS_DATAPLANE_BODY_NAME,
],
[logsFrame?.timeField.name, logsFrame?.bodyField.name]
);
// State
const [extractedFrame, setExtractedFrame] = useState<DataFrame[] | null>(null);
const [organizedFrame, setOrganizedFrame] = useState<DataFrame[] | null>(null);
const [displayedFields, setDisplayedFields] = useState<string[]>(options.displayedFields ?? defaultDisplayedFields);
const styles = useStyles2(getStyles, DEFAULT_SIDEBAR_WIDTH, height, width);
const dataLinksContext = useDataLinksContext();
const dataLinkPostProcessor = dataLinksContext.dataLinkPostProcessor;
// Methods
const onLogsTableOptionsChange: OnLogsTableOptionsChange | undefined = isOnLogsTableOptionsChange(onOptionsChange)
? onOptionsChange
: undefined;
const setDisplayedFieldsFn = isSetDisplayedFields(options.setDisplayedFields)
? options.setDisplayedFields
: setDisplayedFields;
// Callbacks
const onTableOptionsChange = useCallback(
(options: TableOptions) => {
onLogsTableOptionsChange?.(options);
},
[onLogsTableOptionsChange]
);
const handleLogsTableOptionsChange = useCallback(
(options: LogsTableOptions) => {
onOptionsChange(options);
},
[onOptionsChange]
);
const handleSetDisplayedFields = useCallback(
(displayedFields: string[]) => {
setDisplayedFieldsFn(displayedFields);
handleLogsTableOptionsChange({ ...options, displayedFields: displayedFields });
},
[handleLogsTableOptionsChange, options, setDisplayedFieldsFn]
);
const handleTableOnFieldConfigChange = useCallback(
(fieldConfig: FieldConfigSource) => {
onFieldConfigChange(fieldConfig);
},
[onFieldConfigChange]
);
/**
* Extract fields transform
*/
useEffect(() => {
const extractFields = async () => {
return await lastValueFrom(
transformDataFrame(getLogsExtractFields(unTransformedDataFrame), [unTransformedDataFrame])
);
};
extractFields().then((data) => {
const extractedFrames = applyFieldOverrides({
data,
fieldConfig,
replaceVariables: replaceVariables ?? getTemplateSrv().replace.bind(getTemplateSrv()),
theme: config.theme2,
timeZone: timeZone,
dataLinkPostProcessor,
});
setExtractedFrame(extractedFrames);
});
}, [dataLinkPostProcessor, fieldConfig, replaceVariables, timeZone, unTransformedDataFrame]);
/**
* Organize fields transform
*/
useEffect(() => {
const organizeFields = async (displayedFields: string[]) => {
if (!extractedFrame) {
return Promise.resolve(null);
}
let indexByName: Record<string, number> = {};
let includeByName: Record<string, boolean> = {};
if (displayedFields) {
for (const [idx, field] of displayedFields.entries()) {
indexByName[field] = idx;
includeByName[field] = true;
}
}
const organizedFrame = await lastValueFrom(
transformDataFrame(
[
{
id: 'organize',
options: {
indexByName,
includeByName,
},
},
],
extractedFrame
)
);
if (!logsFrame) {
throw new Error('missing logsFrame');
}
for (let frameIndex = 0; frameIndex < organizedFrame.length; frameIndex++) {
const frame = organizedFrame[frameIndex];
for (const [fieldIndex, field] of frame.fields.entries()) {
const isFirstField = fieldIndex === 0;
field.config = {
...field.config,
filterable: field.config?.filterable ?? doesFieldSupportAdHocFiltering(field, logsFrame),
custom: {
...field.config.custom,
inspect: field.config?.custom?.inspect ?? doesFieldSupportInspector(field, logsFrame),
// @todo add row actions panel option
cellOptions:
isFirstField && logsFrame
? {
type: TableCellDisplayMode.Custom,
cellComponent: (cellProps: CustomCellRendererProps) => (
<LogsTableCustomCellRenderer
cellProps={cellProps}
logsFrame={logsFrame}
buildLinkToLog={
isBuildLinkToLogLine(options.buildLinkToLogLine) ? options.buildLinkToLogLine : undefined
}
/>
),
}
: field.config.custom?.cellOptions,
},
};
}
}
return organizedFrame;
};
organizeFields(displayedFields).then((frame) => {
if (frame) {
setOrganizedFrame(frame);
}
});
}, [extractedFrame, displayedFields, logsFrame, options.buildLinkToLogLine]);
if (extractedFrame === null || organizedFrame === null || logsFrame === null) {
return;
}
console.log('render::LogsTable', { extractedFrame, organizedFrame });
return (
<div className={styles.wrapper}>
<div className={styles.sidebarWrapper}>
<LogsTableFieldSelector
clear={() => {}}
columnsWithMeta={displayedFieldsToColumns(displayedFields, logsFrame)}
dataFrames={extractedFrame}
logs={[]}
reorder={(columns: string[]) => {}}
setSidebarWidth={(width) => {}}
sidebarWidth={DEFAULT_SIDEBAR_WIDTH}
toggle={(key: string) => {
if (displayedFields.includes(key)) {
handleSetDisplayedFields(displayedFields.filter((f) => f !== key));
} else {
handleSetDisplayedFields([...displayedFields, key]);
}
}}
/>
</div>
<div className={styles.tableWrapper}>
<TablePanel
data={{ ...data, series: organizedFrame }}
width={width - DEFAULT_SIDEBAR_WIDTH}
height={height}
id={id}
timeRange={timeRange}
timeZone={timeZone}
options={{ ...options, frameIndex, showHeader }}
transparent={transparent}
fieldConfig={fieldConfig}
renderCounter={renderCounter}
title={title}
eventBus={eventBus}
onOptionsChange={onTableOptionsChange}
onFieldConfigChange={handleTableOnFieldConfigChange}
replaceVariables={replaceVariables}
onChangeTimeRange={onChangeTimeRange}
/>
</div>
</div>
);
};
function displayedFieldsToColumns(displayedFields: string[], logsFrame: LogsFrame): FieldNameMetaStore {
const columns: FieldNameMetaStore = {};
for (const [idx, field] of displayedFields.entries()) {
columns[field] = {
percentOfLinesWithLabel: 0,
type:
field === logsFrame.bodyField.name
? 'BODY_FIELD'
: field === logsFrame.timeField.name
? 'TIME_FIELD'
: undefined,
active: true,
index: idx,
};
}
return columns;
}
function doesFieldSupportInspector(field: Field, logsFrame: LogsFrame) {
// const unsupportedFields = [logsFrame.bodyField.name]
// return !unsupportedFields.includes(field.name);
return false;
}
function doesFieldSupportAdHocFiltering(field: Field, logsFrame: LogsFrame): boolean {
const unsupportedFields = [logsFrame.timeField.name, logsFrame.bodyField.name];
return !unsupportedFields.includes(field.name);
}
const getStyles = (theme: GrafanaTheme2, sidebarWidth: number, height: number, width: number) => {
return {
tableWrapper: css({
paddingLeft: sidebarWidth,
height,
width,
}),
sidebarWrapper: css({
position: 'absolute',
height: height,
width: sidebarWidth,
}),
wrapper: css({
height,
width,
}),
};
};

View File

@@ -0,0 +1,234 @@
import { FieldConfigProperty, identityOverrideProcessor, PanelPlugin, standardEditorsRegistry } from '@grafana/data';
import { t } from '@grafana/i18n';
import {
TableCellDisplayMode,
TableCellHeight,
TableCellOptions,
TableCellTooltipPlacement,
} from '@grafana/schema/dist/esm/common/common.gen';
import { defaultTableFieldOptions } from '@grafana/schema/dist/esm/veneer/common.types';
import { PaginationEditor } from '../table/PaginationEditor';
import { TableCellOptionEditor } from '../table/TableCellOptionEditor';
import { tablePanelChangedHandler } from '../table/migrations';
import { defaultOptions, FieldConfig as TableFieldConfig } from '../table/panelcfg.gen';
import { tableSuggestionsSupplier } from '../table/suggestions';
import { LogsTable } from './LogsTable';
import { Options } from './panelcfg.gen';
// @todo can we pull stuff from table module instead of manually adding?
export const plugin = new PanelPlugin<Options, TableFieldConfig>(LogsTable)
.setPanelChangeHandler(tablePanelChangedHandler)
.useFieldConfig({
standardOptions: {
[FieldConfigProperty.Actions]: {
hideFromDefaults: false,
},
},
useCustomConfig: (builder) => {
const category = [t('table.category-logs-table', 'Logs Table')];
const cellCategory = [t('table.category-cell-options', 'Cell options')];
builder
.addNumberInput({
path: 'minWidth',
name: t('table.name-min-column-width', 'Minimum column width'),
category,
description: t('table.description-min-column-width', 'The minimum width for column auto resizing'),
settings: {
placeholder: '150',
min: 50,
max: 500,
},
shouldApply: () => true,
defaultValue: defaultTableFieldOptions.minWidth,
})
.addNumberInput({
path: 'width',
name: t('table.name-column-width', 'Column width'),
category,
settings: {
placeholder: t('table.placeholder-column-width', 'auto'),
min: 20,
},
shouldApply: () => true,
defaultValue: defaultTableFieldOptions.width,
})
.addRadio({
path: 'align',
name: t('table.name-column-alignment', 'Column alignment'),
category,
settings: {
options: [
{ label: t('table.column-alignment-options.label-auto', 'Auto'), value: 'auto' },
{ label: t('table.column-alignment-options.label-left', 'Left'), value: 'left' },
{ label: t('table.column-alignment-options.label-center', 'Center'), value: 'center' },
{ label: t('table.column-alignment-options.label-right', 'Right'), value: 'right' },
],
},
defaultValue: defaultTableFieldOptions.align,
})
.addBooleanSwitch({
path: 'filterable',
name: t('table.name-column-filter', 'Column filter'),
category,
description: t('table.description-column-filter', 'Enables/disables field filters in table'),
defaultValue: defaultTableFieldOptions.filterable,
})
.addBooleanSwitch({
path: 'wrapText',
name: t('table.name-wrap-text', 'Wrap text'),
category,
})
.addBooleanSwitch({
path: 'wrapHeaderText',
name: t('table.name-wrap-header-text', 'Wrap header text'),
category,
})
.addBooleanSwitch({
path: 'hideFrom.viz',
name: t('table.name-hide-in-table', 'Hide in table'),
category,
defaultValue: undefined,
hideFromDefaults: true,
})
.addCustomEditor({
id: 'footer.reducers',
category: [t('table.category-table-footer', 'Table footer')],
path: 'footer.reducers',
name: t('table.name-calculation', 'Calculation'),
description: t('table.description-calculation', 'Choose a reducer function / calculation'),
editor: standardEditorsRegistry.get('stats-picker').editor,
override: standardEditorsRegistry.get('stats-picker').editor,
defaultValue: [],
process: identityOverrideProcessor,
shouldApply: () => true,
settings: {
allowMultiple: true,
},
})
.addCustomEditor<void, TableCellOptions>({
id: 'cellOptions',
path: 'cellOptions',
name: t('table.name-cell-type', 'Cell type'),
editor: TableCellOptionEditor,
override: TableCellOptionEditor,
defaultValue: defaultTableFieldOptions.cellOptions,
process: identityOverrideProcessor,
category: cellCategory,
shouldApply: () => true,
})
.addBooleanSwitch({
path: 'inspect',
name: t('table.name-cell-value-inspect', 'Cell value inspect'),
description: t('table.description-cell-value-inspect', 'Enable cell value inspection in a modal window'),
defaultValue: false,
category: cellCategory,
showIf: (cfg) => {
return (
cfg.cellOptions.type === TableCellDisplayMode.Auto ||
cfg.cellOptions.type === TableCellDisplayMode.JSONView ||
cfg.cellOptions.type === TableCellDisplayMode.ColorText ||
cfg.cellOptions.type === TableCellDisplayMode.ColorBackground
);
},
})
.addFieldNamePicker({
path: 'tooltip.field',
name: t('table.name-tooltip-from-field', 'Tooltip from field'),
description: t(
'table.description-tooltip-from-field',
'Render a cell from a field (hidden or visible) in a tooltip'
),
category: cellCategory,
})
.addSelect({
path: 'tooltip.placement',
name: t('table.name-tooltip-placement', 'Tooltip placement'),
category: cellCategory,
settings: {
options: [
{
label: t('table.tooltip-placement-options.label-auto', 'Auto'),
value: TableCellTooltipPlacement.Auto,
},
{
label: t('table.tooltip-placement-options.label-top', 'Top'),
value: TableCellTooltipPlacement.Top,
},
{
label: t('table.tooltip-placement-options.label-right', 'Right'),
value: TableCellTooltipPlacement.Right,
},
{
label: t('table.tooltip-placement-options.label-bottom', 'Bottom'),
value: TableCellTooltipPlacement.Bottom,
},
{
label: t('table.tooltip-placement-options.label-left', 'Left'),
value: TableCellTooltipPlacement.Left,
},
],
},
showIf: (cfg) => cfg.tooltip?.field !== undefined,
})
.addFieldNamePicker({
path: 'styleField',
name: t('table.name-styling-from-field', 'Styling from field'),
description: t('table.description-styling-from-field', 'A field containing JSON objects with CSS properties'),
category: cellCategory,
});
},
})
.setPanelOptions((builder) => {
const category = [t('table.category-table', 'Table')];
builder
.addBooleanSwitch({
path: 'showHeader',
name: t('table.name-show-table-header', 'Show table header'),
category,
defaultValue: defaultOptions.showHeader,
})
.addNumberInput({
path: 'frozenColumns.left',
name: t('table.name-frozen-columns', 'Frozen columns'),
description: t('table.description-frozen-columns', 'Columns are frozen from the left side of the table'),
settings: {
placeholder: 'none',
},
category,
})
.addRadio({
path: 'cellHeight',
name: t('table.name-cell-height', 'Cell height'),
category,
defaultValue: defaultOptions.cellHeight,
settings: {
options: [
{ value: TableCellHeight.Sm, label: t('table.cell-height-options.label-small', 'Small') },
{ value: TableCellHeight.Md, label: t('table.cell-height-options.label-medium', 'Medium') },
{ value: TableCellHeight.Lg, label: t('table.cell-height-options.label-large', 'Large') },
],
},
})
.addNumberInput({
path: 'maxRowHeight',
name: t('table.name-max-height', 'Max row height'),
category,
settings: {
placeholder: t('table.placeholder-max-height', 'none'),
min: 0,
},
})
.addCustomEditor({
id: 'enablePagination',
path: 'enablePagination',
name: t('table.name-enable-pagination', 'Enable pagination'),
category,
editor: PaginationEditor,
defaultValue: defaultOptions?.enablePagination,
});
})
// @todo
//@ts-expect-error
.setSuggestionsSupplier(tableSuggestionsSupplier);

View File

@@ -0,0 +1,69 @@
// Copyright 2026 Grafana Labs
//
// 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 grafanaplugin
//import (
// "github.com/grafana/grafana/packages/grafana-schema/src/common"
//)
composableKinds: PanelCfg: {
maturity: "experimental"
lineage: {
schemas: [{
version: [0, 0]
schema: {
Options: {
// showLabels: bool
// showCommonLabels: bool
// showFieldSelector?: bool
// showTime: bool
// showLogContextToggle: bool
showControls?: bool
controlsStorageKey?: string
// wrapLogMessage: bool
// prettifyLogMessage: bool
// enableLogDetails: bool
// syntaxHighlighting?: bool
// sortOrder: common.LogsSortOrder
// dedupStrategy: common.LogsDedupStrategy
// enableInfiniteScrolling?: bool
// noInteractions?: bool
// showLogAttributes?: bool
// fontSize?: "default" | "small" @cuetsy(kind="enum", memberNames="default|small")
// detailsMode?: "inline" | "sidebar" @cuetsy(kind="enum", memberNames="inline|sidebar")
// timestampResolution?: "ms" | "ns" @cuetsy(kind="enum", memberNames="ms|ns")
// @todo filter methods no longer needed as props since these are defined by context?
// onClickFilterLabel?: _
// onClickFilterOutLabel?: _
// isFilterLabelActive?: _
// onClickFilterString?: _
// onClickFilterOutString?: _
// onClickShowField?: _
// onClickHideField?: _
// onLogOptionsChange?: _
// logRowMenuIconsBefore?: _
// logRowMenuIconsAfter?: _
// logLineMenuCustomItems?: _
// onNewLogsReceived?: _
displayedFields?: [...string]
setDisplayedFields?: _
buildLinkToLogLine?: _
} @cuetsy(kind="interface")
}
}]
lenses: []
}
}

View File

@@ -0,0 +1,57 @@
// Code generated - EDITING IS FUTILE. DO NOT EDIT.
//
// Generated by:
// public/app/plugins/gen.go
// Using jennies:
// TSTypesJenny
// PluginTsTypesJenny
//
// Run 'make gen-cue' from repository root to regenerate.
export interface Options {
buildLinkToLogLine?: unknown;
controlsStorageKey?: string;
/**
* isFilterLabelActive?: _
* onClickFilterString?: _
* onClickFilterOutString?: _
* onClickShowField?: _
* onClickHideField?: _
* onLogOptionsChange?: _
* logRowMenuIconsBefore?: _
* logRowMenuIconsAfter?: _
* logLineMenuCustomItems?: _
* onNewLogsReceived?: _
*/
displayedFields?: Array<string>;
/**
* wrapLogMessage: bool
* prettifyLogMessage: bool
* enableLogDetails: bool
* syntaxHighlighting?: bool
* sortOrder: common.LogsSortOrder
* dedupStrategy: common.LogsDedupStrategy
* enableInfiniteScrolling?: bool
* noInteractions?: bool
* showLogAttributes?: bool
* fontSize?: "default" | "small" @cuetsy(kind="enum", memberNames="default|small")
* detailsMode?: "inline" | "sidebar" @cuetsy(kind="enum", memberNames="inline|sidebar")
* timestampResolution?: "ms" | "ns" @cuetsy(kind="enum", memberNames="ms|ns")
* TODO: figure out how to define callbacks
*/
onClickFilterLabel?: unknown;
onClickFilterOutLabel?: unknown;
setDisplayedFields?: unknown;
/**
* showLabels: bool
* showCommonLabels: bool
* showFieldSelector?: bool
* showTime: bool
* showLogContextToggle: bool
*/
showControls?: boolean;
}
export const defaultOptions: Partial<Options> = {
displayedFields: [],
};

View File

@@ -0,0 +1,23 @@
{
"type": "panel",
"name": "Logs Table",
"id": "logs_table",
"state": "alpha",
"info": {
"author": {
"name": "Grafana Labs",
"url": "https://grafana.com"
},
"logos": {
"small": "img/icn-logs-panel.svg",
"large": "img/icn-logs-panel.svg"
},
"links": [
{ "name": "Raise issue", "url": "https://github.com/grafana/grafana/issues/new" },
{
"name": "Documentation",
"url": "https://grafana.com/docs/grafana/latest/panels-visualizations/visualizations/@todo/"
}
]
}
}

View File

@@ -0,0 +1,16 @@
import { Field } from '@grafana/data';
import { LogsFrame } from '../../../features/logs/logsFrame';
import type { Options as TableOptions } from '../table/panelcfg.gen';
import type { Options as LogsTableOptions } from './panelcfg.gen';
export type OnLogsTableOptionsChange = (option: LogsTableOptions & TableOptions) => void;
export type BuildLinkToLogLine = (logsFrame: LogsFrame, rowIndex: number, field: Field) => string;
export function isOnLogsTableOptionsChange(callback: unknown): callback is OnLogsTableOptionsChange {
return typeof callback === 'function';
}
export function isBuildLinkToLogLine(callback: unknown): callback is BuildLinkToLogLine {
return typeof callback === 'function';
}