Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions docs/basic-command-line-interface-cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,8 +114,10 @@ robotdashboard -r index=0,index=1:4;9,index=10
robotdashboard --removeruns 'run_start=2024-07-30 15:27:20.184407,index=20'
robotdashboard -r alias=some_cool_alias,tag=prod,tag=dev -r alias=alias12345
robotdashboard -r limit=10
robotdashboard -r age=10d # (y)ear/(d)ay/(h)our/(m)inute/(s)econd supported
robotdashboard -r age=-10d
robotdashboard -r limit=10,tag=nightly # keep 10 newest 'nightly' runs, leave others
robotdashboard -r limit=10,tag=nightly,tag=prod # scope to multiple tags
robotdashboard -r age=10d # remove runs OLDER than 10 days. (y)ear/(d)ay/(h)our/(m)inute/(s)econd supported
robotdashboard -r age=-10d # remove runs YOUNGER than 10 days (note the leading minus)
# Log data of removed runs in jsonl
robotdashboard -r limit=10 --logremoved "/myLogDir/removedRuns.jsonl"
robotdashboard -r limit=10 --logremoved "run:suite:/myLogDir/removedRuns.jsonl"
Expand All @@ -128,8 +130,10 @@ robotdashboard -r limit=10 --logremoved run:keyword
- Index ranges use `:` for ranges and `;` for lists.
- Quotation marks are required when spaces exist in identifiers.
- With limit=10 only the 10 most recent runs will be kept, all others will be removed.
- With limit=10,tag=nightly only the 10 most recent runs **carrying that tag** are kept; older tagged runs are removed and runs without the tag are left untouched. Add more `tag=` values to scope to multiple tags. Only `limit` supports this tag scoping — `tag` and `age` combined just run as two independent operations.
- With age=10d only runs _**older**_ than 10 days will be removed
- With age=-10d only runs _**younger**_ than 10 days will be removed
- With age=-10d (leading minus) only runs _**younger**_ than 10 days will be removed
- Supported age units: (y)ear, (d)ay, (h)our, (m)inute, (s)econd — e.g. `age=12h`, `age=-30m`
- Optional: `--logremoved` logs run data to a `.jsonl` file before removal.
- Format: `[types:]path` where types are colon-separated from `run`, `suite`, `test`, `keyword`, `all`.
- If no types are specified, defaults to `all` (runs, suites, tests and keywords).
Expand Down
2 changes: 2 additions & 0 deletions docs/dashboard-server.md
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,8 @@ The admin page supports four methods for adding test results:
| **By Alias** | Comma-separated alias names. |
| **By Tag** | Comma-separated tags — removes all runs matching any of the specified tags. |
| **By Limit** | Keep only the N most recent runs; all older runs are deleted. |
| **By Limit + Tag(s)** | Supply `limit` together with `tags` to scope the limit: the N most recent runs matching any given tag are kept, older matching runs are deleted, and runs without those tags are left untouched (e.g. `{"limit": 10, "tags": ["nightly"]}`). Only `limit` supports this tag scoping — `age` + `tags` together just run as two independent operations. |
| **By Age** | Remove runs by age threshold. `"10d"` removes runs **older** than 10 days; a leading minus `"-10d"` removes runs **younger** than 10 days. Units: (y)ear, (d)ay, (h)our, (m)inute, (s)econd. |
| **Remove All** | Irreversibly deletes all runs from the database. |

> **Note:** When a run is removed, the server automatically checks whether a corresponding log file exists in the `robot_logs/` folder (derived by replacing `output` → `log` and `.xml` → `.html` in the stored path). If found, it is deleted alongside the run. The console response will confirm whether a log was removed or note that none was found.
Expand Down
52 changes: 48 additions & 4 deletions robotframework_dashboard/arguments.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,47 @@ def _check_project_version_usage(self, tags, arguments):
print(" ERROR: Mixing --projectversion and version_ tags not supported")
exit(2)

def _process_remove_runs(self, parts):
"""Process and validate the comma-separated --removeruns parts.

Detects a scoped-retention combination ('limit' + 'tags') and rewrites
it into the internal 'limit=N;tag=...' form so the limit acts on the
tagged subset instead of running as two independent operations. This
lets users write the combo with the regular comma separator (e.g.
'-r "limit=10,tag=nightly"') instead of ';'.

Scope note (matches issue #309): only 'limit' can be scoped by tags.
'age' + 'tags' is intentionally NOT supported here — combining them
just runs as two independent operations, same as on main.

Rules:
- No tags, or tags without a 'limit' partner -> no combination, every
part stays independent (unchanged behavior, including 'age'+'tags').
- Only one 'limit' may be combined with tags (error otherwise).
"""
limit_parts = [p for p in parts if p.startswith("limit=")]
tag_parts = [p for p in parts if p.startswith("tag=")]
other_parts = [
p for p in parts if not (p.startswith("limit=") or p.startswith("tag="))
]

if not tag_parts or not limit_parts:
return parts
if len(limit_parts) > 1:
print(" ERROR: Only one 'limit' may be combined with 'tag(s)'.")
exit(3)

tag_values = [p.replace("tag=", "") for p in tag_parts]
limit_value = limit_parts[0].replace("limit=", "")
combo = limit_parts[0] + "".join(f";tag={value}" for value in tag_values)
print(
f" INFO: Combining 'limit' with 'tag(s)' -> keeping the {limit_value} most recent "
f"run(s) tagged with [{', '.join(tag_values)}] and removing older matching runs "
f"(runs without these tags are left untouched)."
)
# other independent options (index/run_start/alias) run before the combo
return other_parts + [combo]

def _check_argument_warnings(self, arguments, outputs, outputfolderpaths, use_logs, generate_dashboard, no_autoupdate, offline_dependencies):
"""Checks for argument combinations that are valid but likely unintended and prints warnings"""
no_outputs = not outputs and not outputfolderpaths
Expand Down Expand Up @@ -275,6 +316,7 @@ def _parse_arguments(self):
" • '-r run_start=2024-07-30 15:27:20.184407' -> remove specified run\n"
" • '-r alias=some_alias,tag=prod'\n"
" • '-r limit=10' -> keep only the 10 most recent runs\n"
" • '-r limit=10,tag=nightly' -> keep 10 newest 'nightly' runs, leave others\n"
" • '-r age=10d' -> remove runs older than 10 days\n"
" • '-r age=-10d' -> remove runs younger than 10 days\n"
" • (y)ear/(d)ay/(h)our/(m)inute/(s)econd supported\n"
Expand Down Expand Up @@ -531,11 +573,13 @@ def _process_arguments(self, arguments):
# handles the processing of --removeruns
remove_runs = None
if arguments.removeruns:
remove_runs = []
raw_parts = []
for runs in arguments.removeruns:
parts = str(runs[0]).split(",")
for part in parts:
remove_runs.append(part)
for part in str(runs[0]).split(","):
part = part.strip()
if part:
raw_parts.append(part)
remove_runs = self._process_remove_runs(raw_parts)

# handles the boolean handling of relevant arguments
generate_dashboard = self._normalize_bool(
Expand Down
59 changes: 46 additions & 13 deletions robotframework_dashboard/database.py
Original file line number Diff line number Diff line change
Expand Up @@ -433,22 +433,25 @@ def remove_runs(self, remove_runs: list):
console += self._remove_by_index(run, run_starts)
elif "alias=" in run:
console += self._remove_by_alias(run, run_starts, run_aliases)
elif "limit=" in run:
# checked before "tag=" because a scoped combo ("limit=10;tag=x")
# still contains the substring "tag=" and would otherwise be
# misrouted to _remove_by_tag
console += self._remove_by_limit(run, run_starts, run_tags)
elif "tag=" in run:
console += self._remove_by_tag(run, run_starts, run_tags)
elif "limit=" in run:
console += self._remove_by_limit(run, run_starts)
elif "age=" in run:
console += self._remove_by_age(run, run_starts)
else:
print(
f" ERROR: incorrect usage of the remove_run feature ({run}), check out robotdashboard --help for instructions"
)
console += f" ERROR: incorrect usage of the remove_run feature ({run}), check out robotdashboard --help for instructions\n"
except:
except Exception as error:
print(
f" ERROR: Could not find run to remove from the database: {run}, check out robotdashboard --help for instructions"
f" ERROR: Could not remove run: {run}, reason: {error}, check out robotdashboard --help for instructions"
)
console += f" ERROR: Could not find run to remove from the database: {run}, check out robotdashboard --help for instructions\n"
console += f" ERROR: Could not remove run: {run}, reason: {error}, check out robotdashboard --help for instructions\n"
return console

def _remove_by_run_start(self, run: str, run_starts: list):
Expand Down Expand Up @@ -513,16 +516,39 @@ def _remove_by_tag(self, run: str, run_starts: list, run_tags: list):
console += f" WARNING: no runs were removed as no runs were found with tag: {tag}\n"
return console

def _remove_by_limit(self, run: str, run_starts: list):
def _remove_by_limit(self, run: str, run_starts: list, run_tags: list = None):
"""Keep the N newest runs, removing older ones.

When tag filters are appended (e.g. 'limit=10;tag=nightly;tag=prod'),
the limit is scoped to runs matching any of those tags: the N newest
matching runs are kept, older matching runs are removed, and runs that
do not match any tag are left untouched.
"""
console = ""
limit = int(run.replace("limit=", ""))
if limit >= len(run_starts):
parts = run.split(";")
limit = int(parts[0].replace("limit=", ""))
tag_filters = [
part.replace("tag=", "") for part in parts[1:] if part.startswith("tag=")
]
# run_starts are ordered oldest -> newest, so keeping the N newest means
# dropping the leading (oldest) candidates.
if tag_filters and run_tags is not None:
candidates = [
index
for index, run_tag in enumerate(run_tags)
if any(tag in run_tag for tag in tag_filters)
]
scope = f" with tag(s) {', '.join(tag_filters)}"
else:
candidates = list(range(len(run_starts)))
scope = ""
if limit >= len(candidates):
print(
f" WARNING: no runs were removed as the provided limit ({limit}) is higher than the total number of runs ({len(run_starts)})"
f" WARNING: no runs were removed as the provided limit ({limit}) is higher than the total number of runs{scope} ({len(candidates)})"
)
console += f" WARNING: no runs were removed as the provided limit ({limit}) is higher than the total number of runs ({len(run_starts)})\n"
console += f" WARNING: no runs were removed as the provided limit ({limit}) is higher than the total number of runs{scope} ({len(candidates)})\n"
return console
for index in range(len(run_starts) - limit):
for index in candidates[: len(candidates) - limit]:
self._remove_run(run_starts[index])
print(
f" Removed run from the database: index={index}, run_start={run_starts[index]}"
Expand All @@ -531,12 +557,16 @@ def _remove_by_limit(self, run: str, run_starts: list):
return console

def _remove_by_age(self, run_query: str, run_starts: list):
# NOTE: issue #309 / PR #313 only asked for tag-scoped retention on
# "limit" (see _remove_by_limit); age intentionally has no tag scoping
# here, matching the original issue's proposed direction.
console = ""
try:
clean_query = run_query.replace("age=", "")
mod, delta = self.parse_time_range(clean_query)
except ValueError as e:
return f" ERROR: {e}"
print(f" ERROR: {e}")
return f" ERROR: {e}\n"
cutoff = datetime.now(timezone.utc)-delta
targets = []
for r in run_starts:
Expand All @@ -553,7 +583,10 @@ def _remove_by_age(self, run_query: str, run_starts: list):
except ValueError as e:
print(f" WARNING: Skipping invalid timestamp: '{r}' ({e})")
if not targets:
console += f" WARNING: no runs were removed as no runs were within range {clean_query}"
print(
f" WARNING: no runs were removed as no runs were within range {clean_query}"
)
console += f" WARNING: no runs were removed as no runs were within range {clean_query}\n"
return console
for run_to_remove in targets:
self._remove_run(run_to_remove)
Expand Down
25 changes: 23 additions & 2 deletions robotframework_dashboard/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@
"tags": ["tag1", "tag2", "tag3"],
},
{"limit": 10},
{"limit": 10, "tags": ["nightly"]},
{"age": "10d"},
{"age": "-10d"},
{"all": True},
Expand Down Expand Up @@ -147,11 +148,19 @@
"description": "Remove runs older than a threshold (e.g., '10d') or younger than a threshold (e.g., '-10d'). Supports (y)ear/(d)ay/(h)our/(m)inute/(s)econd.",
"value": {"age": "10d"},
},
# NOTE: 'age' intentionally has no tag-scoped variant — see issue #309,
# which only asked for tag-scoped retention on 'limit' (below).
# 'age' + 'tags' together just run as two independent operations.
"limit": {
"summary": "Remove all but the N most recent runs",
"description": "Keep only the specified number of most recent runs, deleting the rest.",
"value": {"limit": 10},
},
"limit_by_tag": {
"summary": "Keep N most recent runs within a tag",
"description": "When 'tags' is combined with 'limit', the limit is scoped to runs matching any given tag: the N newest matching runs are kept, older matching runs are removed, and runs without those tags are left untouched.",
"value": {"limit": 10, "tags": ["nightly"]},
},
"all": {
"summary": "Remove all outputs",
"description": "Delete all runs currently stored in the database. This is irreversible.",
Expand Down Expand Up @@ -621,13 +630,25 @@ async def remove_outputs_from_database(
if remove_output.aliases != None:
for run in remove_output.aliases:
remove_runs.append(f"alias={run}")
if remove_output.tags != None:
# When tags are combined with limit, scope the limit to the
# tagged runs (keep/remove only matching runs, leave others
# alone) instead of removing all tagged runs outright.
# NOTE: 'age' has no tag-scoped variant (see issue #309,
# which only requested this for 'limit') — 'age' + 'tags'
# together just run as two independent operations below.
scope_tags = remove_output.tags != None and remove_output.limit != None
tag_suffix = (
"".join(f";tag={tag}" for tag in remove_output.tags)
if scope_tags
else ""
)
if remove_output.tags != None and not scope_tags:
for run in remove_output.tags:
remove_runs.append(f"tag={run}")
if remove_output.age != None:
remove_runs.append(f"age={remove_output.age}")
if remove_output.limit != None:
remove_runs.append(f"limit={remove_output.limit}")
remove_runs.append(f"limit={remove_output.limit}{tag_suffix}")
paths_before = self.robotdashboard.get_run_paths()
console = self.robotdashboard.remove_outputs(remove_runs)
paths_after = self.robotdashboard.get_run_paths()
Expand Down
2 changes: 1 addition & 1 deletion robotframework_dashboard/templates/admin.html
Original file line number Diff line number Diff line change
Expand Up @@ -248,7 +248,7 @@ <h3>Remove output.xml(s) From Database</h3>
</div>
<div class="col-8">
<label class="form-label form-label-sm mt-1" for="removeLimit">E.g. "10" (number of most recent
runs to keep)</label>
runs to keep). Combine with the tag field above to keep the N newest runs per tag (other runs untouched).</label>
<input class="form-control form-control-sm" id="removeLimit" type="number"></input>
</div>
<div class="col-2 d-flex">
Expand Down
62 changes: 62 additions & 0 deletions tests/python/test_arguments.py
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,68 @@ def test_process_arguments_with_removeruns():
assert result.remove_runs == ["index=0", "index=1"]


# --- _process_remove_runs: scoped retention combinations ---

def test_remove_runs_passthrough_without_combination():
# No tags -> nothing combined
assert ArgumentParser()._process_remove_runs(["index=0", "limit=10"]) == [
"index=0",
"limit=10",
]


def test_remove_runs_tag_only_passthrough():
# tags without a limit/age partner stay independent (full wipe)
assert ArgumentParser()._process_remove_runs(["tag=dev"]) == ["tag=dev"]


def test_remove_runs_limit_and_tag_combined():
assert ArgumentParser()._process_remove_runs(["limit=10", "tag=dev"]) == [
"limit=10;tag=dev"
]


def test_remove_runs_age_and_tag_not_combined():
# issue #309 only asked for tag-scoped retention on "limit" — "age" + "tag"
# is intentionally left as two independent operations, unchanged from main
assert ArgumentParser()._process_remove_runs(["age=10d", "tag=dev"]) == [
"age=10d",
"tag=dev",
]


def test_remove_runs_limit_and_multiple_tags_combined():
assert ArgumentParser()._process_remove_runs(
["limit=10", "tag=dev", "tag=prod"]
) == ["limit=10;tag=dev;tag=prod"]


def test_remove_runs_combination_with_other_options_keeps_order():
# independent ops first (in order), scoped combination last
assert ArgumentParser()._process_remove_runs(
["index=0", "limit=10", "tag=dev"]
) == ["index=0", "limit=10;tag=dev"]


def test_remove_runs_multiple_limits_with_tag_errors(capsys):
with pytest.raises(SystemExit) as exc:
ArgumentParser()._process_remove_runs(["limit=10", "limit=5", "tag=dev"])
assert exc.value.code == 3
assert "Only one 'limit'" in capsys.readouterr().out


def test_remove_runs_emits_info_message(capsys):
ArgumentParser()._process_remove_runs(["limit=10", "tag=dev"])
out = capsys.readouterr().out
assert "INFO: Combining 'limit' with 'tag(s)'" in out


def test_remove_runs_combination_via_process_arguments():
args = _make_namespace(removeruns=[["limit=10,tag=dev"]])
result = ArgumentParser()._process_arguments(args)
assert result.remove_runs == ["limit=10;tag=dev"]


def test_process_arguments_with_messageconfig(tmp_path):
msg_file = tmp_path / "messages.txt"
msg_file.write_text("Template: ${name}\nLine 2\n")
Expand Down
Loading
Loading