From b8c0924ab1b2a932a35202710c19703d372fd0c9 Mon Sep 17 00:00:00 2001 From: Ryan McKinley Date: Fri, 20 Dec 2019 10:47:45 -0800 Subject: [PATCH] NewsPanel: add news as a builtin panel (#21128) --- .../src/dataframe/DataFrameView.ts | 8 ++ packages/grafana-data/src/types/theme.ts | 3 + .../__snapshots__/TimePicker.test.tsx.snap | 2 + .../src/themes/_variables.dark.scss.tmpl.ts | 2 +- .../src/themes/_variables.light.scss.tmpl.ts | 2 +- packages/grafana-ui/src/themes/dark.ts | 1 + packages/grafana-ui/src/themes/index.ts | 3 + packages/grafana-ui/src/themes/light.ts | 7 + packages/grafana-ui/src/themes/mixins.ts | 21 +++ .../app/features/plugins/built_in_plugins.ts | 2 + public/app/plugins/panel/news/NewsPanel.tsx | 126 ++++++++++++++++++ .../plugins/panel/news/NewsPanelEditor.tsx | 68 ++++++++++ public/app/plugins/panel/news/img/news.svg | 48 +++++++ public/app/plugins/panel/news/module.tsx | 6 + public/app/plugins/panel/news/plugin.json | 20 +++ public/app/plugins/panel/news/rss.ts | 24 ++++ public/app/plugins/panel/news/types.ts | 34 +++++ public/app/plugins/panel/news/utils.test.ts | 68 ++++++++++ public/app/plugins/panel/news/utils.ts | 39 ++++++ 19 files changed, 482 insertions(+), 2 deletions(-) create mode 100644 packages/grafana-ui/src/themes/mixins.ts create mode 100755 public/app/plugins/panel/news/NewsPanel.tsx create mode 100755 public/app/plugins/panel/news/NewsPanelEditor.tsx create mode 100644 public/app/plugins/panel/news/img/news.svg create mode 100755 public/app/plugins/panel/news/module.tsx create mode 100755 public/app/plugins/panel/news/plugin.json create mode 100644 public/app/plugins/panel/news/rss.ts create mode 100755 public/app/plugins/panel/news/types.ts create mode 100644 public/app/plugins/panel/news/utils.test.ts create mode 100644 public/app/plugins/panel/news/utils.ts diff --git a/packages/grafana-data/src/dataframe/DataFrameView.ts b/packages/grafana-data/src/dataframe/DataFrameView.ts index 32a9af420fa..5162a94e56b 100644 --- a/packages/grafana-data/src/dataframe/DataFrameView.ts +++ b/packages/grafana-data/src/dataframe/DataFrameView.ts @@ -73,4 +73,12 @@ export class DataFrameView implements Vector { iterator(this.get(i)); } } + + map(iterator: (item: T, index: number) => V) { + const acc: V[] = []; + for (let i = 0; i < this.data.length; i++) { + acc.push(iterator(this.get(i), i)); + } + return acc; + } } diff --git a/packages/grafana-data/src/types/theme.ts b/packages/grafana-data/src/types/theme.ts index d2b605ac32d..5c75e86b912 100644 --- a/packages/grafana-data/src/types/theme.ts +++ b/packages/grafana-data/src/types/theme.ts @@ -206,6 +206,9 @@ export interface GrafanaTheme extends GrafanaThemeCommons { textFaint: string; textEmphasis: string; + // panel + panelBg: string; + // TODO: move to background section bodyBg: string; pageBg: string; diff --git a/packages/grafana-ui/src/components/TimePicker/__snapshots__/TimePicker.test.tsx.snap b/packages/grafana-ui/src/components/TimePicker/__snapshots__/TimePicker.test.tsx.snap index 5ed7a7e82a5..1dc6507c838 100644 --- a/packages/grafana-ui/src/components/TimePicker/__snapshots__/TimePicker.test.tsx.snap +++ b/packages/grafana-ui/src/components/TimePicker/__snapshots__/TimePicker.test.tsx.snap @@ -154,6 +154,7 @@ exports[`TimePicker renders buttons correctly 1`] = ` "orangeDark": "#ff780a", "pageBg": "#161719", "pageHeaderBorder": "#343436", + "panelBg": "#212124", "purple": "#9933cc", "queryGreen": "#74e680", "queryKeyword": "#66d9ef", @@ -460,6 +461,7 @@ exports[`TimePicker renders content correctly after beeing open 1`] = ` "orangeDark": "#ff780a", "pageBg": "#161719", "pageHeaderBorder": "#343436", + "panelBg": "#212124", "purple": "#9933cc", "queryGreen": "#74e680", "queryKeyword": "#66d9ef", diff --git a/packages/grafana-ui/src/themes/_variables.dark.scss.tmpl.ts b/packages/grafana-ui/src/themes/_variables.dark.scss.tmpl.ts index 9eeba2e2738..90428e98549 100644 --- a/packages/grafana-ui/src/themes/_variables.dark.scss.tmpl.ts +++ b/packages/grafana-ui/src/themes/_variables.dark.scss.tmpl.ts @@ -111,7 +111,7 @@ $hr-border-color: $dark-9; // Panel // ------------------------- -$panel-bg: $dark-4; +$panel-bg: ${theme.colors.panelBg}; $panel-border: solid 1px $dark-1; $panel-header-hover-bg: $dark-9; $panel-corner: $panel-bg; diff --git a/packages/grafana-ui/src/themes/_variables.light.scss.tmpl.ts b/packages/grafana-ui/src/themes/_variables.light.scss.tmpl.ts index ce90786ae19..7cabe5ff1e5 100644 --- a/packages/grafana-ui/src/themes/_variables.light.scss.tmpl.ts +++ b/packages/grafana-ui/src/themes/_variables.light.scss.tmpl.ts @@ -103,7 +103,7 @@ $hr-border-color: $gray-4 !default; // Panel // ------------------------- -$panel-bg: $white; +$panel-bg: ${theme.colors.panelBg}; $panel-border: solid 1px $gray-5; $panel-header-hover-bg: $gray-6; $panel-corner: $gray-4; diff --git a/packages/grafana-ui/src/themes/dark.ts b/packages/grafana-ui/src/themes/dark.ts index cec68fc0850..84ab4bed341 100644 --- a/packages/grafana-ui/src/themes/dark.ts +++ b/packages/grafana-ui/src/themes/dark.ts @@ -75,6 +75,7 @@ const darkTheme: GrafanaTheme = { linkExternal: basicColors.blue, headingColor: basicColors.gray4, pageHeaderBorder: basicColors.dark9, + panelBg: basicColors.dark4, // Next-gen forms functional colors formLabel: basicColors.gray70, diff --git a/packages/grafana-ui/src/themes/index.ts b/packages/grafana-ui/src/themes/index.ts index d9eaced9a73..d1e0ea0295f 100644 --- a/packages/grafana-ui/src/themes/index.ts +++ b/packages/grafana-ui/src/themes/index.ts @@ -3,3 +3,6 @@ import { getTheme, mockTheme } from './getTheme'; import { selectThemeVariant } from './selectThemeVariant'; export { stylesFactory } from './stylesFactory'; export { ThemeContext, withTheme, mockTheme, getTheme, selectThemeVariant, useTheme, mockThemeContext }; + +import * as styleMixins from './mixins'; +export { styleMixins }; diff --git a/packages/grafana-ui/src/themes/light.ts b/packages/grafana-ui/src/themes/light.ts index 730661a2cd7..271e7052687 100644 --- a/packages/grafana-ui/src/themes/light.ts +++ b/packages/grafana-ui/src/themes/light.ts @@ -64,12 +64,16 @@ const lightTheme: GrafanaTheme = { critical: basicColors.redShade, bodyBg: basicColors.gray7, pageBg: basicColors.gray7, + + // Text colors body: basicColors.gray1, text: basicColors.gray1, textStrong: basicColors.dark2, textWeak: basicColors.gray2, textEmphasis: basicColors.dark5, textFaint: basicColors.dark4, + + // Link colors link: basicColors.gray1, linkDisabled: basicColors.gray3, linkHover: basicColors.dark1, @@ -77,6 +81,9 @@ const lightTheme: GrafanaTheme = { headingColor: basicColors.gray1, pageHeaderBorder: basicColors.gray4, + // panel + panelBg: basicColors.white, + // Next-gen forms functional colors formLabel: basicColors.gray33, formDescription: basicColors.gray33, diff --git a/packages/grafana-ui/src/themes/mixins.ts b/packages/grafana-ui/src/themes/mixins.ts new file mode 100644 index 00000000000..fe67172a12f --- /dev/null +++ b/packages/grafana-ui/src/themes/mixins.ts @@ -0,0 +1,21 @@ +import { GrafanaTheme } from '@grafana/data'; + +export function cardChrome(theme: GrafanaTheme): string { + if (theme.isDark) { + return ` + background: linear-gradient(135deg, ${theme.colors.dark8}, ${theme.colors.dark6}); + &:hover { + background: linear-gradient(135deg, ${theme.colors.dark9}, ${theme.colors.dark6}); + } + box-shadow: -1px -1px 0 0 hsla(0, 0%, 100%, 0.1), 1px 1px 0 0 rgba(0, 0, 0, 0.3); + `; + } + + return ` + background: linear-gradient(135deg, ${theme.colors.gray6}, ${theme.colors.gray5}); + &:hover { + background: linear-gradient(135deg, ${theme.colors.dark5}, ${theme.colors.gray6}); + } + box-shadow: -1px -1px 0 0 hsla(0, 0%, 100%, 0.1), 1px 1px 0 0 rgba(0, 0, 0, 0.1); + `; +} diff --git a/public/app/features/plugins/built_in_plugins.ts b/public/app/features/plugins/built_in_plugins.ts index a95d67b83d7..e06e6ee6f5c 100644 --- a/public/app/features/plugins/built_in_plugins.ts +++ b/public/app/features/plugins/built_in_plugins.ts @@ -52,6 +52,7 @@ import * as gaugePanel from 'app/plugins/panel/gauge/module'; import * as pieChartPanel from 'app/plugins/panel/piechart/module'; import * as barGaugePanel from 'app/plugins/panel/bargauge/module'; import * as logsPanel from 'app/plugins/panel/logs/module'; +import * as newsPanel from 'app/plugins/panel/news/module'; const exampleApp = async () => await import(/* webpackChunkName: "exampleApp" */ 'app/plugins/app/example-app/module'); @@ -85,6 +86,7 @@ const builtInPlugins: any = { 'app/plugins/panel/heatmap/module': heatmapPanel, 'app/plugins/panel/table/module': tablePanel, 'app/plugins/panel/table2/module': table2Panel, + 'app/plugins/panel/news/module': newsPanel, 'app/plugins/panel/singlestat/module': singlestatPanel, 'app/plugins/panel/stat/module': singlestatPanel2, 'app/plugins/panel/gettingstarted/module': gettingStartedPanel, diff --git a/public/app/plugins/panel/news/NewsPanel.tsx b/public/app/plugins/panel/news/NewsPanel.tsx new file mode 100755 index 00000000000..9f665495077 --- /dev/null +++ b/public/app/plugins/panel/news/NewsPanel.tsx @@ -0,0 +1,126 @@ +// Libraries +import React, { PureComponent } from 'react'; +import { css } from 'emotion'; + +// Utils & Services +import { GrafanaTheme } from '@grafana/data'; +import { stylesFactory, CustomScrollbar, styleMixins } from '@grafana/ui'; +import config from 'app/core/config'; +import { feedToDataFrame } from './utils'; +import { sanitize } from 'app/core/utils/text'; +import { loadRSSFeed } from './rss'; + +// Types +import { PanelProps, DataFrameView, dateTime } from '@grafana/data'; +import { NewsOptions, NewsItem, DEFAULT_FEED_URL } from './types'; + +interface Props extends PanelProps {} + +interface State { + news?: DataFrameView; + isError?: boolean; +} + +export class NewsPanel extends PureComponent { + constructor(props: Props) { + super(props); + + this.state = {}; + } + + componentDidMount(): void { + this.loadFeed(); + } + + componentDidUpdate(prevProps: Props): void { + if (this.props.options.feedUrl !== prevProps.options.feedUrl) { + this.loadFeed(); + } + } + + async loadFeed() { + const { options } = this.props; + try { + const url = options.feedUrl ?? DEFAULT_FEED_URL; + const res = await loadRSSFeed(url); + const frame = feedToDataFrame(res); + this.setState({ + news: new DataFrameView(frame), + isError: false, + }); + } catch (err) { + console.error('Error Loading News', err); + this.setState({ + news: undefined, + isError: true, + }); + } + } + + render() { + const { isError, news } = this.state; + const styles = getStyles(config.theme); + + if (isError) { + return
Error Loading News
; + } + if (!news) { + return
loading...
; + } + + return ( +
+ + {news.map((item, index) => { + return ( + + ); + } +} + +const getStyles = stylesFactory((theme: GrafanaTheme) => ({ + container: css` + height: 100%; + `, + item: css` + ${styleMixins.cardChrome(theme)} + padding: ${theme.spacing.sm}; + position: relative; + margin-bottom: 4px; + border-radius: 3px; + margin-right: ${theme.spacing.sm}; + `, + title: css` + color: ${theme.colors.linkExternal}; + max-width: calc(100% - 70px); + font-size: 16px; + margin-bottom: ${theme.spacing.sm}; + `, + content: css` + p { + margin-bottom: 4px; + } + `, + date: css` + position: absolute; + top: 0; + right: 0; + background: ${theme.colors.bodyBg}; + width: 55px; + text-align: right; + padding: ${theme.spacing.xs}; + font-weight: 500; + border-radius: 0 0 0 3px; + color: ${theme.colors.textWeak}; + `, +})); diff --git a/public/app/plugins/panel/news/NewsPanelEditor.tsx b/public/app/plugins/panel/news/NewsPanelEditor.tsx new file mode 100755 index 00000000000..91d0fae3c54 --- /dev/null +++ b/public/app/plugins/panel/news/NewsPanelEditor.tsx @@ -0,0 +1,68 @@ +import React, { PureComponent } from 'react'; +import { FormField, PanelOptionsGroup, Button } from '@grafana/ui'; +import { PanelEditorProps } from '@grafana/data'; +import { NewsOptions, DEFAULT_FEED_URL } from './types'; + +const PROXY_PREFIX = 'https://cors-anywhere.herokuapp.com/'; + +interface State { + feedUrl: string; +} + +export class NewsPanelEditor extends PureComponent, State> { + constructor(props: PanelEditorProps) { + super(props); + + this.state = { + feedUrl: props.options.feedUrl, + }; + } + + onUpdatePanel = () => + this.props.onOptionsChange({ + ...this.props.options, + feedUrl: this.state.feedUrl, + }); + + onFeedUrlChange = ({ target }: any) => this.setState({ feedUrl: target.value }); + + onSetProxyPrefix = () => { + const feedUrl = PROXY_PREFIX + this.state.feedUrl; + this.setState({ feedUrl }); + this.props.onOptionsChange({ + ...this.props.options, + feedUrl, + }); + }; + + render() { + const feedUrl = this.state.feedUrl || ''; + const suggestProxy = feedUrl && !feedUrl.startsWith(PROXY_PREFIX); + return ( + <> + +
+ +
+ {suggestProxy && ( +
+
+
If the feed is unable to connect, consider a CORS proxy
+ +
+ )} +
+ + ); + } +} diff --git a/public/app/plugins/panel/news/img/news.svg b/public/app/plugins/panel/news/img/news.svg new file mode 100644 index 00000000000..f31b02be5d2 --- /dev/null +++ b/public/app/plugins/panel/news/img/news.svg @@ -0,0 +1,48 @@ + + + + + + + + + + + + + + + diff --git a/public/app/plugins/panel/news/module.tsx b/public/app/plugins/panel/news/module.tsx new file mode 100755 index 00000000000..3b21f68642f --- /dev/null +++ b/public/app/plugins/panel/news/module.tsx @@ -0,0 +1,6 @@ +import { PanelPlugin } from '@grafana/data'; +import { NewsPanel } from './NewsPanel'; +import { NewsPanelEditor } from './NewsPanelEditor'; +import { defaults, NewsOptions } from './types'; + +export const plugin = new PanelPlugin(NewsPanel).setDefaults(defaults).setEditor(NewsPanelEditor); diff --git a/public/app/plugins/panel/news/plugin.json b/public/app/plugins/panel/news/plugin.json new file mode 100755 index 00000000000..ca473f68cd2 --- /dev/null +++ b/public/app/plugins/panel/news/plugin.json @@ -0,0 +1,20 @@ +{ + "type": "panel", + "name": "News Panel", + "id": "news", + + "skipDataQuery": true, + + "state": "alpha", + + "info": { + "author": { + "name": "Grafana Project", + "url": "https://grafana.com" + }, + "logos": { + "small": "img/news.svg", + "large": "img/news.svg" + } + } +} diff --git a/public/app/plugins/panel/news/rss.ts b/public/app/plugins/panel/news/rss.ts new file mode 100644 index 00000000000..71409f38f0a --- /dev/null +++ b/public/app/plugins/panel/news/rss.ts @@ -0,0 +1,24 @@ +import { RssFeed, RssItem } from './types'; + +export async function loadRSSFeed(url: string): Promise { + const rsp = await fetch(url); + const txt = await rsp.text(); + const domParser = new DOMParser(); + const doc = domParser.parseFromString(txt, 'text/xml'); + const feed: RssFeed = { + items: [], + }; + + doc.querySelectorAll('item').forEach(node => { + const item: RssItem = { + title: node.querySelector('title').textContent, + link: node.querySelector('link').textContent, + content: node.querySelector('description').textContent, + pubDate: node.querySelector('pubDate').textContent, + }; + + feed.items.push(item); + }); + + return feed; +} diff --git a/public/app/plugins/panel/news/types.ts b/public/app/plugins/panel/news/types.ts new file mode 100755 index 00000000000..985088df7b6 --- /dev/null +++ b/public/app/plugins/panel/news/types.ts @@ -0,0 +1,34 @@ +// TODO: when grafana blog has CORS headers updated, remove the cors-anywhere prefix +export const DEFAULT_FEED_URL = 'https://cors-anywhere.herokuapp.com/' + 'https://grafana.com/blog/index.xml'; + +export interface NewsOptions { + feedUrl?: string; +} + +export const defaults: NewsOptions = { + // will default to grafana blog +}; + +export interface NewsItem { + date: number; + title: string; + link: string; + content: string; +} + +/** + * Helper class for rss-parser + */ +export interface RssFeed { + title?: string; + description?: string; + items: RssItem[]; +} + +export interface RssItem { + title: string; + link: string; + pubDate?: string; + content?: string; + contentSnippet?: string; +} diff --git a/public/app/plugins/panel/news/utils.test.ts b/public/app/plugins/panel/news/utils.test.ts new file mode 100644 index 00000000000..9a808bedc2b --- /dev/null +++ b/public/app/plugins/panel/news/utils.test.ts @@ -0,0 +1,68 @@ +import { feedToDataFrame } from './utils'; +import { RssFeed, NewsItem } from './types'; +import { DataFrameView } from '@grafana/data'; + +describe('news', () => { + test('convert RssFeed to DataFrame', () => { + const frame = feedToDataFrame(grafana20191216); + expect(frame.length).toBe(5); + + // Iterate the links + const view = new DataFrameView(frame); + const links = view.map((item: NewsItem) => { + return item.link; + }); + expect(links).toEqual([ + 'https://grafana.com/blog/2019/12/13/meet-the-grafana-labs-team-aengus-rooney/', + 'https://grafana.com/blog/2019/12/12/register-now-grafanacon-2020-is-coming-to-amsterdam-may-13-14/', + 'https://grafana.com/blog/2019/12/10/pro-tips-dashboard-navigation-using-links/', + 'https://grafana.com/blog/2019/12/09/how-to-do-automatic-annotations-with-grafana-and-loki/', + 'https://grafana.com/blog/2019/12/06/meet-the-grafana-labs-team-ward-bekker/', + ]); + }); +}); + +const grafana20191216 = { + items: [ + { + title: 'Meet the Grafana Labs Team: Aengus Rooney', + link: 'https://grafana.com/blog/2019/12/13/meet-the-grafana-labs-team-aengus-rooney/', + pubDate: 'Fri, 13 Dec 2019 00:00:00 +0000', + content: '\n\n

As Grafana Labs continues to grow, we’d like you to get to know the team members...', + }, + { + title: 'Register Now! GrafanaCon 2020 Is Coming to Amsterdam May 13-14', + link: 'https://grafana.com/blog/2019/12/12/register-now-grafanacon-2020-is-coming-to-amsterdam-may-13-14/', + pubDate: 'Thu, 12 Dec 2019 00:00:00 +0000', + content: '\n\n

Amsterdam, we’re coming back!

\n\n

Mark your calendars for May 13-14, 2020....', + }, + { + title: 'Pro Tips: Dashboard Navigation Using Links', + link: 'https://grafana.com/blog/2019/12/10/pro-tips-dashboard-navigation-using-links/', + pubDate: 'Tue, 10 Dec 2019 00:00:00 +0000', + content: + '\n\n

Great dashboards answer a limited set of related questions. If you try to answer too many questions in a single dashboard, it can become overly complex. ...', + }, + { + title: 'How to Do Automatic Annotations with Grafana and Loki', + link: 'https://grafana.com/blog/2019/12/09/how-to-do-automatic-annotations-with-grafana-and-loki/', + pubDate: 'Mon, 09 Dec 2019 00:00:00 +0000', + content: + '\n\n

Grafana annotations are great! They clearly mark the occurrence of an event to help operators and devs correlate events with metrics. You may not be aware of this, but Grafana can automatically annotate graphs by ...', + }, + { + title: 'Meet the Grafana Labs Team: Ward Bekker', + link: 'https://grafana.com/blog/2019/12/06/meet-the-grafana-labs-team-ward-bekker/', + pubDate: 'Fri, 06 Dec 2019 00:00:00 +0000', + content: + '\n\n

As Grafana Labs continues to grow, we’d like you to get to know the team members who are building the cool stuff you’re using. Check out the latest of our Friday team profiles.

\n\n

Meet Ward!

\n\n

Name: Ward...', + }, + ], + feedUrl: 'https://grafana.com/blog/index.xml', + title: 'Blog on Grafana Labs', + description: 'Recent content in Blog on Grafana Labs', + generator: 'Hugo -- gohugo.io', + link: 'https://grafana.com/blog/', + language: 'en-us', + lastBuildDate: 'Fri, 13 Dec 2019 00:00:00 +0000', +} as RssFeed; diff --git a/public/app/plugins/panel/news/utils.ts b/public/app/plugins/panel/news/utils.ts new file mode 100644 index 00000000000..28ae0eb151d --- /dev/null +++ b/public/app/plugins/panel/news/utils.ts @@ -0,0 +1,39 @@ +import { RssFeed } from './types'; +import { ArrayVector, FieldType, DataFrame, dateTime } from '@grafana/data'; + +export function feedToDataFrame(feed: RssFeed): DataFrame { + const date = new ArrayVector([]); + const title = new ArrayVector([]); + const link = new ArrayVector([]); + const content = new ArrayVector([]); + + for (const item of feed.items) { + const val = dateTime(item.pubDate); + + try { + date.buffer.push(val.valueOf()); + title.buffer.push(item.title); + link.buffer.push(item.link); + + let body = item.content.replace(/<\/?[^>]+(>|$)/g, ''); + + if (body && body.length > 300) { + body = body.substr(0, 300); + } + + content.buffer.push(body); + } catch (err) { + console.warn('Error reading news item:', err, item); + } + } + + return { + fields: [ + { name: 'date', type: FieldType.time, config: { title: 'Date' }, values: date }, + { name: 'title', type: FieldType.string, config: {}, values: title }, + { name: 'link', type: FieldType.string, config: {}, values: link }, + { name: 'content', type: FieldType.string, config: {}, values: content }, + ], + length: date.length, + }; +}