diff --git a/tensorboard/webapp/BUILD b/tensorboard/webapp/BUILD index 14d718b558..d4b5664306 100644 --- a/tensorboard/webapp/BUILD +++ b/tensorboard/webapp/BUILD @@ -38,6 +38,7 @@ ng_module( "selectors.ts", ], deps = [ + "//tensorboard/webapp/alert/store", "//tensorboard/webapp/app_routing/store", "//tensorboard/webapp/experiments/store:selectors", "//tensorboard/webapp/metrics/store", @@ -74,6 +75,8 @@ ng_module( ":mat_icon", ":oss_plugins_module", ":reducer_config", + "//tensorboard/webapp/alert", + "//tensorboard/webapp/alert/views:alert_snackbar", "//tensorboard/webapp/angular:expect_angular_platform_browser_animations", "//tensorboard/webapp/app_routing", "//tensorboard/webapp/app_routing:route_registry", @@ -106,6 +109,7 @@ ng_module( "app_state.ts", ], deps = [ + "//tensorboard/webapp/alert/store:types", "//tensorboard/webapp/app_routing/store:types", "//tensorboard/webapp/core/store", "//tensorboard/webapp/experiments/store:types", @@ -204,6 +208,9 @@ tensorboard_html_binary( tf_ng_web_test_suite( name = "karma_test", deps = [ + "//tensorboard/webapp/alert:test_lib", + "//tensorboard/webapp/alert/store:test_lib", + "//tensorboard/webapp/alert/views:views_test", "//tensorboard/webapp/app_routing:app_routing_test", "//tensorboard/webapp/app_routing:route_config_test", "//tensorboard/webapp/app_routing:testing", @@ -259,6 +266,7 @@ tf_ng_web_test_suite( "//tensorboard/webapp/plugins/text_v2/effects:effects_test_lib", "//tensorboard/webapp/plugins/text_v2/store:store_test_lib", "//tensorboard/webapp/reloader:test_lib", + "//tensorboard/webapp/routes:routes_test_lib", "//tensorboard/webapp/runs/data_source:runs_data_source_test", "//tensorboard/webapp/runs/effects:effects_test", "//tensorboard/webapp/runs/store:store_test", diff --git a/tensorboard/webapp/alert/BUILD b/tensorboard/webapp/alert/BUILD new file mode 100644 index 0000000000..d856218598 --- /dev/null +++ b/tensorboard/webapp/alert/BUILD @@ -0,0 +1,64 @@ +load("//tensorboard/defs:defs.bzl", "tf_ts_library") +load("@npm_angular_bazel//:index.bzl", "ng_module") + +package(default_visibility = ["//tensorboard:internal"]) + +ng_module( + name = "alert", + srcs = [ + "alert_module.ts", + ], + deps = [ + ":alert_action", + "//tensorboard/webapp/alert/effects", + "//tensorboard/webapp/alert/store", + "//tensorboard/webapp/alert/store:types", + "//tensorboard/webapp/alert/views:alert_snackbar", + "@npm//@angular/core", + "@npm//@ngrx/effects", + "@npm//@ngrx/store", + ], +) + +ng_module( + name = "alert_action", + srcs = [ + "alert_action_module.ts", + ], + deps = [ + ":types", + "@npm//@angular/core", + "@npm//@ngrx/store", + ], +) + +tf_ts_library( + name = "types", + srcs = [ + "types.ts", + ], +) + +tf_ts_library( + name = "test_lib", + testonly = True, + srcs = [ + "alert_action_test.ts", + ], + deps = [ + ":alert_action", + "//tensorboard/webapp:app_state", + "//tensorboard/webapp/alert/actions", + "//tensorboard/webapp/alert/effects", + "//tensorboard/webapp/alert/store", + "//tensorboard/webapp/angular:expect_angular_core_testing", + "//tensorboard/webapp/angular:expect_ngrx_store_testing", + "@npm//@angular/common", + "@npm//@angular/compiler", + "@npm//@angular/core", + "@npm//@ngrx/effects", + "@npm//@ngrx/store", + "@npm//@types/jasmine", + "@npm//rxjs", + ], +) diff --git a/tensorboard/webapp/alert/actions/BUILD b/tensorboard/webapp/alert/actions/BUILD new file mode 100644 index 0000000000..77089a4992 --- /dev/null +++ b/tensorboard/webapp/alert/actions/BUILD @@ -0,0 +1,17 @@ +load("//tensorboard/defs:defs.bzl", "tf_ts_library") + +package(default_visibility = ["//tensorboard:internal"]) + +tf_ts_library( + name = "actions", + srcs = [ + "index.ts", + ], + visibility = [ + "//tensorboard/webapp/alert:__subpackages__", + ], + deps = [ + "//tensorboard/webapp/alert:types", + "@npm//@ngrx/store", + ], +) diff --git a/tensorboard/webapp/alert/actions/index.ts b/tensorboard/webapp/alert/actions/index.ts new file mode 100644 index 0000000000..b27614ceb7 --- /dev/null +++ b/tensorboard/webapp/alert/actions/index.ts @@ -0,0 +1,28 @@ +/* Copyright 2020 The TensorFlow Authors. All Rights Reserved. + +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 {createAction, props} from '@ngrx/store'; + +import {AlertReport} from '../types'; + +/** @typehack */ import * as _typeHackModels from '@ngrx/store/src/models'; +/** @typehack */ import * as _typeHackStore from '@ngrx/store'; + +/** + * Fires when an alert is to be reported. + */ +export const alertReported = createAction( + '[Alert] Alert Reported', + props() +); diff --git a/tensorboard/webapp/alert/alert_action_module.ts b/tensorboard/webapp/alert/alert_action_module.ts new file mode 100644 index 0000000000..9acdad37f3 --- /dev/null +++ b/tensorboard/webapp/alert/alert_action_module.ts @@ -0,0 +1,119 @@ +/* Copyright 2020 The TensorFlow Authors. All Rights Reserved. + +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 { + Inject, + ModuleWithProviders, + NgModule, + Optional, + InjectionToken, +} from '@angular/core'; +import {Action, ActionCreator, Creator} from '@ngrx/store'; +import {AlertReport} from './types'; + +// While this token is not used outside, it must be exported so that stricter +// build tools may discover it during compilation. +export const ACTION_TO_ALERT_PROVIDER = new InjectionToken< + ActionToAlertConfig[] +>('[Alert] Action-To-Alert Provider'); + +export type ActionToAlertTransformer = (action: Action) => AlertReport | null; + +export interface ActionToAlertConfig { + /** + * The action to listen for. + */ + actionCreator: ActionCreator; + + /** + * A function that returns an alert report, or null, when the action is + * received. + */ + alertFromAction: ActionToAlertTransformer; +} + +/** + * An NgModule that provides alert-producing actions. These action configs are + * collected by AlertModule, which tracks application alerts. + * + * When the configured action fires, the AlertModule may respond. + * + * @NgModule({ + * imports: [ + * AlertActionModule.registerAlertActions([ + * { + * action: fetchKeysFailed, + * alertFromAction: () => {localizedMessage: "Keys failed to fetch."}, + * }, + * { + * action: greenButtonClicked, + * alertFromAction: (actionPayload) => { + * if (!actionPayload.wasButtonEnabled) { + * return {localizedMessage: "Green button failed."}; + * } + * return null; + * } + * } + * ]), + * ], + * }) + */ +@NgModule({}) +export class AlertActionModule { + /** + * Map from action creator type to transformer function. + */ + private readonly providers = new Map(); + + constructor( + @Optional() + @Inject(ACTION_TO_ALERT_PROVIDER) + providers: ActionToAlertConfig[][] + ) { + for (const configs of providers || []) { + for (const config of configs) { + if (this.providers.has(config.actionCreator.type)) { + throw new RangeError( + `"${config.actionCreator.type}" is already registered for alerts.` + + ' Multiple alerts for the same action is not allowed.' + ); + } + this.providers.set(config.actionCreator.type, config.alertFromAction); + } + } + } + + getAlertFromAction(action: Action): AlertReport | null { + const lambda = this.providers.get(action.type); + if (!lambda) { + return null; + } + return lambda(action); + } + + static registerAlertActions( + providerFactory: () => ActionToAlertConfig[] + ): ModuleWithProviders { + return { + ngModule: AlertActionModule, + providers: [ + { + provide: ACTION_TO_ALERT_PROVIDER, + multi: true, + useFactory: providerFactory, + }, + ], + }; + } +} diff --git a/tensorboard/webapp/alert/alert_action_test.ts b/tensorboard/webapp/alert/alert_action_test.ts new file mode 100644 index 0000000000..5c3d7d7e14 --- /dev/null +++ b/tensorboard/webapp/alert/alert_action_test.ts @@ -0,0 +1,87 @@ +/* Copyright 2020 The TensorFlow Authors. All Rights Reserved. + +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 {TestBed} from '@angular/core/testing'; +import {EffectsModule} from '@ngrx/effects'; +import {provideMockActions} from '@ngrx/effects/testing'; +import {Action, createAction, Store} from '@ngrx/store'; +import {MockStore, provideMockStore} from '@ngrx/store/testing'; +import {ReplaySubject} from 'rxjs'; +import {State} from '../app_state'; +import * as alertActions from './actions'; +import {AlertActionModule} from './alert_action_module'; +import {AlertEffects} from './effects'; + +const alertActionOccurred = createAction('[Test] Action Occurred (need alert)'); +const noAlertActionOccurred = createAction('[Test] Action Occurred (no alert)'); + +describe('alert_effects', () => { + let actions$: ReplaySubject; + let store: MockStore>; + let recordedActions: Action[] = []; + let shouldReportAlert: boolean; + + beforeEach(async () => { + shouldReportAlert = false; + actions$ = new ReplaySubject(1); + + await TestBed.configureTestingModule({ + imports: [ + AlertActionModule.registerAlertActions(() => [ + { + actionCreator: alertActionOccurred, + alertFromAction: (action: Action) => { + if (shouldReportAlert) { + return {localizedMessage: 'alert details'}; + } + return null; + }, + }, + ]), + EffectsModule.forFeature([AlertEffects]), + EffectsModule.forRoot([]), + ], + providers: [provideMockActions(actions$), provideMockStore({})], + }).compileComponents(); + + store = TestBed.inject>(Store) as MockStore; + recordedActions = []; + spyOn(store, 'dispatch').and.callFake((action: Action) => { + recordedActions.push(action); + }); + }); + + it(`reports an alert when 'alertFromAction' returns a report`, () => { + shouldReportAlert = true; + actions$.next(alertActionOccurred); + + expect(recordedActions).toEqual([ + alertActions.alertReported({localizedMessage: 'alert details'}), + ]); + }); + + it(`does not alert when 'alertFromAction' returns null`, () => { + shouldReportAlert = false; + actions$.next(alertActionOccurred); + + expect(recordedActions).toEqual([]); + }); + + it(`does not alert when a non-matching action is fired`, () => { + shouldReportAlert = true; + actions$.next(noAlertActionOccurred); + + expect(recordedActions).toEqual([]); + }); +}); diff --git a/tensorboard/webapp/alert/alert_module.ts b/tensorboard/webapp/alert/alert_module.ts new file mode 100644 index 0000000000..db4f5f1452 --- /dev/null +++ b/tensorboard/webapp/alert/alert_module.ts @@ -0,0 +1,32 @@ +/* Copyright 2020 The TensorFlow Authors. All Rights Reserved. + +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 {NgModule} from '@angular/core'; +import {EffectsModule} from '@ngrx/effects'; +import {StoreModule} from '@ngrx/store'; +import {AlertActionModule} from './alert_action_module'; +import {AlertEffects} from './effects'; +import {reducers} from './store'; +import {ALERT_FEATURE_KEY} from './store/alert_types'; +import {AlertSnackbarModule} from './views/alert_snackbar_module'; + +@NgModule({ + imports: [ + AlertActionModule, + AlertSnackbarModule, + StoreModule.forFeature(ALERT_FEATURE_KEY, reducers), + EffectsModule.forFeature([AlertEffects]), + ], +}) +export class AlertModule {} diff --git a/tensorboard/webapp/alert/effects/BUILD b/tensorboard/webapp/alert/effects/BUILD new file mode 100644 index 0000000000..b43a30c7bb --- /dev/null +++ b/tensorboard/webapp/alert/effects/BUILD @@ -0,0 +1,19 @@ +load("@npm_angular_bazel//:index.bzl", "ng_module") + +package(default_visibility = ["//tensorboard:internal"]) + +ng_module( + name = "effects", + srcs = [ + "index.ts", + ], + deps = [ + "//tensorboard/webapp:app_state", + "//tensorboard/webapp/alert:alert_action", + "//tensorboard/webapp/alert/actions", + "@npm//@angular/core", + "@npm//@ngrx/effects", + "@npm//@ngrx/store", + "@npm//rxjs", + ], +) diff --git a/tensorboard/webapp/alert/effects/index.ts b/tensorboard/webapp/alert/effects/index.ts new file mode 100644 index 0000000000..8c8fa38e43 --- /dev/null +++ b/tensorboard/webapp/alert/effects/index.ts @@ -0,0 +1,49 @@ +/* Copyright 2020 The TensorFlow Authors. All Rights Reserved. + +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 {Injectable} from '@angular/core'; +import {Actions, createEffect} from '@ngrx/effects'; +import {Store} from '@ngrx/store'; +import {tap} from 'rxjs/operators'; +import {State} from '../../app_state'; +import {alertReported} from '../actions'; +import {AlertActionModule} from '../alert_action_module'; + +/** @typehack */ import * as _typeHackNgrxEffects from '@ngrx/effects/effects'; +/** @typehack */ import * as _typeHackStore from '@ngrx/store'; +/** @typehack */ import * as _typeHackRxjs from 'rxjs'; + +@Injectable() +export class AlertEffects { + constructor( + private readonly actions$: Actions, + private readonly store: Store, + private readonly alertActionModule: AlertActionModule + ) {} + + /** @export */ + reportRegisteredActionAlerts$ = createEffect( + () => { + return this.actions$.pipe( + tap((action) => { + const alertInfo = this.alertActionModule.getAlertFromAction(action); + if (alertInfo) { + this.store.dispatch(alertReported(alertInfo)); + } + }) + ); + }, + {dispatch: false} + ); +} diff --git a/tensorboard/webapp/alert/store/BUILD b/tensorboard/webapp/alert/store/BUILD new file mode 100644 index 0000000000..7a13ca254e --- /dev/null +++ b/tensorboard/webapp/alert/store/BUILD @@ -0,0 +1,53 @@ +load("//tensorboard/defs:defs.bzl", "tf_ts_library") + +package(default_visibility = ["//tensorboard:internal"]) + +tf_ts_library( + name = "store", + srcs = [ + "alert_reducers.ts", + "alert_selectors.ts", + "index.ts", + ], + deps = [ + ":types", + "//tensorboard/webapp/alert:types", + "//tensorboard/webapp/alert/actions", + "@npm//@ngrx/store", + ], +) + +tf_ts_library( + name = "types", + srcs = [ + "alert_types.ts", + ], + deps = [ + "//tensorboard/webapp/alert:types", + ], +) + +tf_ts_library( + name = "testing", + testonly = True, + srcs = ["testing.ts"], + deps = [ + ":types", + ], +) + +tf_ts_library( + name = "test_lib", + testonly = True, + srcs = [ + "alert_reducers_test.ts", + "alert_selectors_test.ts", + ], + deps = [ + ":store", + ":testing", + ":types", + "//tensorboard/webapp/alert/actions", + "@npm//@types/jasmine", + ], +) diff --git a/tensorboard/webapp/alert/store/alert_reducers.ts b/tensorboard/webapp/alert/store/alert_reducers.ts new file mode 100644 index 0000000000..3585f35bad --- /dev/null +++ b/tensorboard/webapp/alert/store/alert_reducers.ts @@ -0,0 +1,40 @@ +/* Copyright 2020 The TensorFlow Authors. All Rights Reserved. + +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 {Action, createReducer, on} from '@ngrx/store'; +import * as actions from '../actions'; +import {AlertState} from './alert_types'; + +/** @typehack */ import * as _typeHackStore from '@ngrx/store/store'; + +const initialState: AlertState = { + latestAlert: null, +}; + +const reducer = createReducer( + initialState, + on( + actions.alertReported, + (state: AlertState, {localizedMessage}): AlertState => { + return { + ...state, + latestAlert: {localizedMessage, created: Date.now()}, + }; + } + ) +); + +export function reducers(state: AlertState | undefined, action: Action) { + return reducer(state, action); +} diff --git a/tensorboard/webapp/alert/store/alert_reducers_test.ts b/tensorboard/webapp/alert/store/alert_reducers_test.ts new file mode 100644 index 0000000000..ecf63a1f73 --- /dev/null +++ b/tensorboard/webapp/alert/store/alert_reducers_test.ts @@ -0,0 +1,60 @@ +/* Copyright 2020 The TensorFlow Authors. All Rights Reserved. + +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 * as alertActions from '../actions'; +import * as alertReducers from './alert_reducers'; +import {buildAlertState} from './testing'; + +describe('alert_reducers', () => { + it('saves alerts with a timestamp', () => { + spyOn(Date, 'now').and.returnValues(123, 234); + const action1 = alertActions.alertReported({ + localizedMessage: 'Foo1 failed', + }); + const action2 = alertActions.alertReported({ + localizedMessage: 'Foo2 failed', + }); + const state1 = buildAlertState({latestAlert: null}); + + const state2 = alertReducers.reducers(state1, action1); + expect(state2.latestAlert!).toEqual({ + localizedMessage: 'Foo1 failed', + created: 123, + }); + + const state3 = alertReducers.reducers(state2, action2); + expect(state3.latestAlert!).toEqual({ + localizedMessage: 'Foo2 failed', + created: 234, + }); + }); + + it('updates state with a different alert if the report is the same', () => { + const action1 = alertActions.alertReported({ + localizedMessage: 'Foo failed again', + }); + const action2 = alertActions.alertReported({ + localizedMessage: 'Foo failed again', + }); + const state1 = buildAlertState({latestAlert: null}); + + const state2 = alertReducers.reducers(state1, action1); + const state2LatestAlert = state2.latestAlert; + + const state3 = alertReducers.reducers(state2, action2); + const state3LatestAlert = state3.latestAlert; + + expect(state2LatestAlert).not.toBe(state3LatestAlert); + }); +}); diff --git a/tensorboard/webapp/alert/store/alert_selectors.ts b/tensorboard/webapp/alert/store/alert_selectors.ts new file mode 100644 index 0000000000..81a0b32da0 --- /dev/null +++ b/tensorboard/webapp/alert/store/alert_selectors.ts @@ -0,0 +1,31 @@ +/* Copyright 2020 The TensorFlow Authors. All Rights Reserved. + +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 {createSelector, createFeatureSelector} from '@ngrx/store'; +import {AlertInfo} from '../types'; +import {AlertState, State, ALERT_FEATURE_KEY} from './alert_types'; + +/** @typehack */ import * as _typeHackSelector from '@ngrx/store/src/selector'; +/** @typehack */ import * as _typeHackStore from '@ngrx/store/store'; + +const selectAlertState = createFeatureSelector( + ALERT_FEATURE_KEY +); + +export const getLatestAlert = createSelector( + selectAlertState, + (state: AlertState): AlertInfo | null => { + return state.latestAlert; + } +); diff --git a/tensorboard/webapp/alert/store/alert_selectors_test.ts b/tensorboard/webapp/alert/store/alert_selectors_test.ts new file mode 100644 index 0000000000..98265ba73a --- /dev/null +++ b/tensorboard/webapp/alert/store/alert_selectors_test.ts @@ -0,0 +1,49 @@ +/* Copyright 2020 The TensorFlow Authors. All Rights Reserved. + +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 * as selectors from './alert_selectors'; +import {buildAlertState, buildStateFromAlertState} from './testing'; + +describe('alert_selectors', () => { + describe('getAlert', () => { + beforeEach(() => { + // Clear the memoization. + selectors.getLatestAlert.release(); + }); + + it('returns null when there is no alert', () => { + const state = buildStateFromAlertState( + buildAlertState({ + latestAlert: null, + }) + ); + expect(selectors.getLatestAlert(state)).toBe(null); + }); + + it('returns the current alert', () => { + const state = buildStateFromAlertState( + buildAlertState({ + latestAlert: { + localizedMessage: 'The sky is orange', + created: 2020, + }, + }) + ); + expect(selectors.getLatestAlert(state)).toEqual({ + localizedMessage: 'The sky is orange', + created: 2020, + }); + }); + }); +}); diff --git a/tensorboard/webapp/alert/store/alert_types.ts b/tensorboard/webapp/alert/store/alert_types.ts new file mode 100644 index 0000000000..b36fe38288 --- /dev/null +++ b/tensorboard/webapp/alert/store/alert_types.ts @@ -0,0 +1,25 @@ +/* Copyright 2019 The TensorFlow Authors. All Rights Reserved. + +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 {AlertInfo} from '../types'; + +export const ALERT_FEATURE_KEY = 'alerts'; + +export interface AlertState { + latestAlert: AlertInfo | null; +} + +export interface State { + [ALERT_FEATURE_KEY]?: AlertState; +} diff --git a/tensorboard/webapp/alert/store/index.ts b/tensorboard/webapp/alert/store/index.ts new file mode 100644 index 0000000000..b0cb37baaa --- /dev/null +++ b/tensorboard/webapp/alert/store/index.ts @@ -0,0 +1,18 @@ +/* Copyright 2020 The TensorFlow Authors. All Rights Reserved. + +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. +==============================================================================*/ + +export * from './alert_reducers'; +export * from './alert_selectors'; +export {State} from './alert_types'; diff --git a/tensorboard/webapp/alert/store/testing.ts b/tensorboard/webapp/alert/store/testing.ts new file mode 100644 index 0000000000..cf3cb8e159 --- /dev/null +++ b/tensorboard/webapp/alert/store/testing.ts @@ -0,0 +1,26 @@ +/* Copyright 2020 The TensorFlow Authors. All Rights Reserved. + +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 {ALERT_FEATURE_KEY, AlertState, State} from './alert_types'; + +export function buildAlertState(override: Partial): AlertState { + return { + latestAlert: null, + ...override, + }; +} + +export function buildStateFromAlertState(runsState: AlertState): State { + return {[ALERT_FEATURE_KEY]: runsState}; +} diff --git a/tensorboard/webapp/alert/types.ts b/tensorboard/webapp/alert/types.ts new file mode 100644 index 0000000000..013d616717 --- /dev/null +++ b/tensorboard/webapp/alert/types.ts @@ -0,0 +1,25 @@ +/* Copyright 2020 The TensorFlow Authors. All Rights Reserved. + +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. +==============================================================================*/ +/** + * An alert structure used when creating newly reported alerts. + */ +export interface AlertReport { + localizedMessage: string; +} + +/** + * An alert exposed by the feature's selectors. + */ +export type AlertInfo = AlertReport & {created: number}; diff --git a/tensorboard/webapp/alert/views/BUILD b/tensorboard/webapp/alert/views/BUILD new file mode 100644 index 0000000000..3e4b92cfb2 --- /dev/null +++ b/tensorboard/webapp/alert/views/BUILD @@ -0,0 +1,44 @@ +load("@npm_angular_bazel//:index.bzl", "ng_module") +load("//tensorboard/defs:defs.bzl", "tf_ts_library") + +package(default_visibility = ["//tensorboard:internal"]) + +ng_module( + name = "alert_snackbar", + srcs = [ + "alert_snackbar_container.ts", + "alert_snackbar_module.ts", + ], + deps = [ + "//tensorboard/webapp:app_state", + "//tensorboard/webapp:selectors", + "//tensorboard/webapp/alert/store", + "//tensorboard/webapp/angular:expect_angular_material_snackbar", + "@npm//@angular/common", + "@npm//@angular/core", + "@npm//@ngrx/store", + "@npm//rxjs", + ], +) + +tf_ts_library( + name = "views_test", + testonly = True, + srcs = [ + "alert_snackbar_test.ts", + ], + deps = [ + ":alert_snackbar", + "//tensorboard/webapp:selectors", + "//tensorboard/webapp/alert/store", + "//tensorboard/webapp/alert/store:testing", + "//tensorboard/webapp/angular:expect_angular_core_testing", + "//tensorboard/webapp/angular:expect_angular_material_snackbar", + "//tensorboard/webapp/angular:expect_angular_platform_browser_animations", + "//tensorboard/webapp/angular:expect_ngrx_store_testing", + "@npm//@angular/core", + "@npm//@angular/platform-browser", + "@npm//@ngrx/store", + "@npm//@types/jasmine", + ], +) diff --git a/tensorboard/webapp/alert/views/alert_snackbar_container.ts b/tensorboard/webapp/alert/views/alert_snackbar_container.ts new file mode 100644 index 0000000000..8988572e51 --- /dev/null +++ b/tensorboard/webapp/alert/views/alert_snackbar_container.ts @@ -0,0 +1,69 @@ +/* Copyright 2020 The TensorFlow Authors. All Rights Reserved. + +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 { + ChangeDetectionStrategy, + Component, + OnDestroy, + OnInit, +} from '@angular/core'; +import {MatSnackBar} from '@angular/material/snack-bar'; +import {Store} from '@ngrx/store'; +import {Subject} from 'rxjs'; +import {filter, takeUntil} from 'rxjs/operators'; + +import {State} from '../../app_state'; +import {getLatestAlert} from '../../selectors'; + +/** + * Renders alerts in a 'snackbar' to indicate them to the user. + */ +@Component({ + selector: 'alert-snackbar', + template: '', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class AlertSnackbarContainer implements OnInit, OnDestroy { + private readonly ngUnsubscribe = new Subject(); + + constructor( + private readonly store: Store, + private readonly snackBar: MatSnackBar + ) {} + + ngOnInit() { + this.store + .select(getLatestAlert) + .pipe( + takeUntil(this.ngUnsubscribe), + filter((alert) => Boolean(alert)) + ) + .subscribe((alert) => { + this.showAlert(alert!.localizedMessage); + }); + } + + ngOnDestroy() { + this.ngUnsubscribe.next(); + this.ngUnsubscribe.complete(); + } + + private showAlert(localizedMessage: string) { + this.snackBar.open(localizedMessage, '', { + duration: 5000, + horizontalPosition: 'start', + verticalPosition: 'bottom', + }); + } +} diff --git a/tensorboard/webapp/alert/views/alert_snackbar_module.ts b/tensorboard/webapp/alert/views/alert_snackbar_module.ts new file mode 100644 index 0000000000..3b846b69f8 --- /dev/null +++ b/tensorboard/webapp/alert/views/alert_snackbar_module.ts @@ -0,0 +1,29 @@ +/* Copyright 2020 The TensorFlow Authors. All Rights Reserved. + +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 {NgModule} from '@angular/core'; +import {CommonModule} from '@angular/common'; +import {MatSnackBarModule} from '@angular/material/snack-bar'; + +import {AlertSnackbarContainer} from './alert_snackbar_container'; + +/** + * Provides the 'alert snackbar' view. + */ +@NgModule({ + declarations: [AlertSnackbarContainer], + exports: [AlertSnackbarContainer], + imports: [CommonModule, MatSnackBarModule], +}) +export class AlertSnackbarModule {} diff --git a/tensorboard/webapp/alert/views/alert_snackbar_test.ts b/tensorboard/webapp/alert/views/alert_snackbar_test.ts new file mode 100644 index 0000000000..2f03e905e9 --- /dev/null +++ b/tensorboard/webapp/alert/views/alert_snackbar_test.ts @@ -0,0 +1,96 @@ +/* Copyright 2020 The TensorFlow Authors. All Rights Reserved. + +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 {TestBed} from '@angular/core/testing'; +import {MatSnackBar} from '@angular/material/snack-bar'; +import {NoopAnimationsModule} from '@angular/platform-browser/animations'; +import {Store} from '@ngrx/store'; +import {provideMockStore, MockStore} from '@ngrx/store/testing'; +import {AlertSnackbarContainer} from './alert_snackbar_container'; +import {State} from '../store'; +import * as selectors from '../../selectors'; +import {buildStateFromAlertState, buildAlertState} from '../store/testing'; + +describe('alert snackbar', () => { + let store: MockStore; + let snackBarOpenSpy: jasmine.Spy; + + beforeEach(async () => { + snackBarOpenSpy = jasmine.createSpy(); + await TestBed.configureTestingModule({ + imports: [NoopAnimationsModule], + providers: [ + provideMockStore({ + initialState: buildStateFromAlertState(buildAlertState({})), + }), + { + provide: MatSnackBar, + useValue: { + open: snackBarOpenSpy, + }, + }, + ], + declarations: [AlertSnackbarContainer], + }).compileComponents(); + store = TestBed.inject>(Store) as MockStore; + }); + + it('opens the snackbar on each alert', () => { + const fixture = TestBed.createComponent(AlertSnackbarContainer); + fixture.detectChanges(); + expect(snackBarOpenSpy).not.toHaveBeenCalled(); + + store.overrideSelector(selectors.getLatestAlert, { + localizedMessage: 'Foo failed', + created: 0, + }); + store.refreshState(); + + expect(snackBarOpenSpy.calls.count()).toBe(1); + expect(snackBarOpenSpy.calls.mostRecent().args[0]).toBe('Foo failed'); + + store.overrideSelector(selectors.getLatestAlert, { + localizedMessage: 'Foo2 failed', + created: 1, + }); + store.refreshState(); + + expect(snackBarOpenSpy.calls.count()).toBe(2); + expect(snackBarOpenSpy.calls.mostRecent().args[0]).toBe('Foo2 failed'); + }); + + it('opens the snackbar again on receiving the same alert', () => { + const fixture = TestBed.createComponent(AlertSnackbarContainer); + fixture.detectChanges(); + expect(snackBarOpenSpy).not.toHaveBeenCalled(); + + store.overrideSelector(selectors.getLatestAlert, { + localizedMessage: 'Foo failed again', + created: 0, + }); + store.refreshState(); + + expect(snackBarOpenSpy.calls.count()).toBe(1); + expect(snackBarOpenSpy.calls.mostRecent().args[0]).toBe('Foo failed again'); + + store.overrideSelector(selectors.getLatestAlert, { + localizedMessage: 'Foo failed again', + created: 1, + }); + store.refreshState(); + + expect(snackBarOpenSpy.calls.count()).toBe(2); + expect(snackBarOpenSpy.calls.mostRecent().args[0]).toBe('Foo failed again'); + }); +}); diff --git a/tensorboard/webapp/angular/BUILD b/tensorboard/webapp/angular/BUILD index 0d87e58359..35bc6f26f8 100644 --- a/tensorboard/webapp/angular/BUILD +++ b/tensorboard/webapp/angular/BUILD @@ -184,6 +184,15 @@ tf_ts_library( ], ) +# This is a dummy rule used as a @angular/material/snackbar dependency. +tf_ts_library( + name = "expect_angular_material_snackbar", + srcs = [], + deps = [ + "@npm//@angular/material", + ], +) + # This is a dummy rule used as a @angular/material/sort dependency. tf_ts_library( name = "expect_angular_material_sort", diff --git a/tensorboard/webapp/app_container.ng.html b/tensorboard/webapp/app_container.ng.html index 2843beebbd..3cbceef280 100644 --- a/tensorboard/webapp/app_container.ng.html +++ b/tensorboard/webapp/app_container.ng.html @@ -19,6 +19,7 @@
+ diff --git a/tensorboard/webapp/app_module.ts b/tensorboard/webapp/app_module.ts index 9759ce288b..7a39867263 100644 --- a/tensorboard/webapp/app_module.ts +++ b/tensorboard/webapp/app_module.ts @@ -23,6 +23,8 @@ import {AppRoutingModule} from './app_routing/app_routing_module'; import {AppRoutingViewModule} from './app_routing/views/app_routing_view_module'; import {CoreModule} from './core/core_module'; import {ExperimentsModule} from './experiments/experiments_module'; +import {AlertModule} from './alert/alert_module'; +import {AlertSnackbarModule} from './alert/views/alert_snackbar_module'; import {HashStorageModule} from './core/views/hash_storage_module'; import {PageTitleModule} from './core/views/page_title_module'; import {FeatureFlagModule} from './feature_flag/feature_flag_module'; @@ -30,7 +32,6 @@ import {HeaderModule} from './header/header_module'; import {MatIconModule} from './mat_icon_module'; import {PluginsModule} from './plugins/plugins_module'; import {ROOT_REDUCERS, loggerMetaReducerFactory} from './reducer_config'; -import {ReloaderModule} from './reloader/reloader_module'; import {RunsModule} from './runs/runs_module'; import {SettingsModule} from './settings/settings_module'; import {TensorBoardWrapperModule} from './tb_wrapper/tb_wrapper_module'; @@ -49,6 +50,8 @@ import {routesFactory} from './routes'; AppRoutingModule, AppRoutingViewModule, RouteRegistryModule.registerRoutes(routesFactory), + AlertModule, + AlertSnackbarModule, TensorBoardWrapperModule, CoreModule, ExperimentsModule, diff --git a/tensorboard/webapp/app_state.ts b/tensorboard/webapp/app_state.ts index df3f31f72b..97b3c807e1 100644 --- a/tensorboard/webapp/app_state.ts +++ b/tensorboard/webapp/app_state.ts @@ -13,6 +13,7 @@ See the License for the specific language governing permissions and limitations under the License. ==============================================================================*/ +import {State as AlertState} from './alert/store/alert_types'; import {State as AppRoutingState} from './app_routing/store/app_routing_types'; import {State as CoreState} from './core/store/core_types'; import {State as ExperimentsState} from './experiments/store/experiments_types'; @@ -22,7 +23,8 @@ import {State as NpmiState} from './plugins/npmi/store/npmi_types'; import {State as RunsState} from './runs/store/runs_types'; import {State as TextState} from './plugins/text_v2/store/text_types'; -export type State = AppRoutingState & +export type State = AlertState & + AppRoutingState & CoreState & ExperimentsState & FeatureFlagState & diff --git a/tensorboard/webapp/metrics/store/metrics_selectors.ts b/tensorboard/webapp/metrics/store/metrics_selectors.ts index cbb82afeaf..a33c4e4a77 100644 --- a/tensorboard/webapp/metrics/store/metrics_selectors.ts +++ b/tensorboard/webapp/metrics/store/metrics_selectors.ts @@ -20,6 +20,7 @@ import {DeepReadonly} from '../../util/types'; import { CardId, CardIdWithMetadata, + CardUniqueInfo, CardMetadata, HistogramMode, NonPinnedCardId, @@ -238,6 +239,13 @@ export const getCardPinnedState = createSelector( } ); +export const getUnresolvedImportedPinnedCards = createSelector( + selectMetricsState, + (state: MetricsState): CardUniqueInfo[] => { + return state.unresolvedImportedPinnedCards; + } +); + /** * Settings. */ diff --git a/tensorboard/webapp/metrics/store/metrics_selectors_test.ts b/tensorboard/webapp/metrics/store/metrics_selectors_test.ts index f22a959091..2095d710d3 100644 --- a/tensorboard/webapp/metrics/store/metrics_selectors_test.ts +++ b/tensorboard/webapp/metrics/store/metrics_selectors_test.ts @@ -512,6 +512,35 @@ describe('metrics selectors', () => { }); }); + describe('getUnresolvedImportedPinnedCards', () => { + it('returns unresolved imported pinned cards', () => { + selectors.getUnresolvedImportedPinnedCards.release(); + + const state = appStateFromMetricsState( + buildMetricsState({ + unresolvedImportedPinnedCards: [ + {plugin: PluginType.SCALARS, tag: 'accuracy'}, + { + plugin: PluginType.IMAGES, + tag: 'output', + runId: 'exp1/run1', + sample: 5, + }, + ], + }) + ); + expect(selectors.getUnresolvedImportedPinnedCards(state)).toEqual([ + {plugin: PluginType.SCALARS, tag: 'accuracy'}, + { + plugin: PluginType.IMAGES, + tag: 'output', + runId: 'exp1/run1', + sample: 5, + }, + ]); + }); + }); + describe('settings', () => { it('returns tooltipSort when called getMetricsTooltipSort', () => { selectors.getMetricsTooltipSort.release(); diff --git a/tensorboard/webapp/routes/BUILD b/tensorboard/webapp/routes/BUILD index 0a18dbc776..18935709a9 100644 --- a/tensorboard/webapp/routes/BUILD +++ b/tensorboard/webapp/routes/BUILD @@ -10,9 +10,53 @@ tf_ts_library( "index.ts", ], deps = [ + ":core_deeplink_provider", "//tensorboard/webapp/app_routing:route_config", "//tensorboard/webapp/app_routing:types", "//tensorboard/webapp/tb_wrapper", "@npm//@angular/core", ], ) + +tf_ts_library( + name = "core_deeplink_provider", + srcs = [ + "core_deeplink_provider.ts", + ], + deps = [ + "//tensorboard/webapp:app_state", + "//tensorboard/webapp:selectors", + "//tensorboard/webapp/app_routing:deep_link_provider", + "//tensorboard/webapp/app_routing:route_config", + "//tensorboard/webapp/app_routing:types", + "//tensorboard/webapp/metrics:types", + "//tensorboard/webapp/metrics/data_source:types", + "//tensorboard/webapp/tb_wrapper", + "@npm//@angular/core", + "@npm//@ngrx/store", + "@npm//rxjs", + ], +) + +tf_ts_library( + name = "routes_test_lib", + testonly = True, + srcs = [ + "core_deeplink_provider_test.ts", + ], + deps = [ + ":core_deeplink_provider", + "//tensorboard/webapp:app_state", + "//tensorboard/webapp:selectors", + "//tensorboard/webapp/angular:expect_angular_core_testing", + "//tensorboard/webapp/angular:expect_ngrx_store_testing", + "//tensorboard/webapp/app_routing:deep_link_provider", + "//tensorboard/webapp/app_routing:types", + "//tensorboard/webapp/metrics:test_lib", + "//tensorboard/webapp/metrics/data_source:types", + "@npm//@angular/core", + "@npm//@ngrx/store", + "@npm//@types/jasmine", + "@npm//rxjs", + ], +) diff --git a/tensorboard/webapp/routes/core_deeplink_provider.ts b/tensorboard/webapp/routes/core_deeplink_provider.ts new file mode 100644 index 0000000000..374720042b --- /dev/null +++ b/tensorboard/webapp/routes/core_deeplink_provider.ts @@ -0,0 +1,171 @@ +/* Copyright 2020 The TensorFlow Authors. All Rights Reserved. + +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 {Injectable} from '@angular/core'; +import {Store} from '@ngrx/store'; +import {DeepLinkProvider} from '../app_routing/deep_link_provider'; +import {SerializableQueryParams} from '../app_routing/types'; +import { + CardUniqueInfo, + URLDeserializedState as MetricsURLDeserializedState, +} from '../metrics/types'; +import { + isSampledPlugin, + isSingleRunPlugin, + isPluginType, +} from '../metrics/data_source/types'; +import {combineLatest, Observable} from 'rxjs'; +import {map} from 'rxjs/operators'; + +import {State} from '../app_state'; +import * as selectors from '../selectors'; + +export type DeserializedState = MetricsURLDeserializedState; + +/** + * Provides deeplinking for the core dashboards page. + */ +@Injectable() +export class CoreDeepLinkProvider extends DeepLinkProvider { + private getMetricsPinnedCards( + store: Store + ): Observable { + return combineLatest([ + store.select(selectors.getPinnedCardsWithMetadata), + store.select(selectors.getUnresolvedImportedPinnedCards), + ]).pipe( + map(([pinnedCards, unresolvedImportedPinnedCards]) => { + if (!pinnedCards.length && !unresolvedImportedPinnedCards.length) { + return []; + } + + const pinnedCardsToStore = pinnedCards.map( + ({plugin, tag, sample, runId}) => { + const info = {plugin, tag} as CardUniqueInfo; + if (isSingleRunPlugin(plugin)) { + info.runId = runId!; + } + if (isSampledPlugin(plugin)) { + info.sample = sample!; + } + return info; + } + ); + // Intentionally order unresolved cards last, so that cards pinned by + // the user in this session have priority. + const cardsToStore = [ + ...pinnedCardsToStore, + ...unresolvedImportedPinnedCards, + ]; + return [{key: 'pinnedCards', value: JSON.stringify(cardsToStore)}]; + }) + ); + } + + serializeStateToQueryParams( + store: Store + ): Observable { + return this.getMetricsPinnedCards(store); + } + + deserializeQueryParams( + queryParams: SerializableQueryParams + ): DeserializedState { + let pinnedCards = null; + for (const {key, value} of queryParams) { + if (key === 'pinnedCards') { + pinnedCards = extractPinnedCardsFromURLText(value); + break; + } + } + return { + metrics: { + pinnedCards: pinnedCards || [], + }, + }; + } +} + +function extractPinnedCardsFromURLText( + urlText: string +): CardUniqueInfo[] | null { + // Check that the URL text parses. + let object; + try { + object = JSON.parse(urlText) as unknown; + } catch { + return null; + } + if (!Array.isArray(object)) { + return null; + } + + const result = []; + for (const item of object) { + // Validate types. + const isPluginString = typeof item.plugin === 'string'; + const isRunString = typeof item.runId === 'string'; + const isSampleNumber = typeof item.sample === 'number'; + const isTagString = typeof item.tag === 'string'; + const isRunTypeValid = isRunString || typeof item.runId === 'undefined'; + const isSampleTypeValid = + isSampleNumber || typeof item.sample === 'undefined'; + if ( + !isPluginString || + !isTagString || + !isRunTypeValid || + !isSampleTypeValid + ) { + continue; + } + + // Required fields and range errors. + if (!isPluginType(item.plugin)) { + continue; + } + if (!item.tag) { + continue; + } + if (isSingleRunPlugin(item.plugin)) { + // A single run plugin must specify a non-empty run. + if (!item.runId) { + continue; + } + } else { + // A multi run plugin must not specify a run. + if (item.runId) { + continue; + } + } + if (isSampleNumber) { + if (!isSampledPlugin(item.plugin)) { + continue; + } + if (!Number.isInteger(item.sample) || item.sample < 0) { + continue; + } + } + + // Assemble result. + const resultItem = {plugin: item.plugin, tag: item.tag} as CardUniqueInfo; + if (isRunString) { + resultItem.runId = item.runId; + } + if (isSampleNumber) { + resultItem.sample = item.sample; + } + result.push(resultItem); + } + return result; +} diff --git a/tensorboard/webapp/routes/core_deeplink_provider_test.ts b/tensorboard/webapp/routes/core_deeplink_provider_test.ts new file mode 100644 index 0000000000..a11aa25f46 --- /dev/null +++ b/tensorboard/webapp/routes/core_deeplink_provider_test.ts @@ -0,0 +1,233 @@ +/* Copyright 2020 The TensorFlow Authors. All Rights Reserved. + +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 {TestBed} from '@angular/core/testing'; +import {Store} from '@ngrx/store'; +import {MockStore, provideMockStore} from '@ngrx/store/testing'; +import {skip} from 'rxjs/operators'; + +import * as selectors from '../selectors'; +import {DeepLinkProvider} from '../app_routing/deep_link_provider'; +import {SerializableQueryParams} from '../app_routing/types'; +import {State} from '../app_state'; +import {appStateFromMetricsState, buildMetricsState} from '../metrics/testing'; +import {PluginType} from '../metrics/data_source/types'; +import {CoreDeepLinkProvider} from './core_deeplink_provider'; + +describe('core deeplink provider', () => { + let store: MockStore; + let provider: DeepLinkProvider; + let queryParamsSerialized: SerializableQueryParams[]; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + providers: [ + provideMockStore({ + initialState: { + ...appStateFromMetricsState(buildMetricsState()), + }, + }), + ], + }).compileComponents(); + + store = TestBed.inject>(Store) as MockStore; + queryParamsSerialized = []; + + provider = new CoreDeepLinkProvider(); + provider + .serializeStateToQueryParams(store) + .pipe( + // Skip the initial bootstrap. + skip(1) + ) + .subscribe((queryParams) => { + queryParamsSerialized.push(queryParams); + }); + }); + + describe('time series', () => { + it('serializes pinned card state when store updates', () => { + store.overrideSelector(selectors.getPinnedCardsWithMetadata, [ + { + cardId: 'card1', + plugin: PluginType.SCALARS, + tag: 'accuracy', + runId: null, + }, + ]); + store.overrideSelector(selectors.getUnresolvedImportedPinnedCards, [ + { + plugin: PluginType.SCALARS, + tag: 'loss', + }, + ]); + store.refreshState(); + + expect(queryParamsSerialized[queryParamsSerialized.length - 1]).toEqual([ + { + key: 'pinnedCards', + value: + '[{"plugin":"scalars","tag":"accuracy"},{"plugin":"scalars","tag":"loss"}]', + }, + ]); + + store.overrideSelector(selectors.getPinnedCardsWithMetadata, [ + { + cardId: 'card1', + plugin: PluginType.SCALARS, + tag: 'accuracy2', + runId: null, + }, + ]); + store.overrideSelector(selectors.getUnresolvedImportedPinnedCards, [ + { + plugin: PluginType.SCALARS, + tag: 'loss2', + }, + ]); + store.refreshState(); + + expect(queryParamsSerialized[queryParamsSerialized.length - 1]).toEqual([ + { + key: 'pinnedCards', + value: + '[{"plugin":"scalars","tag":"accuracy2"},{"plugin":"scalars","tag":"loss2"}]', + }, + ]); + }); + + it('serializes nothing when states are empty', () => { + store.overrideSelector(selectors.getPinnedCardsWithMetadata, []); + store.overrideSelector(selectors.getUnresolvedImportedPinnedCards, []); + store.refreshState(); + + expect(queryParamsSerialized[queryParamsSerialized.length - 1]).toEqual( + [] + ); + }); + + it('deserializes empty pinned cards', () => { + const state = provider.deserializeQueryParams([]); + + expect(state).toEqual({metrics: {pinnedCards: []}}); + }); + + it('deserializes valid pinned cards', () => { + const state = provider.deserializeQueryParams([ + { + key: 'pinnedCards', + value: + '[{"plugin":"scalars","tag":"accuracy"},{"plugin":"images","tag":"loss","runId":"exp1/123","sample":5}]', + }, + ]); + + expect(state).toEqual({ + metrics: { + pinnedCards: [ + {plugin: PluginType.SCALARS, tag: 'accuracy'}, + { + plugin: PluginType.IMAGES, + tag: 'loss', + runId: 'exp1/123', + sample: 5, + }, + ], + }, + }); + }); + + it('sanitizes pinned cards on deserialization', () => { + const cases = [ + { + // malformed URL value + serializedValue: 'blah[{"plugin":"scalars","tag":"accuracy"}]', + expectedPinnedCards: [], + }, + { + // no plugin + serializedValue: + '[{"tag":"loss"},{"plugin":"scalars","tag":"default"}]', + expectedPinnedCards: [{plugin: PluginType.SCALARS, tag: 'default'}], + }, + { + // unknown plugin + serializedValue: + '[{"plugin":"unknown","tag":"loss"},{"plugin":"scalars","tag":"default"}]', + expectedPinnedCards: [{plugin: PluginType.SCALARS, tag: 'default'}], + }, + { + // tag is not a string + serializedValue: + '[{"plugin":"scalars","tag":5},{"plugin":"scalars","tag":"default"}]', + expectedPinnedCards: [{plugin: PluginType.SCALARS, tag: 'default'}], + }, + { + // tag is empty + serializedValue: + '[{"plugin":"scalars","tag":""},{"plugin":"scalars","tag":"default"}]', + expectedPinnedCards: [{plugin: PluginType.SCALARS, tag: 'default'}], + }, + { + // runId is not a string + serializedValue: + '[{"plugin":"images","tag":"loss","runId":123},{"plugin":"scalars","tag":"default"}]', + expectedPinnedCards: [{plugin: PluginType.SCALARS, tag: 'default'}], + }, + { + // runId is empty + serializedValue: + '[{"plugin":"images","tag":"loss","runId":""},{"plugin":"scalars","tag":"default"}]', + expectedPinnedCards: [{plugin: PluginType.SCALARS, tag: 'default'}], + }, + { + // runId provided with multi-run plugin + serializedValue: + '[{"plugin":"scalars","tag":"loss","runId":"123"},{"plugin":"scalars","tag":"default"}]', + expectedPinnedCards: [{plugin: PluginType.SCALARS, tag: 'default'}], + }, + { + // sample provided with non-sampled plugin + serializedValue: + '[{"plugin":"scalars","tag":"loss","sample":5},{"plugin":"scalars","tag":"default"}]', + expectedPinnedCards: [{plugin: PluginType.SCALARS, tag: 'default'}], + }, + { + // sample is not a number + serializedValue: + '[{"plugin":"images","tag":"loss","sample":"5"},{"plugin":"scalars","tag":"default"}]', + expectedPinnedCards: [{plugin: PluginType.SCALARS, tag: 'default'}], + }, + { + // sample is not an integer + serializedValue: + '[{"plugin":"images","tag":"loss","sample":5.5},{"plugin":"scalars","tag":"default"}]', + expectedPinnedCards: [{plugin: PluginType.SCALARS, tag: 'default'}], + }, + { + // sample is negative + serializedValue: + '[{"plugin":"images","tag":"loss","sample":-5},{"plugin":"scalars","tag":"default"}]', + expectedPinnedCards: [{plugin: PluginType.SCALARS, tag: 'default'}], + }, + ]; + for (const {serializedValue, expectedPinnedCards} of cases) { + const state = provider.deserializeQueryParams([ + {key: 'pinnedCards', value: serializedValue}, + ]); + + expect(state).toEqual({metrics: {pinnedCards: expectedPinnedCards}}); + } + }); + }); +}); diff --git a/tensorboard/webapp/routes/index.ts b/tensorboard/webapp/routes/index.ts index d19f868816..cc087a27e1 100644 --- a/tensorboard/webapp/routes/index.ts +++ b/tensorboard/webapp/routes/index.ts @@ -17,6 +17,7 @@ import {Component, Type} from '@angular/core'; import {TensorBoardWrapperComponent} from '../tb_wrapper/tb_wrapper_component'; import {RouteDef} from '../app_routing/route_config_types'; import {RouteKind} from '../app_routing/types'; +import {CoreDeepLinkProvider} from './core_deeplink_provider'; export function routesFactory(): RouteDef[] { return [ @@ -25,6 +26,7 @@ export function routesFactory(): RouteDef[] { path: '/', ngComponent: TensorBoardWrapperComponent as Type, defaultRoute: true, + deepLinkProvider: new CoreDeepLinkProvider(), }, ]; } diff --git a/tensorboard/webapp/selectors.ts b/tensorboard/webapp/selectors.ts index 7205b57107..b707091e71 100644 --- a/tensorboard/webapp/selectors.ts +++ b/tensorboard/webapp/selectors.ts @@ -14,6 +14,7 @@ limitations under the License. ==============================================================================*/ export * from './app_routing/store/app_routing_selectors'; export * from './experiments/store/experiments_selectors'; +export * from './alert/store/alert_selectors'; export * from './metrics/store/metrics_selectors'; export * from './runs/store/runs_selectors'; export * from './util/ui_selectors';