mirror of https://github.com/grafana/grafana
NewsPanel: add news as a builtin panel (#21128)
parent
22ff0eab15
commit
b8c0924ab1
@ -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); |
||||
`;
|
||||
} |
@ -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}; |
||||
`,
|
||||
})); |
@ -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> |
||||
</> |
||||
); |
||||
} |
||||
} |
After Width: | Height: | Size: 4.1 KiB |
@ -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); |
@ -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" |
||||
} |
||||
} |
||||
} |
@ -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; |
||||
} |
@ -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; |
||||
} |
@ -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’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’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’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.</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; |
@ -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, |
||||
}; |
||||
} |
Loading…
Reference in new issue