diff --git a/.env b/.env index 17934f5..807e3ca 100644 --- a/.env +++ b/.env @@ -14,8 +14,8 @@ REACT_APP_KEYCLOAK_CLIENT_ID=cate-webui # REACT_APP_MAX_NUM_USERS=50 # TODO: document me -# REACT_APP_CATEHUB_ENDPOINT=https://stage.catehub.brockmann-consult.de -REACT_APP_CATEHUB_ENDPOINT=http://localhost:8000 -REACT_APP_CATEHUB_WEBAPI_MANAG_PATH=/user/{username}/webapi -REACT_APP_CATEHUB_WEBAPI_CLOSE_PATH=/user/{username}/webapi/shutdown -REACT_APP_CATEHUB_WEBAPI_COUNT_PATH=/webapi/count \ No newline at end of file +REACT_APP_CATEHUB_ENDPOINT=https://stage.catehub.climate.esa.int/api/v2 +#REACT_APP_CATEHUB_ENDPOINT=http://localhost:8080/api/v2 +REACT_APP_CATEHUB_WEBAPI_MANAG_PATH=/users/{username}/webapis +REACT_APP_CATEHUB_WEBAPI_CLOSE_PATH=/users/{username}/webapis +REACT_APP_CATEHUB_WEBAPI_COUNT_PATH=/webapis \ No newline at end of file diff --git a/CHANGES.md b/CHANGES.md index d0dcf45..a77ce21 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,3 +1,30 @@ +### Changes 3.0 (in development) + +* Cate App 3.0 now requires the Web API service of the `cate 3.0+` + Python package. + +* In order to keep alive the connection to the Web API service, + Cate App now sends a keepalive signal every 2.5 seconds. (#150) + +* Optimisations in the DATA SOURCES panel (that have been enabled by + using [xcube](https://xcube.readthedocs.io/) in the backend): + - Initalising the "CCI Open Data Portal" data store + is now accellerated by a magnitude. + - Local caching of remote data sources when opening datasets + is now much faster and more reliable. + - Added new experimental store "CCI Zarr Store" that offers + selected CCI datasets that have been converted to Zarr format + and are read from JASMIN object storage. + - Ability to add more data stores has been greatly improved. + +* We now obtain Cate Hub's status information from a dedicated GitHub + repository [cate-status](https://github.com/CCI-Tools/cate-status). + +* Adapted to changed cate-hub API. (An API response no longer has + `status` and `result` properties, instead a response _is_ the result + and the response status is represented by the HTTP response code.) + + ### Changes 2.2.3 * Fixed a problem that prevented using Matomo Analytics service. diff --git a/Dockerfile b/Dockerfile index 461f6b6..1a8232c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,7 +2,7 @@ FROM node:stretch-slim as build-deps LABEL maintainer="helge.dzierzon@brockmann-consult.de" LABEL name="Cate App" -LABEL version="2.2.3" +LABEL version="3.0.0-dev.0" RUN apt-get -y update && apt-get install -y git apt-utils wget vim diff --git a/appveyor.yml b/appveyor.yml index 13020f5..50c3f78 100644 --- a/appveyor.yml +++ b/appveyor.yml @@ -1,4 +1,4 @@ -version: 2.2.3-{build} +version: 3.0.0-dev.0-{build} image: Ubuntu stack: node 12 install: diff --git a/package.json b/package.json index c6f109f..3761057 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "cate-app", - "version": "2.2.3", + "version": "3.0.0-dev.0", "private": true, "dependencies": { "@blueprintjs/core": "^3.30.1", @@ -50,6 +50,7 @@ "@types/dom4": "^1.5.20", "@types/geojson": "^1.0.6", "@types/jest": "^24.0.0", + "@types/json-schema": "^7.0.7", "@types/mocha": "^2.2.41", "@types/node": "^12.0.0", "@types/oboe": "^2.0.28", diff --git a/src/renderer/actions.spec.ts b/src/renderer/actions.spec.ts index 0e9debc..5c000a0 100644 --- a/src/renderer/actions.spec.ts +++ b/src/renderer/actions.spec.ts @@ -122,7 +122,7 @@ describe('Actions', () => { ]); }); - it('updateDataSourceTemporalCoverage', () => { + it('updateDataSourceMetaInfo', () => { dispatch(actions.updateDataStores( [ {id: 'local-1'}, @@ -133,9 +133,10 @@ describe('Actions', () => { {id: 'fileset-1'}, {id: 'fileset-2'} ] as any)); - dispatch(actions.updateDataSourceTemporalCoverage('local-2', - 'fileset-1', - ['2010-01-01', '2014-12-30'])); + dispatch(actions.updateDataSourceMetaInfo('local-2', + 'fileset-1', + {'data_id': 'x', 'type_specifier': 'y'}, + 'ok')); expect(getState().data.dataStores).to.deep.equal( [ {id: 'local-1'}, @@ -143,7 +144,8 @@ describe('Actions', () => { id: 'local-2', dataSources: [ { id: 'fileset-1', - temporalCoverage: ['2010-01-01', '2014-12-30'] + metaInfo: {'data_id': 'x', 'type_specifier': 'y'}, + metaInfoStatus: 'ok', }, {id: 'fileset-2'} ] diff --git a/src/renderer/actions.ts b/src/renderer/actions.ts index be19198..56b5165 100644 --- a/src/renderer/actions.ts +++ b/src/renderer/actions.ts @@ -41,10 +41,11 @@ import * as selectors from './selectors'; import { BackendConfigState, ColorMapCategoryState, - ControlState, + ControlState, DatasetDescriptor, DataSourceState, DataStoreState, - GeographicPosition, + GeographicPosition, + HubStatus, ImageStatisticsState, LayerState, MessageState, @@ -67,6 +68,7 @@ import { } from './state'; import { AUTO_LAYER_ID, + findDataSource, findResourceByName, genSimpleId, getCsvUrl, @@ -137,6 +139,7 @@ export const SET_WEBAPI_STATUS = 'SET_WEBAPI_STATUS'; export const SET_WEBAPI_CLIENT = 'SET_WEBAPI_CLIENT'; export const SET_WEBAPI_SERVICE_URL = 'SET_WEBAPI_SERVICE_URL'; export const SET_WEBAPI_SERVICE_INFO = 'SET_WEBAPI_SERVICE_INFO'; +export const UPDATE_HUB_STATUS = 'UPDATE_HUB_STATUS'; export const UPDATE_DIALOG_STATE = 'UPDATE_DIALOG_STATE'; export const UPDATE_TASK_STATE = 'UPDATE_TASK_STATE'; export const REMOVE_TASK_STATE = 'REMOVE_TASK_STATE'; @@ -312,6 +315,27 @@ export function connectWebAPIService(webAPIServiceURL: string): ThunkAction { const webAPIClient = newWebAPIClient(selectors.apiWebSocketsUrlSelector(getState())); + const formatMessage = (message: string, event: any): string => { + if (event.message) { + return `${message} (${event.message})`; + } else { + return message; + } + }; + + /** + * Called to inform backend we are still alive. + * Hopefully avoids closing WebSocket connection. + */ + const keepAlive = () => { + if (webAPIClient.isOpen) { + console.debug("calling keep_alive()"); + webAPIClient.call('keep_alive', []) + } + }; + + let keepAliveTimer = null; + webAPIClient.onOpen = () => { dispatch(setWebAPIClient(webAPIClient)); dispatch(loadBackendConfig()); @@ -319,22 +343,19 @@ export function connectWebAPIService(webAPIServiceURL: string): ThunkAction { dispatch(loadPreferences()); dispatch(loadDataStores()); dispatch(loadOperations()); - }; - - const formatMessage = (message: string, event: any): string => { - if (event.message) { - return `${message} (${event.message})`; - } else { - return message; - } + keepAliveTimer = setInterval(keepAlive, 2500); }; webAPIClient.onClose = (event) => { + if (keepAliveTimer !== null) { + clearInterval(keepAliveTimer); + } const webAPIStatus = getState().communication.webAPIStatus; if (webAPIStatus === 'shuttingDown' || webAPIStatus === 'loggingOut') { // When we are logging off, the webAPIClient is expected to close. return; } + // When we end up here, the connection closed unintentionally. console.error('webAPIClient.onClose:', event); dispatch(setWebAPIStatus('closed')); showToast({type: 'notification', text: formatMessage('Connection to Cate service closed', event)}); @@ -353,6 +374,10 @@ export function connectWebAPIService(webAPIServiceURL: string): ThunkAction { }; } +export function updateHubStatus(hubStatus: HubStatus): Action { + return {type: UPDATE_HUB_STATUS, payload: hubStatus}; +} + export function updateInitialState(initialState: Object): Action { return {type: UPDATE_INITIAL_STATE, payload: initialState}; } @@ -885,7 +910,7 @@ export function setSelectedPlacemarkId(selectedPlacemarkId: string | null): Acti export const UPDATE_DATA_STORES = 'UPDATE_DATA_STORES'; export const UPDATE_DATA_SOURCES = 'UPDATE_DATA_SOURCES'; -export const UPDATE_DATA_SOURCE_TEMPORAL_COVERAGE = 'UPDATE_DATA_SOURCE_TEMPORAL_COVERAGE'; +export const UPDATE_DATA_SOURCE_META_INFO = 'UPDATE_DATA_SOURCE_META_INFO'; /** * Asynchronously load the available Cate data stores. @@ -987,33 +1012,54 @@ export function setSelectedDataStoreIdImpl(selectedDataStoreId: string | null) { return updateSessionState({selectedDataStoreId}); } -export function setSelectedDataSourceId(selectedDataSourceId: string | null) { - return updateSessionState({selectedDataSourceId}); +export function setSelectedDataSourceId(selectedDataSourceId: string): ThunkAction { + return (dispatch: Dispatch, getState: GetState) => { + dispatch(updateSessionState({selectedDataSourceId})); + const dataStoreId = getState().session.selectedDataStoreId; + if (dataStoreId && selectedDataSourceId) { + dispatch(loadDataSourceMetaInfo(dataStoreId, selectedDataSourceId)); + } + } } export function setDataSourceFilterExpr(dataSourceFilterExpr: string) { return updateSessionState({dataSourceFilterExpr}); } -export function loadTemporalCoverage(dataStoreId: string, dataSourceId: string): ThunkAction { +export function loadDataSourceMetaInfo(dataStoreId: string, dataSourceId: string): ThunkAction { return (dispatch: Dispatch, getState: GetState) => { + const dataStores = getState().data.dataStores; + if (!dataStores) { + return; + } + const dataSource = findDataSource(dataStores, dataStoreId, dataSourceId); + if (!dataSource || dataSource.metaInfoStatus !== 'init') { + return; + } + + dispatch(updateDataSourceMetaInfo(dataStoreId, dataSourceId, undefined, 'loading')); function call(onProgress) { - return selectors.datasetAPISelector(getState()).getDataSourceTemporalCoverage(dataStoreId, dataSourceId, onProgress); + return selectors.datasetAPISelector(getState()).getDataSourceMetaInfo(dataStoreId, dataSourceId, onProgress); } - function action(temporalCoverage) { - dispatch(updateDataSourceTemporalCoverage(dataStoreId, dataSourceId, temporalCoverage)); + function action(metaInfo: DatasetDescriptor) { + dispatch(updateDataSourceMetaInfo(dataStoreId, dataSourceId, metaInfo, 'ok')); } - callAPI({title: `Load temporal coverage for ${dataSourceId}`, dispatch, call, action}); + function planB() { + dispatch(updateDataSourceMetaInfo(dataStoreId, dataSourceId, undefined, 'error')); + } + + callAPI({title: `Loading meta data for ${dataSourceId}`, dispatch, call, action, planB}); }; } -export function updateDataSourceTemporalCoverage(dataStoreId: string, - dataSourceId: string, - temporalCoverage: [string, string] | null): Action { - return {type: UPDATE_DATA_SOURCE_TEMPORAL_COVERAGE, payload: {dataStoreId, dataSourceId, temporalCoverage}}; +export function updateDataSourceMetaInfo(dataStoreId: string, + dataSourceId: string, + metaInfo: DatasetDescriptor | undefined, + metaInfoStatus: 'loading' | 'ok' | 'error' = 'ok'): Action { + return {type: UPDATE_DATA_SOURCE_META_INFO, payload: {dataStoreId, dataSourceId, metaInfo, metaInfoStatus}}; } export function openDataset(dataSourceId: string, args: any, updateLocalDataSources: boolean): ThunkAction { @@ -2712,4 +2758,3 @@ function readDroppedFile(file: File, dispatch: Dispatch) { console.warn('Dropped file of unrecognized type: ', file.name); } } - diff --git a/src/renderer/components/DataSourceDetails.tsx b/src/renderer/components/DataSourceDetails.tsx index 4c19138..c6d9685 100644 --- a/src/renderer/components/DataSourceDetails.tsx +++ b/src/renderer/components/DataSourceDetails.tsx @@ -23,20 +23,24 @@ const DataSourceDetails: React.FC = ({dataSource}) => { if (!dataSource) { return null; } + let metaInfoKeys; - if (dataSource.metaInfo) { - metaInfoKeys = Object.keys(dataSource.metaInfo).filter(key => key !== 'variables'); - } let variables; - if (dataSource.metaInfo.variables) { - variables = dataSource.metaInfo.variables; + + const metaInfo = dataSource.metaInfo; + + if (metaInfo) { + metaInfoKeys = Object.keys(metaInfo).filter(key => key !== 'variables'); + if (metaInfo.variables) { + variables = metaInfo.variables; + } } const details: DetailPart[] = [ renderAbstract(dataSource), renderVariablesTable(variables), - renderMetaInfoTable(dataSource.metaInfo, metaInfoKeys), - renderMetaInfoLicences(dataSource.metaInfo), + renderMetaInfoTable(metaInfo, metaInfoKeys), + renderMetaInfoLicences(metaInfo), ]; return ( @@ -84,18 +88,26 @@ function renderAbstract(dataSource: DataSourceState): DetailPart { ); } - if (dataSource.temporalCoverage) { + if (dataSource.metaInfo && dataSource.metaInfo.time_range) { + let [start, end] = dataSource.metaInfo.time_range; + if (!start && !end) { + start = end = 'unknown'; + } else if (!start) { + start = 'unknown'; + } else if (!end) { + end = 'today'; + } temporalCoverage = (
Temporal coverage
- + - +
Start{dataSource.temporalCoverage[0]}{start}
End{dataSource.temporalCoverage[1]}{end}
diff --git a/src/renderer/components/DataSourceItem.tsx b/src/renderer/components/DataSourceItem.tsx index 6b52c09..40b71a6 100644 --- a/src/renderer/components/DataSourceItem.tsx +++ b/src/renderer/components/DataSourceItem.tsx @@ -36,20 +36,7 @@ interface DataSourceItemProps { const DataSourceItem: React.FC = ({dataSource, showDataSourceIDs}) => { const metaInfo = dataSource.metaInfo; - // TODO: get rid of render logic here - const ecvId = ((metaInfo && metaInfo.cci_project) || '').toLowerCase(); - const ecvMetaItem = ECV_META.ecvs[ecvId]; - let backgroundColor, label; - if (ecvMetaItem) { - backgroundColor = ECV_META.colors[ecvMetaItem.color] || ecvMetaItem.color; - label = ecvMetaItem.label; - } - if (!backgroundColor) { - backgroundColor = ECV_META.colors["default"] || "#0BB7A0"; - } - if (!label) { - label = ecvId.substr(0, 3).toUpperCase() || '?'; - } + const {backgroundColor, label} = dataSourceToTextIconProps(dataSource); const icon =
{label}
; const title = dataSource.title || (metaInfo && metaInfo.title); @@ -81,4 +68,35 @@ const DataSourceItem: React.FC = ({dataSource, showDataSour } -export default DataSourceItem; \ No newline at end of file +export default DataSourceItem; + + +function dataSourceToTextIconProps(dataSource: DataSourceState) { + let ecvId; + let label; + if (dataSource.title) { + ecvId = dataSource.title.split(' ', 1)[0].toLowerCase(); + label = dataSource.title.substr(0, 3).toUpperCase(); + } + if (!ecvId || !ECV_META.ecvs[ecvId]) { + // This is a CCI-store specific hack + const idParts = dataSource.id.split('.', 2); + if (idParts.length > 1) { + ecvId = idParts[1].toLowerCase(); + } + } + const ecvMetaItem = ecvId && ECV_META.ecvs[ecvId]; + let backgroundColor; + if (ecvMetaItem) { + backgroundColor = ECV_META.colors[ecvMetaItem.color] || ecvMetaItem.color; + label = ecvMetaItem.label || label; + } + if (!backgroundColor) { + backgroundColor = ECV_META.colors["default"] || "#0BB7A0"; + } + if (!label) { + label = (ecvId && ecvId.substr(0, 3).toUpperCase()) || '?'; + } + return {backgroundColor, label}; +} + diff --git a/src/renderer/containers/AppModePage.tsx b/src/renderer/containers/AppModePage.tsx index a48990a..7ed274a 100644 --- a/src/renderer/containers/AppModePage.tsx +++ b/src/renderer/containers/AppModePage.tsx @@ -9,13 +9,10 @@ import GdprBanner from './GdprBanner'; import { TermsAndConditions } from '../components/TermsAndConditions'; import { DEFAULT_SERVICE_URL } from '../initial-state'; import { State } from '../state'; - import cateIcon from '../resources/cate-icon-512.png'; import VersionTags from './VersionTags'; -const maintenanceReason: string | undefined = process.env.REACT_APP_CATEHUB_MAINTENANCE; - const CENTER_DIV_STYLE: CSSProperties = { display: 'flex', alignItems: 'center', @@ -40,14 +37,24 @@ interface IDispatch { } interface IAppModePageProps { + hubOk?: boolean; + hubMessage?: string | null; } // noinspection JSUnusedLocalSymbols function mapStateToProps(state: State): IAppModePageProps { - return {}; + const hubStatus = state.communication.hubStatus; + return { + hubOk: !!hubStatus && hubStatus.status === 'ok', + hubMessage: hubStatus && hubStatus.message, + }; } -const _AppModePage: React.FC = () => { +const _AppModePage: React.FC = ( + { + hubOk, + hubMessage + }) => { const history = useHistory(); const [, keycloakInitialized] = useKeycloak(); @@ -96,21 +103,24 @@ const _AppModePage: React.FC = () => { {'Cate
- {maintenanceReason ? ( -
-  {maintenanceReason} + {hubOk && ( +
+ Please select a Cate service provision mode
- ) : ( -
- Please select a Cate service provision mode -
- )} + )} + + {hubMessage && ( +
+  {hubMessage} +
+ )} +
}> @@ -121,7 +131,7 @@ const _AppModePage: React.FC = () => { I agree to the  diff --git a/src/renderer/containers/AppRouter.tsx b/src/renderer/containers/AppRouter.tsx new file mode 100644 index 0000000..a6e76e0 --- /dev/null +++ b/src/renderer/containers/AppRouter.tsx @@ -0,0 +1,46 @@ +import * as React from 'react'; +import { connect } from 'react-redux'; +import { BrowserRouter as Router, Redirect, Route, Switch } from "react-router-dom"; + +import { State, HubStatus } from '../state'; +import AppMainPageForHub from '../containers/AppMainPageForHub'; +import AppMainPageForSA from './AppMainPageForSA'; +import AppModePage from './AppModePage'; + + +interface IAppRouterProps { + hubStatus: HubStatus | null; +} + +function mapStateToProps(state: State): IAppRouterProps { + return { + hubStatus: state.communication.hubStatus + }; +} + +const AppRouter: React.FC = ({hubStatus}) => { + return ( + + + + + + + + + + { + // It should read + // (hubStatus === null || hubStatus.status !== 'ok') + // but this will always bring us back to "/" after login :( + (hubStatus !== null && hubStatus.status !== 'ok') + ? () + : () + } + + + + ); +} + +export default connect(mapStateToProps)(AppRouter); diff --git a/src/renderer/containers/DataSourcesPanel.tsx b/src/renderer/containers/DataSourcesPanel.tsx index 7b8e00a..78fecdd 100644 --- a/src/renderer/containers/DataSourcesPanel.tsx +++ b/src/renderer/containers/DataSourcesPanel.tsx @@ -97,7 +97,7 @@ interface IDataSourcesPanelDispatch { updateSessionState(sessionState: any): void; - loadTemporalCoverage(dataStoreId: string, dataSourceId: string): void; + loadDataSourceMetaInfo(dataStoreId: string, dataSourceId: string): void; showDialog(dialogId: string): void; @@ -111,7 +111,6 @@ const mapDispatchToProps = { setSessionState: actions.setSessionProperty, setControlState: actions.setControlProperty, updateSessionState: actions.updateSessionState, - loadTemporalCoverage: actions.loadTemporalCoverage, showDialog: actions.showDialog, hideDialog: actions.hideDialog, }; @@ -260,16 +259,9 @@ class DataSourcesPanel extends React.Component + response.json()) + .then(hubStatus => + store.dispatch(actions.updateHubStatus( + {...hubStatus, deployment}))) + .catch(e => console.error(e)); + ReactDOM.render( ( - - - - - - - - - { - maintenanceReason - ? () - : () - } - - - - - - - - + + + + + ), document.getElementById('root') ); if (!isElectron()) { - // Dektop-PWA app install, see https://web.dev/customize-install/ + // + // Desktop-PWA app install, see https://web.dev/customize-install/ // window.addEventListener('beforeinstallprompt', (event: Event) => { // Update UI notify the user they can install the PWA @@ -115,4 +102,3 @@ function getKeycloakConfig(): KeycloakConfig { } return {realm, url, clientId}; } - diff --git a/src/renderer/reducers.ts b/src/renderer/reducers.ts index 0b5e465..b22a14c 100644 --- a/src/renderer/reducers.ts +++ b/src/renderer/reducers.ts @@ -111,17 +111,16 @@ const dataReducer = (state: DataState = INITIAL_DATA_STATE, action: Action): Dat return action.payload.dataSources.slice(); }); } - case actions.UPDATE_DATA_SOURCE_TEMPORAL_COVERAGE: { + case actions.UPDATE_DATA_SOURCE_META_INFO: { return updateDataStores(state, action, dataStore => { const newDataSources = [...dataStore.dataSources]; - const dataSourceId = action.payload.dataSourceId; - const temporalCoverage = action.payload.temporalCoverage; + const {dataSourceId, metaInfo, metaInfoStatus} = action.payload; const dataSourceIndex = newDataSources.findIndex(dataSource => dataSource.id === dataSourceId); if (dataSourceIndex < 0) { throw Error('illegal data source ID: ' + dataSourceId); } const oldDataSource = newDataSources[dataSourceIndex]; - newDataSources[dataSourceIndex] = {...oldDataSource, temporalCoverage}; + newDataSources[dataSourceIndex] = {...oldDataSource, metaInfo, metaInfoStatus}; return newDataSources; }); } @@ -842,6 +841,12 @@ const communicationReducer = (state: CommunicationState = INITIAL_COMMUNICATION_ const webAPIServiceInfo = action.payload.webAPIServiceInfo; return {...state, webAPIServiceInfo}; } + case actions.UPDATE_HUB_STATUS: { + return { + ...state, + hubStatus: action.payload + } + } case actions.UPDATE_TASK_STATE: return { ...state, diff --git a/src/renderer/selectors.ts b/src/renderer/selectors.ts index fe45d73..12912f9 100644 --- a/src/renderer/selectors.ts +++ b/src/renderer/selectors.ts @@ -440,18 +440,27 @@ export const selectedDataSourceSelector = createSelector( selectedDataSourceSelector, - (selectedDataSource: DataSourceState): [string, string] | null => { - return selectedDataSource ? selectedDataSource.temporalCoverage : null; + (selectedDataSource: DataSourceState): [string | null, string | null] | null => { + return (selectedDataSource + && selectedDataSource.metaInfo + && selectedDataSource.metaInfo.time_range) || null; } ); -export const canCacheDataSourceSelector = createSelector( +export const canCacheDataSourceSelector = createSelector( + selectedDataStoreIdSelector, selectedDataSourceSelector, - (selectedDataSource: DataSourceState | null): boolean => { + (selectedDataStoreId, selectedDataSource): boolean => { + if (selectedDataStoreId === null + || selectedDataStoreId === 'local' + || selectedDataStoreId === 'cci-zarr-store') { + return false; + } return selectedDataSource ? canCacheDataSource(selectedDataSource) : false; } ); diff --git a/src/renderer/state-util.ts b/src/renderer/state-util.ts index 6e1869e..eee2b60 100644 --- a/src/renderer/state-util.ts +++ b/src/renderer/state-util.ts @@ -251,6 +251,20 @@ export function findOperation(operations: OperationState[], name: string): Opera return operations && operations.find(op => op.qualifiedName === name || op.name === name); } +export function findDataSource(dataStores: DataStoreState[], + dataStoreId: string, + dataSourceId: string): DataSourceState | null { + const dataStore = dataStores && dataStores.find(dataStore => dataStore.id === dataStoreId); + if (dataStore) { + const dataSource = dataStore.dataSources + && dataStore.dataSources.find(dataSource => dataSource.id === dataSourceId); + if (dataSource) { + return dataSource; + } + } + return null; +} + export function findVariableIndexCoordinates(resources: ResourceState[], ref: VariableDataRefState): any[] { const resource = findResourceById(resources, ref.resId); if (!resource) { diff --git a/src/renderer/state.ts b/src/renderer/state.ts index 44e8d7c..a39e71d 100644 --- a/src/renderer/state.ts +++ b/src/renderer/state.ts @@ -1,5 +1,6 @@ import { IconName } from '@blueprintjs/core'; import { Feature, FeatureCollection, GeoJsonObject, Point } from 'geojson'; +import { JSONSchema7 } from 'json-schema'; import { KeycloakProfile } from 'keycloak-js'; import { SimpleStyle } from '../common/geojson-simple-style'; @@ -66,6 +67,12 @@ export interface WebAPIServiceInfo { hostOS?: HostOS; } +export interface HubStatus { + status: "ok" | "offline"; + message?: string; + deployment: "development" | "production"; +} + export interface DataStoreNotice { id: string; title: string; @@ -85,10 +92,78 @@ export interface DataStoreState { export type DataSourceVerificationFlags = "open" | "cache" | "map"; + +export interface DatasetDescriptor { + data_id: string; + type_specifier: string; + crs?: string; + bbox?: [number, number, number, number]; + spatial_res?: number; + time_range?: [string | null, string | null]; + time_period?: string; + variables?: VariableDescriptor[]; + abstract?: string; + catalog_url?: string; + catalogue_url?: string; + cci_project?: string; + info_url?: string; + licences?: [string]; + title?: string; + uuid?: string; + // Anything else: + [attr_name: string]: any; +} + +export interface VariableDescriptor { + name: string; + units?: string; + long_name?: string; + standard_name?: string; +} + +// TODO: (forman) use this in the future instead of DatasetDescriptor/VariableDescriptor +// {{{{{{{{{{{{ + +export interface DataDescriptor2 { + data_id: string; + type_specifier: string; + crs?: string | null; + bbox?: [number, number, number, number] | null; + time_range?: [string | null, string | null] | null; + time_period?: string | null; + open_params_schema?: JSONSchema7 | null; +} + +export interface GeoDataFrameDescriptor2 extends DataDescriptor2 { +} + +export interface DatasetDescriptor2 extends DataDescriptor2 { + spatial_res?: number | null; + dims?: { [dim_name: string]: number } | null; + coords?: { [var_name: string]: VariableDescriptor2 } | null; + data_vars?: { [var_name: string]: VariableDescriptor2 } | null; + attrs?: { [attr_name: string]: any } | null; +} + +export interface VariableDescriptor2 { + name: string; + dtype: string; + dims: string[]; + chunks?: number[] | null; + attrs?: { [attr_name: string]: any } | null; +} +// }}}}}}}}}}}} + + export interface DataSourceState { id: string; title?: string; - metaInfo: { [key: string]: any } | null; + // TODO: (forman) replace by descriptor in the future + metaInfo?: DatasetDescriptor; + metaInfoStatus: 'init' | 'loading' | 'ok' | 'error'; + // TODO: (forman) use this in the future instead of metaInfo + // descriptor?: DatasetDescriptor2 | GeoDataFrameDescriptor2; + // descriptorStatus: 'init' | 'loading' | 'ok' | 'error'; typeSpecifier?: string | null; verificationFlags?: DataSourceVerificationFlags[] | null; temporalCoverage?: [string, string] | null; @@ -658,6 +733,7 @@ export interface CommunicationState { webAPIStatus: WebAPIStatus | null; webAPIClient: WebAPIClient | null; userProfile: KeycloakProfile | null; + hubStatus: HubStatus | null; // A map that stores the current state of any tasks (e.g. data fetch jobs from remote API) given a jobId tasks: { [jobId: number]: TaskState; }; } diff --git a/src/renderer/webapi/WebAPIClient.ts b/src/renderer/webapi/WebAPIClient.ts index a6ed030..f3f8bbe 100644 --- a/src/renderer/webapi/WebAPIClient.ts +++ b/src/renderer/webapi/WebAPIClient.ts @@ -85,7 +85,12 @@ export type JobResponseTransformer = (any) => JobResponse; * This is non JSON-RCP, which only allows for either the "response" or an "error" object. */ export interface WebAPIClient { + /** + * Test if the connection is open and active. + */ + readonly isOpen: boolean; readonly url: string; + onOpen: (event) => void; onClose: (event) => void; onError: (event) => void; @@ -142,16 +147,14 @@ class WebAPIClientImpl implements WebAPIClient { onError: (event) => void; onWarning: (event) => void; - readonly socket: WebSocketMin; + private readonly socket: WebSocketMin; private currentMessageId = 0; - private activeJobs: JobImpl[]; - private isOpen: boolean; + private readonly activeJobs: JobImpl[]; constructor(url: string, firstMessageId = 0, socket?: WebSocketMin) { this.url = url; this.currentMessageId = firstMessageId; this.activeJobs = []; - this.isOpen = false; this.socket = socket ? socket : new WebSocket(url); this.socket.onopen = (event) => { if (this.onOpen) { @@ -174,6 +177,10 @@ class WebAPIClientImpl implements WebAPIClient { } } + get isOpen(): boolean { + return this.socket.readyState === WebSocket.OPEN; + } + call(method: string, params: Array | Object, onProgress?: (progress: JobProgress) => void, diff --git a/src/renderer/webapi/WebSocketMock.ts b/src/renderer/webapi/WebSocketMock.ts index 7dd1cda..af5d68e 100644 --- a/src/renderer/webapi/WebSocketMock.ts +++ b/src/renderer/webapi/WebSocketMock.ts @@ -4,6 +4,8 @@ * @author Norman Fomferra */ export interface WebSocketMin { + readonly readyState: number; + onclose: (this: this, ev: CloseEvent) => any; onerror: (this: this, ev: ErrorEvent) => any; onmessage: (this: this, ev: MessageEvent) => any; @@ -52,6 +54,7 @@ export class WebSocketMock implements WebSocketMin { onclose: (this: this, ev: any) => any; readonly messageLog: string[] = []; readonly serviceObj: any; + readyState: number; // <<<< WebSocketMin implementation //////////////////////////////////////////// @@ -68,6 +71,7 @@ export class WebSocketMock implements WebSocketMin { } this.serviceObj = serviceObj; this.asyncCalls = asyncCalls; + this.readyState = WebSocket.CONNECTING; } send(data: string) { @@ -77,6 +81,7 @@ export class WebSocketMock implements WebSocketMin { close(code?: number, reason?: string): void { this.onclose({code, reason}); + this.readyState = WebSocket.CLOSED; } emulateIncomingMessages(...messages: Object[]) { @@ -95,14 +100,19 @@ export class WebSocketMock implements WebSocketMin { emulateOpen(event) { this.onopen(event); + this.readyState = WebSocket.OPEN; } emulateError(event) { + this.readyState = WebSocket.CLOSING; this.onerror(event); + this.readyState = WebSocket.CLOSED; } emulateClose(event) { + this.readyState = WebSocket.CLOSING; this.onclose(event); + this.readyState = WebSocket.CLOSED; } private maybeUseServiceObj(messageText) { diff --git a/src/renderer/webapi/apis/DatasetAPI.ts b/src/renderer/webapi/apis/DatasetAPI.ts index fd3e5e6..e59d77c 100644 --- a/src/renderer/webapi/apis/DatasetAPI.ts +++ b/src/renderer/webapi/apis/DatasetAPI.ts @@ -1,51 +1,8 @@ -import { DataSourceState, DataStoreState, DimSizes } from '../../state'; +import { DatasetDescriptor, DataSourceState, DataStoreState, DimSizes } from '../../state'; import { JobProgress, JobPromise } from '../Job'; import { WebAPIClient } from '../WebAPIClient'; -function responseToTemporalCoverage(response: any): [string, string] | null { - if (response && response.temporal_coverage_start && response.temporal_coverage_end) { - return [response.temporal_coverage_start, response.temporal_coverage_end]; - } - return null; -} - -function addVerificationFlagsForTesting(dataSources: DataSourceState[]): DataSourceState[] { - return dataSources.map((ds, i) => { - // console.debug(`dataSources[${i}]:`, ds); - return ds; - /* - if (i === 0) { - return { - ...ds, - typeSpecifier: 'dataset', - verificationFlags: null - } - } else if (i === 1) { - return { - ...ds, - typeSpecifier: 'dataset', - verificationFlags: ['open'] - } - } else if (i === 2) { - return { - ...ds, - typeSpecifier: 'dataset', - verificationFlags: ['open', 'map'] - } - } else if (i === 3) { - return { - ...ds, - typeSpecifier: 'dataset', - verificationFlags: ['open', 'map', 'cache'] - } - } else { - return ds; - } - */ - }); -} - export class DatasetAPI { private readonly webAPIClient: WebAPIClient; @@ -59,19 +16,18 @@ export class DatasetAPI { getDataSources(dataStoreId: string, onProgress: (progress: JobProgress) => void): JobPromise { - return this.webAPIClient - .call('get_data_sources', - [dataStoreId], - onProgress, - addVerificationFlagsForTesting); + return this.webAPIClient.call('get_data_sources', + [dataStoreId], + onProgress, + DatasetAPI.responseToDataSources); } - getDataSourceTemporalCoverage(dataStoreId: string, dataSourceId: string, - onProgress: (progress: JobProgress) => void): JobPromise<[string, string] | null> { - return this.webAPIClient.call('get_data_source_temporal_coverage', + getDataSourceMetaInfo(dataStoreId: string, dataSourceId: string, + onProgress: (progress: JobProgress) => void): JobPromise { + return this.webAPIClient.call('get_data_source_meta_info', [dataStoreId, dataSourceId], - onProgress, responseToTemporalCoverage - ); + onProgress, + DatasetAPI.responseToMetaInfo); } addLocalDataSource(dataSourceId: string, filePathPattern: string, @@ -94,4 +50,23 @@ export class DatasetAPI { indexers: DimSizes): JobPromise<{ [varName: string]: number } | null> { return this.webAPIClient.call('extract_pixel_values', [baseDir, source, point, indexers]); } + + static responseToDataSources(dataSources: any[]): DataSourceState[] { + // noinspection JSUnusedLocalSymbols + return dataSources.map((dataSource, i): DataSourceState => { + console.debug(`dataSources[${i}]:`, dataSource); + return { + id: dataSource['id'], + title: dataSource['title'], + typeSpecifier: dataSource['type_specifier'] || 'dataset', + verificationFlags: dataSource['verification_flags'], + metaInfoStatus: 'init', + }; + }); + } + + static responseToMetaInfo(response: any): DatasetDescriptor { + return response as DatasetDescriptor; + } } + diff --git a/src/renderer/webapi/apis/ServiceProvisionAPI.ts b/src/renderer/webapi/apis/ServiceProvisionAPI.ts index 09411f0..c4accb4 100644 --- a/src/renderer/webapi/apis/ServiceProvisionAPI.ts +++ b/src/renderer/webapi/apis/ServiceProvisionAPI.ts @@ -34,6 +34,9 @@ interface CountResult { running_pods: number; } +/** + * Represents the cate-hub API. + */ export class ServiceProvisionAPI { /** @@ -81,16 +84,11 @@ export class ServiceProvisionAPI { if (!response.ok) { throw HttpError.fromResponse(response); } - const jsonObject = await response.json(); - if (jsonObject.status !== 'ok') { - throw new Error(jsonObject.message); - } - return jsonObject.result as T; + return response.json(); } } - -function getEndpointUrl(): string { +export function getEndpointUrl(): string { if (process.env.REACT_APP_CATEHUB_ENDPOINT) { return process.env.REACT_APP_CATEHUB_ENDPOINT; } else if (window.location.host.indexOf('stage') >= 0) { diff --git a/src/serviceWorker.ts b/src/serviceWorker.ts index dc8c19e..88a54bf 100644 --- a/src/serviceWorker.ts +++ b/src/serviceWorker.ts @@ -14,7 +14,7 @@ // "./service-worker.js" file changes). Therefore we use a version number here, // so we can force updates. // -const CATE_PWA_VERSION = "2.2.3"; +const CATE_PWA_VERSION = "3.0.0-dev.0"; console.debug(`Cate PWA version ${CATE_PWA_VERSION}`); diff --git a/src/version.ts b/src/version.ts index b6e2b4a..ac6ce31 100644 --- a/src/version.ts +++ b/src/version.ts @@ -2,4 +2,4 @@ // 1. with the version field in "../package.json". // 2. with CATE_PWA_VERSION in "./serviceWorker.ts" // -export const CATE_APP_VERSION = "2.2.3"; +export const CATE_APP_VERSION = "3.0.0-dev.0"; diff --git a/yarn.lock b/yarn.lock index a423bd9..9623094 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1772,6 +1772,11 @@ resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.5.tgz#dcce4430e64b443ba8945f0290fb564ad5bac6dd" integrity sha512-7+2BITlgjgDhH0vvwZU/HZJVyk+2XUlvxXe8dFMedNX/aMkaOq++rMAFXc0tM7ij15QaWlbdQASBR9dihi+bDQ== +"@types/json-schema@^7.0.7": + version "7.0.7" + resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.7.tgz#98a993516c859eb0d5c4c8f098317a9ea68db9ad" + integrity sha512-cxWFQVseBm6O9Gbw1IWb8r6OS4OhSt3hPZLkFApLjM8TEXROBuQGLAH2i2gZpcXdLBIrpXuTDhH7Vbm1iXmNGA== + "@types/minimatch@*": version "3.0.3" resolved "https://registry.yarnpkg.com/@types/minimatch/-/minimatch-3.0.3.tgz#3dca0e3f33b200fc7d1139c0cd96c1268cadfd9d"