Task ID: task_e_682d4d4650a88323875c0c5a3397d7b1
charset-normalizer ------------------------------ 30.87 KiB/145.08 KiB
certifi ------------------------------ 75.91 KiB/155.88 KiB
werkzeug ------------------------------ 14.88 KiB/219.24 KiB
python-dateutil ------------------------------ 48.00 KiB/224.50 KiB
pytest ------------------------------ 16.00 KiB/335.58 KiB
pyright ------------------------------ 16.00 KiB/5.31 MiB
ruff ------------------------------ 16.00 KiB/11.02 MiB
duckdb ------------------------------ 14.91 KiB/19.27 MiB
Building scubaduck @ file:///workspace/scubaduck
⠙ Preparing packages... (0/33)
iniconfig ------------------------------ 5.91 KiB/5.91 KiB
python-slugify ------------------------------ 9.82 KiB/9.82 KiB
pluggy ------------------------------ 16.00 KiB/20.06 KiB
markupsafe ------------------------------ 22.58 KiB/22.58 KiB
execnet ------------------------------ 32.00 KiB/39.66 KiB
pytest-xdist ------------------------------ 30.90 KiB/45.03 KiB
requests ------------------------------ 32.00 KiB/63.41 KiB
idna ------------------------------ 64.00 KiB/68.79 KiB
text-unidecode ------------------------------ 76.32 KiB/76.32 KiB
click ------------------------------ 30.88 KiB/99.76 KiB
flask ------------------------------ 16.00 KiB/100.88 KiB
urllib3 ------------------------------ 78.68 KiB/125.66 KiB
jinja2 ------------------------------ 32.00 KiB/131.74 KiB
charset-normalizer ------------------------------ 30.87 KiB/145.08 KiB
certifi ------------------------------ 75.91 KiB/155.88 KiB
werkzeug ------------------------------ 14.88 KiB/219.24 KiB
python-dateutil ------------------------------ 48.00 KiB/224.50 KiB
pytest ------------------------------ 16.00 KiB/335.58 KiB
pyright ------------------------------ 16.00 KiB/5.31 MiB
ruff ------------------------------ 16.00 KiB/11.02 MiB
duckdb ------------------------------ 14.91 KiB/19.27 MiB
Building scubaduck @ file:///workspace/scubaduck
⠙ Preparing packages... (0/33)
python-slugify ------------------------------ 9.82 KiB/9.82 KiB
pluggy ------------------------------ 16.00 KiB/20.06 KiB
markupsafe ------------------------------ 22.58 KiB/22.58 KiB
execnet ------------------------------ 32.00 KiB/39.66 KiB
pytest-xdist ------------------------------ 30.90 KiB/45.03 KiB
requests ------------------------------ 32.00 KiB/63.41 KiB
idna ------------------------------ 64.00 KiB/68.79 KiB
text-unidecode ------------------------------ 76.32 KiB/76.32 KiB
click ------------------------------ 30.88 KiB/99.76 KiB
flask ------------------------------ 16.00 KiB/100.88 KiB
urllib3 ------------------------------ 78.68 KiB/125.66 KiB
jinja2 ------------------------------ 32.00 KiB/131.74 KiB
charset-normalizer ------------------------------ 30.87 KiB/145.08 KiB
certifi ------------------------------ 75.91 KiB/155.88 KiB
werkzeug ------------------------------ 14.88 KiB/219.24 KiB
python-dateutil ------------------------------ 48.00 KiB/224.50 KiB
pytest ------------------------------ 16.00 KiB/335.58 KiB
pyright ------------------------------ 16.00 KiB/5.31 MiB
ruff ------------------------------ 16.00 KiB/11.02 MiB
duckdb ------------------------------ 14.91 KiB/19.27 MiB
Building scubaduck @ file:///workspace/scubaduck
⠙ Preparing packages... (0/33)
python-slugify ------------------------------ 9.82 KiB/9.82 KiB
pluggy ------------------------------ 20.06 KiB/20.06 KiB
markupsafe ------------------------------ 22.58 KiB/22.58 KiB
execnet ------------------------------ 32.00 KiB/39.66 KiB
pytest-xdist ------------------------------ 30.90 KiB/45.03 KiB
requests ------------------------------ 32.00 KiB/63.41 KiB
idna ------------------------------ 64.00 KiB/68.79 KiB
click ------------------------------ 30.88 KiB/99.76 KiB
flask ------------------------------ 16.00 KiB/100.88 KiB
urllib3 ------------------------------ 78.68 KiB/125.66 KiB
jinja2 ------------------------------ 32.00 KiB/131.74 KiB
charset-normalizer ------------------------------ 30.87 KiB/145.08 KiB
certifi ------------------------------ 75.91 KiB/155.88 KiB
werkzeug ------------------------------ 14.88 KiB/219.24 KiB
python-dateutil ------------------------------ 48.00 KiB/224.50 KiB
pytest ------------------------------ 16.00 KiB/335.58 KiB
pyright ------------------------------ 16.00 KiB/5.31 MiB
ruff ------------------------------ 16.00 KiB/11.02 MiB
duckdb ------------------------------ 14.91 KiB/19.27 MiB
Building scubaduck @ file:///workspace/scubaduck
⠙ Preparing packages... (0/33)
pluggy ------------------------------ 20.06 KiB/20.06 KiB
markupsafe ------------------------------ 22.58 KiB/22.58 KiB
execnet ------------------------------ 32.00 KiB/39.66 KiB
pytest-xdist ------------------------------ 30.90 KiB/45.03 KiB
requests ------------------------------ 32.00 KiB/63.41 KiB
idna ------------------------------ 64.00 KiB/68.79 KiB
click ------------------------------ 62.88 KiB/99.76 KiB
flask ------------------------------ 32.00 KiB/100.88 KiB
urllib3 ------------------------------ 78.68 KiB/125.66 KiB
jinja2 ------------------------------ 48.00 KiB/131.74 KiB
charset-normalizer ------------------------------ 46.87 KiB/145.08 KiB
certifi ------------------------------ 91.91 KiB/155.88 KiB
werkzeug ------------------------------ 30.88 KiB/219.24 KiB
python-dateutil ------------------------------ 62.41 KiB/224.50 KiB
pytest ------------------------------ 32.00 KiB/335.58 KiB
pyright ------------------------------ 16.00 KiB/5.31 MiB
ruff ------------------------------ 32.00 KiB/11.02 MiB
duckdb ------------------------------ 30.91 KiB/19.27 MiB
Building scubaduck @ file:///workspace/scubaduck
⠙ Preparing packages... (0/33)
pluggy ------------------------------ 20.06 KiB/20.06 KiB
execnet ------------------------------ 39.66 KiB/39.66 KiB
pytest-xdist ------------------------------ 30.90 KiB/45.03 KiB
requests ------------------------------ 48.00 KiB/63.41 KiB
packaging ------------------------------ 6.87 KiB/64.91 KiB
idna ------------------------------ 68.79 KiB/68.79 KiB
click ------------------------------ 62.88 KiB/99.76 KiB
flask ------------------------------ 32.00 KiB/100.88 KiB
urllib3 ------------------------------ 78.68 KiB/125.66 KiB
jinja2 ------------------------------ 64.00 KiB/131.74 KiB
charset-normalizer ------------------------------ 62.87 KiB/145.08 KiB
certifi ------------------------------ 107.91 KiB/155.88 KiB
werkzeug ------------------------------ 30.88 KiB/219.24 KiB
python-dateutil ------------------------------ 78.41 KiB/224.50 KiB
pytest ------------------------------ 32.00 KiB/335.58 KiB
greenlet ------------------------------ 6.90 KiB/589.71 KiB
pyright ------------------------------ 16.00 KiB/5.31 MiB
ruff ------------------------------ 32.00 KiB/11.02 MiB
duckdb ------------------------------ 46.91 KiB/19.27 MiB
Building scubaduck @ file:///workspace/scubaduck
⠙ Preparing packages... (0/33)
pluggy ------------------------------ 20.06 KiB/20.06 KiB
execnet ------------------------------ 39.66 KiB/39.66 KiB
pytest-xdist ------------------------------ 30.90 KiB/45.03 KiB
requests ------------------------------ 48.00 KiB/63.41 KiB
packaging ------------------------------ 6.87 KiB/64.91 KiB
click ------------------------------ 62.88 KiB/99.76 KiB
flask ------------------------------ 48.00 KiB/100.88 KiB
urllib3 ------------------------------ 78.68 KiB/125.66 KiB
jinja2 ------------------------------ 76.70 KiB/131.74 KiB
charset-normalizer ------------------------------ 110.87 KiB/145.08 KiB
certifi ------------------------------ 155.88 KiB/155.88 KiB
werkzeug ------------------------------ 46.88 KiB/219.24 KiB
python-dateutil ------------------------------ 78.41 KiB/224.50 KiB
pytest ------------------------------ 32.00 KiB/335.58 KiB
greenlet ------------------------------ 6.90 KiB/589.71 KiB
pyright ------------------------------ 16.00 KiB/5.31 MiB
ruff ------------------------------ 32.00 KiB/11.02 MiB
duckdb ------------------------------ 78.91 KiB/19.27 MiB
Building scubaduck @ file:///workspace/scubaduck
⠙ Preparing packages... (0/33)
execnet ------------------------------ 39.66 KiB/39.66 KiB
pytest-xdist ------------------------------ 30.90 KiB/45.03 KiB
requests ------------------------------ 48.00 KiB/63.41 KiB
packaging ------------------------------ 6.87 KiB/64.91 KiB
click ------------------------------ 62.88 KiB/99.76 KiB
flask ------------------------------ 48.00 KiB/100.88 KiB
urllib3 ------------------------------ 78.68 KiB/125.66 KiB
jinja2 ------------------------------ 76.70 KiB/131.74 KiB
charset-normalizer ------------------------------ 126.87 KiB/145.08 KiB
certifi ------------------------------ 155.88 KiB/155.88 KiB
werkzeug ------------------------------ 62.88 KiB/219.24 KiB
python-dateutil ------------------------------ 94.41 KiB/224.50 KiB
pytest ------------------------------ 48.00 KiB/335.58 KiB
greenlet ------------------------------ 14.91 KiB/589.71 KiB
pyright ------------------------------ 16.00 KiB/5.31 MiB
ruff ------------------------------ 79.77 KiB/11.02 MiB
duckdb ------------------------------ 126.91 KiB/19.27 MiB
Building scubaduck @ file:///workspace/scubaduck
⠙ Preparing packages... (0/33)
pytest-xdist ------------------------------ 45.03 KiB/45.03 KiB
requests ------------------------------ 63.41 KiB/63.41 KiB
packaging ------------------------------ 16.00 KiB/64.91 KiB
click ------------------------------ 94.88 KiB/99.76 KiB
flask ------------------------------ 64.00 KiB/100.88 KiB
urllib3 ------------------------------ 94.68 KiB/125.66 KiB
jinja2 ------------------------------ 108.70 KiB/131.74 KiB
charset-normalizer ------------------------------ 126.87 KiB/145.08 KiB
certifi ------------------------------ 155.88 KiB/155.88 KiB
werkzeug ------------------------------ 78.88 KiB/219.24 KiB
python-dateutil ------------------------------ 190.41 KiB/224.50 KiB
pytest ------------------------------ 80.00 KiB/335.58 KiB
greenlet ------------------------------ 14.91 KiB/589.71 KiB
pyright ------------------------------ 95.72 KiB/5.31 MiB
ruff ------------------------------ 143.77 KiB/11.02 MiB
duckdb ------------------------------ 222.91 KiB/19.27 MiB
Building scubaduck @ file:///workspace/scubaduck
⠙ Preparing packages... (0/33)
pytest-xdist ------------------------------ 45.03 KiB/45.03 KiB
requests ------------------------------ 63.41 KiB/63.41 KiB
packaging ------------------------------ 32.00 KiB/64.91 KiB
click ------------------------------ 99.76 KiB/99.76 KiB
flask ------------------------------ 64.00 KiB/100.88 KiB
urllib3 ------------------------------ 94.68 KiB/125.66 KiB
jinja2 ------------------------------ 124.70 KiB/131.74 KiB
charset-normalizer ------------------------------ 142.87 KiB/145.08 KiB
werkzeug ------------------------------ 94.88 KiB/219.24 KiB
python-dateutil ------------------------------ 222.41 KiB/224.50 KiB
pytest ------------------------------ 80.00 KiB/335.58 KiB
greenlet ------------------------------ 30.91 KiB/589.71 KiB
pyright ------------------------------ 175.72 KiB/5.31 MiB
ruff ------------------------------ 191.77 KiB/11.02 MiB
duckdb ------------------------------ 318.91 KiB/19.27 MiB
playwright ------------------------------ 8.00 KiB/43.05 MiB
Building scubaduck @ file:///workspace/scubaduck
⠙ Preparing packages... (0/33)
pytest-xdist ------------------------------ 45.03 KiB/45.03 KiB
packaging ------------------------------ 48.00 KiB/64.91 KiB
click ------------------------------ 99.76 KiB/99.76 KiB
flask ------------------------------ 64.00 KiB/100.88 KiB
urllib3 ------------------------------ 94.68 KiB/125.66 KiB
jinja2 ------------------------------ 124.70 KiB/131.74 KiB
charset-normalizer ------------------------------ 142.87 KiB/145.08 KiB
werkzeug ------------------------------ 94.88 KiB/219.24 KiB
python-dateutil ------------------------------ 222.41 KiB/224.50 KiB
pytest ------------------------------ 96.00 KiB/335.58 KiB
greenlet ------------------------------ 46.91 KiB/589.71 KiB
pyright ------------------------------ 207.72 KiB/5.31 MiB
ruff ------------------------------ 223.77 KiB/11.02 MiB
duckdb ------------------------------ 350.91 KiB/19.27 MiB
playwright ------------------------------ 8.00 KiB/43.05 MiB
Building scubaduck @ file:///workspace/scubaduck
⠹ Preparing packages... (16/33)
pytest-xdist ------------------------------ 45.03 KiB/45.03 KiB
packaging ------------------------------ 48.00 KiB/64.91 KiB
click ------------------------------ 99.76 KiB/99.76 KiB
flask ------------------------------ 64.00 KiB/100.88 KiB
urllib3 ------------------------------ 94.68 KiB/125.66 KiB
jinja2 ------------------------------ 124.70 KiB/131.74 KiB
charset-normalizer ------------------------------ 142.87 KiB/145.08 KiB
werkzeug ------------------------------ 94.88 KiB/219.24 KiB
python-dateutil ------------------------------ 222.41 KiB/224.50 KiB
pytest ------------------------------ 96.00 KiB/335.58 KiB
greenlet ------------------------------ 46.91 KiB/589.71 KiB
pyright ------------------------------ 223.72 KiB/5.31 MiB
ruff ------------------------------ 239.77 KiB/11.02 MiB
duckdb ------------------------------ 350.91 KiB/19.27 MiB
playwright ------------------------------ 8.00 KiB/43.05 MiB
Building scubaduck @ file:///workspace/scubaduck
⠹ Preparing packages... (16/33)
packaging ------------------------------ 48.00 KiB/64.91 KiB
click ------------------------------ 99.76 KiB/99.76 KiB
flask ------------------------------ 80.00 KiB/100.88 KiB
urllib3 ------------------------------ 110.68 KiB/125.66 KiB
jinja2 ------------------------------ 131.74 KiB/131.74 KiB
charset-normalizer ------------------------------ 142.87 KiB/145.08 KiB
werkzeug ------------------------------ 94.88 KiB/219.24 KiB
python-dateutil ------------------------------ 222.41 KiB/224.50 KiB
pytest ------------------------------ 112.00 KiB/335.58 KiB
greenlet ------------------------------ 46.91 KiB/589.71 KiB
pyright ------------------------------ 271.72 KiB/5.31 MiB
ruff ------------------------------ 287.77 KiB/11.02 MiB
duckdb ------------------------------ 408.56 KiB/19.27 MiB
playwright ------------------------------ 8.00 KiB/43.05 MiB
Building scubaduck @ file:///workspace/scubaduck
⠹ Preparing packages... (16/33)
packaging ------------------------------ 48.00 KiB/64.91 KiB
flask ------------------------------ 80.00 KiB/100.88 KiB
urllib3 ------------------------------ 110.68 KiB/125.66 KiB
jinja2 ------------------------------ 131.74 KiB/131.74 KiB
charset-normalizer ------------------------------ 142.87 KiB/145.08 KiB
werkzeug ------------------------------ 94.88 KiB/219.24 KiB
python-dateutil ------------------------------ 222.41 KiB/224.50 KiB
pytest ------------------------------ 112.00 KiB/335.58 KiB
greenlet ------------------------------ 46.91 KiB/589.71 KiB
pyright ------------------------------ 271.72 KiB/5.31 MiB
ruff ------------------------------ 287.77 KiB/11.02 MiB
duckdb ------------------------------ 408.56 KiB/19.27 MiB
playwright ------------------------------ 8.00 KiB/43.05 MiB
Building scubaduck @ file:///workspace/scubaduck
⠹ Preparing packages... (16/33)
packaging ------------------------------ 64.91 KiB/64.91 KiB
flask ------------------------------ 80.00 KiB/100.88 KiB
urllib3 ------------------------------ 110.68 KiB/125.66 KiB
jinja2 ------------------------------ 131.74 KiB/131.74 KiB
charset-normalizer ------------------------------ 145.08 KiB/145.08 KiB
werkzeug ------------------------------ 94.88 KiB/219.24 KiB
pytest ------------------------------ 128.00 KiB/335.58 KiB
greenlet ------------------------------ 63.39 KiB/589.71 KiB
pyright ------------------------------ 335.72 KiB/5.31 MiB
ruff ------------------------------ 367.77 KiB/11.02 MiB
duckdb ------------------------------ 456.56 KiB/19.27 MiB
playwright ------------------------------ 8.00 KiB/43.05 MiB
Building scubaduck @ file:///workspace/scubaduck
⠹ Preparing packages... (16/33)
packaging ------------------------------ 64.91 KiB/64.91 KiB
flask ------------------------------ 80.00 KiB/100.88 KiB
urllib3 ------------------------------ 110.68 KiB/125.66 KiB
jinja2 ------------------------------ 131.74 KiB/131.74 KiB
werkzeug ------------------------------ 110.88 KiB/219.24 KiB
pytest ------------------------------ 144.00 KiB/335.58 KiB
greenlet ------------------------------ 63.39 KiB/589.71 KiB
pyright ------------------------------ 383.72 KiB/5.31 MiB
ruff ------------------------------ 399.77 KiB/11.02 MiB
duckdb ------------------------------ 504.56 KiB/19.27 MiB
playwright ------------------------------ 14.91 KiB/43.05 MiB
Building scubaduck @ file:///workspace/scubaduck
⠹ Preparing packages... (16/33)
packaging ------------------------------ 64.91 KiB/64.91 KiB
flask ------------------------------ 96.00 KiB/100.88 KiB
urllib3 ------------------------------ 125.66 KiB/125.66 KiB
werkzeug ------------------------------ 110.88 KiB/219.24 KiB
pytest ------------------------------ 160.00 KiB/335.58 KiB
greenlet ------------------------------ 95.39 KiB/589.71 KiB
pyright ------------------------------ 431.72 KiB/5.31 MiB
ruff ------------------------------ 463.77 KiB/11.02 MiB
duckdb ------------------------------ 552.56 KiB/19.27 MiB
playwright ------------------------------ 30.91 KiB/43.05 MiB
Building scubaduck @ file:///workspace/scubaduck
⠹ Preparing packages... (16/33)
flask ------------------------------ 96.00 KiB/100.88 KiB
urllib3 ------------------------------ 125.66 KiB/125.66 KiB
werkzeug ------------------------------ 126.88 KiB/219.24 KiB
pytest ------------------------------ 176.00 KiB/335.58 KiB
greenlet ------------------------------ 207.39 KiB/589.71 KiB
pyright ------------------------------ 543.72 KiB/5.31 MiB
ruff ------------------------------ 559.77 KiB/11.02 MiB
duckdb ------------------------------ 648.56 KiB/19.27 MiB
playwright ------------------------------ 30.91 KiB/43.05 MiB
Building scubaduck @ file:///workspace/scubaduck
⠹ Preparing packages... (16/33)
flask ------------------------------ 100.88 KiB/100.88 KiB
werkzeug ------------------------------ 142.88 KiB/219.24 KiB
pytest ------------------------------ 191.20 KiB/335.58 KiB
greenlet ------------------------------ 271.39 KiB/589.71 KiB
pyright ------------------------------ 607.72 KiB/5.31 MiB
ruff ------------------------------ 623.77 KiB/11.02 MiB
duckdb ------------------------------ 728.56 KiB/19.27 MiB
playwright ------------------------------ 46.91 KiB/43.05 MiB
Building scubaduck @ file:///workspace/scubaduck
⠹ Preparing packages... (16/33)
werkzeug ------------------------------ 142.88 KiB/219.24 KiB
pytest ------------------------------ 191.20 KiB/335.58 KiB
greenlet ------------------------------ 287.39 KiB/589.71 KiB
pyright ------------------------------ 623.72 KiB/5.31 MiB
ruff ------------------------------ 639.77 KiB/11.02 MiB
duckdb ------------------------------ 744.56 KiB/19.27 MiB
playwright ------------------------------ 46.91 KiB/43.05 MiB
Building scubaduck @ file:///workspace/scubaduck
⠹ Preparing packages... (16/33)
werkzeug ------------------------------ 158.88 KiB/219.24 KiB
pytest ------------------------------ 239.20 KiB/335.58 KiB
greenlet ------------------------------ 479.39 KiB/589.71 KiB
pyright ------------------------------ 1.00 MiB/5.31 MiB
ruff ------------------------------ 1.03 MiB/11.02 MiB
duckdb ------------------------------ 1.11 MiB/19.27 MiB
playwright ------------------------------ 78.91 KiB/43.05 MiB
Building scubaduck @ file:///workspace/scubaduck
⠹ Preparing packages... (16/33)
pytest ------------------------------ 303.20 KiB/335.58 KiB
greenlet ------------------------------ 479.39 KiB/589.71 KiB
pyright ------------------------------ 1.34 MiB/5.31 MiB
ruff ------------------------------ 1.48 MiB/11.02 MiB
duckdb ------------------------------ 1.54 MiB/19.27 MiB
playwright ------------------------------ 110.91 KiB/43.05 MiB
Building scubaduck @ file:///workspace/scubaduck
⠹ Preparing packages... (16/33)
greenlet ------------------------------ 511.39 KiB/589.71 KiB
pyright ------------------------------ 1.73 MiB/5.31 MiB
ruff ------------------------------ 1.87 MiB/11.02 MiB
duckdb ------------------------------ 1.98 MiB/19.27 MiB
playwright ------------------------------ 334.91 KiB/43.05 MiB
Building scubaduck @ file:///workspace/scubaduck
⠹ Preparing packages... (16/33)
greenlet ------------------------------ 543.39 KiB/589.71 KiB
pyright ------------------------------ 1.79 MiB/5.31 MiB
ruff ------------------------------ 2.17 MiB/11.02 MiB
duckdb ------------------------------ 2.26 MiB/19.27 MiB
playwright ------------------------------ 648.56 KiB/43.05 MiB
Building scubaduck @ file:///workspace/scubaduck
⠹ Preparing packages... (16/33)
pyright ------------------------------ 1.82 MiB/5.31 MiB
ruff ------------------------------ 3.05 MiB/11.02 MiB
duckdb ------------------------------ 3.09 MiB/19.27 MiB
playwright ------------------------------ 1.46 MiB/43.05 MiB
Building scubaduck @ file:///workspace/scubaduck
⠹ Preparing packages... (16/33)
pyright ------------------------------ 1.84 MiB/5.31 MiB
ruff ------------------------------ 3.69 MiB/11.02 MiB
duckdb ------------------------------ 3.71 MiB/19.27 MiB
playwright ------------------------------ 2.08 MiB/43.05 MiB
Building scubaduck @ file:///workspace/scubaduck
⠹ Preparing packages... (16/33)
pyright ------------------------------ 1.91 MiB/5.31 MiB
ruff ------------------------------ 5.06 MiB/11.02 MiB
duckdb ------------------------------ 5.06 MiB/19.27 MiB
playwright ------------------------------ 3.42 MiB/43.05 MiB
Building scubaduck @ file:///workspace/scubaduck
⠸ Preparing packages... (28/33)
pyright ------------------------------ 1.98 MiB/5.31 MiB
ruff ------------------------------ 6.72 MiB/11.02 MiB
duckdb ------------------------------ 6.67 MiB/19.27 MiB
playwright ------------------------------ 5.00 MiB/43.05 MiB
Building scubaduck @ file:///workspace/scubaduck
⠸ Preparing packages... (28/33)
pyright ------------------------------ 2.08 MiB/5.31 MiB
ruff ------------------------------ 8.34 MiB/11.02 MiB
duckdb ------------------------------ 8.24 MiB/19.27 MiB
playwright ------------------------------ 6.62 MiB/43.05 MiB
Building scubaduck @ file:///workspace/scubaduck
⠸ Preparing packages... (28/33)
pyright ------------------------------ 2.12 MiB/5.31 MiB
ruff ------------------------------ 9.93 MiB/11.02 MiB
duckdb ------------------------------ 9.85 MiB/19.27 MiB
playwright ------------------------------ 8.16 MiB/43.05 MiB
Building scubaduck @ file:///workspace/scubaduck
⠸ Preparing packages... (28/33)
pyright ------------------------------ 2.15 MiB/5.31 MiB
duckdb ------------------------------ 11.00 MiB/19.27 MiB
playwright ------------------------------ 9.34 MiB/43.05 MiB
Building scubaduck @ file:///workspace/scubaduck
⠸ Preparing packages... (28/33)
pyright ------------------------------ 2.15 MiB/5.31 MiB
duckdb ------------------------------ 11.47 MiB/19.27 MiB
playwright ------------------------------ 9.86 MiB/43.05 MiB
Building scubaduck @ file:///workspace/scubaduck
⠼ Preparing packages... (29/33)
pyright ------------------------------ 2.20 MiB/5.31 MiB
duckdb ------------------------------ 13.87 MiB/19.27 MiB
playwright ------------------------------ 12.28 MiB/43.05 MiB
Building scubaduck @ file:///workspace/scubaduck
⠼ Preparing packages... (29/33)
pyright ------------------------------ 2.23 MiB/5.31 MiB
duckdb ------------------------------ 16.12 MiB/19.27 MiB
playwright ------------------------------ 14.55 MiB/43.05 MiB
Built scubaduck @ file:///workspace/scubaduck
⠼ Preparing packages... (29/33)
pyright ------------------------------ 2.26 MiB/5.31 MiB
duckdb ------------------------------ 17.79 MiB/19.27 MiB
playwright ------------------------------ 16.18 MiB/43.05 MiB
⠼ Preparing packages... (29/33)
pyright ------------------------------ 2.28 MiB/5.31 MiB
duckdb ------------------------------ 18.12 MiB/19.27 MiB
playwright ------------------------------ 16.59 MiB/43.05 MiB
⠼ Preparing packages... (29/33)
pyright ------------------------------ 2.39 MiB/5.31 MiB
playwright ------------------------------ 19.34 MiB/43.05 MiB
⠼ Preparing packages... (29/33)
pyright ------------------------------ 2.39 MiB/5.31 MiB
playwright ------------------------------ 19.43 MiB/43.05 MiB
⠴ Preparing packages... (31/33)
pyright ------------------------------ 2.51 MiB/5.31 MiB
playwright ------------------------------ 21.75 MiB/43.05 MiB
⠴ Preparing packages... (31/33)
pyright ------------------------------ 2.65 MiB/5.31 MiB
playwright ------------------------------ 23.98 MiB/43.05 MiB
⠴ Preparing packages... (31/33)
pyright ------------------------------ 2.75 MiB/5.31 MiB
playwright ------------------------------ 26.02 MiB/43.05 MiB
⠦ Preparing packages... (31/33)
pyright ------------------------------ 2.87 MiB/5.31 MiB
playwright ------------------------------ 27.84 MiB/43.05 MiB
⠦ Preparing packages... (31/33)
pyright ------------------------------ 3.03 MiB/5.31 MiB
playwright ------------------------------ 29.50 MiB/43.05 MiB
⠦ Preparing packages... (31/33)
pyright ------------------------------ 3.18 MiB/5.31 MiB
playwright ------------------------------ 31.00 MiB/43.05 MiB
⠦ Preparing packages... (31/33)
pyright ------------------------------ 3.28 MiB/5.31 MiB
playwright ------------------------------ 32.95 MiB/43.05 MiB
⠧ Preparing packages... (31/33)
pyright ------------------------------ 3.45 MiB/5.31 MiB
playwright ------------------------------ 34.93 MiB/43.05 MiB
⠧ Preparing packages... (31/33)
pyright ------------------------------ 3.58 MiB/5.31 MiB
playwright ------------------------------ 36.39 MiB/43.05 MiB
⠧ Preparing packages... (31/33)
pyright ------------------------------ 3.72 MiB/5.31 MiB
playwright ------------------------------ 38.44 MiB/43.05 MiB
⠧ Preparing packages... (31/33)
pyright ------------------------------ 3.85 MiB/5.31 MiB
playwright ------------------------------ 40.93 MiB/43.05 MiB
⠧ Preparing packages... (31/33)
pyright ------------------------------ 4.19 MiB/5.31 MiB
playwright ------------------------------ 41.73 MiB/43.05 MiB
⠇ Preparing packages... (31/33)
pyright ------------------------------ 4.23 MiB/5.31 MiB
⠇ Preparing packages... (31/33)
pyright ------------------------------ 4.39 MiB/5.31 MiB
⠇ Preparing packages... (31/33)
pyright ------------------------------ 4.58 MiB/5.31 MiB
⠇ Preparing packages... (31/33)
Prepared 33 packages in 1.51s
░░░░░░░░░░░░░░░░░░░░ [0/0] Installing wheels...
░░░░░░░░░░░░░░░░░░░░ [0/33] Installing wheels...
░░░░░░░░░░░░░░░░░░░░ [0/33] six==1.17.0
░░░░░░░░░░░░░░░░░░░░ [1/33] six==1.17.0
░░░░░░░░░░░░░░░░░░░░ [1/33] requests==2.32.3
█░░░░░░░░░░░░░░░░░░░ [2/33] requests==2.32.3
█░░░░░░░░░░░░░░░░░░░ [2/33] flask==3.1.1
█░░░░░░░░░░░░░░░░░░░ [3/33] flask==3.1.1
█░░░░░░░░░░░░░░░░░░░ [3/33] ruff==0.11.10
██░░░░░░░░░░░░░░░░░░ [4/33] ruff==0.11.10
██░░░░░░░░░░░░░░░░░░ [4/33] typing-extensions==4.13.2
███░░░░░░░░░░░░░░░░░ [5/33] typing-extensions==4.13.2
███░░░░░░░░░░░░░░░░░ [5/33] pytest-xdist==3.6.1
███░░░░░░░░░░░░░░░░░ [6/33] pytest-xdist==3.6.1
███░░░░░░░░░░░░░░░░░ [6/33] itsdangerous==2.2.0
████░░░░░░░░░░░░░░░░ [7/33] itsdangerous==2.2.0
████░░░░░░░░░░░░░░░░ [7/33] scubaduck==0.1.0 (from file:///workspace/scubaduck)
████░░░░░░░░░░░░░░░░ [8/33] scubaduck==0.1.0 (from file:///workspace/scubaduck)
████░░░░░░░░░░░░░░░░ [8/33] pytest-base-url==2.1.0
█████░░░░░░░░░░░░░░░ [9/33] pytest-base-url==2.1.0
███████████████████░ [32/33] pyright==1.1.400
Installed 33 packages in 79ms
+ 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/6Ftsn7-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.15.1/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.15.1/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
++ 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% 64.0s167.7 MiB [] 0% 29.7s167.7 MiB [] 0% 21.9s167.7 MiB [] 0% 14.7s167.7 MiB [] 0% 9.7s167.7 MiB [] 1% 6.7s167.7 MiB [] 2% 5.5s167.7 MiB [] 3% 4.3s167.7 MiB [] 4% 3.4s167.7 MiB [] 5% 3.1s167.7 MiB [] 5% 3.0s167.7 MiB [] 6% 2.9s167.7 MiB [] 7% 2.7s167.7 MiB [] 8% 2.4s167.7 MiB [] 9% 2.4s167.7 MiB [] 10% 2.3s167.7 MiB [] 11% 2.1s167.7 MiB [] 13% 1.9s167.7 MiB [] 15% 1.7s167.7 MiB [] 16% 1.7s167.7 MiB [] 17% 1.6s167.7 MiB [] 19% 1.4s167.7 MiB [] 21% 1.4s167.7 MiB [] 23% 1.3s167.7 MiB [] 25% 1.2s167.7 MiB [] 27% 1.1s167.7 MiB [] 28% 1.1s167.7 MiB [] 30% 1.0s167.7 MiB [] 31% 1.0s167.7 MiB [] 32% 1.0s167.7 MiB [] 33% 1.0s167.7 MiB [] 34% 1.0s167.7 MiB [] 35% 1.0s167.7 MiB [] 36% 1.0s167.7 MiB [] 37% 1.0s167.7 MiB [] 39% 0.9s167.7 MiB [] 41% 0.9s167.7 MiB [] 44% 0.8s167.7 MiB [] 46% 0.7s167.7 MiB [] 48% 0.7s167.7 MiB [] 50% 0.7s167.7 MiB [] 52% 0.6s167.7 MiB [] 53% 0.6s167.7 MiB [] 55% 0.6s167.7 MiB [] 57% 0.6s167.7 MiB [] 58% 0.5s167.7 MiB [] 60% 0.5s167.7 MiB [] 62% 0.5s167.7 MiB [] 64% 0.5s167.7 MiB [] 65% 0.4s167.7 MiB [] 66% 0.4s167.7 MiB [] 67% 0.4s167.7 MiB [] 69% 0.4s167.7 MiB [] 72% 0.4s167.7 MiB [] 74% 0.3s167.7 MiB [] 76% 0.3s167.7 MiB [] 78% 0.3s167.7 MiB [] 80% 0.2s167.7 MiB [] 82% 0.2s167.7 MiB [] 85% 0.2s167.7 MiB [] 87% 0.1s167.7 MiB [] 89% 0.1s167.7 MiB [] 91% 0.1s167.7 MiB [] 92% 0.1s167.7 MiB [] 95% 0.1s167.7 MiB [] 96% 0.0s167.7 MiB [] 98% 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 [] 4% 0.4s2.3 MiB [] 11% 0.3s2.3 MiB [] 23% 0.2s2.3 MiB [] 47% 0.1s2.3 MiB [] 81% 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% 22.6s101.4 MiB [] 0% 12.7s101.4 MiB [] 0% 7.9s101.4 MiB [] 1% 4.6s101.4 MiB [] 2% 3.2s101.4 MiB [] 5% 1.9s101.4 MiB [] 7% 1.4s101.4 MiB [] 9% 1.2s101.4 MiB [] 10% 1.3s101.4 MiB [] 11% 1.4s101.4 MiB [] 12% 1.4s101.4 MiB [] 14% 1.2s101.4 MiB [] 15% 1.2s101.4 MiB [] 17% 1.2s101.4 MiB [] 18% 1.1s101.4 MiB [] 20% 1.1s101.4 MiB [] 22% 1.0s101.4 MiB [] 25% 0.9s101.4 MiB [] 27% 0.9s101.4 MiB [] 30% 0.8s101.4 MiB [] 33% 0.7s101.4 MiB [] 34% 0.7s101.4 MiB [] 35% 0.7s101.4 MiB [] 37% 0.7s101.4 MiB [] 40% 0.6s101.4 MiB [] 42% 0.6s101.4 MiB [] 43% 0.6s101.4 MiB [] 45% 0.6s101.4 MiB [] 46% 0.6s101.4 MiB [] 47% 0.6s101.4 MiB [] 50% 0.5s101.4 MiB [] 53% 0.5s101.4 MiB [] 57% 0.4s101.4 MiB [] 58% 0.4s101.4 MiB [] 60% 0.4s101.4 MiB [] 62% 0.4s101.4 MiB [] 65% 0.3s101.4 MiB [] 67% 0.3s101.4 MiB [] 69% 0.3s101.4 MiB [] 73% 0.2s101.4 MiB [] 75% 0.2s101.4 MiB [] 76% 0.2s101.4 MiB [] 77% 0.2s101.4 MiB [] 80% 0.2s101.4 MiB [] 82% 0.2s101.4 MiB [] 83% 0.2s101.4 MiB [] 88% 0.1s101.4 MiB [] 92% 0.1s101.4 MiB [] 97% 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’ll check the root AGENTS.md file per the instructions before proceeding with the feature.
root@10d3a4e8077f:/workspace# ls
scubaduck
root@10d3a4e8077f:/workspace# cd scubaduck
root@10d3a4e8077f:/workspace/scubaduck# ls
AGENTS.md codex_setup.sh scubaduck tests
README.md pyproject.toml stubs uv.lock
Time to take a look at the AGENTS.md in the repo root.
root@10d3a4e8077f:/workspace/scubaduck# 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
Looking over the instructions: I'll start by checking out the repo layout and structure.
root@10d3a4e8077f:/workspace/scubaduck# ls -R | head
.:
AGENTS.md
README.md
codex_setup.sh
pyproject.toml
scubaduck
stubs
tests
uv.lock
I’ll check the README to get familiar with the project's details.
root@10d3a4e8077f:/workspace/scubaduck# cat README.md | head -n 20
I really like Scuba (Meta's internal real-time database system). The distributed
, real-time database part of Scuba is quite difficult (and expensive) to replica
te, but I also really like Scuba's UI for doing queries, and I have found myself
wishing that I have access to it even for "small" databases, e.g., I have a sql
ite dataset I want to explore.
Pivotal ideas:
* Time series by default. In the dedicated "time series" view, there are many fe
atures specifically oriented towards working towards tables that represent event
s that occurred over time: the start, end, compare, aggregate and granularity fi
elds all specially privilege the timestamp field. In fact, you can't log events
to Scuba's backing data store without a timestamp, they always come with one. (S
cuba also supports other views that don't presuppose a time series, but the time
series is the most beloved and well used view.) This is in contrast to typical
software which tries to generalize to arbitrary data first, with time series bei
ng added on later.
* It's all about exploration. Scuba is predicated on the idea that you don't kno
w what you're looking for, that you are going to spend time tweaking queries and
changing filters/grouping as part of an investigation to figure out why a syste
m behaves the way it is. So the filters/comparisons/groupings you want to edit a
re always visible on the left sidebar, with the expectation that you're going to
tweak the query to look at something else. Similarly, all the parameters of you
r query get saved into your URL, so your browser history can double up as a quer
y history / you can easily share a query with someone else. This is contrast to
typical software which is often oriented to making pretty dashboards and reports
. (This function is important too, but it's not what I want in exploration mode!
)
* You can fix data problems in the query editor. It's pretty common to have mess
ed up and ended up with a database that doesn't have exactly the columns you nee
d, or some columns that are corrupted in some way. Scuba has pretty robust suppo
rt for defining custom columns with arbitrary SQL functions, grouping over them
as if they were native functions, and doing so with minimal runtime cost (Scuba
aims to turn around your query in milliseconds!) Having to go and run a huge dat
a pipeline to fix your data is a big impediment to exploration; quick and easy c
ustom columns means you can patch over problems when you're investigating and fi
x them for real later.
We're going to build a exploratory data analysis tool like Scuba for time series
database (i.e., a database with a mandatory timestamp representing the time an
event occurred). We'll use DuckDB as the underlying SQL engine served from a Py
thon server, and render the GUI/results as a webpage with vanilla HTML and JS. W
e'll use choices.js to support token inputs. We define a token input to mean a
text input element where as you type a dropdown displays with valid values, and
if you select one or press enter, the selection turns into a token/chip that can
only be deleted as one unit.
To start, we are going to support one views: samples. The samples view only all
ows you to view individual samples from the database, subject to a filter. Our m
ain UI concept is that there is a left sidebar that is the query editor, and the
right side that shows the view. The sidebar is always visible and defaults to
the query parameters of the current view. After you make changes to the query,
clicking the "Dive" button updates the view. The URL of the page encodes all of
the values of the query (and gets updated when you Dive), so the browser's back
button lets you view previous queries.
The query editor's job is to generate a SQL query, which then is applied on the
database, and then the result visualized according to the view.
Here are the settings you can apply to the query. The help text should show up w
hen you mouse over the field name:
* Start/End - Help text: "Sets the start/end of the time range to query. Can be
any kind of datetime string. For example: 'April 23, 2014' or 'yesterday'." The
UI for this selector supports both relative selections (now, -1 hour, -3 hours,
-12 hours, -1 day, -3 days, -1 week, -1 fortnight, -30 days, -90 days) as well a
s specifying an absolute date. The way this field is rendered is there is a fre
e form text box, a drop down arrow (for the relative selectors), and then a cale
ndar button (for date selection).
* Order By - Help text: "Choose a column to sort results by." There is an ASC/D
ESC toggle next to it.
The README is large, but I'll focus on reviewing the code and directory structure.
root@10d3a4e8077f:/workspace/scubaduck# ls scubaduck
__init__.py sample.csv server.py static
I'll take a look at the server.py file now.
root@10d3a4e8077f:/workspace/scubaduck# sed -n '1,160p' scubaduck/server.py
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any, Dict, List, Tuple
import re
from datetime import datetime, timedelta, timezone
import time
from pathlib import Path
import sqlite3
import traceback
import duckdb
from dateutil import parser as dtparser
from flask import Flask, jsonify, request, send_from_directory
@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"
def _load_database(path: Path) -> duckdb.DuckDBPyConnection:
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()
sconn = sqlite3.connect(path)
info = sconn.execute("PRAGMA table_info(events)").fetchall()
col_defs = ", ".join(f"{r[1]} {r[2]}" for r in info)
con.execute(f"CREATE TABLE events ({col_defs})")
placeholders = ",".join("?" for _ in info)
for row in sconn.execute("SELECT * FROM events"):
con.execute(f"INSERT INTO events VALUES ({placeholders})", row)
sconn.close()
else:
con = duckdb.connect(path)
return con
_REL_RE = re.compile(
r"([+-]?\d+(?:\.\d*)?)\s*(hour|hours|day|days|week|weeks|fortnight|fortnight
s)",
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()
delta: timedelta
if unit.startswith("hour"):
delta = timedelta(hours=qty)
elif unit.startswith("day"):
delta = timedelta(days=qty)
elif unit.startswith("week"):
delta = timedelta(weeks=qty)
elif unit.startswith("fortnight"):
delta = timedelta(weeks=2 * qty)
else: # pragma: no cover - defensive
raise ValueError(f"Unsupported unit: {unit}")
dt = datetime.now(timezone.utc) + delta
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 _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
def build_query(params: QueryParams, column_types: Dict[str, str] | None = None)
-> str:
select_parts: list[str] = []
group_cols = params.group_by[:]
if params.graph_type == "timeseries":
sec = _granularity_seconds(params.granularity, params.start, params.end)
x_axis = params.x_axis or "timestamp"
bucket_expr = (
f"TIMESTAMP 'epoch' + INTERVAL '{sec} second' * "
f"CAST(floor(epoch({x_axis})/{sec}) AS BIGINT)"
)
select_parts.append(f"{bucket_expr} AS bucket")
group_cols = ["bucket"] + group_cols
has_agg = bool(group_cols) or params.aggregate is not None
if has_agg:
select_parts.extend(group_cols)
agg = (params.aggregate or "avg").lower()
def agg_expr(col: str) -> str:
if agg.startswith("p"):
quant = float(agg[1:]) / 100
return f"quantile({col}, {quant})"
if agg == "count distinct":
return f"count(DISTINCT {col})"
root@10d3a4e8077f:/workspace/scubaduck# sed -n '160,320p' scubaduck/server.py
return f"count(DISTINCT {col})"
if agg == "avg" and column_types is not None:
ctype = column_types.get(col, "").upper()
if "TIMESTAMP" in ctype or "DATE" in ctype or "TIME" in ctype:
return (
"TIMESTAMP 'epoch' + INTERVAL '1 second' * "
f"CAST(avg(epoch({col})) AS BIGINT)"
)
return f"{agg}({col})"
for col in params.columns:
if col in group_cols:
continue
select_parts.append(f"{agg_expr(col)} AS {col}")
if params.show_hits:
select_parts.insert(len(group_cols), "count(*) AS Hits")
else:
select_parts.extend(params.columns)
for name, expr in params.derived_columns.items():
select_parts.append(f"{expr} AS {name}")
select_clause = ", ".join(select_parts) if select_parts else "*"
query = f"SELECT {select_clause} FROM events"
where_parts: list[str] = []
if params.start:
where_parts.append(f"timestamp >= '{params.start}'")
if params.end:
where_parts.append(f"timestamp <= '{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 == "=":
vals = " OR ".join(f"{f.column} = '{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)
if op == "contains":
where_parts.append(f"{f.column} ILIKE '%' || {val} || '%'")
elif op == "!contains":
where_parts.append(f"{f.column} NOT ILIKE '%' || {val} || '%'")
elif op == "empty":
where_parts.append(f"{f.column} = {val}")
elif op == "!empty":
where_parts.append(f"{f.column} != {val}")
else:
where_parts.append(f"{f.column} {op} {val}")
if where_parts:
query += " WHERE " + " AND ".join(where_parts)
if group_cols:
query += " GROUP BY " + ", ".join(group_cols)
if params.order_by:
query += f" ORDER BY {params.order_by} {params.order_dir}"
if params.limit is not None:
query += f" LIMIT {params.limit}"
return query
def create_app(db_file: str | Path | None = None) -> Flask:
app = Flask(__name__, static_folder="static")
db_path = Path(db_file or Path(__file__).with_name("sample.csv")).resolve()
con = _load_database(db_path)
column_types: Dict[str, str] = {
r[1]: r[2] for r in con.execute("PRAGMA table_info(events)").fetchall()
}
sample_cache: Dict[Tuple[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("/api/columns")
def columns() -> Any: # pyright: ignore[reportUnusedFunction]
rows = con.execute("PRAGMA table_info(events)").fetchall()
return jsonify([{"name": r[1], "type": r[2]} for r in rows])
def _cache_get(key: Tuple[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], 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]
column = request.args.get("column")
substr = request.args.get("q", "")
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 = (column, substr)
cached = _cache_get(key)
if cached is not None:
return jsonify(cached)
rows = con.execute(
f"SELECT DISTINCT {column} FROM events WHERE CAST({column} AS VARCHA
R) 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"),
)
for f in payload.get("filters", []):
params.filters.append(Filter(f["column"], f["op"], f.get("value")))
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"
}
),
root@10d3a4e8077f:/workspace/scubaduck# sed -n '320,640p' scubaduck/server.py
),
400,
)
valid_cols = set(column_types.keys())
if params.graph_type == "timeseries":
if params.x_axis is None:
for cand in ["time", "timestamp"]:
if cand in valid_cols:
params.x_axis = cand
break
if params.x_axis is None or params.x_axis not in valid_cols:
return jsonify({"error": "Invalid x_axis"}), 400
ctype = column_types.get(params.x_axis, "").upper()
if not any(t in ctype for t in ["TIMESTAMP", "DATE", "TIME"]):
return jsonify({"error": "x_axis must be a time column"}), 400
for col in params.columns:
if col not in valid_cols:
return jsonify({"error": f"Unknown column: {col}"}), 400
for col in params.group_by:
if col not in valid_cols:
return jsonify({"error": f"Unknown column: {col}"}), 400
if params.order_by and params.order_by not in valid_cols:
return jsonify({"error": f"Unknown column: {params.order_by}"}), 400
if params.group_by:
agg = (params.aggregate or "avg").lower()
if agg.startswith("p") or agg == "sum":
need_numeric = True
allow_time = False
elif agg == "avg" or agg in {"min", "max"}:
need_numeric = False
allow_time = True
else:
need_numeric = False
allow_time = False
if need_numeric or allow_time:
for c in params.columns:
if c in params.group_by or c == params.x_axis:
continue
ctype = column_types.get(c, "").upper()
is_numeric = any(
t in ctype
for t in [
"INT",
"DECIMAL",
"REAL",
"DOUBLE",
"FLOAT",
"NUMERIC",
"HUGEINT",
]
)
is_time = "TIMESTAMP" in ctype or "DATE" in ctype or "TIME"
in ctype
if need_numeric and not is_numeric:
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,
)
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,
)
return jsonify({"sql": sql, "rows": rows})
return app
app = create_app()
if __name__ == "__main__":
app.run(debug=True)
I need to look at the static directory to understand how to modify for derived columns.
root@10d3a4e8077f:/workspace/scubaduck# ls scubaduck/static
index.html
root@10d3a4e8077f:/workspace/scubaduck# cat scubaduck/static/index.html | head -
n 40
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>ScubaDuck</title>
<style>
body { margin: 0; font-family: sans-serif; height: 100vh; display: flex; fle
x-direction: column; }
#header { padding: 10px; font-weight: bold; border-bottom: 1px solid #ccc; }
#content { flex: 1; display: flex; height: calc(100vh - 42px); overflow: hid
den; }
#sidebar { width: 450px; padding: 10px; border-right: 3px solid #ccc; overfl
ow-y: auto; display: flex; flex-direction: column; box-sizing: border-box; }
#sidebar-resizer { width: 5px; cursor: col-resize; background: #ccc; }
#view { flex: 1; padding: 10px; overflow-y: auto; overflow-x: auto; }
.field { display: flex; align-items: center; margin-bottom: 10px; }
.field label { width: 80px; text-align: right; margin-right: 5px; }
.help { margin-left: 4px; cursor: help; }
.rel-btn { margin-left: 4px; }
#tabs { display: flex; align-items: center; margin-bottom: 10px; }
#tabs .tab { margin-right: 5px; background: none; border: 1px solid #ccc; pa
dding: 4px 8px; cursor: pointer; width: 120px; text-align: center; box-sizing: b
order-box; }
#tabs .tab.active { background: #eee; font-weight: bold; }
#dive { margin-left: auto; background: green; color: white; border: none; pa
dding: 5px 10px; cursor: pointer; }
.tab-content { display: none; }
.tab-content.active { display: block; }
#filter_list { display: flex; flex-direction: column; }
#filters .filter {
border: 1px solid #ccc;
padding: 5px;
margin-bottom: 5px;
position: relative;
display: flex;
flex-direction: column;
}
#filters .filter-row { display: flex; margin-bottom: 5px; }
#filters .filter-row .f-col { flex: 1; }
#filters .filter-row .f-op {
margin-left: 5px;
width: fit-content;
flex: 0 0 auto;
}
.chip-input input {
border: none;
root@10d3a4e8077f:/workspace/scubaduck# sed -n '40,80p' scubaduck/static/index.h
tml
border: none;
flex: 1;
min-width: 60px;
margin: 2px;
outline: none;
}
.chip-box { position: relative; }
.chip-input { display: flex; flex-wrap: wrap; border: 1px solid #ccc; paddin
g: 2px; min-height: 24px; }
.chip { background: #eee; border: 1px solid #999; padding: 2px 4px; margin:
2px; border-radius: 3px; display: flex; align-items: center; }
.chip .x { margin-left: 4px; cursor: pointer; }
.chip-copy { margin-left: 4px; cursor: pointer; background: none; border: no
ne; }
.chip-dropdown { position: absolute; left: 0; right: 0; top: 100%; backgroun
d: white; border: 1px solid #ccc; max-height: 120px; overflow-y: auto; z-index:
10; display: none; }
.chip-dropdown div { padding: 2px 4px; cursor: pointer; }
.chip-dropdown div.highlight { background: #bde4ff; }
.rel-box { position: relative; display: flex; }
.rel-dropdown { position: absolute; left: 0; right: 0; top: 100%; background
: white; border: 1px solid #ccc; z-index: 10; display: none; }
.rel-dropdown div { padding: 2px 4px; cursor: pointer; }
.rel-dropdown div:hover { background: #bde4ff; }
.dropdown { position: relative; display: inline-block; }
.dropdown-display {
border: 1px solid #ccc;
padding: 2px 18px 2px 4px;
cursor: pointer;
min-width: 80px;
position: relative;
}
.dropdown-display::after {
content: '\25BC';
position: absolute;
right: 4px;
pointer-events: none;
}
.dropdown-menu { position: absolute; left: 0; right: 0; top: 100%; backgroun
d: white; border: 1px solid #ccc; z-index: 10; max-height: 160px; overflow-y: au
to; display: none; }
.dropdown-menu input { width: 100%; box-sizing: border-box; padding: 2px 4px
; border: none; border-bottom: 1px solid #ccc; }
.dropdown-menu div { padding: 2px 4px; cursor: pointer; }
.dropdown-menu div.selected { background: #bde4ff; }
.dropdown-menu .option:hover { background: #eee; }
.dropdown-menu input::placeholder { color: #999; }
#filters .filter button.remove {
margin-left: 5px;
width: 20px;
I'm looking through the JavaScript, just a bit more to make sure I have everything.
root@10d3a4e8077f:/workspace/scubaduck# sed -n '80,160p' scubaduck/static/index.
html
width: 20px;
flex: 0 0 auto;
padding: 0;
text-align: center;
line-height: 1;
}
#filters h4 { margin: 0 0 5px 0; }
table { border-collapse: collapse; min-width: 100%; }
th, td { border: 1px solid #ccc; padding: 4px; box-sizing: border-box; }
th { text-align: left; cursor: pointer; position: relative; }
th.sorted { color: blue; }
tr:nth-child(even) td { background: #f9f9f9; }
tr.selected td { background: #bde4ff !important; }
tr:hover:not(.selected) td { background: #eee; }
#column_actions {
text-align: right;
margin-bottom: 5px;
}
#column_actions a {
margin-left: 5px;
}
.col-group-header {
overflow: hidden;
}
.col-group-header .links {
float: right;
}
.col-group-header .links a {
margin-left: 5px;
}
/* Column resizer removed */
</style>
</head>
<body>
<div id="header">sample.csv - events <select id="graph_type"><option value="sa
mples">Samples</option><option value="table">Table</option><option value="timese
ries">Time Series</option></select></div>
<div id="content">
<div id="sidebar">
<div id="tabs">
<button class="tab active" data-tab="settings">View Settings</button>
<button class="tab" data-tab="columns">Columns</button>
<button id="dive" onclick="dive()">Dive</button>
</div>
<div id="settings" class="tab-content active">
<div class="field">
<label>Start<span class="help" title="Sets the start/end of the time r
ange to query. Can be any kind of datetime string. For example: 'April 23, 2014'
or 'yesterday'.">[?]</span></label>
<div class="rel-box">
<input id="start" type="text" />
<button type="button" class="rel-btn" data-target="start-select">	
660;</button>
<div id="start-select" class="rel-dropdown" data-input="start">
<div data-value="-1 hour">-1 hour</div>
<div data-value="-3 hours">-3 hours</div>
<div data-value="-12 hours">-12 hours</div>
<div data-value="-1 day">-1 day</div>
<div data-value="-3 days">-3 days</div>
<div data-value="-1 week">-1 week</div>
<div data-value="-1 fortnight">-1 fortnight</div>
<div data-value="-30 days">-30 days</div>
<div data-value="-90 days">-90 days</div>
</div>
</div>
</div>
<div class="field">
<label>End<span class="help" title="Sets the start/end of the time ran
ge to query. Can be any kind of datetime string. For example: 'April 23, 2014' o
r 'yesterday'.">[?]</span></label>
<div class="rel-box">
<input id="end" type="text" />
<button type="button" class="rel-btn" data-target="end-select">φ
0;</button>
<div id="end-select" class="rel-dropdown" data-input="end">
<div data-value="now">now</div>
<div data-value="-1 hour">-1 hour</div>
<div data-value="-3 hours">-3 hours</div>
<div data-value="-12 hours">-12 hours</div>
<div data-value="-1 day">-1 day</div>
<div data-value="-3 days">-3 days</div>
<div data-value="-1 week">-1 week</div>
<div data-value="-1 fortnight">-1 fortnight</div>
<div data-value="-30 days">-30 days</div>
<div data-value="-90 days">-90 days</div>
</div>
</div>
</div>
<div class="field">
I need to move ahead a bit more to capture the rest of the info.
root@10d3a4e8077f:/workspace/scubaduck# sed -n '160,320p' scubaduck/static/index
.html
<div class="field">
<label>Order By<span class="help" title="Choose a column to sort resul
ts by.">[?]</span></label>
<select id="order_by"></select>
<button id="order_dir" type="button">ASC \u25B2</button>
</div>
<div class="field">
<label>Limit<span class="help" title="Choose the maximum number of res
ults to show in the chart after any aggregations have been applied. For example,
a limit of 10 will show no more than 10 rows for a table, etc.">[?]</span></lab
el>
<input id="limit" type="number" value="100" />
</div>
<div id="x_axis_field" class="field" style="display:none;">
<label>X-axis</label>
<select id="x_axis"></select>
</div>
<div id="granularity_field" class="field" style="display:none;">
<label>Granularity</label>
<select id="granularity">
<option>Auto</option>
<option>Fine</option>
<option>1 second</option>
<option>5 seconds</option>
<option>10 seconds</option>
<option>30 seconds</option>
<option>1 minute</option>
<option>4 minutes</option>
<option>5 minutes</option>
<option>10 minutes</option>
<option>15 minutes</option>
<option>30 minutes</option>
<option>1 hour</option>
<option>3 hours</option>
<option>6 hours</option>
<option>1 day</option>
<option>1 week</option>
<option>30 days</option>
</select>
</div>
<div id="fill_field" class="field" style="display:none;">
<label>Fill Missing Buckets</label>
<select id="fill">
<option value="0">Fill with 0 (Per Series)</option>
<option value="connect">Connect (Per Series)</option>
<option value="blank">Leave blank</option>
</select>
</div>
<div id="group_by_field" class="field" style="display:none;">
<label>Group By</label>
<div class="chip-box">
<div class="chip-input">
<input id="group_by" class="f-val" type="text">
<button type="button" class="chip-copy">⎘</button>
</div>
<div class="chip-dropdown"></div>
</div>
</div>
<div id="aggregate_field" class="field" style="display:none;">
<label>Aggregate</label>
<select id="aggregate">
<option>Avg</option>
<option>Count</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>
<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>
</div>
<div id="sidebar-resizer"></div>
<div id="view">
<table id="results"></table>
</div>
</div>
<script>
const allColumns = [];
const columnTypes = {};
const stringColumns = [];
const integerColumns = [];
const timeColumns = [];
let selectedColumns = [];
let displayType = 'samples';
let groupBy = {chips: [], addChip: () => {}, renderChips: () => {}};
function initDropdown(select) {
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();
root@10d3a4e8077f:/workspace/scubaduck# sed -n '320,640p' scubaduck/static/index
.html
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 => {
evt.preventDefault();
select.value = o.value;
select.dispatchEvent(new Event('change'));
updateDisplay();
close();
});
list.appendChild(div);
});
}
search.addEventListener('input', renderOptions);
select.addEventListener('change', updateDisplay);
}
// Sidebar resizing
const sidebar = document.getElementById('sidebar');
const sidebarResizer = document.getElementById('sidebar-resizer');
let sidebarWidth = parseInt(localStorage.getItem('sidebarWidth') || 450, 10);
sidebar.style.width = sidebarWidth + 'px';
let sidebarResize = false;
function startSidebarDrag(e) {
e.preventDefault();
sidebarResize = true;
document.addEventListener('mousemove', onSidebarDrag);
document.addEventListener('mouseup', stopSidebarDrag);
}
function onSidebarDrag(e) {
if (!sidebarResize) return;
sidebarWidth = Math.max(200, e.clientX - sidebar.getBoundingClientRect().left)
;
sidebar.style.width = sidebarWidth + 'px';
}
function stopSidebarDrag() {
document.removeEventListener('mousemove', onSidebarDrag);
document.removeEventListener('mouseup', stopSidebarDrag);
sidebarResize = false;
localStorage.setItem('sidebarWidth', sidebarWidth);
}
sidebarResizer.addEventListener('mousedown', startSidebarDrag);
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 showTable = graphTypeSel.value === 'table';
const showTS = graphTypeSel.value === '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' : '';
}
});
if (showTS) {
const lim = document.getElementById('limit');
if (!lim.dataset.setByUser) {
lim.value = '7';
}
}
displayType = graphTypeSel.value;
}
orderDirBtn.addEventListener('click', () => {
orderDir = orderDir === 'ASC' ? 'DESC' : 'ASC';
updateOrderDirButton();
});
updateOrderDirButton();
graphTypeSel.addEventListener('change', updateDisplayTypeUI);
fetch('/api/columns').then(r => r.json()).then(cols => {
const orderSelect = document.getElementById('order_by');
const xAxisSelect = document.getElementById('x_axis');
const groupsEl = document.getElementById('column_groups');
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);
let g = 'string';
if (t.includes('INT')) g = 'integer';
if (t.includes('TIMESTAMP')) {
g = 'time';
...
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 => {
if (!dd.parentElement.contains(e.target)) dd.style.display = 'none';
});
});
function updateSelectedColumns() {
const base = allColumns.filter(name => {
const cb = document.querySelector(`#column_groups input[value="${name}"]`);
if (!cb || !cb.checked) return false;
if (graphTypeSel.value === 'table' && isStringColumn(name)) return false;
return true;
});
if (graphTypeSel.value === 'table' || graphTypeSel.value === 'timeseries') {
selectedColumns = groupBy.chips.slice();
if (document.getElementById('show_hits').checked) selectedColumns.push('Hits
');
base.forEach(c => {
if (!selectedColumns.includes(c)) selectedColumns.push(c);
});
} else {
selectedColumns = base;
}
}
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();
return t.includes('TIMESTAMP');
}
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';
return val.toFixed(3);
}
function initChipInput(filter, fetchOptions) {
const input = filter.querySelector('.f-val');
const chipsEl = filter.querySelector('.chip-input');
const dropdown = filter.querySelector('.chip-dropdown');
const copyBtn = filter.querySelector('.chip-copy');
filter.chips = [];
filter.renderChips = renderChips;
filter.addChip = addChip;
let highlight = 0;
let dropdownLocked = false;
chipsEl.addEventListener('click', () => {
input.focus();
});
function renderChips() {
chipsEl.querySelectorAll('.chip').forEach(c => c.remove());
filter.chips.forEach((v, i) => {
const span = document.createElement('span');
span.className = 'chip';
span.textContent = v;
const x = document.createElement('span');
x.className = 'x';
x.textContent = '✖';
x.addEventListener('click', e => {
e.stopPropagation();
filter.chips.splice(i, 1);
renderChips();
input.focus();
});
span.appendChild(x);
chipsEl.insertBefore(span, input);
});
}
I’ll see if I can find references for 'groupBy' in the code.
root@10d3a4e8077f:/workspace/scubaduck# grep -n "addDerived" -n scubaduck/static
/index.html
I’ll check the bottom for the dive function and any relevant details.
root@10d3a4e8077f:/workspace/scubaduck# grep -n "function dive" -n scubaduck/sta
tic/index.html
841:function dive(push=true) {
root@10d3a4e8077f:/workspace/scubaduck# sed -n '840,940p' scubaduck/static/index
.html
function dive(push=true) {
const payload = collectParams();
if (push) {
history.pushState(payload, '', paramsToSearch(payload));
}
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 = {
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'),
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};
})
};
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') {
payload.x_axis = document.getElementById('x_axis').value;
payload.granularity = document.getElementById('granularity').value;
payload.fill = document.getElementById('fill').value;
}
return payload;
}
function paramsToSearch(params) {
const sp = new URLSearchParams();
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.columns && params.columns.length) sp.set('columns', params.columns.
join(','));
if (params.filters && params.filters.length) sp.set('filters', JSON.stringify(
params.filters));
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) {
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;
}
graphTypeSel.value = params.graph_type || 'samples';
updateDisplayTypeUI();
if (params.x_axis) document.getElementById('x_axis').value = params.x_axis;
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);
I'm continuing to scroll for more details.
root@10d3a4e8077f:/workspace/scubaduck# sed -n '940,1120p' scubaduck/static/inde
x.html
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;
document.querySelectorAll('#column_groups input').forEach(cb => {
cb.checked = !params.columns || params.columns.includes(cb.value);
});
updateSelectedColumns();
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('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('columns')) params.columns = sp.get('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');
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);
});
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');
}
if (!isStringColumn(col)) th.style.textAlign = 'right';
header.appendChild(th);
});
table.appendChild(header);
rows.forEach(row => {
const tr = document.createElement('tr');
tr.addEventListener('click', () => {
tr.classList.toggle('selected');
});
row.forEach((v, i) => {
const col = selectedColumns[i];
const td = document.createElement('td');
if (isTimeColumn(col)) {
const d = new Date(v);
td.textContent = d.toLocaleString('en-US', {
weekday: 'short',
month: 'short',
day: 'numeric',
year: 'numeric',
hour: 'numeric',
minute: 'numeric',
second: 'numeric',
hour12: true,
timeZoneName: 'short'
});
} 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);
}
}
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;
});
}
renderTable(rows);
}
function showTimeSeries(data) {
const view = document.getElementById('view');
if (data.rows.length === 0) {
view.innerHTML = '<p id="empty-message">Empty data provided to table</p>';
return;
}
const width = 600;
const height = 400;
view.innerHTML = '<div id="legend"></div><svg id="chart" width="' + width + '"
height="' + height + '"></svg>';
const svg = document.getElementById('chart');
const legend = document.getElementById('legend');
const groups = groupBy.chips || [];
I'm scrolling to locate the chip input initialization and find where "initChipInput" is defined.
root@10d3a4e8077f:/workspace/scubaduck# grep -n "initChipInput" -n scubaduck/sta
tic/index.html
508: initChipInput(groupBy, typed =>
607:function initChipInput(filter, fetchOptions) {
828: initChipInput(container, (typed, el) => {
I'll check lines 480-520 for relevant changes.
root@10d3a4e8077f:/workspace/scubaduck# sed -n '480,540p' scubaduck/static/index
.html
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();
groupBy = document.getElementById('group_by').closest('.field');
initChipInput(groupBy, typed =>
allColumns.filter(c => c.toLowerCase().includes(typed.toLowerCase()))
);
initDropdown(orderSelect);
initDropdown(document.getElementById('aggregate'));
document.getElementById('limit').addEventListener('input', e => {
e.target.dataset.setByUser = '1';
});
updateDisplayTypeUI();
addFilter();
initFromUrl();
});
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');
I’m paging through until line 560 to check out more content.
root@10d3a4e8077f:/workspace/scubaduck# sed -n '540,620p' scubaduck/static/index
.html
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 => {
if (!dd.parentElement.contains(e.target)) dd.style.display = 'none';
});
});
function updateSelectedColumns() {
const base = allColumns.filter(name => {
const cb = document.querySelector(`#column_groups input[value="${name}"]`);
if (!cb || !cb.checked) return false;
if (graphTypeSel.value === 'table' && isStringColumn(name)) return false;
return true;
});
if (graphTypeSel.value === 'table' || graphTypeSel.value === 'timeseries') {
selectedColumns = groupBy.chips.slice();
if (document.getElementById('show_hits').checked) selectedColumns.push('Hits
');
base.forEach(c => {
if (!selectedColumns.includes(c)) selectedColumns.push(c);
});
} else {
selectedColumns = base;
}
}
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();
return t.includes('TIMESTAMP');
}
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';
return val.toFixed(3);
}
function initChipInput(filter, fetchOptions) {
const input = filter.querySelector('.f-val');
const chipsEl = filter.querySelector('.chip-input');
const dropdown = filter.querySelector('.chip-dropdown');
const copyBtn = filter.querySelector('.chip-copy');
filter.chips = [];
filter.renderChips = renderChips;
filter.addChip = addChip;
let highlight = 0;
let dropdownLocked = false;
chipsEl.addEventListener('click', () => {
input.focus();
});
I need to adapt the front-end to support derived columns, including multi-input filters and URL storage. I should check for existing multi-input features and review filter code near 'addFilter'.
root@10d3a4e8077f:/workspace/scubaduck# grep -n "addFilter" -n scubaduck/static/
index.html
242: <button id="add_filter" type="button" onclick="addFilter()">Add Fi
lter</button>
517: addFilter();
772:function addFilter() {
953: addFilter();
967: addFilter();
I'll start by focusing on the lines near line 772.
root@10d3a4e8077f:/workspace/scubaduck# sed -n '760,820p' scubaduck/static/index
.html
}
input.addEventListener('focus', loadOptions);
input.addEventListener('input', loadOptions);
document.addEventListener('click', evt => {
if (evt.target !== input) {
hideDropdown();
}
});
}
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'],
]
: [
['=', '='],
['!=', '!='],
['<', '<'],
['>', '>'],
];
opSel.innerHTML = ops.map(o => `<option value="${o[0]}">${o[1]}</option>`).j
oin('');
updateInputVis();
}
function updateInputVis() {
const op = container.querySelector('.f-op').value;
const box = container.querySelector('.chip-box');
root@10d3a4e8077f:/workspace/scubaduck# sed -n '820,900p' scubaduck/static/index
.html
const box = container.querySelector('.chip-box');
box.style.display = op === 'empty' || op === '!empty' ? 'none' : 'block';
}
colSel.addEventListener('change', populateOps);
container.querySelector('.f-op').addEventListener('change', updateInputVis);
populateOps();
document.getElementById('filter_list').appendChild(container);
initChipInput(container, (typed, el) => {
const colEl = el.querySelector('.f-col select') || el.querySelector('.f-col'
);
if (!colEl) return [];
const col = colEl.value;
if (!isStringColumn(col)) return [];
return fetch(`/api/samples?column=${encodeURIComponent(col)}&q=${encodeURICo
mponent(typed)}`)
.then(r => r.json());
});
}
let lastQueryTime = 0;
let queryStart = 0;
function dive(push=true) {
const payload = collectParams();
if (push) {
history.pushState(payload, '', paramsToSearch(payload));
}
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 = {
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'),
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};
})
};
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') {
payload.x_axis = document.getElementById('x_axis').value;
payload.granularity = document.getElementById('granularity').value;
payload.fill = document.getElementById('fill').value;
}
return payload;
}
function paramsToSearch(params) {
const sp = new URLSearchParams();
I'm checking the front-end initialization but need to search for derived column functionality.
root@10d3a4e8077f:/workspace/scubaduck# ls tests
conftest.py test_server.py test_web.py
from scubaduck import server
import pytest
def test_basic_query() -> None:
app = server.app
client = app.test_client()
payload = {
"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_filter_multi_token() -> None:
app = server.app
client = app.test_client()
payload = {
"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 = {
"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 = {
"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 _make_payload() -> dict[str, object]:
return {
"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": [],
}
def test_database_types(tmp_path: Path) -> None:
csv_file = tmp_path / "events.csv"
csv_file.write_text(Path("scubaduck/sample.csv").read_text())
sqlite_file = tmp_path / "events.sqlite"
import sqlite3
conn = sqlite3.connect(sqlite_file)
conn.execute(
"CREATE TABLE events (timestamp TEXT, event TEXT, value INTEGER, user TE
XT)"
)
with open(csv_file) as f:
next(f)
for line in f:
ts, ev, val, user = line.strip().split(",")
conn.execute(
"INSERT INTO events VALUES (?, ?, ?, ?)", (ts, ev, int(val), use
r)
)
conn.commit()
conn.close() # pyright: ignore[reportUnknownMemberType, reportAttributeAcce
ssIssue]
duckdb_file = tmp_path / "events.duckdb"
con = duckdb.connect(duckdb_file)
con.execute(
f"CREATE TABLE events AS SELECT * FROM read_csv_auto('{csv_file.as_posix
()}')"
)
con.close() # pyright: ignore[reportUnknownMemberType, reportAttributeAcces
sIssue]
for db in (csv_file, sqlite_file, duckdb_file):
app = server.create_app(db)
client = app.test_client()
payload = _make_payload()
rv = client.post(
"/api/query", data=json.dumps(payload), content_type="application/js
on"
)
rows = rv.get_json()["rows"]
assert len(rows) == 3
def test_group_by_table() -> None:
app = server.app
client = app.test_client()
payload = {
"start": "2024-01-01 00:00:00",
"end": "2024-01-03 00:00:00",
"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_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 = {
"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()
assert rv.status_code == 200
assert len(data["rows"]) == 1
assert data["rows"][0][3] == "charlie"
def test_invalid_time_error() -> None:
app = server.app
client = app.test_client()
payload = {
"start": "nonsense",
"end": "now",
"order_by": "timestamp",
"limit": 10,
"columns": ["timestamp"],
"filters": [],
}
rv = client.post(
"/api/query", data=json.dumps(payload), content_type="application/json"
)
data = rv.get_json()
assert rv.status_code == 400
assert "error" in data
def test_query_error_returns_sql_and_traceback() -> None:
app = server.app
client = app.test_client()
payload = {
"start": "2024-01-01 00:00:00",
"end": "2024-01-03 00:00:00",
"columns": ["event"],
"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 == 400
assert "error" in data
def test_table_unknown_column_error() -> None:
app = server.app
client = app.test_client()
payload = {
"start": "2024-01-01 00:00:00",
"end": "2024-01-03 00:00:00",
"graph_type": "table",
"order_by": "timestamp",
"limit": 100,
"columns": ["user", "Hits", "value"],
"group_by": ["user"],
"aggregate": "Count",
"show_hits": True,
}
rv = client.post(
"/api/query", data=json.dumps(payload), content_type="application/json"
)
data = rv.get_json()
assert rv.status_code == 400
assert "Unknown column" in data["error"]
def test_samples_view_rejects_group_by() -> None:
app = server.app
client = app.test_client()
payload = {
"graph_type": "samples",
"group_by": ["user"],
"columns": ["timestamp"],
}
rv = client.post(
"/api/query", data=json.dumps(payload), content_type="application/json"
)
data = rv.get_json()
assert rv.status_code == 400
assert "only valid" in data["error"]
def test_table_avg_with_timestamp() -> None:
app = server.app
client = app.test_client()
payload = {
"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"],
}
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 = {
"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
I'll scroll to the end to review the full content.
root@10d3a4e8077f:/workspace/scubaduck# sed -n '400,800p' tests/test_server.py
I should have a look at test_web next to investigate further.
def run_query(
page: Any,
url: str,
*,
start: str | None = None,
end: str | None = None,
order_by: str | None = None,
order_dir: str | None = "ASC",
limit: int | None = None,
group_by: list[str] | None = None,
aggregate: str | None = None,
) -> dict[str, Any]:
page.goto(url)
page.wait_for_selector("#order_by option", state="attached")
page.wait_for_selector("#order_dir", state="attached")
page.wait_for_function("window.lastResults !== undefined")
if start is not None:
page.fill("#start", start)
if end is not None:
page.fill("#end", end)
if order_by is not None:
select_value(page, "#order_by", order_by)
if order_dir is not None and order_dir == "DESC":
page.click("#order_dir")
if limit is not None:
page.fill("#limit", str(limit))
if group_by is not None:
select_value(page, "#graph_type", "table")
page.evaluate(
"g => { groupBy.chips = g; groupBy.renderChips(); }",
group_by,
)
if aggregate is not None:
select_value(page, "#graph_type", "table")
select_value(page, "#aggregate", aggregate)
page.evaluate("window.lastResults = undefined")
page.click("text=Dive")
page.wait_for_function("window.lastResults !== undefined")
return page.evaluate("window.lastResults")
def test_range_filters(page: Any, server_url: str) -> None:
data = run_query(
page,
server_url,
start="2024-01-02 00:00:00",
end="2024-01-02 04:00:00",
order_by="timestamp",
limit=100,
)
assert len(data["rows"]) == 2
from dateutil import parser
timestamps = [parser.parse(row[0]).replace(tzinfo=None) for row in data["row
s"]]
assert timestamps == [
parser.parse("2024-01-02 00:00:00"),
parser.parse("2024-01-02 03:00:00"),
]
def test_order_by(page: Any, server_url: str) -> None:
data = run_query(
page,
server_url,
start="2024-01-01 00:00:00",
end="2024-01-03 00:00:00",
order_by="value",
order_dir="DESC",
limit=100,
)
values = [row[2] for row in data["rows"]]
assert values == sorted(values, reverse=True)
def test_limit(page: Any, server_url: str) -> None:
data = run_query(
page,
server_url,
start="2024-01-01 00:00:00",
end="2024-01-03 00:00:00",
order_by="timestamp",
limit=2,
)
assert len(data["rows"]) == 2
def test_simple_filter(page: Any, server_url: str) -> None:
page.goto(server_url)
page.wait_for_selector("#order_by option", state="attached")
page.click("text=Add Filter")
filter_el = page.query_selector("#filters .filter:last-child")
assert filter_el
page.evaluate(
"arg => setSelectValue(arg.el.querySelector('.f-col'), arg.val)",
{"el": filter_el, "val": "user"},
)
val_input = filter_el.query_selector(".f-val")
val_input.click()
page.keyboard.type("alice")
page.keyboard.press("Enter")
page.click("text=Dive")
page.wait_for_function("window.lastResults !== undefined")
data = page.evaluate("window.lastResults")
assert len(data["rows"]) == 2
assert all(row[3] == "alice" for row in data["rows"])
def test_default_filter_and_layout(page: Any, server_url: str) -> None:
page.goto(server_url)
page.wait_for_selector("#order_by option", state="attached")
count = page.evaluate("document.querySelectorAll('#filters .filter').length"
)
assert count == 1
last_is_button = page.evaluate(
"document.querySelector('#filters').lastElementChild.id === 'add_filter'
"
)
assert last_is_button
position = page.evaluate(
"getComputedStyle(document.querySelector('#filters .filter button.remove
')).position"
)
assert position == "static"
def test_filter_remove_alignment(page: Any, server_url: str) -> None:
page.goto(server_url)
page.wait_for_selector("#order_by option", state="attached")
diff = page.evaluate(
"() => { const r=document.querySelector('#filters .filter-row').getBound
ingClientRect(); const x=document.querySelector('#filters .filter-row button.rem
ove').getBoundingClientRect(); return Math.abs(r.right - x.right); }"
)
assert diff <= 1
def test_header_and_tabs(page: Any, server_url: str) -> None:
page.goto(server_url)
page.wait_for_selector("#order_by option", state="attached")
header = page.text_content("#header")
assert "sample.csv" in header
assert "events" in header
assert page.is_visible("#settings")
assert page.is_hidden("#columns")
page.click("text=Columns")
assert page.is_visible("#columns")
cols = [c.strip() for c in page.locator("#column_groups li").all_inner_texts
()]
assert "timestamp" in cols
assert "event" in cols
page.click("text=View Settings")
assert page.is_visible("#settings")
btn_color = page.evaluate(
"getComputedStyle(document.querySelector('#dive')).backgroundColor"
)
assert "rgb(0, 128, 0)" == btn_color
sidebar_overflow = page.evaluate(
"getComputedStyle(document.querySelector('#sidebar')).overflowY"
)
view_overflow = page.evaluate(
"getComputedStyle(document.querySelector('#view')).overflowY"
)
assert sidebar_overflow == "auto"
assert view_overflow == "auto"
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_help_and_alignment(page: Any, server_url: str) -> None:
page.goto(server_url)
assert text_align == "right"
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="timestamp",
order_dir="ASC",
limit=100,
)
# header alignment
align = page.evaluate(
"getComputedStyle(document.querySelector('#results th')).textAlign"
)
assert align == "right"
header = page.locator("#results th").nth(3)
def values() -> list[str]:
return page.locator("#results td:nth-child(4)").all_inner_texts()
orig_rows = values()
assert orig_rows == ["alice", "bob", "alice", "charlie"]
first_sql = page.evaluate("window.lastResults.sql")
header.click()
assert values() == sorted(orig_rows)
assert header.inner_text().endswith("▲")
color = page.evaluate(
"getComputedStyle(document.querySelector('#results th:nth-child(4)')).co
lor"
)
assert "0, 0, 255" in color
assert page.evaluate("window.lastResults.sql") == first_sql
header.click()
assert values() == sorted(orig_rows, reverse=True)
assert header.inner_text().endswith("▼")
header.click()
assert values() == orig_rows
assert header.inner_text() == "user"
color = page.evaluate(
"getComputedStyle(document.querySelector('#results th:nth-child(4)')).co
lor"
)
assert "0, 0, 255" not in color
def test_relative_dropdown(page: Any, server_url: str) -> None:
page.goto(server_url)
page.wait_for_selector("#order_by option", state="attached")
btn = page.query_selector('[data-target="start-select"]')
assert btn
btn.click()
page.click("#start-select div:text('-3 hours')")
assert page.input_value("#start") == "-3 hours"
def test_end_dropdown_now(page: Any, server_url: str) -> None:
page.goto(server_url)
page.wait_for_selector("#order_by option", state="attached")
page.click('[data-target="end-select"]')
page.click("#end-select div:text('now')")
assert page.input_value("#end") == "now"
def test_invalid_time_error_shown(page: Any, server_url: str) -> None:
data = run_query(
page,
server_url,
start="nonsense",
end="now",
order_by="timestamp",
)
assert "error" in data
msg = page.text_content("#view")
assert "nonsense" in msg
def test_table_avg_group_by(page: Any, server_url: str) -> None:
data = run_query(
page,
server_url,
start="2024-01-01 00:00:00",
end="2024-01-03 00:00:00",
order_by="timestamp",
group_by=["user"],
aggregate="Avg",
)
assert "error" not in data
assert len(data["rows"]) == 3
def test_column_toggle_and_selection(page: Any, server_url: str) -> None:
page.goto(server_url)
page.wait_for_selector("#order_by option", state="attached")
page.click("text=Columns")
page.wait_for_selector("#column_groups input", state="attached")
count = page.evaluate(
"document.querySelectorAll('#column_groups input:checked').length"
)
assert count == 4
page.click("#columns_none")
count = page.evaluate(
"document.querySelectorAll('#column_groups input:checked').length"
)
assert count == 0
page.click("#columns_all")
count = page.evaluate(
"document.querySelectorAll('#column_groups input:checked').length"
)
assert count == 4
page.uncheck("#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 00:00:00")
select_value(page, "#order_by", "timestamp")
page.fill("#limit", "10")
page.evaluate("window.lastResults = undefined")
page.click("text=Dive")
page.wait_for_function("window.lastResults !== undefined")
data = page.evaluate("window.lastResults")
assert len(data["rows"][0]) == 3
headers = page.locator("#results th").all_inner_texts()
assert "value" not in headers
def test_columns_links_alignment(page: Any, server_url: str) -> None:
page.goto(server_url)
page.wait_for_selector("#order_by option", state="attached")
page.click("text=Columns")
page.wait_for_selector("#column_groups input", state="attached")
tag = page.evaluate("document.getElementById('columns_all').tagName")
assert tag == "A"
align = page.evaluate(
"getComputedStyle(document.querySelector('#column_actions')).textAlign"
)
assert align == "right"
def test_column_group_links(page: Any, server_url: str) -> None:
page.goto(server_url)
page.wait_for_selector("#order_by option", state="attached")
page.click("text=Columns")
page.wait_for_selector("#column_groups a", state="attached")
tag = page.evaluate("document.querySelector('#column_groups .col-group a').t
agName")
assert tag == "A"
def test_column_group_links_float_right(page: Any, server_url: str) -> None:
page.goto(server_url)
page.wait_for_selector("#order_by option", state="attached")
page.click("text=Columns")
page.wait_for_selector("#column_groups .col-group .links", state="attached")
float_val = page.evaluate(
"getComputedStyle(document.querySelector('#column_groups .col-group .lin
ks')).float"
)
assert float_val == "right"
def test_chip_dropdown_navigation(page: Any, server_url: str) -> None:
page.goto(server_url)
page.wait_for_selector("#order_by option", state="attached")
page.click("text=Add Filter")
f = page.query_selector("#filters .filter:last-child")
assert f
page.evaluate(
"arg => setSelectValue(arg.el.querySelector('.f-col'), arg.val)",
{"el": f, "val": "user"},
)
inp = f.query_selector(".f-val")
inp.click()
page.wait_for_selector("#filters .filter:last-child .chip-dropdown div")
page.keyboard.type("ali")
page.wait_for_selector("text=alice")
page.keyboard.press("ArrowDown")
page.keyboard.press("Enter")
chips = page.evaluate(
"Array.from(document.querySelectorAll('#filters .filter:last-child .chip
')).map(c => c.firstChild.textContent)"
)
assert chips == ["ali"]
page.click("#filters .filter:last-child .chip .x")
page.wait_for_selector(".chip", state="detached")
I'm moving towards the bottom to check the rest.
inp.click()
page.keyboard.type("alice")
page.keyboard.press("Enter")
inp.click()
page.keyboard.type("bob")
page.keyboard.press("Enter")
f.query_selector(".chip-copy").click()
assert page.evaluate("navigator.clipboard._data") == "alice,bob"
page.evaluate(
"var f=document.querySelector('#filters .filter:last-child'); f.chips=[]
; f.querySelectorAll('.chip').forEach(c=>c.remove())"
)
page.wait_for_selector("#filters .chip", state="detached")
inp.click()
page.evaluate(
"var dt=new DataTransfer(); dt.setData('text/plain','alice,bob'); var e=
new ClipboardEvent('paste',{clipboardData:dt}); document.querySelector('#filters
.filter:last-child .f-val').dispatchEvent(e);"
)
chips = page.evaluate(
"Array.from(document.querySelectorAll('#filters .filter:last-child .chip
')).map(c => c.firstChild.textContent)"
)
assert chips[:2] == ["alice", "bob"]
page.evaluate(
"var f=document.querySelector('#filters .filter:last-child'); f.chips=[]
; f.querySelectorAll('.chip').forEach(c=>c.remove())"
)
page.wait_for_selector("#filters .chip", state="detached")
inp.click()
page.evaluate(
"var dt=new DataTransfer(); dt.setData('text/plain','alice,bob'); var e=
new ClipboardEvent('paste',{clipboardData:dt}); Object.defineProperty(e,'shiftKe
y',{value:true}); document.querySelector('#filters .filter:last-child .f-val').d
ispatchEvent(e);"
)
chips = page.evaluate(
"Array.from(document.querySelectorAll('#filters .filter:last-child .chip
')).map(c => c.firstChild.textContent)"
)
assert chips[-1] == "alice,bob"
def test_chip_dropdown_hides_on_outside_click(page: Any, server_url: str) -> Non
e:
page.goto(server_url)
page.wait_for_selector("#order_by option", state="attached")
page.click("text=Add Filter")
f = page.query_selector("#filters .filter:last-child")
assert f
page.evaluate(
"arg => setSelectValue(arg.el.querySelector('.f-col'), arg.val)",
{"el": f, "val": "user"},
)
inp = f.query_selector(".f-val")
inp.click()
page.wait_for_selector("#filters .filter:last-child .chip-dropdown div")
page.click("#header")
page.wait_for_selector("#filters .filter:last-child .chip-dropdown", state="
hidden")
def test_chip_input_no_outline(page: Any, server_url: str) -> None:
page.goto(server_url)
page.wait_for_selector("#order_by option", state="attached")
page.click("text=Add Filter")
inp = page.query_selector("#filters .filter:last-child .f-val")
assert inp
inp.click()
outline = page.evaluate(
"getComputedStyle(document.querySelector('#filters .filter:last-child .f
-val')).outlineStyle"
)
assert outline == "none"
def test_chip_enter_keeps_focus(page: Any, server_url: str) -> None:
page.goto(server_url)
page.wait_for_selector("#order_by option", state="attached")
page.click("text=Add Filter")
f = page.query_selector("#filters .filter:last-child")
assert f
page.evaluate(
"arg => setSelectValue(arg.el.querySelector('.f-col'), arg.val)",
{"el": f, "val": "user"},
)
inp = f.query_selector(".f-val")
inp.click()
page.wait_for_selector("#filters .filter:last-child .chip-dropdown")
page.keyboard.type("alice")
page.keyboard.press("Enter")
focused = page.evaluate(
"document.activeElement === document.querySelector('#filters .filter:las
t-child .f-val')"
)
assert focused
visible = page.evaluate(
"getComputedStyle(document.querySelector('#filters .filter:last-child .c
hip-dropdown')).display"
)
assert visible == "none"
def test_chip_delete_keeps_focus(page: Any, server_url: str) -> None:
page.goto(server_url)
page.wait_for_selector("#order_by option", state="attached")
page.click("text=Add Filter")
f = page.query_selector("#filters .filter:last-child")
assert f
page.evaluate(
"arg => setSelectValue(arg.el.querySelector('.f-col'), arg.val)",
{"el": f, "val": "user"},
)
inp = f.query_selector(".f-val")
inp.click()
page.wait_for_selector("#filters .filter:last-child .chip-dropdown")
page.keyboard.type("alice")
page.keyboard.press("Enter")
page.keyboard.type("b")
page.wait_for_selector("#filters .filter:last-child .chip-dropdown")
f.query_selector(".chip .x").click()
page.wait_for_selector("#filters .filter:last-child .chip", state="detached"
)
focused = page.evaluate(
...
end="2024-01-02 00:00:00",
order_by="timestamp",
limit=1,
)
cell = page.text_content("#results td")
assert cell != "Invalid Date"
valid = page.evaluate("v => !isNaN(Date.parse(v))", cell)
assert valid
def test_url_query_persistence(page: Any, server_url: str) -> None:
page.goto(server_url)
page.wait_for_selector("#order_by option", state="attached")
page.wait_for_function("window.lastResults !== undefined")
page.fill("#start", "2024-01-01 00:00:00")
page.fill("#end", "2024-01-02 00:00:00")
page.fill("#limit", "1")
page.evaluate("window.lastResults = undefined")
page.click("text=Dive")
page.wait_for_function("window.lastResults !== undefined")
first_url = page.url
first_rows = page.evaluate("window.lastResults.rows.length")
page.fill("#limit", "2")
page.evaluate("window.lastResults = undefined")
page.click("text=Dive")
page.wait_for_function("window.lastResults !== undefined")
second_url = page.url
second_rows = page.evaluate("window.lastResults.rows.length")
assert second_rows != first_rows
assert first_url != second_url
page.go_back()
page.wait_for_function("window.lastResults !== undefined")
assert page.url == first_url
assert page.evaluate("window.lastResults.rows.length") == first_rows
def test_load_from_url(page: Any, server_url: str) -> None:
url = (
f"{server_url}?start=2024-01-01%2000:00:00&end=2024-01-02%2000:00:00"
"&order_by=timestamp&limit=2"
)
page.goto(url)
page.wait_for_selector("#order_by option", state="attached")
page.wait_for_function("window.lastResults !== undefined")
assert page.input_value("#start") == "2024-01-01 00:00:00"
assert page.input_value("#end") == "2024-01-02 00:00:00"
assert page.input_value("#limit") == "2"
assert page.evaluate("window.lastResults.rows.length") == 2
def test_empty_data_message(page: Any, server_url: str) -> None:
data = run_query(
page,
server_url,
start="2025-01-01 00:00:00",
end="2025-01-02 00:00:00",
order_by="timestamp",
limit=100,
)
assert data["rows"] == []
msg = page.text_content("#view")
assert "Empty data provided to table" in msg
def test_group_by_chip_from_url(page: Any, server_url: str) -> None:
url = f"{server_url}?graph_type=table&group_by=user&order_by=user&limit=10"
page.goto(url)
page.wait_for_selector("#group_by_field .chip", state="attached")
chips = page.evaluate(
"Array.from(document.querySelectorAll('#group_by_field .chip')).map(c =>
c.firstChild.textContent)"
)
assert chips == ["user"]
def test_group_by_autocomplete(page: Any, server_url: str) -> None:
page.goto(f"{server_url}?graph_type=table")
page.wait_for_selector("#group_by_field", state="visible")
inp = page.query_selector("#group_by_field .f-val")
assert inp
inp.click()
page.keyboard.type("us")
page.wait_for_selector("#group_by_field .chip-dropdown div")
options = page.locator("#group_by_field .chip-dropdown div").all_inner_texts
()
assert "user" in options
def test_group_by_copy_icon(page: Any, server_url: str) -> None:
page.goto(f"{server_url}?graph_type=table")
page.wait_for_selector("#group_by_field", state="visible")
icon = page.text_content("#group_by_field .chip-copy")
assert icon == "⎘"
def test_group_by_input_no_border(page: Any, server_url: str) -> None:
page.goto(f"{server_url}?graph_type=table")
page.wait_for_selector("#group_by_field", state="visible")
border = page.evaluate(
"getComputedStyle(document.querySelector('#group_by_field .f-val')).bord
erStyle"
)
assert border == "none"
def test_table_group_by_query(page: Any, server_url: str) -> None:
data = run_query(
page,
server_url,
start="2024-01-01 00:00:00",
end="2024-01-03 00:00:00",
order_by="timestamp",
limit=100,
group_by=["user"],
aggregate="Count",
)
assert "error" not in data
assert len(data["rows"]) == 3
def test_table_avg_no_group_by(page: Any, server_url: str) -> None:
data = run_query(
I'm quickly getting to the final tests in the codebase. Almost there!
page,
server_url,
aggregate="Avg",
)
assert len(data["rows"]) == 1
row = data["rows"][0]
assert row[0] == 4
from dateutil import parser
ts = parser.parse(row[1]).replace(tzinfo=None)
assert ts == parser.parse("2024-01-01 13:00:00")
assert row[2] == 25
def test_table_headers_show_aggregate(page: Any, server_url: str) -> None:
run_query(
page,
server_url,
aggregate="Avg",
)
headers = page.locator("#results th").all_inner_texts()
assert "Hits" in headers
assert "timestamp (avg)" in headers
assert "value (avg)" in headers
def test_format_number_function(page: Any, server_url: str) -> None:
page.goto(server_url)
page.wait_for_selector("#order_by option", state="attached")
vals = page.evaluate(
"() => [formatNumber(815210), formatNumber(999.999), formatNumber(0.0004
), formatNumber(0)]"
)
assert vals == ["815.21 K", "999.999", "0.000", "0"]
I'm working on adding derived columns to handle in QueryParams and column validation. I'll adjust the column validation to allow these columns as valid. Derived columns won't have predefined types, so I'll make sure to treat them accordingly for selection. Also, I'll follow certain restrictions on the types like Aggregated, String, and Numeric. I'll merge the server-provided columns with local ones and ensure proper handling. Then, I'll focus on UI, using dropdowns and text inputs for column type and names, and saving these settings to the URL. Let's check existing features for inspiration!
I'll add a new section in the Columns tab for derived columns, following given instructions.
atic/index.html
94: #column_actions {
98: #column_actions a {
247: <div id="column_actions">
.html
<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>
</div>
<div id="sidebar-resizer"></div>
<div id="view">
<table id="results"></table>
</div>
</div>
<script>
const allColumns = [];
const columnTypes = {};
const stringColumns = [];
const integerColumns = [];
const timeColumns = [];
let selectedColumns = [];
let displayType = 'samples';
let groupBy = {chips: [], addChip: () => {}, renderChips: () => {}};
function initDropdown(select) {
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() {
I'm looking into 'column_actions' and 'column_groups' to add the derived columns UI.
root@10d3a4e8077f:/workspace/scubaduck# grep -n "column_groups" -n scubaduck/sta
tic/index.html
251: <div id="column_groups"></div>
397: document.querySelectorAll('#column_groups .col-group').forEach(g => {
419: const groupsEl = document.getElementById('column_groups');
554: const cb = document.querySelector(`#column_groups input[value="${name}"]
`);
945: document.querySelectorAll('#column_groups input').forEach(cb => {
I’ll search for occurrences of '#derived_columns' to gather relevant information.
root@10d3a4e8077f:/workspace/scubaduck# grep -n "derived" -n scubaduck/static/in
dex.html
I need to check the column groups for classification to match derived columns properly.
root@10d3a4e8077f:/workspace/scubaduck# grep -n "col-group" -n scubaduck/static/
index.html
101: .col-group-header {
104: .col-group-header .links {
107: .col-group-header .links a {
397: document.querySelectorAll('#column_groups .col-group').forEach(g => {
398: if (g.querySelector('.col-group-header').textContent.startsWith('Strings
')) {
452: div.className = 'col-group';
454: header.className = 'col-group-header';
root@10d3a4e8077f:/workspace/scubaduck# sed -n '440,520p' scubaduck/static/index
.html
orderSelect.appendChild(o);
}
});
timeColumns.forEach(name => {
const o = document.createElement('option');
o.value = name;
o.textContent = name;
xAxisSelect.appendChild(o);
});
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();
groupBy = document.getElementById('group_by').closest('.field');
initChipInput(groupBy, typed =>
allColumns.filter(c => c.toLowerCase().includes(typed.toLowerCase()))
);
initDropdown(orderSelect);
initDropdown(document.getElementById('aggregate'));
document.getElementById('limit').addEventListener('input', e => {
e.target.dataset.setByUser = '1';
});
updateDisplayTypeUI();
addFilter();
initFromUrl();
});
Going to map out the UI for derived column creation in the "Columns" section.
root@10d3a4e8077f:/workspace/scubaduck# head scubaduck/sample.csv
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
I'll check lines 320-342 for how "derived_columns" is used to maybe validate columns.
root@10d3a4e8077f:/workspace/scubaduck# grep -n "derived_columns" -n scubaduck/s
erver.py
35: derived_columns: dict[str, str] = field(default_factory=lambda: {})
178: for name, expr in params.derived_columns.items():
300: derived_columns=payload.get("derived_columns", {}),
I'll add derived columns validation, update DOM structure, and ensure integration with the columns bar.
I'll store derived columns separately, handle grouping, and integrate them into query and dropdowns.
I'll store derived columns in JSON, including attributes, and filter only included ones for query.
I'll add DOM elements and functions to handle adding derived columns, similar to filters.
root@10d3a4e8077f:/workspace/scubaduck# grep -n "addFilter()" -n scubaduck/stati
c/index.html
242: <button id="add_filter" type="button" onclick="addFilter()">Add Fi
lter</button>
517: addFilter();
772:function addFilter() {
953: addFilter();
967: addFilter();
I’ll check the bottom of the file to determine where to add new functions.
root@10d3a4e8077f:/workspace/scubaduck# tail -n 50 scubaduck/static/index.html
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);
}
document.getElementById('query_info').textContent =
`Your query took about ${lastQueryTime} 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) {
msg += '\n' + err.traceback;
}
}
view.innerHTML = `<pre id="error-message">${msg}</pre>`;
document.getElementById('query_info').textContent = '';
}
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'));
}
}
}
</script>
</body>
</html>
I’ll revisit lines 740 to 900 to locate the definition of addFilter.
root@10d3a4e8077f:/workspace/scubaduck# sed -n '740,980p' scubaduck/static/index
.html
dropdown.appendChild(d);
});
if (vals.length) {
highlight = 0;
updateHighlight();
showDropdown();
} else {
hideDropdown();
}
}
function loadOptions() {
dropdownLocked = false;
if (!fetchOptions) {
dropdown.innerHTML = '';
return;
}
Promise.resolve(fetchOptions(input.value, filter)).then(values => {
renderDropdown(Array.isArray(values) ? values : []);
});
}
input.addEventListener('focus', loadOptions);
input.addEventListener('input', loadOptions);
document.addEventListener('click', evt => {
if (evt.target !== input) {
hideDropdown();
}
});
}
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'],
]
: [
['=', '='],
['!=', '!='],
['<', '<'],
['>', '>'],
];
opSel.innerHTML = ops.map(o => `<option value="${o[0]}">${o[1]}</option>`).j
oin('');
updateInputVis();
}
function updateInputVis() {
const op = container.querySelector('.f-op').value;
const box = container.querySelector('.chip-box');
box.style.display = op === 'empty' || op === '!empty' ? 'none' : 'block';
}
colSel.addEventListener('change', populateOps);
container.querySelector('.f-op').addEventListener('change', updateInputVis);
populateOps();
document.getElementById('filter_list').appendChild(container);
initChipInput(container, (typed, el) => {
const colEl = el.querySelector('.f-col select') || el.querySelector('.f-col'
);
if (!colEl) return [];
const col = colEl.value;
if (!isStringColumn(col)) return [];
return fetch(`/api/samples?column=${encodeURIComponent(col)}&q=${encodeURICo
mponent(typed)}`)
.then(r => r.json());
});
}
let lastQueryTime = 0;
let queryStart = 0;
function dive(push=true) {
const payload = collectParams();
if (push) {
history.pushState(payload, '', paramsToSearch(payload));
}
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;
})
...
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'),
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};
})
};
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') {
payload.x_axis = document.getElementById('x_axis').value;
payload.granularity = document.getElementById('granularity').value;
payload.fill = document.getElementById('fill').value;
}
return payload;
}
function paramsToSearch(params) {
const sp = new URLSearchParams();
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.columns && params.columns.length) sp.set('columns', params.columns.
join(','));
if (params.filters && params.filters.length) sp.set('filters', JSON.stringify(
params.filters));
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) {
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;
}
graphTypeSel.value = params.graph_type || 'samples';
updateDisplayTypeUI();
if (params.x_axis) document.getElementById('x_axis').value = params.x_axis;
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;
document.querySelectorAll('#column_groups input').forEach(cb => {
cb.checked = !params.columns || params.columns.includes(cb.value);
});
updateSelectedColumns();
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('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('columns')) params.columns = sp.get('columns').split(',').filter(c
=> c);
if (sp.has('filters')) {
root@10d3a4e8077f:/workspace/scubaduck# sed -n '980,1150p' scubaduck/static/inde
x.html
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');
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);
});
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');
}
if (!isStringColumn(col)) th.style.textAlign = 'right';
header.appendChild(th);
});
table.appendChild(header);
rows.forEach(row => {
const tr = document.createElement('tr');
tr.addEventListener('click', () => {
tr.classList.toggle('selected');
});
row.forEach((v, i) => {
const col = selectedColumns[i];
const td = document.createElement('td');
if (isTimeColumn(col)) {
const d = new Date(v);
td.textContent = d.toLocaleString('en-US', {
weekday: 'short',
month: 'short',
day: 'numeric',
year: 'numeric',
hour: 'numeric',
minute: 'numeric',
second: 'numeric',
hour12: true,
timeZoneName: 'short'
});
} 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);
}
}
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;
});
}
renderTable(rows);
}
function showTimeSeries(data) {
const view = document.getElementById('view');
if (data.rows.length === 0) {
view.innerHTML = '<p id="empty-message">Empty data provided to table</p>';
return;
}
const width = 600;
const height = 400;
view.innerHTML = '<div id="legend"></div><svg id="chart" width="' + width + '"
height="' + height + '"></svg>';
const svg = document.getElementById('chart');
const legend = document.getElementById('legend');
const groups = groupBy.chips || [];
const hasHits = document.getElementById('show_hits').checked ? 1 : 0;
const series = {};
let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
data.rows.forEach(r => {
const ts = new Date(r[0]).getTime();
const key = groups.map((_, i) => r[1 + i]).join(':') || 'all';
const val = Number(r[1 + groups.length + hasHits]);
if (!series[key]) series[key] = [];
series[key].push({x: ts, y: val});
if (ts < minX) minX = ts;
if (ts > maxX) maxX = ts;
if (val < minY) minY = val;
if (val > maxY) maxY = val;
});
const colors = ['#1f77b4','#ff7f0e','#2ca02c','#d62728','#9467bd','#8c564b','#
e377c2'];
let colorIndex = 0;
const xScale = x => ((x - minX) / (maxX - minX)) * (width - 60) + 50;
const yScale = y => height - 30 - ((y - minY) / (maxY - minY)) * (height - 60)
;
Object.keys(series).forEach(key => {
const pts = series[key];
const color = colors[colorIndex++ % colors.length];
const path = pts.map((p,i)=> (i===0? 'M':'L') + xScale(p.x) + ' ' + yScale(p
.y)).join(' ');
const el = document.createElementNS('http://www.w3.org/2000/svg','path');
el.setAttribute('d', path);
el.setAttribute('fill','none');
el.setAttribute('stroke', color);
svg.appendChild(el);
const item = document.createElement('div');
item.textContent = key;
item.style.color = color;
I'll create functions like addDerived(), setSelectValue(), and updateSelectedColumns to handle derived columns.
I’ll treat aggregated columns as numeric, even though they could behave differently. Let’s implement!
I’ll adjust validation to include derived columns, and send their types through QueryParams.
I'll skip type checks for derived columns and adjust validation accordingly, ensuring they pass checks.
I’ll add a "Derived Columns" section with the necessary styling and functions to handle them.
root@10d3a4e8077f:/workspace/scubaduck# grep -n "#filters .filter" -n scubaduck/
static/index.html
24: #filters .filter {
32: #filters .filter-row { display: flex; margin-bottom: 5px; }
33: #filters .filter-row .f-col { flex: 1; }
34: #filters .filter-row .f-op {
78: #filters .filter button.remove {
875: filters: Array.from(document.querySelectorAll('#filters .filter')).map(f
=> {
I'll take a look at the CSS in that area to understand what's going on.
root@10d3a4e8077f:/workspace/scubaduck# sed -n '20,40p' scubaduck/static/index.h
tml
#dive { margin-left: auto; background: green; color: white; border: none; pa
dding: 5px 10px; cursor: pointer; }
.tab-content { display: none; }
.tab-content.active { display: block; }
#filter_list { display: flex; flex-direction: column; }
#filters .filter {
border: 1px solid #ccc;
padding: 5px;
margin-bottom: 5px;
position: relative;
display: flex;
flex-direction: column;
}
#filters .filter-row { display: flex; margin-bottom: 5px; }
#filters .filter-row .f-col { flex: 1; }
#filters .filter-row .f-op {
margin-left: 5px;
width: fit-content;
flex: 0 0 auto;
}
.chip-input input {
border: none;
I'll move on to lines 50-70 to get the relevant CSS details.
root@10d3a4e8077f:/workspace/scubaduck# sed -n '40,80p' scubaduck/static/index.h
tml
border: none;
flex: 1;
min-width: 60px;
margin: 2px;
outline: none;
}
.chip-box { position: relative; }
.chip-input { display: flex; flex-wrap: wrap; border: 1px solid #ccc; paddin
g: 2px; min-height: 24px; }
.chip { background: #eee; border: 1px solid #999; padding: 2px 4px; margin:
2px; border-radius: 3px; display: flex; align-items: center; }
.chip .x { margin-left: 4px; cursor: pointer; }
.chip-copy { margin-left: 4px; cursor: pointer; background: none; border: no
ne; }
.chip-dropdown { position: absolute; left: 0; right: 0; top: 100%; backgroun
d: white; border: 1px solid #ccc; max-height: 120px; overflow-y: auto; z-index:
10; display: none; }
.chip-dropdown div { padding: 2px 4px; cursor: pointer; }
.chip-dropdown div.highlight { background: #bde4ff; }
.rel-box { position: relative; display: flex; }
.rel-dropdown { position: absolute; left: 0; right: 0; top: 100%; background
: white; border: 1px solid #ccc; z-index: 10; display: none; }
.rel-dropdown div { padding: 2px 4px; cursor: pointer; }
.rel-dropdown div:hover { background: #bde4ff; }
.dropdown { position: relative; display: inline-block; }
.dropdown-display {
border: 1px solid #ccc;
padding: 2px 18px 2px 4px;
cursor: pointer;
min-width: 80px;
position: relative;
}
.dropdown-display::after {
content: '\25BC';
position: absolute;
right: 4px;
pointer-events: none;
}
.dropdown-menu { position: absolute; left: 0; right: 0; top: 100%; backgroun
d: white; border: 1px solid #ccc; z-index: 10; max-height: 160px; overflow-y: au
to; display: none; }
.dropdown-menu input { width: 100%; box-sizing: border-box; padding: 2px 4px
; border: none; border-bottom: 1px solid #ccc; }
.dropdown-menu div { padding: 2px 4px; cursor: pointer; }
.dropdown-menu div.selected { background: #bde4ff; }
.dropdown-menu .option:hover { background: #eee; }
.dropdown-menu input::placeholder { color: #999; }
#filters .filter button.remove {
margin-left: 5px;
width: 20px;
I'll keep scanning, focusing on lines 80 to 120.
root@10d3a4e8077f:/workspace/scubaduck# sed -n '80,120p' scubaduck/static/index.
html
width: 20px;
flex: 0 0 auto;
padding: 0;
text-align: center;
line-height: 1;
}
#filters h4 { margin: 0 0 5px 0; }
table { border-collapse: collapse; min-width: 100%; }
th, td { border: 1px solid #ccc; padding: 4px; box-sizing: border-box; }
th { text-align: left; cursor: pointer; position: relative; }
th.sorted { color: blue; }
tr:nth-child(even) td { background: #f9f9f9; }
tr.selected td { background: #bde4ff !important; }
tr:hover:not(.selected) td { background: #eee; }
#column_actions {
text-align: right;
margin-bottom: 5px;
}
#column_actions a {
margin-left: 5px;
}
.col-group-header {
overflow: hidden;
}
.col-group-header .links {
float: right;
}
.col-group-header .links a {
margin-left: 5px;
}
/* Column resizer removed */
</style>
</head>
<body>
<div id="header">sample.csv - events <select id="graph_type"><option value="sa
mples">Samples</option><option value="table">Table</option><option value="timese
ries">Time Series</option></select></div>
<div id="content">
<div id="sidebar">
<div id="tabs">
<button class="tab active" data-tab="settings">View Settings</button>
<button class="tab" data-tab="columns">Columns</button>
<button id="dive" onclick="dive()">Dive</button>
I’ll reuse the filter style for derived columns, adding specific tweaks like text areas. Next, I’ll implement addDerived()
and handle DOM and data updates, ensuring derived columns appear in dropdowns.
I’ll modify the fetch code to store base columns and update derived columns on change.
root@10d3a4e8077f:/workspace/scubaduck# grep -n "const allColumns" -n scubaduck/
static/index.html
260:const allColumns = [];
I’m opening the script to check the global arrays defined at the top.
root@10d3a4e8077f:/workspace/scubaduck# sed -n '256,272p' scubaduck/static/index
.html
<table id="results"></table>
</div>
</div>
<script>
const allColumns = [];
const columnTypes = {};
const stringColumns = [];
const integerColumns = [];
const timeColumns = [];
let selectedColumns = [];
let displayType = 'samples';
let groupBy = {chips: [], addChip: () => {}, renderChips: () => {}};
function initDropdown(select) {
const wrapper = document.createElement('div');
wrapper.className = 'dropdown';
if (select.classList.contains('f-col')) {
I see the arrays at line 260. I'll adjust baseColumns
, derivedColumns
, and check the fetch code.
root@10d3a4e8077f:/workspace/scubaduck# sed -n '388,508p' scubaduck/static/index
.html
function updateDisplayTypeUI() {
const showTable = graphTypeSel.value === 'table';
const showTS = graphTypeSel.value === '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' : '';
}
});
if (showTS) {
const lim = document.getElementById('limit');
if (!lim.dataset.setByUser) {
lim.value = '7';
}
}
displayType = graphTypeSel.value;
}
orderDirBtn.addEventListener('click', () => {
orderDir = orderDir === 'ASC' ? 'DESC' : 'ASC';
updateOrderDirButton();
});
updateOrderDirButton();
graphTypeSel.addEventListener('change', updateDisplayTypeUI);
fetch('/api/columns').then(r => r.json()).then(cols => {
const orderSelect = document.getElementById('order_by');
const xAxisSelect = document.getElementById('x_axis');
const groupsEl = document.getElementById('column_groups');
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);
let g = 'string';
if (t.includes('INT')) g = 'integer';
if (t.includes('TIMESTAMP')) {
g = 'time';
timeColumns.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);
});
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();
groupBy = document.getElementById('group_by').closest('.field');
initChipInput(groupBy, typed =>
root@10d3a4e8077f:/workspace/scubaduck# sed -n '508,548p' scubaduck/static/index
.html
initChipInput(groupBy, typed =>
allColumns.filter(c => c.toLowerCase().includes(typed.toLowerCase()))
);
initDropdown(orderSelect);
initDropdown(document.getElementById('aggregate'));
document.getElementById('limit').addEventListener('input', e => {
e.target.dataset.setByUser = '1';
});
updateDisplayTypeUI();
addFilter();
initFromUrl();
});
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 => {
if (!dd.parentElement.contains(e.target)) dd.style.display = 'none';
root@10d3a4e8077f:/workspace/scubaduck# grep -n "Add Filter" -n tests/test_web.p
y
102: page.click("text=Add Filter")
379: page.click("text=Add Filter")
407: page.click("text=Add Filter")
452: page.click("text=Add Filter")
469: page.click("text=Add Filter")
482: page.click("text=Add Filter")
507: page.click("text=Add Filter")
536: page.click("text=Add Filter")
564: page.click("text=Add Filter")
581: page.click("text=Add Filter")
611: page.click("text=Add Filter")
I’ll check the bottom of the test_web file for existing URL persistence or query features.
)
assert "error" not in data
assert len(data["rows"]) == 3
def test_table_avg_no_group_by(page: Any, server_url: str) -> None:
data = run_query(
page,
server_url,
aggregate="Avg",
)
assert len(data["rows"]) == 1
row = data["rows"][0]
assert row[0] == 4
from dateutil import parser
ts = parser.parse(row[1]).replace(tzinfo=None)
assert ts == parser.parse("2024-01-01 13:00:00")
assert row[2] == 25
def test_table_headers_show_aggregate(page: Any, server_url: str) -> None:
run_query(
page,
server_url,
aggregate="Avg",
)
headers = page.locator("#results th").all_inner_texts()
assert "Hits" in headers
assert "timestamp (avg)" in headers
assert "value (avg)" in headers
def test_format_number_function(page: Any, server_url: str) -> None:
page.goto(server_url)
page.wait_for_selector("#order_by option", state="attached")
vals = page.evaluate(
"() => [formatNumber(815210), formatNumber(999.999), formatNumber(0.0004
), formatNumber(0)]"
)
assert vals == ["815.21 K", "999.999", "0.000", "0"]
I’ll review functions like 'select_value' and 'page.evaluate' before adding new tests.
def select_value(page: Any, selector: str, value: str) -> None:
page.evaluate(
"arg => setSelectValue(arg.sel, arg.val)",
{"sel": selector, "val": value},
)
def run_query(
page: Any,
url: str,
*,
start: str | None = None,
end: str | None = None,
order_by: str | None = None,
order_dir: str | None = "ASC",
limit: int | None = None,
group_by: list[str] | None = None,
aggregate: str | None = None,
) -> dict[str, Any]:
page.goto(url)
page.wait_for_selector("#order_by option", state="attached")
page.wait_for_selector("#order_dir", state="attached")
page.wait_for_function("window.lastResults !== undefined")
if start is not None:
page.fill("#start", start)
if end is not None:
page.fill("#end", end)
if order_by is not None:
select_value(page, "#order_by", order_by)
if order_dir is not None and order_dir == "DESC":
page.click("#order_dir")
if limit is not None:
page.fill("#limit", str(limit))
if group_by is not None:
select_value(page, "#graph_type", "table")
select_value(page, "#graph_type", "table")
page.evaluate(
"g => { groupBy.chips = g; groupBy.renderChips(); }",
group_by,
)
if aggregate is not None:
select_value(page, "#graph_type", "table")
select_value(page, "#aggregate", aggregate)
page.evaluate("window.lastResults = undefined")
page.click("text=Dive")
page.wait_for_function("window.lastResults !== undefined")
return page.evaluate("window.lastResults")
def test_range_filters(page: Any, server_url: str) -> None:
data = run_query(
page,
server_url,
start="2024-01-02 00:00:00",
end="2024-01-02 04:00:00",
order_by="timestamp",
limit=100,
)
assert len(data["rows"]) == 2
from dateutil import parser
timestamps = [parser.parse(row[0]).replace(tzinfo=None) for row in data["row
s"]]
assert timestamps == [
parser.parse("2024-01-02 00:00:00"),
parser.parse("2024-01-02 03:00:00"),
]
def test_order_by(page: Any, server_url: str) -> None:
data = run_query(
page,
server_url,
start="2024-01-01 00:00:00",
end="2024-01-03 00:00:00",
order_by="value",
order_dir="DESC",
I'll create a test for the derived column query, initially using the default name "derived_1".
Filter suggestions depend on allColumns
, but new derived columns won’t show in filters after creation.
root@10d3a4e8077f:/workspace/scubaduck# git status --short
I'll open the server file now to make the needed changes.
),
400,
)
valid_cols = set(column_types.keys())
if params.graph_type == "timeseries":
if params.x_axis is None:
for cand in ["time", "timestamp"]:
if cand in valid_cols:
params.x_axis = cand
break
if params.x_axis is None or params.x_axis not in valid_cols:
return jsonify({"error": "Invalid x_axis"}), 400
ctype = column_types.get(params.x_axis, "").upper()
if not any(t in ctype for t in ["TIMESTAMP", "DATE", "TIME"]):
return jsonify({"error": "x_axis must be a time column"}), 400
for col in params.columns:
if col not in valid_cols:
return jsonify({"error": f"Unknown column: {col}"}), 400
for col in params.group_by:
if col not in valid_cols:
return jsonify({"error": f"Unknown column: {col}"}), 400
if params.order_by and params.order_by not in valid_cols:
return jsonify({"error": f"Unknown column: {params.order_by}"}), 400
if params.group_by:
agg = (params.aggregate or "avg").lower()
if agg.startswith("p") or agg == "sum":
need_numeric = True
allow_time = False
elif agg == "avg" or agg in {"min", "max"}:
need_numeric = False
allow_time = True
else:
need_numeric = False
allow_time = False
if need_numeric or allow_time:
for c in params.columns:
if c in params.group_by or c == params.x_axis:
continue
ctype = column_types.get(c, "").upper()
is_numeric = any(
t in ctype
for t in [
"INT",
"DECIMAL",
"REAL",
"DOUBLE",
"FLOAT",
"NUMERIC",
"HUGEINT",
]
)
is_time = "TIMESTAMP" in ctype or "DATE" in ctype or "TIME"
in ctype
if need_numeric and not is_numeric:
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,
)
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,
)
return jsonify({"sql": sql, "rows": rows})
return app
app = create_app()
if __name__ == "__main__":
app.run(debug=True)
I’ll proceed with the patch.
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"),
)
for f in payload.get("filters", []):
params.filters.append(Filter(f["column"], f["op"], f.get("value")))
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,
)
valid_cols = set(column_types.keys())
if params.graph_type == "timeseries":
if params.x_axis is None:
for cand in ["time", "timestamp"]:
if cand in valid_cols:
params.x_axis = cand
break
if params.x_axis is None or params.x_axis not in valid_cols:
return jsonify({"error": "Invalid x_axis"}), 400
ctype = column_types.get(params.x_axis, "").upper()
if not any(t in ctype for t in ["TIMESTAMP", "DATE", "TIME"]):
return jsonify({"error": "x_axis must be a time column"}), 400
for col in params.columns:
if col not in valid_cols:
return jsonify({"error": f"Unknown column: {col}"}), 400
for col in params.group_by:
if col not in valid_cols:
return jsonify({"error": f"Unknown column: {col}"}), 400
if params.order_by and params.order_by not in valid_cols:
return jsonify({"error": f"Unknown column: {params.order_by}"}), 400
if params.group_by:
agg = (params.aggregate or "avg").lower()
if agg.startswith("p") or agg == "sum":
need_numeric = True
allow_time = False
elif agg == "avg" or agg in {"min", "max"}:
need_numeric = False
allow_time = True
else:
need_numeric = False
allow_time = False
if need_numeric or allow_time:
for c in params.columns:
if c in params.group_by or c == params.x_axis:
continue
ctype = column_types.get(c, "").upper()
I’ll include the derived columns in the patch.
root@10d3a4e8077f:/workspace/scubaduck# apply_patch<<'PATCH'
> *** Begin Patch
> *** Update File: scubaduck/server.py
> @@
> - valid_cols = set(column_types.keys())
> + valid_cols = set(column_types.keys())
> + valid_cols.update(params.derived_columns.keys())
> *** End Patch
> PATCH
Success. Updated the following files:
M scubaduck/server.py
I'll adjust the numeric and time validations to fit the new requirements.
root@10d3a4e8077f:/workspace/scubaduck# apply_patch<<'PATCH'
> *** Begin Patch
> *** Update File: scubaduck/server.py
> @@
> - for c in params.columns:
> - if c in params.group_by or c == params.x_axis:
> - continue
> - ctype = column_types.get(c, "").upper()
> + for c in params.columns:
> + if c in params.group_by or c == params.x_axis:
> + continue
> + if c not in column_types:
> + continue
> + ctype = column_types.get(c, "").upper()
> *** End Patch
> PATCH
Success. Updated the following files:
M scubaduck/server.py
I’ll update the patch for column types. No other changes, just focus on this.
root@10d3a4e8077f:/workspace/scubaduck# sed -n '250,318p' scubaduck/static/index
.html
</div>
<div id="column_groups"></div>
</div>
</div>
<div id="sidebar-resizer"></div>
<div id="view">
<table id="results"></table>
</div>
</div>
<script>
const allColumns = [];
const columnTypes = {};
const stringColumns = [];
const integerColumns = [];
const timeColumns = [];
let selectedColumns = [];
let displayType = 'samples';
let groupBy = {chips: [], addChip: () => {}, renderChips: () => {}};
function initDropdown(select) {
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();
}
});
I'll add the Derived Columns section after <div id="column_actions">
or at the bottom.
root@10d3a4e8077f:/workspace/scubaduck# grep -n "column_actions" -n scubaduck/st
atic/index.html
94: #column_actions {
98: #column_actions a {
247: <div id="column_actions">
I’m checking the lines between 240 and 260 for any necessary content.
root@10d3a4e8077f:/workspace/scubaduck# sed -n '240,260p' scubaduck/static/index
.html
<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>
</div>
<div id="sidebar-resizer"></div>
<div id="view">
<table id="results"></table>
</div>
</div>
<script>
const allColumns = [];
I’ll add the derived columns UI right after column_groups and adjust the patch accordingly.
root@10d3a4e8077f:/workspace/scubaduck# sed -n '256,276p' scubaduck/static/index
.html
<table id="results"></table>
</div>
</div>
<script>
const allColumns = [];
const columnTypes = {};
const stringColumns = [];
const integerColumns = [];
const timeColumns = [];
let selectedColumns = [];
let displayType = 'samples';
let groupBy = {chips: [], addChip: () => {}, renderChips: () => {}};
function initDropdown(select) {
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);
I’ll patch the code to introduce a section for Derived columns.
root@10d3a4e8077f:/workspace/scubaduck# apply_patch<<'PATCH'
> *** Begin Patch
> *** Update File: scubaduck/static/index.html
> @@
> - <div id="column_groups"></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 D
erived</button>
> + </div>
> </div>
> *** End Patch
> PATCH
Success. Updated the following files:
M scubaduck/static/index.html
I'll add the new CSS style for derived columns, placing it near the filter style.
root@10d3a4e8077f:/workspace/scubaduck# grep -n "derived_columns" -n scubaduck/s
tatic/index.html
252: <div id="derived_columns">
I need to define the styles for #derived_columns and .derived classes.
root@10d3a4e8077f:/workspace/scubaduck# sed -n '16,40p' scubaduck/static/index.h
tml
.rel-btn { margin-left: 4px; }
#tabs { display: flex; align-items: center; margin-bottom: 10px; }
#tabs .tab { margin-right: 5px; background: none; border: 1px solid #ccc; pa
dding: 4px 8px; cursor: pointer; width: 120px; text-align: center; box-sizing: b
order-box; }
#tabs .tab.active { background: #eee; font-weight: bold; }
#dive { margin-left: auto; background: green; color: white; border: none; pa
dding: 5px 10px; cursor: pointer; }
.tab-content { display: none; }
.tab-content.active { display: block; }
#filter_list { display: flex; flex-direction: column; }
#filters .filter {
border: 1px solid #ccc;
padding: 5px;
margin-bottom: 5px;
position: relative;
display: flex;
flex-direction: column;
}
#filters .filter-row { display: flex; margin-bottom: 5px; }
#filters .filter-row .f-col { flex: 1; }
#filters .filter-row .f-op {
margin-left: 5px;
width: fit-content;
flex: 0 0 auto;
}
.chip-input input {
border: none;
I'm thinking of adding a style after #filters.filter. Let's try it out.
root@10d3a4e8077f:/workspace/scubaduck# apply_patch<<'PATCH'
> *** Begin Patch
> *** Update File: scubaduck/static/index.html
> @@
> #filters .filter {
> border: 1px solid #ccc;
> padding: 5px;
> margin-bottom: 5px;
> position: relative;
> display: flex;
> flex-direction: column;
> }
> + #derived_columns .derived {
> + border: 1px solid #ccc;
> + padding: 5px;
> + margin-bottom: 5px;
> + display: flex;
> + flex-direction: column;
> + }
> + #derived_columns .derived-row {
> + display: flex;
> + margin-bottom: 5px;
> + }
> + #derived_columns .derived-row input[type="text"] {
> + margin-left: 5px;
> + flex: 1;
> + }
> + #derived_columns textarea {
> + width: 100%;
> + box-sizing: border-box;
> + }
> *** End Patch
> PATCH
Success. Updated the following files:
M scubaduck/static/index.html
I'll define the necessary base arrays at the top of the script.
root@10d3a4e8077f:/workspace/scubaduck# sed -n '260,276p' scubaduck/static/index
.html
<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>
root@10d3a4e8077f:/workspace/scubaduck# sed -n '276,294p' scubaduck/static/index
.html
</div>
</div>
<div id="sidebar-resizer"></div>
<div id="view">
<table id="results"></table>
</div>
</div>
<script>
const allColumns = [];
const columnTypes = {};
const stringColumns = [];
const integerColumns = [];
const timeColumns = [];
let selectedColumns = [];
let displayType = 'samples';
let groupBy = {chips: [], addChip: () => {}, renderChips: () => {}};
function initDropdown(select) {
const wrapper = document.createElement('div');
I'll patch the code to include base arrays and derivedColumns functionality.
root@10d3a4e8077f:/workspace/scubaduck# apply_patch<<'PATCH'
> *** Begin Patch
> *** Update File: scubaduck/static/index.html
> @@
> -const allColumns = [];
> -const columnTypes = {};
> -const stringColumns = [];
> -const integerColumns = [];
> -const timeColumns = [];
> +const allColumns = [];
> +const baseColumns = [];
> +const columnTypes = {};
> +const stringColumns = [];
> +const baseStringColumns = [];
> +const integerColumns = [];
> +const baseIntegerColumns = [];
> +const timeColumns = [];
> +const baseTimeColumns = [];
> +const derivedColumns = [];
> *** End Patch
> PATCH
Success. Updated the following files:
M scubaduck/static/index.html
I'll adjust the fetch columns section and push to the base arrays as needed.
root@10d3a4e8077f:/workspace/scubaduck# sed -n '404,448p' scubaduck/static/index
.html
document.removeEventListener('mousemove', onSidebarDrag);
document.removeEventListener('mouseup', stopSidebarDrag);
sidebarResize = false;
localStorage.setItem('sidebarWidth', sidebarWidth);
}
sidebarResizer.addEventListener('mousedown', startSidebarDrag);
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 showTable = graphTypeSel.value === 'table';
const showTS = graphTypeSel.value === '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' : '';
}
});
if (showTS) {
const lim = document.getElementById('limit');
if (!lim.dataset.setByUser) {
lim.value = '7';
}
}
displayType = graphTypeSel.value;
}
orderDirBtn.addEventListener('click', () => {
orderDir = orderDir === 'ASC' ? 'DESC' : 'ASC';
updateOrderDirButton();
});
updateOrderDirButton();
graphTypeSel.addEventListener('change', updateDisplayTypeUI);
fetch('/api/columns').then(r => r.json()).then(cols => {
const orderSelect = document.getElementById('order_by');
const xAxisSelect = document.getElementById('x_axis');
const groupsEl = document.getElementById('column_groups');
root@10d3a4e8077f:/workspace/scubaduck# sed -n '448,520p' scubaduck/static/index
.html
const groupsEl = document.getElementById('column_groups');
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);
let g = 'string';
if (t.includes('INT')) g = 'integer';
if (t.includes('TIMESTAMP')) {
g = 'time';
timeColumns.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);
});
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();
root@10d3a4e8077f:/workspace/scubaduck# sed -n '520,560p' scubaduck/static/index
.html
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();
groupBy = document.getElementById('group_by').closest('.field');
initChipInput(groupBy, typed =>
allColumns.filter(c => c.toLowerCase().includes(typed.toLowerCase()))
);
initDropdown(orderSelect);
initDropdown(document.getElementById('aggregate'));
document.getElementById('limit').addEventListener('input', e => {
e.target.dataset.setByUser = '1';
});
updateDisplayTypeUI();
addFilter();
initFromUrl();
});
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', () => {
I’m going to update the fetch initialization to include base arrays. Time to patch!
root@10d3a4e8077f:/workspace/scubaduck# apply_patch<<'PATCH'
> *** Begin Patch
> *** Update File: scubaduck/static/index.html
> @@
> - const orderSelect = document.getElementById('order_by');
> - const xAxisSelect = document.getElementById('x_axis');
> - const groupsEl = document.getElementById('column_groups');
> + const orderSelect = document.getElementById('order_by');
> + const xAxisSelect = document.getElementById('x_axis');
> + const groupsEl = document.getElementById('column_groups');
> *** End Patch
> PATCH
Success. Updated the following files:
M scubaduck/static/index.html
I'll work on the patch to include the base arrays.
root@10d3a4e8077f:/workspace/scubaduck# apply_patch<<'PATCH'
> *** Begin Patch
> *** Update File: scubaduck/static/index.html
> @@
> - cols.forEach(c => {
> + cols.forEach(c => {
> const t = c.type.toUpperCase();
> columnTypes[c.name] = c.type;
> - allColumns.push(c.name);
> + allColumns.push(c.name);
> + baseColumns.push(c.name);
> let g = 'string';
> if (t.includes('INT')) g = 'integer';
> if (t.includes('TIMESTAMP')) {
> g = 'time';
> - timeColumns.push(c.name);
> + timeColumns.push(c.name);
> + baseTimeColumns.push(c.name);
> }
> + if (g === 'string') {
> + stringColumns.push(c.name);
> + baseStringColumns.push(c.name);
> + } else {
> + integerColumns.push(c.name);
> + baseIntegerColumns.push(c.name);
> + }
> *** End Patch
> PATCH
Success. Updated the following files:
M scubaduck/static/index.html
Next, I'll add a function to refresh derived columns after initFromUrl()
call.
root@10d3a4e8077f:/workspace/scubaduck# grep -n "initFromUrl()" -n scubaduck/sta
tic/index.html
556: initFromUrl();
1031:function initFromUrl() {
root@10d3a4e8077f:/workspace/scubaduck# sed -n '540,558p' scubaduck/static/index
.html
e.preventDefault();
groupsEl.querySelectorAll('input').forEach(cb => (cb.checked = false));
updateSelectedColumns();
});
updateSelectedColumns();
groupBy = document.getElementById('group_by').closest('.field');
initChipInput(groupBy, typed =>
allColumns.filter(c => c.toLowerCase().includes(typed.toLowerCase()))
);
initDropdown(orderSelect);
initDropdown(document.getElementById('aggregate'));
document.getElementById('limit').addEventListener('input', e => {
e.target.dataset.setByUser = '1';
});
updateDisplayTypeUI();
addFilter();
initFromUrl();
});
I'll define addDerived()
and refreshDerivedColumns()
to handle updates and dropdown options.
root@10d3a4e8077f:/workspace/scubaduck# grep -n "showError" -n scubaduck/static/
index.html
899: showError(err);
1213:function showError(err) {
I’ll check lines 730-772 for addFilter
and add the new function after that.
root@10d3a4e8077f:/workspace/scubaduck# sed -n '736,766p' scubaduck/static/index
.html
e.preventDefault();
} else if (e.key === 'Backspace' && input.value === '') {
if (filter.chips.length > 0) {
filter.chips.pop();
renderChips();
}
} else if (e.key === 'Enter') {
if (dropdown.style.display !== 'none' && dropdown.children.length > 0) {
const val = dropdown.children[highlight].dataset.value;
if (val !== input.value.trim()) {
addChip(val);
} else {
addChip(input.value.trim());
}
} else {
addChip(input.value.trim());
}
hideDropdown();
e.preventDefault();
}
});
function renderDropdown(vals) {
dropdown.innerHTML = '';
const typed = input.value.trim();
if (typed) {
vals.splice(1, 0, typed);
}
vals.forEach((v, i) => {
const d = document.createElement('div');
d.textContent = v;
root@10d3a4e8077f:/workspace/scubaduck# sed -n '768,820p' scubaduck/static/index
.html
d.addEventListener('mouseover', () => {
highlight = i;
updateHighlight();
});
d.addEventListener('mousedown', evt => {
evt.preventDefault();
addChip(v);
hideDropdown();
input.blur();
});
dropdown.appendChild(d);
});
if (vals.length) {
highlight = 0;
updateHighlight();
showDropdown();
} else {
hideDropdown();
}
}
function loadOptions() {
dropdownLocked = false;
if (!fetchOptions) {
dropdown.innerHTML = '';
return;
}
Promise.resolve(fetchOptions(input.value, filter)).then(values => {
renderDropdown(Array.isArray(values) ? values : []);
});
}
input.addEventListener('focus', loadOptions);
input.addEventListener('input', loadOptions);
document.addEventListener('click', evt => {
if (evt.target !== input) {
hideDropdown();
}
});
}
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">
I’m continuing to scroll to line 840 and beyond. Let’s keep going.
root@10d3a4e8077f:/workspace/scubaduck# sed -n '820,900p' scubaduck/static/index
.html
<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'],
]
: [
['=', '='],
['!=', '!='],
['<', '<'],
['>', '>'],
];
opSel.innerHTML = ops.map(o => `<option value="${o[0]}">${o[1]}</option>`).j
oin('');
updateInputVis();
}
function updateInputVis() {
const op = container.querySelector('.f-op').value;
const box = container.querySelector('.chip-box');
box.style.display = op === 'empty' || op === '!empty' ? 'none' : 'block';
}
colSel.addEventListener('change', populateOps);
container.querySelector('.f-op').addEventListener('change', updateInputVis);
populateOps();
document.getElementById('filter_list').appendChild(container);
initChipInput(container, (typed, el) => {
const colEl = el.querySelector('.f-col select') || el.querySelector('.f-col'
);
if (!colEl) return [];
const col = colEl.value;
if (!isStringColumn(col)) return [];
return fetch(`/api/samples?column=${encodeURIComponent(col)}&q=${encodeURICo
mponent(typed)}`)
.then(r => r.json());
});
}
let lastQueryTime = 0;
let queryStart = 0;
function dive(push=true) {
const payload = collectParams();
if (push) {
history.pushState(payload, '', paramsToSearch(payload));
}
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);
});
I’m still scrolling to get more content.
root@10d3a4e8077f:/workspace/scubaduck# sed -n '900,980p' scubaduck/static/index
.html
});
}
function collectParams() {
updateSelectedColumns();
const payload = {
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'),
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};
})
};
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') {
payload.x_axis = document.getElementById('x_axis').value;
payload.granularity = document.getElementById('granularity').value;
payload.fill = document.getElementById('fill').value;
}
return payload;
}
function paramsToSearch(params) {
const sp = new URLSearchParams();
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.columns && params.columns.length) sp.set('columns', params.columns.
join(','));
if (params.filters && params.filters.length) sp.set('filters', JSON.stringify(
params.filters));
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) {
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;
}
graphTypeSel.value = params.graph_type || 'samples';
updateDisplayTypeUI();
if (params.x_axis) document.getElementById('x_axis').value = params.x_axis;
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();
}
root@10d3a4e8077f:/workspace/scubaduck# sed -n '980,1060p' scubaduck/static/inde
x.html
}
if (params.aggregate) document.getElementById('aggregate').value = params.aggr
egate;
document.getElementById('show_hits').checked = params.show_hits ?? true;
document.querySelectorAll('#column_groups input').forEach(cb => {
cb.checked = !params.columns || params.columns.includes(cb.value);
});
updateSelectedColumns();
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('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('columns')) params.columns = sp.get('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');
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);
});
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 (
It looks like parseSearch needs an update to include derived columns. Time to patch it.
root@10d3a4e8077f:/workspace/scubaduck# apply_patch<<'PATCH'
> *** Begin Patch
> *** Update File: scubaduck/static/index.html
> @@
> function dive(push=true) {
> - const payload = collectParams();
> - if (push) {
> - history.pushState(payload, '', paramsToSearch(payload));
> - }
> + 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');
> *** End Patch
> PATCH
Success. Updated the following files:
M scubaduck/static/index.html
root@10d3a4e8077f:/workspace/scubaduck# apply_patch<<'PATCH'
> *** Begin Patch
> *** Update File: scubaduck/static/index.html
> @@
> function collectParams() {
> updateSelectedColumns();
> const payload = {
> @@
> - filters: Array.from(document.querySelectorAll('#filters .filter')).map(f
=> {
> + filters: Array.from(document.querySelectorAll('#filters .filter')).map(f
=> {
> const chips = f.chips || [];
> const op = f.querySelector('.f-op').value;
> let value = null;
> @@
> - })
> + }),
> + derived_columns: Array.from(document.querySelectorAll('#derived_list .der
ived')).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,
> + }))
> };
> *** End Patch
> PATCH
Success. Updated the following files:
M scubaduck/static/index.html
I'll update paramsToSearch to also handle derived_columns in the next patch.
root@10d3a4e8077f:/workspace/scubaduck# grep -n "paramsToSearch" -n scubaduck/st
atic/index.html
882: history.pushState(params, '', paramsToSearch(params));
949:function paramsToSearch(params) {
1045: history.replaceState(params, '', paramsToSearch(params));
root@10d3a4e8077f:/workspace/scubaduck# sed -n '948,990p' scubaduck/static/index
.html
function paramsToSearch(params) {
const sp = new URLSearchParams();
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.columns && params.columns.length) sp.set('columns', params.columns.
join(','));
if (params.filters && params.filters.length) sp.set('filters', JSON.stringify(
params.filters));
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) {
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;
}
graphTypeSel.value = params.graph_type || 'samples';
updateDisplayTypeUI();
if (params.x_axis) document.getElementById('x_axis').value = params.x_axis;
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);
I'll implement a new parameter to handle derived columns. Should be straightforward.
root@10d3a4e8077f:/workspace/scubaduck# apply_patch<<'PATCH'
> *** Begin Patch
> *** Update File: scubaduck/static/index.html
> @@
> if (params.limit !== null && params.limit !== undefined) sp.set('limit', pa
rams.limit);
> if (params.columns && params.columns.length) sp.set('columns', params.colum
ns.join(','));
> if (params.filters && params.filters.length) sp.set('filters', JSON.stringi
fy(params.filters));
> + if (params.derived_columns && params.derived_columns.length) sp.set('derive
d_columns', JSON.stringify(params.derived_columns));
> if (params.graph_type) sp.set('graph_type', params.graph_type);
> *** End Patch
> PATCH
Success. Updated the following files:
M scubaduck/static/index.html
I’ll update applyParams to manage derived_columns properly.
root@10d3a4e8077f:/workspace/scubaduck# sed -n '990,1060p' scubaduck/static/inde
x.html
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;
document.querySelectorAll('#column_groups input').forEach(cb => {
cb.checked = !params.columns || params.columns.includes(cb.value);
});
updateSelectedColumns();
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('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('columns')) params.columns = sp.get('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');
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);
});
let originalRows = [];
let sortState = {index: null, dir: null};
function renderTable(rows) {
I'll add derived columns to applyParams for better functionality.
root@10d3a4e8077f:/workspace/scubaduck# apply_patch<<'PATCH'
> *** Begin Patch
> *** Update File: scubaduck/static/index.html
> @@
> document.querySelectorAll('#column_groups input').forEach(cb => {
> cb.checked = !params.columns || params.columns.includes(cb.value);
> });
> updateSelectedColumns();
> + 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');
> *** End Patch
> PATCH
Success. Updated the following files:
M scubaduck/static/index.html
I need to adjust parseSearch to handle the new 'derived_columns' data.
root@10d3a4e8077f:/workspace/scubaduck# apply_patch<<'PATCH'
> *** Begin Patch
> *** Update File: scubaduck/static/index.html
> @@
> if (sp.has('fill')) params.fill = sp.get('fill');
> + if (sp.has('derived_columns')) {
> + try { params.derived_columns = JSON.parse(sp.get('derived_columns')); } c
atch(e) { params.derived_columns = []; }
> + }
> return params;
> }
> *** End Patch
> PATCH
Success. Updated the following files:
M scubaduck/static/index.html
I'll add the new functions after addFilter
and ensure everything fits in the right order.
root@10d3a4e8077f:/workspace/scubaduck# grep -n "addFilter()" -n scubaduck/stati
c/index.html
261: <button id="add_filter" type="button" onclick="addFilter()">Add Fi
lter</button>
555: addFilter();
810:function addFilter() {
1011: addFilter();
1025: addFilter();
root@10d3a4e8077f:/workspace/scubaduck# sed -n '796,840p' scubaduck/static/index
.html
renderDropdown(Array.isArray(values) ? values : []);
});
}
input.addEventListener('focus', loadOptions);
input.addEventListener('input', loadOptions);
document.addEventListener('click', evt => {
if (evt.target !== input) {
hideDropdown();
}
});
}
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'],
I'll add derived functions after addFilter
to make things more convenient.
root@10d3a4e8077f:/workspace/scubaduck# sed -n '880,920p' scubaduck/static/index
.html
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 = {
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'),
graph_type: graphTypeSel.value,
filters: Array.from(document.querySelectorAll('#filters .filter')).map(f =>
{
const chips = f.chips || [];
I’ll add addDerived
and refreshDerivedColumns
after addFilter
, and update updateSelectedColumns
.
root@10d3a4e8077f:/workspace/scubaduck# grep -n "function updateSelectedColumns"
-n scubaduck/static/index.html
590:function updateSelectedColumns() {
root@10d3a4e8077f:/workspace/scubaduck# sed -n '588,620p' scubaduck/static/index
.html
});
function updateSelectedColumns() {
const base = allColumns.filter(name => {
const cb = document.querySelector(`#column_groups input[value="${name}"]`);
if (!cb || !cb.checked) return false;
if (graphTypeSel.value === 'table' && isStringColumn(name)) return false;
return true;
});
if (graphTypeSel.value === 'table' || graphTypeSel.value === 'timeseries') {
selectedColumns = groupBy.chips.slice();
if (document.getElementById('show_hits').checked) selectedColumns.push('Hits
');
base.forEach(c => {
if (!selectedColumns.includes(c)) selectedColumns.push(c);
});
} else {
selectedColumns = base;
}
}
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();
return t.includes('TIMESTAMP');
I'll update the selected columns with derived columns included.
root@10d3a4e8077f:/workspace/scubaduck# apply_patch<<'PATCH'
> *** Begin Patch
> *** Update File: scubaduck/static/index.html
> @@
> function updateSelectedColumns() {
> const base = allColumns.filter(name => {
> const cb = document.querySelector(`#column_groups input[value="${name}"]`
);
> if (!cb || !cb.checked) return false;
> if (graphTypeSel.value === 'table' && isStringColumn(name)) return false;
> return true;
> });
> + derivedColumns.forEach(dc => {
> + if (dc.include && !allColumns.includes(dc.name)) allColumns.push(dc.name)
;
> + });
> if (graphTypeSel.value === 'table' || graphTypeSel.value === 'timeseries')
{
> selectedColumns = groupBy.chips.slice();
> if (document.getElementById('show_hits').checked) selectedColumns.push('H
its');
> base.forEach(c => {
> if (!selectedColumns.includes(c)) selectedColumns.push(c);
> });
> + derivedColumns.forEach(dc => {
> + if (dc.include && !selectedColumns.includes(dc.name)) selectedColumns.p
ush(dc.name);
> + });
> } else {
> - selectedColumns = base;
> + selectedColumns = base.slice();
> + derivedColumns.forEach(dc => {
> + if (dc.include) selectedColumns.push(dc.name);
> + });
> }
> }
> *** End Patch
> PATCH
Success. Updated the following files:
M scubaduck/static/index.html
I'll update refreshDerivedColumns
to handle arrays and columnTypes after updating columns.
root@10d3a4e8077f:/workspace/scubaduck# grep -n "refreshDerivedColumns" -n scuba
duck/static/index.html
1015: refreshDerivedColumns();
I see that we need to define functions: addDerived and refreshDerivedColumns. Let's implement them.
root@10d3a4e8077f:/workspace/scubaduck# sed -n '1020,1038p' scubaduck/static/ind
ex.html
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() {
I'll define addDerived right after addFilter, just before let lastQueryTime = 0;
.
root@10d3a4e8077f:/workspace/scubaduck# grep -n "let lastQueryTime" -n scubaduck
/static/index.html
885:let lastQueryTime = 0;
I'll add the new functions after the addFilter around line 856.
root@10d3a4e8077f:/workspace/scubaduck# sed -n '840,890p' scubaduck/static/index
.html
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'],
]
: [
['=', '='],
['!=', '!='],
['<', '<'],
['>', '>'],
];
opSel.innerHTML = ops.map(o => `<option value="${o[0]}">${o[1]}</option>`).j
oin('');
updateInputVis();
}
function updateInputVis() {
const op = container.querySelector('.f-op').value;
const box = container.querySelector('.chip-box');
box.style.display = op === 'empty' || op === '!empty' ? 'none' : 'block';
}
colSel.addEventListener('change', populateOps);
container.querySelector('.f-op').addEventListener('change', updateInputVis);
populateOps();
document.getElementById('filter_list').appendChild(container);
initChipInput(container, (typed, el) => {
const colEl = el.querySelector('.f-col select') || el.querySelector('.f-col'
);
if (!colEl) return [];
const col = colEl.value;
if (!isStringColumn(col)) return [];
return fetch(`/api/samples?column=${encodeURIComponent(col)}&q=${encodeURICo
mponent(typed)}`)
.then(r => r.json());
});
}
let lastQueryTime = 0;
let queryStart = 0;
function dive(push=true) {
const params = collectParams();
if (push) {
I'll place the new functions before let lastQueryTime = 0;
. Time for a patch!
root@10d3a4e8077f:/workspace/scubaduck# apply_patch<<'PATCH'
> *** Begin Patch
> *** Update File: scubaduck/static/index.html
> @@
> initChipInput(container, (typed, el) => {
> const colEl = el.querySelector('.f-col select') || el.querySelector('.f-c
ol');
> if (!colEl) return [];
> const col = colEl.value;
> if (!isStringColumn(col)) return [];
> return fetch(`/api/samples?column=${encodeURIComponent(col)}&q=${encodeUR
IComponent(typed)}`)
> .then(r => r.json());
> });
> }
> +
> +function nextDerivedName() {
> + let n = 1;
> + while (true) {
> + const name = `derived_${n}`;
> + if (!derivedColumns.some(d => d.name === name) && !allColumns.includes(na
me)) return name;
> + n++;
> + }
> +}
> +
> +function addDerived(data = {}) {
> + const container = document.createElement('div');
> + container.className = 'derived';
> + container.innerHTML = `
> + <div class="derived-row">
> + <select class="d-type">
> + <option value="aggregated">Aggregated</option>
> + <option value="string">String</option>
> + <option value="numeric">Numeric</option>
> + </select>
> + <input class="d-name" type="text">
> + </div>
> + <label><input type="checkbox" class="d-use" checked> Include in Query</la
bel>
> + <textarea class="d-expr" rows="2"></textarea>
> + `;
> + document.getElementById('derived_list').appendChild(container);
> + const obj = {
> + type: data.type || 'string',
> + name: data.name || nextDerivedName(),
> + 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 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);
> + 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);
> + }
> + });
> + updateSelectedColumns();
> +}
> *** End Patch
> PATCH
Success. Updated the following files:
M scubaduck/static/index.html
I'll verify if the functions are defined and check for missing parentheses or duplicates.
root@10d3a4e8077f:/workspace/scubaduck# sed -n '880,920p' scubaduck/static/index
.html
return fetch(`/api/samples?column=${encodeURIComponent(col)}&q=${encodeURICo
mponent(typed)}`)
.then(r => r.json());
});
}
function nextDerivedName() {
let n = 1;
while (true) {
const name = `derived_${n}`;
if (!derivedColumns.some(d => d.name === name) && !allColumns.includes(name)
) return name;
n++;
}
}
function addDerived(data = {}) {
const container = document.createElement('div');
container.className = 'derived';
container.innerHTML = `
<div class="derived-row">
<select class="d-type">
<option value="aggregated">Aggregated</option>
<option value="string">String</option>
<option value="numeric">Numeric</option>
</select>
<input class="d-name" type="text">
</div>
<label><input type="checkbox" class="d-use" checked> Include in Query</label
>
<textarea class="d-expr" rows="2"></textarea>
`;
document.getElementById('derived_list').appendChild(container);
const obj = {
type: data.type || 'string',
name: data.name || nextDerivedName(),
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;
root@10d3a4e8077f:/workspace/scubaduck# sed -n '920,976p' scubaduck/static/index
.html
container.querySelector('.d-use').checked = obj.include;
['change','input'].forEach(evt => {
container.addEventListener(evt, refreshDerivedColumns);
});
derivedColumns.push(obj);
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);
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);
}
});
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);
})
I'll remove the earlier insertion in updateSelectedColumns to avoid duplicate updates in allColumns.
root@10d3a4e8077f:/workspace/scubaduck# sed -n '592,616p' scubaduck/static/index
.html
const cb = document.querySelector(`#column_groups input[value="${name}"]`);
if (!cb || !cb.checked) return false;
if (graphTypeSel.value === 'table' && isStringColumn(name)) return false;
return true;
});
derivedColumns.forEach(dc => {
if (dc.include && !allColumns.includes(dc.name)) allColumns.push(dc.name);
});
if (graphTypeSel.value === 'table' || graphTypeSel.value === 'timeseries') {
selectedColumns = groupBy.chips.slice();
if (document.getElementById('show_hits').checked) selectedColumns.push('Hits
');
base.forEach(c => {
if (!selectedColumns.includes(c)) selectedColumns.push(c);
});
derivedColumns.forEach(dc => {
if (dc.include && !selectedColumns.includes(dc.name)) selectedColumns.push
(dc.name);
});
} else {
selectedColumns = base.slice();
derivedColumns.forEach(dc => {
if (dc.include) selectedColumns.push(dc.name);
});
}
}
I'll remove the line, as it's already handled by refreshDerivedColumns.
root@10d3a4e8077f:/workspace/scubaduck# apply_patch<<'PATCH'
> *** Begin Patch
> *** Update File: scubaduck/static/index.html
> @@
> - derivedColumns.forEach(dc => {
> - if (dc.include && !allColumns.includes(dc.name)) allColumns.push(dc.name)
;
> - });
> *** End Patch
> PATCH
Success. Updated the following files:
M scubaduck/static/index.html
I'll review the rest of the lines for updateSelectedColumns.
root@10d3a4e8077f:/workspace/scubaduck# sed -n '588,616p' scubaduck/static/index
.html
});
function updateSelectedColumns() {
const base = allColumns.filter(name => {
const cb = document.querySelector(`#column_groups input[value="${name}"]`);
if (!cb || !cb.checked) return false;
if (graphTypeSel.value === 'table' && isStringColumn(name)) return false;
return true;
});
if (graphTypeSel.value === 'table' || graphTypeSel.value === 'timeseries') {
selectedColumns = groupBy.chips.slice();
if (document.getElementById('show_hits').checked) selectedColumns.push('Hits
');
base.forEach(c => {
if (!selectedColumns.includes(c)) selectedColumns.push(c);
});
derivedColumns.forEach(dc => {
if (dc.include && !selectedColumns.includes(dc.name)) selectedColumns.push
(dc.name);
});
} else {
selectedColumns = base.slice();
derivedColumns.forEach(dc => {
if (dc.include) selectedColumns.push(dc.name);
});
}
}
function isStringColumn(name) {
const t = (columnTypes[name] || '').toUpperCase();
return t.includes('CHAR') || t.includes('STRING') || t.includes('VARCHAR');
I'm opening 'test_server.py' to adjust the tests for derived column functionality.
root@10d3a4e8077f:/workspace/scubaduck# tail -n 20 tests/test_server.py
def test_timeseries_basic() -> None:
app = server.app
client = app.test_client()
payload = {
"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
root@10d3a4e8077f:/workspace/scubaduck# apply_patch<<'PATCH'
> *** Begin Patch
> *** Update File: tests/test_server.py
> @@
> assert len(data["rows"]) == 4
> +
> +
> +def test_derived_column_basic() -> None:
> + app = server.app
> + client = app.test_client()
> + payload = {
> + "start": "2024-01-01 00:00:00",
> + "end": "2024-01-03 00:00:00",
> + "limit": 10,
> + "columns": ["timestamp", "val2"],
> + "derived_columns": {"val2": "value * 2"},
> + "filters": [],
> + }
> + rv = client.post(
> + "/api/query", data=json.dumps(payload), content_type="application/jso
n"
> + )
> + data = rv.get_json()
> + assert rv.status_code == 200
> + assert data["rows"][0][1] == 20
> *** End Patch
> PATCH
Success. Updated the following files:
M tests/test_server.py
Time to include a frontend test and patch up after the last tests.
s/test_web.py
827:def test_format_number_function(page: Any, server_url: str) -> None:
root@10d3a4e8077f:/workspace/scubaduck# tail -n 20 tests/test_web.py
def test_table_headers_show_aggregate(page: Any, server_url: str) -> None:
run_query(
page,
server_url,
aggregate="Avg",
)
headers = page.locator("#results th").all_inner_texts()
assert "Hits" in headers
assert "timestamp (avg)" in headers
assert "value (avg)" in headers
def test_format_number_function(page: Any, server_url: str) -> None:
page.goto(server_url)
page.wait_for_selector("#order_by option", state="attached")
vals = page.evaluate(
"() => [formatNumber(815210), formatNumber(999.999), formatNumber(0.0004
), formatNumber(0)]"
)
assert vals == ["815.21 K", "999.999", "0.000", "0"]
Time to patch things up before adding a new test.
> *** Begin Patch
> *** Update File: tests/test_web.py
> @@
> def test_format_number_function(page: Any, server_url: str) -> None:
> page.goto(server_url)
> page.wait_for_selector("#order_by option", state="attached")
> vals = page.evaluate(
> "() => [formatNumber(815210), formatNumber(999.999), formatNumber(0.0
004), formatNumber(0)]"
> )
> assert vals == ["815.21 K", "999.999", "0.000", "0"]
> +
> +
> +def test_derived_column_query(page: Any, server_url: str) -> None:
> + page.goto(server_url)
> + page.wait_for_selector("#order_by option", state="attached")
> + page.click("text=Columns")
> + page.click("text=Add Derived")
> + expr = page.query_selector("#derived_list .derived textarea")
> + assert expr
> + 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")
> + page.fill("#limit", "10")
> + page.evaluate("window.lastResults = undefined")
> + page.click("text=Dive")
> + page.wait_for_function("window.lastResults !== undefined")
> + data = page.evaluate("window.lastResults")
> + assert data["rows"][0][-1] == 20
> *** End Patch
> PATCH
Success. Updated the following files:
M tests/test_web.py
scubaduck/static/index.html scubaduck/server.py tests/test_server.py tests/test
_web.py
error: Failed to parse scubaduck/static/index.html:1:1: Expected a statement
3 files left unchanged
(scubaduck) root@10d3a4e8077f:/workspace/scubaduck#
I'll use ruff format on Python files only; HTML files won't be processed.
(scubaduck) root@10d3a4e8077f:/workspace/scubaduck# ruff format
6 files left unchanged
(scubaduck) root@10d3a4e8077f:/workspace/scubaduck# ruff check
All checks passed!
(scubaduck) root@10d3a4e8077f:/workspace/scubaduck# pyright
0 errors, 0 warnings, 0 informations
(scubaduck) root@10d3a4e8077f:/workspace/scubaduck# pytest -q
bringing up nodes...
............F............................................F [100%]
=================================== FAILURES ===================================
__________________________ test_derived_column_basic ___________________________
[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_server.py", line 388, in test_derived_co
lumn_basic
assert rv.status_code == 200
AssertionError: assert 400 == 200
+ where 400 = <WrapperTestResponse 656 bytes [400 BAD REQUEST]>.status_code
----------------------------- Captured stdout call -----------------------------
Query failed:
SELECT timestamp, val2, value * 2 AS val2 FROM events WHERE timestamp >= '2024-0
1-01 00:00:00' AND timestamp <= '2024-01-03 00:00:00' LIMIT 10
Traceback (most recent call last):
File "/workspace/scubaduck/scubaduck/server.py", line 397, in query
rows = con.execute(sql).fetchall()
^^^^^^^^^^^^^^^^
duckdb.duckdb.BinderException: Binder Error: Column "val2" referenced that exist
s in the SELECT clause - but this column cannot be referenced before it is defin
ed
_____________________ test_derived_column_query[chromium] ______________________
[gw3] 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.py", line 852, in test_derived_colum
n_query
assert data["rows"][0][-1] == 20
~~~~^^^^^^^^
KeyError: 'rows'
----------------------------- Captured stdout call -----------------------------
Query failed:
SELECT timestamp, event, value, user, derived_1, value * 2 AS derived_1 FROM eve
nts WHERE timestamp >= '2024-01-01 00:00:00' AND timestamp <= '2024-01-03 00:00:
00' ORDER BY timestamp ASC LIMIT 10
Traceback (most recent call last):
File "/workspace/scubaduck/scubaduck/server.py", line 397, in query
rows = con.execute(sql).fetchall()
^^^^^^^^^^^^^^^^
duckdb.duckdb.BinderException: Binder Error: Column "derived_1" referenced that
exists in the SELECT clause - but this column cannot be referenced before it is
defined
------------------------------ Captured log call -------------------------------
INFO werkzeug:_internal.py:97 127.0.0.1 - - [21/May/2025 03:55:41] "GET / HT
TP/1.1" 200 -
INFO werkzeug:_internal.py:97 127.0.0.1 - - [21/May/2025 03:55:41] "GET /api
/columns HTTP/1.1" 200 -
INFO werkzeug:_internal.py:97 127.0.0.1 - - [21/May/2025 03:55:41] "POST /ap
i/query HTTP/1.1" 200 -
INFO werkzeug:_internal.py:97 127.0.0.1 - - [21/May/2025 03:55:41] "POST /ap
i/query HTTP/1.1" 400 -
=========================== short test summary info ============================
FAILED tests/test_server.py::test_derived_column_basic - assert 400 == 200
FAILED tests/test_web.py::test_derived_column_query[chromium] - KeyError: 'rows'
2 failed, 56 passed in 8.83s
(scubaduck) root@10d3a4e8077f:/workspace/scubaduck#
Looks like including derived columns in both lists causes duplicates—let's adjust the logic.
Keep both base and derived columns for rendering, but exclude derived from the payload.
(scubaduck) root@10d3a4e8077f:/workspace/scubaduck# grep -n "collectParams()" -n
scubaduck/static/index.html
950: const params = collectParams();
979:function collectParams() {
(scubaduck) root@10d3a4e8077f:/workspace/scubaduck# sed -n '979,1003p' scubaduck
/static/index.html
function collectParams() {
updateSelectedColumns();
const payload = {
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'),
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,
(scubaduck) root@10d3a4e8077f:/workspace/scubaduck# sed -n '1003,1030p' scubaduc
k/static/index.html
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') {
payload.x_axis = document.getElementById('x_axis').value;
payload.granularity = document.getElementById('granularity').value;
payload.fill = document.getElementById('fill').value;
}
return payload;
}
function paramsToSearch(params) {
const sp = new URLSearchParams();
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.columns && params.columns.length) sp.set('columns', params.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') {
(scubaduck) root@10d3a4e8077f:/workspace/scubaduck#
I need to modify the columns filtering. Let's make the necessary patch.
(scubaduck) root@10d3a4e8077f:/workspace/scubaduck# apply_patch<<'PATCH'
> *** Begin Patch
> *** Update File: scubaduck/static/index.html
> @@
> - columns: selectedColumns.filter(c => c !== 'Hits'),
> + columns: selectedColumns.filter(c =>
> + c !== 'Hits' && !derivedColumns.some(dc => dc.name === c)
> + ),
> *** End Patch
> PATCH
Success. Updated the following files:
M scubaduck/static/index.html
(scubaduck) root@10d3a4e8077f:/workspace/scubaduck#
I’ll change updateSelectedColumns to prevent auto-adding derived columns, but handle that in refreshDerivedColumns.
(scubaduck) root@10d3a4e8077f:/workspace/scubaduck# ruff format tests/test_serve
r.py tests/test_web.py scubaduck/server.py
3 files left unchanged
(scubaduck) root@10d3a4e8077f:/workspace/scubaduck# ruff check
All checks passed!
(scubaduck) root@10d3a4e8077f:/workspace/scubaduck#
I'll run pyright to check the type correctness of the code.
(scubaduck) root@10d3a4e8077f:/workspace/scubaduck# pyright
0 errors, 0 warnings, 0 informations
(scubaduck) root@10d3a4e8077f:/workspace/scubaduck#
I'll run the tests once more to see if they pass this time.
(scubaduck) root@10d3a4e8077f:/workspace/scubaduck# pytest -q
bringing up nodes...
.............F............................................ [100%]
=================================== FAILURES ===================================
__________________________ test_derived_column_basic ___________________________
[gw3] 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_server.py", line 388, in test_derived_co
lumn_basic
assert rv.status_code == 200
AssertionError: assert 400 == 200
+ where 400 = <WrapperTestResponse 656 bytes [400 BAD REQUEST]>.status_code
----------------------------- Captured stdout call -----------------------------
Query failed:
SELECT timestamp, val2, value * 2 AS val2 FROM events WHERE timestamp >= '2024-0
1-01 00:00:00' AND timestamp <= '2024-01-03 00:00:00' LIMIT 10
Traceback (most recent call last):
File "/workspace/scubaduck/scubaduck/server.py", line 397, in query
rows = con.execute(sql).fetchall()
^^^^^^^^^^^^^^^^
duckdb.duckdb.BinderException: Binder Error: Column "val2" referenced that exist
s in the SELECT clause - but this column cannot be referenced before it is defin
ed
=========================== short test summary info ============================
FAILED tests/test_server.py::test_derived_column_basic - assert 400 == 200
1 failed, 57 passed in 8.46s
(scubaduck) root@10d3a4e8077f:/workspace/scubaduck#
I'll update the test_server to send columns and derived_map, excluding val2 from columns.
(scubaduck) root@10d3a4e8077f:/workspace/scubaduck# sed -n '360,408p' tests/test
_server.py
"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_derived_column_basic() -> None:
app = server.app
client = app.test_client()
payload = {
"start": "2024-01-01 00:00:00",
"end": "2024-01-03 00:00:00",
"limit": 10,
"columns": ["timestamp", "val2"],
"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
(scubaduck) root@10d3a4e8077f:/workspace/scubaduck#
I'll update the test to check that rows[0][1]
is 20, as needed.
> *** Begin Patch
> *** Update File: tests/test_server.py
> @@
> - payload = {
> - "start": "2024-01-01 00:00:00",
> - "end": "2024-01-03 00:00:00",
> - "limit": 10,
> - "columns": ["timestamp", "val2"],
> - "derived_columns": {"val2": "value * 2"},
> - "filters": [],
> - }
> + payload = {
> + "start": "2024-01-01 00:00:00",
> + "end": "2024-01-03 00:00:00",
> + "limit": 10,
> + "columns": ["timestamp"],
> + "derived_columns": {"val2": "value * 2"},
> + "filters": [],
> + }
> *** End Patch
> PATCH
Success. Updated the following files:
M tests/test_server.py
(scubaduck) root@10d3a4e8077f:/workspace/scubaduck#
I'll give ruff format another shot to make sure the code is properly styled.
(scubaduck) root@10d3a4e8077f:/workspace/scubaduck# ruff format tests/test_serve
r.py
1 file left unchanged
(scubaduck) root@10d3a4e8077f:/workspace/scubaduck# ruff check
All checks passed!
(scubaduck) root@10d3a4e8077f:/workspace/scubaduck# pyright
0 errors, 0 warnings, 0 informations
(scubaduck) root@10d3a4e8077f:/workspace/scubaduck#
I'll re-run the tests now and see if they pass this time.
(scubaduck) root@10d3a4e8077f:/workspace/scubaduck# pytest -q
bringing up nodes...
.......................................................... [100%]
58 passed in 8.59s
(scubaduck) root@10d3a4e8077f:/workspace/scubaduck#
All tests have passed, so it's time to go ahead and commit the changes.
(scubaduck) root@10d3a4e8077f:/workspace/scubaduck# git status --short
M scubaduck/server.py
M scubaduck/static/index.html
M tests/test_server.py
M tests/test_web.py
(scubaduck) root@10d3a4e8077f:/workspace/scubaduck#
Time to commit the changes now!
(scubaduck) root@10d3a4e8077f:/workspace/scubaduck# git add scubaduck/server.py
scubaduck/static/index.html tests/test_server.py tests/test_web.py
(scubaduck) root@10d3a4e8077f:/workspace/scubaduck# git commit -m "feat(ui): add
derived column support" && git status --short
[work 2abef9c] feat(ui): add derived column support
4 files changed, 179 insertions(+), 5 deletions(-)
(scubaduck) root@10d3a4e8077f:/workspace/scubaduck#
Let's check the current status of the repository with git status
.
(scubaduck) root@10d3a4e8077f:/workspace/scubaduck# git status --short
(scubaduck) root@10d3a4e8077f:/workspace/scubaduck#
I’ll start capturing the relevant code lines around valid_cols in server.py.
(scubaduck) root@10d3a4e8077f:/workspace/scubaduck# nl -ba scubaduck/server.py |
sed -n '300,355p'
300 derived_columns=payload.get("derived_columns", {}),
301 graph_type=payload.get("graph_type", "samples"),
302 group_by=payload.get("group_by", []),
303 aggregate=payload.get("aggregate"),
304 show_hits=payload.get("show_hits", False),
305 x_axis=payload.get("x_axis"),
306 granularity=payload.get("granularity", "Auto"),
307 fill=payload.get("fill", "0"),
308 )
309 for f in payload.get("filters", []):
310 params.filters.append(Filter(f["column"], f["op"], f.get("va
lue")))
311
312 if params.graph_type not in {"table", "timeseries"} and (
313 params.group_by or params.aggregate or params.show_hits
314 ):
315 return (
316 jsonify(
317 {
318 "error": "group_by, aggregate and show_hits are
only valid for table or timeseries view"
319 }
320 ),
321 400,
322 )
323
324 valid_cols = set(column_types.keys())
325 valid_cols.update(params.derived_columns.keys())
326 if params.graph_type == "timeseries":
327 if params.x_axis is None:
328 for cand in ["time", "timestamp"]:
329 if cand in valid_cols:
330 params.x_axis = cand
331 break
332 if params.x_axis is None or params.x_axis not in valid_cols:
333 return jsonify({"error": "Invalid x_axis"}), 400
334 ctype = column_types.get(params.x_axis, "").upper()
335 if not any(t in ctype for t in ["TIMESTAMP", "DATE", "TIME"]
):
336 return jsonify({"error": "x_axis must be a time column"}
), 400
337 for col in params.columns:
338 if col not in valid_cols:
339 return jsonify({"error": f"Unknown column: {col}"}), 400
340 for col in params.group_by:
341 if col not in valid_cols:
342 return jsonify({"error": f"Unknown column: {col}"}), 400
343 if params.order_by and params.order_by not in valid_cols:
344 return jsonify({"error": f"Unknown column: {params.order_by}
"}), 400
345
346 if params.group_by:
347 agg = (params.aggregate or "avg").lower()
348 if agg.startswith("p") or agg == "sum":
349 need_numeric = True
350 allow_time = False
351 elif agg == "avg" or agg in {"min", "max"}:
352 need_numeric = False
353 allow_time = True
354 else:
355 need_numeric = False
(scubaduck) root@10d3a4e8077f:/workspace/scubaduck#
I'll continue displaying the patch for the numeric check.
(scubaduck) root@10d3a4e8077f:/workspace/scubaduck# nl -ba scubaduck/server.py |
sed -n '346,385p'
346 if params.group_by:
347 agg = (params.aggregate or "avg").lower()
348 if agg.startswith("p") or agg == "sum":
349 need_numeric = True
350 allow_time = False
351 elif agg == "avg" or agg in {"min", "max"}:
352 need_numeric = False
353 allow_time = True
354 else:
355 need_numeric = False
356 allow_time = False
357 if need_numeric or allow_time:
358 for c in params.columns:
359 if c in params.group_by or c == params.x_axis:
360 continue
361 if c not in column_types:
362 continue
363 ctype = column_types.get(c, "").upper()
364 is_numeric = any(
365 t in ctype
366 for t in [
367 "INT",
368 "DECIMAL",
369 "REAL",
370 "DOUBLE",
371 "FLOAT",
372 "NUMERIC",
373 "HUGEINT",
374 ]
375 )
376 is_time = "TIMESTAMP" in ctype or "DATE" in ctype or
"TIME" in ctype
377 if need_numeric and not is_numeric:
378 return (
379 jsonify(
380 {
381 "error": f"Aggregate {agg} cannot be
applied to column {c}",
382 }
383 ),
384 400,
385 )
(scubaduck) root@10d3a4e8077f:/workspace/scubaduck#
I’ll grab the lines for the JS function addDerived and others.
(scubaduck) root@10d3a4e8077f:/workspace/scubaduck# nl -ba scubaduck/static/inde
x.html | sed -n '250,320p'
250 <option>p99.9</option>
251 <option>p99.99</option>
252 </select>
253 </div>
254 <div id="show_hits_field" class="field" style="display:none;">
255 <label>Show Hits</label>
256 <input id="show_hits" type="checkbox" checked>
257 </div>
258 <div id="filters">
259 <h4>Filters<span class="help" title="You can create as many fi
lters as you want. You can either write a filter using a UI or manual SQL. In th
e UI, filter consists of a column name, a relation (e.g., =, !=, <, >) and then
a text field. 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>
260 <div id="filter_list"></div>
261 <button id="add_filter" type="button" onclick="addFilter()">Ad
d Filter</button>
262 </div>
263 <div id="query_info" style="margin-top:10px;"></div>
264 </div>
265 <div id="columns" class="tab-content">
266 <div id="column_actions">
267 <a id="columns_all" href="#">All</a>
268 <a id="columns_none" href="#">None</a>
269 </div>
270 <div id="column_groups"></div>
271 <div id="derived_columns">
272 <h4>Derived Columns</h4>
273 <div id="derived_list"></div>
274 <button id="add_derived" type="button" onclick="addDerived()">
Add Derived</button>
275 </div>
276 </div>
277 </div>
278 <div id="sidebar-resizer"></div>
279 <div id="view">
280 <table id="results"></table>
281 </div>
282 </div>
283 <script>
284 const allColumns = [];
285 const baseColumns = [];
286 const columnTypes = {};
287 const stringColumns = [];
288 const baseStringColumns = [];
289 const integerColumns = [];
290 const baseIntegerColumns = [];
291 const timeColumns = [];
292 const baseTimeColumns = [];
293 const derivedColumns = [];
294 let selectedColumns = [];
295 let displayType = 'samples';
296 let groupBy = {chips: [], addChip: () => {}, renderChips: () => {}};
297
298 function initDropdown(select) {
299 const wrapper = document.createElement('div');
300 wrapper.className = 'dropdown';
301 if (select.classList.contains('f-col')) {
302 wrapper.classList.add('f-col');
303 }
304 select.parentNode.insertBefore(wrapper, select);
305 wrapper.appendChild(select);
306 select.style.display = 'none';
307 const disp = document.createElement('div');
308 disp.className = 'dropdown-display';
309 function updateDisplay() {
310 const opt = select.options[select.selectedIndex];
311 disp.textContent = opt ? opt.textContent : '';
312 }
313 updateDisplay();
314 wrapper.appendChild(disp);
315 const menu = document.createElement('div');
316 menu.className = 'dropdown-menu';
317 const search = document.createElement('input');
318 search.placeholder = 'Search';
319 menu.appendChild(search);
320 const list = document.createElement('div');
(scubaduck) root@10d3a4e8077f:/workspace/scubaduck#
I'll identify the lines where the addDerived functions are implemented.
(scubaduck) root@10d3a4e8077f:/workspace/scubaduck# nl -ba scubaduck/static/inde
x.html | sed -n '860,972p'
860 }
861
862 function updateInputVis() {
863 const op = container.querySelector('.f-op').value;
864 const box = container.querySelector('.chip-box');
865 box.style.display = op === 'empty' || op === '!empty' ? 'none' : 'bl
ock';
866 }
867
868 colSel.addEventListener('change', populateOps);
869 container.querySelector('.f-op').addEventListener('change', updateInpu
tVis);
870 populateOps();
871 document.getElementById('filter_list').appendChild(container);
872 initChipInput(container, (typed, el) => {
873 const colEl = el.querySelector('.f-col select') || el.querySelector(
'.f-col');
874 if (!colEl) return [];
875 const col = colEl.value;
876 if (!isStringColumn(col)) return [];
877 return fetch(`/api/samples?column=${encodeURIComponent(col)}&q=${enc
odeURIComponent(typed)}`)
878 .then(r => r.json());
879 });
880 }
881
882 function nextDerivedName() {
883 let n = 1;
884 while (true) {
885 const name = `derived_${n}`;
886 if (!derivedColumns.some(d => d.name === name) && !allColumns.includ
es(name)) return name;
887 n++;
888 }
889 }
890
891 function addDerived(data = {}) {
892 const container = document.createElement('div');
893 container.className = 'derived';
894 container.innerHTML = `
895 <div class="derived-row">
896 <select class="d-type">
897 <option value="aggregated">Aggregated</option>
898 <option value="string">String</option>
899 <option value="numeric">Numeric</option>
900 </select>
901 <input class="d-name" type="text">
902 </div>
903 <label><input type="checkbox" class="d-use" checked> Include in Quer
y</label>
904 <textarea class="d-expr" rows="2"></textarea>
905 `;
906 document.getElementById('derived_list').appendChild(container);
907 const obj = {
908 type: data.type || 'string',
909 name: data.name || nextDerivedName(),
910 expr: data.expr || '',
911 include: data.include !== undefined ? data.include : true,
912 el: container
913 };
914 container.querySelector('.d-type').value = obj.type;
915 container.querySelector('.d-name').value = obj.name;
916 container.querySelector('.d-expr').value = obj.expr;
917 container.querySelector('.d-use').checked = obj.include;
918 ['change','input'].forEach(evt => {
919 container.addEventListener(evt, refreshDerivedColumns);
920 });
921 derivedColumns.push(obj);
922 refreshDerivedColumns();
923 }
924
925 function refreshDerivedColumns() {
926 allColumns.splice(0, allColumns.length, ...baseColumns);
927 stringColumns.splice(0, stringColumns.length, ...baseStringColumns);
928 integerColumns.splice(0, integerColumns.length, ...baseIntegerColumns)
;
929 timeColumns.splice(0, timeColumns.length, ...baseTimeColumns);
930 derivedColumns.forEach(d => {
931 d.type = d.el.querySelector('.d-type').value;
932 d.name = d.el.querySelector('.d-name').value;
933 d.expr = d.el.querySelector('.d-expr').value;
934 d.include = d.el.querySelector('.d-use').checked;
935 allColumns.push(d.name);
936 columnTypes[d.name] = d.type;
937 if (d.type === 'string') {
938 stringColumns.push(d.name);
939 } else {
940 integerColumns.push(d.name);
941 }
942 });
943 updateSelectedColumns();
944 }
945
946 let lastQueryTime = 0;
947 let queryStart = 0;
948
949 function dive(push=true) {
950 const params = collectParams();
951 if (push) {
952 history.pushState(params, '', paramsToSearch(params));
953 }
954 const payload = Object.assign({}, params);
955 const dcMap = {};
956 (params.derived_columns || []).forEach(d => {
957 if (d.include) dcMap[d.name] = d.expr;
958 });
959 payload.derived_columns = dcMap;
960 const view = document.getElementById('view');
961 view.innerHTML = '<p>Loading...</p>';
962 window.lastResults = undefined;
963 queryStart = performance.now();
964 fetch('/api/query', {method:'POST', headers:{'Content-Type':'applicati
on/json'}, body:JSON.stringify(payload)})
965 .then(async r => {
966 const data = await r.json();
967 if (!r.ok) throw data;
968 return data;
969 })
970 .then(data => {
971 lastQueryTime = Math.round(performance.now() - queryStart);
972 showResults(data);
(scubaduck) root@10d3a4e8077f:/workspace/scubaduck#
I'll grab the line numbers where I modified collectParams.
(scubaduck) root@10d3a4e8077f:/workspace/scubaduck# nl -ba scubaduck/static/inde
x.html | sed -n '978,1040p'
978
979 function collectParams() {
980 updateSelectedColumns();
981 const payload = {
982 start: document.getElementById('start').value,
983 end: document.getElementById('end').value,
984 order_by: document.getElementById('order_by').value,
985 order_dir: orderDir,
986 limit: parseInt(document.getElementById('limit').value, 10),
987 columns: selectedColumns.filter(c =>
988 c !== 'Hits' && !derivedColumns.some(dc => dc.name === c)
989 ),
990 graph_type: graphTypeSel.value,
991 filters: Array.from(document.querySelectorAll('#filters .filter')).m
ap(f => {
992 const chips = f.chips || [];
993 const op = f.querySelector('.f-op').value;
994 let value = null;
995 if (op !== 'empty' && op !== '!empty') {
996 value = chips.length === 0 ? null : (chips.length === 1 ? chips[
0] : chips);
997 }
998 const colSel = f.querySelector('.f-col select') || f.querySelector
('.f-col');
999 return {column: colSel.value, op, value};
1000 }),
1001 derived_columns: Array.from(document.querySelectorAll('#derived_list
.derived')).map(d => ({
1002 type: d.querySelector('.d-type').value,
1003 name: d.querySelector('.d-name').value,
1004 expr: d.querySelector('.d-expr').value,
1005 include: d.querySelector('.d-use').checked,
1006 }))
1007 };
1008 if (graphTypeSel.value === 'table' || graphTypeSel.value === 'timeseri
es') {
1009 payload.group_by = groupBy.chips || [];
1010 payload.aggregate = document.getElementById('aggregate').value;
1011 payload.show_hits = document.getElementById('show_hits').checked;
1012 }
1013 if (graphTypeSel.value === 'timeseries') {
1014 payload.x_axis = document.getElementById('x_axis').value;
1015 payload.granularity = document.getElementById('granularity').value;
1016 payload.fill = document.getElementById('fill').value;
1017 }
1018 return payload;
1019 }
1020
1021 function paramsToSearch(params) {
1022 const sp = new URLSearchParams();
1023 if (params.start) sp.set('start', params.start);
1024 if (params.end) sp.set('end', params.end);
1025 if (params.order_by) sp.set('order_by', params.order_by);
1026 if (params.order_dir) sp.set('order_dir', params.order_dir);
1027 if (params.limit !== null && params.limit !== undefined) sp.set('limit
', params.limit);
1028 if (params.columns && params.columns.length) sp.set('columns', params.
columns.join(','));
1029 if (params.filters && params.filters.length) sp.set('filters', JSON.st
ringify(params.filters));
1030 if (params.derived_columns && params.derived_columns.length) sp.set('d
erived_columns', JSON.stringify(params.derived_columns));
1031 if (params.graph_type) sp.set('graph_type', params.graph_type);
1032 if (params.graph_type === 'table' || params.graph_type === 'timeseries
') {
1033 if (params.group_by && params.group_by.length) sp.set('group_by', pa
rams.group_by.join(','));
1034 if (params.aggregate) sp.set('aggregate', params.aggregate);
1035 if (params.show_hits) sp.set('show_hits', '1');
1036 }
1037 if (params.graph_type === 'timeseries') {
1038 if (params.x_axis) sp.set('x_axis', params.x_axis);
1039 if (params.granularity) sp.set('granularity', params.granularity);
1040 if (params.fill) sp.set('fill', params.fill);
(scubaduck) root@10d3a4e8077f:/workspace/scubaduck#
I'll grab the line ranges for the changes made to parseSearch.
(scubaduck) root@10d3a4e8077f:/workspace/scubaduck# nl -ba scubaduck/static/inde
x.html | sed -n '1040,1085p'
1040 if (params.fill) sp.set('fill', params.fill);
1041 }
1042 const qs = sp.toString();
1043 return qs ? '?' + qs : '';
1044 }
1045
1046 function applyParams(params) {
1047 document.getElementById('start').value = params.start || '';
1048 document.getElementById('end').value = params.end || '';
1049 if (params.order_by) {
1050 document.getElementById('order_by').value = params.order_by;
1051 }
1052 orderDir = params.order_dir || 'ASC';
1053 updateOrderDirButton();
1054 if (params.limit !== undefined && params.limit !== null) {
1055 document.getElementById('limit').value = params.limit;
1056 }
1057 graphTypeSel.value = params.graph_type || 'samples';
1058 updateDisplayTypeUI();
1059 if (params.x_axis) document.getElementById('x_axis').value = params.x_
axis;
1060 if (params.granularity) document.getElementById('granularity').value =
params.granularity;
1061 if (params.fill) document.getElementById('fill').value = params.fill;
1062 if (params.group_by) {
1063 groupBy.chips.splice(0, groupBy.chips.length, ...params.group_by);
1064 groupBy.renderChips();
1065 }
1066 if (params.aggregate) document.getElementById('aggregate').value = par
ams.aggregate;
1067 document.getElementById('show_hits').checked = params.show_hits ?? tru
e;
1068 document.querySelectorAll('#column_groups input').forEach(cb => {
1069 cb.checked = !params.columns || params.columns.includes(cb.value);
1070 });
1071 updateSelectedColumns();
1072 const dlist = document.getElementById('derived_list');
1073 dlist.innerHTML = '';
1074 derivedColumns.splice(0, derivedColumns.length);
1075 if (params.derived_columns && params.derived_columns.length) {
1076 params.derived_columns.forEach(dc => addDerived(dc));
1077 }
1078 refreshDerivedColumns();
1079 const list = document.getElementById('filter_list');
1080 list.innerHTML = '';
1081 if (params.filters && params.filters.length) {
1082 params.filters.forEach(f => {
1083 addFilter();
1084 const el = list.lastElementChild;
1085 const colSel = el.querySelector('.f-col select') || el.querySelect
or('.f-col');
(scubaduck) root@10d3a4e8077f:/workspace/scubaduck# nl -ba scubaduck/static/inde
x.html | sed -n '1100,1125p'
1100
1101 function parseSearch() {
1102 const sp = new URLSearchParams(window.location.search);
1103 const params = {};
1104 if (sp.has('start')) params.start = sp.get('start');
1105 if (sp.has('end')) params.end = sp.get('end');
1106 if (sp.has('order_by')) params.order_by = sp.get('order_by');
1107 if (sp.has('order_dir')) params.order_dir = sp.get('order_dir');
1108 if (sp.has('limit')) params.limit = parseInt(sp.get('limit'), 10);
1109 if (sp.has('columns')) params.columns = sp.get('columns').split(',').f
ilter(c => c);
1110 if (sp.has('filters')) {
1111 try { params.filters = JSON.parse(sp.get('filters')); } catch(e) { p
arams.filters = []; }
1112 }
1113 if (sp.has('graph_type')) params.graph_type = sp.get('graph_type');
1114 if (sp.has('group_by')) params.group_by = sp.get('group_by').split(','
).filter(c => c);
1115 if (sp.has('aggregate')) params.aggregate = sp.get('aggregate');
1116 if (sp.has('show_hits')) params.show_hits = sp.get('show_hits') === '1
';
1117 if (sp.has('x_axis')) params.x_axis = sp.get('x_axis');
1118 if (sp.has('granularity')) params.granularity = sp.get('granularity');
1119 if (sp.has('fill')) params.fill = sp.get('fill');
1120 if (sp.has('derived_columns')) {
1121 try { params.derived_columns = JSON.parse(sp.get('derived_columns'))
; } catch(e) { params.derived_columns = []; }
1122 }
1123 return params;
1124 }
1125
(scubaduck) root@10d3a4e8077f:/workspace/scubaduck#
I'll extract the line ranges for the test changes related to the derived column.
(scubaduck) root@10d3a4e8077f:/workspace/scubaduck# nl -ba tests/test_web.py | s
ed -n '824,870p'
824 assert "value (avg)" in headers
825
826
827 def test_format_number_function(page: Any, server_url: str) -> None:
828 page.goto(server_url)
829 page.wait_for_selector("#order_by option", state="attached")
830 vals = page.evaluate(
831 "() => [formatNumber(815210), formatNumber(999.999), formatNumbe
r(0.0004), formatNumber(0)]"
832 )
833 assert vals == ["815.21 K", "999.999", "0.000", "0"]
834
835
836 def test_derived_column_query(page: Any, server_url: str) -> None:
837 page.goto(server_url)
838 page.wait_for_selector("#order_by option", state="attached")
839 page.click("text=Columns")
840 page.click("text=Add Derived")
841 expr = page.query_selector("#derived_list .derived textarea")
842 assert expr
843 expr.fill("value * 2")
844 page.click("text=View Settings")
845 page.fill("#start", "2024-01-01 00:00:00")
846 page.fill("#end", "2024-01-03 00:00:00")
847 page.fill("#limit", "10")
848 page.evaluate("window.lastResults = undefined")
849 page.click("text=Dive")
850 page.wait_for_function("window.lastResults !== undefined")
851 data = page.evaluate("window.lastResults")
852 assert data["rows"][0][-1] == 20
(scubaduck) root@10d3a4e8077f:/workspace/scubaduck#
I also made a change to test_server.
(scubaduck) root@10d3a4e8077f:/workspace/scubaduck# nl -ba tests/test_server.py
| sed -n '372,402p'
372
373 def test_derived_column_basic() -> None:
374 app = server.app
375 client = app.test_client()
376 payload = {
377 "start": "2024-01-01 00:00:00",
378 "end": "2024-01-03 00:00:00",
379 "limit": 10,
380 "columns": ["timestamp"],
381 "derived_columns": {"val2": "value * 2"},
382 "filters": [],
383 }
384 rv = client.post(
385 "/api/query", data=json.dumps(payload), content_type="applicatio
n/json"
386 )
387 data = rv.get_json()
388 assert rv.status_code == 200
389 assert data["rows"][0][1] == 20
(scubaduck) root@10d3a4e8077f:/workspace/scubaduck#
Time to write up the PR message! Let's get this final draft ready.