From 94a08a86f15d535c5dc34c2165e0dbe80f364ec8 Mon Sep 17 00:00:00 2001 From: Koen Vossen Date: Fri, 24 Nov 2023 22:54:15 +0100 Subject: [PATCH 1/7] Add realtime data --- solarathon/pages/dashboard.py | 151 +++++++++++++++++++++++++--------- 1 file changed, 110 insertions(+), 41 deletions(-) diff --git a/solarathon/pages/dashboard.py b/solarathon/pages/dashboard.py index 705da26..471f906 100644 --- a/solarathon/pages/dashboard.py +++ b/solarathon/pages/dashboard.py @@ -13,14 +13,40 @@ #responsiveness #refactor """ +import threading +from threading import Event +from time import sleep import solara -from typing import Optional +from typing import Optional, cast import requests +import logging +import sys + +# root = logging.getLogger() +# root.setLevel(logging.DEBUG) + # some app state that outlives a single page +from pydantic import BaseModel, Field + +symbols_url = "https://api.binance.com/api/v3/exchangeInfo" +try: + symbols_response = requests.get(symbols_url) + symbols_data = symbols_response.json() + available_symbols = sorted(list(set([symbol['baseAsset'].lower() for symbol in symbols_data['symbols']]))) +except Exception as e: + available_symbols = [] + init_app_state = solara.reactive(["ada", "btc","bnb", "eth","doge", "xrp"]) + +class TickerData(BaseModel): + symbol: str + last_price: float = Field(..., alias="lastPrice") + price_change_percent: float = Field(..., alias="priceChangePercent") + + @solara.component def GeckoIcon (name: str, img: str): with solara.v.Html(tag="a", attributes={"href": f"https://www.binance.com/en/trade", "target": "_blank"}): @@ -34,7 +60,7 @@ def GeckoIcon (name: str, img: str): solara.v.ListItemTitle(children=[name], class_="v-list-item__title_avatar") def processColor(procentChange): - if procentChange.startswith("-"): + if procentChange < 0: return "red" else: return "green" @@ -48,13 +74,40 @@ def format_price(price): formatted_price = '{:,.0f}'.format(float(price)).replace(',', ' ') return formatted_price + +def get_binance_ticket(symbol: str) -> TickerData: + binance_url = f"https://api.binance.com/api/v3/ticker/24hr?symbol={symbol}" + return TickerData.model_validate_json( + json_data=requests.get(binance_url).content + ) + + @solara.component -def DashboardCard(price: str, procentChange: str, icon: solara.Element = None, market_cap: Optional[str] = None, market_cap_change_percentage: Optional[str] = None, pending: Optional[bool] = False): - if pending: - with solara.Card( - GeckoIcon('', '') - , style={"width":"330px", "min-width": "280px","max-width": "350px", "background-color": "#1B2028", "color": "#ffff", "border-radius": "16px", "padding": "20px", "box-shadow": "rgba(0, 0, 0, 0) 0px 0px, rgba(0, 0, 0, 0) 0px 0px, rgba(0, 0, 0, 0.2) 0px 4px 6px -1px, rgba(0, 0, 0, 0.14) 0px 2px 4px -1px"}, margin=0, classes=["my-2", "mx-auto",]): - +def DashboardCard(symbol: str, icon: solara.Element | str = None, market_cap: Optional[str] = None, market_cap_change_percentage: Optional[str] = None, pending: Optional[bool] = False): + ticker_data, set_ticker_data = solara.use_state( + cast(Optional[TickerData], None) + ) + + def fetch_data(event: threading.Event): + while True: + set_ticker_data(get_binance_ticket(symbol)) + if event.wait(10): + print(f"Stopping {symbol}") + return + + # run the render loop in a separate thread + result: solara.Result[bool] = solara.use_thread( + fetch_data, + intrusive_cancel=False + ) + if result.error: + raise result.error + + if not ticker_data: + with solara.Card( + GeckoIcon('', ''), + style={"width":"330px", "min-width": "280px","max-width": "350px", "background-color": "#1B2028", "color": "#ffff", "border-radius": "16px", "padding": "20px", "box-shadow": "rgba(0, 0, 0, 0) 0px 0px, rgba(0, 0, 0, 0) 0px 0px, rgba(0, 0, 0, 0.2) 0px 4px 6px -1px, rgba(0, 0, 0, 0.14) 0px 2px 4px -1px"}, margin=0, classes=["my-2", "mx-auto",]): + with solara.Div(): with solara.Div( style={ @@ -87,30 +140,19 @@ def DashboardCard(price: str, procentChange: str, icon: solara.Element = None, m }, ): with solara.GridFixed(columns=2, justify_items="space-between", align_items="baseline"): - solara.Text(str(f"{decimals(price)}$"), style={"font-size": "1.5rem", "font-weight": 500}) + solara.Text(str(f"{decimals(ticker_data.last_price)}$"), style={"font-size": "1.5rem", "font-weight": 500}) solara.Text(str("price"), style={"font-size": "0.6rem"}) if market_cap is not None: solara.Text(str(f"{format_price(decimals(market_cap))}$"), style={"font-weight": 400}) if market_cap is not None: solara.Text(str("market cap"), style={"font-size": "0.6rem"}) - solara.Text(str(procentChange + "%"), style={"color": processColor(procentChange), "font-weight": 500}) + solara.Text(f"{ticker_data.price_change_percent}%", style={"color": processColor(ticker_data.price_change_percent), "font-weight": 500}) solara.Text(str("24h change price"), style={"font-size": "0.6rem"}) - if market_cap_change_percentage is not None: solara.Text(str(market_cap_change_percentage) + "%", style={"color": processColor(procentChange), "font-weight": 500}) + if market_cap_change_percentage is not None: solara.Text(str(market_cap_change_percentage) + "%", style={"color": processColor(ticker_data.price_change_percent), "font-weight": 500}) if market_cap_change_percentage is not None: solara.Text(str("24h change market cap"), style={"font-size": "0.6rem"}) @solara.component def Page(): - loading, set_loading = solara.use_state(True) default_currency = "USDT" default_echange = "Binance" - symbols_url = "https://api.binance.com/api/v3/exchangeInfo" - coingecko_json_url = "https://api.coingecko.com/api/v3/coins/markets?vs_currency=usd&order=market_cap_desc" - binance_url = "https://api.binance.com/api/v3/ticker/24hr?symbol=" - - try: - symbols_response = requests.get(symbols_url, verify=False) - symbols_data = symbols_response.json() - available_symbols = sorted(list(set([symbol['baseAsset'].lower() for symbol in symbols_data['symbols']]))) - except Exception as e: - available_symbols = [] all_tickers = list(init_app_state.value) + available_symbols @@ -143,25 +185,52 @@ def Page(): ) with solara.GridFixed(columns=3, align_items="end", justify_items="stretch"): - try: - coingecko_response = requests.get(coingecko_json_url, verify=False) - coingecko_data = coingecko_response.json() - for symbol in init_app_state.value: - coingecko_data_for_symbol = get_data_for_symbol(symbol, coingecko_data) - json_url = f"{binance_url}{symbol.upper()}{default_currency}" - response = requests.get(json_url, verify=False) - data = response.json() - - if coingecko_data_for_symbol: - set_loading(False) - DashboardCard(data['lastPrice'], data['priceChangePercent'], GeckoIcon(data['symbol'], coingecko_data_for_symbol['image']), coingecko_data_for_symbol['market_cap'], coingecko_data_for_symbol['market_cap_change_percentage_24h'], loading) - else: - set_loading(False) - DashboardCard(data['lastPrice'], data['priceChangePercent'], data['symbol'], loading) - - except Exception as e: - print(f"An error occurred: {e}") - solara.Error(f"Error {e}") + def get_coingecko_data(): + coingecko_json_url = "https://api.coingecko.com/api/v3/coins/markets?vs_currency=usd&order=market_cap_desc" + + coingecko_response = requests.get(coingecko_json_url) + return coingecko_response.json() + + coingecko_data, set_coingecko_data = solara.use_state(cast(Optional[dict], None)) + + def fetch_data(should_stop: Event): + while True: + set_coingecko_data(get_coingecko_data()) + if should_stop.wait(30): + return + + # run the render loop in a separate thread + result: solara.Result[bool] = solara.use_thread( + fetch_data, + intrusive_cancel=False + ) + if result.error: + raise result.error + + if not coingecko_data: + return + + if 'status' in coingecko_data: + solara.Error(f"Failed to retrieve data: {coingecko_data}") + else: + for symbol in init_app_state.value: + coingecko_data_for_symbol = get_data_for_symbol(symbol, coingecko_data) + + binance_symbol = symbol.upper() + default_currency + + if coingecko_data_for_symbol: + DashboardCard( + binance_symbol, + GeckoIcon(binance_symbol, coingecko_data_for_symbol['image']), + coingecko_data_for_symbol['market_cap'], + coingecko_data_for_symbol['market_cap_change_percentage_24h'], + ) + else: + DashboardCard( + binance_symbol, + binance_symbol + ) + def get_data_for_symbol(symbol, coingecko_list): for coin in coingecko_list: From 3870863821e9e1e78bef177d1f71a135482ec8a0 Mon Sep 17 00:00:00 2001 From: Koen Vossen Date: Fri, 24 Nov 2023 23:06:25 +0100 Subject: [PATCH 2/7] Make it stable --- solarathon/pages/dashboard.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/solarathon/pages/dashboard.py b/solarathon/pages/dashboard.py index 471f906..3cfc0a4 100644 --- a/solarathon/pages/dashboard.py +++ b/solarathon/pages/dashboard.py @@ -224,12 +224,12 @@ def fetch_data(should_stop: Event): GeckoIcon(binance_symbol, coingecko_data_for_symbol['image']), coingecko_data_for_symbol['market_cap'], coingecko_data_for_symbol['market_cap_change_percentage_24h'], - ) + ).key(symbol) else: DashboardCard( binance_symbol, binance_symbol - ) + ).key(symbol) def get_data_for_symbol(symbol, coingecko_list): From 5b2be6feeb498df3246b5e4a8b558e294f99d872 Mon Sep 17 00:00:00 2001 From: Koen Vossen Date: Fri, 24 Nov 2023 23:09:33 +0100 Subject: [PATCH 3/7] Refresh binance data every 5 seconds --- solarathon/pages/dashboard.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/solarathon/pages/dashboard.py b/solarathon/pages/dashboard.py index 3cfc0a4..6f57f10 100644 --- a/solarathon/pages/dashboard.py +++ b/solarathon/pages/dashboard.py @@ -91,7 +91,7 @@ def DashboardCard(symbol: str, icon: solara.Element | str = None, market_cap: Op def fetch_data(event: threading.Event): while True: set_ticker_data(get_binance_ticket(symbol)) - if event.wait(10): + if event.wait(5): print(f"Stopping {symbol}") return From d1c0efec37856b162fcc21d6066aae480b5353fc Mon Sep 17 00:00:00 2001 From: Koen Vossen Date: Fri, 24 Nov 2023 23:13:55 +0100 Subject: [PATCH 4/7] Minor refactor --- solarathon/pages/dashboard.py | 29 ++++++++++++++++++++++------- 1 file changed, 22 insertions(+), 7 deletions(-) diff --git a/solarathon/pages/dashboard.py b/solarathon/pages/dashboard.py index 6f57f10..c7abc5d 100644 --- a/solarathon/pages/dashboard.py +++ b/solarathon/pages/dashboard.py @@ -30,13 +30,6 @@ # some app state that outlives a single page from pydantic import BaseModel, Field -symbols_url = "https://api.binance.com/api/v3/exchangeInfo" -try: - symbols_response = requests.get(symbols_url) - symbols_data = symbols_response.json() - available_symbols = sorted(list(set([symbol['baseAsset'].lower() for symbol in symbols_data['symbols']]))) -except Exception as e: - available_symbols = [] init_app_state = solara.reactive(["ada", "btc","bnb", "eth","doge", "xrp"]) @@ -149,11 +142,33 @@ def fetch_data(event: threading.Event): if market_cap_change_percentage is not None: solara.Text(str(market_cap_change_percentage) + "%", style={"color": processColor(ticker_data.price_change_percent), "font-weight": 500}) if market_cap_change_percentage is not None: solara.Text(str("24h change market cap"), style={"font-size": "0.6rem"}) + +def get_available_symbols(): + symbols_url = "https://api.binance.com/api/v3/exchangeInfo" + try: + symbols_response = requests.get(symbols_url) + symbols_data = symbols_response.json() + available_symbols = sorted(list(set([symbol['baseAsset'].lower() for symbol in symbols_data['symbols']]))) + except Exception as e: + available_symbols = [] + + return available_symbols + + @solara.component def Page(): default_currency = "USDT" default_echange = "Binance" + # Store in state and make sure it won't be refetched when + # component is rerendered. + available_symbols, _ = solara.use_state( + solara.use_memo( + get_available_symbols, + dependencies=[] + ) + ) + all_tickers = list(init_app_state.value) + available_symbols solara.SelectMultiple(f"Tickers from {default_echange}", init_app_state, all_tickers) From 8f5c14c8ad77f103aa216c15c47a2d45749b0487 Mon Sep 17 00:00:00 2001 From: agora Date: Mon, 27 Nov 2023 18:55:23 +0100 Subject: [PATCH 5/7] Fix union type --- solarathon/pages/dashboard.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/solarathon/pages/dashboard.py b/solarathon/pages/dashboard.py index c7abc5d..d44f567 100644 --- a/solarathon/pages/dashboard.py +++ b/solarathon/pages/dashboard.py @@ -23,6 +23,7 @@ import logging import sys +from typing import Union # root = logging.getLogger() # root.setLevel(logging.DEBUG) @@ -76,7 +77,7 @@ def get_binance_ticket(symbol: str) -> TickerData: @solara.component -def DashboardCard(symbol: str, icon: solara.Element | str = None, market_cap: Optional[str] = None, market_cap_change_percentage: Optional[str] = None, pending: Optional[bool] = False): +def DashboardCard(symbol: str, icon: Union[solara.Element, str] = None, market_cap: Optional[str] = None, market_cap_change_percentage: Optional[str] = None, pending: Optional[bool] = False): ticker_data, set_ticker_data = solara.use_state( cast(Optional[TickerData], None) ) From 1b51f4c7a7a9b12d87aa4c4dba134fc3c9ef68e2 Mon Sep 17 00:00:00 2001 From: agora Date: Tue, 28 Nov 2023 01:06:11 +0100 Subject: [PATCH 6/7] feat(dashboard): draggable cards --- solarathon/pages/dashboard.py | 83 +++++++++++++++++++++++------------ 1 file changed, 55 insertions(+), 28 deletions(-) diff --git a/solarathon/pages/dashboard.py b/solarathon/pages/dashboard.py index d44f567..036cb4c 100644 --- a/solarathon/pages/dashboard.py +++ b/solarathon/pages/dashboard.py @@ -10,14 +10,14 @@ TODO: #can be added setting for init basket of tickers #default_currency can be taken from key qoteAsset from api/v3/exchangeInfo -#responsiveness -#refactor """ import threading from threading import Event from time import sleep import solara +from solara.alias import rv + from typing import Optional, cast import requests @@ -39,19 +39,21 @@ class TickerData(BaseModel): symbol: str last_price: float = Field(..., alias="lastPrice") price_change_percent: float = Field(..., alias="priceChangePercent") + high_price: float= Field(..., alias="highPrice") + low_price: float = Field(..., alias="lowPrice") @solara.component def GeckoIcon (name: str, img: str): with solara.v.Html(tag="a", attributes={"href": f"https://www.binance.com/en/trade", "target": "_blank"}): with solara.v.ListItem(class_="pa-0"): - with solara.v.ListItemAvatar(color="grey darken-3"): + with solara.v.ListItemAvatar(color="white"): solara.v.Img( class_="elevation-6", src=img, ) with solara.v.ListItemContent(): - solara.v.ListItemTitle(children=[name], class_="v-list-item__title_avatar") + solara.v.ListItemTitle(children=[name], style_=f"color: white") def processColor(procentChange): if procentChange < 0: @@ -98,10 +100,8 @@ def fetch_data(event: threading.Event): raise result.error if not ticker_data: - with solara.Card( - GeckoIcon('', ''), - style={"width":"330px", "min-width": "280px","max-width": "350px", "background-color": "#1B2028", "color": "#ffff", "border-radius": "16px", "padding": "20px", "box-shadow": "rgba(0, 0, 0, 0) 0px 0px, rgba(0, 0, 0, 0) 0px 0px, rgba(0, 0, 0, 0.2) 0px 4px 6px -1px, rgba(0, 0, 0, 0.14) 0px 2px 4px -1px"}, margin=0, classes=["my-2", "mx-auto",]): - + with rv.Card(style_=f"width: 100%; height: 100%; font-family: sans-serif; padding: 20px 20px; background-color: #1B2028; color: #ffff; border-radius: 16px; box-shadow: rgba(0, 0, 0, 0) 0px 0px, rgba(0, 0, 0, 0) 0px 0px, rgba(0, 0, 0, 0.2) 0px 4px 6px -1px, rgba(0, 0, 0, 0.14) 0px 2px 4px -1px") as main: + rv.CardTitle(children=[GeckoIcon('', '')], style_="padding: 0px 0px; padding-bottom: 5px;") with solara.Div(): with solara.Div( style={ @@ -121,12 +121,11 @@ def fetch_data(event: threading.Event): solara.Text(str("24h change price"), style={"font-size": "0.6rem"}) solara.Text('loading', style={"font-weight": 500}) solara.Text(str("24h change market cap"), style={"font-size": "0.6rem"}) + return main else: - with solara.Card( - icon - , style={"width":"330px", "min-width": "280px","max-width": "350px", "background-color": "#1B2028", "color": "#ffff", "border-radius": "16px", "padding": "20px", "box-shadow": "rgba(0, 0, 0, 0) 0px 0px, rgba(0, 0, 0, 0) 0px 0px, rgba(0, 0, 0, 0.2) 0px 4px 6px -1px, rgba(0, 0, 0, 0.14) 0px 2px 4px -1px"}, margin=0, classes=["my-2", "mx-auto",]): - with solara.Div(): - with solara.Div( + with rv.Card(style_=f"width: 100%; height: 100%; font-family: sans-serif; padding: 20px 20px; background-color: #1B2028; color: #ffff; border-radius: 16px; box-shadow: rgba(0, 0, 0, 0) 0px 0px, rgba(0, 0, 0, 0) 0px 0px, rgba(0, 0, 0, 0.2) 0px 4px 6px -1px, rgba(0, 0, 0, 0.14) 0px 2px 4px -1px") as main: + rv.CardTitle(children=[icon], style_="padding: 0px 0px; padding-bottom: 5px;") + with solara.Div( style={ "display": "inline", "color": "white", @@ -134,7 +133,7 @@ def fetch_data(event: threading.Event): }, ): with solara.GridFixed(columns=2, justify_items="space-between", align_items="baseline"): - solara.Text(str(f"{decimals(ticker_data.last_price)}$"), style={"font-size": "1.5rem", "font-weight": 500}) + solara.Text(str(f"{decimals(ticker_data.last_price)}$"), style={"font-size": "1.5rem", "font-weight": 700}) solara.Text(str("price"), style={"font-size": "0.6rem"}) if market_cap is not None: solara.Text(str(f"{format_price(decimals(market_cap))}$"), style={"font-weight": 400}) if market_cap is not None: solara.Text(str("market cap"), style={"font-size": "0.6rem"}) @@ -142,6 +141,13 @@ def fetch_data(event: threading.Event): solara.Text(str("24h change price"), style={"font-size": "0.6rem"}) if market_cap_change_percentage is not None: solara.Text(str(market_cap_change_percentage) + "%", style={"color": processColor(ticker_data.price_change_percent), "font-weight": 500}) if market_cap_change_percentage is not None: solara.Text(str("24h change market cap"), style={"font-size": "0.6rem"}) + + if market_cap_change_percentage is None: solara.Text(str(f"{ticker_data.high_price}$"), style={"font-size": "0.6rem"}) + if market_cap_change_percentage is None: solara.Text("Height", style={"font-size": "0.6rem"}) + if market_cap_change_percentage is None: solara.Text(str(f"{ticker_data.low_price}$"), style={"font-size": "0.6rem"}) + if market_cap_change_percentage is None: solara.Text("Low", style={"font-size": "0.6rem"}) + + return main def get_available_symbols(): @@ -160,6 +166,12 @@ def get_available_symbols(): def Page(): default_currency = "USDT" default_echange = "Binance" + grid_layout_initial = [ + {"h": 6, "i": "0", "moved": False, "w": 3, "x": 0, "y": 0}, + {"h": 6, "i": "1", "moved": False, "w": 5, "x": 3, "y": 0}, + {"h": 6, "i": "2", "moved": False, "w": 4, "x": 8, "y": 0}, + ] + grid_layout, set_grid_layout = solara.use_state(grid_layout_initial) # Store in state and make sure it won't be refetched when # component is rerendered. @@ -172,13 +184,8 @@ def Page(): all_tickers = list(init_app_state.value) + available_symbols - solara.SelectMultiple(f"Tickers from {default_echange}", init_app_state, all_tickers) - solara.Style( """ - .v-list-item__title_avatar { - color: white !important; - } .v-list-item__content { color: #1B2028; @@ -199,8 +206,13 @@ def Page(): } """ ) + + with solara.VBox() as main: + solara.SelectMultiple(f"Tickers from {default_echange}", init_app_state, all_tickers) + + dashboard_cards = [] + row_widths = [3, 5, 4] - with solara.GridFixed(columns=3, align_items="end", justify_items="stretch"): def get_coingecko_data(): coingecko_json_url = "https://api.coingecko.com/api/v3/coins/markets?vs_currency=usd&order=market_cap_desc" @@ -229,23 +241,38 @@ def fetch_data(should_stop: Event): if 'status' in coingecko_data: solara.Error(f"Failed to retrieve data: {coingecko_data}") else: - for symbol in init_app_state.value: + for i, symbol in enumerate(init_app_state.value): coingecko_data_for_symbol = get_data_for_symbol(symbol, coingecko_data) binance_symbol = symbol.upper() + default_currency if coingecko_data_for_symbol: - DashboardCard( - binance_symbol, - GeckoIcon(binance_symbol, coingecko_data_for_symbol['image']), - coingecko_data_for_symbol['market_cap'], - coingecko_data_for_symbol['market_cap_change_percentage_24h'], - ).key(symbol) + card = DashboardCard( + binance_symbol, + GeckoIcon(binance_symbol, coingecko_data_for_symbol['image']), + coingecko_data_for_symbol['market_cap'], + coingecko_data_for_symbol['market_cap_change_percentage_24h'], + ).key(symbol) + dashboard_cards.append(card) + else: - DashboardCard( + card = DashboardCard( binance_symbol, binance_symbol ).key(symbol) + dashboard_cards.append(card) + + grid_layout_dyn = [ + {"h": 6, "i": str(i), "moved": False, "w": row_widths[i % len(row_widths)], "x": sum(row_widths[:i % len(row_widths)]), "y": (i // len(row_widths)) * 6} + for i in range(len(init_app_state.value)) + ] + + len(dashboard_cards)<= 0 and solara.Error("No data available") + solara.GridDraggable(items=dashboard_cards, grid_layout=grid_layout_dyn, resizable=True, draggable=True, on_grid_layout=set_grid_layout) + + return main + + def get_data_for_symbol(symbol, coingecko_list): From e5c18299186f4cd8d571d7034c25584c8c071010 Mon Sep 17 00:00:00 2001 From: agora Date: Tue, 28 Nov 2023 09:21:14 +0100 Subject: [PATCH 7/7] fix: set grid_layout only once when the component is mounted --- solarathon/pages/dashboard.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/solarathon/pages/dashboard.py b/solarathon/pages/dashboard.py index 036cb4c..33685e3 100644 --- a/solarathon/pages/dashboard.py +++ b/solarathon/pages/dashboard.py @@ -262,13 +262,13 @@ def fetch_data(should_stop: Event): ).key(symbol) dashboard_cards.append(card) - grid_layout_dyn = [ + solara.use_memo(lambda: set_grid_layout([ {"h": 6, "i": str(i), "moved": False, "w": row_widths[i % len(row_widths)], "x": sum(row_widths[:i % len(row_widths)]), "y": (i // len(row_widths)) * 6} for i in range(len(init_app_state.value)) - ] + ]),[dashboard_cards]) len(dashboard_cards)<= 0 and solara.Error("No data available") - solara.GridDraggable(items=dashboard_cards, grid_layout=grid_layout_dyn, resizable=True, draggable=True, on_grid_layout=set_grid_layout) + solara.GridDraggable(items=dashboard_cards, grid_layout=grid_layout, resizable=True, draggable=True, on_grid_layout=set_grid_layout) return main