Logs Panel: Base elements for the new visualization (#99084)
* Create base components * Create measurement service * Add container for list * Use measurement to render virtualized log lines * Match rendered styles in 2d context for measuring * Improve virtualization initialization and handle resize * Introduce log line processing * Virtualization: fix measurement of lines with line endings * Virtualization: include scrollbar width in calculation * Remove logs * Virtualization: optimize text measurement * Add support for forceEscape * Log line: properly style wrapped/unwrapped lines * Virtualization: handle possible overflows * Improve overflow handling * LogList: remove scroll position ref * Remove logs * Remove log * Add top/bottom navigation buttons * Add timestamp to pre-processing * Add showtime support * Fix imports * Chore: simplify dedup * Show level * Refactor measurement and measure level and timestamp * Virtualization: skip unnecessary measurements * Improve measurements to minimize overflow chance * Introduce logline colors * Update palette * Remove pretiffying * Add comment * Remove unused variable * Add color for info level * Fix dependencies * Refactor overflow to account for smaller estimations * Debounce resizing * Fix imports * Further optimize height calculation * Remove outline * Unused import * Use less under/overflow method * Respond to height changes * Refactor size adjustment to account for layout changes * Add Logs Panel support * Add margin bottom to log lines * Remove unused option * LogList: container div should never be null Bad API design * Log List: make app not undefined and update containerElement usages * New Logs Panel: Create as new visualization (#99427) * Logs Panel: clean up old panel * Logs Panel New: create as new visualization * Plugin: mark as alpha * Logs panel new: hold container in a state variable * Logs panel: fix no data state * Create newLogsPanel feature flag * Logs: use new feature flag * Prettier * Add new panel to code owners * Logs Navigation: add translations * Address betterer issues * Fix import * Extract translations * Update virtualization.ts * Virtualization: add DOM fallback for text measurement * Run gen-cue * plugins_integration_test: add logs-new to expected plugins
This commit is contained in:
@@ -4668,10 +4668,6 @@ exports[`better eslint`] = {
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "3"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "4"]
|
||||
],
|
||||
"public/app/features/explore/Logs/LogsNavigation.tsx:5381": [
|
||||
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "0"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "1"]
|
||||
],
|
||||
"public/app/features/explore/Logs/LogsSamplePanel.tsx:5381": [
|
||||
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "0"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "1"],
|
||||
|
||||
@@ -535,6 +535,7 @@ playwright.config.ts @grafana/plugins-platform-frontend
|
||||
/public/app/plugins/panel/heatmap/ @grafana/dataviz-squad
|
||||
/public/app/plugins/panel/histogram/ @grafana/dataviz-squad
|
||||
/public/app/plugins/panel/logs/ @grafana/observability-logs
|
||||
/public/app/plugins/panel/logs-new/ @grafana/observability-logs
|
||||
/public/app/plugins/panel/nodeGraph/ @grafana/observability-traces-and-profiling @grafana/app-o11y-visualizations
|
||||
/public/app/plugins/panel/traces/ @grafana/observability-traces-and-profiling
|
||||
/public/app/plugins/panel/flamegraph/ @grafana/observability-traces-and-profiling
|
||||
|
||||
@@ -232,6 +232,7 @@ Experimental features might be changed or removed without prior notice.
|
||||
| `grafanaAdvisor` | Enables Advisor app |
|
||||
| `elasticsearchImprovedParsing` | Enables less memory intensive Elasticsearch result parsing |
|
||||
| `datasourceConnectionsTab` | Shows defined connections for a data source in the plugins detail page |
|
||||
| `newLogsPanel` | Enables the new logs panel in Explore |
|
||||
|
||||
## Development feature toggles
|
||||
|
||||
|
||||
@@ -255,4 +255,5 @@ export interface FeatureToggles {
|
||||
fetchRulesUsingPost?: boolean;
|
||||
alertingAlertmanagerExtraDedupStage?: boolean;
|
||||
alertingAlertmanagerExtraDedupStageStopPipeline?: boolean;
|
||||
newLogsPanel?: boolean;
|
||||
}
|
||||
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
// 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.
|
||||
|
||||
import * as common from '@grafana/schema';
|
||||
|
||||
export const pluginVersion = "11.6.0-pre";
|
||||
|
||||
export interface Options {
|
||||
dedupStrategy: common.LogsDedupStrategy;
|
||||
enableInfiniteScrolling?: boolean;
|
||||
enableLogDetails: boolean;
|
||||
showTime: boolean;
|
||||
sortOrder: common.LogsSortOrder;
|
||||
wrapLogMessage: boolean;
|
||||
}
|
||||
@@ -248,6 +248,16 @@ func GetComposableKinds() ([]ComposableKind, error) {
|
||||
CueFile: logsCue,
|
||||
})
|
||||
|
||||
logsnewCue, err := loadCueFileWithCommon(root, filepath.Join(root, "./public/app/plugins/panel/logs-new/panelcfg.cue"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
kinds = append(kinds, ComposableKind{
|
||||
Name: "logsnew",
|
||||
Filename: "panelcfg.cue",
|
||||
CueFile: logsnewCue,
|
||||
})
|
||||
|
||||
newsCue, err := loadCueFileWithCommon(root, filepath.Join(root, "./public/app/plugins/panel/news/panelcfg.cue"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -1777,6 +1777,13 @@ var (
|
||||
HideFromDocs: true,
|
||||
RequiresRestart: true,
|
||||
},
|
||||
{
|
||||
Name: "newLogsPanel",
|
||||
Description: "Enables the new logs panel in Explore",
|
||||
Stage: FeatureStageExperimental,
|
||||
FrontendOnly: true,
|
||||
Owner: grafanaObservabilityLogsSquad,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -236,3 +236,4 @@ datasourceConnectionsTab,experimental,@grafana/plugins-platform-backend,false,fa
|
||||
fetchRulesUsingPost,experimental,@grafana/alerting-squad,false,false,false
|
||||
alertingAlertmanagerExtraDedupStage,experimental,@grafana/alerting-squad,false,true,false
|
||||
alertingAlertmanagerExtraDedupStageStopPipeline,experimental,@grafana/alerting-squad,false,true,false
|
||||
newLogsPanel,experimental,@grafana/observability-logs,false,false,true
|
||||
|
||||
|
@@ -954,4 +954,8 @@ const (
|
||||
// FlagAlertingAlertmanagerExtraDedupStageStopPipeline
|
||||
// works together with alertingAlertmanagerExtraDedupStage, if enabled, it will stop the pipeline if the timestamps are not matching. Otherwise, it will emit a warning
|
||||
FlagAlertingAlertmanagerExtraDedupStageStopPipeline = "alertingAlertmanagerExtraDedupStageStopPipeline"
|
||||
|
||||
// FlagNewLogsPanel
|
||||
// Enables the new logs panel in Explore
|
||||
FlagNewLogsPanel = "newLogsPanel"
|
||||
)
|
||||
|
||||
@@ -2707,6 +2707,19 @@
|
||||
"frontend": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"metadata": {
|
||||
"name": "newLogsPanel",
|
||||
"resourceVersion": "1738344859933",
|
||||
"creationTimestamp": "2025-01-31T17:34:19Z"
|
||||
},
|
||||
"spec": {
|
||||
"description": "Enables the new logs panel in Explore",
|
||||
"stage": "experimental",
|
||||
"codeowner": "@grafana/observability-logs",
|
||||
"frontend": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"metadata": {
|
||||
"name": "newPDFRendering",
|
||||
|
||||
@@ -150,6 +150,7 @@ func verifyCorePluginCatalogue(t *testing.T, ctx context.Context, ps *pluginstor
|
||||
"histogram": {},
|
||||
"live": {},
|
||||
"logs": {},
|
||||
"logs-new": {},
|
||||
"candlestick": {},
|
||||
"news": {},
|
||||
"nodeGraph": {},
|
||||
|
||||
@@ -55,6 +55,8 @@ import { createAndCopyShortLink, getLogsPermalinkRange } from 'app/core/utils/sh
|
||||
import { InfiniteScroll } from 'app/features/logs/components/InfiniteScroll';
|
||||
import { LogRows } from 'app/features/logs/components/LogRows';
|
||||
import { LogRowContextModal } from 'app/features/logs/components/log-context/LogRowContextModal';
|
||||
import { LogList } from 'app/features/logs/components/panel/LogList';
|
||||
import { ScrollToLogsEvent } from 'app/features/logs/components/panel/virtualization';
|
||||
import { LogLevelColor, dedupLogRows, filterLogLevels } from 'app/features/logs/logsModel';
|
||||
import { getLogLevel, getLogLevelFromKey, getLogLevelInfo } from 'app/features/logs/utils';
|
||||
import { LokiQueryDirection } from 'app/plugins/datasource/loki/dataquery.gen';
|
||||
@@ -709,7 +711,13 @@ const UnthemedLogs: React.FunctionComponent<Props> = (props: Props) => {
|
||||
);
|
||||
|
||||
const scrollToTopLogs = useCallback(() => {
|
||||
if (config.featureToggles.logsInfiniteScrolling) {
|
||||
if (config.featureToggles.newLogsPanel) {
|
||||
eventBus.publish(
|
||||
new ScrollToLogsEvent({
|
||||
scrollTo: 'top',
|
||||
})
|
||||
);
|
||||
} else if (config.featureToggles.logsInfiniteScrolling) {
|
||||
if (logsContainerRef.current) {
|
||||
logsContainerRef.current.scroll({
|
||||
behavior: 'auto',
|
||||
@@ -718,7 +726,25 @@ const UnthemedLogs: React.FunctionComponent<Props> = (props: Props) => {
|
||||
}
|
||||
}
|
||||
topLogsRef.current?.scrollIntoView();
|
||||
}, [logsContainerRef, topLogsRef]);
|
||||
}, [eventBus]);
|
||||
|
||||
const scrollToBottomLogs = useCallback(() => {
|
||||
if (config.featureToggles.newLogsPanel) {
|
||||
eventBus.publish(
|
||||
new ScrollToLogsEvent({
|
||||
scrollTo: 'bottom',
|
||||
})
|
||||
);
|
||||
} else if (config.featureToggles.logsInfiniteScrolling) {
|
||||
if (logsContainerRef.current) {
|
||||
logsContainerRef.current.scroll({
|
||||
behavior: 'auto',
|
||||
top: logsContainerRef.current.scrollHeight,
|
||||
});
|
||||
}
|
||||
}
|
||||
topLogsRef.current?.scrollTo(0, topLogsRef.current.scrollHeight);
|
||||
}, [eventBus]);
|
||||
|
||||
const onPinToContentOutlineClick = useCallback(
|
||||
(row: LogRowModel, allowUnPin = true) => {
|
||||
@@ -968,7 +994,7 @@ const UnthemedLogs: React.FunctionComponent<Props> = (props: Props) => {
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{visualisationType === 'logs' && hasData && (
|
||||
{visualisationType === 'logs' && hasData && !config.featureToggles.newLogsPanel && (
|
||||
<>
|
||||
<div
|
||||
className={config.featureToggles.logsInfiniteScrolling ? styles.scrollableLogRows : styles.logRows}
|
||||
@@ -1037,6 +1063,38 @@ const UnthemedLogs: React.FunctionComponent<Props> = (props: Props) => {
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{visualisationType === 'logs' && config.featureToggles.newLogsPanel && (
|
||||
<>
|
||||
<div data-testid="logRows" ref={logsContainerRef} className={styles.logRows}>
|
||||
{logsContainerRef.current && (
|
||||
<LogList
|
||||
app={CoreApp.Explore}
|
||||
containerElement={logsContainerRef.current}
|
||||
eventBus={eventBus}
|
||||
forceEscape={forceEscape}
|
||||
logs={dedupedRows}
|
||||
showTime={showTime}
|
||||
sortOrder={logsSortOrder}
|
||||
timeZone={timeZone}
|
||||
wrapLogMessage={wrapLogMessage}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<LogsNavigation
|
||||
logsSortOrder={logsSortOrder}
|
||||
visibleRange={navigationRange ?? absoluteRange}
|
||||
absoluteRange={absoluteRange}
|
||||
timeZone={timeZone}
|
||||
onChangeTime={onChangeTime}
|
||||
loading={loading}
|
||||
queries={logsQueries ?? []}
|
||||
scrollToTopLogs={scrollToTopLogs}
|
||||
scrollToBottomLogs={scrollToBottomLogs}
|
||||
addResultsToCache={addResultsToCache}
|
||||
clearCache={clearCache}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{!loading && !hasData && !scanning && (
|
||||
<div className={styles.noDataWrapper}>
|
||||
<div className={styles.noData}>
|
||||
|
||||
@@ -7,6 +7,7 @@ import { config, reportInteraction } from '@grafana/runtime';
|
||||
import { DataQuery, TimeZone } from '@grafana/schema';
|
||||
import { Button, Icon, Spinner, useTheme2 } from '@grafana/ui';
|
||||
import { TOP_BAR_LEVEL_HEIGHT } from 'app/core/components/AppChrome/types';
|
||||
import { t, Trans } from 'app/core/internationalization';
|
||||
|
||||
import { LogsNavigationPages } from './LogsNavigationPages';
|
||||
|
||||
@@ -19,6 +20,7 @@ type Props = {
|
||||
logsSortOrder?: LogsSortOrder | null;
|
||||
onChangeTime: (range: AbsoluteTimeRange) => void;
|
||||
scrollToTopLogs: () => void;
|
||||
scrollToBottomLogs?: () => void;
|
||||
addResultsToCache: () => void;
|
||||
clearCache: () => void;
|
||||
};
|
||||
@@ -35,6 +37,7 @@ function LogsNavigation({
|
||||
loading,
|
||||
onChangeTime,
|
||||
scrollToTopLogs,
|
||||
scrollToBottomLogs,
|
||||
visibleRange,
|
||||
queries,
|
||||
clearCache,
|
||||
@@ -126,7 +129,7 @@ function LogsNavigation({
|
||||
>
|
||||
<div className={styles.navButtonContent}>
|
||||
{loading ? <Spinner /> : <Icon name={oldestLogsFirst ? 'angle-up' : 'angle-down'} size="lg" />}
|
||||
Older logs
|
||||
<Trans i18nKey={'logs.logs-navigation.older-logs'}>Older logs</Trans>
|
||||
</div>
|
||||
</Button>
|
||||
);
|
||||
@@ -156,7 +159,9 @@ function LogsNavigation({
|
||||
<div className={styles.navButtonContent}>
|
||||
{loading && <Spinner />}
|
||||
{onFirstPage || loading ? null : <Icon name={oldestLogsFirst ? 'angle-down' : 'angle-up'} size="lg" />}
|
||||
{onFirstPage ? 'Start of range' : 'Newer logs'}
|
||||
{onFirstPage
|
||||
? t('logs.logs-navigation.start-of-range', 'Start of range')
|
||||
: t('logs.logs-navigation.newer-logs', 'Newer logs')}
|
||||
</div>
|
||||
</Button>
|
||||
);
|
||||
@@ -178,6 +183,11 @@ function LogsNavigation({
|
||||
scrollToTopLogs();
|
||||
}, [scrollToTopLogs]);
|
||||
|
||||
const onScrollToBottomClick = useCallback(() => {
|
||||
reportInteraction('grafana_explore_logs_scroll_bottom_clicked');
|
||||
scrollToBottomLogs?.();
|
||||
}, [scrollToBottomLogs]);
|
||||
|
||||
return (
|
||||
<div className={styles.navContainer}>
|
||||
{!config.featureToggles.logsInfiniteScrolling && (
|
||||
@@ -194,12 +204,23 @@ function LogsNavigation({
|
||||
{oldestLogsFirst ? newerLogsButton : olderLogsButton}
|
||||
</>
|
||||
)}
|
||||
{scrollToBottomLogs && (
|
||||
<Button
|
||||
data-testid="scrollToBottom"
|
||||
className={styles.scrollToBottomButton}
|
||||
variant="secondary"
|
||||
onClick={onScrollToBottomClick}
|
||||
title={t('logs.logs-navigation.scroll-bottom', 'Scroll to bottom')}
|
||||
>
|
||||
<Icon name="arrow-down" size="lg" />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
data-testid="scrollToTop"
|
||||
className={styles.scrollToTopButton}
|
||||
variant="secondary"
|
||||
onClick={onScrollToTopClick}
|
||||
title="Scroll to top"
|
||||
title={t('logs.logs-navigation.scroll-top', 'Scroll to top')}
|
||||
>
|
||||
<Icon name="arrow-up" size="lg" />
|
||||
</Button>
|
||||
@@ -244,6 +265,16 @@ const getStyles = (theme: GrafanaTheme2, oldestLogsFirst: boolean) => {
|
||||
height: '100%',
|
||||
whiteSpace: 'normal',
|
||||
}),
|
||||
scrollToBottomButton: css({
|
||||
width: '40px',
|
||||
height: '40px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
}),
|
||||
scrollToTopButton: css({
|
||||
width: '40px',
|
||||
height: '40px',
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import { css } from '@emotion/css';
|
||||
import { CSSProperties, useEffect, useRef } from 'react';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { useTheme2 } from '@grafana/ui';
|
||||
|
||||
import { ProcessedLogModel } from './processing';
|
||||
import { hasUnderOrOverflow } from './virtualization';
|
||||
|
||||
interface Props {
|
||||
index: number;
|
||||
log: ProcessedLogModel;
|
||||
showTime: boolean;
|
||||
style: CSSProperties;
|
||||
onOverflow?: (index: number, id: string, height: number) => void;
|
||||
wrapLogMessage: boolean;
|
||||
}
|
||||
|
||||
export const LogLine = ({ index, log, style, onOverflow, showTime, wrapLogMessage }: Props) => {
|
||||
const theme = useTheme2();
|
||||
const styles = getStyles(theme);
|
||||
const logLineRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!onOverflow || !logLineRef.current) {
|
||||
return;
|
||||
}
|
||||
const calculatedHeight = typeof style.height === 'number' ? style.height : undefined;
|
||||
const actualHeight = hasUnderOrOverflow(logLineRef.current, calculatedHeight);
|
||||
if (actualHeight) {
|
||||
onOverflow(index, log.uid, actualHeight);
|
||||
}
|
||||
}, [index, log.uid, onOverflow, style.height]);
|
||||
|
||||
return (
|
||||
<div style={style} className={styles.logLine} ref={onOverflow ? logLineRef : undefined}>
|
||||
<div className={wrapLogMessage ? styles.wrappedLogLine : styles.unwrappedLogLine}>
|
||||
{showTime && <span className={`${styles.timestamp} level-${log.logLevel}`}>{log.timestamp}</span>}
|
||||
{log.logLevel && <span className={`${styles.level} level-${log.logLevel}`}>{log.logLevel}</span>}
|
||||
{log.body}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => {
|
||||
const colors = {
|
||||
critical: '#B877D9',
|
||||
error: '#FF5286',
|
||||
warning: '#FBAD37',
|
||||
debug: '#6CCF8E',
|
||||
trace: '#6ed0e0',
|
||||
info: '#6E9FFF',
|
||||
};
|
||||
|
||||
return {
|
||||
logLine: css({
|
||||
color: theme.colors.text.primary,
|
||||
fontFamily: theme.typography.fontFamilyMonospace,
|
||||
fontSize: theme.typography.fontSize,
|
||||
wordBreak: 'break-all',
|
||||
'&:hover': {
|
||||
opacity: 0.9,
|
||||
},
|
||||
}),
|
||||
timestamp: css({
|
||||
color: theme.colors.text.secondary,
|
||||
display: 'inline-block',
|
||||
marginRight: theme.spacing(1),
|
||||
'&.level-critical': {
|
||||
color: colors.critical,
|
||||
},
|
||||
'&.level-error': {
|
||||
color: colors.error,
|
||||
},
|
||||
'&.level-warning': {
|
||||
color: colors.warning,
|
||||
},
|
||||
'&.level-debug': {
|
||||
color: colors.debug,
|
||||
},
|
||||
}),
|
||||
level: css({
|
||||
color: theme.colors.text.secondary,
|
||||
fontWeight: theme.typography.fontWeightBold,
|
||||
display: 'inline-block',
|
||||
marginRight: theme.spacing(1),
|
||||
'&.level-critical': {
|
||||
color: colors.critical,
|
||||
},
|
||||
'&.level-error': {
|
||||
color: colors.error,
|
||||
},
|
||||
'&.level-warning': {
|
||||
color: colors.warning,
|
||||
},
|
||||
'&.level-info': {
|
||||
color: colors.info,
|
||||
},
|
||||
'&.level-debug': {
|
||||
color: colors.debug,
|
||||
},
|
||||
}),
|
||||
overflows: css({
|
||||
outline: 'solid 1px red',
|
||||
}),
|
||||
unwrappedLogLine: css({
|
||||
whiteSpace: 'pre',
|
||||
paddingBottom: theme.spacing(0.5),
|
||||
}),
|
||||
wrappedLogLine: css({
|
||||
whiteSpace: 'pre-wrap',
|
||||
paddingBottom: theme.spacing(0.5),
|
||||
}),
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,135 @@
|
||||
import { debounce } from 'lodash';
|
||||
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||
import { ListChildComponentProps, VariableSizeList } from 'react-window';
|
||||
|
||||
import { CoreApp, EventBus, LogRowModel, LogsSortOrder } from '@grafana/data';
|
||||
import { useTheme2 } from '@grafana/ui';
|
||||
|
||||
import { LogLine } from './LogLine';
|
||||
import { preProcessLogs, ProcessedLogModel } from './processing';
|
||||
import {
|
||||
getLogLineSize,
|
||||
init as initVirtualization,
|
||||
resetLogLineSizes,
|
||||
ScrollToLogsEvent,
|
||||
storeLogLineSize,
|
||||
} from './virtualization';
|
||||
|
||||
interface Props {
|
||||
app: CoreApp;
|
||||
logs: LogRowModel[];
|
||||
containerElement: HTMLDivElement;
|
||||
eventBus: EventBus;
|
||||
forceEscape?: boolean;
|
||||
showTime: boolean;
|
||||
sortOrder: LogsSortOrder;
|
||||
timeZone: string;
|
||||
wrapLogMessage: boolean;
|
||||
}
|
||||
|
||||
export const LogList = ({
|
||||
app,
|
||||
containerElement,
|
||||
logs,
|
||||
eventBus,
|
||||
forceEscape = false,
|
||||
showTime,
|
||||
sortOrder,
|
||||
timeZone,
|
||||
wrapLogMessage,
|
||||
}: Props) => {
|
||||
const [processedLogs, setProcessedLogs] = useState<ProcessedLogModel[]>([]);
|
||||
const [listHeight, setListHeight] = useState(
|
||||
app === CoreApp.Explore ? window.innerHeight * 0.75 : containerElement.clientHeight
|
||||
);
|
||||
const theme = useTheme2();
|
||||
const listRef = useRef<VariableSizeList | null>(null);
|
||||
const widthRef = useRef(containerElement.clientWidth);
|
||||
|
||||
useEffect(() => {
|
||||
initVirtualization(theme);
|
||||
}, [theme]);
|
||||
|
||||
useEffect(() => {
|
||||
const subscription = eventBus.subscribe(ScrollToLogsEvent, (e: ScrollToLogsEvent) => {
|
||||
if (e.payload.scrollTo === 'top') {
|
||||
listRef.current?.scrollTo(0);
|
||||
} else {
|
||||
listRef.current?.scrollToItem(processedLogs.length - 1);
|
||||
}
|
||||
});
|
||||
return () => subscription.unsubscribe();
|
||||
}, [eventBus, processedLogs.length]);
|
||||
|
||||
useEffect(() => {
|
||||
setProcessedLogs(preProcessLogs(logs, { wrap: wrapLogMessage, escape: forceEscape, order: sortOrder, timeZone }));
|
||||
listRef.current?.resetAfterIndex(0);
|
||||
listRef.current?.scrollTo(0);
|
||||
}, [forceEscape, logs, sortOrder, timeZone, wrapLogMessage]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleResize = debounce(() => {
|
||||
setListHeight(app === CoreApp.Explore ? window.innerHeight * 0.75 : containerElement.clientHeight);
|
||||
}, 50);
|
||||
window.addEventListener('resize', handleResize);
|
||||
handleResize();
|
||||
return () => {
|
||||
window.removeEventListener('resize', handleResize);
|
||||
};
|
||||
}, [app, containerElement.clientHeight]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (widthRef.current === containerElement.clientWidth) {
|
||||
return;
|
||||
}
|
||||
resetLogLineSizes();
|
||||
listRef.current?.resetAfterIndex(0);
|
||||
widthRef.current = containerElement.clientWidth;
|
||||
});
|
||||
|
||||
const handleOverflow = useCallback(
|
||||
(index: number, id: string, height: number) => {
|
||||
if (containerElement) {
|
||||
storeLogLineSize(id, containerElement, height);
|
||||
listRef.current?.resetAfterIndex(index);
|
||||
}
|
||||
},
|
||||
[containerElement]
|
||||
);
|
||||
|
||||
const Renderer = useCallback(
|
||||
({ index, style }: ListChildComponentProps) => {
|
||||
return (
|
||||
<LogLine
|
||||
index={index}
|
||||
log={processedLogs[index]}
|
||||
showTime={showTime}
|
||||
style={style}
|
||||
wrapLogMessage={wrapLogMessage}
|
||||
onOverflow={handleOverflow}
|
||||
/>
|
||||
);
|
||||
},
|
||||
[handleOverflow, processedLogs, showTime, wrapLogMessage]
|
||||
);
|
||||
|
||||
if (!containerElement || listHeight == null) {
|
||||
// Wait for container to be rendered
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<VariableSizeList
|
||||
height={listHeight}
|
||||
itemCount={processedLogs.length}
|
||||
itemSize={getLogLineSize.bind(null, processedLogs, containerElement, { wrap: wrapLogMessage, showTime })}
|
||||
itemKey={(index: number) => processedLogs[index].uid}
|
||||
layout="vertical"
|
||||
ref={listRef}
|
||||
style={{ overflowY: 'scroll' }}
|
||||
width="100%"
|
||||
>
|
||||
{Renderer}
|
||||
</VariableSizeList>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,66 @@
|
||||
import { dateTimeFormat, LogRowModel, LogsSortOrder } from '@grafana/data';
|
||||
|
||||
import { escapeUnescapedString, sortLogRows } from '../../utils';
|
||||
|
||||
import { measureTextWidth } from './virtualization';
|
||||
|
||||
export interface ProcessedLogModel extends LogRowModel {
|
||||
body: string;
|
||||
timestamp: string;
|
||||
dimensions: LogDimensions;
|
||||
}
|
||||
|
||||
export interface LogDimensions {
|
||||
timestampWidth: number;
|
||||
levelWidth: number;
|
||||
}
|
||||
|
||||
interface PreProcessOptions {
|
||||
escape: boolean;
|
||||
order: LogsSortOrder;
|
||||
timeZone: string;
|
||||
wrap: boolean;
|
||||
}
|
||||
|
||||
export const preProcessLogs = (
|
||||
logs: LogRowModel[],
|
||||
{ escape, order, timeZone, wrap }: PreProcessOptions
|
||||
): ProcessedLogModel[] => {
|
||||
const orderedLogs = sortLogRows(logs, order);
|
||||
return orderedLogs.map((log) => preProcessLog(log, { wrap, escape, timeZone, expanded: false }));
|
||||
};
|
||||
|
||||
interface PreProcessLogOptions {
|
||||
escape: boolean;
|
||||
expanded: boolean; // Not yet implemented
|
||||
timeZone: string;
|
||||
wrap: boolean;
|
||||
}
|
||||
const preProcessLog = (
|
||||
log: LogRowModel,
|
||||
{ escape, expanded, timeZone, wrap }: PreProcessLogOptions
|
||||
): ProcessedLogModel => {
|
||||
let body = log.entry;
|
||||
const timestamp = dateTimeFormat(log.timeEpochMs, {
|
||||
timeZone,
|
||||
defaultWithMS: true,
|
||||
});
|
||||
|
||||
if (escape && log.hasUnescapedContent) {
|
||||
body = escapeUnescapedString(body);
|
||||
}
|
||||
// With wrapping disabled, we want to turn it into a single-line log entry unless the line is expanded
|
||||
if (!wrap && !expanded) {
|
||||
body = body.replace(/(\r\n|\n|\r)/g, '');
|
||||
}
|
||||
|
||||
return {
|
||||
...log,
|
||||
body,
|
||||
timestamp,
|
||||
dimensions: {
|
||||
timestampWidth: measureTextWidth(timestamp),
|
||||
levelWidth: measureTextWidth(log.logLevel),
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,229 @@
|
||||
import { BusEventWithPayload, GrafanaTheme2 } from '@grafana/data';
|
||||
|
||||
import { ProcessedLogModel } from './processing';
|
||||
|
||||
let ctx: CanvasRenderingContext2D | null = null;
|
||||
let gridSize = 8;
|
||||
let paddingBottom = gridSize * 0.5;
|
||||
let lineHeight = 22;
|
||||
let measurementMode: 'canvas' | 'dom' = 'canvas';
|
||||
|
||||
export function init(theme: GrafanaTheme2) {
|
||||
const font = `${theme.typography.fontSize}px ${theme.typography.fontFamilyMonospace}`;
|
||||
const letterSpacing = theme.typography.body.letterSpacing;
|
||||
|
||||
initDOMmeasurement(font, letterSpacing);
|
||||
initCanvasMeasurement(font, letterSpacing);
|
||||
|
||||
gridSize = theme.spacing.gridSize;
|
||||
paddingBottom = gridSize * 0.5;
|
||||
lineHeight = theme.typography.fontSize * theme.typography.body.lineHeight;
|
||||
|
||||
widthMap = new Map<number, number>();
|
||||
resetLogLineSizes();
|
||||
|
||||
determineMeasurementMode();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function determineMeasurementMode() {
|
||||
if (!ctx) {
|
||||
measurementMode = 'dom';
|
||||
return;
|
||||
}
|
||||
const canvasCharWidth = ctx.measureText('e').width;
|
||||
const domCharWidth = measureTextWidthWithDOM('e');
|
||||
const diff = domCharWidth - canvasCharWidth;
|
||||
if (diff >= 0.1) {
|
||||
console.warn('Virtualized log list: falling back to DOM for measurement');
|
||||
measurementMode = 'dom';
|
||||
}
|
||||
}
|
||||
|
||||
function initCanvasMeasurement(font: string, letterSpacing: string | undefined) {
|
||||
const canvas = document.createElement('canvas');
|
||||
ctx = canvas.getContext('2d');
|
||||
if (!ctx) {
|
||||
return;
|
||||
}
|
||||
ctx.font = font;
|
||||
ctx.fontKerning = 'normal';
|
||||
ctx.fontStretch = 'normal';
|
||||
ctx.fontVariantCaps = 'normal';
|
||||
ctx.textRendering = 'optimizeLegibility';
|
||||
if (letterSpacing) {
|
||||
ctx.letterSpacing = letterSpacing;
|
||||
}
|
||||
}
|
||||
|
||||
const span = document.createElement('span');
|
||||
function initDOMmeasurement(font: string, letterSpacing: string | undefined) {
|
||||
span.style.font = font;
|
||||
span.style.visibility = 'hidden';
|
||||
span.style.position = 'absolute';
|
||||
span.style.wordBreak = 'break-all';
|
||||
if (letterSpacing) {
|
||||
span.style.letterSpacing = letterSpacing;
|
||||
}
|
||||
}
|
||||
|
||||
let widthMap = new Map<number, number>();
|
||||
export function measureTextWidth(text: string): number {
|
||||
if (!ctx) {
|
||||
throw new Error(`Measuring context canvas is not initialized. Call init() before.`);
|
||||
}
|
||||
const key = text.length;
|
||||
|
||||
const storedWidth = widthMap.get(key);
|
||||
if (storedWidth) {
|
||||
return storedWidth;
|
||||
}
|
||||
|
||||
const width = measurementMode === 'canvas' ? ctx.measureText(text).width : measureTextWidthWithDOM(text);
|
||||
widthMap.set(key, width);
|
||||
|
||||
return width;
|
||||
}
|
||||
|
||||
function measureTextWidthWithDOM(text: string) {
|
||||
span.textContent = text;
|
||||
|
||||
document.body.appendChild(span);
|
||||
const width = span.getBoundingClientRect().width;
|
||||
document.body.removeChild(span);
|
||||
|
||||
return width;
|
||||
}
|
||||
|
||||
export function measureTextHeight(text: string, maxWidth: number, beforeWidth = 0) {
|
||||
let logLines = 0;
|
||||
const charWidth = measureTextWidth('e');
|
||||
let logLineCharsLength = Math.round(maxWidth / charWidth);
|
||||
const firstLineCharsLength = Math.floor((maxWidth - beforeWidth) / charWidth) - 2 * charWidth;
|
||||
const textLines = text.split('\n');
|
||||
|
||||
// Skip unnecessary measurements
|
||||
if (textLines.length === 1 && text.length < firstLineCharsLength) {
|
||||
return {
|
||||
lines: 1,
|
||||
height: lineHeight + paddingBottom,
|
||||
};
|
||||
}
|
||||
|
||||
for (const textLine of textLines) {
|
||||
for (let start = 0; start < textLine.length; ) {
|
||||
let testLogLine: string;
|
||||
let width = 0;
|
||||
let delta = 0;
|
||||
let availableWidth = maxWidth - beforeWidth;
|
||||
do {
|
||||
testLogLine = textLine.substring(start, start + logLineCharsLength - delta);
|
||||
width = measureTextWidth(testLogLine);
|
||||
delta += 1;
|
||||
} while (width >= availableWidth);
|
||||
if (beforeWidth) {
|
||||
beforeWidth = 0;
|
||||
}
|
||||
logLines += 1;
|
||||
start += testLogLine.length;
|
||||
}
|
||||
}
|
||||
|
||||
const height = logLines * lineHeight + paddingBottom;
|
||||
|
||||
return {
|
||||
lines: logLines,
|
||||
height,
|
||||
};
|
||||
}
|
||||
|
||||
interface DisplayOptions {
|
||||
wrap: boolean;
|
||||
showTime: boolean;
|
||||
}
|
||||
|
||||
export function getLogLineSize(
|
||||
logs: ProcessedLogModel[],
|
||||
container: HTMLDivElement | null,
|
||||
{ wrap, showTime }: DisplayOptions,
|
||||
index: number
|
||||
) {
|
||||
if (!container) {
|
||||
return 0;
|
||||
}
|
||||
if (!wrap) {
|
||||
return lineHeight + paddingBottom;
|
||||
}
|
||||
const storedSize = retrieveLogLineSize(logs[index].uid, container);
|
||||
if (storedSize) {
|
||||
return storedSize;
|
||||
}
|
||||
const gap = gridSize;
|
||||
let optionsWidth = 0;
|
||||
if (showTime) {
|
||||
optionsWidth += logs[index].dimensions.timestampWidth + gap;
|
||||
}
|
||||
if (logs[index].logLevel) {
|
||||
optionsWidth += logs[index].dimensions.levelWidth + gap;
|
||||
}
|
||||
const { height } = measureTextHeight(logs[index].body, getLogContainerWidth(container), optionsWidth);
|
||||
return height;
|
||||
}
|
||||
|
||||
export function hasUnderOrOverflow(element: HTMLDivElement, calculatedHeight?: number): number | null {
|
||||
const height = calculatedHeight ?? element.clientHeight;
|
||||
if (element.scrollHeight > height) {
|
||||
return element.scrollHeight;
|
||||
}
|
||||
const child = element.firstChild;
|
||||
if (child instanceof HTMLDivElement && child.clientHeight < height) {
|
||||
return child.clientHeight;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const scrollBarWidth = getScrollbarWidth();
|
||||
|
||||
export function getLogContainerWidth(container: HTMLDivElement) {
|
||||
return container.clientWidth - scrollBarWidth;
|
||||
}
|
||||
|
||||
export function getScrollbarWidth() {
|
||||
const hiddenDiv = document.createElement('div');
|
||||
|
||||
hiddenDiv.style.width = '100px';
|
||||
hiddenDiv.style.height = '100px';
|
||||
hiddenDiv.style.overflow = 'scroll';
|
||||
hiddenDiv.style.position = 'absolute';
|
||||
hiddenDiv.style.top = '-9999px';
|
||||
|
||||
document.body.appendChild(hiddenDiv);
|
||||
const width = hiddenDiv.offsetWidth - hiddenDiv.clientWidth;
|
||||
document.body.removeChild(hiddenDiv);
|
||||
|
||||
return width;
|
||||
}
|
||||
|
||||
let logLineSizesMap = new Map<string, number>();
|
||||
export function resetLogLineSizes() {
|
||||
logLineSizesMap = new Map<string, number>();
|
||||
}
|
||||
|
||||
export function storeLogLineSize(id: string, container: HTMLDivElement, height: number) {
|
||||
const key = `${id}_${getLogContainerWidth(container)}`;
|
||||
logLineSizesMap.set(key, height);
|
||||
}
|
||||
|
||||
export function retrieveLogLineSize(id: string, container: HTMLDivElement) {
|
||||
const key = `${id}_${getLogContainerWidth(container)}`;
|
||||
return logLineSizesMap.get(key);
|
||||
}
|
||||
|
||||
export interface ScrollToLogsEventPayload {
|
||||
scrollTo: 'top' | 'bottom';
|
||||
}
|
||||
|
||||
export class ScrollToLogsEvent extends BusEventWithPayload<ScrollToLogsEventPayload> {
|
||||
static type = 'logs-panel-scroll-to';
|
||||
}
|
||||
@@ -92,13 +92,11 @@ export function dedupLogRows(rows: LogRowModel[], strategy?: LogsDedupStrategy):
|
||||
}
|
||||
|
||||
return rows.reduce((result: LogRowModel[], row: LogRowModel, index) => {
|
||||
const rowCopy = { ...row };
|
||||
const previous = result[result.length - 1];
|
||||
if (index > 0 && isDuplicateRow(row, previous, strategy)) {
|
||||
previous.duplicates!++;
|
||||
} else {
|
||||
rowCopy.duplicates = 0;
|
||||
result.push(rowCopy);
|
||||
result.push({ ...row, duplicates: 0 });
|
||||
}
|
||||
return result;
|
||||
}, []);
|
||||
|
||||
@@ -45,6 +45,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 newLogsPanel = async () =>
|
||||
await import(/* webpackChunkName: "newLogsPanel" */ 'app/plugins/panel/logs-new/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');
|
||||
@@ -116,6 +118,7 @@ const builtInPlugins: Record<string, System.Module | (() => Promise<System.Modul
|
||||
'core:plugin/bargauge': barGaugePanel,
|
||||
'core:plugin/barchart': barChartPanel,
|
||||
'core:plugin/logs': logsPanel,
|
||||
'core:plugin/logs-new': newLogsPanel,
|
||||
'core:plugin/traces': tracesPanel,
|
||||
'core:plugin/welcome': welcomeBanner,
|
||||
'core:plugin/nodeGraph': nodeGraph,
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import { css } from '@emotion/css';
|
||||
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { CoreApp, GrafanaTheme2, LogsSortOrder, PanelProps } from '@grafana/data';
|
||||
import { usePanelContext, useStyles2 } from '@grafana/ui';
|
||||
import { LogList } from 'app/features/logs/components/panel/LogList';
|
||||
import { ScrollToLogsEvent } from 'app/features/logs/components/panel/virtualization';
|
||||
import { PanelDataErrorView } from 'app/features/panel/components/PanelDataErrorView';
|
||||
|
||||
import { dataFrameToLogsModel, dedupLogRows } from '../../../features/logs/logsModel';
|
||||
|
||||
import { Options } from './panelcfg.gen';
|
||||
|
||||
interface LogsPanelProps extends PanelProps<Options> {}
|
||||
|
||||
export const LogsPanel = ({
|
||||
data,
|
||||
timeZone,
|
||||
fieldConfig,
|
||||
options: { showTime, wrapLogMessage, sortOrder, dedupStrategy },
|
||||
id,
|
||||
}: LogsPanelProps) => {
|
||||
const isAscending = sortOrder === LogsSortOrder.Ascending;
|
||||
const style = useStyles2(getStyles);
|
||||
const [logsContainer, setLogsContainer] = useState<HTMLDivElement | null>(null);
|
||||
const [panelData, setPanelData] = useState(data);
|
||||
// Prevents the scroll position to change when new data from infinite scrolling is received
|
||||
const keepScrollPositionRef = useRef(false);
|
||||
const { eventBus } = usePanelContext();
|
||||
|
||||
const logs = useMemo(() => {
|
||||
const logsModel = panelData
|
||||
? dataFrameToLogsModel(panelData.series, data.request?.intervalMs, undefined, data.request?.targets)
|
||||
: null;
|
||||
return logsModel ? dedupLogRows(logsModel.rows, dedupStrategy) : [];
|
||||
}, [data.request?.intervalMs, data.request?.targets, dedupStrategy, panelData]);
|
||||
|
||||
useEffect(() => {
|
||||
setPanelData(data);
|
||||
}, [data]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (keepScrollPositionRef.current) {
|
||||
keepScrollPositionRef.current = false;
|
||||
return;
|
||||
}
|
||||
/**
|
||||
* In dashboards, users with newest logs at the bottom have the expectation of keeping the scroll at the bottom
|
||||
* when new data is received. See https://github.com/grafana/grafana/pull/37634
|
||||
*/
|
||||
if (data.request?.app === CoreApp.Dashboard || data.request?.app === CoreApp.PanelEditor) {
|
||||
eventBus.publish(
|
||||
new ScrollToLogsEvent({
|
||||
scrollTo: isAscending ? 'top' : 'bottom',
|
||||
})
|
||||
);
|
||||
}
|
||||
}, [data.request?.app, eventBus, isAscending, logs]);
|
||||
|
||||
if (!logs.length) {
|
||||
return <PanelDataErrorView fieldConfig={fieldConfig} panelId={id} data={data} needsStringField />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={style.container} ref={(element: HTMLDivElement) => setLogsContainer(element)}>
|
||||
{logs.length > 0 && logsContainer && (
|
||||
<LogList
|
||||
app={CoreApp.Dashboard}
|
||||
containerElement={logsContainer}
|
||||
eventBus={eventBus}
|
||||
logs={logs}
|
||||
showTime={showTime}
|
||||
sortOrder={sortOrder}
|
||||
timeZone={timeZone}
|
||||
wrapLogMessage={wrapLogMessage}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => ({
|
||||
container: css({
|
||||
marginBottom: theme.spacing(1.5),
|
||||
minHeight: '100%',
|
||||
maxHeight: '100%',
|
||||
display: 'flex',
|
||||
flex: 1,
|
||||
flexDirection: 'column',
|
||||
}),
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 80.49 80.99"><defs><style>.cls-1{fill:#3865ab;}.cls-2{fill:#8ab8ff;}.cls-3{fill:url(#linear-gradient);}.cls-4{fill:url(#linear-gradient-2);}.cls-5{fill:#3a76d0;}</style><linearGradient id="linear-gradient" x1="-3918.19" y1="8047.29" x2="-3910.1" y2="8047.29" gradientTransform="translate(-3910.1 8051.33) rotate(180)" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#f2cc0c"/><stop offset="1" stop-color="#ff9830"/></linearGradient><linearGradient id="linear-gradient-2" x1="-3918.19" y1="8010.84" x2="-3910.1" y2="8010.84" xlink:href="#linear-gradient"/></defs><g id="Layer_2" data-name="Layer 2"><g id="Layer_1-2" data-name="Layer 1"><path class="cls-1" d="M79.49,8H14.54V0H79.49a1,1,0,0,1,1,1V7A1,1,0,0,1,79.49,8Z"/><path class="cls-1" d="M59.49,26.27H14.54v-8h45a1,1,0,0,1,1,1v6A1,1,0,0,1,59.49,26.27Z"/><path class="cls-2" d="M6.92,19.38a4,4,0,0,0-1.34-.85,4.1,4.1,0,0,0-3.08,0,4,4,0,0,0-1.33.85,4,4,0,0,0-.85,1.34,4,4,0,0,0,.85,4.41A4.44,4.44,0,0,0,2.5,26a3.76,3.76,0,0,0,3.08,0,4.61,4.61,0,0,0,1.34-.85,4.19,4.19,0,0,0,0-5.75Z"/><path class="cls-1" d="M48.49,44.49H14.54v-8h34a1,1,0,0,1,1,1v6A1,1,0,0,1,48.49,44.49Z"/><path class="cls-1" d="M79.49,81H14.54V73H79.49a1,1,0,0,1,1,1v6A1,1,0,0,1,79.49,81Z"/><path class="cls-2" d="M7.77,75.4a4,4,0,0,1-.85,4.41,4.61,4.61,0,0,1-1.34.85,3.76,3.76,0,0,1-3.08,0,4.44,4.44,0,0,1-1.33-.85A4,4,0,0,1,.32,75.4a4,4,0,0,1,.85-1.34,4,4,0,0,1,1.33-.85,4.1,4.1,0,0,1,3.08,0,4,4,0,0,1,1.34.85A4,4,0,0,1,7.77,75.4Z"/><path class="cls-3" d="M6.92,1.15A4.36,4.36,0,0,0,5.58.3,4.1,4.1,0,0,0,2.5.3a4.32,4.32,0,0,0-1.33.85A4,4,0,0,0,.32,2.49a4,4,0,0,0,.85,4.42,4.87,4.87,0,0,0,1.33.85,3.84,3.84,0,0,0,3.08,0,5.07,5.07,0,0,0,1.34-.85,4.07,4.07,0,0,0,.85-4.42A4,4,0,0,0,6.92,1.15Z"/><path class="cls-4" d="M6.92,37.61a4,4,0,0,0-1.34-.85,4,4,0,0,0-3.08,0,4,4,0,0,0-1.33.85,4,4,0,0,0-.85,1.33,4,4,0,0,0,.85,4.42,4.64,4.64,0,0,0,1.33.85,3.84,3.84,0,0,0,3.08,0,4.83,4.83,0,0,0,1.34-.85,4.07,4.07,0,0,0,.85-4.42A4,4,0,0,0,6.92,37.61Z"/><path class="cls-1" d="M70.49,62.72H14.54v-8h56a1,1,0,0,1,1,1v6A1,1,0,0,1,70.49,62.72Z"/><path class="cls-5" d="M6.92,55.83A4.36,4.36,0,0,0,5.58,55,4.1,4.1,0,0,0,2.5,55a4.32,4.32,0,0,0-1.33.85,4.2,4.2,0,0,0,0,5.76,4.87,4.87,0,0,0,1.33.85,3.84,3.84,0,0,0,3.08,0,5.07,5.07,0,0,0,1.34-.85,4.07,4.07,0,0,0,.85-4.42A4,4,0,0,0,6.92,55.83Z"/></g></g></svg>
|
||||
|
After Width: | Height: | Size: 2.4 KiB |
@@ -0,0 +1,73 @@
|
||||
import { PanelPlugin, LogsSortOrder, LogsDedupStrategy, LogsDedupDescription } from '@grafana/data';
|
||||
|
||||
import { LogsPanel } from './LogsPanel';
|
||||
import { Options } from './panelcfg.gen';
|
||||
import { LogsPanelSuggestionsSupplier } from './suggestions';
|
||||
|
||||
export const plugin = new PanelPlugin<Options>(LogsPanel)
|
||||
.setPanelOptions((builder) => {
|
||||
builder
|
||||
.addBooleanSwitch({
|
||||
path: 'showTime',
|
||||
name: 'Time',
|
||||
description: '',
|
||||
defaultValue: false,
|
||||
})
|
||||
.addBooleanSwitch({
|
||||
path: 'wrapLogMessage',
|
||||
name: 'Wrap lines',
|
||||
description: '',
|
||||
defaultValue: false,
|
||||
})
|
||||
.addBooleanSwitch({
|
||||
path: 'enableLogDetails',
|
||||
name: 'Enable log details',
|
||||
description: '',
|
||||
defaultValue: true,
|
||||
})
|
||||
.addBooleanSwitch({
|
||||
path: 'enableInfiniteScrolling',
|
||||
name: 'Enable infinite scrolling',
|
||||
description: 'Experimental. Request more results by scrolling to the bottom of the logs list.',
|
||||
defaultValue: false,
|
||||
})
|
||||
.addRadio({
|
||||
path: 'dedupStrategy',
|
||||
name: 'Deduplication',
|
||||
description: '',
|
||||
settings: {
|
||||
options: [
|
||||
{ value: LogsDedupStrategy.none, label: 'None', description: LogsDedupDescription[LogsDedupStrategy.none] },
|
||||
{
|
||||
value: LogsDedupStrategy.exact,
|
||||
label: 'Exact',
|
||||
description: LogsDedupDescription[LogsDedupStrategy.exact],
|
||||
},
|
||||
{
|
||||
value: LogsDedupStrategy.numbers,
|
||||
label: 'Numbers',
|
||||
description: LogsDedupDescription[LogsDedupStrategy.numbers],
|
||||
},
|
||||
{
|
||||
value: LogsDedupStrategy.signature,
|
||||
label: 'Signature',
|
||||
description: LogsDedupDescription[LogsDedupStrategy.signature],
|
||||
},
|
||||
],
|
||||
},
|
||||
defaultValue: LogsDedupStrategy.none,
|
||||
})
|
||||
.addRadio({
|
||||
path: 'sortOrder',
|
||||
name: 'Order',
|
||||
description: '',
|
||||
settings: {
|
||||
options: [
|
||||
{ value: LogsSortOrder.Descending, label: 'Newest first' },
|
||||
{ value: LogsSortOrder.Ascending, label: 'Oldest first' },
|
||||
],
|
||||
},
|
||||
defaultValue: LogsSortOrder.Descending,
|
||||
});
|
||||
})
|
||||
.setSuggestionsSupplier(new LogsPanelSuggestionsSupplier());
|
||||
@@ -0,0 +1,40 @@
|
||||
// Copyright 2023 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: {
|
||||
showTime: bool
|
||||
wrapLogMessage: bool
|
||||
enableLogDetails: bool
|
||||
sortOrder: common.LogsSortOrder
|
||||
dedupStrategy: common.LogsDedupStrategy
|
||||
enableInfiniteScrolling?: bool
|
||||
} @cuetsy(kind="interface")
|
||||
}
|
||||
}]
|
||||
lenses: []
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
// 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.
|
||||
|
||||
import * as common from '@grafana/schema';
|
||||
|
||||
export interface Options {
|
||||
dedupStrategy: common.LogsDedupStrategy;
|
||||
enableInfiniteScrolling?: boolean;
|
||||
enableLogDetails: boolean;
|
||||
showTime: boolean;
|
||||
sortOrder: common.LogsSortOrder;
|
||||
wrapLogMessage: boolean;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"type": "panel",
|
||||
"name": "Logs (new)",
|
||||
"id": "logs-new",
|
||||
"state": "alpha",
|
||||
|
||||
"info": {
|
||||
"author": {
|
||||
"name": "Grafana Labs",
|
||||
"url": "https://grafana.com"
|
||||
},
|
||||
"logos": {
|
||||
"small": "img/icn-logs-panel.svg",
|
||||
"large": "img/icn-logs-panel.svg"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { VisualizationSuggestionsBuilder, VisualizationSuggestionScore } from '@grafana/data';
|
||||
import { SuggestionName } from 'app/types/suggestions';
|
||||
|
||||
import { Options } from './panelcfg.gen';
|
||||
|
||||
export class LogsPanelSuggestionsSupplier {
|
||||
getSuggestionsForData(builder: VisualizationSuggestionsBuilder) {
|
||||
const list = builder.getListAppender<Options, {}>({
|
||||
name: '',
|
||||
pluginId: 'logs-new',
|
||||
options: {},
|
||||
fieldConfig: {
|
||||
defaults: {
|
||||
custom: {},
|
||||
},
|
||||
overrides: [],
|
||||
},
|
||||
});
|
||||
|
||||
const { dataSummary: ds } = builder;
|
||||
|
||||
// Require a string & time field
|
||||
if (!ds.hasData || !ds.hasTimeField || !ds.hasStringField) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (ds.preferredVisualisationType === 'logs') {
|
||||
list.append({ name: SuggestionName.Logs, score: VisualizationSuggestionScore.Best });
|
||||
} else {
|
||||
list.append({ name: SuggestionName.Logs });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1911,6 +1911,13 @@
|
||||
"shortcut": "alt+select to enable again"
|
||||
}
|
||||
},
|
||||
"logs-navigation": {
|
||||
"newer-logs": "Newer logs",
|
||||
"older-logs": "Older logs",
|
||||
"scroll-bottom": "Scroll to bottom",
|
||||
"scroll-top": "Scroll to top",
|
||||
"start-of-range": "Start of range"
|
||||
},
|
||||
"popover-menu": {
|
||||
"copy": "Copy selection",
|
||||
"disable-menu": "Disable menu",
|
||||
|
||||
@@ -1911,6 +1911,13 @@
|
||||
"shortcut": "äľŧ+şęľęčŧ ŧő ęʼnäþľę äģäįʼn"
|
||||
}
|
||||
},
|
||||
"logs-navigation": {
|
||||
"newer-logs": "Ńęŵęř ľőģş",
|
||||
"older-logs": "Øľđęř ľőģş",
|
||||
"scroll-bottom": "Ŝčřőľľ ŧő þőŧŧőm",
|
||||
"scroll-top": "Ŝčřőľľ ŧő ŧőp",
|
||||
"start-of-range": "Ŝŧäřŧ őƒ řäʼnģę"
|
||||
},
|
||||
"popover-menu": {
|
||||
"copy": "Cőpy şęľęčŧįőʼn",
|
||||
"disable-menu": "Đįşäþľę męʼnū",
|
||||
|
||||
Reference in New Issue
Block a user