Task ID: task_e_682fd2584e148323bdbc610ebc943389
Configuring language runtimes...
+ rm -- /tmp/8sySfA-setup_script.sh
+ source ./codex_setup.sh
++ uv sync --frozen
Using CPython 3.12.3 interpreter at: /usr/bin/python3.12
Creating virtual environment at: .venv
Building scubaduck @ file:///workspace/scubaduck
Downloading duckdb (19.3MiB)
Downloading ruff (11.0MiB)
Downloading playwright (43.1MiB)
Downloading pyright (5.3MiB)
Downloading ruff
Downloading duckdb
Built scubaduck @ file:///workspace/scubaduck
Downloading playwright
Downloading pyright
Prepared 33 packages in 1.57s
Installed 33 packages in 94ms
+ 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/8sySfA-setup_script.sh ']'
+++ deactivate nondestructive
+++ unset -f pydoc
+++ '[' -z '' ']'
+++ '[' -z '' ']'
+++ hash -r
+++ '[' -z '' ']'
+++ unset VIRTUAL_ENV
+++ unset VIRTUAL_ENV_PROMPT
+++ '[' '!' nondestructive = nondestructive ']'
+++ VIRTUAL_ENV=/workspace/scubaduck/.venv
+++ '[' linux-gnu = cygwin ']'
+++ '[' linux-gnu = msys ']'
+++ export VIRTUAL_ENV
+++ '[' -z '' ']'
+++ unset SCRIPT_PATH
+++ _OLD_VIRTUAL_PATH=/root/.cargo/bin:/root/.rbenv/shims:/root/.rbenv/bin:/root/.rbenv/shims:/root/.local/share/swiftly/bin:/root/.bun/bin:/root/.nvm/versions/node/v22.16.0/bin:/root/.pyenv/shims:3441PYENV_ROOT/shims:/root/.pyenv/bin:/usr/local/go/bin:/root/go/bin:/root/.rbenv/bin:/root/.rbenv/shims:/root/.bun/bin:/root/.local/bin:/root/.pyenv/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
+++ PATH=/workspace/scubaduck/.venv/bin:/root/.cargo/bin:/root/.rbenv/shims:/root/.rbenv/bin:/root/.rbenv/shims:/root/.local/share/swiftly/bin:/root/.bun/bin:/root/.nvm/versions/node/v22.16.0/bin:/root/.pyenv/shims:3441PYENV_ROOT/shims:/root/.pyenv/bin:/usr/local/go/bin:/root/go/bin:/root/.rbenv/bin:/root/.rbenv/shims:/root/.bun/bin:/root/.local/bin:/root/.pyenv/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
+++ export PATH
+++ '[' xscubaduck '!=' x ']'
+++ VIRTUAL_ENV_PROMPT=scubaduck
+++ export VIRTUAL_ENV_PROMPT
+++ '[' -z '' ']'
+++ '[' -z '' ']'
+++ _OLD_VIRTUAL_PS1=
+++ PS1='(scubaduck) '
+++ export PS1
+++ alias pydoc
+++ true
+++ hash -r
++ python -c 'import os; import duckdb; con = duckdb.connect(); con.execute(f"SET http_proxy = '\''{os.getenv("HTTP_PROXY")}'\''"); con.execute("INSTALL '\''sqlite'\'';")'
++ playwright install chromium
Downloading Chromium 136.0.7103.25 (playwright build v1169) from https://cdn.playwright.dev/dbazure/download/playwright/builds/chromium/1169/chromium-linux.zip
167.7 MiB [] 0% 0.0s167.7 MiB [] 0% 25.8s167.7 MiB [] 0% 28.4s167.7 MiB [] 0% 21.0s167.7 MiB [] 0% 14.7s167.7 MiB [] 0% 9.5s167.7 MiB [] 1% 6.8s167.7 MiB [] 1% 5.5s167.7 MiB [] 3% 3.9s167.7 MiB [] 4% 2.8s167.7 MiB [] 5% 2.6s167.7 MiB [] 6% 2.6s167.7 MiB [] 8% 2.2s167.7 MiB [] 9% 2.1s167.7 MiB [] 10% 2.0s167.7 MiB [] 11% 1.8s167.7 MiB [] 13% 1.6s167.7 MiB [] 15% 1.5s167.7 MiB [] 17% 1.4s167.7 MiB [] 19% 1.3s167.7 MiB [] 21% 1.2s167.7 MiB [] 22% 1.2s167.7 MiB [] 24% 1.1s167.7 MiB [] 26% 1.1s167.7 MiB [] 28% 1.0s167.7 MiB [] 30% 1.0s167.7 MiB [] 31% 1.0s167.7 MiB [] 33% 0.9s167.7 MiB [] 35% 0.9s167.7 MiB [] 37% 0.8s167.7 MiB [] 39% 0.8s167.7 MiB [] 41% 0.7s167.7 MiB [] 43% 0.7s167.7 MiB [] 45% 0.7s167.7 MiB [] 47% 0.6s167.7 MiB [] 49% 0.6s167.7 MiB [] 51% 0.6s167.7 MiB [] 53% 0.5s167.7 MiB [] 54% 0.5s167.7 MiB [] 56% 0.5s167.7 MiB [] 57% 0.5s167.7 MiB [] 59% 0.5s167.7 MiB [] 61% 0.4s167.7 MiB [] 62% 0.4s167.7 MiB [] 64% 0.4s167.7 MiB [] 65% 0.4s167.7 MiB [] 67% 0.4s167.7 MiB [] 69% 0.3s167.7 MiB [] 70% 0.3s167.7 MiB [] 72% 0.3s167.7 MiB [] 73% 0.3s167.7 MiB [] 75% 0.3s167.7 MiB [] 77% 0.2s167.7 MiB [] 79% 0.2s167.7 MiB [] 81% 0.2s167.7 MiB [] 82% 0.2s167.7 MiB [] 84% 0.2s167.7 MiB [] 86% 0.1s167.7 MiB [] 88% 0.1s167.7 MiB [] 89% 0.1s167.7 MiB [] 91% 0.1s167.7 MiB [] 92% 0.1s167.7 MiB [] 94% 0.1s167.7 MiB [] 96% 0.0s167.7 MiB [] 98% 0.0s167.7 MiB [] 99% 0.0s167.7 MiB [] 100% 0.0s
Chromium 136.0.7103.25 (playwright build v1169) downloaded to /root/.cache/ms-playwright/chromium-1169
Downloading FFMPEG playwright build v1011 from https://cdn.playwright.dev/dbazure/download/playwright/builds/ffmpeg/1011/ffmpeg-linux.zip
2.3 MiB [] 0% 0.0s2.3 MiB [] 5% 0.4s2.3 MiB [] 14% 0.2s2.3 MiB [] 25% 0.2s2.3 MiB [] 50% 0.1s2.3 MiB [] 92% 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% 11.4s101.4 MiB [] 0% 10.1s101.4 MiB [] 0% 6.2s101.4 MiB [] 1% 3.3s101.4 MiB [] 3% 2.2s101.4 MiB [] 6% 1.5s101.4 MiB [] 8% 1.1s101.4 MiB [] 10% 1.1s101.4 MiB [] 13% 0.9s101.4 MiB [] 16% 0.8s101.4 MiB [] 20% 0.7s101.4 MiB [] 24% 0.6s101.4 MiB [] 27% 0.6s101.4 MiB [] 29% 0.5s101.4 MiB [] 33% 0.5s101.4 MiB [] 36% 0.4s101.4 MiB [] 38% 0.4s101.4 MiB [] 41% 0.4s101.4 MiB [] 43% 0.4s101.4 MiB [] 47% 0.4s101.4 MiB [] 50% 0.3s101.4 MiB [] 54% 0.3s101.4 MiB [] 57% 0.3s101.4 MiB [] 61% 0.2s101.4 MiB [] 65% 0.2s101.4 MiB [] 68% 0.2s101.4 MiB [] 70% 0.2s101.4 MiB [] 74% 0.2s101.4 MiB [] 77% 0.1s101.4 MiB [] 80% 0.1s101.4 MiB [] 85% 0.1s101.4 MiB [] 88% 0.1s101.4 MiB [] 92% 0.0s101.4 MiB [] 95% 0.0s101.4 MiB [] 99% 0.0s101.4 MiB [] 100% 0.0s
Chromium Headless Shell 136.0.7103.25 (playwright build v1169) downloaded to /root/.cache/ms-playwright/chromium_headless_shell-1169
++ echo 'source .venv/bin/activate'
I'll check the AGENTS.md file at the root of the repository for guidance.
ls
^C
Hmm, no output yet. Let me try running ls one more time, just in case.
^Cpwd
ls
I think I may have missed starting at the repo root. Let's double-check!
pwd
bash: .venv/bin/activate: No such file or directory
root@df047255e87d:/workspace# ls
scubaduck
root@df047255e87d:/workspace# pwd
/workspace
Hmm, it looks like an alias might be interfering. I'll try running again.
root@df047255e87d:/workspace# ls
scubaduck
I'm moving to the scubaduck subdirectory within /workspace.
root@df047255e87d:/workspace# cd scubaduck
root@df047255e87d:/workspace/scubaduck# ls
AGENTS.md codex_setup.sh scubaduck tests
README.md pyproject.toml stubs uv.lock
I’m checking the AGENTS.md file in the root directory.
root@df047255e87d:/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
I’ll activate the virtual environment, then use ruff check and pyright as needed.
root@df047255e87d:/workspace/scubaduck# ls
AGENTS.md codex_setup.sh scubaduck tests
README.md pyproject.toml stubs uv.lock
I'm checking out the scubaduck subpackage to explore its contents.
root@df047255e87d:/workspace/scubaduck# ls scubaduck
__init__.py sample.csv server.py static
I need to review the tests and confirm their status.
root@df047255e87d:/workspace/scubaduck# ls tests
conftest.py test_server_errors.py test_web_columns.py
test_multi_table_web.py test_server_time.py test_web_graph.py
test_server.py test_server_timeseries.py test_web_table.py
test_server_basic.py test_web_basics.py test_web_timeseries.py
test_server_db_types.py test_web_chips.py web_utils.py
I’ll open the tests/test_web_table.py file to review the code.
root@df047255e87d:/workspace/scubaduck# sed -n '1,160p' tests/test_web_table.py
from __future__ import annotations
from typing import Any
from tests.web_utils import run_query
def test_table_sorting(page: Any, server_url: str) -> None:
run_query(
page,
server_url,
start="2024-01-01 00:00:00",
end="2024-01-03 00:00:00",
order_by="user",
order_dir="ASC",
limit=100,
)
# header alignment
align = page.evaluate(
"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_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_table_enhancements(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",
limit=10,
)
border = page.evaluate(
"getComputedStyle(document.querySelector('#results td')).borderStyle"
)
assert border == "solid"
color1 = page.evaluate(
"getComputedStyle(document.querySelector('#results tr:nth-child(2) td'))
.backgroundColor"
)
color2 = page.evaluate(
"getComputedStyle(document.querySelector('#results tr:nth-child(3) td'))
.backgroundColor"
)
assert color1 != color2
page.hover("#results tr:nth-child(2)")
hover_color = page.evaluate(
"getComputedStyle(document.querySelector('#results tr:nth-child(2) td'))
.backgroundColor"
)
assert hover_color != color1
page.click("#results tr:nth-child(2)")
selected_color = page.evaluate(
"getComputedStyle(document.querySelector('#results tr:nth-child(2) td'))
.backgroundColor"
)
assert "189, 228, 255" in selected_color
overflow = page.evaluate(
"var v=document.getElementById('view'); v.scrollWidth > v.clientWidth"
)
assert not overflow
def test_table_single_selection(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",
limit=10,
)
page.click("#results tr:nth-child(2)")
page.click("#results tr:nth-child(3)")
count = page.evaluate("document.querySelectorAll('#results tr.selected').len
gth")
assert count == 1
is_third = page.evaluate(
"document.querySelector('#results tr:nth-child(3)').classList.contains('
selected')"
)
assert is_third
def test_timestamp_rendering(page: Any, server_url: str) -> None:
run_query(
page,
server_url,
start="2024-01-01 00:00:00",
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_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)
root@df047255e87d:/workspace/scubaduck# sed -n '160,320p' tests/test_web_table.p
y
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="user",
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(
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"]
def test_numeric_cell_nowrap(page: Any, server_url: str) -> None:
run_query(page, server_url, limit=10)
whitespace = page.evaluate(
"getComputedStyle(document.querySelector('#results td:nth-child(3)')).wh
iteSpace"
)
assert whitespace == "nowrap"
def test_date_cell_nowrap(page: Any, server_url: str) -> None:
run_query(page, server_url, limit=10)
whitespace = page.evaluate(
"getComputedStyle(document.querySelector('#results td:nth-child(1)')).wh
iteSpace"
)
assert whitespace == "nowrap"
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
def test_derived_column_remove(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")
assert page.query_selector("#derived_list .derived button.remove")
page.click("#derived_list .derived button.remove")
count = page.evaluate("document.querySelectorAll('#derived_list .derived').l
ength")
assert count == 0
def test_sql_query_display(page: Any, server_url: str) -> None:
data = run_query(
page,
server_url,
start="2024-01-01 00:00:00",
end="2024-01-02 00:00:00",
order_by="timestamp",
limit=10,
)
sql = data["sql"]
displayed = page.text_content("#sql_query")
assert displayed is not None
assert displayed.strip() == sql
root@df047255e87d:/workspace/scubaduck# sed -n '320,640p' tests/test_web_table.p
y
It looks like 320 marks the end of the file. I'll check test_server.py next.
root@df047255e87d:/workspace/scubaduck# sed -n '1,160p' tests/test_server.py
"""Legacy test module.
Tests have been split into multiple files:
- test_server_basic.py
- test_server_db_types.py
- test_server_time.py
- test_server_errors.py
- test_server_timeseries.py
"""
I need to check the static directory, focusing on frontend code related to table view.
root@df047255e87d:/workspace/scubaduck# ls scubaduck/static
index.html js
root@df047255e87d:/workspace/scubaduck# ls scubaduck/static/js
chip_input.js table_view.js timeseries_chart.js
sidebar.js time_column.js view_settings.js
I'll take a look at table_view.js now.
root@df047255e87d:/workspace/scubaduck# sed -n '1,160p' scubaduck/static/js/tabl
e_view.js
// Logic for rendering the table based views. Extracted from index.html so that
// the inline script only handles wiring up the UI.
let originalRows = [];
let sortState = { index: null, dir: null };
function renderTable(rows) {
const table = document.getElementById("results");
table.innerHTML = "";
if (rows.length === 0) return;
let hitsIndex = selectedColumns.indexOf("Hits");
let totalHits = 0;
if (hitsIndex !== -1) {
totalHits = rows.reduce((s, r) => s + Number(r[hitsIndex]), 0);
}
const header = document.createElement("tr");
selectedColumns.forEach((col, i) => {
const th = document.createElement("th");
let label = col;
if (
displayType === "table" &&
col !== "Hits" &&
!(groupBy.chips || []).includes(col)
) {
const agg = document.getElementById("aggregate").value.toLowerCase();
label += ` (${agg})`;
}
th.textContent = label;
th.dataset.index = i;
th.addEventListener("click", handleSort);
if (sortState.index === i) {
th.classList.add("sorted");
th.textContent = label + (sortState.dir === "desc" ? " \u25BC" : " \u25B2"
);
}
if (!isStringColumn(col)) th.style.textAlign = "right";
header.appendChild(th);
});
table.appendChild(header);
rows.forEach((row) => {
const tr = document.createElement("tr");
tr.addEventListener("click", () => {
const wasSelected = tr.classList.contains("selected");
document
.querySelectorAll("#results tr.selected")
.forEach((el) => el.classList.remove("selected"));
if (!wasSelected) {
tr.classList.add("selected");
}
});
row.forEach((v, i) => {
const col = selectedColumns[i];
const td = document.createElement("td");
if (isTimeColumn(col)) {
let d;
const t = (columnTypes[col] || "").toUpperCase();
if (t.includes("TIMESTAMP") || t.includes("DATE") || t.includes("TIME"))
{
d = new Date(v);
} else {
const unit = document.getElementById("time_unit").value;
const factors = { s: 1000, ms: 1, us: 0.001, ns: 0.000001 };
d = new Date(Number(v) * (factors[unit] || 1000));
}
td.textContent = d.toLocaleString("en-US", {
weekday: "short",
month: "short",
day: "numeric",
year: "numeric",
hour: "numeric",
minute: "numeric",
second: "numeric",
hour12: true,
timeZoneName: "short",
});
td.classList.add("date");
} else {
if (col === "Hits") {
const pct = totalHits ? ((v / totalHits) * 100).toFixed(1) : "0";
td.textContent = `${formatNumber(v)} (${pct}%)`;
} else {
td.textContent = isStringColumn(col) ? v : formatNumber(v);
}
}
if (!isStringColumn(col) && !isTimeColumn(col)) {
td.classList.add("numeric");
}
td.style.textAlign = isStringColumn(col) ? "left" : "right";
tr.appendChild(td);
});
table.appendChild(tr);
});
// ensure table does not overflow unless necessary
const view = document.getElementById("view");
if (table.scrollWidth <= view.clientWidth) {
table.style.width = "100%";
}
}
function handleSort(e) {
const idx = parseInt(e.target.dataset.index, 10);
if (sortState.index !== idx) {
sortState.index = idx;
sortState.dir = "asc";
} else if (sortState.dir === "asc") {
sortState.dir = "desc";
} else if (sortState.dir === "desc") {
sortState.index = null;
sortState.dir = null;
} else {
sortState.dir = "asc";
}
let rows = originalRows.slice();
if (sortState.index !== null) {
rows.sort((a, b) => {
const va = a[sortState.index];
const vb = b[sortState.index];
if (va === vb) return 0;
if (sortState.dir === "desc") return va < vb ? 1 : -1;
return va > vb ? 1 : -1;
});
}
renderTable(rows);
}
function showResults(data) {
window.lastResults = data;
const view = document.getElementById("view");
if (graphTypeSel.value === "timeseries") {
showTimeSeries(data);
} else {
if (data.rows.length === 0) {
view.innerHTML =
'<p id="empty-message">Empty data provided to table</p><table id="result
s"></table>';
} else {
view.innerHTML = '<table id="results"></table>';
}
originalRows = data.rows.slice();
sortState = { index: null, dir: null };
renderTable(originalRows);
}
const sqlEl = document.createElement("pre");
sqlEl.id = "sql_query";
sqlEl.style.whiteSpace = "pre-wrap";
sqlEl.style.marginTop = "10px";
sqlEl.textContent = data.sql;
view.appendChild(sqlEl);
document.getElementById("query_info").textContent = `Your query took about ${l
astQueryTime} ms`;
}
function showError(err) {
window.lastResults = err;
const view = document.getElementById("view");
let msg = "";
if (typeof err === "string") {
msg = err;
} else if (err) {
msg = err.error || "Error";
if (err.sql) {
msg += "\nSQL: " + err.sql;
}
if (err.traceback) {
root@df047255e87d:/workspace/scubaduck# sed -n '160,320p' scubaduck/static/js/ta
ble_view.js
if (err.traceback) {
msg += "\n" + err.traceback;
}
}
view.innerHTML = `<pre id="error-message">${msg}</pre>`;
document.getElementById("query_info").textContent = "";
}
I need to locate where selectedColumns is defined to understand its role in renderTable.
root@df047255e87d:/workspace/scubaduck# grep -n "selectedColumns" -r
scubaduck/static/js/table_view.js:11: let hitsIndex = selectedColumns.indexOf("
Hits");
scubaduck/static/js/table_view.js:17: selectedColumns.forEach((col, i) => {
scubaduck/static/js/table_view.js:51: const col = selectedColumns[i];
scubaduck/static/js/timeseries_chart.js:38: const valueCols = selectedColumns.s
lice(groups.length + hasHits);
scubaduck/static/js/view_settings.js:15:let selectedColumns = [];
scubaduck/static/js/view_settings.js:439: selectedColumns = groupBy.chips.sli
ce();
scubaduck/static/js/view_settings.js:440: if (document.getElementById('show_h
its').checked) selectedColumns.push('Hits');
scubaduck/static/js/view_settings.js:442: if (!selectedColumns.includes(c))
selectedColumns.push(c);
scubaduck/static/js/view_settings.js:445: if (dc.include && !selectedColumn
s.includes(dc.name)) selectedColumns.push(dc.name);
scubaduck/static/js/view_settings.js:448: selectedColumns = base.slice();
scubaduck/static/js/view_settings.js:450: if (dc.include) selectedColumns.p
ush(dc.name);
scubaduck/static/js/view_settings.js:453: columnValues[type] = selectedColumns.
slice();
scubaduck/static/js/view_settings.js:687: columns: selectedColumns.filter(c =
>
I’ll take a look at view_settings.js now.
root@df047255e87d:/workspace/scubaduck# sed -n '1,100p' scubaduck/static/js/view
_settings.js
// Logic for View Settings, Columns, and URL handling extracted from index.html
const allColumns = [];
const baseColumns = [];
const columnTypes = {};
const stringColumns = [];
const baseStringColumns = [];
const integerColumns = [];
const baseIntegerColumns = [];
const timeColumns = [];
const baseTimeColumns = [];
const timeColumnOptions = [];
const baseTimeColumnOptions = [];
const derivedColumns = [];
let selectedColumns = [];
let displayType = 'samples';
let groupBy = {chips: [], addChip: () => {}, renderChips: () => {}};
let defaultTimeColumn = '';
const limitInput = document.getElementById('limit');
const limitValues = {
samples: parseInt(limitInput.value, 10),
table: parseInt(limitInput.value, 10),
timeseries: 7
};
const columnValues = {
samples: [],
table: [],
timeseries: []
};
limitInput.addEventListener('input', () => {
limitValues[displayType] = parseInt(limitInput.value, 10);
limitInput.dataset.setByUser = '1';
});
function initDropdown(select) {
// Avoid creating duplicate wrappers if this dropdown was already initialised.
if (select.dataset.dropdownInit) {
const disp = select.parentElement?.querySelector('.dropdown-display');
if (disp) {
const opt = select.options[select.selectedIndex];
disp.textContent = opt ? opt.textContent : '';
}
return;
}
select.dataset.dropdownInit = '1';
const wrapper = document.createElement('div');
wrapper.className = 'dropdown';
if (select.classList.contains('f-col')) {
wrapper.classList.add('f-col');
}
select.parentNode.insertBefore(wrapper, select);
wrapper.appendChild(select);
select.style.display = 'none';
const disp = document.createElement('div');
disp.className = 'dropdown-display';
function updateDisplay() {
const opt = select.options[select.selectedIndex];
disp.textContent = opt ? opt.textContent : '';
}
updateDisplay();
wrapper.appendChild(disp);
const menu = document.createElement('div');
menu.className = 'dropdown-menu';
const search = document.createElement('input');
search.placeholder = 'Search';
menu.appendChild(search);
const list = document.createElement('div');
menu.appendChild(list);
wrapper.appendChild(menu);
function close() {
menu.style.display = 'none';
}
function open() {
renderOptions();
menu.style.display = 'block';
search.focus();
}
disp.addEventListener('click', () => {
if (menu.style.display === 'block') {
close();
} else {
open();
}
});
document.addEventListener('click', e => {
if (!wrapper.contains(e.target)) {
close();
}
});
function renderOptions() {
const q = search.value.toLowerCase();
list.innerHTML = '';
Array.from(select.options).forEach(o => {
if (!o.textContent.toLowerCase().includes(q)) return;
I’m looking for the lines where selectedColumns are being computed.
root@df047255e87d:/workspace/scubaduck# grep -n "selectedColumns" -n scubaduck/s
tatic/js/view_settings.js
15:let selectedColumns = [];
439: selectedColumns = groupBy.chips.slice();
440: if (document.getElementById('show_hits').checked) selectedColumns.push('
Hits');
442: if (!selectedColumns.includes(c)) selectedColumns.push(c);
445: if (dc.include && !selectedColumns.includes(dc.name)) selectedColumns.
push(dc.name);
448: selectedColumns = base.slice();
450: if (dc.include) selectedColumns.push(dc.name);
453: columnValues[type] = selectedColumns.slice();
687: columns: selectedColumns.filter(c =>
I'm checking the code around line 430 to see what has changed.
root@df047255e87d:/workspace/scubaduck# sed -n '430,460p' scubaduck/static/js/vi
ew_settings.js
function updateSelectedColumns(type = graphTypeSel.value) {
const base = allColumns.filter(name => {
const cb = document.querySelector(`#column_groups input[value="${name}"]`);
if (!cb || !cb.checked) return false;
if (type === 'table' && isStringColumn(name)) return false;
return true;
});
if (type === 'table' || type === 'timeseries') {
selectedColumns = groupBy.chips.slice();
if (document.getElementById('show_hits').checked) selectedColumns.push('Hits
');
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);
});
}
columnValues[type] = selectedColumns.slice();
updateColumnsTabCount();
}
function isStringColumn(name) {
const t = (columnTypes[name] || '').toUpperCase();
return t.includes('CHAR') || t.includes('STRING') || t.includes('VARCHAR');
}
I’m continuing past the 460 mark to gather more details.
root@df047255e87d:/workspace/scubaduck# sed -n '460,520p' scubaduck/static/js/vi
ew_settings.js
}
function isIntegerColumn(name) {
const t = (columnTypes[name] || '').toUpperCase();
return t.includes('INT');
}
function isTimeColumn(name) {
const t = (columnTypes[name] || '').toUpperCase();
if (t.includes('TIMESTAMP') || t.includes('DATE') || t.includes('TIME')) retur
n true;
const sel = document.getElementById('time_column').value;
const xsel = document.getElementById('x_axis').value;
if (name === sel || name === xsel) return true;
return false;
}
function formatNumber(val) {
if (typeof val !== 'number') val = Number(val);
if (Number.isNaN(val)) return '';
if (val === 0) return '0';
const abs = Math.abs(val);
if (abs > 999.999) {
const units = [
{n: 1e12, s: 'T'},
{n: 1e9, s: 'B'},
{n: 1e6, s: 'M'},
{n: 1e3, s: 'K'},
];
for (const u of units) {
if (abs >= u.n) {
return (val / u.n).toFixed(2) + ' ' + u.s;
}
}
}
if (abs < 0.0005) return '0.000';
if (Number.isInteger(val)) return val.toString();
return val.toFixed(3);
}
function addFilter() {
const container = document.createElement('div');
container.className = 'filter';
container.innerHTML = `
<div class="filter-row">
<select class="f-col"></select>
<select class="f-op"></select>
<button type="button" class="remove" onclick="this.closest('.filter').remo
ve()">✖</button>
</div>
<div class="chip-box">
<div class="chip-input">
<input class="f-val" type="text">
<button type="button" class="chip-copy">⎘</button>
</div>
<div class="chip-dropdown"></div>
</div>
`;
const colSel = container.querySelector('.f-col');
colSel.innerHTML = allColumns.map(c => `<option value="${c}">${c}</option>`).j
oin('');
initDropdown(colSel);
I’ll check the code where table headings are set to account for aggregate counting.
root@df047255e87d:/workspace/scubaduck# sed -n '1,160p' scubaduck/server.py
from __future__ import annotations
from dataclasses import dataclass, field, replace
from typing import Any, Dict, List, Tuple, cast
import re
from datetime import datetime, timedelta, timezone
import time
from pathlib import Path
import os
import traceback
import math
import duckdb
from dateutil import parser as dtparser
from flask import Flask, jsonify, request, send_from_directory
def _quote(ident: str) -> str:
"""Return identifier quoted for SQL."""
return f'"{ident.replace('"', '""')}"'
@dataclass
class Filter:
column: str
op: str
value: str | int | float | list[str] | None
@dataclass
class QueryParams:
start: str | None = None
end: str | None = None
order_by: str | None = None
order_dir: str = "ASC"
limit: int | None = None
columns: list[str] = field(default_factory=lambda: [])
filters: list[Filter] = field(default_factory=lambda: [])
derived_columns: dict[str, str] = field(default_factory=lambda: {})
graph_type: str = "samples"
group_by: list[str] = field(default_factory=lambda: [])
aggregate: str | None = None
show_hits: bool = False
x_axis: str | None = None
granularity: str = "Auto"
fill: str = "0"
table: str = "events"
time_column: str | None = "timestamp"
time_unit: str = "s"
def _load_database(path: Path) -> duckdb.DuckDBPyConnection:
if not path.exists():
raise FileNotFoundError(path)
ext = path.suffix.lower()
if ext == ".csv":
con = duckdb.connect()
con.execute(
f"CREATE TABLE events AS SELECT * FROM read_csv_auto('{path.as_posix
()}')"
)
elif ext in {".db", ".sqlite"}:
con = duckdb.connect()
con.execute("LOAD sqlite")
con.execute(f"ATTACH '{path.as_posix()}' AS db (TYPE SQLITE)")
tables = [
r[0]
for r in con.execute(
"SELECT name FROM sqlite_master WHERE type='table'"
).fetchall()
]
for t in tables:
con.execute(f'CREATE VIEW "{t}" AS SELECT * FROM db."{t}"')
else:
con = duckdb.connect(path)
return con
def _create_test_database() -> duckdb.DuckDBPyConnection:
"""Return a DuckDB connection with a small multi-table dataset."""
con = duckdb.connect()
con.execute(
"CREATE TABLE events (id INTEGER PRIMARY KEY, ts INTEGER, val REAL, name
TEXT, flag BOOLEAN)"
)
con.execute("INSERT INTO events VALUES (1, 1704067200, 1.5, 'alice', 1)")
con.execute("INSERT INTO events VALUES (2, 1704070800, 2.0, 'bob', 0)")
con.execute('CREATE TABLE extra (ts INTEGER, "desc" TEXT, num INTEGER)')
con.execute("INSERT INTO extra VALUES (1704067200, 'x', 1)")
con.execute("INSERT INTO extra VALUES (1704070800, 'y', 2)")
return con
_REL_RE = re.compile(
r"([+-]?\d+(?:\.\d*)?)\s*(hour|hours|day|days|week|weeks|fortnight|fortnight
s)",
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)
I'm looking through the functions that generate SQL queries for tables.
root@df047255e87d:/workspace/scubaduck# grep -n "aggregate" -n scubaduck/server.
py | head
44: aggregate: str | None = None
224: has_agg = bool(group_cols) or params.aggregate is not None
230: agg = (params.aggregate or "count").lower()
452: aggregate=payload.get("aggregate"),
476: params.group_by or params.aggregate or params.show_hits
481: "error": "group_by, aggregate and show_hits are only
valid for table or timeseries view"
520: agg = (params.aggregate or "count").lower()
root@df047255e87d:/workspace/scubaduck# sed -n '210,260p' scubaduck/server.py
raise ValueError("x_axis required for timeseries")
xexpr = _time_expr(x_axis, column_types, params.time_unit)
if params.start:
bucket_expr = (
f"TIMESTAMP '{params.start}' + INTERVAL '{sec} second' * "
f"CAST(floor((epoch({xexpr}) - epoch(TIMESTAMP '{params.start}')
)/{sec}) AS BIGINT)"
)
else:
bucket_expr = (
f"TIMESTAMP 'epoch' + INTERVAL '{sec} second' * "
f"CAST(floor(epoch({xexpr})/{sec}) AS BIGINT)"
)
select_parts.append(f"{bucket_expr} AS bucket")
group_cols = ["bucket"] + group_cols
has_agg = bool(group_cols) or params.aggregate is not None
if has_agg:
select_cols = (
group_cols[1:] if params.graph_type == "timeseries" else group_cols
)
select_parts.extend(_quote(c) for c in select_cols)
agg = (params.aggregate or "count").lower()
def agg_expr(col: str) -> str:
expr = _quote(col)
ctype = column_types.get(col, "").upper() if column_types else ""
if "BOOL" in ctype:
expr = f"CAST({_quote(col)} AS BIGINT)"
if agg.startswith("p"):
quant = float(agg[1:]) / 100
return f"quantile({expr}, {quant})"
if agg == "count distinct":
return f"count(DISTINCT {expr})"
if agg == "avg" and column_types is not None:
if "TIMESTAMP" in ctype or "DATE" in ctype or "TIME" in ctype:
return (
"TIMESTAMP 'epoch' + INTERVAL '1 second' * "
f"CAST(avg(epoch({_quote(col)})) AS BIGINT)"
)
return f"{agg}({expr})"
if agg == "count":
select_parts.append("count(*) AS Count")
else:
for col in params.columns:
if col in group_cols:
continue
select_parts.append(f"{agg_expr(col)} AS {_quote(col)}")
if params.show_hits:
select_parts.insert(len(group_cols), "count(*) AS Hits")
else:
select_parts.extend(_quote(c) for c in params.columns)
root@df047255e87d:/workspace/scubaduck# sed -n '260,340p' scubaduck/server.py
select_parts.extend(_quote(c) for c in params.columns)
if has_agg and params.derived_columns:
inner_params = replace(
params,
derived_columns={},
order_by=None,
limit=None,
)
inner_sql = build_query(inner_params, column_types)
outer_select = ["t.*"] + [
f"{expr} AS {name}" for name, expr in params.derived_columns.items()
]
indented_inner = "\n".join(" " + line for line in inner_sql.splitline
s())
lines = [
f"SELECT {', '.join(outer_select)}",
"FROM (",
indented_inner,
") t",
]
if params.order_by:
lines.append(f"ORDER BY {_quote(params.order_by)} {params.order_dir}
")
elif params.graph_type == "timeseries":
lines.append("ORDER BY bucket")
if params.limit is not None:
lines.append(f"LIMIT {params.limit}")
return "\n".join(lines)
for name, expr in params.derived_columns.items():
select_parts.append(f"{expr} AS {name}")
select_clause = ", ".join(select_parts) if select_parts else "*"
lines = [f"SELECT {select_clause}", f'FROM "{params.table}"']
where_parts: list[str] = []
if params.time_column:
time_expr = _time_expr(params.time_column, column_types, params.time_uni
t)
else:
time_expr = None
if time_expr and params.start:
where_parts.append(f"{time_expr} >= '{params.start}'")
if time_expr and params.end:
where_parts.append(f"{time_expr} <= '{params.end}'")
for f in params.filters:
op = f.op
if op in {"empty", "!empty"}:
val = "''"
else:
if f.value is None:
continue
if isinstance(f.value, list):
if not f.value:
continue
if op == "=":
qcol = _quote(f.column)
vals = " OR ".join(f"{qcol} = '{v}'" for v in f.value)
where_parts.append(f"({vals})")
continue
val = f"'{f.value}'" if isinstance(f.value, str) else str(f.value)
qcol = _quote(f.column)
if op == "contains":
where_parts.append(f"{qcol} ILIKE '%' || {val} || '%'")
elif op == "!contains":
where_parts.append(f"{qcol} NOT ILIKE '%' || {val} || '%'")
elif op == "empty":
where_parts.append(f"{qcol} = {val}")
elif op == "!empty":
where_parts.append(f"{qcol} != {val}")
else:
where_parts.append(f"{qcol} {op} {val}")
if where_parts:
lines.append("WHERE " + " AND ".join(where_parts))
if group_cols:
lines.append("GROUP BY " + ", ".join(_quote(c) for c in group_cols))
if params.order_by:
lines.append(f"ORDER BY {_quote(params.order_by)} {params.order_dir}")
elif params.graph_type == "timeseries":
lines.append("ORDER BY bucket")
if params.limit is not None:
lines.append(f"LIMIT {params.limit}")
return "\n".join(lines)
I’ll search the tests for "Hits column" or "group_by" to verify the query builder’s behavior.
root@df047255e87d:/workspace/scubaduck# grep -n "Hits" -r tests | head
tests/test_server_errors.py:57: "columns": ["user", "Hits", "value"],
tests/test_web_table.py:234: assert "Hits" in headers
I’ll check out the test_web_table file from around line 230.
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"]
def test_numeric_cell_nowrap(page: Any, server_url: str) -> None:
run_query(page, server_url, limit=10)
whitespace = page.evaluate(
"getComputedStyle(document.querySelector('#results td:nth-child(3)')).wh
iteSpace"
)
assert whitespace == "nowrap"
def test_date_cell_nowrap(page: Any, server_url: str) -> None:
run_query(page, server_url, limit=10)
whitespace = page.evaluate(
"getComputedStyle(document.querySelector('#results td:nth-child(1)')).wh
iteSpace"
)
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="user",
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(
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)
I'll look for tests that mention table headers, count, or group_by.
py | head
8:def test_table_sorting(page: Any, server_url: str) -> None:
56:def test_table_avg_group_by(page: Any, server_url: str) -> None:
70:def test_table_enhancements(page: Any, server_url: str) -> None:
110:def test_table_single_selection(page: Any, server_url: str) -> None:
155: assert "Empty data provided to table" in msg
159: url = f"{server_url}?graph_type=table&group_by=user&order_by=user&limit=
10"
169: page.goto(f"{server_url}?graph_type=table")
181: page.goto(f"{server_url}?graph_type=table")
188: page.goto(f"{server_url}?graph_type=table")
196:def test_table_group_by_query(page: Any, server_url: str) -> None:
Continuing to scroll after 196 lines to capture more details.
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="user",
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(
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)
I'll add a new test to check for the correct columns and table dimensions.
root@df047255e87d:/workspace/scubaduck# grep -n "aggregate=\"Count\"" -r tests |
head
tests/test_web_table.py:205: aggregate="Count",
I'll search for tests involving "count" or "aggregate count" to check header verification.
root@df047255e87d:/workspace/scubaduck# grep -n "aggregate=\"count\"" -ri tests
| head
tests/test_web_table.py:205: aggregate="Count",
I'll adjust the code to align selected columns with server returns for aggregate counts.
I’ll inspect the run_query
function in tests/web_utils.py
to understand query behavior.
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")
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)
if page.input_value("#graph_type") != "samples":
page.click("text=Columns")
page.wait_for_selector("#column_groups input", state="attached")
if not page.is_checked("#column_groups input[value='value']"):
page.check("#column_groups input[value='value']")
order_col = order_by or page.input_value("#order_by")
if order_col and not page.is_checked(
f"#column_groups input[value='{order_col}']"
):
if page.query_selector(f"#column_groups input[value='{order_col}']")
:
page.check(f"#column_groups input[value='{order_col}']")
page.click("text=View Settings")
page.evaluate("window.lastResults = undefined")
page.click("text=Dive")
page.wait_for_function("window.lastResults !== undefined")
return page.evaluate("window.lastResults")
I'll check out the front-end layout in index.html to understand how the query results display.
root@df047255e87d:/workspace/scubaduck# sed -n '1,160p' scubaduck/static/index.h
tml
<!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;
}
#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 .derived-row button.remove {
margin-left: 5px;
width: 20px;
flex: 0 0 auto;
padding: 0;
text-align: center;
line-height: 1;
}
#derived_columns textarea {
width: 100%;
box-sizing: border-box;
}
#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;
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;
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; }
td.numeric { white-space: nowrap; }
td.date { white-space: nowrap; }
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;
}
#ts-container {
display: flex;
}
#legend {
width: 150px;
flex: 0 0 150px;
margin-right: 10px;
overflow-y: auto;
}
.legend-group {
margin-bottom: 4px;
}
.legend-header {
font-weight: normal;
}
.legend-item {
display: flex;
justify-content: space-between;
padding-left: 8px;
}
.legend-value {
margin-left: 4px;
I'll continue scrolling to look at the rest of the file, focusing on the bottom.
root@df047255e87d:/workspace/scubaduck# grep -n "table_view.js" -n scubaduck/sta
tic/index.html
357:<script src="/js/table_view.js"></script>
root@df047255e87d:/workspace/scubaduck# sed -n '320,380p' scubaduck/static/index
.html
<option>p99.9</option>
<option>p99.99</option>
</select>
</div>
<div id="show_hits_field" class="field" style="display:none;">
<label>Show Hits</label>
<input id="show_hits" type="checkbox" checked>
</div>
<div id="filters">
<h4>Filters<span class="help" title="You can create as many filters as
you want. You can either write a filter using a UI or manual SQL. In the UI, fi
lter consists of a column name, a relation (e.g., =, !=, <, >) and then a text f
ield. The text field is a token input. It accepts multiple tokens for = relation
, in which case we match using an OR for all options.">[?]</span></h4>
<div id="filter_list"></div>
<button id="add_filter" type="button" onclick="addFilter()">Add Filter
</button>
</div>
<div id="query_info" style="margin-top:10px;"></div>
</div>
<div id="columns" class="tab-content">
<div id="column_actions">
<a id="columns_all" href="#">All</a>
<a id="columns_none" href="#">None</a>
</div>
<div id="column_groups"></div>
<div id="derived_columns">
<h4>Derived Columns</h4>
<div id="derived_list"></div>
<button id="add_derived" type="button" onclick="addDerived()">Add Deri
ved</button>
</div>
</div>
</div>
<div id="sidebar-resizer"></div>
<div id="view">
<table id="results"></table>
</div>
</div>
<script src="/js/chip_input.js"></script>
<script src="/js/timeseries_chart.js"></script>
<script src="/js/time_column.js"></script>
<script src="/js/sidebar.js"></script>
<script src="/js/table_view.js"></script>
<script src="/js/view_settings.js"></script>
</body>
</html>
The issue is with selectedColumns in view_settings.js when aggregate is count. I'll check server query usage.
root@df047255e87d:/workspace/scubaduck# grep -n "columnValues" -n scubaduck/stat
ic/js -r
scubaduck/static/js/view_settings.js:25:const columnValues = {
scubaduck/static/js/view_settings.js:164: cb.checked = columnValues[newType].
includes(cb.value);
scubaduck/static/js/view_settings.js:336: columnValues.samples = allColumns.s
lice();
scubaduck/static/js/view_settings.js:337: columnValues.table = [];
scubaduck/static/js/view_settings.js:338: columnValues.timeseries = [];
scubaduck/static/js/view_settings.js:453: columnValues[type] = selectedColumns.
slice();
scubaduck/static/js/view_settings.js:690: samples_columns: columnValues.sampl
es.slice(),
scubaduck/static/js/view_settings.js:691: table_columns: columnValues.table.s
lice(),
scubaduck/static/js/view_settings.js:692: timeseries_columns: columnValues.ti
meseries.slice(),
scubaduck/static/js/view_settings.js:788: if (params.samples_columns) columnVal
ues.samples = params.samples_columns;
scubaduck/static/js/view_settings.js:789: if (params.table_columns) columnValue
s.table = params.table_columns;
scubaduck/static/js/view_settings.js:790: if (params.timeseries_columns) column
Values.timeseries = params.timeseries_columns;
scubaduck/static/js/view_settings.js:792: cb.checked = columnValues[graphType
Sel.value].includes(cb.value);
I need to locate where the query is being built, possibly at 'buildUrl' or 'execute'.
root@df047255e87d:/workspace/scubaduck# grep -n "fetch" -n scubaduck/static/js -
r
scubaduck/static/js/view_settings.js:196: return fetch('/api/columns?table=' +
encodeURIComponent(table)).then(r => r.json()).then(cols => {
scubaduck/static/js/view_settings.js:349: fetch('/api/tables').then(r => r.json
()).then(tables => {
scubaduck/static/js/view_settings.js:561: return fetch(`/api/samples?column=$
{encodeURIComponent(col)}&q=${encodeURIComponent(typed)}`)
scubaduck/static/js/view_settings.js:661: fetch('/api/query', {method:'POST', h
eaders:{'Content-Type':'application/json'}, body:JSON.stringify(payload)})
scubaduck/static/js/chip_input.js:2:function initChipInput(filter, fetchOptions)
{
scubaduck/static/js/chip_input.js:148: if (!fetchOptions) {
scubaduck/static/js/chip_input.js:152: Promise.resolve(fetchOptions(input.val
ue, filter)).then(values => {
root@df047255e87d:/workspace/scubaduck# sed -n '640,720p' scubaduck/static/js/vi
ew_settings.js
updateSelectedColumns();
}
let lastQueryTime = 0;
let queryStart = 0;
function dive(push=true) {
const params = collectParams();
if (push) {
history.pushState(params, '', paramsToSearch(params));
}
const payload = Object.assign({}, params);
const dcMap = {};
(params.derived_columns || []).forEach(d => {
if (d.include) dcMap[d.name] = d.expr;
});
payload.derived_columns = dcMap;
const view = document.getElementById('view');
view.innerHTML = '<p>Loading...</p>';
window.lastResults = undefined;
queryStart = performance.now();
fetch('/api/query', {method:'POST', headers:{'Content-Type':'application/json'
}, body:JSON.stringify(payload)})
.then(async r => {
const data = await r.json();
if (!r.ok) throw data;
return data;
})
.then(data => {
lastQueryTime = Math.round(performance.now() - queryStart);
showResults(data);
})
.catch(err => {
showError(err);
});
}
function collectParams() {
updateSelectedColumns();
const payload = {
table: document.getElementById('table').value,
time_column: document.getElementById('time_column').value,
time_unit: document.getElementById('time_unit').value,
start: document.getElementById('start').value,
end: document.getElementById('end').value,
order_by: document.getElementById('order_by').value,
order_dir: orderDir,
limit: parseInt(document.getElementById('limit').value, 10),
columns: selectedColumns.filter(c =>
c !== 'Hits' && !derivedColumns.some(dc => dc.name === c)
),
samples_columns: columnValues.samples.slice(),
table_columns: columnValues.table.slice(),
timeseries_columns: columnValues.timeseries.slice(),
graph_type: graphTypeSel.value,
filters: Array.from(document.querySelectorAll('#filters .filter')).map(f =>
{
const chips = f.chips || [];
const op = f.querySelector('.f-op').value;
let value = null;
if (op !== 'empty' && op !== '!empty') {
value = chips.length === 0 ? null : (chips.length === 1 ? chips[0] : chi
ps);
}
const colSel = f.querySelector('.f-col select') || f.querySelector('.f-col
');
return {column: colSel.value, op, value};
}),
derived_columns: Array.from(document.querySelectorAll('#derived_list .derive
d')).map(d => ({
type: d.querySelector('.d-type').value,
name: d.querySelector('.d-name').value,
expr: d.querySelector('.d-expr').value,
include: d.querySelector('.d-use').checked,
}))
};
if (graphTypeSel.value === 'table' || graphTypeSel.value === 'timeseries') {
payload.group_by = groupBy.chips || [];
payload.aggregate = document.getElementById('aggregate').value;
payload.show_hits = document.getElementById('show_hits').checked;
}
if (graphTypeSel.value === 'timeseries') {
const xval = document.getElementById('x_axis').value;
if (xval) payload.x_axis = xval;
payload.granularity = document.getElementById('granularity').value;
payload.fill = document.getElementById('fill').value;
Looks like I need to modify updateSelectedColumns for aggregate=Count; let's review it around line 470.
root@df047255e87d:/workspace/scubaduck# sed -n '424,454p' scubaduck/static/js/vi
ew_settings.js
function updateColumnsTabCount() {
const baseCount = document.querySelectorAll('#column_groups input:checked').le
ngth;
const derivedCount = document.querySelectorAll('#derived_list .derived .d-use:
checked').length;
const btn = document.getElementById('columns_tab');
if (btn) btn.textContent = `Columns (${baseCount + derivedCount})`;
}
function updateSelectedColumns(type = graphTypeSel.value) {
const base = allColumns.filter(name => {
const cb = document.querySelector(`#column_groups input[value="${name}"]`);
if (!cb || !cb.checked) return false;
if (type === 'table' && isStringColumn(name)) return false;
return true;
});
if (type === 'table' || type === 'timeseries') {
selectedColumns = groupBy.chips.slice();
if (document.getElementById('show_hits').checked) selectedColumns.push('Hits
');
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);
});
}
columnValues[type] = selectedColumns.slice();
updateColumnsTabCount();
I'm adjusting logic for table or timeseries views to handle 'Count' aggregates differently.
I'm adjusting logic to exclude base/derived columns in table view for aggregate 'Count'.
I need to confirm updateSelectedColumns triggers when graph type or aggregate changes for proper filtering.
root@df047255e87d:/workspace/scubaduck# grep -n "aggregate" -n scubaduck/static/
js/view_settings.js
148: document.getElementById('aggregate_field').style.display = showTable || sh
owTS ? 'flex' : 'none';
344: initDropdown(document.getElementById('aggregate'));
581: <option value="aggregated">Aggregated</option>
713: payload.aggregate = document.getElementById('aggregate').value;
743: if (params.aggregate) sp.set('aggregate', params.aggregate);
786: if (params.aggregate) document.getElementById('aggregate').value = params.
aggregate;
843: if (sp.has('aggregate')) params.aggregate = sp.get('aggregate');
I’ll check around line 148 for the event binding logic.
root@df047255e87d:/workspace/scubaduck# sed -n '120,160p' scubaduck/static/js/vi
ew_settings.js
div.addEventListener('mousedown', evt => {
evt.preventDefault();
select.value = o.value;
select.dispatchEvent(new Event('change'));
updateDisplay();
close();
});
list.appendChild(div);
});
}
search.addEventListener('input', renderOptions);
select.addEventListener('change', updateDisplay);
}
let orderDir = 'ASC';
const orderDirBtn = document.getElementById('order_dir');
const graphTypeSel = document.getElementById('graph_type');
function updateOrderDirButton() {
orderDirBtn.textContent = orderDir + (orderDir === 'ASC' ? ' \u25B2' : ' \u25B
C');
}
function updateDisplayTypeUI() {
const prevType = displayType;
updateSelectedColumns(prevType);
const newType = graphTypeSel.value;
const showTable = newType === 'table';
const showTS = newType === 'timeseries';
document.getElementById('group_by_field').style.display = showTable || showTS
? 'flex' : 'none';
document.getElementById('aggregate_field').style.display = showTable || showTS
? 'flex' : 'none';
document.getElementById('show_hits_field').style.display = showTable ? 'flex'
: 'none';
document.getElementById('x_axis_field').style.display = showTS ? 'flex' : 'non
e';
document.getElementById('granularity_field').style.display = showTS ? 'flex' :
'none';
document.getElementById('fill_field').style.display = showTS ? 'flex' : 'none'
;
document.querySelectorAll('#column_groups .col-group').forEach(g => {
if (g.querySelector('.col-group-header').textContent.startsWith('Strings'))
{
g.style.display = showTable || showTS ? 'none' : '';
}
});
limitValues[prevType] = parseInt(limitInput.value, 10);
if (showTS && limitValues.timeseries === undefined) {
limitValues.timeseries = 7;
I'm looking for any aggregate change events within the system.
root@df047255e87d:/workspace/scubaduck# grep -n "aggregate_field" -n scubaduck/s
tatic/js/view_settings.js
148: document.getElementById('aggregate_field').style.display = showTable || sh
owTS ? 'flex' : 'none';
I'll check the index.html file, especially around the aggregate field, for any related events.
root@df047255e87d:/workspace/scubaduck# grep -n "aggregate" -n scubaduck/static/
index.html | head
303: <div id="aggregate_field" class="field" style="display:none;">
305: <select id="aggregate">
root@df047255e87d:/workspace/scubaduck# sed -n '300,310p' scubaduck/static/index
.html
<div class="chip-dropdown"></div>
</div>
</div>
<div id="aggregate_field" class="field" style="display:none;">
<label>Aggregate</label>
<select id="aggregate">
<option>Count</option>
<option>Avg</option>
<option>Sum</option>
<option>Min</option>
<option>Max</option>
root@df047255e87d:/workspace/scubaduck# sed -n '310,340p' scubaduck/static/index
.html
<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>
I think the event updateSelectedColumns
might be triggered when the #aggregate select changes.
root@df047255e87d:/workspace/scubaduck# grep -n "aggregate" scubaduck/static/js/
view_settings.js | head
148: document.getElementById('aggregate_field').style.display = showTable || sh
owTS ? 'flex' : 'none';
344: initDropdown(document.getElementById('aggregate'));
581: <option value="aggregated">Aggregated</option>
713: payload.aggregate = document.getElementById('aggregate').value;
743: if (params.aggregate) sp.set('aggregate', params.aggregate);
786: if (params.aggregate) document.getElementById('aggregate').value = params.
aggregate;
843: if (sp.has('aggregate')) params.aggregate = sp.get('aggregate');
I'll look deeper for 'aggregate' to find the event-related information.
root@df047255e87d:/workspace/scubaduck# grep -n "aggregate")" -n scubaduck/stati
c/js -r
bash: syntax error near unexpected token `)'
I'll search for 'aggregate_field' and '#aggregate' to find relevant information.
root@df047255e87d:/workspace/scubaduck# grep -n "#aggregate" -r scubaduck/static
/js
It seems like the event might be using the plain ID 'aggregate.' I'll check.
root@df047255e87d:/workspace/scubaduck# grep -n "aggregate'" -r scubaduck/static
/js | head
scubaduck/static/js/timeseries_chart.js:313: const agg = document.getElementB
yId('aggregate').value.toLowerCase();
scubaduck/static/js/view_settings.js:344: initDropdown(document.getElementByI
d('aggregate'));
scubaduck/static/js/view_settings.js:713: payload.aggregate = document.getEle
mentById('aggregate').value;
scubaduck/static/js/view_settings.js:743: if (params.aggregate) sp.set('aggre
gate', params.aggregate);
scubaduck/static/js/view_settings.js:786: if (params.aggregate) document.getEle
mentById('aggregate').value = params.aggregate;
scubaduck/static/js/view_settings.js:843: if (sp.has('aggregate')) params.aggre
gate = sp.get('aggregate');
I'll search the codebase for both "aggregate" and "event" keywords.
root@df047255e87d:/workspace/scubaduck# grep -n "aggregate).*" -r scubaduck/stat
ic/js
scubaduck/static/js/view_settings.js:743: if (params.aggregate) sp.set('aggre
gate', params.aggregate);
scubaduck/static/js/view_settings.js:786: if (params.aggregate) document.getEle
mentById('aggregate').value = params.aggregate;
I'll check line 340 in view_settings.js to review the code there.
root@df047255e87d:/workspace/scubaduck# sed -n '320,360p' scubaduck/static/js/vi
ew_settings.js
updateSelectedColumns();
});
div.appendChild(ul);
groupsEl.appendChild(div);
});
document.getElementById('columns_all').addEventListener('click', e => {
e.preventDefault();
groupsEl.querySelectorAll('input').forEach(cb => (cb.checked = true));
updateSelectedColumns();
});
document.getElementById('columns_none').addEventListener('click', e => {
e.preventDefault();
groupsEl.querySelectorAll('input').forEach(cb => (cb.checked = false));
updateSelectedColumns();
});
updateSelectedColumns();
columnValues.samples = allColumns.slice();
columnValues.table = [];
columnValues.timeseries = [];
groupBy = document.getElementById('group_by').closest('.field');
initChipInput(groupBy, typed =>
allColumns.filter(c => c.toLowerCase().includes(typed.toLowerCase()))
);
initDropdown(orderSelect);
initDropdown(document.getElementById('aggregate'));
});
}
let columnsInitialized = false;
fetch('/api/tables').then(r => r.json()).then(tables => {
const tableSel = document.getElementById('table');
tables.forEach(t => {
const o = document.createElement('option');
o.value = t;
o.textContent = t;
tableSel.appendChild(o);
});
initDropdown(tableSel);
const measure = document.createElement('span');
measure.style.visibility = 'hidden';
measure.style.position = 'absolute';
I wonder if there's an event for aggregate or group_by in the code.
root@df047255e87d:/workspace/scubaduck# grep -n "updateSelectedColumns" -n scuba
duck/static/js/view_settings.js
143: updateSelectedColumns(prevType);
174: updateSelectedColumns(newType);
306: cb.addEventListener('change', updateSelectedColumns);
315: updateSelectedColumns();
320: updateSelectedColumns();
328: updateSelectedColumns();
333: updateSelectedColumns();
335: updateSelectedColumns();
431:function updateSelectedColumns(type = graphTypeSel.value) {
640: updateSelectedColumns();
677: updateSelectedColumns();
794: updateSelectedColumns(graphTypeSel.value);
I'll take a look at lines 640 and 677 to see what's happening there.
root@df047255e87d:/workspace/scubaduck# sed -n '640,700p' scubaduck/static/js/vi
ew_settings.js
updateSelectedColumns();
}
let lastQueryTime = 0;
let queryStart = 0;
function dive(push=true) {
const params = collectParams();
if (push) {
history.pushState(params, '', paramsToSearch(params));
}
const payload = Object.assign({}, params);
const dcMap = {};
(params.derived_columns || []).forEach(d => {
if (d.include) dcMap[d.name] = d.expr;
});
payload.derived_columns = dcMap;
const view = document.getElementById('view');
view.innerHTML = '<p>Loading...</p>';
window.lastResults = undefined;
queryStart = performance.now();
fetch('/api/query', {method:'POST', headers:{'Content-Type':'application/json'
}, body:JSON.stringify(payload)})
.then(async r => {
const data = await r.json();
if (!r.ok) throw data;
return data;
})
.then(data => {
lastQueryTime = Math.round(performance.now() - queryStart);
showResults(data);
})
.catch(err => {
showError(err);
});
}
function collectParams() {
updateSelectedColumns();
const payload = {
table: document.getElementById('table').value,
time_column: document.getElementById('time_column').value,
time_unit: document.getElementById('time_unit').value,
start: document.getElementById('start').value,
end: document.getElementById('end').value,
order_by: document.getElementById('order_by').value,
order_dir: orderDir,
limit: parseInt(document.getElementById('limit').value, 10),
columns: selectedColumns.filter(c =>
c !== 'Hits' && !derivedColumns.some(dc => dc.name === c)
),
samples_columns: columnValues.samples.slice(),
table_columns: columnValues.table.slice(),
timeseries_columns: columnValues.timeseries.slice(),
graph_type: graphTypeSel.value,
filters: Array.from(document.querySelectorAll('#filters .filter')).map(f =>
{
const chips = f.chips || [];
const op = f.querySelector('.f-op').value;
let value = null;
if (op !== 'empty' && op !== '!empty') {
value = chips.length === 0 ? null : (chips.length === 1 ? chips[0] : chi
ps);
}
Let me check what's going on in line 677.
root@df047255e87d:/workspace/scubaduck# sed -n '668,688p' scubaduck/static/js/vi
ew_settings.js
lastQueryTime = Math.round(performance.now() - queryStart);
showResults(data);
})
.catch(err => {
showError(err);
});
}
function collectParams() {
updateSelectedColumns();
const payload = {
table: document.getElementById('table').value,
time_column: document.getElementById('time_column').value,
time_unit: document.getElementById('time_unit').value,
start: document.getElementById('start').value,
end: document.getElementById('end').value,
order_by: document.getElementById('order_by').value,
order_dir: orderDir,
limit: parseInt(document.getElementById('limit').value, 10),
columns: selectedColumns.filter(c =>
c !== 'Hits' && !derivedColumns.some(dc => dc.name === c)
I'll continue scrolling through the changelog starting at line 777.
root@df047255e87d:/workspace/scubaduck# sed -n '690,816p' scubaduck/static/js/vi
ew_settings.js
samples_columns: columnValues.samples.slice(),
table_columns: columnValues.table.slice(),
timeseries_columns: columnValues.timeseries.slice(),
graph_type: graphTypeSel.value,
filters: Array.from(document.querySelectorAll('#filters .filter')).map(f =>
{
const chips = f.chips || [];
const op = f.querySelector('.f-op').value;
let value = null;
if (op !== 'empty' && op !== '!empty') {
value = chips.length === 0 ? null : (chips.length === 1 ? chips[0] : chi
ps);
}
const colSel = f.querySelector('.f-col select') || f.querySelector('.f-col
');
return {column: colSel.value, op, value};
}),
derived_columns: Array.from(document.querySelectorAll('#derived_list .derive
d')).map(d => ({
type: d.querySelector('.d-type').value,
name: d.querySelector('.d-name').value,
expr: d.querySelector('.d-expr').value,
include: d.querySelector('.d-use').checked,
}))
};
if (graphTypeSel.value === 'table' || graphTypeSel.value === 'timeseries') {
payload.group_by = groupBy.chips || [];
payload.aggregate = document.getElementById('aggregate').value;
payload.show_hits = document.getElementById('show_hits').checked;
}
if (graphTypeSel.value === 'timeseries') {
const xval = document.getElementById('x_axis').value;
if (xval) payload.x_axis = xval;
payload.granularity = document.getElementById('granularity').value;
payload.fill = document.getElementById('fill').value;
}
return payload;
}
function paramsToSearch(params) {
const sp = new URLSearchParams();
if (params.table) sp.set('table', params.table);
if (params.time_column) sp.set('time_column', params.time_column);
if (params.time_unit) sp.set('time_unit', params.time_unit);
if (params.start) sp.set('start', params.start);
if (params.end) sp.set('end', params.end);
if (params.order_by) sp.set('order_by', params.order_by);
if (params.order_dir) sp.set('order_dir', params.order_dir);
if (params.limit !== null && params.limit !== undefined) sp.set('limit', param
s.limit);
if (params.samples_columns && params.samples_columns.length) sp.set('samples_c
olumns', params.samples_columns.join(','));
if (params.table_columns && params.table_columns.length) sp.set('table_columns
', params.table_columns.join(','));
if (params.timeseries_columns && params.timeseries_columns.length) sp.set('tim
eseries_columns', params.timeseries_columns.join(','));
if (params.filters && params.filters.length) sp.set('filters', JSON.stringify(
params.filters));
if (params.derived_columns && params.derived_columns.length) sp.set('derived_c
olumns', JSON.stringify(params.derived_columns));
if (params.graph_type) sp.set('graph_type', params.graph_type);
if (params.graph_type === 'table' || params.graph_type === 'timeseries') {
if (params.group_by && params.group_by.length) sp.set('group_by', params.gro
up_by.join(','));
if (params.aggregate) sp.set('aggregate', params.aggregate);
if (params.show_hits) sp.set('show_hits', '1');
}
if (params.graph_type === 'timeseries') {
if (params.x_axis) sp.set('x_axis', params.x_axis);
if (params.granularity) sp.set('granularity', params.granularity);
if (params.fill) sp.set('fill', params.fill);
}
const qs = sp.toString();
return qs ? '?' + qs : '';
}
function applyParams(params) {
if (params.table) document.getElementById('table').value = params.table;
document.getElementById('time_column').value = params.time_column || defaultTi
meColumn;
updateTimeFieldVisibility();
if (params.time_unit) document.getElementById('time_unit').value = params.time
_unit;
document.getElementById('start').value = params.start || '';
document.getElementById('end').value = params.end || '';
if (params.order_by) {
document.getElementById('order_by').value = params.order_by;
}
orderDir = params.order_dir || 'ASC';
updateOrderDirButton();
if (params.limit !== undefined && params.limit !== null) {
document.getElementById('limit').value = params.limit;
limitValues[params.graph_type || 'samples'] = params.limit;
limitInput.dataset.setByUser = '1';
}
graphTypeSel.value = params.graph_type || 'samples';
updateDisplayTypeUI();
limitInput.value = limitValues[graphTypeSel.value];
if (params.x_axis) {
document.getElementById('x_axis').value = params.x_axis;
} else {
document.getElementById('x_axis').value = '';
}
if (params.granularity) document.getElementById('granularity').value = params.
granularity;
if (params.fill) document.getElementById('fill').value = params.fill;
if (params.group_by) {
groupBy.chips.splice(0, groupBy.chips.length, ...params.group_by);
groupBy.renderChips();
}
if (params.aggregate) document.getElementById('aggregate').value = params.aggr
egate;
document.getElementById('show_hits').checked = params.show_hits ?? true;
if (params.samples_columns) columnValues.samples = params.samples_columns;
if (params.table_columns) columnValues.table = params.table_columns;
if (params.timeseries_columns) columnValues.timeseries = params.timeseries_col
umns;
document.querySelectorAll('#column_groups input').forEach(cb => {
cb.checked = columnValues[graphTypeSel.value].includes(cb.value);
});
updateSelectedColumns(graphTypeSel.value);
const dlist = document.getElementById('derived_list');
dlist.innerHTML = '';
derivedColumns.splice(0, derivedColumns.length);
if (params.derived_columns && params.derived_columns.length) {
params.derived_columns.forEach(dc => addDerived(dc));
}
refreshDerivedColumns();
const list = document.getElementById('filter_list');
list.innerHTML = '';
if (params.filters && params.filters.length) {
params.filters.forEach(f => {
addFilter();
const el = list.lastElementChild;
const colSel = el.querySelector('.f-col select') || el.querySelector('.f-c
ol');
colSel.value = f.column;
colSel.dispatchEvent(new Event('change'));
el.querySelector('.f-op').value = f.op;
el.querySelector('.f-op').dispatchEvent(new Event('change'));
if (f.value !== null && f.op !== 'empty' && f.op !== '!empty') {
const values = Array.isArray(f.value) ? f.value : [f.value];
values.forEach(v => el.addChip(v));
el.renderChips();
I'm adjusting updateSelectedColumns
to handle both aggregate and selectedColumns matching rows length, while also ensuring it aligns with how renderTable
handles the headers. I'll make sure selectedColumns
are updated correctly after the query to maintain consistency.
I need to ensure that selectedColumns aren't overwritten by the 'count' aggregate in columnValues.
root@df047255e87d:/workspace/scubaduck# sed -n '130,190p' scubaduck/static/js/vi
ew_settings.js
search.addEventListener('input', renderOptions);
select.addEventListener('change', updateDisplay);
}
let orderDir = 'ASC';
const orderDirBtn = document.getElementById('order_dir');
const graphTypeSel = document.getElementById('graph_type');
function updateOrderDirButton() {
orderDirBtn.textContent = orderDir + (orderDir === 'ASC' ? ' \u25B2' : ' \u25B
C');
}
function updateDisplayTypeUI() {
const prevType = displayType;
updateSelectedColumns(prevType);
const newType = graphTypeSel.value;
const showTable = newType === 'table';
const showTS = newType === 'timeseries';
document.getElementById('group_by_field').style.display = showTable || showTS
? 'flex' : 'none';
document.getElementById('aggregate_field').style.display = showTable || showTS
? 'flex' : 'none';
document.getElementById('show_hits_field').style.display = showTable ? 'flex'
: 'none';
document.getElementById('x_axis_field').style.display = showTS ? 'flex' : 'non
e';
document.getElementById('granularity_field').style.display = showTS ? 'flex' :
'none';
document.getElementById('fill_field').style.display = showTS ? 'flex' : 'none'
;
document.querySelectorAll('#column_groups .col-group').forEach(g => {
if (g.querySelector('.col-group-header').textContent.startsWith('Strings'))
{
g.style.display = showTable || showTS ? 'none' : '';
}
});
limitValues[prevType] = parseInt(limitInput.value, 10);
if (showTS && limitValues.timeseries === undefined) {
limitValues.timeseries = 7;
}
limitInput.value = limitValues[newType];
document.querySelectorAll('#column_groups input').forEach(cb => {
cb.checked = columnValues[newType].includes(cb.value);
});
if (showTS) {
document.querySelectorAll('#column_groups input').forEach(cb => {
if (isTimeColumn(cb.value) || isStringColumn(cb.value)) {
cb.checked = false;
}
});
document.getElementById('order_by').value = '';
}
updateSelectedColumns(newType);
displayType = newType;
}
function updateTimeFieldVisibility() {
const show = document.getElementById('time_column').value !== '';
document.getElementById('start').closest('.field').style.display = show
? 'flex'
: 'none';
document.getElementById('end').closest('.field').style.display = show
? 'flex'
: 'none';
}
orderDirBtn.addEventListener('click', () => {
orderDir = orderDir === 'ASC' ? 'DESC' : 'ASC';
updateOrderDirButton();
});
updateOrderDirButton();
I need to store selected columns independent of aggregators. I'll modify the update function.
I'll adjust the function to store all base and derived columns, even for aggregates.
I’ll implement the function to store full columns but adjust display for aggregates like count.
I’ll adjust the server to exclude 'Count' when aggregate is 'Count' and show_hits is enabled.
root@df047255e87d:/workspace/scubaduck# grep -n "Count" tests/test_server*.py
tests/test_server_errors.py:59: "aggregate": "Count",
tests/test_server_time.py:67: "aggregate": "Count",
tests/test_server_timeseries.py:71: "aggregate": "Count",
tests/test_server_timeseries.py:142: "aggregate": "Count",
.py
"/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 = {
"table": "events",
"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 = {
"table": "events",
"graph_type": "samples",
"group_by": ["user"],
"columns": ["timestamp"],
}
rv = client.post(
"/api/query", data=json.dumps(payload), content_type="application/json"
I’ll check the start of tests/test_server_time.py, focusing on the aggregate count.
y
payload = {
"table": "events",
"start": "2024-01-01 00:00:00",
"end": "2024-01-01 01:00:00",
"graph_type": "timeseries",
"granularity": "1 hour",
"columns": ["event"],
"aggregate": "Count",
"time_column": "created",
"time_unit": "ms",
}
rv = client.post(
"/api/query", data=json.dumps(payload), content_type="application/json"
)
data = rv.get_json()
assert rv.status_code == 200
assert len(data["rows"]) == 2
def test_integer_time_unit_us_default_start_end(tmp_path: Path) -> None:
csv_file = tmp_path / "events.csv"
I'll take a look at the tests in test_server_timeseries.py.
def test_timeseries_basic() -> None:
app = server.app
client = app.test_client()
payload = {
"table": "events",
"start": "2024-01-01 00:00:00",
"end": "2024-01-03 00:00:00",
"graph_type": "timeseries",
"limit": 100,
"group_by": ["user"],
"aggregate": "Count",
"columns": ["value"],
"x_axis": "timestamp",
"granularity": "1 day",
}
rv = client.post(
"/api/query", data=json.dumps(payload), content_type="application/json"
)
data = rv.get_json()
assert rv.status_code == 200
assert len(data["rows"]) == 4
def test_timeseries_orders_by_xaxis() -> None:
app = server.app
client = app.test_client()
payload = {
"table": "events",
"start": "2024-01-01 00:00:00",
"end": "2024-01-03 00:00:00",
"graph_type": "timeseries",
"limit": 100,
"columns": ["value"],
"x_axis": "timestamp",
"granularity": "1 day",
}
rv = client.post(
"/api/query", data=json.dumps(payload), content_type="application/json"
)
data = rv.get_json()
assert rv.status_code == 200
rows = data["rows"]
from dateutil import parser
timestamps = [parser.parse(r[0]).replace(tzinfo=None) for r in rows]
assert timestamps == sorted(timestamps)
def test_timeseries_count_no_columns() -> None:
app = server.app
client = app.test_client()
payload: dict[str, Any] = {
"table": "events",
"start": "2024-01-01 00:00:00",
"end": "2024-01-03 00:00:00",
"graph_type": "timeseries",
"granularity": "1 day",
"columns": [],
}
rv = client.post(
data = rv.get_json()
assert rv.status_code == 200
rows = data["rows"]
assert len(rows) == 2
assert rows[0][1] == 2
assert rows[1][1] == 2
def test_timeseries_limit_applies_to_series() -> None:
app = server.app
client = app.test_client()
payload = {
"table": "events",
"start": "2024-01-01 00:00:00",
"end": "2024-01-03 00:00:00",
"graph_type": "timeseries",
"limit": 1,
"order_by": "user",
"group_by": ["user"],
"aggregate": "Count",
"columns": ["value"],
"x_axis": "timestamp",
"granularity": "1 day",
}
rv = client.post(
"/api/query", data=json.dumps(payload), content_type="application/json"
)
data = rv.get_json()
assert rv.status_code == 200
assert len(data["rows"]) == 2
assert all(r[1] == "alice" for r in data["rows"])
def test_timeseries_auto_and_fine_buckets() -> None:
app = server.app
client = app.test_client()
def run(gran: str) -> None:
payload = {
"start": "2024-01-01 00:00:00",
"end": "2024-01-02 03:00:00",
"graph_type": "timeseries",
"columns": ["value"],
"x_axis": "timestamp",
"granularity": gran,
}
rv = client.post(
"/api/query", data=json.dumps(payload), content_type="application/js
on"
)
data = rv.get_json()
assert rv.status_code == 200
from dateutil import parser
start = parser.parse(cast(str, payload["start"])).replace(tzinfo=None)
buckets = [
parser.parse(cast(str, r[0])).replace(tzinfo=None) for r in data["ro
ws"]
]
assert buckets[0] == start
if len(buckets) > 1:
step = (buckets[1] - buckets[0]).total_seconds()
assert step % data["bucket_size"] == 0
assert any(r[1] != 0 for r in data["rows"])
run("Auto")
run("Fine")
def test_timeseries_string_column_error() -> None:
app = server.app
client = app.test_client()
payload = {
"table": "events",
"start": "2024-01-01 00:00:00",
"end": "2024-01-03 00:00:00",
"graph_type": "timeseries",
"limit": 7,
"columns": ["timestamp", "event", "value", "user"],
"x_axis": "timestamp",
"granularity": "1 hour",
}
rv = client.post(
"/api/query", data=json.dumps(payload), content_type="application/json"
)
assert rv.status_code == 200
def test_derived_column_basic() -> None:
app = server.app
client = app.test_client()
payload = {
"table": "events",
"start": "2024-01-01 00:00:00",
"end": "2024-01-03 00:00:00",
"limit": 10,
"columns": ["timestamp"],
"derived_columns": {"val2": "value * 2"},
"filters": [],
}
rv = client.post(
"/api/query", data=json.dumps(payload), content_type="application/json"
)
data = rv.get_json()
assert rv.status_code == 200
assert data["rows"][0][1] == 20
def test_timeseries_derived_column() -> None:
app = server.app
client = app.test_client()
payload = {
"table": "events",
"start": "2024-01-01 00:00:00",
"end": "2024-01-03 00:00:00",
"graph_type": "timeseries",
"granularity": "1 hour",
"limit": 7,
"columns": ["value"],
"derived_columns": {"derived_1": "value * 2"},
Checking out the "test_timeseries_count_no_columns" to understand the count behavior without columns.
root@df047255e87d:/workspace/scubaduck# grep -n "count_no_columns" -n tests/test
_server_timeseries.py
109:def test_timeseries_count_no_columns() -> None:
series.py
assert timestamps == sorted(timestamps)
def test_timeseries_count_no_columns() -> None:
app = server.app
client = app.test_client()
payload: dict[str, Any] = {
"table": "events",
"start": "2024-01-01 00:00:00",
"end": "2024-01-03 00:00:00",
"graph_type": "timeseries",
"granularity": "1 day",
"columns": [],
}
rv = client.post(
"/api/query", data=json.dumps(payload), content_type="application/json"
)
data = rv.get_json()
assert rv.status_code == 200
rows = data["rows"]
assert len(rows) == 2
assert rows[0][1] == 2
assert rows[1][1] == 2
Looks like test_count_no_columns is present for timeseries, but not for the table.
sic.py
I'll explore timeseries_chart.js to see how aggregated data handles the Count column.
root@df047255e87d:/workspace/scubaduck# grep -n "Count" -n scubaduck/static/js -
r
scubaduck/static/js/view_settings.js:424:function updateColumnsTabCount() {
scubaduck/static/js/view_settings.js:425: const baseCount = document.querySelec
torAll('#column_groups input:checked').length;
scubaduck/static/js/view_settings.js:426: const derivedCount = document.querySe
lectorAll('#derived_list .derived .d-use:checked').length;
scubaduck/static/js/view_settings.js:428: if (btn) btn.textContent = `Columns (
${baseCount + derivedCount})`;
scubaduck/static/js/view_settings.js:454: updateColumnsTabCount();
I think I need to adjust how selectedColumns is computed, but keep columnValues as is. I'll ensure the server sends the correct data without the Count column when show_hits is True. However, I'll also avoid showing the Count column in the table and test whether renderTable properly handles this alignment.
When selectedColumns
length is less than row length, undefined values are safely handled as false.
I need to ensure the server doesn't return the Count column when show_hits is True.
root@df047255e87d:/workspace/scubaduck# grep -n "show_hits" -r tests | head
tests/test_server_errors.py:60: "show_hits": True,
tests/test_server_db_types.py:144: "show_hits": True,
tests/test_server_timeseries.py:22: "show_hits": True,
tests/test_web_graph.py:14: assert page.is_visible("#show_hits_field")
I'll open the test file around line 144 to review show_hits.
assert rv.status_code == 200
assert data["rows"][0][1] == big_value
def test_sqlite_boolean_aggregation(tmp_path: Path) -> None:
sqlite_file = tmp_path / "bool.sqlite"
import sqlite3
conn = sqlite3.connect(sqlite_file)
conn.execute("CREATE TABLE events (timestamp TEXT, flag BOOLEAN)")
conn.execute("INSERT INTO events VALUES ('2024-01-01 00:00:00', 1)")
conn.execute("INSERT INTO events VALUES ('2024-01-01 00:30:00', 0)")
conn.commit()
conn.close() # pyright: ignore[reportUnknownMemberType, reportAttributeAcce
ssIssue]
app = server.create_app(sqlite_file)
client = app.test_client()
payload = {
"table": "events",
"start": "2024-01-01 00:00:00",
"end": "2024-01-02 00:00:00",
"graph_type": "table",
"aggregate": "Avg",
"columns": ["flag"],
"show_hits": True,
}
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][0] == 2
assert data["rows"][0][1] == 0.5
def test_envvar_db(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
csv_file = tmp_path / "custom.csv"
csv_file.write_text("timestamp,event,value,user\n2024-01-01 00:00:00,login,5
,bob\n")
monkeypatch.setenv("SCUBADUCK_DB", str(csv_file))
app = server.create_app()
client = app.test_client()
I'll take another look at the tests/test_server_timeseries file, focusing on show_hits.
from __future__ import annotations
import json
from typing import Any, cast
from scubaduck import server
def test_group_by_table() -> None:
app = server.app
client = app.test_client()
payload = {
"table": "events",
"start": "2024-01-01 00:00:00",
"end": "2024-01-03 00:00:00",
"graph_type": "table",
"order_by": "user",
"limit": 10,
"columns": ["value"],
"group_by": ["user"],
"aggregate": "Sum",
"show_hits": True,
}
rv = client.post(
"/api/query", data=json.dumps(payload), content_type="application/json"
)
rows = rv.get_json()["rows"]
assert rows[0][0] == "alice"
assert rows[0][1] == 2
assert rows[0][2] == 40
def test_table_avg_with_timestamp() -> None:
app = server.app
client = app.test_client()
payload = {
If show_hits
is true, only add a "Hits" column for count aggregation.
For aggregates other than count, add show_hits
without duplicating results. I'll adjust the server code.
f"CAST(floor(epoch({xexpr})/{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_cols = (
group_cols[1:] if params.graph_type == "timeseries" else group_cols
)
select_parts.extend(_quote(c) for c in select_cols)
agg = (params.aggregate or "count").lower()
def agg_expr(col: str) -> str:
expr = _quote(col)
ctype = column_types.get(col, "").upper() if column_types else ""
if "BOOL" in ctype:
expr = f"CAST({_quote(col)} AS BIGINT)"
if agg.startswith("p"):
quant = float(agg[1:]) / 100
return f"quantile({expr}, {quant})"
if agg == "count distinct":
return f"count(DISTINCT {expr})"
if agg == "avg" and column_types is not None:
if "TIMESTAMP" in ctype or "DATE" in ctype or "TIME" in ctype:
return (
"TIMESTAMP 'epoch' + INTERVAL '1 second' * "
f"CAST(avg(epoch({_quote(col)})) AS BIGINT)"
)
return f"{agg}({expr})"
if agg == "count":
select_parts.append("count(*) AS Count")
else:
for col in params.columns:
if col in group_cols:
continue
select_parts.append(f"{agg_expr(col)} AS {_quote(col)}")
if params.show_hits:
select_parts.insert(len(group_cols), "count(*) AS Hits")
else:
select_parts.extend(_quote(c) for c in params.columns)
if has_agg and params.derived_columns:
inner_params = replace(
params,
derived_columns={},
order_by=None,
limit=None,
)
inner_sql = build_query(inner_params, column_types)
outer_select = ["t.*"] + [
f"{expr} AS {name}" for name, expr in params.derived_columns.items()
]
indented_inner = "\n".join(" " + line for line in inner_sql.splitline
s())
lines = [
f"SELECT {', '.join(outer_select)}",
"FROM (",
indented_inner,
") t",
]
if params.order_by:
lines.append(f"ORDER BY {_quote(params.order_by)} {params.order_dir}
")
elif params.graph_type == "timeseries":
lines.append("ORDER BY bucket")
if params.limit is not None:
lines.append(f"LIMIT {params.limit}")
return "\n".join(lines)
for name, expr in params.derived_columns.items():
select_parts.append(f"{expr} AS {name}")
select_clause = ", ".join(select_parts) if select_parts else "*"
lines = [f"SELECT {select_clause}", f'FROM "{params.table}"']
where_parts: list[str] = []
if params.time_column:
time_expr = _time_expr(params.time_column, column_types, params.time_uni
t)
else:
time_expr = None
if time_expr and params.start:
where_parts.append(f"{time_expr} >= '{params.start}'")
if time_expr and params.end:
where_parts.append(f"{time_expr} <= '{params.end}'")
for f in params.filters:
op = f.op
if op in {"empty", "!empty"}:
val = "''"
else:
if f.value is None:
continue
if isinstance(f.value, list):
if not f.value:
continue
I'll adjust code to handle 'count' aggregates and show hits or count accordingly.
root@df047255e87d:/workspace/scubaduck# grep -n "aggregate=\"Count\"" -r tests |
grep -n show_hits
There's no test dependent on Count or Hits, but I'll double-check the server tests to be sure.
root@df047255e87d:/workspace/scubaduck# grep -n "Hits" -r tests/test_server*.py
tests/test_server_errors.py:57: "columns": ["user", "Hits", "value"],
I'll take a look at the relevant part to check things.
root@df047255e87d:/workspace/scubaduck# sed -n '48,72p' tests/test_server_errors
.py
app = server.app
client = app.test_client()
payload = {
"table": "events",
"start": "2024-01-01 00:00:00",
"end": "2024-01-03 00:00:00",
"graph_type": "table",
"order_by": "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()
I’ll check the bottom of test_web_table.py to add the new test and inspect the run_query function.
page.click("text=Add Derived")
assert page.query_selector("#derived_list .derived button.remove")
page.click("#derived_list .derived button.remove")
count = page.evaluate("document.querySelectorAll('#derived_list .derived').l
ength")
assert count == 0
def test_sql_query_display(page: Any, server_url: str) -> None:
data = run_query(
page,
server_url,
start="2024-01-01 00:00:00",
end="2024-01-02 00:00:00",
order_by="timestamp",
limit=10,
)
sql = data["sql"]
displayed = page.text_content("#sql_query")
assert displayed is not None
assert displayed.strip() == sql
I need to add a test for showing only the id and Hits columns in Table view. Let's check if run_query selects all columns correctly by default, or if I should ensure this manually. I'll also look for any run_query instances in existing tests.
root@df047255e87d:/workspace/scubaduck# grep -n "Columns All" -r tests
I'll search for "column_actions" or similar terms related to columns.
root@df047255e87d:/workspace/scubaduck# grep -n "columns_all" -r tests
tests/test_web_columns.py:24: page.click("#columns_all")
tests/test_web_columns.py:50: tag = page.evaluate("document.getElementById('c
olumns_all').tagName")
Time to check out the contents of the tests/test_web_columns.py file.
from tests.web_utils import select_value
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_columns_tab_selected_count(page: Any, server_url: str) -> None:
page.goto(server_url)
page.wait_for_selector("#order_by option", state="attached")
py
page.wait_for_selector("#order_by option", state="attached")
count_text = page.text_content("#columns_tab")
assert count_text is not None and "(4)" in count_text
page.click("text=Columns")
page.wait_for_selector("#column_groups input", state="attached")
page.uncheck("#column_groups input[value='value']")
count_text = page.text_content("#columns_tab")
assert count_text is not None and "(3)" in count_text
I'm figuring out how to ensure all columns are checked after calling run_query
.
I’ll replicate run_query
with custom steps to check all columns after query execution.
I’ll set up a custom function to run the query with all columns selected.
root@df047255e87d:/workspace/scubaduck# cat 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 should double-check the _create_test_database function for the dataset details.
duck/server.py
81:def _create_test_database() -> duckdb.DuckDBPyConnection:
349: con = _create_test_database()
root@df047255e87d:/workspace/scubaduck# sed -n '80,110p' scubaduck/server.py
def _create_test_database() -> duckdb.DuckDBPyConnection:
"""Return a DuckDB connection with a small multi-table dataset."""
con = duckdb.connect()
con.execute(
"CREATE TABLE events (id INTEGER PRIMARY KEY, ts INTEGER, val REAL, name
TEXT, flag BOOLEAN)"
)
con.execute("INSERT INTO events VALUES (1, 1704067200, 1.5, 'alice', 1)")
con.execute("INSERT INTO events VALUES (2, 1704070800, 2.0, 'bob', 0)")
con.execute('CREATE TABLE extra (ts INTEGER, "desc" TEXT, num INTEGER)')
con.execute("INSERT INTO extra VALUES (1704067200, 'x', 1)")
con.execute("INSERT INTO extra VALUES (1704070800, 'y', 2)")
return con
_REL_RE = re.compile(
r"([+-]?\d+(?:\.\d*)?)\s*(hour|hours|day|days|week|weeks|fortnight|fortnight
s)",
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)
I'm double-checking if there are more rows in the database beyond the two known ones.
root@df047255e87d:/workspace/scubaduck# grep -n "INSERT INTO events" -n scubaduc
k/server.py
87: con.execute("INSERT INTO events VALUES (1, 1704067200, 1.5, 'alice', 1)")
88: con.execute("INSERT INTO events VALUES (2, 1704070800, 2.0, 'bob', 0)")
The table should have 2 rows of data, plus a header—totals 3 TRs.
ults tr'" -r tests | head
I'll begin by looking for the relevant files or resources.
tests/test_web_table.py:85: "getComputedStyle(document.querySelector('#re
sults tr:nth-child(2) td')).backgroundColor"
tests/test_web_table.py:88: "getComputedStyle(document.querySelector('#re
sults tr:nth-child(3) td')).backgroundColor"
tests/test_web_table.py:92: page.hover("#results tr:nth-child(2)")
tests/test_web_table.py:94: "getComputedStyle(document.querySelector('#re
sults tr:nth-child(2) td')).backgroundColor"
tests/test_web_table.py:98: page.click("#results tr:nth-child(2)")
tests/test_web_table.py:100: "getComputedStyle(document.querySelector('#r
esults tr:nth-child(2) td')).backgroundColor"
tests/test_web_table.py:119: page.click("#results tr:nth-child(2)")
tests/test_web_table.py:120: page.click("#results tr:nth-child(3)")
tests/test_web_table.py:124: "document.querySelector('#results tr:nth-chi
ld(3)').classList.contains('selected')"
I'll check the test_web_table file around lines 228 or 250 to verify header counts.
root@df047255e87d:/workspace/scubaduck# grep -n "all_inner_texts" -n tests/test_
web_table.py | head
27: return page.locator("#results td:nth-child(4)").all_inner_texts()
176: options = page.locator("#group_by_field .chip-dropdown div").all_inner_t
exts()
233: headers = page.locator("#results th").all_inner_texts()
I'm checking around line 230 to spot the pattern.
root@df047255e87d:/workspace/scubaduck# sed -n '224,240p' tests/test_web_table.p
y
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)
I'll base my test on checking headers with specific order and verifying table dimensions.
root@df047255e87d:/workspace/scubaduck# grep -n "querySelectorAll('#results tr'"
-r tests
I’ll search to see if any tests check for cell count.
root@df047255e87d:/workspace/scubaduck# grep -n "tr').length" -r tests
I'll search for occurrences of 'document.querySelectorAll("#results td").length' in the code.
tests/test_web_table.py:27: return page.locator("#results td:nth-child(4)
").all_inner_texts()
tests/test_web_table.py:80: "getComputedStyle(document.querySelector('#re
sults td')).borderStyle"
tests/test_web_table.py:138: cell = page.text_content("#results td")
tests/test_web_table.py:251: "getComputedStyle(document.querySelector('#r
esults td:nth-child(3)')).whiteSpace"
tests/test_web_table.py:259: "getComputedStyle(document.querySelector('#r
esults td:nth-child(1)')).whiteSpace"
I’ll check for page.locator("#results tr")
throughout the tests to confirm the method.
tests/test_web_table.py:85: "getComputedStyle(document.querySelector('#re
sults tr:nth-child(2) td')).backgroundColor"
tests/test_web_table.py:88: "getComputedStyle(document.querySelector('#re
sults tr:nth-child(3) td')).backgroundColor"
tests/test_web_table.py:92: page.hover("#results tr:nth-child(2)")
tests/test_web_table.py:94: "getComputedStyle(document.querySelector('#re
sults tr:nth-child(2) td')).backgroundColor"
tests/test_web_table.py:98: page.click("#results tr:nth-child(2)")
tests/test_web_table.py:100: "getComputedStyle(document.querySelector('#r
esults tr:nth-child(2) td')).backgroundColor"
tests/test_web_table.py:119: page.click("#results tr:nth-child(2)")
tests/test_web_table.py:120: page.click("#results tr:nth-child(3)")
tests/test_web_table.py:121: count = page.evaluate("document.querySelectorAll
('#results tr.selected').length")
tests/test_web_table.py:124: "document.querySelector('#results tr:nth-chi
ld(3)').classList.contains('selected')"
I’ll implement a row count check by comparing actual rows with header and expected results.
root@df047255e87d:/workspace/scubaduck# grep -n "length")" -r tests | head
bash: syntax error near unexpected token `)'
I'm looking to see where .length)
appears in the code.
root@df047255e87d:/workspace/scubaduck# grep -n "length\)" -r tests | head
grep: Unmatched ) or \)
I'm going to search for "length" to help with the quoting issue.
root@df047255e87d:/workspace/scubaduck# grep -n "length" -r tests | head
tests/test_web_columns.py:15: "document.querySelectorAll('#column_groups
input:checked').length"
tests/test_web_columns.py:21: "document.querySelectorAll('#column_groups
input:checked').length"
tests/test_web_columns.py:26: "document.querySelectorAll('#column_groups
input:checked').length"
tests/test_web_graph.py:48: "document.querySelectorAll('#column_groups in
put:checked').length"
tests/test_web_graph.py:53: "document.querySelectorAll('#column_groups in
put:checked').length"
tests/test_web_table.py:121: count = page.evaluate("document.querySelectorAll
('#results tr.selected').length")
tests/test_web_table.py:290: count = page.evaluate("document.querySelectorAll
('#derived_list .derived').length")
tests/test_web_basics.py:131: count = page.evaluate("document.querySelectorAl
l('#filters .filter').length")
tests/test_web_basics.py:241: first_rows = page.evaluate("window.lastResults.
rows.length")
tests/test_web_basics.py:248: second_rows = page.evaluate("window.lastResults
.rows.length")
Time to check column types in the backend and activate the virtual environment first.
root@df047255e87d:/workspace/scubaduck# source .venv/bin/activate
(scubaduck) root@df047255e87d:/workspace/scubaduck#
I'll begin making changes to server.py now!
(scubaduck) root@df047255e87d:/workspace/scubaduck# sed -n '220,262p' scubaduck/
server.py
f"CAST(floor(epoch({xexpr})/{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_cols = (
group_cols[1:] if params.graph_type == "timeseries" else group_cols
)
select_parts.extend(_quote(c) for c in select_cols)
agg = (params.aggregate or "count").lower()
def agg_expr(col: str) -> str:
expr = _quote(col)
ctype = column_types.get(col, "").upper() if column_types else ""
if "BOOL" in ctype:
expr = f"CAST({_quote(col)} AS BIGINT)"
if agg.startswith("p"):
quant = float(agg[1:]) / 100
return f"quantile({expr}, {quant})"
if agg == "count distinct":
return f"count(DISTINCT {expr})"
if agg == "avg" and column_types is not None:
if "TIMESTAMP" in ctype or "DATE" in ctype or "TIME" in ctype:
return (
"TIMESTAMP 'epoch' + INTERVAL '1 second' * "
f"CAST(avg(epoch({_quote(col)})) AS BIGINT)"
)
return f"{agg}({expr})"
if agg == "count":
select_parts.append("count(*) AS Count")
else:
for col in params.columns:
if col in group_cols:
continue
select_parts.append(f"{agg_expr(col)} AS {_quote(col)}")
if params.show_hits:
select_parts.insert(len(group_cols), "count(*) AS Hits")
else:
select_parts.extend(_quote(c) for c in params.columns)
if has_agg and params.derived_columns:
(scubaduck) root@df047255e87d:/workspace/scubaduck#
Alright, I'll apply the modifications and proceed with the patch!
(scubaduck) root@df047255e87d:/workspace/scubaduck# apply_patch<<'PATCH'
> *** Begin Patch
> *** Update File: scubaduck/server.py
> @@
> - if agg == "count":
> - select_parts.append("count(*) AS Count")
> - else:
> - for col in params.columns:
> - if col in group_cols:
> - continue
> - select_parts.append(f"{agg_expr(col)} AS {_quote(col)}")
> - if params.show_hits:
> - select_parts.insert(len(group_cols), "count(*) AS Hits")
> + if agg == "count":
> + if params.show_hits:
> + select_parts.insert(len(group_cols), "count(*) AS Hits")
> + else:
> + select_parts.append("count(*) AS Count")
> + else:
> + for col in params.columns:
> + if col in group_cols:
> + continue
> + select_parts.append(f"{agg_expr(col)} AS {_quote(col)}")
> + if params.show_hits:
> + select_parts.insert(len(group_cols), "count(*) AS Hits")
> *** End Patch
> PATCH
Success. Updated the following files:
M scubaduck/server.py
(scubaduck) root@df047255e87d:/workspace/scubaduck#
Time to make changes to the view_settings.js file!
(scubaduck) root@df047255e87d:/workspace/scubaduck# sed -n '430,460p' scubaduck/
static/js/view_settings.js
function updateSelectedColumns(type = graphTypeSel.value) {
const base = allColumns.filter(name => {
const cb = document.querySelector(`#column_groups input[value="${name}"]`);
if (!cb || !cb.checked) return false;
if (type === 'table' && isStringColumn(name)) return false;
return true;
});
if (type === 'table' || type === 'timeseries') {
selectedColumns = groupBy.chips.slice();
if (document.getElementById('show_hits').checked) selectedColumns.push('Hits
');
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);
});
}
columnValues[type] = selectedColumns.slice();
updateColumnsTabCount();
}
function isStringColumn(name) {
const t = (columnTypes[name] || '').toUpperCase();
return t.includes('CHAR') || t.includes('STRING') || t.includes('VARCHAR');
}
(scubaduck) root@df047255e87d:/workspace/scubaduck#
I'll update the function to define storeSel
properly and adjust selected columns logic.
(scubaduck) root@df047255e87d:/workspace/scubaduck# apply_patch<<'PATCH'
> *** Begin Patch
> *** Update File: scubaduck/static/js/view_settings.js
> @@
> -function updateSelectedColumns(type = graphTypeSel.value) {
> - const base = allColumns.filter(name => {
> - const cb = document.querySelector(`#column_groups input[value="${name}"]`
);
> - if (!cb || !cb.checked) return false;
> - if (type === 'table' && isStringColumn(name)) return false;
> - return true;
> - });
> - if (type === 'table' || type === 'timeseries') {
> - selectedColumns = groupBy.chips.slice();
> - if (document.getElementById('show_hits').checked) selectedColumns.push('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.slice();
> - derivedColumns.forEach(dc => {
> - if (dc.include) selectedColumns.push(dc.name);
> - });
> - }
> - columnValues[type] = selectedColumns.slice();
> - updateColumnsTabCount();
> +function updateSelectedColumns(type = graphTypeSel.value) {
> + const base = allColumns.filter(name => {
> + const cb = document.querySelector(`#column_groups input[value="${name}"]`
);
> + if (!cb || !cb.checked) return false;
> + if (type === 'table' && isStringColumn(name)) return false;
> + return true;
> + });
> + let storeCols;
> + if (type === 'table' || type === 'timeseries') {
> + storeCols = groupBy.chips.slice();
> + if (document.getElementById('show_hits').checked) storeCols.push('Hits');
> + base.forEach(c => {
> + if (!storeCols.includes(c)) storeCols.push(c);
> + });
> + derivedColumns.forEach(dc => {
> + if (dc.include && !storeCols.includes(dc.name)) storeCols.push(dc.name)
;
> + });
> + selectedColumns = storeCols.slice();
> + if (type === 'table') {
> + const agg = document.getElementById('aggregate').value.toLowerCase();
> + if (agg === 'count') {
> + selectedColumns = groupBy.chips.slice();
> + if (document.getElementById('show_hits').checked) selectedColumns.pus
h('Hits');
> + }
> + }
> + } else {
> + selectedColumns = base.slice();
> + derivedColumns.forEach(dc => {
> + if (dc.include) selectedColumns.push(dc.name);
> + });
> + storeCols = selectedColumns.slice();
> + }
> + columnValues[type] = storeCols.slice();
> + updateColumnsTabCount();
> }
> *** End Patch
> PATCH
Success. Updated the following files:
M scubaduck/static/js/view_settings.js
(scubaduck) root@df047255e87d:/workspace/scubaduck#
The patch worked! Now, I'll add a new test in test_web_table.py
at the end.
(scubaduck) root@df047255e87d:/workspace/scubaduck# wc -n tests/test_web_table.p
y
wc: invalid option -- 'n'
Try 'wc --help' for more information.
(scubaduck) root@df047255e87d:/workspace/scubaduck#
I need to verify the number of lines where changes were made.
(scubaduck) root@df047255e87d:/workspace/scubaduck# wc -l tests/test_web_table.p
y
306 tests/test_web_table.py
(scubaduck) root@df047255e87d:/workspace/scubaduck#
The file has 306 lines, so now I’ll review the last section.
(scubaduck) root@df047255e87d:/workspace/scubaduck# tail -n 20 tests/test_web_ta
ble.py
page.click("text=Add Derived")
assert page.query_selector("#derived_list .derived button.remove")
page.click("#derived_list .derived button.remove")
count = page.evaluate("document.querySelectorAll('#derived_list .derived').l
ength")
assert count == 0
def test_sql_query_display(page: Any, server_url: str) -> None:
data = run_query(
page,
server_url,
start="2024-01-01 00:00:00",
end="2024-01-02 00:00:00",
order_by="timestamp",
limit=10,
)
sql = data["sql"]
displayed = page.text_content("#sql_query")
assert displayed is not None
assert displayed.strip() == sql
(scubaduck) root@df047255e87d:/workspace/scubaduck#
I’ll open lines 272 to 308 to check the context around the test.
assert whitespace == "nowrap"
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
def test_derived_column_remove(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")
assert page.query_selector("#derived_list .derived button.remove")
page.click("#derived_list .derived button.remove")
count = page.evaluate("document.querySelectorAll('#derived_list .derived').l
ength")
assert count == 0
def test_sql_query_display(page: Any, server_url: str) -> None:
data = run_query(
page,
server_url,
start="2024-01-01 00:00:00",
end="2024-01-02 00:00:00",
order_by="timestamp",
limit=10,
)
sql = data["sql"]
displayed = page.text_content("#sql_query")
assert displayed is not None
assert displayed.strip() == sql
(scubaduck) root@df047255e87d:/workspace/scubaduck#
I’m preparing to import select_value
at the top of the test file.
ble.py
from __future__ import annotations
from typing import Any
from tests.web_utils import run_query
def test_table_sorting(page: Any, server_url: str) -> None:
run_query(
page,
(scubaduck) root@df047255e87d:/workspace/scubaduck#
I should check if select_value
exists in tests/web_utils.py
before updating the import.
from typing import Any
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")
(scubaduck) root@df047255e87d:/workspace/scubaduck#
I'll continue scrolling to capture the rest of this section.
(scubaduck) root@df047255e87d:/workspace/scubaduck# sed -n '40,96p' tests/web_ut
ils.py
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)
if page.input_value("#graph_type") != "samples":
page.click("text=Columns")
page.wait_for_selector("#column_groups input", state="attached")
if not page.is_checked("#column_groups input[value='value']"):
page.check("#column_groups input[value='value']")
order_col = order_by or page.input_value("#order_by")
if order_col and not page.is_checked(
f"#column_groups input[value='{order_col}']"
):
if page.query_selector(f"#column_groups input[value='{order_col}']")
:
page.check(f"#column_groups input[value='{order_col}']")
page.click("text=View Settings")
page.evaluate("window.lastResults = undefined")
page.click("text=Dive")
page.wait_for_function("window.lastResults !== undefined")
return page.evaluate("window.lastResults")
(scubaduck) root@df047255e87d:/workspace/scubaduck#
I’ll import select_value and then work on implementing that new test.
(scubaduck) root@df047255e87d:/workspace/scubaduck# apply_patch<<'PATCH'
> *** Begin Patch
> *** Update File: tests/test_web_table.py
> @@
> -from tests.web_utils import run_query
> +from tests.web_utils import run_query, select_value
> *** End Patch
> PATCH
Success. Updated the following files:
M tests/test_web_table.py
(scubaduck) root@df047255e87d:/workspace/scubaduck#
I'll append the new test case at the bottom of the file.
> *** Begin Patch
> *** Update File: tests/test_web_table.py
> @@
> def test_sql_query_display(page: Any, server_url: str) -> None:
> @@
> assert displayed.strip() == sql
> +
> +
> +def test_table_count_headings(page: Any, server_url: str) -> None:
> + page.goto(f"{server_url}?graph_type=table")
> + page.wait_for_selector("#order_by option", state="attached")
> + page.click("text=Columns")
> + page.wait_for_selector("#column_groups input", state="attached")
> + page.click("#columns_all")
> + page.click("text=View Settings")
> + page.evaluate("g => { groupBy.chips = g; groupBy.renderChips(); }", ["id"
])
> + select_value(page, "#aggregate", "Count")
> + page.evaluate("window.lastResults = undefined")
> + page.click("text=Dive")
> + page.wait_for_function("window.lastResults !== undefined")
> + headers = page.locator("#results th").all_inner_texts()
> + assert headers == ["id", "Hits"]
> + row_count = page.evaluate("document.querySelectorAll('#results tr').lengt
h")
> + assert row_count == 3
> + col_count = page.evaluate(
> + "document.querySelectorAll('#results tr:nth-child(2) td').length"
> + )
> + assert col_count == 2
> *** End Patch
> PATCH
Success. Updated the following files:
M tests/test_web_table.py
(scubaduck) root@df047255e87d:/workspace/scubaduck#
I’ll run ruff format, ruff check, and pyright before moving forward.
(scubaduck) root@df047255e87d:/workspace/scubaduck# ruff format scubaduck/static
/js/view_settings.js tests/test_web_table.py scubaduck/server.py >/tmp/ruff_form
at.log && tail -n 20 /tmp/ruff_format.log
error: Failed to parse scubaduck/static/js/view_settings.js:1:1: Expected a stat
ement
(scubaduck) root@df047255e87d:/workspace/scubaduck#
Since ruff only works with Python files, I'll run it on test_web_table.py and server.py.
(scubaduck) root@df047255e87d:/workspace/scubaduck# ruff format tests/test_web_t
able.py scubaduck/server.py >/tmp/ruff_format.log && tail -n 20 /tmp/ruff_format
.log
2 files left unchanged
(scubaduck) root@df047255e87d:/workspace/scubaduck# ruff check >/tmp/ruff_check.
log && tail -n 20 /tmp/ruff_check.log
All checks passed!
(scubaduck) root@df047255e87d:/workspace/scubaduck# pyright >/tmp/pyright.log &&
tail -n 20 /tmp/pyright.log
0 errors, 0 warnings, 0 informations
(scubaduck) root@df047255e87d:/workspace/scubaduck#
Pyright passed! Now it's time to run the tests and make sure everything works.
(scubaduck) root@df047255e87d:/workspace/scubaduck# pytest -q >/tmp/pytest.log &
& tail -n 20 /tmp/pytest.log
(scubaduck) root@df047255e87d:/workspace/scubaduck#
The tests completed fast, but I'll need to look at the results now.
bringing up nodes...
bringing up nodes...
...................................................F......F..F........F. [ 66%]
..................................... [100%]
=================================== FAILURES ===================================
_____________________ test_table_count_headings[chromium] ______________________
[gw1] linux -- Python 3.12.3 /workspace/scubaduck/.venv/bin/python
Traceback (most recent call last):
File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/_pytest/runner.p
y", line 341, in from_call
result: TResult | None = func()
^^^^^^
File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/_pytest/runner.p
y", line 242, in <lambda>
lambda: runtest_hook(item=item, **kwds), when=when, reraise=reraise
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/pluggy/_hooks.py
", line 512, in __call__
return self._hookexec(self.name, self._hookimpls.copy(), kwargs, firstresult
)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
^
File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/pluggy/_manager.
py", line 120, in _hookexec
return self._inner_hookexec(hook_name, methods, kwargs, firstresult)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/pluggy/_callers.
py", line 167, in _multicall
raise exception
File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/pluggy/_callers.
py", line 139, in _multicall
teardown.throw(exception)
File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/_pytest/threadex
ception.py", line 92, in pytest_runtest_call
yield from thread_exception_runtest_hook()
File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/_pytest/threadex
ception.py", line 68, in thread_exception_runtest_hook
yield
File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/pluggy/_callers.
py", line 139, in _multicall
teardown.throw(exception)
File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/_pytest/unraisab
leexception.py", line 95, in pytest_runtest_call
yield from unraisable_exception_runtest_hook()
File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/_pytest/unraisab
leexception.py", line 70, in unraisable_exception_runtest_hook
yield
File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/pluggy/_callers.
py", line 139, in _multicall
teardown.throw(exception)
File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/_pytest/logging.
py", line 846, in pytest_runtest_call
yield from self._runtest_for(item, "call")
File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/_pytest/logging.
py", line 829, in _runtest_for
yield
File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/pluggy/_callers.
py", line 139, in _multicall
teardown.throw(exception)
File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/_pytest/capture.
py", line 898, in pytest_runtest_call
return (yield)
^^^^^
File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/pluggy/_callers.
py", line 139, in _multicall
teardown.throw(exception)
File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/_pytest/skipping
.py", line 257, in pytest_runtest_call
return (yield)
^^^^^
File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/pluggy/_callers.
py", line 121, in _multicall
res = hook_impl.function(*args)
^^^^^^^^^^^^^^^^^^^^^^^^^
File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/_pytest/runner.p
y", line 174, in pytest_runtest_call
item.runtest()
File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/_pytest/python.p
y", line 1627, in runtest
self.ihook.pytest_pyfunc_call(pyfuncitem=self)
File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/pluggy/_hooks.py
", line 512, in __call__
return self._hookexec(self.name, self._hookimpls.copy(), kwargs, firstresult
)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
^
File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/pluggy/_manager.
py", line 120, in _hookexec
return self._inner_hookexec(hook_name, methods, kwargs, firstresult)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/pluggy/_callers.
py", line 167, in _multicall
raise exception
File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/pluggy/_callers.
py", line 121, in _multicall
res = hook_impl.function(*args)
^^^^^^^^^^^^^^^^^^^^^^^^^
File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/_pytest/python.p
y", line 159, in pytest_pyfunc_call
result = testfunction(**testargs)
^^^^^^^^^^^^^^^^^^^^^^^^
File "/workspace/scubaduck/tests/test_web_table.py", line 322, in test_table_c
ount_headings
assert headers == ["id", "Hits"]
AssertionError: assert [] == ['id', 'Hits']
Right contains 2 more items, first extra item: 'id'
Use -v to get more diff
----------------------------- Captured stdout call -----------------------------
Query failed:
SELECT count(*) AS Hits
FROM "events"
WHERE "timestamp" >= '2024-01-01 00:00:00' AND "timestamp" <= '2024-01-02 03:00:
00'
ORDER BY "timestamp" ASC
LIMIT 100
Traceback (most recent call last):
File "/workspace/scubaduck/scubaduck/server.py", line 653, in query
rows = con.execute(sql).fetchall()
^^^^^^^^^^^^^^^^
duckdb.duckdb.BinderException: Binder Error: column "timestamp" must appear in t
he GROUP BY clause or must be part of an aggregate function.
Either add it to the GROUP BY list, or use "ANY_VALUE(timestamp)" if the exact v
...
result: TResult | None = func()
^^^^^^
File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/_pytest/runner.p
y", line 242, in <lambda>
lambda: runtest_hook(item=item, **kwds), when=when, reraise=reraise
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/pluggy/_hooks.py
", line 512, in __call__
return self._hookexec(self.name, self._hookimpls.copy(), kwargs, firstresult
)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
^
File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/pluggy/_manager.
py", line 120, in _hookexec
return self._inner_hookexec(hook_name, methods, kwargs, firstresult)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/pluggy/_callers.
py", line 167, in _multicall
raise exception
File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/pluggy/_callers.
py", line 139, in _multicall
teardown.throw(exception)
File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/_pytest/threadex
ception.py", line 92, in pytest_runtest_call
yield from thread_exception_runtest_hook()
File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/_pytest/threadex
ception.py", line 68, in thread_exception_runtest_hook
yield
File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/pluggy/_callers.
py", line 139, in _multicall
teardown.throw(exception)
File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/_pytest/unraisab
leexception.py", line 95, in pytest_runtest_call
yield from unraisable_exception_runtest_hook()
File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/_pytest/unraisab
leexception.py", line 70, in unraisable_exception_runtest_hook
yield
File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/pluggy/_callers.
py", line 139, in _multicall
teardown.throw(exception)
File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/_pytest/logging.
py", line 846, in pytest_runtest_call
yield from self._runtest_for(item, "call")
File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/_pytest/logging.
py", line 829, in _runtest_for
yield
File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/pluggy/_callers.
py", line 139, in _multicall
teardown.throw(exception)
File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/_pytest/capture.
py", line 898, in pytest_runtest_call
return (yield)
^^^^^
File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/pluggy/_callers.
py", line 139, in _multicall
teardown.throw(exception)
File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/_pytest/skipping
.py", line 257, in pytest_runtest_call
return (yield)
^^^^^
File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/pluggy/_callers.
py", line 121, in _multicall
res = hook_impl.function(*args)
^^^^^^^^^^^^^^^^^^^^^^^^^
File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/_pytest/runner.p
y", line 174, in pytest_runtest_call
item.runtest()
File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/_pytest/python.p
y", line 1627, in runtest
self.ihook.pytest_pyfunc_call(pyfuncitem=self)
File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/pluggy/_hooks.py
", line 512, in __call__
return self._hookexec(self.name, self._hookimpls.copy(), kwargs, firstresult
)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
^
File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/pluggy/_manager.
py", line 120, in _hookexec
return self._inner_hookexec(hook_name, methods, kwargs, firstresult)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/pluggy/_callers.
py", line 167, in _multicall
raise exception
File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/pluggy/_callers.
py", line 121, in _multicall
res = hook_impl.function(*args)
^^^^^^^^^^^^^^^^^^^^^^^^^
File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/_pytest/python.p
y", line 159, in pytest_pyfunc_call
result = testfunction(**testargs)
^^^^^^^^^^^^^^^^^^^^^^^^
File "/workspace/scubaduck/tests/test_web_timeseries.py", line 258, in test_ti
meseries_resize
assert after["last"] > before["last"]
AssertionError: assert 570 > 570
------------------------------ Captured log call -------------------------------
INFO werkzeug:_internal.py:97 127.0.0.1 - - [23/May/2025 01:48:40] "GET / HT
TP/1.1" 200 -
INFO werkzeug:_internal.py:97 127.0.0.1 - - [23/May/2025 01:48:40] "GET /js/
chip_input.js HTTP/1.1" 200 -
INFO werkzeug:_internal.py:97 127.0.0.1 - - [23/May/2025 01:48:40] "GET /js/
timeseries_chart.js HTTP/1.1" 200 -
INFO werkzeug:_internal.py:97 127.0.0.1 - - [23/May/2025 01:48:40] "GET /js/
time_column.js HTTP/1.1" 200 -
INFO werkzeug:_internal.py:97 127.0.0.1 - - [23/May/2025 01:48:40] "GET /js/
sidebar.js HTTP/1.1" 200 -
INFO werkzeug:_internal.py:97 127.0.0.1 - - [23/May/2025 01:48:40] "GET /js/
table_view.js HTTP/1.1" 200 -
INFO werkzeug:_internal.py:97 127.0.0.1 - - [23/May/2025 01:48:40] "GET /js/
view_settings.js HTTP/1.1" 200 -
INFO werkzeug:_internal.py:97 127.0.0.1 - - [23/May/2025 01:48:40] "GET /api
/tables HTTP/1.1" 200 -
INFO werkzeug:_internal.py:97 127.0.0.1 - - [23/May/2025 01:48:41] "GET /api
/columns?table=events HTTP/1.1" 200 -
INFO werkzeug:_internal.py:97 127.0.0.1 - - [23/May/2025 01:48:41] "POST /ap
i/query HTTP/1.1" 200 -
INFO werkzeug:_internal.py:97 127.0.0.1 - - [23/May/2025 01:48:41] "POST /ap
i/query HTTP/1.1" 200 -
=========================== short test summary info ============================
FAILED tests/test_web_table.py::test_table_count_headings[chromium] - Asserti...
FAILED tests/test_web_timeseries.py::test_timeseries_auto_timezone[chromium]
FAILED tests/test_web_timeseries.py::test_timeseries_single_bucket[chromium]
FAILED tests/test_web_timeseries.py::test_timeseries_resize[chromium] - asser...
4 failed, 105 passed in 14.52s
(scubaduck) root@df047255e87d:/workspace/scubaduck#
Test failures are due to query ordering issues. After fixing those, I'll check other errors.
(scubaduck) root@df047255e87d:/workspace/scubaduck# grep -n "timeseries_resize"
-n /tmp/pytest.log
275:_______________________ test_timeseries_resize[chromium] ___________________
____
341: File "/workspace/scubaduck/tests/test_web_timeseries.py", line 258, in tes
t_timeseries_resize
360:FAILED tests/test_web_timeseries.py::test_timeseries_resize[chromium] - asse
r...
(scubaduck) root@df047255e87d:/workspace/scubaduck#
Three other timeseries tests failed too; let's open them and investigate.
from typing import Any
from tests.web_utils import select_value
def test_timeseries_default_query(page: Any, server_url: str) -> None:
page.goto(server_url)
page.wait_for_selector("#graph_type", state="attached")
select_value(page, "#graph_type", "timeseries")
page.click("text=Columns")
page.check("#column_groups input[value='value']")
page.click("text=View Settings")
page.evaluate("window.lastResults = undefined")
page.click("text=Dive")
page.wait_for_function("window.lastResults !== undefined")
data = page.evaluate("window.lastResults")
assert "error" not in data
assert page.is_visible("#chart")
page.click("text=Columns")
assert not page.is_checked("#column_groups input[value='timestamp']")
def test_timeseries_single_bucket(page: Any, server_url: str) -> None:
page.goto(server_url)
page.wait_for_selector("#graph_type", state="attached")
page.fill("#start", "2024-01-01 00:00:00")
page.fill("#end", "2024-01-01 00:00:00")
select_value(page, "#graph_type", "timeseries")
page.click("text=Columns")
page.check("#column_groups input[value='value']")
page.click("text=View Settings")
page.evaluate("window.lastResults = undefined")
page.click("text=Dive")
page.wait_for_function("window.lastResults !== undefined")
path = page.get_attribute("#chart path", "d")
assert path is not None and "NaN" not in path
def test_timeseries_fill_options(page: Any, server_url: str) -> None:
page.goto(server_url)
page.wait_for_selector("#graph_type", state="attached")
page.fill("#start", "2024-01-01 00:00:00")
page.fill("#end", "2024-01-02 03:00:00")
select_value(page, "#graph_type", "timeseries")
page.click("text=Columns")
page.check("#column_groups input[value='value']")
page.click("text=View Settings")
select_value(page, "#granularity", "1 hour")
select_value(page, "#fill", "0")
page.evaluate("window.lastResults = undefined")
page.click("text=Dive")
page.wait_for_function("window.lastResults !== undefined")
path_zero = page.get_attribute("#chart path", "d")
assert path_zero is not None and path_zero.count("L") > 20
select_value(page, "#fill", "connect")
page.evaluate("window.lastResults = undefined")
page.click("text=Dive")
page.wait_for_function("window.lastResults !== undefined")
path_conn = page.get_attribute("#chart path", "d")
assert path_conn is not None and path_conn.count("M") == 1
select_value(page, "#fill", "blank")
page.evaluate("window.lastResults = undefined")
page.click("text=Dive")
page.wait_for_function("window.lastResults !== undefined")
path_blank = page.get_attribute("#chart path", "d")
assert path_blank is not None and path_blank.count("M") > 1
def test_timeseries_hover_highlight(page: Any, server_url: str) -> None:
page.goto(server_url)
page.wait_for_selector("#graph_type", state="attached")
select_value(page, "#graph_type", "timeseries")
page.click("text=Columns")
page.check("#column_groups input[value='value']")
page.click("text=View Settings")
(scubaduck) root@df047255e87d:/workspace/scubaduck#
Let's continue scrolling to review the failed tests around line 240.
(scubaduck) root@df047255e87d:/workspace/scubaduck# grep -n "timeseries_resize"
-n tests/test_web_timeseries.py
224:def test_timeseries_resize(page: Any, server_url: str) -> None:
(scubaduck) root@df047255e87d:/workspace/scubaduck#
I'll take a look at lines 220-260 now.
count = page.eval_on_selector_all("#chart path", "els => els.length")
assert count == 2
def test_timeseries_resize(page: Any, server_url: str) -> None:
page.goto(server_url)
page.wait_for_selector("#graph_type", state="attached")
select_value(page, "#graph_type", "timeseries")
page.click("text=Columns")
page.check("#column_groups input[value='value']")
page.click("text=View Settings")
page.evaluate("window.lastResults = undefined")
page.click("text=Dive")
page.wait_for_function("window.lastResults !== undefined")
page.wait_for_selector("#chart path", state="attached")
def chart_info() -> dict[str, float]:
return page.evaluate(
"() => {const p=document.querySelector('#chart path'); const nums=p.
getAttribute('d').match(/[-0-9.]+/g).map(parseFloat); return {width: parseFloat(
document.getElementById('chart').getAttribute('width')), last: nums[nums.length-
2]};}"
)
before = chart_info()
legend_width = page.evaluate(
"parseFloat(getComputedStyle(document.getElementById('legend')).width)"
)
assert page.evaluate(
"() => document.getElementById('legend').getBoundingClientRect().right <
= document.getElementById('chart').getBoundingClientRect().left"
)
page.evaluate("document.getElementById('sidebar').style.width='200px'")
page.wait_for_function(
"width => document.getElementById('chart').getAttribute('width') != widt
h",
arg=before["width"],
)
after = chart_info()
legend_width_after = page.evaluate(
"parseFloat(getComputedStyle(document.getElementById('legend')).width)"
)
assert after["width"] > before["width"]
assert after["last"] > before["last"]
assert legend_width_after == legend_width
def test_timeseries_no_overflow(page: Any, server_url: str) -> None:
page.goto(server_url)
page.wait_for_selector("#graph_type", state="attached")
select_value(page, "#graph_type", "timeseries")
page.click("text=Columns")
page.check("#column_groups input[value='value']")
page.click("text=View Settings")
page.evaluate("window.lastResults = undefined")
page.click("text=Dive")
(scubaduck) root@df047255e87d:/workspace/scubaduck#
I'll check for "Count" in timeseries tests to understand what's affecting the failures.
(scubaduck) root@df047255e87d:/workspace/scubaduck# grep -n "Count" tests/test_w
eb_timeseries.py
(scubaduck) root@df047255e87d:/workspace/scubaduck#
The failure likely relates to changed server query affecting timeseries tests; check default values.
tic/js/timeseries_chart.js
let resizeObserver = null;
let currentChart = null;
function showTimeSeries(data) {
function parseTs(s) {
if (s.match(/GMT/) || s.endsWith('Z') || /\+\d{2}:?\d{2}$/.test(s)) {
return new Date(s).getTime();
}
return new Date(s + 'Z').getTime();
}
const view = document.getElementById('view');
if (data.rows.length === 0) {
view.innerHTML = '<p id="empty-message">Empty data provided to table</p>';
return;
}
const height = 600;
view.innerHTML =
'<div id="ts-container"><div id="legend"></div><div id="chart-wrapper"><svg
id="chart" height="' +
height +
'"></svg></div></div>';
const svg = document.getElementById('chart');
const legend = document.getElementById('legend');
const crosshairLine = document.createElementNS('http://www.w3.org/2000/svg', '
line');
crosshairLine.id = 'crosshair_line';
crosshairLine.setAttribute('stroke', '#555');
crosshairLine.style.display = 'none';
const crosshairDots = document.createElementNS('http://www.w3.org/2000/svg', '
g');
crosshairDots.id = 'crosshair_dots';
crosshairDots.style.display = 'none';
const groups = groupBy.chips || [];
const hasHits = document.getElementById('show_hits').checked ? 1 : 0;
const fill = document.getElementById('fill').value;
const bucketMs = (data.bucket_size || 3600) * 1000;
const start = data.start ? parseTs(data.start) : null;
const end = data.end ? parseTs(data.end) : null;
const startIdx = 1 + groups.length + hasHits;
const valueCols = selectedColumns.slice(groups.length + hasHits);
const series = {};
data.rows.forEach(r => {
const ts = parseTs(r[0]);
const groupKey = groups.map((_, i) => r[1 + i]).join(':') || 'all';
valueCols.forEach((name, i) => {
const val = Number(r[startIdx + i]);
const key = groupKey === 'all' ? name : groupKey + ':' + name;
if (!series[key]) series[key] = {};
series[key][ts] = val;
});
});
const buckets = [];
let minX = start !== null ? start : Infinity;
let maxX = end !== null ? end : -Infinity;
if (start !== null && end !== null) {
for (let t = start; t <= end; t += bucketMs) {
buckets.push(t);
}
} else {
Object.keys(series).forEach(k => {
const s = series[k];
Object.keys(s).forEach(t => {
const n = Number(t);
if (n < minX) minX = n;
if (n > maxX) maxX = n;
});
});
for (let t = minX; t <= maxX; t += bucketMs) {
buckets.push(t);
}
}
let minY = Infinity,
maxY = -Infinity;
Object.keys(series).forEach(key => {
const vals = series[key];
buckets.forEach(b => {
const v = vals[b];
const val = v === undefined && fill === '0' ? 0 : v;
if (val === undefined) return;
if (val < minY) minY = val;
(scubaduck) root@df047255e87d:/workspace/scubaduck#
I'm reviewing the logic around selecting columns for timeseries charts, especially with aggregate counts. Adjustments are needed for handling aggregated columns when show_hits
is true, as the server omits certain columns. I'll tweak updateSelectedColumns to ensure correct alignment.
I see selectedColumns
for timeseries should include bucket, group_by, Hits, and value columns. I'll adjust updateSelectedColumns
to correctly compute this, ensuring time and bucket columns are handled properly.
(scubaduck) root@df047255e87d:/workspace/scubaduck# grep -n "x_axis" -n scubaduc
k/static/js/view_settings.js
150: document.getElementById('x_axis_field').style.display = showTS ? 'flex' :
'none';
198: const xAxisSelect = document.getElementById('x_axis');
481: const xsel = document.getElementById('x_axis').value;
727: const xval = document.getElementById('x_axis').value;
728: if (xval) payload.x_axis = xval;
757: if (params.x_axis) sp.set('x_axis', params.x_axis);
785: if (params.x_axis) {
786: document.getElementById('x_axis').value = params.x_axis;
788: document.getElementById('x_axis').value = '';
855: if (sp.has('x_axis')) params.x_axis = sp.get('x_axis');
(scubaduck) root@df047255e87d:/workspace/scubaduck#
I'll go back and check lines around 198 for context.
(scubaduck) root@df047255e87d:/workspace/scubaduck# sed -n '180,210p' scubaduck/
static/js/view_settings.js
? 'flex'
: 'none';
document.getElementById('end').closest('.field').style.display = show
? 'flex'
: 'none';
}
orderDirBtn.addEventListener('click', () => {
orderDir = orderDir === 'ASC' ? 'DESC' : 'ASC';
updateOrderDirButton();
});
updateOrderDirButton();
graphTypeSel.addEventListener('change', updateDisplayTypeUI);
document.getElementById('time_column').addEventListener('change', updateTimeFiel
dVisibility);
updateTimeFieldVisibility();
function loadColumns(table) {
return fetch('/api/columns?table=' + encodeURIComponent(table)).then(r => r.js
on()).then(cols => {
const orderSelect = document.getElementById('order_by');
const xAxisSelect = document.getElementById('x_axis');
const groupsEl = document.getElementById('column_groups');
const timeColumnSelect = document.getElementById('time_column');
orderSelect.innerHTML = '';
xAxisSelect.innerHTML = '';
const defOpt = document.createElement('option');
defOpt.value = '';
defOpt.textContent = '(default)';
xAxisSelect.appendChild(defOpt);
timeColumnSelect.innerHTML = '';
const noneOpt = document.createElement('option');
noneOpt.value = '';
noneOpt.textContent = '(none)';
(scubaduck) root@df047255e87d:/workspace/scubaduck#
I'm looking for where 'bucket' is inserted into selectedColumns. Let's search for it.
(scubaduck) root@df047255e87d:/workspace/scubaduck# grep -n "bucket" -n scubaduc
k/static/js
grep: scubaduck/static/js: Is a directory
(scubaduck) root@df047255e87d:/workspace/scubaduck#
Hmm, I wonder if I ran 'grep bucket -r' outside of the root directory.
(scubaduck) root@df047255e87d:/workspace/scubaduck# grep -n "bucket" -r scubaduc
k/static/js
scubaduck/static/js/timeseries_chart.js:34: const bucketMs = (data.bucket_size
|| 3600) * 1000;
scubaduck/static/js/timeseries_chart.js:51: const buckets = [];
scubaduck/static/js/timeseries_chart.js:55: for (let t = start; t <= end; t +
= bucketMs) {
scubaduck/static/js/timeseries_chart.js:56: buckets.push(t);
scubaduck/static/js/timeseries_chart.js:67: for (let t = minX; t <= maxX; t +
= bucketMs) {
scubaduck/static/js/timeseries_chart.js:68: buckets.push(t);
scubaduck/static/js/timeseries_chart.js:76: buckets.forEach(b => {
scubaduck/static/js/timeseries_chart.js:103: buckets,
scubaduck/static/js/timeseries_chart.js:114: bucketPixels: [],
scubaduck/static/js/timeseries_chart.js:320: buckets.forEach(b => {
scubaduck/static/js/timeseries_chart.js:389: currentChart.bucketPixels = buck
ets.map(xScale);
scubaduck/static/js/timeseries_chart.js:480: const pixels = currentChart.buck
etPixels;
scubaduck/static/js/timeseries_chart.js:491: const bucket = currentChart.buck
ets[idx];
scubaduck/static/js/timeseries_chart.js:503: let v = vals[bucket];
(scubaduck) root@df047255e87d:/workspace/scubaduck#
In timeseries, when aggregation is 'Count' and 'show_hits' is true, the 'Hits' column appears, but the bucket column is handled separately. The new timeseries logic must ensure selectedColumns replicates the old behavior. If ‘Hits’ is required, it’s added to selectedColumns after group columns, using the correct startIdx for aggregation.
The script may fail if no value columns exist—let’s check how timeseries_chart handles this.
(scubaduck) root@df047255e87d:/workspace/scubaduck# grep -n "valueCols" -n scuba
duck/static/js/timeseries_chart.js
38: const valueCols = selectedColumns.slice(groups.length + hasHits);
43: valueCols.forEach((name, i) => {
(scubaduck) root@df047255e87d:/workspace/scubaduck# sed -n '38,65p' scubaduck/st
atic/js/timeseries_chart.js
const valueCols = selectedColumns.slice(groups.length + hasHits);
const series = {};
data.rows.forEach(r => {
const ts = parseTs(r[0]);
const groupKey = groups.map((_, i) => r[1 + i]).join(':') || 'all';
valueCols.forEach((name, i) => {
const val = Number(r[startIdx + i]);
const key = groupKey === 'all' ? name : groupKey + ':' + name;
if (!series[key]) series[key] = {};
series[key][ts] = val;
});
});
const buckets = [];
let minX = start !== null ? start : Infinity;
let maxX = end !== null ? end : -Infinity;
if (start !== null && end !== null) {
for (let t = start; t <= end; t += bucketMs) {
buckets.push(t);
}
} else {
Object.keys(series).forEach(k => {
const s = series[k];
Object.keys(s).forEach(t => {
const n = Number(t);
if (n < minX) minX = n;
if (n > maxX) maxX = n;
});
(scubaduck) root@df047255e87d:/workspace/scubaduck# sed -n '65,135p' scubaduck/s
tatic/js/timeseries_chart.js
});
});
for (let t = minX; t <= maxX; t += bucketMs) {
buckets.push(t);
}
}
let minY = Infinity,
maxY = -Infinity;
Object.keys(series).forEach(key => {
const vals = series[key];
buckets.forEach(b => {
const v = vals[b];
const val = v === undefined && fill === '0' ? 0 : v;
if (val === undefined) return;
if (val < minY) minY = val;
if (val > maxY) maxY = val;
});
});
if (fill === '0') {
if (minY > 0) minY = 0;
if (maxY < 0) maxY = 0;
}
const colors = [
'#1f77b4',
'#ff7f0e',
'#2ca02c',
'#d62728',
'#9467bd',
'#8c564b',
'#e377c2'
];
currentChart = {
svg,
legend,
series,
buckets,
minX,
maxX,
minY,
maxY,
fill,
colors,
height,
crosshairLine,
crosshairDots,
seriesEls: {},
bucketPixels: [],
xScale: null,
yScale: null,
selected: null,
frozen: false
};
const intervals = [
{unit: 'second', step: 1, ms: 1000},
{unit: 'second', step: 2, ms: 2000},
{unit: 'second', step: 5, ms: 5000},
{unit: 'second', step: 10, ms: 10000},
{unit: 'second', step: 15, ms: 15000},
{unit: 'second', step: 30, ms: 30000},
{unit: 'minute', step: 1, ms: 60000},
{unit: 'minute', step: 2, ms: 120000},
{unit: 'minute', step: 5, ms: 300000},
{unit: 'minute', step: 10, ms: 600000},
{unit: 'minute', step: 15, ms: 900000},
{unit: 'minute', step: 30, ms: 1800000},
{unit: 'hour', step: 1, ms: 3600000},
{unit: 'hour', step: 2, ms: 7200000},
(scubaduck) root@df047255e87d:/workspace/scubaduck#
It seems the chart breaks if no aggregated values are selected or if "Hits" is excluded. I realized we can't remove the Count column when showing Hits in timeseries, as the chart expects at least one data series.
I'll ensure the Count column stays for timeseries even with "show_hits" set to True.
To avoid errors, I should restore the original behavior where the server always returns Count for timeseries, adding Hits only if show_hits is True.
The patch ensures timeseries keeps Count, while table view omits Count if show_hits is True.
(scubaduck) root@df047255e87d:/workspace/scubaduck# sed -n '236,260p' scubaduck/
server.py
expr = f"CAST({_quote(col)} AS BIGINT)"
if agg.startswith("p"):
quant = float(agg[1:]) / 100
return f"quantile({expr}, {quant})"
if agg == "count distinct":
return f"count(DISTINCT {expr})"
if agg == "avg" and column_types is not None:
if "TIMESTAMP" in ctype or "DATE" in ctype or "TIME" in ctype:
return (
"TIMESTAMP 'epoch' + INTERVAL '1 second' * "
f"CAST(avg(epoch({_quote(col)})) AS BIGINT)"
)
return f"{agg}({expr})"
if agg == "count":
if params.show_hits:
select_parts.insert(len(group_cols), "count(*) AS Hits")
else:
select_parts.append("count(*) AS Count")
else:
for col in params.columns:
if col in group_cols:
continue
select_parts.append(f"{agg_expr(col)} AS {_quote(col)}")
if params.show_hits:
(scubaduck) root@df047255e87d:/workspace/scubaduck#
I added the patch before, now it's time to modify for table-specific logic.
(scubaduck) root@df047255e87d:/workspace/scubaduck# apply_patch<<'PATCH'
> *** Begin Patch
> *** Update File: scubaduck/server.py
> @@
> - if agg == "count":
> - if params.show_hits:
> - select_parts.insert(len(group_cols), "count(*) AS Hits")
> - else:
> - select_parts.append("count(*) AS Count")
> + if agg == "count":
> + if params.graph_type == "table" and params.show_hits:
> + select_parts.insert(len(group_cols), "count(*) AS Hits")
> + else:
> + select_parts.append("count(*) AS Count")
> + if params.show_hits:
> + select_parts.insert(len(group_cols), "count(*) AS Hits")
> *** End Patch
> PATCH
Success. Updated the following files:
M scubaduck/server.py
(scubaduck) root@df047255e87d:/workspace/scubaduck#
I updated the server patch. No need to change updateSelectedColumns for timeseries—columns can still be included. Potential misalignment might occur if base columns are selected but not returned. Checking how timeseries_chart handles this.
I adjusted the server to output Count or Hits based on show_hits. Timeseries is safe. Fixing failed tests, especially from server patch modification or earlier test interference.
(scubaduck) root@df047255e87d:/workspace/scubaduck# apply_patch<<'PATCH'
> *** Begin Patch
> *** Update File: tests/test_web_table.py
> @@
> page.click("#columns_all")
> page.click("text=View Settings")
> page.evaluate("g => { groupBy.chips = g; groupBy.renderChips(); }", ["id"
])
> + select_value(page, "#order_by", "id")
> select_value(page, "#aggregate", "Count")
> *** End Patch
> PATCH
Success. Updated the following files:
M tests/test_web_table.py
(scubaduck) root@df047255e87d:/workspace/scubaduck#
I'll format the test file and server using ruff now.
(scubaduck) root@df047255e87d:/workspace/scubaduck# ruff format tests/test_web_t
able.py scubaduck/server.py >/tmp/ruff_format.log && tail -n 20 /tmp/ruff_format
.log
2 files left unchanged
(scubaduck) root@df047255e87d:/workspace/scubaduck#
I'll run the Ruff check again to ensure everything is in order.
(scubaduck) root@df047255e87d:/workspace/scubaduck# ruff check >/tmp/ruff_check.
log && tail -n 20 /tmp/ruff_check.log
All checks passed!
(scubaduck) root@df047255e87d:/workspace/scubaduck#
I'm going to run Pyright to check for type errors in the code.
(scubaduck) root@df047255e87d:/workspace/scubaduck# pyright >/tmp/pyright.log &&
tail -n 20 /tmp/pyright.log
0 errors, 0 warnings, 0 informations
(scubaduck) root@df047255e87d:/workspace/scubaduck#
I'll try running pytest again to check if the issue persists.
(scubaduck) root@df047255e87d:/workspace/scubaduck# pytest -q >/tmp/pytest.log &
& tail -n 20 /tmp/pytest.log
bringing up nodes...
bringing up nodes...
...................................................F.................... [ 66%]
..................................... [100%]
=================================== FAILURES ===================================
_____________________ test_table_count_headings[chromium] ______________________
[gw0] linux -- Python 3.12.3 /workspace/scubaduck/.venv/bin/python
Traceback (most recent call last):
File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/_pytest/runner.p
y", line 341, in from_call
result: TResult | None = func()
^^^^^^
File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/_pytest/runner.p
y", line 242, in <lambda>
lambda: runtest_hook(item=item, **kwds), when=when, reraise=reraise
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/pluggy/_hooks.py
", line 512, in __call__
return self._hookexec(self.name, self._hookimpls.copy(), kwargs, firstresult
)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
^
File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/pluggy/_manager.
py", line 120, in _hookexec
return self._inner_hookexec(hook_name, methods, kwargs, firstresult)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/pluggy/_callers.
py", line 167, in _multicall
raise exception
File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/pluggy/_callers.
py", line 139, in _multicall
teardown.throw(exception)
File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/_pytest/threadex
ception.py", line 92, in pytest_runtest_call
yield from thread_exception_runtest_hook()
File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/_pytest/threadex
ception.py", line 68, in thread_exception_runtest_hook
yield
File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/pluggy/_callers.
py", line 139, in _multicall
teardown.throw(exception)
File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/_pytest/unraisab
leexception.py", line 95, in pytest_runtest_call
yield from unraisable_exception_runtest_hook()
File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/_pytest/unraisab
leexception.py", line 70, in unraisable_exception_runtest_hook
yield
File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/pluggy/_callers.
py", line 139, in _multicall
teardown.throw(exception)
File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/_pytest/logging.
py", line 846, in pytest_runtest_call
yield from self._runtest_for(item, "call")
File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/_pytest/logging.
py", line 829, in _runtest_for
yield
File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/pluggy/_callers.
py", line 139, in _multicall
teardown.throw(exception)
File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/_pytest/capture.
py", line 898, in pytest_runtest_call
return (yield)
^^^^^
File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/pluggy/_callers.
py", line 139, in _multicall
teardown.throw(exception)
File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/_pytest/skipping
.py", line 257, in pytest_runtest_call
return (yield)
^^^^^
File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/pluggy/_callers.
py", line 121, in _multicall
res = hook_impl.function(*args)
^^^^^^^^^^^^^^^^^^^^^^^^^
File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/_pytest/runner.p
y", line 174, in pytest_runtest_call
item.runtest()
File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/_pytest/python.p
y", line 1627, in runtest
self.ihook.pytest_pyfunc_call(pyfuncitem=self)
File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/pluggy/_hooks.py
", line 512, in __call__
return self._hookexec(self.name, self._hookimpls.copy(), kwargs, firstresult
)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
^
File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/pluggy/_manager.
py", line 120, in _hookexec
return self._inner_hookexec(hook_name, methods, kwargs, firstresult)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/pluggy/_callers.
py", line 167, in _multicall
raise exception
File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/pluggy/_callers.
py", line 121, in _multicall
res = hook_impl.function(*args)
^^^^^^^^^^^^^^^^^^^^^^^^^
File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/_pytest/python.p
y", line 159, in pytest_pyfunc_call
result = testfunction(**testargs)
^^^^^^^^^^^^^^^^^^^^^^^^
File "/workspace/scubaduck/tests/test_web_table.py", line 323, in test_table_c
ount_headings
assert headers == ["id", "Hits"]
AssertionError: assert [] == ['id', 'Hits']
Right contains 2 more items, first extra item: 'id'
Use -v to get more diff
----------------------------- Captured stdout call -----------------------------
Query failed:
SELECT count(*) AS Hits
FROM "events"
WHERE "timestamp" >= '2024-01-01 00:00:00' AND "timestamp" <= '2024-01-02 03:00:
00'
ORDER BY "timestamp" ASC
LIMIT 100
Traceback (most recent call last):
File "/workspace/scubaduck/scubaduck/server.py", line 655, in query
rows = con.execute(sql).fetchall()
^^^^^^^^^^^^^^^^
duckdb.duckdb.BinderException: Binder Error: column "timestamp" must appear in t
he GROUP BY clause or must be part of an aggregate function.
Either add it to the GROUP BY list, or use "ANY_VALUE(timestamp)" if the exact v
alue of "timestamp" is not important.
LINE 4: ORDER BY "timestamp" ASC
^
------------------------------ Captured log call -------------------------------
INFO werkzeug:_internal.py:97 127.0.0.1 - - [23/May/2025 01:50:39] "GET /?gr
aph_type=table HTTP/1.1" 200 -
INFO werkzeug:_internal.py:97 127.0.0.1 - - [23/May/2025 01:50:39] "GET /js/
chip_input.js HTTP/1.1" 200 -
INFO werkzeug:_internal.py:97 127.0.0.1 - - [23/May/2025 01:50:39] "GET /js/
timeseries_chart.js HTTP/1.1" 200 -
INFO werkzeug:_internal.py:97 127.0.0.1 - - [23/May/2025 01:50:39] "GET /js/
time_column.js HTTP/1.1" 200 -
INFO werkzeug:_internal.py:97 127.0.0.1 - - [23/May/2025 01:50:39] "GET /js/
sidebar.js HTTP/1.1" 200 -
INFO werkzeug:_internal.py:97 127.0.0.1 - - [23/May/2025 01:50:39] "GET /js/
table_view.js HTTP/1.1" 200 -
INFO werkzeug:_internal.py:97 127.0.0.1 - - [23/May/2025 01:50:39] "GET /js/
view_settings.js HTTP/1.1" 200 -
INFO werkzeug:_internal.py:97 127.0.0.1 - - [23/May/2025 01:50:39] "GET /api
/tables HTTP/1.1" 200 -
INFO werkzeug:_internal.py:97 127.0.0.1 - - [23/May/2025 01:50:39] "GET /api
/columns?table=events HTTP/1.1" 200 -
INFO werkzeug:_internal.py:97 127.0.0.1 - - [23/May/2025 01:50:39] "POST /ap
i/query HTTP/1.1" 400 -
INFO werkzeug:_internal.py:97 127.0.0.1 - - [23/May/2025 01:50:39] "POST /ap
i/query HTTP/1.1" 400 -
=========================== short test summary info ============================
FAILED tests/test_web_table.py::test_table_count_headings[chromium] - Asserti...
1 failed, 108 passed in 14.05s
(scubaduck) root@df047255e87d:/workspace/scubaduck#
There might be an issue with calling select_value too late. I'll check setSelectValue.
(scubaduck) root@df047255e87d:/workspace/scubaduck# grep -n "setSelectValue" -r
scubaduck/static/js
scubaduck/static/js/view_settings.js:877:function setSelectValue(selector, value
) {
(scubaduck) root@df047255e87d:/workspace/scubaduck# sed -n '872,888p' scubaduck/
static/js/view_settings.js
const params = e.state || parseSearch();
applyParams(params);
dive(false);
});
function setSelectValue(selector, value) {
const el = typeof selector === 'string' ? document.querySelector(selector) : s
elector;
if (el) {
const select = el.tagName === 'SELECT' ? el : el.querySelector('select');
if (select) {
select.value = value;
select.dispatchEvent(new Event('change'));
}
}
}
(scubaduck) root@df047255e87d:/workspace/scubaduck#
I think the order_by option might not be set in the right sequence. Let's check!
(scubaduck) root@df047255e87d:/workspace/scubaduck# sed -n '20,60p' tests/web_ut
ils.py
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)
if page.input_value("#graph_type") != "samples":
page.click("text=Columns")
page.wait_for_selector("#column_groups input", state="attached")
if not page.is_checked("#column_groups input[value='value']"):
page.check("#column_groups input[value='value']")
order_col = order_by or page.input_value("#order_by")
if order_col and not page.is_checked(
f"#column_groups input[value='{order_col}']"
):
if page.query_selector(f"#column_groups input[value='{order_col}']")
:
page.check(f"#column_groups input[value='{order_col}']")
page.click("text=View Settings")
page.evaluate("window.lastResults = undefined")
(scubaduck) root@df047255e87d:/workspace/scubaduck# sed -n '60,90p' tests/web_ut
ils.py
page.evaluate("window.lastResults = undefined")
page.click("text=Dive")
page.wait_for_function("window.lastResults !== undefined")
return page.evaluate("window.lastResults")
(scubaduck) root@df047255e87d:/workspace/scubaduck#
I might have mis-specified the 'id' value. Let's check the columns loaded during the test.
(scubaduck) root@df047255e87d:/workspace/scubaduck# grep -n "id" tests/test_web_
table.py
82: assert border == "solid"
105: "var v=document.getElementById('view'); v.scrollWidth > v.clientWidt
h"
139: assert cell != "Invalid Date"
140: valid = page.evaluate("v => !isNaN(Date.parse(v))", cell)
141: assert valid
155: assert "Empty data provided to table" in msg
316: page.evaluate("g => { groupBy.chips = g; groupBy.renderChips(); }", ["id
"])
317: select_value(page, "#order_by", "id")
323: assert headers == ["id", "Hits"]
(scubaduck) root@df047255e87d:/workspace/scubaduck#
I'll review tests/test_web_basics and search for 'order_by' to confirm its details.
(scubaduck) root@df047255e87d:/workspace/scubaduck# grep -n "order_by" -n tests/
test_web_basics.py
14: order_by="user",
27:def test_order_by(page: Any, server_url: str) -> None:
33: order_by="value",
47: order_by="user",
109: page.wait_for_selector("#order_by option", state="attached")
130: page.wait_for_selector("#order_by option", state="attached")
145: page.wait_for_selector("#order_by option", state="attached")
154: page.wait_for_selector("#order_by option", state="attached")
187: page.wait_for_selector("#order_by option", state="attached")
201: page.wait_for_selector("#order_by option", state="attached")
211: page.wait_for_selector("#order_by option", state="attached")
223: order_by="user",
232: page.wait_for_selector("#order_by option", state="attached")
261: "&order_by=timestamp&limit=2"
264: page.wait_for_selector("#order_by option", state="attached")
(scubaduck) root@df047255e87d:/workspace/scubaduck#
I'll check out tests/test_web_basics to explore the available options.
(scubaduck) root@df047255e87d:/workspace/scubaduck# sed -n '10,40p' tests/test_w
eb_basics.py
page,
server_url,
start="2024-01-02 00:00:00",
end="2024-01-02 04:00:00",
order_by="user",
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)
(scubaduck) root@df047255e87d:/workspace/scubaduck#
I'll look for instances where "order_by id" might be used in the tests.
(scubaduck) root@df047255e87d:/workspace/scubaduck# grep -n "order_by=\"id\"" -r
tests
(scubaduck) root@df047255e87d:/workspace/scubaduck#
I don't see any tests using order_by id, so I'll check the API for column options.
ck/server.py | head
39: columns: list[str] = field(default_factory=lambda: [])
41: derived_columns: dict[str, str] = field(default_factory=lambda: {})
258: for col in params.columns:
265: select_parts.extend(_quote(c) for c in params.columns)
267: if has_agg and params.derived_columns:
270: derived_columns={},
276: f"{expr} AS {name}" for name, expr in params.derived_columns.ite
ms()
293: for name, expr in params.derived_columns.items():
362: columns_cache: Dict[str, Dict[str, str]] = {}
364: def get_columns(table: str) -> Dict[str, str]:
elif params.graph_type == "timeseries":
lines.append("ORDER BY bucket")
if params.limit is not None:
lines.append(f"LIMIT {params.limit}")
return "\n".join(lines)
def create_app(db_file: str | Path | None = None) -> Flask:
app = Flask(__name__, static_folder="static")
if db_file is None:
env_db = os.environ.get("SCUBADUCK_DB")
if env_db:
db_file = env_db
if isinstance(db_file, str) and db_file.upper() == "TEST":
con = _create_test_database()
else:
db_path = Path(db_file or Path(__file__).with_name("sample.csv")).resolv
e()
con = _load_database(db_path)
tables = [r[0] for r in con.execute("SHOW TABLES").fetchall()]
if not tables:
raise ValueError("No tables found in database")
default_table = tables[0]
columns_cache: Dict[str, Dict[str, str]] = {}
def get_columns(table: str) -> Dict[str, str]:
if table not in columns_cache:
rows = con.execute(f'PRAGMA table_info("{table}")').fetchall()
if not rows:
raise ValueError(f"Unknown table: {table}")
columns_cache[table] = {r[1]: r[2] for r in rows}
return columns_cache[table]
sample_cache: Dict[Tuple[str, str, str], Tuple[List[str], float]] = {}
CACHE_TTL = 60.0
CACHE_LIMIT = 200
@app.route("/")
def index() -> Any: # pyright: ignore[reportUnusedFunction]
assert app.static_folder is not None
return send_from_directory(app.static_folder, "index.html")
(scubaduck) root@df047255e87d:/workspace/scubaduck#
I think the issue with the query could be related to order_by
, so I'll check run_query
parameters and try customizing it with id
.
I'll trigger run_query
with specific parameters, then open columns and rerun the query.
(scubaduck) root@df047255e87d:/workspace/scubaduck# apply_patch<<'PATCH'
> *** Begin Patch
> *** Update File: tests/test_web_table.py
> @@
> - page.goto(f"{server_url}?graph_type=table")
> - page.wait_for_selector("#order_by option", state="attached")
> - page.click("text=Columns")
> - page.wait_for_selector("#column_groups input", state="attached")
> - page.click("#columns_all")
> - page.click("text=View Settings")
> - page.evaluate("g => { groupBy.chips = g; groupBy.renderChips(); }", ["id"
])
> - select_value(page, "#order_by", "id")
> - select_value(page, "#aggregate", "Count")
> - page.evaluate("window.lastResults = undefined")
> - page.click("text=Dive")
> - page.wait_for_function("window.lastResults !== undefined")
> + run_query(
> + page,
> + server_url,
> + order_by="id",
> + group_by=["id"],
> + aggregate="Count",
> + )
> + page.click("text=Columns")
> + page.wait_for_selector("#column_groups input", state="attached")
> + page.click("#columns_all")
> + page.click("text=View Settings")
> + page.evaluate("window.lastResults = undefined")
> + page.click("text=Dive")
> + page.wait_for_function("window.lastResults !== undefined")
> *** End Patch
> PATCH
Success. Updated the following files:
M tests/test_web_table.py
(scubaduck) root@df047255e87d:/workspace/scubaduck# ruff format tests/test_web_t
able.py scubaduck/server.py >/tmp/ruff_format.log && tail -n 20 /tmp/ruff_format
.log
2 files left unchanged
(scubaduck) root@df047255e87d:/workspace/scubaduck#
I'll review the ruff check now.
(scubaduck) root@df047255e87d:/workspace/scubaduck# ruff check >/tmp/ruff_check.
log && tail -n 20 /tmp/ruff_check.log
(scubaduck) root@df047255e87d:/workspace/scubaduck#
I don’t see “All checks passed!”—I’ll open the file to investigate further.
(scubaduck) root@df047255e87d:/workspace/scubaduck# cat /tmp/ruff_check.log
tests/test_web_table.py:5:40: F401 [*] `tests.web_utils.select_value` imported b
ut unused
|
3 | from typing import Any
4 |
5 | from tests.web_utils import run_query, select_value
| ^^^^^^^^^^^^ F401
|
= help: Remove unused import: `tests.web_utils.select_value`
Found 1 error.
[*] 1 fixable with the `--fix` option.
(scubaduck) root@df047255e87d:/workspace/scubaduck#
I’ll clean up the import of select_value since it’s no longer used.
(scubaduck) root@df047255e87d:/workspace/scubaduck# apply_patch<<'PATCH'
> *** Begin Patch
> *** Update File: tests/test_web_table.py
> @@
> -from tests.web_utils import run_query, select_value
> +from tests.web_utils import run_query
> *** End Patch
> PATCH
Success. Updated the following files:
M tests/test_web_table.py
(scubaduck) root@df047255e87d:/workspace/scubaduck# ruff format tests/test_web_t
able.py scubaduck/server.py >/tmp/ruff_format.log && tail -n 20 /tmp/ruff_format
.log
2 files left unchanged
(scubaduck) root@df047255e87d:/workspace/scubaduck# ruff check >/tmp/ruff_check.
log && tail -n 20 /tmp/ruff_check.log
All checks passed!
(scubaduck) root@df047255e87d:/workspace/scubaduck# pyright >/tmp/pyright.log &&
tail -n 20 /tmp/pyright.log
0 errors, 0 warnings, 0 informations
(scubaduck) root@df047255e87d:/workspace/scubaduck#
I'll run pytest now to check if everything functions as expected.
(scubaduck) root@df047255e87d:/workspace/scubaduck# pytest -q >/tmp/pytest.log &
& tail -n 20 /tmp/pytest.log
bringing up nodes...
bringing up nodes...
........................................................................ [ 66%]
.................................F... [100%]
=================================== FAILURES ===================================
_____________________ test_table_count_headings[chromium] ______________________
[gw0] linux -- Python 3.12.3 /workspace/scubaduck/.venv/bin/python
Traceback (most recent call last):
File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/_pytest/runner.p
y", line 341, in from_call
result: TResult | None = func()
^^^^^^
File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/_pytest/runner.p
y", line 242, in <lambda>
lambda: runtest_hook(item=item, **kwds), when=when, reraise=reraise
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/pluggy/_hooks.py
", line 512, in __call__
return self._hookexec(self.name, self._hookimpls.copy(), kwargs, firstresult
)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
^
File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/pluggy/_manager.
py", line 120, in _hookexec
return self._inner_hookexec(hook_name, methods, kwargs, firstresult)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/pluggy/_callers.
py", line 167, in _multicall
raise exception
File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/pluggy/_callers.
py", line 139, in _multicall
teardown.throw(exception)
File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/_pytest/threadex
ception.py", line 92, in pytest_runtest_call
yield from thread_exception_runtest_hook()
File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/_pytest/threadex
ception.py", line 68, in thread_exception_runtest_hook
yield
File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/pluggy/_callers.
py", line 139, in _multicall
teardown.throw(exception)
File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/_pytest/unraisab
leexception.py", line 95, in pytest_runtest_call
yield from unraisable_exception_runtest_hook()
File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/_pytest/unraisab
leexception.py", line 70, in unraisable_exception_runtest_hook
yield
File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/pluggy/_callers.
py", line 139, in _multicall
teardown.throw(exception)
File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/_pytest/logging.
py", line 846, in pytest_runtest_call
yield from self._runtest_for(item, "call")
File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/_pytest/logging.
py", line 829, in _runtest_for
yield
File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/pluggy/_callers.
py", line 139, in _multicall
teardown.throw(exception)
File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/_pytest/capture.
py", line 898, in pytest_runtest_call
return (yield)
^^^^^
File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/pluggy/_callers.
py", line 139, in _multicall
teardown.throw(exception)
File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/_pytest/skipping
.py", line 257, in pytest_runtest_call
return (yield)
^^^^^
File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/pluggy/_callers.
py", line 121, in _multicall
res = hook_impl.function(*args)
^^^^^^^^^^^^^^^^^^^^^^^^^
File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/_pytest/runner.p
y", line 174, in pytest_runtest_call
item.runtest()
File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/_pytest/python.p
y", line 1627, in runtest
self.ihook.pytest_pyfunc_call(pyfuncitem=self)
File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/pluggy/_hooks.py
", line 512, in __call__
return self._hookexec(self.name, self._hookimpls.copy(), kwargs, firstresult
)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
^
File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/pluggy/_manager.
py", line 120, in _hookexec
return self._inner_hookexec(hook_name, methods, kwargs, firstresult)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/pluggy/_callers.
py", line 167, in _multicall
raise exception
File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/pluggy/_callers.
py", line 121, in _multicall
res = hook_impl.function(*args)
^^^^^^^^^^^^^^^^^^^^^^^^^
File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/_pytest/python.p
y", line 159, in pytest_pyfunc_call
result = testfunction(**testargs)
^^^^^^^^^^^^^^^^^^^^^^^^
File "/workspace/scubaduck/tests/test_web_table.py", line 310, in test_table_c
ount_headings
run_query(
File "/workspace/scubaduck/tests/web_utils.py", line 54, in run_query
if order_col and not page.is_checked(
^^^^^^^^^^^^^^^^
File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/playwright/sync_
api/_generated.py", line 8223, in is_checked
self._sync(
File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/playwright/_impl
/_sync_base.py", line 115, in _sync
return task.result()
^^^^^^^^^^^^^
File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/playwright/_impl
/_page.py", line 430, in is_checked
return await self._main_frame.is_checked(**locals_to_params(locals()))
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/playwright/_impl
/_frame.py", line 329, in is_checked
return await self._channel.send("isChecked", locals_to_params(locals()))
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/playwright/_impl
/_connection.py", line 61, in send
return await self._connection.wrap_api_call(
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/playwright/_impl
/_connection.py", line 528, in wrap_api_call
raise rewrite_error(error, f"{parsed_st['apiName']}: {error}") from None
playwright._impl._errors.TimeoutError: Page.is_checked: Timeout 30000ms exceeded
.
Call log:
- waiting for locator("#column_groups input[value='id']")
------------------------------ Captured log call -------------------------------
INFO werkzeug:_internal.py:97 127.0.0.1 - - [23/May/2025 01:52:13] "GET / HT
TP/1.1" 200 -
INFO werkzeug:_internal.py:97 127.0.0.1 - - [23/May/2025 01:52:13] "GET /js/
chip_input.js HTTP/1.1" 200 -
INFO werkzeug:_internal.py:97 127.0.0.1 - - [23/May/2025 01:52:13] "GET /js/
timeseries_chart.js HTTP/1.1" 200 -
INFO werkzeug:_internal.py:97 127.0.0.1 - - [23/May/2025 01:52:13] "GET /js/
time_column.js HTTP/1.1" 200 -
INFO werkzeug:_internal.py:97 127.0.0.1 - - [23/May/2025 01:52:13] "GET /js/
sidebar.js HTTP/1.1" 200 -
INFO werkzeug:_internal.py:97 127.0.0.1 - - [23/May/2025 01:52:13] "GET /js/
table_view.js HTTP/1.1" 200 -
INFO werkzeug:_internal.py:97 127.0.0.1 - - [23/May/2025 01:52:13] "GET /js/
view_settings.js HTTP/1.1" 200 -
INFO werkzeug:_internal.py:97 127.0.0.1 - - [23/May/2025 01:52:13] "GET /api
/tables HTTP/1.1" 200 -
INFO werkzeug:_internal.py:97 127.0.0.1 - - [23/May/2025 01:52:13] "GET /api
/columns?table=events HTTP/1.1" 200 -
INFO werkzeug:_internal.py:97 127.0.0.1 - - [23/May/2025 01:52:13] "POST /ap
i/query HTTP/1.1" 200 -
=========================== short test summary info ============================
FAILED tests/test_web_table.py::test_table_count_headings[chromium] - playwri...
1 failed, 108 passed in 42.38s
(scubaduck) root@df047255e87d:/workspace/scubaduck#
I'm checking if the 'id' column exists or if it's excluded due to rowid settings.
(scubaduck) root@df047255e87d:/workspace/scubaduck# python - <<'PY'
> import json
> from scubaduck import server
> app = server.app
> client = app.test_client()
> r = client.get('/api/columns?table=events')
> print(r.json)
> PY
[{'name': 'timestamp', 'type': 'TIMESTAMP'}, {'name': 'event', 'type': 'VARCHAR'
}, {'name': 'value', 'type': 'BIGINT'}, {'name': 'user', 'type': 'VARCHAR'}]
(scubaduck) root@df047255e87d:/workspace/scubaduck#
I'm confirming if the default sample.csv or the test database is used in tests.
(scubaduck) root@df047255e87d:/workspace/scubaduck# sed -n '1,80p' tests/conftes
t.py
from __future__ import annotations
import threading
from collections.abc import Iterator
import pytest
from werkzeug.serving import make_server
from scubaduck.server import app
@pytest.fixture()
def server_url() -> Iterator[str]:
httpd = make_server("127.0.0.1", 0, app)
port = httpd.server_port
thread = threading.Thread(target=httpd.serve_forever)
thread.start()
try:
yield f"http://127.0.0.1:{port}"
finally:
httpd.shutdown()
thread.join()
(scubaduck) root@df047255e87d:/workspace/scubaduck#
The TEST dataset requires 'id' for grouping, but sample.csv doesn't include that column.
(scubaduck) root@df047255e87d:/workspace/scubaduck# grep -n "TEST" -r tests | he
ad
grep: tests/__pycache__/test_multi_table_web.cpython-312-pytest-8.3.5.pyc: binar
y file matches
tests/test_server_timeseries.py:253: app = server.create_app("TEST")
tests/test_multi_table_web.py:14: app = create_app("TEST")
grep: tests/__pycache__/test_server_timeseries.cpython-312-pytest-8.3.5.pyc: bin
ary file matches
(scubaduck) root@df047255e87d:/workspace/scubaduck#
It seems the test isn't using the TEST dataset, but sample.csv. I'll inspect server.py.
(scubaduck) root@df047255e87d:/workspace/scubaduck# grep -n "create_app()" -n sc
ubaduck/server.py
676:app = create_app()
(scubaduck) root@df047255e87d:/workspace/scubaduck#
I see that tests use sample.csv by default. I might need to modify server_url and create a custom fixture for the "TEST" mode. I'll check tests/test_multi_table_web.py to learn their setup.
import threading
from collections.abc import Iterator
from typing import Any
import pytest
from werkzeug.serving import make_server
from scubaduck.server import create_app
from tests.web_utils import select_value
@pytest.fixture()
def multi_table_server_url() -> Iterator[str]:
app = create_app("TEST")
httpd = make_server("127.0.0.1", 0, app)
port = httpd.server_port
thread = threading.Thread(target=httpd.serve_forever)
thread.start()
try:
yield f"http://127.0.0.1:{port}"
finally:
httpd.shutdown()
thread.join()
def test_table_param_updates_on_dive(page: Any, multi_table_server_url: str) ->
None:
page.goto(multi_table_server_url + "?table=events")
page.wait_for_selector("#table option", state="attached")
select_value(page, "#table", "extra")
page.evaluate("window.lastResults = undefined")
page.click("text=Dive")
page.wait_for_function("window.lastResults !== undefined")
table_param = page.evaluate(
"new URLSearchParams(window.location.search).get('table')"
)
assert table_param == "extra"
def test_table_dropdown_persists_on_refresh(
page: Any, multi_table_server_url: str
(scubaduck) root@df047255e87d:/workspace/scubaduck#
I'll check if the multi_table_server_url
fixture is already in use, or if I need a new one.
url" -r tests
tests/test_multi_table_web.py:13:def multi_table_server_url() -> Iterator[str]:
tests/test_multi_table_web.py:26:def test_table_param_updates_on_dive(page: Any,
multi_table_server_url: str) -> None:
tests/test_multi_table_web.py:27: page.goto(multi_table_server_url + "?table=
events")
tests/test_multi_table_web.py:40: page: Any, multi_table_server_url: str
tests/test_multi_table_web.py:42: page.goto(multi_table_server_url + "?table=
events")
grep: tests/__pycache__/test_multi_table_web.cpython-312-pytest-8.3.5.pyc: binar
y file matches
(scubaduck) root@df047255e87d:/workspace/scubaduck#
I'll update the test to use multi_table_server_url
as server_url
. Let's patch!
> *** Begin Patch
> *** Update File: tests/test_web_table.py
> @@
> -def test_table_count_headings(page: Any, server_url: str) -> None:
> - run_query(
> - page,
> - server_url,
> +def test_table_count_headings(page: Any, multi_table_server_url: str) -> None
:
> + run_query(
> + page,
> + multi_table_server_url,
> *** End Patch
> PATCH
Success. Updated the following files:
M tests/test_web_table.py
(scubaduck) root@df047255e87d:/workspace/scubaduck#
An import update isn't needed right now. I'll just run ruff format again.
(scubaduck) root@df047255e87d:/workspace/scubaduck# ruff format tests/test_web_t
able.py scubaduck/server.py >/tmp/ruff_format.log && tail -n 20 /tmp/ruff_format
.log
2 files left unchanged
(scubaduck) root@df047255e87d:/workspace/scubaduck# ruff check >/tmp/ruff_check.
log && tail -n 20 /tmp/ruff_check.log
All checks passed!
(scubaduck) root@df047255e87d:/workspace/scubaduck# pyright >/tmp/pyright.log &&
tail -n 20 /tmp/pyright.log
0 errors, 0 warnings, 0 informations
(scubaduck) root@df047255e87d:/workspace/scubaduck#
I'll give pytest another shot.
(scubaduck) root@df047255e87d:/workspace/scubaduck# pytest -q >/tmp/pytest.log &
& tail -n 20 /tmp/pytest.log
bringing up nodes...
bringing up nodes...
..................................................E..................... [ 66%]
..................................... [100%]
==================================== ERRORS ====================================
____________ ERROR at setup of test_table_count_headings[chromium] _____________
[gw0] linux -- Python 3.12.3 /workspace/scubaduck/.venv/bin/python
file /workspace/scubaduck/tests/test_web_table.py, line 309
def test_table_count_headings(page: Any, multi_table_server_url: str) -> None:
E fixture 'multi_table_server_url' not found
> available fixtures: _artifacts_recorder, _pw_artifacts_folder, _verify_u
rl, base_url, browser, browser_channel, browser_context_args, browser_name, brow
ser_type, browser_type_launch_args, cache, capfd, capfdbinary, caplog, capsys, c
apsysbinary, connect_options, context, delete_output_dir, device, doctest_namesp
ace, is_chromium, is_firefox, is_webkit, launch_browser, monkeypatch, new_contex
t, output_path, page, playwright, pytestconfig, record_property, record_testsuit
e_property, record_xml_attribute, recwarn, server_url, testrun_uid, tmp_path, tm
p_path_factory, tmpdir, tmpdir_factory, worker_id
> use 'pytest --fixtures [testpath]' for help on them.
/workspace/scubaduck/tests/test_web_table.py:309
=========================== short test summary info ============================
ERROR tests/test_web_table.py::test_table_count_headings[chromium]
108 passed, 1 error in 14.31s
(scubaduck) root@df047255e87d:/workspace/scubaduck#
I can't import multi_table_server_url because it’s not globally shared. Replicating the code seems simplest.
I’ll update tests/conftest.py to add the multi_table_server_url fixture for all tests.
(scubaduck) root@df047255e87d:/workspace/scubaduck# sed -n '1,40p' tests/conftes
t.py
from __future__ import annotations
import threading
from collections.abc import Iterator
import pytest
from werkzeug.serving import make_server
from scubaduck.server import app
@pytest.fixture()
def server_url() -> Iterator[str]:
httpd = make_server("127.0.0.1", 0, app)
port = httpd.server_port
thread = threading.Thread(target=httpd.serve_forever)
thread.start()
try:
yield f"http://127.0.0.1:{port}"
finally:
httpd.shutdown()
thread.join()
(scubaduck) root@df047255e87d:/workspace/scubaduck#
I'll create the multi_table_server_url fixture based on test_multi_table_web.
import threading
from collections.abc import Iterator
from typing import Any
import pytest
from werkzeug.serving import make_server
from scubaduck.server import create_app
from tests.web_utils import select_value
@pytest.fixture()
def multi_table_server_url() -> Iterator[str]:
app = create_app("TEST")
httpd = make_server("127.0.0.1", 0, app)
port = httpd.server_port
thread = threading.Thread(target=httpd.serve_forever)
thread.start()
try:
yield f"http://127.0.0.1:{port}"
finally:
httpd.shutdown()
thread.join()
def test_table_param_updates_on_dive(page: Any, multi_table_server_url: str) ->
None:
page.goto(multi_table_server_url + "?table=events")
page.wait_for_selector("#table option", state="attached")
select_value(page, "#table", "extra")
page.evaluate("window.lastResults = undefined")
page.click("text=Dive")
page.wait_for_function("window.lastResults !== undefined")
table_param = page.evaluate(
"new URLSearchParams(window.location.search).get('table')"
)
assert table_param == "extra"
def test_table_dropdown_persists_on_refresh(
page: Any, multi_table_server_url: str
(scubaduck) root@df047255e87d:/workspace/scubaduck#
I’ll copy the fixture into conftest.py and import create_app to ensure reusability.
> *** Begin Patch
> *** Update File: tests/conftest.py
> @@
> -from scubaduck.server import app
> +from scubaduck.server import app, create_app
> *** End Patch
> PATCH
Success. Updated the following files:
M tests/conftest.py
(scubaduck) root@df047255e87d:/workspace/scubaduck#
I'll define the necessary fixture for the tests.
> *** Begin Patch
> *** Update File: tests/conftest.py
> @@
> def server_url() -> Iterator[str]:
> @@
> httpd.shutdown()
> thread.join()
> +
> +
> +@pytest.fixture()
> +def multi_table_server_url() -> Iterator[str]:
> + app = create_app("TEST")
> + httpd = make_server("127.0.0.1", 0, app)
> + port = httpd.server_port
> + thread = threading.Thread(target=httpd.serve_forever)
> + thread.start()
> + try:
> + yield f"http://127.0.0.1:{port}"
> + finally:
> + httpd.shutdown()
> + thread.join()
> *** End Patch
> PATCH
Success. Updated the following files:
M tests/conftest.py
(scubaduck) root@df047255e87d:/workspace/scubaduck# ruff format tests/conftest.p
y tests/test_web_table.py scubaduck/server.py >/tmp/ruff_format.log && tail -n 2
0 /tmp/ruff_format.log
3 files left unchanged
log && tail -n 20 /tmp/ruff_check.log
All checks passed!
(scubaduck) root@df047255e87d:/workspace/scubaduck# pyright >/tmp/pyright.log &&
tail -n 20 /tmp/pyright.log
0 errors, 0 warnings, 0 informations
(scubaduck) root@df047255e87d:/workspace/scubaduck# pytest -q >/tmp/pytest.log &
& tail -n 20 /tmp/pytest.log
bringing up nodes...
bringing up nodes...
........................................................................ [ 66%]
.................................F... [100%]
=================================== FAILURES ===================================
_____________________ test_table_count_headings[chromium] ______________________
[gw0] linux -- Python 3.12.3 /workspace/scubaduck/.venv/bin/python
Traceback (most recent call last):
File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/_pytest/runner.p
y", line 341, in from_call
result: TResult | None = func()
^^^^^^
File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/_pytest/runner.p
y", line 242, in <lambda>
lambda: runtest_hook(item=item, **kwds), when=when, reraise=reraise
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/pluggy/_hooks.py
", line 512, in __call__
return self._hookexec(self.name, self._hookimpls.copy(), kwargs, firstresult
)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
^
File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/pluggy/_manager.
py", line 120, in _hookexec
return self._inner_hookexec(hook_name, methods, kwargs, firstresult)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/pluggy/_callers.
py", line 167, in _multicall
raise exception
File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/pluggy/_callers.
py", line 139, in _multicall
teardown.throw(exception)
File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/_pytest/threadex
ception.py", line 92, in pytest_runtest_call
yield from thread_exception_runtest_hook()
File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/_pytest/threadex
ception.py", line 68, in thread_exception_runtest_hook
yield
File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/pluggy/_callers.
py", line 139, in _multicall
teardown.throw(exception)
File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/_pytest/unraisab
leexception.py", line 95, in pytest_runtest_call
yield from unraisable_exception_runtest_hook()
File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/_pytest/unraisab
leexception.py", line 70, in unraisable_exception_runtest_hook
yield
File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/pluggy/_callers.
py", line 139, in _multicall
teardown.throw(exception)
File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/_pytest/logging.
py", line 846, in pytest_runtest_call
yield from self._runtest_for(item, "call")
File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/_pytest/logging.
py", line 829, in _runtest_for
yield
File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/pluggy/_callers.
py", line 139, in _multicall
teardown.throw(exception)
File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/_pytest/capture.
py", line 898, in pytest_runtest_call
return (yield)
^^^^^
File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/pluggy/_callers.
py", line 139, in _multicall
teardown.throw(exception)
File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/_pytest/skipping
.py", line 257, in pytest_runtest_call
return (yield)
^^^^^
File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/pluggy/_callers.
py", line 121, in _multicall
res = hook_impl.function(*args)
^^^^^^^^^^^^^^^^^^^^^^^^^
File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/_pytest/runner.p
y", line 174, in pytest_runtest_call
item.runtest()
File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/_pytest/python.p
y", line 1627, in runtest
self.ihook.pytest_pyfunc_call(pyfuncitem=self)
File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/pluggy/_hooks.py
", line 512, in __call__
return self._hookexec(self.name, self._hookimpls.copy(), kwargs, firstresult
)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
^
File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/pluggy/_manager.
py", line 120, in _hookexec
return self._inner_hookexec(hook_name, methods, kwargs, firstresult)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/pluggy/_callers.
py", line 167, in _multicall
raise exception
File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/pluggy/_callers.
py", line 121, in _multicall
res = hook_impl.function(*args)
^^^^^^^^^^^^^^^^^^^^^^^^^
File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/_pytest/python.p
y", line 159, in pytest_pyfunc_call
result = testfunction(**testargs)
^^^^^^^^^^^^^^^^^^^^^^^^
File "/workspace/scubaduck/tests/test_web_table.py", line 310, in test_table_c
ount_headings
run_query(
File "/workspace/scubaduck/tests/web_utils.py", line 51, in run_query
if not page.is_checked("#column_groups input[value='value']"):
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/playwright/sync_
api/_generated.py", line 8223, in is_checked
self._sync(
File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/playwright/_impl
/_sync_base.py", line 115, in _sync
return task.result()
^^^^^^^^^^^^^
File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/playwright/_impl
/_page.py", line 430, in is_checked
return await self._main_frame.is_checked(**locals_to_params(locals()))
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/playwright/_impl
/_frame.py", line 329, in is_checked
return await self._channel.send("isChecked", locals_to_params(locals()))
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/playwright/_impl
/_connection.py", line 61, in send
return await self._connection.wrap_api_call(
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/playwright/_impl
/_connection.py", line 528, in wrap_api_call
raise rewrite_error(error, f"{parsed_st['apiName']}: {error}") from None
playwright._impl._errors.TimeoutError: Page.is_checked: Timeout 30000ms exceeded
.
Call log:
- waiting for locator("#column_groups input[value='value']")
------------------------------ Captured log call -------------------------------
INFO werkzeug:_internal.py:97 127.0.0.1 - - [23/May/2025 01:54:43] "GET / HT
TP/1.1" 200 -
INFO werkzeug:_internal.py:97 127.0.0.1 - - [23/May/2025 01:54:43] "GET /js/
chip_input.js HTTP/1.1" 200 -
INFO werkzeug:_internal.py:97 127.0.0.1 - - [23/May/2025 01:54:43] "GET /js/
timeseries_chart.js HTTP/1.1" 200 -
INFO werkzeug:_internal.py:97 127.0.0.1 - - [23/May/2025 01:54:43] "GET /js/
time_column.js HTTP/1.1" 200 -
INFO werkzeug:_internal.py:97 127.0.0.1 - - [23/May/2025 01:54:43] "GET /js/
sidebar.js HTTP/1.1" 200 -
INFO werkzeug:_internal.py:97 127.0.0.1 - - [23/May/2025 01:54:43] "GET /js/
table_view.js HTTP/1.1" 200 -
INFO werkzeug:_internal.py:97 127.0.0.1 - - [23/May/2025 01:54:43] "GET /js/
view_settings.js HTTP/1.1" 200 -
INFO werkzeug:_internal.py:97 127.0.0.1 - - [23/May/2025 01:54:43] "GET /api
/tables HTTP/1.1" 200 -
INFO werkzeug:_internal.py:97 127.0.0.1 - - [23/May/2025 01:54:43] "GET /api
/columns?table=events HTTP/1.1" 200 -
INFO werkzeug:_internal.py:97 127.0.0.1 - - [23/May/2025 01:54:43] "POST /ap
i/query HTTP/1.1" 200 -
=========================== short test summary info ============================
FAILED tests/test_web_table.py::test_table_count_headings[chromium] - playwri...
1 failed, 108 passed in 42.52s
(scubaduck) root@df047255e87d:/workspace/scubaduck#
The issue is that run_query expects a "value" column, which doesn't exist in TEST dataset.
import threading
from collections.abc import Iterator
from typing import Any
import pytest
from werkzeug.serving import make_server
from scubaduck.server import create_app
from tests.web_utils import select_value
@pytest.fixture()
def multi_table_server_url() -> Iterator[str]:
app = create_app("TEST")
httpd = make_server("127.0.0.1", 0, app)
port = httpd.server_port
thread = threading.Thread(target=httpd.serve_forever)
thread.start()
try:
yield f"http://127.0.0.1:{port}"
finally:
httpd.shutdown()
thread.join()
def test_table_param_updates_on_dive(page: Any, multi_table_server_url: str) ->
None:
page.goto(multi_table_server_url + "?table=events")
page.wait_for_selector("#table option", state="attached")
select_value(page, "#table", "extra")
page.evaluate("window.lastResults = undefined")
page.click("text=Dive")
page.wait_for_function("window.lastResults !== undefined")
table_param = page.evaluate(
"new URLSearchParams(window.location.search).get('table')"
)
assert table_param == "extra"
def test_table_dropdown_persists_on_refresh(
page: Any, multi_table_server_url: str
) -> None:
page.goto(multi_table_server_url + "?table=events")
page.wait_for_selector("#table option", state="attached")
select_value(page, "#table", "extra")
page.evaluate("window.lastResults = undefined")
page.click("text=Dive")
page.wait_for_function("window.lastResults !== undefined")
page.reload()
page.wait_for_selector("#table option", state="attached")
assert page.input_value("#table") == "extra"
disp = page.text_content("#table + .dropdown-display")
assert disp is not None and disp.strip() == "extra"
(scubaduck) root@df047255e87d:/workspace/scubaduck#
I'll revert to the manual approach, adapting it for the TEST dataset and confirming the "id" option.
> *** Begin Patch
> *** Update File: tests/test_web_table.py
> @@
> -def test_table_count_headings(page: Any, multi_table_server_url: str) -> None
:
> - run_query(
> - page,
> - multi_table_server_url,
> - order_by="id",
> - group_by=["id"],
> - aggregate="Count",
> - )
> - page.click("text=Columns")
> - page.wait_for_selector("#column_groups input", state="attached")
> - page.click("#columns_all")
> - page.click("text=View Settings")
> - page.evaluate("window.lastResults = undefined")
> - page.click("text=Dive")
> - page.wait_for_function("window.lastResults !== undefined")
> +def test_table_count_headings(page: Any, multi_table_server_url: str) -> None
:
> + page.goto(f"{multi_table_server_url}?graph_type=table")
> + page.wait_for_selector("#order_by option", state="attached")
> + page.click("text=Columns")
> + page.wait_for_selector("#column_groups input", state="attached")
> + page.click("#columns_all")
> + page.click("text=View Settings")
> + page.evaluate("g => { groupBy.chips = g; groupBy.renderChips(); }", ["id"
])
> + select_value(page, "#aggregate", "Count")
> + select_value(page, "#order_by", "id")
> + page.evaluate("window.lastResults = undefined")
> + page.click("text=Dive")
> + page.wait_for_function("window.lastResults !== undefined")
> *** End Patch
> PATCH
Success. Updated the following files:
M tests/test_web_table.py
(scubaduck) root@df047255e87d:/workspace/scubaduck#
Looks like I need to reimport select_value from tests.web_utils after all.
(scubaduck) root@df047255e87d:/workspace/scubaduck# apply_patch<<'PATCH'
> *** Begin Patch
> *** Update File: tests/test_web_table.py
> @@
> -from tests.web_utils import run_query
> +from tests.web_utils import run_query, select_value
> *** End Patch
> PATCH
Success. Updated the following files:
M tests/test_web_table.py
(scubaduck) root@df047255e87d:/workspace/scubaduck# ruff format tests/conftest.p
y tests/test_web_table.py scubaduck/server.py >/tmp/ruff_format.log && tail -n 2
0 /tmp/ruff_format.log
3 files left unchanged
(scubaduck) root@df047255e87d:/workspace/scubaduck#
I'll look into the ruff tool now.
(scubaduck) root@df047255e87d:/workspace/scubaduck# ruff check >/tmp/ruff_check.
log && tail -n 20 /tmp/ruff_check.log
All checks passed!
(scubaduck) root@df047255e87d:/workspace/scubaduck# pyright >/tmp/pyright.log &&
tail -n 20 /tmp/pyright.log
0 errors, 0 warnings, 0 informations
(scubaduck) root@df047255e87d:/workspace/scubaduck#
I’ll try running pytest once more to check the results.
(scubaduck) root@df047255e87d:/workspace/scubaduck# pytest -q >/tmp/pytest.log &
& tail -n 20 /tmp/pytest.log
bringing up nodes...
bringing up nodes...
........................................................................ [ 66%]
..................................... [100%]
109 passed in 14.15s
(scubaduck) root@df047255e87d:/workspace/scubaduck#
All tests passed. Time to check the git status and commit the changes.
(scubaduck) root@df047255e87d:/workspace/scubaduck# git status --short
M scubaduck/server.py
M scubaduck/static/js/view_settings.js
M tests/conftest.py
M tests/test_web_table.py
(scubaduck) root@df047255e87d:/workspace/scubaduck# git add scubaduck/server.py
scubaduck/static/js/view_settings.js tests/conftest.py tests/test_web_table.py
(scubaduck) root@df047255e87d:/workspace/scubaduck# git commit -m "fix(table): h
ide unused columns on count" && git status --short
[work 1cfce19] fix(table): hide unused columns on count
4 files changed, 62 insertions(+), 10 deletions(-)
(scubaduck) root@df047255e87d:/workspace/scubaduck# git status --short
(scubaduck) root@df047255e87d:/workspace/scubaduck#
I need to gather citations for the patch. Let's include lines 230-255 from server.py.
sed -n '228,259p'
228 )
229 select_parts.extend(_quote(c) for c in select_cols)
230 agg = (params.aggregate or "count").lower()
231
232 def agg_expr(col: str) -> str:
233 expr = _quote(col)
234 ctype = column_types.get(col, "").upper() if column_types el
se ""
235 if "BOOL" in ctype:
236 expr = f"CAST({_quote(col)} AS BIGINT)"
237 if agg.startswith("p"):
238 quant = float(agg[1:]) / 100
239 return f"quantile({expr}, {quant})"
240 if agg == "count distinct":
241 return f"count(DISTINCT {expr})"
242 if agg == "avg" and column_types is not None:
243 if "TIMESTAMP" in ctype or "DATE" in ctype or "TIME" in
ctype:
244 return (
245 "TIMESTAMP 'epoch' + INTERVAL '1 second' * "
246 f"CAST(avg(epoch({_quote(col)})) AS BIGINT)"
247 )
248 return f"{agg}({expr})"
249
250 if agg == "count":
251 if params.graph_type == "table" and params.show_hits:
252 select_parts.insert(len(group_cols), "count(*) AS Hits")
253 else:
254 select_parts.append("count(*) AS Count")
255 if params.show_hits:
256 select_parts.insert(len(group_cols), "count(*) AS Hi
ts")
257 else:
258 for col in params.columns:
259 if col in group_cols:
(scubaduck) root@df047255e87d:/workspace/scubaduck#
I’ll check out the patch lines for view_settings.js next.
(scubaduck) root@df047255e87d:/workspace/scubaduck# nl -ba scubaduck/static/js/v
iew_settings.js | sed -n '430,470p'
430
431 function updateSelectedColumns(type = graphTypeSel.value) {
432 const base = allColumns.filter(name => {
433 const cb = document.querySelector(`#column_groups input[value="${nam
e}"]`);
434 if (!cb || !cb.checked) return false;
435 if (type === 'table' && isStringColumn(name)) return false;
436 return true;
437 });
438 let storeCols;
439 if (type === 'table' || type === 'timeseries') {
440 storeCols = groupBy.chips.slice();
441 if (document.getElementById('show_hits').checked) storeCols.push('Hi
ts');
442 base.forEach(c => {
443 if (!storeCols.includes(c)) storeCols.push(c);
444 });
445 derivedColumns.forEach(dc => {
446 if (dc.include && !storeCols.includes(dc.name)) storeCols.push(dc.
name);
447 });
448 selectedColumns = storeCols.slice();
449 if (type === 'table') {
450 const agg = document.getElementById('aggregate').value.toLowerCase
();
451 if (agg === 'count') {
452 selectedColumns = groupBy.chips.slice();
453 if (document.getElementById('show_hits').checked) selectedColumn
s.push('Hits');
454 }
455 }
456 } else {
457 selectedColumns = base.slice();
458 derivedColumns.forEach(dc => {
459 if (dc.include) selectedColumns.push(dc.name);
460 });
461 storeCols = selectedColumns.slice();
462 }
463 columnValues[type] = storeCols.slice();
464 updateColumnsTabCount();
465 }
466
467 function isStringColumn(name) {
468 const t = (columnTypes[name] || '').toUpperCase();
469 return t.includes('CHAR') || t.includes('STRING') || t.includes('VARCH
AR');
470 }
(scubaduck) root@df047255e87d:/workspace/scubaduck#
I'm adding lines to the test for the new functionality in the web table.
(scubaduck) root@df047255e87d:/workspace/scubaduck# nl -ba tests/test_web_table.
py | sed -n '300,336p'
300 order_by="timestamp",
301 limit=10,
302 )
303 sql = data["sql"]
304 displayed = page.text_content("#sql_query")
305 assert displayed is not None
306 assert displayed.strip() == sql
307
308
309 def test_table_count_headings(page: Any, multi_table_server_url: str) ->
None:
310 page.goto(f"{multi_table_server_url}?graph_type=table")
311 page.wait_for_selector("#order_by option", state="attached")
312 page.click("text=Columns")
313 page.wait_for_selector("#column_groups input", state="attached")
314 page.click("#columns_all")
315 page.click("text=View Settings")
316 page.evaluate("g => { groupBy.chips = g; groupBy.renderChips(); }",
["id"])
317 select_value(page, "#aggregate", "Count")
318 select_value(page, "#order_by", "id")
319 page.evaluate("window.lastResults = undefined")
320 page.click("text=Dive")
321 page.wait_for_function("window.lastResults !== undefined")
322 headers = page.locator("#results th").all_inner_texts()
323 assert headers == ["id", "Hits"]
324 row_count = page.evaluate("document.querySelectorAll('#results tr').
length")
325 assert row_count == 3
326 col_count = page.evaluate(
327 "document.querySelectorAll('#results tr:nth-child(2) td').length
"
328 )
329 assert col_count == 2
(scubaduck) root@df047255e87d:/workspace/scubaduck#
I’ll add the necessary lines from tests/conftest to include the new fixture.
(scubaduck) root@df047255e87d:/workspace/scubaduck# nl -ba tests/conftest.py | s
ed -n '1,40p'
1 from __future__ import annotations
2
3 import threading
4 from collections.abc import Iterator
5
6 import pytest
7 from werkzeug.serving import make_server
8
9 from scubaduck.server import app, create_app
10
11
12 @pytest.fixture()
13 def server_url() -> Iterator[str]:
14 httpd = make_server("127.0.0.1", 0, app)
15 port = httpd.server_port
16 thread = threading.Thread(target=httpd.serve_forever)
17 thread.start()
18 try:
19 yield f"http://127.0.0.1:{port}"
20 finally:
21 httpd.shutdown()
22 thread.join()
23
24
25 @pytest.fixture()
26 def multi_table_server_url() -> Iterator[str]:
27 app = create_app("TEST")
28 httpd = make_server("127.0.0.1", 0, app)
29 port = httpd.server_port
30 thread = threading.Thread(target=httpd.serve_forever)
31 thread.start()
32 try:
33 yield f"http://127.0.0.1:{port}"
34 finally:
35 httpd.shutdown()
36 thread.join()
(scubaduck) root@df047255e87d:/workspace/scubaduck#
I’ll draft the PR body, highlighting changes, referencing lines, and confirming passing tests.