Skip to content
Open
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
14 changes: 11 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,12 @@ 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)
robotdashboard -r age=10d,tag=nightly # remove 'nightly' runs older than 10 days, leave others
robotdashboard -r age=10d,tag=nightly,tag=prod # scope to multiple tags
# 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 +132,12 @@ 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.
- 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`
- With age=10d,tag=nightly only runs **carrying that tag** older than 10 days are removed; runs without the tag are left untouched. Add more `tag=` values to scope to multiple tags.
- `tag` can be combined with **either** `limit` **or** `age` (not both at once — that is an error). The combination is detected automatically and an `INFO` line explains exactly what will be deleted; if other options (index/alias/run_start) are present too, the deletion order is printed.
- 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
3 changes: 3 additions & 0 deletions docs/dashboard-server.md
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,9 @@ 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"]}`). |
| **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. |
| **By Age + Tag(s)** | Supply `age` together with `tags` to scope the age threshold: only runs matching any given tag within the age range are deleted, runs without those tags are left untouched (e.g. `{"age": "10d", "tags": ["nightly"]}`). |
| **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
99 changes: 95 additions & 4 deletions robotframework_dashboard/arguments.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,92 @@ 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' or 'age'+'tags')
and rewrites it into the internal 'limit=N;tag=...' / 'age=X;tag=...'
form so the retention acts on the tagged subset instead of running as
independent operations. This lets users combine options with the regular
comma separator (e.g. '-r "limit=10,tag=nightly"') instead of ';'.

Rules:
- 'tags' may be combined with EITHER 'limit' OR 'age', not both (error).
- Only one 'limit'/'age' may be combined with tags (error otherwise).
- When a combination is detected an INFO message explains exactly what
will be deleted; if other options are also present, the deletion order
is printed too.
"""
limit_parts = [p for p in parts if p.startswith("limit=")]
age_parts = [p for p in parts if p.startswith("age=")]
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("age=")
or p.startswith("tag=")
)
]
has_limit, has_age, has_tag = bool(limit_parts), bool(age_parts), bool(tag_parts)

# No tags, or tags without a retention partner -> no combination,
# behave exactly as before (every part is an independent operation).
if not has_tag or (not has_limit and not has_age):
return parts

# Tags require exactly one retention partner.
if has_limit and has_age:
print(
" ERROR: Cannot combine 'limit' and 'age' with 'tags' at the same time.\n"
" Provide either 'limit'+'tag(s)' (keep N newest tagged runs) OR\n"
" 'age'+'tag(s)' (remove tagged runs in an age range), not both."
)
exit(3)
if len(limit_parts) > 1:
print(" ERROR: Only one 'limit' may be combined with 'tag(s)'.")
exit(4)
if len(age_parts) > 1:
print(" ERROR: Only one 'age' may be combined with 'tag(s)'.")
exit(5)

tag_values = [p.replace("tag=", "") for p in tag_parts]
tag_suffix = "".join(f";tag={value}" for value in tag_values)
tag_list = ", ".join(tag_values)
if has_limit:
retention = limit_parts[0]
limit_value = retention.replace("limit=", "")
combo_message = (
f" INFO: Combining 'limit' with 'tag(s)' -> keeping the {limit_value} most recent "
f"run(s) tagged with [{tag_list}] and removing older matching runs "
f"(runs without these tags are left untouched)."
)
else:
retention = age_parts[0]
age_value = retention.replace("age=", "")
direction = "younger than" if age_value.startswith("-") else "older than"
combo_message = (
f" INFO: Combining 'age' with 'tag(s)' -> removing run(s) tagged with "
f"[{tag_list}] that are {direction} {age_value.lstrip('-')} "
f"(runs without these tags are left untouched)."
)
combo = f"{retention}{tag_suffix}"

# Independent options (index/run_start/alias) keep their original order
# and run before the scoped combination.
result = other_parts + [combo]
if other_parts:
print(
" INFO: A scoped combination was detected alongside other remove options.\n"
" They will be deleted in this order:"
)
for position, part in enumerate(other_parts, start=1):
print(f" {position}. {part}")
print(f" {len(other_parts) + 1}. {combo} (the scoped combination)")
print(combo_message)
return result

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,8 +361,11 @@ 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"
" • '-r age=10d,tag=nightly' -> remove 'nightly' runs older than 10 days, leave others\n"
" • combine 'tag' with EITHER 'limit' OR 'age' (not both) to scope retention to those tag(s)\n"
" • (y)ear/(d)ay/(h)our/(m)inute/(s)econd supported\n"
),
action="append",
Expand Down Expand Up @@ -531,11 +620,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
66 changes: 52 additions & 14 deletions robotframework_dashboard/database.py
Original file line number Diff line number Diff line change
Expand Up @@ -433,12 +433,12 @@ 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 "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)
console += self._remove_by_limit(run, run_starts, run_tags)
elif "age=" in run:
console += self._remove_by_age(run, run_starts)
console += self._remove_by_age(run, run_starts, run_tags)
elif "tag=" in run:
console += self._remove_by_tag(run, run_starts, run_tags)
else:
print(
f" ERROR: incorrect usage of the remove_run feature ({run}), check out robotdashboard --help for instructions"
Expand Down Expand Up @@ -513,33 +513,70 @@ 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]}"
)
console += f" Removed run from the database: index={index}, run_start={run_starts[index]}\n"
return console

def _remove_by_age(self, run_query: str, run_starts: list):
def _remove_by_age(self, run_query: str, run_starts: list, run_tags: list = None):
"""Remove runs by age threshold.

When tag filters are appended (e.g. 'age=10d;tag=nightly;tag=prod'),
only runs matching any of those tags are considered: matching runs that
fall within the age range are removed, runs without the tag(s) are left
untouched.
"""
console = ""
parts = run_query.split(";")
tag_filters = [
part.replace("tag=", "") for part in parts[1:] if part.startswith("tag=")
]
try:
clean_query = run_query.replace("age=", "")
clean_query = parts[0].replace("age=", "")
mod, delta = self.parse_time_range(clean_query)
except ValueError as e:
return f" ERROR: {e}"
cutoff = datetime.now(timezone.utc)-delta
targets = []
for r in run_starts:
for index, r in enumerate(run_starts):
if tag_filters and run_tags is not None:
if not any(tag in run_tags[index] for tag in tag_filters):
continue
try:
run_dt = datetime.fromisoformat(r)
if run_dt.tzinfo is None:
Expand All @@ -553,7 +590,8 @@ 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}"
scope = f" with tag(s) {', '.join(tag_filters)}" if tag_filters else ""
console += f" WARNING: no runs were removed as no runs{scope} were within range {clean_query}"
return console
for run_to_remove in targets:
self._remove_run(run_to_remove)
Expand Down
Loading
Loading