diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 014933d..58a20ba 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,164 +1,45 @@ -name: Build and Release +name: CI on: push: branches: - main - tags: - - 'v*' + - gui paths-ignore: - - '**.md' + - '**/*.md' - 'LICENSE' - '.gitignore' pull_request: branches: - main paths-ignore: - - '**.md' + - '**/*.md' - 'LICENSE' - '.gitignore' workflow_dispatch: permissions: - contents: write + contents: read jobs: - build-windows: - runs-on: windows-latest - - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: '3.11' - - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install -r requirements.txt - pip install -r requirements-dev.txt - pip install pyinstaller - - - name: Run tests - run: | - python -m pytest tests/ -v --cov=src --cov-report=term-missing - - - name: Build executable - run: | - pyinstaller OmniboardLauncher.spec - - - name: Upload artifact - uses: actions/upload-artifact@v4 - with: - name: OmniboardLauncher-Windows - path: dist/OmniboardLauncher.exe + test: + runs-on: ubuntu-latest - build-macos: - runs-on: macos-latest - steps: - name: Checkout code uses: actions/checkout@v4 - - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: '3.11' - - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install -r requirements.txt - pip install -r requirements-dev.txt - pip install pyinstaller - - - name: Run tests - run: | - python -m pytest tests/ -v --cov=src --cov-report=term-missing - - - name: Build executable - run: | - pyinstaller OmniboardLauncher.spec - - - name: Upload artifact - uses: actions/upload-artifact@v4 - with: - name: OmniboardLauncher-macOS - path: dist/OmniboardLauncher - build-linux: - runs-on: ubuntu-latest - - steps: - - name: Checkout code - uses: actions/checkout@v4 - - name: Set up Python uses: actions/setup-python@v5 with: python-version: '3.11' - + - name: Install dependencies run: | python -m pip install --upgrade pip pip install -r requirements.txt pip install -r requirements-dev.txt - pip install pyinstaller - + - name: Run tests run: | python -m pytest tests/ -v --cov=src --cov-report=term-missing - - - name: Build executable - run: | - pyinstaller OmniboardLauncher.spec - - - name: Upload artifact - uses: actions/upload-artifact@v4 - with: - name: OmniboardLauncher-Linux - path: dist/OmniboardLauncher - - release: - if: startsWith(github.ref, 'refs/tags/') - needs: [build-windows, build-macos, build-linux] - runs-on: ubuntu-latest - - steps: - - name: Download Windows artifact - uses: actions/download-artifact@v4 - with: - name: OmniboardLauncher-Windows - path: ./windows - - - name: Download macOS artifact - uses: actions/download-artifact@v4 - with: - name: OmniboardLauncher-macOS - path: ./macos - - - name: Download Linux artifact - uses: actions/download-artifact@v4 - with: - name: OmniboardLauncher-Linux - path: ./linux - - - name: Rename artifacts - run: | - mv ./macos/OmniboardLauncher ./macos/OmniboardLauncher-macOS - mv ./linux/OmniboardLauncher ./linux/OmniboardLauncher-Linux - - - name: Create Release - uses: softprops/action-gh-release@v1 - with: - files: | - ./windows/OmniboardLauncher.exe - ./macos/OmniboardLauncher-macOS - ./linux/OmniboardLauncher-Linux - draft: true - prerelease: false - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..dbf40a3 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,149 @@ +name: Release + +on: + push: + tags: + - 'v*' + +permissions: + contents: write + +jobs: + build-windows: + runs-on: windows-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + pip install -r requirements-dev.txt + pip install pyinstaller + + - name: Run tests + run: | + python -m pytest tests/ -v --cov=src --cov-report=term-missing + + - name: Build executable + run: | + pyinstaller OmniboardLauncher.spec + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: OmniboardLauncher-Windows + path: dist/OmniboardLauncher.exe + + build-macos: + runs-on: macos-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + pip install -r requirements-dev.txt + pip install pyinstaller + + - name: Run tests + run: | + python -m pytest tests/ -v --cov=src --cov-report=term-missing + + - name: Build executable + run: | + pyinstaller OmniboardLauncher.spec + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: OmniboardLauncher-macOS + path: dist/OmniboardLauncher + + build-linux: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + pip install -r requirements-dev.txt + pip install pyinstaller + + - name: Run tests + run: | + python -m pytest tests/ -v --cov=src --cov-report=term-missing + + - name: Build executable + run: | + pyinstaller OmniboardLauncher.spec + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: OmniboardLauncher-Linux + path: dist/OmniboardLauncher + + release: + needs: [build-windows, build-macos, build-linux] + runs-on: ubuntu-latest + + steps: + - name: Download Windows artifact + uses: actions/download-artifact@v4 + with: + name: OmniboardLauncher-Windows + path: ./windows + + - name: Download macOS artifact + uses: actions/download-artifact@v4 + with: + name: OmniboardLauncher-macOS + path: ./macos + + - name: Download Linux artifact + uses: actions/download-artifact@v4 + with: + name: OmniboardLauncher-Linux + path: ./linux + + - name: Rename artifacts + run: | + mv ./macos/OmniboardLauncher ./macos/OmniboardLauncher-macOS + mv ./linux/OmniboardLauncher ./linux/OmniboardLauncher-Linux + + - name: Create Release + uses: softprops/action-gh-release@v1 + with: + files: | + ./windows/OmniboardLauncher.exe + ./macos/OmniboardLauncher-macOS + ./linux/OmniboardLauncher-Linux + draft: true + prerelease: false + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/OmniboardLauncher.spec b/OmniboardLauncher.spec index 10234ce..e5e69f3 100644 --- a/OmniboardLauncher.spec +++ b/OmniboardLauncher.spec @@ -7,11 +7,23 @@ a = Analysis( pathex=[], binaries=[], datas=[('assets/DataBase.ico', '.')], - hiddenimports=['customtkinter', 'pymongo', 'src.mongodb', 'src.omniboard', 'src.gui'], + hiddenimports=[ + 'customtkinter', + 'pymongo', + 'src.mongodb', + 'src.omniboard', + 'src.gui', + 'src.prefs', + # Optional but recommended to ensure bundling when present + 'keyring', + 'dns', # dnspython for mongodb+srv + 'platformdirs', + ], hookspath=[], hooksconfig={}, runtime_hooks=[], - excludes=[], + # Exclude Qt bindings we do not use to avoid PyInstaller's multiple-Qt error + excludes=['PyQt5', 'PyQt6', 'PySide2', 'PySide6'], win_no_prefer_redirects=False, win_private_assemblies=False, cipher=block_cipher, diff --git a/README.md b/README.md index 561f2f4..04ce5b0 100644 --- a/README.md +++ b/README.md @@ -1,67 +1,389 @@ -# Omniboard Launcher App +# AltarViewer +[![Python Version](https://img.shields.io/badge/python-3.8%2B-blue.svg)](https://www.python.org/downloads/) +[![License](https://img.shields.io/badge/license-GPL%20v3-blue.svg)](LICENSE) +[![Version](https://img.shields.io/badge/version-1.0.0--dev-orange.svg)](https://github.com/Alienor134/launch_omniboard/releases) -YOu can download the executable app from the [releases](https://github.com/Alienor134/launch_omniboard/releases) or launch the script with python (see below). +A graphical user interface application for launching and managing [Omniboard](https://vivekratnavel.github.io/omniboard/) instances to visualize and track MongoDB-backed experiments from the DREAM/Altar ecosystem.
- - + +
-The following documentation was generated by Copilot +## Table of Contents -## Overview +- [Features](#features) +- [Installation](#installation) + - [Prerequisites](#prerequisites) + - [From Source](#from-source) + - [From Binary Release](#from-binary-release) +- [Usage](#usage) + - [Quick Start](#quick-start) + - [Configuration](#configuration) +- [Development](#development) + - [Setting Up Development Environment](#setting-up-development-environment) + - [Running Tests](#running-tests) + - [Building the Executable](#building-the-executable) +- [Architecture](#architecture) +- [Contributing](#contributing) +- [Troubleshooting](#troubleshooting) +- [Versioning](#versioning) +- [License](#license) -This application provides a graphical interface to: -- Connect to a local MongoDB instance. -- List available databases. -- Select a database and launch [Omniboard](https://vivekratnavel.github.io/omniboard/) in a Docker container for experiment tracking and visualization. -- Clean up Omniboard Docker containers. +docker --version +## Features -## Requirements +- **MongoDB Connection Management**: Connect to local, remote, or Atlas MongoDB instances (port or full URI) +- **Database Discovery**: Automatically list available databases +- **One-Click Omniboard Launch**: Deploy Omniboard in isolated Docker containers +- **Web UI**: Dash-based web application served in your browser +- **Docker Integration**: Automatic container management and cleanup +- **Multi-Instance Support**: Run multiple Omniboard instances on different ports +- **Deterministic Port Assignment**: Hash-based port generation preserves browser cookies per database +- **Container Cleanup**: Easy removal of all Omniboard containers -- Python 3.7+ -- Docker Desktop (must be running) -- Local MongoDB instance running -- No need to install `pymongo` or `tkinter` separately if using the provided executable. +## Installation + +You can use any of these options: + +### Option 1 – Executable + +Download the latest platform-specific launcher from the [AltarViewer releases](https://github.com/DreamRepo/AltarViewer/releases) and run it. + +### Option 2 – Python app + +```bash +git clone https://github.com/DreamRepo/Altar.git +cd Altar/AltarViewer +python -m venv venv + +# Activate the venv (one of these) +venv\Scripts\activate # Windows +source venv/bin/activate # macOS/Linux + +pip install -r requirements.txt +python -m src.main +``` + +### Option 3 – Docker image + +```bash +docker pull alienor134/altarviewer:latest +docker run -d \ + -p 8060:8060 \ + -v /var/run/docker.sock:/var/run/docker.sock \ + -e MONGO_DEFAULT_URL="mongodb://:27017/" \ + --name altarviewer \ + alienor134/altarviewer:latest +``` + +Replace `` with the host where MongoDB is reachable from the container (for example `host.docker.internal` on Docker Desktop). + +### Option 4 – Docker Compose (AltarDocker stack) + +Use AltarDocker to spin up MongoDB and MinIO, then point AltarViewer to that MongoDB: + +```bash +git clone https://github.com/DreamRepo/AltarDocker.git +cd AltarDocker +docker compose -f docker-compose_default.yml up -d +``` + +Then start AltarViewer via option 1–3 and connect it to the MongoDB instance from the stack (for example `mongodb://localhost:27017`). ## Usage -### As a Python Script +### Quick Start + +1. **Launch the application** (using any install option above) + +2. **Connect to MongoDB** + - Choose a connection mode: + - Port: enter your MongoDB port (default: `27017` for localhost) + - Full URI: paste a full MongoDB connection URI (works with Atlas, remote VMs, authentication, TLS and options) + - Security note: do not paste passwords here. Prefer the "Credential URI" tab to avoid storing secrets in plain text. + - Example: `mongodb+srv://user:pass@my-cluster.mongodb.net/?retryWrites=true&w=majority` + - Credential URI: paste a credential-less URI (e.g., `mongodb://host:27017/yourdb`) and enter username/password separately + - Optionally save your password securely using the OS keyring + - Click "Connect" to list available databases + +4. **Select a database** + - Choose a database from the dropdown list + - Click "Launch Omniboard" + +5. **Access Omniboard** + - A clickable link will appear in the interface + - Omniboard opens automatically in your default browser + +### Configuration + +#### MongoDB Connection +- **Connection Modes**: + - Port: quick local development; launches Omniboard with `-m host:port:database` + - On Windows/macOS (Docker Desktop), if you connect to `localhost`/`127.0.0.1`, the app maps it to `host.docker.internal` so the container can reach your host MongoDB. + - Full URI: recommended for Atlas/remote; launches Omniboard with `--mu ` + - The selected database is injected into the URI path before launching Omniboard, while preserving credentials and query parameters. + - Example constructed argument: + - `--mu "mongodb+srv://user:pass@MONGO_IP/DB_NAME?authsource=DB_NAME"` + - For security, the app does not persist Full URI values between sessions. + - Credential URI: enter a credential-less URI and provide username/password separately + - Passwords are stored only in the OS keyring if you opt in; they are never written to disk +- **Default Port**: 27017 +- **Authentication**: Supply credentials in your URI for Full URI mode + +#### Port Management +- **Deterministic Port Assignment**: Ports are generated using a hash of the database name (base: 20000, range: 10000) +- **Browser Cookie Preservation**: The same database always gets the same port, preserving Omniboard customizations and cookies in your browser +- **Automatic Conflict Resolution**: If the preferred port is unavailable, the next free port is automatically selected +- **Port Range**: 20000-29999 (based on SHA-256 hash of database name) + +## Development + +### Setting Up Development Environment + +1. **Clone and setup** + ```bash + git clone https://github.com/DreamRepo/Altar.git + cd Altar/AltarViewer + python -m venv venv + source venv/bin/activate # or venv\Scripts\activate on Windows + ``` + +2. **Install development dependencies** + ```bash + pip install -r requirements.txt + pip install -r requirements-dev.txt + ``` + + +### Running Tests + +```bash +# Run all tests +pytest + +# Run with coverage +pytest --cov=src --cov-report=html + +# Run specific test file +pytest tests/test_mongodb.py + +# Run with verbose output +pytest -v +``` + +### Building the Executable + +Build a standalone executable using PyInstaller: + +```bash +# Install PyInstaller (if not in requirements-dev.txt) +pip install pyinstaller + +# Build executable +pyinstaller OmniboardLauncher.spec + +# Output will be in dist/ directory +``` + +#### Customizing the Build + +Edit [OmniboardLauncher.spec](OmniboardLauncher.spec) to customize: +- Application name and icon +- Bundled data files +- Hidden imports +- Build options + +## Architecture -1. Install dependencies: - ``` - pip install pymongo - pip install customtkinter - ``` +``` +AltarViewer/ +├── src/ +│ ├── main.py # Application entry point +│ ├── gui.py # GUI implementation (CustomTkinter) +│ ├── mongodb.py # MongoDB connection logic +│ ├── omniboard.py # Docker/Omniboard management +│ └── prefs.py # Secure preferences (JSON + OS keyring) +├── tests/ +│ ├── conftest.py # Pytest configuration +│ ├── test_mongodb.py # MongoDB tests +│ └── test_omniboard.py # Omniboard tests +├── assets/ # Images and resources +├── requirements.txt # Production dependencies +├── requirements-dev.txt # Development dependencies +└── OmniboardLauncher.spec # PyInstaller specification +``` - or refer to the requirements.txt +### Key Components -2. Run the script: - ``` - python omniboard_launch.py - ``` +- **GUI Layer** ([gui.py](src/gui.py)): CustomTkinter-based interface +- **MongoDB Layer** ([mongodb.py](src/mongodb.py)): Database connection and queries +- **Omniboard Layer** ([omniboard.py](src/omniboard.py)): Docker container management with hash-based port assignment +- **Main Controller** ([main.py](src/main.py)): Application orchestration +### Port Assignment Algorithm -### App Features +The application uses a deterministic hash-based port assignment: +```python +port = 20000 + (SHA256(database_name) % 10000) +``` +This ensures: +- **Consistency**: Same database → same port +- **Browser Persistence**: Cookies and customizations are preserved +- **Conflict Handling**: Automatic fallback to next available port if needed -- **Port selection:** Enter the port of your local MongoDB (default: 27017). -- **Connect:** Lists all available databases. -- **Database selection:** Select a database to enable the "Launch Omniboard" button. -- **Launch Omniboard:** Starts Omniboard in Docker and prints a clickable link to access it in your browser. -- **Clear Omniboard Docker Containers:** Removes all Omniboard containers started by this app. +## Contributing -## Notes +We welcome contributions! Please follow these guidelines: + +### Getting Started + +1. **Fork the repository** on GitHub +2. **Clone your fork** locally + ```bash + git clone https://github.com/YOUR_USERNAME/Altar.git + cd Altar/AltarViewer + ``` +3. **Create a feature branch** + ```bash + git checkout -b feature/your-feature-name + ``` + +### Development Workflow + +1. **Make your changes** + - Follow [PEP 8](https://pep8.org/) style guidelines + - Add tests for new features + - Update documentation as needed + +2. **Run tests and linting** + ```bash + pytest + ``` + +3. **Commit your changes** + ```bash + git add . + git commit -m "feat: add your feature description" + ``` + + Use [Conventional Commits](https://www.conventionalcommits.org/): + - `feat:` New feature + - `fix:` Bug fix + - `docs:` Documentation changes + - `test:` Test additions or changes + - `refactor:` Code refactoring + - `chore:` Maintenance tasks + +4. **Push to your fork** + ```bash + git push origin feature/your-feature-name + ``` + +5. **Create a Pull Request** + - Provide a clear description of changes + - Reference any related issues + - Ensure all tests pass -- Each Omniboard instance runs on a free port (starting from 9005). -- Docker must be installed and running. -- The app only supports local MongoDB connections. -- The clickable link for Omniboard appears in the app interface after launching. ## Troubleshooting -- If you see "Connection Error", ensure MongoDB is running and accessible on the specified port. -- If Docker reports port conflicts, use the "Clear Omniboard Docker Containers" button. -- For any issues with the executable, ensure all dependencies are bundled or use the Python script directly. +### Connection Errors + +**Problem**: "Connection Error" when connecting to MongoDB + +**Solutions**: +- Ensure MongoDB is running: `mongosh` or `mongo` +- Check the port number (default: 27017) +- Verify firewall settings allow connections +- Check MongoDB logs for authentication issues + +### Docker Issues + +**Problem**: Docker-related errors when launching Omniboard + +**Solutions**: +- Verify Docker Desktop is running: `docker ps` +- Check Docker has sufficient resources allocated +- Ensure port 9005+ are not in use by other applications +- Try clearing old containers: Use the cleanup button in the app + - If using the packaged EXE, ensure the Docker CLI is on PATH or installed in the default location. The app resolves common Docker paths but may fail if the CLI is missing. + - On slower machines, Docker initialization can take >30s after launch; the app now waits up to 60s, but if you still see “Docker not running”, retry once Docker is fully ready. + - The app does not auto-start Docker on any OS. Please start Docker Desktop (or the Docker service) manually, wait for it to be ready, and then launch Omniboard. + +Note: The app does not rewrite hosts (e.g., no host.docker.internal mapping). It uses the host you provide as-is. + +### Omniboard stuck on "Loading app..." + +**Common causes**: +- The connection string used inside the container is missing the selected database +- `localhost` from the host OS is unreachable from inside Docker (Windows/macOS) +- Authentication failure or insufficient permissions on the selected database + +**What the app does**: +- In Full URI mode, it injects the selected database into the URI and uses `--mu`, preserving credentials/options + +**What to check**: +- Validate your URI with `mongosh` and ensure it has read access to the selected DB +- Confirm the container is running, or use the cleanup button and relaunch +- Give the container a few seconds after launch to initialize + +### Database list shows only one entry + +Some deployments (e.g., MongoDB Atlas or non-admin users) do not allow the `listDatabases` command. In that case, the app falls back to the database present in your connection URI so you can still launch Omniboard for it. + +### Port Conflicts + +**Problem**: "Port already in use" errors + +**Solutions**: +- The application automatically finds the next available port if the preferred port is busy +- Use the "Clear Omniboard Docker Containers" button to remove old containers +- Manually check and stop containers: + ```bash + docker ps + docker stop + ``` +- Check for other applications using ports 20000-29999 + +**Note**: Each database consistently uses the same port (hash-based), allowing your browser to remember Omniboard customizations and preferences per database + +### Import Errors + +**Problem**: Missing module errors when running from source + +**Solutions**: +- Reinstall dependencies: `pip install -r requirements.txt` +- Ensure virtual environment is activated +- Check Python version compatibility (3.8+) + +### Keyring not available or password not remembered + +If the "Save password securely" option is disabled or your password does not reappear: +- Ensure the `keyring` package is installed in your environment: `pip install keyring` +- On Linux, ensure you have a supported keyring backend (e.g., gnome-keyring/Secret Service or KWallet) and a running session +- The app never writes passwords to disk; they are stored only in the OS keychain when this option is enabled + +### Preferences file shows up when building/running from repo + +Older versions saved preferences at `~/.altarviewer_config.json`, which could be affected if the `HOME` environment variable was overridden (e.g., by certain shells/tools) while running inside a repository folder. The app now stores preferences in the standard OS config location (e.g., `%APPDATA%\AltarViewer\config.json` on Windows) using `platformdirs`, so it no longer depends on the current working directory or `HOME`. Existing legacy configs are read for compatibility but new saves go to the stable location. + +### Getting Help + +- Check existing [GitHub Issues](https://github.com/DreamRepo/Altar/issues) +- Review [Omniboard documentation](https://vivekratnavel.github.io/omniboard/) +- Contact the DREAM/Altar team + + +### Release Process + +1. Update version in relevant files +2. Create a git tag: `git tag -a v1.0.0 -m "Release version 1.0.0"` +3. Push tag: `git push origin v1.0.0` +4. Build and publish release manually (the tagged commit will only produce a draft release) + +## License + +This project is licensed under the GNU General Public License v3.0 - see the [LICENSE](LICENSE) file for details. ---- \ No newline at end of file diff --git a/requirements-dev.txt b/requirements-dev.txt index d14e63a..57110b7 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,2 +1,6 @@ +# Testing pytest>=7.4.0 pytest-cov>=4.1.0 + +# Build tools +pyinstaller>=5.0.0 diff --git a/requirements.txt b/requirements.txt index b15babf..60af430 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,5 @@ customtkinter==5.2.2 pymongo>=4.0.0 +dnspython>=2.3.0 +keyring>=23.0.0 +platformdirs>=3.0.0 diff --git a/src/gui.py b/src/gui.py index dd015be..17702d9 100644 --- a/src/gui.py +++ b/src/gui.py @@ -2,9 +2,18 @@ import customtkinter as ctk from tkinter import messagebox import webbrowser +import threading +import sys -from mongodb import MongoDBClient -from omniboard import OmniboardManager +# Support both package imports (tests, python -m) and direct script runs +try: + from .mongodb import MongoDBClient + from .omniboard import OmniboardManager + from .prefs import Preferences +except ImportError: + from mongodb import MongoDBClient + from omniboard import OmniboardManager + from prefs import Preferences # Set appearance mode and color theme ctk.set_appearance_mode("dark") @@ -26,6 +35,7 @@ def __init__(self): # Initialize backend managers self.mongo_client = MongoDBClient() self.omniboard_manager = OmniboardManager() + self.preferences = Preferences() # UI state variables self.port_var = ctk.StringVar(value="27017") @@ -42,6 +52,13 @@ def __init__(self): self._create_connection_frame() self._create_database_frame() self._create_omniboard_frame() + # Load saved preferences (best-effort) + try: + self._load_prefs_and_apply() + except Exception: + pass + # Track last mode to persist prefs on mode switches + self._last_mode = self.connection_mode.get() # Show window after 2 second delay self.after(2000, self.deiconify) @@ -70,7 +87,7 @@ def _create_connection_frame(self): # Connection mode selector self.mode_selector = ctk.CTkSegmentedButton( self.connection_frame, - values=["Port", "Full URL"], + values=["Port", "Full URI", "Credential URI"], variable=self.connection_mode, command=self.on_connection_mode_change ) @@ -87,16 +104,69 @@ def _create_connection_frame(self): ) self.port_entry.grid(row=2, column=1, padx=(5, 10), pady=5, sticky="w") - # MongoDB URL entry (initially hidden) - self.url_label = ctk.CTkLabel(self.connection_frame, text="MongoDB URL:") + # MongoDB URI entry (initially hidden) + self.url_label = ctk.CTkLabel(self.connection_frame, text="MongoDB URI:") self.url_entry = ctk.CTkEntry( self.connection_frame, textvariable=self.mongo_url_var, placeholder_text="mongodb://localhost:27017/", width=300 ) + # Warning label for credentials in URI (initially hidden) + self.url_warning_label = ctk.CTkLabel( + self.connection_frame, + text="do not paste password here, use credential URI tab instead", + text_color="#cc9900", + font=ctk.CTkFont(size=11), + anchor="w", + wraplength=380, + justify="left", + ) self.url_label.grid_remove() self.url_entry.grid_remove() + self.url_warning_label.grid_remove() + + # Credential URI mode widgets (initially hidden) + self.cred_uri_label = ctk.CTkLabel(self.connection_frame, text="Credential-less MongoDB URI:") + self.cred_uri_entry = ctk.CTkEntry( + self.connection_frame, + placeholder_text="mongodb://host:27017/yourdb?options", + width=300, + ) + self.cred_user_label = ctk.CTkLabel(self.connection_frame, text="Username:") + self.cred_user_entry = ctk.CTkEntry( + self.connection_frame, + placeholder_text="username", + width=150, + ) + self.cred_pass_label = ctk.CTkLabel(self.connection_frame, text="Password:") + self.cred_pass_entry = ctk.CTkEntry( + self.connection_frame, + placeholder_text="password", + show="•", + width=150, + ) + self.cred_authsrc_label = ctk.CTkLabel(self.connection_frame, text="Auth Source:") + self.cred_authsrc_entry = ctk.CTkEntry( + self.connection_frame, + placeholder_text="admin (optional)", + width=150, + ) + # Remember password (secure keyring) checkbox (only for Credential URI mode) + self.remember_pwd_chk = ctk.CTkCheckBox( + self.connection_frame, + text="Save password securely (OS keyring)", + command=self.on_remember_toggle, + ) + # Hide initially + for w in ( + self.cred_uri_label, self.cred_uri_entry, + self.cred_user_label, self.cred_user_entry, + self.cred_pass_label, self.cred_pass_entry, + self.cred_authsrc_label, self.cred_authsrc_entry, + self.remember_pwd_chk, + ): + w.grid_remove() # Connect button self.connect_btn = ctk.CTkButton( @@ -106,7 +176,8 @@ def _create_connection_frame(self): height=28, font=ctk.CTkFont(size=13, weight="bold") ) - self.connect_btn.grid(row=3, column=0, columnspan=2, padx=10, pady=5, sticky="ew") + # Place connect button; will be dynamically re-positioned per mode + self.connect_btn.grid(row=4, column=0, columnspan=2, padx=10, pady=5, sticky="ew") def _create_database_frame(self): """Create the database selection frame.""" @@ -200,17 +271,75 @@ def _create_omniboard_frame(self): lambda e: self.omniboard_info_text.configure(cursor="")) def on_connection_mode_change(self, value): - """Toggle between Port and Full URL input modes.""" + """Toggle between Port and Full URI input modes.""" + # If leaving Credential URI mode, persist current preferences (and keyring if opted-in) + try: + if getattr(self, "_last_mode", None) == "Credential URI": + self._save_prefs(remember_pwd=bool(self.remember_pwd_chk.get())) + except Exception: + pass if value == "Port": self.port_label.grid(row=2, column=0, padx=(10, 5), pady=5, sticky="w") self.port_entry.grid(row=2, column=1, padx=(5, 10), pady=5, sticky="w") self.url_label.grid_remove() self.url_entry.grid_remove() - else: # Full URL + self.url_warning_label.grid_remove() + # Hide credential URI widgets + for w in ( + self.cred_uri_label, self.cred_uri_entry, + self.cred_user_label, self.cred_user_entry, + self.cred_pass_label, self.cred_pass_entry, + self.cred_authsrc_label, self.cred_authsrc_entry, + self.remember_pwd_chk, + ): + w.grid_remove() + # Connect button row for this mode + self.connect_btn.grid_configure(row=4) + elif value == "Full URI": self.port_label.grid_remove() self.port_entry.grid_remove() self.url_label.grid(row=2, column=0, padx=(10, 5), pady=5, sticky="w") self.url_entry.grid(row=2, column=1, padx=(5, 10), pady=5, sticky="ew") + # Show the warning below the URI input + self.url_warning_label.grid(row=3, column=0, columnspan=2, padx=(10, 10), pady=(0, 4), sticky="w") + # Hide credential URI widgets + for w in ( + self.cred_uri_label, self.cred_uri_entry, + self.cred_user_label, self.cred_user_entry, + self.cred_pass_label, self.cred_pass_entry, + self.cred_authsrc_label, self.cred_authsrc_entry, + self.remember_pwd_chk, + ): + w.grid_remove() + # Connect button row for this mode + self.connect_btn.grid_configure(row=4) + else: # Credential URI + # Hide port and full URI widgets + self.port_label.grid_remove() + self.port_entry.grid_remove() + self.url_label.grid_remove() + self.url_entry.grid_remove() + self.url_warning_label.grid_remove() + # Show credential-less URI row + self.cred_uri_label.grid(row=2, column=0, padx=(10, 5), pady=5, sticky="w") + self.cred_uri_entry.grid(row=2, column=1, padx=(5, 10), pady=5, sticky="ew") + # Show username/password/authSource rows + self.cred_user_label.grid(row=3, column=0, padx=(10, 5), pady=2, sticky="w") + self.cred_user_entry.grid(row=3, column=1, padx=(5, 10), pady=2, sticky="w") + self.cred_pass_label.grid(row=4, column=0, padx=(10, 5), pady=2, sticky="w") + self.cred_pass_entry.grid(row=4, column=1, padx=(5, 10), pady=2, sticky="w") + self.cred_authsrc_label.grid(row=5, column=0, padx=(10, 5), pady=2, sticky="w") + self.cred_authsrc_entry.grid(row=5, column=1, padx=(5, 10), pady=2, sticky="w") + self.remember_pwd_chk.grid(row=6, column=0, columnspan=2, padx=(10, 10), pady=(4, 4), sticky="w") + # Place connect button below these rows + self.connect_btn.grid_configure(row=7) + # Auto-fill password from keyring if we have a remembered one and the field is empty + try: + self._auto_fill_credential_password_if_needed() + except Exception: + pass + # Update last mode + self._last_mode = value def connect(self): """Connect to MongoDB and list available databases.""" @@ -224,16 +353,59 @@ def connect(self): try: # Connect based on mode - if self.connection_mode.get() == "Port": + mode = self.connection_mode.get() + if mode == "Port": port = self.port_var.get() or "27017" dbs = self.mongo_client.connect_by_port(port) - else: + elif mode == "Full URI": url = self.mongo_url_var.get().strip() if not url: - messagebox.showerror("Error", "Please provide a valid MongoDB URL.") + messagebox.showerror("Error", "Please provide a valid MongoDB URI.") self.selected_label.configure(text="Connection failed") return dbs = self.mongo_client.connect_by_url(url) + else: # Credential URI + base_uri = self.cred_uri_entry.get().strip() + user = self.cred_user_entry.get().strip() + pwd = self.cred_pass_entry.get() + auth_src = self.cred_authsrc_entry.get().strip() + if not base_uri: + messagebox.showerror("Error", "Please provide a credential-less MongoDB URI.") + self.selected_label.configure(text="Connection failed") + return + # Ensure scheme + if not base_uri.startswith("mongodb://") and not base_uri.startswith("mongodb+srv://"): + base_uri = "mongodb://" + base_uri + # Build a temporary URI for connecting (do not display it) + from urllib.parse import urlparse, urlunparse, quote_plus + parsed = urlparse(base_uri) + # Inject userinfo if provided + userinfo = "" + if user: + userinfo += quote_plus(user) + if pwd: + userinfo += f":{quote_plus(pwd)}" + userinfo += "@" + host = parsed.hostname or "localhost" + port = f":{parsed.port}" if parsed.port else "" + netloc = f"{userinfo}{host}{port}" + query = parsed.query + if auth_src and "authSource=" not in query: + sep = "&" if query else "?" + query = f"{query}{sep}authSource={auth_src}" if query else f"authSource={auth_src}" + temp_uri = urlunparse((parsed.scheme, netloc, parsed.path, "", query, "")) + dbs = self.mongo_client.connect_by_url(temp_uri) + # Save preferences (including secure password via keyring if opted-in) + try: + self._save_prefs(remember_pwd=bool(self.remember_pwd_chk.get())) + except Exception: + pass + if mode != "Credential URI": + # Save non-secret prefs for other modes + try: + self._save_prefs(remember_pwd=False) + except Exception: + pass self.db_list = dbs @@ -289,6 +461,13 @@ def connect(self): "MongoDB authentication failed.\n\n" "Please check your username and password in the connection URL." ) + elif "dnspython" in error_msg.lower() or ("mongodb+srv" in error_msg.lower() and "dns" in error_msg.lower()): + friendly_msg = ( + "SRV connection detected but 'dnspython' is not installed.\n\n" + "To use URIs starting with 'mongodb+srv://', please install dnspython:\n" + "pip install dnspython\n\n" + "Alternatively, use a standard 'mongodb://' URI with explicit host and port." + ) else: friendly_msg = f"Connection Error:\n\n{error_msg}" @@ -318,41 +497,171 @@ def launch_omniboard(self): if not db_name: messagebox.showerror("Error", "No database selected.") return + # Gather connection details once on UI thread + mongo_host, mongo_port, _ = self.mongo_client.parse_connection_url() + mongo_uri = None + mode = self.connection_mode.get() + if mode == "Full URI": + if hasattr(self.mongo_client, "get_connection_uri"): + mongo_uri = self.mongo_client.get_connection_uri() + elif mode == "Credential URI": + if hasattr(self.mongo_client, "get_connection_uri"): + mongo_uri = self.mongo_client.get_connection_uri() - try: - # Get MongoDB connection details - mongo_host, mongo_port, _ = self.mongo_client.parse_connection_url() - - # Launch Omniboard - container_name, host_port = self.omniboard_manager.launch( - db_name=db_name, - mongo_host=mongo_host, - mongo_port=mongo_port + # Require Docker to be running; no auto-start + if not self.omniboard_manager.is_docker_running(): + messagebox.showinfo( + "Docker not running", + "Docker Desktop is not running. Please launch Docker Desktop manually, " + "wait until it is ready, and then click 'Launch Omniboard' again.", ) - - url = f"http://localhost:{host_port}" - - # Update textbox with clickable link - self.omniboard_info_text.configure(state="normal") - text_before = f"Omniboard for '{db_name}': " - self.omniboard_info_text.insert("end", text_before) - - # Insert URL as a clickable link - url_start = self.omniboard_info_text.index("end-1c") - self.omniboard_info_text.insert("end", url) - url_end = self.omniboard_info_text.index("end-1c") - - # Apply tags - self.omniboard_info_text.tag_add("link", url_start, url_end) - self.omniboard_info_text.tag_add(f"url_{url}", url_start, url_end) - - self.omniboard_info_text.insert("end", "\n") - self.omniboard_info_text.configure(state="disabled") - - # Open in browser after 4 second delay to allow Omniboard to fully start - self.after(4000, lambda: webbrowser.open(url)) - except Exception as e: - messagebox.showerror("Launch Error", str(e)) + return + + # Docker is already running; launch in background + self._launch_container_async(db_name, mongo_host, mongo_port, mongo_uri) + + def _launch_container_async(self, db_name: str, mongo_host: str, mongo_port: int, mongo_uri: str | None): + """Run container launch in a worker thread and update UI on completion.""" + def worker(): + try: + container_name, host_port = self.omniboard_manager.launch( + db_name=db_name, + mongo_host=mongo_host, + mongo_port=mongo_port, + mongo_uri=mongo_uri, + ) + url = f"http://localhost:{host_port}" + self.after(0, lambda: self._on_omniboard_launched(db_name, url)) + except Exception as e: + self.after(0, lambda: messagebox.showerror("Launch Error", str(e))) + + self.selected_label.configure(text=f"Launching Omniboard for '{db_name}'…") + self.launch_btn.configure(state="disabled") + threading.Thread(target=worker, daemon=True).start() + + def _on_omniboard_launched(self, db_name: str, url: str): + # Update textbox with clickable link + self.omniboard_info_text.configure(state="normal") + text_before = f"Omniboard for '{db_name}': " + self.omniboard_info_text.insert("end", text_before) + + # Insert URL as a clickable link + url_start = self.omniboard_info_text.index("end-1c") + self.omniboard_info_text.insert("end", url) + url_end = self.omniboard_info_text.index("end-1c") + + # Apply tags + self.omniboard_info_text.tag_add("link", url_start, url_end) + self.omniboard_info_text.tag_add(f"url_{url}", url_start, url_end) + + self.omniboard_info_text.insert("end", "\n") + self.omniboard_info_text.configure(state="disabled") + self.launch_btn.configure(state="normal") + + # Open in browser after a short delay to allow Omniboard to fully start + self.after(6000, lambda: webbrowser.open(url)) + + def _auto_fill_credential_password_if_needed(self): + """If remember is enabled and password field is empty, load from keyring.""" + if not hasattr(self, "preferences"): + return + data = self.preferences.load() + remember = int(data.get("remember_pwd", 0)) == 1 or int(self.remember_pwd_chk.get()) == 1 + if not remember: + return + # Prefer current UI username, else stored one + user = self.cred_user_entry.get().strip() or data.get("user") or "default" + if not self.cred_pass_entry.get().strip(): + pwd = self.preferences.load_password_if_any(user) + if pwd: + self.cred_pass_entry.delete(0, "end") + self.cred_pass_entry.insert(0, pwd) + # Ensure checkbox reflects remembered state + if int(self.remember_pwd_chk.get()) != 1: + self.remember_pwd_chk.select() + + def on_remember_toggle(self): + """Handle toggling of the remember password checkbox.""" + if self.connection_mode.get() != "Credential URI": + return + if not hasattr(self, "preferences"): + return + # Check keyring availability + if not self.preferences.is_keyring_available(): + messagebox.showinfo( + "Keyring not available", + "Password storage requires the 'keyring' package and an OS-supported keychain.\n" + "Please install dependencies (pip install keyring) or disable this option.", + ) + self.remember_pwd_chk.deselect() + return + # If enabling, immediately save current password (if any) + if int(self.remember_pwd_chk.get()) == 1: + user = (self.cred_user_entry.get().strip() or "default") + pwd = self.cred_pass_entry.get() + try: + self.preferences.save_password_if_allowed(True, user, pwd) + # Persist non-secret prefs too + self._save_prefs(remember_pwd=True) + except Exception: + # If saving fails, uncheck it + self.remember_pwd_chk.deselect() + else: + # If disabling, remove stored password + user = (self.cred_user_entry.get().strip() or "default") + try: + self.preferences.save_password_if_allowed(False, user, "") + self._save_prefs(remember_pwd=False) + except Exception: + pass + + def _load_prefs_and_apply(self): + """Load saved preferences and apply to the UI.""" + data = self.preferences.load() + if not isinstance(data, dict) or not data: + return + mode = data.get("mode") or "Port" + # Set mode and update UI + self.connection_mode.set(mode) + self.on_connection_mode_change(mode) + if mode == "Port": + if data.get("port"): + self.port_var.set(str(data.get("port"))) + elif mode == "Full URI": + # Do not load/persist the Full URI value for security/privacy + # Leave the default placeholder as-is + pass + elif mode == "Credential URI": + self.cred_uri_entry.delete(0, "end"); self.cred_uri_entry.insert(0, data.get("cred_uri", "")) + self.cred_user_entry.delete(0, "end"); self.cred_user_entry.insert(0, data.get("user", "")) + self.cred_authsrc_entry.delete(0, "end"); self.cred_authsrc_entry.insert(0, data.get("auth_source", "")) + if int(data.get("remember_pwd", 0)) == 1: + self.remember_pwd_chk.select() + pwd = self.preferences.load_password_if_any(data.get("user") or "default") + if pwd: + self.cred_pass_entry.delete(0, "end") + self.cred_pass_entry.insert(0, pwd) + + def _save_prefs(self, remember_pwd: bool): + """Save current preferences. Password stored in OS keyring if requested.""" + mode = self.connection_mode.get() + data = {"mode": mode} + if mode == "Port": + data.update({"port": self.port_var.get().strip()}) + elif mode == "Full URI": + # Intentionally do not persist the Full URI + pass + elif mode == "Credential URI": + user = (self.cred_user_entry.get().strip() or "default") + pwd = self.cred_pass_entry.get() + data.update({ + "cred_uri": self.cred_uri_entry.get().strip(), + "user": user, + "auth_source": self.cred_authsrc_entry.get().strip(), + "remember_pwd": 1 if remember_pwd else 0, + }) + self.preferences.save_password_if_allowed(remember_pwd, user, pwd) + self.preferences.save_without_password(data) def clear_omniboard_docker(self): """Remove all Omniboard Docker containers.""" diff --git a/src/main.py b/src/main.py index 7fb2d33..8776120 100644 --- a/src/main.py +++ b/src/main.py @@ -1,5 +1,9 @@ """Main entry point for Omniboard Launcher application.""" -from gui import MongoApp +# Support both package execution (python -m src.main) and direct script runs (python src/main.py) +try: + from .gui import MongoApp +except ImportError: + from gui import MongoApp def main(): diff --git a/src/mongodb.py b/src/mongodb.py index d182dcf..6237a8b 100644 --- a/src/mongodb.py +++ b/src/mongodb.py @@ -1,7 +1,9 @@ """MongoDB client management.""" from pymongo import MongoClient +from pymongo.errors import OperationFailure from typing import List, Optional -from urllib.parse import urlparse +from urllib.parse import urlparse, parse_qs +import importlib.util class MongoDBClient: @@ -45,6 +47,15 @@ def connect_by_url(self, url: str) -> List[str]: # Ensure proper protocol if not url.startswith("mongodb://") and not url.startswith("mongodb+srv://"): url = "mongodb://" + url + + # If using SRV, ensure dnspython is available (required by PyMongo) + parsed = urlparse(url) + if parsed.scheme == "mongodb+srv": + if importlib.util.find_spec("dns") is None: + raise RuntimeError( + "The 'mongodb+srv://' scheme requires the 'dnspython' package. " + "Please install it (e.g., pip install dnspython) or use a standard 'mongodb://' URI." + ) self.uri = url return self._connect() @@ -62,8 +73,30 @@ def _connect(self) -> List[str]: self.client.close() self.client = MongoClient(self.uri, serverSelectionTimeoutMS=3000) - databases = self.client.list_database_names() - return databases + try: + # Standard behaviour: attempt to list all databases. This + # requires appropriate permissions (typically admin-level). + return self.client.list_database_names() + except OperationFailure as exc: + # Some deployments (Atlas/VM with non-admin user) forbid + # listDatabases. Fall back to the database inside the URI + # so the GUI can proceed with selection and Omniboard launch. + msg = str(exc).lower() + if "listdatabases" in msg or "not authorized" in msg or "command listdatabases" in msg: + _, _, database = self.parse_connection_url() + if database: + return [database] + # If no DB path was provided, try to infer from authSource + try: + parsed = urlparse(self.uri or "") + params = parse_qs(parsed.query) + auth_source = params.get("authSource", [None])[0] + if auth_source: + return [auth_source] + except Exception: + pass + # Otherwise, re-raise the original error + raise def parse_connection_url(self) -> tuple[str, int, Optional[str]]: """Parse the current connection URL. @@ -82,8 +115,12 @@ def parse_connection_url(self) -> tuple[str, int, Optional[str]]: return host, port, database + def get_connection_uri(self) -> Optional[str]: + """Return the current MongoDB connection URI (if any).""" + return self.uri + def close(self): """Close the MongoDB connection.""" if self.client: self.client.close() - self.client = None + self.client = None \ No newline at end of file diff --git a/src/omniboard.py b/src/omniboard.py index eb0291a..a32460d 100644 --- a/src/omniboard.py +++ b/src/omniboard.py @@ -5,12 +5,43 @@ import uuid import sys import time +import os +import shutil from typing import List, Optional +from urllib.parse import urlparse, urlunparse class OmniboardManager: """Manages Omniboard Docker containers.""" + @staticmethod + def _docker_cmd_base() -> List[str]: + """Resolve the Docker CLI executable path. + + Returns a list representing the base command to invoke Docker, ensuring + it works in frozen executables where PATH may be limited. + """ + # Prefer system PATH resolution + path = shutil.which("docker") + if path: + return [path] + # Windows default installation paths + candidates = [ + r"C:\\Program Files\\Docker\\Docker\\resources\\bin\\docker.exe", + r"C:\\Program Files\\Docker\\Docker\\resources\\bin\\com.docker.cli.exe", + ] + # Common Unix/Mac locations + candidates.extend([ + "/usr/bin/docker", + "/usr/local/bin/docker", + "/opt/homebrew/bin/docker", + ]) + for c in candidates: + if os.path.exists(c): + return [c] + # Fallback to plain 'docker' (may still succeed if shell resolves it) + return ["docker"] + @staticmethod def is_docker_running() -> bool: """Check if Docker daemon is running. @@ -19,12 +50,24 @@ def is_docker_running() -> bool: True if Docker is running, False otherwise """ try: + base = OmniboardManager._docker_cmd_base() + # First try a lightweight version check result = subprocess.run( - ["docker", "info"], + base + ["version", "--format", "{{.Server.Version}}"], + capture_output=True, + text=True, + timeout=8, + ) + if result.returncode == 0 and result.stdout.strip(): + return True + # Fallback to info with formatting + result2 = subprocess.run( + base + ["info", "--format", "{{.ServerVersion}}"], capture_output=True, - timeout=5 + text=True, + timeout=8, ) - return result.returncode == 0 + return result2.returncode == 0 and result2.stdout.strip() != "" except (subprocess.TimeoutExpired, FileNotFoundError): return False @@ -57,13 +100,13 @@ def start_docker_desktop(): stderr=subprocess.DEVNULL ) - # Wait up to 30 seconds for Docker to start - for _ in range(30): + # Wait up to 60 seconds for Docker to start + for _ in range(60): time.sleep(1) if OmniboardManager.is_docker_running(): return - raise Exception("Docker Desktop failed to start within 30 seconds") + raise Exception("Docker Desktop failed to start within 60 seconds") @staticmethod def ensure_docker_running(): @@ -108,7 +151,8 @@ def find_available_port(start_port: int) -> int: # Also check if Docker is using this port try: result = subprocess.run( - ["docker", "ps", "--filter", f"publish={port}", "--format", "{{.ID}}"], + OmniboardManager._docker_cmd_base() + + ["ps", "--filter", f"publish={port}", "--format", "{{.ID}}"], capture_output=True, text=True, timeout=5 @@ -120,27 +164,14 @@ def find_available_port(start_port: int) -> int: except OSError: port += 1 - @staticmethod - def adjust_mongo_host_for_docker(mongo_host: str) -> str: - """Adjust MongoDB host for Docker networking. - - Args: - mongo_host: Original MongoDB host - - Returns: - Adjusted host for Docker - """ - if sys.platform.startswith("win") or sys.platform == "darwin": - if mongo_host in ["localhost", "127.0.0.1"]: - return "host.docker.internal" - return mongo_host def launch( self, db_name: str, mongo_host: str, mongo_port: int, - host_port: Optional[int] = None + host_port: Optional[int] = None, + mongo_uri: Optional[str] = None, ) -> tuple[str, int]: """Launch an Omniboard Docker container. @@ -149,6 +180,9 @@ def launch( mongo_host: MongoDB host mongo_port: MongoDB port host_port: Optional host port (will find available if not provided) + mongo_uri: Optional full MongoDB connection URI. When provided, + Omniboard will be launched with this URI (using --mu) and the + selected database will be injected into the URI path. Returns: Tuple of (container_name, host_port) @@ -164,23 +198,38 @@ def launch( preferred_port = self.generate_port_for_database(db_name) host_port = self.find_available_port(preferred_port) - # Adjust host for Docker - docker_mongo_host = self.adjust_mongo_host_for_docker(mongo_host) - - # Build Docker command - mongo_arg = f"{docker_mongo_host}:{mongo_port}:{db_name}" container_name = f"omniboard_{uuid.uuid4().hex[:8]}" - - docker_cmd = [ - "docker", "run", "-it", "--rm", - "-p", f"{host_port}:9000", + + # Decide whether to use full URI or host:port:db form + if mongo_uri: + # Build a Docker-adjusted URI and ensure DB is included in the path + mongo_arg = self._adjust_mongo_uri_for_docker(mongo_uri, db_name=db_name) + mongo_flag = "--mu" + else: + # Port mode: if user connected to localhost on a Desktop platform, + # containers cannot reach the host via 127.0.0.1; use host.docker.internal. + host_for_container = mongo_host + if mongo_host in ("localhost", "127.0.0.1") and (sys.platform.startswith("win") or sys.platform == "darwin"): + host_for_container = "host.docker.internal" + mongo_arg = f"{host_for_container}:{mongo_port}:{db_name}" + mongo_flag = "-m" + + # Build Docker command (detached) + docker_cmd = OmniboardManager._docker_cmd_base() + [ + "run", "-d", "--rm", + "-p", f"127.0.0.1:{host_port}:9000", "--name", container_name, "vivekratnavel/omniboard", - "-m", mongo_arg + mongo_flag, mongo_arg, ] # Launch container - subprocess.Popen(docker_cmd) + subprocess.Popen( + docker_cmd, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + stdin=subprocess.DEVNULL, + ) return container_name, host_port @@ -193,11 +242,18 @@ def list_containers() -> List[str]: """ try: result = subprocess.run( - 'docker ps -a --filter "name=omniboard_" --format "{{.ID}}"', - shell=True, + OmniboardManager._docker_cmd_base() + + [ + "ps", + "-a", + "--filter", + "name=omniboard_", + "--format", + "{{.ID}}", + ], capture_output=True, text=True, - timeout=10 + timeout=10, ) return result.stdout.strip().splitlines() except (subprocess.TimeoutExpired, FileNotFoundError): @@ -217,11 +273,46 @@ def clear_all_containers(self) -> int: for cid in container_ids: try: subprocess.run( - f"docker rm -f {cid}", - shell=True, - timeout=10 + OmniboardManager._docker_cmd_base() + ["rm", "-f", cid], + timeout=10, ) except (subprocess.TimeoutExpired, FileNotFoundError): pass return len(container_ids) + + def _adjust_mongo_uri_for_docker(self, mongo_uri: str, db_name: Optional[str] = None) -> str: + """Inject DB name into a full MongoDB URI and preserve credentials and query. + + Host resolution adjustments are intentionally not performed. + """ + try: + parsed = urlparse(mongo_uri) + except Exception: + # If parsing fails, return original URI + return mongo_uri + + # Reconstruct netloc with potential creds and port (no host rewriting) + userinfo = "" + if parsed.username: + userinfo += parsed.username + if parsed.password: + userinfo += f":{parsed.password}" + userinfo += "@" + port_part = f":{parsed.port}" if parsed.port else "" + netloc = f"{userinfo}{parsed.hostname or ''}{port_part}" + + # Always set the path to the selected DB if provided + path = parsed.path or "" + if db_name: + path = f"/{db_name}" + + adjusted = urlunparse(( + parsed.scheme, + netloc, + path, + "", + parsed.query, + "", + )) + return adjusted \ No newline at end of file diff --git a/src/prefs.py b/src/prefs.py new file mode 100644 index 0000000..7a27f6c --- /dev/null +++ b/src/prefs.py @@ -0,0 +1,79 @@ +import json +import os +from pathlib import Path +from typing import Optional + +try: + from platformdirs import user_config_dir # type: ignore +except Exception: + user_config_dir = None # Fallback to legacy path if platformdirs missing + +try: + import keyring # type: ignore +except ImportError: # keyring is optional; methods will no-op if unavailable + keyring = None + +KEYRING_SERVICE = "AltarViewer" + +# Determine stable, OS-appropriate config path +if user_config_dir: + _config_dir = Path(user_config_dir("AltarViewer", "DreamRepo")) +else: + # Fallback: avoid relying on HOME if it points to a non-user location + # Prefer APPDATA on Windows, else default to Path.home() + base = Path(os.getenv("APPDATA", str(Path.home()))) + _config_dir = base / "AltarViewer" + +_config_dir.mkdir(parents=True, exist_ok=True) +CONFIG_PATH = _config_dir / "config.json" + +# Legacy location used by older versions; read-only fallback +LEGACY_CONFIG_PATH = Path.home() / ".altarviewer_config.json" + +class Preferences: + def is_keyring_available(self) -> bool: + return keyring is not None + + def load(self) -> dict: + try: + if CONFIG_PATH.exists(): + return json.loads(CONFIG_PATH.read_text(encoding="utf-8")) + # Backward compatibility: read legacy config if present + if LEGACY_CONFIG_PATH.exists(): + return json.loads(LEGACY_CONFIG_PATH.read_text(encoding="utf-8")) + except Exception: + pass + return {} + + def save_without_password(self, data: dict): + # Ensure password is never written to disk + clean = dict(data) + for k in ("password", "pwd"): + if k in clean: + clean.pop(k) + try: + CONFIG_PATH.write_text(json.dumps(clean, ensure_ascii=False, indent=2), encoding="utf-8") + except Exception: + pass + + def save_password_if_allowed(self, remember: bool, user: str, password: str): + if not keyring: + return + try: + if remember and password: + keyring.set_password(KEYRING_SERVICE, user, password) + else: + try: + keyring.delete_password(KEYRING_SERVICE, user) + except Exception: + pass + except Exception: + pass + + def load_password_if_any(self, user: str) -> Optional[str]: + if not keyring: + return None + try: + return keyring.get_password(KEYRING_SERVICE, user) + except Exception: + return None diff --git a/tests/conftest.py b/tests/conftest.py index 828e9fd..3e18200 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -3,6 +3,6 @@ import sys from pathlib import Path -# Add src directory to path -src_path = Path(__file__).parent.parent / "src" -sys.path.insert(0, str(src_path)) +# Ensure repository root is on sys.path so `import src.*` works +repo_root = Path(__file__).parent.parent +sys.path.insert(0, str(repo_root)) \ No newline at end of file diff --git a/tests/test_docker_behavior.py b/tests/test_docker_behavior.py new file mode 100644 index 0000000..be679bf --- /dev/null +++ b/tests/test_docker_behavior.py @@ -0,0 +1,102 @@ +"""Tests for Docker detection and GUI behavior regarding manual start requirement.""" +import os +import sys +import subprocess +import types +import pytest + +from src.omniboard import OmniboardManager + + +def test_docker_cmd_base_uses_candidates(monkeypatch): + """When docker is not on PATH, fallback candidates should be considered.""" + # Simulate no docker on PATH + monkeypatch.setattr("shutil.which", lambda name: None) + + # Pretend one Windows candidate exists regardless of platform + def fake_exists(path: str) -> bool: + return path.endswith("com.docker.cli.exe") + + monkeypatch.setattr("os.path.exists", fake_exists) + + base = OmniboardManager._docker_cmd_base() + assert isinstance(base, list) and base + assert base[0].endswith("com.docker.cli.exe") + + +def test_is_docker_running_prefers_version(monkeypatch): + """is_docker_running should return True when `docker version` succeeds.""" + + def fake_run(args, capture_output=False, text=False, timeout=None): + class R: + def __init__(self, rc, out=""): + self.returncode = rc + self.stdout = out + self.stderr = "" + if "version" in args: + return R(0, "25.0.3\n") + return R(1, "") + + monkeypatch.setattr(subprocess, "run", fake_run) + + assert OmniboardManager.is_docker_running() is True + + +def test_is_docker_running_fallback_info(monkeypatch): + """If version fails but info works, detection should still succeed.""" + + def fake_run(args, capture_output=False, text=False, timeout=None): + class R: + def __init__(self, rc, out=""): + self.returncode = rc + self.stdout = out + self.stderr = "" + if "version" in args: + return R(1, "") + if "info" in args: + return R(0, "25.0.3\n") + return R(1, "") + + monkeypatch.setattr(subprocess, "run", fake_run) + + assert OmniboardManager.is_docker_running() is True + + +@pytest.mark.skipif(os.environ.get("DISPLAY") is None and not sys.platform.startswith("win"), + reason="Tk requires a display; skip on headless CI") +def test_gui_does_not_autostart(monkeypatch): + """GUI should not auto-start Docker; it should prompt the user and return.""" + from src.gui import MongoApp + + # Instantiate the app (headless); + app = MongoApp() + + # Select a database so launch_omniboard proceeds to docker checks + app.selected_db.set("mydb") + + # Mock docker running check to return False + monkeypatch.setattr(app.omniboard_manager, "is_docker_running", lambda: False) + + # Ensure start_docker_desktop is not called + start_called = {"v": False} + + def fake_start(): + start_called["v"] = True + + monkeypatch.setattr(app.omniboard_manager, "start_docker_desktop", fake_start) + + # Stub messagebox to capture info messages without showing dialogs + infos = [] + + monkeypatch.setattr("src.gui.messagebox.showinfo", lambda *a, **k: infos.append(a[0])) + monkeypatch.setattr("src.gui.messagebox.showerror", lambda *a, **k: None) + + # Attempt to launch + app.launch_omniboard() + + # Assert no auto-start and that a message was shown + assert start_called["v"] is False + assert infos and "Docker not running" in infos[0] + + # Cleanup the Tk app + app.destroy() diff --git a/tests/test_integration.py b/tests/test_integration.py index 7d6b337..9a385ec 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -9,7 +9,6 @@ def test_imports(self): """Test that all modules can be imported.""" from src.mongodb import MongoDBClient from src.omniboard import OmniboardManager - from src.gui import MongoApp # Basic instantiation mongo = MongoDBClient() @@ -41,6 +40,5 @@ def test_workflow_simulation(self): assert isinstance(preferred_port, int) assert 20000 <= preferred_port < 30000 - # Simulate host adjustment - docker_host = omni.adjust_mongo_host_for_docker(host) - assert docker_host in ["localhost", "host.docker.internal"] + # No host adjustment is performed anymore; use host as-is + assert host == "localhost" diff --git a/tests/test_localhost_container_access.py b/tests/test_localhost_container_access.py new file mode 100644 index 0000000..f3dc552 --- /dev/null +++ b/tests/test_localhost_container_access.py @@ -0,0 +1,63 @@ +"""Tests for Port mode localhost mapping to host.docker.internal on Desktop platforms.""" +import sys +import subprocess + +from src.omniboard import OmniboardManager + + +def _capture_popen(monkeypatch): + recorded = {"args": None} + + class DummyPopen: + def __init__(self, args, **kwargs): + recorded["args"] = args + # Provide minimal interface + def communicate(self, *a, **k): + return ("", "") + + monkeypatch.setattr(subprocess, "Popen", DummyPopen) + return recorded + + +def test_port_mode_maps_localhost_on_windows(monkeypatch): + recorded = _capture_popen(monkeypatch) + # Pretend Docker is running and docker command base is just ['docker'] + monkeypatch.setattr(OmniboardManager, "ensure_docker_running", lambda self: None) + monkeypatch.setattr(sys, "platform", "win32", raising=False) + + m = OmniboardManager() + # Choose a fixed port to avoid find_available_port + container, host_port = m.launch( + db_name="mydb", + mongo_host="localhost", + mongo_port=27017, + host_port=25001, + mongo_uri=None, + ) + + args = recorded["args"] + assert args is not None + # Ensure '-m' is present and the argument uses host.docker.internal + assert "-m" in args + idx = args.index("-m") + assert args[idx + 1].startswith("host.docker.internal:27017:") + + +def test_port_mode_keeps_remote_hosts(monkeypatch): + recorded = _capture_popen(monkeypatch) + monkeypatch.setattr(OmniboardManager, "ensure_docker_running", lambda self: None) + monkeypatch.setattr(sys, "platform", "darwin", raising=False) + + m = OmniboardManager() + m.launch( + db_name="db2", + mongo_host="mongo.example.com", + mongo_port=27018, + host_port=25002, + mongo_uri=None, + ) + + args = recorded["args"] + assert args is not None + idx = args.index("-m") + assert args[idx + 1] == "mongo.example.com:27018:db2" diff --git a/tests/test_omniboard.py b/tests/test_omniboard.py index ab84e9b..1dde20e 100644 --- a/tests/test_omniboard.py +++ b/tests/test_omniboard.py @@ -29,24 +29,19 @@ def test_generate_port_custom_range(self): port = manager.generate_port_for_database("test", base=5000, span=1000) assert 5000 <= port < 6000 - def test_adjust_mongo_host_for_docker_localhost_windows(self): - """Test host adjustment for Windows/Mac localhost.""" + def test_adjust_mongo_uri_injects_db_and_preserves_host(self): + """Ensure DB is injected into URI without rewriting host.""" manager = OmniboardManager() - - if sys.platform.startswith("win") or sys.platform == "darwin": - assert manager.adjust_mongo_host_for_docker("localhost") == "host.docker.internal" - assert manager.adjust_mongo_host_for_docker("127.0.0.1") == "host.docker.internal" - else: - # On Linux, localhost stays as is - assert manager.adjust_mongo_host_for_docker("localhost") in ["localhost", "host.docker.internal"] - - def test_adjust_mongo_host_for_docker_remote(self): - """Test host adjustment for remote hosts.""" - manager = OmniboardManager() - - # Remote hosts should not be changed - assert manager.adjust_mongo_host_for_docker("192.168.1.100") == "192.168.1.100" - assert manager.adjust_mongo_host_for_docker("mongo.example.com") == "mongo.example.com" + # Localhost example + uri = "mongodb://user:pass@localhost:27017/?replicaSet=rs0" + adjusted = manager._adjust_mongo_uri_for_docker(uri, db_name="mydb") + assert adjusted.startswith("mongodb://user:pass@localhost:27017/") + assert "/mydb" in adjusted + assert "replicaSet=rs0" in adjusted + # Remote host example + uri2 = "mongodb://user:pass@mongo.example.com:27017/" + adjusted2 = manager._adjust_mongo_uri_for_docker(uri2, db_name="abc") + assert "mongo.example.com:27017/abc" in adjusted2 def test_find_available_port(self): """Test finding available port."""