From 70f43b4a2e434c08326c1c16bf4066e853eaa6f4 Mon Sep 17 00:00:00 2001 From: dheerajoruganty Date: Thu, 1 May 2025 19:17:26 +0000 Subject: [PATCH 01/12] feat: Add refresh button to refresh tools and health check --- registry/main.py | 94 ++++++++++++++++++++++ registry/templates/index.html | 146 +++++++++++++++++++++++++++++++++- 2 files changed, 239 insertions(+), 1 deletion(-) diff --git a/registry/main.py b/registry/main.py index 6ff4e1f..a76aef0 100644 --- a/registry/main.py +++ b/registry/main.py @@ -1041,6 +1041,55 @@ async def get_service_tools( # --- Endpoint to get tool list for a service --- END +# --- Refresh Endpoint --- START +@app.post("/api/refresh/{service_path:path}") +async def refresh_service(service_path: str, username: Annotated[str, Depends(api_auth)]): + if not service_path.startswith('/'): + service_path = '/' + service_path + + # Check if service exists + if service_path not in REGISTERED_SERVERS: + raise HTTPException(status_code=404, detail="Service path not registered") + + # Check if service is enabled + is_enabled = MOCK_SERVICE_STATE.get(service_path, False) + if not is_enabled: + raise HTTPException(status_code=400, detail="Cannot refresh a disabled service") + + print(f"Manual refresh requested for {service_path} by user '{username}'...") + try: + # Trigger the health check (which also updates tools if healthy) + await perform_single_health_check(service_path) + except Exception as e: + # Catch potential errors during the check itself + print(f"ERROR during manual refresh check for {service_path}: {e}") + # Update status to reflect the error + error_status = f"error: refresh execution failed ({type(e).__name__})" + SERVER_HEALTH_STATUS[service_path] = error_status + SERVER_LAST_CHECK_TIME[service_path] = datetime.now(timezone.utc) + # Still broadcast the error state + await broadcast_single_service_update(service_path) + # Return error response + raise HTTPException(status_code=500, detail=f"Refresh check failed: {e}") + + # Check completed, broadcast the latest status + await broadcast_single_service_update(service_path) + + # Return the latest status from global state + final_status = SERVER_HEALTH_STATUS.get(service_path, "unknown") + final_last_checked_dt = SERVER_LAST_CHECK_TIME.get(service_path) + final_last_checked_iso = final_last_checked_dt.isoformat() if final_last_checked_dt else None + final_num_tools = REGISTERED_SERVERS.get(service_path, {}).get("num_tools", 0) + + return { + "service_path": service_path, + "status": final_status, + "last_checked_iso": final_last_checked_iso, + "num_tools": final_num_tools + } +# --- Refresh Endpoint --- END + + # --- Add Edit Routes --- @app.get("/edit/{service_path:path}", response_class=HTMLResponse) @@ -1120,6 +1169,51 @@ async def edit_server_submit( return RedirectResponse(url="/", status_code=status.HTTP_303_SEE_OTHER) +# --- Helper function to broadcast single service update --- START +async def broadcast_single_service_update(service_path: str): + """Sends the current status, tool count, and last check time for a specific service.""" + global active_connections, SERVER_HEALTH_STATUS, SERVER_LAST_CHECK_TIME, REGISTERED_SERVERS + + if not active_connections: + return # No clients connected + + status = SERVER_HEALTH_STATUS.get(service_path, "unknown") + last_checked_dt = SERVER_LAST_CHECK_TIME.get(service_path) + last_checked_iso = last_checked_dt.isoformat() if last_checked_dt else None + num_tools = REGISTERED_SERVERS.get(service_path, {}).get("num_tools", 0) + + update_data = { + service_path: { + "status": status, + "last_checked_iso": last_checked_iso, + "num_tools": num_tools + } + } + message = json.dumps(update_data) + print(f"--- BROADCAST SINGLE: Sending update for {service_path}: {message}") + + # Use the same concurrent sending logic as in toggle + disconnected_clients = set() + current_connections = list(active_connections) # Copy to iterate safely + send_tasks = [] + for conn in current_connections: + send_tasks.append((conn, conn.send_text(message))) + + results = await asyncio.gather(*(task for _, task in send_tasks), return_exceptions=True) + + for i, result in enumerate(results): + conn, _ = send_tasks[i] + if isinstance(result, Exception): + print(f"Error sending single update to WebSocket client {conn.client}: {result}. Marking for removal.") + disconnected_clients.add(conn) + if disconnected_clients: + print(f"Removing {len(disconnected_clients)} disconnected clients after single update broadcast.") + for conn in disconnected_clients: + if conn in active_connections: + active_connections.remove(conn) +# --- Helper function to broadcast single service update --- END + + # --- WebSocket Endpoint --- @app.websocket("/ws/health_status") async def websocket_endpoint(websocket: WebSocket): diff --git a/registry/templates/index.html b/registry/templates/index.html index 9d48f45..1f3e412 100644 --- a/registry/templates/index.html +++ b/registry/templates/index.html @@ -447,6 +447,22 @@ .sidebar-stats span:last-child { font-weight: 600; } + + /* Refresh button specific styles */ + .refresh-button { + background: none; + border: none; + padding: 0; + margin: 0 0 0 5px; + cursor: pointer; + vertical-align: middle; + color: inherit; + font-size: 1em; /* Match icon span */ + } + .refresh-button:disabled { + cursor: not-allowed; + opacity: 0.5; + } @@ -1210,10 +1245,11 @@

{{ service.display_name }}

{% set display_text = 'unknown' %} {% endif %} - {# Generate IDs #} - {% set badge_id = 'status-badge-' + service.path | replace('/', '_') | replace(':', '_') %} - {% set spinner_id = 'spinner-for-' + service.path | replace('/', '_') | replace(':', '_') %} - {% set last_checked_id = 'last-checked-' + service.path | replace('/', '_') | replace(':', '_') %} + {# Generate IDs - REMOVE leading slash BEFORE replacing #} + {% set safe_path = service.path | replace('/', '', 1) | replace('/', '_') | replace(':', '_') %} + {% set badge_id = 'status-badge-' + safe_path %} + {% set spinner_id = 'spinner-for-' + safe_path %} + {% set last_checked_id = 'last-checked-' + safe_path %} {# Render badge with determined initial text/class #}
From feb8f35e87a300ed3404445301ececda30891048 Mon Sep 17 00:00:00 2001 From: dheerajoruganty Date: Thu, 1 May 2025 19:27:54 +0000 Subject: [PATCH 03/12] docs: Update README to include new api endpoints --- .gitignore | 1 + README.md | 197 +++++++++++++++++++++++++++++++++-------------------- 2 files changed, 126 insertions(+), 72 deletions(-) diff --git a/.gitignore b/.gitignore index 1d3689e..959c35d 100644 --- a/.gitignore +++ b/.gitignore @@ -176,3 +176,4 @@ cookies.txt # MCP Gateway specific registry/server_state.json +registry/nginx_mcp_revproxy.conf diff --git a/README.md b/README.md index 1f574b8..daa94f0 100644 --- a/README.md +++ b/README.md @@ -7,115 +7,160 @@ # MCP Gateway Registry -This application provides a web interface and API for registering and managing backend services that can be proxied through a gateway (like Nginx). It allows viewing service status, descriptions, and toggling their availability (currently simulated). +This application provides a web interface and API for registering and managing backend MCP (Meta-Computation Protocol) services. It acts as a central registry, health monitor, and dynamic reverse proxy configuration generator for Nginx. + +## Features + +* **Service Registration:** Register MCP services via JSON files or the web UI/API. +* **Web UI:** Manage services, view status, and monitor health through a web interface. +* **Authentication:** Secure login system for the web UI and API access. +* **Health Checks:** + * Periodic background checks for enabled services (checks `/sse` endpoint). + * Manual refresh trigger via UI button or API endpoint. +* **Real-time UI Updates:** Uses WebSockets to push health status, tool counts, and last-checked times to all connected clients. +* **Dynamic Nginx Configuration:** Generates an Nginx reverse proxy configuration file (`registry/nginx_mcp_revproxy.conf`) based on registered services and their enabled/disabled state. +* **MCP Tool Discovery:** Automatically fetches and displays the list of tools (name, description, schema) for healthy services using the MCP client library. +* **Service Management:** + * Enable/Disable services directly from the UI. + * Edit service details (name, description, URL, tags, etc.). +* **Filtering & Statistics:** Filter the service list in the UI (All, Enabled, Disabled, Issues) and view basic statistics. +* **UI Customization:** + * Dark/Light theme toggle (persisted in local storage). + * Collapsible sidebar (state persisted in local storage). +* **State Persistence:** Enabled/Disabled state is saved to `registry/server_state.json` (and ignored by Git). ## Prerequisites -* Python 3.12+ -* [uv](https://github.com/astral-sh/uv) (or `pip`) for package management. +* Python 3.11+ (or compatible version supporting FastAPI and MCP Client) +* [uv](https://github.com/astral-sh/uv) (recommended) or `pip` for package management. +* Nginx (or another reverse proxy) installed and configured to use the generated configuration file. ## Installation -1. **Clone the repository (if you haven't already):** +1. **Clone the repository:** ```bash git clone cd mcp-gateway ``` 2. **Create and activate a virtual environment (recommended):** - Using `venv` (standard Python): - ```bash - python -m venv .venv - source .venv/bin/activate # On Windows use `.venv\Scripts\activate` - ``` - Using `uv` (which handles environments automatically): - You can skip explicit environment creation if you use `uv run`. - -3. **Install dependencies:** Dependencies are defined in `pyproject.toml`. - - Using `uv`: - ```bash - # Installs dependencies defined in pyproject.toml - uv pip install . - ``` - Using `pip`: - ```bash - # Installs dependencies defined in pyproject.toml - pip install . - ``` + * Using `venv`: + ```bash + python -m venv .venv + source .venv/bin/activate # Linux/macOS + # .venv\Scripts\activate # Windows + ``` + * `uv` handles environments automatically via `uv run` or `uv pip sync`. + +3. **Install dependencies:** (Defined in `pyproject.toml` and locked in `uv.lock`) + * Using `uv`: + ```bash + uv pip sync + ``` + * Using `pip`: + ```bash + pip install . + ``` ## Configuration -1. **Environment Variables:** The application uses a `.env` file in the project root (`mcp-gateway/`) for configuration. Create this file if it doesn't exist: +1. **Environment Variables:** Create a `.env` file in the project root (`mcp-gateway/`). ```bash - cp .env.example .env # If you create an example file - # Or create it manually touch .env ``` - -2. **Edit `.env`:** Add the following variables: + Add the following variables, replacing placeholders with secure values: ```dotenv - # A strong, randomly generated secret key for session security - SECRET_KEY='your_strong_random_secret_key' + # REQUIRED: A strong, randomly generated secret key for session security + SECRET_KEY='your_strong_random_secret_key_32_chars_or_more' - # Credentials for the web interface login + # REQUIRED: Credentials for the web interface login ADMIN_USER='admin' ADMIN_PASSWORD='your_secure_password' ``` - *Replace the placeholder values with secure ones.* - -3. **Service Definitions:** Services are defined by JSON files in the `registry/servers/` directory. See existing files for the expected format. The application loads these on startup. + **⚠️ IMPORTANT:** Use a strong, unpredictable `SECRET_KEY` for production environments. + +2. **Service Definitions:** Services can be added via the UI after starting the application. Alternatively, you can manually create JSON files in the `registry/servers/` directory before the first run. Each file defines one service. Example (`my_service.json`): + ```json + { + "server_name": "My Example Service", + "description": "Provides example functionality.", + "path": "/my-service", + "proxy_pass_url": "http://localhost:8001", + "tags": ["example", "test"], + "num_tools": 0, + "num_stars": 0, + "is_python": true, + "license": "MIT", + "tool_list": [] + } + ``` ## Running the Application -**Using `uv run`:** - -This command leverages `uv` to manage the environment and run the development server. - -```bash -uv run uvicorn registry.main:app --reload --host 0.0.0.0 --port 7860 -``` -* `--reload`: Enables auto-reload on code changes. -* `--host 0.0.0.0`: Makes the server accessible on your network. -* `--port 7860`: Specifies the port to run on. - -**Using `uvicorn` directly (after installing dependencies and activating venv):** - -```bash -uvicorn registry.main:app --reload --host 0.0.0.0 --port 7860 -``` - -Once running, you can access the web interface at `http://:7860`. +1. **Start the FastAPI server:** + * Using `uv`: + ```bash + uv run uvicorn registry.main:app --reload --host 0.0.0.0 --port 7860 + ``` + * Using `uvicorn` directly (ensure virtual environment is active): + ```bash + uvicorn registry.main:app --reload --host 0.0.0.0 --port 7860 + ``` + * `--reload`: Enables auto-reload for development. Remove for production. + * `--host 0.0.0.0`: Makes the server accessible on your network. + * `--port 7860`: Specifies the port. + +2. **Configure Nginx:** + * The application generates `registry/nginx_mcp_revproxy.conf` on startup. + * Ensure your Nginx instance is running and includes this configuration file in its main `nginx.conf` (e.g., using an `include` directive in the `http` block). + * Reload or restart Nginx to apply the configuration (`sudo nginx -s reload`). + * **Note:** Detailed Nginx setup is beyond the scope of this README. The generated file assumes Nginx is listening on a standard port (e.g., 80 or 443) and proxies requests starting with registered paths (e.g., `/my-service`) to the appropriate backend defined by `proxy_pass_url`. + +3. **Access the UI:** Open your web browser and navigate to the address where Nginx is serving the application (e.g., `http://`). You should be redirected to the login page at `/login` (served by the FastAPI app). *Direct access via port 7860 is primarily for the UI itself; service proxying relies on Nginx.* + +## Usage + +1. **Login:** Use the `ADMIN_USER` and `ADMIN_PASSWORD` from your `.env` file. +2. **Register Service:** Use the "Register New Service" form in the UI (or the API). +3. **Manage Services:** + * Toggle the Enabled/Disabled switch. The Nginx config automatically comments/uncomments the relevant `location` block. + * Click "Modify" to edit service details. + * Click the refresh icon (🔄) in the card header to manually trigger a health check and tool list update for enabled services. +4. **View Tools:** Click the tool count icon (🔧) in the card footer to open a modal displaying discovered tools and their schemas for healthy services. +5. **Filter:** Use the sidebar links to filter the displayed services. ## Project Structure -* `registry/`: Contains the main FastAPI application (`main.py`). +* `registry/`: Main FastAPI application (`main.py`). * `servers/`: Stores JSON definitions for each registered service. * `static/`: Static assets (CSS, JS, images). - * `templates/`: Jinja2 HTML templates. -* `.env`: Configuration file (needs to be created). + * `templates/`: Jinja2 HTML templates (`index.html`, `login.html`, etc.). + * `server_state.json`: Stores the enabled/disabled state (created automatically, **ignored by Git**). + * `nginx_mcp_revproxy.conf`: Nginx config generated dynamically (**ignored by Git**). + * `nginx_template.conf`: Template used for Nginx config generation. +* `.env`: Environment variables (local configuration, **ignored by Git**). +* `.gitignore`: Specifies files ignored by Git. +* `pyproject.toml`: Project metadata and dependencies. +* `uv.lock`: Locked dependency versions (used by `uv`). * `README.md`: This file. +* `LICENSE`: Project license file. -## Registering New Services (API) +## API Endpoints (Brief Overview) -You can register new services programmatically by sending a POST request to the `/register` endpoint. +* `POST /register`: Register a new service (form data). +* `POST /toggle/{service_path}`: Enable/disable a service (form data). +* `POST /edit/{service_path}`: Update service details (form data). +* `GET /api/server_details/{service_path}`: Get full details for a service (JSON). +* `GET /api/tools/{service_path}`: Get the discovered tool list for a service (JSON). +* `POST /api/refresh/{service_path}`: Manually trigger a health check/tool update. +* `GET /login`, `POST /login`, `POST /logout`: Authentication routes. +* `WS /ws/health_status`: WebSocket endpoint for real-time updates. -* **URL:** `/register` -* **Method:** `POST` -* **Authentication:** Requires a valid session cookie obtained via login. -* **Content-Type:** `application/x-www-form-urlencoded` -* **Form Data:** - * `name`: (String) Display name of the service. - * `description`: (String) Description of the service. - * `path`: (String) URL path for the service (e.g., `/my-service`). - * `tags`: (String, Optional) Comma-separated list of tags. - * `num_tools`: (Integer, Optional, Default: 0) Number of tools. - * `num_stars`: (Integer, Optional, Default: 0) Number of stars. - * `is_python`: (Boolean, Optional, Default: false) Whether it's a Python service. - * `license`: (String, Optional, Default: "N/A") License information. +*(Authentication via session cookie is required for most non-login routes)* -**Example using `curl` (after logging in via the browser to get a cookie):** +## Key Dependencies +<<<<<<< HEAD ```bash curl -X POST http://localhost:7860/register \ -H "Content-Type: application/x-www-form-urlencoded" \ @@ -131,3 +176,11 @@ curl -X POST http://localhost:7860/register \ *(Remember to replace the cookie value)* This will create a corresponding JSON file in `registry/servers/`. +======= +* FastAPI +* Uvicorn +* Jinja2 +* python-dotenv +* itsdangerous (for session signing) +* mcp.py (MCP Client Library) +>>>>>>> 812e383 (docs: Update README to include new api endpoints) From 94d09b3b2f85770a35d5b4c004962174f8a356d3 Mon Sep 17 00:00:00 2001 From: dheerajoruganty Date: Thu, 1 May 2025 19:32:12 +0000 Subject: [PATCH 04/12] docs:Fix Readme --- README.md | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/README.md b/README.md index daa94f0..8e1dfa3 100644 --- a/README.md +++ b/README.md @@ -158,9 +158,6 @@ This application provides a web interface and API for registering and managing b *(Authentication via session cookie is required for most non-login routes)* -## Key Dependencies - -<<<<<<< HEAD ```bash curl -X POST http://localhost:7860/register \ -H "Content-Type: application/x-www-form-urlencoded" \ @@ -176,11 +173,3 @@ curl -X POST http://localhost:7860/register \ *(Remember to replace the cookie value)* This will create a corresponding JSON file in `registry/servers/`. -======= -* FastAPI -* Uvicorn -* Jinja2 -* python-dotenv -* itsdangerous (for session signing) -* mcp.py (MCP Client Library) ->>>>>>> 812e383 (docs: Update README to include new api endpoints) From f9418f8ce6dc03a65f42e83e6527882947a549f4 Mon Sep 17 00:00:00 2001 From: dheerajoruganty Date: Thu, 1 May 2025 19:56:35 +0000 Subject: [PATCH 05/12] feat: Dynamic text resizing for long server names --- .github/workflows/lint.yml | 2 +- registry/static/css/styles.css | 73 +++++++++ registry/templates/index.html | 268 +++++++++++++++++++++++++-------- 3 files changed, 283 insertions(+), 60 deletions(-) create mode 100644 registry/static/css/styles.css diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index adca1be..b4111f8 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -1,6 +1,6 @@ name: Lint -on: [push, pull_request] +on: [pull_request] permissions: contents: read # Required to check out the code diff --git a/registry/static/css/styles.css b/registry/static/css/styles.css new file mode 100644 index 0000000..1e5103e --- /dev/null +++ b/registry/static/css/styles.css @@ -0,0 +1,73 @@ +/* Light Mode Styles */ +[data-theme="light"] { + --bg-color: #f8f9fa; /* Very light gray */ + --text-color: #212529; /* Dark text */ + --card-bg: #ffffff; /* White cards */ + --card-border: #000000; /* Black card border */ + --sidebar-bg: #e9ecef; /* Lighter gray sidebar */ + --sidebar-text: #212529; /* Darker sidebar text */ + --button-bg: #007bff; /* Blue button */ + --button-text: #ffffff; + --link-color: #007bff; + --toggle-bg-on: #28a745; /* Green for enabled toggle */ + --toggle-bg-off: #6c757d; /* Gray for disabled toggle */ + --toggle-slider: #fff; + --badge-bg: #007bff; + --badge-text: #ffffff; + --health-healthy-bg: #d4edda; /* Light green */ + --health-healthy-text: #155724; /* Dark green */ + --health-unhealthy-bg: #f8d7da; /* Light red */ + --health-unhealthy-text: #721c24; /* Dark red */ + --health-checking-bg: #fff3cd; /* Light yellow */ + --health-checking-text: #856404; /* Dark yellow */ + --health-disabled-bg: #e2e3e5; /* Light gray */ + --health-disabled-text: #6c757d; /* Dark gray */ + --health-error-bg: #f5c6cb; /* Light pink/red for error */ + --health-error-text: #721c24; /* Dark red */ + --input-bg: #fff; + --input-border: #ced4da; + --input-text: #495057; + --search-btn-bg: #6c757d; /* Gray search button */ + --search-btn-text: #fff; + --official-badge-bg: #007bff; + --official-badge-text: #fff; + --modify-btn-bg: #007bff; + --modify-btn-text: #fff; + --danger-btn-bg: #dc3545; + --danger-btn-text: #fff; + + /* Added rule for sidebar toggle button */ + #sidebar-toggle-btn { + background-color: var(--button-bg); + color: var(--button-text); + /* Add border or other styles if needed */ + /* border: 1px solid var(--button-bg); */ + } +} + +// ... existing code ... + +.service-card { + background-color: var(--card-bg); + border: 1px solid var(--card-border); + border-radius: 0.375rem; /* 6px */ + margin-bottom: 1.5rem; /* 24px */ + padding: 1.5rem; /* 24px */ + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05); + transition: box-shadow 0.2s ease-in-out; +} + +// ... existing code ... + +.sidebar-filter-item a, +.sidebar-stats-label { + color: var(--sidebar-text); /* Use variable for sidebar text */ + text-decoration: none; + display: block; + padding: 0.5rem 1rem; + border-radius: 0.25rem; + transition: background-color 0.2s ease, color 0.2s ease; + margin-bottom: 0.25rem; +} + +// ... existing code ... diff --git a/registry/templates/index.html b/registry/templates/index.html index 9f66d0d..6ace7e7 100644 --- a/registry/templates/index.html +++ b/registry/templates/index.html @@ -17,56 +17,68 @@ 100% { transform: rotate(360deg); } } - /* -- Theme Color Variables -- */ + /* -- Theme Color Variables -- Nova-Inspired Light Mode */ :root { - --bg-color: #f8f9fa; /* Light background */ - --text-color: #212529; /* Dark text */ + --bg-color: #f8f9fa; /* Very light gray/off-white */ + --text-color: #16191f; /* Very dark gray/near black */ --card-bg: #ffffff; /* White cards */ - --card-border: #dee2e6; /* Keep border color */ - --header-bg: #e9ecef; - --sidebar-bg: var(--bg-color); /* Match main background */ - --button-bg: #0d6efd; /* Primary button */ + --card-border: #e0e0e0; /* Lighter gray border */ + --header-bg: #ffffff; /* White header */ + --sidebar-bg: var(--bg-color); /* Sidebar matches main background */ + --accent-color: #7a00cc; /* Purple accent */ + --accent-light-bg: #f7f5ff; /* Very light purple background */ + --button-bg: var(--accent-color); /* Purple button */ --button-text: #ffffff; - --secondary-button-bg: #6c757d; /* Gray secondary */ - --secondary-button-text: #ffffff; - --link-color: #0d6efd; - --badge-bg: #6c757d; - --badge-text: #ffffff; - --official-badge-bg: #0d6efd; - --official-badge-text: #ffffff; + --secondary-button-bg: #e9ecef; /* Light gray secondary button */ + --secondary-button-text: var(--text-color); + --link-color: var(--accent-color); /* Purple links */ + --badge-bg: #e9ecef; /* Light gray badge */ + --badge-text: var(--text-color); + --official-badge-bg: var(--accent-light-bg); /* Light purple badge */ + --official-badge-text: var(--accent-color); /* Purple text on badge */ --input-bg: #ffffff; - --input-text: #212529; + --input-text: var(--text-color); --input-placeholder: #6c757d; - /* Theme Toggle Button Colors - Light Mode */ - --toggle-button-bg: #ffffff; /* White background */ - --toggle-button-text: #212529; /* Dark icon */ - --toggle-button-border: #dee2e6; /* Light border */ - /* Add more variables as needed */ - } - + --input-border: #ced4da; + --input-border-focus: var(--accent-color); /* Purple focus border */ + /* Theme Toggle (Keep consistent or adapt?) */ + --toggle-button-bg: var(--input-bg); + --toggle-button-text: var(--text-color); + --toggle-button-border: var(--input-border); + /* Sidebar Toggle (Keep consistent or adapt?) */ + --sidebar-toggle-bg-light: none; + --sidebar-toggle-text-light: var(--text-color); + --sidebar-toggle-border-light: none; + } + + /* Dark mode would also need updating to match */ html.dark-mode { - --bg-color: #212529; /* Dark background */ - --text-color: #f8f9fa; /* Light text */ - --card-bg: #343a40; /* Darker cards */ + /* TODO: Define Nova-inspired dark theme variables */ + --bg-color: #212529; /* Placeholder dark */ + --text-color: #f8f9fa; /* Placeholder light text */ + --card-bg: #343a40; /* Placeholder dark card */ --card-border: #495057; - --header-bg: #495057; - --sidebar-bg: var(--bg-color); /* Match main background */ - --button-bg: #0d6efd; /* Keep primary button */ + --header-bg: #212529; /* Dark header */ + --accent-color: #a040ff; /* Lighter purple for dark */ + --accent-light-bg: #3a304f; /* Darker purple bg */ + --button-bg: var(--accent-color); --button-text: #ffffff; - --secondary-button-bg: #6c757d; /* Keep secondary button gray */ - --secondary-button-text: #ffffff; - --link-color: #6ea8fe; /* Lighter blue link */ - --badge-bg: #adb5bd; - --badge-text: #212529; - --official-badge-bg: #6ea8fe; /* Lighter blue official */ - --official-badge-text: #212529; /* Dark text on light blue */ - --input-bg: #495057; - --input-text: #f8f9fa; + --secondary-button-bg: #495057; /* Dark gray secondary */ + --secondary-button-text: var(--text-color); + --link-color: var(--accent-color); + --badge-bg: #495057; + --badge-text: var(--text-color); + --official-badge-bg: var(--accent-light-bg); + --official-badge-text: var(--accent-color); + --input-bg: #343a40; + --input-text: var(--text-color); --input-placeholder: #adb5bd; - /* Theme Toggle Button Colors - Dark Mode */ - --toggle-button-bg: #495057; /* Dark background (matches header) */ - --toggle-button-text: #f8f9fa; /* Light icon */ - --toggle-button-border: #6c757d; /* Darker border */ + --input-border: #495057; + --input-border-focus: var(--accent-color); + /* Keep toggles simple for now */ + --toggle-button-bg: #495057; + --toggle-button-text: var(--text-color); + --toggle-button-border: #6c757d; } /* Apply variables */ @@ -79,6 +91,8 @@ .main-header { background-color: var(--header-bg); border-bottom: 1px solid var(--card-border); /* Add subtle border */ + /* Add box-shadow for slight elevation */ + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04); /* Assuming header text color is inherited or set in style.css */ } .sidebar { @@ -106,19 +120,38 @@ border: 1px solid var(--card-border); color: var(--text-color); /* Ensure card text uses theme color */ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05); /* Subtle shadow */ + border-radius: 12px; /* --- Add rounding --- */ } .service-card h2, .service-card .owner, .service-card .description { color: var(--text-color); /* Explicitly set text color */ } /* Example for a specific badge if needed */ - .badge { - background-color: var(--badge-bg); - color: var(--badge-text); + .badge, + .official-badge { + /* background-color set by specific class/variable */ + /* color set by specific class/variable */ + padding: 0.25em 0.75em; + border-radius: 1em; /* Pill shape */ + font-size: 0.8em; + font-weight: 600; + vertical-align: middle; + display: inline-block; /* Ensure padding applies */ } .edit-button { /* Assuming style.css defines button styles, override if needed */ /* background-color: var(--button-bg); */ /* color: var(--button-text); */ + background-color: var(--button-bg); + color: var(--button-text); + border: none; + padding: 8px 16px; + border-radius: 8px; /* --- Add rounding --- */ + font-weight: 600; + cursor: pointer; + transition: background-color 0.2s ease; + } + .edit-button:hover { + opacity: 0.9; /* Slight fade on hover */ } a { color: var(--link-color); @@ -130,22 +163,33 @@ .search-bar button { background-color: var(--secondary-button-bg); color: var(--secondary-button-text); - border: 1px solid var(--secondary-button-bg); + border: 1px solid var(--card-border); /* Use card border for light gray */ + padding: 8px 16px; + border-radius: 8px; /* --- Add rounding --- */ + font-weight: 600; + cursor: pointer; + transition: background-color 0.2s ease, border-color 0.2s ease; } - .edit-button { /* Assuming this is primary */ - background-color: var(--button-bg); - color: var(--button-text); - /* Add padding/border etc. if needed from style.css */ + .logout-button:hover, + .search-bar button:hover { + background-color: var(--card-border); /* Darken slightly on hover */ } .search-bar input[type="search"] { background-color: var(--input-bg); color: var(--input-text); - border: 1px solid var(--card-border); + border: 1px solid var(--input-border); + border-radius: 8px; /* --- Add rounding --- */ + padding: 8px 12px; } .search-bar input[type="search"]::placeholder { color: var(--input-placeholder); opacity: 1; /* Override browser defaults */ } + .search-bar input[type="search"]:focus { + outline: none; + border-color: var(--input-border-focus); + box-shadow: 0 0 0 2px rgba(122, 0, 204, 0.2); /* Optional focus ring */ + } .official-badge { background-color: var(--official-badge-bg); color: var(--official-badge-text); @@ -216,6 +260,22 @@ display: flex; align-items: center; gap: 8px; + flex-shrink: 0; /* --- Prevent icons from shrinking --- */ + } + + /* Added overflow:hidden to card-header */ + .card-header { + display: flex; + align-items: center; + margin-bottom: 10px; + flex-wrap: nowrap; /* Explicitly prevent wrapping */ + } + + /* --- Add style for h2 within card-header --- */ + .card-header h2 { + flex-grow: 1; /* Allow title to take up available space */ + min-width: 0; /* Allow title to shrink below its content size */ + margin-right: 10px; /* Add some space between title and right items */ } /* New style for controls row */ @@ -344,12 +404,14 @@ /* z-index: 10; */ background: none; border: none; - color: var(--text-color); + color: #ffffff; /* Set color explicitly to white */ font-size: 1.5em; /* Adjust size */ cursor: pointer; /* Restore original padding/margin */ padding: 0 10px; margin-right: 10px; + /* Add transition */ + transition: opacity 0.2s ease; /* Only transition opacity */ } .sidebar-toggle-button:hover { opacity: 0.7; @@ -420,18 +482,19 @@ display: block; color: var(--text-color); text-decoration: none; - padding: 8px 10px; - border-radius: 4px; + padding: 10px 12px; /* Adjust padding */ + border-radius: 8px; /* --- Add rounding --- */ font-size: 0.95em; + font-weight: 500; /* Slightly less bold */ transition: background-color 0.2s ease, color 0.2s ease; } .sidebar-link:hover { - background-color: var(--card-bg); /* Subtle hover */ - color: var(--link-color); /* Highlight on hover */ + background-color: var(--secondary-button-bg); /* Use light gray hover */ + color: var(--text-color); /* Keep text dark on hover */ } .sidebar-link.active-filter { - background-color: rgba(13, 110, 253, 0.1); /* Light primary background for active */ - color: var(--link-color); + background-color: var(--accent-light-bg); /* Light purple background for active */ + color: var(--accent-color); /* Purple text */ font-weight: 600; } .sidebar-stats li { @@ -477,8 +540,11 @@ console.log('[HEAD SCRIPT] Determined initial theme:', theme); if (theme === 'dark') { document.documentElement.classList.add('dark-mode'); + // Optional: document.documentElement.setAttribute('data-theme', 'dark'); } else { document.documentElement.classList.remove('dark-mode'); + // Explicitly set data-theme for light mode on initial load + document.documentElement.setAttribute('data-theme', 'light'); } })(); @@ -515,6 +581,19 @@ } } + // --- Add Debounce Function --- + function debounce(func, wait) { + let timeout; + return function executedFunction(...args) { + const later = () => { + clearTimeout(timeout); + func(...args); + }; + clearTimeout(timeout); + timeout = setTimeout(later, wait); + }; + } + // ===================================================================== // == Core UI Update Functions // ===================================================================== @@ -704,6 +783,63 @@ // TODO: Update a "no results" message visibility if needed } + // ===================================================================== + // == Dynamic Font Resizing Logic (for Card Titles) + // ===================================================================== + + function adjustTitleFontSize(element, defaultFontSizePx = 24, minFontSizePx = 12, stepPx = 1) { + if (!element) return; + + // Reset to default size first to handle widening containers AND ensure CSS overrides apply correctly + element.style.fontSize = `${defaultFontSizePx}px`; + + // Allow browser to reflow/repaint before measuring + // requestAnimationFrame helps but setTimeout might be more robust here if issues persist + // requestAnimationFrame(() => { + // Check if overflow happens at default size + if (element.scrollWidth > element.clientWidth) { + let currentSize = defaultFontSizePx; + // Reduce size step-by-step + while (element.scrollWidth > element.clientWidth && currentSize > minFontSizePx) { + currentSize -= stepPx; + element.style.fontSize = `${currentSize}px`; + } + // Final check: if still overflowing at min size, ensure it's set to min + if (element.scrollWidth > element.clientWidth && currentSize <= minFontSizePx) { + element.style.fontSize = `${minFontSizePx}px`; + } + } + // Optional: If it fits even at default, clear the inline style to inherit from CSS + // else { + // element.style.fontSize = ''; // Let CSS rule apply + // } + // }); + } + + function adjustAllTitles() { + const titles = document.querySelectorAll('.service-card .card-header h2'); + if (titles.length === 0) return; + + // Get default font size from the first title (assuming they are the same via CSS) + // Important: Ensure CSS for h2 has a base font-size set (e.g., 1.5em) + const defaultFontSize = window.getComputedStyle(titles[0]).fontSize; + const defaultFontSizePx = parseFloat(defaultFontSize); // Handles 'px' unit correctly + if (isNaN(defaultFontSizePx) || defaultFontSizePx <= 0) { + console.warn("Could not determine default font size for titles. Using fallback."); + defaultFontSizePx = 24; // Fallback default size + } + + // Define minimum size (adjust as needed) + const minFontSizePx = Math.max(10, defaultFontSizePx * 0.6); // Example: 60% of default, but at least 10px + + titles.forEach(title => { + // Use a slight delay with setTimeout to ensure layout is stable before measuring/adjusting + setTimeout(() => { + adjustTitleFontSize(title, defaultFontSizePx, minFontSizePx, 1); // Use 1px step + }, 0); + }); + } + // ===================================================================== // == Modal Logic // ===================================================================== @@ -1003,6 +1139,14 @@ const isDarkMode = htmlElement.classList.toggle('dark-mode'); localStorage.setItem('theme', isDarkMode ? 'dark' : 'light'); console.log('Theme toggled to:', isDarkMode ? 'dark' : 'light'); + + // Update the data-theme attribute consistently + if (isDarkMode) { + htmlElement.removeAttribute('data-theme'); // Remove light theme attribute + // Optional: htmlElement.setAttribute('data-theme', 'dark'); + } else { + htmlElement.setAttribute('data-theme', 'light'); // Add light theme attribute + } // Update button icon if needed (e.g., sun/moon) - requires icon elements const themeToggleButton = document.getElementById('theme-toggle'); if (themeToggleButton) { @@ -1051,6 +1195,7 @@ // Initial UI setup updateAllTimestamps(); // Format initial timestamps + adjustAllTitles(); // Adjust titles on initial load // Start WebSocket connection connectWebSocket(); // Handles ongoing UI updates @@ -1086,6 +1231,9 @@ sidebarNav.addEventListener('click', handleFilterClick); } + // Add debounced resize listener for title adjustments + window.addEventListener('resize', debounce(adjustAllTitles, 150)); + // Apply initial filter state applyCardFilter('all'); // Show all cards initially @@ -1272,12 +1420,14 @@

{{ service.display_name }}