diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1ab73f3..5df7c79 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -88,10 +88,11 @@ jobs: } trap cleanup EXIT - # entrypoint.sh only needs DATA_PATH to exist and to be readable. + # Exercise the preconfigured-path mode against the mounted data directory. # Docker picks the host port, so the runner needs no free port of ours. mkdir -p /tmp/smoke-data docker run -d --name smoke -p 127.0.0.1::8080 \ + -e DATA_PATH=/data \ -v /tmp/smoke-data:/data:ro \ smoke:${{ matrix.lancedb }} diff --git a/CHANGELOG.md b/CHANGELOG.md index 244bde9..86dc478 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- Dataset locations can be selected in the UI when `DATA_PATH` is unset. Tables can be opened at main, a numeric version, or a tag. - 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/CONTRIBUTING.md b/CONTRIBUTING.md index 7c620cc..4488bea 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -97,7 +97,7 @@ docker build -f docker/Dockerfile \ -t lance-data-viewer:dev . # Run with your data -docker run --rm -p 8080:8080 -v $(pwd)/data:/data:ro lance-data-viewer:dev +docker run --rm -p 8080:8080 -e DATA_PATH=/data -v $(pwd)/data:/data:ro lance-data-viewer:dev # Open the UI open http://localhost:8080 diff --git a/README.md b/README.md index ee8248d..594e35e 100644 --- a/README.md +++ b/README.md @@ -26,10 +26,15 @@ chmod -R o+rx /path/to/your/lance ```bash docker run --rm -p 8080:8080 \ + -e DATA_PATH=/data \ -v /path/to/your/lance:/data:ro \ ghcr.io/lance-format/lance-data-viewer:lancedb-0.33.0 ``` +Alternatively, omit `DATA_PATH`/the `/data` mount and enter a database +location in the web UI. The location can be a local path available to the +viewer process or an object-store URI supported by LanceDB. + 4. **Open the UI** ``` @@ -63,11 +68,13 @@ If you have datasets created with older Lance versions: ```bash # For datasets created with Lance 0.16.x docker run --rm -p 8080:8080 \ + -e DATA_PATH=/data \ -v /path/to/your/old/lance/data:/data:ro \ ghcr.io/lance-format/lance-data-viewer:lancedb-0.16.0 # For very old datasets (Lance 0.3.x era) docker run --rm -p 8080:8080 \ + -e DATA_PATH=/data \ -v /path/to/your/legacy/data:/data:ro \ ghcr.io/lance-format/lance-data-viewer:lancedb-0.3.4 ``` @@ -77,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 @@ -87,12 +95,19 @@ docker run --rm -p 8080:8080 \ | Variable | Default | Description | |----------|---------|-------------| -| `DATA_PATH` | `/data` | Directory containing Lance tables | +| `DATA_PATH` | unset | Directory containing Lance tables; the UI asks when unset | | `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 not set, the UI requires a Lance database location before +loading 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: @@ -140,7 +155,7 @@ docker build -f docker/Dockerfile --build-arg LANCEDB_VERSION=0.3.4 -t lance-dat chmod -R o+rx data # Run with your data (replace 'data' with your lance folder path) -docker run --rm -p 8080:8080 -v $(pwd)/data:/data:ro lance-data-viewer:dev +docker run --rm -p 8080:8080 -e DATA_PATH=/data -v $(pwd)/data:/data:ro lance-data-viewer:dev # Open the web interface open http://localhost:8080 @@ -176,7 +191,7 @@ docker build -f docker/Dockerfile \ -t lance-data-viewer:dev . # Run in background -docker run --rm -d -p 8080:8080 -v $(pwd)/data:/data:ro lance-data-viewer:dev +docker run --rm -d -p 8080:8080 -e DATA_PATH=/data -v $(pwd)/data:/data:ro lance-data-viewer:dev # View logs docker logs $(docker ps -q --filter ancestor=lance-data-viewer:dev) @@ -217,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 +- Without `DATA_PATH`, the UI can request locations accessible to the server; do not expose that mode to untrusted users ### Contributing diff --git a/backend/app.py b/backend/app.py index 226702e..75f0b89 100644 --- a/backend/app.py +++ b/backend/app.py @@ -55,9 +55,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 +69,119 @@ 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 get_lance_connection(data_location: Optional[str] = None): + """Connect to the configured database, or a location supplied by the UI.""" + location = str(DATA_PATH).strip() if DATA_PATH is not None else "" + if not location: + location = (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", + ) + 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 +338,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 +359,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 +433,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 +534,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..c63e687 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -36,6 +36,94 @@ 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_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 +160,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 +173,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/Dockerfile b/docker/Dockerfile index db06ce4..fd07d84 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -33,7 +33,6 @@ RUN chmod -R 755 /web USER appuser ENV PATH=/home/appuser/.local/bin:$PATH -ENV DATA_PATH=/data ENV PORT=8080 EXPOSE 8080 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; }