Codex Task Logs

Task ID: task_e_682fd8fa5bcc83238c76cdb7fa28ae48

Environment setup
Configuring language runtimes... + rm -- /tmp/iU2Bls-setup_script.sh + source ./codex_setup.sh ++ uv sync --frozen Using CPython 3.12.3 interpreter at: /usr/bin/python3.12 Creating virtual environment at: .venv Building scubaduck @ file:///workspace/scubaduck Downloading ruff (11.0MiB) Downloading pyright (5.3MiB) Downloading duckdb (19.3MiB) Downloading playwright (43.1MiB) Downloading ruff Built scubaduck @ file:///workspace/scubaduck Downloading duckdb Downloading playwright Downloading pyright Prepared 33 packages in 4.39s Installed 33 packages in 347ms + blinker==1.9.0 + certifi==2025.4.26 + charset-normalizer==3.4.2 + click==8.2.0 + duckdb==1.2.2 + execnet==2.1.1 + flask==3.1.1 + greenlet==3.2.2 + idna==3.10 + iniconfig==2.1.0 + itsdangerous==2.2.0 + jinja2==3.1.6 + markupsafe==3.0.2 + nodeenv==1.9.1 + packaging==25.0 + playwright==1.52.0 + pluggy==1.6.0 + pyee==13.0.0 + pyright==1.1.400 + pytest==8.3.5 + pytest-base-url==2.1.0 + pytest-playwright==0.7.0 + pytest-xdist==3.6.1 + python-dateutil==2.9.0.post0 + python-slugify==8.0.4 + requests==2.32.3 + ruff==0.11.10 + scubaduck==0.1.0 (from file:///workspace/scubaduck) + six==1.17.0 + text-unidecode==1.3 + typing-extensions==4.13.2 + urllib3==2.4.0 + werkzeug==3.1.3 ++ source .venv/bin/activate +++ '[' -z '' ']' +++ '[' -n x ']' +++ SCRIPT_PATH=.venv/bin/activate +++ '[' .venv/bin/activate = /tmp/iU2Bls-setup_script.sh ']' +++ deactivate nondestructive +++ unset -f pydoc +++ '[' -z '' ']' +++ '[' -z '' ']' +++ hash -r +++ '[' -z '' ']' +++ unset VIRTUAL_ENV +++ unset VIRTUAL_ENV_PROMPT +++ '[' '!' nondestructive = nondestructive ']' +++ VIRTUAL_ENV=/workspace/scubaduck/.venv +++ '[' linux-gnu = cygwin ']' +++ '[' linux-gnu = msys ']' +++ export VIRTUAL_ENV +++ '[' -z '' ']' +++ unset SCRIPT_PATH +++ _OLD_VIRTUAL_PATH=/root/.cargo/bin:/root/.rbenv/shims:/root/.rbenv/bin:/root/.rbenv/shims:/root/.local/share/swiftly/bin:/root/.bun/bin:/root/.nvm/versions/node/v22.16.0/bin:/root/.pyenv/shims:3441PYENV_ROOT/shims:/root/.pyenv/bin:/usr/local/go/bin:/root/go/bin:/root/.rbenv/bin:/root/.rbenv/shims:/root/.bun/bin:/root/.local/bin:/root/.pyenv/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin +++ PATH=/workspace/scubaduck/.venv/bin:/root/.cargo/bin:/root/.rbenv/shims:/root/.rbenv/bin:/root/.rbenv/shims:/root/.local/share/swiftly/bin:/root/.bun/bin:/root/.nvm/versions/node/v22.16.0/bin:/root/.pyenv/shims:3441PYENV_ROOT/shims:/root/.pyenv/bin:/usr/local/go/bin:/root/go/bin:/root/.rbenv/bin:/root/.rbenv/shims:/root/.bun/bin:/root/.local/bin:/root/.pyenv/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin +++ export PATH +++ '[' xscubaduck '!=' x ']' +++ VIRTUAL_ENV_PROMPT=scubaduck +++ export VIRTUAL_ENV_PROMPT +++ '[' -z '' ']' +++ '[' -z '' ']' +++ _OLD_VIRTUAL_PS1= +++ PS1='(scubaduck) ' +++ export PS1 +++ alias pydoc +++ true +++ hash -r ++ python -c 'import os; import duckdb; con = duckdb.connect(); con.execute(f"SET http_proxy = '\''{os.getenv("HTTP_PROXY")}'\''"); con.execute("INSTALL '\''sqlite'\'';")' ++ playwright install chromium Downloading Chromium 136.0.7103.25 (playwright build v1169) from https://cdn.playwright.dev/dbazure/download/playwright/builds/chromium/1169/chromium-linux.zip 167.7 MiB [] 0% 0.0s167.7 MiB [] 0% 36.2s167.7 MiB [] 0% 28.2s167.7 MiB [] 0% 17.9s167.7 MiB [] 0% 12.2s167.7 MiB [] 1% 8.7s167.7 MiB [] 1% 9.3s167.7 MiB [] 1% 8.0s167.7 MiB [] 2% 7.4s167.7 MiB [] 2% 7.0s167.7 MiB [] 2% 6.0s167.7 MiB [] 3% 5.6s167.7 MiB [] 4% 4.9s167.7 MiB [] 4% 4.6s167.7 MiB [] 5% 4.5s167.7 MiB [] 5% 4.6s167.7 MiB [] 6% 4.6s167.7 MiB [] 6% 4.3s167.7 MiB [] 7% 4.1s167.7 MiB [] 7% 4.4s167.7 MiB [] 7% 4.3s167.7 MiB [] 8% 4.2s167.7 MiB [] 9% 4.1s167.7 MiB [] 9% 4.3s167.7 MiB [] 9% 4.4s167.7 MiB [] 10% 4.5s167.7 MiB [] 10% 4.6s167.7 MiB [] 10% 4.5s167.7 MiB [] 10% 4.6s167.7 MiB [] 11% 4.4s167.7 MiB [] 12% 4.3s167.7 MiB [] 13% 4.2s167.7 MiB [] 13% 4.1s167.7 MiB [] 14% 3.9s167.7 MiB [] 15% 3.8s167.7 MiB [] 16% 3.8s167.7 MiB [] 16% 3.7s167.7 MiB [] 17% 3.6s167.7 MiB [] 18% 3.5s167.7 MiB [] 19% 3.3s167.7 MiB [] 20% 3.1s167.7 MiB [] 21% 3.0s167.7 MiB [] 22% 2.9s167.7 MiB [] 23% 2.9s167.7 MiB [] 24% 2.8s167.7 MiB [] 25% 2.7s167.7 MiB [] 26% 2.7s167.7 MiB [] 26% 2.6s167.7 MiB [] 27% 2.6s167.7 MiB [] 28% 2.6s167.7 MiB [] 29% 2.5s167.7 MiB [] 30% 2.4s167.7 MiB [] 31% 2.3s167.7 MiB [] 32% 2.3s167.7 MiB [] 33% 2.2s167.7 MiB [] 34% 2.2s167.7 MiB [] 35% 2.2s167.7 MiB [] 35% 2.1s167.7 MiB [] 36% 2.1s167.7 MiB [] 37% 2.1s167.7 MiB [] 37% 2.0s167.7 MiB [] 37% 2.1s167.7 MiB [] 38% 2.0s167.7 MiB [] 39% 2.0s167.7 MiB [] 40% 2.0s167.7 MiB [] 41% 1.9s167.7 MiB [] 42% 1.9s167.7 MiB [] 43% 1.8s167.7 MiB [] 44% 1.8s167.7 MiB [] 44% 1.7s167.7 MiB [] 45% 1.7s167.7 MiB [] 46% 1.7s167.7 MiB [] 47% 1.6s167.7 MiB [] 48% 1.6s167.7 MiB [] 49% 1.6s167.7 MiB [] 50% 1.5s167.7 MiB [] 51% 1.5s167.7 MiB [] 52% 1.5s167.7 MiB [] 52% 1.4s167.7 MiB [] 53% 1.4s167.7 MiB [] 54% 1.4s167.7 MiB [] 55% 1.3s167.7 MiB [] 56% 1.3s167.7 MiB [] 58% 1.2s167.7 MiB [] 59% 1.2s167.7 MiB [] 60% 1.2s167.7 MiB [] 61% 1.1s167.7 MiB [] 62% 1.1s167.7 MiB [] 63% 1.0s167.7 MiB [] 64% 1.0s167.7 MiB [] 65% 1.0s167.7 MiB [] 66% 0.9s167.7 MiB [] 67% 0.9s167.7 MiB [] 68% 0.9s167.7 MiB [] 69% 0.8s167.7 MiB [] 70% 0.8s167.7 MiB [] 71% 0.8s167.7 MiB [] 72% 0.8s167.7 MiB [] 73% 0.7s167.7 MiB [] 74% 0.7s167.7 MiB [] 75% 0.7s167.7 MiB [] 76% 0.7s167.7 MiB [] 76% 0.6s167.7 MiB [] 77% 0.6s167.7 MiB [] 78% 0.6s167.7 MiB [] 79% 0.6s167.7 MiB [] 80% 0.5s167.7 MiB [] 81% 0.5s167.7 MiB [] 82% 0.5s167.7 MiB [] 83% 0.4s167.7 MiB [] 84% 0.4s167.7 MiB [] 85% 0.4s167.7 MiB [] 86% 0.4s167.7 MiB [] 87% 0.3s167.7 MiB [] 88% 0.3s167.7 MiB [] 89% 0.3s167.7 MiB [] 90% 0.3s167.7 MiB [] 91% 0.2s167.7 MiB [] 92% 0.2s167.7 MiB [] 93% 0.2s167.7 MiB [] 94% 0.1s167.7 MiB [] 95% 0.1s167.7 MiB [] 96% 0.1s167.7 MiB [] 97% 0.1s167.7 MiB [] 98% 0.0s167.7 MiB [] 99% 0.0s167.7 MiB [] 100% 0.0s Chromium 136.0.7103.25 (playwright build v1169) downloaded to /root/.cache/ms-playwright/chromium-1169 Downloading FFMPEG playwright build v1011 from https://cdn.playwright.dev/dbazure/download/playwright/builds/ffmpeg/1011/ffmpeg-linux.zip 2.3 MiB [] 0% 0.0s2.3 MiB [] 2% 1.0s2.3 MiB [] 8% 0.4s2.3 MiB [] 17% 0.2s2.3 MiB [] 37% 0.1s2.3 MiB [] 75% 0.0s2.3 MiB [] 100% 0.0s FFMPEG playwright build v1011 downloaded to /root/.cache/ms-playwright/ffmpeg-1011 Downloading Chromium Headless Shell 136.0.7103.25 (playwright build v1169) from https://cdn.playwright.dev/dbazure/download/playwright/builds/chromium/1169/chromium-headless-shell-linux.zip 101.4 MiB [] 0% 0.0s101.4 MiB [] 0% 21.9s101.4 MiB [] 0% 17.1s101.4 MiB [] 0% 10.2s101.4 MiB [] 1% 6.4s101.4 MiB [] 1% 4.4s101.4 MiB [] 2% 3.8s101.4 MiB [] 3% 3.5s101.4 MiB [] 4% 3.3s101.4 MiB [] 4% 3.0s101.4 MiB [] 6% 2.5s101.4 MiB [] 7% 2.3s101.4 MiB [] 8% 2.1s101.4 MiB [] 9% 2.0s101.4 MiB [] 10% 2.0s101.4 MiB [] 11% 1.9s101.4 MiB [] 13% 1.7s101.4 MiB [] 14% 1.6s101.4 MiB [] 16% 1.5s101.4 MiB [] 17% 1.5s101.4 MiB [] 19% 1.4s101.4 MiB [] 20% 1.4s101.4 MiB [] 21% 1.3s101.4 MiB [] 22% 1.3s101.4 MiB [] 24% 1.3s101.4 MiB [] 25% 1.3s101.4 MiB [] 27% 1.2s101.4 MiB [] 28% 1.2s101.4 MiB [] 30% 1.1s101.4 MiB [] 31% 1.1s101.4 MiB [] 32% 1.1s101.4 MiB [] 34% 1.1s101.4 MiB [] 35% 1.0s101.4 MiB [] 37% 1.0s101.4 MiB [] 39% 1.0s101.4 MiB [] 40% 0.9s101.4 MiB [] 42% 0.9s101.4 MiB [] 43% 0.9s101.4 MiB [] 44% 0.9s101.4 MiB [] 46% 0.8s101.4 MiB [] 47% 0.8s101.4 MiB [] 48% 0.8s101.4 MiB [] 49% 0.8s101.4 MiB [] 50% 0.8s101.4 MiB [] 52% 0.7s101.4 MiB [] 53% 0.7s101.4 MiB [] 54% 0.7s101.4 MiB [] 55% 0.7s101.4 MiB [] 56% 0.7s101.4 MiB [] 57% 0.7s101.4 MiB [] 59% 0.7s101.4 MiB [] 60% 0.7s101.4 MiB [] 61% 0.7s101.4 MiB [] 62% 0.6s101.4 MiB [] 64% 0.6s101.4 MiB [] 65% 0.6s101.4 MiB [] 66% 0.6s101.4 MiB [] 67% 0.6s101.4 MiB [] 68% 0.6s101.4 MiB [] 69% 0.5s101.4 MiB [] 70% 0.5s101.4 MiB [] 71% 0.5s101.4 MiB [] 72% 0.5s101.4 MiB [] 73% 0.5s101.4 MiB [] 74% 0.5s101.4 MiB [] 75% 0.4s101.4 MiB [] 76% 0.4s101.4 MiB [] 78% 0.4s101.4 MiB [] 79% 0.4s101.4 MiB [] 80% 0.4s101.4 MiB [] 80% 0.3s101.4 MiB [] 82% 0.3s101.4 MiB [] 83% 0.3s101.4 MiB [] 85% 0.3s101.4 MiB [] 87% 0.2s101.4 MiB [] 89% 0.2s101.4 MiB [] 91% 0.2s101.4 MiB [] 93% 0.1s101.4 MiB [] 95% 0.1s101.4 MiB [] 96% 0.1s101.4 MiB [] 98% 0.0s101.4 MiB [] 99% 0.0s101.4 MiB [] 100% 0.0s Chromium Headless Shell 136.0.7103.25 (playwright build v1169) downloaded to /root/.cache/ms-playwright/chromium_headless_shell-1169 ++ echo 'source .venv/bin/activate'
Internet turned off
Bug: with TEST db, time series view, group by num, we see nothing on the graph. When we remove the group by, we see two spikes (corresponding to the two rows in the table), so those spikes should be present in the SVG when we group by (ideally two distinct series.) Add a test for this and fix it.

I'm checking the instructions and planning to open an interactive session in the container.

shell
ls
^C

Looks like "ls" isn't working—let me verify command access inside root.

shell
^Cls bash-5.2# ls scubaduck bash-5.2#

I'm confirming that I'm at root, time to move into the scubaduck repo!

shell
bash-5.2# cd scubaduck bash-5.2# ls AGENTS.md codex_setup.sh scubaduck tests README.md pyproject.toml stubs uv.lock bash-5.2#

I’m inspecting the repo root now and will take a look at AGENTS.md.

shell
bash-5.2# cat AGENTS.md - Use "source .venv/bin/activate" to activate the virtual environment before running any commands - ONLY use non-interactive commands like cat, sed, apply_patch to do edits. Do NOT use interactive editors. - Do NOT attempt to install packages. Only the packages specified in pyproject.toml are available. You cannot add new packages. If you desperately want another package, make a note of it in the final PR description. - Use conventional commits to format PR title - There are no nested AGENTS.md files, this is the only agents file - When using Playwright, ONLY use chromium browser. This browser is already installed. - Use "ruff check" to check lint, "ruff format" to autoformat files and "pyright" to typecheck. - When writing the PR description, include the original prompt supplied by the user. - When you add functionality to the server, add server tests to tests/test_server.py. When you add features to the frontend, add frontend tests to tests/test_web.py bash-5.2#

Reviewing the instructions. Need to investigate the bug with the time series graph and grouping logic.

shell
bash-5.2# ls -R .: AGENTS.md codex_setup.sh scubaduck tests README.md pyproject.toml stubs uv.lock ./scubaduck: __init__.py sample.csv server.py static ./scubaduck/static: index.html js ./scubaduck/static/js: chip_input.js table_view.js timeseries_chart.js sidebar.js time_column.js view_settings.js ./stubs: duckdb ./stubs/duckdb: __init__.pyi ./tests: conftest.py test_server_time.py test_web_graph.py test_multi_table_web.py test_server_timeseries.py test_web_table.py test_server.py test_web.py test_web_timeseries.py test_server_basic.py test_web_basics.py web_utils.py test_server_db_types.py test_web_chips.py test_server_errors.py test_web_columns.py bash-5.2#

I'll take a look at the tests related to timeseries now.

shell
bash-5.2# sed -n '1,160p' tests/test_web_timeseries.py from __future__ import annotations from typing import Any from tests.web_utils import select_value def test_timeseries_default_query(page: Any, server_url: str) -> None: page.goto(server_url) page.wait_for_selector("#graph_type", state="attached") select_value(page, "#graph_type", "timeseries") page.click("text=Columns") page.check("#column_groups input[value='value']") page.click("text=View Settings") page.evaluate("window.lastResults = undefined") page.click("text=Dive") page.wait_for_function("window.lastResults !== undefined") data = page.evaluate("window.lastResults") assert "error" not in data assert page.is_visible("#chart") page.click("text=Columns") assert not page.is_checked("#column_groups input[value='timestamp']") def test_timeseries_single_bucket(page: Any, server_url: str) -> None: page.goto(server_url) page.wait_for_selector("#graph_type", state="attached") page.fill("#start", "2024-01-01 00:00:00") page.fill("#end", "2024-01-01 00:00:00") select_value(page, "#graph_type", "timeseries") page.click("text=Columns") page.check("#column_groups input[value='value']") page.click("text=View Settings") page.evaluate("window.lastResults = undefined") page.click("text=Dive") page.wait_for_function("window.lastResults !== undefined") path = page.get_attribute("#chart path", "d") assert path is not None and "NaN" not in path def test_timeseries_fill_options(page: Any, server_url: str) -> None: page.goto(server_url) page.wait_for_selector("#graph_type", state="attached") page.fill("#start", "2024-01-01 00:00:00") page.fill("#end", "2024-01-02 03:00:00") select_value(page, "#graph_type", "timeseries") page.click("text=Columns") page.check("#column_groups input[value='value']") page.click("text=View Settings") select_value(page, "#granularity", "1 hour") select_value(page, "#fill", "0") page.evaluate("window.lastResults = undefined") page.click("text=Dive") page.wait_for_function("window.lastResults !== undefined") path_zero = page.get_attribute("#chart path", "d") assert path_zero is not None and path_zero.count("L") > 20 select_value(page, "#fill", "connect") page.evaluate("window.lastResults = undefined") page.click("text=Dive") page.wait_for_function("window.lastResults !== undefined") path_conn = page.get_attribute("#chart path", "d") assert path_conn is not None and path_conn.count("M") == 1 select_value(page, "#fill", "blank") page.evaluate("window.lastResults = undefined") page.click("text=Dive") page.wait_for_function("window.lastResults !== undefined") path_blank = page.get_attribute("#chart path", "d") assert path_blank is not None and path_blank.count("M") > 1 def test_timeseries_hover_highlight(page: Any, server_url: str) -> None: page.goto(server_url) page.wait_for_selector("#graph_type", state="attached") select_value(page, "#graph_type", "timeseries") page.click("text=Columns") page.check("#column_groups input[value='value']") page.click("text=View Settings") page.evaluate("window.lastResults = undefined") page.click("text=Dive") page.wait_for_function("window.lastResults !== undefined") page.wait_for_selector("#chart path", state="attached") path_el = page.query_selector("#chart path") assert path_el page.evaluate( "el => el.dispatchEvent(new MouseEvent('mouseenter', {bubbles: true}))", path_el, ) width = page.evaluate( "getComputedStyle(document.querySelector('#chart path')).strokeWidth" ) assert "2.5" in width color = page.evaluate( "getComputedStyle(document.querySelector('#legend .legend-item')).backgr oundColor" ) assert "221, 221, 221" in color def test_timeseries_crosshair(page: Any, server_url: str) -> None: page.goto(server_url) page.wait_for_selector("#graph_type", state="attached") select_value(page, "#graph_type", "timeseries") page.click("text=Columns") page.check("#column_groups input[value='value']") page.click("text=View Settings") page.evaluate("window.lastResults = undefined") page.click("text=Dive") page.wait_for_function("window.lastResults !== undefined") page.wait_for_selector("#chart path", state="attached") page.eval_on_selector( "#chart", "el => { const r = el.getBoundingClientRect(); el.dispatchEvent(new Mous eEvent('mousemove', {clientX: r.left + r.width/2, clientY: r.top + r.height/2, b ubbles: true})); }", ) line_display = page.evaluate( "document.getElementById('crosshair_line').style.display" ) assert line_display != "none" count = page.eval_on_selector_all("#crosshair_dots circle", "els => els.leng th") assert count > 0 page.eval_on_selector( "#chart", "el => el.dispatchEvent(new MouseEvent('mouseleave', {bubbles: true}))", ) line_display = page.evaluate( "document.getElementById('crosshair_line').style.display" ) assert line_display == "none" def test_timeseries_crosshair_freeze(page: Any, server_url: str) -> None: page.goto(server_url) page.wait_for_selector("#graph_type", state="attached") select_value(page, "#graph_type", "timeseries") page.click("text=Columns") page.check("#column_groups input[value='value']") page.click("text=View Settings") page.evaluate("window.lastResults = undefined") page.click("text=Dive") page.wait_for_function("window.lastResults !== undefined") page.wait_for_selector("#chart path", state="attached") page.eval_on_selector( "#chart", "el => { const r = el.getBoundingClientRect(); el.dispatchEvent(new Mous eEvent('mousemove', {clientX: r.left + r.width/2, clientY: r.top + r.height/2, b ubbles: true})); }", ) page.eval_on_selector( "#chart", "el => { const r = el.getBoundingClientRect(); el.dispatchEvent(new Mous eEvent('click', {clientX: r.left + r.width/2, clientY: r.top + r.height/2, bubbl es: true})); }", ) line_display = page.evaluate( "document.getElementById('crosshair_line').style.display" ) assert line_display != "none" pos1 = page.evaluate("document.getElementById('crosshair_line').getAttribute ('x1')") page.eval_on_selector( "#chart", "el => { const r = el.getBoundingClientRect(); el.dispatchEvent(new Mous eEvent('mousemove', {clientX: r.left + r.width/4, clientY: r.top + r.height/2, b ubbles: true})); }", ) pos2 = page.evaluate("document.getElementById('crosshair_line').getAttribute ('x1')")
bash-5.2# sed -n '160,320p' tests/test_web_timeseries.py pos2 = page.evaluate("document.getElementById('crosshair_line').getAttribute ('x1')") assert pos1 == pos2 page.eval_on_selector( "#chart", "el => el.dispatchEvent(new MouseEvent('mouseleave', {bubbles: true}))", ) line_display = page.evaluate( "document.getElementById('crosshair_line').style.display" ) assert line_display != "none" page.eval_on_selector( "#chart", "el => { const r = el.getBoundingClientRect(); el.dispatchEvent(new Mous eEvent('click', {clientX: r.left + r.width/2, clientY: r.top + r.height/2, bubbl es: true})); }", ) line_display = page.evaluate( "document.getElementById('crosshair_line').style.display" ) assert line_display == "none" def test_timeseries_auto_timezone(browser: Any, server_url: str) -> None: context = browser.new_context(timezone_id="America/New_York") page = context.new_page() page.goto(server_url) page.wait_for_selector("#graph_type", state="attached") select_value(page, "#graph_type", "timeseries") page.click("text=Columns") page.check("#column_groups input[value='value']") page.click("text=View Settings") page.evaluate("window.lastResults = undefined") page.click("text=Dive") page.wait_for_function("window.lastResults !== undefined") path = page.get_attribute("#chart path", "d") context.close() assert path is not None coords = [float(p.split(" ")[1]) for p in path.replace("M", "L").split("L")[ 1:]] assert max(coords) > min(coords) def test_timeseries_multi_series(page: Any, server_url: str) -> None: page.goto(server_url) page.wait_for_selector("#graph_type", state="attached") select_value(page, "#graph_type", "timeseries") page.click("text=Columns") page.check("#column_groups input[value='value']") page.click("text=Add Derived") expr = page.query_selector("#derived_list .derived textarea") assert expr name_inp = page.query_selector("#derived_list .derived .d-name") assert name_inp name_inp.fill("value_2") expr.fill("value * 2") page.click("text=View Settings") page.fill("#start", "2024-01-01 00:00:00") page.fill("#end", "2024-01-03 00:00:00") select_value(page, "#granularity", "1 hour") select_value(page, "#aggregate", "Avg") page.evaluate("window.lastResults = undefined") page.click("text=Dive") page.wait_for_function("window.lastResults !== undefined") count = page.eval_on_selector_all("#chart path", "els => els.length") assert count == 2 def test_timeseries_resize(page: Any, server_url: str) -> None: page.goto(server_url) page.wait_for_selector("#graph_type", state="attached") select_value(page, "#graph_type", "timeseries") page.click("text=Columns") page.check("#column_groups input[value='value']") page.click("text=View Settings") page.evaluate("window.lastResults = undefined") page.click("text=Dive") page.wait_for_function("window.lastResults !== undefined") page.wait_for_selector("#chart path", state="attached") def chart_info() -> dict[str, float]: return page.evaluate( "() => {const p=document.querySelector('#chart path'); const nums=p. getAttribute('d').match(/[-0-9.]+/g).map(parseFloat); return {width: parseFloat( document.getElementById('chart').getAttribute('width')), last: nums[nums.length- 2]};}" ) before = chart_info() legend_width = page.evaluate( "parseFloat(getComputedStyle(document.getElementById('legend')).width)" ) assert page.evaluate( "() => document.getElementById('legend').getBoundingClientRect().right < = document.getElementById('chart').getBoundingClientRect().left" ) page.evaluate("document.getElementById('sidebar').style.width='200px'") page.wait_for_function( "width => document.getElementById('chart').getAttribute('width') != widt h", arg=before["width"], ) after = chart_info() legend_width_after = page.evaluate( "parseFloat(getComputedStyle(document.getElementById('legend')).width)" ) assert after["width"] > before["width"] assert after["last"] > before["last"] assert legend_width_after == legend_width def test_timeseries_no_overflow(page: Any, server_url: str) -> None: page.goto(server_url) page.wait_for_selector("#graph_type", state="attached") select_value(page, "#graph_type", "timeseries") page.click("text=Columns") page.check("#column_groups input[value='value']") page.click("text=View Settings") page.evaluate("window.lastResults = undefined") page.click("text=Dive") page.wait_for_function("window.lastResults !== undefined") overflow = page.evaluate( "var v=document.getElementById('view'); v.scrollWidth > v.clientWidth" ) assert not overflow def test_timeseries_axis_ticks(page: Any, server_url: str) -> None: page.goto(server_url) page.wait_for_selector("#graph_type", state="attached") select_value(page, "#graph_type", "timeseries") page.click("text=Columns") page.check("#column_groups input[value='value']") page.click("text=View Settings") page.evaluate("window.lastResults = undefined") page.click("text=Dive") page.wait_for_function("window.lastResults !== undefined") page.wait_for_selector("#chart text.tick-label", state="attached") count = page.eval_on_selector_all("#chart text.tick-label", "els => els.leng th") assert count > 2 def test_timeseries_y_axis_labels(page: Any, server_url: str) -> None: page.goto(server_url) page.wait_for_selector("#graph_type", state="attached") select_value(page, "#graph_type", "timeseries") page.click("text=Columns") page.check("#column_groups input[value='value']") page.click("text=View Settings") page.evaluate("window.lastResults = undefined") page.click("text=Dive") page.wait_for_function("window.lastResults !== undefined") page.wait_for_selector("#chart text.y-tick-label", state="attached") count = page.eval_on_selector_all("#chart text.y-tick-label", "els => els.le ngth") grid_count = page.eval_on_selector_all("#chart line.grid", "els => els.lengt h") assert count > 0 and count == grid_count def test_timeseries_interval_offset(page: Any, server_url: str) -> None: page.goto(server_url) page.wait_for_selector("#graph_type", state="attached") select_value(page, "#graph_type", "timeseries") page.click("text=Columns") page.check("#column_groups input[value='value']") page.click("text=View Settings") page.fill("#start", "2024-01-01 00:00:00") page.fill("#end", "2024-01-03 12:00:00") select_value(page, "#granularity", "1 hour") page.evaluate("window.lastResults = undefined") page.click("text=Dive")
bash-5.2# sed -n '320,640p' tests/test_web_timeseries.py page.click("text=Dive") page.wait_for_function("window.lastResults !== undefined") page.wait_for_selector("#chart text.tick-label", state="attached") labels = page.eval_on_selector_all( "#chart text.tick-label", "els => els.map(e => e.textContent)" ) assert labels assert all(lbl != "00:00" for lbl in labels) times = [lbl for lbl in labels if ":" in lbl] assert times for t in times: h = int(t.split(":")[0]) assert h % 4 == 0 def test_timeseries_legend_values(page: Any, server_url: str) -> None: page.goto(server_url) page.wait_for_selector("#graph_type", state="attached") select_value(page, "#graph_type", "timeseries") page.click("text=Columns") page.check("#column_groups input[value='value']") page.click("text=View Settings") page.evaluate("g => { groupBy.chips = g; groupBy.renderChips(); }", ["user"] ) select_value(page, "#aggregate", "Avg") page.evaluate("window.lastResults = undefined") page.click("text=Dive") page.wait_for_function("window.lastResults !== undefined") headers = page.evaluate( "() => Array.from(document.querySelectorAll('#legend .legend-header')).m ap(e => e.textContent)" ) assert any(h.startswith("alice") for h in headers) page.wait_for_selector("#chart path", state="attached") page.eval_on_selector( "#chart", "el => { const r=el.getBoundingClientRect(); el.dispatchEvent(new MouseE vent('mousemove', {clientX:r.left+r.width/2, clientY:r.top+r.height/2, bubbles:t rue})); }", ) value = page.evaluate("document.querySelector('#legend .legend-value').textC ontent") assert value != "" def test_timeseries_group_links(page: Any, server_url: str) -> None: page.goto(server_url) page.wait_for_selector("#graph_type", state="attached") select_value(page, "#graph_type", "timeseries") page.click("text=Columns") page.check("#column_groups input[value='value']") page.click("text=View Settings") page.fill("#start", "2024-01-01 00:00:00") page.fill("#end", "2024-01-02 03:00:00") page.evaluate("window.lastResults = undefined") page.click("text=Dive") page.wait_for_function("window.lastResults !== undefined") assert page.text_content("#legend .drill-links h4") == "Group by" page.click("#legend .drill-links a:text('user')") page.wait_for_function("window.lastResults !== undefined") chips = page.evaluate("groupBy.chips") assert chips == ["user"] assert page.text_content("#legend .drill-links h4") == "Drill up" assert page.is_visible("#legend .drill-links a:text('Aggregate')") bash-5.2#

I'll make sure no additional tests are needed for now.

shell
bash-5.2# sed -n '640,960p' tests/test_web_timeseries.py bash-5.2#

I'll dive into the test_server_timeseries.py file to review those API tests.

shell
bash-5.2# sed -n '1,200p' tests/test_server_timeseries.py from __future__ import annotations import json from typing import Any, cast from scubaduck import server def test_group_by_table() -> None: app = server.app client = app.test_client() payload = { "table": "events", "start": "2024-01-01 00:00:00", "end": "2024-01-03 00:00:00", "graph_type": "table", "order_by": "user", "limit": 10, "columns": ["value"], "group_by": ["user"], "aggregate": "Sum", "show_hits": True, } rv = client.post( "/api/query", data=json.dumps(payload), content_type="application/json" ) rows = rv.get_json()["rows"] assert rows[0][0] == "alice" assert rows[0][1] == 2 assert rows[0][2] == 40 def test_table_avg_with_timestamp() -> None: app = server.app client = app.test_client() payload = { "table": "events", "start": "2024-01-01 00:00:00", "end": "2024-01-03 00:00:00", "graph_type": "table", "order_by": "user", "limit": 100, "columns": ["user", "timestamp", "value"], "group_by": ["user"], "aggregate": "Avg", } rv = client.post( "/api/query", data=json.dumps(payload), content_type="application/json" ) data = rv.get_json() assert rv.status_code == 200 assert "error" not in data rows = data["rows"] assert rows[0][0] == "alice" from dateutil import parser ts = parser.parse(rows[0][1]).replace(tzinfo=None) assert ts == parser.parse("2024-01-01 12:00:00") def test_timeseries_basic() -> None: app = server.app client = app.test_client() payload = { "table": "events", "start": "2024-01-01 00:00:00", "end": "2024-01-03 00:00:00", "graph_type": "timeseries", "limit": 100, "group_by": ["user"], "aggregate": "Count", "columns": ["value"], "x_axis": "timestamp", "granularity": "1 day", } rv = client.post( "/api/query", data=json.dumps(payload), content_type="application/json" ) data = rv.get_json() assert rv.status_code == 200 assert len(data["rows"]) == 4 def test_timeseries_orders_by_xaxis() -> None: app = server.app client = app.test_client() payload = { "table": "events", "start": "2024-01-01 00:00:00", "end": "2024-01-03 00:00:00", "graph_type": "timeseries", "limit": 100, "columns": ["value"], "x_axis": "timestamp", "granularity": "1 day", } rv = client.post( "/api/query", data=json.dumps(payload), content_type="application/json" ) data = rv.get_json() assert rv.status_code == 200 rows = data["rows"] from dateutil import parser timestamps = [parser.parse(r[0]).replace(tzinfo=None) for r in rows] assert timestamps == sorted(timestamps) def test_timeseries_count_no_columns() -> None: app = server.app client = app.test_client() payload: dict[str, Any] = { "table": "events", "start": "2024-01-01 00:00:00", "end": "2024-01-03 00:00:00", "graph_type": "timeseries", "granularity": "1 day", "columns": [], } rv = client.post( "/api/query", data=json.dumps(payload), content_type="application/json" ) data = rv.get_json() assert rv.status_code == 200 rows = data["rows"] assert len(rows) == 2 assert rows[0][1] == 2 assert rows[1][1] == 2 def test_timeseries_limit_applies_to_series() -> None: app = server.app client = app.test_client() payload = { "table": "events", "start": "2024-01-01 00:00:00", "end": "2024-01-03 00:00:00", "graph_type": "timeseries", "limit": 1, "order_by": "user", "group_by": ["user"], "aggregate": "Count", "columns": ["value"], "x_axis": "timestamp", "granularity": "1 day", } rv = client.post( "/api/query", data=json.dumps(payload), content_type="application/json" ) data = rv.get_json() assert rv.status_code == 200 assert len(data["rows"]) == 2 assert all(r[1] == "alice" for r in data["rows"]) def test_timeseries_auto_and_fine_buckets() -> None: app = server.app client = app.test_client() def run(gran: str) -> None: payload = { "start": "2024-01-01 00:00:00", "end": "2024-01-02 03:00:00", "graph_type": "timeseries", "columns": ["value"], "x_axis": "timestamp", "granularity": gran, } rv = client.post( "/api/query", data=json.dumps(payload), content_type="application/js on" ) data = rv.get_json() assert rv.status_code == 200 from dateutil import parser start = parser.parse(cast(str, payload["start"])).replace(tzinfo=None) buckets = [ parser.parse(cast(str, r[0])).replace(tzinfo=None) for r in data["ro ws"] ] assert buckets[0] == start if len(buckets) > 1: step = (buckets[1] - buckets[0]).total_seconds() assert step % data["bucket_size"] == 0 assert any(r[1] != 0 for r in data["rows"]) run("Auto") run("Fine") def test_timeseries_string_column_error() -> None: app = server.app client = app.test_client() payload = { "table": "events", "start": "2024-01-01 00:00:00", "end": "2024-01-03 00:00:00", "graph_type": "timeseries", "limit": 7, "columns": ["timestamp", "event", "value", "user"], "x_axis": "timestamp",
bash-5.2# sed -n '200,400p' tests/test_server_timeseries.py "x_axis": "timestamp", "granularity": "1 hour", } rv = client.post( "/api/query", data=json.dumps(payload), content_type="application/json" ) assert rv.status_code == 200 def test_derived_column_basic() -> None: app = server.app client = app.test_client() payload = { "table": "events", "start": "2024-01-01 00:00:00", "end": "2024-01-03 00:00:00", "limit": 10, "columns": ["timestamp"], "derived_columns": {"val2": "value * 2"}, "filters": [], } rv = client.post( "/api/query", data=json.dumps(payload), content_type="application/json" ) data = rv.get_json() assert rv.status_code == 200 assert data["rows"][0][1] == 20 def test_timeseries_derived_column() -> None: app = server.app client = app.test_client() payload = { "table": "events", "start": "2024-01-01 00:00:00", "end": "2024-01-03 00:00:00", "graph_type": "timeseries", "granularity": "1 hour", "limit": 7, "columns": ["value"], "derived_columns": {"derived_1": "value * 2"}, "aggregate": "Avg", } rv = client.post( "/api/query", data=json.dumps(payload), content_type="application/json" ) data = rv.get_json() assert rv.status_code == 200 rows = data["rows"] assert all(r[2] == r[1] * 2 for r in rows) def test_reserved_word_column() -> None: app = server.create_app("TEST") client = app.test_client() payload = { "table": "extra", "columns": ["ts", "desc"], "order_by": "ts", "time_column": "", } rv = client.post( "/api/query", data=json.dumps(payload), content_type="application/json" ) data = rv.get_json() assert rv.status_code == 200 assert len(data["rows"]) == 2 assert data["rows"][0][1] == "x" bash-5.2#

I'll continue reading to gather information about the timeseries tests at the end.

shell
bash-5.2# sed -n '400,800p' tests/test_server_timeseries.py bash-5.2#

The timeseries server tests are done. Next, I’ll check the test_web_graph.py file for graph tests.

shell
bash-5.2# sed -n '1,160p' tests/test_web_graph.py from __future__ import annotations from typing import Any from tests.web_utils import select_value def test_graph_type_table_fields(page: Any, server_url: str) -> None: page.goto(server_url) page.wait_for_selector("#graph_type", state="attached") select_value(page, "#graph_type", "table") assert page.is_visible("#group_by_field") assert page.is_visible("#aggregate_field") assert page.is_visible("#show_hits_field") page.click("text=Columns") assert not page.is_visible("text=Strings:") def test_graph_type_timeseries_fields(page: Any, server_url: str) -> None: page.goto(server_url) page.wait_for_selector("#graph_type", state="attached") select_value(page, "#graph_type", "timeseries") assert page.is_visible("#group_by_field") assert page.is_visible("#aggregate_field") assert page.is_visible("#x_axis_field") assert page.is_visible("#granularity_field") assert page.is_visible("#fill_field") def test_limit_persists_per_chart_type(page: Any, server_url: str) -> None: page.goto(server_url) page.wait_for_selector("#graph_type", state="attached") assert page.input_value("#limit") == "100" select_value(page, "#graph_type", "timeseries") assert page.input_value("#limit") == "7" select_value(page, "#graph_type", "samples") assert page.input_value("#limit") == "100" def test_columns_persist_per_chart_type(page: Any, server_url: str) -> None: page.goto(server_url) page.wait_for_selector("#graph_type", state="attached") page.click("text=Columns") page.wait_for_selector("#column_groups input", state="attached") page.uncheck("#column_groups input[value='value']") select_value(page, "#graph_type", "timeseries") count = page.evaluate( "document.querySelectorAll('#column_groups input:checked').length" ) assert count == 0 select_value(page, "#graph_type", "samples") count = page.evaluate( "document.querySelectorAll('#column_groups input:checked').length" ) assert count == 3 bash-5.2#

The graph doesn't show spikes when grouping by num. I'll search for related tests.

shell
bash-5.2# grep -n "group_by" -n tests/test_web_timeseries.py bash-5.2#

I see no direct string but will explore test cases using group-by or numbers.

shell
bash-5.2# cat scubaduck/sample.csv | head timestamp,event,value,user 2024-01-01 00:00:00,login,10,alice 2024-01-01 01:00:00,logout,20,bob 2024-01-02 00:00:00,login,30,alice 2024-01-02 03:00:00,login,40,charlie bash-5.2#

I'll check server.py to see how the DB interacts with the sample.csv data.

shell
bash-5.2# sed -n '1,200p' scubaduck/server.py from __future__ import annotations from dataclasses import dataclass, field, replace from typing import Any, Dict, List, Tuple, cast import re from datetime import datetime, timedelta, timezone import time from pathlib import Path import os import traceback import math import duckdb from dateutil import parser as dtparser from dateutil.relativedelta import relativedelta from flask import Flask, jsonify, request, send_from_directory def _quote(ident: str) -> str: """Return identifier quoted for SQL.""" return f'"{ident.replace('"', '""')}"' @dataclass class Filter: column: str op: str value: str | int | float | list[str] | None @dataclass class QueryParams: start: str | None = None end: str | None = None order_by: str | None = None order_dir: str = "ASC" limit: int | None = None columns: list[str] = field(default_factory=lambda: []) filters: list[Filter] = field(default_factory=lambda: []) derived_columns: dict[str, str] = field(default_factory=lambda: {}) graph_type: str = "samples" group_by: list[str] = field(default_factory=lambda: []) aggregate: str | None = None show_hits: bool = False x_axis: str | None = None granularity: str = "Auto" fill: str = "0" table: str = "events" time_column: str | None = "timestamp" time_unit: str = "s" def _load_database(path: Path) -> duckdb.DuckDBPyConnection: if not path.exists(): raise FileNotFoundError(path) ext = path.suffix.lower() if ext == ".csv": con = duckdb.connect() con.execute( f"CREATE TABLE events AS SELECT * FROM read_csv_auto('{path.as_posix ()}')" ) elif ext in {".db", ".sqlite"}: con = duckdb.connect() con.execute("LOAD sqlite") con.execute(f"ATTACH '{path.as_posix()}' AS db (TYPE SQLITE)") tables = [ r[0] for r in con.execute( "SELECT name FROM sqlite_master WHERE type='table'" ).fetchall() ] for t in tables: con.execute(f'CREATE VIEW "{t}" AS SELECT * FROM db."{t}"') else: con = duckdb.connect(path) return con def _create_test_database() -> duckdb.DuckDBPyConnection: """Return a DuckDB connection with a small multi-table dataset.""" con = duckdb.connect() con.execute( "CREATE TABLE events (id INTEGER PRIMARY KEY, ts INTEGER, val REAL, name TEXT, flag BOOLEAN)" ) con.execute("INSERT INTO events VALUES (1, 1704067200, 1.5, 'alice', 1)") con.execute("INSERT INTO events VALUES (2, 1704070800, 2.0, 'bob', 0)") con.execute('CREATE TABLE extra (ts INTEGER, "desc" TEXT, num INTEGER)') con.execute("INSERT INTO extra VALUES (1704067200, 'x', 1)") con.execute("INSERT INTO extra VALUES (1704070800, 'y', 2)") return con _REL_RE = re.compile( r"([+-]?\d+(?:\.\d*)?)\s*(hour|hours|day|days|week|weeks|fortnight|fortnight s|month|months|year|years)", re.IGNORECASE, ) def parse_time(val: str | None) -> str | None: """Parse an absolute or relative time string into ``YYYY-MM-DD HH:MM:SS``."" " if val is None or val == "": return None s = val.strip() if s.lower() == "now": dt = datetime.now(timezone.utc) return dt.replace(microsecond=0).strftime("%Y-%m-%d %H:%M:%S") m = _REL_RE.fullmatch(s) if m: qty = float(m.group(1)) unit = m.group(2).lower() now = datetime.now(timezone.utc) dt: datetime if unit.startswith("hour"): dt = now + timedelta(hours=qty) elif unit.startswith("day"): dt = now + timedelta(days=qty) elif unit.startswith("week"): dt = now + timedelta(weeks=qty) elif unit.startswith("fortnight"): dt = now + timedelta(weeks=2 * qty) elif unit.startswith("month"): if qty.is_integer(): dt = now + relativedelta(months=int(qty)) else: dt = now + timedelta(days=30 * qty) elif unit.startswith("year"): if qty.is_integer(): dt = now + relativedelta(years=int(qty)) else: dt = now + timedelta(days=365 * qty) else: # pragma: no cover - defensive raise ValueError(f"Unsupported unit: {unit}") return dt.replace(microsecond=0).strftime("%Y-%m-%d %H:%M:%S") dt = dtparser.parse(s) return dt.replace(microsecond=0, tzinfo=None).strftime("%Y-%m-%d %H:%M:%S") def _numeric_to_datetime(value: int | float, unit: str) -> datetime: """Convert a numeric timestamp ``value`` with unit ``unit`` to ``datetime``. Heuristically fall back to seconds when the converted value is before 1990 but the seconds interpretation is in a reasonable range. This handles integer columns stored in seconds even when ``unit`` is mistakenly set to a finer granularity. """ divisor = { "s": 1, "ms": 1000, "us": 1_000_000, "ns": 1_000_000_000, }.get(unit, 1) dt = datetime.fromtimestamp(int(value) / divisor, tz=timezone.utc) if unit != "s" and dt.year < 1990: alt = datetime.fromtimestamp(int(value), tz=timezone.utc) if alt.year >= 1990: dt = alt return dt def _granularity_seconds(granularity: str, start: str | None, end: str | None) - > int: gran = granularity.lower() mapping = { "1 second": 1, "5 seconds": 5, "10 seconds": 10, "30 seconds": 30, "1 minute": 60, "4 minutes": 240, "5 minutes": 300, "10 minutes": 600, "15 minutes": 900, "30 minutes": 1800, "1 hour": 3600, "3 hours": 10800, "6 hours": 21600, "1 day": 86400, "1 week": 604800, "30 days": 2592000, } if gran in mapping: return mapping[gran] if gran in {"auto", "fine"} and start and end: try: s = dtparser.parse(start) e = dtparser.parse(end) except Exception: return 3600 total = max((e - s).total_seconds(), 1) buckets = 100 if gran == "auto" else 500 return max(int(total // buckets), 1) return 3600 bash-5.2#

I'm scrolling further to properly group the data by timeseries.

shell
bash-5.2# grep -n "timeseries" -n scubaduck/server.py 252: if params.graph_type == "timeseries": 256: raise ValueError("x_axis required for timeseries") 274: group_cols[1:] if params.graph_type == "timeseries" else group_c ols 336: elif params.graph_type == "timeseries": 390: elif params.graph_type == "timeseries": 530: if params.graph_type not in {"table", "timeseries"} and ( 536: "error": "group_by, aggregate and show_hits are only valid for table or timeseries view" 544: if params.graph_type == "timeseries": 574: if params.group_by or params.graph_type == "timeseries": 675: if params.graph_type == "timeseries": bash-5.2#

I’ll focus on reviewing the parts related to timeseries queries and formatting.

shell
base = f"CAST({qcol} AS BIGINT)" expr = f"CAST({base} * {multiplier} AS BIGINT)" if multiplier != 1 e lse base return f"make_timestamp({expr})" return qcol def build_query(params: QueryParams, column_types: Dict[str, str] | None = None) -> str: select_parts: list[str] = [] group_cols = params.group_by[:] selected_for_order = set(params.columns) | set(params.derived_columns.keys() ) if params.graph_type == "timeseries": sec = _granularity_seconds(params.granularity, params.start, params.end) x_axis = params.x_axis or params.time_column if x_axis is None: raise ValueError("x_axis required for timeseries") xexpr = _time_expr(x_axis, column_types, params.time_unit) if params.start: bucket_expr = ( f"TIMESTAMP '{params.start}' + INTERVAL '{sec} second' * " f"CAST(floor((epoch({xexpr}) - epoch(TIMESTAMP '{params.start}') )/{sec}) AS BIGINT)" ) else: bucket_expr = ( f"TIMESTAMP 'epoch' + INTERVAL '{sec} second' * " f"CAST(floor(epoch({xexpr})/{sec}) AS BIGINT)" ) select_parts.append(f"{bucket_expr} AS bucket") group_cols = ["bucket"] + group_cols selected_for_order.add("bucket") has_agg = bool(group_cols) or params.aggregate is not None if has_agg: select_cols = ( group_cols[1:] if params.graph_type == "timeseries" else group_cols ) select_parts.extend(_quote(c) for c in select_cols) agg = (params.aggregate or "count").lower() selected_for_order.update(group_cols) def agg_expr(col: str) -> str: expr = _quote(col) ctype = column_types.get(col, "").upper() if column_types else "" if "BOOL" in ctype: expr = f"CAST({_quote(col)} AS BIGINT)" if agg.startswith("p"): quant = float(agg[1:]) / 100 return f"quantile({expr}, {quant})" if agg == "count distinct": return f"count(DISTINCT {expr})" if agg == "avg" and column_types is not None: if "TIMESTAMP" in ctype or "DATE" in ctype or "TIME" in ctype: return ( "TIMESTAMP 'epoch' + INTERVAL '1 second' * " f"CAST(avg(epoch({_quote(col)})) AS BIGINT)" ) return f"{agg}({expr})" if agg == "count": select_parts.append("count(*) AS Count") selected_for_order.add("Count") else: for col in params.columns: if col in group_cols: continue select_parts.append(f"{agg_expr(col)} AS {_quote(col)}") selected_for_order.add(col) if params.show_hits: select_parts.insert(len(group_cols), "count(*) AS Hits") selected_for_order.add("Hits") else: select_parts.extend(_quote(c) for c in params.columns) selected_for_order.update(params.columns) order_by = params.order_by if params.order_by in selected_for_order else Non e if has_agg and params.derived_columns: inner_params = replace( params, derived_columns={}, order_by=None, limit=None, ) inner_sql = build_query(inner_params, column_types) outer_select = ["t.*"] + [ f"{expr} AS {name}" for name, expr in params.derived_columns.items() ] indented_inner = "\n".join(" " + line for line in inner_sql.splitline s()) lines = [ f"SELECT {', '.join(outer_select)}", "FROM (", indented_inner, ") t", ] if order_by: lines.append(f"ORDER BY {_quote(order_by)} {params.order_dir}") elif params.graph_type == "timeseries": lines.append("ORDER BY bucket") if params.limit is not None: lines.append(f"LIMIT {params.limit}") return "\n".join(lines) for name, expr in params.derived_columns.items(): select_parts.append(f"{expr} AS {name}") selected_for_order.add(name) select_clause = ", ".join(select_parts) if select_parts else "*" lines = [f"SELECT {select_clause}", f'FROM "{params.table}"'] where_parts: list[str] = [] if params.time_column: time_expr = _time_expr(params.time_column, column_types, params.time_uni t) else: time_expr = None if time_expr and params.start: where_parts.append(f"{time_expr} >= '{params.start}'") if time_expr and params.end: where_parts.append(f"{time_expr} <= '{params.end}'") for f in params.filters: op = f.op if op in {"empty", "!empty"}: val = "''" else: if f.value is None: continue if isinstance(f.value, list): if not f.value: continue if op == "=": qcol = _quote(f.column) vals = " OR ".join(f"{qcol} = '{v}'" for v in f.value) where_parts.append(f"({vals})") continue val = f"'{f.value}'" if isinstance(f.value, str) else str(f.value) qcol = _quote(f.column) if op == "contains": where_parts.append(f"{qcol} ILIKE '%' || {val} || '%'") elif op == "!contains": where_parts.append(f"{qcol} NOT ILIKE '%' || {val} || '%'") elif op == "empty": where_parts.append(f"{qcol} = {val}") elif op == "!empty": where_parts.append(f"{qcol} != {val}") else: where_parts.append(f"{qcol} {op} {val}") if where_parts: lines.append("WHERE " + " AND ".join(where_parts)) if group_cols: lines.append("GROUP BY " + ", ".join(_quote(c) for c in group_cols)) if order_by: lines.append(f"ORDER BY {_quote(order_by)} {params.order_dir}") elif params.graph_type == "timeseries": lines.append("ORDER BY bucket") if params.limit is not None: lines.append(f"LIMIT {params.limit}") return "\n".join(lines) def create_app(db_file: str | Path | None = None) -> Flask: app = Flask(__name__, static_folder="static") if db_file is None: env_db = os.environ.get("SCUBADUCK_DB") if env_db: db_file = env_db if isinstance(db_file, str) and db_file.upper() == "TEST": con = _create_test_database() else: db_path = Path(db_file or Path(__file__).with_name("sample.csv")).resolv e() con = _load_database(db_path) tables = [r[0] for r in con.execute("SHOW TABLES").fetchall()] if not tables: raise ValueError("No tables found in database") default_table = tables[0] columns_cache: Dict[str, Dict[str, str]] = {} def get_columns(table: str) -> Dict[str, str]: if table not in columns_cache: rows = con.execute(f'PRAGMA table_info("{table}")').fetchall() if not rows: raise ValueError(f"Unknown table: {table}") columns_cache[table] = {r[1]: r[2] for r in rows} return columns_cache[table]
bash-5.2# sed -n '420,840p' scubaduck/server.py return columns_cache[table] sample_cache: Dict[Tuple[str, str, str], Tuple[List[str], float]] = {} CACHE_TTL = 60.0 CACHE_LIMIT = 200 @app.route("/") def index() -> Any: # pyright: ignore[reportUnusedFunction] assert app.static_folder is not None return send_from_directory(app.static_folder, "index.html") @app.route("/js/<path:filename>") def js(filename: str) -> Any: # pyright: ignore[reportUnusedFunction] assert app.static_folder is not None folder = Path(app.static_folder) / "js" return send_from_directory(folder, filename) @app.route("/api/tables") def tables_endpoint() -> Any: # pyright: ignore[reportUnusedFunction] return jsonify(tables) @app.route("/api/columns") def columns() -> Any: # pyright: ignore[reportUnusedFunction] table = request.args.get("table", default_table) rows = con.execute(f'PRAGMA table_info("{table}")').fetchall() return jsonify([{"name": r[1], "type": r[2]} for r in rows]) def _cache_get(key: Tuple[str, str, str]) -> List[str] | None: item = sample_cache.get(key) if item is None: return None vals, ts = item if time.time() - ts > CACHE_TTL: del sample_cache[key] return None sample_cache[key] = (vals, time.time()) return vals def _cache_set(key: Tuple[str, str, str], vals: List[str]) -> None: sample_cache[key] = (vals, time.time()) if len(sample_cache) > CACHE_LIMIT: oldest = min(sample_cache.items(), key=lambda kv: kv[1][1])[0] del sample_cache[oldest] @app.route("/api/samples") def sample_values() -> Any: # pyright: ignore[reportUnusedFunction] table = request.args.get("table", default_table) column = request.args.get("column") substr = request.args.get("q", "") column_types = get_columns(table) if not column or column not in column_types: return jsonify([]) ctype = column_types[column].upper() if "CHAR" not in ctype and "STRING" not in ctype and "VARCHAR" not in ct ype: return jsonify([]) key = (table, column, substr) cached = _cache_get(key) if cached is not None: return jsonify(cached) qcol = _quote(column) rows = con.execute( f"SELECT DISTINCT {qcol} FROM \"{table}\" WHERE CAST({qcol} AS VARCH AR) ILIKE '%' || ? || '%' LIMIT 20", [substr], ).fetchall() values = [r[0] for r in rows] _cache_set(key, values) return jsonify(values) @app.route("/api/query", methods=["POST"]) def query() -> Any: # pyright: ignore[reportUnusedFunction] payload = request.get_json(force=True) try: start = parse_time(payload.get("start")) end = parse_time(payload.get("end")) except Exception as exc: return jsonify({"error": str(exc)}), 400 params = QueryParams( start=start, end=end, order_by=payload.get("order_by"), order_dir=payload.get("order_dir", "ASC"), limit=payload.get("limit"), columns=payload.get("columns", []), derived_columns=payload.get("derived_columns", {}), graph_type=payload.get("graph_type", "samples"), group_by=payload.get("group_by", []), aggregate=payload.get("aggregate"), show_hits=payload.get("show_hits", False), x_axis=payload.get("x_axis"), granularity=payload.get("granularity", "Auto"), fill=payload.get("fill", "0"), table=payload.get("table", default_table), time_column=payload.get("time_column", "timestamp"), time_unit=payload.get("time_unit", "s"), ) for f in payload.get("filters", []): params.filters.append(Filter(f["column"], f["op"], f.get("value"))) if params.table not in tables: return jsonify({"error": "Invalid table"}), 400 column_types = get_columns(params.table) if params.time_column and params.time_column not in column_types: return jsonify({"error": "Invalid time_column"}), 400 if params.time_unit not in {"s", "ms", "us", "ns"}: return jsonify({"error": "Invalid time_unit"}), 400 if params.graph_type not in {"table", "timeseries"} and ( params.group_by or params.aggregate or params.show_hits ): return ( jsonify( { "error": "group_by, aggregate and show_hits are only val id for table or timeseries view" } ), 400, ... return ( jsonify( { "error": f"Aggregate {agg} cannot be applied to column {c}", } ), 400, ) if allow_time and not (is_numeric or is_time): return ( jsonify( { "error": f"Aggregate {agg} cannot be applied to column {c}", } ), 400, ) if (params.start is None or params.end is None) and ( params.x_axis or params.time_column ): axis = params.x_axis or params.time_column assert axis is not None row = cast( tuple[datetime | None, datetime | None], con.execute( f'SELECT min({_quote(axis)}), max({_quote(axis)}) FROM "{par ams.table}"' ).fetchall()[0], ) mn, mx = row if isinstance(mn, (int, float)): try: mn = _numeric_to_datetime(mn, params.time_unit) except Exception: return ( jsonify( { "error": ( f"Invalid time value {mn} for column {axis}" f" with time_unit {params.time_unit}" ) } ), 400, ) if isinstance(mx, (int, float)): try: mx = _numeric_to_datetime(mx, params.time_unit) except Exception: return ( jsonify( { "error": ( f"Invalid time value {mx} for column {axis}" f" with time_unit {params.time_unit}" ) } ), 400, ) if params.start is None and mn is not None: params.start = ( mn.strftime("%Y-%m-%d %H:%M:%S") if not isinstance(mn, str) else mn ) if params.end is None and mx is not None: params.end = ( mx.strftime("%Y-%m-%d %H:%M:%S") if not isinstance(mx, str) else mx ) bucket_size: int | None = None if params.graph_type == "timeseries": bucket_size = _granularity_seconds( params.granularity, params.start if isinstance(params.start, str) else None, params.end if isinstance(params.end, str) else None, ) if ( params.limit is not None and params.start is not None and params.end is not None ): try: start_dt = dtparser.parse(params.start) end_dt = dtparser.parse(params.end) buckets = math.ceil( (end_dt - start_dt).total_seconds() / bucket_size ) if buckets > 1: params.limit *= buckets except Exception: pass sql = build_query(params, column_types) try: rows = con.execute(sql).fetchall() except Exception as exc: tb = traceback.format_exc() print(f"Query failed:\n{sql}\n{tb}") return ( jsonify({"sql": sql, "error": str(exc), "traceback": tb}), 400, ) result: Dict[str, Any] = {"sql": sql, "rows": rows} if params.start is not None: result["start"] = str(params.start) if params.end is not None: result["end"] = str(params.end) if bucket_size is not None: result["bucket_size"] = bucket_size return jsonify(result) return app app = create_app() if __name__ == "__main__": app.run(debug=True) bash-5.2#

I'll take a look at the static timeseries chart in the JS code.

shell
let currentChart = null; function showTimeSeries(data) { function parseTs(s) { if (s.match(/GMT/) || s.endsWith('Z') || /\+\d{2}:?\d{2}$/.test(s)) { return new Date(s).getTime(); } return new Date(s + 'Z').getTime(); } const view = document.getElementById('view'); if (data.rows.length === 0) { view.innerHTML = '<p id="empty-message">Empty data provided to table</p>'; return; } const height = 600; view.innerHTML = '<div id="ts-container"><div id="legend"></div><div id="chart-wrapper"><svg id="chart" height="' + height + '"></svg></div></div>'; const svg = document.getElementById('chart'); const legend = document.getElementById('legend'); const crosshairLine = document.createElementNS('http://www.w3.org/2000/svg', ' line'); crosshairLine.id = 'crosshair_line'; crosshairLine.setAttribute('stroke', '#555'); crosshairLine.style.display = 'none'; const crosshairDots = document.createElementNS('http://www.w3.org/2000/svg', ' g'); crosshairDots.id = 'crosshair_dots'; crosshairDots.style.display = 'none'; const groups = groupBy.chips || []; const hasHits = document.getElementById('show_hits').checked ? 1 : 0; const fill = document.getElementById('fill').value; const bucketMs = (data.bucket_size || 3600) * 1000; const start = data.start ? parseTs(data.start) : null; const end = data.end ? parseTs(data.end) : null; const startIdx = 1 + groups.length + hasHits; const valueCols = selectedColumns.slice(groups.length + hasHits); const series = {}; data.rows.forEach(r => { const ts = parseTs(r[0]); const groupKey = groups.map((_, i) => r[1 + i]).join(':') || 'all'; valueCols.forEach((name, i) => { const val = Number(r[startIdx + i]); const key = groupKey === 'all' ? name : groupKey + ':' + name; if (!series[key]) series[key] = {}; series[key][ts] = val; }); }); const buckets = []; let minX = start !== null ? start : Infinity; let maxX = end !== null ? end : -Infinity; if (start !== null && end !== null) { for (let t = start; t <= end; t += bucketMs) { buckets.push(t); } } else { Object.keys(series).forEach(k => { const s = series[k]; Object.keys(s).forEach(t => { const n = Number(t); if (n < minX) minX = n; if (n > maxX) maxX = n; }); }); for (let t = minX; t <= maxX; t += bucketMs) { buckets.push(t); } } let minY = Infinity, maxY = -Infinity; Object.keys(series).forEach(key => { const vals = series[key]; buckets.forEach(b => { const v = vals[b]; const val = v === undefined && fill === '0' ? 0 : v; if (val === undefined) return; if (val < minY) minY = val; if (val > maxY) maxY = val; }); }); if (fill === '0') { if (minY > 0) minY = 0; if (maxY < 0) maxY = 0; } const colors = [ '#1f77b4', '#ff7f0e', '#2ca02c', '#d62728', '#9467bd', '#8c564b', '#e377c2' ]; currentChart = { svg, legend, series, buckets, minX, maxX, minY, maxY, fill, colors, height, crosshairLine, crosshairDots, seriesEls: {}, bucketPixels: [], xScale: null, yScale: null, selected: null, frozen: false }; const intervals = [ {unit: 'second', step: 1, ms: 1000}, {unit: 'second', step: 2, ms: 2000}, {unit: 'second', step: 5, ms: 5000}, {unit: 'second', step: 10, ms: 10000}, {unit: 'second', step: 15, ms: 15000}, {unit: 'second', step: 30, ms: 30000}, {unit: 'minute', step: 1, ms: 60000}, {unit: 'minute', step: 2, ms: 120000}, {unit: 'minute', step: 5, ms: 300000}, {unit: 'minute', step: 10, ms: 600000}, {unit: 'minute', step: 15, ms: 900000}, {unit: 'minute', step: 30, ms: 1800000}, {unit: 'hour', step: 1, ms: 3600000}, {unit: 'hour', step: 2, ms: 7200000}, {unit: 'hour', step: 3, ms: 10800000}, {unit: 'hour', step: 4, ms: 14400000}, {unit: 'hour', step: 6, ms: 21600000}, {unit: 'hour', step: 12, ms: 43200000}, {unit: 'day', step: 1, ms: 86400000}, {unit: 'day', step: 2, ms: 172800000}, {unit: 'week', step: 1, ms: 604800000}, {unit: 'week', step: 2, ms: 1209600000}, {unit: 'month', step: 1}, {unit: 'month', step: 3}, {unit: 'month', step: 6}, {unit: 'year', step: 1}, {unit: 'year', step: 2}, {unit: 'year', step: 5}, {unit: 'year', step: 10} ]; function chooseInterval(start, end) { const span = end - start; function approxMs(i) { if (i.ms) return i.ms; if (i.unit === 'month') return i.step * 2629800000; if (i.unit === 'year') return i.step * 31557600000; return 1000; } let best = intervals[0]; let bestScore = Infinity; intervals.forEach(i => { const count = span / approxMs(i); const score = Math.abs(count - 15); if (score < bestScore) { best = i; bestScore = score; } }); return best; } function generateTicks(start, end, intv) { const ticks = []; if (intv.unit === 'month' || intv.unit === 'year') { let d = new Date(start); d.setUTCDate(1); if (intv.unit === 'year') d.setUTCMonth(0); let unitVal = intv.unit === 'month' ? d.getUTCFullYear() * 12 + d.getUTCMonth() : d.getUTCFullYear(); unitVal = Math.ceil(unitVal / intv.step) * intv.step; while (true) { const year = intv.unit === 'month' ? Math.floor(unitVal / 12) : unitVal; const month = intv.unit === 'month' ? unitVal % 12 : 0; const t = Date.UTC(year, month, 1); if (t > end) break; if (t >= start) ticks.push(t); unitVal += intv.step; } } else { const step = intv.ms * intv.step; let t = Math.ceil(start / step) * step; if (intv.unit === 'week') { const d = new Date(t); const adj = (d.getUTCDay() + 6) % 7; t = d.getTime() - adj * 86400000; t = Math.ceil(t / step) * step; } if (t === start) t += step; for (; t <= end; t += step) ticks.push(t); } return ticks; } function labelUnit(intv) { if (intv.unit === 'year') return 'year'; if (intv.unit === 'month') return 'month'; if (intv.unit === 'day' || intv.unit === 'week') return 'day'; if (intv.unit === 'hour') return 'hour'; return 'minute'; } function fmt(date, unit) { const pad = n => String(n).padStart(2, '0'); const mon = date.toLocaleString('en-US', {month: 'short'}); switch (unit) { case 'year': return String(date.getFullYear()); case 'month': if (date.getMonth() === 0) return String(date.getFullYear()); return `${mon} ${date.getFullYear()}`; case 'day': if (date.getDate() === 1) return `${mon} ${date.getFullYear()}`; return `${date.getDate()} ${mon}`; case 'hour': if (date.getHours() === 0 && date.getMinutes() === 0) return `${date.getDate()} ${mon}`; return `${pad(date.getHours())}:${pad(date.getMinutes())}`; default: if (date.getMinutes() === 0 && date.getSeconds() === 0) return `${pad(date.getHours())}:${pad(date.getMinutes())}`; return `${pad(date.getMinutes())}:${pad(date.getSeconds())}`; } } function niceNum(range, round) { bash-5.2#

Scrolling further to confirm the group by process is properly addressed.

shell
bash-5.2# grep -n 'group' -n scubaduck/static/js/timeseries_chart.js 31: const groups = groupBy.chips || []; 37: const startIdx = 1 + groups.length + hasHits; 38: const valueCols = selectedColumns.slice(groups.length + hasHits); 42: const groupKey = groups.map((_, i) => r[1 + i]).join(':') || 'all'; 45: const key = groupKey === 'all' ? name : groupKey + ':' + name; 314: const groups = {}; 346: const groupKey = idx === -1 ? 'all' : key.slice(0, idx); 348: let group = groups[groupKey]; 349: if (!group) { 351: gEl.className = 'legend-group'; 355: groupKey === 'all' ? agg : `${groupKey} ${agg}`; 361: group = {items}; 362: groups[groupKey] = group; 373: group.items.appendChild(item); 430: if ((groupBy.chips || []).length) { 437: groupBy.chips = []; 438: groupBy.renderChips(); 450: groupBy.addChip(col); 451: groupBy.renderChips(); bash-5.2#

I'll look for group_by in the server or any JS files.

shell
bash-5.2# grep -n "groupKey" -n scubaduck/static/js/timeseries_chart.js 42: const groupKey = groups.map((_, i) => r[1 + i]).join(':') || 'all'; 45: const key = groupKey === 'all' ? name : groupKey + ':' + name; 346: const groupKey = idx === -1 ? 'all' : key.slice(0, idx); 348: let group = groups[groupKey]; 355: groupKey === 'all' ? agg : `${groupKey} ${agg}`; 362: groups[groupKey] = group; bash-5.2#

I'm checking if "data.rows" is being recognized and if groupKey is correct for the test.

shell
bash-5.2# grep -n "extra" -R tests tests/test_server_timeseries.py:256: "table": "extra", tests/test_multi_table_web.py:29: select_value(page, "#table", "extra") tests/test_multi_table_web.py:36: assert table_param == "extra" tests/test_multi_table_web.py:44: select_value(page, "#table", "extra") tests/test_multi_table_web.py:50: assert page.input_value("#table") == "extra " tests/test_multi_table_web.py:52: assert disp is not None and disp.strip() == "extra" bash-5.2#

I'll open the tests that mention "extra" to check out how they're used.

shell
bash-5.2# sed -n '240,300p' tests/test_server_timeseries.py "derived_columns": {"derived_1": "value * 2"}, "aggregate": "Avg", } rv = client.post( "/api/query", data=json.dumps(payload), content_type="application/json" ) data = rv.get_json() assert rv.status_code == 200 rows = data["rows"] assert all(r[2] == r[1] * 2 for r in rows) def test_reserved_word_column() -> None: app = server.create_app("TEST") client = app.test_client() payload = { "table": "extra", "columns": ["ts", "desc"], "order_by": "ts", "time_column": "", } rv = client.post( "/api/query", data=json.dumps(payload), content_type="application/json" ) data = rv.get_json() assert rv.status_code == 200 assert len(data["rows"]) == 2 assert data["rows"][0][1] == "x" bash-5.2#

I need to add a time series group-by test for the "num" column on the extra table. Let's look at the relevant test files.

shell
from typing import Any import pytest from werkzeug.serving import make_server from scubaduck.server import create_app from tests.web_utils import select_value @pytest.fixture() def multi_table_server_url() -> Iterator[str]: app = create_app("TEST") httpd = make_server("127.0.0.1", 0, app) port = httpd.server_port thread = threading.Thread(target=httpd.serve_forever) thread.start() try: yield f"http://127.0.0.1:{port}" finally: httpd.shutdown() thread.join() def test_table_param_updates_on_dive(page: Any, multi_table_server_url: str) -> None: page.goto(multi_table_server_url + "?table=events") page.wait_for_selector("#table option", state="attached") select_value(page, "#table", "extra") page.evaluate("window.lastResults = undefined") page.click("text=Dive") page.wait_for_function("window.lastResults !== undefined") table_param = page.evaluate( "new URLSearchParams(window.location.search).get('table')" ) assert table_param == "extra" def test_table_dropdown_persists_on_refresh( page: Any, multi_table_server_url: str ) -> None: page.goto(multi_table_server_url + "?table=events") page.wait_for_selector("#table option", state="attached") select_value(page, "#table", "extra") page.evaluate("window.lastResults = undefined") page.click("text=Dive") page.wait_for_function("window.lastResults !== undefined") page.reload() page.wait_for_selector("#table option", state="attached") assert page.input_value("#table") == "extra" disp = page.text_content("#table + .dropdown-display") assert disp is not None and disp.strip() == "extra" bash-5.2#

I’ll base the new test on tests/test_web_timeseries.py, adding it at the bottom.

shell
bash-5.2# source .venv/bin/activate (scubaduck) bash-5.2#

I need to replicate the bug first. I'll check the test database and build_query.

shell
def test_basic_query() -> None: app = server.app client = app.test_client() payload = { "table": "events", "start": "2024-01-01 00:00:00", "end": "2024-01-02 00:00:00", "order_by": "timestamp", "order_dir": "ASC", "limit": 10, "columns": ["timestamp", "event", "value", "user"], "filters": [], } rv = client.post( "/api/query", data=json.dumps(payload), content_type="application/json" ) data = rv.get_json() assert data rows = data["rows"] # We expect first three rows (until 2024-01-02 00:00:00) assert len(rows) == 3 assert rows[0][1] == "login" assert rows[1][1] == "logout" def test_js_served() -> None: app = server.app client = app.test_client() rv = client.get("/js/chip_input.js") assert rv.status_code == 200 assert b"initChipInput" in rv.data def test_filter_multi_token() -> None: app = server.app client = app.test_client() payload = { "table": "events", "start": "2024-01-01 00:00:00", "end": "2024-01-02 03:00:00", "order_by": "timestamp", "limit": 10, "columns": ["timestamp", "event", "value", "user"], "filters": [{"column": "user", "op": "=", "value": ["alice", "charlie"]} ], } rv = client.post( "/api/query", data=json.dumps(payload), content_type="application/json" ) data = rv.get_json() assert data rows = data["rows"] # Should only return rows for alice and charlie assert len(rows) == 3 assert rows[0][3] == "alice" assert rows[-1][3] == "charlie" def test_empty_filter_is_noop() -> None: app = server.app client = app.test_client() base_payload = { "table": "events", "start": "2024-01-01 00:00:00", "end": "2024-01-03 00:00:00", "limit": 100, "columns": ["timestamp", "event", "value", "user"], } no_filter = {**base_payload, "filters": []} empty_filter = { **base_payload, "filters": [{"column": "user", "op": "=", "value": None}], } rv1 = client.post( "/api/query", data=json.dumps(no_filter), content_type="application/json " ) rv2 = client.post( "/api/query", data=json.dumps(empty_filter), content_type="application/j son" ) rows1 = rv1.get_json()["rows"] rows2 = rv2.get_json()["rows"] assert rows1 == rows2 def test_select_columns() -> None: app = server.app client = app.test_client() payload = { "table": "events", "start": "2024-01-01 00:00:00", "end": "2024-01-03 00:00:00", "order_by": "timestamp", "limit": 10, "columns": ["timestamp", "user"], "filters": [], } rv = client.post( "/api/query", data=json.dumps(payload), content_type="application/json" ) data = rv.get_json() assert data rows = data["rows"] assert len(rows[0]) == 2 assert rows[0][1] == "alice" def test_string_filter_ops() -> None: app = server.app client = app.test_client() base = { "start": "2024-01-01 00:00:00", "end": "2024-01-03 00:00:00", "order_by": "timestamp", "limit": 100, "columns": ["timestamp", "event", "value", "user"], } contains = { **base, "filters": [{"column": "user", "op": "contains", "value": "ali"}], } rv = client.post( "/api/query", data=json.dumps(contains), content_type="application/json" ) rows = rv.get_json()["rows"] assert all("ali" in r[3] for r in rows) regex = { **base, "filters": [{"column": "user", "op": "~", "value": "^a.*"}], } rv = client.post( "/api/query", data=json.dumps(regex), content_type="application/json" ) rows = rv.get_json()["rows"] assert all(r[3].startswith("a") for r in rows) assert len(rows) == 2 not_empty = {**base, "filters": [{"column": "user", "op": "!empty"}]} rv = client.post( "/api/query", data=json.dumps(not_empty), content_type="application/json " ) assert len(rv.get_json()["rows"]) == 4 def test_order_by_ignored_when_not_selected() -> None: app = server.app client = app.test_client() payload = { "table": "events", "order_by": "value", "columns": ["timestamp"], } rv = client.post( "/api/query", data=json.dumps(payload), content_type="application/json" ) data = rv.get_json() assert rv.status_code == 200 assert "ORDER BY" not in data["sql"] (scubaduck) bash-5.2#

Time to run some tests to verify everything: 'ruff check', 'ruff format', 'pyright', and 'pytest -q'.

shell
(scubaduck) bash-5.2# ruff check All checks passed!
(scubaduck) bash-5.2# ruff format --diff 19 files already formatted
(scubaduck) bash-5.2# pyright








File "/usr/lib/python3.12/urllib/request.py", line 1344, in do_open h.request(req.get_method(), req.selector, req.data, headers, File "/usr/lib/python3.12/http/client.py", line 1336, in request self._send_request(method, url, body, headers, encode_chunked) File "/usr/lib/python3.12/http/client.py", line 1382, in _send_request self.endheaders(body, encode_chunked=encode_chunked) File "/usr/lib/python3.12/http/client.py", line 1331, in endheaders self._send_output(message_body, encode_chunked=encode_chunked) File "/usr/lib/python3.12/http/client.py", line 1091, in _send_output self.send(msg) File "/usr/lib/python3.12/http/client.py", line 1035, in send self.connect() File "/usr/lib/python3.12/http/client.py", line 1470, in connect super().connect() File "/usr/lib/python3.12/http/client.py", line 1001, in connect self.sock = self._create_connection( ^^^^^^^^^^^^^^^^^^^^^^^^ File "/usr/lib/python3.12/socket.py", line 852, in create_connection raise exceptions[0] File "/usr/lib/python3.12/socket.py", line 837, in create_connection sock.connect(sa) OSError: [Errno 113] No route to host During handling of the above exception, another exception occurred: Traceback (most recent call last): File "<frozen runpy>", line 198, in _run_module_as_main File "<frozen runpy>", line 88, in _run_code File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/nodeenv.py", lin e 1548, in <module> main() File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/nodeenv.py", lin e 1119, in main args.node = get_last_stable_node_version() ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/nodeenv.py", lin e 1052, in get_last_stable_node_version return _get_versions_json()[0]['version'].lstrip('v') ^^^^^^^^^^^^^^^^^^^^ File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/nodeenv.py", lin e 1028, in _get_versions_json response = urlopen('%s/index.json' % src_base_url) ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/nodeenv.py", lin e 652, in urlopen return urllib2.urlopen(req) ^^^^^^^^^^^^^^^^^^^^ File "/usr/lib/python3.12/urllib/request.py", line 215, in urlopen return opener.open(url, data, timeout) ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ File "/usr/lib/python3.12/urllib/request.py", line 515, in open response = self._open(req, data) ^^^^^^^^^^^^^^^^^^^^^ File "/usr/lib/python3.12/urllib/request.py", line 532, in _open result = self._call_chain(self.handle_open, protocol, protocol + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ File "/usr/lib/python3.12/urllib/request.py", line 492, in _call_chain result = func(*args) ^^^^^^^^^^^ File "/usr/lib/python3.12/urllib/request.py", line 1392, in https_open return self.do_open(http.client.HTTPSConnection, req, ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ File "/usr/lib/python3.12/urllib/request.py", line 1347, in do_open raise URLError(err) urllib.error.URLError: <urlopen error [Errno 113] No route to host> Traceback (most recent call last): File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/pyright/node.py" , line 93, in _install_node_env subprocess.run(args, check=True) File "/usr/lib/python3.12/subprocess.py", line 571, in run raise CalledProcessError(retcode, process.args, subprocess.CalledProcessError: Command '['/workspace/scubaduck/.venv/bin/python' , '-m', 'nodeenv', '/root/.cache/pyright-python/nodeenv']' returned non-zero exi t status 1. The above exception was the direct cause of the following exception: Traceback (most recent call last): File "/workspace/scubaduck/.venv/bin/pyright", line 10, in <module> sys.exit(entrypoint()) ^^^^^^^^^^^^ File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/pyright/cli.py", line 31, in entrypoint sys.exit(main(sys.argv[1:])) ^^^^^^^^^^^^^^^^^^ File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/pyright/cli.py", line 18, in main return run(*args, **kwargs).returncode ^^^^^^^^^^^^^^^^^^^^ File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/pyright/cli.py", line 27, in run return node.run('node', str(script), *args, **kwargs) ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/pyright/node.py" , line 138, in run strategy = _resolve_strategy(target) ^^^^^^^^^^^^^^^^^^^^^^^^^ File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/pyright/node.py" , line 130, in _resolve_strategy return NodeenvStrategy(type='nodeenv', path=_ensure_node_env(target)) ^^^^^^^^^^^^^^^^^^^^^^^^ File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/pyright/node.py" , line 54, in _ensure_node_env _install_node_env() File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/pyright/node.py" , line 95, in _install_node_env raise RuntimeError( RuntimeError: nodeenv failed; for more reliable node.js binaries try `pip instal l pyright[nodejs]` (scubaduck) bash-5.2#

Pyright fails due to network issues while trying to install nodeenv. I'll note it in the final message.

shell
(scubaduck) bash-5.2# pytest -q

bringing up nodes...










........................................................................ [ 62%]

............................................ [100%] 116 passed in 23.50s (scubaduck) bash-5.2#

I'm setting up a new test based on the bug, looking at existing test styles.

shell
(scubaduck) bash-5.2# sed -n '1,200p' tests/conftest.py from __future__ import annotations import threading from collections.abc import Iterator import pytest from werkzeug.serving import make_server from scubaduck.server import app @pytest.fixture() def server_url() -> Iterator[str]: httpd = make_server("127.0.0.1", 0, app) port = httpd.server_port thread = threading.Thread(target=httpd.serve_forever) thread.start() try: yield f"http://127.0.0.1:{port}" finally: httpd.shutdown() thread.join() (scubaduck) bash-5.2#

The server_url fixture uses the default app with a sample dataset. I'll check test_server_time.py for related tests.

shell
from scubaduck import server def test_integer_time_column(tmp_path: Path) -> None: csv_file = tmp_path / "events.csv" csv_file.write_text("created,event\n1704067200,login\n1704070800,logout\n") app = server.create_app(csv_file) client = app.test_client() payload = { "table": "events", "start": "2024-01-01 00:00:00", "end": "2024-01-01 01:00:00", "order_by": "created", "columns": ["created", "event"], "time_column": "created", } rv = client.post( "/api/query", data=json.dumps(payload), content_type="application/json" ) data = rv.get_json() assert rv.status_code == 200 assert len(data["rows"]) == 2 def test_integer_time_unit_ms(tmp_path: Path) -> None: csv_file = tmp_path / "events.csv" csv_file.write_text("created,event\n1704067200000,login\n1704070800000,logou t\n") app = server.create_app(csv_file) client = app.test_client() payload = { "table": "events", "start": "2024-01-01 00:00:00", "end": "2024-01-01 01:00:00", "order_by": "created", "columns": ["created", "event"], "time_column": "created", "time_unit": "ms", } rv = client.post( "/api/query", data=json.dumps(payload), content_type="application/json" ) data = rv.get_json() assert rv.status_code == 200 assert len(data["rows"]) == 2 def test_timeseries_default_xaxis_uses_time_column(tmp_path: Path) -> None: csv_file = tmp_path / "events.csv" csv_file.write_text("created,event\n1704067200000,login\n1704070800000,logou t\n") app = server.create_app(csv_file) client = app.test_client() payload = { "table": "events", "start": "2024-01-01 00:00:00", "end": "2024-01-01 01:00:00", "graph_type": "timeseries", "granularity": "1 hour", "columns": ["event"], "aggregate": "Count", "time_column": "created", "time_unit": "ms", } rv = client.post( "/api/query", data=json.dumps(payload), content_type="application/json" ) data = rv.get_json() assert rv.status_code == 200 assert len(data["rows"]) == 2 def test_integer_time_unit_us_default_start_end(tmp_path: Path) -> None: csv_file = tmp_path / "events.csv" csv_file.write_text( "created,event\n1704067200000000,login\n1704070800000000,logout\n" ) app = server.create_app(csv_file) client = app.test_client() payload = { "table": "events", "order_by": "created", "columns": ["created", "event"], "time_column": "created", "time_unit": "us", } rv = client.post( "/api/query", data=json.dumps(payload), content_type="application/json" ) data = rv.get_json() assert rv.status_code == 200 assert data["start"] == "2024-01-01 00:00:00" assert data["end"] == "2024-01-01 01:00:00" assert len(data["rows"]) == 2 def test_sqlite_integer_time_unit_us(tmp_path: Path) -> None: sqlite_file = tmp_path / "events.sqlite" import sqlite3 conn = sqlite3.connect(sqlite_file) conn.execute("CREATE TABLE visits (visit_time INTEGER, event TEXT)") big_ts = 13384551652000000 conn.execute("INSERT INTO visits VALUES (?, ?)", (big_ts, "foo")) conn.commit() conn.close() # pyright: ignore[reportUnknownMemberType, reportAttributeAcce ssIssue] app = server.create_app(sqlite_file) client = app.test_client() payload = { "table": "visits", "start": "2394-02-20 00:00:00", "end": "2394-02-21 00:00:00", "order_by": "visit_time", "columns": ["visit_time", "event"], "time_column": "visit_time", "time_unit": "us", } rv = client.post( "/api/query", data=json.dumps(payload), content_type="application/json" ) data = rv.get_json() assert rv.status_code == 200 assert len(data["rows"]) == 1 def test_relative_time_query(monkeypatch: pytest.MonkeyPatch) -> None: app = server.app client = app.test_client() from datetime import datetime fixed_now = datetime(2024, 1, 2, 4, 0, 0) class FixedDateTime(datetime): @classmethod def now(cls, tz=None): # type: ignore[override] return fixed_now if tz is None else fixed_now.astimezone(tz) monkeypatch.setattr(server, "datetime", FixedDateTime) payload = { "table": "events", "start": "-1 hour", "end": "now", "order_by": "timestamp", "limit": 100, "columns": ["timestamp", "event", "value", "user"], "filters": [], } rv = client.post( "/api/query", data=json.dumps(payload), content_type="application/json" ) data = rv.get_json() (scubaduck) bash-5.2#

The current file is too big. I’ll create a new test, potentially in test_multi_table_web.py.

shell
(scubaduck) bash-5.2# grep -n "TEST" -R tests | head grep: tests/__pycache__/test_multi_table_web.cpython-312-pytest-8.3.5.pyc: binar y file matches grep: tests/__pycache__/test_server_timeseries.cpython-312-pytest-8.3.5.pyc: bin ary file matches grep: tests/__pycache__/test_web_table.cpython-312-pytest-8.3.5.pyc: binary file matches grep: tests/__pycache__/test_server_time.cpython-312-pytest-8.3.5.pyc: binary fi le matches tests/test_server_timeseries.py:253: app = server.create_app("TEST") tests/test_web_table.py:17: app = create_app("TEST") tests/test_server_time.py:234: app = server.create_app("TEST") tests/test_server_time.py:252: app = server.create_app("TEST") tests/test_multi_table_web.py:14: app = create_app("TEST") (scubaduck) bash-5.2#

I’m checking the create_test_database function and exploring issues with the timeseries graph rendering.

shell
(scubaduck) bash-5.2# python - <<'PY' > from scubaduck.server import create_app > import json > app = create_app('TEST') > client = app.test_client() > payload = { > 'table': 'extra', > 'start': '2024-01-01 00:00:00', > 'end': '2024-01-01 01:00:00', > 'graph_type': 'timeseries', > 'columns': ['num'], > 'group_by': ['num'], > 'x_axis': 'ts', > 'granularity': '1 hour', > 'aggregate': 'Count' > } > rv = client.post('/api/query', data=json.dumps(payload), content_type='applica tion/json') > print('status', rv.status_code) > print(rv.get_json()) > PY
status 400 {'error': 'Invalid time_column'} (scubaduck) bash-5.2#

I'm realizing I need to specify 'ts' as the time_column since the default doesn't exist.

shell
(scubaduck) bash-5.2# python - <<'PY' > from scubaduck.server import create_app > import json > app = create_app('TEST') > client = app.test_client() > payload = { > 'table': 'extra', > 'start': '2024-01-01 00:00:00', > 'end': '2024-01-01 03:00:00', > 'graph_type': 'timeseries', > 'columns': ['num'], > 'group_by': ['num'], > 'x_axis': 'ts', > 'granularity': '1 hour', > 'aggregate': 'Count', > 'time_column': 'ts' > } > rv = client.post('/api/query', data=json.dumps(payload), content_type='applica tion/json') > print('status', rv.status_code) > print(rv.get_json()) > PY
status 200 {'bucket_size': 3600, 'end': '2024-01-01 03:00:00', 'rows': [['Mon, 01 Jan 2024 00:00:00 GMT', 1, 1], ['Mon, 01 Jan 2024 01:00:00 GMT', 2, 1]], 'sql': 'SELECT T IMESTAMP \'2024-01-01 00:00:00\' + INTERVAL \'3600 second\' * CAST(floor((epoch( make_timestamp(CAST(CAST("ts" AS BIGINT) * 1000000 AS BIGINT))) - epoch(TIMESTAM P \'2024-01-01 00:00:00\'))/3600) AS BIGINT) AS bucket, "num", count(*) AS Count \nFROM "extra"\nWHERE make_timestamp(CAST(CAST("ts" AS BIGINT) * 1000000 AS BIGI NT)) >= \'2024-01-01 00:00:00\' AND make_timestamp(CAST(CAST("ts" AS BIGINT) * 1 000000 AS BIGINT)) <= \'2024-01-01 03:00:00\'\nGROUP BY "bucket", "num"\nORDER B Y bucket', 'start': '2024-01-01 00:00:00'} (scubaduck) bash-5.2#

The server returned two rows; now, I'm checking the timeseries JS to parse selectedColumns.

shell
(scubaduck) bash-5.2# grep -n "selectedColumns" -R scubaduck/static/js scubaduck/static/js/table_view.js:11: let hitsIndex = selectedColumns.indexOf(" Hits"); scubaduck/static/js/table_view.js:17: selectedColumns.forEach((col, i) => { scubaduck/static/js/table_view.js:51: const col = selectedColumns[i]; scubaduck/static/js/timeseries_chart.js:38: const valueCols = selectedColumns.s lice(groups.length + hasHits); scubaduck/static/js/view_settings.js:15:let selectedColumns = []; scubaduck/static/js/view_settings.js:440: selectedColumns = groupBy.chips.sli ce(); scubaduck/static/js/view_settings.js:441: if (document.getElementById('show_h its').checked) selectedColumns.push('Hits'); scubaduck/static/js/view_settings.js:445: if (!selectedColumns.includes(c )) selectedColumns.push(c); scubaduck/static/js/view_settings.js:448: if (dc.include && !selectedColu mns.includes(dc.name)) selectedColumns.push(dc.name); scubaduck/static/js/view_settings.js:452: selectedColumns = base.slice(); scubaduck/static/js/view_settings.js:454: if (dc.include) selectedColumns.p ush(dc.name); scubaduck/static/js/view_settings.js:457: columnValues[type] = selectedColumns. slice(); scubaduck/static/js/view_settings.js:459: if (orderCol && !selectedColumns.incl udes(orderCol)) { scubaduck/static/js/view_settings.js:460: selectedColumns.push(orderCol); scubaduck/static/js/view_settings.js:695: columns: selectedColumns.filter(c = > (scubaduck) bash-5.2#

I'll check out the "view_settings.js" at line 15 in the scubaduck static folder.

shell
} function updateSelectedColumns(type = graphTypeSel.value) { const base = allColumns.filter(name => { const cb = document.querySelector(`#column_groups input[value="${name}"]`); if (!cb || !cb.checked) return false; if (type === 'table' && isStringColumn(name)) return false; return true; }); if (type === 'table' || type === 'timeseries') { selectedColumns = groupBy.chips.slice(); if (document.getElementById('show_hits').checked) selectedColumns.push('Hits '); const agg = document.getElementById('aggregate').value.toLowerCase(); if (!(type === 'table' && agg === 'count')) { base.forEach(c => { if (!selectedColumns.includes(c)) selectedColumns.push(c); }); derivedColumns.forEach(dc => { if (dc.include && !selectedColumns.includes(dc.name)) selectedColumns.pu sh(dc.name); }); } } else { selectedColumns = base.slice(); derivedColumns.forEach(dc => { if (dc.include) selectedColumns.push(dc.name); }); } columnValues[type] = selectedColumns.slice(); const orderCol = document.getElementById('order_by').value; if (orderCol && !selectedColumns.includes(orderCol)) { selectedColumns.push(orderCol); } updateColumnsTabCount(); } function isStringColumn(name) { const t = (columnTypes[name] || '').toUpperCase(); return t.includes('CHAR') || t.includes('STRING') || t.includes('VARCHAR'); } function isIntegerColumn(name) { const t = (columnTypes[name] || '').toUpperCase(); return t.includes('INT'); } function isTimeColumn(name) { const t = (columnTypes[name] || '').toUpperCase(); if (t.includes('TIMESTAMP') || t.includes('DATE') || t.includes('TIME')) retur n true; const sel = document.getElementById('time_column').value; const xsel = document.getElementById('x_axis').value; if (name === sel || name === xsel) return true; return false; } function formatNumber(val) { if (typeof val !== 'number') val = Number(val); if (Number.isNaN(val)) return ''; if (val === 0) return '0'; const abs = Math.abs(val); if (abs > 999.999) { const units = [ {n: 1e12, s: 'T'}, {n: 1e9, s: 'B'}, {n: 1e6, s: 'M'}, {n: 1e3, s: 'K'}, ]; for (const u of units) { if (abs >= u.n) { return (val / u.n).toFixed(2) + ' ' + u.s; } } } if (abs < 0.0005) return '0.000'; if (Number.isInteger(val)) return val.toString(); return val.toFixed(3); } function addFilter() { const container = document.createElement('div'); container.className = 'filter'; container.innerHTML = ` <div class="filter-row"> <select class="f-col"></select> <select class="f-op"></select> <button type="button" class="remove" onclick="this.closest('.filter').remo ve()">✖</button> </div> <div class="chip-box"> <div class="chip-input"> <input class="f-val" type="text"> <button type="button" class="chip-copy">&#x2398;</button> </div> <div class="chip-dropdown"></div> </div> `; const colSel = container.querySelector('.f-col'); colSel.innerHTML = allColumns.map(c => `<option value="${c}">${c}</option>`).j oin(''); initDropdown(colSel); function populateOps() { const opSel = container.querySelector('.f-op'); const col = colSel.value; const ops = isStringColumn(col) ? [ ['=', '='], ['!=', '!='], ['~', 'matches regex'], ['!~', 'not matches regex'], ['contains', 'contains'], ['!contains', 'not contains'], ['empty', 'empty'], ['!empty', 'not empty'], ['LIKE', 'like'], ] : [ ['=', '='], ['!=', '!='], ['<', '<'], ['>', '>'], ... expr: data.expr || '', include: data.include !== undefined ? data.include : true, el: container }; container.querySelector('.d-type').value = obj.type; container.querySelector('.d-name').value = obj.name; container.querySelector('.d-expr').value = obj.expr; container.querySelector('.d-use').checked = obj.include; ['change','input'].forEach(evt => { container.addEventListener(evt, refreshDerivedColumns); }); derivedColumns.push(obj); refreshDerivedColumns(); } function removeDerived(btn) { const el = btn.closest('.derived'); const idx = derivedColumns.findIndex(d => d.el === el); if (idx !== -1) { derivedColumns.splice(idx, 1); } el.remove(); refreshDerivedColumns(); } function refreshDerivedColumns() { allColumns.splice(0, allColumns.length, ...baseColumns); stringColumns.splice(0, stringColumns.length, ...baseStringColumns); integerColumns.splice(0, integerColumns.length, ...baseIntegerColumns); timeColumns.splice(0, timeColumns.length, ...baseTimeColumns); timeColumnOptions.splice(0, timeColumnOptions.length, ...baseTimeColumnOptions ); derivedColumns.forEach(d => { d.type = d.el.querySelector('.d-type').value; d.name = d.el.querySelector('.d-name').value; d.expr = d.el.querySelector('.d-expr').value; d.include = d.el.querySelector('.d-use').checked; allColumns.push(d.name); columnTypes[d.name] = d.type; if (d.type === 'string') { stringColumns.push(d.name); } else { integerColumns.push(d.name); timeColumnOptions.push(d.name); } }); updateSelectedColumns(); } let lastQueryTime = 0; let queryStart = 0; function dive(push=true) { const params = collectParams(); if (push) { history.pushState(params, '', paramsToSearch(params)); } const payload = Object.assign({}, params); const dcMap = {}; (params.derived_columns || []).forEach(d => { if (d.include) dcMap[d.name] = d.expr; }); payload.derived_columns = dcMap; const view = document.getElementById('view'); view.innerHTML = '<p>Loading...</p>'; window.lastResults = undefined; queryStart = performance.now(); fetch('/api/query', {method:'POST', headers:{'Content-Type':'application/json' }, body:JSON.stringify(payload)}) .then(async r => { const data = await r.json(); if (!r.ok) throw data; return data; }) .then(data => { lastQueryTime = Math.round(performance.now() - queryStart); showResults(data); }) .catch(err => { showError(err); }); } function collectParams() { updateSelectedColumns(); const payload = { table: document.getElementById('table').value, time_column: document.getElementById('time_column').value, time_unit: document.getElementById('time_unit').value, start: document.getElementById('start').value, end: document.getElementById('end').value, order_by: document.getElementById('order_by').value, order_dir: orderDir, limit: parseInt(document.getElementById('limit').value, 10), columns: selectedColumns.filter(c => c !== 'Hits' && !derivedColumns.some(dc => dc.name === c) ), samples_columns: columnValues.samples.slice(), table_columns: columnValues.table.slice(), timeseries_columns: columnValues.timeseries.slice(), graph_type: graphTypeSel.value, filters: Array.from(document.querySelectorAll('#filters .filter')).map(f => { const chips = f.chips || []; const op = f.querySelector('.f-op').value; let value = null; if (op !== 'empty' && op !== '!empty') { value = chips.length === 0 ? null : (chips.length === 1 ? chips[0] : chi ps); } const colSel = f.querySelector('.f-col select') || f.querySelector('.f-col '); return {column: colSel.value, op, value}; }), derived_columns: Array.from(document.querySelectorAll('#derived_list .derive d')).map(d => ({ type: d.querySelector('.d-type').value, name: d.querySelector('.d-name').value, expr: d.querySelector('.d-expr').value, include: d.querySelector('.d-use').checked, })) }; if (graphTypeSel.value === 'table' || graphTypeSel.value === 'timeseries') { payload.group_by = groupBy.chips || []; (scubaduck) bash-5.2#

Still scrolling to make sure collectParams grabs x_axis correctly.

shell
(scubaduck) bash-5.2# sed -n '720,960p' scubaduck/static/js/view_settings.js payload.group_by = groupBy.chips || []; payload.aggregate = document.getElementById('aggregate').value; payload.show_hits = document.getElementById('show_hits').checked; } if (graphTypeSel.value === 'timeseries') { const xval = document.getElementById('x_axis').value; if (xval) payload.x_axis = xval; payload.granularity = document.getElementById('granularity').value; payload.fill = document.getElementById('fill').value; } return payload; } function paramsToSearch(params) { const sp = new URLSearchParams(); if (params.table) sp.set('table', params.table); if (params.time_column) sp.set('time_column', params.time_column); if (params.time_unit) sp.set('time_unit', params.time_unit); if (params.start) sp.set('start', params.start); if (params.end) sp.set('end', params.end); if (params.order_by) sp.set('order_by', params.order_by); if (params.order_dir) sp.set('order_dir', params.order_dir); if (params.limit !== null && params.limit !== undefined) sp.set('limit', param s.limit); if (params.samples_columns && params.samples_columns.length) sp.set('samples_c olumns', params.samples_columns.join(',')); if (params.table_columns && params.table_columns.length) sp.set('table_columns ', params.table_columns.join(',')); if (params.timeseries_columns && params.timeseries_columns.length) sp.set('tim eseries_columns', params.timeseries_columns.join(',')); if (params.filters && params.filters.length) sp.set('filters', JSON.stringify( params.filters)); if (params.derived_columns && params.derived_columns.length) sp.set('derived_c olumns', JSON.stringify(params.derived_columns)); if (params.graph_type) sp.set('graph_type', params.graph_type); if (params.graph_type === 'table' || params.graph_type === 'timeseries') { if (params.group_by && params.group_by.length) sp.set('group_by', params.gro up_by.join(',')); if (params.aggregate) sp.set('aggregate', params.aggregate); if (params.show_hits) sp.set('show_hits', '1'); } if (params.graph_type === 'timeseries') { if (params.x_axis) sp.set('x_axis', params.x_axis); if (params.granularity) sp.set('granularity', params.granularity); if (params.fill) sp.set('fill', params.fill); } const qs = sp.toString(); return qs ? '?' + qs : ''; } function applyParams(params) { if (params.table) document.getElementById('table').value = params.table; document.getElementById('time_column').value = params.time_column || defaultTi meColumn; updateTimeFieldVisibility(); if (params.time_unit) document.getElementById('time_unit').value = params.time _unit; document.getElementById('start').value = params.start || ''; document.getElementById('end').value = params.end || ''; if (params.order_by) { document.getElementById('order_by').value = params.order_by; } orderDir = params.order_dir || 'ASC'; updateOrderDirButton(); if (params.limit !== undefined && params.limit !== null) { document.getElementById('limit').value = params.limit; limitValues[params.graph_type || 'samples'] = params.limit; limitInput.dataset.setByUser = '1'; } graphTypeSel.value = params.graph_type || 'samples'; updateDisplayTypeUI(); limitInput.value = limitValues[graphTypeSel.value]; if (params.x_axis) { document.getElementById('x_axis').value = params.x_axis; } else { document.getElementById('x_axis').value = ''; } if (params.granularity) document.getElementById('granularity').value = params. granularity; if (params.fill) document.getElementById('fill').value = params.fill; if (params.group_by) { groupBy.chips.splice(0, groupBy.chips.length, ...params.group_by); groupBy.renderChips(); } if (params.aggregate) document.getElementById('aggregate').value = params.aggr egate; document.getElementById('show_hits').checked = params.show_hits ?? true; if (params.samples_columns) columnValues.samples = params.samples_columns; if (params.table_columns) columnValues.table = params.table_columns; if (params.timeseries_columns) columnValues.timeseries = params.timeseries_col umns; document.querySelectorAll('#column_groups input').forEach(cb => { cb.checked = columnValues[graphTypeSel.value].includes(cb.value); }); updateSelectedColumns(graphTypeSel.value); const dlist = document.getElementById('derived_list'); dlist.innerHTML = ''; derivedColumns.splice(0, derivedColumns.length); if (params.derived_columns && params.derived_columns.length) { params.derived_columns.forEach(dc => addDerived(dc)); } refreshDerivedColumns(); const list = document.getElementById('filter_list'); list.innerHTML = ''; if (params.filters && params.filters.length) { params.filters.forEach(f => { addFilter(); const el = list.lastElementChild; const colSel = el.querySelector('.f-col select') || el.querySelector('.f-c ol'); colSel.value = f.column; colSel.dispatchEvent(new Event('change')); el.querySelector('.f-op').value = f.op; el.querySelector('.f-op').dispatchEvent(new Event('change')); if (f.value !== null && f.op !== 'empty' && f.op !== '!empty') { const values = Array.isArray(f.value) ? f.value : [f.value]; values.forEach(v => el.addChip(v)); el.renderChips(); } }); } else { addFilter(); } } function parseSearch() { const sp = new URLSearchParams(window.location.search); const params = {}; if (sp.has('table')) params.table = sp.get('table'); if (sp.has('time_column')) params.time_column = sp.get('time_column'); if (sp.has('time_unit')) params.time_unit = sp.get('time_unit'); if (sp.has('start')) params.start = sp.get('start'); if (sp.has('end')) params.end = sp.get('end'); if (sp.has('order_by')) params.order_by = sp.get('order_by'); if (sp.has('order_dir')) params.order_dir = sp.get('order_dir'); if (sp.has('limit')) params.limit = parseInt(sp.get('limit'), 10); if (sp.has('samples_columns')) params.samples_columns = sp.get('samples_column s').split(',').filter(c => c); if (sp.has('table_columns')) params.table_columns = sp.get('table_columns').sp lit(',').filter(c => c); if (sp.has('timeseries_columns')) params.timeseries_columns = sp.get('timeseri es_columns').split(',').filter(c => c); if (sp.has('filters')) { try { params.filters = JSON.parse(sp.get('filters')); } catch(e) { params.fi lters = []; } } if (sp.has('graph_type')) params.graph_type = sp.get('graph_type'); if (sp.has('group_by')) params.group_by = sp.get('group_by').split(',').filter (c => c); if (sp.has('aggregate')) params.aggregate = sp.get('aggregate'); if (sp.has('show_hits')) params.show_hits = sp.get('show_hits') === '1'; if (sp.has('x_axis')) params.x_axis = sp.get('x_axis'); if (sp.has('granularity')) params.granularity = sp.get('granularity'); if (sp.has('fill')) params.fill = sp.get('fill'); if (sp.has('derived_columns')) { try { params.derived_columns = JSON.parse(sp.get('derived_columns')); } catc h(e) { params.derived_columns = []; } } return params; } function initFromUrl() { const params = parseSearch(); history.replaceState(params, '', paramsToSearch(params)); applyParams(params); dive(false); } window.addEventListener('popstate', e => { const params = e.state || parseSearch(); applyParams(params); dive(false); }); function setSelectValue(selector, value) { const el = typeof selector === 'string' ? document.querySelector(selector) : s elector; if (el) { const select = el.tagName === 'SELECT' ? el : el.querySelector('select'); if (select) { select.value = value; select.dispatchEvent(new Event('change')); } } } (scubaduck) bash-5.2#

It seems like the issue might relate to how groups are handled with numeric values. I'll try replicating with Node and focus on showTimeSeries. Specifically, I suspect the issue lies in how selectedColumns is being constructed. Let's check how updated columns are computed in updateSelectedColumns, especially with checkboxes.

shell
(scubaduck) bash-5.2# grep -n "column_groups" -n scubaduck/static/index.html 340: <div id="column_groups"></div>
(scubaduck) bash-5.2# sed -n '320,360p' scubaduck/static/index.html <option>p99.9</option> <option>p99.99</option> </select> </div> <div id="show_hits_field" class="field" style="display:none;"> <label>Show Hits</label> <input id="show_hits" type="checkbox" checked> </div> <div id="filters"> <h4>Filters<span class="help" title="You can create as many filters as you want. You can either write a filter using a UI or manual SQL. In the UI, fi lter consists of a column name, a relation (e.g., =, !=, <, >) and then a text f ield. The text field is a token input. It accepts multiple tokens for = relation , in which case we match using an OR for all options.">[?]</span></h4> <div id="filter_list"></div> <button id="add_filter" type="button" onclick="addFilter()">Add Filter </button> </div> <div id="query_info" style="margin-top:10px;"></div> </div> <div id="columns" class="tab-content"> <div id="column_actions"> <a id="columns_all" href="#">All</a> <a id="columns_none" href="#">None</a> </div> <div id="column_groups"></div> <div id="derived_columns"> <h4>Derived Columns</h4> <div id="derived_list"></div> <button id="add_derived" type="button" onclick="addDerived()">Add Deri ved</button> </div> </div> </div> <div id="sidebar-resizer"></div> <div id="view"> <table id="results"></table> </div> </div> <script src="/js/chip_input.js"></script> <script src="/js/timeseries_chart.js"></script> <script src="/js/time_column.js"></script> <script src="/js/sidebar.js"></script> <script src="/js/table_view.js"></script> <script src="/js/view_settings.js"></script> </body> </html> (scubaduck) bash-5.2#

I'll open the view_settings JS file to check where the column checkboxes are created.

shell
(scubaduck) bash-5.2# sed -n '1,120p' scubaduck/static/js/view_settings.js // Logic for View Settings, Columns, and URL handling extracted from index.html const allColumns = []; const baseColumns = []; const columnTypes = {}; const stringColumns = []; const baseStringColumns = []; const integerColumns = []; const baseIntegerColumns = []; const timeColumns = []; const baseTimeColumns = []; const timeColumnOptions = []; const baseTimeColumnOptions = []; const derivedColumns = []; let selectedColumns = []; let displayType = 'samples'; let groupBy = {chips: [], addChip: () => {}, renderChips: () => {}}; let defaultTimeColumn = ''; const limitInput = document.getElementById('limit'); const limitValues = { samples: parseInt(limitInput.value, 10), table: parseInt(limitInput.value, 10), timeseries: 7 }; const columnValues = { samples: [], table: [], timeseries: [] }; limitInput.addEventListener('input', () => { limitValues[displayType] = parseInt(limitInput.value, 10); limitInput.dataset.setByUser = '1'; }); function initDropdown(select) { // Avoid creating duplicate wrappers if this dropdown was already initialised. if (select.dataset.dropdownInit) { const disp = select.parentElement?.querySelector('.dropdown-display'); if (disp) { const opt = select.options[select.selectedIndex]; disp.textContent = opt ? opt.textContent : ''; } return; } select.dataset.dropdownInit = '1'; const wrapper = document.createElement('div'); wrapper.className = 'dropdown'; if (select.classList.contains('f-col')) { wrapper.classList.add('f-col'); } select.parentNode.insertBefore(wrapper, select); wrapper.appendChild(select); select.style.display = 'none'; const disp = document.createElement('div'); disp.className = 'dropdown-display'; function updateDisplay() { const opt = select.options[select.selectedIndex]; disp.textContent = opt ? opt.textContent : ''; } updateDisplay(); wrapper.appendChild(disp); const menu = document.createElement('div'); menu.className = 'dropdown-menu'; const search = document.createElement('input'); search.placeholder = 'Search'; menu.appendChild(search); const list = document.createElement('div'); menu.appendChild(list); wrapper.appendChild(menu); function close() { menu.style.display = 'none'; } function open() { renderOptions(); menu.style.display = 'block'; search.focus(); } disp.addEventListener('click', () => { if (menu.style.display === 'block') { close(); } else { open(); } }); document.addEventListener('click', e => { if (!wrapper.contains(e.target)) { close(); } }); function renderOptions() { const q = search.value.toLowerCase(); list.innerHTML = ''; Array.from(select.options).forEach(o => { if (!o.textContent.toLowerCase().includes(q)) return; const div = document.createElement('div'); div.className = 'option'; if (q) { const text = o.textContent; const idx = text.toLowerCase().indexOf(q); if (idx !== -1) { div.innerHTML = text.slice(0, idx) + '<u>' + text.slice(idx, idx + q.length) + '</u>' + text.slice(idx + q.length); } else { div.textContent = text; } } else { div.textContent = o.textContent; } if (o.value === select.value) div.classList.add('selected'); div.addEventListener('mousedown', evt => { (scubaduck) bash-5.2#

The selectedColumns array is built during query to display in showResults. Let's verify.

shell
(scubaduck) bash-5.2# grep -n "showResults" -R scubaduck/static/js scubaduck/static/js/table_view.js:124:function showResults(data) { scubaduck/static/js/view_settings.js:677: showResults(data); (scubaduck) bash-5.2#

I’m about to open scubaduck/static/js/table_view.js. Let’s check it out!

shell
(scubaduck) bash-5.2# sed -n '120,160p' scubaduck/static/js/table_view.js } renderTable(rows); } function showResults(data) { window.lastResults = data; const view = document.getElementById("view"); if (graphTypeSel.value === "timeseries") { showTimeSeries(data); } else { if (data.rows.length === 0) { view.innerHTML = '<p id="empty-message">Empty data provided to table</p><table id="result s"></table>'; } else { view.innerHTML = '<table id="results"></table>'; } originalRows = data.rows.slice(); sortState = { index: null, dir: null }; renderTable(originalRows); } const sqlEl = document.createElement("pre"); sqlEl.id = "sql_query"; sqlEl.style.whiteSpace = "pre-wrap"; sqlEl.style.marginTop = "10px"; sqlEl.textContent = data.sql; view.appendChild(sqlEl); document.getElementById("query_info").textContent = `Your query took about ${l astQueryTime} ms`; } function showError(err) { window.lastResults = err; const view = document.getElementById("view"); let msg = ""; if (typeof err === "string") { msg = err; } else if (err) { msg = err.error || "Error"; if (err.sql) { msg += "\nSQL: " + err.sql; } if (err.traceback) { (scubaduck) bash-5.2#

I’ll search “selectedColumns” after showTimeSeries to confirm everything in the range 1-114.

shell
(scubaduck) bash-5.2# grep -n "selectedColumns" -n scubaduck/static/js/timeserie s_chart.js 38: const valueCols = selectedColumns.slice(groups.length + hasHits); (scubaduck) bash-5.2#

I'm reviewing how selectedColumns works, considering group_by, hits visibility, and derived columns.

shell
(scubaduck) bash-5.2# grep -n "show_hits_field" -n scubaduck/static/js grep: scubaduck/static/js: Is a directory (scubaduck) bash-5.2#

I’m starting to search through the repository now. Let’s see what I find!

shell
(scubaduck) bash-5.2# grep -n "show_hits_field" -R scubaduck scubaduck/static/index.html:324: <div id="show_hits_field" class="field" style="display:none;"> scubaduck/static/js/view_settings.js:149: document.getElementById('show_hits_fi eld').style.display = showTable ? 'flex' : 'none'; (scubaduck) bash-5.2#

I'll take a look at the lines around 149 to see what's there.

shell
(scubaduck) bash-5.2# sed -n '120,180p' scubaduck/static/js/view_settings.js div.addEventListener('mousedown', evt => { evt.preventDefault(); select.value = o.value; select.dispatchEvent(new Event('change')); updateDisplay(); close(); }); list.appendChild(div); }); } search.addEventListener('input', renderOptions); select.addEventListener('change', updateDisplay); } let orderDir = 'ASC'; const orderDirBtn = document.getElementById('order_dir'); const graphTypeSel = document.getElementById('graph_type'); function updateOrderDirButton() { orderDirBtn.textContent = orderDir + (orderDir === 'ASC' ? ' \u25B2' : ' \u25B C'); } function updateDisplayTypeUI() { const prevType = displayType; updateSelectedColumns(prevType); const newType = graphTypeSel.value; const showTable = newType === 'table'; const showTS = newType === 'timeseries'; document.getElementById('group_by_field').style.display = showTable || showTS ? 'flex' : 'none'; document.getElementById('aggregate_field').style.display = showTable || showTS ? 'flex' : 'none'; document.getElementById('show_hits_field').style.display = showTable ? 'flex' : 'none'; document.getElementById('x_axis_field').style.display = showTS ? 'flex' : 'non e'; document.getElementById('granularity_field').style.display = showTS ? 'flex' : 'none'; document.getElementById('fill_field').style.display = showTS ? 'flex' : 'none' ; document.querySelectorAll('#column_groups .col-group').forEach(g => { if (g.querySelector('.col-group-header').textContent.startsWith('Strings')) { g.style.display = showTable || showTS ? 'none' : ''; } }); limitValues[prevType] = parseInt(limitInput.value, 10); if (showTS && limitValues.timeseries === undefined) { limitValues.timeseries = 7; } limitInput.value = limitValues[newType]; document.querySelectorAll('#column_groups input').forEach(cb => { cb.checked = columnValues[newType].includes(cb.value); }); if (showTS) { document.querySelectorAll('#column_groups input').forEach(cb => { if (isTimeColumn(cb.value) || isStringColumn(cb.value)) { cb.checked = false; } }); document.getElementById('order_by').value = ''; } updateSelectedColumns(newType); displayType = newType; } function updateTimeFieldVisibility() { const show = document.getElementById('time_column').value !== ''; document.getElementById('start').closest('.field').style.display = show ? 'flex' (scubaduck) bash-5.2#

Show_hits is hidden for timeseries but remains checked by default, sending its value.

StartIdx uses group length and hasHits. Timeseries may default to integer columns selected.

shell
(scubaduck) bash-5.2# grep -n "column_groups" -n scubaduck/static/js grep: scubaduck/static/js: Is a directory (scubaduck) bash-5.2#

I'll check the repository to see if there's an initialization script available.

shell
(scubaduck) bash-5.2# grep -n "column_groups" -R scubaduck/static/js scubaduck/static/js/view_settings.js:153: document.querySelectorAll('#column_gr oups .col-group').forEach(g => { scubaduck/static/js/view_settings.js:163: document.querySelectorAll('#column_gr oups input').forEach(cb => { scubaduck/static/js/view_settings.js:167: document.querySelectorAll('#column_ groups input').forEach(cb => { scubaduck/static/js/view_settings.js:200: const groupsEl = document.getElemen tById('column_groups'); scubaduck/static/js/view_settings.js:426: const baseCount = document.querySelec torAll('#column_groups input:checked').length; scubaduck/static/js/view_settings.js:434: const cb = document.querySelector(` #column_groups input[value="${name}"]`); scubaduck/static/js/view_settings.js:799: document.querySelectorAll('#column_gr oups input').forEach(cb => {
(scubaduck) bash-5.2# sed -n '180,240p' scubaduck/static/js/view_settings.js ? 'flex' : 'none'; document.getElementById('end').closest('.field').style.display = show ? 'flex' : 'none'; document.getElementById('time_unit').style.display = show ? '' : 'none'; } orderDirBtn.addEventListener('click', () => { orderDir = orderDir === 'ASC' ? 'DESC' : 'ASC'; updateOrderDirButton(); }); updateOrderDirButton(); graphTypeSel.addEventListener('change', updateDisplayTypeUI); document.getElementById('time_column').addEventListener('change', updateTimeFiel dVisibility); updateTimeFieldVisibility(); function loadColumns(table) { return fetch('/api/columns?table=' + encodeURIComponent(table)).then(r => r.js on()).then(cols => { const orderSelect = document.getElementById('order_by'); const xAxisSelect = document.getElementById('x_axis'); const groupsEl = document.getElementById('column_groups'); const timeColumnSelect = document.getElementById('time_column'); orderSelect.innerHTML = ''; xAxisSelect.innerHTML = ''; const defOpt = document.createElement('option'); defOpt.value = ''; defOpt.textContent = '(default)'; xAxisSelect.appendChild(defOpt); timeColumnSelect.innerHTML = ''; const noneOpt = document.createElement('option'); noneOpt.value = ''; noneOpt.textContent = '(none)'; timeColumnSelect.appendChild(noneOpt); groupsEl.innerHTML = ''; allColumns.length = 0; stringColumns.length = 0; integerColumns.length = 0; timeColumns.length = 0; timeColumnOptions.length = 0; baseColumns.length = 0; baseStringColumns.length = 0; baseIntegerColumns.length = 0; baseTimeColumns.length = 0; baseTimeColumnOptions.length = 0; for (const k in columnTypes) delete columnTypes[k]; const groups = { time: {name: 'Time', cols: [], ul: null}, integer: {name: 'Integers', cols: [], ul: null}, string: {name: 'Strings', cols: [], ul: null}, }; cols.forEach(c => { const t = c.type.toUpperCase(); columnTypes[c.name] = c.type; allColumns.push(c.name); baseColumns.push(c.name); let g = 'string'; const isNumeric = t.includes('INT') || t.includes('DECIMAL') || t.includes ('NUMERIC') || t.includes('REAL') || t.includes('DOUBLE') || t.includes('FLOAT') || t.includes('HUGEINT'); const isTimeType = t.includes('TIMESTAMP') || t.includes('DATE') || t.incl udes('TIME'); if (isNumeric || isTimeType) { timeColumnOptions.push(c.name); baseTimeColumnOptions.push(c.name);
(scubaduck) bash-5.2# sed -n '240,420p' scubaduck/static/js/view_settings.js baseTimeColumnOptions.push(c.name); } if (isTimeType) { g = 'time'; timeColumns.push(c.name); baseTimeColumns.push(c.name); } else if (isNumeric) { g = 'integer'; } if (g === 'string') { stringColumns.push(c.name); baseStringColumns.push(c.name); } else if (g === 'integer') { integerColumns.push(c.name); baseIntegerColumns.push(c.name); } groups[g].cols.push(c.name); if (g !== 'string') { const o = document.createElement('option'); o.value = c.name; o.textContent = c.name; orderSelect.appendChild(o); } }); timeColumns.forEach(name => { const o = document.createElement('option'); o.value = name; o.textContent = name; xAxisSelect.appendChild(o); }); timeColumnOptions.forEach(name => { const o = document.createElement('option'); o.value = name; o.textContent = name; timeColumnSelect.appendChild(o); }); xAxisSelect.value = ''; defaultTimeColumn = guessTimeColumn(cols) || ''; updateTimeFieldVisibility(); Object.keys(groups).forEach(key => { const g = groups[key]; const div = document.createElement('div'); div.className = 'col-group'; const header = document.createElement('div'); header.className = 'col-group-header'; header.appendChild(document.createTextNode(g.name + ': ')); const links = document.createElement('span'); links.className = 'links'; const allBtn = document.createElement('a'); allBtn.href = '#'; allBtn.textContent = 'All'; const noneBtn = document.createElement('a'); noneBtn.href = '#'; noneBtn.textContent = 'None'; links.appendChild(allBtn); links.appendChild(noneBtn); header.appendChild(links); div.appendChild(header); const ul = document.createElement('ul'); g.ul = ul; g.cols.forEach(name => { const li = document.createElement('li'); const label = document.createElement('label'); const cb = document.createElement('input'); cb.type = 'checkbox'; cb.value = name; cb.checked = true; cb.addEventListener('change', updateSelectedColumns); label.appendChild(cb); label.appendChild(document.createTextNode(' ' + name)); li.appendChild(label); ul.appendChild(li); }); allBtn.addEventListener('click', e => { e.preventDefault(); ul.querySelectorAll('input').forEach(cb => (cb.checked = true)); updateSelectedColumns(); }); noneBtn.addEventListener('click', e => { e.preventDefault(); ul.querySelectorAll('input').forEach(cb => (cb.checked = false)); updateSelectedColumns(); }); div.appendChild(ul); groupsEl.appendChild(div); }); document.getElementById('columns_all').addEventListener('click', e => { e.preventDefault(); groupsEl.querySelectorAll('input').forEach(cb => (cb.checked = true)); updateSelectedColumns(); }); document.getElementById('columns_none').addEventListener('click', e => { e.preventDefault(); groupsEl.querySelectorAll('input').forEach(cb => (cb.checked = false)); updateSelectedColumns(); }); updateSelectedColumns(); columnValues.samples = allColumns.slice(); columnValues.table = []; columnValues.timeseries = []; groupBy = document.getElementById('group_by').closest('.field'); initChipInput(groupBy, typed => allColumns.filter(c => c.toLowerCase().includes(typed.toLowerCase())) ); initDropdown(orderSelect); initDropdown(document.getElementById('aggregate')); }); } let columnsInitialized = false; fetch('/api/tables').then(r => r.json()).then(tables => { const tableSel = document.getElementById('table'); tables.forEach(t => { const o = document.createElement('option'); o.value = t; o.textContent = t; tableSel.appendChild(o); }); initDropdown(tableSel); const measure = document.createElement('span'); measure.style.visibility = 'hidden'; measure.style.position = 'absolute'; document.body.appendChild(measure); let maxWidth = 0; tables.forEach(t => { measure.textContent = t; const w = measure.getBoundingClientRect().width; if (w > maxWidth) maxWidth = w; }); measure.remove(); const disp = tableSel.parentElement.querySelector('.dropdown-display'); if (disp) disp.style.minWidth = maxWidth + 30 + 'px'; const table = parseSearch().table || tables[0]; tableSel.value = table; tableSel.dispatchEvent(new Event('change')); loadColumns(table).then(() => { updateDisplayTypeUI(); addFilter(); initFromUrl(); columnsInitialized = true; }); tableSel.addEventListener('change', () => { loadColumns(tableSel.value).then(() => { if (columnsInitialized) { // keep the newly selected table when updating from the URL const params = parseSearch(); params.table = tableSel.value; applyParams(params); } }); }); }); document.querySelectorAll('#tabs .tab').forEach(btn => { btn.addEventListener('click', () => { document.querySelectorAll('#tabs .tab').forEach(t => t.classList.remove('act ive')); document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('a ctive')); btn.classList.add('active'); document.getElementById(btn.dataset.tab).classList.add('active'); }); }); document.querySelectorAll('.rel-btn').forEach(btn => { btn.addEventListener('click', () => { const dd = document.getElementById(btn.dataset.target); const show = dd.style.display === 'none' || dd.style.display === ''; document.querySelectorAll('.rel-dropdown').forEach(d => (d.style.display = ' none')); dd.style.display = show ? 'block' : 'none'; }); }); document.querySelectorAll('.rel-dropdown div').forEach(opt => { opt.addEventListener('click', () => { const box = opt.closest('.rel-box'); const input = box.querySelector('input'); input.value = opt.dataset.value || opt.textContent; opt.parentElement.style.display = 'none'; }); }); document.addEventListener('click', e => { document.querySelectorAll('.rel-dropdown').forEach(dd => { (scubaduck) bash-5.2#

The issue seems to be with show_hits and how it's affecting the columns in the query.

The select columns are ordered correctly, but I mistakenly inserted Hits in the wrong place.

Inserted count(*) AS Hits shifted count(*) AS Count, expected order issues resolved.

I forgot to add 'Count' to selectedColumns; searching for a fix in updateSelectedColumns.

shell
(scubaduck) bash-5.2# grep -n "Count'" -n scubaduck/static/js/view_settings.js (scubaduck) bash-5.2#

I'm checking test_server_timeseries.py for the "Count" aggregator and row format.

shell
def test_timeseries_basic() -> None: app = server.app client = app.test_client() payload = { "table": "events", "start": "2024-01-01 00:00:00", "end": "2024-01-03 00:00:00", "graph_type": "timeseries", "limit": 100, "group_by": ["user"], "aggregate": "Count", "columns": ["value"], "x_axis": "timestamp", "granularity": "1 day", } rv = client.post( "/api/query", data=json.dumps(payload), content_type="application/json" ) data = rv.get_json() assert rv.status_code == 200 assert len(data["rows"]) == 4 def test_timeseries_orders_by_xaxis() -> None: app = server.app client = app.test_client() payload = { "table": "events", "start": "2024-01-01 00:00:00", "end": "2024-01-03 00:00:00", "graph_type": "timeseries", "limit": 100, "columns": ["value"], "x_axis": "timestamp", "granularity": "1 day", } rv = client.post( "/api/query", data=json.dumps(payload), content_type="application/json" ) data = rv.get_json() assert rv.status_code == 200 rows = data["rows"] from dateutil import parser timestamps = [parser.parse(r[0]).replace(tzinfo=None) for r in rows] assert timestamps == sorted(timestamps) def test_timeseries_count_no_columns() -> None: app = server.app client = app.test_client() payload: dict[str, Any] = { "table": "events", "start": "2024-01-01 00:00:00", "end": "2024-01-03 00:00:00", "graph_type": "timeseries", "granularity": "1 day", "columns": [], } rv = client.post( (scubaduck) bash-5.2#

I'm going through previous sections to gather more information.

shell
) data = rv.get_json() assert rv.status_code == 200 rows = data["rows"] assert len(rows) == 2 assert rows[0][1] == 2 assert rows[1][1] == 2 def test_timeseries_limit_applies_to_series() -> None: app = server.app client = app.test_client() payload = { "table": "events", "start": "2024-01-01 00:00:00", "end": "2024-01-03 00:00:00", "graph_type": "timeseries", "limit": 1, "order_by": "user", "group_by": ["user"], "aggregate": "Count", "columns": ["value"], "x_axis": "timestamp", "granularity": "1 day", } rv = client.post( "/api/query", data=json.dumps(payload), content_type="application/json" ) data = rv.get_json() assert rv.status_code == 200 assert len(data["rows"]) == 2 assert all(r[1] == "alice" for r in data["rows"]) def test_timeseries_auto_and_fine_buckets() -> None: app = server.app client = app.test_client() def run(gran: str) -> None: payload = { "start": "2024-01-01 00:00:00", "end": "2024-01-02 03:00:00", "graph_type": "timeseries", "columns": ["value"], "x_axis": "timestamp", "granularity": gran, } rv = client.post( "/api/query", data=json.dumps(payload), content_type="application/js on" ) data = rv.get_json() assert rv.status_code == 200 from dateutil import parser start = parser.parse(cast(str, payload["start"])).replace(tzinfo=None) buckets = [ parser.parse(cast(str, r[0])).replace(tzinfo=None) for r in data["ro ws"] ] assert buckets[0] == start if len(buckets) > 1: step = (buckets[1] - buckets[0]).total_seconds() assert step % data["bucket_size"] == 0 assert any(r[1] != 0 for r in data["rows"]) run("Auto") run("Fine") def test_timeseries_string_column_error() -> None: app = server.app client = app.test_client() payload = { "table": "events", "start": "2024-01-01 00:00:00", "end": "2024-01-03 00:00:00", "graph_type": "timeseries", "limit": 7, "columns": ["timestamp", "event", "value", "user"], "x_axis": "timestamp", (scubaduck) bash-5.2#

I’m checking around line 80, where the test calls payload columns. Let me verify.

shell
"columns": ["value"], "x_axis": "timestamp", "granularity": "1 day", } rv = client.post( "/api/query", data=json.dumps(payload), content_type="application/json" ) data = rv.get_json() assert rv.status_code == 200 rows = data["rows"] from dateutil import parser timestamps = [parser.parse(r[0]).replace(tzinfo=None) for r in rows] assert timestamps == sorted(timestamps) def test_timeseries_count_no_columns() -> None: app = server.app client = app.test_client() payload: dict[str, Any] = { "table": "events", "start": "2024-01-01 00:00:00", "end": "2024-01-03 00:00:00", "graph_type": "timeseries", "granularity": "1 day", "columns": [], } rv = client.post( "/api/query", data=json.dumps(payload), content_type="application/json" ) data = rv.get_json() assert rv.status_code == 200 rows = data["rows"] assert len(rows) == 2 assert rows[0][1] == 2 assert rows[1][1] == 2 def test_timeseries_limit_applies_to_series() -> None: app = server.app client = app.test_client() payload = { "table": "events", "start": "2024-01-01 00:00:00", "end": "2024-01-03 00:00:00", "graph_type": "timeseries", (scubaduck) bash-5.2#

I need to check the row format to confirm it's two columns: bucket and count.

shell
> from scubaduck.server import app > import json > client = app.test_client() > payload={ > "table":"events", > "start":"2024-01-01 00:00:00", > "end":"2024-01-03 00:00:00", > "graph_type":"timeseries", > "granularity":"1 day", > "columns": [] > } > rv = client.post("/api/query", data=json.dumps(payload), content_type="applica tion/json") > print(rv.status_code) > print(rv.get_json()) > PY
200 {'bucket_size': 86400, 'end': '2024-01-03 00:00:00', 'rows': [['Mon, 01 Jan 2024 00:00:00 GMT', 2], ['Tue, 02 Jan 2024 00:00:00 GMT', 2]], 'sql': 'SELECT TIMEST AMP \'2024-01-01 00:00:00\' + INTERVAL \'86400 second\' * CAST(floor((epoch("tim estamp") - epoch(TIMESTAMP \'2024-01-01 00:00:00\'))/86400) AS BIGINT) AS bucket , count(*) AS Count\nFROM "events"\nWHERE "timestamp" >= \'2024-01-01 00:00:00\' AND "timestamp" <= \'2024-01-03 00:00:00\'\nGROUP BY "bucket"\nORDER BY bucket' , 'start': '2024-01-01 00:00:00'} (scubaduck) bash-5.2#

I'm noticing a difference in show_hits between the UI and server tests. I'll update it.

shell
(scubaduck) bash-5.2# python - <<'PY' > from scubaduck.server import create_app > import json > app = create_app('TEST') > client = app.test_client() > payload={ > 'table':'extra', > 'start':'2024-01-01 00:00:00', > 'end':'2024-01-01 03:00:00', > 'graph_type':'timeseries', > 'columns':['num'], > 'group_by':['num'], > 'x_axis':'ts', > 'granularity':'1 hour', > 'aggregate':'Count', > 'time_column':'ts', > 'show_hits':True > } > rv=client.post('/api/query',data=json.dumps(payload),content_type='application /json') > print(rv.status_code) > print(rv.get_json()) > PY
200 {'bucket_size': 3600, 'end': '2024-01-01 03:00:00', 'rows': [['Mon, 01 Jan 2024 00:00:00 GMT', 1, 1, 1], ['Mon, 01 Jan 2024 01:00:00 GMT', 2, 1, 1]], 'sql': 'SE LECT TIMESTAMP \'2024-01-01 00:00:00\' + INTERVAL \'3600 second\' * CAST(floor(( epoch(make_timestamp(CAST(CAST("ts" AS BIGINT) * 1000000 AS BIGINT))) - epoch(TI MESTAMP \'2024-01-01 00:00:00\'))/3600) AS BIGINT) AS bucket, "num", count(*) AS Hits, count(*) AS Count\nFROM "extra"\nWHERE make_timestamp(CAST(CAST("ts" AS B IGINT) * 1000000 AS BIGINT)) >= \'2024-01-01 00:00:00\' AND make_timestamp(CAST( CAST("ts" AS BIGINT) * 1000000 AS BIGINT)) <= \'2024-01-01 03:00:00\'\nGROUP BY "bucket", "num"\nORDER BY bucket', 'start': '2024-01-01 00:00:00'} (scubaduck) bash-5.2#

It seems when the 'Count' column isn’t added, no chart is drawn. I’ll check the code to ensure it's pushed correctly.

shell
(scubaduck) bash-5.2# grep -n "Count" -n scubaduck/static/js/view_settings.js | head 425:function updateColumnsTabCount() { 426: const baseCount = document.querySelectorAll('#column_groups input:checked' ).length; 427: const derivedCount = document.querySelectorAll('#derived_list .derived .d- use:checked').length; 429: if (btn) btn.textContent = `Columns (${baseCount + derivedCount})`; 462: updateColumnsTabCount(); (scubaduck) bash-5.2#

I need to adjust updateSelectedColumns to handle the 'Count' or 'Count Distinct' aggregator.

shell
(scubaduck) bash-5.2# sed -n '428,470p' scubaduck/static/js/view_settings.js const btn = document.getElementById('columns_tab'); if (btn) btn.textContent = `Columns (${baseCount + derivedCount})`; } function updateSelectedColumns(type = graphTypeSel.value) { const base = allColumns.filter(name => { const cb = document.querySelector(`#column_groups input[value="${name}"]`); if (!cb || !cb.checked) return false; if (type === 'table' && isStringColumn(name)) return false; return true; }); if (type === 'table' || type === 'timeseries') { selectedColumns = groupBy.chips.slice(); if (document.getElementById('show_hits').checked) selectedColumns.push('Hits '); const agg = document.getElementById('aggregate').value.toLowerCase(); if (!(type === 'table' && agg === 'count')) { base.forEach(c => { if (!selectedColumns.includes(c)) selectedColumns.push(c); }); derivedColumns.forEach(dc => { if (dc.include && !selectedColumns.includes(dc.name)) selectedColumns.pu sh(dc.name); }); } } else { selectedColumns = base.slice(); derivedColumns.forEach(dc => { if (dc.include) selectedColumns.push(dc.name); }); } columnValues[type] = selectedColumns.slice(); const orderCol = document.getElementById('order_by').value; if (orderCol && !selectedColumns.includes(orderCol)) { selectedColumns.push(orderCol); } updateColumnsTabCount(); } function isStringColumn(name) { const t = (columnTypes[name] || '').toUpperCase(); return t.includes('CHAR') || t.includes('STRING') || t.includes('VARCHAR'); } function isIntegerColumn(name) { (scubaduck) bash-5.2#

It seems 'Count' isn't mentioned—could it be tied to show_hits in the table view?

shell
(scubaduck) bash-5.2# grep -n "table_count_no_columns" -n tests -R grep: tests/__pycache__/test_web_table.cpython-312-pytest-8.3.5.pyc: binary file matches tests/test_web_table.py:333:def test_table_count_no_columns(page: Any, test_data set_server_url: str) -> None:
page, server_url, start="2024-01-01 00:00:00", end="2024-01-02 00:00:00", order_by="timestamp", limit=10, ) sql = data["sql"] displayed = page.text_content("#sql_query") assert displayed is not None assert displayed.strip() == sql def test_table_count_no_columns(page: Any, test_dataset_server_url: str) -> None : page.goto(test_dataset_server_url) page.wait_for_selector("#order_by option", state="attached") select_value(page, "#graph_type", "table") page.click("text=Columns") page.click("#columns_all") page.click("text=View Settings") page.evaluate("groupBy.chips = ['id']; groupBy.renderChips();") select_value(page, "#aggregate", "Count") page.evaluate("window.lastResults = undefined") page.click("text=Dive") page.wait_for_function("window.lastResults !== undefined") headers = page.locator("#results th").all_inner_texts() assert headers == ["id", "Hits"] col_count = page.locator("#results th").count() row_count = page.locator("#results tr").count() assert col_count == 2 assert row_count == 3 overflow = page.evaluate( "var v=document.getElementById('view'); v.scrollWidth > v.clientWidth" ) assert not overflow (scubaduck) bash-5.2#

I'll verify that when "Count" is used as aggregator, the table shows "Hits" instead.

shell
(scubaduck) bash-5.2# python - <<'PY' > from scubaduck.server import app > import json > client = app.test_client() > payload={ > 'table':'events', > 'start':'2024-01-01 00:00:00', > 'end':'2024-01-02 00:00:00', > 'graph_type':'table', > 'group_by':['user'], > 'aggregate':'Count', > 'columns':['value'], > 'order_by':'user', > 'show_hits':True, > 'limit':10 > } > rv=client.post('/api/query', data=json.dumps(payload), content_type='applicati on/json') > print(rv.status_code) > print(rv.get_json()) > PY
200 {'end': '2024-01-02 00:00:00', 'rows': [['alice', 2, 2], ['bob', 1, 1]], 'sql': 'SELECT "user", count(*) AS Hits, count(*) AS Count\nFROM "events"\nWHERE "times tamp" >= \'2024-01-01 00:00:00\' AND "timestamp" <= \'2024-01-02 00:00:00\'\nGRO UP BY "user"\nORDER BY "user" ASC\nLIMIT 10', 'start': '2024-01-01 00:00:00'} (scubaduck) bash-5.2#

I'm analyzing why the test expects [id, Hits], but the server returns [id, Hits, Count].

shell
(scubaduck) bash-5.2# sed -n '1,120p' scubaduck/static/js/table_view.js // Logic for rendering the table based views. Extracted from index.html so that // the inline script only handles wiring up the UI. let originalRows = []; let sortState = { index: null, dir: null }; function renderTable(rows) { const table = document.getElementById("results"); table.innerHTML = ""; if (rows.length === 0) return; let hitsIndex = selectedColumns.indexOf("Hits"); let totalHits = 0; if (hitsIndex !== -1) { totalHits = rows.reduce((s, r) => s + Number(r[hitsIndex]), 0); } const header = document.createElement("tr"); selectedColumns.forEach((col, i) => { const th = document.createElement("th"); let label = col; if ( displayType === "table" && col !== "Hits" && !(groupBy.chips || []).includes(col) ) { const agg = document.getElementById("aggregate").value.toLowerCase(); label += ` (${agg})`; } th.textContent = label; th.dataset.index = i; th.addEventListener("click", handleSort); if (sortState.index === i) { th.classList.add("sorted"); th.textContent = label + (sortState.dir === "desc" ? " \u25BC" : " \u25B2" ); } th.style.textAlign = "left"; header.appendChild(th); }); table.appendChild(header); rows.forEach((row) => { const tr = document.createElement("tr"); tr.addEventListener("click", () => { const wasSelected = tr.classList.contains("selected"); document .querySelectorAll("#results tr.selected") .forEach((el) => el.classList.remove("selected")); if (!wasSelected) { tr.classList.add("selected"); } }); row.forEach((v, i) => { const col = selectedColumns[i]; const td = document.createElement("td"); if (isTimeColumn(col)) { let d; const t = (columnTypes[col] || "").toUpperCase(); if (t.includes("TIMESTAMP") || t.includes("DATE") || t.includes("TIME")) { d = new Date(v); } else { const unit = document.getElementById("time_unit").value; const factors = { s: 1000, ms: 1, us: 0.001, ns: 0.000001 }; d = new Date(Number(v) * (factors[unit] || 1000)); } td.textContent = d.toLocaleString("en-US", { weekday: "short", month: "short", day: "numeric", year: "numeric", hour: "numeric", minute: "numeric", second: "numeric", hour12: true, timeZoneName: "short", }); td.classList.add("date"); } else { if (col === "Hits") { const pct = totalHits ? ((v / totalHits) * 100).toFixed(1) : "0"; td.textContent = `${formatNumber(v)} (${pct}%)`; } else { td.textContent = isStringColumn(col) ? v : formatNumber(v); } } if (!isStringColumn(col) && !isTimeColumn(col)) { td.classList.add("numeric"); } td.style.textAlign = isStringColumn(col) ? "left" : "right"; tr.appendChild(td); }); table.appendChild(tr); }); // ensure table does not overflow unless necessary const view = document.getElementById("view"); if (table.scrollWidth <= view.clientWidth) { table.style.width = "100%"; } } function handleSort(e) { const idx = parseInt(e.target.dataset.index, 10); if (sortState.index !== idx) { sortState.index = idx; sortState.dir = "asc"; } else if (sortState.dir === "asc") { sortState.dir = "desc"; } else if (sortState.dir === "desc") { sortState.index = null; sortState.dir = null; } else { sortState.dir = "asc"; } let rows = originalRows.slice(); if (sortState.index !== null) { rows.sort((a, b) => { const va = a[sortState.index]; const vb = b[sortState.index]; if (va === vb) return 0; if (sortState.dir === "desc") return va < vb ? 1 : -1; return va > vb ? 1 : -1; }); } (scubaduck) bash-5.2#

The issue might be caused by mismatched row and selectedColumns lengths, causing misalignment.

The query might not include "Count" because it's only added when necessary for aggregation.

shell
(scubaduck) bash-5.2# sed -n '248,330p' scubaduck/server.py def build_query(params: QueryParams, column_types: Dict[str, str] | None = None) -> str: select_parts: list[str] = [] group_cols = params.group_by[:] selected_for_order = set(params.columns) | set(params.derived_columns.keys() ) if params.graph_type == "timeseries": sec = _granularity_seconds(params.granularity, params.start, params.end) x_axis = params.x_axis or params.time_column if x_axis is None: raise ValueError("x_axis required for timeseries") xexpr = _time_expr(x_axis, column_types, params.time_unit) if params.start: bucket_expr = ( f"TIMESTAMP '{params.start}' + INTERVAL '{sec} second' * " f"CAST(floor((epoch({xexpr}) - epoch(TIMESTAMP '{params.start}') )/{sec}) AS BIGINT)" ) else: bucket_expr = ( f"TIMESTAMP 'epoch' + INTERVAL '{sec} second' * " f"CAST(floor(epoch({xexpr})/{sec}) AS BIGINT)" ) select_parts.append(f"{bucket_expr} AS bucket") group_cols = ["bucket"] + group_cols selected_for_order.add("bucket") has_agg = bool(group_cols) or params.aggregate is not None if has_agg: select_cols = ( group_cols[1:] if params.graph_type == "timeseries" else group_cols ) select_parts.extend(_quote(c) for c in select_cols) agg = (params.aggregate or "count").lower() selected_for_order.update(group_cols) def agg_expr(col: str) -> str: expr = _quote(col) ctype = column_types.get(col, "").upper() if column_types else "" if "BOOL" in ctype: expr = f"CAST({_quote(col)} AS BIGINT)" if agg.startswith("p"): quant = float(agg[1:]) / 100 return f"quantile({expr}, {quant})" if agg == "count distinct": return f"count(DISTINCT {expr})" if agg == "avg" and column_types is not None: if "TIMESTAMP" in ctype or "DATE" in ctype or "TIME" in ctype: return ( "TIMESTAMP 'epoch' + INTERVAL '1 second' * " f"CAST(avg(epoch({_quote(col)})) AS BIGINT)" ) return f"{agg}({expr})" if agg == "count": select_parts.append("count(*) AS Count") selected_for_order.add("Count") else: for col in params.columns: if col in group_cols: continue select_parts.append(f"{agg_expr(col)} AS {_quote(col)}") selected_for_order.add(col) if params.show_hits: select_parts.insert(len(group_cols), "count(*) AS Hits") selected_for_order.add("Hits") else: select_parts.extend(_quote(c) for c in params.columns) selected_for_order.update(params.columns) order_by = params.order_by if params.order_by in selected_for_order else Non e if has_agg and params.derived_columns: inner_params = replace( params, derived_columns={}, order_by=None, limit=None, ) inner_sql = build_query(inner_params, column_types) outer_select = ["t.*"] + [ f"{expr} AS {name}" for name, expr in params.derived_columns.items() ] indented_inner = "\n".join(" " + line for line in inner_sql.splitline s()) lines = [ f"SELECT {', '.join(outer_select)}", "FROM (", (scubaduck) bash-5.2#

The table test expects 2 headers: 'id' and 'Hits', but gets an extra 'Count'.

I’ll check manually using a JS snippet, even though Node may not be installed.

shell
(scubaduck) bash-5.2# node --version bash: node: command not found (scubaduck) bash-5.2#

To ensure timeseries chart displays, I'll guarantee at least one 'value' column is selected.

I'll ensure 'Count' gets added to selectedColumns for timeseries with aggregation.

I'll confirm the location of test_dataset_server_url and replicate the existing timeseries tests.

shell
(scubaduck) bash-5.2# sed -n '1,30p' tests/test_web_table.py from __future__ import annotations from typing import Any from collections.abc import Iterator import threading import pytest from werkzeug.serving import make_server from scubaduck.server import create_app from tests.web_utils import run_query, select_value @pytest.fixture() def test_dataset_server_url() -> Iterator[str]: app = create_app("TEST") httpd = make_server("127.0.0.1", 0, app) port = httpd.server_port thread = threading.Thread(target=httpd.serve_forever) thread.start() try: yield f"http://127.0.0.1:{port}" finally: httpd.shutdown() thread.join() def test_table_sorting(page: Any, server_url: str) -> None: run_query( (scubaduck) bash-5.2#

I'll reuse the test_dataset_server_url fixture for test_timeseries_group_by_num, checking for similar tests.

shell
(scubaduck) bash-5.2# grep -n "test_dataset_server_url" -n tests/test_web_timese ries.py (scubaduck) bash-5.2#

I'll add the new test to tests/test_web_timeseries.py to maintain timeseries coverage.

shell
py 16:def test_dataset_server_url() -> Iterator[str]: 333:def test_table_count_no_columns(page: Any, test_dataset_server_url: str) -> None: 334: page.goto(test_dataset_server_url) (scubaduck) bash-5.2#

I see a fixture at the top of this file. I'll replicate it in test_web_timeseries.py.

shell
import pytest from werkzeug.serving import make_server from scubaduck.server import create_app from tests.web_utils import run_query, select_value @pytest.fixture() def test_dataset_server_url() -> Iterator[str]: app = create_app("TEST") httpd = make_server("127.0.0.1", 0, app) port = httpd.server_port thread = threading.Thread(target=httpd.serve_forever) thread.start() try:
try: yield f"http://127.0.0.1:{port}" finally: httpd.shutdown() thread.join() def test_table_sorting(page: Any, server_url: str) -> None: run_query( page, server_url, start="2024-01-01 00:00:00", end="2024-01-03 00:00:00", order_by="user", order_dir="ASC", limit=100, ) # header alignment align = page.evaluate( (scubaduck) bash-5.2#

I'll copy the fixture into tests/test_web_timeseries.py and check for imports at the top.

shell
from __future__ import annotations from typing import Any from tests.web_utils import select_value def test_timeseries_default_query(page: Any, server_url: str) -> None: page.goto(server_url) page.wait_for_selector("#graph_type", state="attached") select_value(page, "#graph_type", "timeseries") page.click("text=Columns") page.check("#column_groups input[value='value']") page.click("text=View Settings") page.evaluate("window.lastResults = undefined") page.click("text=Dive") page.wait_for_function("window.lastResults !== undefined") data = page.evaluate("window.lastResults") assert "error" not in data assert page.is_visible("#chart") (scubaduck) bash-5.2#

I'm adding the fixture near the top and defining necessary imports like Iterator and threading. Then, I'll proceed with adding the new test function to replicate the issue with the TIMESERIES view.

shell
(scubaduck) bash-5.2# grep -n "aggregate" -n scubaduck/static/index.html | head 303: <div id="aggregate_field" class="field" style="display:none;"> 305: <select id="aggregate">
<label>Aggregate</label> <select id="aggregate"> <option>Count</option> <option>Avg</option> <option>Sum</option> <option>Min</option> <option>Max</option> <option>Count Distinct</option> <option>p5</option> <option>p25</option> <option>p50</option> <option>p70</option> <option>p75</option> <option>p90</option> <option>p95</option> <option>p99</option> <option>p99.9</option> (scubaduck) bash-5.2#

The default aggregator is 'Count'. In the bug, no data shows when grouping by 'num'. I'll try reproducing the issue using a script and verify with the 'extra' table in the time series view. Let me check how the 'ts' column is guessed in the code.

shell
(scubaduck) bash-5.2# grep -n "guessTimeColumn" -n scubaduck/static/js/view_sett ings.js 277: defaultTimeColumn = guessTimeColumn(cols) || '';
(scubaduck) bash-5.2# sed -n '260,288p' scubaduck/static/js/view_settings.js o.textContent = c.name; orderSelect.appendChild(o); } }); timeColumns.forEach(name => { const o = document.createElement('option'); o.value = name; o.textContent = name; xAxisSelect.appendChild(o); }); timeColumnOptions.forEach(name => { const o = document.createElement('option'); o.value = name; o.textContent = name; timeColumnSelect.appendChild(o); }); xAxisSelect.value = ''; defaultTimeColumn = guessTimeColumn(cols) || ''; updateTimeFieldVisibility(); Object.keys(groups).forEach(key => { const g = groups[key]; const div = document.createElement('div'); div.className = 'col-group'; const header = document.createElement('div'); header.className = 'col-group-header'; header.appendChild(document.createTextNode(g.name + ': ')); const links = document.createElement('span'); links.className = 'links'; const allBtn = document.createElement('a'); (scubaduck) bash-5.2#

I'm checking where guessTimeColumn is defined earlier in the code.

shell
(scubaduck) bash-5.2# grep -n "function guessTimeColumn" -n scubaduck/static/js/ view_settings.js
(scubaduck) bash-5.2# grep -n "guessTimeColumn" -n scubaduck/static/js/view_sett ings.js 277: defaultTimeColumn = guessTimeColumn(cols) || ''; (scubaduck) bash-5.2#

I'll search the repo again to see if guessTimeColumn is defined elsewhere.

shell
(scubaduck) bash-5.2# grep -n "guessTimeColumn" -R scubaduck scubaduck/static/js/time_column.js:2:function guessTimeColumn(columns) { scubaduck/static/js/view_settings.js:277: defaultTimeColumn = guessTimeColumn (cols) || '';
(scubaduck) bash-5.2# sed -n '1,60p' scubaduck/static/js/time_column.js // Helper for choosing a default time column based on column names/types function guessTimeColumn(columns) { const heur = ['timestamp','created','created_at','event_time','time','date','o ccurred','happened','logged']; let heurGuess = null; let timestamp = null; columns.forEach(c => { const t = (c.type || '').toUpperCase(); const isNumeric = t.includes('INT') || t.includes('DECIMAL') || t.includes(' NUMERIC') || t.includes('REAL') || t.includes('DOUBLE') || t.includes(' FLOAT') || t.includes('HUGEINT'); const isTimeType = t.includes('TIMESTAMP') || t.includes('DATE') || t.includ es('TIME'); if (heur.some(h => c.name.toLowerCase().includes(h)) && (isTimeType || isNum eric)) { if (!heurGuess) heurGuess = c.name; } if (!timestamp && isTimeType) { timestamp = c.name; } }); return heurGuess || timestamp || null; } (scubaduck) bash-5.2#

I'll replicate the bug by running tests on the front end using Playwright and simulate the behavior.

I’ll set the start date to 2024-01-01 00:00:00 and end to 01:00 for two rows.

To avoid duplicates, I'll ensure 'num' isn't added after group_by and aggregator Count is included.

shell
(scubaduck) bash-5.2# sed -n '440,464p' scubaduck/static/js/view_settings.js selectedColumns = groupBy.chips.slice(); if (document.getElementById('show_hits').checked) selectedColumns.push('Hits '); const agg = document.getElementById('aggregate').value.toLowerCase(); if (!(type === 'table' && agg === 'count')) { base.forEach(c => { if (!selectedColumns.includes(c)) selectedColumns.push(c); }); derivedColumns.forEach(dc => { if (dc.include && !selectedColumns.includes(dc.name)) selectedColumns.pu sh(dc.name); }); } } else { selectedColumns = base.slice(); derivedColumns.forEach(dc => { if (dc.include) selectedColumns.push(dc.name); }); } columnValues[type] = selectedColumns.slice(); const orderCol = document.getElementById('order_by').value; if (orderCol && !selectedColumns.includes(orderCol)) { selectedColumns.push(orderCol); } updateColumnsTabCount(); } (scubaduck) bash-5.2#

I'll patch the condition for timeseries aggregator count to ensure 'Count' always appears.

shell
(scubaduck) bash-5.2# apply_patch<<'PATCH' > *** Begin Patch > *** Update File: scubaduck/static/js/view_settings.js > @@ > - selectedColumns = groupBy.chips.slice(); > - if (document.getElementById('show_hits').checked) selectedColumns.push('H its'); > - const agg = document.getElementById('aggregate').value.toLowerCase(); > - if (!(type === 'table' && agg === 'count')) { > - base.forEach(c => { > - if (!selectedColumns.includes(c)) selectedColumns.push(c); > - }); > - derivedColumns.forEach(dc => { > - if (dc.include && !selectedColumns.includes(dc.name)) selectedColumns .push(dc.name); > - }); > - } > + selectedColumns = groupBy.chips.slice(); > + if (document.getElementById('show_hits').checked) selectedColumns.push('H its'); > + const agg = document.getElementById('aggregate').value.toLowerCase(); > + if (!(type === 'table' && agg === 'count')) { > + base.forEach(c => { > + if (!selectedColumns.includes(c)) selectedColumns.push(c); > + }); > + derivedColumns.forEach(dc => { > + if (dc.include && !selectedColumns.includes(dc.name)) selectedColumns .push(dc.name); > + }); > + } > + if (type === 'timeseries' && agg === 'count' && !selectedColumns.includes ('Count')) { > + selectedColumns.push('Count'); > + } > } else { > *** End Patch > PATCH Success. Updated the following files: M scubaduck/static/js/view_settings.js (scubaduck) bash-5.2#

I'll add the new test and necessary imports to the top of "test_web_timeseries.py".

shell
(scubaduck) bash-5.2# sed -n '1,20p' tests/test_web_timeseries.py from __future__ import annotations from typing import Any from tests.web_utils import select_value def test_timeseries_default_query(page: Any, server_url: str) -> None: page.goto(server_url) page.wait_for_selector("#graph_type", state="attached") select_value(page, "#graph_type", "timeseries") page.click("text=Columns") page.check("#column_groups input[value='value']") page.click("text=View Settings") page.evaluate("window.lastResults = undefined") page.click("text=Dive") page.wait_for_function("window.lastResults !== undefined") data = page.evaluate("window.lastResults") assert "error" not in data assert page.is_visible("#chart") (scubaduck) bash-5.2#

I'll add the fixture right after the imports for better structure.

shell
> *** Begin Patch > *** Update File: tests/test_web_timeseries.py > @@ > -from typing import Any > +from typing import Any > +from collections.abc import Iterator > +import threading > +import pytest > +from werkzeug.serving import make_server > +from scubaduck.server import create_app > *** End Patch > PATCH Success. Updated the following files: M tests/test_web_timeseries.py
(scubaduck) bash-5.2# grep -n "server_url" -n tests/test_web_timeseries.py | hea d 13:def test_timeseries_default_query(page: Any, server_url: str) -> None: 14: page.goto(server_url) 30:def test_timeseries_single_bucket(page: Any, server_url: str) -> None: 31: page.goto(server_url) 46:def test_timeseries_fill_options(page: Any, server_url: str) -> None: 47: page.goto(server_url) 79:def test_timeseries_hover_highlight(page: Any, server_url: str) -> None: 80: page.goto(server_url) 106:def test_timeseries_crosshair(page: Any, server_url: str) -> None: 107: page.goto(server_url) (scubaduck) bash-5.2#

I'll add a test_dataset_server_url fixture, similar to test_web_table, after imports.

shell
(scubaduck) bash-5.2# sed -n '8,18p' tests/test_web_timeseries.py from scubaduck.server import create_app from tests.web_utils import select_value def test_timeseries_default_query(page: Any, server_url: str) -> None: page.goto(server_url) page.wait_for_selector("#graph_type", state="attached") select_value(page, "#graph_type", "timeseries") page.click("text=Columns") page.check("#column_groups input[value='value']") (scubaduck) bash-5.2#

I realized I need to add the fixture after imports, but before functions.

shell
> *** Begin Patch > *** Update File: tests/test_web_timeseries.py > @@ > -from scubaduck.server import create_app > - > -from tests.web_utils import select_value > +from scubaduck.server import create_app > + > +from tests.web_utils import select_value > + > + > +@pytest.fixture() > +def test_dataset_server_url() -> Iterator[str]: > + app = create_app("TEST") > + httpd = make_server("127.0.0.1", 0, app) > + port = httpd.server_port > + thread = threading.Thread(target=httpd.serve_forever) > + thread.start() > + try: > + yield f"http://127.0.0.1:{port}" > + finally: > + httpd.shutdown() > + thread.join() > *** End Patch > PATCH Success. Updated the following files: M tests/test_web_timeseries.py (scubaduck) bash-5.2#

I'll find the last line of the file to add the new test.

shell
def test_timeseries_group_links(page: Any, server_url: str) -> None: page.goto(server_url) page.wait_for_selector("#graph_type", state="attached") select_value(page, "#graph_type", "timeseries") page.click("text=Columns") page.check("#column_groups input[value='value']") page.click("text=View Settings") page.fill("#start", "2024-01-01 00:00:00") page.fill("#end", "2024-01-02 03:00:00") page.evaluate("window.lastResults = undefined") page.click("text=Dive") page.wait_for_function("window.lastResults !== undefined") assert page.text_content("#legend .drill-links h4") == "Group by" page.click("#legend .drill-links a:text('user')") page.wait_for_function("window.lastResults !== undefined") chips = page.evaluate("groupBy.chips") assert chips == ["user"] assert page.text_content("#legend .drill-links h4") == "Drill up" assert page.is_visible("#legend .drill-links a:text('Aggregate')") (scubaduck) bash-5.2#

This is the final function; no new lines follow. Tests will come next.

shell
(scubaduck) bash-5.2# wc -l tests/test_web_timeseries.py 397 tests/test_web_timeseries.py (scubaduck) bash-5.2#

I'll add the new test after the last function and apply the patch.

shell
(scubaduck) bash-5.2# apply_patch<<'PATCH' > *** Begin Patch > *** Update File: tests/test_web_timeseries.py > @@ > def test_timeseries_group_links(page: Any, server_url: str) -> None: > @@ > assert page.is_visible("#legend .drill-links a:text('Aggregate')") > + > + > +def test_timeseries_group_by_num(page: Any, test_dataset_server_url: str) -> None: > + page.goto(test_dataset_server_url) > + page.wait_for_selector("#graph_type", state="attached") > + select_value(page, "#table", "extra") > + select_value(page, "#graph_type", "timeseries") > + page.evaluate("groupBy.chips = ['num']; groupBy.renderChips();") > + page.evaluate("window.lastResults = undefined") > + page.click("text=Dive") > + page.wait_for_function("window.lastResults !== undefined") > + page.wait_for_selector("#chart path", state="attached") > + count = page.eval_on_selector_all("#chart path", "els => els.length") > + assert count == 2 > *** End Patch > PATCH Success. Updated the following files: M tests/test_web_timeseries.py (scubaduck) bash-5.2#

I'll run "ruff format" to verify the JavaScript changes and check everything.

shell
(scubaduck) bash-5.2# ruff format scubaduck/static/js/view_settings.js tests/tes t_web_timeseries.py --silent
(scubaduck) bash-5.2# ruff check All checks passed! (scubaduck) bash-5.2#

I'll try running pyright, but it will probably fail due to a network issue.

shell
(scubaduck) bash-5.2# pyright

File "/usr/lib/python3.12/urllib/request.py", line 1344, in do_open h.request(req.get_method(), req.selector, req.data, headers, File "/usr/lib/python3.12/http/client.py", line 1336, in request self._send_request(method, url, body, headers, encode_chunked) File "/usr/lib/python3.12/http/client.py", line 1382, in _send_request self.endheaders(body, encode_chunked=encode_chunked) File "/usr/lib/python3.12/http/client.py", line 1331, in endheaders self._send_output(message_body, encode_chunked=encode_chunked) File "/usr/lib/python3.12/http/client.py", line 1091, in _send_output self.send(msg) File "/usr/lib/python3.12/http/client.py", line 1035, in send self.connect() File "/usr/lib/python3.12/http/client.py", line 1470, in connect super().connect() File "/usr/lib/python3.12/http/client.py", line 1001, in connect self.sock = self._create_connection( ^^^^^^^^^^^^^^^^^^^^^^^^ File "/usr/lib/python3.12/socket.py", line 852, in create_connection raise exceptions[0] File "/usr/lib/python3.12/socket.py", line 837, in create_connection sock.connect(sa) OSError: [Errno 113] No route to host During handling of the above exception, another exception occurred: Traceback (most recent call last): File "<frozen runpy>", line 198, in _run_module_as_main File "<frozen runpy>", line 88, in _run_code File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/nodeenv.py", lin e 1548, in <module> main() File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/nodeenv.py", lin e 1119, in main args.node = get_last_stable_node_version() ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/nodeenv.py", lin e 1052, in get_last_stable_node_version return _get_versions_json()[0]['version'].lstrip('v') ^^^^^^^^^^^^^^^^^^^^ File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/nodeenv.py", lin e 1028, in _get_versions_json response = urlopen('%s/index.json' % src_base_url) ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/nodeenv.py", lin e 652, in urlopen return urllib2.urlopen(req) ^^^^^^^^^^^^^^^^^^^^ File "/usr/lib/python3.12/urllib/request.py", line 215, in urlopen return opener.open(url, data, timeout) ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ File "/usr/lib/python3.12/urllib/request.py", line 515, in open response = self._open(req, data) ^^^^^^^^^^^^^^^^^^^^^ File "/usr/lib/python3.12/urllib/request.py", line 532, in _open result = self._call_chain(self.handle_open, protocol, protocol + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ File "/usr/lib/python3.12/urllib/request.py", line 492, in _call_chain result = func(*args) ^^^^^^^^^^^ File "/usr/lib/python3.12/urllib/request.py", line 1392, in https_open return self.do_open(http.client.HTTPSConnection, req, ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ File "/usr/lib/python3.12/urllib/request.py", line 1347, in do_open raise URLError(err) urllib.error.URLError: <urlopen error [Errno 113] No route to host> Traceback (most recent call last): File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/pyright/node.py" , line 93, in _install_node_env subprocess.run(args, check=True) File "/usr/lib/python3.12/subprocess.py", line 571, in run raise CalledProcessError(retcode, process.args, subprocess.CalledProcessError: Command '['/workspace/scubaduck/.venv/bin/python' , '-m', 'nodeenv', '/root/.cache/pyright-python/nodeenv']' returned non-zero exi t status 1. The above exception was the direct cause of the following exception: Traceback (most recent call last): File "/workspace/scubaduck/.venv/bin/pyright", line 10, in <module> sys.exit(entrypoint()) ^^^^^^^^^^^^ File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/pyright/cli.py", line 31, in entrypoint sys.exit(main(sys.argv[1:])) ^^^^^^^^^^^^^^^^^^ File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/pyright/cli.py", line 18, in main return run(*args, **kwargs).returncode ^^^^^^^^^^^^^^^^^^^^ File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/pyright/cli.py", line 27, in run return node.run('node', str(script), *args, **kwargs) ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/pyright/node.py" , line 138, in run strategy = _resolve_strategy(target) ^^^^^^^^^^^^^^^^^^^^^^^^^ File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/pyright/node.py" , line 130, in _resolve_strategy return NodeenvStrategy(type='nodeenv', path=_ensure_node_env(target)) ^^^^^^^^^^^^^^^^^^^^^^^^ File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/pyright/node.py" , line 54, in _ensure_node_env _install_node_env() File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/pyright/node.py" , line 95, in _install_node_env raise RuntimeError( RuntimeError: nodeenv failed; for more reliable node.js binaries try `pip instal l pyright[nodejs]` (scubaduck) bash-5.2#

Network interference caused Pyright to fail, but I'll rerun the tests now.

shell
(scubaduck) bash-5.2# pytest -q
bringing up nodes...






















..............................................................FFF

Looks like the tests failed again. It's time to check the error details.

shell

..............................................................FFFFF.F... [ 61%]












































... py", line 898, in pytest_runtest_call return (yield) ^^^^^ File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/pluggy/_callers. py", line 139, in _multicall teardown.throw(exception) File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/_pytest/skipping .py", line 257, in pytest_runtest_call return (yield) ^^^^^ File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/pluggy/_callers. py", line 121, in _multicall res = hook_impl.function(*args) ^^^^^^^^^^^^^^^^^^^^^^^^^ File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/_pytest/runner.p y", line 174, in pytest_runtest_call item.runtest() File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/_pytest/python.p y", line 1627, in runtest self.ihook.pytest_pyfunc_call(pyfuncitem=self) File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/pluggy/_hooks.py ", line 512, in __call__ return self._hookexec(self.name, self._hookimpls.copy(), kwargs, firstresult ) ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ ^ File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/pluggy/_manager. py", line 120, in _hookexec return self._inner_hookexec(hook_name, methods, kwargs, firstresult) ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/pluggy/_callers. py", line 167, in _multicall raise exception File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/pluggy/_callers. py", line 121, in _multicall res = hook_impl.function(*args) ^^^^^^^^^^^^^^^^^^^^^^^^^ File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/_pytest/python.p y", line 159, in pytest_pyfunc_call result = testfunction(**testargs) ^^^^^^^^^^^^^^^^^^^^^^^^ File "/workspace/scubaduck/tests/test_web_timeseries.py", line 211, in test_ti meseries_auto_timezone path = page.get_attribute("#chart path", "d") ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/playwright/sync_ api/_generated.py", line 10878, in get_attribute self._sync( File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/playwright/_impl /_sync_base.py", line 115, in _sync return task.result() ^^^^^^^^^^^^^ File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/playwright/_impl /_page.py", line 970, in get_attribute return await self._main_frame.get_attribute(**locals_to_params(locals())) ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/playwright/_impl /_frame.py", line 628, in get_attribute return await self._channel.send("getAttribute", locals_to_params(locals())) ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/playwright/_impl /_connection.py", line 61, in send return await self._connection.wrap_api_call( ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/playwright/_impl /_connection.py", line 528, in wrap_api_call raise rewrite_error(error, f"{parsed_st['apiName']}: {error}") from None playwright._impl._errors.TimeoutError: Page.get_attribute: Timeout 30000ms excee ded. Call log: - waiting for locator("#chart path") ------------------------------ Captured log call ------------------------------- INFO werkzeug:_internal.py:97 127.0.0.1 - - [23/May/2025 02:16:48] "GET / HT TP/1.1" 200 - INFO werkzeug:_internal.py:97 127.0.0.1 - - [23/May/2025 02:16:48] "GET /js/ chip_input.js HTTP/1.1" 200 - INFO werkzeug:_internal.py:97 127.0.0.1 - - [23/May/2025 02:16:48] "GET /js/ timeseries_chart.js HTTP/1.1" 200 - INFO werkzeug:_internal.py:97 127.0.0.1 - - [23/May/2025 02:16:48] "GET /js/ time_column.js HTTP/1.1" 200 - INFO werkzeug:_internal.py:97 127.0.0.1 - - [23/May/2025 02:16:48] "GET /js/ sidebar.js HTTP/1.1" 200 - INFO werkzeug:_internal.py:97 127.0.0.1 - - [23/May/2025 02:16:48] "GET /js/ table_view.js HTTP/1.1" 200 - INFO werkzeug:_internal.py:97 127.0.0.1 - - [23/May/2025 02:16:48] "GET /js/ view_settings.js HTTP/1.1" 200 - INFO werkzeug:_internal.py:97 127.0.0.1 - - [23/May/2025 02:16:48] "GET /api /tables HTTP/1.1" 200 - INFO werkzeug:_internal.py:97 127.0.0.1 - - [23/May/2025 02:16:48] "GET /api /columns?table=events HTTP/1.1" 200 - INFO werkzeug:_internal.py:97 127.0.0.1 - - [23/May/2025 02:16:48] "POST /ap i/query HTTP/1.1" 200 - INFO werkzeug:_internal.py:97 127.0.0.1 - - [23/May/2025 02:16:48] "POST /ap i/query HTTP/1.1" 400 - ____________________ test_timeseries_fill_options[chromium] ____________________ [gw4] linux -- Python 3.12.3 /workspace/scubaduck/.venv/bin/python Traceback (most recent call last): File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/_pytest/runner.p y", line 341, in from_call result: TResult | None = func() ^^^^^^ File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/_pytest/runner.p y", line 242, in <lambda> lambda: runtest_hook(item=item, **kwds), when=when, reraise=reraise ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/pluggy/_hooks.py ", line 512, in __call__ return self._hookexec(self.name, self._hookimpls.copy(), kwargs, firstresult ) ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ ^ File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/pluggy/_manager. py", line 120, in _hookexec return self._inner_hookexec(hook_name, methods, kwargs, firstresult) ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/pluggy/_callers. py", line 167, in _multicall raise exception File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/pluggy/_callers. py", line 139, in _multicall teardown.throw(exception) File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/_pytest/threadex ception.py", line 92, in pytest_runtest_call ... File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/_pytest/capture. py", line 898, in pytest_runtest_call return (yield) ^^^^^ File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/pluggy/_callers. py", line 139, in _multicall teardown.throw(exception) File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/_pytest/skipping .py", line 257, in pytest_runtest_call return (yield) ^^^^^ File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/pluggy/_callers. py", line 121, in _multicall res = hook_impl.function(*args) ^^^^^^^^^^^^^^^^^^^^^^^^^ File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/_pytest/runner.p y", line 174, in pytest_runtest_call item.runtest() File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/_pytest/python.p y", line 1627, in runtest self.ihook.pytest_pyfunc_call(pyfuncitem=self) File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/pluggy/_hooks.py ", line 512, in __call__ return self._hookexec(self.name, self._hookimpls.copy(), kwargs, firstresult ) ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ ^ File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/pluggy/_manager. py", line 120, in _hookexec return self._inner_hookexec(hook_name, methods, kwargs, firstresult) ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/pluggy/_callers. py", line 167, in _multicall raise exception File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/pluggy/_callers. py", line 121, in _multicall res = hook_impl.function(*args) ^^^^^^^^^^^^^^^^^^^^^^^^^ File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/_pytest/python.p y", line 159, in pytest_pyfunc_call result = testfunction(**testargs) ^^^^^^^^^^^^^^^^^^^^^^^^ File "/workspace/scubaduck/tests/test_web_timeseries.py", line 130, in test_ti meseries_crosshair page.wait_for_selector("#chart path", state="attached") File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/playwright/sync_ api/_generated.py", line 8187, in wait_for_selector self._sync( File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/playwright/_impl /_sync_base.py", line 115, in _sync return task.result() ^^^^^^^^^^^^^ File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/playwright/_impl /_page.py", line 425, in wait_for_selector return await self._main_frame.wait_for_selector(**locals_to_params(locals()) ) ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ ^ File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/playwright/_impl /_frame.py", line 323, in wait_for_selector await self._channel.send("waitForSelector", locals_to_params(locals())) File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/playwright/_impl /_connection.py", line 61, in send return await self._connection.wrap_api_call( ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/playwright/_impl /_connection.py", line 528, in wrap_api_call raise rewrite_error(error, f"{parsed_st['apiName']}: {error}") from None playwright._impl._errors.TimeoutError: Page.wait_for_selector: Timeout 30000ms e xceeded. Call log: - waiting for locator("#chart path") ------------------------------ Captured log call ------------------------------- INFO werkzeug:_internal.py:97 127.0.0.1 - - [23/May/2025 02:17:49] "GET / HT TP/1.1" 200 - INFO werkzeug:_internal.py:97 127.0.0.1 - - [23/May/2025 02:17:49] "GET /js/ chip_input.js HTTP/1.1" 200 - INFO werkzeug:_internal.py:97 127.0.0.1 - - [23/May/2025 02:17:49] "GET /js/ timeseries_chart.js HTTP/1.1" 200 - INFO werkzeug:_internal.py:97 127.0.0.1 - - [23/May/2025 02:17:49] "GET /js/ time_column.js HTTP/1.1" 200 - INFO werkzeug:_internal.py:97 127.0.0.1 - - [23/May/2025 02:17:49] "GET /js/ sidebar.js HTTP/1.1" 200 - INFO werkzeug:_internal.py:97 127.0.0.1 - - [23/May/2025 02:17:49] "GET /js/ table_view.js HTTP/1.1" 200 - INFO werkzeug:_internal.py:97 127.0.0.1 - - [23/May/2025 02:17:49] "GET /js/ view_settings.js HTTP/1.1" 200 - INFO werkzeug:_internal.py:97 127.0.0.1 - - [23/May/2025 02:17:49] "GET /api /tables HTTP/1.1" 200 - INFO werkzeug:_internal.py:97 127.0.0.1 - - [23/May/2025 02:17:49] "GET /api /columns?table=events HTTP/1.1" 200 - INFO werkzeug:_internal.py:97 127.0.0.1 - - [23/May/2025 02:17:49] "POST /ap i/query HTTP/1.1" 200 - INFO werkzeug:_internal.py:97 127.0.0.1 - - [23/May/2025 02:17:50] "POST /ap i/query HTTP/1.1" 400 - =========================== short test summary info ============================ FAILED tests/test_web_timeseries.py::test_timeseries_default_query[chromium] - A ssertionError: assert 'error' not in {'error': 'Unknown column: Count'} FAILED tests/test_web_timeseries.py::test_timeseries_crosshair_freeze[chromium] - playwright._impl._errors.TimeoutError: Page.wait_for_selector: Timeout 3000... FAILED tests/test_web_timeseries.py::test_timeseries_single_bucket[chromium] - p laywright._impl._errors.TimeoutError: Page.get_attribute: Timeout 30000ms ... FAILED tests/test_web_timeseries.py::test_timeseries_axis_ticks[chromium] - play wright._impl._errors.TimeoutError: Page.wait_for_selector: Timeout 3000... FAILED tests/test_web_timeseries.py::test_timeseries_interval_offset[chromium] - playwright._impl._errors.TimeoutError: Page.wait_for_selector: Timeout 3000... FAILED tests/test_web_timeseries.py::test_timeseries_group_by_num[chromium] - pl aywright._impl._errors.TimeoutError: Page.wait_for_selector: Timeout 3000... FAILED tests/test_web_timeseries.py::test_timeseries_auto_timezone[chromium] - p laywright._impl._errors.TimeoutError: Page.get_attribute: Timeout 30000ms ... FAILED tests/test_web_timeseries.py::test_timeseries_fill_options[chromium] - pl aywright._impl._errors.TimeoutError: Page.get_attribute: Timeout 30000ms ... FAILED tests/test_web_timeseries.py::test_timeseries_y_axis_labels[chromium] - p laywright._impl._errors.TimeoutError: Page.wait_for_selector: Timeout 3000... FAILED tests/test_web_timeseries.py::test_timeseries_group_links[chromium] - pla ywright._impl._errors.TimeoutError: Page.text_content: Timeout 30000ms e... FAILED tests/test_web_timeseries.py::test_timeseries_hover_highlight[chromium] - playwright._impl._errors.TimeoutError: Page.wait_for_selector: Timeout 3000... FAILED tests/test_web_timeseries.py::test_timeseries_resize[chromium] - playwrig ht._impl._errors.TimeoutError: Page.wait_for_selector: Timeout 3000... FAILED tests/test_web_timeseries.py::test_timeseries_crosshair[chromium] - playw right._impl._errors.TimeoutError: Page.wait_for_selector: Timeout 3000... 13 failed, 104 passed in 138.28s (0:02:18) (scubaduck) bash-5.2#

Looks like 'Count' shouldn't be included when sending columns to the server. I'll modify collectParams.

shell
(scubaduck) bash-5.2# grep -n "collectParams" -n scubaduck/static/js/view_settin gs.js 658: const params = collectParams(); 687:function collectParams() {
(scubaduck) bash-5.2# sed -n '687,732p' scubaduck/static/js/view_settings.js function collectParams() { updateSelectedColumns(); const payload = { table: document.getElementById('table').value, time_column: document.getElementById('time_column').value, time_unit: document.getElementById('time_unit').value, start: document.getElementById('start').value, end: document.getElementById('end').value, order_by: document.getElementById('order_by').value, order_dir: orderDir, limit: parseInt(document.getElementById('limit').value, 10), columns: selectedColumns.filter(c => c !== 'Hits' && !derivedColumns.some(dc => dc.name === c) ), samples_columns: columnValues.samples.slice(), table_columns: columnValues.table.slice(), timeseries_columns: columnValues.timeseries.slice(), graph_type: graphTypeSel.value, filters: Array.from(document.querySelectorAll('#filters .filter')).map(f => { const chips = f.chips || []; const op = f.querySelector('.f-op').value; let value = null; if (op !== 'empty' && op !== '!empty') { value = chips.length === 0 ? null : (chips.length === 1 ? chips[0] : chi ps); } const colSel = f.querySelector('.f-col select') || f.querySelector('.f-col '); return {column: colSel.value, op, value}; }), derived_columns: Array.from(document.querySelectorAll('#derived_list .derive d')).map(d => ({ type: d.querySelector('.d-type').value, name: d.querySelector('.d-name').value, expr: d.querySelector('.d-expr').value, include: d.querySelector('.d-use').checked, })) }; if (graphTypeSel.value === 'table' || graphTypeSel.value === 'timeseries') { payload.group_by = groupBy.chips || []; payload.aggregate = document.getElementById('aggregate').value; payload.show_hits = document.getElementById('show_hits').checked; } if (graphTypeSel.value === 'timeseries') { const xval = document.getElementById('x_axis').value; if (xval) payload.x_axis = xval; payload.granularity = document.getElementById('granularity').value; payload.fill = document.getElementById('fill').value; } (scubaduck) bash-5.2#

I'll update collectParams to exclude both 'Hits' and 'Count' from the columns filter.

shell
(scubaduck) bash-5.2# apply_patch<<'PATCH' > *** Begin Patch > *** Update File: scubaduck/static/js/view_settings.js > @@ > - columns: selectedColumns.filter(c => > - c !== 'Hits' && !derivedColumns.some(dc => dc.name === c) > - ), > + columns: selectedColumns.filter( > + c => c !== 'Hits' && c !== 'Count' && !derivedColumns.some(dc => dc.nam e === c) > + ), > *** End Patch > PATCH Success. Updated the following files: M scubaduck/static/js/view_settings.js
(scubaduck) bash-5.2# ruff format scubaduck/static/js/view_settings.js tests/tes t_web_timeseries.py --silent (scubaduck) bash-5.2#

Time for another ruff check to keep things clean and tidy.

shell
(scubaduck) bash-5.2# ruff check All checks passed!
(scubaduck) bash-5.2# pyright
Traceback (most recent call last): File "/usr/lib/python3.12/urllib/request.py", line 1344, in do_open h.request(req.get_method(), req.selector, req.data, headers, File "/usr/lib/python3.12/http/client.py", line 1336, in request self._send_request(method, url, body, headers, encode_chunked) File "/usr/lib/python3.12/http/client.py", line 1382, in _send_request self.endheaders(body, encode_chunked=encode_chunked) File "/usr/lib/python3.12/http/client.py", line 1331, in endheaders self._send_output(message_body, encode_chunked=encode_chunked) File "/usr/lib/python3.12/http/client.py", line 1091, in _send_output self.send(msg) File "/usr/lib/python3.12/http/client.py", line 1035, in send self.connect() File "/usr/lib/python3.12/http/client.py", line 1470, in connect super().connect() File "/usr/lib/python3.12/http/client.py", line 1001, in connect self.sock = self._create_connection( ^^^^^^^^^^^^^^^^^^^^^^^^ File "/usr/lib/python3.12/socket.py", line 852, in create_connection raise exceptions[0] File "/usr/lib/python3.12/socket.py", line 837, in create_connection sock.connect(sa) OSError: [Errno 113] No route to host During handling of the above exception, another exception occurred: Traceback (most recent call last): File "<frozen runpy>", line 198, in _run_module_as_main File "<frozen runpy>", line 88, in _run_code File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/nodeenv.py", lin e 1548, in <module> main() File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/nodeenv.py", lin e 1119, in main args.node = get_last_stable_node_version() ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/nodeenv.py", lin e 1052, in get_last_stable_node_version return _get_versions_json()[0]['version'].lstrip('v') ^^^^^^^^^^^^^^^^^^^^ File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/nodeenv.py", lin e 1028, in _get_versions_json response = urlopen('%s/index.json' % src_base_url) ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/nodeenv.py", lin e 652, in urlopen return urllib2.urlopen(req) ^^^^^^^^^^^^^^^^^^^^ File "/usr/lib/python3.12/urllib/request.py", line 215, in urlopen return opener.open(url, data, timeout) ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ File "/usr/lib/python3.12/urllib/request.py", line 515, in open response = self._open(req, data) ^^^^^^^^^^^^^^^^^^^^^ File "/usr/lib/python3.12/urllib/request.py", line 532, in _open result = self._call_chain(self.handle_open, protocol, protocol + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ File "/usr/lib/python3.12/urllib/request.py", line 492, in _call_chain result = func(*args) ^^^^^^^^^^^ File "/usr/lib/python3.12/urllib/request.py", line 1392, in https_open return self.do_open(http.client.HTTPSConnection, req, ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ File "/usr/lib/python3.12/urllib/request.py", line 1347, in do_open raise URLError(err) urllib.error.URLError: <urlopen error [Errno 113] No route to host> Traceback (most recent call last): File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/pyright/node.py" , line 93, in _install_node_env subprocess.run(args, check=True) File "/usr/lib/python3.12/subprocess.py", line 571, in run raise CalledProcessError(retcode, process.args, subprocess.CalledProcessError: Command '['/workspace/scubaduck/.venv/bin/python' , '-m', 'nodeenv', '/root/.cache/pyright-python/nodeenv']' returned non-zero exi t status 1. The above exception was the direct cause of the following exception: Traceback (most recent call last): File "/workspace/scubaduck/.venv/bin/pyright", line 10, in <module> sys.exit(entrypoint()) ^^^^^^^^^^^^ File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/pyright/cli.py", line 31, in entrypoint sys.exit(main(sys.argv[1:])) ^^^^^^^^^^^^^^^^^^ File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/pyright/cli.py", line 18, in main return run(*args, **kwargs).returncode ^^^^^^^^^^^^^^^^^^^^ File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/pyright/cli.py", line 27, in run return node.run('node', str(script), *args, **kwargs) ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/pyright/node.py" , line 138, in run strategy = _resolve_strategy(target) ^^^^^^^^^^^^^^^^^^^^^^^^^ File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/pyright/node.py" , line 130, in _resolve_strategy return NodeenvStrategy(type='nodeenv', path=_ensure_node_env(target)) ^^^^^^^^^^^^^^^^^^^^^^^^ File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/pyright/node.py" , line 54, in _ensure_node_env _install_node_env() File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/pyright/node.py" , line 95, in _install_node_env raise RuntimeError( RuntimeError: nodeenv failed; for more reliable node.js binaries try `pip instal l pyright[nodejs]` (scubaduck) bash-5.2#

I'll attempt to rerun the tests now.

shell
(scubaduck) bash-5.2# pytest tests/test_web_timeseries.py::test_timeseries_defau lt_query -q
bringing up nodes...
. [100%] 1 passed in 2.69s (scubaduck) bash-5.2#

Time to run the tests that were failing before and see if they pass now.

shell
(scubaduck) bash-5.2# pytest tests/test_web_timeseries.py::test_timeseries_singl e_bucket -q
bringing up nodes...
. [100%] 1 passed in 3.09s
(scubaduck) bash-5.2# pytest tests/test_web_timeseries.py::test_timeseries_fill_ options -q
bringing up nodes...
. [100%] 1 passed in 3.63s
(scubaduck) bash-5.2# pytest tests/test_web_timeseries.py::test_timeseries_group _by_num -q
bringing up nodes...














F [100%] =================================== FAILURES =================================== ____________________ test_timeseries_group_by_num[chromium] ____________________ [gw0] linux -- Python 3.12.3 /workspace/scubaduck/.venv/bin/python Traceback (most recent call last): File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/_pytest/runner.p y", line 341, in from_call result: TResult | None = func() ^^^^^^ File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/_pytest/runner.p y", line 242, in <lambda> lambda: runtest_hook(item=item, **kwds), when=when, reraise=reraise ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/pluggy/_hooks.py ", line 512, in __call__ return self._hookexec(self.name, self._hookimpls.copy(), kwargs, firstresult ) ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ ^ File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/pluggy/_manager. py", line 120, in _hookexec return self._inner_hookexec(hook_name, methods, kwargs, firstresult) ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/pluggy/_callers. py", line 167, in _multicall raise exception File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/pluggy/_callers. py", line 139, in _multicall teardown.throw(exception) File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/_pytest/threadex ception.py", line 92, in pytest_runtest_call yield from thread_exception_runtest_hook() File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/_pytest/threadex ception.py", line 68, in thread_exception_runtest_hook yield File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/pluggy/_callers. py", line 139, in _multicall teardown.throw(exception) File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/_pytest/unraisab leexception.py", line 95, in pytest_runtest_call yield from unraisable_exception_runtest_hook() File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/_pytest/unraisab leexception.py", line 70, in unraisable_exception_runtest_hook yield File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/pluggy/_callers. py", line 139, in _multicall teardown.throw(exception) File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/_pytest/logging. py", line 846, in pytest_runtest_call yield from self._runtest_for(item, "call") File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/_pytest/logging. py", line 829, in _runtest_for yield File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/pluggy/_callers. py", line 139, in _multicall teardown.throw(exception) File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/_pytest/capture. py", line 898, in pytest_runtest_call return (yield) ^^^^^ File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/pluggy/_callers. py", line 139, in _multicall teardown.throw(exception) File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/_pytest/skipping .py", line 257, in pytest_runtest_call return (yield) ^^^^^ File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/pluggy/_callers. py", line 121, in _multicall res = hook_impl.function(*args) ^^^^^^^^^^^^^^^^^^^^^^^^^ File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/_pytest/runner.p y", line 174, in pytest_runtest_call item.runtest() File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/_pytest/python.p y", line 1627, in runtest self.ihook.pytest_pyfunc_call(pyfuncitem=self) File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/pluggy/_hooks.py ", line 512, in __call__ return self._hookexec(self.name, self._hookimpls.copy(), kwargs, firstresult ) ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ ^ File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/pluggy/_manager. py", line 120, in _hookexec return self._inner_hookexec(hook_name, methods, kwargs, firstresult) ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/pluggy/_callers. py", line 167, in _multicall raise exception File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/pluggy/_callers. py", line 121, in _multicall res = hook_impl.function(*args) ^^^^^^^^^^^^^^^^^^^^^^^^^ File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/_pytest/python.p y", line 159, in pytest_pyfunc_call result = testfunction(**testargs) ^^^^^^^^^^^^^^^^^^^^^^^^ File "/workspace/scubaduck/tests/test_web_timeseries.py", line 409, in test_ti meseries_group_by_num page.wait_for_selector("#chart path", state="attached") File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/playwright/sync_ api/_generated.py", line 8187, in wait_for_selector self._sync( File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/playwright/_impl /_sync_base.py", line 115, in _sync return task.result() ^^^^^^^^^^^^^ File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/playwright/_impl /_page.py", line 425, in wait_for_selector return await self._main_frame.wait_for_selector(**locals_to_params(locals()) ) ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ ^ File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/playwright/_impl /_frame.py", line 323, in wait_for_selector await self._channel.send("waitForSelector", locals_to_params(locals())) File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/playwright/_impl /_connection.py", line 61, in send return await self._connection.wrap_api_call( ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/playwright/_impl /_connection.py", line 528, in wrap_api_call raise rewrite_error(error, f"{parsed_st['apiName']}: {error}") from None playwright._impl._errors.TimeoutError: Page.wait_for_selector: Timeout 30000ms e xceeded. Call log: - waiting for locator("#chart path") ------------------------------ Captured log call ------------------------------- INFO werkzeug:_internal.py:97 127.0.0.1 - - [23/May/2025 02:19:06] "GET / HT TP/1.1" 200 - INFO werkzeug:_internal.py:97 127.0.0.1 - - [23/May/2025 02:19:06] "GET /js/ chip_input.js HTTP/1.1" 200 - INFO werkzeug:_internal.py:97 127.0.0.1 - - [23/May/2025 02:19:06] "GET /js/ timeseries_chart.js HTTP/1.1" 200 - INFO werkzeug:_internal.py:97 127.0.0.1 - - [23/May/2025 02:19:06] "GET /js/ time_column.js HTTP/1.1" 200 - INFO werkzeug:_internal.py:97 127.0.0.1 - - [23/May/2025 02:19:06] "GET /js/ sidebar.js HTTP/1.1" 200 - INFO werkzeug:_internal.py:97 127.0.0.1 - - [23/May/2025 02:19:06] "GET /js/ table_view.js HTTP/1.1" 200 - INFO werkzeug:_internal.py:97 127.0.0.1 - - [23/May/2025 02:19:06] "GET /js/ view_settings.js HTTP/1.1" 200 - INFO werkzeug:_internal.py:97 127.0.0.1 - - [23/May/2025 02:19:06] "GET /api /tables HTTP/1.1" 200 - INFO werkzeug:_internal.py:97 127.0.0.1 - - [23/May/2025 02:19:06] "GET /api /columns?table=events HTTP/1.1" 200 - INFO werkzeug:_internal.py:97 127.0.0.1 - - [23/May/2025 02:19:06] "POST /ap i/query HTTP/1.1" 200 - INFO werkzeug:_internal.py:97 127.0.0.1 - - [23/May/2025 02:19:06] "GET /api /columns?table=extra HTTP/1.1" 200 - INFO werkzeug:_internal.py:97 127.0.0.1 - - [23/May/2025 02:19:06] "POST /ap i/query HTTP/1.1" 400 - =========================== short test summary info ============================ FAILED tests/test_web_timeseries.py::test_timeseries_group_by_num[chromium] - pl aywright._impl._errors.TimeoutError: Page.wait_for_selector: Timeout 3000... 1 failed in 32.83s (scubaduck) bash-5.2#

It looks like I missed waiting for columns to load after selecting the “extra” table.

I'll manually run the test to debug and replicate with the new patch.

shell
(scubaduck) bash-5.2# python - <<'PY' > from playwright.sync_api import sync_playwright > from tests.web_utils import select_value > from scubaduck.server import create_app > from werkzeug.serving import make_server > import threading > > app = create_app('TEST') > httpd = make_server('127.0.0.1', 0, app) > port = httpd.server_port > th = threading.Thread(target=httpd.serve_forever) > th.start() > url = f"http://127.0.0.1:{port}" > > with sync_playwright() as p: > browser = p.chromium.launch() > page = browser.new_page() > page.goto(url) > page.wait_for_selector('#table option', state='attached') > select_value(page, '#table', 'extra') > page.wait_for_selector('#graph_type', state='attached') > select_value(page, '#graph_type', 'timeseries') > page.evaluate("groupBy.chips=['num'];groupBy.renderChips();") > page.evaluate("window.lastResults=undefined") > page.click("text=Dive") > page.wait_for_function("window.lastResults !== undefined") > print('data', page.evaluate('window.lastResults')) > page.wait_for_selector('#chart path', state='attached', timeout=5000) > print('path count', page.eval_on_selector_all('#chart path', 'els=>els.len gth')) > browser.close() > > httpd.shutdown() > th.join() > PY
127.0.0.1 - - [23/May/2025 02:19:50] "GET / HTTP/1.1" 200 - 127.0.0.1 - - [23/May/2025 02:19:50] "GET /js/chip_input.js HTTP/1.1" 200 - 127.0.0.1 - - [23/May/2025 02:19:50] "GET /js/timeseries_chart.js HTTP/1.1" 200 - 127.0.0.1 - - [23/May/2025 02:19:50] "GET /js/time_column.js HTTP/1.1" 200 - 127.0.0.1 - - [23/May/2025 02:19:50] "GET /js/sidebar.js HTTP/1.1" 200 - 127.0.0.1 - - [23/May/2025 02:19:50] "GET /js/table_view.js HTTP/1.1" 200 - 127.0.0.1 - - [23/May/2025 02:19:50] "GET /js/view_settings.js HTTP/1.1" 200 - 127.0.0.1 - - [23/May/2025 02:19:50] "GET /api/tables HTTP/1.1" 200 - 127.0.0.1 - - [23/May/2025 02:19:50] "GET /api/columns?table=events HTTP/1.1" 20 0 - 127.0.0.1 - - [23/May/2025 02:19:50] "POST /api/query HTTP/1.1" 200 - 127.0.0.1 - - [23/May/2025 02:19:50] "GET /api/columns?table=extra HTTP/1.1" 200 - 127.0.0.1 - - [23/May/2025 02:19:50] "POST /api/query HTTP/1.1" 400 - data {'error': 'Invalid x_axis'}

The issue comes from the x_axis not updating after switching tables. I'll wait for the 'extra' table's columns to load before setting the x_axis. Then I'll adjust the test and make sure the 'timestamp' bug is fixed.

shell
Traceback (most recent call last): File "<stdin>", line 27, in <module> File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/playwright/sync_ api/_generated.py", line 8187, in wait_for_selector self._sync( File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/playwright/_impl /_sync_base.py", line 115, in _sync return task.result() ^^^^^^^^^^^^^ File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/playwright/_impl /_page.py", line 425, in wait_for_selector return await self._main_frame.wait_for_selector(**locals_to_params(locals()) ) ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ ^ File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/playwright/_impl /_frame.py", line 323, in wait_for_selector await self._channel.send("waitForSelector", locals_to_params(locals())) File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/playwright/_impl /_connection.py", line 61, in send return await self._connection.wrap_api_call( ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/playwright/_impl /_connection.py", line 528, in wrap_api_call raise rewrite_error(error, f"{parsed_st['apiName']}: {error}") from None playwright._impl._errors.TimeoutError: Page.wait_for_selector: Timeout 5000ms ex ceeded. Call log: - waiting for locator("#chart path") sed -n '388,420p' tests/test_web_timeseries.py

I'll display lines around 400 for the patch.

shell
sed -n '392,418p' tests/test_web_timeseries.py

^CException ignored in: <module 'threading' from '/usr/lib/python3.12/threading. py'> Traceback (most recent call last): File "/usr/lib/python3.12/threading.py", line 1622, in _shutdown lock.acquire() KeyboardInterrupt: (scubaduck) bash-5.2#

It looks like I might have accidentally cut off the script. Let me open it again.

shell
(scubaduck) bash-5.2# sed -n '392,420p' tests/test_web_timeseries.py page.click("#legend .drill-links a:text('user')") page.wait_for_function("window.lastResults !== undefined") chips = page.evaluate("groupBy.chips") assert chips == ["user"] assert page.text_content("#legend .drill-links h4") == "Drill up" assert page.is_visible("#legend .drill-links a:text('Aggregate')") def test_timeseries_group_by_num(page: Any, test_dataset_server_url: str) -> Non e: page.goto(test_dataset_server_url) page.wait_for_selector("#graph_type", state="attached") select_value(page, "#table", "extra") select_value(page, "#graph_type", "timeseries") page.evaluate("groupBy.chips = ['num']; groupBy.renderChips();") page.evaluate("window.lastResults = undefined") page.click("text=Dive") page.wait_for_function("window.lastResults !== undefined") page.wait_for_selector("#chart path", state="attached") count = page.eval_on_selector_all("#chart path", "els => els.length") assert count == 2 (scubaduck) bash-5.2#

I'll patch to wait for the '#x_axis option[value="ts"]' and set x_axis.

shell
> *** Begin Patch > *** Update File: tests/test_web_timeseries.py > @@ > def test_timeseries_group_by_num(page: Any, test_dataset_server_url: str) -> None: > page.goto(test_dataset_server_url) > page.wait_for_selector("#graph_type", state="attached") > - select_value(page, "#table", "extra") > - select_value(page, "#graph_type", "timeseries") > + select_value(page, "#table", "extra") > + page.wait_for_selector("#x_axis option[value='ts']", state="attached") > + select_value(page, "#graph_type", "timeseries") > + select_value(page, "#x_axis", "ts") > page.evaluate("groupBy.chips = ['num']; groupBy.renderChips();") > *** End Patch > PATCH Success. Updated the following files: M tests/test_web_timeseries.py
(scubaduck) bash-5.2# ruff format tests/test_web_timeseries.py scubaduck/static/ js/view_settings.js --silent
(scubaduck) bash-5.2# ruff check All checks passed! (scubaduck) bash-5.2#

I'll quickly run pyright, but it’s likely going to fail.

shell
(scubaduck) bash-5.2# pyright >/tmp/pyright.log && tail -n 20 /tmp/pyright.log

Traceback (most recent call last): File "/usr/lib/python3.12/urllib/request.py", line 1344, in do_open h.request(req.get_method(), req.selector, req.data, headers, File "/usr/lib/python3.12/http/client.py", line 1336, in request self._send_request(method, url, body, headers, encode_chunked) File "/usr/lib/python3.12/http/client.py", line 1382, in _send_request self.endheaders(body, encode_chunked=encode_chunked) File "/usr/lib/python3.12/http/client.py", line 1331, in endheaders self._send_output(message_body, encode_chunked=encode_chunked) File "/usr/lib/python3.12/http/client.py", line 1091, in _send_output self.send(msg) File "/usr/lib/python3.12/http/client.py", line 1035, in send self.connect() File "/usr/lib/python3.12/http/client.py", line 1470, in connect super().connect() File "/usr/lib/python3.12/http/client.py", line 1001, in connect self.sock = self._create_connection( ^^^^^^^^^^^^^^^^^^^^^^^^ File "/usr/lib/python3.12/socket.py", line 852, in create_connection raise exceptions[0] File "/usr/lib/python3.12/socket.py", line 837, in create_connection sock.connect(sa) OSError: [Errno 113] No route to host During handling of the above exception, another exception occurred: Traceback (most recent call last): File "<frozen runpy>", line 198, in _run_module_as_main File "<frozen runpy>", line 88, in _run_code File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/nodeenv.py", lin e 1548, in <module> main() File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/nodeenv.py", lin e 1119, in main args.node = get_last_stable_node_version() ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/nodeenv.py", lin e 1052, in get_last_stable_node_version return _get_versions_json()[0]['version'].lstrip('v') ^^^^^^^^^^^^^^^^^^^^ File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/nodeenv.py", lin e 1028, in _get_versions_json response = urlopen('%s/index.json' % src_base_url) ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/nodeenv.py", lin e 652, in urlopen return urllib2.urlopen(req) ^^^^^^^^^^^^^^^^^^^^ File "/usr/lib/python3.12/urllib/request.py", line 215, in urlopen return opener.open(url, data, timeout) ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ File "/usr/lib/python3.12/urllib/request.py", line 515, in open response = self._open(req, data) ^^^^^^^^^^^^^^^^^^^^^ File "/usr/lib/python3.12/urllib/request.py", line 532, in _open result = self._call_chain(self.handle_open, protocol, protocol + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ File "/usr/lib/python3.12/urllib/request.py", line 492, in _call_chain result = func(*args) ^^^^^^^^^^^ File "/usr/lib/python3.12/urllib/request.py", line 1392, in https_open return self.do_open(http.client.HTTPSConnection, req, ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ File "/usr/lib/python3.12/urllib/request.py", line 1347, in do_open raise URLError(err) urllib.error.URLError: <urlopen error [Errno 113] No route to host> Traceback (most recent call last): File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/pyright/node.py" , line 93, in _install_node_env subprocess.run(args, check=True) File "/usr/lib/python3.12/subprocess.py", line 571, in run raise CalledProcessError(retcode, process.args, subprocess.CalledProcessError: Command '['/workspace/scubaduck/.venv/bin/python' , '-m', 'nodeenv', '/root/.cache/pyright-python/nodeenv']' returned non-zero exi t status 1. The above exception was the direct cause of the following exception: Traceback (most recent call last): File "/workspace/scubaduck/.venv/bin/pyright", line 10, in <module> sys.exit(entrypoint()) ^^^^^^^^^^^^ File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/pyright/cli.py", line 31, in entrypoint sys.exit(main(sys.argv[1:])) ^^^^^^^^^^^^^^^^^^ File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/pyright/cli.py", line 18, in main return run(*args, **kwargs).returncode ^^^^^^^^^^^^^^^^^^^^ File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/pyright/cli.py", line 27, in run return node.run('node', str(script), *args, **kwargs) ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/pyright/node.py" , line 138, in run strategy = _resolve_strategy(target) ^^^^^^^^^^^^^^^^^^^^^^^^^ File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/pyright/node.py" , line 130, in _resolve_strategy return NodeenvStrategy(type='nodeenv', path=_ensure_node_env(target)) ^^^^^^^^^^^^^^^^^^^^^^^^ File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/pyright/node.py" , line 54, in _ensure_node_env _install_node_env() File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/pyright/node.py" , line 95, in _install_node_env raise RuntimeError( RuntimeError: nodeenv failed; for more reliable node.js binaries try `pip instal l pyright[nodejs]` (scubaduck) bash-5.2#

Time to try the test once more after applying the patch.

shell
(scubaduck) bash-5.2# pytest tests/test_web_timeseries.py::test_timeseries_group _by_num -q
bringing up nodes...













F [100%] =================================== FAILURES =================================== ____________________ test_timeseries_group_by_num[chromium] ____________________ [gw0] linux -- Python 3.12.3 /workspace/scubaduck/.venv/bin/python Traceback (most recent call last): File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/_pytest/runner.p y", line 341, in from_call result: TResult | None = func() ^^^^^^ File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/_pytest/runner.p y", line 242, in <lambda> lambda: runtest_hook(item=item, **kwds), when=when, reraise=reraise ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/pluggy/_hooks.py ", line 512, in __call__ return self._hookexec(self.name, self._hookimpls.copy(), kwargs, firstresult ) ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ ^ File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/pluggy/_manager. py", line 120, in _hookexec return self._inner_hookexec(hook_name, methods, kwargs, firstresult) ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/pluggy/_callers. py", line 167, in _multicall raise exception File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/pluggy/_callers. py", line 139, in _multicall teardown.throw(exception) File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/_pytest/threadex ception.py", line 92, in pytest_runtest_call yield from thread_exception_runtest_hook() File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/_pytest/threadex ception.py", line 68, in thread_exception_runtest_hook yield File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/pluggy/_callers. py", line 139, in _multicall teardown.throw(exception) File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/_pytest/unraisab leexception.py", line 95, in pytest_runtest_call yield from unraisable_exception_runtest_hook() File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/_pytest/unraisab leexception.py", line 70, in unraisable_exception_runtest_hook yield File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/pluggy/_callers. py", line 139, in _multicall teardown.throw(exception) File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/_pytest/logging. py", line 846, in pytest_runtest_call yield from self._runtest_for(item, "call") File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/_pytest/logging. py", line 829, in _runtest_for yield File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/pluggy/_callers. py", line 139, in _multicall teardown.throw(exception) File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/_pytest/capture. py", line 898, in pytest_runtest_call return (yield) ^^^^^ File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/pluggy/_callers. py", line 139, in _multicall teardown.throw(exception) File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/_pytest/skipping .py", line 257, in pytest_runtest_call return (yield) ^^^^^ File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/pluggy/_callers. py", line 121, in _multicall res = hook_impl.function(*args) ^^^^^^^^^^^^^^^^^^^^^^^^^ File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/_pytest/runner.p y", line 174, in pytest_runtest_call item.runtest() File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/_pytest/python.p y", line 1627, in runtest self.ihook.pytest_pyfunc_call(pyfuncitem=self) File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/pluggy/_hooks.py ", line 512, in __call__ return self._hookexec(self.name, self._hookimpls.copy(), kwargs, firstresult ) ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ ^ File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/pluggy/_manager. py", line 120, in _hookexec return self._inner_hookexec(hook_name, methods, kwargs, firstresult) ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/pluggy/_callers. py", line 167, in _multicall raise exception File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/pluggy/_callers. py", line 121, in _multicall res = hook_impl.function(*args) ^^^^^^^^^^^^^^^^^^^^^^^^^ File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/_pytest/python.p y", line 159, in pytest_pyfunc_call result = testfunction(**testargs) ^^^^^^^^^^^^^^^^^^^^^^^^ File "/workspace/scubaduck/tests/test_web_timeseries.py", line 404, in test_ti meseries_group_by_num page.wait_for_selector("#x_axis option[value='ts']", state="attached") File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/playwright/sync_ api/_generated.py", line 8187, in wait_for_selector self._sync( File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/playwright/_impl /_sync_base.py", line 115, in _sync return task.result() ^^^^^^^^^^^^^ File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/playwright/_impl /_page.py", line 425, in wait_for_selector return await self._main_frame.wait_for_selector(**locals_to_params(locals()) ) ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ ^ File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/playwright/_impl /_frame.py", line 323, in wait_for_selector await self._channel.send("waitForSelector", locals_to_params(locals())) File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/playwright/_impl /_connection.py", line 61, in send return await self._connection.wrap_api_call( ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/playwright/_impl /_connection.py", line 528, in wrap_api_call raise rewrite_error(error, f"{parsed_st['apiName']}: {error}") from None playwright._impl._errors.TimeoutError: Page.wait_for_selector: Timeout 30000ms e xceeded. Call log: - waiting for locator("#x_axis option[value='ts']") ------------------------------ Captured log call ------------------------------- INFO werkzeug:_internal.py:97 127.0.0.1 - - [23/May/2025 02:20:22] "GET / HT TP/1.1" 200 - INFO werkzeug:_internal.py:97 127.0.0.1 - - [23/May/2025 02:20:22] "GET /js/ chip_input.js HTTP/1.1" 200 - INFO werkzeug:_internal.py:97 127.0.0.1 - - [23/May/2025 02:20:22] "GET /js/ timeseries_chart.js HTTP/1.1" 200 - INFO werkzeug:_internal.py:97 127.0.0.1 - - [23/May/2025 02:20:22] "GET /js/ time_column.js HTTP/1.1" 200 - INFO werkzeug:_internal.py:97 127.0.0.1 - - [23/May/2025 02:20:22] "GET /js/ sidebar.js HTTP/1.1" 200 - INFO werkzeug:_internal.py:97 127.0.0.1 - - [23/May/2025 02:20:22] "GET /js/ table_view.js HTTP/1.1" 200 - INFO werkzeug:_internal.py:97 127.0.0.1 - - [23/May/2025 02:20:22] "GET /js/ view_settings.js HTTP/1.1" 200 - INFO werkzeug:_internal.py:97 127.0.0.1 - - [23/May/2025 02:20:22] "GET /api /tables HTTP/1.1" 200 - INFO werkzeug:_internal.py:97 127.0.0.1 - - [23/May/2025 02:20:22] "GET /api /columns?table=events HTTP/1.1" 200 - INFO werkzeug:_internal.py:97 127.0.0.1 - - [23/May/2025 02:20:22] "POST /ap i/query HTTP/1.1" 200 - INFO werkzeug:_internal.py:97 127.0.0.1 - - [23/May/2025 02:20:22] "GET /api /columns?table=extra HTTP/1.1" 200 - =========================== short test summary info ============================ FAILED tests/test_web_timeseries.py::test_timeseries_group_by_num[chromium] - pl aywright._impl._errors.TimeoutError: Page.wait_for_selector: Timeout 3000... 1 failed in 32.45s

It seems like the "ts" option isn't available until the columns load. I'll update the wait strategy.

shell
(scubaduck) bash-5.2# apply_patch<<'PATCH' > *** Begin Patch > *** Update File: tests/test_web_timeseries.py > @@ > - select_value(page, "#table", "extra") > - page.wait_for_selector("#x_axis option[value='ts']", state="attached") > + select_value(page, "#table", "extra") > + page.wait_for_function("document.querySelector('#x_axis option[value=\'ts \']') !== null") > select_value(page, "#graph_type", "timeseries") > select_value(page, "#x_axis", "ts") > *** End Patch > PATCH Success. Updated the following files: M tests/test_web_timeseries.py
(scubaduck) bash-5.2# ruff format tests/test_web_timeseries.py scubaduck/static/ js/view_settings.js --silent
(scubaduck) bash-5.2# ruff check All checks passed! (scubaduck) bash-5.2#

I'll give the test another go to see if I can get a different result.

shell
(scubaduck) bash-5.2# pytest tests/test_web_timeseries.py::test_timeseries_group _by_num -q
bringing up nodes...
F [100%] =================================== FAILURES =================================== ____________________ test_timeseries_group_by_num[chromium] ____________________ [gw0] linux -- Python 3.12.3 /workspace/scubaduck/.venv/bin/python Traceback (most recent call last): File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/_pytest/runner.p y", line 341, in from_call result: TResult | None = func() ^^^^^^ File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/_pytest/runner.p y", line 242, in <lambda> lambda: runtest_hook(item=item, **kwds), when=when, reraise=reraise ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/pluggy/_hooks.py ", line 512, in __call__ return self._hookexec(self.name, self._hookimpls.copy(), kwargs, firstresult ) ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ ^ File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/pluggy/_manager. py", line 120, in _hookexec return self._inner_hookexec(hook_name, methods, kwargs, firstresult) ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/pluggy/_callers. py", line 167, in _multicall raise exception File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/pluggy/_callers. py", line 139, in _multicall teardown.throw(exception) File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/_pytest/threadex ception.py", line 92, in pytest_runtest_call yield from thread_exception_runtest_hook() File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/_pytest/threadex ception.py", line 68, in thread_exception_runtest_hook yield File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/pluggy/_callers. py", line 139, in _multicall teardown.throw(exception) File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/_pytest/unraisab leexception.py", line 95, in pytest_runtest_call yield from unraisable_exception_runtest_hook() File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/_pytest/unraisab leexception.py", line 70, in unraisable_exception_runtest_hook yield File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/pluggy/_callers. py", line 139, in _multicall teardown.throw(exception) File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/_pytest/logging. py", line 846, in pytest_runtest_call yield from self._runtest_for(item, "call") File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/_pytest/logging. py", line 829, in _runtest_for yield File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/pluggy/_callers. py", line 139, in _multicall teardown.throw(exception) File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/_pytest/capture. py", line 898, in pytest_runtest_call return (yield) ^^^^^ File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/pluggy/_callers. py", line 139, in _multicall teardown.throw(exception) File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/_pytest/skipping .py", line 257, in pytest_runtest_call return (yield) ^^^^^ File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/pluggy/_callers. py", line 121, in _multicall res = hook_impl.function(*args) ^^^^^^^^^^^^^^^^^^^^^^^^^ File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/_pytest/runner.p y", line 174, in pytest_runtest_call item.runtest() File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/_pytest/python.p y", line 1627, in runtest self.ihook.pytest_pyfunc_call(pyfuncitem=self) File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/pluggy/_hooks.py ", line 512, in __call__ return self._hookexec(self.name, self._hookimpls.copy(), kwargs, firstresult ) ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ ^ File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/pluggy/_manager. py", line 120, in _hookexec return self._inner_hookexec(hook_name, methods, kwargs, firstresult) ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/pluggy/_callers. py", line 167, in _multicall raise exception File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/pluggy/_callers. py", line 121, in _multicall res = hook_impl.function(*args) ^^^^^^^^^^^^^^^^^^^^^^^^^ File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/_pytest/python.p y", line 159, in pytest_pyfunc_call result = testfunction(**testargs) ^^^^^^^^^^^^^^^^^^^^^^^^ File "/workspace/scubaduck/tests/test_web_timeseries.py", line 404, in test_ti meseries_group_by_num page.wait_for_function( File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/playwright/sync_ api/_generated.py", line 11564, in wait_for_function self._sync( File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/playwright/_impl /_sync_base.py", line 115, in _sync return task.result() ^^^^^^^^^^^^^ File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/playwright/_impl /_page.py", line 1088, in wait_for_function return await self._main_frame.wait_for_function(**locals_to_params(locals()) ) ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ ^ File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/playwright/_impl /_frame.py", line 771, in wait_for_function return from_channel(await self._channel.send("waitForFunction", params)) ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/playwright/_impl /_connection.py", line 61, in send return await self._connection.wrap_api_call( ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/playwright/_impl /_connection.py", line 528, in wrap_api_call raise rewrite_error(error, f"{parsed_st['apiName']}: {error}") from None playwright._impl._errors.Error: Page.wait_for_function: SyntaxError: missing ) a fter argument list at eval (<anonymous>) at predicate (eval at evaluate (:313:29), <anonymous>:3:38) at next (eval at evaluate (:313:29), <anonymous>:25:31) at eval (eval at evaluate (:313:29), <anonymous>:38:11) at UtilityScript.evaluate (<anonymous>:315:16) at UtilityScript.<anonymous> (<anonymous>:1:44) ------------------------------ Captured log call ------------------------------- INFO werkzeug:_internal.py:97 127.0.0.1 - - [23/May/2025 02:21:07] "GET / HT TP/1.1" 200 - INFO werkzeug:_internal.py:97 127.0.0.1 - - [23/May/2025 02:21:07] "GET /js/ chip_input.js HTTP/1.1" 200 - INFO werkzeug:_internal.py:97 127.0.0.1 - - [23/May/2025 02:21:07] "GET /js/ timeseries_chart.js HTTP/1.1" 200 - INFO werkzeug:_internal.py:97 127.0.0.1 - - [23/May/2025 02:21:07] "GET /js/ time_column.js HTTP/1.1" 200 - INFO werkzeug:_internal.py:97 127.0.0.1 - - [23/May/2025 02:21:07] "GET /js/ sidebar.js HTTP/1.1" 200 - INFO werkzeug:_internal.py:97 127.0.0.1 - - [23/May/2025 02:21:07] "GET /js/ table_view.js HTTP/1.1" 200 - INFO werkzeug:_internal.py:97 127.0.0.1 - - [23/May/2025 02:21:07] "GET /js/ view_settings.js HTTP/1.1" 200 - INFO werkzeug:_internal.py:97 127.0.0.1 - - [23/May/2025 02:21:07] "GET /api /tables HTTP/1.1" 200 - INFO werkzeug:_internal.py:97 127.0.0.1 - - [23/May/2025 02:21:07] "GET /api /columns?table=events HTTP/1.1" 200 - INFO werkzeug:_internal.py:97 127.0.0.1 - - [23/May/2025 02:21:07] "POST /ap i/query HTTP/1.1" 200 - INFO werkzeug:_internal.py:97 127.0.0.1 - - [23/May/2025 02:21:07] "GET /api /columns?table=extra HTTP/1.1" 200 - =========================== short test summary info ============================ FAILED tests/test_web_timeseries.py::test_timeseries_group_by_num[chromium] - pl aywright._impl._errors.Error: Page.wait_for_function: SyntaxError: missin... 1 failed in 2.81s (scubaduck) bash-5.2#

I'll replace the single quotes with double quotes and escape the inner quotation marks.

shell
(scubaduck) bash-5.2# apply_patch<<'PATCH' > *** Begin Patch > *** Update File: tests/test_web_timeseries.py > @@ > - page.wait_for_function("document.querySelector('#x_axis option[value=\'ts \']') !== null") > + page.wait_for_function("document.querySelector('#x_axis option[value=\"ts \"]') !== null") > *** End Patch > PATCH Failed to find expected lines [" page.wait_for_function(\"document.querySelec tor('#x_axis option[value=\\'ts\\']') !== null\")"] in tests/test_web_timeseries .py (scubaduck) bash-5.2#

Looks like the patch failed due to quoting. I’ll open the snippet to fix it.

shell
(scubaduck) bash-5.2# grep -n "wait_for_function" -n tests/test_web_timeseries.p y | tail 268: page.wait_for_function( 290: page.wait_for_function("window.lastResults !== undefined") 306: page.wait_for_function("window.lastResults !== undefined") 321: page.wait_for_function("window.lastResults !== undefined") 340: page.wait_for_function("window.lastResults !== undefined") 365: page.wait_for_function("window.lastResults !== undefined") 390: page.wait_for_function("window.lastResults !== undefined") 393: page.wait_for_function("window.lastResults !== undefined") 404: page.wait_for_function( 412: page.wait_for_function("window.lastResults !== undefined") (scubaduck) bash-5.2#

I’ll focus on lines 399 to 409 to make the necessary patch.

shell
(scubaduck) bash-5.2# sed -n '398,408p' tests/test_web_timeseries.py def test_timeseries_group_by_num(page: Any, test_dataset_server_url: str) -> Non e: page.goto(test_dataset_server_url) page.wait_for_selector("#graph_type", state="attached") select_value(page, "#table", "extra") page.wait_for_function( "document.querySelector('#x_axis option[value='ts']') !== null" ) select_value(page, "#graph_type", "timeseries") select_value(page, "#x_axis", "ts") (scubaduck) bash-5.2#

I'll switch to double quotes around 'ts' to fix the escaping issue.

shell
> *** Begin Patch > *** Update File: tests/test_web_timeseries.py > @@ > - page.wait_for_function( > - "document.querySelector('#x_axis option[value='ts']') !== null" > - ) > + page.wait_for_function( > + "document.querySelector('#x_axis option[value=\"ts\"]') !== null" > + ) > *** End Patch > PATCH Success. Updated the following files: M tests/test_web_timeseries.py
(scubaduck) bash-5.2# ruff format tests/test_web_timeseries.py scubaduck/static/ js/view_settings.js --silent
(scubaduck) bash-5.2# ruff check All checks passed!
(scubaduck) bash-5.2# pytest tests/test_web_timeseries.py::test_timeseries_group _by_num -q
bringing up nodes...













F [100%] =================================== FAILURES =================================== ____________________ test_timeseries_group_by_num[chromium] ____________________ [gw0] linux -- Python 3.12.3 /workspace/scubaduck/.venv/bin/python Traceback (most recent call last): File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/_pytest/runner.p y", line 341, in from_call result: TResult | None = func() ^^^^^^ File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/_pytest/runner.p y", line 242, in <lambda> lambda: runtest_hook(item=item, **kwds), when=when, reraise=reraise ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/pluggy/_hooks.py ", line 512, in __call__ return self._hookexec(self.name, self._hookimpls.copy(), kwargs, firstresult ) ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ ^ File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/pluggy/_manager. py", line 120, in _hookexec return self._inner_hookexec(hook_name, methods, kwargs, firstresult) ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/pluggy/_callers. py", line 167, in _multicall raise exception File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/pluggy/_callers. py", line 139, in _multicall teardown.throw(exception) File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/_pytest/threadex ception.py", line 92, in pytest_runtest_call yield from thread_exception_runtest_hook() File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/_pytest/threadex ception.py", line 68, in thread_exception_runtest_hook yield File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/pluggy/_callers. py", line 139, in _multicall teardown.throw(exception) File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/_pytest/unraisab leexception.py", line 95, in pytest_runtest_call yield from unraisable_exception_runtest_hook() File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/_pytest/unraisab leexception.py", line 70, in unraisable_exception_runtest_hook yield File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/pluggy/_callers. py", line 139, in _multicall teardown.throw(exception) File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/_pytest/logging. py", line 846, in pytest_runtest_call yield from self._runtest_for(item, "call") File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/_pytest/logging. py", line 829, in _runtest_for yield File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/pluggy/_callers. py", line 139, in _multicall teardown.throw(exception) File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/_pytest/capture. py", line 898, in pytest_runtest_call return (yield) ^^^^^ File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/pluggy/_callers. py", line 139, in _multicall teardown.throw(exception) File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/_pytest/skipping .py", line 257, in pytest_runtest_call return (yield) ^^^^^ File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/pluggy/_callers. py", line 121, in _multicall res = hook_impl.function(*args) ^^^^^^^^^^^^^^^^^^^^^^^^^ File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/_pytest/runner.p y", line 174, in pytest_runtest_call item.runtest() File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/_pytest/python.p y", line 1627, in runtest self.ihook.pytest_pyfunc_call(pyfuncitem=self) File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/pluggy/_hooks.py ", line 512, in __call__ return self._hookexec(self.name, self._hookimpls.copy(), kwargs, firstresult ) ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ ^ File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/pluggy/_manager. py", line 120, in _hookexec return self._inner_hookexec(hook_name, methods, kwargs, firstresult) ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/pluggy/_callers. py", line 167, in _multicall raise exception File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/pluggy/_callers. py", line 121, in _multicall res = hook_impl.function(*args) ^^^^^^^^^^^^^^^^^^^^^^^^^ File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/_pytest/python.p y", line 159, in pytest_pyfunc_call result = testfunction(**testargs) ^^^^^^^^^^^^^^^^^^^^^^^^ File "/workspace/scubaduck/tests/test_web_timeseries.py", line 404, in test_ti meseries_group_by_num page.wait_for_function( File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/playwright/sync_ api/_generated.py", line 11564, in wait_for_function self._sync( File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/playwright/_impl /_sync_base.py", line 115, in _sync return task.result() ^^^^^^^^^^^^^ File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/playwright/_impl /_page.py", line 1088, in wait_for_function return await self._main_frame.wait_for_function(**locals_to_params(locals()) ) ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ ^ File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/playwright/_impl /_frame.py", line 771, in wait_for_function return from_channel(await self._channel.send("waitForFunction", params)) ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/playwright/_impl /_connection.py", line 61, in send return await self._connection.wrap_api_call( ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/playwright/_impl /_connection.py", line 528, in wrap_api_call raise rewrite_error(error, f"{parsed_st['apiName']}: {error}") from None playwright._impl._errors.TimeoutError: Page.wait_for_function: Timeout 30000ms e xceeded. ------------------------------ Captured log call ------------------------------- INFO werkzeug:_internal.py:97 127.0.0.1 - - [23/May/2025 02:21:33] "GET / HT TP/1.1" 200 - INFO werkzeug:_internal.py:97 127.0.0.1 - - [23/May/2025 02:21:33] "GET /js/ chip_input.js HTTP/1.1" 200 - INFO werkzeug:_internal.py:97 127.0.0.1 - - [23/May/2025 02:21:33] "GET /js/ timeseries_chart.js HTTP/1.1" 200 - INFO werkzeug:_internal.py:97 127.0.0.1 - - [23/May/2025 02:21:33] "GET /js/ time_column.js HTTP/1.1" 200 - INFO werkzeug:_internal.py:97 127.0.0.1 - - [23/May/2025 02:21:33] "GET /js/ sidebar.js HTTP/1.1" 200 - INFO werkzeug:_internal.py:97 127.0.0.1 - - [23/May/2025 02:21:33] "GET /js/ table_view.js HTTP/1.1" 200 - INFO werkzeug:_internal.py:97 127.0.0.1 - - [23/May/2025 02:21:33] "GET /js/ view_settings.js HTTP/1.1" 200 - INFO werkzeug:_internal.py:97 127.0.0.1 - - [23/May/2025 02:21:33] "GET /api /tables HTTP/1.1" 200 - INFO werkzeug:_internal.py:97 127.0.0.1 - - [23/May/2025 02:21:33] "GET /api /columns?table=events HTTP/1.1" 200 - INFO werkzeug:_internal.py:97 127.0.0.1 - - [23/May/2025 02:21:33] "POST /ap i/query HTTP/1.1" 200 - INFO werkzeug:_internal.py:97 127.0.0.1 - - [23/May/2025 02:21:33] "GET /api /columns?table=extra HTTP/1.1" 200 - =========================== short test summary info ============================ FAILED tests/test_web_timeseries.py::test_timeseries_group_by_num[chromium] - pl aywright._impl._errors.TimeoutError: Page.wait_for_function: Timeout 3000... 1 failed in 32.61s (scubaduck) bash-5.2#

Hmm, the quoting in wait_for_function might be causing a syntax error. Let me inspect line 404.

shell
(scubaduck) bash-5.2# sed -n '402,407p' tests/test_web_timeseries.py page.wait_for_selector("#graph_type", state="attached") select_value(page, "#table", "extra") page.wait_for_function( "document.querySelector('#x_axis option[value=\"ts\"]') !== null" ) select_value(page, "#graph_type", "timeseries") (scubaduck) bash-5.2#

I'm considering whether the syntax issue comes from quoting or the arrow function. Let’s try wait_for_selector with a longer timeout to avoid previous timeouts.

shell
(scubaduck) bash-5.2# apply_patch<<'PATCH' > *** Begin Patch > *** Update File: tests/test_web_timeseries.py > @@ > - page.wait_for_function( > - "document.querySelector('#x_axis option[value=\"ts\"]') !== null" > - ) > + page.wait_for_selector("#x_axis option[value='ts']", state="attached") > *** End Patch > PATCH Success. Updated the following files: M tests/test_web_timeseries.py
(scubaduck) bash-5.2# ruff format tests/test_web_timeseries.py scubaduck/static/ js/view_settings.js --silent
(scubaduck) bash-5.2# ruff check All checks passed! (scubaduck) bash-5.2#

I'll give the test another shot.

shell
(scubaduck) bash-5.2# pytest tests/test_web_timeseries.py::test_timeseries_group _by_num -q
bringing up nodes...













F [100%] =================================== FAILURES =================================== ____________________ test_timeseries_group_by_num[chromium] ____________________ [gw1] linux -- Python 3.12.3 /workspace/scubaduck/.venv/bin/python Traceback (most recent call last): File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/_pytest/runner.p y", line 341, in from_call result: TResult | None = func() ^^^^^^ File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/_pytest/runner.p y", line 242, in <lambda> lambda: runtest_hook(item=item, **kwds), when=when, reraise=reraise ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/pluggy/_hooks.py ", line 512, in __call__ return self._hookexec(self.name, self._hookimpls.copy(), kwargs, firstresult ) ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ ^ File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/pluggy/_manager. py", line 120, in _hookexec return self._inner_hookexec(hook_name, methods, kwargs, firstresult) ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/pluggy/_callers. py", line 167, in _multicall raise exception File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/pluggy/_callers. py", line 139, in _multicall teardown.throw(exception) File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/_pytest/threadex ception.py", line 92, in pytest_runtest_call yield from thread_exception_runtest_hook() File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/_pytest/threadex ception.py", line 68, in thread_exception_runtest_hook yield File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/pluggy/_callers. py", line 139, in _multicall teardown.throw(exception) File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/_pytest/unraisab leexception.py", line 95, in pytest_runtest_call yield from unraisable_exception_runtest_hook() File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/_pytest/unraisab leexception.py", line 70, in unraisable_exception_runtest_hook yield File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/pluggy/_callers. py", line 139, in _multicall teardown.throw(exception) File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/_pytest/logging. py", line 846, in pytest_runtest_call yield from self._runtest_for(item, "call") File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/_pytest/logging. py", line 829, in _runtest_for yield File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/pluggy/_callers. py", line 139, in _multicall teardown.throw(exception) File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/_pytest/capture. py", line 898, in pytest_runtest_call return (yield) ^^^^^ File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/pluggy/_callers. py", line 139, in _multicall teardown.throw(exception) File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/_pytest/skipping .py", line 257, in pytest_runtest_call return (yield) ^^^^^ File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/pluggy/_callers. py", line 121, in _multicall res = hook_impl.function(*args) ^^^^^^^^^^^^^^^^^^^^^^^^^ File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/_pytest/runner.p y", line 174, in pytest_runtest_call item.runtest() File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/_pytest/python.p y", line 1627, in runtest self.ihook.pytest_pyfunc_call(pyfuncitem=self) File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/pluggy/_hooks.py ", line 512, in __call__ return self._hookexec(self.name, self._hookimpls.copy(), kwargs, firstresult ) ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ ^ File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/pluggy/_manager. py", line 120, in _hookexec return self._inner_hookexec(hook_name, methods, kwargs, firstresult) ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/pluggy/_callers. py", line 167, in _multicall raise exception File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/pluggy/_callers. py", line 121, in _multicall res = hook_impl.function(*args) ^^^^^^^^^^^^^^^^^^^^^^^^^ File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/_pytest/python.p y", line 159, in pytest_pyfunc_call result = testfunction(**testargs) ^^^^^^^^^^^^^^^^^^^^^^^^ File "/workspace/scubaduck/tests/test_web_timeseries.py", line 404, in test_ti meseries_group_by_num page.wait_for_selector("#x_axis option[value='ts']", state="attached") File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/playwright/sync_ api/_generated.py", line 8187, in wait_for_selector self._sync( File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/playwright/_impl /_sync_base.py", line 115, in _sync return task.result() ^^^^^^^^^^^^^ File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/playwright/_impl /_page.py", line 425, in wait_for_selector return await self._main_frame.wait_for_selector(**locals_to_params(locals()) ) ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ ^ File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/playwright/_impl /_frame.py", line 323, in wait_for_selector await self._channel.send("waitForSelector", locals_to_params(locals())) File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/playwright/_impl /_connection.py", line 61, in send return await self._connection.wrap_api_call( ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/playwright/_impl /_connection.py", line 528, in wrap_api_call raise rewrite_error(error, f"{parsed_st['apiName']}: {error}") from None playwright._impl._errors.TimeoutError: Page.wait_for_selector: Timeout 30000ms e xceeded. Call log: - waiting for locator("#x_axis option[value='ts']") ------------------------------ Captured log call ------------------------------- INFO werkzeug:_internal.py:97 127.0.0.1 - - [23/May/2025 02:22:25] "GET / HT TP/1.1" 200 - INFO werkzeug:_internal.py:97 127.0.0.1 - - [23/May/2025 02:22:25] "GET /js/ chip_input.js HTTP/1.1" 200 - INFO werkzeug:_internal.py:97 127.0.0.1 - - [23/May/2025 02:22:25] "GET /js/ timeseries_chart.js HTTP/1.1" 200 - INFO werkzeug:_internal.py:97 127.0.0.1 - - [23/May/2025 02:22:25] "GET /js/ time_column.js HTTP/1.1" 200 - INFO werkzeug:_internal.py:97 127.0.0.1 - - [23/May/2025 02:22:25] "GET /js/ sidebar.js HTTP/1.1" 200 - INFO werkzeug:_internal.py:97 127.0.0.1 - - [23/May/2025 02:22:25] "GET /js/ table_view.js HTTP/1.1" 200 - INFO werkzeug:_internal.py:97 127.0.0.1 - - [23/May/2025 02:22:25] "GET /js/ view_settings.js HTTP/1.1" 200 - INFO werkzeug:_internal.py:97 127.0.0.1 - - [23/May/2025 02:22:25] "GET /api /tables HTTP/1.1" 200 - INFO werkzeug:_internal.py:97 127.0.0.1 - - [23/May/2025 02:22:25] "GET /api /columns?table=events HTTP/1.1" 200 - INFO werkzeug:_internal.py:97 127.0.0.1 - - [23/May/2025 02:22:25] "POST /ap i/query HTTP/1.1" 200 - INFO werkzeug:_internal.py:97 127.0.0.1 - - [23/May/2025 02:22:25] "GET /api /columns?table=extra HTTP/1.1" 200 - =========================== short test summary info ============================ FAILED tests/test_web_timeseries.py::test_timeseries_group_by_num[chromium] - pl aywright._impl._errors.TimeoutError: Page.wait_for_selector: Timeout 3000... 1 failed in 32.71s (scubaduck) bash-5.2#

The 'ts' option might not appear because the dataset is handled as numeric, not time.

The issue is that numeric columns like 'ts' aren't recognized as time columns, but setting 'time_column' to 'ts' while leaving x_axis blank resolves the problem.

shell
(scubaduck) bash-5.2# apply_patch<<'PATCH' > *** Begin Patch > *** Update File: tests/test_web_timeseries.py > @@ > - select_value(page, "#table", "extra") > - page.wait_for_selector("#x_axis option[value='ts']", state="attached") > - select_value(page, "#graph_type", "timeseries") > - select_value(page, "#x_axis", "ts") > + select_value(page, "#table", "extra") > + page.wait_for_selector("#time_column option[value='ts']", state="attached ") > + select_value(page, "#time_column", "ts") > + select_value(page, "#graph_type", "timeseries") > + select_value(page, "#x_axis", "") > *** End Patch > PATCH Success. Updated the following files: M tests/test_web_timeseries.py
(scubaduck) bash-5.2# ruff format tests/test_web_timeseries.py scubaduck/static/ js/view_settings.js --silent
(scubaduck) bash-5.2# ruff check All checks passed! (scubaduck) bash-5.2#

I'll try running the test again to see if the issue persists.

shell
(scubaduck) bash-5.2# pytest tests/test_web_timeseries.py::test_timeseries_group _by_num -q
bringing up nodes...
. [100%] 1 passed in 3.03s (scubaduck) bash-5.2#

The initial test passed, but I'll run the full suite just to make sure.

shell
(scubaduck) bash-5.2# pytest -q
bringing up nodes...




........................................................................ [ 61%]
............................................. [100%] 117 passed in 22.38s (scubaduck) bash-5.2#

I'll check git status now to see if everything's in order.

shell
(scubaduck) bash-5.2# git status --short M scubaduck/static/js/view_settings.js M tests/test_web_timeseries.py (scubaduck) bash-5.2#

It's time to stage my changes and commit them to the repository!

shell
(scubaduck) bash-5.2# git diff --staged --name-only (scubaduck) bash-5.2#

Time to add the changes to the stage and commit them.

shell
(scubaduck) bash-5.2# git add scubaduck/static/js/view_settings.js tests/test_we b_timeseries.py
(scubaduck) bash-5.2# git status --short M scubaduck/static/js/view_settings.js M tests/test_web_timeseries.py (scubaduck) bash-5.2#

I need to double-check the file status using git status to confirm staging.

shell
(scubaduck) bash-5.2# git status On branch work Changes to be committed: (use "git restore --staged <file>..." to unstage) modified: scubaduck/static/js/view_settings.js modified: tests/test_web_timeseries.py (scubaduck) bash-5.2#

The changes are staged! I'll commit with a message: "fix bug and add test."

shell
(scubaduck) bash-5.2# git commit -m "fix(web): show series for count group in ti meseries" && git status --short [work eda99b2] fix(web): show series for count group in timeseries 2 files changed, 41 insertions(+), 2 deletions(-)
(scubaduck) bash-5.2# git status --short (scubaduck) bash-5.2#

Time to write the PR message and include the relevant lines for context.

shell
(scubaduck) bash-5.2# nl -ba scubaduck/static/js/view_settings.js | sed -n '430, 468p' 430 } 431 432 function updateSelectedColumns(type = graphTypeSel.value) { 433 const base = allColumns.filter(name => { 434 const cb = document.querySelector(`#column_groups input[value="${nam e}"]`); 435 if (!cb || !cb.checked) return false; 436 if (type === 'table' && isStringColumn(name)) return false; 437 return true; 438 }); 439 if (type === 'table' || type === 'timeseries') { 440 selectedColumns = groupBy.chips.slice(); 441 if (document.getElementById('show_hits').checked) selectedColumns.pu sh('Hits'); 442 const agg = document.getElementById('aggregate').value.toLowerCase() ; 443 if (!(type === 'table' && agg === 'count')) { 444 base.forEach(c => { 445 if (!selectedColumns.includes(c)) selectedColumns.push(c); 446 }); 447 derivedColumns.forEach(dc => { 448 if (dc.include && !selectedColumns.includes(dc.name)) selectedCo lumns.push(dc.name); 449 }); 450 } 451 if (type === 'timeseries' && agg === 'count' && !selectedColumns.inc ludes('Count')) { 452 selectedColumns.push('Count'); 453 } 454 } else { 455 selectedColumns = base.slice(); 456 derivedColumns.forEach(dc => { 457 if (dc.include) selectedColumns.push(dc.name); 458 }); 459 } 460 columnValues[type] = selectedColumns.slice(); 461 const orderCol = document.getElementById('order_by').value; 462 if (orderCol && !selectedColumns.includes(orderCol)) { 463 selectedColumns.push(orderCol); 464 } 465 updateColumnsTabCount(); 466 } 467 468 function isStringColumn(name) { (scubaduck) bash-5.2#

I'll grab the lines related to the collectParams patch for reference.

shell
(scubaduck) bash-5.2# nl -ba scubaduck/static/js/view_settings.js | sed -n '688, 717p' 688 updateSelectedColumns(); 689 const payload = { 690 table: document.getElementById('table').value, 691 time_column: document.getElementById('time_column').value, 692 time_unit: document.getElementById('time_unit').value, 693 start: document.getElementById('start').value, 694 end: document.getElementById('end').value, 695 order_by: document.getElementById('order_by').value, 696 order_dir: orderDir, 697 limit: parseInt(document.getElementById('limit').value, 10), 698 columns: selectedColumns.filter( 699 c => c !== 'Hits' && c !== 'Count' && !derivedColumns.some(dc => d c.name === c) 700 ), 701 samples_columns: columnValues.samples.slice(), 702 table_columns: columnValues.table.slice(), 703 timeseries_columns: columnValues.timeseries.slice(), 704 graph_type: graphTypeSel.value, 705 filters: Array.from(document.querySelectorAll('#filters .filter')).m ap(f => { 706 const chips = f.chips || []; 707 const op = f.querySelector('.f-op').value; 708 let value = null; 709 if (op !== 'empty' && op !== '!empty') { 710 value = chips.length === 0 ? null : (chips.length === 1 ? chips[ 0] : chips); 711 } 712 const colSel = f.querySelector('.f-col select') || f.querySelector ('.f-col'); 713 return {column: colSel.value, op, value}; 714 }), 715 derived_columns: Array.from(document.querySelectorAll('#derived_list .derived')).map(d => ({ 716 type: d.querySelector('.d-type').value, 717 name: d.querySelector('.d-name').value, (scubaduck) bash-5.2#

I'll grab the lines from the test file next.

shell
(scubaduck) bash-5.2# nl -ba tests/test_web_timeseries.py | sed -n '396,414p' 396 assert page.text_content("#legend .drill-links h4") == "Drill up" 397 assert page.is_visible("#legend .drill-links a:text('Aggregate')") 398 399 400 def test_timeseries_group_by_num(page: Any, test_dataset_server_url: str ) -> None: 401 page.goto(test_dataset_server_url) 402 page.wait_for_selector("#graph_type", state="attached") 403 select_value(page, "#table", "extra") 404 page.wait_for_selector("#time_column option[value='ts']", state="att ached") 405 select_value(page, "#time_column", "ts") 406 select_value(page, "#graph_type", "timeseries") 407 select_value(page, "#x_axis", "") 408 page.evaluate("groupBy.chips = ['num']; groupBy.renderChips();") 409 page.evaluate("window.lastResults = undefined") 410 page.click("text=Dive") 411 page.wait_for_function("window.lastResults !== undefined") 412 page.wait_for_selector("#chart path", state="attached") 413 count = page.eval_on_selector_all("#chart path", "els => els.length" ) 414 assert count == 2 (scubaduck) bash-5.2#

Time to dive into the commit log for a closer look.

shell
(scubaduck) bash-5.2# git log -1 --stat commit eda99b24b5d1e784d713358c692dc9b7bb069100 (HEAD -> work) Author: Codex <codex@openai.com> Date: Fri May 23 02:24:03 2025 +0000 fix(web): show series for count group in timeseries scubaduck/static/js/view_settings.js | 7 +++++-- tests/test_web_timeseries.py | 36 ++++++++++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 2 deletions(-) (scubaduck) bash-5.2#

Time to finalize the PR and include the original prompt as instructed.