Streaming using WebSockets and Sproutcore
This is a quick brain dump on how I the Chililog Workbench Stream page works.
I don’t profess to be a Sproutcore guru so if you know of a better way to do what I’ve done, please let me know!
The code is located here: https://github.com/chililog/chililog-server/tree/master/src/main/sc2
Stream.html Page
The stream page is just a standard HTML page. No tricky server side code here. I’ve used Sproutcore V2 to do all the UI rendering.
The main action happens with this bit of html:
<script type="text/javascript">
<div class="container">
<div class="title">
<h1>Stream</h1>
Watch real time log entries as they come in. Select your repository and click Start.</div>
<div id="streamContent" class="clearfix">
<form class="alert-message block-message info form-stacked">
<fieldset>
{{view App.RepositoryField id="repositoryField" }}
{{view App.SeverityField id="severityField" }}
{{view App.SourceField id="sourceField" }}
{{view App.HostField id="hostField" }}
{{view App.ActionButton id="actionButton" class="btn primary"}}
{{view App.ClearButton id="clearButton" class="btn" isVisibleBinding="App.pageController.showActionButton2" }}
<div class="clearfix"></div>
</fieldset>
</form>
{{view App.ErrorMessage id="errorMessage"}}
<div id="results" style="display: none;">
<div class="heading ui-corner-all">Log Entries</div>
</div>
{{#view id="bottombar" class="clearfix"}}
<div style="float: right;">
{{view App.ClearButton id="clearButton2" class="btn" isVisibleBinding="App.pageController.showActionButton2" }}
{{view App.ActionButton id="actionButton2" class="btn primary" isVisibleBinding="App.pageController.showActionButton2" }}</div>
<div style="float: left;">
{{view App.TestMessageButton id="testMessageButton" class="btn" isVisibleBinding="App.pageController.isStreaming" }}</div>
{{/view}}</div>
...
</script>
Points to note:
- The form #streamContent > form renders the streaming criteria.
- #results is where incoming log entries will be rendered.
- #bottombar holds the buttons that appear at the bottom of the page.
- The “code behind” specific to this html file is stream.js.
- The CSS and styling mainly comes from Twitter Bootstrap
Views
Fields
I’ve got views named like App.RepositoryField and App.SourceField.
I’ve taken a field to be a label, a data entry control and optional help.
To stop repetition, I found it easy to define a generic field views (in app_views.js) :
- App.FieldView – label and data control on the same line
- App.StackedFieldView - label on top of data control.
I then inherit and customise these generic fields as so:
/**
* @class
* Source field
*/
App.SourceField = App.StackedFieldView.extend({
label: '_stream.source'.loc(),
DataView : App.TextBoxView.extend(App.CriteriaFieldDataMixin, {
classNames: 'medium'.w(),
valueBinding: 'App.pageController.source',
name: 'source',
disabledBinding: SC.Binding.from('App.pageController.isStreaming').oneWay().bool()
})
});
Simple Bindings
I’ve set my fields and other views to bind to values in the App.pageController (see below).
To bind a property’s value, just append the word “Binding” to the property name and set the value of the property to the name of the object containing the desired value.
In the above example, valueBinding is set to App.pageController.source. This means that every time the value of App.pageController.source changes, the value property my text box will be set to new value. The browser will then render the new value.
Complex Bindings
Sometimes, you need more than a simple 1-1 mapping. Sproutcore supports binding transformation.
In the above example, disableBinding is set to SC.Binding.from(‘App.pageController.isStreaming’).oneWay().bool().
This means :
- the property disable will be set to the contents of App.pageController.isStreaming.
- oneWay() flags that App.pageController.isStreaming NOT be set to the value of disabled.
- bool() transforms the data content into a true/false.
For more on Sproutcore transformation, see http://wiki.sproutcore.com/w/page/12412963/Runtime-Bindings.
I know it is old documentation. I’ve tried to submit this to the guides but my pull request has not been accepted as yet.
Controllers
I’ve tried to make sure that views and business logic do not directly communicate with each other. I feel this separation of concern makes the app easier to maintain.
They communicate via App.pageController and App.statechart.
- Business logic changes properties in the App.pageController and views react to that
- Views updates App.pageController with data and triggers business logic by sending actions to App.statechart.
/**
* @class
* Mediates between state charts and views
*/
App.pageController = SC.Object.create({
/**
* Selected item of the repository field
*
* @type App.RepositoryStatusRecord
*/
repository: null,
/**
* Selected item of the severity field
*
* @type SC.Object
*/
severity: null,
/**
* Value of the source field
*
* @type String
*/
source: '',
/**
* Value of the host field
*
* @type String
*/
host: '',
/**
* Error message to display
*
* @type String
*/
errorMessage: '',
/**
* Indicates if we are currently streaming or not
*
* @type Boolean
*/
isStreaming: NO,
/**
* Maximum number of log entries displayed. If this is exceeded, the earliest entries are deleted
*
* @type int
*/
maxRowsToDisplay: 1000,
/**
* Options for displaying in the repository dropdown
*
* @type SC.ArrayProxy of SC.RepositoryStatusRecord
*/
repositoryOptions: SC.ArrayProxy.create(),
/**
* Options for displaying in the severity dropdown
*
* @type Array of SC.Object
*/
severityOptions: [
SC.Object.create({label: '_repositoryEntryRecord.Severity.Emergency'.loc(), value: '0'}),
SC.Object.create({label: '_repositoryEntryRecord.Severity.Action'.loc(), value: '1'}),
SC.Object.create({label: '_repositoryEntryRecord.Severity.Critical'.loc(), value: '2'}),
SC.Object.create({label: '_repositoryEntryRecord.Severity.Error'.loc(), value: '3'}),
SC.Object.create({label: '_repositoryEntryRecord.Severity.Warning'.loc(), value: '4'}),
SC.Object.create({label: '_repositoryEntryRecord.Severity.Notice'.loc(), value: '5'}),
SC.Object.create({label: '_repositoryEntryRecord.Severity.Information'.loc(), value: '6'}),
SC.Object.create({label: '_repositoryEntryRecord.Severity.Debug'.loc(), value: '7', selected: YES })
],
/**
* Flag to indicate if we want the bottom bar to show or not
*
* @type Boolean
*/
showActionButton2: NO,
/**
* Writes a log entry to the results area
* @param {Object} logEntry data to write to page
* @param {Boolean} doSeverityCheck YES to perform severity check to decide if log entry is to be dispalyed or not
*/
writeLogEntry: function (logEntry, doSeverityCheck) {
...
}
});
State Chart
If you are developing an app using SC2, please consider using a State Chart. I really helps order you thinking and your logic.
App.statechart = SC.Statechart.create({
rootState: SC.State.extend({
initialSubstate: 'notStreaming',
/**
* Prompt the user for criteria
*/
notStreaming: SC.State.extend({
enterState: function() {
},
exitState: function() {
},
doStart: function() {
App.pageController.set('errorMessage', '');
this.gotoState('streaming');
}
}),
/**
* Startup web socket and wait for incoming messages
*/
streaming: SC.State.extend({
enterState: function() {
App.pageController.set('isStreaming', YES);
App.streamingController.startStreaming();
},
/**
* Stop streaming
*/
doStop: function() {
App.pageController.set('errorMessage', '');
this.gotoState('notStreaming');
},
exitState: function() {
App.streamingController.stopStreaming();
App.pageController.set('isStreaming', NO);
}
})
})
});
This is a simple state chart.
- Start state is notStreaming.
- If the you clicks Start button, it sends a doStart action to the state chart.
- This triggers the doStart() under notStreaming to execute. This moves the state to streaming.
- While streaming, if the user clicks Stop, it sends a doStop action to the state chart.
- This triggers the doStop() under streaming to execute. This moves the state back to notStreaming.
During the transitions, the isStreaming property in the App.pageController is updated.
This will trigger our views to enable/disable (like App.SourceField as detailed above) as well as change label (like App.ActionButton which changes between start and stop).
Receiving Log Entries
When the Start button is clicked, we start receiving log entries.
For simplicity, I’ve put the code in its own controller: App.streamingController.
App.streamingController = SC.Object.create({
/**
* Current Websocket being used to talk to the server
*
* @type WebSocket
*/
webSocket: null,
/**
* Make web socket connection and wait for log entries to come down
*/
startStreaming: function() {
try {
$('#results').css('display', 'block');
var webSocket = new App.WebSocket('ws://' + document.domain + ':61615/websocket');
webSocket.onopen = function () {
SC.Logger.log('Socket opening');
var request = {
MessageType: 'SubscriptionRequest',
MessageID: new Date().getTime() + '',
RepositoryName: App.pageController.getPath('repository.name'),
Source: App.pageController.getPath('source'),
Host: App.pageController.getPath('host'),
Severity: App.pageController.getPath('severity.value'),
Username: App.sessionEngine.getPath('loggedInUser.username'),
Password: 'token:' + App.sessionEngine.get('authenticationToken')
};
var requestJSON = JSON.stringify(request);
webSocket.send(requestJSON);
App.pageController.writeLogEntry({
Timestamp: '',
Source: 'workbench',
Host: 'local',
Severity: '6',
Message: 'Started. Waiting for messages ...'
}, false);
};
webSocket.onmessage = function (evt) {
SC.Logger.log('Socket received message: ' + evt.data);
try {
var response = JSON.parse(evt.data);
if (!response.Success) {
// Turn our error into a message for display
response.LogEntry = {
Timestamp: '',
Source: 'Chililog',
Host: 'Chililog',
Severity: '3',
Message: response.ErrorMessage
};
}
App.pageController.writeLogEntry(response.LogEntry, true);
}
catch (exception) {
SC.Logger.log('Error parsing log entry. ' + exception);
}
};
webSocket.onclose = function (evt) {
SC.Logger.log('Socket close');
App.pageController.writeLogEntry({
Timestamp: '',
Source: 'workbench',
Host: 'local',
Severity: '6',
Message: 'Stopped'
}, false);
};
webSocket.onerror = function (evt) {
SC.Logger.log('Socket error: ' + evt.data);
};
App.streamingController.set('webSocket', webSocket);
} catch (exception) {
App.pageController.errorMessage = exception;
this.gotoState('notStreaming');
}
},
/**
* Stop streaming
*/
stopStreaming: function() {
try {
App.streamingController.get('webSocket').close();
} catch (exception) {
SC.Logger.log('Error closing web socket: ' + exception);
}
}
});
When we start streaming, App.streamingController.startStreaming() is called. This establishes a web socket connect with the server.
When log entries are received, it uses App.pageController.writeLogEntry() to render it. I’ve written directly to the DOM using jQuery rather than use a DataStore because:
- I found that the data store performed very slowly compared to writing directly to the DOM when there is a large number of log entries to display
- Because log entries are displayed as read only, I do not need CRUD functions of SC.Record and SC.DataStore
Sending Log Entries
To send test log entries, I setup another web socket.
I put this code local to the Send Test Message button.
App.TestMessageButton = App.ButtonView.extend({
label: '_stream.test'.loc(),
click: function() {
this.set('disabled', YES);
var username = App.sessionEngine.getPath('loggedInUser.username');
var scDate = SC.DateTime.create({ timezone: 0});
var ts = scDate.toFormattedString('%Y-%m-%dT%H:%M:%S.%sZ');
var request = {
MessageType: 'PublicationRequest',
MessageID: new Date().getTime() + '',
RepositoryName: App.pageController.getPath('repository.name'),
Username: App.sessionEngine.getPath('loggedInUser.username'),
Password: 'token:' + App.sessionEngine.get('authenticationToken'),
LogEntries: [
{ Timestamp: ts, Source: 'workbench', Host: 'local', Severity: '7', Message: 'Test DEBUG message sent from browser with all sorts of funny characters !@#$%^&*()_+{}[]:";\'<>,.?/ ' + navigator.userAgent},
{ Timestamp: ts, Source: 'workbench', Host: 'local', Severity: '4', Message: 'Test WARNING message with a timestamp and a very long example of a java class path org.chililog.server.pubsub.websocket.AVeryLongClassName. The time is now ' + new Date() },
{ Timestamp: ts, Source: 'workbench', Host: 'local', Severity: '3', Message: 'Test ERROR message sent by ' + username}
]
};
try {
var me = this;
var webSocket = new App.WebSocket('ws://' + document.domain + ':61615/websocket');
webSocket.onopen = function () {
SC.Logger.log('Test Socket opening');
var requestJSON = JSON.stringify(request);
// Sent it
webSocket.send(requestJSON);
};
webSocket.onmessage = function (evt) {
SC.Logger.log('Socket received message: ' + evt.data);
try {
var response = JSON.parse(evt.data);
if (!response.Success) {
response.LogEntry = { Timestamp: '', Source: 'Chililog', Host: 'Chililog', Severity: '3', Message: response.ErrorMessage };
App.pageController.writeLogEntry(response.LogEntry, true);
}
// Close after we get a response so that button is enabled and the user can send another message
webSocket.close();
}
catch (exception) {
SC.Logger.log('Error parsing log entry. ' + exception);
}
};
webSocket.onclose = function (evt) {
SC.Logger.log('Test Socket close');
me.set('disabled', NO); //cannot use this because it is the websocket
};
webSocket.onerror = function (evt) {
SC.Logger.log('Test Socket error: ' + evt.data);
var logEntry = { Timestamp: '', Source: 'Chililog', Host: 'Chililog', Severity: '3', Message: evt.data };
App.pageController.writeLogEntry(logEntry, true);
};
} catch (exception) {
SC.Logger.log('Test Socket error: ' + evt.data);
var logEntry = { Timestamp: '', Source: 'Chililog', Host: 'Chililog', Severity: '3', Message: exception };
App.pageController.writeLogEntry(logEntry, true);
}
return;
}
});



