Merge pull request #60 from tiqi-group/fix/connection_toast_timeout

Fix/connection toast timeout
This commit is contained in:
Mose Müller 2023-11-03 08:52:17 +01:00 committed by GitHub
commit c5beee5d50
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 261 additions and 42 deletions

View File

@ -109,7 +109,6 @@ const reducer = (state: State, action: Action): State => {
throw new Error();
}
};
const App = () => {
const [state, dispatch] = useReducer(reducer, null);
const stateRef = useRef(state); // Declare a reference to hold the current state
@ -150,7 +149,11 @@ const App = () => {
socket.on('disconnect', () => {
setConnectionStatus('disconnected');
setTimeout(() => {
setConnectionStatus('reconnecting');
// Only set "reconnecting" is the state is still "disconnected"
// E.g. when the client has already reconnected
setConnectionStatus((currentState) =>
currentState === 'disconnected' ? 'reconnecting' : currentState
);
}, 2000);
});

View File

@ -28,9 +28,7 @@ export const DataServiceComponent = React.memo(
if (name) {
fullAccessPath = parentPath.concat('.' + name);
}
console.log(fullAccessPath);
const id = getIdFromFullAccessPath(fullAccessPath);
console.log(id);
return (
<div className="dataServiceComponent" id={id}>

View File

@ -1,16 +1,19 @@
from __future__ import annotations
from abc import ABC
from typing import TYPE_CHECKING, Any
from typing import TYPE_CHECKING, Any, Optional
if TYPE_CHECKING:
from .callback_manager import CallbackManager
from .data_service import DataService
from .task_manager import TaskManager
from pydase.data_service.callback_manager import CallbackManager
from pydase.data_service.data_service import DataService
from pydase.data_service.state_manager import StateManager
from pydase.data_service.task_manager import TaskManager
class AbstractDataService(ABC):
__root__: DataService
_task_manager: TaskManager
_callback_manager: CallbackManager
_state_manager: StateManager
_autostart_tasks: dict[str, tuple[Any]]
_filename: Optional[str]

View File

@ -1,14 +1,13 @@
import json
import logging
import os
from enum import Enum
from typing import Any, Optional, cast, get_type_hints
from typing import Any, Optional, get_type_hints
import rpyc
import pydase.units as u
from pydase.data_service.abstract_data_service import AbstractDataService
from pydase.data_service.callback_manager import CallbackManager
from pydase.data_service.state_manager import StateManager
from pydase.data_service.task_manager import TaskManager
from pydase.utils.helpers import (
convert_arguments_to_hinted_types,
@ -41,8 +40,10 @@ def process_callable_attribute(attr: Any, args: dict[str, Any]) -> Any:
class DataService(rpyc.Service, AbstractDataService):
def __init__(self, filename: Optional[str] = None) -> None:
self._filename: Optional[str] = filename
self._callback_manager: CallbackManager = CallbackManager(self)
self._task_manager = TaskManager(self)
self._state_manager = StateManager(self)
if not hasattr(self, "_autostart_tasks"):
self._autostart_tasks = {}
@ -51,12 +52,11 @@ class DataService(rpyc.Service, AbstractDataService):
"""Keep track of the root object. This helps to filter the emission of
notifications."""
self._filename: Optional[str] = filename
self._callback_manager.register_callbacks()
self.__check_instance_classes()
self._initialised = True
self._load_values_from_json()
self._state_manager.load_state()
def __setattr__(self, __name: str, __value: Any) -> None:
# converting attributes that are not properties
@ -129,15 +129,6 @@ class DataService(rpyc.Service, AbstractDataService):
# allow all other attributes
setattr(self, name, value)
def _load_values_from_json(self) -> None:
if self._filename is not None:
# Check if the file specified by the filename exists
if os.path.exists(self._filename):
with open(self._filename, "r") as f:
# Load JSON data from file and update class attributes with these
# values
self.load_DataService_from_JSON(cast(dict[str, Any], json.load(f)))
def write_to_file(self) -> None:
"""
Serialize the DataService instance and write it to a JSON file.
@ -145,14 +136,8 @@ class DataService(rpyc.Service, AbstractDataService):
Args:
filename (str): The name of the file to write to.
"""
if self._filename is not None:
with open(self._filename, "w") as f:
json.dump(self.serialize(), f, indent=4)
else:
logger.error(
f"Class {self.__class__.__name__} was not initialised with a filename. "
'Skipping "write_to_file"...'
)
if self._state_manager is not None:
self._state_manager.save_state()
def load_DataService_from_JSON(self, json_dict: dict[str, Any]) -> None:
# Traverse the serialized representation and set the attributes of the class

View File

@ -0,0 +1,146 @@
import json
import logging
import os
from typing import TYPE_CHECKING, Any, cast
import pydase.units as u
from pydase.utils.helpers import (
generate_paths_from_DataService_dict,
get_nested_value_from_DataService_by_path_and_key,
set_nested_value_in_dict,
)
if TYPE_CHECKING:
from pydase import DataService
logger = logging.getLogger(__name__)
class StateManager:
"""
Manages the state of a DataService instance, serving as both a cache and a
persistence layer. It is designed to provide quick access to the latest known state
for newly connecting web clients without the need for expensive property accesses
that may involve complex calculations or I/O operations.
The StateManager listens for state change notifications from the DataService's
callback manager and updates its cache accordingly. This cache does not always
reflect the most current complex property states but rather retains the value from
the last known state, optimizing for performance and reducing the load on the
system.
While the StateManager ensures that the cached state is as up-to-date as possible,
it does not autonomously update complex properties of the DataService. Such
properties must be updated programmatically, for instance, by invoking specific
tasks or methods that trigger the necessary operations to refresh their state.
The cached state maintained by the StateManager is particularly useful for web
clients that connect to the system and need immediate access to the current state of
the DataService. By avoiding direct and potentially costly property accesses, the
StateManager provides a snapshot of the DataService's state that is sufficiently
accurate for initial rendering and interaction.
Attributes:
cache (dict[str, Any]):
A dictionary cache of the DataService's state.
filename (str):
The file name used for storing the DataService's state.
service (DataService):
The DataService instance whose state is being managed.
Note:
The StateManager's cache updates are triggered by notifications and do not
include autonomous updates of complex DataService properties, which must be
managed programmatically. The cache serves the purpose of providing immediate
state information to web clients, reflecting the state after the last property
update.
"""
def __init__(self, service: "DataService"):
self.cache: dict[str, Any] = {} # Initialize an empty cache
self.filename = service._filename
self.service = service
self.service._callback_manager.add_notification_callback(self.update_cache)
def update_cache(self, parent_path: str, name: str, value: Any) -> None:
# Remove the part before the first "." in the parent_path
parent_path = ".".join(parent_path.split(".")[1:])
# Construct the full path
full_path = f"{parent_path}.{name}" if parent_path else name
set_nested_value_in_dict(self.cache, full_path, value)
def save_state(self) -> None:
"""
Serialize the DataService instance and write it to a JSON file.
Args:
filename (str): The name of the file to write to.
"""
if self.filename is not None:
with open(self.filename, "w") as f:
json.dump(self.cache, f, indent=4)
else:
logger.error(
f"Class {self.__class__.__name__} was not initialised with a filename. "
'Skipping "write_to_file"...'
)
def load_state(self) -> None:
# Traverse the serialized representation and set the attributes of the class
if self.cache == {}:
self.cache = self.service.serialize()
json_dict = self._load_state_from_file()
if json_dict == {}:
logger.debug("Could not load the service state.")
return
serialized_class = self.cache
for path in generate_paths_from_DataService_dict(json_dict):
value = get_nested_value_from_DataService_by_path_and_key(
json_dict, path=path
)
value_type = get_nested_value_from_DataService_by_path_and_key(
json_dict, path=path, key="type"
)
class_value_type = get_nested_value_from_DataService_by_path_and_key(
serialized_class, path=path, key="type"
)
if class_value_type == value_type:
class_attr_is_read_only = (
get_nested_value_from_DataService_by_path_and_key(
serialized_class, path=path, key="readonly"
)
)
if class_attr_is_read_only:
logger.debug(
f'Attribute "{path}" is read-only. Ignoring value from JSON '
"file..."
)
continue
# Split the path into parts
parts = path.split(".")
attr_name = parts[-1]
# Convert dictionary into Quantity
if class_value_type == "Quantity":
value = u.convert_to_quantity(value)
self.service.update_DataService_attribute(parts[:-1], attr_name, value)
else:
logger.info(
f'Attribute type of "{path}" changed from "{value_type}" to '
f'"{class_value_type}". Ignoring value from JSON file...'
)
def _load_state_from_file(self) -> dict[str, Any]:
if self.filename is not None:
# Check if the file specified by the filename exists
if os.path.exists(self.filename):
with open(self.filename, "r") as f:
# Load JSON data from file and update class attributes with these
# values
return cast(dict[str, Any], json.load(f))
return {}

View File

@ -1,13 +1,13 @@
{
"files": {
"main.css": "/static/css/main.c444b055.css",
"main.js": "/static/js/main.964bc334.js",
"main.js": "/static/js/main.16698eff.js",
"index.html": "/index.html",
"main.c444b055.css.map": "/static/css/main.c444b055.css.map",
"main.964bc334.js.map": "/static/js/main.964bc334.js.map"
"main.16698eff.js.map": "/static/js/main.16698eff.js.map"
},
"entrypoints": [
"static/css/main.c444b055.css",
"static/js/main.964bc334.js"
"static/js/main.16698eff.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.964bc334.js"></script><link href="/static/css/main.c444b055.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.16698eff.js"></script><link href="/static/css/main.c444b055.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

View File

@ -323,8 +323,8 @@ class Server:
logger.info("Shutting down")
logger.info(f"Saving data to {self._service._filename}.")
if self._service._filename is not None:
self._service.write_to_file()
if self._service._state_manager is not None:
self._service._state_manager.save_state()
await self.__cancel_servers()
await self.__cancel_tasks()

View File

@ -114,7 +114,7 @@ class WebAPI:
@app.get("/service-properties")
def service_properties() -> dict[str, Any]:
return self.service.serialize()
return self.service._state_manager.cache
# exposing custom.css file provided by user
if self.css is not None:

View File

@ -272,6 +272,90 @@ def get_nested_value_from_DataService_by_path_and_key(
return current_data.get(key, None)
def set_nested_value_in_dict(data_dict: dict[str, Any], path: str, value: Any) -> None:
"""
Set the value associated with a specific key in a dictionary given a path.
This function traverses the dictionary according to the path provided and
sets the value at that path. The path is a string with dots connecting
the levels and brackets indicating list indices.
Args:
cache (dict): The cache dictionary to set the value in.
path (str): The path to where the value should be set in the dictionary.
value (Any): The value to be set at the specified path in the dictionary.
Examples:
Let's consider the following dictionary:
cache = {
"attr1": {"type": "int", "value": 10},
"attr2": {
"type": "MyClass",
"value": {"attr3": {"type": "float", "value": 20.5}}
}
}
The function can be used to set the value of 'attr1' as follows:
set_nested_value_in_cache(cache, "attr1", 15)
It can also be used to set the value of 'attr3', which is nested within 'attr2',
as follows:
set_nested_value_in_cache(cache, "attr2.attr3", 25.0)
"""
parts = path.split(".")
current_dict: dict[str, Any] = data_dict
index: Optional[int] = None
for attr_name in parts:
# Check if the key contains an index part like '[<index>]'
if "[" in attr_name and attr_name.endswith("]"):
attr_name, index_part = attr_name.split("[", 1)
index_part = index_part.rstrip("]") # remove the closing bracket
# Convert the index part to an integer
if index_part.isdigit():
index = int(index_part)
else:
logger.error(f"Invalid index format in key: {attr_name}")
current_dict = cast(dict[str, Any], current_dict.get(attr_name, None))
if not isinstance(current_dict, dict):
# key does not exist in dictionary, e.g. when class does not have this
# attribute
return
if index is not None:
if 0 <= index < len(current_dict["value"]):
try:
current_dict = cast(dict[str, Any], current_dict["value"][index])
except Exception as e:
logger.error(f"Could not change {path}. Exception: {e}")
return
else:
# TODO: appending to a list will probably be done here
logger.error(f"Could not change {path}...")
return
# When the attribute is a class instance, the attributes are nested in the
# "value" key
if (
current_dict["type"] not in STANDARD_TYPES
and current_dict["type"] != "method"
):
current_dict = cast(dict[str, Any], current_dict.get("value", None)) # type: ignore
index = None
# setting the new value
try:
current_dict["value"] = value
except Exception as e:
logger.error(e)
def convert_arguments_to_hinted_types(
args: dict[str, Any], type_hints: dict[str, Any]
) -> dict[str, Any] | str: