From 99bed0d3c544d1a1b728e30142b20733e6a2cf1b Mon Sep 17 00:00:00 2001 From: Alienor134 Date: Mon, 5 Jan 2026 10:42:10 +0100 Subject: [PATCH 01/22] docs: versions, details, contributions --- .github/workflows/build.yml | 46 ++--- README.md | 396 ++++++++++++++++++++++++++++++++---- requirements-dev.txt | 4 + 3 files changed, 383 insertions(+), 63 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 014933d..ea02b4b 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -25,31 +25,31 @@ permissions: 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: @@ -58,31 +58,31 @@ jobs: 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: @@ -91,31 +91,31 @@ jobs: 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: @@ -126,31 +126,31 @@ jobs: 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: diff --git a/README.md b/README.md index 561f2f4..e392327 100644 --- a/README.md +++ b/README.md @@ -1,67 +1,383 @@ -# 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. +## Features -## Requirements +- **MongoDB Connection Management**: Connect to local or remote MongoDB instances +- **Database Discovery**: Automatically list all available databases +- **One-Click Omniboard Launch**: Deploy Omniboard in isolated Docker containers +- **Modern GUI**: Built with CustomTkinter for a clean, modern interface +- **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 + +### Prerequisites + +#### System Requirements +- **Operating System**: Windows 10/11, macOS 10.14+, or Linux +- **Python**: 3.8 or higher +- **Docker Desktop**: Latest version ([Download](https://www.docker.com/products/docker-desktop/)) +- **MongoDB**: Running instance (local or remote) + +#### Verify Prerequisites +```bash +# Check Python version +python --version # Should be 3.8+ + +# Check Docker is running +docker --version +docker ps + +# Check MongoDB is accessible +mongosh --version # or mongo --version +``` + +### From Source + +1. **Clone the repository** + ```bash + git clone https://github.com/DreamRepo/Altar.git + cd Altar/AltarViewer + ``` + +2. **Create a virtual environment** (recommended) + ```bash + python -m venv venv + + # On Windows + venv\Scripts\activate + + # On macOS/Linux + source venv/bin/activate + ``` + +3. **Install dependencies** + ```bash + pip install -r requirements.txt + ``` + +4. **Run the application** + ```bash + python src/main.py + ``` + +### From Binary Release + +Download the latest executable from the [releases page](https://github.com/Alienor134/launch_omniboard/releases) and run directly. No Python installation required. ## Usage -### As a Python Script +### Quick Start + +1. **Launch the application** + ```bash + python src/main.py + ``` + +2. **Connect to MongoDB** + - Enter your MongoDB host and port (default: `localhost:27017`) + - Click "Connect" to list available databases + +3. **Select a database** + - Choose a database from the dropdown list + - Click "Launch Omniboard" + +4. **Access Omniboard** + - A clickable link will appear in the interface + - Omniboard opens automatically in your default browser + +### Configuration + +#### MongoDB Connection +- **Default Port**: 27017 +- **Connection String**: Supports standard MongoDB URIs +- **Authentication**: Configure in MongoDB settings (currently local connections) + +#### 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 +├── 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: -- 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. +### 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 + # Add linting tools as configured + ``` + +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 + +### Code Style + +- Follow PEP 8 conventions +- Maximum line length: 100 characters +- Use type hints where applicable +- Document all public functions and classes + +### Reporting Issues + +- Use the [GitHub issue tracker](https://github.com/DreamRepo/Altar/issues) +- Include Python version, OS, and steps to reproduce +- Provide error messages and logs when applicable ## 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 + +### 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+) + +### 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 + +## Versioning + +This project uses [Semantic Versioning](https://semver.org/) (SemVer): + +- **MAJOR** version for incompatible API changes +- **MINOR** version for backwards-compatible functionality additions +- **PATCH** version for backwards-compatible bug fixes +- **-dev**, **-alpha**, **-beta** suffixes for pre-release versions + +### Current Version: 1.0.0-dev + +### Version History + +See [CHANGELOG.md](CHANGELOG.md) for detailed version history and release notes. + +### Release Process + +1. Update version in relevant files +2. Update CHANGELOG.md with release notes +3. Create a git tag: `git tag -a v1.0.0 -m "Release version 1.0.0"` +4. Push tag: `git push origin v1.0.0` +5. Build and publish release manually + +## License + +This project is licensed under the GNU General Public License v3.0 - see the [LICENSE](LICENSE) file for details. + +--- + +**Part of the DREAM/Altar Ecosystem** ---- \ No newline at end of file +- [AltarDocker](../AltarDocker/): Docker deployment infrastructure +- [AltarExtractor](../AltarExtractor/): Data extraction and visualization +- [AltarSender](../AltarSender/): Experiment data submission +- [Altar Documentation](../README.md): Main project documentation \ 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 From 989fa90ca6ecf48fd7ca326c9df642bd41ac6e11 Mon Sep 17 00:00:00 2001 From: Alienor134 Date: Mon, 5 Jan 2026 10:48:45 +0100 Subject: [PATCH 02/22] docs: simplify --- README.md | 26 -------------------------- 1 file changed, 26 deletions(-) diff --git a/README.md b/README.md index e392327..e9c8fc4 100644 --- a/README.md +++ b/README.md @@ -280,18 +280,6 @@ We welcome contributions! Please follow these guidelines: - Reference any related issues - Ensure all tests pass -### Code Style - -- Follow PEP 8 conventions -- Maximum line length: 100 characters -- Use type hints where applicable -- Document all public functions and classes - -### Reporting Issues - -- Use the [GitHub issue tracker](https://github.com/DreamRepo/Altar/issues) -- Include Python version, OS, and steps to reproduce -- Provide error messages and logs when applicable ## Troubleshooting @@ -346,20 +334,6 @@ We welcome contributions! Please follow these guidelines: - Review [Omniboard documentation](https://vivekratnavel.github.io/omniboard/) - Contact the DREAM/Altar team -## Versioning - -This project uses [Semantic Versioning](https://semver.org/) (SemVer): - -- **MAJOR** version for incompatible API changes -- **MINOR** version for backwards-compatible functionality additions -- **PATCH** version for backwards-compatible bug fixes -- **-dev**, **-alpha**, **-beta** suffixes for pre-release versions - -### Current Version: 1.0.0-dev - -### Version History - -See [CHANGELOG.md](CHANGELOG.md) for detailed version history and release notes. ### Release Process From 17661d3cf3b45f70362de5a8aba2e8b13bd68b62 Mon Sep 17 00:00:00 2001 From: Alienor134 Date: Mon, 5 Jan 2026 10:50:49 +0100 Subject: [PATCH 03/22] docs: simplify more --- README.md | 16 +++------------- 1 file changed, 3 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index e9c8fc4..1440bd4 100644 --- a/README.md +++ b/README.md @@ -253,7 +253,6 @@ We welcome contributions! Please follow these guidelines: 2. **Run tests and linting** ```bash pytest - # Add linting tools as configured ``` 3. **Commit your changes** @@ -338,20 +337,11 @@ We welcome contributions! Please follow these guidelines: ### Release Process 1. Update version in relevant files -2. Update CHANGELOG.md with release notes -3. Create a git tag: `git tag -a v1.0.0 -m "Release version 1.0.0"` -4. Push tag: `git push origin v1.0.0` -5. Build and publish release manually +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. ---- - -**Part of the DREAM/Altar Ecosystem** - -- [AltarDocker](../AltarDocker/): Docker deployment infrastructure -- [AltarExtractor](../AltarExtractor/): Data extraction and visualization -- [AltarSender](../AltarSender/): Experiment data submission -- [Altar Documentation](../README.md): Main project documentation \ No newline at end of file From 24edffdf65c5fa89f047824d1e082ba3ccddd5d1 Mon Sep 17 00:00:00 2001 From: Alienor134 Date: Mon, 5 Jan 2026 11:05:00 +0100 Subject: [PATCH 04/22] fix: github tocken --- .github/workflows/build.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index ea02b4b..922043f 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -29,6 +29,8 @@ jobs: steps: - name: Checkout code uses: actions/checkout@v4 + with: + token: ${{ secrets.GITHUB_TOKEN }} - name: Set up Python uses: actions/setup-python@v5 @@ -62,6 +64,8 @@ jobs: steps: - name: Checkout code uses: actions/checkout@v4 + with: + token: ${{ secrets.GITHUB_TOKEN }} - name: Set up Python uses: actions/setup-python@v5 @@ -95,6 +99,8 @@ jobs: steps: - name: Checkout code uses: actions/checkout@v4 + with: + token: ${{ secrets.GITHUB_TOKEN }} - name: Set up Python uses: actions/setup-python@v5 From 67114db89acf30b877817ec7eeadc94d5394df89 Mon Sep 17 00:00:00 2001 From: Alienor134 Date: Thu, 8 Jan 2026 22:26:37 +0100 Subject: [PATCH 05/22] =?UTF-8?q?ctk=20to=20dash=20migration=C3=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/build.yml | 14 +- OmniboardLauncher.spec | 17 +- docker-compose.yml | 18 ++ requirements.txt | 8 +- src/app.py | 31 +++ src/assets/custom.css | 323 ++++++++++++++++++++++++++++++ src/callbacks.py | 298 ++++++++++++++++++++++++++++ src/gui.py | 386 ------------------------------------ src/layout.py | 215 ++++++++++++++++++++ src/main.py | 43 +++- src/mongodb.py | 6 +- src/omniboard.py | 68 ++++++- tests/conftest.py | 14 +- tests/test_integration.py | 1 - 14 files changed, 1027 insertions(+), 415 deletions(-) create mode 100644 docker-compose.yml create mode 100644 src/app.py create mode 100644 src/assets/custom.css create mode 100644 src/callbacks.py delete mode 100644 src/gui.py create mode 100644 src/layout.py diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 922043f..9f42bf0 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -29,8 +29,6 @@ jobs: steps: - name: Checkout code uses: actions/checkout@v4 - with: - token: ${{ secrets.GITHUB_TOKEN }} - name: Set up Python uses: actions/setup-python@v5 @@ -64,8 +62,6 @@ jobs: steps: - name: Checkout code uses: actions/checkout@v4 - with: - token: ${{ secrets.GITHUB_TOKEN }} - name: Set up Python uses: actions/setup-python@v5 @@ -99,8 +95,6 @@ jobs: steps: - name: Checkout code uses: actions/checkout@v4 - with: - token: ${{ secrets.GITHUB_TOKEN }} - name: Set up Python uses: actions/setup-python@v5 @@ -118,6 +112,14 @@ jobs: run: | python -m pytest tests/ -v --cov=src --cov-report=term-missing + - name: Validate Docker Compose configuration + run: | + docker compose -f docker-compose.yml config + + - name: Build Docker image + run: | + docker compose build + - name: Build executable run: | pyinstaller OmniboardLauncher.spec diff --git a/OmniboardLauncher.spec b/OmniboardLauncher.spec index 10234ce..7a267de 100644 --- a/OmniboardLauncher.spec +++ b/OmniboardLauncher.spec @@ -7,11 +7,24 @@ a = Analysis( pathex=[], binaries=[], datas=[('assets/DataBase.ico', '.')], - hiddenimports=['customtkinter', 'pymongo', 'src.mongodb', 'src.omniboard', 'src.gui'], + hiddenimports=[ + 'dash', + 'dash.dependencies', + 'dash_bootstrap_components', + 'flask', + 'plotly', + 'pymongo', + 'src.mongodb', + 'src.omniboard', + 'src.app', + 'src.layout', + 'src.callbacks', + ], 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/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..e46cc99 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,18 @@ +version: '3.8' + +services: + altarviewer: + build: . + container_name: altarviewer + ports: + - "8060:8060" + volumes: + # Mount Docker socket to allow spawning Omniboard containers + - /var/run/docker.sock:/var/run/docker.sock + environment: + - PORT=8060 + - DOCKER_MODE=true + - MONGO_DEFAULT_URL=mongodb://host.docker.internal:27017/ + - MONGO_DEFAULT_HOST=host.docker.internal + restart: unless-stopped + # Uses the default Docker Compose network; no custom network required diff --git a/requirements.txt b/requirements.txt index b15babf..2cdfa7b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,8 @@ -customtkinter==5.2.2 +# Core dependencies (shared) pymongo>=4.0.0 + +# Web UI dependencies (Dash) +dash>=2.14.0 +dash-bootstrap-components>=1.5.0 +flask>=3.0.0 + diff --git a/src/app.py b/src/app.py new file mode 100644 index 0000000..85fcb9b --- /dev/null +++ b/src/app.py @@ -0,0 +1,31 @@ +"""Dash application for AltarViewer.""" +from dash import Dash +import dash_bootstrap_components as dbc +from layout import build_layout + + +def create_dash_app(): + """Create and configure the Dash application.""" + app = Dash( + __name__, + external_stylesheets=[ + dbc.themes.DARKLY, # Dark Bootstrap theme + "https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" + ], + suppress_callback_exceptions=True, + title="AltarViewer - Omniboard Launcher", + assets_folder='assets' + ) + + app.layout = build_layout() + + # Import callbacks after app is created to register them + from callbacks import register_callbacks + register_callbacks(app) + + return app + + +if __name__ == "__main__": + app = create_dash_app() + app.run(debug=True, port=8060) diff --git a/src/assets/custom.css b/src/assets/custom.css new file mode 100644 index 0000000..d6f240d --- /dev/null +++ b/src/assets/custom.css @@ -0,0 +1,323 @@ +/* Altar Theme for AltarViewer */ +:root { + --bg: #0b0c10; + --panel: #0f1117; + --card: #11131a; + --text: #e6e9ef; + --muted: #b3b9c6; + --brand: #4f7cff; + --brand-2: #7f56d9; + --stroke: #1c2030; + --shadow: 0 10px 30px rgba(0,0,0,0.35); +} + +body { + background: var(--bg) !important; + color: var(--text) !important; + font-family: Inter, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, sans-serif !important; + line-height: 1.4; + font-size: 14px; +} + +/* Navbar styling */ +.navbar { + background: var(--panel) !important; + border-bottom: 1px solid var(--stroke); + box-shadow: var(--shadow); +} + +.navbar-brand { + color: var(--text) !important; + font-weight: 600; +} + +/* Container */ +.container { + padding-top: 2rem; + padding-bottom: 2rem; +} + +/* Cards */ +.card { + background: var(--card) !important; + border: 1px solid var(--stroke) !important; + border-radius: 12px !important; + box-shadow: var(--shadow); + transition: transform 0.2s ease, border-color 0.2s ease; + color: var(--text) !important; +} + +.card:hover { + transform: translateY(-2px); + border-color: rgba(79, 124, 255, 0.35) !important; +} + +.card-body { + padding: 1rem; +} + +.card-title { + color: var(--text) !important; + font-weight: 600; + margin-bottom: 0.75rem; + font-size: 1rem; +} + +/* Buttons */ +.btn-primary { + background: linear-gradient(135deg, var(--brand), var(--brand-2)) !important; + border: none !important; + color: white !important; + font-weight: 500; + padding: 0.5rem 1rem; + border-radius: 6px; + transition: all 0.2s ease; + font-size: 13px; +} + +.btn-primary:hover { + transform: translateY(-1px); + box-shadow: 0 4px 12px rgba(79, 124, 255, 0.4); +} + +.btn-success { + background: linear-gradient(135deg, #10b981, #059669) !important; + border: none !important; + color: white !important; + font-weight: 500; + padding: 0.5rem 1rem; + border-radius: 6px; + transition: all 0.2s ease; + font-size: 13px; +} + +.btn-success:hover { + transform: translateY(-1px); + box-shadow: 0 4px 12px rgba(16, 185, 129, 0.4); +} + +.btn-warning { + background: transparent !important; + border: 1px solid rgba(239, 68, 68, 0.5) !important; + color: #ef4444 !important; + font-weight: 500; + padding: 0.5rem 1rem; + border-radius: 6px; + transition: all 0.2s ease; + font-size: 13px; +} + +.btn-warning:hover { + background: rgba(239, 68, 68, 0.1) !important; + border-color: #ef4444 !important; + color: #ef4444 !important; + box-shadow: 0 4px 12px rgba(239, 68, 68, 0.3); +} + +.btn-outline-warning { + background: transparent !important; + border: 1px solid rgba(239, 68, 68, 0.5) !important; + color: #ef4444 !important; +} + +.btn-outline-warning:hover { + background: rgba(239, 68, 68, 0.1) !important; + border-color: #ef4444 !important; + color: #ef4444 !important; +} + +.btn-light { + background: rgba(255, 255, 255, 0.95) !important; + border: none !important; + color: var(--bg) !important; + transition: all 0.15s ease; + border-radius: 6px; + padding: 0.4rem 0.8rem; + margin-bottom: 0.3rem !important; + font-size: 13px; + font-weight: 500; + cursor: pointer; + text-align: left !important; +} + +.btn-light:hover { + background: white !important; + color: var(--brand) !important; + transform: translateX(4px); + box-shadow: 0 2px 8px rgba(79, 124, 255, 0.3); +} + +.btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +/* Spinner */ +.spinner-border { + border-color: var(--brand) !important; + border-right-color: transparent !important; +} + +/* Inputs */ +input[type="text"], +input[type="password"] { + background: var(--panel) !important; + border: 1px solid var(--stroke) !important; + color: var(--text) !important; + border-radius: 6px; + padding: 0.5rem 0.8rem; + transition: all 0.2s ease; + font-size: 13px; +} + +input[type="text"]:focus, +input[type="password"]:focus { + background: var(--card) !important; + border-color: var(--brand) !important; + box-shadow: 0 0 0 3px rgba(79, 124, 255, 0.15); + outline: none; +} + +input::placeholder { + color: var(--muted) !important; + opacity: 0.6; +} + +/* Radio buttons */ +.form-check-input { + background-color: white !important; + border-color: var(--stroke) !important; +} + +.form-check-input:checked { + background-color: var(--brand) !important; + border-color: var(--brand) !important; +} + +/* Connection mode selector styled as white pills */ +#connection-mode .form-check-label { + background: rgba(255, 255, 255, 0.95) !important; + color: var(--bg) !important; + padding: 0.15rem 0.75rem; + border-radius: 999px; + margin-right: 0.35rem; + cursor: pointer; + font-size: 13px; +} + +#connection-mode .form-check-input:checked + .form-check-label { + background: var(--brand) !important; + color: white !important; +} + +/* Labels */ +label { + color: var(--text) !important; + font-weight: 500; + margin-bottom: 0.4rem; + font-size: 13px; +} + +/* Text colors */ +.text-muted { + color: var(--muted) !important; +} + +.text-primary { + color: var(--brand) !important; +} + +/* Alerts */ +.alert { + border-radius: 8px; + border: none; + font-weight: 500; +} + +.alert-success { + background: rgba(16, 185, 129, 0.15) !important; + color: #10b981 !important; + border: 1px solid rgba(16, 185, 129, 0.3) !important; +} + +.alert-danger { + background: rgba(239, 68, 68, 0.15) !important; + color: #ef4444 !important; + border: 1px solid rgba(239, 68, 68, 0.3) !important; +} + +.alert-info { + background: rgba(79, 124, 255, 0.15) !important; + color: var(--brand) !important; + border: 1px solid rgba(79, 124, 255, 0.3) !important; +} + +/* Database list container */ +#database-list-container { + background: white !important; + border-radius: 6px; +} + +/* Database items */ +.db-item { + transition: all 0.15s ease; +} + +.db-item:hover { + background: rgba(79, 124, 255, 0.08) !important; + color: var(--brand) !important; + transform: translateX(2px); +} + +/* Launched instances */ +#launched-instances { + background: var(--panel) !important; + border-color: var(--stroke) !important; +} + +/* Links */ +a { + color: var(--brand) !important; + text-decoration: none; + transition: color 0.2s ease; +} + +a:hover { + color: var(--brand-2) !important; + text-decoration: underline; +} + +/* Scrollbar */ +::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +::-webkit-scrollbar-track { + background: var(--panel); +} + +::-webkit-scrollbar-thumb { + background: var(--stroke); + border-radius: 4px; +} + +::-webkit-scrollbar-thumb:hover { + background: var(--brand); +} + +/* Animations */ +@keyframes fadeIn { + from { + opacity: 0; + transform: translateY(10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.card { + animation: fadeIn 0.3s ease; +} diff --git a/src/callbacks.py b/src/callbacks.py new file mode 100644 index 0000000..9ccda57 --- /dev/null +++ b/src/callbacks.py @@ -0,0 +1,298 @@ +"""Dash callbacks for AltarViewer (Dash web UI). + +This module wires the Dash UI to three main concerns: + +* MongoDB connection & database discovery +* Database selection UX (highlighting, enabling launch) +* Omniboard lifecycle (launch, clear-all) + +The callbacks are thin wrappers around small helper functions so the +behaviour is easier to understand and evolve. +""" +import os +from dash import Output, Input, State, html, no_update, ALL +import dash_bootstrap_components as dbc +from mongodb import MongoDBClient +from omniboard import OmniboardManager + + +# Initialize managers (shared, module-level singletons) +mongo_client = MongoDBClient() +omniboard_manager = OmniboardManager() + + +# --- Styling helpers ------------------------------------------------------ + +DB_ITEM_BASE_STYLE = { + "padding": "0.4rem 0.6rem", + "cursor": "pointer", + "fontSize": "13px", + "color": "black", + "borderRadius": "4px", + "transition": "all 0.15s ease", +} + +DB_ITEM_SELECTED_STYLE = dict( + DB_ITEM_BASE_STYLE, + **{"backgroundColor": "#4f7cff", "color": "white"}, +) + + +def _friendly_mongo_error_message(error: Exception) -> str: + """Return a short, user-friendly error message for Mongo failures.""" + error_msg = str(error) + + lower = error_msg.lower() + if "serverselectiontimeouterror" in error_msg or "timed out" in lower: + return ( + "Cannot connect to MongoDB server. " + "Please ensure MongoDB is running and the connection details are correct." + ) + if "connection refused" in lower: + return ( + "Connection refused by MongoDB server. " + "MongoDB may not be running on this port." + ) + if "authentication failed" in lower: + return "MongoDB authentication failed. Please check your credentials." + + return f"Connection Error: {error_msg}" + + +def _build_instances_ui(launched_containers): + """Build the UI list of launched Omniboard instances. + + All instances are shown as clickable links; Omniboard startup time is + left to the user (they may need to refresh the page if it is not ready + yet), so we avoid extra polling complexity. + """ + if not launched_containers: + return [ + html.P( + "No Omniboard instances launched yet", + className="text-muted text-center mb-0", + ) + ] + + items = [] + for info in launched_containers: + db_name = info.get("database", "?") + url = info.get("url", "") + + content = [ + html.Span( + f"{db_name}: ", + style={"fontWeight": "500", "marginRight": "0.5rem"}, + ), + html.A( + url, + href=url, + target="_blank", + className="text-decoration-none", + ), + ] + + items.append( + html.Div( + content, + style={"padding": "0.3rem 0", "fontSize": "13px"}, + ) + ) + + return items + + +def register_callbacks(app): + """Register all callbacks with the app instance.""" + + @app.callback( + [Output("port-input-container", "style"), + Output("url-input-container", "style")], + Input("connection-mode", "value") + ) + def toggle_connection_mode(mode): + """Toggle between port and URL input modes.""" + if mode == "port": + return {"display": "block", "marginBottom": "1rem"}, {"display": "none"} + else: + return {"display": "none"}, {"display": "block", "marginBottom": "1rem"} + + + @app.callback( + [Output("connection-store", "data"), + Output("databases-store", "data"), + Output("connection-status", "children"), + Output("database-list-container", "children")], + Input("connect-btn", "n_clicks"), + [State("connection-mode", "value"), + State("port-input", "value"), + State("url-input", "value")], + prevent_initial_call=True + ) + def connect_to_mongodb(n_clicks, mode, port, url): + """Connect to MongoDB and retrieve database list.""" + if not n_clicks: + return no_update, no_update, no_update, no_update + + try: + # Connect based on mode + if mode == "port": + port_value = port or "27017" + databases = mongo_client.connect_by_port(port_value) + connection_info = {"mode": "port", "port": port_value} + else: + # If URL is empty, fall back to the configured default URL + default_url = os.environ.get("MONGO_DEFAULT_URL", "mongodb://localhost:27017/") + url_value = (url or "").strip() or default_url + databases = mongo_client.connect_by_url(url_value) + connection_info = {"mode": "url", "url": url_value} + + # Create database list UI + if not databases: + db_list_ui = html.P( + "No databases found", + className="text-muted", + style={"padding": "1rem 0", "fontSize": "13px"} + ) + else: + db_list_ui = [ + html.Div( + db, + id={"type": "db-button", "index": db}, + className="db-item", + style=DB_ITEM_BASE_STYLE, + ) + for db in databases + ] + + success_msg = dbc.Alert( + f"Successfully connected! Found {len(databases)} database(s).", + color="success", + dismissable=True, + duration=4000, + ) + + return connection_info, databases, success_msg, db_list_ui + + except Exception as e: + error_alert = dbc.Alert( + _friendly_mongo_error_message(e), + color="danger", + dismissable=True, + ) + + return no_update, no_update, error_alert, no_update + + + @app.callback( + [Output("selected-db-store", "data"), + Output("selected-database", "children"), + Output("launch-btn", "disabled"), + Output({"type": "db-button", "index": ALL}, "style")], + Input({"type": "db-button", "index": ALL}, "n_clicks"), + State({"type": "db-button", "index": ALL}, "id"), + prevent_initial_call=True + ) + def select_database(n_clicks_list, button_ids): + """Handle database selection and visually highlight the chosen database.""" + from dash import callback_context + + # No trigger information: nothing to do + if not callback_context.triggered: + return no_update, no_update, no_update, no_update + + triggered_id = callback_context.triggered[0]["prop_id"].split(".")[0] + + import json + try: + button_id = json.loads(triggered_id) + db_name = button_id["index"] + except Exception: + return no_update, no_update, no_update, no_update + + # Compute styles for all db buttons, highlighting the selected one + styles = [] + for btn_id in button_ids: + if isinstance(btn_id, dict) and btn_id.get("index") == db_name: + styles.append(DB_ITEM_SELECTED_STYLE) + else: + styles.append(DB_ITEM_BASE_STYLE) + + # We now rely on visual highlight, so we no longer need the "Selected: ..." text + return db_name, "", False, styles + @app.callback( + [Output("launched-containers-store", "data", allow_duplicate=True), + Output("launched-instances", "children", allow_duplicate=True)], + Input("launch-btn", "n_clicks"), + [State("selected-db-store", "data"), + State("launched-containers-store", "data")], + prevent_initial_call=True + ) + def launch_omniboard(n_clicks, selected_db, launched_containers): + """Launch Omniboard container for selected database. + """ + if not n_clicks or not selected_db: + return no_update, no_update + + try: + # Get MongoDB connection details + mongo_host, mongo_port, _ = mongo_client.parse_connection_url() + + # Launch Omniboard (non-blocking, container starts in background) + container_name, host_port = omniboard_manager.launch( + db_name=selected_db, + mongo_host=mongo_host, + mongo_port=mongo_port + ) + + # Add to launched containers list + if launched_containers is None: + launched_containers = [] + + container_info = { + "database": selected_db, + "port": host_port, + "container": container_name, + "url": f"http://localhost:{host_port}", + } + launched_containers.append(container_info) + + # Build UI for launched instances + instances_ui = _build_instances_ui(launched_containers) + + return launched_containers, instances_ui + + except Exception as e: + error_card = dbc.Alert( + f"Failed to launch Omniboard: {str(e)}", + color="danger", + dismissable=True, + ) + return no_update, error_card + + + @app.callback( + [Output("launched-containers-store", "data", allow_duplicate=True), + Output("launched-instances", "children", allow_duplicate=True)], + Input("clear-btn", "n_clicks"), + prevent_initial_call=True + ) + def clear_containers(n_clicks): + """Clear all Omniboard containers.""" + if not n_clicks: + return no_update, no_update + + try: + # Best-effort clear; ignore the count as we don't currently + # surface an extra alert, just reset the store/UI. + omniboard_manager.clear_all_containers() + + return [], _build_instances_ui([]) + + except Exception as e: + error = dbc.Alert( + f"Error clearing containers: {str(e)}", + color="danger", + dismissable=True, + ) + return no_update, error diff --git a/src/gui.py b/src/gui.py deleted file mode 100644 index dd015be..0000000 --- a/src/gui.py +++ /dev/null @@ -1,386 +0,0 @@ -"""Main application GUI using CustomTkinter.""" -import customtkinter as ctk -from tkinter import messagebox -import webbrowser - -from mongodb import MongoDBClient -from omniboard import OmniboardManager - -# Set appearance mode and color theme -ctk.set_appearance_mode("dark") - - -class MongoApp(ctk.CTk): - """Main application window for MongoDB Database Selector and Omniboard Launcher.""" - - def __init__(self): - """Initialize the main application window.""" - super().__init__() - self.title("MongoDB Database Selector") - self.geometry("550x700") - self.resizable(False, False) - - # Hide window initially to allow background loading - self.withdraw() - - # Initialize backend managers - self.mongo_client = MongoDBClient() - self.omniboard_manager = OmniboardManager() - - # UI state variables - self.port_var = ctk.StringVar(value="27017") - self.mongo_url_var = ctk.StringVar(value="mongodb://localhost:27017/") - self.connection_mode = ctk.StringVar(value="Port") - self.db_list = [] - self.selected_db = ctk.StringVar() - - # Configure grid weight - self.grid_columnconfigure(0, weight=1) - - # Initialize UI components - self._create_title() - self._create_connection_frame() - self._create_database_frame() - self._create_omniboard_frame() - - # Show window after 2 second delay - self.after(2000, self.deiconify) - - def _create_title(self): - """Create the title label.""" - title_label = ctk.CTkLabel( - self, - text="MongoDB & Omniboard Launcher", - font=ctk.CTkFont(size=20, weight="bold") - ) - title_label.grid(row=0, column=0, padx=20, pady=(10, 5), sticky="ew") - - def _create_connection_frame(self): - """Create the connection configuration frame.""" - self.connection_frame = ctk.CTkFrame(self) - self.connection_frame.grid(row=1, column=0, padx=20, pady=5, sticky="ew") - self.connection_frame.grid_columnconfigure(1, weight=1) - - ctk.CTkLabel( - self.connection_frame, - text="Connect to MongoDB", - font=ctk.CTkFont(size=14, weight="bold") - ).grid(row=0, column=0, columnspan=2, padx=10, pady=(5, 2), sticky="w") - - # Connection mode selector - self.mode_selector = ctk.CTkSegmentedButton( - self.connection_frame, - values=["Port", "Full URL"], - variable=self.connection_mode, - command=self.on_connection_mode_change - ) - self.mode_selector.grid(row=1, column=0, columnspan=2, padx=10, pady=2, sticky="ew") - - # Port entry - self.port_label = ctk.CTkLabel(self.connection_frame, text="Port:") - self.port_label.grid(row=2, column=0, padx=(10, 5), pady=5, sticky="w") - self.port_entry = ctk.CTkEntry( - self.connection_frame, - textvariable=self.port_var, - width=100, - placeholder_text="27017" - ) - 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:") - self.url_entry = ctk.CTkEntry( - self.connection_frame, - textvariable=self.mongo_url_var, - placeholder_text="mongodb://localhost:27017/", - width=300 - ) - self.url_label.grid_remove() - self.url_entry.grid_remove() - - # Connect button - self.connect_btn = ctk.CTkButton( - self.connection_frame, - text="Connect to MongoDB", - command=self.connect, - 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") - - def _create_database_frame(self): - """Create the database selection frame.""" - self.db_frame = ctk.CTkFrame(self) - self.db_frame.grid(row=2, column=0, padx=20, pady=5, sticky="nsew") - self.db_frame.grid_columnconfigure(0, weight=1) - self.grid_rowconfigure(2, weight=1) - - ctk.CTkLabel( - self.db_frame, - text="Available Databases", - font=ctk.CTkFont(size=14, weight="bold") - ).grid(row=0, column=0, padx=10, pady=(5, 2), sticky="w") - - # Scrollable frame for databases - self.db_scrollable_frame = ctk.CTkScrollableFrame( - self.db_frame, - height=300, - fg_color=("gray95", "gray20") - ) - self.db_scrollable_frame.grid(row=1, column=0, padx=10, pady=2, sticky="nsew") - self.db_frame.grid_rowconfigure(1, weight=1) - - self.db_labels = [] - self.selected_db_label = None - - # Selected database label - self.selected_label = ctk.CTkLabel( - self.db_frame, - text="No database selected", - font=ctk.CTkFont(size=12), - text_color="gray70" - ) - self.selected_label.grid(row=2, column=0, padx=10, pady=5, sticky="ew") - - def _create_omniboard_frame(self): - """Create the Omniboard control frame.""" - self.omniboard_frame = ctk.CTkFrame(self) - self.omniboard_frame.grid(row=3, column=0, padx=20, pady=5, sticky="ew") - self.omniboard_frame.grid_columnconfigure(0, weight=1) - - # Launch Omniboard button - self.launch_btn = ctk.CTkButton( - self.omniboard_frame, - text="Launch Omniboard", - command=self.launch_omniboard, - state="disabled", - height=32, - font=ctk.CTkFont(size=13, weight="bold"), - fg_color="#1f6aa5", - hover_color="#144870" - ) - self.launch_btn.grid(row=0, column=0, padx=10, pady=(5, 3), sticky="ew") - - # Clear Docker containers button - self.clear_docker_btn = ctk.CTkButton( - self.omniboard_frame, - text="Clear All Omniboard Containers", - command=self.clear_omniboard_docker, - height=28, - font=ctk.CTkFont(size=12), - fg_color="#8B0000", - hover_color="#660000" - ) - self.clear_docker_btn.grid(row=1, column=0, padx=10, pady=3, sticky="ew") - - # Label for Omniboard URLs - ctk.CTkLabel( - self.omniboard_frame, - text="Omniboard URLs:", - font=ctk.CTkFont(size=11, weight="bold"), - anchor="w" - ).grid(row=2, column=0, padx=10, pady=(5, 0), sticky="w") - - # Omniboard info textbox (for clickable links) - self.omniboard_info_text = ctk.CTkTextbox( - self.omniboard_frame, - height=80, - wrap="word", - font=ctk.CTkFont(size=11) - ) - self.omniboard_info_text.grid(row=3, column=0, padx=10, pady=(2, 5), sticky="ew") - self.omniboard_info_text.configure(state="disabled") - - # Configure link tag for blue, underlined, clickable text - self.omniboard_info_text.tag_config("link", foreground="#1f6aa5", underline=True) - self.omniboard_info_text.tag_bind("link", "", self.on_link_click) - self.omniboard_info_text.tag_bind("link", "", - lambda e: self.omniboard_info_text.configure(cursor="hand2")) - self.omniboard_info_text.tag_bind("link", "", - lambda e: self.omniboard_info_text.configure(cursor="")) - - def on_connection_mode_change(self, value): - """Toggle between Port and Full URL input modes.""" - 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.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") - - def connect(self): - """Connect to MongoDB and list available databases.""" - # Clear previous database labels - for label in self.db_labels: - label.destroy() - self.db_labels.clear() - self.selected_db_label = None - - self.selected_label.configure(text="Connecting...") - - try: - # Connect based on mode - if self.connection_mode.get() == "Port": - port = self.port_var.get() or "27017" - dbs = self.mongo_client.connect_by_port(port) - else: - url = self.mongo_url_var.get().strip() - if not url: - messagebox.showerror("Error", "Please provide a valid MongoDB URL.") - self.selected_label.configure(text="Connection failed") - return - dbs = self.mongo_client.connect_by_url(url) - - self.db_list = dbs - - if not dbs: - self.selected_label.configure(text="No databases found") - no_db_label = ctk.CTkLabel( - self.db_scrollable_frame, - text="No databases found", - text_color="gray60" - ) - no_db_label.pack(pady=10) - self.db_labels.append(no_db_label) - else: - for db in dbs: - label = ctk.CTkLabel( - self.db_scrollable_frame, - text=db, - height=22, - font=ctk.CTkFont(size=13), - cursor="hand2", - anchor="w", - padx=10, - fg_color="transparent" - ) - label.pack(pady=0, padx=5, fill="x") - label.bind("", lambda e, d=db: self.select_database(d)) - label.bind("", lambda e, l=label: l.configure(fg_color=("gray85", "gray30"))) - label.bind("", lambda e, l=label: l.configure(fg_color="transparent") - if l != self.selected_db_label else None) - self.db_labels.append(label) - - self.selected_label.configure(text="Please select a database") - except Exception as e: - error_msg = str(e) - - # Provide friendlier error messages - if "ServerSelectionTimeoutError" in error_msg or "timed out" in error_msg.lower(): - friendly_msg = ( - "Cannot connect to MongoDB server.\n\n" - "Please ensure:\n" - "• MongoDB is running on the specified port\n" - "• The port number is correct\n" - "• Your firewall allows the connection" - ) - elif "connection refused" in error_msg.lower(): - friendly_msg = ( - "Connection refused by MongoDB server.\n\n" - "MongoDB may not be running on this port.\n" - "Please start MongoDB or verify the port number." - ) - elif "authentication failed" in error_msg.lower(): - friendly_msg = ( - "MongoDB authentication failed.\n\n" - "Please check your username and password in the connection URL." - ) - else: - friendly_msg = f"Connection Error:\n\n{error_msg}" - - messagebox.showerror("MongoDB Connection Failed", friendly_msg) - self.selected_label.configure(text="Connection failed") - - def select_database(self, db_name): - """Select a database and enable the launch button.""" - self.selected_db.set(db_name) - self.selected_label.configure( - text=f"Selected: {db_name}", - text_color=("#1f6aa5", "#5fb4ff") - ) - self.launch_btn.configure(state="normal") - - # Update label appearance to show selection - for label in self.db_labels: - if isinstance(label, ctk.CTkLabel) and label.cget("text") == db_name: - label.configure(fg_color=("#1f6aa5", "#1f6aa5"), text_color="white") - self.selected_db_label = label - elif isinstance(label, ctk.CTkLabel) and label.cget("text") != "No databases found": - label.configure(fg_color="transparent", text_color=("black", "white")) - - def launch_omniboard(self): - """Launch Omniboard in a Docker container for the selected database.""" - db_name = self.selected_db.get() - if not db_name: - messagebox.showerror("Error", "No database selected.") - return - - 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 - ) - - 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)) - - def clear_omniboard_docker(self): - """Remove all Omniboard Docker containers.""" - try: - count = self.omniboard_manager.clear_all_containers() - - if count == 0: - messagebox.showinfo("Docker", "No Omniboard containers to remove.") - else: - messagebox.showinfo("Docker", f"Removed {count} Omniboard container(s).") - - # Clear the info textbox - self.omniboard_info_text.configure(state="normal") - self.omniboard_info_text.delete("1.0", "end") - self.omniboard_info_text.configure(state="disabled") - except Exception as e: - messagebox.showerror("Docker Error", str(e)) - - def on_link_click(self, event): - """Handle clicks on hyperlinks in the textbox.""" - try: - index = self.omniboard_info_text.index(f"@{event.x},{event.y}") - tags = self.omniboard_info_text.tag_names(index) - - for tag in tags: - if tag.startswith('url_'): - url = tag[4:] - webbrowser.open(url) - break - except Exception: - pass diff --git a/src/layout.py b/src/layout.py new file mode 100644 index 0000000..938df74 --- /dev/null +++ b/src/layout.py @@ -0,0 +1,215 @@ +"""Dash layout components for AltarViewer.""" +import os +from dash import html, dcc +import dash_bootstrap_components as dbc + + +def build_layout(): + """Build the main application layout.""" + return dbc.Container( + [ + # Storage components + dcc.Store(id="connection-store", storage_type="session"), + dcc.Store(id="databases-store", storage_type="session"), + dcc.Store(id="selected-db-store", storage_type="session"), + dcc.Store(id="launched-containers-store", storage_type="session", data=[]), + + # Navbar with Altar branding + dbc.Navbar( + dbc.Container( + [ + html.Div( + [ + dbc.NavbarBrand( + "AltarViewer", + class_name="mb-0 h4", + style={"fontWeight": "600"} + ), + html.Span( + " Omniboard Launcher", + style={"color": "var(--muted)", "marginLeft": "0.5rem"} + ), + ], + style={"display": "flex", "alignItems": "center"} + ), + ], + fluid=True, + ), + dark=True, + sticky="top", + class_name="mb-4", + style={"background": "var(--panel)", "borderBottom": "1px solid var(--stroke)"} + ), + + # Connection Section + build_connection_section(), + + # Database Selection Section + build_database_section(), + + # Omniboard Control Section + build_omniboard_section(), + + ], + fluid=False, + style={"maxWidth": "800px"}, + ) + + +def build_connection_section(): + """Build MongoDB connection configuration section.""" + # Default Mongo URL can be overridden via environment (e.g. Docker) + default_mongo_url = os.environ.get("MONGO_DEFAULT_URL", "mongodb://localhost:27017/") + return dbc.Card( + dbc.CardBody( + [ + html.H5("Connect to MongoDB", className="card-title mb-2", style={"fontSize": "0.95rem"}), + + # Connection mode selector + dbc.RadioItems( + id="connection-mode", + options=[ + {"label": "Connect by Port", "value": "port"}, + {"label": "Connect by Full URL", "value": "url"}, + ], + value="port", + inline=True, + className="mb-2", + style={"fontSize": "13px"}, + ), + + # Port input (visible by default) + html.Div( + id="port-input-container", + children=[ + dbc.Label("MongoDB Port:"), + dbc.Input( + id="port-input", + type="text", + placeholder="27017", + value="27017", + ), + ], + className="mb-3", + ), + + # URL input (hidden by default) + html.Div( + id="url-input-container", + children=[ + dbc.Label("MongoDB URL:"), + dbc.Input( + id="url-input", + type="text", + placeholder=default_mongo_url, + value=default_mongo_url, + ), + ], + className="mb-3", + style={"display": "none"}, + ), + + # Connect button + dbc.Button( + "Connect to MongoDB", + id="connect-btn", + color="primary", + className="w-100", + ), + + # Connection status + html.Div(id="connection-status", className="mt-2"), + ] + ), + className="mb-3", + ) + + +def build_database_section(): + """Build database selection section.""" + return dbc.Card( + dbc.CardBody( + [ + html.H5("Available Databases", className="card-title mb-2", style={"fontSize": "0.95rem"}), + + # Database list container - white background, 2 columns + html.Div( + id="database-list-container", + children=[ + html.P( + "Connect to MongoDB to see available databases", + className="text-muted", + style={"padding": "1rem", "fontSize": "13px"}, + ) + ], + style={ + "maxHeight": "280px", + "overflowY": "auto", + "padding": "0.75rem", + "background": "white", + "borderRadius": "6px", + "display": "grid", + "gridTemplateColumns": "1fr 1fr", + "gap": "0.25rem", + }, + ), + + # Selected database display + html.Div(id="selected-database", className="mt-2", style={"fontSize": "13px"}), + ] + ), + className="mb-3", + ) + + +def build_omniboard_section(): + """Build Omniboard control section.""" + return dbc.Card( + dbc.CardBody( + [ + html.H5("Omniboard Controls", className="card-title mb-2", style={"fontSize": "0.95rem"}), + + # Launch button + dbc.Button( + "Launch Omniboard for Selected Database", + id="launch-btn", + color="primary", + className="w-100 mb-2", + disabled=True, + ), + + # Clear containers button + dbc.Button( + "Clear All Omniboard Containers", + id="clear-btn", + color="warning", + className="w-100 mb-3", + outline=True, + ), + + # Launched instances display (no extra loading spinner; Omniboard + # startup time is left to the user) + html.Div( + id="launched-instances", + children=[ + html.P( + "No Omniboard instances launched yet", + className="text-muted text-center mb-0", + ) + ], + style={ + "border": "1px solid #dee2e6", + "borderRadius": "0.25rem", + "padding": "1rem", + "minHeight": "100px", + }, + ), + html.Small( + "Note: Omniboard may take a few seconds to start after launch. " + "If the page opens blank, wait a bit and refresh.", + className="text-muted d-block mt-1", + style={"fontSize": "11px"}, + ), + ] + ), + ) diff --git a/src/main.py b/src/main.py index 7fb2d33..c51b7c9 100644 --- a/src/main.py +++ b/src/main.py @@ -1,11 +1,46 @@ """Main entry point for Omniboard Launcher application.""" -from gui import MongoApp +import os +import sys +import webbrowser +import threading +import time +from app import create_dash_app + + +def is_executable_mode(): + """Check if running as a PyInstaller executable.""" + return getattr(sys, 'frozen', False) and hasattr(sys, '_MEIPASS') + + +def open_browser(url, delay=2): + """Open browser after a short delay.""" + time.sleep(delay) + webbrowser.open(url) def main(): - """Launch the Omniboard application.""" - app = MongoApp() - app.mainloop() + """Launch the Omniboard application in appropriate mode.""" + app = create_dash_app() + + port = int(os.environ.get("PORT", 8060)) + + if is_executable_mode(): + # Desktop executable mode: Auto-open browser + print(f"Starting AltarViewer on http://localhost:{port}") + print("Opening browser...") + + # Open browser in background thread + threading.Thread(target=open_browser, args=(f"http://localhost:{port}",), daemon=True).start() + + # Run server + app.run(debug=False, port=port, host="127.0.0.1") + else: + # Docker/development mode: Listen on all interfaces + host = "0.0.0.0" if os.environ.get("DOCKER_MODE") else "127.0.0.1" + debug = os.environ.get("DEBUG", "false").lower() == "true" + + print(f"Starting AltarViewer on http://localhost:{port}") + app.run(debug=debug, port=port, host=host) if __name__ == "__main__": diff --git a/src/mongodb.py b/src/mongodb.py index d182dcf..bf30a2f 100644 --- a/src/mongodb.py +++ b/src/mongodb.py @@ -1,4 +1,5 @@ """MongoDB client management.""" +import os from pymongo import MongoClient from typing import List, Optional from urllib.parse import urlparse @@ -13,7 +14,7 @@ def __init__(self): self.uri: Optional[str] = None def connect_by_port(self, port: str = "27017") -> List[str]: - """Connect to MongoDB using localhost and port. + """Connect to MongoDB using default host and port. Args: port: MongoDB port number @@ -24,7 +25,8 @@ def connect_by_port(self, port: str = "27017") -> List[str]: Raises: Exception: If connection fails """ - self.uri = f"mongodb://localhost:{port}/" + default_host = os.environ.get("MONGO_DEFAULT_HOST", "localhost") + self.uri = f"mongodb://{default_host}:{port}/" return self._connect() def connect_by_url(self, url: str) -> List[str]: diff --git a/src/omniboard.py b/src/omniboard.py index eb0291a..17b3404 100644 --- a/src/omniboard.py +++ b/src/omniboard.py @@ -5,12 +5,31 @@ import uuid import sys import time +import os from typing import List, Optional class OmniboardManager: """Manages Omniboard Docker containers.""" + @staticmethod + def is_running_in_docker() -> bool: + """Check if the application is running inside a Docker container. + + Returns: + True if running in Docker, False otherwise + """ + # Check for Docker environment indicators + if os.path.exists('/.dockerenv'): + return True + if os.environ.get('DOCKER_MODE') == 'true': + return True + try: + with open('/proc/1/cgroup', 'rt') as f: + return 'docker' in f.read() + except: + return False + @staticmethod def is_docker_running() -> bool: """Check if Docker daemon is running. @@ -19,10 +38,12 @@ def is_docker_running() -> bool: True if Docker is running, False otherwise """ try: + creationflags = getattr(subprocess, "CREATE_NO_WINDOW", 0) result = subprocess.run( ["docker", "info"], capture_output=True, - timeout=5 + timeout=5, + creationflags=creationflags, ) return result.returncode == 0 except (subprocess.TimeoutExpired, FileNotFoundError): @@ -37,10 +58,12 @@ def start_docker_desktop(): """ if sys.platform.startswith("win"): # Windows + creationflags = getattr(subprocess, "CREATE_NO_WINDOW", 0) subprocess.Popen( ["powershell", "-Command", "Start-Process", "'C:\\Program Files\\Docker\\Docker\\Docker Desktop.exe'"], stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL + stderr=subprocess.DEVNULL, + creationflags=creationflags, ) elif sys.platform == "darwin": # macOS @@ -69,11 +92,25 @@ def start_docker_desktop(): def ensure_docker_running(): """Ensure Docker is running, start it if needed. + Skips check if running inside Docker (container mode). + Raises: Exception: If Docker cannot be started """ + # Skip Docker checks if we're running inside a Docker container + if OmniboardManager.is_running_in_docker(): + return + + # For desktop usage we no longer try to auto-start Docker Desktop or + # poll repeatedly, as that caused a poor UX (flashing Docker console + # windows and long waits). Instead we simply check once and, if Docker + # is not available, raise a clear error so the UI can display a + # helpful message to the user. if not OmniboardManager.is_docker_running(): - OmniboardManager.start_docker_desktop() + raise RuntimeError( + "Docker does not appear to be running. Please start Docker Desktop " + "(or the Docker daemon) and try again." + ) @staticmethod def generate_port_for_database(db_name: str, base: int = 20000, span: int = 10000) -> int: @@ -107,11 +144,13 @@ def find_available_port(start_port: int) -> int: s.bind(("", port)) # Also check if Docker is using this port try: + creationflags = getattr(subprocess, "CREATE_NO_WINDOW", 0) result = subprocess.run( ["docker", "ps", "--filter", f"publish={port}", "--format", "{{.ID}}"], capture_output=True, text=True, - timeout=5 + timeout=5, + creationflags=creationflags, ) if result.stdout.strip() == "": return port @@ -172,15 +211,22 @@ def launch( container_name = f"omniboard_{uuid.uuid4().hex[:8]}" docker_cmd = [ - "docker", "run", "-it", "--rm", + "docker", "run", "-d", "--rm", "-p", f"{host_port}:9000", "--name", container_name, "vivekratnavel/omniboard", "-m", mongo_arg ] - # Launch container - subprocess.Popen(docker_cmd) + # Launch container in detached mode + creationflags = getattr(subprocess, "CREATE_NO_WINDOW", 0) + subprocess.Popen( + docker_cmd, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + stdin=subprocess.DEVNULL, + creationflags=creationflags, + ) return container_name, host_port @@ -192,12 +238,14 @@ def list_containers() -> List[str]: List of container IDs """ try: + creationflags = getattr(subprocess, "CREATE_NO_WINDOW", 0) result = subprocess.run( 'docker ps -a --filter "name=omniboard_" --format "{{.ID}}"', shell=True, capture_output=True, text=True, - timeout=10 + timeout=10, + creationflags=creationflags, ) return result.stdout.strip().splitlines() except (subprocess.TimeoutExpired, FileNotFoundError): @@ -216,10 +264,12 @@ def clear_all_containers(self) -> int: for cid in container_ids: try: + creationflags = getattr(subprocess, "CREATE_NO_WINDOW", 0) subprocess.run( f"docker rm -f {cid}", shell=True, - timeout=10 + timeout=10, + creationflags=creationflags, ) except (subprocess.TimeoutExpired, FileNotFoundError): pass diff --git a/tests/conftest.py b/tests/conftest.py index 828e9fd..fabad29 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,8 +1,14 @@ -"""Test configuration and fixtures.""" +"""Test configuration and fixtures. + +Ensure the project root (which contains the ``src`` package) is on ``sys.path`` +so imports like ``from src.mongodb import MongoDBClient`` work regardless of +where pytest is invoked from. +""" import pytest 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)) + +project_root = Path(__file__).parent.parent +if str(project_root) not in sys.path: + sys.path.insert(0, str(project_root)) diff --git a/tests/test_integration.py b/tests/test_integration.py index 7d6b337..18bf063 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() From 529d40f71a6bc5932ec254d3c132f56d9e9be76f Mon Sep 17 00:00:00 2001 From: Alienor134 Date: Thu, 8 Jan 2026 22:28:02 +0100 Subject: [PATCH 06/22] add web_app branch to workflow --- .github/workflows/build.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 9f42bf0..a3c5c0a 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -4,6 +4,7 @@ on: push: branches: - main + - web_app tags: - 'v*' paths-ignore: From 14c3310d5707a73e8bf38589dfdc0a39d0f41771 Mon Sep 17 00:00:00 2001 From: Alienor134 Date: Thu, 8 Jan 2026 22:30:43 +0100 Subject: [PATCH 07/22] missing Dockerfile --- Dockerfile | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 Dockerfile diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..6a7ef80 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,27 @@ +FROM python:3.11-slim + +# Install Docker CLI (needed to spawn Omniboard containers) +RUN apt-get update && \ + apt-get install -y \ + docker.io \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +# Copy requirements and install dependencies +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# Copy application source +COPY src/ ./src/ + +# Set environment variables +ENV PORT=8060 +ENV DOCKER_MODE=true +ENV PYTHONUNBUFFERED=1 + +# Expose the port +EXPOSE 8060 + +# Run the application +CMD ["python", "src/main.py"] From 33437f81bce9bf15bd3eb8bbb8a4b8ed793e6521 Mon Sep 17 00:00:00 2001 From: Alienor134 Date: Fri, 9 Jan 2026 09:39:55 +0100 Subject: [PATCH 08/22] Add Docker Hub image build and release docs --- .github/workflows/build.yml | 40 +++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index a3c5c0a..2dcb28c 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -121,6 +121,23 @@ jobs: run: | docker compose build + - name: Log in to Docker Hub + if: startsWith(github.ref, 'refs/tags/') + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Build and push Docker image to Docker Hub + if: startsWith(github.ref, 'refs/tags/') + env: + IMAGE_NAME: ${{ secrets.DOCKERHUB_USERNAME }}/altarviewer + run: | + TAG=${GITHUB_REF#refs/tags/} + docker build -t $IMAGE_NAME:$TAG -t $IMAGE_NAME:latest . + docker push $IMAGE_NAME:$TAG + docker push $IMAGE_NAME:latest + - name: Build executable run: | pyinstaller OmniboardLauncher.spec @@ -167,6 +184,29 @@ jobs: ./windows/OmniboardLauncher.exe ./macos/OmniboardLauncher-macOS ./linux/OmniboardLauncher-Linux + body: | + ## Downloads + + This release includes platform-specific launchers and a Docker image. + + ### Executables + - Windows: `OmniboardLauncher.exe` + - macOS: `OmniboardLauncher-macOS` + - Linux: `OmniboardLauncher-Linux` + + ### Docker image + + The Docker image is published to Docker Hub as: + + ```bash + docker pull ${{ secrets.DOCKERHUB_USERNAME }}/altarviewer:${{ github.ref_name }} + ``` + + Run it with: + + ```bash + docker run -p 8060:8060 ${{ secrets.DOCKERHUB_USERNAME }}/altarviewer:${{ github.ref_name }} + ``` draft: true prerelease: false env: From ccb1cfaaf37b8f771ebdf035db1ee419f4a7de3b Mon Sep 17 00:00:00 2001 From: Alienor134 Date: Fri, 9 Jan 2026 09:52:50 +0100 Subject: [PATCH 09/22] bug: typo in secret var name --- .github/workflows/build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 2dcb28c..1e783f9 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -126,7 +126,7 @@ jobs: uses: docker/login-action@v3 with: username: ${{ secrets.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_TOKEN }} + password: ${{ secrets.DOCKERHUB_SECRET }} - name: Build and push Docker image to Docker Hub if: startsWith(github.ref, 'refs/tags/') From 62bf8197ac5026e9c7f56cd4394f24e9a1378a65 Mon Sep 17 00:00:00 2001 From: Alienor134 Date: Fri, 9 Jan 2026 11:02:03 +0100 Subject: [PATCH 10/22] handle VM/Atlas --- src/callbacks.py | 17 +++++++++--- src/mongodb.py | 36 ++++++++++++++++++++++--- src/omniboard.py | 70 +++++++++++++++++++++++++++++++++++++++++++----- 3 files changed, 109 insertions(+), 14 deletions(-) diff --git a/src/callbacks.py b/src/callbacks.py index 9ccda57..fa5f0c3 100644 --- a/src/callbacks.py +++ b/src/callbacks.py @@ -225,10 +225,11 @@ def select_database(n_clicks_list, button_ids): Output("launched-instances", "children", allow_duplicate=True)], Input("launch-btn", "n_clicks"), [State("selected-db-store", "data"), - State("launched-containers-store", "data")], + State("launched-containers-store", "data"), + State("connection-store", "data")], prevent_initial_call=True ) - def launch_omniboard(n_clicks, selected_db, launched_containers): + def launch_omniboard(n_clicks, selected_db, launched_containers, connection_info): """Launch Omniboard container for selected database. """ if not n_clicks or not selected_db: @@ -238,11 +239,21 @@ def launch_omniboard(n_clicks, selected_db, launched_containers): # Get MongoDB connection details mongo_host, mongo_port, _ = mongo_client.parse_connection_url() + # For URL-based connections (e.g. Atlas / remote VM with + # authentication), reuse the full connection URI so that + # Omniboard can authenticate and honour any options. For the + # simple port-based mode we keep using host/port only so + # localhost mappings continue to work as before. + mongo_uri = None + if isinstance(connection_info, dict) and connection_info.get("mode") == "url": + mongo_uri = mongo_client.get_connection_uri() + # Launch Omniboard (non-blocking, container starts in background) container_name, host_port = omniboard_manager.launch( db_name=selected_db, mongo_host=mongo_host, - mongo_port=mongo_port + mongo_port=mongo_port, + mongo_uri=mongo_uri, ) # Add to launched containers list diff --git a/src/mongodb.py b/src/mongodb.py index bf30a2f..600e3d2 100644 --- a/src/mongodb.py +++ b/src/mongodb.py @@ -1,9 +1,11 @@ """MongoDB client management.""" import os -from pymongo import MongoClient from typing import List, Optional from urllib.parse import urlparse +from pymongo import MongoClient +from pymongo.errors import OperationFailure + class MongoDBClient: """Handles MongoDB connections and database operations.""" @@ -62,10 +64,36 @@ def _connect(self) -> List[str]: """ if self.client: 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 MongoDB deployments (Atlas / VM with non-admin users) + # do not allow the connected user to run the listDatabases + # command. In that case, fall back to the database specified in + # the connection URI (if any) instead of failing entirely. + message = str(exc).lower() + if "listdatabases" in message or "not authorized" in message: + _, _, database = self.parse_connection_url() + if database: + return [database] + # For all other failures, or if no database is encoded in the + # URI, propagate the original error so the UI can show it. + raise + + def get_connection_uri(self) -> Optional[str]: + """Return the current MongoDB connection URI, if any. + + This is used by downstream components (e.g. Omniboard launcher) + when they need to reuse the exact connection string, including + credentials and options. + """ + + return self.uri def parse_connection_url(self) -> tuple[str, int, Optional[str]]: """Parse the current connection URL. diff --git a/src/omniboard.py b/src/omniboard.py index 17b3404..70e2d30 100644 --- a/src/omniboard.py +++ b/src/omniboard.py @@ -8,6 +8,8 @@ import os from typing import List, Optional +from urllib.parse import urlparse, urlunparse + class OmniboardManager: """Manages Omniboard Docker containers.""" @@ -173,13 +175,60 @@ def adjust_mongo_host_for_docker(mongo_host: str) -> str: if mongo_host in ["localhost", "127.0.0.1"]: return "host.docker.internal" return mongo_host + + def _adjust_mongo_uri_for_docker(self, mongo_uri: str, db_name: Optional[str] = None) -> str: + """Adjust a full MongoDB URI for Docker networking when needed. + + When connecting to a local MongoDB instance from a container on + Windows/macOS, we need to replace ``localhost``/``127.0.0.1`` with + ``host.docker.internal``. This helper performs that substitution + while preserving user info, port and query parameters. If a + ``db_name`` is provided, it is injected as the path component of + the URI (``/``) so Omniboard connects to the selected + database, with any authentication or options remaining in the + query string. + """ + + try: + parsed = urlparse(mongo_uri) + except Exception: + # If parsing fails for any reason, fall back to the original + # URI rather than breaking Omniboard launch. + return mongo_uri + + host = parsed.hostname + if not host: + return mongo_uri + + adjusted_host = self.adjust_mongo_host_for_docker(host) + + # Rebuild netloc preserving credentials and port + netloc = "" + if parsed.username: + netloc += parsed.username + if parsed.password: + netloc += f":{parsed.password}" + netloc += "@" + + netloc += adjusted_host + if parsed.port: + netloc += f":{parsed.port}" + + # Inject selected database into the path, if provided + path = parsed.path + if db_name: + path = f"/{db_name}" + + new_parsed = parsed._replace(netloc=netloc, path=path) + return urlunparse(new_parsed) 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. @@ -203,11 +252,18 @@ 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}" + # Build Mongo connection argument for Omniboard. When a full + # MongoDB URI is available (typically for remote/Atlas-style + # deployments with authentication), reuse it so that credentials + # and options are preserved. Otherwise, fall back to the legacy + # host:port:db form for simple local setups. + if mongo_uri: + mongo_arg = self._adjust_mongo_uri_for_docker(mongo_uri, db_name=db_name) + mongo_flag = "--mu" # Omniboard expects full URIs with --mu + else: + docker_mongo_host = self.adjust_mongo_host_for_docker(mongo_host) + mongo_arg = f"{docker_mongo_host}:{mongo_port}:{db_name}" + mongo_flag = "-m" # host:port:database form container_name = f"omniboard_{uuid.uuid4().hex[:8]}" docker_cmd = [ @@ -215,7 +271,7 @@ def launch( "-p", f"{host_port}:9000", "--name", container_name, "vivekratnavel/omniboard", - "-m", mongo_arg + mongo_flag, mongo_arg, ] # Launch container in detached mode From aef4e4fcc627aa45127bcdb5575812ca9173b316 Mon Sep 17 00:00:00 2001 From: Alienor134 Date: Fri, 9 Jan 2026 11:41:38 +0100 Subject: [PATCH 11/22] docs: improve release notes with Docker usage instructions - Document how to pull the Docker image from Docker Hub: docker pull alienor134/altarviewer:v1.0.1 - Document how to run the container with docker.sock mounted and Mongo URL: 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:v1.0.1 --- .github/workflows/build.yml | 24 +++++++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 1e783f9..d1ef9af 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -190,23 +190,41 @@ jobs: This release includes platform-specific launchers and a Docker image. ### Executables + + Use these when running Omniboard Launcher directly on your machine: + - Windows: `OmniboardLauncher.exe` - macOS: `OmniboardLauncher-macOS` - Linux: `OmniboardLauncher-Linux` ### Docker image - The Docker image is published to Docker Hub as: + The Docker image is published on Docker Hub as: ```bash docker pull ${{ secrets.DOCKERHUB_USERNAME }}/altarviewer:${{ github.ref_name }} ``` - Run it with: + Run it with Docker so that the container can start Omniboard instances via the host Docker daemon: ```bash - docker run -p 8060:8060 ${{ secrets.DOCKERHUB_USERNAME }}/altarviewer:${{ github.ref_name }} + docker run -d \ + -p 8060:8060 \ + -v /var/run/docker.sock:/var/run/docker.sock \ + -e MONGO_DEFAULT_URL="mongodb://:27017/" \ + --name altarviewer \ + ${{ secrets.DOCKERHUB_USERNAME }}/altarviewer:${{ github.ref_name }} ``` + + #### Choosing `` + + Set `` depending on where MongoDB is reachable *from inside the container*: + + - `host.docker.internal` – when MongoDB runs on the same machine as Docker (Docker Desktop on Windows/macOS). + - `localhost` – when MongoDB runs on the same Linux host where the container is running, and port `27017` is bound on the host. + - A hostname or IP (for example `my-mongo.internal` or `10.0.0.5`) – when MongoDB is on another server or a managed service reachable over the network. + + > Tip: If you use a MongoDB Atlas or other cloud URI, you can leave `MONGO_DEFAULT_URL` as-is and paste your full connection string in the **"MongoDB URL"** field inside the app instead. draft: true prerelease: false env: From 73cbf88aafa28cc6cc5fa01b00722f8a0eeb2f29 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ali=C3=A9nor=20Lahlou?= Date: Fri, 9 Jan 2026 15:12:47 +0100 Subject: [PATCH 12/22] Fix: change image path to absolute url Temporary fix for Altar github pages documentation which includes the submodule readme files --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 1440bd4..837e080 100644 --- a/README.md +++ b/README.md @@ -7,8 +7,8 @@ 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.
- - + +
## Table of Contents From 4a6abf3d61ee2ce50b744fb37fe4850f1372ff89 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ali=C3=A9nor=20Lahlou?= Date: Fri, 9 Jan 2026 15:40:20 +0100 Subject: [PATCH 13/22] docs: inculde the docker and migration to web app --- README.md | 94 ++++++++++++++++++++++++++----------------------------- 1 file changed, 44 insertions(+), 50 deletions(-) diff --git a/README.md b/README.md index 837e080..b8a8e03 100644 --- a/README.md +++ b/README.md @@ -31,12 +31,13 @@ A graphical user interface application for launching and managing [Omniboard](ht - [Versioning](#versioning) - [License](#license) +docker --version ## Features -- **MongoDB Connection Management**: Connect to local or remote MongoDB instances -- **Database Discovery**: Automatically list all available databases +- **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 -- **Modern GUI**: Built with CustomTkinter for a clean, modern interface +- **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 @@ -44,78 +45,71 @@ A graphical user interface application for launching and managing [Omniboard](ht ## Installation -### Prerequisites +You can use any of these options: + +### Option 1 – Executable -#### System Requirements -- **Operating System**: Windows 10/11, macOS 10.14+, or Linux -- **Python**: 3.8 or higher -- **Docker Desktop**: Latest version ([Download](https://www.docker.com/products/docker-desktop/)) -- **MongoDB**: Running instance (local or remote) +Download the latest platform-specific launcher from the [AltarViewer releases](https://github.com/DreamRepo/AltarViewer/releases) and run it. + +### Option 2 – Python app -#### Verify Prerequisites ```bash -# Check Python version -python --version # Should be 3.8+ +git clone https://github.com/DreamRepo/Altar.git +cd Altar/AltarViewer +python -m venv venv -# Check Docker is running -docker --version -docker ps +# Activate the venv (one of these) +venv\Scripts\activate # Windows +source venv/bin/activate # macOS/Linux -# Check MongoDB is accessible -mongosh --version # or mongo --version +pip install -r requirements.txt +python -m src.main ``` -### From Source +### Option 3 – Docker image -1. **Clone the repository** - ```bash - git clone https://github.com/DreamRepo/Altar.git - cd Altar/AltarViewer - ``` +```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 +``` -2. **Create a virtual environment** (recommended) - ```bash - python -m venv venv - - # On Windows - venv\Scripts\activate - - # On macOS/Linux - source venv/bin/activate - ``` +Replace `` with the host where MongoDB is reachable from the container (for example `host.docker.internal` on Docker Desktop). -3. **Install dependencies** - ```bash - pip install -r requirements.txt - ``` +### Option 4 – Docker Compose (AltarDocker stack) -4. **Run the application** - ```bash - python src/main.py - ``` +Use AltarDocker to spin up MongoDB and MinIO, then point AltarViewer to that MongoDB: -### From Binary Release +```bash +git clone https://github.com/DreamRepo/AltarDocker.git +cd AltarDocker +docker compose -f docker-compose_default.yml up -d +``` -Download the latest executable from the [releases page](https://github.com/Alienor134/launch_omniboard/releases) and run directly. No Python installation required. +Then start AltarViewer via option 1–3 and connect it to the MongoDB instance from the stack (for example `mongodb://localhost:27017`). ## Usage ### Quick Start -1. **Launch the application** - ```bash - python src/main.py - ``` +1. **Launch the application** (using any install option above) + +2. **Open the web app** + - In your browser, go to [http://localhost:8060](http://localhost:8060) to reach the AltarViewer UI. -2. **Connect to MongoDB** +3. **Connect to MongoDB** - Enter your MongoDB host and port (default: `localhost:27017`) - Click "Connect" to list available databases -3. **Select a database** +4. **Select a database** - Choose a database from the dropdown list - Click "Launch Omniboard" -4. **Access Omniboard** +5. **Access Omniboard** - A clickable link will appear in the interface - Omniboard opens automatically in your default browser From 7a123dd13c4bc550cb2d9e2bb26960ad1a0b0fee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ali=C3=A9nor=20Lahlou?= Date: Wed, 14 Jan 2026 14:46:59 +0100 Subject: [PATCH 14/22] handle mongo URI --- src/gui.py | 37 ++++++++++++++-------- src/mongodb.py | 22 +++++++++++-- src/omniboard.py | 78 +++++++++++++++++++++++++++++++++++++++++------ tests/conftest.py | 6 ++-- 4 files changed, 116 insertions(+), 27 deletions(-) diff --git a/src/gui.py b/src/gui.py index dd015be..78ee10a 100644 --- a/src/gui.py +++ b/src/gui.py @@ -3,8 +3,13 @@ from tkinter import messagebox import webbrowser -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 +except ImportError: + from mongodb import MongoDBClient + from omniboard import OmniboardManager # Set appearance mode and color theme ctk.set_appearance_mode("dark") @@ -70,7 +75,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"], variable=self.connection_mode, command=self.on_connection_mode_change ) @@ -87,8 +92,8 @@ 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, @@ -200,13 +205,13 @@ 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 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 + else: # 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") @@ -230,7 +235,7 @@ def connect(self): else: 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) @@ -322,12 +327,20 @@ def launch_omniboard(self): try: # Get MongoDB connection details mongo_host, mongo_port, _ = self.mongo_client.parse_connection_url() - + # When using a full URI, reuse it so Omniboard can authenticate and + # honour any URI options. We'll inject the selected DB downstream. + mongo_uri = None + if self.connection_mode.get() == "Full URI": + # Access the current connection URI if available + if hasattr(self.mongo_client, "get_connection_uri"): + mongo_uri = self.mongo_client.get_connection_uri() + # Launch Omniboard container_name, host_port = self.omniboard_manager.launch( db_name=db_name, mongo_host=mongo_host, - mongo_port=mongo_port + mongo_port=mongo_port, + mongo_uri=mongo_uri, ) url = f"http://localhost:{host_port}" @@ -349,8 +362,8 @@ def launch_omniboard(self): 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)) + # Open in browser after a short delay to allow Omniboard to fully start + self.after(6000, lambda: webbrowser.open(url)) except Exception as e: messagebox.showerror("Launch Error", str(e)) diff --git a/src/mongodb.py b/src/mongodb.py index d182dcf..d49b00b 100644 --- a/src/mongodb.py +++ b/src/mongodb.py @@ -1,5 +1,6 @@ """MongoDB client management.""" from pymongo import MongoClient +from pymongo.errors import OperationFailure from typing import List, Optional from urllib.parse import urlparse @@ -62,8 +63,21 @@ 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] + # Otherwise, re-raise the original error + raise def parse_connection_url(self) -> tuple[str, int, Optional[str]]: """Parse the current connection URL. @@ -82,6 +96,10 @@ 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: diff --git a/src/omniboard.py b/src/omniboard.py index eb0291a..ed025b5 100644 --- a/src/omniboard.py +++ b/src/omniboard.py @@ -6,6 +6,7 @@ import sys import time from typing import List, Optional +from urllib.parse import urlparse, urlunparse class OmniboardManager: @@ -140,7 +141,8 @@ def launch( 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 +151,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 +169,35 @@ 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]}" - + + # 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: + # Adjust host for Docker networking + docker_mongo_host = self.adjust_mongo_host_for_docker(mongo_host) + mongo_arg = f"{docker_mongo_host}:{mongo_port}:{db_name}" + mongo_flag = "-m" + + # Build Docker command (detached) docker_cmd = [ - "docker", "run", "-it", "--rm", + "docker", "run", "-d", "--rm", "-p", f"{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 @@ -225,3 +242,44 @@ def clear_all_containers(self) -> int: pass return len(container_ids) + + def _adjust_mongo_uri_for_docker(self, mongo_uri: str, db_name: Optional[str] = None) -> str: + """Adjust a full MongoDB URI for Docker networking and inject DB name. + + - On Windows/macOS, replace localhost/127.0.0.1 with host.docker.internal + - Ensure the selected database is present in the URI path + - Preserve credentials and query parameters + """ + try: + parsed = urlparse(mongo_uri) + except Exception: + # If parsing fails, return original URI + return mongo_uri + + host = parsed.hostname or "" + adjusted_host = self.adjust_mongo_host_for_docker(host) + + # Reconstruct netloc with potential creds and port + 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}{adjusted_host}{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 diff --git a/tests/conftest.py b/tests/conftest.py index 828e9fd..36cb2b8 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)) From 9fdb52295f07f24dbaf11baa8a7d928bb1c659e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ali=C3=A9nor=20Lahlou?= Date: Wed, 14 Jan 2026 15:00:39 +0100 Subject: [PATCH 15/22] update readme, split workflow --- .github/workflows/build.yml | 137 ++----------------------------- .github/workflows/release.yml | 149 ++++++++++++++++++++++++++++++++++ README.md | 34 +++++++- 3 files changed, 186 insertions(+), 134 deletions(-) create mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 922043f..58a20ba 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,106 +1,33 @@ -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 - with: - token: ${{ secrets.GITHUB_TOKEN }} - - - 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 - with: - token: ${{ secrets.GITHUB_TOKEN }} - - - 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: + test: runs-on: ubuntu-latest steps: - name: Checkout code uses: actions/checkout@v4 - with: - token: ${{ secrets.GITHUB_TOKEN }} - name: Set up Python uses: actions/setup-python@v5 @@ -112,59 +39,7 @@ jobs: 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/README.md b/README.md index 1440bd4..20cee3c 100644 --- a/README.md +++ b/README.md @@ -108,7 +108,10 @@ Download the latest executable from the [releases page](https://github.com/Alien ``` 2. **Connect to MongoDB** - - Enter your MongoDB host and port (default: `localhost:27017`) + - 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) + - Example: `mongodb+srv://user:pass@my-cluster.mongodb.net/?retryWrites=true&w=majority` - Click "Connect" to list available databases 3. **Select a database** @@ -122,9 +125,14 @@ Download the latest executable from the [releases page](https://github.com/Alien ### Configuration #### MongoDB Connection +- **Connection Modes**: + - Port: quick local development; launches Omniboard with `-m host:port:database` + - 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"` - **Default Port**: 27017 -- **Connection String**: Supports standard MongoDB URIs -- **Authentication**: Configure in MongoDB settings (currently local connections) +- **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) @@ -302,6 +310,26 @@ We welcome contributions! Please follow these guidelines: - Ensure port 9005+ are not in use by other applications - Try clearing old containers: Use the cleanup button in the app +### 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 +- On Windows/macOS, it maps `localhost` to `host.docker.internal` for container connectivity + +**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 From 95be9938a6763a306c331ca79c9ff3464b9b8b95 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ali=C3=A9nor=20Lahlou?= Date: Wed, 14 Jan 2026 15:34:45 +0100 Subject: [PATCH 16/22] requirement update --- OmniboardLauncher.spec | 3 +++ requirements.txt | 10 ++-------- 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/OmniboardLauncher.spec b/OmniboardLauncher.spec index 738e185..01ac0d8 100644 --- a/OmniboardLauncher.spec +++ b/OmniboardLauncher.spec @@ -9,6 +9,9 @@ a = Analysis( datas=[('assets/DataBase.ico', '.')], hiddenimports=[ 'customtkinter', + 'tkinter', + 'darkdetect', + 'PIL', 'pymongo', 'src.mongodb', 'src.omniboard', diff --git a/requirements.txt b/requirements.txt index 2cdfa7b..1e8da34 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,8 +1,2 @@ -# Core dependencies (shared) -pymongo>=4.0.0 - -# Web UI dependencies (Dash) -dash>=2.14.0 -dash-bootstrap-components>=1.5.0 -flask>=3.0.0 - +customtkinter==5.2.2 +pymongo>=4.0.0 \ No newline at end of file From 1b32d2225f2125df0e6d4e93dd74569c8f27810f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ali=C3=A9nor=20Lahlou?= Date: Wed, 14 Jan 2026 16:47:37 +0100 Subject: [PATCH 17/22] robust Full URI (mongodb+srv) support; harden docker subprocess --- requirements.txt | 1 + src/gui.py | 7 +++++++ src/main.py | 6 +++++- src/mongodb.py | 10 ++++++++++ src/omniboard.py | 20 +++++++++++++------- 5 files changed, 36 insertions(+), 8 deletions(-) diff --git a/requirements.txt b/requirements.txt index b15babf..40b1fc8 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,3 @@ customtkinter==5.2.2 pymongo>=4.0.0 +dnspython>=2.3.0 diff --git a/src/gui.py b/src/gui.py index 78ee10a..13f80ec 100644 --- a/src/gui.py +++ b/src/gui.py @@ -294,6 +294,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}" 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 d49b00b..1f9a42d 100644 --- a/src/mongodb.py +++ b/src/mongodb.py @@ -3,6 +3,7 @@ from pymongo.errors import OperationFailure from typing import List, Optional from urllib.parse import urlparse +import importlib.util class MongoDBClient: @@ -46,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() diff --git a/src/omniboard.py b/src/omniboard.py index ed025b5..7942694 100644 --- a/src/omniboard.py +++ b/src/omniboard.py @@ -185,7 +185,7 @@ def launch( # Build Docker command (detached) docker_cmd = [ "docker", "run", "-d", "--rm", - "-p", f"{host_port}:9000", + "-p", f"127.0.0.1:{host_port}:9000", "--name", container_name, "vivekratnavel/omniboard", mongo_flag, mongo_arg, @@ -210,11 +210,18 @@ def list_containers() -> List[str]: """ try: result = subprocess.run( - 'docker ps -a --filter "name=omniboard_" --format "{{.ID}}"', - shell=True, + [ + "docker", + "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): @@ -234,9 +241,8 @@ def clear_all_containers(self) -> int: for cid in container_ids: try: subprocess.run( - f"docker rm -f {cid}", - shell=True, - timeout=10 + ["docker", "rm", "-f", cid], + timeout=10, ) except (subprocess.TimeoutExpired, FileNotFoundError): pass From ad243ca9d7695025df1996dd2defefaf2700cc91 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ali=C3=A9nor=20Lahlou?= Date: Wed, 14 Jan 2026 18:05:33 +0100 Subject: [PATCH 18/22] feature: add credential URI connection --- OmniboardLauncher.spec | 13 +- README.md | 37 ++- requirements.txt | 2 + src/gui.py | 381 ++++++++++++++++++++--- src/mongodb.py | 13 +- src/omniboard.py | 103 +++--- src/prefs.py | 79 +++++ tests/test_docker_behavior.py | 97 ++++++ tests/test_integration.py | 5 +- tests/test_localhost_container_access.py | 63 ++++ tests/test_omniboard.py | 29 +- 11 files changed, 708 insertions(+), 114 deletions(-) create mode 100644 src/prefs.py create mode 100644 tests/test_docker_behavior.py create mode 100644 tests/test_localhost_container_access.py diff --git a/OmniboardLauncher.spec b/OmniboardLauncher.spec index 10234ce..67cf1b3 100644 --- a/OmniboardLauncher.spec +++ b/OmniboardLauncher.spec @@ -7,7 +7,18 @@ 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=[], diff --git a/README.md b/README.md index 20cee3c..271fbd9 100644 --- a/README.md +++ b/README.md @@ -108,11 +108,14 @@ Download the latest executable from the [releases page](https://github.com/Alien ``` 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) - - Example: `mongodb+srv://user:pass@my-cluster.mongodb.net/?retryWrites=true&w=majority` - - Click "Connect" to list available databases + - 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 3. **Select a database** - Choose a database from the dropdown list @@ -127,10 +130,14 @@ Download the latest executable from the [releases page](https://github.com/Alien #### 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 @@ -205,7 +212,8 @@ AltarViewer/ │ ├── main.py # Application entry point │ ├── gui.py # GUI implementation (CustomTkinter) │ ├── mongodb.py # MongoDB connection logic -│ └── omniboard.py # Docker/Omniboard management +│ ├── omniboard.py # Docker/Omniboard management +│ └── prefs.py # Secure preferences (JSON + OS keyring) ├── tests/ │ ├── conftest.py # Pytest configuration │ ├── test_mongodb.py # MongoDB tests @@ -309,6 +317,11 @@ We welcome contributions! Please follow these guidelines: - 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..." @@ -319,7 +332,6 @@ We welcome contributions! Please follow these guidelines: **What the app does**: - In Full URI mode, it injects the selected database into the URI and uses `--mu`, preserving credentials/options -- On Windows/macOS, it maps `localhost` to `host.docker.internal` for container connectivity **What to check**: - Validate your URI with `mongosh` and ensure it has read access to the selected DB @@ -355,6 +367,17 @@ Some deployments (e.g., MongoDB Atlas or non-admin users) do not allow the `list - 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) diff --git a/requirements.txt b/requirements.txt index 40b1fc8..60af430 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +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 13f80ec..17702d9 100644 --- a/src/gui.py +++ b/src/gui.py @@ -2,14 +2,18 @@ import customtkinter as ctk from tkinter import messagebox import webbrowser +import threading +import sys # 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") @@ -31,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") @@ -47,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) @@ -75,7 +87,7 @@ def _create_connection_frame(self): # Connection mode selector self.mode_selector = ctk.CTkSegmentedButton( self.connection_frame, - values=["Port", "Full URI"], + values=["Port", "Full URI", "Credential URI"], variable=self.connection_mode, command=self.on_connection_mode_change ) @@ -100,8 +112,61 @@ def _create_connection_frame(self): 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( @@ -111,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.""" @@ -206,16 +272,74 @@ def _create_omniboard_frame(self): def on_connection_mode_change(self, value): """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 URI + 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.""" @@ -229,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 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 @@ -330,49 +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() + + # 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.", + ) + return - try: - # Get MongoDB connection details - mongo_host, mongo_port, _ = self.mongo_client.parse_connection_url() - # When using a full URI, reuse it so Omniboard can authenticate and - # honour any URI options. We'll inject the selected DB downstream. - mongo_uri = None - if self.connection_mode.get() == "Full URI": - # Access the current connection URI if available - if hasattr(self.mongo_client, "get_connection_uri"): - mongo_uri = self.mongo_client.get_connection_uri() - - # Launch Omniboard - container_name, host_port = self.omniboard_manager.launch( - db_name=db_name, - mongo_host=mongo_host, - mongo_port=mongo_port, - mongo_uri=mongo_uri, + # 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.", ) - - 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 a short delay to allow Omniboard to fully start - self.after(6000, lambda: webbrowser.open(url)) - except Exception as e: - messagebox.showerror("Launch Error", str(e)) + 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/mongodb.py b/src/mongodb.py index 1f9a42d..6237a8b 100644 --- a/src/mongodb.py +++ b/src/mongodb.py @@ -2,7 +2,7 @@ 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 @@ -86,6 +86,15 @@ def _connect(self) -> List[str]: _, _, 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 @@ -114,4 +123,4 @@ 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 7942694..d7b4931 100644 --- a/src/omniboard.py +++ b/src/omniboard.py @@ -5,6 +5,8 @@ import uuid import sys import time +import os +import shutil from typing import List, Optional from urllib.parse import urlparse, urlunparse @@ -12,6 +14,34 @@ 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. @@ -20,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 @@ -58,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(): @@ -109,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 @@ -121,20 +164,6 @@ 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, @@ -177,14 +206,17 @@ def launch( mongo_arg = self._adjust_mongo_uri_for_docker(mongo_uri, db_name=db_name) mongo_flag = "--mu" else: - # Adjust host for Docker networking - docker_mongo_host = self.adjust_mongo_host_for_docker(mongo_host) - mongo_arg = f"{docker_mongo_host}:{mongo_port}:{db_name}" + # 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 = [ - "docker", "run", "-d", "--rm", + docker_cmd = OmniboardManager._docker_cmd_base() + [ + "run", "-d", "--rm", "-p", f"127.0.0.1:{host_port}:9000", "--name", container_name, "vivekratnavel/omniboard", @@ -210,8 +242,8 @@ def list_containers() -> List[str]: """ try: result = subprocess.run( - [ - "docker", + OmniboardManager._docker_cmd_base() + + [ "ps", "-a", "--filter", @@ -241,7 +273,7 @@ def clear_all_containers(self) -> int: for cid in container_ids: try: subprocess.run( - ["docker", "rm", "-f", cid], + OmniboardManager._docker_cmd_base() + ["rm", "-f", cid], timeout=10, ) except (subprocess.TimeoutExpired, FileNotFoundError): @@ -250,11 +282,9 @@ def clear_all_containers(self) -> int: return len(container_ids) def _adjust_mongo_uri_for_docker(self, mongo_uri: str, db_name: Optional[str] = None) -> str: - """Adjust a full MongoDB URI for Docker networking and inject DB name. + """Inject DB name into a full MongoDB URI and preserve credentials and query. - - On Windows/macOS, replace localhost/127.0.0.1 with host.docker.internal - - Ensure the selected database is present in the URI path - - Preserve credentials and query parameters + Host resolution adjustments are intentionally not performed. """ try: parsed = urlparse(mongo_uri) @@ -262,10 +292,7 @@ def _adjust_mongo_uri_for_docker(self, mongo_uri: str, db_name: Optional[str] = # If parsing fails, return original URI return mongo_uri - host = parsed.hostname or "" - adjusted_host = self.adjust_mongo_host_for_docker(host) - - # Reconstruct netloc with potential creds and port + # Reconstruct netloc with potential creds and port (no host rewriting) userinfo = "" if parsed.username: userinfo += parsed.username @@ -273,7 +300,7 @@ def _adjust_mongo_uri_for_docker(self, mongo_uri: str, db_name: Optional[str] = userinfo += f":{parsed.password}" userinfo += "@" port_part = f":{parsed.port}" if parsed.port else "" - netloc = f"{userinfo}{adjusted_host}{port_part}" + netloc = f"{userinfo}{parsed.hostname or ''}{port_part}" # Always set the path to the selected DB if provided path = parsed.path or "" 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/test_docker_behavior.py b/tests/test_docker_behavior.py new file mode 100644 index 0000000..2802d0e --- /dev/null +++ b/tests/test_docker_behavior.py @@ -0,0 +1,97 @@ +"""Tests for Docker detection and GUI behavior regarding manual start requirement.""" +import subprocess +import types + +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 + + +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..f3bb16f 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -41,6 +41,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.""" From 90b4cf4ffcf1f3a47efe26c53acc5ea87026fc46 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ali=C3=A9nor=20Lahlou?= Date: Wed, 14 Jan 2026 18:09:38 +0100 Subject: [PATCH 19/22] fix: remove pop message in tests --- tests/test_docker_behavior.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/test_docker_behavior.py b/tests/test_docker_behavior.py index 2802d0e..be679bf 100644 --- a/tests/test_docker_behavior.py +++ b/tests/test_docker_behavior.py @@ -1,6 +1,9 @@ """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 @@ -59,6 +62,8 @@ def __init__(self, rc, out=""): 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 From 7ce674b142c68e7dfdb16defe5359b8e24e0428e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ali=C3=A9nor=20Lahlou?= Date: Thu, 22 Jan 2026 15:26:26 +0100 Subject: [PATCH 20/22] fix: host.docke.internal does not exist on linux, replace by 172.17.0.1 --- README.md | 116 +++++++++++++++-------- src/omniboard.py | 13 ++- tests/test_localhost_container_access.py | 21 ++++ 3 files changed, 105 insertions(+), 45 deletions(-) diff --git a/README.md b/README.md index 04ce5b0..ad063a5 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ [![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) +[![Release](https://img.shields.io/github/v/release/DreamRepo/AltarViewer?include_prereleases&sort=semver)](https://github.com/DreamRepo/AltarViewer/releases) 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. @@ -15,9 +15,9 @@ A graphical user interface application for launching and managing [Omniboard](ht - [Features](#features) - [Installation](#installation) - - [Prerequisites](#prerequisites) - - [From Source](#from-source) - - [From Binary Release](#from-binary-release) + - [Prerequisites](#prerequisites) + - [From Binary Release](#from-binary-release) + - [From Source](#from-source) - [Usage](#usage) - [Quick Start](#quick-start) - [Configuration](#configuration) @@ -31,7 +31,6 @@ A graphical user interface application for launching and managing [Omniboard](ht - [Versioning](#versioning) - [License](#license) -docker --version ## Features - **MongoDB Connection Management**: Connect to local, remote, or Atlas MongoDB instances (port or full URI) @@ -47,11 +46,45 @@ docker --version You can use any of these options: -### Option 1 – Executable +### From Binary Release -Download the latest platform-specific launcher from the [AltarViewer releases](https://github.com/DreamRepo/AltarViewer/releases) and run it. +Prebuilt executables for Windows, macOS, and Linux are attached to each GitHub Release (built by our GitHub Actions workflow). -### Option 2 – Python app +1) Download +- Go to the repository's [Releases](https://github.com/DreamRepo/AltarViewer/releases) page +- Under the latest release, download the asset for your OS/architecture. The filename typically contains the OS name, for example: + - Windows: contains `windows` or `win` and ends with `.exe` + - macOS: contains `macos` or `darwin` (may be a `.zip` that contains the app/binary) + - Linux: contains `linux` (often an ELF binary or a tarball) + +2) Run +- Windows (PowerShell): + ```powershell + # If you downloaded a zip, extract it first + .\OmniboardLauncher.exe + ``` + First run: If you see Windows SmartScreen, click “More info” → “Run anyway”. If the file is blocked, right‑click → Properties → check “Unblock”. + +- macOS (Terminal): + ```bash + # If you downloaded a zip, extract it first + chmod +x ./OmniboardLauncher # may already be executable + ./OmniboardLauncher + ``` + First run: If Gatekeeper blocks the app, open it via System Settings → Privacy & Security → “Open Anyway”, or right‑click the app → Open. + +- Linux (Terminal): + ```bash + # If you downloaded a tar/zip, extract it first + chmod +x ./OmniboardLauncher + ./OmniboardLauncher + ``` + Notes: You may need a recent glibc (on older distros). If you see a “permission denied” on a mounted filesystem, copy the binary into your home directory and try again. + +3) Optional CLI usage +- You can also run the executable from a terminal to capture logs. The GUI guides you through connecting to MongoDB and launching Omniboard. + +### From Source ```bash git clone https://github.com/DreamRepo/Altar.git @@ -66,47 +99,38 @@ pip install -r requirements.txt python -m src.main ``` -### Option 3 – Docker image +Alternatively, clone this repository directly if you only need the Viewer: ```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 +git clone https://github.com/DreamRepo/AltarViewer.git +cd AltarViewer +python -m venv venv +source venv/bin/activate # or venv\Scripts\activate on Windows +pip install -r requirements.txt +python -m src.main ``` -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: +## Usage -```bash -git clone https://github.com/DreamRepo/AltarDocker.git -cd AltarDocker -docker compose -f docker-compose_default.yml up -d -``` +### Quick Start -Then start AltarViewer via option 1–3 and connect it to the MongoDB instance from the stack (for example `mongodb://localhost:27017`). -## Usage +1. **Start docker desktop** +- For windows: launch docker desktop executable. -### Quick Start +- For Linux: ```systemctl --user start docker-desktop``` -1. **Launch the application** (using any install option above) +2. **Launch the application** (using any install option above) -2. **Connect to MongoDB** +3. **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 + - Credential URI: paste a credential-less URI (e.g., `mongodb://host:27017/yourdb`) and enter username/password/auth_source separately from the following schemes: `mongodb+srv://user:pass@my-cluster.mongodb.net/?retryWrites=true&w=majority`, `mongodb://username:password@host:27017/?authsource=db_name` - Optionally save your password securely using the OS keyring - - Click "Connect" to list available databases + - Click "Connect" to list available databases 4. **Select a database** - Choose a database from the dropdown list @@ -120,8 +144,10 @@ Then start AltarViewer via option 1–3 and connect it to the MongoDB instance f #### 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. + - Port: quick local development; launches Omniboard with `-m host:port:database` + - If you connect to `localhost` or `127.0.0.1`, the app maps it so the Docker container can reach your host MongoDB: + - Windows/macOS: `host.docker.internal` + - Linux: `172.17.0.1` (Docker bridge gateway) - 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: @@ -312,7 +338,9 @@ We welcome contributions! Please follow these guidelines: - 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. +Note: In Port mode, the app automatically maps `localhost`/`127.0.0.1` so containers can reach MongoDB running on the host: +- Windows/macOS: `host.docker.internal` +- Linux: `172.17.0.1` ### Omniboard stuck on "Loading app..." @@ -371,17 +399,23 @@ Older versions saved preferences at `~/.altarviewer_config.json`, which could be ### Getting Help -- Check existing [GitHub Issues](https://github.com/DreamRepo/Altar/issues) +- Check existing [GitHub Issues](https://github.com/DreamRepo/AltarViewer/issues) - Review [Omniboard documentation](https://vivekratnavel.github.io/omniboard/) - Contact the DREAM/Altar team +## Versioning + +We use Semantic Versioning (SemVer) for AltarViewer. The latest version is shown by the Release badge at the top of this README. See the [Releases](https://github.com/DreamRepo/AltarViewer/releases) page for notes and downloadable artifacts. + ### 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) +1. Update version where applicable (e.g., badges or app metadata if needed) +2. Create and push a tag (use the next SemVer): + - `git tag -a vX.Y.Z -m "Release vX.Y.Z"` + - `git push origin vX.Y.Z` +3. GitHub Actions builds platform-specific binaries and uploads them to the Release +4. Publish the Release when artifacts are validated ## License diff --git a/src/omniboard.py b/src/omniboard.py index a32460d..3c0b5c7 100644 --- a/src/omniboard.py +++ b/src/omniboard.py @@ -206,11 +206,16 @@ def launch( 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. + # Port mode: when connecting to a MongoDB running on the host, + # containers cannot reach the host via 127.0.0.1. + # Use host.docker.internal on Windows/macOS and the default Docker + # bridge gateway (172.17.0.1) on Linux. 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" + if mongo_host in ("localhost", "127.0.0.1"): + if sys.platform.startswith("linux"): + host_for_container = "172.17.0.1" + else: + host_for_container = "host.docker.internal" mongo_arg = f"{host_for_container}:{mongo_port}:{db_name}" mongo_flag = "-m" diff --git a/tests/test_localhost_container_access.py b/tests/test_localhost_container_access.py index f3dc552..6846d74 100644 --- a/tests/test_localhost_container_access.py +++ b/tests/test_localhost_container_access.py @@ -61,3 +61,24 @@ def test_port_mode_keeps_remote_hosts(monkeypatch): assert args is not None idx = args.index("-m") assert args[idx + 1] == "mongo.example.com:27018:db2" + + +def test_port_mode_maps_localhost_on_linux(monkeypatch): + recorded = _capture_popen(monkeypatch) + monkeypatch.setattr(OmniboardManager, "ensure_docker_running", lambda self: None) + monkeypatch.setattr(sys, "platform", "linux", raising=False) + + m = OmniboardManager() + m.launch( + db_name="ldb", + mongo_host="localhost", + mongo_port=27017, + host_port=25003, + mongo_uri=None, + ) + + args = recorded["args"] + assert args is not None + idx = args.index("-m") + # On Linux we expect the Docker bridge gateway IP + assert args[idx + 1].startswith("172.17.0.1:27017:") From 1ee48e5381a2bfff8ef9af3a3409e04be948bc2a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ali=C3=A9nor=20Lahlou?= Date: Thu, 22 Jan 2026 15:52:04 +0100 Subject: [PATCH 21/22] rename to AltarViewer --- .github/workflows/release.yml | 34 +++++++++++----------- OmniboardLauncher.spec => AltarViewer.spec | 2 +- README.md | 18 +++++++----- src/gui.py | 4 +-- src/main.py | 4 +-- 5 files changed, 32 insertions(+), 30 deletions(-) rename OmniboardLauncher.spec => AltarViewer.spec (97%) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index dbf40a3..08043c7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -34,13 +34,13 @@ jobs: - name: Build executable run: | - pyinstaller OmniboardLauncher.spec + pyinstaller AltarViewer.spec - name: Upload artifact uses: actions/upload-artifact@v4 with: - name: OmniboardLauncher-Windows - path: dist/OmniboardLauncher.exe + name: AltarViewer-Windows + path: dist/AltarViewer.exe build-macos: runs-on: macos-latest @@ -67,13 +67,13 @@ jobs: - name: Build executable run: | - pyinstaller OmniboardLauncher.spec + pyinstaller AltarViewer.spec - name: Upload artifact uses: actions/upload-artifact@v4 with: - name: OmniboardLauncher-macOS - path: dist/OmniboardLauncher + name: AltarViewer-macOS + path: dist/AltarViewer build-linux: runs-on: ubuntu-latest @@ -100,13 +100,13 @@ jobs: - name: Build executable run: | - pyinstaller OmniboardLauncher.spec + pyinstaller AltarViewer.spec - name: Upload artifact uses: actions/upload-artifact@v4 with: - name: OmniboardLauncher-Linux - path: dist/OmniboardLauncher + name: AltarViewer-Linux + path: dist/AltarViewer release: needs: [build-windows, build-macos, build-linux] @@ -116,33 +116,33 @@ jobs: - name: Download Windows artifact uses: actions/download-artifact@v4 with: - name: OmniboardLauncher-Windows + name: AltarViewer-Windows path: ./windows - name: Download macOS artifact uses: actions/download-artifact@v4 with: - name: OmniboardLauncher-macOS + name: AltarViewer-macOS path: ./macos - name: Download Linux artifact uses: actions/download-artifact@v4 with: - name: OmniboardLauncher-Linux + name: AltarViewer-Linux path: ./linux - name: Rename artifacts run: | - mv ./macos/OmniboardLauncher ./macos/OmniboardLauncher-macOS - mv ./linux/OmniboardLauncher ./linux/OmniboardLauncher-Linux + mv ./macos/AltarViewer ./macos/AltarViewer-macOS + mv ./linux/AltarViewer ./linux/AltarViewer-Linux - name: Create Release uses: softprops/action-gh-release@v1 with: files: | - ./windows/OmniboardLauncher.exe - ./macos/OmniboardLauncher-macOS - ./linux/OmniboardLauncher-Linux + ./windows/AltarViewer.exe + ./macos/AltarViewer-macOS + ./linux/AltarViewer-Linux draft: true prerelease: false env: diff --git a/OmniboardLauncher.spec b/AltarViewer.spec similarity index 97% rename from OmniboardLauncher.spec rename to AltarViewer.spec index e5e69f3..727f43b 100644 --- a/OmniboardLauncher.spec +++ b/AltarViewer.spec @@ -39,7 +39,7 @@ exe = EXE( a.zipfiles, a.datas, [], - name='OmniboardLauncher', + name='AltarViewer', debug=False, bootloader_ignore_signals=False, strip=False, diff --git a/README.md b/README.md index ad063a5..3bb22fd 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,8 @@ [![License](https://img.shields.io/badge/license-GPL%20v3-blue.svg)](LICENSE) [![Release](https://img.shields.io/github/v/release/DreamRepo/AltarViewer?include_prereleases&sort=semver)](https://github.com/DreamRepo/AltarViewer/releases) +[➡️ Download the latest release](https://github.com/DreamRepo/AltarViewer/releases) for Windows, macOS, or Linux. + 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.
@@ -61,23 +63,23 @@ Prebuilt executables for Windows, macOS, and Linux are attached to each GitHub R - Windows (PowerShell): ```powershell # If you downloaded a zip, extract it first - .\OmniboardLauncher.exe + .\AltarViewer.exe ``` First run: If you see Windows SmartScreen, click “More info” → “Run anyway”. If the file is blocked, right‑click → Properties → check “Unblock”. - macOS (Terminal): ```bash # If you downloaded a zip, extract it first - chmod +x ./OmniboardLauncher # may already be executable - ./OmniboardLauncher + chmod +x ./AltarViewer # may already be executable + ./AltarViewer ``` First run: If Gatekeeper blocks the app, open it via System Settings → Privacy & Security → “Open Anyway”, or right‑click the app → Open. - Linux (Terminal): ```bash # If you downloaded a tar/zip, extract it first - chmod +x ./OmniboardLauncher - ./OmniboardLauncher + chmod +x ./AltarViewer + ./AltarViewer ``` Notes: You may need a recent glibc (on older distros). If you see a “permission denied” on a mounted filesystem, copy the binary into your home directory and try again. @@ -208,14 +210,14 @@ Build a standalone executable using PyInstaller: pip install pyinstaller # Build executable -pyinstaller OmniboardLauncher.spec +pyinstaller AltarViewer.spec # Output will be in dist/ directory ``` #### Customizing the Build -Edit [OmniboardLauncher.spec](OmniboardLauncher.spec) to customize: +Edit [AltarViewer.spec](AltarViewer.spec) to customize: - Application name and icon - Bundled data files - Hidden imports @@ -238,7 +240,7 @@ AltarViewer/ ├── assets/ # Images and resources ├── requirements.txt # Production dependencies ├── requirements-dev.txt # Development dependencies -└── OmniboardLauncher.spec # PyInstaller specification +└── AltarViewer.spec # PyInstaller specification ``` ### Key Components diff --git a/src/gui.py b/src/gui.py index 17702d9..8b76b04 100644 --- a/src/gui.py +++ b/src/gui.py @@ -20,7 +20,7 @@ class MongoApp(ctk.CTk): - """Main application window for MongoDB Database Selector and Omniboard Launcher.""" + """Main application window for MongoDB Database Selector (AltarViewer).""" def __init__(self): """Initialize the main application window.""" @@ -67,7 +67,7 @@ def _create_title(self): """Create the title label.""" title_label = ctk.CTkLabel( self, - text="MongoDB & Omniboard Launcher", + text="AltarViewer", font=ctk.CTkFont(size=20, weight="bold") ) title_label.grid(row=0, column=0, padx=20, pady=(10, 5), sticky="ew") diff --git a/src/main.py b/src/main.py index 8776120..9065540 100644 --- a/src/main.py +++ b/src/main.py @@ -1,4 +1,4 @@ -"""Main entry point for Omniboard Launcher application.""" +"""Main entry point for AltarViewer application.""" # Support both package execution (python -m src.main) and direct script runs (python src/main.py) try: from .gui import MongoApp @@ -7,7 +7,7 @@ def main(): - """Launch the Omniboard application.""" + """Launch the AltarViewer application.""" app = MongoApp() app.mainloop() From 6abbce6642a09624cc1bfb1e2001070982a81f9d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ali=C3=A9nor=20Lahlou?= Date: Wed, 28 Jan 2026 17:21:00 +0100 Subject: [PATCH 22/22] Update README.md: altarviewer to altarviewer-linux --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 3bb22fd..dc8aa3b 100644 --- a/README.md +++ b/README.md @@ -70,8 +70,8 @@ Prebuilt executables for Windows, macOS, and Linux are attached to each GitHub R - macOS (Terminal): ```bash # If you downloaded a zip, extract it first - chmod +x ./AltarViewer # may already be executable - ./AltarViewer + chmod +x ./AltarViewer-Linux # may already be executable + ./AltarViewer-Linux ``` First run: If Gatekeeper blocks the app, open it via System Settings → Privacy & Security → “Open Anyway”, or right‑click the app → Open.