diff --git a/chi/context.py b/chi/context.py
index 0a94001..fd1a741 100644
--- a/chi/context.py
+++ b/chi/context.py
@@ -1,5 +1,6 @@
import logging
import os
+import re
import sys
import time
from typing import List, Optional
@@ -72,6 +73,7 @@ def default_key_name():
_auth_plugin = None
_session = None
_sites = {}
+_lease_id = None
version = "1.1"
@@ -440,6 +442,70 @@ def on_change(change):
else:
print("Choose site feature is only available in an ipynb environment.")
+def use_lease_id(lease_id: str) -> None:
+ """
+ Sets the current lease ID to use in the global context.
+
+ This configures the lease so it can be stored for ease
+ of restoring suspended sessions. Further lease validation,
+ visualizations, and selectors are available in the lease module.
+
+ Args:
+ lease_id (str): The ID of the lease to use.
+ """
+ global _lease_id
+
+ if not re.fullmatch(r"[A-Za-z0-9\-]+", lease_id):
+ raise CHIValueError(f'Lease ID "{lease_id}" is invalid. It must contain only letters, numbers, and hyphens with no spaces or special characters.')
+
+ _lease_id = lease_id
+
+ print(f"Now using lease with ID {lease_id}.")
+
+def get_lease_id():
+ """
+ Returns the currently active lease ID, if one has been set.
+
+ Returns:
+ str or None: The lease ID currently in use, or None if no lease has been selected.
+ """
+ if _lease_id is None:
+ print("No lease ID has been set. Use `use_lease_id()` to select one.")
+ return _lease_id
+
+def get_project_name(project_id: Optional[str] = None) -> str:
+ """
+ Returns the name of a project by ID, or the current project name if no ID is given.
+
+ Args:
+ project_id (str, optional): The ID of the project. If None, uses the current session project.
+
+ Returns:
+ str: The name of the project.
+
+ Raises:
+ ResourceError: If the project cannot be found or the request fails.
+ """
+ keystone_session = session()
+ keystone_client = KeystoneClient(
+ session=keystone_session,
+ interface=getattr(keystone_session, "interface", None),
+ region_name=getattr(keystone_session, "region_name", None),
+ )
+
+ try:
+ if project_id:
+ project = keystone_client.projects.get(project_id)
+ else:
+ current_id = keystone_session.get_project_id()
+ project = keystone_client.projects.get(current_id)
+ except keystone_exceptions.NotFound:
+ raise ResourceError("Project not found.")
+ except keystone_exceptions.Unauthorized:
+ raise ResourceError("Failed to retrieve project. Check your credentials.")
+
+ return project.name
+
def list_projects(show: str = None) -> List[str]:
"""
diff --git a/chi/lease.py b/chi/lease.py
index d663c2f..50a7fa5 100644
--- a/chi/lease.py
+++ b/chi/lease.py
@@ -1,20 +1,23 @@
import json
import logging
import numbers
+import os
import re
import time
from datetime import datetime, timedelta
from typing import TYPE_CHECKING, List, Optional, Union
+import pandas
from blazarclient.exception import BlazarClientException
+from ipydatagrid import DataGrid, Expr, TextRenderer
from IPython.display import display
-from ipywidgets import HTML
+from ipywidgets import HTML, Box, Layout
from packaging.version import Version
from chi import context, server, util
-from .clients import blazar
-from .context import _is_ipynb
+from .clients import blazar, connection
+from .context import _is_ipynb, get_project_name
from .exception import CHIValueError, ResourceError, ServiceError
from .hardware import Device, Node
from .network import PUBLIC_NETWORK, get_network_id, list_floating_ips
@@ -259,6 +262,135 @@ def _populate_from_json(self, lease_json):
# self.events = lease_json.get('events', [])
+ def _ipython_display_(self):
+ """
+ Displays a styled summary of the lease when run in a Jupyter notebook.
+
+ This method is called automatically by the Jupyter display system when
+ an instance of the Lease object is the final expression in a cell.
+ It presents key lease attributes using ipywidgets for readability.
+ """
+ layout = Layout(padding="4px 10px")
+ style = {
+ "description_width": "initial",
+ "background": "#d3d3d3",
+ "white_space": "nowrap",
+ }
+
+ status_style = style.copy()
+ status_colors = {
+ "ACTIVE": "#a2d9fe",
+ "PENDING": "#ffe599",
+ "TERMINATED": "#f69084",
+ }
+ if self.status:
+ status_style["background"] = status_colors.get(self.status, "#d3d3d3")
+
+ children = [
+ # HTML(f"Lease ID: {self.id}", style=style, layout=layout),
+ HTML(f"Status: {self.status}", style=status_style, layout=layout),
+ HTML(f"Name: {self.name}", style=style, layout=layout),
+ ]
+
+ if self.start_date:
+ children.append(
+ HTML(
+ f"Start: {self.start_date.strftime('%Y-%m-%d %H:%M')}",
+ style=style,
+ layout=layout,
+ )
+ )
+ if self.end_date:
+ children.append(
+ HTML(
+ f"End: {self.end_date.strftime('%Y-%m-%d %H:%M')}",
+ style=style,
+ layout=layout,
+ )
+ )
+
+ remaining = None
+ if self.end_date and datetime.now() < self.end_date:
+ remaining = self.end_date - datetime.now()
+
+ if remaining:
+ days = remaining.days
+ hours = remaining.seconds // 3600
+ minutes = (remaining.seconds % 3600) // 60
+ children.append(
+ HTML(
+ f"Remaining: {days:02d}d {hours:02d}h {minutes:02d}m",
+ style=style,
+ layout=layout,
+ )
+ )
+
+ # Reservations
+ children.append(
+ HTML(
+ f"Node Reservations: {len(self.node_reservations)}",
+ style=style,
+ layout=layout,
+ )
+ )
+ children.append(
+ HTML(
+ f"FIP Reservations: {len(self.fip_reservations)}",
+ style=style,
+ layout=layout,
+ )
+ )
+ children.append(
+ HTML(
+ f"Device Reservations: {len(self.device_reservations)}",
+ style=style,
+ layout=layout,
+ )
+ )
+
+ if self.project_id:
+ try:
+ project_name = context.get_project_name(self.project_id)
+ children.append(
+ HTML(
+ f"Project Name: {project_name}",
+ style=style,
+ layout=layout,
+ )
+ )
+ except ResourceError:
+ children.append(
+ HTML(
+ f"Project ID: {self.project_id}",
+ style=style,
+ layout=layout,
+ )
+ )
+ if self.user_id:
+ user_id = connection().get_user_id()
+ if self.user_id == user_id:
+ label = os.getenv("OS_USERNAME")
+ children.append(
+ HTML(f"User Name: {label}", style=style, layout=layout)
+ )
+ else:
+ label = self.user_id # [:8] # or just show a truncated ID
+ children.append(
+ HTML(f"User ID: {label}", style=style, layout=layout)
+ )
+ if self.created_at:
+ children.append(
+ HTML(
+ f"Created At: {self.created_at.strftime('%Y-%m-%d %H:%M')}",
+ style=style,
+ layout=layout,
+ )
+ )
+
+ box = Box(children=children)
+ box.layout = Layout(flex_flow="row wrap")
+ display(box)
+
def add_device_reservation(
self,
amount: int = None,
@@ -1078,6 +1210,119 @@ def list_leases() -> List[Lease]:
return leases
+def _status_color(cell):
+ return "#a2d9fe" if cell.value == "2-ACTIVE" else (
+ "#ffe599" if cell.value == "1-PENDING" else (
+ "#f69084" if cell.value == "3-TERMINATED" else "#e0e0e0"))
+
+def show_leases() -> DataGrid:
+ """
+ Displays a table of the user's leases in an interactive, sortable format.
+
+ Uses an ipydatagrid to present key lease attributes such as ID, name, status,
+ duration, and reservation counts. The grid supports sorting, filtering, and
+ scrolling for easy exploration of lease state.
+
+ Returns:
+ DataGrid: An ipydatagrid widget displaying the leases.
+ """
+
+ def estimate_column_width(df, column, char_px=7, padding=0):
+ if column not in df.columns:
+ raise ValueError(f"Column '{column}' not found in DataFrame.")
+ max_chars = df[column].astype(str).map(len).max()
+ return max(max_chars * char_px + padding, 80)
+
+
+ leases = list_leases()
+
+ rows = []
+ for lease in leases:
+
+ try:
+ project_name = get_project_name(lease.project_id)
+ except ResourceError:
+ project_name = lease.project_id[:8] if lease.project_id else "Unknown"
+
+ if lease.user_id == connection().current_user_id:
+ user_label = os.getenv("OS_USERNAME")
+ else:
+ user_label = lease.user_id if lease.user_id else "Unknown"
+
+ if lease.start_date and lease.end_date:
+ duration_hrs = round((lease.end_date - lease.start_date).total_seconds() / 3600, 1)
+ else:
+ duration_hrs = "N/A"
+
+ # Inside your row-building loop:
+ if lease.end_date and lease.end_date > datetime.now():
+ remaining_td = lease.end_date - datetime.now()
+ remaining_str = f"{remaining_td.days:02d}d {(remaining_td.seconds // 3600):02d}h"
+ elif lease.end_date and lease.end_date <= datetime.now():
+ remaining_str = "Expired"
+ else:
+ remaining_str = "N/A"
+
+
+ # prepending status with numeric makes it possible to character sort
+ # since ipydatagrid does not allow custom sort functions
+ status_order = {
+ "PENDING": "1-PENDING",
+ "ACTIVE": "2-ACTIVE",
+ "TERMINATED": "3-TERMINATED"
+ }
+
+ rows.append({
+ "Name": lease.name,
+ "Status": status_order.get(lease.status, f"4-{lease.status}"),
+ "User": user_label,
+ "Project": project_name,
+ "Start": lease.start_date.strftime("%Y-%m-%d %H:%M") if lease.start_date else "",
+ "End": lease.end_date.strftime("%Y-%m-%d %H:%M") if lease.end_date else "",
+ "Remaining": remaining_str,
+ "Total Hours": duration_hrs,
+ "# Nodes": len(lease.node_reservations),
+ "# FIPs": len(lease.fip_reservations),
+ "Created": lease.created_at.strftime("%Y-%m-%d %H:%M") if lease.created_at else "",
+ "Lease ID": lease.id,
+ "_is_user_lease": 0 if lease.user_id == connection().current_user_id else 1
+ })
+
+ df = pandas.DataFrame(rows)
+ df = pandas.DataFrame(rows)
+ df = df.sort_values(by=["_is_user_lease", "Status", "Created"])
+ df = df.drop(columns=["_is_user_lease"])
+
+ renderers = {
+ "Status": TextRenderer(
+ background_color=Expr(_status_color),
+ text_color="black",
+ ),
+
+ }
+
+ display(DataGrid(
+ df,
+ layout={"height": "400px", "width": "100%"},
+ column_widths={
+ "key": 30,
+ "Name": int(estimate_column_width(df, "Name")),
+ "Status": 120,
+ "Remaining": 80,
+ "Total Hours": 50,
+ "# Nodes": 30,
+ "# FIPs": 30,
+ "Project": 100,
+ "User": 75,
+ "Start": 95,
+ "End": 95,
+ "Created": 95,
+ "Lease ID": 30,
+
+ },
+ renderers=renderers
+ ))
+
def _get_lease_from_blazar(ref: str):
blazar_client = blazar()