Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions InfoLogger/public/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
--row-height: 0.91rem; /* default, overridden by JS zoom */
}
.logs-content { border-top: 1px solid #aaa; }
.bold { font-weight: bold; }

/* logs tables */
.table-logs-header { width: 100%; border-collapse: collapse; }
Expand Down
54 changes: 54 additions & 0 deletions InfoLogger/public/common/jsonFetch.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
/**
* @license
* Copyright CERN and copyright holders of ALICE O2. This software is
* distributed under the terms of the GNU General Public License v3 (GPL
* Version 3), copied verbatim in the file "COPYING".
*
* See http://alice-o2.web.cern.ch/license for full licensing information.
*
* In applying this license CERN does not waive the privileges and immunities
* granted to it by virtue of its status as an Intergovernmental Organization
* or submit itself to any jurisdiction.
*/

import { fetchClient } from '/js/src/index.js';

/**
* Send a request to an endpoint, and extract the response. If errors occurred, return an error containing a message.
*
* The endpoint is expected to follow some conventions:
* - If request is valid but no data was sent as response, it must return a 204
* - If an error occurred on the backend:
* - request can be status ok with {message: string} body describing the error
* - or request can be status error with or without body that contains a message field describing the error
* - If request is valid and data is sent as response, it must return a json with the expected data
* @param {string} endpoint - the remote endpoint to send request to
* @param {RequestInit} options - the request options, see {@link fetch } native function
* (method, headers, body, abort.signal, etc.)
* @returns {Promise<Resolve<object>.Error<{message: string}>>} resolve with the result or reject with the error message
*/
export const jsonFetch = async (endpoint, options) => {
try {
const response = await fetchClient(endpoint, options);
if (response.status === 204) {
return null;
}

const result = await response.json();

if (response.ok) {
return result;
}

const serverMessage = result && typeof result.message === 'string'
? result.message
: null;

throw new Error(serverMessage || `Request failed with status ${response.status}`);
} catch (error) {
if (error && typeof error.message === 'string') {
throw error;
}
throw new Error('Parsing result from server failed', { cause: error });
}
};
40 changes: 40 additions & 0 deletions InfoLogger/public/common/jsonPost.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
/**
* @license
* Copyright CERN and copyright holders of ALICE O2. This software is
* distributed under the terms of the GNU General Public License v3 (GPL
* Version 3), copied verbatim in the file "COPYING".
*
* See http://alice-o2.web.cern.ch/license for full licensing information.
*
* In applying this license CERN does not waive the privileges and immunities
* granted to it by virtue of its status as an Intergovernmental Organization
* or submit itself to any jurisdiction.
*/

import { jsonFetch } from './jsonFetch.js';

/**
* Build and send a POST request to a remote endpoint, and extract the response.
* @param {string} endpoint - the remote endpoint to send request to
* @param {RequestInit} options - the request options, see {@link fetch } native function
* (method, headers, body, abort.signal, etc.)
* @returns {Promise<Resolve<object>.Error<{message: string}>>} resolve with the result or reject with the error
*/
export const jsonPost = async (endpoint, options = {}) => {
if (options.body && typeof options.body === 'object') {
options.body = JSON.stringify(options.body);
}
try {
const result = await jsonFetch(endpoint, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
},
...options,
});
return result;
} catch (error) {
return Promise.reject({ message: error.message || error });
}
};
77 changes: 55 additions & 22 deletions InfoLogger/public/log/Log.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import LogFilter from '../logFilter/LogFilter.js';
import ContextMenu from './ContextMenu.js';
import { MODE } from '../constants/mode.const.js';
import { TIME_MS } from '../common/Timezone.js';
import { jsonPost } from '../common/jsonPost.js';

/**
* Model Log, encapsulate all log management and queries
Expand Down Expand Up @@ -48,6 +49,7 @@ export default class Log extends Observable {
this.limitReached = null;

this.queryResult = RemoteData.notAsked();
this.queryAbortController = null;

this.list = [];
this.item = null;
Expand Down Expand Up @@ -327,59 +329,90 @@ export default class Log extends Observable {
}

/**
* Query database according to filters.
* Only is service is available and configured on server side.
* If live mode is enabled, it is turned off.
* `list` is then reset and filled with result.
* Method to execute a query with the current filters configuration via button click or "Enter" keypress on filters.
* (thus, check of DB status still needed)
* If the user has no filters set, a prompt is shown to confirm the execution
* If the user is in live mode, first stop live mode and then execute query in order to have a consistent result
* Recalculate the stats and go to last log once query is executed
* If the query is aborted by user, restore previous query result and do nothing
* @returns {Promise<null|object>} null if query is aborted, result of the query otherwise
*/
async query() {
if (!this.model.frameworkInfo.isSuccess() || !this.model.frameworkInfo.payload.mysql.status.ok) {
Comment thread
isaachilly marked this conversation as resolved.
throw new Error('Query service is not available');
}

if (!this.filter.hasActiveTextFilters()) {
if (!window.confirm('No date or text filters set.'
+ ' This will return a large amount of data. Execute query anyway?')) {
return;
}
}

this.queryResult = RemoteData.loading();
this.notify();

if (this.isLiveModeRunning()) {
this.liveStop(MODE.QUERY);
} else {
this.activeMode = MODE.QUERY;
}

const queryArguments = {
criterias: this.filter.criterias,
options: { limit: this.limit },
};
const { result, ok } = await this.model.loader.post('/api/query', queryArguments, true);
if (!ok) {
this.queryResult = RemoteData.failure(result.message);
this.list = [];
} else {
const previousQueryResult = this.queryResult;
this.queryResult = RemoteData.loading();
this.notify();

const abortController = new AbortController();
this.queryAbortController = abortController;

let result = 'Unable to execute query';
try {
result = await jsonPost('/api/query', {
body: {
criterias: this.filter.criterias,
options: { limit: this.limit },
},
signal: abortController.signal,
});
this.resetStats();
this.queryResult = RemoteData.success(result);
this.list = result.rows;
this.limitReached = result.count === this.limit;
this.list.forEach((log) => this.addStats(log));
this.goToLastItem();

this.limitReached = result.count === this.limit;
if (this.limitReached) {
this.model.notification.show(
`Matching results reached the buffer size of ${this.limit.toLocaleString('en-US')}.`
+ ' There might be more logs that match your filters but are not shown, consider refining your filters.',
'warning',
);
}
} catch (error) {
if (abortController.signal.aborted) {
this.queryResult = previousQueryResult;
} else {
result = { message: error.message || result };
this.queryResult = RemoteData.failure(result.message);
this.list = [];
this.resetStats();
}
} finally {
this.queryAbortController = null;
}
this.notify();
}

this.resetStats();
this.list.forEach((log) => this.addStats(log));
/**
* Method to allow for cancellation of ongoing HTTP request for query mode if:
* - a query is still ongoing
* - an abort controller is present.
* If the query is successfully aborted, a notification is shown to user.
* @returns {void}
*/
cancelQuery() {
if (!this.queryResult.isLoading() || !this.queryAbortController) {
return;
}

this.goToLastItem();
this.notify();
this.queryAbortController.abort();
this.model.notification.show('Query cancelled', 'warning', 2000);
}

/**
Expand Down
134 changes: 85 additions & 49 deletions InfoLogger/public/log/commandLogs.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,12 +29,14 @@ let queryButtonType = BUTTON.PRIMARY;
let liveButtonType = BUTTON.DEFAULT;
let liveButtonIcon = iconMediaPlay();

export default (model) => [
/**
* Component for the command buttons (Query, Live, Clear, navigation between errors and download)
* @param {Model} model - root model of the application
* @returns {vnode} - the view of the command buttons
*/
export const commandLogs = (model) => [
userActionsDropdown(model),
h('div.btn-group.mh3', [
queryButton(model),
liveButton(model),
], ''),
h('div.btn-group.mh3', interactionModesGroupButton(model)),
h('button.btn.mh3', { onclick: () => model.log.empty(), style: 'font-weight: bold' }, 'Clear'),
h('button.btn', {
disabled: !model.log.list.length,
Expand Down Expand Up @@ -69,6 +71,84 @@ export default (model) => [
zoomButtonGroup(model.zoom),
];

/**
* Group of buttons for switching between Query and Live modes.
* @param {Model} model - root model of the application
* @returns {vnode} - the view of the interaction mode buttons
*/
const interactionModesGroupButton = (model) => {
const { frameworkInfo } = model;

return frameworkInfo.match({
NotAsked: () => h('button.btn', { disabled: true }, ''),
Loading: () => h('button.btn', { disabled: true, className: 'loading' }, 'Loading'),
Failure: () => [],
Success: (frameworkInfo) =>
[
queryButton(model, frameworkInfo),
liveButton(model, frameworkInfo),
],
});
};

/**
* Query button final state depends on the following states
* - services lookup
* - services result
* - query lookup
* @param {Model} model - root model of the application
* @param {RemoteData.payload} frameworkInfo - the payload containing framework information
* @returns {vnode} - the view of the query button
*/
const queryButton = (model, frameworkInfo) => {
const { log: logModel } = model;
const { queryResult } = logModel;
const { mysql: { status: { ok: isDbReady = false } = {} } = {} } = frameworkInfo;

if (queryResult.isLoading()) {
return h('button.btn.bold', {
id: 'cancel-query-button',
title: 'Cancel ongoing query',
className: BUTTON.DANGER,
onclick: () => logModel.cancelQuery(),
}, 'Cancel');
}

return h('button.btn.bold', {
id: 'query-button',
title: isDbReady ? 'Query database with filters (Enter)' : 'Query service not configured',
disabled: !isDbReady || queryResult.isLoading(),
className: queryButtonType,
onclick: () => toggleButtonStates(model, false),
}, 'Query');
};

/**
* Live button final state depends on the following states
* - services lookup
* - services result
* - websocket status
* @param {Model} model - root model of the application
* @param {RemoteData.payload} frameworkInfo - the payload containing framework information
* @returns {vnode} - the view of the live button
*/
const liveButton = (model, frameworkInfo) => {
const { log: logModel, ws } = model;
const { queryResult } = logModel;
const { authed: isWsAuthedAndReady = false } = ws;
const { infoLoggerServer: { status: { ok: isLiveServiceReady = false } = {} } = {} } = frameworkInfo;

const isLiveModeReady = isLiveServiceReady && isWsAuthedAndReady;
const title = isLiveModeReady ? 'Stream logs with filtering' : 'Live service not configured';

return h('button.btn.bold', {
title,
disabled: !isLiveModeReady || queryResult.isLoading(),
className: !isLiveModeReady ? 'loading' : liveButtonType,
onclick: () => toggleButtonStates(model, true),
}, 'Live', ' ', liveButtonIcon);
};

/**
* Button dropdown to show current user and logout link
* @param {Model} model - root model of the application
Expand Down Expand Up @@ -105,28 +185,6 @@ const saveUserProfileMenuItem = (model) =>
title: 'Save the columns size and visibility as your profile',
}, 'Save Profile');

/**
* Query button final state depends on the following states
* - services lookup
* - services result
* - query lookup
* @param {Model} model - root model of the application
* @returns {vnode} - the view of the query button
*/
const queryButton = (model) => h('button.btn', model.frameworkInfo.match({
NotAsked: () => ({ disabled: true }),
Loading: () => ({ disabled: true, className: 'loading' }),
Success: (frameworkInfo) => ({
title: frameworkInfo.mysql && frameworkInfo.mysql.status.ok
? 'Query database with filters (Enter)' : 'Query service not configured',
disabled: !frameworkInfo.mysql || !frameworkInfo.mysql.status.ok || model.log.queryResult.isLoading(),
className: model.log.queryResult.isLoading() ? 'loading' : queryButtonType,
style: 'font-weight: bold',
onclick: () => toggleButtonStates(model, false),
}),
Failure: () => ({ disabled: true, className: 'danger' }),
}), 'Query');

/**
* Group of buttons which allow the user to engage with the download functionality
* * Download queries logs - will create a file containing all logs from the table (visible/hidden)
Expand Down Expand Up @@ -186,28 +244,6 @@ const zoomButtonGroup = (zoom) =>
}, h('span', { style: 'font-size:0.8em' }, iconPlus())),
]);

/**
* Live button final state depends on the following states
* - services lookup
* - services result
* - query lookup
* - websocket status
* @param {Model} model - root model of the application
* @returns {vnode} - the view of the live button
*/
const liveButton = (model) => h('button.btn', model.frameworkInfo.match({
NotAsked: () => ({ disabled: true }),
Loading: () => ({ disabled: true, className: 'loading' }),
Success: (frameworkInfo) => ({
title: frameworkInfo.infoLoggerServer.status.ok ? 'Stream logs with filtering' : 'Live service not configured',
disabled: !frameworkInfo.infoLoggerServer.status.ok || model.log.queryResult.isLoading(),
className: !model.ws.authed ? 'loading' : liveButtonType,
style: 'font-weight: bold',
onclick: () => toggleButtonStates(model, true),
}),
Failure: () => ({ disabled: true, className: 'danger' }),
}), 'Live', ' ', liveButtonIcon);

/**
* Method to toggle states of the buttons(Query/Live) depending on the mode the tool is running on
* @param {Model} model - root model of the application
Expand Down
Loading
Loading