Commit b8c0924a by Ryan McKinley Committed by GitHub

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

parent 22ff0eab
......@@ -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;
}
}
......@@ -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;
......
......@@ -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",
......
......@@ -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;
......
......@@ -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;
......
......@@ -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,
......
......@@ -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 };
......@@ -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,
......
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);
`;
}
......@@ -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,
......
// 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};
`,
}));
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>
</>
);
}
}
<?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>
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);
{
"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"
}
}
}
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;
}
// 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;
}
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;
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,
};
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment