diff --git a/dash_app/callbacks/callback_functions.py b/dash_app/callbacks/callback_functions.py
index 763449a..b5a5dcb 100644
--- a/dash_app/callbacks/callback_functions.py
+++ b/dash_app/callbacks/callback_functions.py
@@ -6,8 +6,14 @@
from dash_app.utils.data_directories import (
get_all_filenames,
get_all_root_log_directories,
+ get_base_path_directories,
get_runs,
)
+from dash_app.utils.grouping import (
+ SEPARATOR,
+ add_group_column,
+ get_group_by_options,
+)
from dash_app.utils.metadata import format_metadata_rows
from dash_app.utils.plots import (
create_files_count_plot,
@@ -120,7 +126,9 @@ def populate_datatable(analysis_id):
return df_dict, columns, metadata_rows, project_id
-def create_high_level_plot(switch_on, analysis_id):
+def create_high_level_plot(
+ switch_on, analysis_id, group_by_indices=None, separator=SEPARATOR
+):
metadata = _fetch_analysis_metadata(analysis_id)
analysis_type = metadata.get("analysis_sub_type")
analysis_level = metadata.get("analysis_level")
@@ -149,6 +157,12 @@ def create_high_level_plot(switch_on, analysis_id):
"width": "90%",
}
+ # The parts to group by are read with the same separator they were offered
+ # with, so the options are built before the data is grouped.
+ group_by_options = get_group_by_options(df, separator=separator)
+ if group_by_indices:
+ df = add_group_column(df, group_by_indices, separator=separator)
+
if analysis_type == "file-count":
fig = create_files_count_plot(df, theme)
elif analysis_type == "umap":
@@ -162,7 +176,7 @@ def create_high_level_plot(switch_on, analysis_id):
metadata_rows = format_metadata_rows(metadata)
- return fig, style, metadata_rows, project_id
+ return fig, style, metadata_rows, project_id, group_by_options
def make_api_call(json_payload, endpoint, requests_type="POST"):
@@ -223,6 +237,11 @@ def get_log_data_directory_options(project_id: int):
return [{"label": lab, "value": val} for (lab, val) in zip(labels, values)]
+def get_base_path_options(selected_path=None):
+ labels, values = get_base_path_directories(selected_path)
+ return [{"label": lab, "value": val} for (lab, val) in zip(labels, values)]
+
+
def poll_task_status(task_id: int) -> dict:
# There shouldn't be any error here unless the task_id is wrong.
response, error = make_api_call({}, f"task-status/{task_id}", requests_type="GET")
diff --git a/dash_app/components/form_inputs.py b/dash_app/components/form_inputs.py
index 770be80..1bacfc1 100644
--- a/dash_app/components/form_inputs.py
+++ b/dash_app/components/form_inputs.py
@@ -426,6 +426,73 @@ def analysis_name_input(id):
)
+def group_by_input(id):
+ label_id = f"{id}-label"
+ return dbc.Col(
+ [
+ dbc.Label(
+ "Color by name part",
+ id=label_id,
+ html_for=id,
+ width="auto",
+ style={"textDecoration": "underline", "cursor": "pointer"},
+ ),
+ dbc.Tooltip(
+ "Directory names are split on '_'. Pick the parts to color the plot by, "
+ "for example the part holding the run type. Picking several parts combines "
+ "them into one label, in the order they were picked.",
+ target=label_id,
+ placement="bottom",
+ ),
+ dcc.Dropdown(
+ id=id,
+ multi=True,
+ placeholder="No grouping",
+ className="dbc border border-light-subtle rounded",
+ optionHeight=40,
+ maxHeight=350,
+ ),
+ ],
+ width=6,
+ )
+
+
+def separator_input(id):
+ label_id = f"{id}-label"
+ return dbc.Col(
+ [
+ dbc.Label(
+ "Name separator",
+ id=label_id,
+ html_for=id,
+ width="auto",
+ style={"textDecoration": "underline", "cursor": "pointer"},
+ ),
+ dbc.Tooltip(
+ "The character the names are split on before the parts are picked. "
+ "Changing it clears the picked parts, since the parts are counted "
+ "with the separator they were listed with.",
+ target=label_id,
+ placement="bottom",
+ ),
+ dcc.Dropdown(
+ id=id,
+ options=[
+ {"label": "Underscore _", "value": "_"},
+ {"label": "Hyphen -", "value": "-"},
+ {"label": "Dot .", "value": "."},
+ {"label": "Slash /", "value": "/"},
+ {"label": "Space", "value": " "},
+ ],
+ value="_",
+ clearable=False,
+ className="dbc border border-light-subtle rounded",
+ ),
+ ],
+ width=3,
+ )
+
+
def base_path_input(id):
label_id = f"{id}-label"
return dbc.Col(
@@ -438,14 +505,24 @@ def base_path_input(id):
style={"textDecoration": "underline", "cursor": "pointer"},
),
dbc.Tooltip(
- "Optional. Must be directory inside log_data/. Leave empty or unchanged to use the default. Directory must already exist.",
+ "The directory the project's log data is read from. Every analysis in the "
+ "project picks its data from inside this directory, so keep the root to make "
+ "all of log_data/ available. The directories in log_data/ are listed here; "
+ "select one and open the list again to see the directories inside it.",
target=label_id,
placement="bottom",
),
- dbc.Input(
- id=id,
- type="text",
- value="./log_data",
+ dcc.Loading(
+ type="circle",
+ delay_show=200,
+ overlay_style={"visibility": "visible"},
+ children=dcc.Dropdown(
+ id=id,
+ placeholder="Select a directory under log_data/",
+ className="dbc border border-light-subtle rounded",
+ optionHeight=40,
+ maxHeight=350,
+ ),
),
],
)
diff --git a/dash_app/components/layouts.py b/dash_app/components/layouts.py
index e33d911..243b1f5 100644
--- a/dash_app/components/layouts.py
+++ b/dash_app/components/layouts.py
@@ -2,7 +2,11 @@
from dash import dash_table, dcc, html
from dash_app.components.color_mode_switch import color_mode_switch
-from dash_app.components.form_inputs import submit_button
+from dash_app.components.form_inputs import (
+ group_by_input,
+ separator_input,
+ submit_button,
+)
from dash_app.components.forms import project_settings_form
from dash_app.components.nav import crate_analysis_nav, nav
from dash_app.components.toasts import error_toast, success_toast
@@ -429,13 +433,23 @@ def create_project_layout(
def create_high_level_viz_result_layout(
- plot_content_id, metadata_table_id, error_toast_id, success_toast_id
+ plot_content_id,
+ metadata_table_id,
+ error_toast_id,
+ success_toast_id,
+ group_by_id,
+ separator_id,
):
error_toast_row = dbc.Row(error_toast(error_toast_id))
success_toast_row = dbc.Row(success_toast(success_toast_id))
table_row = dbc.Row(dbc.Table(id=metadata_table_id, hover=True, responsive=True))
+ group_by_row = dbc.Row(
+ [group_by_input(group_by_id), separator_input(separator_id)],
+ class_name="mb-3",
+ )
+
plot_row = dbc.Row(
dcc.Loading(
type="default",
@@ -464,7 +478,7 @@ def create_high_level_viz_result_layout(
)
layout = [
- dbc.Container([table_row, error_toast_row, success_toast_row]),
+ dbc.Container([table_row, group_by_row, error_toast_row, success_toast_row]),
dbc.Container(plot_row, fluid=True, style={"paddingBottom": "200px"}),
]
diff --git a/dash_app/page_templates/high_level_viz_result_page_base.py b/dash_app/page_templates/high_level_viz_result_page_base.py
index f7cfdf0..e288a20 100644
--- a/dash_app/page_templates/high_level_viz_result_page_base.py
+++ b/dash_app/page_templates/high_level_viz_result_page_base.py
@@ -23,6 +23,8 @@ def create_layout(config, analysis_id=None):
config_ids["metadata"],
config_ids["error_toast"],
config_ids["success_toast"],
+ config_ids["group_by"],
+ config_ids["separator"],
)
return base + content
@@ -30,28 +32,46 @@ def create_layout(config, analysis_id=None):
def register_callback(config):
config_ids = config["ids"]
+ # Part numbers only mean something for the separator they were listed with,
+ # so a new separator starts from no grouping.
+ @callback(
+ Output(config_ids["group_by"], "value"),
+ Input(config_ids["separator"], "value"),
+ prevent_initial_call=True,
+ )
+ def reset_group_by(_):
+ return []
+
+ # The name parts to group by are offered by the plot callback itself, since
+ # the values to pick from are only known once the results have been read.
@callback(
Output(config_ids["plot_content"], "figure"),
Output(config_ids["plot_content"], "style"),
Output(config_ids["metadata"], "children"),
Output(config_ids["project_link"], "href"),
+ Output(config_ids["group_by"], "options"),
Output(config_ids["error_toast"], "children"),
Output(config_ids["error_toast"], "is_open"),
Output(config_ids["success_toast"], "children"),
Output(config_ids["success_toast"], "is_open"),
Input("switch", "value"),
+ Input(config_ids["group_by"], "value"),
+ Input(config_ids["separator"], "value"),
State(config_ids["analysis_id"], "data"),
)
- def create_plot(switch_on, analysis_id):
+ def create_plot(switch_on, group_by_indices, separator, analysis_id):
try:
- fig, style, metadata_rows, project_id = create_high_level_plot(
- switch_on, analysis_id
+ fig, style, metadata_rows, project_id, group_by_options = (
+ create_high_level_plot(
+ switch_on, analysis_id, group_by_indices, separator
+ )
)
return (
fig,
style,
[html.Tbody(metadata_rows)],
f"/dash/project/{project_id}",
+ group_by_options,
dash.no_update,
False,
dash.no_update,
@@ -63,6 +83,7 @@ def create_plot(switch_on, analysis_id):
dash.no_update,
dash.no_update,
dash.no_update,
+ dash.no_update,
str(e),
True,
dash.no_update,
diff --git a/dash_app/pages/home.py b/dash_app/pages/home.py
index 71b711a..9ac050a 100644
--- a/dash_app/pages/home.py
+++ b/dash_app/pages/home.py
@@ -2,7 +2,7 @@
import dash_bootstrap_components as dbc
from dash import ALL, Input, Output, State, callback, dcc
-from dash_app.callbacks.callback_functions import make_api_call
+from dash_app.callbacks.callback_functions import get_base_path_options, make_api_call
from dash_app.components.forms import project_form
from dash_app.components.layouts import create_home_layout
from dash_app.utils.metadata import format_project_overview
@@ -59,6 +59,27 @@ def get_projects(_1, _2):
return (group_items, dash.no_update, dash.no_update, dash.no_update, False)
+# Selecting a directory reveals the directories below it, so that deeper paths
+# can be reached by opening the dropdown again after selecting.
+@callback(
+ Output("base-path-proj", "options"),
+ Input("open-btn-proj", "n_clicks"),
+ Input("base-path-proj", "value"),
+)
+def get_base_paths(_, selected_path):
+ return get_base_path_options(selected_path)
+
+
+@callback(
+ Output("base-path-proj", "value"),
+ Input("base-path-proj", "id"),
+)
+def set_default_base_path(_):
+ # The log data root is the first option and the default base path
+ options = get_base_path_options()
+ return options[0]["value"] if options else None
+
+
@callback(
Output("collapse-proj", "is_open"),
Input("open-btn-proj", "n_clicks"),
diff --git a/dash_app/pages/result_pages/directory_level_visualisations.py b/dash_app/pages/result_pages/directory_level_visualisations.py
index 1c9959c..80d020a 100644
--- a/dash_app/pages/result_pages/directory_level_visualisations.py
+++ b/dash_app/pages/result_pages/directory_level_visualisations.py
@@ -14,6 +14,8 @@
"metadata": "metadata-high-dir-res",
"error_toast": "error-toast-high-dir-res",
"success_toast": "success-toast-high-dir-res",
+ "group_by": "group-by-high-dir-res",
+ "separator": "separator-high-dir-res",
},
}
diff --git a/dash_app/pages/result_pages/file_level_visualisations.py b/dash_app/pages/result_pages/file_level_visualisations.py
index 20da9ae..e90d018 100644
--- a/dash_app/pages/result_pages/file_level_visualisations.py
+++ b/dash_app/pages/result_pages/file_level_visualisations.py
@@ -14,6 +14,8 @@
"metadata": "metadata-high-file-res",
"error_toast": "error-toast-high-file-res",
"success_toast": "success-toast-high-file-res",
+ "group_by": "group-by-high-file-res",
+ "separator": "separator-high-file-res",
},
}
diff --git a/dash_app/utils/data_directories.py b/dash_app/utils/data_directories.py
index 3547184..a12052c 100644
--- a/dash_app/utils/data_directories.py
+++ b/dash_app/utils/data_directories.py
@@ -49,3 +49,66 @@ def get_all_root_log_directories(base_path=None) -> tuple[list[str], list[str]]:
paths = [os.path.abspath(base_path) + "/"] + directory_paths + file_paths
return names, paths
+
+
+def get_base_path_directories(
+ selected_path: str | None = None, max_depth: int = 1
+) -> tuple[list[str], list[str]]:
+ """Directories that can be picked as a project base path.
+
+ Lists the log data root and the directories directly below it. When a
+ directory is already selected, the directories inside it are listed as
+ well, so that deeper paths are reached one level at a time by selecting a
+ directory and opening the dropdown again. Names are relative to the log
+ data root so that they read like the paths a user would type by hand.
+ """
+ root = os.path.abspath(current_app.config["LOG_DATA_PATH"])
+
+ if not os.path.isdir(root):
+ return [], []
+
+ paths = _walk_directories(root, max_depth)
+
+ if selected_path:
+ selected = os.path.abspath(selected_path)
+ if selected != root and _is_inside(selected, root):
+ # The selection and the directories leading to it stay listed, so
+ # that a deeper selection keeps showing in the dropdown and can be
+ # stepped back out of.
+ paths |= _path_and_parents(selected, root)
+ paths |= _walk_directories(selected, max_depth)
+
+ names = [f"{os.path.basename(root) or root} (root)"]
+ sorted_paths = [root] + sorted(paths)
+ names += [os.path.relpath(path, root) for path in sorted_paths[1:]]
+
+ return names, sorted_paths
+
+
+def _walk_directories(base_path: str, max_depth: int) -> set[str]:
+ directories = set()
+
+ for dirpath, dirnames, _ in os.walk(base_path):
+ depth = dirpath[len(base_path) :].count(os.sep)
+ if depth >= max_depth:
+ dirnames.clear()
+ continue
+
+ dirnames[:] = [dir for dir in dirnames if not dir.startswith(".")]
+ directories.update(os.path.join(dirpath, dir) for dir in dirnames)
+
+ return directories
+
+
+def _path_and_parents(path: str, root: str) -> set[str]:
+ paths = set()
+
+ while path != root and path != os.path.dirname(path):
+ paths.add(path)
+ path = os.path.dirname(path)
+
+ return paths
+
+
+def _is_inside(path: str, root: str) -> bool:
+ return os.path.commonpath([path, root]) == root
diff --git a/dash_app/utils/grouping.py b/dash_app/utils/grouping.py
new file mode 100644
index 0000000..0b6826f
--- /dev/null
+++ b/dash_app/utils/grouping.py
@@ -0,0 +1,102 @@
+import polars as pl
+
+GROUP_COLUMN = "group"
+SEPARATOR = "_"
+
+
+def get_group_source_column(df):
+ """The column naming the points of a plot.
+
+ File level results identify a point by seq_id, which is the directory and
+ the file name, while directory level results only have the directory name.
+ """
+ return "seq_id" if "seq_id" in df.columns else "run"
+
+
+def add_group_column(df, group_by_indices, source_column=None, separator=SEPARATOR):
+ """Label every row by the chosen parts of its name.
+
+ Works like LogDelta's group_by_indices setting: the name is split on the
+ separator and the parts at the given indices, in the order they were given,
+ make up the label. Negative indices count from the end of the name, so that
+ the last part can be picked no matter how long the name is. A name that has
+ none of those parts is used as is.
+ """
+ source_column = source_column or get_group_source_column(df)
+
+ label = (
+ pl.col(source_column)
+ .str.split(separator)
+ .list.gather(group_by_indices, null_on_oob=True)
+ .list.drop_nulls()
+ .list.join(separator)
+ )
+
+ return df.with_columns(
+ pl.when(label.str.len_chars() > 0)
+ .then(label)
+ .otherwise(pl.col(source_column))
+ .alias(GROUP_COLUMN)
+ )
+
+
+def get_group_by_options(
+ df, source_column=None, separator=SEPARATOR, max_values_shown=4
+):
+ """Dropdown options for the name parts the results can be grouped by.
+
+ Every option lists the values found at that position, so that the part to
+ group by can be picked without knowing the naming convention beforehand.
+ Names that are not all of the same length also get options counted from the
+ end, where the parts of such names line up.
+ """
+ source_column = source_column or get_group_source_column(df)
+ if source_column not in df.columns:
+ return []
+
+ parts = df[source_column].str.split(separator)
+ shortest = parts.list.len().min() or 0
+ longest = parts.list.len().max() or 0
+
+ indices = list(range(longest))
+ if shortest != longest:
+ indices += [-position for position in range(1, longest + 1)]
+
+ options = []
+ for index in indices:
+ values = sorted(
+ set(parts.list.get(index, null_on_oob=True).drop_nulls().to_list())
+ )
+ if not values:
+ continue
+
+ label = _part_label(index, shortest, longest)
+ options.append(
+ {
+ "label": f"{label}: {_format_values(values, max_values_shown)}",
+ "value": index,
+ }
+ )
+
+ return options
+
+
+def _part_label(index, shortest, longest):
+ if index < 0:
+ return f"Part {index} (from end)"
+
+ if index == longest - 1 and shortest == longest:
+ return f"Part {index} (last)"
+
+ return f"Part {index}"
+
+
+def _format_values(values, max_values_shown, max_value_length=24):
+ shown = ", ".join(
+ value if len(value) <= max_value_length else f"{value[:max_value_length]}..."
+ for value in values[:max_values_shown]
+ )
+ if len(values) > max_values_shown:
+ shown = f"{shown}, ... ({len(values)} values)"
+
+ return shown
diff --git a/dash_app/utils/plots.py b/dash_app/utils/plots.py
index fb4aa86..c49631e 100644
--- a/dash_app/utils/plots.py
+++ b/dash_app/utils/plots.py
@@ -1,6 +1,35 @@
import polars as pl
import plotly.graph_objects as go
+from dash_app.utils.grouping import GROUP_COLUMN
+
+# Colors and shapes are combined, so the number of groups that stay apart is the
+# count of colors times the count of shapes. The colors are picked from the
+# published colour-blind safe palettes (Okabe & Ito, Paul Tol, IBM) as the five
+# that stay furthest apart when simulated for protanopia, deuteranopia and
+# tritanopia, while keeping enough contrast against both the light and the dark
+# theme. The shape carries the difference for anyone who sees no color at all.
+GROUP_COLORS = [
+ "#33BBEE", # blue
+ "#E69F00", # orange
+ "#117733", # green
+ "#AA3377", # magenta
+ "#785EF0", # violet
+]
+
+GROUP_SYMBOLS = [
+ "circle",
+ "square",
+ "diamond",
+ "triangle-up",
+ "triangle-down",
+ "cross",
+ "x",
+ "star",
+ "pentagon",
+ "hexagram",
+]
+
def get_options(df) -> list[dict]:
seq_ids = sorted(df["seq_id"].unique().to_list())
@@ -153,15 +182,13 @@ def create_line_level_plot_minimal(
def create_unique_term_count_plot(df, theme="plotly_white"):
fig = go.Figure()
- fig.add_trace(
- go.Scatter(
- x=df["unique_term_count"],
- y=df["line_count"],
- mode="markers",
- text=df["run"],
- hovertemplate="Run: %{text}
Unique terms: %{x}
Lines:%{y}",
- name="Runs",
- )
+ _add_marker_traces(
+ fig,
+ _split_by_group(df, "Runs"),
+ x_column="unique_term_count",
+ y_column="line_count",
+ text_column="run",
+ hovertemplate="Run: %{text}
Unique terms: %{x}
Lines:%{y}",
)
fig.update_layout(
@@ -181,32 +208,21 @@ def create_unique_term_count_plot_by_file(
):
fig = go.Figure()
- if color_by_directory:
- runs = df["run"].unique()
- for run in sorted(runs):
- df_run = df.filter(pl.col("run") == run)
-
- fig.add_trace(
- go.Scatter(
- x=df_run["unique_term_count"],
- y=df_run["line_count"],
- mode="markers",
- text=df_run["seq_id"],
- hovertemplate="File: %{text}
Unique terms: %{x}
Lines:%{y}",
- name=f"Directory: {run}",
- )
- )
+ if GROUP_COLUMN in df.columns:
+ frames = _split_by_group(df, "Files")
+ elif color_by_directory:
+ frames = _split_by_directory(df)
else:
- fig.add_trace(
- go.Scatter(
- x=df["unique_term_count"],
- y=df["line_count"],
- mode="markers",
- text=df["seq_id"],
- hovertemplate="File: %{text}
Unique terms: %{x}
Lines:%{y}",
- name="Files",
- )
- )
+ frames = [("Files", df)]
+
+ _add_marker_traces(
+ fig,
+ frames,
+ x_column="unique_term_count",
+ y_column="line_count",
+ text_column="seq_id",
+ hovertemplate="File: %{text}
Unique terms: %{x}
Lines:%{y}",
+ )
fig.update_layout(
title="Unique term count by file",
@@ -223,15 +239,13 @@ def create_unique_term_count_plot_by_file(
def create_files_count_plot(df, theme="plotly_white"):
fig = go.Figure()
- fig.add_trace(
- go.Scatter(
- x=df["file_count"],
- y=df["line_count"],
- mode="markers",
- text=df["run"],
- hovertemplate="Run: %{text}
Files: %{x}
Lines:%{y}",
- name="Runs",
- )
+ _add_marker_traces(
+ fig,
+ _split_by_group(df, "Runs"),
+ x_column="file_count",
+ y_column="line_count",
+ text_column="run",
+ hovertemplate="Run: %{text}
Files: %{x}
Lines:%{y}",
)
fig.update_layout(
@@ -247,32 +261,22 @@ def create_files_count_plot(df, theme="plotly_white"):
def create_umap_plot(df, group_col, color_by_directory=False, theme="plotly_white"):
fig = go.Figure()
- if group_col == "seq_id" and color_by_directory:
- runs = df["run"].unique()
- for run in sorted(runs):
- df_run = df.filter(pl.col("run") == run)
-
- fig.add_trace(
- go.Scatter(
- x=df_run["UMAP1"],
- y=df_run["UMAP2"],
- mode="markers",
- text=df_run[group_col],
- hovertemplate=f"{group_col}: %{{text}}
UMAP1: %{{x}}
UMAP2:%{{y}}",
- name=f"Directory: {run}",
- marker=dict(symbol="x", size=4),
- )
- )
+ if GROUP_COLUMN in df.columns:
+ frames = _split_by_group(df, None)
+ elif group_col == "seq_id" and color_by_directory:
+ frames = _split_by_directory(df)
else:
- fig.add_trace(
- go.Scatter(
- x=df["UMAP1"],
- y=df["UMAP2"],
- mode="markers",
- text=df[group_col],
- hovertemplate=f"{group_col}: %{{text}}
UMAP1: %{{x}}
UMAP2:%{{y}}",
- )
- )
+ frames = [(None, df)]
+
+ _add_marker_traces(
+ fig,
+ frames,
+ x_column="UMAP1",
+ y_column="UMAP2",
+ text_column=group_col,
+ hovertemplate=f"{group_col}: %{{text}}
UMAP1: %{{x}}
UMAP2:%{{y}}",
+ size=6,
+ )
fig.update_layout(
title="UMAP comparison",
@@ -284,6 +288,54 @@ def create_umap_plot(df, group_col, color_by_directory=False, theme="plotly_whit
return fig
+def _add_marker_traces(
+ fig, frames, x_column, y_column, text_column, hovertemplate, size=8
+):
+ """Draw one marker trace per frame, each with its own color and shape."""
+ for index, (name, frame) in enumerate(frames):
+ fig.add_trace(
+ go.Scatter(
+ x=frame[x_column],
+ y=frame[y_column],
+ mode="markers",
+ text=frame[text_column],
+ hovertemplate=hovertemplate,
+ name=name,
+ marker=_group_marker(index, size),
+ )
+ )
+
+
+def _group_marker(index, size):
+ # The colors are cycled through first and the shape changes once they run
+ # out, so every combination is used before any of them comes back.
+ return dict(
+ color=GROUP_COLORS[index % len(GROUP_COLORS)],
+ symbol=GROUP_SYMBOLS[(index // len(GROUP_COLORS)) % len(GROUP_SYMBOLS)],
+ size=size,
+ # A neutral outline keeps the palest markers visible in both themes.
+ line=dict(width=1, color="rgba(128, 128, 128, 0.8)"),
+ )
+
+
+def _split_by_group(df, default_name):
+ """Frames to draw as separate traces, one per group when the data is grouped."""
+ if GROUP_COLUMN not in df.columns:
+ return [(default_name, df)]
+
+ return [
+ (group, df.filter(pl.col(GROUP_COLUMN) == group))
+ for group in sorted(df[GROUP_COLUMN].unique())
+ ]
+
+
+def _split_by_directory(df):
+ return [
+ (f"Directory: {run}", df.filter(pl.col("run") == run))
+ for run in sorted(df["run"].unique())
+ ]
+
+
def _wrap_log(text, width=80):
return "
".join([text[i : i + width] for i in range(0, len(text), width)])
diff --git a/docker-compose-dev.yml b/docker-compose-dev.yml
index 67120d5..2237235 100644
--- a/docker-compose-dev.yml
+++ b/docker-compose-dev.yml
@@ -26,6 +26,8 @@ services:
command: celery -A server.make_celery worker --concurrency=4 --loglevel=info
volumes:
- .:/app
+ - ${LOG_DATA_DIRECTORY}:/app/log_data/
+ - ${RESULTS_DIRECTORY}:/app/analysis_results
environment:
DB_URL: "${DB_URL}"
CELERY_BROKER_URL: "${CELERY_BROKER_URL}"
diff --git a/docs/RunAgainstLogLeadBranch.md b/docs/RunAgainstLogLeadBranch.md
new file mode 100644
index 0000000..43ca9fe
--- /dev/null
+++ b/docs/RunAgainstLogLeadBranch.md
@@ -0,0 +1,102 @@
+# Testing VisualLogAnalyzer against an unreleased LogLead branch
+
+Use this when you want to test LogLead changes on GitHub (a branch not yet
+released to PyPI) against VisualLogAnalyzer.
+
+## 1. Point requirements.txt at the branch
+
+Edit [`requirements.txt`](../requirements.txt) and replace the pinned line
+```
+LogLead==1.2.3
+```
+with a direct URL to a source tarball of the branch:
+
+```
+LogLead @ https://github.com/EvoTestOps/LogLead/archive/refs/heads/.tar.gz
+```
+
+Swap `` for whichever branch you're testing, e.g. `feature/more-loaders`.
+
+Note: this uses a plain HTTPS tarball, not a `git+https://...` reference —
+a `git+` URL would need the `git` binary inside the build image, which the
+`python:3.12-slim` base image doesn't include, and we don't want a
+permanent Dockerfile change just for occasional branch testing.
+
+## 2. Rebuild the Docker images
+
+LogLead is installed at image build time (`Dockerfile` runs
+`pip install -r requirements.txt`), so a container restart alone won't
+pick up the change — you need to rebuild:
+
+```bash
+docker compose -f docker-compose-dev.yml build app celery
+docker compose -f docker-compose-dev.yml up -d
+```
+
+(use `docker-compose.yml` instead of `docker-compose-dev.yml` if you're
+not using the dev override).
+
+If you re-run this later to pick up *new* commits pushed to the same
+branch, Docker's layer cache won't notice the branch moved (the
+requirements.txt text is unchanged), so force it:
+
+```bash
+docker compose -f docker-compose-dev.yml build --no-cache app celery
+```
+
+## 3. Verify what actually got installed
+
+```bash
+docker compose -f docker-compose-dev.yml exec app pip freeze | grep -i loglead
+```
+
+You should see the tarball URL you set in step 1, confirming the branch
+version installed rather than the PyPI release, e.g.:
+
+```
+LogLead @ https://github.com/EvoTestOps/LogLead/archive/refs/heads/feature/more-loaders.tar.gz
+```
+
+## 4. Automated smoke test
+
+Tests need the repo's bundled `log_data/LO2` mounted at `/app/log_data`, so (re)create the containers with `.env.sample` (`LOG_DATA_DIRECTORY=./log_data`) rather than your own `.env` — a plain `exec` on an already-running container won't change its mounts:
+
+```bash
+docker compose -f docker-compose-dev.yml --env-file .env.sample up -d --force-recreate app celery
+docker compose -f docker-compose-dev.yml exec app pytest tests
+curl -s http://localhost:5000/health
+```
+
+## 5. Switch back to your own data
+
+Recreate `app`/`celery` again, this time without `--env-file .env.sample`, so they pick up your regular `.env` (e.g. `LOG_DATA_DIRECTORY=~/Datasets`) instead:
+
+```bash
+docker compose -f docker-compose-dev.yml up -d --force-recreate app celery
+```
+
+## 6. Running and Manual Testing
+
+This repeats the relevant parts of [usage_guide.md](usage_guide.md) — see
+that file for the full walkthrough and screenshots.
+
+1. Make sure `LOG_DATA_DIRECTORY` and `RESULTS_DIRECTORY` in `.env` (or
+ `.env.sample`) point at the correct folders:
+ ```
+ LOG_DATA_DIRECTORY=./log_data
+ RESULTS_DIRECTORY=./analysis_results
+ ```
+2. Start the stack (already done in step 2 above if you rebuilt with
+ `up -d`; otherwise `docker compose -f docker-compose-dev.yml --env-file
+ .env up`).
+3. Open in a browser.
+
+## 7. Revert back to the released version when done
+
+```
+LogLead==1.2.3
+```
+
+then rebuild again (step 2). Don't leave a branch pin committed to `main`
+long-term — it's a floating target (CI and anyone else building the image
+would silently pick up whatever the branch currently contains).
diff --git a/tests/test_end_to_end.py b/tests/test_end_to_end.py
index 6cd4b5b..f9a1b32 100644
--- a/tests/test_end_to_end.py
+++ b/tests/test_end_to_end.py
@@ -2,6 +2,19 @@
from playwright.sync_api import Page, expect
+def select_base_path(page: Page, directory: str):
+ """Pick a directory in the project form's base path dropdown.
+
+ The options come from a callback and are virtualized, so wait for them to
+ arrive and type the directory to narrow the list down to the options that
+ are actually rendered.
+ """
+ page.click("#base-path-proj")
+ page.locator(".VirtualizedSelectOption").first.wait_for()
+ page.locator("#base-path-proj input").first.press_sequentially(directory)
+ page.locator(".VirtualizedSelectOption", has_text=directory).first.click()
+
+
def test_has_title(page: Page):
page.goto("http://127.0.0.1:5000/dash/")
@@ -24,7 +37,7 @@ def test_can_run_file_count_analysis(page: Page):
page.get_by_role("button", name="Create a new project").click()
page.get_by_label("Project name").fill("File count test")
- page.get_by_label("Base path").fill("./log_data/LO2")
+ select_base_path(page, "LO2")
page.get_by_role("button", name="Create", exact=True).click()
project_link = page.locator("#project-group li a", has_text="File count test")
@@ -60,7 +73,7 @@ def test_can_run_ano_line_level(page: Page):
page.get_by_role("button", name="Create a new project").click()
page.get_by_label("Project name").fill("Ano Line Test")
- page.get_by_label("Base path").fill("./log_data/LO2")
+ select_base_path(page, "LO2")
page.get_by_role("button", name="Create", exact=True).click()
project_link = page.locator("#project-group li a", has_text="Ano Line Test")
@@ -106,7 +119,7 @@ def test_error_ano_line_with_bad_inputs(page: Page):
page.get_by_role("button", name="Create a new project").click()
page.get_by_label("Project name").fill("Error Ano Line")
- page.get_by_label("Base path").fill("./log_data/LO2")
+ select_base_path(page, "LO2")
page.get_by_role("button", name="Create", exact=True).click()
project_link = page.locator("#project-group li a", has_text="Error Ano Line")