NewsPanel: add news as a builtin panel (#21128)

This commit is contained in:
Ryan McKinley 2019-12-20 10:47:45 -08:00 committed by GitHub
parent 22ff0eab15
commit b8c0924ab1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
19 changed files with 482 additions and 2 deletions

View File

@ -73,4 +73,12 @@ export class DataFrameView<T = any> implements Vector<T> {
iterator(this.get(i));
}
}
map<V>(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;
}
}

View File

@ -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;

View File

@ -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",

View File

@ -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;

View File

@ -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;

View File

@ -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,

View File

@ -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 };

View File

@ -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,

View File

@ -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);
`;
}

View File

@ -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,

View File

@ -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<NewsOptions> {}
interface State {
news?: DataFrameView<NewsItem>;
isError?: boolean;
}
export class NewsPanel extends PureComponent<Props, State> {
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<NewsItem>(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 <div>Error Loading News</div>;
}
if (!news) {
return <div>loading...</div>;
}
return (
<div className={styles.container}>
<CustomScrollbar>
{news.map((item, index) => {
return (
<div key={index} className={styles.item}>
<a href={item.link} target="_blank">
<div className={styles.title}>{item.title}</div>
<div className={styles.date}>{dateTime(item.date).format('MMM DD')} </div>
<div className={styles.content} dangerouslySetInnerHTML={{ __html: sanitize(item.content) }} />
</a>
</div>
);
})}
</CustomScrollbar>
</div>
);
}
}
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};
`,
}));

View File

@ -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<PanelEditorProps<NewsOptions>, State> {
constructor(props: PanelEditorProps<NewsOptions>) {
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 (
<>
<PanelOptionsGroup title="Feed">
<div className="gf-form">
<FormField
label="URL"
labelWidth={4}
inputWidth={30}
value={feedUrl || ''}
placeholder={DEFAULT_FEED_URL}
onChange={this.onFeedUrlChange}
onBlur={this.onUpdatePanel}
/>
</div>
{suggestProxy && (
<div>
<br />
<div>If the feed is unable to connect, consider a CORS proxy</div>
<Button variant="inverse" onClick={this.onSetProxyPrefix}>
Use Proxy
</Button>
</div>
)}
</PanelOptionsGroup>
</>
);
}
}

View File

@ -0,0 +1,48 @@
<?xml version="1.0" encoding="iso-8859-1"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1"
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
x="0px" y="0px"
viewBox="0 0 395.569 395.569"
style="enable-background:new 0 0 395.569 395.569; fill:#1573B9"
xml:space="preserve">
<g>
<path d="M365.11,81.124c-2.3-29.794-27.261-53.339-57.635-53.339H57.826C25.941,27.785,0,53.726,0,85.61v224.35
c0,31.884,25.941,57.825,57.826,57.825h279.918c31.885,0,57.826-25.941,57.826-57.825V132.03
C395.569,110.043,383.225,90.899,365.11,81.124z M333.99,309.96c0,14.619-11.894,26.514-26.514,26.514H57.826
c-14.62,0-26.514-11.895-26.514-26.514V85.61c0-14.619,11.894-26.514,26.514-26.514h249.65c14.62,0,26.514,11.895,26.514,26.514
V309.96z"/>
<path d="M62.901,145.138h0.652c4.505,0,8.156-3.651,8.156-8.157v-28.756l24.358,33.548c1.536,2.113,3.988,3.365,6.601,3.365h0.791
c0.03,0,0.06-0.006,0.088-0.006c0.041,0,0.081,0.006,0.123,0.006h0.652c4.504,0,8.156-3.651,8.156-8.157V83.399
c0-4.505-3.652-8.157-8.156-8.157h-0.652c-4.506,0-8.157,3.652-8.157,8.157v28.755L71.156,78.606
c-1.535-2.113-3.989-3.364-6.6-3.364h-0.792c-0.03,0-0.058,0.006-0.088,0.006c-0.042,0-0.082-0.006-0.123-0.006h-0.652
c-4.505,0-8.156,3.652-8.156,8.157v53.582C54.745,141.487,58.395,145.138,62.901,145.138z"/>
<path d="M162.419,128.824h-25.441v-10.971h20.764c4.504,0,8.156-3.651,8.156-8.155v-0.653c0-4.506-3.652-8.157-8.156-8.157h-20.764
v-8.681h24.349c4.505,0,8.157-3.651,8.157-8.156v-0.652c0-4.505-3.651-8.157-8.157-8.157h-33.158c-4.505,0-8.157,3.652-8.157,8.157
v53.582c0,4.506,3.652,8.157,8.157,8.157h34.251c4.504,0,8.157-3.651,8.157-8.157C170.576,132.477,166.924,128.824,162.419,128.824
z"/>
<path d="M198.958,145.138h0.501c0.011,0,0.021-0.002,0.032-0.002c0.01,0,0.02,0.002,0.032,0.002h0.421
c3.502,0,6.612-2.235,7.731-5.553l10.196-30.274l10.148,30.265c1.114,3.324,4.227,5.563,7.733,5.563h0.501
c0.011,0,0.02-0.002,0.032-0.002s0.022,0.002,0.033,0.002h0.421c3.495,0,6.602-2.227,7.724-5.537l18.168-53.583
c0.845-2.489,0.437-5.235-1.095-7.371c-1.531-2.137-4-3.405-6.628-3.405h-0.835c-3.516,0-6.636,2.253-7.742,5.59L236.268,111.2
l-10.11-30.376c-1.109-3.333-4.227-5.581-7.739-5.581h-1.14c-3.517,0-6.636,2.253-7.742,5.59L199.474,111.2l-10.109-30.376
c-1.109-3.333-4.227-5.581-7.74-5.581h-0.834c-2.629,0-5.097,1.268-6.63,3.405c-1.532,2.136-1.94,4.882-1.095,7.371l18.168,53.583
C192.355,142.911,195.462,145.138,198.958,145.138z"/>
<path d="M289.496,145.138c18.084,0,26.178-10.525,26.177-20.893c0.125-16.176-13.955-20.431-22.368-22.973
c-9.335-2.822-10.215-3.955-10.215-6.244c0-1.362,3.264-2.82,8.12-2.82c3.25,0,7.099,0.954,9.36,2.318
c3.816,2.306,8.773,1.12,11.135-2.661l0.299-0.479c1.155-1.848,1.521-4.082,1.018-6.202c-0.501-2.121-1.832-3.952-3.693-5.085
c-4.992-3.041-11.765-4.857-18.119-4.857c-17.33,0-25.087,9.937-25.087,19.786c0,15.75,13.332,19.788,22.153,22.459
c9.605,2.909,10.453,4.065,10.432,6.699c0,2.739-4.776,3.986-9.212,3.986c-4.31,0-9.038-1.84-11.766-4.579
c-1.529-1.536-3.605-2.399-5.77-2.402c-0.003,0-0.005,0-0.008,0c-2.162,0-4.239,0.86-5.768,2.39l-0.445,0.446
c-1.543,1.543-2.404,3.64-2.389,5.822c0.015,2.183,0.904,4.269,2.467,5.791C271.838,141.499,280.911,145.138,289.496,145.138z"/>
<path d="M147.338,168.909H69.755c-8.646,0-15.656,7.009-15.656,15.656c0,8.646,7.009,15.656,15.656,15.656h77.582
c8.646,0,15.656-7.01,15.656-15.656C162.994,175.918,155.984,168.909,147.338,168.909z"/>
<path d="M147.338,221.094H69.755c-8.646,0-15.656,7.01-15.656,15.656c0,8.646,7.009,15.656,15.656,15.656h77.582
c8.646,0,15.656-7.009,15.656-15.656C162.994,228.104,155.984,221.094,147.338,221.094z"/>
<path d="M147.338,273.281H69.755c-8.646,0-15.656,7.009-15.656,15.656c0,8.646,7.009,15.656,15.656,15.656h77.582
c8.646,0,15.656-7.01,15.656-15.656C162.994,280.29,155.984,273.281,147.338,273.281z"/>
<path d="M306.61,166.698H186.967c-5.005,0-9.064,4.059-9.064,9.063V297.74c0,5.005,4.059,9.063,9.064,9.063H306.61
c5.006,0,9.064-4.059,9.064-9.063V175.761C315.674,170.756,311.616,166.698,306.61,166.698z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 4.1 KiB

View File

@ -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<NewsOptions>(NewsPanel).setDefaults(defaults).setEditor(NewsPanelEditor);

View File

@ -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"
}
}
}

View File

@ -0,0 +1,24 @@
import { RssFeed, RssItem } from './types';
export async function loadRSSFeed(url: string): Promise<RssFeed> {
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;
}

View File

@ -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;
}

View File

@ -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<NewsItem>(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<p>As Grafana Labs continues to grow, we&rsquo;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<p>Amsterdam, we&rsquo;re coming back!</p>\n\n<p>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<p>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<p>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<p>As Grafana Labs continues to grow, we&rsquo;d like you to get to know the team members who are building the cool stuff you&rsquo;re using. Check out the latest of our Friday team profiles.</p>\n\n<h2 id="meet-ward">Meet Ward!</h2>\n\n<p><strong>Name:</strong> 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;

View File

@ -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<number>([]);
const title = new ArrayVector<string>([]);
const link = new ArrayVector<string>([]);
const content = new ArrayVector<string>([]);
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,
};
}