Merge pull request #73 from tiqi-group/feat/notify_frontend_about_logged_errors

Adds capability of notifying frontend about logged errors
This commit is contained in:
Mose Müller 2023-11-27 16:17:33 +01:00 committed by GitHub
commit d0869b707b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
28 changed files with 153 additions and 159 deletions

View File

@ -3,19 +3,12 @@
"autoDocstring.startOnNewLine": true,
"autoDocstring.generateDocstringOnEnter": true,
"editor.formatOnSave": true,
"editor.codeActionsOnSave": {
"source.fixAll.eslint": true
},
"editor.rulers": [
88
],
"python.defaultInterpreterPath": ".venv/bin/python",
"python.formatting.provider": "black",
"python.linting.lintOnSave": true,
"python.linting.enabled": true,
"python.linting.flake8Enabled": true,
"python.linting.mypyEnabled": true,
"[python]": {
"editor.defaultFormatter": "ms-python.black-formatter",
"editor.tabSize": 4,
"editor.detectIndentation": false,
"editor.codeActionsOnSave": {

View File

@ -1,6 +1,6 @@
body {
min-width: 576px;
max-width: 1200px;
max-width: 2000px;
}
input.instantUpdate {
background-color: rgba(255, 0, 0, 0.1);
@ -17,10 +17,13 @@ input.instantUpdate {
position: fixed !important;
padding: 5px;
}
.notificationToast {
.debugToast, .infoToast {
background-color: rgba(114, 214, 253, 0.5) !important;
}
.exceptionToast {
.warningToast {
background-color: rgba(255, 181, 44, 0.603) !important;
}
.errorToast, .criticalToast {
background-color: rgba(216, 41, 18, 0.678) !important;
}
.buttonComponent {

View File

@ -6,7 +6,11 @@ import {
DataServiceJSON
} from './components/DataServiceComponent';
import './App.css';
import { Notifications } from './components/NotificationsComponent';
import {
Notifications,
Notification,
LevelName
} from './components/NotificationsComponent';
import { ConnectionToast } from './components/ConnectionToast';
import { SerializedValue, setNestedValueByPath, State } from './utils/stateUtils';
@ -21,8 +25,9 @@ type Action =
type UpdateMessage = {
data: { parent_path: string; name: string; value: SerializedValue };
};
type ExceptionMessage = {
data: { exception: string; type: string };
type LogMessage = {
levelname: LevelName;
message: string;
};
const reducer = (state: State, action: Action): State => {
@ -45,8 +50,7 @@ const App = () => {
const [isInstantUpdate, setIsInstantUpdate] = useState(false);
const [showSettings, setShowSettings] = useState(false);
const [showNotification, setShowNotification] = useState(false);
const [notifications, setNotifications] = useState([]);
const [exceptions, setExceptions] = useState([]);
const [notifications, setNotifications] = useState<Notification[]>([]);
const [connectionStatus, setConnectionStatus] = useState('connecting');
// Keep the state reference up to date
@ -88,51 +92,38 @@ const App = () => {
});
socket.on('notify', onNotify);
socket.on('exception', onException);
socket.on('log', onLogMessage);
return () => {
socket.off('notify', onNotify);
socket.off('exception', onException);
socket.off('log', onLogMessage);
};
}, []);
// Adding useCallback to prevent notify to change causing a re-render of all
// components
const addNotification = useCallback((text: string) => {
// Getting the current time in the required format
const timeString = new Date().toISOString().substring(11, 19);
// Adding an id to the notification to provide a way of removing it
const id = Math.random();
const addNotification = useCallback(
(message: string, levelname: LevelName = 'DEBUG') => {
// Getting the current time in the required format
const timeStamp = new Date().toISOString().substring(11, 19);
// Adding an id to the notification to provide a way of removing it
const id = Math.random();
// Custom logic for notifications
setNotifications((prevNotifications) => [
{ id, text, time: timeString },
...prevNotifications
]);
}, []);
// Custom logic for notifications
setNotifications((prevNotifications) => [
{ levelname, id, message, timeStamp },
...prevNotifications
]);
},
[]
);
const notifyException = (text: string) => {
// Getting the current time in the required format
const timeString = new Date().toISOString().substring(11, 19);
// Adding an id to the notification to provide a way of removing it
const id = Math.random();
// Custom logic for notifications
setExceptions((prevNotifications) => [
{ id, text, time: timeString },
...prevNotifications
]);
};
const removeNotificationById = (id: number) => {
setNotifications((prevNotifications) =>
prevNotifications.filter((n) => n.id !== id)
);
};
const removeExceptionById = (id: number) => {
setExceptions((prevNotifications) => prevNotifications.filter((n) => n.id !== id));
};
const handleCloseSettings = () => setShowSettings(false);
const handleShowSettings = () => setShowSettings(true);
@ -149,9 +140,8 @@ const App = () => {
});
}
function onException(value: ExceptionMessage) {
const newException = `${value.data.type}: ${value.data.exception}.`;
notifyException(newException);
function onLogMessage(value: LogMessage) {
addNotification(value.message, value.levelname);
}
// While the data is loading
@ -170,9 +160,7 @@ const App = () => {
<Notifications
showNotification={showNotification}
notifications={notifications}
exceptions={exceptions}
removeNotificationById={removeNotificationById}
removeExceptionById={removeExceptionById}
/>
<Offcanvas

View File

@ -3,6 +3,7 @@ import { runMethod } from '../socket';
import { InputGroup, Form, Button } from 'react-bootstrap';
import { DocStringComponent } from './DocStringComponent';
import { getIdFromFullAccessPath } from '../utils/stringUtils';
import { LevelName } from './NotificationsComponent';
interface AsyncMethodProps {
name: string;
@ -11,7 +12,7 @@ interface AsyncMethodProps {
value: Record<string, string>;
docString?: string;
hideOutput?: boolean;
addNotification: (message: string) => void;
addNotification: (message: string, levelname?: LevelName) => void;
}
export const AsyncMethodComponent = React.memo((props: AsyncMethodProps) => {

View File

@ -3,6 +3,7 @@ import { ToggleButton } from 'react-bootstrap';
import { setAttribute } from '../socket';
import { DocStringComponent } from './DocStringComponent';
import { getIdFromFullAccessPath } from '../utils/stringUtils';
import { LevelName } from './NotificationsComponent';
interface ButtonComponentProps {
name: string;
@ -11,7 +12,7 @@ interface ButtonComponentProps {
readOnly: boolean;
docString: string;
mapping?: [string, string]; // Enforce a tuple of two strings
addNotification: (message: string) => void;
addNotification: (message: string, levelname?: LevelName) => void;
}
export const ButtonComponent = React.memo((props: ButtonComponentProps) => {

View File

@ -3,6 +3,7 @@ import { InputGroup, Form, Row, Col } from 'react-bootstrap';
import { setAttribute } from '../socket';
import { DocStringComponent } from './DocStringComponent';
import { getIdFromFullAccessPath } from '../utils/stringUtils';
import { LevelName } from './NotificationsComponent';
interface ColouredEnumComponentProps {
name: string;
@ -11,7 +12,7 @@ interface ColouredEnumComponentProps {
docString?: string;
readOnly: boolean;
enumDict: Record<string, string>;
addNotification: (message: string) => void;
addNotification: (message: string, levelname?: LevelName) => void;
}
export const ColouredEnumComponent = React.memo((props: ColouredEnumComponentProps) => {

View File

@ -4,13 +4,14 @@ import { Card, Collapse } from 'react-bootstrap';
import { ChevronDown, ChevronRight } from 'react-bootstrap-icons';
import { Attribute, GenericComponent } from './GenericComponent';
import { getIdFromFullAccessPath } from '../utils/stringUtils';
import { LevelName } from './NotificationsComponent';
type DataServiceProps = {
name: string;
props: DataServiceJSON;
parentPath?: string;
isInstantUpdate: boolean;
addNotification: (message: string) => void;
addNotification: (message: string, levelname?: LevelName) => void;
};
export type DataServiceJSON = Record<string, Attribute>;

View File

@ -2,6 +2,7 @@ import React, { useEffect, useRef } from 'react';
import { InputGroup, Form, Row, Col } from 'react-bootstrap';
import { setAttribute } from '../socket';
import { DocStringComponent } from './DocStringComponent';
import { LevelName } from './NotificationsComponent';
interface EnumComponentProps {
name: string;
@ -9,7 +10,7 @@ interface EnumComponentProps {
value: string;
docString?: string;
enumDict: Record<string, string>;
addNotification: (message: string) => void;
addNotification: (message: string, levelname?: LevelName) => void;
}
export const EnumComponent = React.memo((props: EnumComponentProps) => {

View File

@ -10,6 +10,7 @@ import { ListComponent } from './ListComponent';
import { DataServiceComponent, DataServiceJSON } from './DataServiceComponent';
import { ImageComponent } from './ImageComponent';
import { ColouredEnumComponent } from './ColouredEnumComponent';
import { LevelName } from './NotificationsComponent';
type AttributeType =
| 'str'
@ -40,7 +41,7 @@ type GenericComponentProps = {
name: string;
parentPath: string;
isInstantUpdate: boolean;
addNotification: (message: string) => void;
addNotification: (message: string, levelname?: LevelName) => void;
};
export const GenericComponent = React.memo(

View File

@ -3,6 +3,7 @@ import { Card, Collapse, Image } from 'react-bootstrap';
import { DocStringComponent } from './DocStringComponent';
import { ChevronDown, ChevronRight } from 'react-bootstrap-icons';
import { getIdFromFullAccessPath } from '../utils/stringUtils';
import { LevelName } from './NotificationsComponent';
interface ImageComponentProps {
name: string;
@ -11,7 +12,7 @@ interface ImageComponentProps {
readOnly: boolean;
docString: string;
format: string;
addNotification: (message: string) => void;
addNotification: (message: string, levelname?: LevelName) => void;
}
export const ImageComponent = React.memo((props: ImageComponentProps) => {

View File

@ -2,6 +2,7 @@ import React, { useEffect, useRef } from 'react';
import { DocStringComponent } from './DocStringComponent';
import { Attribute, GenericComponent } from './GenericComponent';
import { getIdFromFullAccessPath } from '../utils/stringUtils';
import { LevelName } from './NotificationsComponent';
interface ListComponentProps {
name: string;
@ -9,7 +10,7 @@ interface ListComponentProps {
value: Attribute[];
docString: string;
isInstantUpdate: boolean;
addNotification: (message: string) => void;
addNotification: (message: string, levelname?: LevelName) => void;
}
export const ListComponent = React.memo((props: ListComponentProps) => {

View File

@ -3,6 +3,7 @@ import { runMethod } from '../socket';
import { Button, InputGroup, Form, Collapse } from 'react-bootstrap';
import { DocStringComponent } from './DocStringComponent';
import { getIdFromFullAccessPath } from '../utils/stringUtils';
import { LevelName } from './NotificationsComponent';
interface MethodProps {
name: string;
@ -10,7 +11,7 @@ interface MethodProps {
parameters: Record<string, string>;
docString?: string;
hideOutput?: boolean;
addNotification: (message: string) => void;
addNotification: (message: string, levelname?: LevelName) => void;
}
export const MethodComponent = React.memo((props: MethodProps) => {

View File

@ -1,70 +1,71 @@
import React from 'react';
import { ToastContainer, Toast } from 'react-bootstrap';
export type LevelName = 'CRITICAL' | 'ERROR' | 'WARNING' | 'INFO' | 'DEBUG';
export type Notification = {
id: number;
time: string;
text: string;
timeStamp: string;
message: string;
levelname: LevelName;
};
type NotificationProps = {
showNotification: boolean;
notifications: Notification[];
exceptions: Notification[];
removeNotificationById: (id: number) => void;
removeExceptionById: (id: number) => void;
};
export const Notifications = React.memo((props: NotificationProps) => {
const {
showNotification,
notifications,
exceptions,
removeExceptionById,
removeNotificationById
} = props;
const { showNotification, notifications, removeNotificationById } = props;
return (
<ToastContainer className="navbarOffset toastContainer" position="top-end">
{showNotification &&
notifications.map((notification) => (
{notifications.map((notification) => {
// Determine if the toast should be shown
const shouldShow =
notification.levelname === 'ERROR' ||
notification.levelname === 'CRITICAL' ||
(showNotification &&
['WARNING', 'INFO', 'DEBUG'].includes(notification.levelname));
if (!shouldShow) {
return null;
}
return (
<Toast
className="notificationToast"
className={notification.levelname.toLowerCase() + 'Toast'}
key={notification.id}
onClose={() => removeNotificationById(notification.id)}
onClick={() => {
removeNotificationById(notification.id);
}}
onClick={() => removeNotificationById(notification.id)}
onMouseLeave={() => {
removeNotificationById(notification.id);
if (notification.levelname !== 'ERROR') {
removeNotificationById(notification.id);
}
}}
show={true}
autohide={true}
delay={2000}>
<Toast.Header closeButton={false} className="notificationToast text-right">
<strong className="me-auto">Notification</strong>
<small>{notification.time}</small>
autohide={
notification.levelname === 'WARNING' ||
notification.levelname === 'INFO' ||
notification.levelname === 'DEBUG'
}
delay={
notification.levelname === 'WARNING' ||
notification.levelname === 'INFO' ||
notification.levelname === 'DEBUG'
? 2000
: undefined
}>
<Toast.Header
closeButton={false}
className={notification.levelname.toLowerCase() + 'Toast text-right'}>
<strong className="me-auto">{notification.levelname}</strong>
<small>{notification.timeStamp}</small>
</Toast.Header>
<Toast.Body>{notification.text}</Toast.Body>
<Toast.Body>{notification.message}</Toast.Body>
</Toast>
))}
{exceptions.map((exception) => (
<Toast
className="exceptionToast"
key={exception.id}
onClose={() => removeExceptionById(exception.id)}
onClick={() => {
removeExceptionById(exception.id);
}}
show={true}
autohide={false}>
<Toast.Header closeButton className="exceptionToast text-right">
<strong className="me-auto">Exception</strong>
<small>{exception.time}</small>
</Toast.Header>
<Toast.Body>{exception.text}</Toast.Body>
</Toast>
))}
);
})}
</ToastContainer>
);
});

View File

@ -4,6 +4,7 @@ import { setAttribute } from '../socket';
import { DocStringComponent } from './DocStringComponent';
import '../App.css';
import { getIdFromFullAccessPath } from '../utils/stringUtils';
import { LevelName } from './NotificationsComponent';
// TODO: add button functionality
@ -23,7 +24,7 @@ interface NumberComponentProps {
value: number,
callback?: (ack: unknown) => void
) => void;
addNotification: (message: string) => void;
addNotification: (message: string, levelname?: LevelName) => void;
}
// TODO: highlight the digit that is being changed by setting both selectionStart and

View File

@ -5,6 +5,7 @@ import { DocStringComponent } from './DocStringComponent';
import { Slider } from '@mui/material';
import { NumberComponent } from './NumberComponent';
import { getIdFromFullAccessPath } from '../utils/stringUtils';
import { LevelName } from './NotificationsComponent';
interface SliderComponentProps {
name: string;
@ -16,7 +17,7 @@ interface SliderComponentProps {
docString: string;
stepSize: number;
isInstantUpdate: boolean;
addNotification: (message: string) => void;
addNotification: (message: string, levelname?: LevelName) => void;
}
export const SliderComponent = React.memo((props: SliderComponentProps) => {

View File

@ -4,6 +4,7 @@ import { setAttribute } from '../socket';
import { DocStringComponent } from './DocStringComponent';
import '../App.css';
import { getIdFromFullAccessPath } from '../utils/stringUtils';
import { LevelName } from './NotificationsComponent';
// TODO: add button functionality
@ -14,7 +15,7 @@ interface StringComponentProps {
readOnly: boolean;
docString: string;
isInstantUpdate: boolean;
addNotification: (message: string) => void;
addNotification: (message: string, levelname?: LevelName) => void;
}
export const StringComponent = React.memo((props: StringComponentProps) => {

View File

@ -106,43 +106,3 @@ function parseListAttrAndIndex(attrString: string): [string, number | null] {
return [attrName, index];
}
const serializationDict = {
attr_list: {
type: 'list',
value: [
{ type: 'int', value: 1, readonly: false, doc: null },
{ type: 'int', value: 2, readonly: false, doc: null },
{
type: 'Quantity',
value: { magnitude: 1.0, unit: 'ms' },
readonly: false,
doc: null
}
],
readonly: false,
doc: null
},
read_sensor_data: {
type: 'method',
value: null,
readonly: true,
doc: null,
async: true,
parameters: {}
},
readout_wait_time: {
type: 'Quantity',
value: { magnitude: 1.0, unit: 'ms' },
readonly: false,
doc: null
}
};
const attrName: string = 'attr_list[2]'; // example attribute name
try {
const result = getNextLevelDictByKey(serializationDict, attrName);
console.log(result);
} catch (error) {
console.error(error);
}

View File

@ -1,13 +1,13 @@
{
"files": {
"main.css": "/static/css/main.c0fa0427.css",
"main.js": "/static/js/main.e9762f7d.js",
"main.css": "/static/css/main.2d8458eb.css",
"main.js": "/static/js/main.08fc7255.js",
"index.html": "/index.html",
"main.c0fa0427.css.map": "/static/css/main.c0fa0427.css.map",
"main.e9762f7d.js.map": "/static/js/main.e9762f7d.js.map"
"main.2d8458eb.css.map": "/static/css/main.2d8458eb.css.map",
"main.08fc7255.js.map": "/static/js/main.08fc7255.js.map"
},
"entrypoints": [
"static/css/main.c0fa0427.css",
"static/js/main.e9762f7d.js"
"static/css/main.2d8458eb.css",
"static/js/main.08fc7255.js"
]
}

View File

@ -1 +1 @@
<!doctype html><html lang="en"><head><meta charset="utf-8"/><link rel="icon" href="/favicon.ico"/><meta name="viewport" content="width=device-width,initial-scale=1"/><meta name="theme-color" content="#000000"/><meta name="description" content="Web site displaying a pydase UI."/><link rel="apple-touch-icon" href="/logo192.png"/><link rel="manifest" href="/manifest.json"/><title>pydase App</title><script defer="defer" src="/static/js/main.e9762f7d.js"></script><link href="/static/css/main.c0fa0427.css" rel="stylesheet"></head><body><noscript>You need to enable JavaScript to run this app.</noscript><div id="root"></div></body></html>
<!doctype html><html lang="en"><head><meta charset="utf-8"/><link rel="icon" href="/favicon.ico"/><meta name="viewport" content="width=device-width,initial-scale=1"/><meta name="theme-color" content="#000000"/><meta name="description" content="Web site displaying a pydase UI."/><link rel="apple-touch-icon" href="/logo192.png"/><link rel="manifest" href="/manifest.json"/><title>pydase App</title><script defer="defer" src="/static/js/main.08fc7255.js"></script><link href="/static/css/main.2d8458eb.css" rel="stylesheet"></head><body><noscript>You need to enable JavaScript to run this app.</noscript><div id="root"></div></body></html>

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -12,6 +12,7 @@ from pydase import DataService
from pydase.data_service.data_service import process_callable_attribute
from pydase.data_service.state_manager import StateManager
from pydase.utils.helpers import get_object_attr_from_path_list
from pydase.utils.logging import SocketIOHandler
from pydase.version import __version__
logger = logging.getLogger(__name__)
@ -91,6 +92,11 @@ class WebAPI:
self.setup_socketio()
self.setup_fastapi_app()
self.setup_logging_handler()
def setup_logging_handler(self) -> None:
logger = logging.getLogger()
logger.addHandler(SocketIOHandler(self.__sio))
def setup_socketio(self) -> None:
# the socketio ASGI app, to notify clients when params update

View File

@ -1,8 +1,10 @@
import asyncio
import logging
import sys
from copy import copy
from typing import Optional
import socketio
import uvicorn.logging
from uvicorn.config import LOGGING_CONFIG
@ -34,6 +36,35 @@ class DefaultFormatter(uvicorn.logging.ColourizedFormatter):
return sys.stderr.isatty() # pragma: no cover
class SocketIOHandler(logging.Handler):
"""
Custom logging handler that emits ERROR and CRITICAL log records to a Socket.IO
server, allowing for real-time logging in applications that use Socket.IO for
communication.
"""
def __init__(self, sio: socketio.AsyncServer) -> None:
super().__init__(logging.ERROR)
self._sio = sio
def format(self, record: logging.LogRecord) -> str:
return f"{record.name}:{record.funcName}:{record.lineno} - {record.message}"
def emit(self, record: logging.LogRecord) -> None:
log_entry = self.format(record)
loop = asyncio.get_event_loop()
loop.create_task(
self._sio.emit( # type: ignore[reportUnknownMemberType]
"log",
{
"levelname": record.levelname,
"message": log_entry,
},
)
)
def setup_logging(level: Optional[str | int] = None) -> None:
"""
Configures the logging settings for the application.