DeviceLogs plugin to Sandy

Summary:
Converted the DeviceLogs plugin to sandy.

Kept logic and UI the same (so same batching, localstorage mechanisms etc). But used sandy api's for log subscribing, state, and separating the logical part of the component from the UI.

Note that some mechanisms work slightly different, like deeplinking and scrollToBottom handling, to reflect the fact that plugins are now long lived

Reviewed By: jknoxville

Differential Revision: D22845466

fbshipit-source-id: 7c98b2ddd9121dc730768ee1bece7e71bb5bec16
This commit is contained in:
Michel Weststrate
2020-08-20 13:31:17 -07:00
committed by Facebook GitHub Bot
parent dd15cffa64
commit 685cc09b3b
9 changed files with 267 additions and 259 deletions

View File

@@ -343,12 +343,11 @@ class PluginContainer extends PureComponent<Props, State> {
let pluginElement: null | React.ReactElement<any>; let pluginElement: null | React.ReactElement<any>;
if (isSandyPlugin(activePlugin)) { if (isSandyPlugin(activePlugin)) {
// Make sure we throw away the container for different pluginKey! // Make sure we throw away the container for different pluginKey!
pluginElement = ( const instance = target.sandyPluginStates.get(activePlugin.id);
<SandyPluginRenderer if (!instance) {
key={pluginKey} return null;
plugin={target.sandyPluginStates.get(activePlugin.id)!} }
/> pluginElement = <SandyPluginRenderer key={pluginKey} plugin={instance} />;
);
} else { } else {
const props: PluginProps<Object> & { const props: PluginProps<Object> & {
key: string; key: string;

View File

@@ -120,6 +120,7 @@ export default class BaseDevice {
this._notifyLogListeners(entry); this._notifyLogListeners(entry);
} }
// TODO: remove getLogs T70688226
getLogs(startDate: Date | null = null) { getLogs(startDate: Date | null = null) {
return startDate != null return startDate != null
? this.logEntries.filter((log) => { ? this.logEntries.filter((log) => {

View File

@@ -12,7 +12,7 @@ import {Logger} from '../fb-interfaces/Logger';
import {PluginNotification} from '../reducers/notifications'; import {PluginNotification} from '../reducers/notifications';
import {PluginDefinition, isSandyPlugin} from '../plugin'; import {PluginDefinition, isSandyPlugin} from '../plugin';
import isHeadless from '../utils/isHeadless'; import isHeadless from '../utils/isHeadless';
import {setStaticView, setDeeplinkPayload} from '../reducers/connections'; import {setStaticView} from '../reducers/connections';
import {ipcRenderer, IpcRendererEvent} from 'electron'; import {ipcRenderer, IpcRendererEvent} from 'electron';
import { import {
setActiveNotifications, setActiveNotifications,
@@ -48,9 +48,11 @@ export default (store: Store, logger: Logger) => {
) => { ) => {
if (eventName === 'click' || (eventName === 'action' && arg === 0)) { if (eventName === 'click' || (eventName === 'action' && arg === 0)) {
store.dispatch( store.dispatch(
setDeeplinkPayload(pluginNotification.notification.action ?? null), setStaticView(
NotificationScreen,
pluginNotification.notification.action ?? null,
),
); );
store.dispatch(setStaticView(NotificationScreen));
} else if (eventName === 'action') { } else if (eventName === 'action') {
if (arg === 1 && pluginNotification.notification.category) { if (arg === 1 && pluginNotification.notification.category) {
// Hide similar (category) // Hide similar (category)

View File

@@ -128,6 +128,7 @@ export type Action =
| { | {
type: 'SET_STATIC_VIEW'; type: 'SET_STATIC_VIEW';
payload: StaticView; payload: StaticView;
deepLinkPayload: unknown;
} }
| { | {
type: 'DISMISS_ERROR'; type: 'DISMISS_ERROR';
@@ -145,10 +146,6 @@ export type Action =
type: 'SELECT_CLIENT'; type: 'SELECT_CLIENT';
payload: string; payload: string;
} }
| {
type: 'SET_DEEPLINK_PAYLOAD';
payload: null | string;
}
| RegisterPluginAction; | RegisterPluginAction;
const DEFAULT_PLUGIN = 'DeviceLogs'; const DEFAULT_PLUGIN = 'DeviceLogs';
@@ -173,12 +170,13 @@ const INITAL_STATE: State = {
export default (state: State = INITAL_STATE, action: Actions): State => { export default (state: State = INITAL_STATE, action: Actions): State => {
switch (action.type) { switch (action.type) {
case 'SET_STATIC_VIEW': { case 'SET_STATIC_VIEW': {
const {payload} = action; const {payload, deepLinkPayload} = action;
const {selectedPlugin} = state; const {selectedPlugin} = state;
return { return {
...state, ...state,
staticView: payload, staticView: payload,
selectedPlugin: payload != null ? null : selectedPlugin, selectedPlugin: payload != null ? null : selectedPlugin,
deepLinkPayload: deepLinkPayload ?? null,
}; };
} }
@@ -252,7 +250,10 @@ export default (state: State = INITAL_STATE, action: Actions): State => {
if (typeof deepLinkPayload === 'string') { if (typeof deepLinkPayload === 'string') {
const deepLinkParams = new URLSearchParams(deepLinkPayload); const deepLinkParams = new URLSearchParams(deepLinkPayload);
const deviceParam = deepLinkParams.get('device'); const deviceParam = deepLinkParams.get('device');
const deviceMatch = state.devices.find((v) => v.title === deviceParam); if (deviceParam) {
const deviceMatch = state.devices.find(
(v) => v.title === deviceParam,
);
if (deviceMatch) { if (deviceMatch) {
selectedDevice = deviceMatch; selectedDevice = deviceMatch;
} else { } else {
@@ -261,6 +262,7 @@ export default (state: State = INITAL_STATE, action: Actions): State => {
); );
} }
} }
}
if (!selectDevice) { if (!selectDevice) {
console.warn('Trying to select a plugin before a device was selected!'); console.warn('Trying to select a plugin before a device was selected!');
} }
@@ -388,9 +390,6 @@ export default (state: State = INITAL_STATE, action: Actions): State => {
errors, errors,
}; };
} }
case 'SET_DEEPLINK_PAYLOAD': {
return {...state, deepLinkPayload: action.payload};
}
case 'REGISTER_PLUGINS': { case 'REGISTER_PLUGINS': {
// plugins are registered after creating the base devices, so update them // plugins are registered after creating the base devices, so update them
const plugins = action.payload; const plugins = action.payload;
@@ -435,13 +434,17 @@ export const selectDevice = (payload: BaseDevice): Action => ({
payload, payload,
}); });
export const setStaticView = (payload: StaticView): Action => { export const setStaticView = (
payload: StaticView,
deepLinkPayload?: unknown,
): Action => {
if (!payload) { if (!payload) {
throw new Error('Cannot set empty static view'); throw new Error('Cannot set empty static view');
} }
return { return {
type: 'SET_STATIC_VIEW', type: 'SET_STATIC_VIEW',
payload, payload,
deepLinkPayload,
}; };
}; };
@@ -479,11 +482,6 @@ export const selectClient = (clientId: string): Action => ({
payload: clientId, payload: clientId,
}); });
export const setDeeplinkPayload = (payload: string | null): Action => ({
type: 'SET_DEEPLINK_PAYLOAD',
payload,
});
export function getAvailableClients( export function getAvailableClients(
device: null | undefined | BaseDevice, device: null | undefined | BaseDevice,
clients: Client[], clients: Client[],

View File

@@ -276,6 +276,7 @@ export class ManagedTable extends React.Component<
prevState: ManagedTableState, prevState: ManagedTableState,
) { ) {
if ( if (
this.props.stickyBottom !== false &&
this.props.rows.length !== prevProps.rows.length && this.props.rows.length !== prevProps.rows.length &&
this.state.shouldScrollToBottom && this.state.shouldScrollToBottom &&
this.state.highlightedRows.size < 2 this.state.highlightedRows.size < 2

View File

@@ -33,6 +33,7 @@ export type LogLevel =
| 'fatal'; | 'fatal';
export interface Device { export interface Device {
readonly realDevice: any; // TODO: temporarily, clean up T70688226
readonly isArchived: boolean; readonly isArchived: boolean;
readonly os: string; readonly os: string;
readonly deviceType: DeviceType; readonly deviceType: DeviceType;
@@ -77,6 +78,7 @@ export class SandyDevicePluginInstance extends BasePluginInstance {
) { ) {
super(flipperLib, definition, initialStates); super(flipperLib, definition, initialStates);
const device: Device = { const device: Device = {
realDevice, // TODO: temporarily, clean up T70688226
// N.B. we model OS as string, not as enum, to make custom device types possible in the future // N.B. we model OS as string, not as enum, to make custom device types possible in the future
os: realDevice.os, os: realDevice.os,
isArchived: realDevice.isArchived, isArchived: realDevice.isArchived,

View File

@@ -7,14 +7,14 @@
* @format * @format
*/ */
import {produce} from 'immer'; import {produce, Draft} from 'immer';
import {useState, useEffect} from 'react'; import {useState, useEffect} from 'react';
import {getCurrentPluginInstance} from '../plugin/PluginBase'; import {getCurrentPluginInstance} from '../plugin/PluginBase';
export type Atom<T> = { export type Atom<T> = {
get(): T; get(): T;
set(newValue: T): void; set(newValue: T): void;
update(recipe: (draft: T) => void): void; update(recipe: (draft: Draft<T>) => void): void;
}; };
class AtomValue<T> implements Atom<T> { class AtomValue<T> implements Atom<T> {
@@ -36,7 +36,7 @@ class AtomValue<T> implements Atom<T> {
} }
} }
update(recipe: (draft: T) => void) { update(recipe: (draft: Draft<T>) => void) {
this.set(produce(this.value, recipe)); this.set(produce(this.value, recipe));
} }

View File

@@ -7,14 +7,15 @@
* @format * @format
*/ */
import {TableBodyRow, TableRowSortOrder} from 'flipper';
import { import {
TableBodyRow, Device,
TableRowSortOrder, DevicePluginClient,
Props as PluginProps,
BaseAction,
DeviceLogEntry, DeviceLogEntry,
produce, createState,
} from 'flipper'; usePlugin,
useValue,
} from 'flipper-plugin';
import {Counter} from './LogWatcher'; import {Counter} from './LogWatcher';
import { import {
@@ -24,17 +25,13 @@ import {
ContextMenu, ContextMenu,
FlexColumn, FlexColumn,
DetailSidebar, DetailSidebar,
FlipperDevicePlugin,
SearchableTable, SearchableTable,
styled, styled,
Device,
createPaste,
textContent, textContent,
KeyboardActions,
MenuTemplate, MenuTemplate,
} from 'flipper'; } from 'flipper';
import LogWatcher from './LogWatcher'; import LogWatcher from './LogWatcher';
import React from 'react'; import React, {useCallback, createRef, MutableRefObject} from 'react';
import {Icon, LogCount, HiddenScrollText} from './logComponents'; import {Icon, LogCount, HiddenScrollText} from './logComponents';
import {pad, getLineCount} from './logUtils'; import {pad, getLineCount} from './logUtils';
@@ -50,16 +47,6 @@ type BaseState = {
readonly entries: Entries; readonly entries: Entries;
}; };
type AdditionalState = {
readonly highlightedRows: ReadonlySet<string>;
readonly counters: ReadonlyArray<Counter>;
readonly timeDirection: 'up' | 'down';
};
type State = BaseState & AdditionalState;
type PersistedState = {};
const COLUMN_SIZE = { const COLUMN_SIZE = {
type: 40, type: 40,
time: 120, time: 120,
@@ -95,7 +82,7 @@ const COLUMNS = {
}, },
} as const; } as const;
const INITIAL_COLUMN_ORDER = [ const COLUMN_ORDER = [
{ {
key: 'type', key: 'type',
visible: true, visible: true,
@@ -325,38 +312,81 @@ export function processEntry(
}; };
} }
export default class LogTable extends FlipperDevicePlugin< export function supportsDevice(device: Device) {
State,
BaseAction,
PersistedState
> {
static keyboardActions: KeyboardActions = [
'clear',
'goToBottom',
'createPaste',
];
batchTimer: NodeJS.Timeout | undefined;
static supportsDevice(device: Device) {
return ( return (
device.os === 'Android' || device.os === 'Android' ||
device.os === 'Metro' || device.os === 'Metro' ||
(device.os === 'iOS' && device.deviceType !== 'physical') (device.os === 'iOS' && device.deviceType !== 'physical')
); );
} }
onKeyboardAction = (action: string) => { export function devicePlugin(client: DevicePluginClient) {
if (action === 'clear') { let counter = 0;
this.clearLogs(); let batch: Array<{
} else if (action === 'goToBottom') { readonly row: TableBodyRow;
this.goToBottom(); readonly entry: DeviceLogEntry;
} else if (action === 'createPaste') { }> = [];
this.createPaste(); let queued: boolean = false;
} let batchTimer: NodeJS.Timeout | undefined;
}; const tableRef: MutableRefObject<ManagedTableClass | null> = createRef();
restoreSavedCounters = (): Array<Counter> => { // TODO T70688226: this can be removed once plugin stores logs,
// rather than the device.
const initialState = addEntriesToState(
client.device.realDevice
.getLogs()
.map((log: DeviceLogEntry) => processEntry(log, '' + counter++)),
);
const rows = createState<ReadonlyArray<TableBodyRow>>(initialState.rows);
const entries = createState<Entries>([]);
const highlightedRows = createState<ReadonlySet<string>>(new Set());
const counters = createState<ReadonlyArray<Counter>>(restoreSavedCounters());
const timeDirection = createState<'up' | 'down'>('up');
const isDeeplinked = createState(false);
client.onDeepLink((payload: unknown) => {
if (typeof payload === 'string') {
highlightedRows.set(calculateHighlightedRows(payload, rows.get()));
isDeeplinked.set(true);
}
});
client.onDeactivate(() => {
isDeeplinked.set(false);
tableRef.current = null;
});
client.onDestroy(() => {
if (batchTimer) {
clearTimeout(batchTimer);
}
});
client.addMenuEntry(
{
action: 'clear',
handler: clearLogs,
},
{
action: 'createPaste',
handler: createPaste,
},
{
action: 'goToBottom',
handler: goToBottom,
},
);
client.device.onLogEntry((entry: DeviceLogEntry) => {
const processedEntry = processEntry(entry, '' + counter++);
incrementCounterIfNeeded(processedEntry.entry);
scheduleEntryForBatch(processedEntry);
});
// TODO: make local storage abstraction T69990351
function restoreSavedCounters(): Counter[] {
const savedCounters = const savedCounters =
window.localStorage.getItem(LOG_WATCHER_LOCAL_STORAGE_KEY) || '[]'; window.localStorage.getItem(LOG_WATCHER_LOCAL_STORAGE_KEY) || '[]';
return JSON.parse(savedCounters).map((counter: Counter) => ({ return JSON.parse(savedCounters).map((counter: Counter) => ({
@@ -364,12 +394,12 @@ export default class LogTable extends FlipperDevicePlugin<
expression: new RegExp(counter.label, 'gi'), expression: new RegExp(counter.label, 'gi'),
count: 0, count: 0,
})); }));
}; }
calculateHighlightedRows = ( function calculateHighlightedRows(
deepLinkPayload: unknown, deepLinkPayload: unknown,
rows: ReadonlyArray<TableBodyRow>, rows: ReadonlyArray<TableBodyRow>,
): Set<string> => { ): Set<string> {
const highlightedRows = new Set<string>(); const highlightedRows = new Set<string>();
if (typeof deepLinkPayload !== 'string') { if (typeof deepLinkPayload !== 'string') {
return highlightedRows; return highlightedRows;
@@ -398,50 +428,15 @@ export default class LogTable extends FlipperDevicePlugin<
} }
} }
return highlightedRows; return highlightedRows;
};
tableRef: ManagedTableClass | undefined;
logListener: Symbol | undefined;
batch: Array<{
readonly row: TableBodyRow;
readonly entry: DeviceLogEntry;
}> = [];
queued: boolean = false;
counter: number = 0;
constructor(props: PluginProps<PersistedState>) {
super(props);
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) => { function incrementCounterIfNeeded(entry: DeviceLogEntry) {
let counterUpdated = false; let counterUpdated = false;
const counters = this.state.counters.map((counter) => { const newCounters = counters.get().map((counter) => {
if (entry.message.match(counter.expression)) { if (entry.message.match(counter.expression)) {
counterUpdated = true; counterUpdated = true;
if (counter.notify) { if (counter.notify) {
// TODO: use new notifications system T69990351
new Notification(`${counter.label}`, { new Notification(`${counter.label}`, {
body: 'The watched log message appeared', body: 'The watched log message appeared',
}); });
@@ -455,161 +450,168 @@ export default class LogTable extends FlipperDevicePlugin<
} }
}); });
if (counterUpdated) { if (counterUpdated) {
this.setState({counters}); counters.set(newCounters);
}
} }
};
scheduleEntryForBatch = (item: { function scheduleEntryForBatch(item: {
row: TableBodyRow; row: TableBodyRow;
entry: DeviceLogEntry; entry: DeviceLogEntry;
}) => { }) {
// batch up logs to be processed every 250ms, if we have lots of log // 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 // messages coming in, then calling an setState 200+ times is actually
// pretty expensive // pretty expensive
this.batch.push(item); batch.push(item);
if (!this.queued) { if (!queued) {
this.queued = true; queued = true;
this.batchTimer = setTimeout(() => { batchTimer = setTimeout(() => {
const thisBatch = this.batch; const thisBatch = batch;
this.batch = []; batch = [];
this.queued = false; queued = false;
this.setState((state) => const newState = addEntriesToState(
addEntriesToState(thisBatch, state, state.timeDirection), thisBatch,
{
rows: rows.get(),
entries: entries.get(),
},
timeDirection.get(),
); );
rows.set(newState.rows);
entries.set(newState.entries);
}, 100); }, 100);
} }
};
componentWillUnmount() {
if (this.batchTimer) {
clearTimeout(this.batchTimer);
} }
if (this.logListener) { function clearLogs() {
this.device.removeLogListener(this.logListener); // TODO T70688226: implement this when the store is local
} client.device.realDevice.clearLogs().catch((e: any) => {
}
clearLogs = () => {
this.device.clearLogs().catch((e) => {
console.error('Failed to clear logs: ', e); console.error('Failed to clear logs: ', e);
}); });
this.setState({ entries.set([]);
entries: [], rows.set([]);
rows: [], highlightedRows.set(new Set());
highlightedRows: new Set(), counters.update((counters) => {
counters: this.state.counters.map((counter) => ({ for (const counter of counters) {
...counter, counter.count = 0;
count: 0, }
})),
}); });
}; }
createPaste = () => { function createPaste() {
let paste = ''; let paste = '';
const mapFn = (row: TableBodyRow) => const mapFn = (row: TableBodyRow) =>
Object.keys(COLUMNS) Object.keys(COLUMNS)
.map((key) => textContent(row.columns[key].value)) .map((key) => textContent(row.columns[key].value))
.join('\t'); .join('\t');
if (this.state.highlightedRows.size > 0) { if (highlightedRows.get().size > 0) {
// create paste from selection // create paste from selection
paste = this.state.rows paste = rows
.filter((row) => this.state.highlightedRows.has(row.key)) .get()
.filter((row) => highlightedRows.get().has(row.key))
.map(mapFn) .map(mapFn)
.join('\n'); .join('\n');
} else { } else {
// create paste with all rows // create paste with all rows
paste = this.state.rows.map(mapFn).join('\n'); paste = rows.get().map(mapFn).join('\n');
} }
createPaste(paste); client.createPaste(paste);
};
setTableRef = (ref: ManagedTableClass) => {
this.tableRef = ref;
};
goToBottom = () => {
if (this.tableRef != null) {
this.tableRef.scrollToBottom();
} }
};
onRowHighlighted = (highlightedRows: Array<string>) => { function goToBottom() {
this.setState({ tableRef.current?.scrollToBottom();
...this.state, }
highlightedRows: new Set(highlightedRows),
});
};
renderSidebar = () => { return {
return ( rows,
<LogWatcher highlightedRows,
counters={this.state.counters} counters,
onChange={(counters) => isDeeplinked,
this.setState({counters}, () => tableRef,
onRowHighlighted(selectedRows: Array<string>) {
highlightedRows.set(new Set(selectedRows));
},
clearLogs,
onSort(order: TableRowSortOrder) {
rows.set(rows.get().slice().reverse());
timeDirection.set(order.direction);
},
updateCounters(newCounters: readonly Counter[]) {
counters.set(newCounters);
// TODO: make local storage abstraction T69989583
window.localStorage.setItem( window.localStorage.setItem(
LOG_WATCHER_LOCAL_STORAGE_KEY, LOG_WATCHER_LOCAL_STORAGE_KEY,
JSON.stringify(this.state.counters), JSON.stringify(newCounters),
),
)
}
/>
); );
},
}; };
}
static ContextMenu = styled(ContextMenu)({ const DeviceLogsContextMenu = styled(ContextMenu)({
flex: 1, flex: 1,
}); });
buildContextMenuItems: () => MenuTemplate = () => [ export function Component() {
const plugin = usePlugin(devicePlugin);
const rows = useValue(plugin.rows);
const highlightedRows = useValue(plugin.highlightedRows);
const isDeeplinked = useValue(plugin.isDeeplinked);
const buildContextMenuItems = useCallback(
(): MenuTemplate => [
{ {
type: 'separator', type: 'separator',
}, },
{ {
label: 'Clear all', label: 'Clear all',
click: this.clearLogs, click: plugin.clearLogs,
}, },
]; ],
[plugin.clearLogs],
);
render() {
return ( return (
<LogTable.ContextMenu <DeviceLogsContextMenu
buildItems={this.buildContextMenuItems} buildItems={buildContextMenuItems}
component={FlexColumn}> component={FlexColumn}>
<SearchableTable <SearchableTable
innerRef={this.setTableRef} innerRef={plugin.tableRef}
floating={false} floating={false}
multiline={true} multiline={true}
columnSizes={COLUMN_SIZE} columnSizes={COLUMN_SIZE}
columnOrder={INITIAL_COLUMN_ORDER} columnOrder={COLUMN_ORDER}
columns={COLUMNS} columns={COLUMNS}
rows={this.state.rows} rows={rows}
highlightedRows={this.state.highlightedRows} highlightedRows={highlightedRows}
onRowHighlighted={this.onRowHighlighted} onRowHighlighted={plugin.onRowHighlighted}
multiHighlight={true} multiHighlight={true}
defaultFilters={DEFAULT_FILTERS} defaultFilters={DEFAULT_FILTERS}
zebra={false} zebra={false}
actions={<Button onClick={this.clearLogs}>Clear Logs</Button>} actions={<Button onClick={plugin.clearLogs}>Clear Logs</Button>}
allowRegexSearch={true} allowRegexSearch={true}
// If the logs is opened through deeplink, then don't scroll as the row is highlighted // If the logs is opened through deeplink, then don't scroll as the row is highlighted
stickyBottom={ stickyBottom={!(isDeeplinked && highlightedRows.size > 0)}
!(this.props.deepLinkPayload && this.state.highlightedRows.size > 0)
}
initialSortOrder={{key: 'time', direction: 'up'}} initialSortOrder={{key: 'time', direction: 'up'}}
onSort={(order: TableRowSortOrder) => onSort={plugin.onSort}
this.setState( />
produce((prevState) => { <DetailSidebar>
prevState.rows.reverse(); <Sidebar />
prevState.timeDirection = order.direction; </DetailSidebar>
}), </DeviceLogsContextMenu>
) );
} }
function Sidebar() {
const plugin = usePlugin(devicePlugin);
const counters = useValue(plugin.counters);
return (
<LogWatcher
counters={counters}
onChange={(counters) => {
plugin.updateCounters(counters);
}}
/> />
<DetailSidebar>{this.renderSidebar()}</DetailSidebar>
</LogTable.ContextMenu>
); );
}
} }

View File

@@ -10,6 +10,9 @@
"flipper-plugin" "flipper-plugin"
], ],
"dependencies": {}, "dependencies": {},
"peerDependencies": {
"flipper-plugin": "0.51.0"
},
"title": "Logs", "title": "Logs",
"icon": "arrow-right", "icon": "arrow-right",
"bugs": { "bugs": {