From c8855bbf79762f9cf53bc2edef0567e56033381f Mon Sep 17 00:00:00 2001 From: pierre merriaux Date: Thu, 28 May 2026 21:07:29 -0400 Subject: [PATCH 01/13] fix: stale cross-machine locks + body stream already read in UI --- aim/sdk/lock_manager.py | 27 +++++++++++++---- aim/sdk/repo.py | 2 +- .../ui/src/services/NetworkService/index.ts | 13 ++++++-- aim/web/ui/src/services/api/api.ts | 30 ++++++++++++------- 4 files changed, 52 insertions(+), 20 deletions(-) diff --git a/aim/sdk/lock_manager.py b/aim/sdk/lock_manager.py index d44f226d63..4d21c13fd9 100644 --- a/aim/sdk/lock_manager.py +++ b/aim/sdk/lock_manager.py @@ -20,6 +20,10 @@ logger = logging.getLogger(__name__) +# Locks written by a different machine cannot have their PID checked remotely. +# Treat them as stale after this many hours of inactivity. +STALE_LOCK_THRESHOLD_HOURS = 4 + class LockingVersion(Enum): LEGACY = 0 @@ -164,8 +168,21 @@ def release_locks(self, run_hash: str, force: bool) -> bool: return success def is_stalled_lock(self, lock_file_path: Path) -> bool: - with open(lock_file_path, mode='r') as lock_metadata_fh: - machine_id, pid, *_ = lock_metadata_fh.read().split('-') - if int(machine_id) == self.machine_id and not psutil.pid_exists(int(pid)): - return True - return False + try: + with open(lock_file_path, mode='r') as lock_metadata_fh: + parts = lock_metadata_fh.read().strip().split('-') + machine_id, pid = int(parts[0]), int(parts[1]) + if machine_id == self.machine_id: + # Same machine: check if the owning process is still alive. + return not psutil.pid_exists(pid) + # Cross-machine lock: the remote PID cannot be checked from here. + # Fall back to file age — if the lock file has not been refreshed + # in STALE_LOCK_THRESHOLD_HOURS it is almost certainly abandoned + # (e.g. a training node that was killed with SIGKILL or SIGTERM + # without releasing the lock). + age = datetime.datetime.now() - datetime.datetime.fromtimestamp( + lock_file_path.stat().st_mtime + ) + return age > datetime.timedelta(hours=STALE_LOCK_THRESHOLD_HOURS) + except Exception: + return False diff --git a/aim/sdk/repo.py b/aim/sdk/repo.py index 1ffef1c9b4..bec9b41202 100644 --- a/aim/sdk/repo.py +++ b/aim/sdk/repo.py @@ -963,7 +963,7 @@ def optimize_container(path, extra_options): rc.optimize_for_read() if self.is_remote_repo: - self._remote_repo_proxy._close_run(run_hash) + return self._remote_repo_proxy._close_run(run_hash) lock_manager = LockManager(self.path) diff --git a/aim/web/ui/src/services/NetworkService/index.ts b/aim/web/ui/src/services/NetworkService/index.ts index 2b455a17d3..32bb73996e 100644 --- a/aim/web/ui/src/services/NetworkService/index.ts +++ b/aim/web/ui/src/services/NetworkService/index.ts @@ -169,9 +169,16 @@ class NetworkService { } if (response.status >= 400) { - return await this.checkCredentials(response, url, () => - this.request(url, options), - ); + // Body may already be consumed above (JSON content-type). Only + // call checkCredentials for 401 (token refresh). For all other + // errors, reject with the already-parsed body so callers get a + // proper error instead of "body stream already read". + if (response.status === 401) { + return await this.checkCredentials(response, url, () => + this.request(url, options), + ); + } + return reject({ message: (body as any)?.message || 'Request failed', res: { body, headers } }); } return resolve({ body, headers }); diff --git a/aim/web/ui/src/services/api/api.ts b/aim/web/ui/src/services/api/api.ts index 93615f5a97..ea642d818d 100644 --- a/aim/web/ui/src/services/api/api.ts +++ b/aim/web/ui/src/services/api/api.ts @@ -48,22 +48,30 @@ function createAPIRequestWrapper( try { if (response.status >= 400) { const body = await response.json(); + // Body stream is now consumed — must not call response.json() again. if (typeof exceptionHandler === 'function') { exceptionHandler(body); } - return await checkCredentials( - response, - url, - () => - createAPIRequestWrapper( - url, - options, - stream, - apiHost, - ).call(exceptionHandler), - ); + // Only attempt a token refresh + retry for 401. For any other + // error the body is already consumed; passing `response` to + // checkCredentials() would call parseResponse() → response.json() + // a second time → "body stream already read". + if (response.status === 401) { + return await checkCredentials( + response, + url, + () => + createAPIRequestWrapper( + url, + options, + stream, + apiHost, + ).call(exceptionHandler), + ); + } + return; } const data = stream ? response.body : await response.json(); From b1d0481aaf533058e2087b7a83181b476593d279 Mon Sep 17 00:00:00 2001 From: pierre merriaux Date: Fri, 29 May 2026 17:24:28 -0400 Subject: [PATCH 02/13] fix(api): skip corrupted runs in active streamer + fix 404 detail format --- aim/web/api/runs/utils.py | 29 +++++++++++++++++++---------- 1 file changed, 19 insertions(+), 10 deletions(-) diff --git a/aim/web/api/runs/utils.py b/aim/web/api/runs/utils.py index bef9a8ef7d..1c0149e315 100644 --- a/aim/web/api/runs/utils.py +++ b/aim/web/api/runs/utils.py @@ -41,7 +41,7 @@ def get_run_or_404(run_id, repo=None): run = repo.get_run(run_id) if not run: - raise HTTPException(status_code=404, detail='Run not found.') + raise HTTPException(status_code=404, detail={'message': 'Run not found.'}) return run @@ -305,6 +305,8 @@ async def run_search_result_streamer( async def run_active_result_streamer(repo: 'Repo', report_progress: Optional[bool] = True): + import logging as _logging + _logger = _logging.getLogger(__name__) try: active_run_hashes = repo.list_active_runs() @@ -314,17 +316,24 @@ async def run_active_result_streamer(repo: 'Repo', report_progress: Optional[boo for run_hash in active_run_hashes: await asyncio.sleep(ASYNC_SLEEP_INTERVAL) - run = Run(run_hash, repo=repo, read_only=True) - if run.active: - run_dict = { - run.hash: { - 'props': get_run_props(run), - 'traces': run.collect_sequence_info(sequence_types='metric'), + try: + run = Run(run_hash, repo=repo, read_only=True) + if run.active: + run_dict = { + run.hash: { + 'props': get_run_props(run), + 'traces': run.collect_sequence_info(sequence_types='metric'), + } } - } - encoded_tree = encode_tree(run_dict) - yield collect_streamable_data(encoded_tree) + encoded_tree = encode_tree(run_dict) + yield collect_streamable_data(encoded_tree) + except Exception as e: + # Skip corrupted or inaccessible runs — a single bad run must + # not abort the entire active-runs stream and cause the server + # to return a partial/invalid response that the frontend tries + # to parse as JSON (leading to "body stream already read"). + _logger.warning(f'Skipping active run {run_hash}: {e}') if report_progress: yield collect_streamable_data( From 5ae773caa0e82d9886d16efb63f290933f2a6744 Mon Sep 17 00:00:00 2001 From: pierre merriaux Date: Mon, 1 Jun 2026 11:54:07 -0400 Subject: [PATCH 03/13] fix(sdk): remove progress file in _close_run + harden index queue thread --- aim/sdk/index_manager.py | 15 +++++++++++++-- aim/sdk/repo.py | 7 +++++++ 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/aim/sdk/index_manager.py b/aim/sdk/index_manager.py index 166e6ae0e8..74cc2089bb 100644 --- a/aim/sdk/index_manager.py +++ b/aim/sdk/index_manager.py @@ -176,8 +176,14 @@ def _process_indexing_queue(self): while not self._stop_event.is_set(): _, run_hash = self.indexing_queue.get() logger.debug(f'Indexing run {run_hash}...') - self.index(run_hash) - self.indexing_queue.task_done() + try: + self.index(run_hash) + except Exception as e: + # An unhandled exception here would silently kill this daemon + # thread, leaving the indexing queue permanently stalled. + logger.error(f'Unexpected error indexing run {run_hash}: {e}') + finally: + self.indexing_queue.task_done() def index(self, run_hash): index = self.repo._get_index_tree('meta', 0).view(()) @@ -197,6 +203,11 @@ def index(self, run_hash): except (aimrocks.errors.RocksIOError, aimrocks.errors.Corruption): logger.warning(f'Indexing thread detected corrupted run: {run_hash}. Skipping.') self._corrupted_runs.add(run_hash) + except Exception as e: + # Catch-all: log and skip rather than propagating to + # _process_indexing_queue where it would kill the thread. + logger.warning(f'Indexing run {run_hash} failed unexpectedly: {e}. Skipping.') + self._corrupted_runs.add(run_hash) return True def _is_run_index_outdated(self, run_hash, index_db): diff --git a/aim/sdk/repo.py b/aim/sdk/repo.py index bec9b41202..9fd8c204cf 100644 --- a/aim/sdk/repo.py +++ b/aim/sdk/repo.py @@ -978,6 +978,13 @@ def optimize_container(path, extra_options): if not meta_run_tree.get('end_time'): meta_run_tree['end_time'] = datetime.datetime.now(pytz.utc).timestamp() + # Remove the progress file so list_active_runs() no longer returns + # this run. Without this, a crashed run stays in meta/progress/ forever + # and the active-runs streamer keeps polling it on every UI refresh. + progress_path = os.path.join(self.path, 'meta', 'progress', run_hash) + if os.path.exists(progress_path): + os.remove(progress_path) + # Run rocksdb optimizations if container locks are removed meta_db_path = os.path.join(self.path, 'meta', 'chunks', run_hash) seqs_db_path = os.path.join(self.path, 'seqs', 'chunks', run_hash) From 97257e3f88dd97360316e4a9ecf7e6652a14c45c Mon Sep 17 00:00:00 2001 From: pierre merriaux Date: Wed, 3 Jun 2026 18:43:08 -0400 Subject: [PATCH 04/13] fix: stale locks, body stream already read, daemon hardening --- aim/sdk/index_manager.py | 36 ++++++++++++++--------- aim/web/api/runs/object_api_utils.py | 43 +++++++++++++++++++--------- 2 files changed, 52 insertions(+), 27 deletions(-) diff --git a/aim/sdk/index_manager.py b/aim/sdk/index_manager.py index 74cc2089bb..04ff8228fc 100644 --- a/aim/sdk/index_manager.py +++ b/aim/sdk/index_manager.py @@ -134,19 +134,25 @@ def stop(self): def _monitor_existing_chunks(self): while not self._stop_event.is_set(): - index_db = self.repo.request_tree('meta', read_only=True) - monitored_chunks = set(self._watches.keys()) - for chunk_path in self.chunks_dir.iterdir(): - if ( - chunk_path.is_dir() - and chunk_path.name not in monitored_chunks - and self._is_run_index_outdated(chunk_path.name, index_db) - ): - logger.debug(f'Monitoring existing chunk: {chunk_path}') - self.monitor_chunk_directory(chunk_path) - logger.debug(f'Triggering indexing for run {chunk_path.name}') - self.add_run_to_queue(chunk_path.name) - self.repo.container_pool.clear() + try: + index_db = self.repo.request_tree('meta', read_only=True) + monitored_chunks = set(self._watches.keys()) + for chunk_path in self.chunks_dir.iterdir(): + try: + if ( + chunk_path.is_dir() + and chunk_path.name not in monitored_chunks + and self._is_run_index_outdated(chunk_path.name, index_db) + ): + logger.debug(f'Monitoring existing chunk: {chunk_path}') + self.monitor_chunk_directory(chunk_path) + logger.debug(f'Triggering indexing for run {chunk_path.name}') + self.add_run_to_queue(chunk_path.name) + except Exception as e: + logger.warning(f'Error checking chunk {chunk_path}: {e}') + self.repo.container_pool.clear() + except Exception as e: + logger.error(f'_monitor_existing_chunks iteration failed: {e}') time.sleep(5) def _stop_monitoring_chunk(self, run_hash): @@ -186,8 +192,8 @@ def _process_indexing_queue(self): self.indexing_queue.task_done() def index(self, run_hash): - index = self.repo._get_index_tree('meta', 0).view(()) try: + index = self.repo._get_index_tree('meta', 0).view(()) run_checksum = self._get_run_checksum(run_hash) meta_tree = self.repo.request_tree('meta', run_hash, read_only=True, skip_read_optimization=True).subtree( 'meta' @@ -203,11 +209,13 @@ def index(self, run_hash): except (aimrocks.errors.RocksIOError, aimrocks.errors.Corruption): logger.warning(f'Indexing thread detected corrupted run: {run_hash}. Skipping.') self._corrupted_runs.add(run_hash) + self._stop_monitoring_chunk(run_hash) except Exception as e: # Catch-all: log and skip rather than propagating to # _process_indexing_queue where it would kill the thread. logger.warning(f'Indexing run {run_hash} failed unexpectedly: {e}. Skipping.') self._corrupted_runs.add(run_hash) + self._stop_monitoring_chunk(run_hash) return True def _is_run_index_outdated(self, run_hash, index_db): diff --git a/aim/web/api/runs/object_api_utils.py b/aim/web/api/runs/object_api_utils.py index 81c0e704ea..ece1d574dc 100644 --- a/aim/web/api/runs/object_api_utils.py +++ b/aim/web/api/runs/object_api_utils.py @@ -25,10 +25,15 @@ def get_blobs_batch(uri_batch: List[str], repo: 'Repo') -> Iterator[bytes]: + import logging as _logging + _logger = _logging.getLogger(__name__) uri_service = URIService(repo=repo) batch_iterator = uri_service.request_batch(uri_batch=uri_batch) - for it in batch_iterator: - yield collect_streamable_data(encode_tree(it)) + try: + for it in batch_iterator: + yield collect_streamable_data(encode_tree(it)) + except Exception as e: + _logger.warning(f'get_blobs_batch: skipping blob due to error: {e}') class CustomObjectApi: @@ -143,10 +148,17 @@ def _pack_run_data(run_: 'Run', traces_: list): progress_reports_sent += 1 last_reported_progress_time = time.time() if run_info.get('traces') and run_info.get('run'): - traces_list = [] - for trace in run_info['traces']: - traces_list.append(self._get_trace_info(trace, True, True)) - yield _pack_run_data(run_info['run'], traces_list) + try: + traces_list = [] + for trace in run_info['traces']: + traces_list.append(self._get_trace_info(trace, True, True)) + yield _pack_run_data(run_info['run'], traces_list) + except Exception as _e: + import logging as _logging + _logging.getLogger(__name__).warning( + f'search_result_streamer: skipping run {run_info["run"].hash} due to error: {_e}' + ) + continue if report_progress: yield collect_streamable_data( encode_tree({f'progress_{progress_reports_sent}': run_info['progress']}) @@ -162,18 +174,23 @@ def _pack_run_data(run_: 'Run', traces_: list): pass async def requested_traces_streamer(self) -> List[dict]: + import logging as _logging + _logger = _logging.getLogger(__name__) try: for key in list(self.trace_cache.keys()): run_info = self.trace_cache[key] await asyncio.sleep(ASYNC_SLEEP_INTERVAL) for trace in run_info['traces']: - trace_dict = self._get_trace_info(trace, False, False) - trace_dict['record_range_used'] = self.record_range - trace_dict['record_range_total'] = self.total_record_range - if self.use_list: - trace_dict['index_range'] = self.index_range - trace_dict['index_range_total'] = self.total_index_range - yield collect_streamable_data(encode_tree(trace_dict)) + try: + trace_dict = self._get_trace_info(trace, False, False) + trace_dict['record_range_used'] = self.record_range + trace_dict['record_range_total'] = self.total_record_range + if self.use_list: + trace_dict['index_range'] = self.index_range + trace_dict['index_range_total'] = self.total_index_range + yield collect_streamable_data(encode_tree(trace_dict)) + except Exception as _e: + _logger.warning(f'requested_traces_streamer: skipping trace due to error: {_e}') del self.trace_cache[key] self.run = None except asyncio.CancelledError: From 7de899f595cf5c2ea43eec591f61d862eeeae7df Mon Sep 17 00:00:00 2001 From: pierre merriaux Date: Wed, 3 Jun 2026 18:48:31 -0400 Subject: [PATCH 05/13] fix: stale locks, body stream already read, daemon hardening --- aim/sdk/index_manager.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/aim/sdk/index_manager.py b/aim/sdk/index_manager.py index 04ff8228fc..9ea1272a06 100644 --- a/aim/sdk/index_manager.py +++ b/aim/sdk/index_manager.py @@ -209,13 +209,27 @@ def index(self, run_hash): except (aimrocks.errors.RocksIOError, aimrocks.errors.Corruption): logger.warning(f'Indexing thread detected corrupted run: {run_hash}. Skipping.') self._corrupted_runs.add(run_hash) +<<<<<<< ours +<<<<<<< ours self._stop_monitoring_chunk(run_hash) +======= +>>>>>>> theirs +======= + self._stop_monitoring_chunk(run_hash) +>>>>>>> theirs except Exception as e: # Catch-all: log and skip rather than propagating to # _process_indexing_queue where it would kill the thread. logger.warning(f'Indexing run {run_hash} failed unexpectedly: {e}. Skipping.') self._corrupted_runs.add(run_hash) +<<<<<<< ours +<<<<<<< ours + self._stop_monitoring_chunk(run_hash) +======= +>>>>>>> theirs +======= self._stop_monitoring_chunk(run_hash) +>>>>>>> theirs return True def _is_run_index_outdated(self, run_hash, index_db): From a4cbc81ec0d3b422e8cdd4b190b8a6083b33310e Mon Sep 17 00:00:00 2001 From: pierre merriaux Date: Wed, 3 Jun 2026 18:50:02 -0400 Subject: [PATCH 06/13] Revert "fix: stale locks, body stream already read, daemon hardening" This reverts commit 7de899f595cf5c2ea43eec591f61d862eeeae7df. --- aim/sdk/index_manager.py | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/aim/sdk/index_manager.py b/aim/sdk/index_manager.py index 9ea1272a06..04ff8228fc 100644 --- a/aim/sdk/index_manager.py +++ b/aim/sdk/index_manager.py @@ -209,27 +209,13 @@ def index(self, run_hash): except (aimrocks.errors.RocksIOError, aimrocks.errors.Corruption): logger.warning(f'Indexing thread detected corrupted run: {run_hash}. Skipping.') self._corrupted_runs.add(run_hash) -<<<<<<< ours -<<<<<<< ours self._stop_monitoring_chunk(run_hash) -======= ->>>>>>> theirs -======= - self._stop_monitoring_chunk(run_hash) ->>>>>>> theirs except Exception as e: # Catch-all: log and skip rather than propagating to # _process_indexing_queue where it would kill the thread. logger.warning(f'Indexing run {run_hash} failed unexpectedly: {e}. Skipping.') self._corrupted_runs.add(run_hash) -<<<<<<< ours -<<<<<<< ours - self._stop_monitoring_chunk(run_hash) -======= ->>>>>>> theirs -======= self._stop_monitoring_chunk(run_hash) ->>>>>>> theirs return True def _is_run_index_outdated(self, run_hash, index_db): From c11da3bbcbdd9478c5b8cb55ca2d96e4fbc01cea Mon Sep 17 00:00:00 2001 From: pierre merriaux Date: Thu, 4 Jun 2026 10:15:39 -0400 Subject: [PATCH 07/13] fix: guard list_corrupted_runs against empty decode_path --- aim/sdk/repo.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/aim/sdk/repo.py b/aim/sdk/repo.py index 9fd8c204cf..ad0d9369ca 100644 --- a/aim/sdk/repo.py +++ b/aim/sdk/repo.py @@ -397,10 +397,11 @@ def list_corrupted_runs(self) -> List[str]: from aim.storage.encoding import decode_path def get_run_hash_from_prefix(prefix: bytes): - return decode_path(prefix)[-1] + parts = decode_path(prefix) + return parts[-1] if parts else None container = RocksUnionContainer(os.path.join(self.path, 'meta'), read_only=True) - return list(map(get_run_hash_from_prefix, container.corrupted_dbs)) + return [h for h in map(get_run_hash_from_prefix, container.corrupted_dbs) if h is not None] def _active_run_hashes(self) -> Set[str]: if self.is_remote_repo: From c76937750aa3484d09bc2b824f4969c5df717fa2 Mon Sep 17 00:00:00 2001 From: pierre merriaux Date: Thu, 4 Jun 2026 12:02:38 -0400 Subject: [PATCH 08/13] fix: catch per-run exceptions in metric and run search streamers --- aim/web/api/runs/utils.py | 109 +++++++++++++++++++++----------------- 1 file changed, 60 insertions(+), 49 deletions(-) diff --git a/aim/web/api/runs/utils.py b/aim/web/api/runs/utils.py index 1c0149e315..c417a08a04 100644 --- a/aim/web/api/runs/utils.py +++ b/aim/web/api/runs/utils.py @@ -198,6 +198,8 @@ async def metric_search_result_streamer( x_axis: Optional[str] = None, report_progress: Optional[bool] = True, ) -> bytes: + import logging as _logging + _logger = _logging.getLogger(__name__) try: last_reported_progress_time = time.time() progress = None @@ -213,43 +215,47 @@ async def metric_search_result_streamer( run = None traces_list = [] - for trace in run_trace_collection.iter(): - if not run: - run = run_trace_collection.run - iters, (values, epochs, timestamps) = trace.data.sample(steps_num).numpy() - - x_axis_trace = run.get_metric(x_axis, trace.context) if x_axis else None - x_axis_iters, x_axis_values = collect_x_axis_data(x_axis_trace, iters) - - traces_list.append( - { - 'name': trace.name, - 'context': trace.context.to_dict(), - 'slice': [0, 0, steps_num], # TODO [AT] change once UI is ready - 'values': numpy_to_encodable(values), - 'iters': numpy_to_encodable(iters), - 'epochs': numpy_to_encodable(epochs), - 'timestamps': numpy_to_encodable(timestamps), - 'x_axis_values': x_axis_values, - 'x_axis_iters': x_axis_iters, - } - ) + try: + for trace in run_trace_collection.iter(): + if not run: + run = run_trace_collection.run + iters, (values, epochs, timestamps) = trace.data.sample(steps_num).numpy() + + x_axis_trace = run.get_metric(x_axis, trace.context) if x_axis else None + x_axis_iters, x_axis_values = collect_x_axis_data(x_axis_trace, iters) + + traces_list.append( + { + 'name': trace.name, + 'context': trace.context.to_dict(), + 'slice': [0, 0, steps_num], # TODO [AT] change once UI is ready + 'values': numpy_to_encodable(values), + 'iters': numpy_to_encodable(iters), + 'epochs': numpy_to_encodable(epochs), + 'timestamps': numpy_to_encodable(timestamps), + 'x_axis_values': x_axis_values, + 'x_axis_iters': x_axis_iters, + } + ) - if run: - run_dict = { - run.hash: { - 'params': get_run_params(run, skip_system=skip_system), - 'traces': traces_list, - 'props': get_run_props(run), + if run: + run_dict = { + run.hash: { + 'params': get_run_params(run, skip_system=skip_system), + 'traces': traces_list, + 'props': get_run_props(run), + } } - } - encoded_tree = encode_tree(run_dict) - yield collect_streamable_data(encoded_tree) - if report_progress: - yield collect_streamable_data(encode_tree({f'progress_{progress_reports_sent}': progress})) - progress_reports_sent += 1 - last_reported_progress_time = time.time() + encoded_tree = encode_tree(run_dict) + yield collect_streamable_data(encoded_tree) + if report_progress: + yield collect_streamable_data(encode_tree({f'progress_{progress_reports_sent}': progress})) + progress_reports_sent += 1 + last_reported_progress_time = time.time() + except Exception as e: + run_hash = run.hash if run else 'unknown' + _logger.warning(f'metric_search_result_streamer: skipping run {run_hash}: {e}') if report_progress and progress: yield collect_streamable_data(encode_tree({f'progress_{progress_reports_sent}': progress})) @@ -265,6 +271,8 @@ async def run_search_result_streamer( exclude_params: Optional[bool] = False, exclude_traces: Optional[bool] = False, ) -> bytes: + import logging as _logging + _logger = _logging.getLogger(__name__) try: run_count = 0 last_reported_progress_time = time.time() @@ -281,22 +289,25 @@ async def run_search_result_streamer( last_reported_progress_time = time.time() if not run_trace_collection: continue - run = run_trace_collection.run - run_dict = {run.hash: {'props': get_run_props(run)}} - if not exclude_params: - run_dict[run.hash]['params'] = get_run_params(run, skip_system=skip_system) - if not exclude_traces: - run_dict[run.hash]['traces'] = run.collect_sequence_info(sequence_types='metric') + try: + run = run_trace_collection.run + run_dict = {run.hash: {'props': get_run_props(run)}} + if not exclude_params: + run_dict[run.hash]['params'] = get_run_params(run, skip_system=skip_system) + if not exclude_traces: + run_dict[run.hash]['traces'] = run.collect_sequence_info(sequence_types='metric') - encoded_tree = encode_tree(run_dict) - yield collect_streamable_data(encoded_tree) - if report_progress: - yield collect_streamable_data(encode_tree({f'progress_{progress_reports_sent}': progress})) - progress_reports_sent += 1 - last_reported_progress_time = time.time() - run_count += 1 - if limit and run_count >= limit: - break + encoded_tree = encode_tree(run_dict) + yield collect_streamable_data(encoded_tree) + if report_progress: + yield collect_streamable_data(encode_tree({f'progress_{progress_reports_sent}': progress})) + progress_reports_sent += 1 + last_reported_progress_time = time.time() + run_count += 1 + if limit and run_count >= limit: + break + except Exception as e: + _logger.warning(f'run_search_result_streamer: skipping run: {e}') if report_progress and progress: yield collect_streamable_data(encode_tree({f'progress_{progress_reports_sent}': progress})) From 9108f2836c790657dbacfc55bf3c49894729a11a Mon Sep 17 00:00:00 2001 From: pierre merriaux Date: Thu, 4 Jun 2026 12:09:49 -0400 Subject: [PATCH 09/13] fix: cap watchdog watches and force GC after indexing to prevent fd leak --- aim/sdk/index_manager.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/aim/sdk/index_manager.py b/aim/sdk/index_manager.py index 04ff8228fc..6ce05892f9 100644 --- a/aim/sdk/index_manager.py +++ b/aim/sdk/index_manager.py @@ -161,9 +161,19 @@ def _stop_monitoring_chunk(self, run_hash): self.chunk_change_observer.unschedule(watch) logger.debug(f'Stopped monitoring chunk: {run_hash}') + # Maximum number of chunk directories to watch simultaneously. + # PollingObserver opens file descriptors for each watch; capping this + # prevents "Too many open files" when there are hundreds of runs. + MAX_WATCHED_CHUNKS = 50 + def monitor_chunk_directory(self, chunk_path): """Ensure chunk directory is monitored using a single handler.""" if chunk_path.name not in self._watches: + if len(self._watches) >= self.MAX_WATCHED_CHUNKS: + # Drop the oldest watch to stay under the fd limit. + oldest = next(iter(self._watches)) + self._stop_monitoring_chunk(oldest) + logger.debug(f'Watch limit reached, dropped oldest watch: {oldest}') watch = self.chunk_change_observer.schedule(self.chunk_change_handler, chunk_path, recursive=True) self._watches[chunk_path.name] = watch logger.debug(f'Started monitoring chunk directory: {chunk_path}') @@ -192,6 +202,7 @@ def _process_indexing_queue(self): self.indexing_queue.task_done() def index(self, run_hash): + import gc try: index = self.repo._get_index_tree('meta', 0).view(()) run_checksum = self._get_run_checksum(run_hash) @@ -216,6 +227,12 @@ def index(self, run_hash): logger.warning(f'Indexing run {run_hash} failed unexpectedly: {e}. Skipping.') self._corrupted_runs.add(run_hash) self._stop_monitoring_chunk(run_hash) + finally: + # Release TreeView references so RocksDB containers can be GC'd + # promptly. Without this, WeakValueDictionary entries stay alive + # until the next GC cycle, accumulating open file descriptors. + self.repo.container_pool.clear() + gc.collect() return True def _is_run_index_outdated(self, run_hash, index_db): From cd1040387c13cf7f3233ab6d1f79c646710c9699 Mon Sep 17 00:00:00 2001 From: pierre merriaux Date: Mon, 8 Jun 2026 18:12:20 -0400 Subject: [PATCH 10/13] fix: increase index lock timeout in _delete_local_run_data --- aim/sdk/repo.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/aim/sdk/repo.py b/aim/sdk/repo.py index ad0d9369ca..130432ff23 100644 --- a/aim/sdk/repo.py +++ b/aim/sdk/repo.py @@ -807,7 +807,9 @@ def _delete_experiment(self, exp_id): def _delete_local_run_data(self, run_hash: str): # remove data from index container - index_tree = self._get_index_container('meta', timeout=0).tree() + # timeout=30: the index daemon holds the LOCK continuously; give it + # enough time to finish its current write before we acquire it. + index_tree = self._get_index_container('meta', timeout=30).tree() del index_tree.subtree(('meta', 'chunks'))[run_hash] # delete rocksdb containers data From a5458a8b0b614e70b0503519600ee5a2f56a3cb7 Mon Sep 17 00:00:00 2001 From: pierre merriaux Date: Thu, 25 Jun 2026 03:06:12 -0400 Subject: [PATCH 11/13] fix unclosed runs --- aim/sdk/reporter/__init__.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/aim/sdk/reporter/__init__.py b/aim/sdk/reporter/__init__.py index b1d6f8a453..41381aeed3 100644 --- a/aim/sdk/reporter/__init__.py +++ b/aim/sdk/reporter/__init__.py @@ -804,3 +804,8 @@ def _run(self): def stop(self): self.stop_signal.set() self.thread.join() + if self.touch_path is not None and self.touch_path.exists(): + try: + self.touch_path.unlink() + except OSError: + pass From 8c346b78ff1ef3d69ff6db36a49e281ad69aa39d Mon Sep 17 00:00:00 2001 From: pierre merriaux Date: Fri, 26 Jun 2026 11:11:27 -0400 Subject: [PATCH 12/13] feat(metrics): fullscreen charts, subset sections, runs legend toggle Legacy Metrics Explorer UI improvements: - Click a chart title to open it fullscreen (90% dialog) with the side toolbar (zoom, smoothing, ...) accessible inside the modal - Group charts into foldable sections by metric.context.subset (e.g. train / val) when present, to reduce clutter - Chart titles now show the metric name only, in bold, without the "metric.name=" prefix or surrounding quotes - Add a "Legend" switch next to Live Update to replace the table with a runs legend (name + plot color); bottom panel shrinks to fit it Bump version to 3.30.0 Co-Authored-By: Claude Opus 4.8 --- aim/VERSION | 2 +- aim/web/ui/package.json | 2 +- .../ChartPanel/ChartGrid/ChartGrid.d.ts | 1 + .../ChartPanel/ChartGrid/ChartGrid.scss | 73 +++++++ .../ChartPanel/ChartGrid/ChartGrid.tsx | 199 +++++++++++++++--- .../src/components/ChartPanel/ChartPanel.tsx | 1 + .../ui/src/components/LineChart/LineChart.tsx | 16 ++ aim/web/ui/src/pages/Metrics/Metrics.scss | 34 +++ aim/web/ui/src/pages/Metrics/Metrics.tsx | 67 +++++- .../components/MetricsBar/MetricsBar.scss | 9 + .../components/MetricsBar/MetricsBar.tsx | 19 +- .../types/components/LineChart/LineChart.d.ts | 1 + .../components/MetricsBar/MetricsBar.d.ts | 2 + aim/web/ui/src/utils/d3/drawArea.ts | 17 +- 14 files changed, 405 insertions(+), 38 deletions(-) diff --git a/aim/VERSION b/aim/VERSION index 1002be7fb7..1cfe511a3e 100644 --- a/aim/VERSION +++ b/aim/VERSION @@ -1 +1 @@ -3.29.1 +3.30.0 diff --git a/aim/web/ui/package.json b/aim/web/ui/package.json index 99ebb2bb8e..72c646daff 100644 --- a/aim/web/ui/package.json +++ b/aim/web/ui/package.json @@ -1,6 +1,6 @@ { "name": "ui_v2", - "version": "3.29.1", + "version": "3.30.0", "private": true, "dependencies": { "@aksel/structjs": "^1.0.0", diff --git a/aim/web/ui/src/components/ChartPanel/ChartGrid/ChartGrid.d.ts b/aim/web/ui/src/components/ChartPanel/ChartGrid/ChartGrid.d.ts index 0273a180b2..506c4d8b58 100644 --- a/aim/web/ui/src/components/ChartPanel/ChartGrid/ChartGrid.d.ts +++ b/aim/web/ui/src/components/ChartPanel/ChartGrid/ChartGrid.d.ts @@ -16,4 +16,5 @@ export interface IChartGridProps { syncHoverState?: (args: ISyncHoverStateArgs) => void; resizeMode?: ResizeModeEnum; onMount?: () => void; + controls?: React.ReactNode; } diff --git a/aim/web/ui/src/components/ChartPanel/ChartGrid/ChartGrid.scss b/aim/web/ui/src/components/ChartPanel/ChartGrid/ChartGrid.scss index 2e02b9bef9..d735862b06 100644 --- a/aim/web/ui/src/components/ChartPanel/ChartGrid/ChartGrid.scss +++ b/aim/web/ui/src/components/ChartPanel/ChartGrid/ChartGrid.scss @@ -4,4 +4,77 @@ min-height: 50%; padding: 4px; box-shadow: -1px -1px 0 0 $grey-lighter; + &__sections { + width: 100%; + height: 100%; + overflow: auto; + } + &__section { + width: 100%; + margin: 0 !important; + &:before { + display: none; + } + &__summary { + position: sticky; + top: 0; + z-index: 3; + background-color: $white; + border-bottom: $border-separator; + min-height: 2.5rem !important; + .MuiAccordionSummary-content { + align-items: center; + margin: $space-xs 0 !important; + } + } + &__count { + margin-left: $space-xs; + } + &__details { + display: flex; + flex-wrap: wrap; + width: 100%; + padding: 0 !important; + } + } + &__fullScreenDialog { + width: 90vw; + height: 90vh; + max-width: 90vw; + max-height: 90vh; + position: relative; + &__closeBtn { + position: absolute; + top: $space-xs; + right: $space-xs; + z-index: 2; + background-color: $white; + } + &__body { + display: flex; + width: 100%; + height: 100%; + } + &__chart { + flex: 1; + height: 100%; + min-width: 0; + padding: $space-md; + box-sizing: border-box; + } + &__controls { + width: 3.75rem; + flex-shrink: 0; + max-height: 100%; + overflow-y: auto; + border-left: $border-separator; + } + } +} + +// Inside foldable subset sections each chart needs a definite height, +// since the accordion details container is auto-sized (unlike the flat grid). +.ChartGrid__section__details > .ChartGrid { + height: 20rem; + min-height: 20rem; } diff --git a/aim/web/ui/src/components/ChartPanel/ChartGrid/ChartGrid.tsx b/aim/web/ui/src/components/ChartPanel/ChartGrid/ChartGrid.tsx index 8cf9ce15c6..4c4bdfa99f 100644 --- a/aim/web/ui/src/components/ChartPanel/ChartGrid/ChartGrid.tsx +++ b/aim/web/ui/src/components/ChartPanel/ChartGrid/ChartGrid.tsx @@ -1,9 +1,17 @@ import React from 'react'; -import { Grid, GridSize } from '@material-ui/core'; +import { + Grid, + GridSize, + Dialog, + Accordion, + AccordionSummary, + AccordionDetails, +} from '@material-ui/core'; import ErrorBoundary from 'components/ErrorBoundary/ErrorBoundary'; import { CHART_TYPES_CONFIG } from 'components/ChartPanel/config'; +import { Button, Icon, Text } from 'components/kit'; import { GRID_SIZE, CHART_GRID_PATTERN } from 'config/charts'; @@ -11,6 +19,15 @@ import { IChartGridProps } from '.'; import './ChartGrid.scss'; +// Subset value carried by each line at runtime via metric.context.subset. +const SUBSET_CONTEXT_KEY = 'subset'; + +function getChartSubset(chartData: any): string | undefined { + const context = chartData?.[0]?.context; + const subset = context?.[SUBSET_CONTEXT_KEY]; + return subset === undefined || subset === null ? undefined : String(subset); +} + function ChartGrid({ data, chartType, @@ -22,38 +39,172 @@ function ChartGrid({ resizeMode, chartPanelOffsetHeight, onMount, + controls, }: IChartGridProps): React.FunctionComponentElement { + const [fullScreenIndex, setFullScreenIndex] = React.useState( + null, + ); + const [fullScreenChartReady, setFullScreenChartReady] = React.useState(false); + const fullScreenRef = React.useRef(null); + + React.useEffect(() => { + if (fullScreenIndex === null) { + setFullScreenChartReady(false); + return; + } + // wait until the dialog is laid out before mounting the D3 chart, + // otherwise it draws into a zero-sized container and the curve is missing + let raf2 = 0; + const raf1 = window.requestAnimationFrame(() => { + raf2 = window.requestAnimationFrame(() => setFullScreenChartReady(true)); + }); + return () => { + window.cancelAnimationFrame(raf1); + window.cancelAnimationFrame(raf2); + }; + }, [fullScreenIndex]); + function getGridSize(dataLength: number, index: number): GridSize { return ( dataLength > 9 ? GRID_SIZE.S : CHART_GRID_PATTERN[dataLength][index] ) as GridSize; } + + // Group chart panels into foldable sections by metric.context.subset. + // Only activated when at least two distinct subset values are present, + // otherwise the charts are rendered as a flat grid (original behaviour). + const subsetSections = React.useMemo(() => { + const sections: { subset: string | undefined; indices: number[] }[] = []; + const byKey: Record = {}; + data.forEach((chartData: any, index: number) => { + const subset = getChartSubset(chartData); + const key = subset ?? '__none__'; + if (!byKey[key]) { + byKey[key] = []; + sections.push({ subset, indices: byKey[key] }); + } + byKey[key].push(index); + }); + return sections; + }, [data]); + + const definedSubsetCount = subsetSections.filter( + (s) => s.subset !== undefined, + ).length; + const useSections = definedSubsetCount >= 2; + + function renderChart(globalIndex: number, gridSize: GridSize) { + const Component = CHART_TYPES_CONFIG[chartType]; + return ( + + setFullScreenIndex(globalIndex)} + /> + + ); + } + + const FullScreenComponent = + fullScreenIndex !== null ? CHART_TYPES_CONFIG[chartType] : null; + return ( - {data.map((chartData: any, index: number) => { - const Component = CHART_TYPES_CONFIG[chartType]; - const gridSize = getGridSize(data.length, index); - return ( - + {subsetSections.map((section) => ( + + } + className='ChartGrid__section__summary' + > + + {section.subset !== undefined + ? `${SUBSET_CONTEXT_KEY}: ${section.subset}` + : 'Other'} + + + {section.indices.length} chart + {section.indices.length > 1 ? 's' : ''} + + + + {section.indices.map((globalIndex, localIndex) => + renderChart( + globalIndex, + getGridSize(section.indices.length, localIndex), + ), + )} + + + ))} + + ) : ( + data.map((_chartData: any, index: number) => + renderChart(index, getGridSize(data.length, index)), + ) + )} + {FullScreenComponent && fullScreenIndex !== null && ( + setFullScreenIndex(null)} + maxWidth='lg' + fullWidth + transitionDuration={0} + classes={{ paper: 'ChartGrid__fullScreenDialog' }} + > + +
+
+ {fullScreenChartReady && ( + + )} +
+ {controls && ( +
+ {controls} +
+ )} +
+
+ )}
); } diff --git a/aim/web/ui/src/components/ChartPanel/ChartPanel.tsx b/aim/web/ui/src/components/ChartPanel/ChartPanel.tsx index 3cfede139c..d1b950f983 100644 --- a/aim/web/ui/src/components/ChartPanel/ChartPanel.tsx +++ b/aim/web/ui/src/components/ChartPanel/ChartPanel.tsx @@ -187,6 +187,7 @@ const ChartPanel = React.forwardRef(function ChartPanel( resizeMode={props.resizeMode} onMount={onChartMount} chartPanelOffsetHeight={props.chartPanelOffsetHeight} + controls={props.controls} /> + {onDoubleClick && ( +
+ )}
diff --git a/aim/web/ui/src/pages/Metrics/Metrics.scss b/aim/web/ui/src/pages/Metrics/Metrics.scss index 94ad5ccaea..1415801ae5 100644 --- a/aim/web/ui/src/pages/Metrics/Metrics.scss +++ b/aim/web/ui/src/pages/Metrics/Metrics.scss @@ -53,6 +53,40 @@ &.hide { display: none; } + // In legend view the bottom panel shrinks to fit the legend content + // instead of taking a fixed share of the height. + &.legendView { + flex: 0 0 auto !important; + height: auto !important; + min-height: 0 !important; + max-height: 40%; + } + } + .Metrics__runsLegend { + display: flex; + flex-wrap: wrap; + align-content: flex-start; + gap: $space-xs $space-md; + padding: $space-sm; + max-height: 100%; + overflow-y: auto; + &__item { + display: flex; + align-items: center; + max-width: 16rem; + } + &__color { + flex-shrink: 0; + width: 0.75rem; + height: 0.75rem; + border-radius: 2px; + margin-right: $space-xxs; + } + &__name { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } } } diff --git a/aim/web/ui/src/pages/Metrics/Metrics.tsx b/aim/web/ui/src/pages/Metrics/Metrics.tsx index 43b622d093..c3891a7abb 100644 --- a/aim/web/ui/src/pages/Metrics/Metrics.tsx +++ b/aim/web/ui/src/pages/Metrics/Metrics.tsx @@ -10,6 +10,7 @@ import ResizePanel from 'components/ResizePanel/ResizePanel'; import ErrorBoundary from 'components/ErrorBoundary/ErrorBoundary'; import Grouping from 'components/Grouping/Grouping'; import ProgressBar from 'components/ProgressBar/ProgressBar'; +import { Text } from 'components/kit'; import pageTitlesEnum from 'config/pageTitles/pageTitles'; import { ResizeModeEnum } from 'config/enums/tableEnums'; @@ -44,6 +45,7 @@ function Metrics( ): React.FunctionComponentElement { const [isProgressBarVisible, setIsProgressBarVisible] = React.useState(false); + const [tableView, setTableView] = React.useState<'table' | 'legend'>('table'); const chartProps = React.useMemo(() => { return (props.lineChartData || []).map((chartData: ILine[]) => ({ axesScaleType: props.axesScaleType, @@ -78,6 +80,26 @@ function Metrics( props.axesScaleRange, ]); + // Unique runs (name + plot color) shown as a legend when the table is collapsed. + const runsLegend = React.useMemo(() => { + const seen = new Map(); + (props.lineChartData || []).forEach((chartData: ILine[]) => { + chartData.forEach((line: any) => { + const hash = line?.run?.hash; + if (hash && !seen.has(hash)) { + seen.set(hash, { + name: line?.run?.props?.name || hash, + color: line?.color || '#000', + }); + } + }); + }); + return Array.from(seen.entries()).map(([hash, value]) => ({ + hash, + ...value, + })); + }, [props.lineChartData]); + return (
@@ -91,6 +113,8 @@ function Metrics( liveUpdateConfig={props.liveUpdateConfig} onLiveUpdateConfigChange={props.onLiveUpdateConfigChange} title={pageTitlesEnum.METRICS_EXPLORER} + tableView={tableView} + onTableViewChange={setTableView} />
{props.resizeMode === ResizeModeEnum.Hide ? null : ( - - + {tableView === 'legend' ? ( +
+ {runsLegend.length > 0 ? ( + runsLegend.map((run) => ( +
+ + + {run.name} + +
+ )) + ) : ( + + No runs to display + + )} +
+ ) : ( + +
- + /> + + )} + )} diff --git a/aim/web/ui/src/pages/Metrics/components/MetricsBar/MetricsBar.scss b/aim/web/ui/src/pages/Metrics/components/MetricsBar/MetricsBar.scss index 6933281196..2f6939ab0d 100644 --- a/aim/web/ui/src/pages/Metrics/components/MetricsBar/MetricsBar.scss +++ b/aim/web/ui/src/pages/Metrics/components/MetricsBar/MetricsBar.scss @@ -1,6 +1,15 @@ @use 'src/styles/abstracts' as *; .MetricsBar { + &__tableViewToggle { + display: flex; + align-items: center; + margin-right: $space-md; + &__Text { + margin-right: $space-xs; + white-space: nowrap; + } + } &__item__bookmark { margin-right: $space-xxxs; padding: 0 $space-xs !important; diff --git a/aim/web/ui/src/pages/Metrics/components/MetricsBar/MetricsBar.tsx b/aim/web/ui/src/pages/Metrics/components/MetricsBar/MetricsBar.tsx index cb3bd1a590..92a20c44c7 100644 --- a/aim/web/ui/src/pages/Metrics/components/MetricsBar/MetricsBar.tsx +++ b/aim/web/ui/src/pages/Metrics/components/MetricsBar/MetricsBar.tsx @@ -7,7 +7,7 @@ import BookmarkForm from 'components/BookmarkForm/BookmarkForm'; import AppBar from 'components/AppBar/AppBar'; import ControlPopover from 'components/ControlPopover/ControlPopover'; import LiveUpdateSettings from 'components/LiveUpdateSettings/LiveUpdateSettings'; -import { Button, Icon, Text } from 'components/kit'; +import { Button, Icon, Text, Switcher } from 'components/kit'; import ErrorBoundary from 'components/ErrorBoundary/ErrorBoundary'; import ConfirmModal from 'components/ConfirmModal/ConfirmModal'; @@ -26,6 +26,8 @@ function MetricsBar({ onBookmarkUpdate, onResetConfigData, onLiveUpdateConfigChange, + tableView, + onTableViewChange, }: IMetricsBarProps): React.FunctionComponentElement { const [popover, setPopover] = React.useState(''); @@ -47,6 +49,21 @@ function MetricsBar({ return ( + {onTableViewChange && ( +
+ + Legend: + + + onTableViewChange(tableView === 'legend' ? 'table' : 'legend') + } + size='small' + color='primary' + /> +
+ )} void; } export interface ILineChartRef { diff --git a/aim/web/ui/src/types/pages/metrics/components/MetricsBar/MetricsBar.d.ts b/aim/web/ui/src/types/pages/metrics/components/MetricsBar/MetricsBar.d.ts index 6ac878516a..b89415c572 100644 --- a/aim/web/ui/src/types/pages/metrics/components/MetricsBar/MetricsBar.d.ts +++ b/aim/web/ui/src/types/pages/metrics/components/MetricsBar/MetricsBar.d.ts @@ -12,4 +12,6 @@ export interface IMetricsBarProps { enabled?: boolean; }) => void; title: string; + tableView?: 'table' | 'legend'; + onTableViewChange?: (value: 'table' | 'legend') => void; } diff --git a/aim/web/ui/src/utils/d3/drawArea.ts b/aim/web/ui/src/utils/d3/drawArea.ts index 08e0982136..764ef183bc 100644 --- a/aim/web/ui/src/utils/d3/drawArea.ts +++ b/aim/web/ui/src/utils/d3/drawArea.ts @@ -112,19 +112,22 @@ function drawArea(args: IDrawAreaArgs): void { .attr('height', offsetHeight + 2 * CircleEnum.Radius); const titleText = Object.entries(chartTitle || {}) - .map( - ([key, value]) => - `${key}=${ - isSystemMetric(value) ? formatSystemMetricName(value) : value - }`, - ) + .map(([, value]) => { + const formatted = isSystemMetric(value) + ? formatSystemMetricName(value) + : value; + // strip the surrounding double quotes added by formatValue + return typeof formatted === 'string' + ? formatted.replace(/^"(.*)"$/, '$1') + : formatted; + }) .join(', '); const title = { x: margin.left / 6, fontSize: 11, fontFamily: 'Inter, sans-serif', - fontWeight: 400, + fontWeight: 600, chartIndex: { fontFamily: 'Inconsolata, monospace', }, From a4d3e9002111d80fce6570dc53d0b4ff254358e0 Mon Sep 17 00:00:00 2001 From: Pierre Date: Mon, 29 Jun 2026 09:11:32 +0000 Subject: [PATCH 13/13] fix(ui): format Metrics.tsx and NetworkService to fix production build prettier/eslint errors were blocking the webpack build on Node.js 20. Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_016tPLioKkPpxbuN5v5FS3P5 --- aim/web/ui/package-lock.json | 4 +- aim/web/ui/src/pages/Metrics/Metrics.tsx | 112 +++++++++--------- .../ui/src/services/NetworkService/index.ts | 5 +- 3 files changed, 64 insertions(+), 57 deletions(-) diff --git a/aim/web/ui/package-lock.json b/aim/web/ui/package-lock.json index 419717c377..cc87ff6e56 100755 --- a/aim/web/ui/package-lock.json +++ b/aim/web/ui/package-lock.json @@ -1,12 +1,12 @@ { "name": "ui_v2", - "version": "3.25.1", + "version": "3.30.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "ui_v2", - "version": "3.25.1", + "version": "3.30.0", "hasInstallScript": true, "dependencies": { "@aksel/structjs": "^1.0.0", diff --git a/aim/web/ui/src/pages/Metrics/Metrics.tsx b/aim/web/ui/src/pages/Metrics/Metrics.tsx index c3891a7abb..dcc05b006f 100644 --- a/aim/web/ui/src/pages/Metrics/Metrics.tsx +++ b/aim/web/ui/src/pages/Metrics/Metrics.tsx @@ -292,60 +292,64 @@ function Metrics( ) : (
)} diff --git a/aim/web/ui/src/services/NetworkService/index.ts b/aim/web/ui/src/services/NetworkService/index.ts index 32bb73996e..19f0a66373 100644 --- a/aim/web/ui/src/services/NetworkService/index.ts +++ b/aim/web/ui/src/services/NetworkService/index.ts @@ -178,7 +178,10 @@ class NetworkService { this.request(url, options), ); } - return reject({ message: (body as any)?.message || 'Request failed', res: { body, headers } }); + return reject({ + message: (body as any)?.message || 'Request failed', + res: { body, headers }, + }); } return resolve({ body, headers });