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 @@

Remove output.xml(s) From Database

+ runs to keep). Combine with the tag field above to keep the N newest runs per tag (other runs untouched).
diff --git a/tests/python/test_arguments.py b/tests/python/test_arguments.py index c0a677a7..593d878d 100644 --- a/tests/python/test_arguments.py +++ b/tests/python/test_arguments.py @@ -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") diff --git a/tests/python/test_database.py b/tests/python/test_database.py index 25c75a3d..5f24dc5d 100644 --- a/tests/python/test_database.py +++ b/tests/python/test_database.py @@ -201,6 +201,87 @@ def test_remove_by_limit_higher_than_count_is_noop(populated_db): assert len(populated_db.get_data()["runs"]) == 1 +# --- remove_runs by limit scoped to tag(s) (issue #309) --- + +def _insert_run(db, xml, tags): + """Helper: insert a run from the given XML with the given tags.""" + processor = OutputProcessor(xml) + processor.get_run_start() + data = processor.get_output_data() + db.insert_output_data(data, tags, None, xml, None) + + +def _tags_of(runs): + return [run["tags"] for run in runs] + + +def _run_starts(db): + return [run["run_start"] for run in db.get_data()["runs"]] + + +def test_remove_by_limit_with_single_tag_keeps_newest_matching(db): + db.open_database() + # oldest -> newest; three "nightly" runs + one unrelated "release" run + _insert_run(db, OUTPUTS_DIR / "output-20250313-002134.xml", ["nightly"]) + _insert_run(db, OUTPUTS_DIR / "output-20250313-002151.xml", ["nightly"]) + _insert_run(db, OUTPUTS_DIR / "output-20250313-002222.xml", ["nightly"]) + _insert_run(db, OUTPUTS_DIR / "output-20250313-002257.xml", ["release"]) + starts_before = _run_starts(db) # ordered oldest -> newest + db.remove_runs(["limit=2;tag=nightly"]) + starts_after = _run_starts(db) + # oldest nightly removed; 2 newest nightly + release remain + assert len(starts_after) == 3 + assert starts_before[0] not in starts_after # oldest nightly removed + assert starts_before[3] in starts_after # release untouched + db.close_database() + + +def test_remove_by_limit_with_multiple_tags(db): + db.open_database() + _insert_run(db, OUTPUTS_DIR / "output-20250313-002134.xml", ["alpha"]) + _insert_run(db, OUTPUTS_DIR / "output-20250313-002151.xml", ["beta"]) + _insert_run(db, OUTPUTS_DIR / "output-20250313-002222.xml", ["alpha"]) + _insert_run(db, OUTPUTS_DIR / "output-20250313-002257.xml", ["gamma"]) + starts_before = _run_starts(db) + # candidates = union of alpha+beta = 3 oldest runs; keep 2 newest of those + db.remove_runs(["limit=2;tag=alpha;tag=beta"]) + starts_after = _run_starts(db) + assert len(starts_after) == 3 + assert starts_before[0] not in starts_after # oldest alpha removed + assert starts_before[3] in starts_after # gamma untouched + db.close_database() + + +def test_remove_by_limit_with_tag_higher_than_count_is_noop(db): + db.open_database() + _insert_run(db, OUTPUTS_DIR / "output-20250313-002134.xml", ["nightly"]) + _insert_run(db, OUTPUTS_DIR / "output-20250313-002151.xml", ["nightly"]) + _insert_run(db, OUTPUTS_DIR / "output-20250313-002222.xml", ["other"]) + console = db.remove_runs(["limit=5;tag=nightly"]) + assert len(db.get_data()["runs"]) == 3 + assert "WARNING" in console + db.close_database() + + +def test_remove_by_limit_only_ignores_tags(db): + db.open_database() + _insert_run(db, OUTPUTS_DIR / "output-20250313-002134.xml", ["nightly"]) + _insert_run(db, OUTPUTS_DIR / "output-20250313-002151.xml", ["release"]) + _insert_run(db, OUTPUTS_DIR / "output-20250313-002222.xml", ["nightly"]) + starts_before = _run_starts(db) + # no tag scope -> global limit, keep 1 newest regardless of tag + db.remove_runs(["limit=1"]) + starts_after = _run_starts(db) + assert starts_after == [starts_before[-1]] # only the newest remains + db.close_database() + + +# NOTE: issue #309 only asked for tag-scoped retention on "limit" +# (see the block above). "age" intentionally has no tag-scoped variant: +# db.remove_runs(["age=10d", "tag=nightly"]) runs as two independent +# operations, same as before this feature — no dedicated test needed here +# beyond the existing plain "age=10d" / "tag=x" coverage. + # --- list_runs --- def test_list_runs_empty_prints_warning(db, capsys): diff --git a/tests/python/test_server.py b/tests/python/test_server.py index 96b9ad9a..a109f8c5 100644 --- a/tests/python/test_server.py +++ b/tests/python/test_server.py @@ -434,6 +434,37 @@ def test_remove_outputs_by_limit(): assert response.json()["success"] == "1" +def test_remove_outputs_by_limit_and_tags_builds_scoped_query(): + """limit + tags -> single scoped 'limit=N;tag=...' query, no standalone tag removals.""" + server = _make_server() + client = _client(server) + response = client.request( + "DELETE", + "/remove-outputs", + json={"limit": 5, "tags": ["nightly", "prod"]}, + ) + assert response.status_code == 200 + args = server.robotdashboard.remove_outputs.call_args[0][0] + assert args == ["limit=5;tag=nightly;tag=prod"] + # tags must not be removed independently when scoped to the limit + assert not any(r == "tag=nightly" for r in args) + + +def test_remove_outputs_by_age_and_tags_stay_independent(): + """age + tags -> issue #309 only scoped 'limit' by tags; 'age' + 'tags' + together still run as two independent operations, unchanged from main.""" + server = _make_server() + client = _client(server) + response = client.request( + "DELETE", + "/remove-outputs", + json={"age": "10d", "tags": ["nightly", "prod"]}, + ) + assert response.status_code == 200 + args = server.robotdashboard.remove_outputs.call_args[0][0] + assert args == ["tag=nightly", "tag=prod", "age=10d"] + + def test_remove_outputs_all_flag(): server = _make_server() server.robotdashboard.get_runs.return_value = ( diff --git a/tests/robot/resources/cli_output/help.txt b/tests/robot/resources/cli_output/help.txt index 85c79bd9..96a04367 100644 --- a/tests/robot/resources/cli_output/help.txt +++ b/tests/robot/resources/cli_output/help.txt @@ -70,6 +70,7 @@ database: • '-r run_start=2024-07-30 15:27:20.184407' -> remove specified run • '-r alias=some_alias,tag=prod' • '-r limit=10' -> keep only the 10 most recent runs + • '-r limit=10,tag=nightly' -> keep 10 newest 'nightly' runs, leave others • '-r age=10d' -> remove runs older than 10 days • '-r age=-10d' -> remove runs younger than 10 days • (y)ear/(d)ay/(h)our/(m)inute/(s)econd supported diff --git a/tests/robot/resources/cli_output/removerun.txt b/tests/robot/resources/cli_output/removerun.txt index 0de8a38f..ce1dfbd5 100644 --- a/tests/robot/resources/cli_output/removerun.txt +++ b/tests/robot/resources/cli_output/removerun.txt @@ -37,7 +37,7 @@ Removed run from the database: index=-1, run_start=2025-03-13 00:30:06.726165+??:?? Removed run from the database: index=6, run_start=2025-03-13 00:24:31.912429+??:?? ERROR: Could not find run to remove from the database: run_start=2025-03-13 00:27:39.871333 - ERROR: Could not find run to remove from the database: alias=abc, check out robotdashboard --help for instructions + ERROR: Could not remove run: alias=abc, reason: 'abc' is not in list, check out robotdashboard --help for instructions WARNING: no runs were removed as no runs were found with tag: tag1 Vacuumed the database in * seconds ======================================================================================