mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Comments: support live comments in dashboards and annotations (#44980)
This commit is contained in:
97
public/app/features/comments/Comment.tsx
Normal file
97
public/app/features/comments/Comment.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
import React from 'react';
|
||||
import { GrafanaTheme2, renderMarkdown } from '@grafana/data';
|
||||
import { css } from '@emotion/css';
|
||||
import { useStyles2 } from '@grafana/ui';
|
||||
import DangerouslySetHtmlContent from 'dangerously-set-html-content';
|
||||
|
||||
import { Message } from './types';
|
||||
|
||||
type Props = {
|
||||
message: Message;
|
||||
};
|
||||
|
||||
export const Comment = ({ message }: Props) => {
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
let senderColor = '#34BA18';
|
||||
let senderName = 'System';
|
||||
let avatarUrl = '/public/img/grafana_icon.svg';
|
||||
if (message.userId > 0) {
|
||||
senderColor = '#19a2e7';
|
||||
senderName = message.user.login;
|
||||
avatarUrl = message.user.avatarUrl;
|
||||
}
|
||||
const timeColor = '#898989';
|
||||
const timeFormatted = new Date(message.created * 1000).toLocaleTimeString();
|
||||
const markdownContent = renderMarkdown(message.content, { breaks: true });
|
||||
|
||||
return (
|
||||
<div className={styles.comment}>
|
||||
<div className={styles.avatarContainer}>
|
||||
<img src={avatarUrl} alt="User avatar" className={styles.avatar} />
|
||||
</div>
|
||||
<div>
|
||||
<div>
|
||||
<span style={{ color: senderColor }}>{senderName}</span>
|
||||
|
||||
<span style={{ color: timeColor }}>{timeFormatted}</span>
|
||||
</div>
|
||||
<div>
|
||||
<DangerouslySetHtmlContent html={markdownContent} className={styles.content} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => ({
|
||||
comment: css`
|
||||
margin-bottom: 10px;
|
||||
padding-top: 3px;
|
||||
padding-bottom: 3px;
|
||||
word-break: break-word;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: top;
|
||||
|
||||
:hover {
|
||||
background-color: #1e1f24;
|
||||
}
|
||||
|
||||
blockquote {
|
||||
padding: 0 0 0 10px;
|
||||
margin: 0 0 10px;
|
||||
}
|
||||
`,
|
||||
avatarContainer: css`
|
||||
align-self: left;
|
||||
margin-top: 6px;
|
||||
margin-right: 10px;
|
||||
`,
|
||||
avatar: css`
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
`,
|
||||
content: css`
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
blockquote p {
|
||||
font-size: 14px;
|
||||
padding-top: 4px;
|
||||
}
|
||||
|
||||
a {
|
||||
color: #43c57e;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
`,
|
||||
});
|
||||
109
public/app/features/comments/CommentManager.tsx
Normal file
109
public/app/features/comments/CommentManager.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
import React, { PureComponent } from 'react';
|
||||
import { isLiveChannelMessageEvent, LiveChannelScope } from '@grafana/data';
|
||||
import { getBackendSrv, getGrafanaLiveSrv } from '@grafana/runtime';
|
||||
import { Unsubscribable } from 'rxjs';
|
||||
|
||||
import { CommentView } from './CommentView';
|
||||
import { Message, MessagePacket } from './types';
|
||||
|
||||
export interface Props {
|
||||
objectType: string;
|
||||
objectId: string;
|
||||
}
|
||||
|
||||
export interface State {
|
||||
messages: Message[];
|
||||
value: string;
|
||||
}
|
||||
|
||||
export class CommentManager extends PureComponent<Props, State> {
|
||||
subscription?: Unsubscribable;
|
||||
packetCounter = 0;
|
||||
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
messages: [],
|
||||
value: '',
|
||||
};
|
||||
}
|
||||
|
||||
async componentDidMount() {
|
||||
const resp = await getBackendSrv().post('/api/comments/get', {
|
||||
objectType: this.props.objectType,
|
||||
objectId: this.props.objectId,
|
||||
});
|
||||
this.packetCounter++;
|
||||
this.setState({
|
||||
messages: resp.comments,
|
||||
});
|
||||
this.updateSubscription();
|
||||
}
|
||||
|
||||
getLiveChannel = () => {
|
||||
const live = getGrafanaLiveSrv();
|
||||
if (!live) {
|
||||
console.error('Grafana live not running, enable "live" feature toggle');
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const address = this.getLiveAddress();
|
||||
if (!address) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return live.getStream<MessagePacket>(address);
|
||||
};
|
||||
|
||||
getLiveAddress = () => {
|
||||
return {
|
||||
scope: LiveChannelScope.Grafana,
|
||||
namespace: 'comment',
|
||||
path: `${this.props.objectType}/${this.props.objectId}`,
|
||||
};
|
||||
};
|
||||
|
||||
updateSubscription = () => {
|
||||
if (this.subscription) {
|
||||
this.subscription.unsubscribe();
|
||||
this.subscription = undefined;
|
||||
}
|
||||
|
||||
const channel = this.getLiveChannel();
|
||||
if (channel) {
|
||||
this.subscription = channel.subscribe({
|
||||
next: (msg) => {
|
||||
if (isLiveChannelMessageEvent(msg)) {
|
||||
const { commentCreated } = msg.message;
|
||||
if (commentCreated) {
|
||||
this.setState((prevState) => ({
|
||||
messages: [...prevState.messages, commentCreated],
|
||||
}));
|
||||
this.packetCounter++;
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
addComment = async (comment: string): Promise<boolean> => {
|
||||
const response = await getBackendSrv().post('/api/comments/create', {
|
||||
objectType: this.props.objectType,
|
||||
objectId: this.props.objectId,
|
||||
content: comment,
|
||||
});
|
||||
|
||||
// TODO: set up error handling
|
||||
console.log(response);
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
render() {
|
||||
return (
|
||||
<CommentView comments={this.state.messages} packetCounter={this.packetCounter} addComment={this.addComment} />
|
||||
);
|
||||
}
|
||||
}
|
||||
70
public/app/features/comments/CommentView.tsx
Normal file
70
public/app/features/comments/CommentView.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import React, { FormEvent, useLayoutEffect, useRef, useState } from 'react';
|
||||
import { css } from '@emotion/css';
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { CustomScrollbar, TextArea, useStyles2 } from '@grafana/ui';
|
||||
|
||||
import { Comment } from './Comment';
|
||||
import { Message } from './types';
|
||||
|
||||
type Props = {
|
||||
comments: Message[];
|
||||
packetCounter: number;
|
||||
addComment: (comment: string) => Promise<boolean>;
|
||||
};
|
||||
|
||||
export const CommentView = ({ comments, packetCounter, addComment }: Props) => {
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
const [comment, setComment] = useState('');
|
||||
const [scrollTop, setScrollTop] = useState(0);
|
||||
const commentViewContainer = useRef<HTMLDivElement>(null);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (commentViewContainer.current) {
|
||||
setScrollTop(commentViewContainer.current.offsetHeight);
|
||||
} else {
|
||||
setScrollTop(0);
|
||||
}
|
||||
}, [packetCounter]);
|
||||
|
||||
const onUpdateComment = (event: FormEvent<HTMLTextAreaElement>) => {
|
||||
const element = event.target as HTMLInputElement;
|
||||
setComment(element.value);
|
||||
};
|
||||
|
||||
const onKeyPress = async (event: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (event?.key === 'Enter' && !event?.shiftKey) {
|
||||
event.preventDefault();
|
||||
|
||||
if (comment.length > 0) {
|
||||
const result = await addComment(comment);
|
||||
if (result) {
|
||||
setComment('');
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<CustomScrollbar scrollTop={scrollTop}>
|
||||
<div ref={commentViewContainer} className={styles.commentViewContainer}>
|
||||
{comments.map((msg) => (
|
||||
<Comment key={msg.id} message={msg} />
|
||||
))}
|
||||
<TextArea
|
||||
placeholder="Write a comment"
|
||||
value={comment}
|
||||
onChange={onUpdateComment}
|
||||
onKeyPress={onKeyPress}
|
||||
autoFocus={true}
|
||||
/>
|
||||
</div>
|
||||
</CustomScrollbar>
|
||||
);
|
||||
};
|
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => ({
|
||||
commentViewContainer: css`
|
||||
margin: 5px;
|
||||
`,
|
||||
});
|
||||
21
public/app/features/comments/types.ts
Normal file
21
public/app/features/comments/types.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
export interface MessagePacket {
|
||||
event: string;
|
||||
commentCreated: Message;
|
||||
}
|
||||
|
||||
export interface Message {
|
||||
id: number;
|
||||
content: string;
|
||||
created: number;
|
||||
userId: number;
|
||||
user: User;
|
||||
}
|
||||
|
||||
// TODO: Interface may exist elsewhere
|
||||
export interface User {
|
||||
id: number;
|
||||
name: string;
|
||||
login: string;
|
||||
email: string;
|
||||
avatarUrl: string;
|
||||
}
|
||||
@@ -15,9 +15,11 @@ import { DashboardModel } from '../../state';
|
||||
import { KioskMode } from 'app/types';
|
||||
import { ShareModal } from 'app/features/dashboard/components/ShareModal';
|
||||
import { SaveDashboardModalProxy } from 'app/features/dashboard/components/SaveDashboard/SaveDashboardModalProxy';
|
||||
import { DashboardCommentsModal } from 'app/features/dashboard/components/DashboardComments/DashboardCommentsModal';
|
||||
import { locationService } from '@grafana/runtime';
|
||||
import { toggleKioskMode } from 'app/core/navigation/kiosk';
|
||||
import { getDashboardSrv } from '../../services/DashboardSrv';
|
||||
import config from 'app/core/config';
|
||||
|
||||
const mapDispatchToProps = {
|
||||
updateTimeZoneForSession,
|
||||
@@ -150,6 +152,26 @@ class DashNav extends PureComponent<Props> {
|
||||
);
|
||||
}
|
||||
|
||||
if (dashboard.uid && config.featureToggles.dashboardComments) {
|
||||
buttons.push(
|
||||
<ModalsController key="button-dashboard-comments">
|
||||
{({ showModal, hideModal }) => (
|
||||
<DashNavButton
|
||||
tooltip="Show dashboard comments"
|
||||
icon="comment-alt-message"
|
||||
iconSize="lg"
|
||||
onClick={() => {
|
||||
showModal(DashboardCommentsModal, {
|
||||
dashboard,
|
||||
onDismiss: hideModal,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</ModalsController>
|
||||
);
|
||||
}
|
||||
|
||||
this.addCustomContent(customLeftActions, buttons);
|
||||
return buttons;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import React from 'react';
|
||||
import { css } from '@emotion/css';
|
||||
import { Modal, useStyles2 } from '@grafana/ui';
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
|
||||
import { CommentManager } from 'app/features/comments/CommentManager';
|
||||
import { DashboardModel } from '../../state/DashboardModel';
|
||||
|
||||
type Props = {
|
||||
dashboard: DashboardModel;
|
||||
onDismiss: () => void;
|
||||
};
|
||||
|
||||
export const DashboardCommentsModal = ({ dashboard, onDismiss }: Props) => {
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
return (
|
||||
<Modal isOpen={true} title="Dashboard comments" icon="save" onDismiss={onDismiss} className={styles.modal}>
|
||||
<CommentManager objectType={'dashboard'} objectId={dashboard.uid} />
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => ({
|
||||
modal: css`
|
||||
width: 500px;
|
||||
height: 60vh;
|
||||
`,
|
||||
});
|
||||
@@ -1,8 +1,11 @@
|
||||
import React from 'react';
|
||||
import { css } from '@emotion/css';
|
||||
import { HorizontalGroup, IconButton, Tag, useStyles2 } from '@grafana/ui';
|
||||
import { GrafanaTheme2, textUtil } from '@grafana/data';
|
||||
|
||||
import alertDef from 'app/features/alerting/state/alertDef';
|
||||
import { css } from '@emotion/css';
|
||||
import { CommentManager } from 'app/features/comments/CommentManager';
|
||||
import config from 'app/core/config';
|
||||
|
||||
interface AnnotationTooltipProps {
|
||||
annotation: AnnotationsDataFrameViewDTO;
|
||||
@@ -12,13 +15,13 @@ interface AnnotationTooltipProps {
|
||||
onDelete: () => void;
|
||||
}
|
||||
|
||||
export const AnnotationTooltip: React.FC<AnnotationTooltipProps> = ({
|
||||
export const AnnotationTooltip = ({
|
||||
annotation,
|
||||
timeFormatter,
|
||||
editable,
|
||||
onEdit,
|
||||
onDelete,
|
||||
}) => {
|
||||
}: AnnotationTooltipProps) => {
|
||||
const styles = useStyles2(getStyles);
|
||||
const time = timeFormatter(annotation.time);
|
||||
const timeEnd = timeFormatter(annotation.timeEnd);
|
||||
@@ -57,8 +60,10 @@ export const AnnotationTooltip: React.FC<AnnotationTooltipProps> = ({
|
||||
);
|
||||
}
|
||||
|
||||
const areAnnotationCommentsEnabled = config.featureToggles.annotationComments;
|
||||
|
||||
return (
|
||||
<div className={styles.wrapper}>
|
||||
<div className={styles.wrapper} style={areAnnotationCommentsEnabled ? { minWidth: '300px' } : {}}>
|
||||
<div className={styles.header}>
|
||||
<HorizontalGroup justify={'space-between'} align={'center'} spacing={'md'}>
|
||||
<div className={styles.meta}>
|
||||
@@ -82,6 +87,11 @@ export const AnnotationTooltip: React.FC<AnnotationTooltipProps> = ({
|
||||
))}
|
||||
</HorizontalGroup>
|
||||
</>
|
||||
{areAnnotationCommentsEnabled && (
|
||||
<div className={styles.commentWrapper}>
|
||||
<CommentManager objectType={'annotation'} objectId={annotation.id.toString()} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -94,6 +104,13 @@ const getStyles = (theme: GrafanaTheme2) => {
|
||||
wrapper: css`
|
||||
max-width: 400px;
|
||||
`,
|
||||
commentWrapper: css`
|
||||
margin-top: 10px;
|
||||
border-top: 2px solid #2d2b34;
|
||||
height: 30vh;
|
||||
overflow-y: scroll;
|
||||
padding: 0 3px;
|
||||
`,
|
||||
header: css`
|
||||
padding: ${theme.spacing(0.5, 1)};
|
||||
border-bottom: 1px solid ${theme.colors.border.weak};
|
||||
|
||||
Reference in New Issue
Block a user