diff --git a/.betterer.results b/.betterer.results index b8241585e54..57c89589246 100644 --- a/.betterer.results +++ b/.betterer.results @@ -4335,8 +4335,7 @@ exports[`better eslint`] = { [0, 0, 0, "Unexpected any. Specify a different type.", "0"] ], "public/app/features/trails/DataTrailCard.tsx:5381": [ - [0, 0, 0, "No untranslated strings. Wrap text with ", "0"], - [0, 0, 0, "No untranslated strings. Wrap text with ", "1"] + [0, 0, 0, "No untranslated strings. Wrap text with ", "0"] ], "public/app/features/trails/DataTrailSettings.tsx:5381": [ [0, 0, 0, "No untranslated strings. Wrap text with ", "0"] @@ -4344,11 +4343,6 @@ exports[`better eslint`] = { "public/app/features/trails/DataTrailsHistory.tsx:5381": [ [0, 0, 0, "No untranslated strings. Wrap text with ", "0"] ], - "public/app/features/trails/DataTrailsHome.tsx:5381": [ - [0, 0, 0, "No untranslated strings. Wrap text with ", "0"], - [0, 0, 0, "No untranslated strings. Wrap text with ", "1"], - [0, 0, 0, "No untranslated strings. Wrap text with ", "2"] - ], "public/app/features/trails/MetricScene.tsx:5381": [ [0, 0, 0, "No untranslated strings. Wrap text with ", "0"], [0, 0, 0, "No untranslated strings. Wrap text with ", "1"] diff --git a/public/app/features/trails/DataTrail.tsx b/public/app/features/trails/DataTrail.tsx index 0a7c491888d..6af2c2405a9 100644 --- a/public/app/features/trails/DataTrail.tsx +++ b/public/app/features/trails/DataTrail.tsx @@ -137,8 +137,14 @@ export class DataTrail extends SceneObjectBase { ); } - // Save the current trail as a recent if the browser closes or reloads - const saveRecentTrail = () => getTrailStore().setRecentTrail(this); + // Save the current trail as a recent (if the browser closes or reloads) if user selects a metric OR applies filters to metric select view + const saveRecentTrail = () => { + const filtersVariable = sceneGraph.lookupVariable(VAR_FILTERS, this); + const hasFilters = filtersVariable instanceof AdHocFiltersVariable && filtersVariable.state.filters.length > 0; + if (this.state.metric || hasFilters) { + getTrailStore().setRecentTrail(this); + } + }; window.addEventListener('unload', saveRecentTrail); return () => { diff --git a/public/app/features/trails/DataTrailBookmarks.tsx b/public/app/features/trails/DataTrailBookmarks.tsx new file mode 100644 index 00000000000..8c13f5d6e3f --- /dev/null +++ b/public/app/features/trails/DataTrailBookmarks.tsx @@ -0,0 +1,97 @@ +import { css } from '@emotion/css'; +import { useState } from 'react'; + +import { GrafanaTheme2 } from '@grafana/data'; +import { SceneComponentProps } from '@grafana/scenes'; +import { IconButton, useStyles2 } from '@grafana/ui'; +import { Trans } from 'app/core/internationalization'; + +import { DataTrailCard } from './DataTrailCard'; +import { DataTrailsHome } from './DataTrailsHome'; +import { getTrailStore, getBookmarkKey } from './TrailStore/TrailStore'; + +interface Props extends SceneComponentProps { + onDelete: (index: number) => void; +} + +export function DataTrailsBookmarks({ model, onDelete }: Props) { + const [toggleBookmark, setToggleBookmark] = useState(false); + const styles = useStyles2(getStyles); + + if (getTrailStore().bookmarks.length === 0) { + return null; + } + + return ( + <> +
+
+
+ Or view bookmarks +
+ setToggleBookmark(!toggleBookmark)} + /> +
+ {toggleBookmark && ( +
+ {getTrailStore().bookmarks.map((bookmark, index) => { + return ( + model.onSelectBookmark(index)} + onDelete={() => onDelete(index)} + /> + ); + })} +
+ )} + + ); +} + +function getStyles(theme: GrafanaTheme2) { + return { + trailList: css({ + display: 'grid', + gridTemplateColumns: 'repeat(3, 1fr)', + gap: `${theme.spacing(4)}`, + alignItems: 'stretch', + justifyItems: 'center', + }), + gap20: css({ + marginTop: theme.spacing(3), + }), + bottomGap24: css({ + marginBottom: theme.spacing(3), + }), + bookmarkHeader: css({ + display: 'flex', + flexDirection: 'row', + alignItems: 'center', + }), + header: css({ + color: theme.colors.text.primary, + textAlign: 'center', + /* H4 */ + fontFamily: 'Inter', + fontSize: '18px', + fontStyle: 'normal', + fontWeight: '400', + lineHeight: '22px' /* 122.222% */, + letterSpacing: '0.045px', + }), + horizontalLine: css({ + width: '400px', + height: '1px', + background: theme.colors.border.weak, + margin: '0 auto', // Center line horizontally + marginTop: '32px', + }), + }; +} diff --git a/public/app/features/trails/DataTrailCard.tsx b/public/app/features/trails/DataTrailCard.tsx index e3b508a85b4..c671104c6e7 100644 --- a/public/app/features/trails/DataTrailCard.tsx +++ b/public/app/features/trails/DataTrailCard.tsx @@ -3,7 +3,8 @@ import { useMemo } from 'react'; import { dateTimeFormat, GrafanaTheme2 } from '@grafana/data'; import { AdHocFiltersVariable, sceneGraph } from '@grafana/scenes'; -import { Card, IconButton, Stack, Tag, useStyles2 } from '@grafana/ui'; +import { Card, IconButton, useStyles2 } from '@grafana/ui'; +import { Trans } from 'app/core/internationalization'; import { DataTrail } from './DataTrail'; import { getTrailStore, DataTrailBookmark } from './TrailStore/TrailStore'; @@ -17,6 +18,15 @@ export type Props = { onDelete?: () => void; }; +// Helper function to truncate the value for a single key:value pair +const truncateValue = (key: string, value: string, maxLength: number) => { + const combinedLength = key.length + 2 + value.length; // 2 for ": " + if (combinedLength > maxLength) { + return value.substring(0, maxLength - key.length - 5) + '...'; // 5 for ": " and "..." + } + return value; +}; + export function DataTrailCard(props: Props) { const { onSelect, onDelete, bookmark } = props; const styles = useStyles2(getStyles); @@ -50,68 +60,140 @@ export function DataTrailCard(props: Props) { const { dsValue, filters, metric, createdAt } = values; return ( - - {getMetricName(metric)} -
- - {filters.map((f) => ( - - ))} - -
- - -
- Datasource: {getDataSourceName(dsValue)} +
+ + +
+ Metric:
- {createdAt && ( - - Created: {dateTimeFormat(createdAt, { format: 'LL' })} - +
{getMetricName(metric)}
+
+ + {filters.map((f) => ( + +
{f.key}:
+
{truncateValue(f.key, f.value, 44)}
+
+ ))} +
+
+
+ Data source: +
+
{dsValue && getDataSourceName(dsValue)}
+
+
+ {onDelete && ( + + + )} - - - {onDelete && ( - - - - )} - +
+
+
+
+ Date created: +
+
{createdAt && dateTimeFormat(createdAt, { format: 'YYYY-MM-DD' })}
+
+
); } -function getStyles(theme: GrafanaTheme2) { +export function getStyles(theme: GrafanaTheme2) { return { + metricLabel: css({ + display: 'inline', + color: theme.colors.text.primary, + fontFamily: 'Inter', + fontSize: '14px', + fontStyle: 'normal', + fontWeight: 400, + }), + metricValue: css({ + display: 'inline', + color: theme.colors.text.primary, + fontFamily: 'Inter', + fontSize: '14px', + fontStyle: 'normal', + fontWeight: 500, + marginLeft: '8px', // Add space between the label and the value + wordBreak: 'break-all', + }), tag: css({ maxWidth: '260px', overflow: 'hidden', textOverflow: 'ellipsis', }), card: css({ - padding: theme.spacing(1), + position: 'relative', + width: '318px', + padding: `12px ${theme.spacing(2)} ${theme.spacing(1)} ${theme.spacing(2)}`, + height: '152px', + alignItems: 'start', + marginBottom: 0, + borderTop: `1px solid ${theme.colors.border.weak}`, + borderRight: `1px solid ${theme.colors.border.weak}`, + borderLeft: `1px solid ${theme.colors.border.weak}`, + borderBottom: 'none', // Remove the bottom border + // eslint-disable-next-line @grafana/no-border-radius-literal + borderRadius: '2px 2px 0 0', // Top-left and top-right corners are 2px, bottom-left and bottom-right are 0; cannot use theme.shape.radius.default because need bottom corners to be 0 }), secondary: css({ color: theme.colors.text.secondary, fontSize: '12px', }), - description: css({ - width: '100%', + datasource: css({ gridArea: 'Description', - margin: theme.spacing(1, 0, 0), - color: theme.colors.text.secondary, - lineHeight: theme.typography.body.lineHeight, + }), + date: css({ + border: `1px solid ${theme.colors.border.weak}`, + // eslint-disable-next-line @grafana/no-border-radius-literal + borderRadius: '0 0 2px 2px', + padding: `${theme.spacing(1)} ${theme.spacing(2)}`, + backgroundColor: theme.colors.background.primary, + }), + meta: css({ + flexWrap: 'wrap', overflow: 'hidden', textOverflow: 'ellipsis', + maxHeight: '54px', + width: '100%', + margin: 0, + gridArea: 'Meta', + color: theme.colors.text.secondary, whiteSpace: 'nowrap', }), - actions: css({ - marginRight: theme.spacing(1), + primaryFont: css({ + display: 'inline', + color: theme.colors.text.primary, + fontFamily: 'Inter', + fontSize: '12px', + fontStyle: 'normal', + fontWeight: '500', + lineHeight: '18px' /* 150% */, + letterSpacing: '0.018px', + }), + secondaryFont: css({ + display: 'inline', + color: theme.colors.text.secondary, + fontFamily: 'Inter', + fontSize: '12px', + fontStyle: 'normal', + fontWeight: '400', + lineHeight: '18px' /* 150% */, + letterSpacing: '0.018px', + }), + deleteButton: css({ + position: 'absolute', + bottom: theme.spacing(1), + right: theme.spacing(1), }), wordwrap: css({ overflow: 'hidden', diff --git a/public/app/features/trails/DataTrailsApp.tsx b/public/app/features/trails/DataTrailsApp.tsx index a3057fa4b66..25115c4e213 100644 --- a/public/app/features/trails/DataTrailsApp.tsx +++ b/public/app/features/trails/DataTrailsApp.tsx @@ -8,7 +8,6 @@ import { Page } from 'app/core/components/Page/Page'; import { DataTrail } from './DataTrail'; import { DataTrailsHome } from './DataTrailsHome'; -import { MetricsHeader } from './MetricsHeader'; import { getTrailStore } from './TrailStore/TrailStore'; import { HOME_ROUTE, TRAILS_ROUTE } from './shared'; import { getMetricName, getUrlForTrail, newMetricsTrail } from './utils'; @@ -40,7 +39,8 @@ export class DataTrailsApp extends SceneObjectBase { } + // Returning null to prevent default behavior which renders a header + renderTitle={() => null} subTitle="" > @@ -59,7 +59,9 @@ function DataTrailView({ trail }: { trail: DataTrail }) { useEffect(() => { if (!isInitialized) { - getTrailStore().setRecentTrail(trail); + if (trail.state.metric !== undefined) { + getTrailStore().setRecentTrail(trail); + } setIsInitialized(true); } }, [trail, isInitialized]); diff --git a/public/app/features/trails/DataTrailsHome.tsx b/public/app/features/trails/DataTrailsHome.tsx index 56ca3b83186..a7c6e9d3859 100644 --- a/public/app/features/trails/DataTrailsHome.tsx +++ b/public/app/features/trails/DataTrailsHome.tsx @@ -1,19 +1,20 @@ import { css } from '@emotion/css'; import { useState } from 'react'; -import { Navigate } from 'react-router-dom-v5-compat'; import { GrafanaTheme2 } from '@grafana/data'; import { SceneComponentProps, sceneGraph, SceneObject, SceneObjectBase, SceneObjectState } from '@grafana/scenes'; -import { Button, EmptyState, Stack, useStyles2 } from '@grafana/ui'; +import { Box, Button, Icon, Stack, TextLink, useStyles2, useTheme2 } from '@grafana/ui'; import { Text } from '@grafana/ui/src/components/Text/Text'; -import { Trans } from '@grafana/ui/src/utils/i18n'; +import { Trans } from 'app/core/internationalization'; import { DataTrail } from './DataTrail'; -import { DataTrailCard } from './DataTrailCard'; +import { DataTrailsBookmarks } from './DataTrailBookmarks'; import { DataTrailsApp } from './DataTrailsApp'; -import { getBookmarkKey, getTrailStore } from './TrailStore/TrailStore'; +import { DataTrailsRecentMetrics } from './DataTrailsRecentMetrics'; +import { getTrailStore } from './TrailStore/TrailStore'; +import { LightModeRocket, DarkModeRocket } from './assets/rockets'; import { reportExploreMetrics } from './interactions'; -import { getDatasourceForNewTrail, getUrlForTrail, newMetricsTrail } from './utils'; +import { getDatasourceForNewTrail, newMetricsTrail } from './utils'; export interface DataTrailsHomeState extends SceneObjectState {} @@ -26,7 +27,6 @@ export class DataTrailsHome extends SceneObjectBase { const app = getAppFor(this); const trail = newMetricsTrail(getDatasourceForNewTrail()); reportExploreMetrics('exploration_started', { cause: 'new_clicked' }); - getTrailStore().setRecentTrail(trail); app.goToUrlForTrail(trail); }; @@ -48,6 +48,7 @@ export class DataTrailsHome extends SceneObjectBase { static Component = ({ model }: SceneComponentProps) => { const [_, setLastDelete] = useState(Date.now()); const styles = useStyles2(getStyles); + const theme = useTheme2(); const onDelete = (index: number) => { getTrailStore().removeBookmark(index); @@ -55,62 +56,40 @@ export class DataTrailsHome extends SceneObjectBase { setLastDelete(Date.now()); // trigger re-render }; - // If there are no recent trails, don't show home page and create a new trail - if (!getTrailStore().recent.length) { - const trail = newMetricsTrail(getDatasourceForNewTrail()); - return ; - } - return (
- - - - - -
- Recent metrics explorations -
- {getTrailStore().recent.map((trail, index) => { - const resolvedTrail = trail.resolve(); - return ( - model.onSelectRecentTrail(resolvedTrail)} - /> - ); - })} +
+ +
{theme.isDark ? : }
+ + Start your metrics exploration! + + + + + Explore your Prometheus-compatible metrics without writing a query. + + + Learn more + + + +
+
-
-
-
- Bookmarks -
- {getTrailStore().bookmarks.length ? ( - getTrailStore().bookmarks.map((bookmark, index) => { - return ( - model.onSelectBookmark(index)} - onDelete={() => onDelete(index)} - /> - ); - }) - ) : ( - - - You haven't created any bookmarks yet. Use the Explore Metrics bookmarks feature to save your - panels as bookmarks. - - - )} -
-
- + +
+ +
); }; @@ -123,30 +102,26 @@ function getAppFor(model: SceneObject) { function getStyles(theme: GrafanaTheme2) { return { container: css({ - flexGrow: 1, display: 'flex', - flexDirection: 'column', - gap: theme.spacing(3), - }), - column: css({ - display: 'flex', - flexGrow: 1, - flexDirection: 'column', - gap: theme.spacing(2), - }), - newTrail: css({ - height: 'auto', justifyContent: 'center', - fontSize: theme.typography.h5.fontSize, - }), - trailCard: css({}), - trailList: css({ - display: 'flex', + alignItems: 'center', flexDirection: 'column', - gap: theme.spacing(2), + height: '100%', + boxSizing: 'border-box', // Ensure padding doesn't cause overflow }), - verticalLine: css({ - borderLeft: `1px solid ${theme.colors.border.weak}`, + homepageBox: css({ + backgroundColor: theme.colors.background.secondary, + width: '725px', + height: '294px', + padding: '40px 32px', + boxSizing: 'border-box', // Ensure padding doesn't cause overflow + flexShrink: 0, + }), + startButton: css({ + fontWeight: theme.typography.fontWeightLight, + }), + gap24: css({ + marginTop: theme.spacing(2), // Adds a 24px gap since there is already a 8px gap from the button }), }; } diff --git a/public/app/features/trails/DataTrailsRecentMetrics.tsx b/public/app/features/trails/DataTrailsRecentMetrics.tsx new file mode 100644 index 00000000000..4a62124b669 --- /dev/null +++ b/public/app/features/trails/DataTrailsRecentMetrics.tsx @@ -0,0 +1,85 @@ +import { css } from '@emotion/css'; +import { useState } from 'react'; + +import { GrafanaTheme2 } from '@grafana/data'; +import { SceneComponentProps } from '@grafana/scenes'; +import { Button, useStyles2, useTheme2 } from '@grafana/ui'; +import { Trans } from 'app/core/internationalization'; + +import { DataTrailCard } from './DataTrailCard'; +import { DataTrailsHome } from './DataTrailsHome'; +import { getTrailStore } from './TrailStore/TrailStore'; + +export function DataTrailsRecentMetrics({ model }: SceneComponentProps) { + const styles = useStyles2(getStyles); + const recentMetrics = getTrailStore().recent; + const theme = useTheme2(); + + const [showAll, setShowAll] = useState(false); + const handleToggleShow = () => { + setShowAll(!showAll); + }; + + if (recentMetrics.length === 0) { + return null; + } + + return ( + <> +
+
+ Or view a recent exploration +
+
+
+ {getTrailStore() + .recent.slice(0, showAll ? recentMetrics.length : 3) + .map((trail, index) => { + const resolvedTrail = trail.resolve(); + return ( + model.onSelectRecentTrail(resolvedTrail)} + /> + ); + })} +
+ {recentMetrics.length > 3 && ( + + )} + + ); +} + +function getStyles(theme: GrafanaTheme2) { + return { + recentExplorationHeader: css({ + marginTop: theme.spacing(6), + marginBottom: theme.spacing(3), + }), + header: css({ + color: theme.colors.text.primary, + textAlign: 'center', + /* H4 */ + fontFamily: 'Inter', + fontSize: '18px', + fontStyle: 'normal', + fontWeight: '400', + lineHeight: '22px' /* 122.222% */, + letterSpacing: '0.045px', + }), + trailList: css({ + display: 'grid', + gridTemplateColumns: 'repeat(3, 1fr)', + gap: `${theme.spacing(4)}`, + alignItems: 'stretch', + justifyItems: 'center', + }), + bottomGap24: css({ + marginBottom: theme.spacing(3), + }), + }; +} diff --git a/public/app/features/trails/TrailStore/TrailStore.ts b/public/app/features/trails/TrailStore/TrailStore.ts index a0373bab200..db9864c6fd6 100644 --- a/public/app/features/trails/TrailStore/TrailStore.ts +++ b/public/app/features/trails/TrailStore/TrailStore.ts @@ -36,10 +36,11 @@ export class TrailStore { private _recent: Array> = []; private _bookmarks: DataTrailBookmark[] = []; private _save: () => void; + private _lastModified: number; constructor() { this.load(); - + this._lastModified = Date.now(); const doSave = () => { const serializedRecent = this._recent .slice(0, MAX_RECENT_TRAILS) @@ -47,6 +48,7 @@ export class TrailStore { localStorage.setItem(RECENT_TRAILS_KEY, JSON.stringify(serializedRecent)); localStorage.setItem(TRAIL_BOOKMARKS_KEY, JSON.stringify(this._bookmarks)); + this._lastModified = Date.now(); }; this._save = debounce(doSave, 1000); @@ -168,10 +170,16 @@ export class TrailStore { return this._recent; } + // Last updated metric + get lastModified() { + return this._lastModified; + } + load() { this._recent = this._loadRecentTrailsFromStorage(); this._bookmarks = this._loadBookmarksFromStorage(); this._refreshBookmarkIndexMap(); + this._lastModified = Date.now(); } setRecentTrail(recentTrail: DataTrail) { diff --git a/public/app/features/trails/assets/rockets.tsx b/public/app/features/trails/assets/rockets.tsx new file mode 100644 index 00000000000..5de1b750afa --- /dev/null +++ b/public/app/features/trails/assets/rockets.tsx @@ -0,0 +1,19 @@ +export const LightModeRocket = () => ( + + + +); + +export const DarkModeRocket = () => ( + + + +); diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index 138865c1b4f..28c3e75f798 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -2884,7 +2884,18 @@ }, "trails": { "bookmarks": { - "empty-state": "You haven't created any bookmarks yet. Use the Explore Metrics bookmarks feature to save your panels as bookmarks." + "or-view-bookmarks": "Or view bookmarks" + }, + "card": { + "data-source": "Data source: ", + "date-created": "Date created: ", + "metric": "Metric:" + }, + "home": { + "learn-more": "Learn more", + "lets-start": "Let's start!", + "start-your-metrics-exploration": "Start your metrics exploration!", + "subtitle": "Explore your Prometheus-compatible metrics without writing a query." }, "metric-overview": { "description-label": "Description", @@ -2899,6 +2910,9 @@ "filter-by": "Filter by", "otel-switch": "This switch enables filtering by OTel resources for OTel native data sources." }, + "recent-metrics": { + "or-view-a-recent-exploration": "Or view a recent exploration" + }, "settings": { "always-keep-selected-metric-graph-in-view": "Always keep selected metric graph in-view", "show-previews-of-metric-graphs": "Show previews of metric graphs" diff --git a/public/locales/pseudo-LOCALE/grafana.json b/public/locales/pseudo-LOCALE/grafana.json index d048607a940..a2b8a080fd9 100644 --- a/public/locales/pseudo-LOCALE/grafana.json +++ b/public/locales/pseudo-LOCALE/grafana.json @@ -2884,7 +2884,18 @@ }, "trails": { "bookmarks": { - "empty-state": "Ÿőū ĥävęʼn'ŧ čřęäŧęđ äʼny þőőĸmäřĸş yęŧ. Ůşę ŧĥę Ēχpľőřę Męŧřįčş þőőĸmäřĸş ƒęäŧūřę ŧő şävę yőūř päʼnęľş äş þőőĸmäřĸş." + "or-view-bookmarks": "Øř vįęŵ þőőĸmäřĸş" + }, + "card": { + "data-source": "Đäŧä şőūřčę: ", + "date-created": "Đäŧę čřęäŧęđ: ", + "metric": "Męŧřįč:" + }, + "home": { + "learn-more": "Ŀęäřʼn mőřę", + "lets-start": "Ŀęŧ'ş şŧäřŧ!", + "start-your-metrics-exploration": "Ŝŧäřŧ yőūř męŧřįčş ęχpľőřäŧįőʼn!", + "subtitle": "Ēχpľőřę yőūř Přőmęŧĥęūş-čőmpäŧįþľę męŧřįčş ŵįŧĥőūŧ ŵřįŧįʼnģ ä qūęřy." }, "metric-overview": { "description-label": "Đęşčřįpŧįőʼn", @@ -2899,6 +2910,9 @@ "filter-by": "Fįľŧęř þy", "otel-switch": "Ŧĥįş şŵįŧčĥ ęʼnäþľęş ƒįľŧęřįʼnģ þy ØŦęľ řęşőūřčęş ƒőř ØŦęľ ʼnäŧįvę đäŧä şőūřčęş." }, + "recent-metrics": { + "or-view-a-recent-exploration": "Øř vįęŵ ä řęčęʼnŧ ęχpľőřäŧįőʼn" + }, "settings": { "always-keep-selected-metric-graph-in-view": "Åľŵäyş ĸęęp şęľęčŧęđ męŧřįč ģřäpĥ įʼn-vįęŵ", "show-previews-of-metric-graphs": "Ŝĥőŵ přęvįęŵş őƒ męŧřįč ģřäpĥş"