Task ID: task_e_682fd8fa5bcc83238c76cdb7fa28ae48
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'
I'm checking the instructions and planning to open an interactive session in the container.
ls
^C
Looks like "ls" isn't working—let me verify command access inside root.
^Cls
bash-5.2# ls
scubaduck
bash-5.2#
I'm confirming that I'm at root, time to move into the scubaduck repo!
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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'.
(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.
(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.
(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.
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.
(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.
(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.
(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.
(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.
}
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">⎘</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.
(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.
(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.
(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.
(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!
(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.
(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.
(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!
(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.
(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.
(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.
(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.
(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.
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.
)
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.
"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.
> 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.
(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.
(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.
(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?
(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.
(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].
(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.
(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.
(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.
(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.
(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.
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.
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.
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.
(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.
(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.
(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.
(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.
(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.
(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".
(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.
> *** 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.
(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.
> *** 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.
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.
(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.
(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.
(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.
(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.
(scubaduck) bash-5.2# pytest -q
bringing up nodes...
..............................................................FFF
Looks like the tests failed again. It's time to check the error details.
..............................................................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
.
(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.
(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.
(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.
(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.
(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.
(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.
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.
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.
(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.
> *** 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.
(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.
(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.
(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.
(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.
(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.
(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.
(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.
> *** 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.
(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.
(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.
(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.
(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.
(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.
(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.
(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!
(scubaduck) bash-5.2# git diff --staged --name-only
(scubaduck) bash-5.2#
Time to add the changes to the stage and commit them.
(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.
(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."
(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.
(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.
(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.
(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.
(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.