diff --git a/.devcontainer/Containerfile b/.devcontainer/Containerfile index 8b2d013..513cbab 100644 --- a/.devcontainer/Containerfile +++ b/.devcontainer/Containerfile @@ -67,7 +67,8 @@ RUN echo 'source /root/.ble.sh/out/ble.sh' >> /root/.bashrc && \ echo 'export FZF_DEFAULT_OPTS="--layout=reverse --preview '\''bat --color=always {}'\''"' >> /root/.bashrc && \ echo 'export FZF_CTRL_T_COMMAND="find . -type f -not -path '\''./.git/*'\''"' >> /root/.bashrc && \ echo 'export FZF_CTRL_T_OPTS="--height 100% --preview '\''bat --color=always {}'\''"' >> /root/.bashrc && \ - echo 'export FZF_CTRL_R_OPTS="--height 100% --preview '\''echo {}'\'' --preview-window up:3:wrap"' >> /root/.bashrc + echo 'export FZF_CTRL_R_OPTS="--height 100% --preview '\''echo {}'\'' --preview-window up:3:wrap"' >> /root/.bashrc && \ + echo 'clear' >> /root/.bashrc RUN microdnf install -y python3.12 python3.12-pip \ && microdnf clean all \ @@ -84,7 +85,7 @@ ENV _ZO_DOCTOR=0 \ PIP_DISABLE_PIP_VERSION_CHECK=on \ PIP_NO_WARN_SCRIPT_LOCATION=on \ PIP_DEFAULT_TIMEOUT=100 \ - POSH_THEME="/root/.posh-themes/powerlevel10k_lean.omp.json" + POSH_THEME="/root/.posh-themes/aliens.omp.json" COPY /app/requirements.txt /app/requirements.txt RUN pip install -r /app/requirements.txt diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 2811307..0e1f9f0 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -7,7 +7,7 @@ "customizations": { "vscode": { "extensions": [ - "ms-python.vscode-pylance" + "ms-python.python" ], "settings": { "python.defaultInterpreterPath": "/venv/bin/python" diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..7e68766 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "python-envs.pythonProjects": [] +} \ No newline at end of file diff --git a/app/components/container_tab.py b/app/components/container_tab.py index 3b32ef2..e4c6cc8 100644 --- a/app/components/container_tab.py +++ b/app/components/container_tab.py @@ -24,19 +24,36 @@ def show(client): df_containers = pd.DataFrame(container_data) - containerCols = st.columns((1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1)) + action = st.selectbox( + "Container Actions", + [ + "Select action...", + "πŸ” Inspect", + "πŸ”— Show Links", + "πŸ“ View Logs", + "πŸ“„ Generate Quadlet", + "▢️ Execute Command", + "▢️ Start", + "⏸️ Pause", + "⏹️ Stop", + "πŸ—‘οΈ Remove", + "🧹 Prune", + "πŸ”„ Refresh" + ] + ) - inspect = container_buttons.show_inspect(containerCols[0]) - show_links = container_buttons.show_links(containerCols[1]) - logs = container_buttons.show_logs(containerCols[2]) - generate_quadlet = container_buttons.show_generate_quadlet(containerCols[3]) - container_exec = container_buttons.show_exec(containerCols[4]) - start = container_buttons.show_start(containerCols[5]) - pause = container_buttons.show_pause(containerCols[6]) - stop = container_buttons.show_stop(containerCols[7]) - remove = container_buttons.show_remove(containerCols[8]) - prune = container_buttons.show_prune(containerCols[9]) - refresh = container_buttons.show_refresh(containerCols[10]) + # Convert dropdown selection to button clicks + inspect = action == "πŸ” Inspect" + show_links = action == "πŸ”— Show Links" + logs = action == "πŸ“ View Logs" + generate_quadlet = action == "πŸ“„ Generate Quadlet" + container_exec = action == "▢️ Execute Command" + start = action == "▢️ Start" + pause = action == "⏸️ Pause" + stop = action == "⏹️ Stop" + remove = action == "πŸ—‘οΈ Remove" + prune = action == "🧹 Prune" + refresh = action == "πŸ”„ Refresh" edited_containers_df = st.data_editor( df_containers, @@ -49,7 +66,7 @@ def show(client): ), }, column_order=["Selected", "Name","ID", "Status", "Image", "Ports", "Created"], - use_container_width=True + width='stretch' ) selected_containers = edited_containers_df[edited_containers_df['Selected']] diff --git a/app/components/image_tab.py b/app/components/image_tab.py index cce21d3..d9bbd83 100644 --- a/app/components/image_tab.py +++ b/app/components/image_tab.py @@ -68,24 +68,29 @@ def show(client): df_images = pd.DataFrame(image_data) - imageCols = st.columns((1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1)) - - with imageCols[0]: - inspect_all = st.button("πŸ”", help="Inspect Selected Images") - - with imageCols[1]: - pull_all = st.button("πŸ“₯", help="Pull Selected Images") - - with imageCols[2]: - remove_all = st.button("πŸ—‘οΈ", help="Remove Selected Images") - - with imageCols[3]: - if st.button("βœ‚οΈ", help="Prune All Images"): - client.images.prune() - client.images.prune_builds() - st.rerun() - with imageCols[4]: - refresh_all = st.button("πŸ”„", help="Refresh All Images") + action = st.selectbox( + "Image Actions", + [ + "Select action...", + "πŸ” Inspect", + "πŸ“₯ Pull", + "πŸ—‘οΈ Remove", + "βœ‚οΈ Prune", + "πŸ”„ Refresh" + ] + ) + + # Convert dropdown selection to button clicks + inspect_all = action == "πŸ” Inspect" + pull_all = action == "πŸ“₯ Pull" + remove_all = action == "πŸ—‘οΈ Remove" + prune_all = action == "βœ‚οΈ Prune" + refresh_all = action == "πŸ”„ Refresh" + + if prune_all: + client.images.prune() + client.images.prune_builds() + st.rerun() edited_images_df = st.data_editor(df_images, hide_index=True, @@ -96,7 +101,7 @@ def show(client): help="Select images for actions" ) }, - use_container_width=True) + width="stretch") selected_images = edited_images_df[edited_images_df['Selected']] diff --git a/app/components/network_tab.py b/app/components/network_tab.py index ac27e19..4188a4a 100644 --- a/app/components/network_tab.py +++ b/app/components/network_tab.py @@ -34,16 +34,20 @@ def show(client): df_networks = pd.DataFrame(network_data) - networkCols = st.columns((1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1)) + action = st.selectbox( + "Network Actions", + [ + "Select action...", + "πŸ” Inspect", + "πŸ—‘οΈ Remove", + "πŸ”„ Refresh" + ] + ) - with networkCols[0]: - inspect_all = st.button("πŸ”", help="Inspect Selected Networks") - - with networkCols[1]: - remove_all = st.button("πŸ—‘οΈ", help="Remove Selected Networks") - - with networkCols[2]: - refresh_all = st.button("πŸ”„", help="Refresh All Networks") + # Convert dropdown selection to button clicks + inspect_all = action == "πŸ” Inspect" + remove_all = action == "πŸ—‘οΈ Remove" + refresh_all = action == "πŸ”„ Refresh" edited_networks_df = st.data_editor(df_networks, hide_index=True, @@ -54,7 +58,7 @@ def show(client): help="Select networks for actions" ) }, - use_container_width=True) + width="stretch") selected_networks = edited_networks_df[edited_networks_df['Selected']] diff --git a/app/components/pod_tab.py b/app/components/pod_tab.py index 30fda68..2f10392 100644 --- a/app/components/pod_tab.py +++ b/app/components/pod_tab.py @@ -40,30 +40,32 @@ def show(client): df_pods = pd.DataFrame(pod_data) - podCols = st.columns((1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1)) - - with podCols[0]: - inspect_all = st.button("πŸ”", help="Inspect Selected Pods") - - with podCols[1]: - start_all = st.button("▢️", help="Start Selected Pods") - - with podCols[2]: - pause_all = st.button("⏸️", help="Pause Selected Pods") - - with podCols[3]: - stop_all = st.button("⏹️", help="Stop Selected Pods") - - with podCols[4]: - remove_all = st.button("πŸ—‘οΈ", help="Remove Selected Pods") - - with podCols[5]: - if st.button("βœ‚οΈ", help="Prune All Pods"): - client.pods.prune() - st.rerun() - - with podCols[6]: - refresh_all = st.button("πŸ”„", help="Refresh All Pods") + action = st.selectbox( + "Pod Actions", + [ + "Select action...", + "πŸ” Inspect", + "▢️ Start", + "⏸️ Pause", + "⏹️ Stop", + "πŸ—‘οΈ Remove", + "βœ‚οΈ Prune", + "πŸ”„ Refresh" + ] + ) + + # Convert dropdown selection to button clicks + inspect_all = action == "πŸ” Inspect" + start_all = action == "▢️ Start" + pause_all = action == "⏸️ Pause" + stop_all = action == "⏹️ Stop" + remove_all = action == "πŸ—‘οΈ Remove" + prune_all = action == "βœ‚οΈ Prune" + refresh_all = action == "πŸ”„ Refresh" + + if prune_all: + client.pods.prune() + st.rerun() edited_pods_df = st.data_editor(df_pods, hide_index=True, @@ -74,7 +76,7 @@ def show(client): help="Select pods for actions" ) }, - use_container_width=True) + width="stretch") selected_pods = edited_pods_df[edited_pods_df['Selected']] diff --git a/app/components/secret_tab.py b/app/components/secret_tab.py index 9b6b1d0..0535ea2 100644 --- a/app/components/secret_tab.py +++ b/app/components/secret_tab.py @@ -48,6 +48,6 @@ def show(client): st.error(f"Error deleting secret: {str(e)}") if secrets_list: - st.dataframe(secrets_list, use_container_width=True) + st.dataframe(secrets_list, width="stretch") else: st.info("No secrets found.") \ No newline at end of file diff --git a/app/components/volume_tab.py b/app/components/volume_tab.py index 22157a3..b4755a7 100644 --- a/app/components/volume_tab.py +++ b/app/components/volume_tab.py @@ -34,19 +34,22 @@ def show(client): df_volumes = pd.DataFrame(volume_data) - volumeCols = st.columns((1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1)) + action = st.selectbox( + "Volume Actions", + [ + "Select action...", + "πŸ” Inspect", + "πŸ—‘οΈ Remove", + "βœ‚οΈ Prune", + "πŸ”„ Refresh" + ] + ) - with volumeCols[0]: - inspect_all = st.button("πŸ”", help="Inspect Selected Volumes") - - with volumeCols[1]: - remove_all = st.button("πŸ—‘οΈ", help="Remove Selected Volumes") - - with volumeCols[2]: - prune_all = st.button("βœ‚οΈ", help="Prune All Volumes") - - with volumeCols[3]: - refresh_all = st.button("πŸ”„", help="Refresh All Volumes") + # Convert dropdown selection to button clicks + inspect_all = action == "πŸ” Inspect" + remove_all = action == "πŸ—‘οΈ Remove" + prune_all = action == "βœ‚οΈ Prune" + refresh_all = action == "πŸ”„ Refresh" edited_volumes_df = st.data_editor(df_volumes, hide_index=True, @@ -57,7 +60,7 @@ def show(client): help="Select volumes for actions" ) }, - use_container_width=True) + width="stretch") selected_volumes = edited_volumes_df[edited_volumes_df['Selected']] diff --git a/app/pages/container_stats.py b/app/pages/container_stats.py index 19c73c5..43e6530 100644 --- a/app/pages/container_stats.py +++ b/app/pages/container_stats.py @@ -5,6 +5,7 @@ sidebar ) import pandas as pd +import numpy as np import altair as alt import time from datetime import datetime @@ -21,25 +22,79 @@ def calculate_cpu_percent(current_stats, previous_stats): return 0.0 def create_cpu_chart(data): + # Ensure we have valid data + if len(data) == 0 or data['cpu_percent'].isnull().all(): + # Create empty chart with domain + return alt.Chart(pd.DataFrame({ + 'timestamp': [datetime.now()], + 'cpu_percent': [0] + })).mark_line( + point=False + ).encode( + x=alt.X('timestamp:T', title='Time'), + y=alt.Y('cpu_percent:Q', title='CPU (%)', scale=alt.Scale(domain=[0, 100])), + tooltip=['timestamp:T', 'cpu_percent:Q'] + ).properties(height=250, title='CPU Usage') + return alt.Chart(data).mark_line( point=False ).encode( x=alt.X('timestamp:T', title='Time'), - y=alt.Y('cpu_percent:Q', title='CPU (%)'), + y=alt.Y('cpu_percent:Q', title='CPU (%)', scale=alt.Scale(domain=[0, max(100, data['cpu_percent'].max() * 1.1)])), tooltip=['timestamp:T', 'cpu_percent:Q'] ).properties(height=250, title='CPU Usage') def create_memory_chart(data): + # Ensure we have valid data + if len(data) == 0 or data['memory_mb'].isnull().all(): + # Create empty chart with domain + return alt.Chart(pd.DataFrame({ + 'timestamp': [datetime.now()], + 'memory_mb': [0] + })).mark_line( + point=False, + color='#00FF00' + ).encode( + x=alt.X('timestamp:T', title='Time'), + y=alt.Y('memory_mb:Q', title='Memory (MB)', scale=alt.Scale(domain=[0, 1000])), + tooltip=['timestamp:T', 'memory_mb:Q'] + ).properties(height=250, title='Memory Usage') + + max_memory = data['memory_mb'].max() return alt.Chart(data).mark_line( point=False, color='#00FF00' ).encode( x=alt.X('timestamp:T', title='Time'), - y=alt.Y('memory_mb:Q', title='Memory (MB)'), + y=alt.Y('memory_mb:Q', title='Memory (MB)', scale=alt.Scale(domain=[0, max_memory * 1.1])), tooltip=['timestamp:T', 'memory_mb:Q'] ).properties(height=250, title='Memory Usage') def create_network_chart(data): + # Ensure we have valid data + if len(data) == 0 or (data['rx_bytes'].isnull().all() and data['tx_bytes'].isnull().all()): + # Create empty chart with domain + empty_data = pd.DataFrame({ + 'timestamp': [datetime.now()], + 'rx_bytes': [0], + 'tx_bytes': [0] + }) + return alt.Chart(empty_data).transform_fold( + ['rx_bytes', 'tx_bytes'], + as_=['Metric', 'Value'] + ).mark_line( + point=False + ).encode( + x=alt.X('timestamp:T', title='Time'), + y=alt.Y('Value:Q', title='Network (KB/s)', scale=alt.Scale(domain=[0, 1000])), + color=alt.Color('Metric:N'), + tooltip=['timestamp:T', 'Value:Q', 'Metric:N'] + ).properties(height=250, title='Network Traffic') + + max_network = max( + data['rx_bytes'].max() if not data['rx_bytes'].isnull().all() else 0, + data['tx_bytes'].max() if not data['tx_bytes'].isnull().all() else 0 + ) return alt.Chart(data).transform_fold( ['rx_bytes', 'tx_bytes'], as_=['Metric', 'Value'] @@ -47,17 +102,17 @@ def create_network_chart(data): point=False ).encode( x=alt.X('timestamp:T', title='Time'), - y=alt.Y('Value:Q', title='Network (KB/s)'), + y=alt.Y('Value:Q', title='Network (KB/s)', scale=alt.Scale(domain=[0, max_network * 1.1])), color=alt.Color('Metric:N'), tooltip=['timestamp:T', 'Value:Q', 'Metric:N'] ).properties(height=250, title='Network Traffic') def show_container_selector(client): containers = client.containers.list(all=True) - container_options = [(c.id, f"{c.name} ({c.short_id})") for c in containers] + container_options = [(None, "Select a container...")] + [(c.id, f"{c.name} ({c.short_id})") for c in containers] if 'current_container_id' not in st.session_state: - st.session_state.current_container_id = container_options[0][0] + st.session_state.current_container_id = None selected_id = st.selectbox( "Select Container", @@ -73,10 +128,32 @@ def show_container_selector(client): return selected_id def clear_placeholders(): + """Clear all placeholder widgets""" if 'placeholders' in st.session_state: for placeholder in st.session_state.placeholders.values(): - placeholder.empty() - st.session_state.placeholders = {} + try: + placeholder.empty() + except: + pass + st.session_state.placeholders = {} + +def cleanup_session_state(): + """Clean up session state when leaving the page""" + # Clear placeholders first + clear_placeholders() + + # Clean up other session state + keys_to_clear = [ + 'stats_data', + 'previous_stats', + 'current_container_id', + 'retention_seconds', + 'page_active' + ] + + for key in keys_to_clear: + if key in st.session_state: + del st.session_state[key] def initialize_stats_data(seconds, end_time=None): """Initialize empty stats data for the specified number of seconds""" @@ -85,10 +162,10 @@ def initialize_stats_data(seconds, end_time=None): return [ { 'timestamp': end_time - pd.Timedelta(seconds=i), - 'cpu_percent': None, - 'memory_mb': None, - 'rx_bytes': None, - 'tx_bytes': None + 'cpu_percent': 0.0, + 'memory_mb': 0.0, + 'rx_bytes': 0.0, + 'tx_bytes': 0.0 } for i in range(seconds, 0, -1) ] @@ -96,38 +173,42 @@ def initialize_stats_data(seconds, end_time=None): def get_network_interfaces(stats): return list(stats['Network'].keys()) +def create_chart_containers(): + """Create the containers for the charts in vertical layout""" + # Clear existing placeholders + clear_placeholders() + + # Create placeholders for vertical layout + st.session_state.placeholders['cpu'] = st.empty() + st.session_state.placeholders['memory'] = st.empty() + st.session_state.placeholders['network'] = st.empty() + def show_container_stats(client, container_id): - container = client.containers.get(container_id) + # Initialize session state for page activity tracking + if 'page_active' not in st.session_state: + st.session_state.page_active = True + + # Reset page activity when the function starts + st.session_state.page_active = True + + # Convert container_id to bytes before getting the container + container_id_bytes = str(container_id).encode('utf-8') + container = client.containers.get(container_id_bytes) container.reload() st.header(f"Container Stats: {container.name}") - col1, col2 = st.columns([1, 1]) - with col1: - if 'chart_layout' not in st.session_state: - st.session_state.chart_layout = "horizontal" - - new_layout = st.radio( - "Chart Layout", - options=["horizontal", "vertical"], - horizontal=True, - ) - - if new_layout != st.session_state.chart_layout: - clear_placeholders() - st.session_state.chart_layout = new_layout + # Retention period control + if 'retention_seconds' not in st.session_state: + st.session_state.retention_seconds = 60 - with col2: - if 'retention_seconds' not in st.session_state: - st.session_state.retention_seconds = 60 - - st.session_state.retention_seconds = st.number_input( - "Data retention period (seconds)", - min_value=10, - max_value=3600, - value=st.session_state.retention_seconds, - help="How many seconds of historical data to keep in the charts" - ) + st.session_state.retention_seconds = st.number_input( + "Data retention period (seconds)", + min_value=10, + max_value=3600, + value=st.session_state.retention_seconds, + help="How many seconds of historical data to keep in the charts" + ) if 'stats_data' not in st.session_state: st.session_state.stats_data = initialize_stats_data(st.session_state.retention_seconds) @@ -136,22 +217,11 @@ def show_container_stats(client, container_id): if 'placeholders' not in st.session_state: st.session_state.placeholders = {} + # Create chart containers if they don't exist if not st.session_state.placeholders: - if st.session_state.chart_layout == "horizontal": - col1, col2, col3 = st.columns(3) - with col1: - st.session_state.placeholders['cpu'] = st.empty() - with col2: - st.session_state.placeholders['memory'] = st.empty() - with col3: - st.session_state.placeholders['network'] = st.empty() - else: - st.session_state.placeholders['cpu'] = st.empty() - st.session_state.placeholders['memory'] = st.empty() - st.session_state.placeholders['network'] = st.empty() + create_chart_containers() - while True: - + try: stats_response = container.stats(stream=False, decode=True) current_stats = stats_response['Stats'][0] @@ -162,8 +232,8 @@ def show_container_stats(client, container_id): if not selected_interface: st.warning("No network interface available.") - break - + return + if st.session_state.previous_stats: time_delta = (current_stats['SystemNano'] - st.session_state.previous_stats['SystemNano']) / 1e9 rx_bytes = (current_stats['Network'][selected_interface]['RxBytes'] - @@ -198,33 +268,65 @@ def show_container_stats(client, container_id): st.session_state.stats_data.append(record) st.session_state.previous_stats = current_stats - - max_points = st.session_state.retention_seconds - if len(st.session_state.stats_data) > max_points: - st.session_state.stats_data = st.session_state.stats_data[-max_points:] - elif len(st.session_state.stats_data) < max_points: - padding = initialize_stats_data(max_points - len(st.session_state.stats_data)) - st.session_state.stats_data = padding + st.session_state.stats_data - stats_df = pd.DataFrame(st.session_state.stats_data) + try: + # Create DataFrame and ensure timestamp is datetime type + stats_df = pd.DataFrame(st.session_state.stats_data) + stats_df['timestamp'] = pd.to_datetime(stats_df['timestamp']) + + # Remove any rows with NaT timestamps or infinite values + stats_df = stats_df.replace([np.inf, -np.inf], np.nan) + stats_df = stats_df.dropna(subset=['timestamp']) - st.session_state.placeholders['cpu'].altair_chart(create_cpu_chart(stats_df), use_container_width=True) - st.session_state.placeholders['memory'].altair_chart(create_memory_chart(stats_df), use_container_width=True) - st.session_state.placeholders['network'].altair_chart(create_network_chart(stats_df), use_container_width=True) - - time.sleep(1) + # Update charts + if 'placeholders' in st.session_state: + st.session_state.placeholders['cpu'].altair_chart(create_cpu_chart(stats_df), use_container_width=True) + st.session_state.placeholders['memory'].altair_chart(create_memory_chart(stats_df), use_container_width=True) + st.session_state.placeholders['network'].altair_chart(create_network_chart(stats_df), use_container_width=True) + except Exception as e: + # If we can't update the charts, the page is probably being unmounted + st.error(f"Error updating charts: {str(e)}") + return + + # Check if we should continue updating + if st.session_state.page_active: + time.sleep(1) + st.rerun() + except Exception as e: + st.error(f"Error updating stats: {str(e)}") + if st.session_state.page_active: + time.sleep(1) + st.rerun() def main(): + # Check if we're coming from a different page + if 'current_page' in st.session_state and st.session_state.current_page != "container_stats": + cleanup_session_state() + + # Set current page + st.session_state.current_page = "container_stats" + + # Configure page st.set_page_config(page_title="Container Stats", layout="wide") - + + # Initialize page state + if 'page_active' not in st.session_state: + st.session_state.page_active = True + header.show() try: selected_uri = sidebar.show_uri_selector() with PodmanClient(base_url=selected_uri, identity="~/.ssh/id_ed25519") as client: sidebar.show_details(client) - container_id = show_container_selector(client) - show_container_stats(client, container_id) + + # Only proceed if the page is still active + if st.session_state.page_active: + container_id = show_container_selector(client) + if container_id is not None: + show_container_stats(client, container_id) + else: + st.info("Please select a container to view its statistics.") except Exception as e: st.exception(e)