diff --git a/ui/src/actions/MessageAction.js b/ui/src/actions/MessageAction.js
new file mode 100644
index 0000000..7785794
--- /dev/null
+++ b/ui/src/actions/MessageAction.js
@@ -0,0 +1,48 @@
+import dispatcher from '../stores/dispatcher';
+import config from 'react-global-configuration';
+import axios from 'axios';
+import {getToken} from './defaultAxios';
+
+/** Fetches all messages from the current user. */
+export function fetchMessages() {
+ axios.get(config.get('url') + 'message').then((resp) => {
+ dispatcher.dispatch({type: 'UPDATE_MESSAGES', payload: resp.data});
+ });
+}
+
+/** Deletes all messages from the current user. */
+export function deleteMessages() {
+ axios.delete(config.get('url') + 'message').then(fetchMessages);
+}
+
+/**
+ * Deletes all messages from the current user and an application.
+ * @param {int} id the application id
+ */
+export function deleteMessagesByApp(id) {
+ axios.delete(config.get('url') + 'application/' + id + '/message').then(fetchMessages);
+}
+
+/**
+ * Deletes a message by id.
+ * @param {int} id the message id
+ */
+export function deleteMessage(id) {
+ axios.delete(config.get('url') + 'message/' + id).then(fetchMessages);
+}
+
+/**
+ * Starts listening to the stream for new messages.
+ */
+export function listenToWebSocket() {
+ const ws = new WebSocket('ws://localhost:80/stream?token=' + getToken());
+
+ ws.onerror = (e) => {
+ console.log('WebSocket connection errored; trying again in 60 seconds', e);
+ setTimeout(listenToWebSocket, 60000);
+ };
+
+ ws.onmessage = (data) => {
+ dispatcher.dispatch({type: 'ONE_MESSAGE', payload: JSON.parse(data.data)});
+ };
+}
diff --git a/ui/src/component/Message.js b/ui/src/component/Message.js
new file mode 100644
index 0000000..521359d
--- /dev/null
+++ b/ui/src/component/Message.js
@@ -0,0 +1,54 @@
+import React, {Component} from 'react';
+import {withStyles} from 'material-ui/styles';
+import Typography from 'material-ui/Typography';
+import IconButton from 'material-ui/IconButton';
+import PropTypes from 'prop-types';
+import Container from './Container';
+import TimeAgo from 'react-timeago';
+import Delete from 'material-ui-icons/Delete';
+
+const styles = () => ({
+ header: {
+ display: 'flex',
+ },
+ headerTitle: {
+ flex: 1,
+ },
+ trash: {
+ marginTop: -15,
+ marginRight: -15,
+ },
+});
+
+class Message extends Component {
+ static propTypes = {
+ classes: PropTypes.object.isRequired,
+ title: PropTypes.string.isRequired,
+ date: PropTypes.string.isRequired,
+ content: PropTypes.string.isRequired,
+ fDelete: PropTypes.func.isRequired,
+ };
+
+ render() {
+ const {fDelete, classes, title, date, content} = this.props;
+
+ return (
+
+
+
+ {title}
+
+
+
+
+
+
+
+ {content}
+
+
+ );
+ }
+}
+
+export default withStyles(styles)(Message);
diff --git a/ui/src/pages/Messages.js b/ui/src/pages/Messages.js
new file mode 100644
index 0000000..7930ba1
--- /dev/null
+++ b/ui/src/pages/Messages.js
@@ -0,0 +1,71 @@
+import React, {Component} from 'react';
+import Grid from 'material-ui/Grid';
+import Typography from 'material-ui/Typography';
+import Message from '../component/Message';
+import MessageStore from '../stores/MessageStore';
+import AppStore from '../stores/AppStore';
+import * as MessageAction from '../actions/MessageAction';
+import DefaultPage from '../component/DefaultPage';
+
+class Messages extends Component {
+ constructor() {
+ super();
+ this.state = {appId: -1, messages: [], name: 'unknown'};
+ }
+
+ componentWillReceiveProps(nextProps) {
+ this.updateAllWithProps(nextProps);
+ }
+
+ componentWillMount() {
+ MessageStore.on('change', this.updateAll);
+ AppStore.on('change', this.updateAll);
+ this.updateAll();
+ }
+
+ componentWillUnmount() {
+ MessageStore.removeListener('change', this.updateAll);
+ AppStore.removeListener('change', this.updateAll);
+ }
+
+ updateAllWithProps = (props) => {
+ const appId = Messages.appId(props);
+ const messages = MessageStore.getForAppId(appId);
+ const name = AppStore.getName(appId);
+ this.setState({appId: appId, messages, name});
+ };
+
+ updateAll = () => this.updateAllWithProps(this.props);
+
+ static appId(props) {
+ if (props === undefined) {
+ return -1;
+ }
+ const {match} = props;
+ return match.params.id !== undefined ? parseInt(match.params.id) : -1;
+ }
+
+ render() {
+ const {name, messages, appId} = this.state;
+ const fDelete = appId === -1 ? MessageAction.deleteMessages : MessageAction.deleteMessagesByApp.bind(this, appId);
+
+ const noMessages = (
+ No messages
+ );
+
+ return (
+
+ {messages.length === 0 ? noMessages : messages.map((message) => {
+ return (
+
+ MessageAction.deleteMessage(message.id)} title={message.title}
+ date={message.date} content={message.message}/>
+
+ );
+ })}
+
+ );
+ }
+}
+
+export default Messages;
diff --git a/ui/src/stores/MessageStore.js b/ui/src/stores/MessageStore.js
new file mode 100644
index 0000000..59cdbbb
--- /dev/null
+++ b/ui/src/stores/MessageStore.js
@@ -0,0 +1,52 @@
+import {EventEmitter} from 'events';
+import dispatcher from './dispatcher';
+
+class MessageStore extends EventEmitter {
+ constructor() {
+ super();
+ this.messages = [];
+ this.messagesForApp = [];
+ }
+
+ get() {
+ return this.messages;
+ }
+
+ getForAppId(id) {
+ if (id === -1) {
+ return this.messages;
+ }
+ if (this.messagesForApp[id]) {
+ return this.messagesForApp[id];
+ }
+ return [];
+ }
+
+ handle(data) {
+ if (data.type === 'UPDATE_MESSAGES') {
+ this.messages = data.payload;
+ this.messagesForApp = [];
+ this.messages.forEach(function(message) {
+ this.createIfNotExist(message.appid);
+ this.messagesForApp[message.appid].push(message);
+ }.bind(this));
+ this.emit('change');
+ } else if (data.type === 'ONE_MESSAGE') {
+ const {payload} = data;
+ this.createIfNotExist(payload.appid);
+ this.messagesForApp[payload.appid].unshift(payload);
+ this.messages.unshift(payload);
+ this.emit('change');
+ }
+ }
+
+ createIfNotExist(id) {
+ if (!(id in this.messagesForApp)) {
+ this.messagesForApp[id] = [];
+ }
+ }
+}
+
+const store = new MessageStore();
+dispatcher.register(store.handle.bind(store));
+export default store;