Skip to content

Commit e58a950

Browse files
committed
Merge branch 'claude/infallible-taussig' into 2026-run-prep
2 parents bfd2a52 + 15c3b26 commit e58a950

6 files changed

Lines changed: 203 additions & 7 deletions

File tree

docs/_sidebar.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323

2424
**Advanced**:
2525
- [Development guide](development/development.md)
26+
- [Database selector](development/database-selector.md)
2627
- [Migrate to RCDB2](development/rcdb2-migration.md)
2728
- [DAQ Setup](daq/daq.md)
2829
- [Website](web_site_setup.md)
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
# Multi-Database Selector
2+
3+
The RCDB web interface supports switching between multiple databases from the browser.
4+
When configured, a dropdown selector appears in the navbar allowing users to pick a database.
5+
The selection is saved in a cookie and persists across sessions.
6+
7+
## How It Works
8+
9+
- The Flask app has two config keys: `AVAILABLE_DATABASES` (a dict of `name -> connection_string`)
10+
and `DEFAULT_DATABASE` (the connection string to use when no cookie is set).
11+
- On each request, `before_request()` checks `AVAILABLE_DATABASES`. If non-empty, it reads the
12+
`rcdb_database` cookie to determine which database to connect to.
13+
- If the cookie is missing or invalid, it falls back to `DEFAULT_DATABASE`. If `DEFAULT_DATABASE`
14+
is not in the available list, it logs a warning and uses the first entry.
15+
- When `AVAILABLE_DATABASES` is empty (the default), all behavior is identical to a single-database setup.
16+
17+
## Configuration
18+
19+
### CLI (`rcdb web`)
20+
21+
Use the `--add-db` flag (repeatable) to register named databases:
22+
23+
```bash
24+
rcdb -c mysql+pymysql://rcdb@prodhost/rcdb web \
25+
--add-db "Production=mysql+pymysql://rcdb@prodhost/rcdb" \
26+
--add-db "Test=mysql+pymysql://rcdb@testhost/rcdb_test"
27+
```
28+
29+
- Each `--add-db` value has the format `NAME=CONNECTION_STRING`.
30+
- The `-c` / `--connection` / `RCDB_CONNECTION` value becomes the default database.
31+
- If no `-c` is provided, the first `--add-db` entry is used as the default.
32+
33+
### WSGI
34+
35+
Set the config keys directly in the WSGI script:
36+
37+
```python
38+
import rcdb.web
39+
40+
rcdb.web.app.config["AVAILABLE_DATABASES"] = {
41+
"Production": "mysql+pymysql://rcdb@prodhost/rcdb",
42+
"Test": "mysql+pymysql://rcdb@testhost/rcdb_test",
43+
}
44+
rcdb.web.app.config["DEFAULT_DATABASE"] = "mysql+pymysql://rcdb@prodhost/rcdb"
45+
46+
application = rcdb.web.app
47+
```
48+
49+
When `AVAILABLE_DATABASES` is set, the `SQL_CONNECTION_STRING` key is not used
50+
for connection selection (though it should still be set as a fallback).
51+
52+
## UI Behavior
53+
54+
The selector appears to the left of the "Run or min-max" search box in the navbar.
55+
Each option shows the database name and a connection hint (e.g. `Production (rcdb@prodhost)`).
56+
57+
When the user selects a different database:
58+
59+
1. A cookie `rcdb_database` is set (1-year expiry, `SameSite=Lax`).
60+
2. The current page reloads, now connected to the selected database.
61+
62+
## Key Files
63+
64+
| File | Role |
65+
|------|------|
66+
| `python/rcdb/web/__init__.py` | Config defaults, `before_request()` logic, `_connection_hint()` helper |
67+
| `python/rcdb/web/templates/layouts/base.html` | Navbar `<select>` element and JS cookie handler |
68+
| `python/rcdb/cli/web.py` | `--add-db` CLI option parsing |

docs/development/development.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,12 @@ https://github.com/JeffersonLab/rcdb
3636
4. Run ```test_all_rcdb```
3737

3838

39+
## Multi-Database Selector
40+
41+
The web interface supports switching between multiple databases from the browser.
42+
See [Database Selector](development/database-selector.md) for configuration details.
43+
44+
3945
## Publishing on pypi
4046

4147
```bash

python/rcdb/cli/web.py

Lines changed: 45 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,22 +6,61 @@
66

77

88
@click.command("web")
9+
@click.option('--add-db', multiple=True,
10+
help='Add a named database as NAME=CONNECTION_STRING. Can be specified multiple times.')
911
@pass_rcdb_context
10-
def web_command(context):
12+
def web_command(context, add_db):
1113
"""
1214
Runs the local RCDB web application using the connection string from
1315
either the CLI context or the RCDB_CONNECTION environment variable.
16+
17+
Use --add-db to enable a database selector in the web UI:
18+
19+
rcdb -c mysql://rcdb@host/rcdb web \\
20+
--add-db "Production=mysql://rcdb@prodhost/rcdb" \\
21+
--add-db "Test=mysql://rcdb@testhost/rcdb_test"
1422
"""
15-
# If user provided --connection on the CLI, context.db.connection_str is set:
23+
24+
# Resolve the default connection string
1625
rcdb_provider = context.db
1726
if rcdb_provider and rcdb_provider.connection_string:
1827
assert isinstance(rcdb_provider, RCDBProvider)
19-
rcdb_web.app.config["SQL_CONNECTION_STRING"] = rcdb_provider.connection_string
28+
connection_string = rcdb_provider.connection_string
2029
elif "RCDB_CONNECTION" in os.environ:
21-
# Otherwise check the environment variable
22-
rcdb_web.app.config["SQL_CONNECTION_STRING"] = os.environ["RCDB_CONNECTION"]
30+
connection_string = os.environ["RCDB_CONNECTION"]
31+
else:
32+
connection_string = None
33+
34+
# Parse --add-db entries into AVAILABLE_DATABASES
35+
if add_db:
36+
available = {}
37+
for entry in add_db:
38+
if '=' not in entry:
39+
click.echo(f"ERROR: --add-db value must be NAME=CONNECTION_STRING, got: {entry}")
40+
raise SystemExit(1)
41+
name, conn = entry.split('=', 1)
42+
name = name.strip()
43+
conn = conn.strip()
44+
if not name or not conn:
45+
click.echo(f"ERROR: --add-db value must have non-empty NAME and CONNECTION_STRING: {entry}")
46+
raise SystemExit(1)
47+
available[name] = conn
48+
49+
rcdb_web.app.config["AVAILABLE_DATABASES"] = available
50+
51+
if connection_string:
52+
rcdb_web.app.config["DEFAULT_DATABASE"] = connection_string
53+
else:
54+
# No -c provided, use the first --add-db entry as default
55+
first_conn = next(iter(available.values()))
56+
rcdb_web.app.config["DEFAULT_DATABASE"] = first_conn
57+
connection_string = first_conn
58+
59+
# SQL_CONNECTION_STRING is still needed as a fallback
60+
rcdb_web.app.config["SQL_CONNECTION_STRING"] = connection_string
61+
elif connection_string:
62+
rcdb_web.app.config["SQL_CONNECTION_STRING"] = connection_string
2363
else:
24-
# If neither is present, show an error and exit
2564
click.echo("ERROR: no connection string found. Provide via CLI or RCDB_CONNECTION env variable.")
2665
raise SystemExit(1)
2766

python/rcdb/web/__init__.py

Lines changed: 69 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
1+
import logging
12
import os
23
from datetime import datetime
4+
from urllib.parse import urlparse
5+
36
from flask import Flask, render_template, g, request, url_for
47
from sqlalchemy.orm import subqueryload
58

@@ -15,11 +18,38 @@
1518
from rcdb.web.modules import conditions_module
1619
from rcdb.web.modules import select_values_module
1720

21+
logger = logging.getLogger(__name__)
22+
1823
DEBUG = True
1924
SECRET_KEY = 'development key'
2025
USERNAME = 'admin'
2126
PASSWORD = 'default'
2227
SQL_CONNECTION_STRING = "mysql+pymysql://rcdb@127.0.0.1/rcdb"
28+
AVAILABLE_DATABASES = {} # dict of {"name": "connection_string", ...}
29+
DEFAULT_DATABASE = "" # connection string used as default
30+
31+
32+
def _connection_hint(conn_str):
33+
"""Extract a short hint from a SQLAlchemy connection string.
34+
35+
Examples:
36+
"mysql+pymysql://rcdb@127.0.0.1/rcdb" -> "rcdb@127.0.0.1"
37+
"sqlite:///path/to/file.db" -> "file.db"
38+
"""
39+
try:
40+
parsed = urlparse(conn_str)
41+
if parsed.scheme.startswith("sqlite"):
42+
# For sqlite, show just the filename
43+
path = parsed.path.lstrip("/")
44+
return os.path.basename(path) if path else conn_str
45+
# For mysql/postgres etc, show user@host
46+
host = parsed.hostname or ""
47+
user = parsed.username or ""
48+
if user:
49+
return f"{user}@{host}"
50+
return host or conn_str
51+
except Exception:
52+
return conn_str
2353

2454

2555
# Get the current directory
@@ -33,8 +63,45 @@
3363

3464
@app.before_request
3565
def before_request():
66+
available_dbs = app.config.get("AVAILABLE_DATABASES", {})
67+
68+
if available_dbs:
69+
# Determine which database to connect to
70+
cookie_db = request.cookies.get("rcdb_database", "")
71+
default_conn = app.config.get("DEFAULT_DATABASE", "")
72+
73+
if cookie_db and cookie_db in available_dbs:
74+
# Cookie points to a valid database
75+
active_name = cookie_db
76+
elif default_conn:
77+
# Find name for the default connection string
78+
active_name = None
79+
for name, conn in available_dbs.items():
80+
if conn == default_conn:
81+
active_name = name
82+
break
83+
if active_name is None:
84+
# DEFAULT_DATABASE not in AVAILABLE_DATABASES
85+
active_name = next(iter(available_dbs))
86+
logger.warning(
87+
"DEFAULT_DATABASE '%s' is not in AVAILABLE_DATABASES, "
88+
"using '%s' instead.", default_conn, active_name
89+
)
90+
else:
91+
# No default set, use first available
92+
active_name = next(iter(available_dbs))
93+
94+
connection_string = available_dbs[active_name]
95+
g.active_db_name = active_name
96+
g.available_databases = available_dbs
97+
else:
98+
# Original single-database behavior
99+
connection_string = app.config["SQL_CONNECTION_STRING"]
100+
g.active_db_name = None
101+
g.available_databases = {}
102+
36103
g.tdb = rcdb.ConfigurationProvider()
37-
g.tdb.connect(app.config["SQL_CONNECTION_STRING"])
104+
g.tdb.connect(connection_string)
38105
app.jinja_env.globals['datetime_now'] = datetime.now
39106

40107

@@ -101,6 +168,7 @@ def url_for_other_page(page):
101168

102169
app.jinja_env.globals['url_for_other_page'] = url_for_other_page
103170
app.jinja_env.globals['rcdb_default_alias'] = rcdb.alias.default_aliases
171+
app.jinja_env.globals['connection_hint'] = _connection_hint
104172

105173
app.register_blueprint(runs_module)
106174
app.register_blueprint(logs_module)

python/rcdb/web/templates/layouts/base.html

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,13 @@
7272
<!-- Search form -->
7373
<form class="navbar-form navbar-left" role="search" action="{{ url_for('runs.search') }}" method="get">
7474
<div class="input-group input-group-sm" style="min-width: 500px">
75+
{% if g.available_databases %}
76+
<select id="dbSelector" class="form-control" style="width:auto; margin-right:4px;">
77+
{% for name, conn in g.available_databases.items() %}
78+
<option value="{{ name }}" {% if name == g.active_db_name %}selected{% endif %}>{{ name }} ({{ connection_hint(conn) }})</option>
79+
{% endfor %}
80+
</select>
81+
{% endif %}
7582
<input type="text" class="form-control" placeholder="Run or min-max" name="rr" style="width:28%; margin-right:4px;">
7683
<input type="text" class="form-control" placeholder="Query" name="q" style="width:70%; ">
7784
<span class="input-group-btn"><button id="qGoBtn" type="submit" class="btn btn-default">GO</button></span>
@@ -269,6 +276,13 @@
269276

270277
var $editor = $('#editor');
271278

279+
// Database selector: save choice in cookie and reload
280+
$("#dbSelector").on("change", function() {
281+
var dbName = $(this).val();
282+
document.cookie = "rcdb_database=" + encodeURIComponent(dbName) + ";path=/;max-age=31536000;SameSite=Lax";
283+
location.reload();
284+
});
285+
272286
{#%
273287
Show help popover about how to use query window
274288
%#}

0 commit comments

Comments
 (0)