From fcf75b787a431288efaac03d865d178a129c6086 Mon Sep 17 00:00:00 2001 From: Norman Fomferra Date: Mon, 23 Sep 2024 09:19:56 +0200 Subject: [PATCH 1/5] started impl for multiple panels --- dashipy/dashipy/server.py | 40 +++++++++++++++------- dashipy/my-config.yaml | 5 +++ dashipy/{dashipy/panel.py => my_panels.py} | 33 +++++++++++++++++- dashipy/pyproject.toml | 2 ++ 4 files changed, 67 insertions(+), 13 deletions(-) create mode 100644 dashipy/my-config.yaml rename dashipy/{dashipy/panel.py => my_panels.py} (53%) diff --git a/dashipy/dashipy/server.py b/dashipy/dashipy/server.py index ea2edf0e..2b11911c 100644 --- a/dashipy/dashipy/server.py +++ b/dashipy/dashipy/server.py @@ -1,11 +1,13 @@ import asyncio +import importlib +from typing import Callable, Any import tornado import tornado.web import tornado.log +import yaml from dashipy.context import Context -from dashipy.panel import get_panel from dashipy import __version__ @@ -26,30 +28,44 @@ def set_default_headers(self): "authorization,content-type", ) + # GET /panels def get(self): - context: Context = self.settings["context"] - panel = get_panel(context) - self.set_header("Content-Type", "text/json") - self.write(panel.to_dict()) + self.write_panels({}) + # POST /panels def post(self): - context: Context = self.settings["context"] event = tornado.escape.json_decode(self.request.body) - print(event) - event_data = event.get("eventData") or {} - panel = get_panel(context, **event_data) + self.write_panels(event.get("eventData") or {}) + + def write_panels(self, params: dict[str, Any]): + context: Context = self.settings["context"] + panels: list[Callable] = self.settings["panels"] self.set_header("Content-Type", "text/json") - self.write(panel.to_dict()) + self.write({k: v(context, **params).to_dict() for k, v in panels}) def make_app(): + # Read config + with open("my-config.yaml") as f: + server_config = yaml.load(f, yaml.SafeLoader) + + # Parse panel factories + panels = [] + for function_ref in server_config.get("panels", []): + module_name, function_name = function_ref.split(":", maxsplit=2) + module = importlib.import_module(module_name) + function = getattr(module, function_name) + panels.append(function) + + # Create app app = tornado.web.Application( [ (r"/", RootHandler), - (r"/panel", PanelHandler), + (r"/panels", PanelHandler), ] ) app.settings["context"] = Context() + app.settings["panels"] = panels return app @@ -58,7 +74,7 @@ async def main(): port = 8888 app = make_app() app.listen(port) - print(f"Listening http://127.0.0.1:{port}...") + print(f"Listening on http://127.0.0.1:{port}...") shutdown_event = asyncio.Event() await shutdown_event.wait() diff --git a/dashipy/my-config.yaml b/dashipy/my-config.yaml new file mode 100644 index 00000000..0e6ec166 --- /dev/null +++ b/dashipy/my-config.yaml @@ -0,0 +1,5 @@ +panels: + - id: panel_1 + code: "my_panels:get_panel_1" + - id: panel_2 + code: "my_panels:get_panel_2" diff --git a/dashipy/dashipy/panel.py b/dashipy/my_panels.py similarity index 53% rename from dashipy/dashipy/panel.py rename to dashipy/my_panels.py index f10e1276..62c2facc 100644 --- a/dashipy/dashipy/panel.py +++ b/dashipy/my_panels.py @@ -5,7 +5,7 @@ from dashipy.context import Context -def get_panel( +def get_panel_1( context: Context, selected_dataset: int = 0, ) -> Panel: @@ -34,3 +34,34 @@ def get_panel( style={"display": "flex", "flexDirection": "column"}, components=[plot, button_group], ) + + +def get_panel_2( + context: Context, + selected_dataset: int = 0, +) -> Panel: + dataset = context.datasets[selected_dataset] + + fig = go.Figure(layout=Layout(title=f"DS #{selected_dataset + 1}", autosize=True)) + fig.add_trace(go.Line(x=[-1.0, 0.0, 1.0], y=dataset)) + plot = Plot(figure=fig) + + buttons = [ + Button(text=f"DS #{i + 1}", name="selected_dataset", value=i) + for i in range(len(context.datasets)) + ] + button_group = Box( + style={ + "display": "flex", + "flexDirection": "row", + "padding": 4, + "justifyContent": "center", + "gap": 4, + }, + components=buttons, + ) + + return Panel( + style={"display": "flex", "flexDirection": "column"}, + components=[plot, button_group], + ) diff --git a/dashipy/pyproject.toml b/dashipy/pyproject.toml index 04acdc59..0b23cf7e 100644 --- a/dashipy/pyproject.toml +++ b/dashipy/pyproject.toml @@ -18,6 +18,8 @@ keywords = [ license = {text = "MIT"} requires-python = ">=3.10" dependencies = [ + "plotly", + "pyaml", "tornado", ] classifiers = [ From f41ecb8d5822dd1f9cf55c68ca5a4e93f41a5f89 Mon Sep 17 00:00:00 2001 From: Norman Fomferra Date: Mon, 23 Sep 2024 14:58:25 +0200 Subject: [PATCH 2/5] have multiple panels now --- dashi/src/App.tsx | 113 ++++++++++++++++++++++++++--------- dashi/src/api.ts | 27 +++++---- dashi/src/lib/DashiPanel.tsx | 11 +++- dashi/src/lib/model.ts | 13 +++- dashipy/dashipy/server.py | 68 ++++++++++++++------- dashipy/my-config.yaml | 6 +- dashipy/my_panels.py | 4 +- 7 files changed, 168 insertions(+), 74 deletions(-) diff --git a/dashi/src/App.tsx b/dashi/src/App.tsx index 452dde29..2081a547 100644 --- a/dashi/src/App.tsx +++ b/dashi/src/App.tsx @@ -1,51 +1,106 @@ import { ReactElement, useEffect, useState } from "react"; import "./App.css"; -import { EventHandler, PanelModel } from "./lib/model.ts"; -import { fetchPanelInit, fetchPanelUpdate, FetchResponse } from "./api.ts"; +import { PanelEventHandler, PanelModel, Panels } from "./lib/model.ts"; +import { fetchPanels, fetchPanel, FetchResponse } from "./api.ts"; import DashiPanel from "./lib/DashiPanel.tsx"; function App() { - const [panelModelResponse, setPanelModelResponse] = useState< - FetchResponse + const [panelsResponse, setPanelsResponse] = useState>( + {}, + ); + + const [panelResponses, setPanelResponses] = useState< + Record> + >({}); + + const [panelVisibilities, setPanelVisibilities] = useState< + Record >({}); useEffect(() => { - fetchPanelInit().then( - (panelModelResponse) => void setPanelModelResponse(panelModelResponse), - ); + fetchPanels().then(setPanelsResponse); }, []); - const handleEvent: EventHandler = (event) => { - fetchPanelUpdate(event).then( - (panelModelResponse) => void setPanelModelResponse(panelModelResponse), - ); + useEffect(() => { + Object.getOwnPropertyNames(panelVisibilities).forEach((panelId) => { + const panelVisible = panelVisibilities[panelId]; + const panelResponse = panelResponses[panelId]; + if (panelVisible && !panelResponse) { + fetchPanel(panelId).then((panelResponse) => { + setPanelResponses({ ...panelResponses, [panelId]: panelResponse }); + }); + } + }); + }, [panelVisibilities, panelResponses]); + + const handlePanelEvent: PanelEventHandler = (event) => { + fetchPanel(event.panelId, event).then((result) => { + setPanelResponses({ ...panelResponses, [event.panelId]: result }); + }); }; - console.info("panelModelResponse:", panelModelResponse); - - const { result, error } = panelModelResponse; - let panelComponent: ReactElement | undefined; - if (result) { - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { type, ...panelProps } = result; - panelComponent = ( - + let panelSelector: ReactElement; + if (panelsResponse.result) { + const panelIds = panelsResponse.result.panels; + panelSelector = ( +
+ {panelIds.map((panelId) => ( +
+ { + setPanelVisibilities({ + ...panelVisibilities, + [panelId]: e.currentTarget.checked, + }); + }} + /> + +
+ ))} +
); - } else if (error) { - panelComponent =
Error: {error}
; + } else if (panelsResponse.error) { + panelSelector =
Error: {panelsResponse.error}
; } else { - panelComponent =
Loading chart...
; + panelSelector =
Loading panels...
; } + const panelComponents: ReactElement[] = []; + Object.getOwnPropertyNames(panelVisibilities).forEach((panelId) => { + const panelVisible = panelVisibilities[panelId]; + const panelResponse = panelResponses[panelId]; + let panelComponent: ReactElement; + if (panelVisible && panelResponse) { + if (panelResponse.result) { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { type, ...panelProps } = panelResponse.result as PanelModel; + panelComponent = ( + + ); + } else if (panelResponse.error) { + panelComponent =
Error: {panelResponse.error}
; + } else { + panelComponent =
Loading chart...
; + } + panelComponents.push(panelComponent); + } + }); + return ( <>

Dashi Demo

- {panelComponent} + {panelSelector} +
{panelComponents}
); } diff --git a/dashi/src/api.ts b/dashi/src/api.ts index 95c804d8..2d2de9ec 100644 --- a/dashi/src/api.ts +++ b/dashi/src/api.ts @@ -1,24 +1,27 @@ -import { EventData, PanelModel } from "./lib/model.ts"; +import { PanelEventData, PanelModel, Panels } from "./lib/model.ts"; export interface FetchResponse { result?: T; error?: string; } -const API_URL = `http://localhost:8888/panel`; - -export async function fetchPanelInit(): Promise> { - return fetchJson(API_URL); +export async function fetchPanels(): Promise> { + return fetchJson("http://localhost:8888/panels"); } -export async function fetchPanelUpdate( - event: EventData, +export async function fetchPanel( + panelId: string, + event?: PanelEventData, ): Promise> { - return fetchJson(API_URL, { - method: "post", - headers: {}, - body: JSON.stringify(event), - }); + const url = `http://localhost:8888/panels/${panelId}`; + if (event) { + return fetchJson(url, { + method: "post", + body: JSON.stringify(event), + }); + } else { + return fetchJson(url); + } } export async function fetchJson( diff --git a/dashi/src/lib/DashiPanel.tsx b/dashi/src/lib/DashiPanel.tsx index 0767c57c..5119f749 100644 --- a/dashi/src/lib/DashiPanel.tsx +++ b/dashi/src/lib/DashiPanel.tsx @@ -1,13 +1,15 @@ -import { EventHandler, makeId, PanelModel } from "./model"; +import { PanelEventHandler, makeId, PanelModel, EventData } from "./model"; import DashiContainer from "./DashiContainer"; export interface DashiPanelProps extends Omit { + panelId: string; width: number; height: number; - onEvent: EventHandler; + onEvent: PanelEventHandler; } function DashiPanel({ + panelId, width, height, id, @@ -15,9 +17,12 @@ function DashiPanel({ components, onEvent, }: DashiPanelProps) { + const handleEvent = (event: EventData) => { + onEvent({ panelId, ...event }); + }; return (
- +
); } diff --git a/dashi/src/lib/model.ts b/dashi/src/lib/model.ts index 9b33d3da..b1653a43 100644 --- a/dashi/src/lib/model.ts +++ b/dashi/src/lib/model.ts @@ -32,12 +32,16 @@ export interface PlotModel extends ComponentModel { config: Partial; } +export interface BoxModel extends ContainerModel { + type: "box"; +} + export interface PanelModel extends ContainerModel { type: "panel"; } -export interface BoxModel extends ContainerModel { - type: "box"; +export interface Panels { + panels: string[]; } export interface EventData { @@ -47,7 +51,12 @@ export interface EventData { eventData?: T; } +export interface PanelEventData extends EventData { + panelId: string; +} + export type EventHandler = (data: EventData) => void; +export type PanelEventHandler = (data: PanelEventData) => void; export function makeId(type: ComponentType, id: string | undefined) { if (typeof id === "string" && id !== "") { diff --git a/dashipy/dashipy/server.py b/dashipy/dashipy/server.py index 2b11911c..4ab17941 100644 --- a/dashipy/dashipy/server.py +++ b/dashipy/dashipy/server.py @@ -11,13 +11,7 @@ from dashipy import __version__ -class RootHandler(tornado.web.RequestHandler): - def get(self): - self.set_header("Content-Type", "text/plain") - self.write(f"dashi-server {__version__}") - - -class PanelHandler(tornado.web.RequestHandler): +class RequestHandler(tornado.web.RequestHandler): def set_default_headers(self): self.set_header("Access-Control-Allow-Origin", "*") @@ -28,20 +22,42 @@ def set_default_headers(self): "authorization,content-type", ) + +class RootHandler(RequestHandler): + def get(self): + self.set_header("Content-Type", "text/plain") + self.write(f"dashi-server {__version__}") + + +class PanelsHandler(RequestHandler): + # GET /panels def get(self): - self.write_panels({}) + panels: dict[str, Callable] = self.settings["panels"] + self.set_header("Content-Type", "text/json") + self.write({"panels": list(panels.keys())}) + - # POST /panels - def post(self): +class PanelRendererHandler(RequestHandler): + # GET /panels/{panel_id} + def get(self, panel_id: str): + self.render_panels(panel_id, {}) + + # POST /panels/{panelId} + def post(self, panel_id: str): event = tornado.escape.json_decode(self.request.body) - self.write_panels(event.get("eventData") or {}) + panel_props = event.get("eventData") or {} + self.render_panels(panel_id, panel_props) - def write_panels(self, params: dict[str, Any]): + def render_panels(self, panel_id: str, panel_props: dict[str, Any]): context: Context = self.settings["context"] - panels: list[Callable] = self.settings["panels"] - self.set_header("Content-Type", "text/json") - self.write({k: v(context, **params).to_dict() for k, v in panels}) + panels: dict[str, Callable] = self.settings["panels"] + panel_renderer = panels.get(panel_id) + if panel_renderer is not None: + self.set_header("Content-Type", "text/json") + self.write(panel_renderer(context, **panel_props).to_dict()) + else: + self.set_status(404, f"panel not found: {panel_props}") def make_app(): @@ -49,19 +65,27 @@ def make_app(): with open("my-config.yaml") as f: server_config = yaml.load(f, yaml.SafeLoader) - # Parse panel factories - panels = [] - for function_ref in server_config.get("panels", []): - module_name, function_name = function_ref.split(":", maxsplit=2) + # Parse panel renderers + panels: dict[str, Callable] = {} + for panel_ref in server_config.get("panels", []): + try: + module_name, function_name = panel_ref.rsplit(".", maxsplit=2) + except (ValueError, AttributeError): + raise TypeError(f"panel renderer syntax error: {panel_ref!r}") module = importlib.import_module(module_name) - function = getattr(module, function_name) - panels.append(function) + render_function = getattr(module, function_name) + if not callable(render_function): + raise TypeError( + f"panel renderer {panel_ref!r} does not refer to a callable" + ) + panels[panel_ref.replace(".", "-").replace("_", "-").lower()] = render_function # Create app app = tornado.web.Application( [ (r"/", RootHandler), - (r"/panels", PanelHandler), + (r"/panels", PanelsHandler), + (r"/panels/([a-z0-9-]+)", PanelRendererHandler), ] ) app.settings["context"] = Context() diff --git a/dashipy/my-config.yaml b/dashipy/my-config.yaml index 0e6ec166..82700bb1 100644 --- a/dashipy/my-config.yaml +++ b/dashipy/my-config.yaml @@ -1,5 +1,3 @@ panels: - - id: panel_1 - code: "my_panels:get_panel_1" - - id: panel_2 - code: "my_panels:get_panel_2" + - my_panels.render_panel_1 + - my_panels.render_panel_2 diff --git a/dashipy/my_panels.py b/dashipy/my_panels.py index 62c2facc..b2522154 100644 --- a/dashipy/my_panels.py +++ b/dashipy/my_panels.py @@ -5,7 +5,7 @@ from dashipy.context import Context -def get_panel_1( +def render_panel_1( context: Context, selected_dataset: int = 0, ) -> Panel: @@ -36,7 +36,7 @@ def get_panel_1( ) -def get_panel_2( +def render_panel_2( context: Context, selected_dataset: int = 0, ) -> Panel: From 385ae10c5111c8058fe9c70c0822d489e9c3bf7e Mon Sep 17 00:00:00 2001 From: Norman Fomferra Date: Mon, 23 Sep 2024 18:02:24 +0200 Subject: [PATCH 3/5] testing plotly.express --- dashipy/dashipy/lib/plot.py | 7 ++++++- dashipy/environment.yml | 1 + dashipy/my_panels.py | 7 ++++--- dashipy/pyproject.toml | 3 ++- 4 files changed, 13 insertions(+), 5 deletions(-) diff --git a/dashipy/dashipy/lib/plot.py b/dashipy/dashipy/lib/plot.py index a3ab32c0..f1e790c3 100644 --- a/dashipy/dashipy/lib/plot.py +++ b/dashipy/dashipy/lib/plot.py @@ -1,3 +1,4 @@ +import json from typing import Any import plotly.graph_objects as go @@ -20,5 +21,9 @@ def __init__( def to_dict(self) -> dict[str, Any]: return { **super().to_dict(), - **self.figure.to_dict(), + # TODO: this is stupid, but if using self.figure.to_dict() + # for plotly.express figures we get + # TypeError: Object of type ndarray is not JSON serializable + **json.loads(self.figure.to_json()), + # **self.figure.to_dict(), } diff --git a/dashipy/environment.yml b/dashipy/environment.yml index 1ffa8534..46500ced 100644 --- a/dashipy/environment.yml +++ b/dashipy/environment.yml @@ -4,6 +4,7 @@ channels: dependencies: - python # Dependencies + - pandas - plotly - pyaml - tornado diff --git a/dashipy/my_panels.py b/dashipy/my_panels.py index b2522154..2a8e3341 100644 --- a/dashipy/my_panels.py +++ b/dashipy/my_panels.py @@ -1,3 +1,4 @@ +import plotly.express as pe import plotly.graph_objects as go from plotly.graph_objs import Layout @@ -42,9 +43,9 @@ def render_panel_2( ) -> Panel: dataset = context.datasets[selected_dataset] - fig = go.Figure(layout=Layout(title=f"DS #{selected_dataset + 1}", autosize=True)) - fig.add_trace(go.Line(x=[-1.0, 0.0, 1.0], y=dataset)) - plot = Plot(figure=fig) + plot = Plot( + pe.line(x=[-1.0, 0.0, 1.0], y=dataset, title=f"DS #{selected_dataset + 1}") + ) buttons = [ Button(text=f"DS #{i + 1}", name="selected_dataset", value=i) diff --git a/dashipy/pyproject.toml b/dashipy/pyproject.toml index 0b23cf7e..e71dadf3 100644 --- a/dashipy/pyproject.toml +++ b/dashipy/pyproject.toml @@ -18,9 +18,10 @@ keywords = [ license = {text = "MIT"} requires-python = ">=3.10" dependencies = [ + "pandas", "plotly", "pyaml", - "tornado", + "tornado" ] classifiers = [ "Development Status :: 5 - Production/Stable", From eab60ad6bf2086d3169a791f89e3278b56300f81 Mon Sep 17 00:00:00 2001 From: Norman Fomferra Date: Mon, 23 Sep 2024 18:02:32 +0200 Subject: [PATCH 4/5] fixes --- dashi/src/App.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/dashi/src/App.tsx b/dashi/src/App.tsx index 2081a547..e1c4160e 100644 --- a/dashi/src/App.tsx +++ b/dashi/src/App.tsx @@ -43,14 +43,14 @@ function App() { if (panelsResponse.result) { const panelIds = panelsResponse.result.panels; panelSelector = ( -
+
{panelIds.map((panelId) => (
{ + onChange={(e) => { setPanelVisibilities({ ...panelVisibilities, [panelId]: e.currentTarget.checked, From 913dc9374d23f4fc3ea7a22cb84bce24c85afdc1 Mon Sep 17 00:00:00 2001 From: Norman Fomferra Date: Tue, 24 Sep 2024 14:00:54 +0200 Subject: [PATCH 5/5] typo --- dashipy/dashipy/server.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/dashipy/dashipy/server.py b/dashipy/dashipy/server.py index 4ab17941..46b841ed 100644 --- a/dashipy/dashipy/server.py +++ b/dashipy/dashipy/server.py @@ -41,15 +41,15 @@ def get(self): class PanelRendererHandler(RequestHandler): # GET /panels/{panel_id} def get(self, panel_id: str): - self.render_panels(panel_id, {}) + self.render_panel(panel_id, {}) - # POST /panels/{panelId} + # POST /panels/{panel_id} def post(self, panel_id: str): event = tornado.escape.json_decode(self.request.body) panel_props = event.get("eventData") or {} - self.render_panels(panel_id, panel_props) + self.render_panel(panel_id, panel_props) - def render_panels(self, panel_id: str, panel_props: dict[str, Any]): + def render_panel(self, panel_id: str, panel_props: dict[str, Any]): context: Context = self.settings["context"] panels: dict[str, Callable] = self.settings["panels"] panel_renderer = panels.get(panel_id)