Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}

Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
22 changes: 19 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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**

```
Expand Down Expand Up @@ -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
```
Expand All @@ -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
Expand All @@ -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:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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

Expand Down
185 changes: 163 additions & 22 deletions backend/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,20 +55,133 @@ 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()
and not name.startswith(".")
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@<number>"
)
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):
Expand Down Expand Up @@ -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:
Expand All @@ -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")
Expand All @@ -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:
Expand Down Expand Up @@ -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")
Expand Down
Loading