Initial commit 🎉
fbshipit-source-id: b6fc29740c6875d2e78953b8a7123890a67930f2 Co-authored-by: Sebastian McKenzie <sebmck@fb.com> Co-authored-by: John Knox <jknox@fb.com> Co-authored-by: Emil Sjölander <emilsj@fb.com> Co-authored-by: Pritesh Nandgaonkar <prit91@fb.com>
This commit is contained in:
323
src/device-plugins/cpu/index.js
Normal file
323
src/device-plugins/cpu/index.js
Normal file
@@ -0,0 +1,323 @@
|
||||
/**
|
||||
* Copyright 2018-present Facebook.
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
* @format
|
||||
*/
|
||||
|
||||
import {SonarDevicePlugin} from 'sonar';
|
||||
var adb = require('adbkit-fb');
|
||||
|
||||
import {
|
||||
FlexColumn,
|
||||
FlexRow,
|
||||
Button,
|
||||
Toolbar,
|
||||
Text,
|
||||
ManagedTable,
|
||||
colors,
|
||||
} from 'sonar';
|
||||
|
||||
type ADBClient = any;
|
||||
type AndroidDevice = any;
|
||||
type TableRows = any;
|
||||
|
||||
// we keep vairable name with underline for to physical path mappings on device
|
||||
type CPUFrequency = {|
|
||||
cpu_id: number,
|
||||
scaling_cur_freq: number,
|
||||
scaling_min_freq: number,
|
||||
scaling_max_freq: number,
|
||||
cpuinfo_max_freq: number,
|
||||
cpuinfo_min_freq: number,
|
||||
|};
|
||||
|
||||
type CPUState = {|
|
||||
cpuFreq: Array<CPUFrequency>,
|
||||
cpuCount: number,
|
||||
monitoring: boolean,
|
||||
|};
|
||||
|
||||
type ShellCallBack = (output: string) => void;
|
||||
|
||||
const ColumnSizes = {
|
||||
cpu_id: '10%',
|
||||
scaling_cur_freq: 'flex',
|
||||
scaling_min_freq: 'flex',
|
||||
scaling_max_freq: 'flex',
|
||||
cpuinfo_min_freq: 'flex',
|
||||
cpuinfo_max_freq: 'flex',
|
||||
};
|
||||
|
||||
const Columns = {
|
||||
cpu_id: {
|
||||
value: 'CPU ID',
|
||||
resizable: true,
|
||||
},
|
||||
scaling_cur_freq: {
|
||||
value: 'Scaling Current',
|
||||
resizable: true,
|
||||
},
|
||||
scaling_min_freq: {
|
||||
value: 'Scaling MIN',
|
||||
resizable: true,
|
||||
},
|
||||
scaling_max_freq: {
|
||||
value: 'Scaling MAX',
|
||||
resizable: true,
|
||||
},
|
||||
cpuinfo_min_freq: {
|
||||
value: 'MIN Frequency',
|
||||
resizable: true,
|
||||
},
|
||||
cpuinfo_max_freq: {
|
||||
value: 'MAX Frequency',
|
||||
resizable: true,
|
||||
},
|
||||
};
|
||||
|
||||
// check if str is a number
|
||||
function isNormalInteger(str) {
|
||||
let n = Math.floor(Number(str));
|
||||
return String(n) === str && n >= 0;
|
||||
}
|
||||
|
||||
// format frequency to MHz, GHz
|
||||
function formatFrequency(freq) {
|
||||
if (freq == -1) {
|
||||
return 'N/A';
|
||||
} else if (freq == -2) {
|
||||
return 'off';
|
||||
} else if (freq > 1000 * 1000) {
|
||||
return (freq / 1000 / 1000).toFixed(2) + ' GHz';
|
||||
} else {
|
||||
return freq / 1000 + ' MHz';
|
||||
}
|
||||
}
|
||||
|
||||
export default class CPUFrequencyTable extends SonarDevicePlugin<CPUState> {
|
||||
static id = 'DeviceCPU';
|
||||
static title = 'CPU';
|
||||
static icon = 'underline';
|
||||
|
||||
adbClient: ADBClient;
|
||||
intervalID: ?IntervalID;
|
||||
device: AndroidDevice;
|
||||
|
||||
init() {
|
||||
this.setState({
|
||||
cpuFreq: [],
|
||||
cpuCount: 0,
|
||||
monitoring: false,
|
||||
});
|
||||
|
||||
this.adbClient = this.device.adb;
|
||||
|
||||
// check how many cores we have on this device
|
||||
this.executeShell((output: string) => {
|
||||
let idx = output.indexOf('-');
|
||||
let cpuFreq = [];
|
||||
let count = parseInt(output.substring(idx + 1), 10) + 1;
|
||||
for (let i = 0; i < count; ++i) {
|
||||
cpuFreq[i] = {
|
||||
cpu_id: i,
|
||||
scaling_cur_freq: -1,
|
||||
scaling_min_freq: -1,
|
||||
scaling_max_freq: -1,
|
||||
cpuinfo_min_freq: -1,
|
||||
cpuinfo_max_freq: -1,
|
||||
};
|
||||
}
|
||||
this.setState({
|
||||
cpuCount: count,
|
||||
cpuFreq: cpuFreq,
|
||||
});
|
||||
}, 'cat /sys/devices/system/cpu/possible');
|
||||
}
|
||||
|
||||
executeShell = (callback: ShellCallBack, command: string) => {
|
||||
this.adbClient
|
||||
.shell(this.device.serial, command)
|
||||
.then(adb.util.readAll)
|
||||
.then(function(output) {
|
||||
return callback(output.toString().trim());
|
||||
});
|
||||
};
|
||||
|
||||
updateCoreFrequency = (core: number, type: string) => {
|
||||
this.executeShell((output: string) => {
|
||||
let cpuFreq = this.state.cpuFreq;
|
||||
let newFreq = isNormalInteger(output) ? parseInt(output, 10) : -1;
|
||||
|
||||
// update table only if frequency changed
|
||||
if (cpuFreq[core][type] != newFreq) {
|
||||
cpuFreq[core][type] = newFreq;
|
||||
if (type == 'scaling_cur_freq' && cpuFreq[core][type] < 0) {
|
||||
// cannot find current freq means offline
|
||||
cpuFreq[core][type] = -2;
|
||||
}
|
||||
|
||||
this.setState({
|
||||
cpuFreq: cpuFreq,
|
||||
});
|
||||
}
|
||||
}, 'cat /sys/devices/system/cpu/cpu' + core + '/cpufreq/' + type);
|
||||
};
|
||||
|
||||
readCoreFrequency = (core: number) => {
|
||||
let freq = this.state.cpuFreq[core];
|
||||
if (freq.cpuinfo_max_freq < 0) {
|
||||
this.updateCoreFrequency(core, 'cpuinfo_max_freq');
|
||||
}
|
||||
if (freq.cpuinfo_min_freq < 0) {
|
||||
this.updateCoreFrequency(core, 'cpuinfo_min_freq');
|
||||
}
|
||||
this.updateCoreFrequency(core, 'scaling_cur_freq');
|
||||
this.updateCoreFrequency(core, 'scaling_min_freq');
|
||||
this.updateCoreFrequency(core, 'scaling_max_freq');
|
||||
};
|
||||
|
||||
onStartMonitor = () => {
|
||||
if (this.intervalID) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.intervalID = setInterval(() => {
|
||||
for (let i = 0; i < this.state.cpuCount; ++i) {
|
||||
this.readCoreFrequency(i);
|
||||
}
|
||||
}, 500);
|
||||
|
||||
this.setState({
|
||||
monitoring: true,
|
||||
});
|
||||
};
|
||||
|
||||
onStopMonitor = () => {
|
||||
if (!this.intervalID) {
|
||||
return;
|
||||
} else {
|
||||
clearInterval(this.intervalID);
|
||||
this.intervalID = null;
|
||||
this.setState({
|
||||
monitoring: false,
|
||||
});
|
||||
this.cleanup();
|
||||
}
|
||||
};
|
||||
|
||||
cleanup = () => {
|
||||
let cpuFreq = this.state.cpuFreq;
|
||||
for (let i = 0; i < this.state.cpuCount; ++i) {
|
||||
cpuFreq[i].scaling_cur_freq = -1;
|
||||
cpuFreq[i].scaling_min_freq = -1;
|
||||
cpuFreq[i].scaling_max_freq = -1;
|
||||
// we don't cleanup cpuinfo_min_freq, cpuinfo_max_freq
|
||||
// because usually they are fixed (hardware)
|
||||
}
|
||||
this.setState({
|
||||
cpuFreq: cpuFreq,
|
||||
});
|
||||
};
|
||||
|
||||
teardown = () => {
|
||||
this.cleanup();
|
||||
};
|
||||
|
||||
buildRow = (freq: CPUFrequency) => {
|
||||
let style = {};
|
||||
if (freq.scaling_cur_freq == -2) {
|
||||
style = {
|
||||
style: {
|
||||
backgroundColor: colors.blueTint30,
|
||||
color: colors.white,
|
||||
fontWeight: 700,
|
||||
},
|
||||
};
|
||||
} else if (
|
||||
freq.scaling_min_freq != freq.cpuinfo_min_freq &&
|
||||
freq.scaling_min_freq > 0 &&
|
||||
freq.cpuinfo_min_freq > 0
|
||||
) {
|
||||
style = {
|
||||
style: {
|
||||
backgroundColor: colors.redTint,
|
||||
color: colors.red,
|
||||
fontWeight: 700,
|
||||
},
|
||||
};
|
||||
} else if (
|
||||
freq.scaling_max_freq != freq.cpuinfo_max_freq &&
|
||||
freq.scaling_max_freq > 0 &&
|
||||
freq.cpuinfo_max_freq > 0
|
||||
) {
|
||||
style = {
|
||||
style: {
|
||||
backgroundColor: colors.yellowTint,
|
||||
color: colors.yellow,
|
||||
fontWeight: 700,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
columns: {
|
||||
cpu_id: {value: <Text>CPU_{freq.cpu_id}</Text>},
|
||||
scaling_cur_freq: {
|
||||
value: <Text>{formatFrequency(freq.scaling_cur_freq)}</Text>,
|
||||
},
|
||||
scaling_min_freq: {
|
||||
value: <Text>{formatFrequency(freq.scaling_min_freq)}</Text>,
|
||||
},
|
||||
scaling_max_freq: {
|
||||
value: <Text>{formatFrequency(freq.scaling_max_freq)}</Text>,
|
||||
},
|
||||
cpuinfo_min_freq: {
|
||||
value: <Text>{formatFrequency(freq.cpuinfo_min_freq)}</Text>,
|
||||
},
|
||||
cpuinfo_max_freq: {
|
||||
value: <Text>{formatFrequency(freq.cpuinfo_max_freq)}</Text>,
|
||||
},
|
||||
},
|
||||
key: freq.cpu_id,
|
||||
style,
|
||||
};
|
||||
};
|
||||
|
||||
frequencyRows = (cpuFreqs: Array<CPUFrequency>): TableRows => {
|
||||
let rows = [];
|
||||
for (const cpuFreq of cpuFreqs) {
|
||||
rows.push(this.buildRow(cpuFreq));
|
||||
}
|
||||
return rows;
|
||||
};
|
||||
|
||||
render() {
|
||||
return (
|
||||
<FlexRow>
|
||||
<FlexColumn fill={true}>
|
||||
<Toolbar position="top">
|
||||
{this.state.monitoring ? (
|
||||
<Button onClick={this.onStopMonitor} icon="pause">
|
||||
Pause
|
||||
</Button>
|
||||
) : (
|
||||
<Button onClick={this.onStartMonitor} icon="play">
|
||||
Start
|
||||
</Button>
|
||||
)}
|
||||
</Toolbar>
|
||||
<ManagedTable
|
||||
multiline={true}
|
||||
columnSizes={ColumnSizes}
|
||||
columns={Columns}
|
||||
autoHeight={true}
|
||||
floating={false}
|
||||
zebra={true}
|
||||
rows={this.frequencyRows(this.state.cpuFreq)}
|
||||
/>
|
||||
</FlexColumn>
|
||||
</FlexRow>
|
||||
);
|
||||
}
|
||||
}
|
||||
22
src/device-plugins/index.js
Normal file
22
src/device-plugins/index.js
Normal file
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* Copyright 2018-present Facebook.
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
* @format
|
||||
*/
|
||||
import {GK} from 'sonar';
|
||||
import logs from './logs/index.js';
|
||||
import cpu from './cpu/index.js';
|
||||
import screen from './screen/index.js';
|
||||
|
||||
const plugins = [logs];
|
||||
|
||||
if (GK.get('sonar_uiperf')) {
|
||||
plugins.push(cpu);
|
||||
}
|
||||
|
||||
if (GK.get('sonar_screen_plugin')) {
|
||||
plugins.push(screen);
|
||||
}
|
||||
|
||||
export const devicePlugins = plugins;
|
||||
562
src/device-plugins/logs/LogTable.js
Normal file
562
src/device-plugins/logs/LogTable.js
Normal file
@@ -0,0 +1,562 @@
|
||||
/**
|
||||
* Copyright 2018-present Facebook.
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
* @format
|
||||
*/
|
||||
|
||||
import type {
|
||||
TableBodyRow,
|
||||
TableColumnOrder,
|
||||
TableColumnSizes,
|
||||
TableColumns,
|
||||
} from 'sonar';
|
||||
import type {Counter} from './LogWatcher.js';
|
||||
import type {DeviceLogEntry} from '../../devices/BaseDevice.js';
|
||||
|
||||
import {
|
||||
Text,
|
||||
ManagedTable,
|
||||
Button,
|
||||
colors,
|
||||
FlexCenter,
|
||||
LoadingIndicator,
|
||||
ContextMenu,
|
||||
FlexColumn,
|
||||
Glyph,
|
||||
} from 'sonar';
|
||||
import {SonarDevicePlugin, SearchableTable} from 'sonar';
|
||||
import textContent from '../../utils/textContent.js';
|
||||
import createPaste from '../../utils/createPaste.js';
|
||||
import LogWatcher from './LogWatcher';
|
||||
|
||||
const LOG_WATCHER_LOCAL_STORAGE_KEY = 'LOG_WATCHER_LOCAL_STORAGE_KEY';
|
||||
|
||||
type Entries = Array<{
|
||||
row: TableBodyRow,
|
||||
entry: DeviceLogEntry,
|
||||
}>;
|
||||
|
||||
type LogsState = {|
|
||||
initialising: boolean,
|
||||
rows: Array<TableBodyRow>,
|
||||
entries: Entries,
|
||||
key2entry: {[key: string]: DeviceLogEntry},
|
||||
highlightedRows: Array<string>,
|
||||
counters: Array<Counter>,
|
||||
|};
|
||||
|
||||
const Icon = Glyph.extends({
|
||||
marginTop: 5,
|
||||
});
|
||||
|
||||
function getLineCount(str: string): number {
|
||||
let count = 1;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
if (str[i] === '\n') {
|
||||
count++;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
function keepKeys(obj, keys) {
|
||||
const result = {};
|
||||
for (const key in obj) {
|
||||
if (keys.includes(key)) {
|
||||
result[key] = obj[key];
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
const COLUMN_SIZE = {
|
||||
type: 32,
|
||||
time: 120,
|
||||
pid: 60,
|
||||
tid: 60,
|
||||
tag: 120,
|
||||
app: 200,
|
||||
message: 'flex',
|
||||
};
|
||||
|
||||
const COLUMNS = {
|
||||
type: {
|
||||
value: '',
|
||||
},
|
||||
time: {
|
||||
value: 'Time',
|
||||
},
|
||||
pid: {
|
||||
value: 'PID',
|
||||
},
|
||||
tid: {
|
||||
value: 'TID',
|
||||
},
|
||||
tag: {
|
||||
value: 'Tag',
|
||||
},
|
||||
app: {
|
||||
value: 'App',
|
||||
},
|
||||
message: {
|
||||
value: 'Message',
|
||||
},
|
||||
};
|
||||
|
||||
const INITIAL_COLUMN_ORDER = [
|
||||
{
|
||||
key: 'type',
|
||||
visible: true,
|
||||
},
|
||||
{
|
||||
key: 'time',
|
||||
visible: false,
|
||||
},
|
||||
{
|
||||
key: 'pid',
|
||||
visible: false,
|
||||
},
|
||||
{
|
||||
key: 'tid',
|
||||
visible: false,
|
||||
},
|
||||
{
|
||||
key: 'tag',
|
||||
visible: true,
|
||||
},
|
||||
{
|
||||
key: 'app',
|
||||
visible: true,
|
||||
},
|
||||
{
|
||||
key: 'message',
|
||||
visible: true,
|
||||
},
|
||||
];
|
||||
|
||||
const LOG_TYPES: {
|
||||
[level: string]: {
|
||||
label: string,
|
||||
color: string,
|
||||
icon?: React.Node,
|
||||
style?: Object,
|
||||
},
|
||||
} = {
|
||||
verbose: {
|
||||
label: 'Verbose',
|
||||
color: colors.purple,
|
||||
},
|
||||
debug: {
|
||||
label: 'Debug',
|
||||
color: colors.grey,
|
||||
},
|
||||
info: {
|
||||
label: 'Info',
|
||||
icon: <Icon name="info-circle" color={colors.cyan} />,
|
||||
color: colors.cyan,
|
||||
},
|
||||
warn: {
|
||||
label: 'Warn',
|
||||
style: {
|
||||
backgroundColor: colors.yellowTint,
|
||||
color: colors.yellow,
|
||||
fontWeight: 500,
|
||||
},
|
||||
icon: <Icon name="caution-triangle" color={colors.yellow} />,
|
||||
color: colors.yellow,
|
||||
},
|
||||
error: {
|
||||
label: 'Error',
|
||||
style: {
|
||||
backgroundColor: colors.redTint,
|
||||
color: colors.red,
|
||||
fontWeight: 500,
|
||||
},
|
||||
icon: <Icon name="caution-octagon" color={colors.red} />,
|
||||
color: colors.red,
|
||||
},
|
||||
fatal: {
|
||||
label: 'Fatal',
|
||||
style: {
|
||||
backgroundColor: colors.redTint,
|
||||
color: colors.red,
|
||||
fontWeight: 700,
|
||||
},
|
||||
icon: <Icon name="stop" color={colors.red} />,
|
||||
color: colors.red,
|
||||
},
|
||||
};
|
||||
|
||||
const DEFAULT_FILTERS = [
|
||||
{
|
||||
type: 'enum',
|
||||
enum: Object.keys(LOG_TYPES).map(value => ({
|
||||
label: LOG_TYPES[value].label,
|
||||
value,
|
||||
})),
|
||||
key: 'type',
|
||||
value: [],
|
||||
persistent: true,
|
||||
},
|
||||
];
|
||||
|
||||
const NonSelectableText = Text.extends({
|
||||
alignSelf: 'baseline',
|
||||
userSelect: 'none',
|
||||
lineHeight: '130%',
|
||||
marginTop: 6,
|
||||
});
|
||||
|
||||
const LogCount = NonSelectableText.extends(
|
||||
{
|
||||
backgroundColor: props => props.color,
|
||||
borderRadius: '999em',
|
||||
fontSize: 11,
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginTop: 4,
|
||||
width: 16,
|
||||
height: 16,
|
||||
color: colors.white,
|
||||
},
|
||||
{
|
||||
ignoreAttributes: ['color'],
|
||||
},
|
||||
);
|
||||
|
||||
const HiddenScrollText = NonSelectableText.extends({
|
||||
'&::-webkit-scrollbar': {
|
||||
display: 'none',
|
||||
},
|
||||
});
|
||||
|
||||
function pad(chunk: mixed, len: number): string {
|
||||
let str = String(chunk);
|
||||
while (str.length < len) {
|
||||
str = `0${str}`;
|
||||
}
|
||||
return str;
|
||||
}
|
||||
|
||||
export default class LogTable extends SonarDevicePlugin<LogsState> {
|
||||
static id = 'DeviceLogs';
|
||||
static title = 'Logs';
|
||||
static icon = 'arrow-right';
|
||||
static keyboardActions = ['clear', 'goToBottom', 'createPaste'];
|
||||
|
||||
onKeyboardAction = (action: string) => {
|
||||
if (action === 'clear') {
|
||||
this.clearLogs();
|
||||
} else if (action === 'goToBottom') {
|
||||
this.goToBottom();
|
||||
} else if (action === 'createPaste') {
|
||||
this.createPaste();
|
||||
}
|
||||
};
|
||||
|
||||
restoreSavedCounters = (): Array<Counter> => {
|
||||
const savedCounters =
|
||||
window.localStorage.getItem(LOG_WATCHER_LOCAL_STORAGE_KEY) || '[]';
|
||||
return JSON.parse(savedCounters).map((counter: Counter) => ({
|
||||
...counter,
|
||||
expression: new RegExp(counter.label, 'gi'),
|
||||
count: 0,
|
||||
}));
|
||||
};
|
||||
|
||||
state = {
|
||||
rows: [],
|
||||
entries: [],
|
||||
key2entry: {},
|
||||
initialising: true,
|
||||
highlightedRows: [],
|
||||
counters: this.restoreSavedCounters(),
|
||||
};
|
||||
|
||||
tableRef: ?ManagedTable;
|
||||
columns: TableColumns;
|
||||
columnSizes: TableColumnSizes;
|
||||
columnOrder: TableColumnOrder;
|
||||
|
||||
init() {
|
||||
let batch: Entries = [];
|
||||
let queued = false;
|
||||
let counter = 0;
|
||||
|
||||
const supportedColumns = this.device.supportedColumns();
|
||||
this.columns = keepKeys(COLUMNS, supportedColumns);
|
||||
this.columnSizes = keepKeys(COLUMN_SIZE, supportedColumns);
|
||||
this.columnOrder = INITIAL_COLUMN_ORDER.filter(obj =>
|
||||
supportedColumns.includes(obj.key),
|
||||
);
|
||||
|
||||
this.device.addLogListener((entry: DeviceLogEntry) => {
|
||||
const {icon, style} =
|
||||
LOG_TYPES[(entry.type: string)] || LOG_TYPES.verbose;
|
||||
|
||||
// clean message
|
||||
const message = entry.message.trim();
|
||||
entry.type === 'error';
|
||||
|
||||
let counterUpdated = false;
|
||||
const counters = this.state.counters.map(counter => {
|
||||
if (message.match(counter.expression)) {
|
||||
counterUpdated = true;
|
||||
if (counter.notify) {
|
||||
new window.Notification(`${counter.label}`, {
|
||||
body: 'The watched log message appeared',
|
||||
});
|
||||
}
|
||||
return {
|
||||
...counter,
|
||||
count: counter.count + 1,
|
||||
};
|
||||
} else {
|
||||
return counter;
|
||||
}
|
||||
});
|
||||
if (counterUpdated) {
|
||||
this.setState({counters});
|
||||
}
|
||||
|
||||
// build the item, it will either be batched or added straight away
|
||||
const item = {
|
||||
entry,
|
||||
row: {
|
||||
columns: {
|
||||
type: {
|
||||
value: icon,
|
||||
},
|
||||
time: {
|
||||
value: (
|
||||
<HiddenScrollText code={true}>
|
||||
{entry.date.toTimeString().split(' ')[0] +
|
||||
'.' +
|
||||
pad(entry.date.getMilliseconds(), 3)}
|
||||
</HiddenScrollText>
|
||||
),
|
||||
},
|
||||
message: {
|
||||
value: <HiddenScrollText code={true}>{message}</HiddenScrollText>,
|
||||
},
|
||||
tag: {
|
||||
value: (
|
||||
<NonSelectableText code={true}>{entry.tag}</NonSelectableText>
|
||||
),
|
||||
isFilterable: true,
|
||||
},
|
||||
pid: {
|
||||
value: (
|
||||
<NonSelectableText code={true}>
|
||||
{String(entry.pid)}
|
||||
</NonSelectableText>
|
||||
),
|
||||
isFilterable: true,
|
||||
},
|
||||
tid: {
|
||||
value: (
|
||||
<NonSelectableText code={true}>
|
||||
{String(entry.tid)}
|
||||
</NonSelectableText>
|
||||
),
|
||||
isFilterable: true,
|
||||
},
|
||||
app: {
|
||||
value: (
|
||||
<NonSelectableText code={true}>{entry.app}</NonSelectableText>
|
||||
),
|
||||
isFilterable: true,
|
||||
},
|
||||
},
|
||||
height: getLineCount(message) * 15 + 10, // 15px per line height + 8px padding
|
||||
style,
|
||||
type: entry.type,
|
||||
filterValue: entry.message,
|
||||
key: String(counter++),
|
||||
},
|
||||
};
|
||||
|
||||
// batch up logs to be processed every 250ms, if we have lots of log
|
||||
// messages coming in, then calling an setState 200+ times is actually
|
||||
// pretty expensive
|
||||
batch.push(item);
|
||||
|
||||
if (!queued) {
|
||||
queued = true;
|
||||
|
||||
setTimeout(() => {
|
||||
const thisBatch = batch;
|
||||
batch = [];
|
||||
queued = false;
|
||||
|
||||
// update rows/entries
|
||||
this.setState(state => {
|
||||
const rows = [...state.rows];
|
||||
const entries = [...state.entries];
|
||||
const key2entry = {...state.key2entry};
|
||||
|
||||
for (let i = 0; i < thisBatch.length; i++) {
|
||||
const {entry, row} = thisBatch[i];
|
||||
entries.push({row, entry});
|
||||
key2entry[row.key] = entry;
|
||||
|
||||
let previousEntry: ?DeviceLogEntry = null;
|
||||
|
||||
if (i > 0) {
|
||||
previousEntry = thisBatch[i - 1].entry;
|
||||
} else if (state.rows.length > 0 && state.entries.length > 0) {
|
||||
previousEntry = state.entries[state.entries.length - 1].entry;
|
||||
}
|
||||
|
||||
this.addRowIfNeeded(rows, row, entry, previousEntry);
|
||||
}
|
||||
|
||||
return {
|
||||
entries,
|
||||
rows,
|
||||
key2entry,
|
||||
};
|
||||
});
|
||||
}, 100);
|
||||
}
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
this.setState({
|
||||
initialising: false,
|
||||
});
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
addRowIfNeeded(
|
||||
rows: Array<TableBodyRow>,
|
||||
row: TableBodyRow,
|
||||
entry: DeviceLogEntry,
|
||||
previousEntry: ?DeviceLogEntry,
|
||||
) {
|
||||
const previousRow = rows.length > 0 ? rows[rows.length - 1] : null;
|
||||
if (
|
||||
previousRow &&
|
||||
previousEntry &&
|
||||
entry.message === previousEntry.message &&
|
||||
entry.tag === previousEntry.tag &&
|
||||
previousRow.type != null
|
||||
) {
|
||||
const count = (previousRow.columns.time.value.props.count || 1) + 1;
|
||||
previousRow.columns.type.value = (
|
||||
<LogCount color={LOG_TYPES[previousRow.type].color}>{count}</LogCount>
|
||||
);
|
||||
} else {
|
||||
rows.push(row);
|
||||
}
|
||||
}
|
||||
|
||||
clearLogs = () => {
|
||||
this.setState({
|
||||
entries: [],
|
||||
rows: [],
|
||||
highlightedRows: [],
|
||||
key2entry: {},
|
||||
counters: this.state.counters.map(counter => ({
|
||||
...counter,
|
||||
count: 0,
|
||||
})),
|
||||
});
|
||||
};
|
||||
|
||||
createPaste = () => {
|
||||
let paste = '';
|
||||
const mapFn = row =>
|
||||
Object.keys(COLUMNS)
|
||||
.map(key => textContent(row.columns[key].value))
|
||||
.join('\t');
|
||||
|
||||
if (this.state.highlightedRows.length > 0) {
|
||||
// create paste from selection
|
||||
paste = this.state.rows
|
||||
.filter(row => this.state.highlightedRows.indexOf(row.key) > -1)
|
||||
.map(mapFn)
|
||||
.join('\n');
|
||||
} else {
|
||||
// create paste with all rows
|
||||
paste = this.state.rows.map(mapFn).join('\n');
|
||||
}
|
||||
createPaste(paste);
|
||||
};
|
||||
|
||||
setTableRef = (ref: React.ElementRef<*>) => {
|
||||
this.tableRef = ref;
|
||||
};
|
||||
|
||||
goToBottom = () => {
|
||||
if (this.tableRef != null) {
|
||||
this.tableRef.scrollToBottom();
|
||||
}
|
||||
};
|
||||
|
||||
onRowHighlighted = (highlightedRows: Array<string>) => {
|
||||
this.setState({
|
||||
...this.state,
|
||||
highlightedRows,
|
||||
});
|
||||
};
|
||||
|
||||
renderSidebar = () => {
|
||||
return (
|
||||
<LogWatcher
|
||||
counters={this.state.counters}
|
||||
onChange={counters =>
|
||||
this.setState({counters}, () =>
|
||||
window.localStorage.setItem(
|
||||
LOG_WATCHER_LOCAL_STORAGE_KEY,
|
||||
JSON.stringify(this.state.counters),
|
||||
),
|
||||
)
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
static ContextMenu = ContextMenu.extends({
|
||||
flex: 1,
|
||||
});
|
||||
|
||||
render() {
|
||||
const {initialising, highlightedRows, rows} = this.state;
|
||||
|
||||
const contextMenuItems = [
|
||||
{
|
||||
type: 'separator',
|
||||
},
|
||||
{
|
||||
label: 'Clear all',
|
||||
click: this.clearLogs,
|
||||
},
|
||||
];
|
||||
return initialising ? (
|
||||
<FlexCenter fill={true}>
|
||||
<LoadingIndicator />
|
||||
</FlexCenter>
|
||||
) : (
|
||||
<LogTable.ContextMenu items={contextMenuItems} component={FlexColumn}>
|
||||
<SearchableTable
|
||||
innerRef={this.setTableRef}
|
||||
floating={false}
|
||||
multiline={true}
|
||||
columnSizes={this.columnSizes}
|
||||
columnOrder={this.columnOrder}
|
||||
columns={this.columns}
|
||||
stickyBottom={highlightedRows.length === 0}
|
||||
rows={rows}
|
||||
onRowHighlighted={this.onRowHighlighted}
|
||||
multiHighlight={true}
|
||||
defaultFilters={DEFAULT_FILTERS}
|
||||
zebra={false}
|
||||
actions={<Button onClick={this.clearLogs}>Clear Logs</Button>}
|
||||
/>
|
||||
</LogTable.ContextMenu>
|
||||
);
|
||||
}
|
||||
}
|
||||
216
src/device-plugins/logs/LogWatcher.js
Normal file
216
src/device-plugins/logs/LogWatcher.js
Normal file
@@ -0,0 +1,216 @@
|
||||
/**
|
||||
* Copyright 2018-present Facebook.
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
* @format
|
||||
*/
|
||||
|
||||
import {
|
||||
PureComponent,
|
||||
FlexColumn,
|
||||
Panel,
|
||||
Input,
|
||||
Toolbar,
|
||||
Text,
|
||||
ManagedTable,
|
||||
Button,
|
||||
colors,
|
||||
} from 'sonar';
|
||||
|
||||
export type Counter = {
|
||||
expression: RegExp,
|
||||
count: number,
|
||||
notify: boolean,
|
||||
label: string,
|
||||
};
|
||||
|
||||
type Props = {|
|
||||
onChange: (counters: Array<Counter>) => void,
|
||||
counters: Array<Counter>,
|
||||
|};
|
||||
|
||||
type State = {
|
||||
input: string,
|
||||
highlightedRow: ?string,
|
||||
};
|
||||
|
||||
const ColumnSizes = {
|
||||
expression: '70%',
|
||||
count: '15%',
|
||||
notify: 'flex',
|
||||
};
|
||||
|
||||
const Columns = {
|
||||
expression: {
|
||||
value: 'Expression',
|
||||
resizable: false,
|
||||
},
|
||||
count: {
|
||||
value: 'Count',
|
||||
resizable: false,
|
||||
},
|
||||
notify: {
|
||||
value: 'Notify',
|
||||
resizable: false,
|
||||
},
|
||||
};
|
||||
|
||||
const Count = Text.extends({
|
||||
alignSelf: 'center',
|
||||
background: colors.macOSHighlightActive,
|
||||
color: colors.white,
|
||||
fontSize: 12,
|
||||
fontWeight: 500,
|
||||
textAlign: 'center',
|
||||
borderRadius: '999em',
|
||||
padding: '4px 9px 3px',
|
||||
lineHeight: '100%',
|
||||
marginLeft: 'auto',
|
||||
});
|
||||
|
||||
const Checkbox = Input.extends({
|
||||
lineHeight: '100%',
|
||||
padding: 0,
|
||||
margin: 0,
|
||||
height: 'auto',
|
||||
alignSelf: 'center',
|
||||
});
|
||||
|
||||
const ExpressionInput = Input.extends({
|
||||
flexGrow: 1,
|
||||
});
|
||||
|
||||
const WatcherPanel = Panel.extends({
|
||||
minHeight: 200,
|
||||
});
|
||||
|
||||
export default class LogWatcher extends PureComponent<Props, State> {
|
||||
state = {
|
||||
input: '',
|
||||
highlightedRow: null,
|
||||
};
|
||||
|
||||
_inputRef: ?HTMLInputElement;
|
||||
|
||||
onAdd = () => {
|
||||
if (
|
||||
this.props.counters.findIndex(({label}) => label === this.state.input) >
|
||||
-1
|
||||
) {
|
||||
// prevent duplicates
|
||||
return;
|
||||
}
|
||||
this.props.onChange([
|
||||
...this.props.counters,
|
||||
{
|
||||
label: this.state.input,
|
||||
expression: new RegExp(this.state.input, 'gi'),
|
||||
notify: false,
|
||||
count: 0,
|
||||
},
|
||||
]);
|
||||
this.setState({input: ''});
|
||||
};
|
||||
|
||||
onChange = (e: SyntheticInputEvent<HTMLInputElement>) => {
|
||||
this.setState({
|
||||
input: e.target.value,
|
||||
});
|
||||
};
|
||||
|
||||
resetCount = (index: number) => {
|
||||
const newCounters = [...this.props.counters];
|
||||
newCounters[index] = {
|
||||
...newCounters[index],
|
||||
count: 0,
|
||||
};
|
||||
this.props.onChange(newCounters);
|
||||
};
|
||||
|
||||
buildRows = () => {
|
||||
return this.props.counters.map(({label, count, notify}, i) => ({
|
||||
columns: {
|
||||
expression: {
|
||||
value: <Text code={true}>{label}</Text>,
|
||||
},
|
||||
count: {
|
||||
value: <Count onClick={() => this.resetCount(i)}>{count}</Count>,
|
||||
},
|
||||
notify: {
|
||||
value: (
|
||||
<Checkbox
|
||||
type="checkbox"
|
||||
checked={notify}
|
||||
onChange={() => this.setNotification(i, !notify)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
},
|
||||
key: label,
|
||||
}));
|
||||
};
|
||||
|
||||
setNotification = (index: number, notify: boolean) => {
|
||||
const newCounters: Array<Counter> = [...this.props.counters];
|
||||
newCounters[index] = {
|
||||
...newCounters[index],
|
||||
notify,
|
||||
};
|
||||
this.props.onChange(newCounters);
|
||||
};
|
||||
|
||||
onRowHighlighted = (rows: Array<string>) => {
|
||||
this.setState({
|
||||
highlightedRow: rows.length === 1 ? rows[0] : null,
|
||||
});
|
||||
};
|
||||
|
||||
onKeyDown = (e: SyntheticKeyboardEvent<>) => {
|
||||
if (
|
||||
(e.key === 'Delete' || e.key === 'Backspace') &&
|
||||
this.state.highlightedRow != null
|
||||
) {
|
||||
this.props.onChange(
|
||||
this.props.counters.filter(
|
||||
({label}) => label !== this.state.highlightedRow,
|
||||
),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
onSubmit = (e: SyntheticKeyboardEvent<>) => {
|
||||
if (e.key === 'Enter') {
|
||||
this.onAdd();
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
return (
|
||||
<FlexColumn fill={true} tabIndex={-1} onKeyDown={this.onKeyDown}>
|
||||
<WatcherPanel
|
||||
heading="Expression Watcher"
|
||||
floating={false}
|
||||
padded={false}>
|
||||
<Toolbar>
|
||||
<ExpressionInput
|
||||
value={this.state.input}
|
||||
placeholder="Expression..."
|
||||
onChange={this.onChange}
|
||||
onKeyDown={this.onSubmit}
|
||||
/>
|
||||
<Button onClick={this.onAdd}>Add counter</Button>
|
||||
</Toolbar>
|
||||
<ManagedTable
|
||||
onRowHighlighted={this.onRowHighlighted}
|
||||
columnSizes={ColumnSizes}
|
||||
columns={Columns}
|
||||
rows={this.buildRows()}
|
||||
autoHeight={true}
|
||||
floating={false}
|
||||
zebra={false}
|
||||
/>
|
||||
</WatcherPanel>
|
||||
</FlexColumn>
|
||||
);
|
||||
}
|
||||
}
|
||||
8
src/device-plugins/logs/index.js
Normal file
8
src/device-plugins/logs/index.js
Normal file
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Copyright 2018-present Facebook.
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
* @format
|
||||
*/
|
||||
import LogTable from './LogTable.js';
|
||||
export default LogTable;
|
||||
282
src/device-plugins/screen/index.js
Normal file
282
src/device-plugins/screen/index.js
Normal file
@@ -0,0 +1,282 @@
|
||||
/**
|
||||
* Copyright 2018-present Facebook.
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
* @format
|
||||
*/
|
||||
|
||||
import {SonarDevicePlugin} from 'sonar';
|
||||
|
||||
import {
|
||||
Button,
|
||||
FlexColumn,
|
||||
FlexRow,
|
||||
LoadingIndicator,
|
||||
styled,
|
||||
colors,
|
||||
Component,
|
||||
} from 'sonar';
|
||||
|
||||
const os = require('os');
|
||||
const fs = require('fs');
|
||||
const adb = require('adbkit-fb');
|
||||
const path = require('path');
|
||||
const exec = require('child_process').exec;
|
||||
const SCREENSHOT_FILE_NAME = 'screen.png';
|
||||
const VIDEO_FILE_NAME = 'video.mp4';
|
||||
const SCREENSHOT_PATH = path.join(
|
||||
os.homedir(),
|
||||
'/.sonar/',
|
||||
SCREENSHOT_FILE_NAME,
|
||||
);
|
||||
const VIDEO_PATH = path.join(os.homedir(), '.sonar', VIDEO_FILE_NAME);
|
||||
|
||||
type AndroidDevice = any;
|
||||
type AdbClient = any;
|
||||
type PullTransfer = any;
|
||||
|
||||
type State = {|
|
||||
pullingData: boolean,
|
||||
recording: boolean,
|
||||
recordingEnabled: boolean,
|
||||
capturingScreenshot: boolean,
|
||||
|};
|
||||
|
||||
const BigButton = Button.extends({
|
||||
height: 200,
|
||||
width: 200,
|
||||
flexGrow: 1,
|
||||
fontSize: 24,
|
||||
});
|
||||
|
||||
const ButtonContainer = FlexRow.extends({
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-around',
|
||||
padding: 20,
|
||||
});
|
||||
|
||||
const LoadingSpinnerContainer = FlexRow.extends({
|
||||
flexGrow: 1,
|
||||
padding: 24,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
});
|
||||
|
||||
const LoadingSpinnerText = styled.text({
|
||||
fontSize: 24,
|
||||
marginLeft: 12,
|
||||
color: colors.grey,
|
||||
});
|
||||
|
||||
class LoadingSpinner extends Component<{}, {}> {
|
||||
render() {
|
||||
return (
|
||||
<LoadingSpinnerContainer>
|
||||
<LoadingIndicator />
|
||||
<LoadingSpinnerText>Pulling files from device...</LoadingSpinnerText>
|
||||
</LoadingSpinnerContainer>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function openFile(path: string): Promise<*> {
|
||||
return new Promise((resolve, reject) => {
|
||||
exec(`${getOpenCommand()} ${path}`, (error, stdout, stderr) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
} else {
|
||||
resolve(path);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function getOpenCommand(): string {
|
||||
//TODO: TESTED ONLY ON MAC!
|
||||
switch (os.platform()) {
|
||||
case 'win32':
|
||||
return 'start';
|
||||
case 'linux':
|
||||
return 'xdg-open';
|
||||
default:
|
||||
return 'open';
|
||||
}
|
||||
}
|
||||
|
||||
function writePngStreamToFile(stream: PullTransfer): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
stream.on('end', () => {
|
||||
resolve(SCREENSHOT_PATH);
|
||||
});
|
||||
stream.on('error', reject);
|
||||
stream.pipe(fs.createWriteStream(SCREENSHOT_PATH));
|
||||
});
|
||||
}
|
||||
|
||||
export default class ScreenPlugin extends SonarDevicePlugin<State> {
|
||||
static id = 'DeviceScreen';
|
||||
static title = 'Screen';
|
||||
static icon = 'mobile';
|
||||
|
||||
device: AndroidDevice;
|
||||
adbClient: AdbClient;
|
||||
|
||||
init() {
|
||||
this.adbClient = this.device.adb;
|
||||
|
||||
this.executeShell(
|
||||
`[ ! -f /system/bin/screenrecord ] && echo "File does not exist"`,
|
||||
).then(output => {
|
||||
if (output) {
|
||||
console.error(
|
||||
'screenrecord util does not exist. Most likely it is an emulator which does not support screen recording via adb',
|
||||
);
|
||||
this.setState({
|
||||
recordingEnabled: false,
|
||||
});
|
||||
} else {
|
||||
this.setState({
|
||||
recordingEnabled: true,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
captureScreenshot = () => {
|
||||
return this.adbClient
|
||||
.screencap(this.device.serial)
|
||||
.then(writePngStreamToFile)
|
||||
.then(openFile)
|
||||
.catch(error => {
|
||||
//TODO: proper logging?
|
||||
console.error(error);
|
||||
});
|
||||
};
|
||||
|
||||
pullFromDevice = (src: string, dst: string): Promise<string> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
return this.adbClient.pull(this.device.serial, src).then(stream => {
|
||||
stream.on('end', () => {
|
||||
resolve(dst);
|
||||
});
|
||||
stream.on('error', reject);
|
||||
stream.pipe(fs.createWriteStream(dst));
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
onRecordingClicked = () => {
|
||||
if (this.state.recording) {
|
||||
this.stopRecording();
|
||||
} else {
|
||||
this.startRecording();
|
||||
}
|
||||
};
|
||||
|
||||
onScreenshotClicked = () => {
|
||||
var self = this;
|
||||
this.setState({
|
||||
capturingScreenshot: true,
|
||||
});
|
||||
this.captureScreenshot().then(() => {
|
||||
self.setState({
|
||||
capturingScreenshot: false,
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
startRecording = () => {
|
||||
const self = this;
|
||||
this.setState({
|
||||
recording: true,
|
||||
});
|
||||
this.executeShell(`screenrecord --bugreport /sdcard/${VIDEO_FILE_NAME}`)
|
||||
.then(output => {
|
||||
if (output) {
|
||||
throw output;
|
||||
}
|
||||
})
|
||||
.then(() => {
|
||||
self.setState({
|
||||
recording: false,
|
||||
pullingData: true,
|
||||
});
|
||||
})
|
||||
.then((): Promise<string> => {
|
||||
return self.pullFromDevice(`/sdcard/${VIDEO_FILE_NAME}`, VIDEO_PATH);
|
||||
})
|
||||
.then(openFile)
|
||||
.then(() => {
|
||||
self.executeShell(`rm /sdcard/${VIDEO_FILE_NAME}`);
|
||||
})
|
||||
.then(() => {
|
||||
self.setState({
|
||||
pullingData: false,
|
||||
});
|
||||
})
|
||||
.catch(error => {
|
||||
console.error(`unable to capture video: ${error}`);
|
||||
self.setState({
|
||||
recording: false,
|
||||
pullingData: false,
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
stopRecording = () => {
|
||||
this.executeShell(`pgrep 'screenrecord' -L 2`);
|
||||
};
|
||||
|
||||
executeShell = (command: string): Promise<string> => {
|
||||
return this.adbClient
|
||||
.shell(this.device.serial, command)
|
||||
.then(adb.util.readAll)
|
||||
.then(output => {
|
||||
return new Promise((resolve, reject) => {
|
||||
resolve(output.toString().trim());
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
getLoadingSpinner = () => {
|
||||
return this.state.pullingData ? <LoadingSpinner /> : null;
|
||||
};
|
||||
|
||||
render() {
|
||||
const recordingEnabled =
|
||||
this.state.recordingEnabled &&
|
||||
!this.state.capturingScreenshot &&
|
||||
!this.state.pullingData;
|
||||
const screenshotEnabled =
|
||||
!this.state.recording &&
|
||||
!this.state.capturingScreenshot &&
|
||||
!this.state.pullingData;
|
||||
return (
|
||||
<FlexColumn>
|
||||
<ButtonContainer>
|
||||
<BigButton
|
||||
key="video_btn"
|
||||
onClick={!recordingEnabled ? null : this.onRecordingClicked}
|
||||
icon={this.state.recording ? 'stop' : 'camcorder'}
|
||||
disabled={!recordingEnabled}
|
||||
selected={true}
|
||||
pulse={this.state.recording}
|
||||
iconSize={24}>
|
||||
{!this.state.recording ? 'Record screen' : 'Stop recording'}
|
||||
</BigButton>
|
||||
<BigButton
|
||||
key="screenshot_btn"
|
||||
icon="camera"
|
||||
selected={true}
|
||||
onClick={!screenshotEnabled ? null : this.onScreenshotClicked}
|
||||
iconSize={24}
|
||||
pulse={this.state.capturingScreenshot}
|
||||
disabled={!screenshotEnabled}>
|
||||
Take screenshot
|
||||
</BigButton>
|
||||
</ButtonContainer>
|
||||
{this.getLoadingSpinner()}
|
||||
</FlexColumn>
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user