diff --git a/docs/basic-command-line-interface-cli.md b/docs/basic-command-line-interface-cli.md index f2e005e3..0fb201a7 100644 --- a/docs/basic-command-line-interface-cli.md +++ b/docs/basic-command-line-interface-cli.md @@ -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" @@ -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). diff --git a/docs/dashboard-server.md b/docs/dashboard-server.md index 3a4118f0..b284ecc9 100644 --- a/docs/dashboard-server.md +++ b/docs/dashboard-server.md @@ -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. diff --git a/robotframework_dashboard/arguments.py b/robotframework_dashboard/arguments.py index 4db71db6..c8bebe71 100644 --- a/robotframework_dashboard/arguments.py +++ b/robotframework_dashboard/arguments.py @@ -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 @@ -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" @@ -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( diff --git a/robotframework_dashboard/database.py b/robotframework_dashboard/database.py index 928b50a1..bb17c299 100644 --- a/robotframework_dashboard/database.py +++ b/robotframework_dashboard/database.py @@ -433,10 +433,13 @@ 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: @@ -444,11 +447,11 @@ def remove_runs(self, remove_runs: list): 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): @@ -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]}" @@ -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: @@ -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) diff --git a/robotframework_dashboard/server.py b/robotframework_dashboard/server.py index 2be5222f..4a8eb21f 100644 --- a/robotframework_dashboard/server.py +++ b/robotframework_dashboard/server.py @@ -113,6 +113,7 @@ "tags": ["tag1", "tag2", "tag3"], }, {"limit": 10}, + {"limit": 10, "tags": ["nightly"]}, {"age": "10d"}, {"age": "-10d"}, {"all": True}, @@ -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.", @@ -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() diff --git a/robotframework_dashboard/templates/admin.html b/robotframework_dashboard/templates/admin.html index 8595971c..5c75b712 100644 --- a/robotframework_dashboard/templates/admin.html +++ b/robotframework_dashboard/templates/admin.html @@ -248,7 +248,7 @@