diff --git a/CHANGELOG.md b/CHANGELOG.md index fce00d8..d5adba2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- Tables can be opened at main, a numeric version, or a tag (#83). +- Dataset locations can be selected in the UI when `DATA_PATH` is set to an empty value (#83). - CI smoke test. Each build starts the image it just built and checks `/healthz`, `/datasets`, and the static files, before the image is published (#67). ### Changed diff --git a/README.md b/README.md index bea0d4f..bc6719e 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,12 @@ docker run --rm -p 8080:8080 \ ghcr.io/lance-format/lance-data-viewer:lancedb-0.36.0 ``` +To enter the database location in the web UI instead of mounting it, set +`DATA_PATH` to an empty value with `-e DATA_PATH=`. The location can be a local +path that the viewer process can read, or an object store URI that LanceDB +supports. Only enable this mode for users you trust, because they can then read +any Lance dataset the server can reach. + 4. **Open the UI** ``` @@ -78,6 +84,7 @@ docker run --rm -p 8080:8080 \ ### Features - **Read-only browsing** with organized left sidebar (Datasets → Columns → Schema) +- **Version browsing** for main, numeric versions, and tags - **Advanced vector visualization** with CLIP embedding detection and sparkline charts - **Schema analysis** with vector column highlighting and type detection - **Server-side pagination** with inline controls and column filtering @@ -88,12 +95,19 @@ docker run --rm -p 8080:8080 \ | Variable | Default | Description | |----------|---------|-------------| -| `DATA_PATH` | `/data` | Directory containing Lance tables | +| `DATA_PATH` | `/data` | Directory containing Lance tables. Set it empty to ask in the UI | | `PORT` | `8080` | Port the server listens on | - **Port:** change host port with `-p 9000:8080`, or set `PORT` env var to change the container's listening port. - **Read-only mount:** keep `:ro` to avoid accidental writes in future versions. +When `DATA_PATH` is empty, the UI asks for a Lance database location before it +loads tables. The reference field accepts: + +- `main` for the latest snapshot on the main branch (default) +- `42` for version 42 on main +- `tag:release` for a tag + ### Docker Compose For pipelines or multi-container setups where lance-data-viewer shares a data volume with other services: @@ -218,6 +232,7 @@ The viewer provides advanced visualization for vector embeddings: - Container runs as non-root - No authentication; bind to localhost during development and run behind a reverse proxy if exposing - Read-only access prevents accidental data modification +- With an empty `DATA_PATH`, the UI can open any location the server can read; do not expose that mode to untrusted users ### Contributing diff --git a/backend/app.py b/backend/app.py index 226702e..546ffeb 100644 --- a/backend/app.py +++ b/backend/app.py @@ -5,6 +5,7 @@ import logging from pathlib import Path from typing import Dict, List, Optional, Any +from urllib.parse import urlparse import json import lancedb @@ -55,9 +56,13 @@ async def lifespan(_app: FastAPI): allow_headers=["*"], ) -DATA_PATH = Path(os.getenv("DATA_PATH", "/data")) +DATA_PATH = os.getenv("DATA_PATH") MAX_LIMIT = 1000 + +class InvalidDatasetReference(ValueError): + """Raised when a requested branch, tag, or version cannot be opened.""" + def validate_dataset_name(name: str) -> bool: return ( name.replace("_", "").replace("-", "").isalnum() @@ -65,10 +70,144 @@ def validate_dataset_name(name: str) -> bool: and len(name) <= 100 ) -def get_lance_connection(): - if not DATA_PATH.exists(): - raise HTTPException(status_code=500, detail="Data path not found") - return lancedb.connect(str(DATA_PATH)) +def local_database_path(location: str) -> Optional[Path]: + """Return the filesystem path for a local location, or None if it is remote. + + Object store URIs such as s3:// are opened by LanceDB without touching the + local filesystem, so only plain paths and file: URIs are local. + """ + scheme = urlparse(location).scheme + if scheme and scheme != "file" and len(scheme) > 1: + return None + if location.startswith("file://"): + return Path(location[7:]) + if location.startswith("file:"): + return Path(location[5:]) + return Path(location) + + +def get_lance_connection(data_location: Optional[str] = None): + """Connect to the configured database, or a location supplied by the UI.""" + configured = str(DATA_PATH).strip() if DATA_PATH is not None else "" + location = configured or (data_location or "").strip() + if not location: + raise HTTPException( + status_code=400, + detail="A Lance dataset location is required when DATA_PATH is not set", + ) + # lancedb.connect() creates a local directory that does not exist. The + # viewer never writes to Lance data, so refuse instead of creating one. + path = local_database_path(location) + if path is not None and not path.expanduser().is_dir(): + if configured: + raise HTTPException(status_code=500, detail="Data path not found") + raise HTTPException( + status_code=400, + detail=f"Lance database location not found: {location}", + ) + return lancedb.connect(location) + + +def _checkout(table, reference): + """Checkout a tag/version while retaining support for older LanceDB clients.""" + checkout = getattr(table, "checkout", None) + if checkout is None: + raise InvalidDatasetReference( + "This LanceDB version does not support tag or version checkout" + ) + checkout(reference) + return table + + +def open_table_at_reference(db, dataset_name: str, reference: str = "main"): + """Open a table at main/latest, a version, tag, or branch reference. + + Accepted forms are ``main``, ``42``, ``tag:release``, + ``branch:experiment``, and ``branch:experiment@42``. A bare name is + accepted as a convenience and resolves as a branch first, then as a tag. + """ + value = (reference or "main").strip() + if not value or value in {"main", "latest"}: + return db.open_table(dataset_name) + + if value.isdigit(): + version = int(value) + try: + return db.open_table(dataset_name, version=version) + except TypeError: + return _checkout(db.open_table(dataset_name), version) + except Exception as error: + raise InvalidDatasetReference( + f"Unable to open main at version {version}: {error}" + ) from error + + if value.startswith("tag:"): + tag = value.removeprefix("tag:").strip() + if not tag: + raise InvalidDatasetReference("Tag name cannot be empty") + try: + return _checkout(db.open_table(dataset_name), tag) + except InvalidDatasetReference: + raise + except Exception as error: + raise InvalidDatasetReference(f"Unable to open tag '{tag}': {error}") from error + + explicit_branch = value.startswith("branch:") + branch_reference = value.removeprefix("branch:").strip() if explicit_branch else value + branch, separator, version_text = branch_reference.rpartition("@") + if not separator: + branch = branch_reference + version = None + else: + if not branch or not version_text.isdigit(): + raise InvalidDatasetReference( + "Branch versions must use branch:name@" + ) + version = int(version_text) + + try: + kwargs = {"branch": branch} + if version is not None: + kwargs["version"] = version + return db.open_table(dataset_name, **kwargs) + except Exception as branch_error: + branch_unsupported = ( + isinstance(branch_error, TypeError) + and "branch" in str(branch_error) + ) + if explicit_branch or version is not None: + if branch_unsupported: + raise InvalidDatasetReference( + f"Branch selection is not supported by LanceDB {lancedb.__version__}" + ) from branch_error + raise InvalidDatasetReference( + f"Unable to open branch '{branch_reference}': {branch_error}" + ) from branch_error + + # A bare name may be either a branch or a tag. Branches take priority. + try: + return _checkout(db.open_table(dataset_name), value) + except Exception as tag_error: + if branch_unsupported: + raise InvalidDatasetReference( + f"No tag named '{value}' was found, and branch selection is " + f"not supported by LanceDB {lancedb.__version__}" + ) from tag_error + raise InvalidDatasetReference( + f"Unable to open branch or tag '{value}': {tag_error}" + ) from branch_error + + +def get_dataset_table( + dataset_name: str, + data_location: Optional[str], + reference: str, +): + db = get_lance_connection(data_location) + try: + return open_table_at_reference(db, dataset_name, reference) + except InvalidDatasetReference as error: + raise HTTPException(status_code=400, detail=str(error)) from error def serialize_schema_metadata(metadata): @@ -225,10 +364,19 @@ async def health_check(): logger.error(f"Error in health check: {e}") return {"ok": False, "error": str(e)} + +@app.get("/config") +def get_config(): + return { + "data_path_configured": bool(DATA_PATH), + "default_reference": "main", + } + + @app.get("/datasets") -def list_datasets(): +def list_datasets(data_location: Optional[str] = Query(default=None)): try: - db = get_lance_connection() + db = get_lance_connection(data_location) if hasattr(db, "list_tables"): table_names = db.list_tables().tables else: @@ -237,54 +385,71 @@ def list_datasets(): table_names = db.table_names() valid_tables = [name for name in table_names if validate_dataset_name(name)] return {"datasets": valid_tables} + except HTTPException: + raise except Exception as e: logger.error(f"Error listing datasets: {e}") raise HTTPException(status_code=500, detail="Failed to list datasets") @app.get("/datasets/{dataset_name}/metadata") -def get_dataset_metadata(dataset_name: str): +def get_dataset_metadata( + dataset_name: str, + data_location: Optional[str] = Query(default=None), + reference: str = Query(default="main"), +): if not validate_dataset_name(dataset_name): raise HTTPException(status_code=400, detail="Invalid dataset name") try: - db = get_lance_connection() - table = db.open_table(dataset_name) + table = get_dataset_table(dataset_name, data_location, reference) return describe_schema(table.schema) + except HTTPException: + raise except Exception as e: logger.error(f"Error getting metadata for {dataset_name}: {e}") raise HTTPException(status_code=500, detail="Failed to get dataset metadata") @app.get("/datasets/{dataset_name}/schema") -def get_dataset_schema(dataset_name: str): +def get_dataset_schema( + dataset_name: str, + data_location: Optional[str] = Query(default=None), + reference: str = Query(default="main"), +): if not validate_dataset_name(dataset_name): raise HTTPException(status_code=400, detail="Invalid dataset name") try: - db = get_lance_connection() - table = db.open_table(dataset_name) + table = get_dataset_table(dataset_name, data_location, reference) description = describe_schema(table.schema) return { "fields": description["fields"], "metadata": description["metadata"], } + except HTTPException: + raise except Exception as e: logger.error(f"Error getting schema for {dataset_name}: {e}") raise HTTPException(status_code=500, detail="Failed to get dataset schema") @app.get("/datasets/{dataset_name}/columns") -def get_dataset_columns(dataset_name: str): +def get_dataset_columns( + dataset_name: str, + data_location: Optional[str] = Query(default=None), + reference: str = Query(default="main"), +): if not validate_dataset_name(dataset_name): raise HTTPException(status_code=400, detail="Invalid dataset name") try: - db = get_lance_connection() - table = db.open_table(dataset_name) + table = get_dataset_table(dataset_name, data_location, reference) description = describe_schema(table.schema) return {"columns": description["columns"]} + except HTTPException: + raise except Exception as e: logger.error(f"Error getting columns for {dataset_name}: {e}") raise HTTPException(status_code=500, detail="Failed to get dataset columns") @@ -294,14 +459,15 @@ def get_dataset_rows( dataset_name: str, limit: int = Query(default=50, ge=1, le=MAX_LIMIT), offset: int = Query(default=0, ge=0), - columns: Optional[str] = Query(default=None) + columns: Optional[str] = Query(default=None), + data_location: Optional[str] = Query(default=None), + reference: str = Query(default="main"), ): if not validate_dataset_name(dataset_name): raise HTTPException(status_code=400, detail="Invalid dataset name") try: - db = get_lance_connection() - table = db.open_table(dataset_name) + table = get_dataset_table(dataset_name, data_location, reference) column_list = None if columns: @@ -394,14 +560,15 @@ def get_dataset_rows( def get_vector_preview( dataset_name: str, column: str, - limit: int = Query(default=100, le=MAX_LIMIT) + limit: int = Query(default=100, le=MAX_LIMIT), + data_location: Optional[str] = Query(default=None), + reference: str = Query(default="main"), ): if not validate_dataset_name(dataset_name): raise HTTPException(status_code=400, detail="Invalid dataset name") try: - db = get_lance_connection() - table = db.open_table(dataset_name) + table = get_dataset_table(dataset_name, data_location, reference) if column not in [field.name for field in table.schema]: raise HTTPException(status_code=400, detail=f"Column '{column}' not found") diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index bb39b5c..f71d3f6 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -7,6 +7,7 @@ import base64 import inspect +from pathlib import Path from types import SimpleNamespace import lancedb @@ -36,6 +37,125 @@ def test_healthz_compat_flags(client): assert compat["lance_v2_format"] == (installed >= parse_version("0.16")) +# /config and dataset selection + +def test_config_reports_environment_data_path(client): + body = client.get("/config").json() + assert body == { + "data_path_configured": True, + "default_reference": "main", + } + + +def test_dataset_location_required_without_environment(client, monkeypatch): + import app as app_module + + monkeypatch.setattr(app_module, "DATA_PATH", None) + response = client.get("/datasets") + assert response.status_code == 400 + assert "location is required" in response.json()["detail"] + + +def test_query_dataset_location_used_without_environment(client, monkeypatch, data_dir): + import app as app_module + + monkeypatch.setattr(app_module, "DATA_PATH", None) + response = client.get("/datasets", params={"data_location": str(data_dir)}) + assert response.status_code == 200 + assert "sample" in response.json()["datasets"] + + +def test_missing_location_is_rejected_and_not_created(client, monkeypatch, tmp_path): + import app as app_module + + missing = tmp_path / "not-a-database" + monkeypatch.setattr(app_module, "DATA_PATH", None) + response = client.get("/datasets", params={"data_location": str(missing)}) + + assert response.status_code == 400 + assert "not found" in response.json()["detail"] + assert not missing.exists() + + +def test_missing_configured_data_path_is_a_server_error(client, monkeypatch, tmp_path): + import app as app_module + + monkeypatch.setattr(app_module, "DATA_PATH", tmp_path / "gone") + response = client.get("/datasets") + + assert response.status_code == 500 + assert not (tmp_path / "gone").exists() + + +def test_remote_locations_skip_the_local_path_check(): + import app as app_module + + assert app_module.local_database_path("s3://bucket/tables") is None + assert app_module.local_database_path("gs://bucket/tables") is None + assert app_module.local_database_path("/srv/lance") == Path("/srv/lance") + assert app_module.local_database_path("file:///srv/lance") == Path("/srv/lance") + + +def test_open_table_reference_forms(client): + import app as app_module + + calls = [] + checked_out = [] + + class Table: + def checkout(self, reference): + checked_out.append(reference) + + class Database: + def open_table(self, name, **kwargs): + calls.append((name, kwargs)) + return Table() + + db = Database() + app_module.open_table_at_reference(db, "sample", "main") + app_module.open_table_at_reference(db, "sample", "42") + app_module.open_table_at_reference(db, "sample", "tag:release") + app_module.open_table_at_reference(db, "sample", "branch:experiment") + app_module.open_table_at_reference(db, "sample", "branch:experiment@7") + + assert calls == [ + ("sample", {}), + ("sample", {"version": 42}), + ("sample", {}), + ("sample", {"branch": "experiment"}), + ("sample", {"branch": "experiment", "version": 7}), + ] + assert checked_out == ["release"] + + +def test_version_checkout_falls_back_for_older_lancedb(client): + import app as app_module + + checked_out = [] + + class Table: + def checkout(self, reference): + checked_out.append(reference) + + class LegacyDatabase: + def open_table(self, name, **kwargs): + if kwargs: + raise TypeError("unexpected keyword argument 'version'") + return Table() + + app_module.open_table_at_reference(LegacyDatabase(), "sample", "3") + assert checked_out == [3] + + +def test_invalid_branch_version_returns_400(client): + response = client.get( + "/datasets/sample/metadata", + params={"reference": "branch:experiment@not-a-version"}, + ) + assert response.status_code == 400 + assert "branch:name@" in response.json()["detail"] + + # /datasets def test_datasets_lists_created_tables(client): @@ -72,7 +192,7 @@ def test_metadata_serializes_utf8_and_binary_schema_metadata(client, monkeypatch ) table = SimpleNamespace(schema=schema) db = SimpleNamespace(open_table=lambda _name: table) - monkeypatch.setattr(app_module, "get_lance_connection", lambda: db) + monkeypatch.setattr(app_module, "get_lance_connection", lambda _location=None: db) response = client.get("/datasets/sample/metadata") assert response.status_code == 200 @@ -85,6 +205,7 @@ def test_dataset_io_handlers_are_synchronous(): import app as app_module handlers = ( + app_module.get_config, app_module.list_datasets, app_module.get_dataset_metadata, app_module.get_dataset_schema, diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index 2d2c8fe..78db5bc 100755 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -1,19 +1,21 @@ #!/bin/bash set -Eeuo pipefail -if [ ! -d "$DATA_PATH" ]; then - echo "ERROR: Data path $DATA_PATH does not exist or is not mounted" - exit 1 -fi +if [ -n "${DATA_PATH:-}" ]; then + if [ ! -d "$DATA_PATH" ]; then + echo "ERROR: Data path $DATA_PATH does not exist or is not mounted" + exit 1 + fi -if [ ! -r "$DATA_PATH" ]; then - echo "ERROR: Data path $DATA_PATH is not readable" - exit 1 + if [ ! -r "$DATA_PATH" ]; then + echo "ERROR: Data path $DATA_PATH is not readable" + exit 1 + fi fi PORT="${PORT:-8080}" echo "Starting Lance Viewer on port ${PORT}..." -echo "Data path: $DATA_PATH" +echo "Data path: ${DATA_PATH:-select in the web UI}" exec python -m uvicorn app:app --host 0.0.0.0 --port "${PORT}" \ No newline at end of file diff --git a/web/vanilla/app.js b/web/vanilla/app.js index 89a9274..40d62a6 100644 --- a/web/vanilla/app.js +++ b/web/vanilla/app.js @@ -7,11 +7,14 @@ class LanceViewer { this.selectedColumns = []; this.allColumns = []; this.apiBase = window.location.origin; + this.dataPathConfigured = false; + this.currentDataLocation = ''; + this.currentReference = 'main'; this.initializeElements(); this.setupEventListeners(); this.checkHealth(); - this.loadDatasets(); + this.initializeConnection(); } initializeElements() { @@ -38,7 +41,12 @@ class LanceViewer { selectNoneCols: document.getElementById('selectNoneCols'), applyColumns: document.getElementById('applyColumns'), tooltip: document.getElementById('tooltip'), - toggleWordWrap: document.getElementById('toggleWordWrap') + toggleWordWrap: document.getElementById('toggleWordWrap'), + dataLocationField: document.getElementById('dataLocationField'), + dataLocation: document.getElementById('dataLocation'), + datasetReference: document.getElementById('datasetReference'), + openDatasetLocation: document.getElementById('openDatasetLocation'), + connectionError: document.getElementById('connectionError') }; } @@ -54,6 +62,12 @@ class LanceViewer { this.elements.selectAllCols.addEventListener('click', () => this.selectAllColumns()); this.elements.selectNoneCols.addEventListener('click', () => this.selectNoColumns()); this.elements.applyColumns.addEventListener('click', () => this.applyColumnSelection()); + this.elements.openDatasetLocation.addEventListener('click', () => this.applyConnection()); + [this.elements.dataLocation, this.elements.datasetReference].forEach(input => { + input.addEventListener('keydown', (event) => { + if (event.key === 'Enter') this.applyConnection(); + }); + }); document.addEventListener('mousemove', (e) => this.updateTooltipPosition(e)); @@ -66,6 +80,71 @@ class LanceViewer { }); } + async initializeConnection() { + try { + const response = await fetch(`${this.apiBase}/config`); + if (!response.ok) throw new Error('Failed to load configuration'); + const config = await response.json(); + this.dataPathConfigured = config.data_path_configured; + this.currentReference = config.default_reference || 'main'; + this.elements.datasetReference.value = this.currentReference; + + if (this.dataPathConfigured) { + this.elements.dataLocationField.style.display = 'none'; + await this.loadDatasets(); + } else { + this.elements.datasetList.innerHTML = + '
Enter a Lance dataset location above.
'; + this.elements.dataLocation.focus(); + } + } catch (error) { + this.showConnectionError(error.message); + } + } + + async applyConnection() { + const dataLocation = this.elements.dataLocation.value.trim(); + if (!this.dataPathConfigured && !dataLocation) { + this.showConnectionError('Lance dataset location is required.'); + this.elements.dataLocation.focus(); + return; + } + + this.currentDataLocation = dataLocation; + this.currentReference = this.elements.datasetReference.value.trim() || 'main'; + this.elements.datasetReference.value = this.currentReference; + this.elements.connectionError.textContent = ''; + this.currentDataset = null; + this.elements.datasetHeader.style.display = 'none'; + this.elements.columnSection.style.display = 'none'; + this.elements.schemaSection.style.display = 'none'; + await this.loadDatasets(); + } + + contextParams(includeReference = true) { + const params = new URLSearchParams(); + if (!this.dataPathConfigured && this.currentDataLocation) { + params.set('data_location', this.currentDataLocation); + } + if (includeReference) { + params.set('reference', this.currentReference); + } + return params; + } + + async responseError(response) { + try { + const body = await response.json(); + return body.detail || `API error: ${response.status} ${response.statusText}`; + } catch (_error) { + return `API error: ${response.status} ${response.statusText}`; + } + } + + showConnectionError(message) { + this.elements.connectionError.textContent = message; + } + async checkHealth() { try { const response = await fetch(`${this.apiBase}/healthz`); @@ -95,9 +174,12 @@ class LanceViewer { async loadDatasets() { try { - const response = await fetch(`${this.apiBase}/datasets`); + this.elements.datasetList.innerHTML = '
Loading datasets...
'; + const params = this.contextParams(false); + const suffix = params.toString() ? `?${params}` : ''; + const response = await fetch(`${this.apiBase}/datasets${suffix}`); if (!response.ok) { - throw new Error(`API error: ${response.status} ${response.statusText}`); + throw new Error(await this.responseError(response)); } const data = await response.json(); @@ -112,20 +194,21 @@ class LanceViewer { const item = document.createElement('div'); item.className = 'dataset-item'; item.textContent = dataset; - item.addEventListener('click', () => this.selectDataset(dataset)); + item.addEventListener('click', () => this.selectDataset(dataset, item)); this.elements.datasetList.appendChild(item); }); } catch (error) { this.elements.datasetList.innerHTML = '
Failed to load datasets
'; + this.showConnectionError(error.message); } } - async selectDataset(datasetName) { + async selectDataset(datasetName, selectedItem) { document.querySelectorAll('.dataset-item').forEach(item => { item.classList.remove('active'); }); - event.target.classList.add('active'); + selectedItem.classList.add('active'); this.currentDataset = datasetName; this.currentPage = 0; @@ -133,6 +216,7 @@ class LanceViewer { this.selectedColumns = []; this.elements.datasetTitle.textContent = datasetName; this.elements.datasetHeader.style.display = 'block'; + this.elements.connectionError.textContent = ''; await Promise.all([ this.loadMetadata(), @@ -142,16 +226,20 @@ class LanceViewer { async loadMetadata() { try { - const response = await fetch(`${this.apiBase}/datasets/${this.currentDataset}/metadata`); + const params = this.contextParams(); + const response = await fetch( + `${this.apiBase}/datasets/${encodeURIComponent(this.currentDataset)}/metadata?${params}` + ); if (!response.ok) { - throw new Error(`API error: ${response.status} ${response.statusText}`); + throw new Error(await this.responseError(response)); } const metadata = await response.json(); this.renderSchema(metadata.fields); this.renderColumns(metadata.columns); return true; } catch (error) { - this.showError('Failed to load metadata'); + this.showConnectionError(error.message); + this.showError(error.message); return false; } } @@ -230,14 +318,18 @@ class LanceViewer { limit: this.pageSize.toString(), offset: (this.currentPage * this.pageSize).toString() }); + const context = this.contextParams(); + context.forEach((value, key) => params.set(key, value)); if (this.selectedColumns.length > 0 && this.selectedColumns.length < this.allColumns.length) { params.append('columns', this.selectedColumns.join(',')); } - const response = await fetch(`${this.apiBase}/datasets/${this.currentDataset}/rows?${params}`); + const response = await fetch( + `${this.apiBase}/datasets/${encodeURIComponent(this.currentDataset)}/rows?${params}` + ); if (!response.ok) { - throw new Error(`API error: ${response.status} ${response.statusText}`); + throw new Error(await this.responseError(response)); } const data = await response.json(); @@ -249,7 +341,8 @@ class LanceViewer { } catch (error) { this.hideLoading(); - this.showError('Failed to load data'); + this.showConnectionError(error.message); + this.showError(error.message); return false; } } diff --git a/web/vanilla/index.html b/web/vanilla/index.html index 85cd52d..0e3ea11 100644 --- a/web/vanilla/index.html +++ b/web/vanilla/index.html @@ -15,6 +15,22 @@

Lance Data Viewer

Connecting...
+
+
+ + + Enter a path or URI accessible to the viewer server. +
+
+ + + Use main, a version number, or tag:name. +
+ +
+
+
diff --git a/web/vanilla/styles.css b/web/vanilla/styles.css index 3e2aaca..1633f03 100644 --- a/web/vanilla/styles.css +++ b/web/vanilla/styles.css @@ -69,6 +69,71 @@ header h1 { font-weight: 500; } +.connection-panel { + display: flex; + align-items: flex-start; + gap: 12px; + margin-bottom: 15px; + padding: 16px 20px; + background: white; + border-radius: 8px; + box-shadow: 0 2px 4px rgba(0,0,0,0.1); + flex-wrap: wrap; +} + +.connection-field { + display: flex; + flex: 1 1 300px; + flex-direction: column; + gap: 5px; +} + +.connection-field label { + color: #2c3e50; + font-size: 0.85rem; + font-weight: 600; +} + +.connection-field input { + width: 100%; + padding: 9px 10px; + border: 1px solid #ced4da; + border-radius: 4px; + font-size: 0.9rem; +} + +.connection-field input:focus { + border-color: #3498db; + box-shadow: 0 0 0 2px rgba(52, 152, 219, 0.15); + outline: none; +} + +.connection-field small { + color: #6c757d; + font-size: 0.72rem; +} + +.connection-panel button { + margin-top: 23px; + padding: 9px 20px; + border: 1px solid #2980b9; + border-radius: 4px; + background: #3498db; + color: white; + cursor: pointer; + font-size: 0.9rem; +} + +.connection-panel button:hover { + background: #2980b9; +} + +.connection-error { + flex-basis: 100%; + color: #721c24; + font-size: 0.85rem; +} + .main-content { display: grid; grid-template-columns: 320px 1fr; @@ -382,6 +447,14 @@ table tr:hover { } @media (max-width: 1024px) { + .connection-panel { + align-items: stretch; + } + + .connection-panel button { + margin-top: 0; + } + .main-content { grid-template-columns: 1fr; }