4 Commits

Author SHA1 Message Date
2c5fe004f5 Improving lazing loading. The scrollbars updates itself 2025-08-22 00:09:59 +02:00
9cf0e5e26a Trying things 2025-08-18 06:59:25 +02:00
67abb45804 Working on improving the perf 2025-08-10 17:42:54 +02:00
5820efb7f1 Updating git 2025-08-10 11:29:39 +02:00
13 changed files with 610 additions and 59 deletions

1
.gitignore vendored
View File

@@ -13,6 +13,7 @@ tools.db
.mytools_db .mytools_db
.idea/MyManagingTools.iml .idea/MyManagingTools.iml
.idea/misc.xml .idea/misc.xml
**/*.prof
# Created by .ignore support plugin (hsz.mobi) # Created by .ignore support plugin (hsz.mobi)
### Python template ### Python template

View File

@@ -18,7 +18,9 @@ clean:
rm -rf Untitled*.ipynb rm -rf Untitled*.ipynb
rm -rf .ipynb_checkpoints rm -rf .ipynb_checkpoints
rm -rf src/tools.db rm -rf src/tools.db
rm -rf src/*.out
rm -rf src/*.prof
find . -name '.sesskey' -exec rm -rf {} + find . -name '.sesskey' -exec rm -rf {} +
find . -name '.pytest_cache' -exec rm -rf {} + find . -name '.pytest_cache' -exec rm -rf {} +
find . -name '__pycache__' -exec rm -rf {} + find . -name '__pycache__' -exec rm -rf {} +
find . -name 'debug.txt' -exec rm -rf {} find . -name 'debug.txt' -exec rm -rf {}

View File

@@ -34,4 +34,11 @@ docker-compose down
1. **Rebuild**: 1. **Rebuild**:
```shell ```shell
docker-compose build docker-compose build
```
# Profiling
```shell
cd src
python -m cProfile -o profile.out main.py
snakeviz profile.out # 'pip install snakeviz' if snakeviz is not installed
``` ```

View File

@@ -25,11 +25,19 @@ function bindTooltipsWithDelegation() {
// Add a single mouseenter and mouseleave listener to the parent element // Add a single mouseenter and mouseleave listener to the parent element
element.addEventListener("mouseenter", (event) => { element.addEventListener("mouseenter", (event) => {
//console.debug("Entering element", event.target)
const cell = event.target.closest("[data-tooltip]"); const cell = event.target.closest("[data-tooltip]");
if (!cell) return; if (!cell) {
// console.debug(" No 'data-tooltip' attribute found. Stopping.");
return;
}
const no_tooltip = element.hasAttribute("mmt-no-tooltip"); const no_tooltip = element.hasAttribute("mmt-no-tooltip");
if (no_tooltip) return; if (no_tooltip) {
// console.debug(" Attribute 'mmt-no-tooltip' found. Cancelling.");
return;
}
const content = cell.querySelector(".truncate") || cell; const content = cell.querySelector(".truncate") || cell;
const isOverflowing = content.scrollWidth > content.clientWidth; const isOverflowing = content.scrollWidth > content.clientWidth;

View File

@@ -1,6 +1,9 @@
import asyncio
import json import json
import logging import logging
from fasthtml.components import Div, sse_message
from fasthtml.core import EventStream
from fasthtml.fastapp import fast_app from fasthtml.fastapp import fast_app
from starlette.datastructures import UploadFile from starlette.datastructures import UploadFile
@@ -136,3 +139,16 @@ def post(session, _id: str, state: str, args: str = None):
logger.debug(f"Entering on_state_changed with args {_id=}, {state=}, {args=}") logger.debug(f"Entering on_state_changed with args {_id=}, {state=}, {args=}")
instance = InstanceManager.get(session, _id) instance = InstanceManager.get(session, _id)
return instance.manage_state_changed(state, args) return instance.manage_state_changed(state, args)
@rt(Routes.YieldRow)
async def get(session, _id: str):
logger.debug(f"Entering {Routes.YieldRow} with args {_id=}")
instance = InstanceManager.get(session, _id)
return EventStream(instance.mk_body_content_sse())
@rt(Routes.GetPage)
def get(session, _id: str, page_index: int):
logger.debug(f"Entering {Routes.GetPage} with args {_id=}, {page_index=}")
instance = InstanceManager.get(session, _id)
return instance.mk_body_content_page(page_index)

View File

@@ -1,6 +1,6 @@
function bindDatagrid(datagridId, allowColumnsReordering) { function bindDatagrid(datagridId, allowColumnsReordering) {
bindScrollbars(datagridId); manageScrollbars(datagridId, true);
makeResizable(datagridId) makeResizable(datagridId);
} }
function bindScrollbars(datagridId) { function bindScrollbars(datagridId) {
@@ -21,7 +21,7 @@ function bindScrollbars(datagridId) {
const table = datagrid.querySelector(".dt2-table"); const table = datagrid.querySelector(".dt2-table");
if (!verticalScrollbar || !verticalWrapper || !horizontalScrollbar || !horizontalWrapper || !body || !table) { if (!verticalScrollbar || !verticalWrapper || !horizontalScrollbar || !horizontalWrapper || !body || !table) {
console.error("Essential scrollbar or content elements are missing in the datagrid."); console.error("Essential scrollbars or content elements are missing in the datagrid.");
return; return;
} }
@@ -176,6 +176,224 @@ function bindScrollbars(datagridId) {
}); });
} }
function manageScrollbars(datagridId, binding) {
console.debug("manageScrollbars on element " + datagridId + " with binding=" + binding);
const datagrid = document.getElementById(datagridId);
if (!datagrid) {
console.error(`Datagrid with id "${datagridId}" not found.`);
return;
}
const verticalScrollbar = datagrid.querySelector(".dt2-scrollbars-vertical");
const verticalWrapper = datagrid.querySelector(".dt2-scrollbars-vertical-wrapper");
const horizontalScrollbar = datagrid.querySelector(".dt2-scrollbars-horizontal");
const horizontalWrapper = datagrid.querySelector(".dt2-scrollbars-horizontal-wrapper");
const body = datagrid.querySelector(".dt2-body");
const table = datagrid.querySelector(".dt2-table");
if (!verticalScrollbar || !verticalWrapper || !horizontalScrollbar || !horizontalWrapper || !body || !table) {
console.error("Essential scrollbars or content elements are missing in the datagrid.");
return;
}
const computeScrollbarVisibility = () => {
// Determine if the content is clipped
const isVerticalRequired = body.scrollHeight > body.clientHeight;
const isHorizontalRequired = table.scrollWidth > table.clientWidth;
// Show or hide the scrollbar wrappers
requestAnimationFrame(() => {
verticalWrapper.style.display = isVerticalRequired ? "block" : "none";
horizontalWrapper.style.display = isHorizontalRequired ? "block" : "none";
});
};
const computeScrollbarSize = () => {
// Vertical scrollbar height
const visibleHeight = body.clientHeight;
const totalHeight = body.scrollHeight;
const wrapperHeight = verticalWrapper.offsetHeight;
let scrollbarHeight = 0;
if (totalHeight > 0) {
scrollbarHeight = (visibleHeight / totalHeight) * wrapperHeight;
}
// Horizontal scrollbar width
const visibleWidth = table.clientWidth;
const totalWidth = table.scrollWidth;
const wrapperWidth = horizontalWrapper.offsetWidth;
let scrollbarWidth = 0;
if (totalWidth > 0) {
scrollbarWidth = (visibleWidth / totalWidth) * wrapperWidth;
}
requestAnimationFrame(() => {
verticalScrollbar.style.height = `${scrollbarHeight}px`;
horizontalScrollbar.style.width = `${scrollbarWidth}px`;
});
};
const updateVerticalScrollbarForMouseWheel = () => {
const maxScrollTop = body.scrollHeight - body.clientHeight;
const wrapperHeight = verticalWrapper.offsetHeight;
if (maxScrollTop > 0) {
const scrollRatio = wrapperHeight / body.scrollHeight;
verticalScrollbar.style.top = `${body.scrollTop * scrollRatio}px`;
}
};
if (binding) {
// Clean up existing managers if they exist
if (datagrid._managers) {
// Remove drag events
if (datagrid._managers.dragManager) {
verticalScrollbar.removeEventListener("mousedown", datagrid._managers.dragManager.verticalMouseDown);
horizontalScrollbar.removeEventListener("mousedown", datagrid._managers.dragManager.horizontalMouseDown);
document.removeEventListener("mousemove", datagrid._managers.dragManager.mouseMove);
document.removeEventListener("mouseup", datagrid._managers.dragManager.mouseUp);
}
// Remove wheel events
if (datagrid._managers.wheelManager) {
body.removeEventListener("wheel", datagrid._managers.wheelManager.handleWheelScrolling);
}
// Remove resize events
if (datagrid._managers.resizeManager) {
window.removeEventListener("resize", datagrid._managers.resizeManager.handleResize);
}
}
// Create managers
const dragManager = {
isDragging: false,
startY: 0,
startX: 0,
updateVerticalScrollbar: (deltaX, deltaY) => {
const wrapperHeight = verticalWrapper.offsetHeight;
const scrollbarHeight = verticalScrollbar.offsetHeight;
const maxScrollTop = body.scrollHeight - body.clientHeight;
const scrollRatio = maxScrollTop / (wrapperHeight - scrollbarHeight);
let newTop = parseFloat(verticalScrollbar.style.top || "0") + deltaY;
newTop = Math.max(0, Math.min(newTop, wrapperHeight - scrollbarHeight));
verticalScrollbar.style.top = `${newTop}px`;
body.scrollTop = newTop * scrollRatio;
},
updateHorizontalScrollbar: (deltaX, deltaY) => {
const wrapperWidth = horizontalWrapper.offsetWidth;
const scrollbarWidth = horizontalScrollbar.offsetWidth;
const maxScrollLeft = table.scrollWidth - table.clientWidth;
const scrollRatio = maxScrollLeft / (wrapperWidth - scrollbarWidth);
let newLeft = parseFloat(horizontalScrollbar.style.left || "0") + deltaX;
newLeft = Math.max(0, Math.min(newLeft, wrapperWidth - scrollbarWidth));
horizontalScrollbar.style.left = `${newLeft}px`;
table.scrollLeft = newLeft * scrollRatio;
},
verticalMouseDown: (e) => {
disableTooltip();
dragManager.isDragging = true;
dragManager.startY = e.clientY;
dragManager.startX = e.clientX;
document.body.style.userSelect = "none";
verticalScrollbar.classList.add("dt2-dragging");
},
horizontalMouseDown: (e) => {
disableTooltip();
dragManager.isDragging = true;
dragManager.startY = e.clientY;
dragManager.startX = e.clientX;
document.body.style.userSelect = "none";
horizontalScrollbar.classList.add("dt2-dragging");
},
mouseMove: (e) => {
if (dragManager.isDragging) {
const deltaY = e.clientY - dragManager.startY;
const deltaX = e.clientX - dragManager.startX;
// Determine which scrollbar is being dragged
if (verticalScrollbar.classList.contains("dt2-dragging")) {
dragManager.updateVerticalScrollbar(deltaX, deltaY);
} else if (horizontalScrollbar.classList.contains("dt2-dragging")) {
dragManager.updateHorizontalScrollbar(deltaX, deltaY);
}
// Reset start points for next update
dragManager.startY = e.clientY;
dragManager.startX = e.clientX;
}
},
mouseUp: () => {
dragManager.isDragging = false;
document.body.style.userSelect = "";
verticalScrollbar.classList.remove("dt2-dragging");
horizontalScrollbar.classList.remove("dt2-dragging");
enableTooltip();
}
};
const wheelManager = {
handleWheelScrolling: (event) => {
const deltaX = event.deltaX;
const deltaY = event.deltaY;
// Scroll the body and table content
body.scrollTop += deltaY; // Vertical scrolling
table.scrollLeft += deltaX; // Horizontal scrolling
// Update the vertical scrollbar position
updateVerticalScrollbarForMouseWheel();
// Prevent default behavior to fully manage the scroll
event.preventDefault();
}
};
const resizeManager = {
handleResize: () => {
computeScrollbarVisibility();
computeScrollbarSize();
updateVerticalScrollbarForMouseWheel();
}
};
// Store managers on datagrid for cleanup
datagrid._managers = {
dragManager,
wheelManager,
resizeManager
};
// Bind events
verticalScrollbar.addEventListener("mousedown", dragManager.verticalMouseDown);
horizontalScrollbar.addEventListener("mousedown", dragManager.horizontalMouseDown);
document.addEventListener("mousemove", dragManager.mouseMove);
document.addEventListener("mouseup", dragManager.mouseUp);
body.addEventListener("wheel", wheelManager.handleWheelScrolling, {passive: false});
window.addEventListener("resize", resizeManager.handleResize);
}
// Always execute computations
computeScrollbarVisibility();
computeScrollbarSize();
}
function makeResizable(datagridId) { function makeResizable(datagridId) {
console.debug("makeResizable on element " + datagridId); console.debug("makeResizable on element " + datagridId);
@@ -494,4 +712,5 @@ function onAfterSettle(datagridId, event) {
if (response.includes("hx-on::before-settle")) { if (response.includes("hx-on::before-settle")) {
bindDatagrid(datagridId) bindDatagrid(datagridId)
} }
} }

View File

@@ -1,3 +1,4 @@
import asyncio
import copy import copy
import logging import logging
from io import BytesIO from io import BytesIO
@@ -20,9 +21,10 @@ from components.datagrid_new.db_management import DataGridDbManager
from components.datagrid_new.settings import DataGridRowState, DataGridColumnState, \ from components.datagrid_new.settings import DataGridRowState, DataGridColumnState, \
DataGridFooterConf, DataGridState, DataGridSettings, DatagridView DataGridFooterConf, DataGridState, DataGridSettings, DatagridView
from components_helpers import mk_icon, mk_ellipsis from components_helpers import mk_icon, mk_ellipsis
from core.fasthtml_helper import MyDiv, mk_my_ellipsis, MySpan, mk_my_icon
from core.instance_manager import InstanceManager from core.instance_manager import InstanceManager
from core.settings_management import SettingsManager from core.settings_management import SettingsManager
from core.utils import get_unique_id, make_safe_id from core.utils import get_unique_id, make_safe_id, timed
logger = logging.getLogger("DataGrid") logger = logging.getLogger("DataGrid")
@@ -59,6 +61,8 @@ class DataGrid(BaseComponent):
self._state: DataGridState = self._db.load_state() self._state: DataGridState = self._db.load_state()
self._settings: DataGridSettings = grid_settings or self._db.load_settings() self._settings: DataGridSettings = grid_settings or self._db.load_settings()
self._df: DataFrame | None = self._db.load_dataframe() self._df: DataFrame | None = self._db.load_dataframe()
self._fast_access = self._init_fast_access(self._df)
self._total_rows = len(self._df) if self._df is not None else 0
# update boundaries if possible # update boundaries if possible
self.set_boundaries(boundaries) self.set_boundaries(boundaries)
@@ -126,6 +130,8 @@ class DataGrid(BaseComponent):
col_id, col_id,
_get_column_type(self._df[make_safe_id(col_id)].dtype)) _get_column_type(self._df[make_safe_id(col_id)].dtype))
for col_index, col_id in enumerate(df.columns)] for col_index, col_id in enumerate(df.columns)]
self._fast_access = self._init_fast_access(self._df)
self._total_rows = len(self._df) if self._df is not None else 0
if save_state: if save_state:
self._db.save_all(None, self._state, self._df) self._db.save_all(None, self._state, self._df)
@@ -386,6 +392,7 @@ class DataGrid(BaseComponent):
id=f"scb_{self._id}", id=f"scb_{self._id}",
) )
@timed
def mk_table(self, oob=False): def mk_table(self, oob=False):
htmx_extra_params = { htmx_extra_params = {
"hx-on::before-settle": f"onAfterSettle('{self._id}', event);", "hx-on::before-settle": f"onAfterSettle('{self._id}', event);",
@@ -439,7 +446,9 @@ class DataGrid(BaseComponent):
_mk_keyboard_management(), _mk_keyboard_management(),
Div( Div(
self.mk_table_header(), self.mk_table_header(),
self.mk_table_body(), #self.mk_table_body(),
#self.mk_table_body_sse(),
self.mk_table_body_page(),
self.mk_table_footer(), self.mk_table_footer(),
cls="dt2-inner-table"), cls="dt2-inner-table"),
cls="dt2-table", cls="dt2-table",
@@ -474,11 +483,46 @@ class DataGrid(BaseComponent):
header_class = "dt2-row dt2-header" + "" if self._settings.header_visible else " hidden" header_class = "dt2-row dt2-header" + "" if self._settings.header_visible else " hidden"
return Div( return Div(
Div(sse_swap="message"),
*[_mk_header(col_def) for col_def in self._state.columns], *[_mk_header(col_def) for col_def in self._state.columns],
cls=header_class, cls=header_class,
id=f"th_{self._id}" id=f"th_{self._id}"
) )
def mk_table_body_sse(self):
"""
This function is used to create a sse update
Unfortunately, the sse does not update the correct element
tb_{self._id} is not updated
Plus UI refreshment issues
"""
max_height = self._compute_body_max_height()
return Div(
hx_ext="sse",
sse_connect=f"{ROUTE_ROOT}{Routes.YieldRow}?_id={self._id}",
sse_close='close',
sse_swap="message",
hx_swap="beforeend",
cls="dt2-body",
style=f"max-height:{max_height}px;",
id=f"tb_{self._id}",
)
def mk_table_body_page(self):
"""
This function is used to update the table body when the vertical scrollbar reaches the end
A new page is added when requested
"""
max_height = self._compute_body_max_height()
return Div(
*self.mk_body_content_page(0),
cls="dt2-body",
style=f"max-height:{max_height}px;",
id=f"tb_{self._id}",
)
def mk_table_body(self): def mk_table_body(self):
df = self._get_filtered_df() df = self._get_filtered_df()
max_height = self._compute_body_max_height() max_height = self._compute_body_max_height()
@@ -507,34 +551,71 @@ class DataGrid(BaseComponent):
id=f"tf_{self._id}" id=f"tf_{self._id}"
) )
def mk_body_content_page(self, page_index: int):
df = self._get_filtered_df()
start = page_index * DATAGRID_PAGE_SIZE
end = start + DATAGRID_PAGE_SIZE
if self._total_rows > end:
last_row = df.index[end - 1]
else:
last_row = None
rows = [Div(
*[self.mk_body_cell(col_pos, row_index, col_def) for col_pos, col_def in enumerate(self._state.columns)],
cls="dt2-row",
data_row=f"{row_index}",
id=f"tr_{self._id}-{row_index}",
**self.commands.get_page(page_index + 1) if row_index == last_row else {}
) for row_index in df.index[start:end]]
rows.append(Script(f"manageScrollbars('{self._id}', false);"),)
return rows
async def mk_body_content_sse(self):
df = self._get_filtered_df()
for i, row_index in enumerate(df.index):
yield sse_message(Div(
*[self.mk_body_cell(col_pos, row_index, col_def) for col_pos, col_def in enumerate(self._state.columns)],
cls="dt2-row",
data_row=f"{row_index}",
id=f"tr_{self._id}-{row_index}",
))
if i % 50 == 0:
await asyncio.sleep(0.01)
logger.debug(f"yielding row {i}")
logger.debug(f"yielding close event")
yield f"event: close\ndata: \n\n"
def mk_body_cell(self, col_pos, row_index, col_def: DataGridColumnState): def mk_body_cell(self, col_pos, row_index, col_def: DataGridColumnState):
if not col_def.usable: if not col_def.usable:
return None return None
if not col_def.visible: if not col_def.visible:
return Div(cls="dt2-col-hidden") return MyDiv(cls="dt2-col-hidden")
content = self.mk_body_cell_content(col_pos, row_index, col_def) content = self.mk_body_cell_content(col_pos, row_index, col_def)
return Div(content, return MyDiv(content,
data_col=col_def.col_id, data_col=col_def.col_id,
style=f"width:{col_def.width}px;", style=f"width:{col_def.width}px;",
cls="dt2-cell") cls="dt2-cell")
def mk_body_cell_content(self, col_pos, row_index, col_def: DataGridColumnState): def mk_body_cell_content(self, col_pos, row_index, col_def: DataGridColumnState):
def mk_bool(value): def mk_bool(_value):
return Div(mk_icon(icon_checked if value else icon_unchecked, can_select=False), return MyDiv(mk_my_icon(icon_checked if _value else icon_unchecked, can_select=False),
cls="dt2-cell-content-checkbox") cls="dt2-cell-content-checkbox")
def mk_text(value): def mk_text(_value):
return mk_ellipsis(value, cls="dt2-cell-content-text") return mk_my_ellipsis(_value, cls="dt2-cell-content-text")
def mk_number(value): def mk_number(_value):
return mk_ellipsis(value, cls="dt2-cell-content-number") return mk_my_ellipsis(_value, cls="dt2-cell-content-number")
def process_cell_content(value): def process_cell_content(_value):
value_str = str(value) value_str = str(_value)
if FILTER_INPUT_CID not in self._state.filtered or ( if FILTER_INPUT_CID not in self._state.filtered or (
keyword := self._state.filtered[FILTER_INPUT_CID]) is None: keyword := self._state.filtered[FILTER_INPUT_CID]) is None:
@@ -545,21 +626,23 @@ class DataGrid(BaseComponent):
return value_str return value_str
len_keyword = len(keyword) len_keyword = len(keyword)
res = [Span(value_str[:index])] if index > 0 else [] res = [MySpan(value_str[:index])] if index > 0 else []
res += [Span(value_str[index:index + len_keyword], cls="dt2-highlight-1")] res += [MySpan(value_str[index:index + len_keyword], cls="dt2-highlight-1")]
res += [Span(value_str[index + len_keyword:])] if len(value_str) > len_keyword else [] res += [MySpan(value_str[index + len_keyword:])] if len(value_str) > len_keyword else []
return tuple(res) return tuple(res)
column_type = col_def.type column_type = col_def.type
# value = self._df.iloc[row_index, col_def.col_index]
value = self._fast_access[col_def.col_id][row_index]
if column_type == ColumnType.Bool: if column_type == ColumnType.Bool:
content = mk_bool(self._df.iloc[row_index, col_def.col_index]) content = mk_bool(value)
elif column_type == ColumnType.Number: elif column_type == ColumnType.Number:
content = mk_number(process_cell_content(self._df.iloc[row_index, col_def.col_index])) content = mk_number(process_cell_content(value))
elif column_type == ColumnType.RowIndex: elif column_type == ColumnType.RowIndex:
content = mk_number(row_index) content = mk_number(row_index)
else: else:
content = mk_text(process_cell_content(self._df.iloc[row_index, col_def.col_index])) content = mk_text(process_cell_content(value))
return content return content
@@ -822,6 +905,26 @@ class DataGrid(BaseComponent):
return True return True
@staticmethod
def _init_fast_access(df):
"""
Generates a fast-access dictionary for a DataFrame.
This method converts the columns of the provided DataFrame into NumPy arrays
and stores them as values in a dictionary, using the column names as keys.
This allows for efficient access to the data stored in the DataFrame.
Args:
df (DataFrame): The input pandas DataFrame whose columns are to be converted
into a dictionary of NumPy arrays.
Returns:
dict: A dictionary where the keys are the column names of the input DataFrame
and the values are the corresponding column values as NumPy arrays.
"""
return {col: df[col].to_numpy() for col in df.columns}
@timed
def __ft__(self): def __ft__(self):
return Div( return Div(
Div( Div(
@@ -844,7 +947,7 @@ class DataGrid(BaseComponent):
@staticmethod @staticmethod
def new(session, data, index=None): def new(session, data, index=None):
datagrid = DataGrid(session, DataGrid.create_component_id(session)) datagrid = DataGrid(session, DataGrid.create_component_id(session))
#dataframe = DataFrame(data, index=index) # dataframe = DataFrame(data, index=index)
dataframe = DataFrame(data) dataframe = DataFrame(data)
datagrid.init_from_dataframe(dataframe) datagrid.init_from_dataframe(dataframe)
return datagrid return datagrid

View File

@@ -91,12 +91,21 @@ class DataGridCommandManager(BaseCommandManager):
return { return {
"hx-post": f"{ROUTE_ROOT}{Routes.OnClick}", "hx-post": f"{ROUTE_ROOT}{Routes.OnClick}",
"hx-target": f"#tsm_{self._id}", "hx-target": f"#tsm_{self._id}",
"hx-trigger" : "click", "hx-trigger": "click",
"hx-swap": "outerHTML", "hx-swap": "outerHTML",
"hx-vals": f'js:{{_id: "{self._id}", cell_id:getCellId(event), modifier:getClickModifier(event), boundaries: getCellBoundaries(event)}}', "hx-vals": f'js:{{_id: "{self._id}", cell_id:getCellId(event), modifier:getClickModifier(event), boundaries: getCellBoundaries(event)}}',
"hx-on::before-request": f'validateOnClickRequest("{self._id}", event)', "hx-on::before-request": f'validateOnClickRequest("{self._id}", event)',
} }
def get_page(self, page_index=0):
return {
"hx-get": f"{ROUTE_ROOT}{Routes.GetPage}",
"hx-target": f"#tb_{self._id}",
"hx-swap": "beforeend",
"hx-vals": f'{{"_id": "{self._id}", "page_index": "{page_index}"}}',
"hx-trigger": f"intersect root:#tb_{self._id} once",
}
def _get_hide_show_columns_attrs(self, mode, col_defs: list, new_value, cls=""): def _get_hide_show_columns_attrs(self, mode, col_defs: list, new_value, cls=""):
str_col_names = ", ".join(f"'{col_def.title}'" for col_def in col_defs) str_col_names = ", ".join(f"'{col_def.title}'" for col_def in col_defs)
tooltip_msg = f"{mode} column{'s' if len(col_defs) > 1 else ''} {str_col_names}" tooltip_msg = f"{mode} column{'s' if len(col_defs) > 1 else ''} {str_col_names}"
@@ -165,4 +174,4 @@ class FilterAllCommands(BaseCommandManager):
"hx_vals": f'{{"_id": "{self._id}", "col_id":"{FILTER_INPUT_CID}"}}', "hx_vals": f'{{"_id": "{self._id}", "col_id":"{FILTER_INPUT_CID}"}}',
"data_tooltip": "Reset filter", "data_tooltip": "Reset filter",
"cls": self.merge_class(cls, "mmt-tooltip"), "cls": self.merge_class(cls, "mmt-tooltip"),
} }

View File

@@ -17,6 +17,7 @@ CONTAINER_HEIGHT = "container_height"
DATAGRID_STATE_FOOTER = "footer" DATAGRID_STATE_FOOTER = "footer"
DATAGRID_PAGE_SIZE = 50
class Routes: class Routes:
Filter = "/filter" # request the filtering in the grid Filter = "/filter" # request the filtering in the grid
@@ -33,6 +34,8 @@ class Routes:
UpdateView = "/update_view" UpdateView = "/update_view"
ShowFooterMenu = "/show_footer_menu" ShowFooterMenu = "/show_footer_menu"
UpdateState = "/update_state" UpdateState = "/update_state"
YieldRow = "/yield-row"
GetPage = "/page"
class ColumnType(Enum): class ColumnType(Enum):
@@ -44,11 +47,13 @@ class ColumnType(Enum):
Choice = "Choice" Choice = "Choice"
List = "List" List = "List"
class ViewType(Enum): class ViewType(Enum):
Table = "Table" Table = "Table"
Chart = "Chart" Chart = "Chart"
Form = "Form" Form = "Form"
class FooterAggregation(Enum): class FooterAggregation(Enum):
Sum = "Sum" Sum = "Sum"
Mean = "Mean" Mean = "Mean"
@@ -59,4 +64,4 @@ class FooterAggregation(Enum):
FilteredMean = "FilteredMean" FilteredMean = "FilteredMean"
FilteredMin = "FilteredMin" FilteredMin = "FilteredMin"
FilteredMax = "FilteredMax" FilteredMax = "FilteredMax"
FilteredCount = "FilteredCount" FilteredCount = "FilteredCount"

View File

@@ -20,7 +20,7 @@ def get(session):
@rt(Routes.AddRepository) @rt(Routes.AddRepository)
def post(session, _id: str, tab_id: str, form_id: str, repository: str, table: str, tab_boundaries:str): def post(session, _id: str, tab_id: str, form_id: str, repository: str, table: str, tab_boundaries: str):
logger.debug( logger.debug(
f"Entering {Routes.AddRepository} with args {debug_session(session)}, {_id=}, {tab_id=}, {form_id=}, {repository=}, {table=}, {tab_boundaries=}") f"Entering {Routes.AddRepository} with args {debug_session(session)}, {_id=}, {tab_id=}, {form_id=}, {repository=}, {table=}, {tab_boundaries=}")
instance = InstanceManager.get(session, _id) # Repository instance = InstanceManager.get(session, _id) # Repository
@@ -34,8 +34,9 @@ def get(session, _id: str, repository_name: str):
@rt(Routes.AddTable) @rt(Routes.AddTable)
def post(session, _id: str, tab_id: str, form_id: str, repository_name: str, table_name: str, tab_boundaries:str): def post(session, _id: str, tab_id: str, form_id: str, repository_name: str, table_name: str, tab_boundaries: str):
logger.debug(f"Entering {Routes.AddTable} with args {debug_session(session)}, {_id=}, {tab_id=}, {form_id=}, {repository_name=}, {table_name=}, {tab_boundaries=}") logger.debug(
f"Entering {Routes.AddTable} with args {debug_session(session)}, {_id=}, {tab_id=}, {form_id=}, {repository_name=}, {table_name=}, {tab_boundaries=}")
instance = InstanceManager.get(session, _id) instance = InstanceManager.get(session, _id)
return instance.add_new_table(tab_id, form_id, repository_name, table_name, json.loads(tab_boundaries)) return instance.add_new_table(tab_id, form_id, repository_name, table_name, json.loads(tab_boundaries))
@@ -48,7 +49,8 @@ def put(session, _id: str, repository: str):
@rt(Routes.ShowTable) @rt(Routes.ShowTable)
def get(session, _id: str, repository: str, table: str, tab_boundaries:str): def get(session, _id: str, repository: str, table: str, tab_boundaries: str):
logger.debug(f"Entering {Routes.ShowTable} with args {debug_session(session)}, {_id=}, {repository=}, {table=}, {tab_boundaries=}") logger.debug(
f"Entering {Routes.ShowTable} with args {debug_session(session)}, {_id=}, {repository=}, {table=}, {tab_boundaries=}")
instance = InstanceManager.get(session, _id) instance = InstanceManager.get(session, _id)
return instance.show_table(repository, table, json.loads(tab_boundaries)) return instance.show_table(repository, table, json.loads(tab_boundaries))

View File

@@ -0,0 +1,73 @@
from fastcore.basics import NotStr
from core.utils import merge_classes
attr_map = {
"cls": "class",
"_id": "id",
}
def safe_attr(attr_name):
attr_name = attr_name.replace("hx_", "hx-")
return attr_map.get(attr_name, attr_name)
def to_html(item):
if item is None:
return ""
elif isinstance(item, str):
return item
elif isinstance(item, (int, float, bool)):
return str(item)
elif isinstance(item, MyFt):
return item.to_html()
elif isinstance(item, NotStr):
return str(item)
else:
raise Exception(f"Unsupported type: {type(item)}, {item=}")
class MyFt:
def __init__(self, name, *args, **kwargs):
self.name = name
self.children = args
self.attrs = kwargs
def to_html(self):
body_items = [to_html(item) for item in self.children]
return f"<{self.name} {' '.join(f'{safe_attr(k)}="{v}"' for k, v in self.attrs.items())}>{' '.join(body_items)}</div>"
def __ft__(self):
return NotStr(self.to_html())
class MyDiv(MyFt):
def __init__(self, *args, **kwargs):
super().__init__("div", *args, **kwargs)
class MySpan(MyFt):
def __init__(self, *args, **kwargs):
super().__init__("span", *args, **kwargs)
def mk_my_ellipsis(txt: str, cls='', **kwargs):
merged_cls = merge_classes("truncate",
cls,
kwargs)
return MyDiv(txt, cls=merged_cls, data_tooltip=txt, **kwargs)
def mk_my_icon(icon, size=20, can_select=True, can_hover=False, cls='', tooltip=None, **kwargs):
merged_cls = merge_classes(f"icon-{size}",
'icon-btn' if can_select else '',
'mmt-btn' if can_hover else '',
cls,
kwargs)
return mk_my_tooltip(icon, tooltip, cls=merged_cls, **kwargs) if tooltip else MyDiv(icon, cls=merged_cls, **kwargs)
def mk_my_tooltip(element, tooltip: str, cls='', **kwargs):
merged_cls = merge_classes("mmt-tooltip",
cls,
kwargs)
return MyDiv(element, cls=merged_cls, data_tooltip=tooltip, **kwargs)

View File

@@ -1,12 +1,16 @@
import ast import ast
import base64 import base64
import cProfile
import functools
import hashlib import hashlib
import importlib import importlib
import inspect import inspect
import pkgutil import pkgutil
import re import re
import time
import types import types
import uuid import uuid
from datetime import datetime
from enum import Enum from enum import Enum
from io import BytesIO from io import BytesIO
from urllib.parse import urlparse from urllib.parse import urlparse
@@ -420,6 +424,66 @@ def split_host_port(url):
return host, port return host, port
def timed(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
start = time.perf_counter()
result = func(*args, **kwargs)
end = time.perf_counter()
# get class name
class_name = None
if args:
# check the first argument to see if it's a class'
if inspect.isclass(args[0]):
class_name = args[0].__name__ # class method
elif hasattr(args[0], "__class__"):
class_name = args[0].__class__.__name__ # instance method
if class_name:
print(f"[PERF] {class_name}.{func.__name__} took {end - start:.4f} sec")
else:
print(f"[PERF] {func.__name__} took {end - start:.4f} sec")
return result
return wrapper
def profile_function(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
profiler = cProfile.Profile()
try:
profiler.enable()
result = func(*args, **kwargs)
finally:
profiler.disable()
# Determine class name if any
class_name = None
if args:
if inspect.isclass(args[0]):
class_name = args[0].__name__ # class method
elif hasattr(args[0], "__class__"):
class_name = args[0].__class__.__name__ # instance method
# Compose filename with timestamp
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
if class_name:
filename = f"{class_name}_{func.__name__}_{timestamp}.prof"
else:
filename = f"{func.__name__}_{timestamp}.prof"
# Dump stats to file
profiler.dump_stats(filename)
print(f"[PROFILE] Profiling data saved to {filename}")
return result
return wrapper
class UnreferencedNamesVisitor(ast.NodeVisitor): class UnreferencedNamesVisitor(ast.NodeVisitor):
""" """
Try to find symbols that will be requested by the ast Try to find symbols that will be requested by the ast
@@ -463,5 +527,4 @@ class UnreferencedNamesVisitor(ast.NodeVisitor):
:rtype: :rtype:
""" """
self.names.add(node.arg) self.names.add(node.arg)
self.visit_selected(node, ["value"]) self.visit_selected(node, ["value"])

View File

@@ -1,6 +1,7 @@
# global layout # global layout
import asyncio
import logging.config import logging.config
import random
from asyncio import sleep
import yaml import yaml
from fasthtml.common import * from fasthtml.common import *
@@ -54,6 +55,9 @@ links = [
Link(href="./assets/daisyui-5-themes.css", rel="stylesheet", type="text/css"), Link(href="./assets/daisyui-5-themes.css", rel="stylesheet", type="text/css"),
Script(src="./assets/tailwindcss-browser@4.js"), Script(src="./assets/tailwindcss-browser@4.js"),
# SSE
Script(src="https://unpkg.com/htmx-ext-sse@2.2.1/sse.js"),
# Old drawer layout # Old drawer layout
Script(src="./assets/DrawerLayout.js", defer=True), Script(src="./assets/DrawerLayout.js", defer=True),
Link(rel="stylesheet", href="./assets/DrawerLayout.css"), Link(rel="stylesheet", href="./assets/DrawerLayout.css"),
@@ -211,6 +215,25 @@ app, rt = fast_app(
pico=False, pico=False,
) )
# -------------------------
# Profiling middleware
# -------------------------
@app.middleware("http")
async def timing_middleware(request, call_next):
import time
start_total = time.perf_counter()
# Call the next middleware or route handler
response = await call_next(request)
end_total = time.perf_counter()
elapsed = end_total - start_total
print(f"[PERF] Total server time: {elapsed:.4f} sec - Path: {request.url.path}")
return response
settings_manager = SettingsManager() settings_manager = SettingsManager()
import_settings = AdminImportSettings(settings_manager, None) import_settings = AdminImportSettings(settings_manager, None)
@@ -253,6 +276,42 @@ def get(session):
DrawerLayoutOld(pages),) DrawerLayoutOld(pages),)
shutdown_event = signal_shutdown()
async def number_generator():
while True: # not shutdown_event.is_set():
data = Article(random.randint(1, 100))
print(data)
yield sse_message(data)
await sleep(1)
@rt("/sse")
def get():
return Titled("SSE Random Number Generator",
P("Generate pairs of random numbers, as the list grows scroll downwards."),
Div(hx_ext="sse",
sse_connect="/number-stream",
hx_swap="beforeend show:bottom",
sse_swap="message"))
@rt("/number-stream")
async def get(): return EventStream(number_generator())
@rt('/toasting')
def get(session):
# Normally one toast is enough, this allows us to see
# different toast types in action.
add_toast(session, f"Toast is being cooked", "info")
add_toast(session, f"Toast is ready", "success")
add_toast(session, f"Toast is getting a bit crispy", "warning")
add_toast(session, f"Toast is burning!", "error")
return Titled("I like toast")
# Error Handling # Error Handling
@app.get("/{path:path}") @app.get("/{path:path}")
def not_found(path: str, session=None): def not_found(path: str, session=None):
@@ -275,18 +334,7 @@ def not_found(path: str, session=None):
setup_toasts(app) setup_toasts(app)
@rt('/toasting') def main():
def get(session):
# Normally one toast is enough, this allows us to see
# different toast types in action.
add_toast(session, f"Toast is being cooked", "info")
add_toast(session, f"Toast is ready", "success")
add_toast(session, f"Toast is getting a bit crispy", "warning")
add_toast(session, f"Toast is burning!", "error")
return Titled("I like toast")
async def main():
logger.info(f" Starting FastHTML server on http://localhost:{APP_PORT}") logger.info(f" Starting FastHTML server on http://localhost:{APP_PORT}")
serve(port=APP_PORT) serve(port=APP_PORT)
@@ -294,9 +342,4 @@ async def main():
if __name__ == "__main__": if __name__ == "__main__":
# Start your application # Start your application
logger.info("Application starting...") logger.info("Application starting...")
try: main()
asyncio.run(main())
except KeyboardInterrupt:
logger.info("\nStopping application...")
except Exception as e:
logger.error(f"Error: {e}")