diff --git a/cmflib/cmf.py b/cmflib/cmf.py index b45fcc0fb..44f9a4da9 100644 --- a/cmflib/cmf.py +++ b/cmflib/cmf.py @@ -47,6 +47,7 @@ from cmflib import graph_wrapper from cmflib.store.sqllite_store import SqlliteStore from cmflib.store.postgres import PostgresStore +from cmflib.store.custom_sqlite_store import CustomSqliteStore from cmflib.metadata_helper import ( get_or_create_parent_context, get_or_create_run_context, @@ -169,6 +170,9 @@ def __init__( cur_folder = os.path.basename(os.getcwd()) pipeline_name = cur_folder self.pipeline_name = pipeline_name + self.custom_store = None + if is_server is False: + self.custom_store = CustomSqliteStore(filepath) self.store = temp_store.connect() self.filepath = filepath self.child_context = None @@ -487,7 +491,7 @@ def create_execution( #print(f"{python_env_file_path} doesn't exists!!") with open(python_env_file_path, 'w') as file: file.write(env_output) - + if self.graph: self.driver.create_execution_node( self.execution_name, @@ -759,7 +763,7 @@ def log_dataset( # If the dataset already exist , then we just link the existing dataset to the execution # We do not update the dataset properties . # We need to append the new properties to the existing dataset properties - custom_props = {} if custom_properties is None else custom_properties + custom_props = {} if custom_properties is None else dict(custom_properties) git_repo = git_get_repo() name = re.split("/", url)[-1] @@ -779,7 +783,7 @@ def log_dataset( dvc_url = dvc_get_url(url) dvc_url_with_pipeline = f"{self.parent_context.name}:{dvc_url}" url = url + ":" + c_hash - if c_hash and c_hash.strip: + if c_hash and c_hash.strip(): existing_artifact.extend(self.store.get_artifacts_by_uri(c_hash)) uri = c_hash @@ -829,8 +833,8 @@ def log_dataset( milliseconds_since_epoch=int(time.time() * 1000), ) - custom_props["git_repo"] = git_repo - custom_props["Commit"] = dataset_commit + system_props = {"git_repo": git_repo, "Commit": dataset_commit} + graph_custom_props = {**custom_props, **system_props} self.execution_label_props["git_repo"] = git_repo self.execution_label_props["Commit"] = dataset_commit @@ -842,7 +846,7 @@ def log_dataset( event, self.execution.id, self.parent_context, - custom_props, + graph_custom_props, ) if event.lower() == "input": self.input_artifacts.append( @@ -878,6 +882,29 @@ def log_dataset( if label: self.log_label(label, artifact_path, label_properties) + execution_uuid = self.execution.properties["Execution_uuid"].string_value.split(",")[-1].strip() + if self.custom_store and execution_uuid and artifact: + try: + # Step 1: Get artifact uri and build artifact-level metadata payload. + artifact_uri = str(getattr(artifact, "uri", "")).strip() + dataset_log_payload = { + "name": name, + "properties": { + "git_repo": git_repo, + "url": dvc_url_with_pipeline, + }, + "custom_properties": custom_props, + } + # Step 2: Upsert one ExecutionLogs row keyed by (execution_uuid, artifact_uri). + if artifact_uri: + self.custom_store.insert_execution_log( + execution_uuid=execution_uuid, + artifact_uri=artifact_uri, + metadata=dataset_log_payload, + ) + except Exception as exc: + logger.warning("Failed to write dataset metadata into execution_log: %s", exc) + os.chdir(logging_dir) return artifact @@ -1118,6 +1145,32 @@ def log_model( self.driver.create_artifact_relationships( self.input_artifacts, child_artifact, self.execution_label_props ) + execution_uuid = self.execution.properties["Execution_uuid"].string_value.split(",")[-1].strip() + if self.custom_store and execution_uuid and artifact: + try: + # Step 1: Get artifact uri and build artifact-level metadata payload. + artifact_uri = str(getattr(artifact, "uri", "")).strip() + model_log_payload = { + "name": model_name, + "properties": { + "model_framework": model_framework, + "model_type": model_type, + "model_name": model_name, + "Commit": model_commit, + "url": url_with_pipeline, + }, + "custom_properties": custom_props, + } + # Step 2: Upsert one ExecutionLogs row keyed by (execution_uuid, artifact_uri). + if artifact_uri: + self.custom_store.insert_execution_log( + execution_uuid=execution_uuid, + artifact_uri=artifact_uri, + metadata=model_log_payload, + ) + except Exception as exc: + logger.warning("Failed to write model metadata into execution_log: %s", exc) + os.chdir(logging_dir) return artifact @@ -1158,7 +1211,8 @@ def log_execution_metrics( self.create_execution(execution_type=assigned_name) assert self.execution is not None, f"Failed to create execution for {self.pipeline_name}!!" - custom_props = {} if custom_properties is None else custom_properties + custom_props = {} if custom_properties is None else dict(custom_properties) + coarse_metrics_name = metrics_name uri = str(uuid.uuid1()) metrics_name = metrics_name + ":" + uri + ":" + str(self.execution.id) metrics = create_new_artifact_event_and_attribution( @@ -1197,6 +1251,29 @@ def log_execution_metrics( self.driver.create_artifact_relationships( self.input_artifacts, child_artifact, self.execution_label_props ) + + execution_uuid = self.execution.properties["Execution_uuid"].string_value.split(",")[-1].strip() + if self.custom_store and execution_uuid and metrics: + try: + # Step 1: Get artifact uri and build artifact-level metadata payload. + artifact_uri = str(getattr(metrics, "uri", "")).strip() + metrics_log_payload = { + "name": coarse_metrics_name, + "properties": { + "metrics_name": metrics_name, + }, + "custom_properties": custom_props, + } + # Step 2: Upsert one ExecutionLogs row keyed by (execution_uuid, artifact_uri). + if artifact_uri: + self.custom_store.insert_execution_log( + execution_uuid=execution_uuid, + artifact_uri=artifact_uri, + metadata=metrics_log_payload, + ) + except Exception as exc: + logger.warning("Failed to write execution metrics metadata into execution_log: %s", exc) + os.chdir(logging_dir) return metrics @@ -1336,6 +1413,30 @@ def commit_metrics(self, metrics_name: str): self.input_artifacts, child_artifact, self.execution_label_props ) + execution_uuid = self.execution.properties["Execution_uuid"].string_value.split(",")[-1].strip() + if self.custom_store and execution_uuid and metrics: + try: + # Step 1: Get artifact uri and build artifact-level metadata payload. + artifact_uri = str(getattr(metrics, "uri", "")).strip() + step_metrics_log_payload = { + "name": metrics_name, + "properties": { + "Commit": metrics_commit, + "url": dvc_url_with_pipeline, + "metrics_path": metrics_path, + }, + "custom_properties": {}, + } + # Step 2: Upsert one ExecutionLogs row keyed by (execution_uuid, artifact_uri). + if artifact_uri: + self.custom_store.insert_execution_log( + execution_uuid=execution_uuid, + artifact_uri=artifact_uri, + metadata=step_metrics_log_payload, + ) + except Exception as exc: + logger.warning("Failed to write step metrics metadata into execution_log: %s", exc) + os.chdir(logging_dir) return metrics @@ -1495,7 +1596,7 @@ def log_label(self, url: str, dataset_name: str, custom_properties: t.Optional[t # If the dataset already exist , then we just link the existing dataset to the execution # We do not update the dataset properties . # We need to append the new properties to the existing dataset properties - custom_props = {} if custom_properties is None else custom_properties + custom_props = {} if custom_properties is None else dict(custom_properties) git_repo = git_get_repo() name = re.split("/", url)[-1] @@ -1506,7 +1607,7 @@ def log_label(self, url: str, dataset_name: str, custom_properties: t.Optional[t # Calculate label_hash label_hash = calculate_md5(url) - # Get dataset_uri from DVC + # Get dataset_uri from DVC for the base artifact dataset_uri = dvc_get_hash(dataset_name) if dataset_uri == "": logger.error(f"[log_label] Error in getting the dvc hash for {dataset_name}, return without logging") @@ -1523,13 +1624,13 @@ def log_label(self, url: str, dataset_name: str, custom_properties: t.Optional[t self.update_existing_artifact(dataset_artifact, dataset_custom_properties) # Prepare label custom properties - custom_props = {} if custom_properties is None else custom_properties + custom_props = {} if custom_properties is None else dict(custom_properties) custom_props["dataset_uri"] = dataset_uri git_repo = git_get_repo() # Check if label artifact already exists existing_artifact = [] - if label_hash and label_hash.strip: + if label_hash and label_hash.strip(): existing_artifact.extend(self.store.get_artifacts_by_uri(label_hash)) url = url + ":" + label_hash @@ -1576,9 +1677,32 @@ def log_label(self, url: str, dataset_name: str, custom_properties: t.Optional[t custom_properties=custom_props, milliseconds_since_epoch=int(time.time() * 1000), ) - custom_props["git_repo"] = git_repo - custom_props["Commit"] = label_hash - + system_props = {"git_repo": git_repo, "Commit": label_hash} + graph_custom_props = {**custom_props, **system_props} + + execution_uuid = self.execution.properties["Execution_uuid"].string_value.split(",")[-1].strip() + if self.custom_store and execution_uuid and artifact: + try: + # Step 1: Get artifact uri and build artifact-level metadata payload. + artifact_uri = str(getattr(artifact, "uri", "")).strip() + label_log_payload = { + "name": name, + "properties": { + "git_repo": git_repo, + "url": url, + }, + "custom_properties": custom_props, + } + # Step 2: Upsert one ExecutionLogs row keyed by (execution_uuid, artifact_uri). + if artifact_uri: + self.custom_store.insert_execution_log( + execution_uuid=execution_uuid, + artifact_uri=artifact_uri, + metadata=label_log_payload, + ) + except Exception as exc: + logger.warning("Failed to write label metadata into execution_log: %s", exc) + if self.graph: # directly linked to dataset via create_label_node self.driver.create_label_node( @@ -1589,7 +1713,7 @@ def log_label(self, url: str, dataset_name: str, custom_properties: t.Optional[t self.execution.id, self.parent_context, dataset_uri, # Pass dataset_uri to link label to dataset - custom_props, + graph_custom_props, ) # NOTE: Labels are NOT added to self.input_artifacts to prevent them from being # linked to other artifacts via create_artifact_relationships(). Labels are @@ -1616,7 +1740,6 @@ def log_label(self, url: str, dataset_name: str, custom_properties: t.Optional[t # } # ) - return artifact class DataSlice: diff --git a/cmflib/cmf_federation.py b/cmflib/cmf_federation.py index 9487e8c51..499e96fb8 100644 --- a/cmflib/cmf_federation.py +++ b/cmflib/cmf_federation.py @@ -1,7 +1,93 @@ import json import typing as t +import logging from cmflib.cmfquery import CmfQuery from cmflib.cmf_merger import parse_json_to_mlmd +from cmflib.utils.helper_functions import get_postgres_config + + +logger = logging.getLogger(__name__) + + +def _append_execution_logs_from_pipeline(pipeline_data: dict, cmd: str) -> None: + # Function Name: _append_execution_logs_from_pipeline + # Input: pipeline_data (dict), cmd (str) + # Output: None + # Description: Append ExecutionLogs rows from incoming pipeline JSON payload. + # Step 1: Run only for metadata push command. + # Step 2: Iterate stage -> execution -> event from pipeline payload. + # Step 3: Skip Environment artifacts and rows missing execution_uuid/artifact_uri. + # Step 4: Build metadata_json with name/properties/custom_properties. + # Step 5: Insert one append-only row per artifact event into ExecutionLogs. + # Step 6: Log failures without interrupting the MLMD merge flow. + if cmd != "push": + return + + conn = None + try: + import psycopg # type: ignore + + config = get_postgres_config() + conn = psycopg.connect( + host=config.get("host"), + port=config.get("port"), + user=config.get("user"), + password=config.get("password"), + dbname=config.get("dbname"), + connect_timeout=10, + ) + + with conn.cursor() as cursor: + for stage in pipeline_data.get("stages", []): + for execution in stage.get("executions", []): + execution_uuid = execution.get("properties", {}).get("Execution_uuid", "") + if not execution_uuid: + continue + + for event in execution.get("events", []): + artifact = event.get("artifact", {}) + if artifact.get("type") == "Environment": + continue + + artifact_uri = str(artifact.get("uri", "")).strip() + if not artifact_uri: + continue + + metadata_payload = { + "custom_properties": artifact.get("custom_properties", {}), + "name": artifact.get("name", ""), + "properties": artifact.get("properties", {}), + } + + cursor.execute( + """ + INSERT INTO executionlogs ( + execution_uuid, + artifact_uri, + metadata_json + ) VALUES (%s, %s, %s::jsonb) + """, + ( + execution_uuid, + artifact_uri, + json.dumps(metadata_payload, default=str), + ), + ) + + conn.commit() + except Exception as exc: + logger.warning("Failed to append rows into ExecutionLogs from update_mlmd: %s", exc) + if conn is not None: + try: + conn.rollback() + except Exception: + pass + finally: + if conn is not None: + try: + conn.close() + except Exception: + pass def identify_existing_and_new_executions(query: CmfQuery, pipeline_data: dict, pipeline_name: str) -> t.Tuple[list, list, list, str]: @@ -123,6 +209,10 @@ def update_mlmd(query: CmfQuery, req_info: str, pipeline_name: str, cmd: str, ex if len(pipeline['stages']) == 0 : status="exists" else: + # Side-write: append tracking rows into server ExecutionLogs using parsed JSON payload. + # This does not modify existing MLMD merge behavior. + _append_execution_logs_from_pipeline(pipeline, cmd) + # metadata push → merge client data into server path # metadata pull → merge server data into client path if cmd == "pull": @@ -160,6 +250,10 @@ def update_mlmd(query: CmfQuery, req_info: str, pipeline_name: str, cmd: str, ex if len(pipeline['stages']) == 0 : status="exists" else: + # Side-write: append tracking rows into server ExecutionLogs using parsed JSON payload. + # This does not modify existing MLMD merge behavior. + _append_execution_logs_from_pipeline(pipeline, cmd) + # metadata push → merge client data into server path # metadata pull → merge server data into client path if cmd == "pull": diff --git a/cmflib/cmfquery.py b/cmflib/cmfquery.py index 080dbff3a..f4f9ecac1 100644 --- a/cmflib/cmfquery.py +++ b/cmflib/cmfquery.py @@ -27,6 +27,7 @@ from cmflib.cmf_merger import parse_json_to_mlmd from cmflib.store.postgres import PostgresStore from cmflib.store.sqllite_store import SqlliteStore +from cmflib.store.custom_sqlite_store import CustomSqliteStore from cmflib.utils.helper_functions import get_postgres_config # Constants for filtering artifact and execution types in lineage visualizations @@ -133,6 +134,11 @@ def __init__(self, filepath: str = "mlmd", is_server=False) -> None: else: temp_store = SqlliteStore({"filename": filepath}) self.store = temp_store.connect() + + # Step 1: Initialize CustomSqliteStore to enable reading execution_log during JSON export. + # Step 2: CustomSqliteStore manages DB connection to same mlmd file as MLMD metadata. + # Step 3: Used later in _get_event_attributes() to patch artifacts with execution_log data. + self.custom_store = CustomSqliteStore(filepath) @staticmethod def _copy( @@ -218,6 +224,102 @@ def _as_pandas_df(elements: t.Iterable, transform_fn: t.Callable[[t.Any], pd.Dat df = pd.concat([df, transform_fn(element)], sort=True, ignore_index=True) return df + def _extract_execution_uuids(self, execution_obj) -> t.List[str]: # type: ignore + """Extract execution UUIDs from execution object properties. + + Args: + execution_obj: MLMD execution object with properties containing 'Execution_uuid'. + + Returns: + List of UUIDs as strings. If comma-separated in properties, splits and returns all. + If not found or error, returns empty list. + """ + # Step 1: Validate execution object has properties attribute. + # Step 2: Extract Execution_uuid field (may be comma-separated for multiple UUIDs). + # Step 3: Split by comma and strip whitespace from each UUID. + # Step 4: Return list of UUIDs; return empty list if not found or error. + + try: + # Step 1: Check if execution object has properties. + if not hasattr(execution_obj, 'properties') or not execution_obj.properties: + return [] + + # Step 2: Extract Execution_uuid from properties dict. + if 'Execution_uuid' not in execution_obj.properties: + return [] + + uuid_str = execution_obj.properties['Execution_uuid'].string_value + if not uuid_str: + return [] + + # Step 3: Split by comma if multiple UUIDs, strip whitespace from each. + uuids = [uuid.strip() for uuid in uuid_str.split(",")] + + # Step 4: Filter out empty strings and return non-empty UUIDs. + return [uuid for uuid in uuids if uuid] + except Exception as e: + # Step 5: Return empty list if any error occurs during extraction. + logger.warning(f"Error extracting Execution_uuid from execution: {e}") + return [] + + def _patch_artifact_with_execution_log( + self, artifact_dict: t.Dict[str, t.Any], execution_uuid: str, artifact_uri: t.Any + ) -> None: + """Patch artifact dictionary with execution_log data if entries differ. + + Strategy: Only replace name, properties, custom_properties if execution_log has them + and they differ from current artifact values. Leave unchanged if same or missing. + + Args: + artifact_dict: Artifact metadata dictionary to patch (modified in-place). + execution_uuid: Execution UUID to use as key for lookup. + artifact_uri: Artifact URI to use as key for lookup. + + Returns: + None (modifies artifact_dict in-place). + """ + # Step 1: Query ExecutionLogs for (execution_uuid, artifact_uri) pair. + # Step 2: If found, compare each patchable field (name, properties, custom_properties). + # Step 3: For each field, check if execution_log value differs from artifact value. + # Step 4: If different, replace artifact value with execution_log value. + # Step 5: If same or missing in execution_log, leave artifact unchanged. + + # Step 1: Query custom_store for execution_log metadata. + execution_log_metadata = self.custom_store.get_execution_log(str(execution_uuid), str(artifact_uri)) + + # If no execution_log entry, nothing to patch; return early. + if execution_log_metadata is None: + return + + # Step 2-5: Patchable fields are: name, properties, custom_properties. + # Only these three fields from execution_log are used to patch artifact. + patchable_fields = ["name", "properties", "custom_properties"] + + for field in patchable_fields: + # Step 2: Check if execution_log has this field. + if field not in execution_log_metadata: + # Field not in execution_log; leave artifact unchanged. + continue + + log_value = execution_log_metadata[field] + artifact_value = artifact_dict.get(field) + + # Merge map-like fields so sparse execution_log payloads do not erase + # richer artifact metadata such as labels_uri/custom properties. + if field in {"properties", "custom_properties"} and isinstance(artifact_value, dict) and isinstance(log_value, dict): + merged_value = dict(artifact_value) + merged_value.update(log_value) + if merged_value != artifact_value: + artifact_dict[field] = merged_value + continue + + # Step 3: Compare values to decide if patch is needed. + # Handle case where artifact field might not exist (use None as default). + if log_value != artifact_value: + # Step 4: Values differ; patch artifact with execution_log value. + artifact_dict[field] = log_value + # else: Step 5 - Values same or field missing; leave unchanged. + def _get_pipelines(self, name: t.Optional[str] = None) -> t.List[mlpb.Context]: # type: ignore # Context type not recognized by mypy, using ignore to bypass """Return list of pipelines with the given name. @@ -978,25 +1080,55 @@ def _get_node_attributes(self, _node: t.Union[mlpb.Context, mlpb.Execution, mlpb ) return _attrs - def _get_event_attributes(self, execution_id: int) -> t.List[t.Dict]: + def _get_event_attributes(self, execution_id: int, execution_obj=None) -> t.List[t.Dict]: # type: ignore """ Extract event attributes for a given execution ID. Args: execution_id (int): The ID of the execution for which event attributes are to be extracted. + execution_obj (Optional): Optional MLMD execution object to enable execution_log patching. Returns: List[Dict]: A list of dictionaries, each containing attributes of an event associated with the execution. """ + # Step 1: Retrieve all events for this execution ID. + # Step 2: For each event, extract event attributes and get associated artifact. + # Step 3: Apply artifact patching if execution_obj provided (contains Execution_uuid). + # Step 4: Try each execution UUID (comma-separated) to find matching execution_log entry. + # Step 5: Return list of events with patched artifacts. + events = [] + + # Step 1: Extract execution UUIDs from execution_obj if provided. + execution_uuids = [] + if execution_obj is not None: + execution_uuids = self._extract_execution_uuids(execution_obj) + + # Step 2-5: Process each event. for event in self.store.get_events_by_execution_ids([execution_id]): + # Step 2a: Get event attributes (e.g., type, artifact_id, etc.). event_attrs = self._get_node_attributes(event, {}) + + # Step 2b: Get artifact associated with this event. artifacts = self.store.get_artifacts_by_id([event.artifact_id]) artifact_attrs = self._get_node_attributes( artifacts[0], {"type": self.store.get_artifact_types_by_id([artifacts[0].type_id])[0].name} ) + + # Step 3: If execution_uuids available, try patching artifact with execution_log data. + if execution_uuids and artifact_attrs.get("uri"): + # Step 4: Try each execution UUID (any match from comma-separated list is accepted). + for uuid in execution_uuids: + # Try to patch artifact with this UUID. + self._patch_artifact_with_execution_log(artifact_attrs, uuid, artifact_attrs["uri"]) + # Note: _patch_artifact_with_execution_log is idempotent; calling it multiple times is safe. + # If one UUID matches, the patch is applied; other UUIDs won't match. + # For efficiency, could break after first successful patch, but multiple attempts are safe. + + # Step 5: Add patched artifact to event attributes. event_attrs["artifact"] = artifact_attrs events.append(event_attrs) + return events def _get_execution_attributes(self, stage_id: int, exec_uuid: t.Optional[str] = None, last_sync_time: t.Optional[int] = None) -> t.List[t.Dict]: @@ -1010,26 +1142,42 @@ def _get_execution_attributes(self, stage_id: int, exec_uuid: t.Optional[str] = Returns: List[Dict]: A list of dictionaries, each containing attributes of an execution associated with the stage. """ + # Step 1: Get all executions for this stage, optionally filtered by exec_uuid. + # Step 2: For each execution, extract attributes and nested events/artifacts. + # Step 3: Pass execution object to _get_event_attributes so patching can access Execution_uuid. + # Step 4: Apply last_sync_time filter if provided. + # Step 5: Return list of execution attributes. + executions = [] + + # Step 1-2: Process each execution. for execution in self.get_all_executions_by_stage(stage_id, execution_uuid=exec_uuid): + # Step 3: Extract events by passing execution object to enable execution_log patching. + events = self._get_event_attributes(execution.id, execution) + + # Build execution attributes dict with nested events. exec_attrs = self._get_node_attributes( execution, { "type": self.store.get_execution_types_by_id([execution.type_id])[0].name, "name": execution.name if execution.name != "" else "", - "events": self._get_event_attributes(execution.id), + "events": events, # Use events variable with patched artifacts. }, ) + # Step 4: Filter by last_sync_time if provided. + # If last_sync_time given, only include execution if it was updated after that timestamp. + # If no last_sync_time, include all executions. # what is usual situtaion - #it does not matter if last sync time is given or not we have to add exec_attrs #however if last sync timr is given then we have to check if last_update_time_since_epoch > last_sync_time # last_update_time_since epoch - + if last_sync_time: if exec_attrs["last_update_time_since_epoch"] > last_sync_time: executions.append(exec_attrs) else: + # Step 5: No time filter; add all execution attributes. executions.append(exec_attrs) return executions diff --git a/cmflib/store/custom_sqlite_store.py b/cmflib/store/custom_sqlite_store.py new file mode 100644 index 000000000..3b1c6f93b --- /dev/null +++ b/cmflib/store/custom_sqlite_store.py @@ -0,0 +1,182 @@ +import json +import os +import sqlite3 +import typing as t + + +class CustomSqliteStore: + """Store execution-level artifact metadata in SQLite.""" + + def __init__(self, db_path: str): + # Function Name: __init__ + # Input: db_path (str) + # Output: None + # Description: Resolve DB path and initialize execution_log table. + # Step 1: Normalize incoming DB path. + # Step 2: Create DB/table if missing. + self.db_path = self._resolve_db_path(db_path) + self._init_db() + + @staticmethod + def _resolve_db_path(path_hint: str) -> str: + # Function Name: _resolve_db_path + # Input: path_hint (str) + # Output: str (resolved DB path) + # Description: Determine final DB path based on input hint or defaults. + # Step 1: Trim whitespace from input path hint. + raw_path = (path_hint or "").strip() + + # Step 2: If no path provided, default to current working directory. + if not raw_path: + raw_path = os.getcwd() + + # Step 3: Normalize path to remove redundant separators and up-level references. + normalized_path = os.path.normpath(raw_path) + + # Step 4: If the path ends with "mlmd", treat it as the final DB path. + if os.path.basename(normalized_path) == "mlmd": + return normalized_path + + # Step 5: Otherwise, treat input as a directory path and place DB at /mlmd. + return os.path.join(normalized_path, "mlmd") + + def _connect(self) -> sqlite3.Connection: + # Function Name: _connect + # Input: None + # Output: sqlite3.Connection + # Description: Create a resilient SQLite connection for shared-file usage. + # Step 1: Open connection with a longer timeout. + # Step 2: Configure journaling and sync pragmas for safer writes. + # Step 3: Return configured connection. + # Use a longer timeout to avoid lock-contention failures when MLMD writes to the same DB. + conn = sqlite3.connect(self.db_path, timeout=15) + # Keep classic journaling when sharing one DB file with MLMD. + + # What each line does: + + # 1. `PRAGMA journal_mode=DELETE;` + # - Uses classic rollback journal mode. + # - Reason for change: your previous mode was WAL, and WAL can be fragile with mixed tooling/writers (MLMD writes, custom sqlite writes, VS Code viewer reads). + # - In shared single-file usage, `DELETE` is simpler and usually less likely to produce weird sidecar-file issues (`mlmd-wal`, `mlmd-shm`) that lead to malformed/open errors in tools. + + # 2. `PRAGMA busy_timeout=15000;` + # - Wait up to 15 seconds if DB is locked instead of failing immediately. + # - Reason: MLMD and custom writes can briefly overlap. Without timeout, you get fast lock errors and possibly incomplete operation sequences. + # - This improves stability under lock contention. + + # 3. `PRAGMA synchronous=FULL;` + # - Forces SQLite to fully flush writes to disk before commit is considered done. + # - Reason: strongest durability, reduces risk of corruption if process/system crashes during writes. + # - Tradeoff: slightly slower writes, but safer. + + # Why this set specifically: + # - You asked to keep a single DB (no separate custom DB). + # - Single-DB multi-writer scenarios need safety over speed. + # - These settings prioritize consistency/durability and reduce corruption probability. + + conn.execute("PRAGMA journal_mode=DELETE;") + conn.execute("PRAGMA busy_timeout=15000;") + conn.execute("PRAGMA synchronous=FULL;") + return conn + + def _init_db(self) -> None: + # Function Name: _init_db + # Input: None + # Output: None + # Description: Ensure ExecutionLogs table exists with expected key shape. + # Step 1: Create parent directory when path contains one. + # Step 2: Create ExecutionLogs table if missing. + # Step 3: Commit schema setup. + db_dir = os.path.dirname(self.db_path) + if db_dir: + os.makedirs(db_dir, exist_ok=True) + with self._connect() as conn: + conn.execute( + """ + CREATE TABLE IF NOT EXISTS ExecutionLogs ( + execution_uuid TEXT NOT NULL, + artifact_uri TEXT NOT NULL, + metadata_json TEXT NOT NULL, + PRIMARY KEY (execution_uuid, artifact_uri) + ) + """ + ) + conn.commit() + + def insert_execution_log( + self, + execution_uuid: str, + artifact_uri: str, + metadata: t.Optional[t.Dict[str, t.Any]] = None, + ) -> None: + # Function Name: insert_execution_log + # Input: execution_uuid (str), artifact_uri (str), metadata (dict | None) + # Output: None + # Description: Upsert one row per (execution_uuid, artifact_uri) with JSON metadata. + # Step 1: Validate required keys. + # Step 2: Serialize metadata to JSON string. + # Step 3: Insert new row or update existing row for same composite key. + # Step 4: Commit transaction. + if not execution_uuid or not artifact_uri: + return + payload = json.dumps(metadata or {}, default=str, sort_keys=True) + with self._connect() as conn: + conn.execute( + """ + INSERT INTO ExecutionLogs ( + execution_uuid, + artifact_uri, + metadata_json + ) VALUES (?, ?, ?) + ON CONFLICT(execution_uuid, artifact_uri) + DO UPDATE SET metadata_json = excluded.metadata_json + """, + ( + execution_uuid, + artifact_uri, + payload, + ), + ) + conn.commit() + + def get_execution_log(self, execution_uuid: str, artifact_uri: str) -> t.Optional[t.Dict[str, t.Any]]: + # Function Name: get_execution_log + # Input: execution_uuid (str), artifact_uri (str) + # Output: Dict or None + # Description: Retrieve metadata from ExecutionLogs for (execution_uuid, artifact_uri) pair. + # Step 1: Validate input parameters are not empty. + # Step 2: Query ExecutionLogs table using composite key. + # Step 3: If row found, parse metadata_json and return as dict. + # Step 4: If row not found or error occurs, return None. + # Step 5: Log errors if JSON parsing fails. + + # Step 1: Skip retrieval if input is invalid. + if not execution_uuid or not artifact_uri: + return None + + try: + # Step 2: Open connection and query ExecutionLogs by composite key (execution_uuid, artifact_uri). + with self._connect() as conn: + cursor = conn.execute( + """ + SELECT metadata_json FROM ExecutionLogs + WHERE execution_uuid = ? AND artifact_uri = ? + """, + (execution_uuid, artifact_uri), + ) + row = cursor.fetchone() + + # Step 3: If row found, deserialize metadata_json from JSON string to dict. + if row: + return json.loads(row[0]) + + # Step 4: Row not found; return None to indicate no custom metadata for this pair. + return None + except json.JSONDecodeError as e: + # Step 5a: Log error if JSON parsing fails (data corruption scenario). + print(f"Error parsing JSON for ({execution_uuid}, {artifact_uri}): {e}") + return None + except Exception as e: + # Step 5b: Catch other exceptions (DB errors) and return None; caller will use original artifact data. + print(f"Error querying ExecutionLogs for ({execution_uuid}, {artifact_uri}): {e}") + return None \ No newline at end of file diff --git a/server/app/db/dbmodels.py b/server/app/db/dbmodels.py index 7b4d502de..edff79188 100644 --- a/server/app/db/dbmodels.py +++ b/server/app/db/dbmodels.py @@ -12,8 +12,10 @@ Index, UniqueConstraint, MetaData, - SmallInteger + SmallInteger, + text ) +from sqlalchemy.dialects.postgresql import JSONB metadata = MetaData() @@ -205,6 +207,18 @@ ) +executionlogs = Table( + "executionlogs", metadata, + Column("execution_uuid", Text, nullable=False), + Column("artifact_uri", Text, nullable=False), + Column("metadata_json", JSONB, nullable=False, server_default=text("'{}'::jsonb")), + + # Indexes + Index("idx_executionlogs_execution_uuid", "execution_uuid"), + Index("idx_executionlogs_artifact_uri", "artifact_uri"), + Index("idx_executionlogs_exec_uri", "execution_uuid", "artifact_uri"), + + # Schedules for periodic syncs scheduled_syncs = Table( "scheduled_syncs", metadata, diff --git a/server/app/db/dbqueries.py b/server/app/db/dbqueries.py index b526ab14e..72d8b525a 100644 --- a/server/app/db/dbqueries.py +++ b/server/app/db/dbqueries.py @@ -14,6 +14,7 @@ executionproperty, event, registered_servers, + executionlogs, scheduled_syncs, sync_logs ) @@ -676,6 +677,19 @@ async def fetch_artifacts_by_stage( effective_page_size = record_per_page if record_per_page is not None else page_size + # Step 0: Aggregate execution_count and distinct names per artifact URI from ExecutionLogs. + execution_log_agg_cte = ( + select( + executionlogs.c.artifact_uri, + func.count(distinct(executionlogs.c.execution_uuid)).label("execution_count"), + func.json_agg( + distinct(func.cast(executionlogs.c.metadata_json[text("'name'")], String)) + ).label("execution_names") + ) + .group_by(executionlogs.c.artifact_uri) + .subquery("execution_log_agg_cte") + ) + # Step 1: Aggregate artifact properties into JSON based on artifact id. artifact_properties_agg_cte = ( select( @@ -705,6 +719,7 @@ async def fetch_artifacts_by_stage( select( artifact.c.id.label("artifact_id"), artifact.c.name, + artifact.c.uri, artifact.c.create_time_since_epoch ) .join( @@ -755,8 +770,11 @@ async def fetch_artifacts_by_stage( select( artifact_type_cte.c.artifact_id, artifact_type_cte.c.name, + artifact_type_cte.c.uri, artifact_type_cte.c.create_time_since_epoch, artifact_properties_agg_cte.c.artifact_properties, + func.coalesce(execution_log_agg_cte.c.execution_count, 0).label("execution_count"), + execution_log_agg_cte.c.execution_names, func.count().over().label("total_records") ) .select_from(artifact_type_cte) @@ -764,6 +782,10 @@ async def fetch_artifacts_by_stage( artifact_properties_agg_cte, artifact_type_cte.c.artifact_id == artifact_properties_agg_cte.c.artifact_id ) + .outerjoin( + execution_log_agg_cte, + artifact_type_cte.c.uri == execution_log_agg_cte.c.artifact_uri + ) ) # Apply search filter if provided @@ -796,6 +818,83 @@ async def fetch_artifacts_by_stage( } +async def fetch_execution_uuids_by_artifact_uri( + db: AsyncSession, + pipeline_name: str, + artifact_uri: str, +): + """Return execution UUIDs and names from ExecutionLogs for a given artifact URI scoped to pipeline.""" + if not artifact_uri: + return [] + + _, pipeline_execution_ids = _get_pipeline_execution_ids_subquery(pipeline_name) + + pipeline_execution_uuids = ( + select(distinct(executionproperty.c.string_value).label("execution_uuid")) + .where(executionproperty.c.name == "Execution_uuid") + .where(executionproperty.c.string_value.isnot(None)) + .where(executionproperty.c.execution_id.in_(select(pipeline_execution_ids.c.execution_id))) + .subquery("pipeline_execution_uuids") + ) + + query = ( + select( + executionlogs.c.execution_uuid.label("execution_uuid"), + func.max(func.cast(executionlogs.c.metadata_json[text("'name'")], String)).label("name"), + ) + .where(executionlogs.c.artifact_uri == artifact_uri) + .where(executionlogs.c.execution_uuid.in_(select(pipeline_execution_uuids.c.execution_uuid))) + .group_by(executionlogs.c.execution_uuid) + .order_by(executionlogs.c.execution_uuid.desc()) + ) + + result = await db.execute(query) + rows = result.mappings().all() + return [ + { + "execution_uuid": row["execution_uuid"], + "name": row.get("name"), + } + for row in rows + ] + + +async def fetch_execution_log_metadata( + db: AsyncSession, + pipeline_name: str, + execution_uuid: str, + artifact_uri: str, +): + """Return metadata_json from ExecutionLogs for one (execution_uuid, artifact_uri) scoped to pipeline.""" + if not execution_uuid or not artifact_uri: + return None + + _, pipeline_execution_ids = _get_pipeline_execution_ids_subquery(pipeline_name) + + pipeline_execution_uuids = ( + select(distinct(executionproperty.c.string_value).label("execution_uuid")) + .where(executionproperty.c.name == "Execution_uuid") + .where(executionproperty.c.string_value.isnot(None)) + .where(executionproperty.c.execution_id.in_(select(pipeline_execution_ids.c.execution_id))) + .subquery("pipeline_execution_uuids") + ) + + query = ( + select(executionlogs.c.metadata_json) + .where(executionlogs.c.execution_uuid == execution_uuid) + .where(executionlogs.c.artifact_uri == artifact_uri) + .where(executionlogs.c.execution_uuid.in_(select(pipeline_execution_uuids.c.execution_uuid))) + .limit(1) + ) + + result = await db.execute(query) + row = result.first() + if not row: + return None + + return row[0] + + async def fetch_artifact_types_by_stage( db: AsyncSession, pipeline_name: str, diff --git a/server/app/main.py b/server/app/main.py index db2c6023c..ef85ab4e1 100644 --- a/server/app/main.py +++ b/server/app/main.py @@ -36,6 +36,8 @@ fetch_executions_by_stage, fetch_artifacts_by_stage, fetch_artifact_types_by_stage, + fetch_execution_uuids_by_artifact_uri, + fetch_execution_log_metadata, register_server_details, get_registered_server_details, get_sync_status, @@ -500,6 +502,47 @@ async def get_artifacts_by_stage( ) +@app.get("/artifact-executions/{pipeline_name}") +async def get_artifact_execution_uuids( + pipeline_name: str, + artifact_uri: str = Query(..., description="Artifact URI"), + db: AsyncSession = Depends(get_db) +): + """Return execution UUIDs and names for a selected artifact URI from ExecutionLogs.""" + executions = await fetch_execution_uuids_by_artifact_uri( + db=db, + pipeline_name=pipeline_name, + artifact_uri=artifact_uri, + ) + return { + "artifact_uri": artifact_uri, + "executions": executions, + "execution_uuids": [item.get("execution_uuid") for item in executions if item.get("execution_uuid")], + } + + +@app.get("/artifact-execution-metadata/{pipeline_name}") +async def get_artifact_execution_metadata( + pipeline_name: str, + execution_uuid: str = Query(..., description="Execution UUID"), + artifact_uri: str = Query(..., description="Artifact URI"), + db: AsyncSession = Depends(get_db) +): + """Return metadata_json for a selected (execution_uuid, artifact_uri) from ExecutionLogs.""" + metadata = await fetch_execution_log_metadata( + db=db, + pipeline_name=pipeline_name, + execution_uuid=execution_uuid, + artifact_uri=artifact_uri, + ) + + return { + "execution_uuid": execution_uuid, + "artifact_uri": artifact_uri, + "metadata": metadata or {}, + } + + @app.get("/execution-lineage/tangled-tree/{uuid}/{pipeline_name}") async def execution_lineage(request: Request, uuid: str, pipeline_name: str): ''' diff --git a/ui/src/client.js b/ui/src/client.js index f1b6e6484..6a484db53 100644 --- a/ui/src/client.js +++ b/ui/src/client.js @@ -328,6 +328,31 @@ class FastAPIClient { }); } + async getArtifactExecutions(pipelineName, artifactUri) { + return this.apiClient + .get(`/artifact-executions/${pipelineName}`, { + params: { + artifact_uri: artifactUri, + }, + }) + .then(({ data }) => { + return data; + }); + } + + async getArtifactExecutionMetadata(pipelineName, executionUuid, artifactUri) { + return this.apiClient + .get(`/artifact-execution-metadata/${pipelineName}`, { + params: { + execution_uuid: executionUuid, + artifact_uri: artifactUri, + }, + }) + .then(({ data }) => { + return data; + }); + } + } diff --git a/ui/src/components/ArtifactCardGrid/index.jsx b/ui/src/components/ArtifactCardGrid/index.jsx index 4ab56242a..c0b8d7f8c 100644 --- a/ui/src/components/ArtifactCardGrid/index.jsx +++ b/ui/src/components/ArtifactCardGrid/index.jsx @@ -32,6 +32,7 @@ const ArtifactCardGrid = ({ isSplitView = false, selectedItems = [], onToggleItem, + selectedArtifactId = null, }) => { const [expandedCard, setExpandedCard] = useState(null); const [showModelPopup, setShowModelPopup] = useState(false); @@ -76,6 +77,16 @@ const ArtifactCardGrid = ({ } }; + const normalizeStepMetricsName = (value) => { + const text = String(value || ""); + return text.toLowerCase().includes("training_metrics") ? "training_metrics" : text; + }; + + const getCardDisplayName = (artifact) => { + const baseValue = artifact?.uri || artifact?.name || "N/A"; + return normalizeStepMetricsName(baseValue); + }; + const renderLabels = (artifact) => { const labelsUri = getPropertyValue(artifact.artifact_properties, "labels_uri"); @@ -92,7 +103,7 @@ const ArtifactCardGrid = ({ .map((label_name, idx) => ( { e.preventDefault(); e.stopPropagation(); @@ -102,7 +113,7 @@ const ArtifactCardGrid = ({ }} title={label_name} > - + @@ -149,14 +160,16 @@ const ArtifactCardGrid = ({ {artifacts.map((artifact, index) => (
a.artifact_id === artifact.artifact_id) - ? 'border-teal-500 shadow-lg' - : 'border-gray-300 hover:border-teal-500 hover:shadow-lg' + className={`rounded-lg border-2 ${selectedArtifactId === artifact.artifact_id + ? 'bg-cyan-50 border-cyan-500 shadow-lg' + : selectedItems.some(a => a.artifact_id === artifact.artifact_id) + ? 'bg-teal-50 border-teal-500 shadow-lg' + : 'bg-white border-gray-300 hover:bg-teal-50 hover:border-teal-500 hover:shadow-lg' } transition-all duration-200 overflow-hidden ${onArtifactClick ? 'cursor-pointer' : ''}`} onClick={() => onArtifactClick && onArtifactClick(artifact)} > {/* Card Header */} -
+
@@ -171,7 +184,8 @@ const ArtifactCardGrid = ({ {artifactType}
-

+

+ URI: {artifactType === "Label" && onLabelClick ? ( - + ) : ( - + )}

@@ -225,60 +239,60 @@ const ArtifactCardGrid = ({ Created: - + {formatDate(artifact.create_time_since_epoch)}
- {/* URI */} - {artifact.uri && artifact.uri !== "N/A" && ( -
- - - -
- URI: - - - -
-
- )} - - {/* Git Info */} - {(getPropertyValue(artifact.artifact_properties, "git_repo") !== "N/A" || - getPropertyValue(artifact.artifact_properties, "Commit") !== "N/A") && ( -
- {getPropertyValue(artifact.artifact_properties, "git_repo") !== "N/A" && ( -
- - - - - - -
- )} - {getPropertyValue(artifact.artifact_properties, "Commit") !== "N/A" && ( -
- - - - - + {/* Artifact Names from ExecutionLogs */} + {(() => { + const rawNames = artifact.execution_names; + const stripQuotes = n => typeof n === 'string' ? n.replace(/^"|"$/g, '') : n; + const names = Array.isArray(rawNames) + ? rawNames.map(stripQuotes).filter(n => n && n !== 'null') + : (typeof rawNames === 'string' + ? (() => { try { const p = JSON.parse(rawNames); return Array.isArray(p) ? p.map(stripQuotes).filter(n => n && n !== 'null') : []; } catch { return []; } })() + : []); + const uniqueDisplayNames = [...new Set(names.map((n) => normalizeStepMetricsName(n)))]; + if (uniqueDisplayNames.length === 0) return null; + return ( +
+
+ {uniqueDisplayNames.map((n, i) => ( + + -
- )} + ))} +
- )} + ); + })()} {/* Labels for Dataset */} {artifactType === "Dataset" && ( -
- Labels: +
+ Labels: {renderLabels(artifact)}
)} + {/* Execution count badge */} + {artifact.execution_count != null && Number(artifact.execution_count) > 0 && ( +
+
+ + + +
+ + Linked in {" "} + + {artifact.execution_count} + {" "} + {Number(artifact.execution_count) === 1 ? "execution" : "executions"} + +
+ )} + {/* Model Card Button */} {artifactType === "Model" && (
- - {/* Card Footer - Expandable Properties */} -
- - - {expandedCard === index && ( -
-
- {artifact.artifact_properties?.map((property, idx) => ( -
-
- -
-
- -
-
- ))} -
-
- )} -
))}
diff --git a/ui/src/components/DetailDrawer/index.jsx b/ui/src/components/DetailDrawer/index.jsx index 2ad7e181e..fc8ec36ae 100644 --- a/ui/src/components/DetailDrawer/index.jsx +++ b/ui/src/components/DetailDrawer/index.jsx @@ -14,7 +14,7 @@ * limitations under the License. ***/ -import React from "react"; +import React, { useState } from "react"; /** * Generic reusable detail drawer that slides in from the right. @@ -22,11 +22,21 @@ import React from "react"; * Props: * - title {string} — Drawer heading (e.g. "Execution Details") * - subtitle {node} — Sub-heading node rendered below the title - * - summaryFields {Array} — [{ label, value, color }] key-info chips * - allProperties {Array} — [{ name, value }] full property list * - onClose {function} — Called when overlay or ✕ is clicked + * - children {node} — Optional custom content rendered below properties + * - showAllProperties {boolean} — Toggle All Properties section visibility */ -const DetailDrawer = ({ title, subtitle, summaryFields = [], allProperties = [], onClose }) => { +const DetailDrawer = ({ + title, + subtitle, + allProperties = [], + onClose, + children, + showAllProperties = true, +}) => { + const [isFullscreen, setIsFullscreen] = useState(false); + if (!onClose) return null; return ( @@ -38,62 +48,78 @@ const DetailDrawer = ({ title, subtitle, summaryFields = [], allProperties = [], /> {/* Drawer panel */} -
+
{/* Header */}

{title}

{subtitle && ( -

{subtitle}

+
{subtitle}
)}
- +
+ + +
{/* Body */}
- {/* Summary chips */} - {summaryFields.length > 0 && ( -
- {summaryFields.map(({ label, value, color }) => - value && value !== "N/A" ? ( -
-
{label}
-
{value}
+ {showAllProperties && ( + <> + {/* Divider + section title */} +
+

Properties

+
+ + {/* All properties */} +
+ {allProperties.map((prop, idx) => ( +
+
{prop.name}
+
{prop.value}
- ) : null - )} -
+ ))} + {allProperties.length === 0 && ( +

No properties available

+ )} +
+ )} - {/* Divider + section title */} -
-

All Properties

-
- - {/* All properties */} -
- {allProperties.map((prop, idx) => ( -
-
{prop.name}
-
{prop.value}
-
- ))} - {allProperties.length === 0 && ( -

No properties available

- )} -
+ {children && ( +
+ {children} +
+ )}
diff --git a/ui/src/components/ExecutionCardGrid/index.jsx b/ui/src/components/ExecutionCardGrid/index.jsx index c617f615a..16c9ce75c 100644 --- a/ui/src/components/ExecutionCardGrid/index.jsx +++ b/ui/src/components/ExecutionCardGrid/index.jsx @@ -22,7 +22,7 @@ import PythonEnvPopup from "../PythonEnvPopup"; const client = new FastAPIClient(config); -const ExecutionCard = ({ execution, filterValue, onCardClick, isSelected = false, onToggle }) => { +const ExecutionCard = ({ execution, filterValue, onCardClick, isSelected = false, isActive = false, onToggle }) => { const [showPythonPopup, setShowPythonPopup] = useState(false); const [pythonEnvData, setPythonEnvData] = useState(""); const [expandedProperties, setExpandedProperties] = useState(false); @@ -88,23 +88,21 @@ const ExecutionCard = ({ execution, filterValue, onCardClick, isSelected = false return ( <>
onCardClick && onCardClick(execution)} > {/* Card Header */} -
+
- - + +
@@ -123,7 +121,7 @@ const ExecutionCard = ({ execution, filterValue, onCardClick, isSelected = false - + {createdAt}
)}

@@ -197,48 +195,6 @@ const ExecutionCard = ({ execution, filterValue, onCardClick, isSelected = false )}

- - {/* Card Footer - Expandable all properties */} -
- - {expandedProperties && ( -
-
- {allProps.map((prop, idx) => ( -
-
- -
-
- -
-
- ))} -
-
- )} -
{showPythonPopup && ( diff --git a/ui/src/pages/artifacts_postgres_grid/index.jsx b/ui/src/pages/artifacts_postgres_grid/index.jsx index 0b278a9ca..971b5a729 100644 --- a/ui/src/pages/artifacts_postgres_grid/index.jsx +++ b/ui/src/pages/artifacts_postgres_grid/index.jsx @@ -31,6 +31,72 @@ import Papa from "papaparse"; const client = new FastAPIClient(config); +const getArtifactPropertiesArray = (artifact) => { + if (!artifact) return []; + + if (Array.isArray(artifact.artifact_properties)) { + return artifact.artifact_properties; + } + + try { + return JSON.parse(artifact.artifact_properties || "[]"); + } catch { + return []; + } +}; + +const getArtifactPropertyValue = (properties, propertyName, fallback = "N/A") => { + console.log("Getting property value for", propertyName, "from properties:", properties); + const matchedValues = properties + .filter((property) => property.name === propertyName) + .map((property) => property.value) + .filter(Boolean); + + const uniqueValues = [...new Set(matchedValues)]; + return uniqueValues.length > 0 ? uniqueValues.join(", ") : fallback; +}; + +const truncateCommit = (commit) => { + if (!commit || commit === "N/A") return commit; + return commit.length > 12 ? commit.slice(0, 12) + "…" : commit; +}; + +const formatDrawerDate = (timestamp) => { + if (!timestamp || timestamp === "N/A") return "N/A"; + try { + return new Date(parseInt(timestamp)).toLocaleString(); + } catch { + return timestamp; + } +}; + +const normalizeStepMetricsName = (value) => { + const text = String(value || ""); + return text.toLowerCase().includes("training_metrics") ? "training_metrics" : text; +}; + +const sanitizeExecutionName = (value) => { + if (value == null) return null; + return String(value).replace(/^"+|"+$/g, ""); +}; + +const normalizeExecutionRecords = (response) => { + const executions = response?.executions; + if (Array.isArray(executions) && executions.length > 0) { + return executions + .map((entry) => ({ + execution_uuid: entry?.execution_uuid || null, + name: sanitizeExecutionName(entry?.name), + })) + .filter((entry) => Boolean(entry.execution_uuid)); + } + + const executionUuids = response?.execution_uuids || []; + return executionUuids + .map((uuid) => ({ execution_uuid: uuid, name: null })) + .filter((entry) => Boolean(entry.execution_uuid)); +}; + const ArtifactsPostgres = () => { const [searchParams] = useSearchParams(); const [selectedPipeline, setSelectedPipeline] = useState(null); @@ -49,6 +115,11 @@ const ArtifactsPostgres = () => { // Artifact detail drawer state const [selectedArtifact, setSelectedArtifact] = useState(null); + const [artifactExecutionUuids, setArtifactExecutionUuids] = useState([]); + const [selectedExecutionUuid, setSelectedExecutionUuid] = useState(null); + const [selectedExecutionMetadata, setSelectedExecutionMetadata] = useState(null); + const [executionListLoading, setExecutionListLoading] = useState(false); + const [executionMetadataLoading, setExecutionMetadataLoading] = useState(false); // Label content panel states const [selectedTableLabel, setSelectedTableLabel] = useState(null); @@ -181,10 +252,55 @@ const ArtifactsPostgres = () => { setSelectedArtifacts([]); }; - const handleArtifactCardClick = (artifact) => { + const handleArtifactCardClick = async (artifact) => { setSelectedArtifact(artifact); + setArtifactExecutionUuids([]); + setSelectedExecutionUuid(null); + setSelectedExecutionMetadata(null); + + if (!artifact?.uri || !selectedPipeline) return; + + setExecutionListLoading(true); + try { + const response = await client.getArtifactExecutions(selectedPipeline, artifact.uri); + const executionRecords = normalizeExecutionRecords(response); + setArtifactExecutionUuids(executionRecords); + setSelectedExecutionUuid(executionRecords.length > 0 ? executionRecords[0].execution_uuid : null); + } catch (error) { + console.error("Error fetching execution UUIDs for artifact:", error); + setArtifactExecutionUuids([]); + setSelectedExecutionUuid(null); + } finally { + setExecutionListLoading(false); + } }; + useEffect(() => { + const fetchExecutionMetadata = async () => { + if (!selectedPipeline || !selectedArtifact?.uri || !selectedExecutionUuid) { + setSelectedExecutionMetadata(null); + return; + } + + setExecutionMetadataLoading(true); + try { + const response = await client.getArtifactExecutionMetadata( + selectedPipeline, + selectedExecutionUuid, + selectedArtifact.uri, + ); + setSelectedExecutionMetadata(response?.metadata || {}); + } catch (error) { + console.error("Error fetching execution metadata:", error); + setSelectedExecutionMetadata(null); + } finally { + setExecutionMetadataLoading(false); + } + }; + + fetchExecutionMetadata(); + }, [selectedPipeline, selectedArtifact, selectedExecutionUuid]); + const handleFilter = (value) => { setFilter(value); setActivePage(1); @@ -251,39 +367,21 @@ const ArtifactsPostgres = () => { }; const artifactDetailProperties = selectedArtifact - ? (Array.isArray(selectedArtifact.artifact_properties) - ? selectedArtifact.artifact_properties - : (() => { - try { - return JSON.parse(selectedArtifact.artifact_properties || "[]"); - } catch { - return []; - } - })()) + ? getArtifactPropertiesArray(selectedArtifact) : []; - const getArtifactDetailProperty = (name) => { - const matchedValues = artifactDetailProperties - .filter((property) => property.name === name) - .map((property) => property.value); - return matchedValues.length > 0 ? matchedValues.join(", ") : "N/A"; - }; - - const formatArtifactDetailDate = (timestamp) => { - if (!timestamp || timestamp === "N/A") return "N/A"; - try { - return new Date(parseInt(timestamp)).toLocaleString(); - } catch { - return timestamp; - } - }; + const artifactCommitValue = selectedArtifact?.uri || getArtifactPropertyValue(artifactDetailProperties, "Commit"); + const selectedExecutionRecord = artifactExecutionUuids.find((entry) => entry.execution_uuid === selectedExecutionUuid); + const selectedExecutionDisplayName = normalizeStepMetricsName( + sanitizeExecutionName(selectedExecutionRecord?.name || selectedExecutionMetadata?.name) || "N/A", + ); + // Summary fields: immutable artifact metadata (Type, Created At, URI as unique identifier) const artifactDetailSummaryFields = selectedArtifact ? [ { label: "Type", value: selectedArtifactType, color: "teal" }, - { label: "URI", value: selectedArtifact.uri, color: "blue" }, - { label: "Created", value: formatArtifactDetailDate(selectedArtifact.create_time_since_epoch), color: "indigo" }, - { label: "Git Repo", value: getArtifactDetailProperty("git_repo"), color: "purple" }, - { label: "Commit", value: getArtifactDetailProperty("Commit"), color: "green" }, + { label: "Created", value: formatDrawerDate(selectedArtifact.create_time_since_epoch), color: "indigo" }, + { label: "URI (Unique ID)", value: selectedArtifact.uri, color: "blue" }, + { label: "Commit", value: artifactCommitValue, color: "violet" }, ] : []; return ( @@ -434,6 +532,7 @@ const ArtifactsPostgres = () => { onLabelClick={handleLabelClick} onArtifactClick={handleArtifactCardClick} isSplitView={true} selectedItems={selectedArtifacts} + selectedArtifactId={selectedArtifact?.artifact_id} onToggleItem={handleToggleArtifact} /> { filterValue={filter} onArtifactClick={handleArtifactCardClick} selectedItems={selectedArtifacts} + selectedArtifactId={selectedArtifact?.artifact_id} onToggleItem={handleToggleArtifact} /> { {selectedArtifact && ( ID: {selectedArtifact.artifact_id || "—"}} - summaryFields={artifactDetailSummaryFields} + subtitle={ +
+ ID: + {selectedArtifact.artifact_id || "—"} + Commit: + {artifactCommitValue} +
+ } allProperties={artifactDetailProperties} - onClose={() => setSelectedArtifact(null)} - /> + showAllProperties={false} + onClose={() => { + setSelectedArtifact(null); + setArtifactExecutionUuids([]); + setSelectedExecutionUuid(null); + setSelectedExecutionMetadata(null); + }} + > +
+
+ {/* Execution UUID List */} +
+
+

Executions (Name + UUID)

+ {artifactExecutionUuids.length} +
+
+ {executionListLoading ? ( +

Loading execution UUIDs...

+ ) : artifactExecutionUuids.length > 0 ? ( + artifactExecutionUuids.map((execution, idx) => { + const executionUuid = execution.execution_uuid; + const executionName = normalizeStepMetricsName( + sanitizeExecutionName(execution.name) || "N/A", + ); + const isActive = selectedExecutionUuid === executionUuid; + return ( + + ); + }) + ) : ( +

No execution versions found

+ )} +
+
+ + {/* Metadata Snapshot Panel */} +
+ {executionMetadataLoading ? ( +
+

Loading metadata...

+
+ ) : selectedExecutionUuid ? ( +
+
+

Execution UUID

+

+ {selectedExecutionUuid} +

+
+ + {/* Name Field */} + {selectedExecutionMetadata?.name && ( +
+

Name

+
+
{selectedExecutionDisplayName}
+
+
+ )} + + {/* Properties Section */} + {selectedExecutionMetadata?.properties && Object.keys(selectedExecutionMetadata.properties).length > 0 && ( +
+

Properties

+
+ {Object.entries(selectedExecutionMetadata.properties).map(([key, value]) => ( +
+
{key}
+
{String(value)}
+
+ ))} +
+
+ + )} + + {/* Custom Properties Section */} + {selectedExecutionMetadata?.custom_properties && Object.keys(selectedExecutionMetadata.custom_properties).length > 0 && ( +
+

Custom Properties

+
+ {Object.entries(selectedExecutionMetadata.custom_properties).map(([key, value]) => ( +
+
{key}
+
{String(value)}
+
+ ))} +
+
+ )} + + {!selectedExecutionMetadata && ( +
No metadata found for selected execution.
+ )} +
+ ) : ( +
+

Select an execution version to view its metadata

+
+ )} +
+
+
+
)} {/* Compare Modal */} diff --git a/ui/src/pages/executions_postgres_grid/index.jsx b/ui/src/pages/executions_postgres_grid/index.jsx index d28ec3405..feeb72389 100644 --- a/ui/src/pages/executions_postgres_grid/index.jsx +++ b/ui/src/pages/executions_postgres_grid/index.jsx @@ -310,6 +310,7 @@ const ExecutionsPostgresGrid = () => { filterValue={filter} onCardClick={setSelectedExecution} isSelected={selectedExecutions.some(e => e.execution_id === execution.execution_id)} + isActive={selectedExecution?.execution_id === execution.execution_id} onToggle={handleToggleExecution} /> ))} @@ -366,7 +367,6 @@ const ExecutionsPostgresGrid = () => { ID: {selectedExecution.execution_id || "—"}} - summaryFields={executionDetailSummaryFields} allProperties={executionDetailProperties} onClose={() => setSelectedExecution(null)} />