Summary: Device plugins have an activate and deactivate hook, that reflects the plugin being selected in the UI. Added these same hooks to client plugins as well. In practice they are called at the same times as `onConnect` and `onDisconnect`, except for background plugins, which connect only once, so it is pretty useful to be still able to make the distinction. Since there is now quite some common functionality between plugins and device plugins, will clean things a bit up in a next diff [Interesting] as it explains the difference between the different lifecycle methods of plugins, and the impact of being a background plugin LIfecycle summary: 1. app connects 2. for background plugins: connect them (`onConnect`) 3. user selects a plugin, triggers `onActivate`. will also trigger `onConnect` the plugin if it _isnt_ a bg plugin 4. user selects a different plugin, triggers `onDeactivate`. will also trigger `onDisconnect` if it isn't a bg plugin. 5. app is unloaded. Triggers `onDisconnect` for bg plugins. Triggers `onDestroy` for all plugins, Reviewed By: jknoxville Differential Revision: D22724791 fbshipit-source-id: 9fe2e666eb37fa2e0bd00fa61d78d2d4b1080137
696 lines
16 KiB
TypeScript
696 lines
16 KiB
TypeScript
/**
|
|
* Copyright (c) Facebook, Inc. and its affiliates.
|
|
*
|
|
* This source code is licensed under the MIT license found in the
|
|
* LICENSE file in the root directory of this source tree.
|
|
*
|
|
* @format
|
|
*/
|
|
|
|
import {
|
|
TableBodyRow,
|
|
TableColumnOrder,
|
|
TableColumnSizes,
|
|
TableColumns,
|
|
TableRowSortOrder,
|
|
Props as PluginProps,
|
|
BaseAction,
|
|
DeviceLogEntry,
|
|
produce,
|
|
} from 'flipper';
|
|
import {Counter} from './LogWatcher';
|
|
|
|
import {
|
|
Text,
|
|
ManagedTableClass,
|
|
Button,
|
|
colors,
|
|
ContextMenu,
|
|
FlexColumn,
|
|
Glyph,
|
|
DetailSidebar,
|
|
FlipperDevicePlugin,
|
|
SearchableTable,
|
|
styled,
|
|
Device,
|
|
createPaste,
|
|
textContent,
|
|
KeyboardActions,
|
|
MenuTemplate,
|
|
} from 'flipper';
|
|
import LogWatcher from './LogWatcher';
|
|
import React from 'react';
|
|
|
|
const LOG_WATCHER_LOCAL_STORAGE_KEY = 'LOG_WATCHER_LOCAL_STORAGE_KEY';
|
|
|
|
type Entries = ReadonlyArray<{
|
|
readonly row: TableBodyRow;
|
|
readonly entry: DeviceLogEntry;
|
|
}>;
|
|
|
|
type BaseState = {
|
|
readonly rows: ReadonlyArray<TableBodyRow>;
|
|
readonly entries: Entries;
|
|
readonly key2entry: {readonly [key: string]: DeviceLogEntry};
|
|
};
|
|
|
|
type AdditionalState = {
|
|
readonly highlightedRows: ReadonlySet<string>;
|
|
readonly counters: ReadonlyArray<Counter>;
|
|
readonly timeDirection: 'up' | 'down';
|
|
};
|
|
|
|
type State = BaseState & AdditionalState;
|
|
|
|
type PersistedState = {};
|
|
|
|
const Icon = styled(Glyph)({
|
|
marginTop: 5,
|
|
});
|
|
|
|
function getLineCount(str: string): number {
|
|
let count = 1;
|
|
if (!(typeof str === 'string')) {
|
|
return 0;
|
|
}
|
|
for (let i = 0; i < str.length; i++) {
|
|
if (str[i] === '\n') {
|
|
count++;
|
|
}
|
|
}
|
|
return count;
|
|
}
|
|
|
|
function keepKeys<A>(obj: A, keys: Array<string>): A {
|
|
const result: A = {} as A;
|
|
for (const key in obj) {
|
|
if (keys.includes(key)) {
|
|
result[key] = obj[key];
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
const COLUMN_SIZE = {
|
|
type: 40,
|
|
time: 120,
|
|
pid: 60,
|
|
tid: 60,
|
|
tag: 120,
|
|
app: 200,
|
|
message: 'flex',
|
|
} as const;
|
|
|
|
const COLUMNS = {
|
|
type: {
|
|
value: '',
|
|
},
|
|
time: {
|
|
value: 'Time',
|
|
sortable: true,
|
|
},
|
|
pid: {
|
|
value: 'PID',
|
|
},
|
|
tid: {
|
|
value: 'TID',
|
|
},
|
|
tag: {
|
|
value: 'Tag',
|
|
},
|
|
app: {
|
|
value: 'App',
|
|
},
|
|
message: {
|
|
value: 'Message',
|
|
},
|
|
} as const;
|
|
|
|
const INITIAL_COLUMN_ORDER = [
|
|
{
|
|
key: 'type',
|
|
visible: true,
|
|
},
|
|
{
|
|
key: 'time',
|
|
visible: true,
|
|
},
|
|
{
|
|
key: 'pid',
|
|
visible: false,
|
|
},
|
|
{
|
|
key: 'tid',
|
|
visible: false,
|
|
},
|
|
{
|
|
key: 'tag',
|
|
visible: true,
|
|
},
|
|
{
|
|
key: 'app',
|
|
visible: true,
|
|
},
|
|
{
|
|
key: 'message',
|
|
visible: true,
|
|
},
|
|
] as const;
|
|
|
|
const LOG_TYPES: {
|
|
[level: string]: {
|
|
label: string;
|
|
color: string;
|
|
icon?: React.ReactNode;
|
|
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 HiddenScrollText = styled(Text)({
|
|
alignSelf: 'baseline',
|
|
lineHeight: '130%',
|
|
marginTop: 5,
|
|
paddingBottom: 3,
|
|
'&::-webkit-scrollbar': {
|
|
display: 'none',
|
|
},
|
|
});
|
|
|
|
const LogCount = styled.div<{backgroundColor: string}>(({backgroundColor}) => ({
|
|
backgroundColor,
|
|
borderRadius: '999em',
|
|
fontSize: 11,
|
|
marginTop: 4,
|
|
minWidth: 16,
|
|
height: 16,
|
|
color: colors.white,
|
|
textAlign: 'center',
|
|
lineHeight: '16px',
|
|
paddingLeft: 4,
|
|
paddingRight: 4,
|
|
textOverflow: 'ellipsis',
|
|
overflow: 'hidden',
|
|
whiteSpace: 'nowrap',
|
|
}));
|
|
|
|
function pad(chunk: any, len: number): string {
|
|
let str = String(chunk);
|
|
while (str.length < len) {
|
|
str = `0${str}`;
|
|
}
|
|
return str;
|
|
}
|
|
|
|
export function addEntriesToState(
|
|
items: Entries,
|
|
state: BaseState = {
|
|
rows: [],
|
|
entries: [],
|
|
key2entry: {},
|
|
} as const,
|
|
addDirection: 'up' | 'down' = 'up',
|
|
): BaseState {
|
|
const rows = [...state.rows];
|
|
const entries = [...state.entries];
|
|
const key2entry = {...state.key2entry};
|
|
|
|
for (let i = 0; i < items.length; i++) {
|
|
const {entry, row} = items[i];
|
|
entries.push({row, entry});
|
|
key2entry[row.key] = entry;
|
|
|
|
let previousEntry: DeviceLogEntry | null = null;
|
|
|
|
if (i > 0) {
|
|
previousEntry = items[i - 1].entry;
|
|
} else if (state.rows.length > 0 && state.entries.length > 0) {
|
|
previousEntry = state.entries[state.entries.length - 1].entry;
|
|
}
|
|
|
|
addRowIfNeeded(rows, row, entry, previousEntry, addDirection);
|
|
}
|
|
|
|
return {
|
|
entries,
|
|
rows,
|
|
key2entry,
|
|
};
|
|
}
|
|
|
|
export function addRowIfNeeded(
|
|
rows: Array<TableBodyRow>,
|
|
row: TableBodyRow,
|
|
entry: DeviceLogEntry,
|
|
previousEntry: DeviceLogEntry | null,
|
|
addDirection: 'up' | 'down' = 'up',
|
|
) {
|
|
const previousRow =
|
|
rows.length > 0
|
|
? addDirection === 'up'
|
|
? rows[rows.length - 1]
|
|
: rows[0]
|
|
: null;
|
|
if (
|
|
previousRow &&
|
|
previousEntry &&
|
|
entry.message === previousEntry.message &&
|
|
entry.tag === previousEntry.tag &&
|
|
previousRow.type != null
|
|
) {
|
|
// duplicate log, increase counter
|
|
const count =
|
|
previousRow.columns.type.value &&
|
|
previousRow.columns.type.value.props &&
|
|
typeof previousRow.columns.type.value.props.children === 'number'
|
|
? previousRow.columns.type.value.props.children + 1
|
|
: 2;
|
|
const type = LOG_TYPES[previousRow.type] || LOG_TYPES.debug;
|
|
previousRow.columns.type.value = (
|
|
<LogCount backgroundColor={type.color}>{count}</LogCount>
|
|
);
|
|
} else {
|
|
if (addDirection === 'up') {
|
|
rows.push(row);
|
|
} else {
|
|
rows.unshift(row);
|
|
}
|
|
}
|
|
}
|
|
|
|
export function processEntry(
|
|
entry: DeviceLogEntry,
|
|
key: string,
|
|
): {
|
|
row: TableBodyRow;
|
|
entry: DeviceLogEntry;
|
|
} {
|
|
const {icon, style} = LOG_TYPES[entry.type] || LOG_TYPES.debug;
|
|
// build the item, it will either be batched or added straight away
|
|
return {
|
|
entry,
|
|
row: {
|
|
columns: {
|
|
type: {
|
|
value: icon,
|
|
align: 'center',
|
|
},
|
|
time: {
|
|
value: (
|
|
<HiddenScrollText code={true}>
|
|
{entry.date.toTimeString().split(' ')[0] +
|
|
'.' +
|
|
pad(entry.date.getMilliseconds(), 3)}
|
|
</HiddenScrollText>
|
|
),
|
|
},
|
|
message: {
|
|
value: (
|
|
<HiddenScrollText code={true}>{entry.message}</HiddenScrollText>
|
|
),
|
|
},
|
|
tag: {
|
|
value: <HiddenScrollText code={true}>{entry.tag}</HiddenScrollText>,
|
|
isFilterable: true,
|
|
},
|
|
pid: {
|
|
value: (
|
|
<HiddenScrollText code={true}>{String(entry.pid)}</HiddenScrollText>
|
|
),
|
|
isFilterable: true,
|
|
},
|
|
tid: {
|
|
value: (
|
|
<HiddenScrollText code={true}>{String(entry.tid)}</HiddenScrollText>
|
|
),
|
|
isFilterable: true,
|
|
},
|
|
app: {
|
|
value: <HiddenScrollText code={true}>{entry.app}</HiddenScrollText>,
|
|
isFilterable: true,
|
|
},
|
|
},
|
|
height: getLineCount(entry.message) * 15 + 10, // 15px per line height + 8px padding
|
|
style,
|
|
type: entry.type,
|
|
filterValue: entry.message,
|
|
key,
|
|
},
|
|
};
|
|
}
|
|
|
|
export default class LogTable extends FlipperDevicePlugin<
|
|
State,
|
|
BaseAction,
|
|
PersistedState
|
|
> {
|
|
static keyboardActions: KeyboardActions = [
|
|
'clear',
|
|
'goToBottom',
|
|
'createPaste',
|
|
];
|
|
|
|
batchTimer: NodeJS.Timeout | undefined;
|
|
|
|
static supportsDevice(device: Device) {
|
|
return (
|
|
device.os === 'Android' ||
|
|
device.os === 'Metro' ||
|
|
(device.os === 'iOS' && device.deviceType !== 'physical')
|
|
);
|
|
}
|
|
|
|
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,
|
|
}));
|
|
};
|
|
|
|
calculateHighlightedRows = (
|
|
deepLinkPayload: unknown,
|
|
rows: ReadonlyArray<TableBodyRow>,
|
|
): Set<string> => {
|
|
const highlightedRows = new Set<string>();
|
|
if (typeof deepLinkPayload !== 'string') {
|
|
return highlightedRows;
|
|
}
|
|
|
|
// Run through array from last to first, because we want to show the last
|
|
// time it the log we are looking for appeared.
|
|
for (let i = rows.length - 1; i >= 0; i--) {
|
|
const filterValue = rows[i].filterValue;
|
|
if (filterValue != null && filterValue.includes(deepLinkPayload)) {
|
|
highlightedRows.add(rows[i].key);
|
|
break;
|
|
}
|
|
}
|
|
if (highlightedRows.size <= 0) {
|
|
// Check if the individual lines in the deeplinkPayload is matched or not.
|
|
const arr = deepLinkPayload.split('\n');
|
|
for (const msg of arr) {
|
|
for (let i = rows.length - 1; i >= 0; i--) {
|
|
const filterValue = rows[i].filterValue;
|
|
if (filterValue != null && filterValue.includes(msg)) {
|
|
highlightedRows.add(rows[i].key);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return highlightedRows;
|
|
};
|
|
|
|
tableRef: ManagedTableClass | undefined;
|
|
columns: TableColumns;
|
|
columnSizes: TableColumnSizes;
|
|
columnOrder: TableColumnOrder;
|
|
logListener: Symbol | undefined;
|
|
|
|
batch: Array<{
|
|
readonly row: TableBodyRow;
|
|
readonly entry: DeviceLogEntry;
|
|
}> = [];
|
|
queued: boolean = false;
|
|
counter: number = 0;
|
|
|
|
constructor(props: PluginProps<PersistedState>) {
|
|
super(props);
|
|
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),
|
|
);
|
|
|
|
const initialState = addEntriesToState(
|
|
this.device
|
|
.getLogs()
|
|
.map((log) => processEntry(log, String(this.counter++))),
|
|
this.state,
|
|
);
|
|
this.state = {
|
|
...initialState,
|
|
highlightedRows: this.calculateHighlightedRows(
|
|
props.deepLinkPayload,
|
|
initialState.rows,
|
|
),
|
|
counters: this.restoreSavedCounters(),
|
|
timeDirection: 'up',
|
|
};
|
|
|
|
this.logListener = this.device.addLogListener((entry: DeviceLogEntry) => {
|
|
const processedEntry = processEntry(entry, String(this.counter++));
|
|
this.incrementCounterIfNeeded(processedEntry.entry);
|
|
this.scheduleEntryForBatch(processedEntry);
|
|
});
|
|
}
|
|
|
|
incrementCounterIfNeeded = (entry: DeviceLogEntry) => {
|
|
let counterUpdated = false;
|
|
const counters = this.state.counters.map((counter) => {
|
|
if (entry.message.match(counter.expression)) {
|
|
counterUpdated = true;
|
|
if (counter.notify) {
|
|
new Notification(`${counter.label}`, {
|
|
body: 'The watched log message appeared',
|
|
});
|
|
}
|
|
return {
|
|
...counter,
|
|
count: counter.count + 1,
|
|
};
|
|
} else {
|
|
return counter;
|
|
}
|
|
});
|
|
if (counterUpdated) {
|
|
this.setState({counters});
|
|
}
|
|
};
|
|
|
|
scheduleEntryForBatch = (item: {
|
|
row: TableBodyRow;
|
|
entry: DeviceLogEntry;
|
|
}) => {
|
|
// 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
|
|
this.batch.push(item);
|
|
|
|
if (!this.queued) {
|
|
this.queued = true;
|
|
|
|
this.batchTimer = setTimeout(() => {
|
|
const thisBatch = this.batch;
|
|
this.batch = [];
|
|
this.queued = false;
|
|
this.setState((state) =>
|
|
addEntriesToState(thisBatch, state, state.timeDirection),
|
|
);
|
|
}, 100);
|
|
}
|
|
};
|
|
|
|
componentWillUnmount() {
|
|
if (this.batchTimer) {
|
|
clearTimeout(this.batchTimer);
|
|
}
|
|
|
|
if (this.logListener) {
|
|
this.device.removeLogListener(this.logListener);
|
|
}
|
|
}
|
|
|
|
clearLogs = () => {
|
|
this.device.clearLogs().catch((e) => {
|
|
console.error('Failed to clear logs: ', e);
|
|
});
|
|
this.setState({
|
|
entries: [],
|
|
rows: [],
|
|
highlightedRows: new Set(),
|
|
key2entry: {},
|
|
counters: this.state.counters.map((counter) => ({
|
|
...counter,
|
|
count: 0,
|
|
})),
|
|
});
|
|
};
|
|
|
|
createPaste = () => {
|
|
let paste = '';
|
|
const mapFn = (row: TableBodyRow) =>
|
|
Object.keys(COLUMNS)
|
|
.map((key) => textContent(row.columns[key].value))
|
|
.join('\t');
|
|
|
|
if (this.state.highlightedRows.size > 0) {
|
|
// create paste from selection
|
|
paste = this.state.rows
|
|
.filter((row) => this.state.highlightedRows.has(row.key))
|
|
.map(mapFn)
|
|
.join('\n');
|
|
} else {
|
|
// create paste with all rows
|
|
paste = this.state.rows.map(mapFn).join('\n');
|
|
}
|
|
createPaste(paste);
|
|
};
|
|
|
|
setTableRef = (ref: ManagedTableClass) => {
|
|
this.tableRef = ref;
|
|
};
|
|
|
|
goToBottom = () => {
|
|
if (this.tableRef != null) {
|
|
this.tableRef.scrollToBottom();
|
|
}
|
|
};
|
|
|
|
onRowHighlighted = (highlightedRows: Array<string>) => {
|
|
this.setState({
|
|
...this.state,
|
|
highlightedRows: new Set(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 = styled(ContextMenu)({
|
|
flex: 1,
|
|
});
|
|
|
|
buildContextMenuItems: () => MenuTemplate = () => [
|
|
{
|
|
type: 'separator',
|
|
},
|
|
{
|
|
label: 'Clear all',
|
|
click: this.clearLogs,
|
|
},
|
|
];
|
|
|
|
render() {
|
|
return (
|
|
<LogTable.ContextMenu
|
|
buildItems={this.buildContextMenuItems}
|
|
component={FlexColumn}>
|
|
<SearchableTable
|
|
innerRef={this.setTableRef}
|
|
floating={false}
|
|
multiline={true}
|
|
columnSizes={this.columnSizes}
|
|
columnOrder={this.columnOrder}
|
|
columns={this.columns}
|
|
rows={this.state.rows}
|
|
highlightedRows={this.state.highlightedRows}
|
|
onRowHighlighted={this.onRowHighlighted}
|
|
multiHighlight={true}
|
|
defaultFilters={DEFAULT_FILTERS}
|
|
zebra={false}
|
|
actions={<Button onClick={this.clearLogs}>Clear Logs</Button>}
|
|
allowRegexSearch={true}
|
|
// If the logs is opened through deeplink, then don't scroll as the row is highlighted
|
|
stickyBottom={
|
|
!(this.props.deepLinkPayload && this.state.highlightedRows.size > 0)
|
|
}
|
|
initialSortOrder={{key: 'time', direction: 'up'}}
|
|
onSort={(order: TableRowSortOrder) =>
|
|
this.setState(
|
|
produce((prevState) => {
|
|
prevState.rows.reverse();
|
|
prevState.timeDirection = order.direction;
|
|
}),
|
|
)
|
|
}
|
|
/>
|
|
<DetailSidebar>{this.renderSidebar()}</DetailSidebar>
|
|
</LogTable.ContextMenu>
|
|
);
|
|
}
|
|
}
|