firewatch
Version:
Firefox OS memory watch
758 lines (687 loc) • 18.5 kB
JavaScript
/** @jsx React.DOM */
var cx = React.addons.classSet;
var Apps = React.createClass({
getInitialState: function() {
return {
connected: false,
device: null,
lastDevice: null,
snapshots: [],
hidden: [],
hideSystem: true,
hideDead: false,
deviceInit: false,
size: {width: 640, height: 480},
didStartProfile: [],
willProfile: [],
profiles: {},
reports: [],
killed: [],
willMeasure: false,
graphSpan: 120000,
meanSpan: 10000,
paths: null,
prefs: null
};
},
updatePref: function(key, value) {
var prefs = _.clone(this.state.prefs || {});
prefs[key] = (value != null) ? value : !prefs[key];
this.socket.emit('prefs', prefs);
this.setState({
prefs: prefs
});
},
handleAppInitialize: function(data) {
this.setState({
connected: true,
snapshots: data.snapshots,
didStartProfile: data.didStartProfile,
profiles: data.profiles,
reports: data.reports,
device: data.device,
killed: data.killed,
paths: data.paths,
prefs: data.prefs
});
},
handleDisconnect: function() {
this.setState({
connected: false
});
},
handleAppSnapshot: function(snapshot) {
this.setState({
snapshots: [snapshot].concat(this.state.snapshots)
});
},
handleDeviceConnected: function(device) {
document.title = 'Firewatch: ' + device;
this.setState({device: device, lastDevice: null});
},
handleDeviceDisconnected: function(error) {
document.title = 'Firewatch';
this.setState({
device: null,
lastDevice: this.state.lastDevice,
deviceError: error
});
},
handleKilled: function(killed) {
this.setState({
killed: this.state.killed.concat(killed)
});
},
componentDidMount: function(root) {
var socket = io.connect(location.origin, {
'max reconnection attempts': 999,
'try multiple transports': false
});
this.socket = socket;
socket.on('disconnect', this.handleDisconnect);
socket.on('initialize', this.handleAppInitialize);
socket.on('snapshot', this.handleAppSnapshot);
socket.on('connected', this.handleDeviceConnected);
socket.on('disconnected', this.handleDeviceDisconnected);
socket.on('killed', this.handleKilled);
socket.on('profile.didStart', this.handleProfileDidStart);
socket.on('profile.willCapture', this.handleProfileWillCapture);
socket.on('profile.didCapture', this.handleProfileDidCapture);
socket.on('reporter.willMeasure', this.handleReportWillMeasure);
socket.on('reporter.didMeasure', this.handleReportDidMeasure);
window.addEventListener('resize', this.handleResize);
this.handleResize();
},
componentDidUpdate: function() {
var side = this.refs.sidebar.getDOMNode();
var size = [side.offsetWidth, side.offsetHeight];
if (!this.pastSize || !_.isEqual(size, this.pastSize)) {
this.pastSize = size;
this.handleResize();
}
},
handleResize: function() {
if (!this.refs.graph) {
return;
}
var element = this.refs.graph.getDOMNode();
this.setState({
size: {
width: element.offsetWidth,
height: element.offsetHeight
}
});
},
handleAppToggle: function(id, state, sys, dead) {
if (dead && this.state.hideDead) {
this.setState({
hideDead: false
});
return;
}
if (sys && this.state.hideSystem) {
this.setState({
hideSystem: false
});
return;
}
var hidden = this.state.hidden.slice();
if (!state) {
hidden.push(id);
} else {
_.pull(hidden, id);
}
this.setState({
hidden: hidden
});
},
handleToggleDead: function() {
this.setState({
hideDead: !this.state.hideDead
});
},
handleToggleSystem: function() {
this.setState({
hideSystem: !this.state.hideSystem
});
},
handleToggleAutoProfile: function() {
this.updatePref('autoProfile');
},
handleChangeThrottle: function(evt) {
var throttle = parseInt(evt.target.value) || 0;
if (!isNaN(throttle) && throttle > 0) {
this.updatePref('throttle', parseInt(evt.target.value));
}
},
handleProfileStart: function(pid) {
// FIXME: Hack
this.socket.emit('profile.start', pid);
},
handleProfileDidStart: function(err, pid) {
this.setState({
didStartProfile: this.state.didStartProfile.concat(pid)
});
},
handleProfileCapture: function(pid, disabled, evt) {
evt.preventDefault();
if (disabled) {
return;
}
if (this.state.didStartProfile.indexOf(pid) == -1) {
return this.handleProfileStart(pid);
}
if (this.state.willProfile.indexOf(pid) != -1) {
return;
}
this.socket.emit('profile.capture', pid);
this.setState({
willProfile: this.state.willProfile.concat(pid)
});
},
handleProfileWillCapture: function(err, pid) {
if (err) {
this.handleProfileDidCapture(err, pid);
}
},
handleProfileDidCapture: function(err, pid, profile) {
this.setState({
willProfile: _.without(this.state.willProfile, pid)
});
if (err) {
return alert('Capturing profile for ' + pid + ' failed:\n' + err);
}
var profiles = _.clone(this.state.profiles);
var list = (profiles[profile.pid] || []).slice();
list.push(profile);
profiles[profile.pid] = list;
this.setState({
profiles: profiles
});
},
handleReport: function() {
this.setState({
willMeasure: true
});
this.socket.emit('report');
},
handleReportWillMeasure: function(err) {
if (err) {
this.handleReportDidMeasure(err);
}
},
handleReportDidMeasure: function(err, report) {
this.setState({
willMeasure: false
});
if (err) {
return alert('Memory report failed\n' + err);
}
this.setState({
reports: this.state.reports.concat(report)
});
},
render: function () {
if (!this.state.prefs) {
return <main>Connecting …</main>
}
var snapshots = this.state.snapshots.slice(0, 500);
if (this.state.snapshots.length > 500) {
snapshots.push(this.state.snapshots.slice(-1)[0]);
// console.log(this.state.snapshots.slice(-1));
}
var last = snapshots[0] || {
apps: [],
mem: {
free: 0,
cache: 0,
total: 0
}
};
var colorScale = d3.scale.category20();
var hidden = this.state.hidden;
var meanSpan = this.state.meanSpan;
var hideSystem = this.state.hideSystem;
var hideDead = this.state.hideDead;
var avgs = {
lag: new Avg(),
interval: new Avg(),
free: new Avg(),
cache: new Avg()
}
var activeApps = _.pluck(last.apps, 'id');
var appsByPid = {};
var device = null;
var startTime = 0;
var flattened = snapshots.reduce(function(apps, snapshot, i) {
if (device == null) {
device = snapshot.device;
startTime = snapshot.time;
}
if (!startTime || startTime - meanSpan < snapshot.time) {
avgs.lag.add(snapshot.lag);
if (snapshot.interval) {
avgs.interval.add(snapshot.interval);
}
avgs.free.add(snapshot.mem.free);
avgs.cache.add(snapshot.mem.cache);
}
return apps.concat(snapshot.apps.filter(function(app) {
var record = appsByPid[app.id];
var dead = activeApps.indexOf(app.id) == -1;
if (!record) {
if (!app.sys || !dead) {
record = appsByPid[app.id] = {
meta: app,
created: app.time,
mem: (new Avg().add(app.mem)),
sys: app.sys,
dead: dead
};
}
} else {
record.created = app.time;
if (record.meta.time - meanSpan < app.time) {
record.mem.add(app.mem);
}
}
return !((hideSystem && app.sys) || (hideDead && record.dead) || _.contains(hidden, app.id));
}));
}, []);
var groupedSnapshots = _.groupBy(flattened, 'pid');
var timeRange = [
_.min(flattened, 'time').time,
_.max(flattened, 'time').time
];
var timeDrawRange = [timeRange[1] - this.state.graphSpan, timeRange[1]];
var filtered = _.filter(flattened, function(snapshot) {
return snapshot.time >= timeDrawRange[0] && snapshot.time <= timeDrawRange[1]
});
var memDrawRange = [
_.min(filtered, 'mem').mem,
_.max(filtered, 'mem').mem
];
var allApps = _.toArray(appsByPid);
var colors = this.colors || (this.colors = {});
_.sortBy(allApps, ['created']).forEach(function(entry, i) {
if (!colors[entry.meta.pid]) {
colors[entry.meta.pid] = null;
}
});
_.forEach(colors, function(color, pid) {
colors[pid] = colorScale(pid);
})
var header = false;
var apps = [];
_.sortBy(allApps, ['dead', 'sys', 'created']).forEach(function(entry) {
var app = entry.meta;
var key = 'row-' + app.id;
var style = {
color: colors[app.pid]
};
var backgroundStyle = {
backgroundColor: colors[app.pid]
};
var checked = hidden.indexOf(app.id) < 0;
var rowCls = {};
var iconCls = {
'fa': true,
'fa-fw': true
};
var disabled = false;
var hide = false;
var status = '';
if (entry.dead) {
var killed = this.state.killed.filter(function(details) {
return details.pid == app.pid;
})[0] || null;
if (killed) {
status += killed.type.toUpperCase();
if (killed.mem) {
status += ' (' + round(killed.mem) + ' MB)';
}
}
if (header != 'dead') {
header = 'dead';
var deadIconCls = {
'fa': true,
'fa-fw': true,
'fa-eye-slash': hideDead,
'fa-eye': !hideDead
};
apps.push(
<tr className='split' key='split-dead' onClick={this.handleToggleDead}>
<td><i className={cx(deadIconCls)}></i></td>
<th colSpan='6'>
Killed
</th>
</tr>
);
}
rowCls.dead = true;
disabled = true;
if (hideDead) {
hide = true;
}
}
if (hidden.indexOf(app.id) != -1) {
hide = true;
}
if (app.sys) {
if (header != 'sys') {
header = 'sys';
var sysIconCls = {
'fa': true,
'fa-fw': true,
'fa-eye-slash': hideSystem,
'fa-eye': !hideSystem
};
apps.push(
<tr className='split' key='split-sys' onClick={this.handleToggleSystem}>
<td><i className={cx(sysIconCls)}></i></td>
<th colSpan='6'>
System
</th>
</tr>
);
}
rowCls.system = true;
if (hideSystem) {
hide = true;
}
}
var name = (app.name.substr(0, 2) == '(P') ? '(Preallocated)' : app.name;
var profileStarted = this.state.didStartProfile.indexOf(app.pid) != -1;
var profileDisabled = !profileStarted
|| this.state.willProfile.indexOf(app.pid) != -1;
var profiles = (this.state.profiles[app.pid] || []).map(function(profile, idx) {
var file = location.origin + '/output/'
+ encodeURIComponent(profile.file);
var url = 'http://people.mozilla.org/~bgirard/cleopatra/?customProfile=' + file;
return (
<li>
<a href={url} target='_new'>
{Math.round((profile.time - startTime) / 1000)}s
</a>
</li>
);
}, this).reverse();
if (this.state.willProfile.indexOf(app.pid) != -1) {
profiles.unshift((
<li>
<i className='fa fa-fw fa-spinner fa-spin'></i>
</li>
));
}
iconCls[(hide ? 'fa-eye-slash' : 'fa-eye')] = true;
if (hide) {
rowCls.hidden = true;
}
var title = '#' + app.pid + ', nice: ' + app.nice + ' oom_adj: ' + app.oomadj;
apps.push(
<tr key={key} className={cx(rowCls)}>
<td onClick={this.handleAppToggle.bind(this, app.id, hidden.indexOf(app.id) != -1, app.sys, entry.dead)}>
<i className={cx(iconCls)} style={style}></i>
</td>
<th>
<span style={style} className='name' title={title}>{name}</span>
<small>{status}</small>
</th>
<td className='small'>
<span title={entry.mem.toString()}>{round(app.mem, 1)}</span> <small>MB</small></td>
<td className='buttons'>
<label onClick={this.handleProfileCapture.bind(this, app.pid, disabled)}>
<input type='checkbox' checked={profileStarted} disabled={disabled || profileStarted} />
<button disabled={disabled || profileDisabled}>Profile</button>
</label>
<ul className='linklist'>{profiles}</ul>
</td>
</tr>
);
}, this);
var reports = (this.state.reports || []).map(function(report, idx) {
var file = this.state.paths.output + '/' + report.file;
var url = 'about:memory?file=' + encodeURIComponent(file);
return (
<li>
<a href={url} title='For security reasons force opening in a new tab by pressing the meta key'>
{Math.round((report.time - startTime) / 1000)}s
</a>
</li>
);
}, this);
var measureDisabled = this.state.willMeasure;
if (measureDisabled) {
reports.unshift((
<li>
<i className='fa fa-fw fa-spinner fa-spin'></i>
</li>
));
}
var connectionClass = 'fa fa-fw '
+ (this.state.connected ? 'fa-chain' : 'fa-chain-broken');
if (!this.state.device) {
connectionClass += ' fa-spin';
}
return (
<main>
<header>
<h1>
<i className={connectionClass}></i>
<span title='Device identifier'>
{this.state.device ? (this.state.device) : 'Waiting for device'}
</span>
<small>{round(last.mem.total, 1)} <small>MB</small></small>
</h1>
<table>
<tr>
<th>Free:</th>
<td>
<span title={avgs.free.toString()}>{round(last.mem.free, 1)}</span> <small>MB</small>
</td>
<th>Sampling:</th>
<td>
<span title={avgs.interval.toString()}>{Math.round(avgs.interval.first)}</span> <small>ms</small>
</td>
</tr>
<tr>
<th>Swap:</th>
<td>
<span title={avgs.cache.toString()}>{round(avgs.cache.first, 1)}</span> <small>MB</small>
</td>
<th>ADB Lag:</th>
<td>
<span title={avgs.lag.toString()}>{Math.round(avgs.lag.first)}</span> <small>ms</small>
</td>
</tr>
</table>
</header>
<article>
<div ref='graph' className='graph'>
<LineChart colors={colors} size={this.state.size} timeRange={timeRange} timeDrawRange={timeDrawRange} memDrawRange={memDrawRange} groupedSnapshots={groupedSnapshots} />
</div>
<aside ref='sidebar'>
<section>
<table className='apps-table'>
<tbody>
{apps}
</tbody>
</table>
</section>
<section>
<div>
<button onClick={this.handleReport} disabled={measureDisabled}>Report Memory</button>
<ul className='linklist'>{reports}</ul>
</div>
<div className='prefs'>
<label>
<small>
<span title='Lower sample rate = less performance impact'>Sampling (ms)</span>
</small>
<input type='number' defaultValue={this.state.prefs.throttle} onChange={this.handleChangeThrottle} />
</label>
<label>
<small>
<span title='Attach profiler to every new process'>Auto Profile</span>
</small>
<input type='checkbox' onChange={this.handleToggleAutoProfile} checked={this.state.prefs.autoProfile} />
</label>
</div>
</section>
</aside>
</article>
</main>
)
}
});
var Chart = React.createClass({
render: function() {
return (
<svg width={this.props.width} height={this.props.height}>
{this.props.children}
</svg>
);
}
});
var DataSeries = React.createClass({
getDefaultProps: function() {
return {
title: '',
data: [],
interpolate: 'linear'
}
},
render: function() {
var props = this.props;
var yScale = props.yScale;
var xScale = props.xScale;
var path = d3.svg.line()
.x(function(d) {
return xScale(d.x);
})
.y(function(d) {
return yScale(d.y);
})
.interpolate(props.interpolate);
return (
<path d={path(props.data)} stroke={props.color} strokeWidth='2' fill='none' />
)
}
});
var LineChart = React.createClass({
render: function() {
var groupedSnapshots = this.props.groupedSnapshots;
var timeRange = this.props.timeRange;
var timeDrawRange = this.props.timeDrawRange;
var memDrawRange = this.props.memDrawRange;
var size = this.props.size;
var xScale = d3.scale.linear()
.domain([
timeDrawRange[0] - timeRange[0],
timeDrawRange[1] - timeRange[0]
])
.range([0, size.width]);
var yScale = d3.scale.linear()
.domain([Math.max(memDrawRange[0] - 0.1, 0), memDrawRange[1] + 0.1])
.nice()
.range([size.height, 0]);
var colors = this.props.colors;
var dataSeries = _.mapValues(groupedSnapshots, function(snapshots, pid) {
var series = snapshots.map(function(snapshot) {
return {
x: snapshot.time - timeRange[0],
y: snapshot.mem
};
});
pid = Number(pid);
var key = 'series' + pid;
var color = colors[pid];
return (
<DataSeries key={key} data={series} size={size} xScale={xScale} yScale={yScale} color={color} />
);
});
var xTicks = xScale.copy().ticks((size.width > 650) ? 10 : 5).map(function(tick) {
var left = xScale(tick);
var label = Math.round(tick / 1000) + 's';
var key = 'xtick' + String(tick);
return (
<g key={key} className='tick'>
<text x={left + 3} y={size.height - 4}>{label}</text>
<line x1={left} y1={0} x2={left} y2={size.height} />
</g>
);
});
var yTicks = yScale.ticks((size.height > 400) ? 10 : 5).map(function(tick) {
var top = yScale(tick);
var key = 'ytick' + String(tick);
return (
<g key={key} className='tick'>
<line x1='0' y1={top} x2={size.width} y2={top} />
<text x='2' y={top + 11}>{tick}</text>
</g>
);
});
return (
<Chart width={this.props.size.width} height={this.props.size.height}>
<g className="ticks yticks">{yTicks}</g>
<g className="ticks xticks">{xTicks}</g>
{dataSeries}
</Chart>
);
}
});
function round(number, decimals) {
return parseFloat(number).toFixed(1);
}
function Avg() {
this.size = 0;
this.sum = 0.0;
this.sq = 0.0;
this.max = Number.NEGATIVE_INFINITY;
this.min = Number.POSITIVE_INFINITY;
this.first = null;
this.value = null;
}
Avg.prototype = {
add: function(value) {
this.size++;
this.sum += value;
this.sq += value * value;
if (this.first == null) {
this.first = value;
}
this.value = value;
if (this.max < value) {
this.max = value;
}
if (this.min > value) {
this.min = value;
}
return this;
},
get mean() {
if (!this.size) {
return 0;
}
return this.sum / this.size;
},
get sd() {
if (this.size < 2) {
return 0;
}
return Math.sqrt(
(this.sq - (this.sum * this.sum / this.size)) /
(this.size - 1)
);
},
toString: function() {
return 'min/max ' + round(this.min) + '/' + round(this.max)
+ ', ~' + round(this.mean) + ' ±' + round(this.sd);
}
};
window.addEventListener('load', function () {
React.renderComponent(
<Apps/>,
document.getElementById('content')
);
});