blob: c3d604425b96c33069345f9531397dedc88a7533 [file] [log] [blame]
// Copyright (C) 2022 The Android Open Source Project
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import {v4 as uuidv4} from 'uuid';
import {Registry} from '../base/registry';
import {TimeSpan, time} from '../base/time';
import {globals} from '../frontend/globals';
import {
Command,
LegacyDetailsPanel,
MetricVisualisation,
Migrate,
Plugin,
PluginContext,
PluginContextTrace,
PluginDescriptor,
PrimaryTrackSortKey,
Store,
TabDescriptor,
TrackDescriptor,
TrackPredicate,
GroupPredicate,
TrackRef,
} from '../public';
import {EngineBase, Engine} from '../trace_processor/engine';
import {Actions} from './actions';
import {SCROLLING_TRACK_GROUP} from './state';
import {addQueryResultsTab} from '../frontend/query_result_tab';
import {Flag, featureFlags} from '../core/feature_flags';
import {assertExists} from '../base/logging';
import {raf} from '../core/raf_scheduler';
import {defaultPlugins} from '../core/default_plugins';
import {PromptOption} from '../frontend/omnibox_manager';
import {horizontalScrollToTs} from '../frontend/scroll_helper';
import {DisposableStack} from '../base/disposable_stack';
import {TraceContext} from '../frontend/trace_context';
// Every plugin gets its own PluginContext. This is how we keep track
// what each plugin is doing and how we can blame issues on particular
// plugins.
// The PluginContext exists for the whole duration a plugin is active.
export class PluginContextImpl implements PluginContext, Disposable {
private trash = new DisposableStack();
private alive = true;
readonly sidebar = {
hide() {
globals.dispatch(
Actions.setSidebar({
visible: false,
}),
);
},
show() {
globals.dispatch(
Actions.setSidebar({
visible: true,
}),
);
},
isVisible() {
return globals.state.sidebarVisible;
},
};
registerCommand(cmd: Command): void {
// Silently ignore if context is dead.
if (!this.alive) return;
const disposable = globals.commandManager.registerCommand(cmd);
this.trash.use(disposable);
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
runCommand(id: string, ...args: any[]): any {
return globals.commandManager.runCommand(id, ...args);
}
constructor(readonly pluginId: string) {}
[Symbol.dispose]() {
this.trash.dispose();
this.alive = false;
}
}
// This PluginContextTrace implementation provides the plugin access to trace
// related resources, such as the engine and the store.
// The PluginContextTrace exists for the whole duration a plugin is active AND a
// trace is loaded.
class PluginContextTraceImpl implements PluginContextTrace, Disposable {
private trash = new DisposableStack();
private alive = true;
readonly engine: Engine;
constructor(
private ctx: PluginContext,
engine: EngineBase,
) {
const engineProxy = engine.getProxy(ctx.pluginId);
this.trash.use(engineProxy);
this.engine = engineProxy;
}
registerCommand(cmd: Command): void {
// Silently ignore if context is dead.
if (!this.alive) return;
const dispose = globals.commandManager.registerCommand(cmd);
this.trash.use(dispose);
}
registerTrack(trackDesc: TrackDescriptor): void {
// Silently ignore if context is dead.
if (!this.alive) return;
const dispose = globals.trackManager.registerTrack({
...trackDesc,
pluginId: this.pluginId,
});
this.trash.use(dispose);
}
addDefaultTrack(track: TrackRef): void {
// Silently ignore if context is dead.
if (!this.alive) return;
const dispose = globals.trackManager.addPotentialTrack(track);
this.trash.use(dispose);
}
registerStaticTrack(track: TrackDescriptor & TrackRef): void {
this.registerTrack(track);
this.addDefaultTrack(track);
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
runCommand(id: string, ...args: any[]): any {
return this.ctx.runCommand(id, ...args);
}
registerTab(desc: TabDescriptor): void {
if (!this.alive) return;
const unregister = globals.tabManager.registerTab(desc);
this.trash.use(unregister);
}
addDefaultTab(uri: string): void {
const remove = globals.tabManager.addDefaultTab(uri);
this.trash.use(remove);
}
registerDetailsPanel(detailsPanel: LegacyDetailsPanel): void {
if (!this.alive) return;
const tabMan = globals.tabManager;
const unregister = tabMan.registerLegacyDetailsPanel(detailsPanel);
this.trash.use(unregister);
}
get sidebar() {
return this.ctx.sidebar;
}
readonly tabs = {
openQuery: (query: string, title: string) => {
addQueryResultsTab({query, title});
},
showTab(uri: string): void {
globals.dispatch(Actions.showTab({uri}));
},
hideTab(uri: string): void {
globals.dispatch(Actions.hideTab({uri}));
},
};
get pluginId(): string {
return this.ctx.pluginId;
}
readonly timeline = {
// Add a new track to the timeline, returning its key.
addTrack(uri: string, displayName: string): string {
const trackKey = uuidv4();
globals.dispatch(
Actions.addTrack({
key: trackKey,
uri,
name: displayName,
trackSortKey: PrimaryTrackSortKey.ORDINARY_TRACK,
trackGroup: SCROLLING_TRACK_GROUP,
}),
);
return trackKey;
},
removeTrack(key: string): void {
globals.dispatch(Actions.removeTracks({trackKeys: [key]}));
},
pinTrack(key: string) {
if (!isPinned(key)) {
globals.dispatch(Actions.toggleTrackPinned({trackKey: key}));
}
},
unpinTrack(key: string) {
if (isPinned(key)) {
globals.dispatch(Actions.toggleTrackPinned({trackKey: key}));
}
},
pinTracksByPredicate(predicate: TrackPredicate) {
const tracks = Object.values(globals.state.tracks);
for (const track of tracks) {
const trackDesc = globals.trackManager.resolveTrackInfo(track.uri);
if (trackDesc && predicate(trackDesc) && !isPinned(track.key)) {
globals.dispatch(
Actions.toggleTrackPinned({
trackKey: track.key,
}),
);
}
}
},
unpinTracksByPredicate(predicate: TrackPredicate) {
const tracks = Object.values(globals.state.tracks);
for (const track of tracks) {
const trackDesc = globals.trackManager.resolveTrackInfo(track.uri);
if (trackDesc && predicate(trackDesc) && isPinned(track.key)) {
globals.dispatch(
Actions.toggleTrackPinned({
trackKey: track.key,
}),
);
}
}
},
removeTracksByPredicate(predicate: TrackPredicate) {
const trackKeysToRemove = Object.values(globals.state.tracks)
.filter((track) => {
const trackDesc = globals.trackManager.resolveTrackInfo(track.uri);
return trackDesc && predicate(trackDesc);
})
.map((trackState) => trackState.key);
globals.dispatch(Actions.removeTracks({trackKeys: trackKeysToRemove}));
},
expandGroupsByPredicate(predicate: GroupPredicate) {
const groups = globals.state.trackGroups;
const groupsToExpand = Object.values(groups)
.filter((group) => group.collapsed)
.filter((group) => {
const ref = {
displayName: group.name,
collapsed: group.collapsed,
};
return predicate(ref);
})
.map((group) => group.key);
for (const groupKey of groupsToExpand) {
globals.dispatch(Actions.toggleTrackGroupCollapsed({groupKey}));
}
},
collapseGroupsByPredicate(predicate: GroupPredicate) {
const groups = globals.state.trackGroups;
const groupsToCollapse = Object.values(groups)
.filter((group) => !group.collapsed)
.filter((group) => {
const ref = {
displayName: group.name,
collapsed: group.collapsed,
};
return predicate(ref);
})
.map((group) => group.key);
for (const groupKey of groupsToCollapse) {
globals.dispatch(Actions.toggleTrackGroupCollapsed({groupKey}));
}
},
get tracks(): TrackRef[] {
const tracks = Object.values(globals.state.tracks);
const pinnedTracks = globals.state.pinnedTracks;
const groups = globals.state.trackGroups;
return tracks.map((trackState) => {
const group = trackState.trackGroup
? groups[trackState.trackGroup]
: undefined;
return {
title: trackState.name,
uri: trackState.uri,
key: trackState.key,
groupName: group?.name,
isPinned: pinnedTracks.includes(trackState.key),
};
});
},
panToTimestamp(ts: time): void {
horizontalScrollToTs(ts);
},
setViewportTime(start: time, end: time): void {
globals.timeline.updateVisibleTime(new TimeSpan(start, end));
},
get viewport(): TimeSpan {
return globals.timeline.visibleWindow.toTimeSpan();
},
};
[Symbol.dispose]() {
this.trash.dispose();
this.alive = false;
}
mountStore<T>(migrate: Migrate<T>): Store<T> {
return globals.store.createSubStore(['plugins', this.pluginId], migrate);
}
get trace(): TraceContext {
return globals.traceContext;
}
get openerPluginArgs(): {[key: string]: unknown} | undefined {
if (globals.state.engine?.source.type !== 'ARRAY_BUFFER') {
return undefined;
}
const pluginArgs = globals.state.engine?.source.pluginArgs;
return (pluginArgs ?? {})[this.pluginId];
}
async prompt(
text: string,
options?: PromptOption[] | undefined,
): Promise<string> {
return globals.omnibox.prompt(text, options);
}
}
function isPinned(trackId: string): boolean {
return globals.state.pinnedTracks.includes(trackId);
}
// 'Static' registry of all known plugins.
export class PluginRegistry extends Registry<PluginDescriptor> {
constructor() {
super((info) => info.pluginId);
}
}
export interface PluginDetails {
plugin: Plugin;
context: PluginContext & Disposable;
traceContext?: PluginContextTraceImpl;
previousOnTraceLoadTimeMillis?: number;
}
function makePlugin(info: PluginDescriptor): Plugin {
const {plugin} = info;
// Class refs are functions, concrete plugins are not
if (typeof plugin === 'function') {
const PluginClass = plugin;
return new PluginClass();
} else {
return plugin;
}
}
export class PluginManager {
private registry: PluginRegistry;
private _plugins: Map<string, PluginDetails>;
private engine?: EngineBase;
private flags = new Map<string, Flag>();
constructor(registry: PluginRegistry) {
this.registry = registry;
this._plugins = new Map();
}
get plugins(): Map<string, PluginDetails> {
return this._plugins;
}
// Must only be called once on startup
async initialize(): Promise<void> {
// Shuffle the order of plugins to weed out any implicit inter-plugin
// dependencies.
const pluginsShuffled = Array.from(pluginRegistry.values())
.map(({pluginId}) => ({pluginId, sort: Math.random()}))
.sort((a, b) => a.sort - b.sort);
for (const {pluginId} of pluginsShuffled) {
const flagId = `plugin_${pluginId}`;
const name = `Plugin: ${pluginId}`;
const flag = featureFlags.register({
id: flagId,
name,
description: `Overrides '${pluginId}' plugin.`,
defaultValue: defaultPlugins.includes(pluginId),
});
this.flags.set(pluginId, flag);
if (flag.get()) {
await this.activatePlugin(pluginId);
}
}
}
/**
* Enable plugin flag - i.e. configure a plugin to start on boot.
* @param id The ID of the plugin.
* @param now Optional: If true, also activate the plugin now.
*/
async enablePlugin(id: string, now?: boolean): Promise<void> {
const flag = this.flags.get(id);
if (flag) {
flag.set(true);
}
now && (await this.activatePlugin(id));
}
/**
* Disable plugin flag - i.e. configure a plugin not to start on boot.
* @param id The ID of the plugin.
* @param now Optional: If true, also deactivate the plugin now.
*/
async disablePlugin(id: string, now?: boolean): Promise<void> {
const flag = this.flags.get(id);
if (flag) {
flag.set(false);
}
now && (await this.deactivatePlugin(id));
}
/**
* Start a plugin just for this session. This setting is not persisted.
* @param id The ID of the plugin to start.
*/
async activatePlugin(id: string): Promise<void> {
if (this.isActive(id)) {
return;
}
const pluginInfo = this.registry.get(id);
const plugin = makePlugin(pluginInfo);
const context = new PluginContextImpl(id);
plugin.onActivate?.(context);
const pluginDetails: PluginDetails = {
plugin,
context,
};
// If a trace is already loaded when plugin is activated, make sure to
// call onTraceLoad().
if (this.engine) {
await doPluginTraceLoad(pluginDetails, this.engine);
}
this._plugins.set(id, pluginDetails);
raf.scheduleFullRedraw();
}
/**
* Stop a plugin just for this session. This setting is not persisted.
* @param id The ID of the plugin to stop.
*/
async deactivatePlugin(id: string): Promise<void> {
const pluginDetails = this.getPluginContext(id);
if (pluginDetails === undefined) {
return;
}
const {context, plugin} = pluginDetails;
await doPluginTraceUnload(pluginDetails);
plugin.onDeactivate && plugin.onDeactivate(context);
context[Symbol.dispose]();
this._plugins.delete(id);
raf.scheduleFullRedraw();
}
/**
* Restore all plugins enable/disabled flags to their default values.
* @param now Optional: Also activates/deactivates plugins to match flag
* settings.
*/
async restoreDefaults(now?: boolean): Promise<void> {
for (const plugin of pluginRegistry.values()) {
const pluginId = plugin.pluginId;
const flag = assertExists(this.flags.get(pluginId));
flag.reset();
if (now) {
if (flag.get()) {
await this.activatePlugin(plugin.pluginId);
} else {
await this.deactivatePlugin(plugin.pluginId);
}
}
}
}
isActive(pluginId: string): boolean {
return this.getPluginContext(pluginId) !== undefined;
}
isEnabled(pluginId: string): boolean {
return Boolean(this.flags.get(pluginId)?.get());
}
getPluginContext(pluginId: string): PluginDetails | undefined {
return this._plugins.get(pluginId);
}
async onTraceLoad(
engine: EngineBase,
beforeEach?: (id: string) => void,
): Promise<void> {
this.engine = engine;
// Shuffle the order of plugins to weed out any implicit inter-plugin
// dependencies.
const pluginsShuffled = Array.from(this._plugins.entries())
.map(([id, plugin]) => ({id, plugin, sort: Math.random()}))
.sort((a, b) => a.sort - b.sort);
// Awaiting all plugins in parallel will skew timing data as later plugins
// will spend most of their time waiting for earlier plugins to load.
// Running in parallel will have very little performance benefit assuming
// most plugins use the same engine, which can only process one query at a
// time.
for (const {id, plugin} of pluginsShuffled) {
beforeEach?.(id);
await doPluginTraceLoad(plugin, engine);
}
}
onTraceClose() {
for (const pluginDetails of this._plugins.values()) {
doPluginTraceUnload(pluginDetails);
}
this.engine = undefined;
}
metricVisualisations(): MetricVisualisation[] {
return Array.from(this._plugins.values()).flatMap((ctx) => {
const tracePlugin = ctx.plugin;
if (tracePlugin.metricVisualisations) {
return tracePlugin.metricVisualisations(ctx.context);
} else {
return [];
}
});
}
}
async function doPluginTraceLoad(
pluginDetails: PluginDetails,
engine: EngineBase,
): Promise<void> {
const {plugin, context} = pluginDetails;
const traceCtx = new PluginContextTraceImpl(context, engine);
pluginDetails.traceContext = traceCtx;
const startTime = performance.now();
const result = await Promise.resolve(plugin.onTraceLoad?.(traceCtx));
const loadTime = performance.now() - startTime;
pluginDetails.previousOnTraceLoadTimeMillis = loadTime;
raf.scheduleFullRedraw();
return result;
}
async function doPluginTraceUnload(
pluginDetails: PluginDetails,
): Promise<void> {
const {traceContext, plugin} = pluginDetails;
if (traceContext) {
plugin.onTraceUnload && (await plugin.onTraceUnload(traceContext));
traceContext[Symbol.dispose]();
pluginDetails.traceContext = undefined;
}
}
// TODO(hjd): Sort out the story for global singletons like these:
export const pluginRegistry = new PluginRegistry();
export const pluginManager = new PluginManager(pluginRegistry);