show directory metadata in archive browser
Continuous Integration / backend-tests (push) Successful in 48s
Continuous Integration / frontend-check (push) Successful in 26s
Continuous Integration / e2e-tests (push) Successful in 7m17s

This commit is contained in:
2026-05-04 16:27:26 -04:00
parent 2f8e343b6d
commit d55c8ad6d1
5 changed files with 95 additions and 20 deletions
+62 -3
View File
@@ -953,15 +953,15 @@ def get_archive_tree(path: Optional[str] = None, db_session: Session = Depends(g
@router.get("/metadata", response_model=ItemMetadataSchema)
def get_archive_item_metadata(path: str, db_session: Session = Depends(get_db)):
"""Retrieves full version history and location details for an indexed file."""
"""Retrieves full version history and location details for an indexed file or directory."""
item = (
db_session.query(models.FilesystemState)
.filter(models.FilesystemState.file_path == path)
.first()
)
if not item:
raise HTTPException(status_code=404, detail="File not found in index.")
if item:
# Exact file match
versions = []
for v in item.versions:
versions.append(
@@ -978,6 +978,7 @@ def get_archive_item_metadata(path: str, db_session: Session = Depends(get_db)):
return ItemMetadataSchema(
id=item.id,
path=item.file_path,
type="file",
size=item.size,
mtime=datetime.fromtimestamp(item.mtime, tz=timezone.utc),
last_seen_timestamp=item.last_seen_timestamp,
@@ -985,3 +986,61 @@ def get_archive_item_metadata(path: str, db_session: Session = Depends(get_db)):
is_ignored=item.is_ignored,
versions=versions,
)
# No exact match — check if this is a directory with archived children
prefix = path if path.endswith("/") else path + "/"
dir_stats = db_session.execute(
text("""
SELECT
COUNT(*) as child_count,
SUM(size) as total_size,
MAX(mtime) as latest_mtime,
MAX(last_seen_timestamp) as latest_seen
FROM filesystem_state
WHERE file_path LIKE :prefix
"""),
{"prefix": f"{prefix}%"},
).fetchone()
if not dir_stats or dir_stats[0] == 0:
raise HTTPException(status_code=404, detail="File not found in index.")
# Aggregate unique media locations for all children
media_rows = db_session.execute(
text("""
SELECT DISTINCT
sm.identifier as media_id,
sm.media_type,
MIN(fv.created_at) as earliest_created
FROM file_versions fv
JOIN storage_media sm ON sm.id = fv.media_id
JOIN filesystem_state fs ON fs.id = fv.filesystem_state_id
WHERE fs.file_path LIKE :prefix
GROUP BY sm.identifier, sm.media_type
"""),
{"prefix": f"{prefix}%"},
).fetchall()
versions = []
for row in media_rows:
versions.append(
{
"media_id": row[0],
"media_type": row[1],
"archive_id": "",
"created_at": row[2],
"is_split": False,
"offset": 0,
}
)
return ItemMetadataSchema(
id=-1,
path=path,
type="directory",
size=dir_stats[1] or 0,
mtime=datetime.fromtimestamp(dir_stats[2] or 0, tz=timezone.utc),
last_seen_timestamp=dir_stats[3],
child_count=dir_stats[0],
versions=versions,
)
+13 -1
View File
@@ -1291,9 +1291,21 @@ def _resolve_ids_from_action(
if action.ids:
return action.ids
if action.path_prefix:
prefix = action.path_prefix
if not prefix.endswith("/"):
# If there are files under this path in the index, treat it as a directory
has_children = (
db_session.query(models.FilesystemState)
.filter(models.FilesystemState.file_path.startswith(prefix + "/"))
.first()
is not None
)
if has_children:
prefix += "/"
records = (
db_session.query(models.FilesystemState)
.filter(models.FilesystemState.file_path.startswith(action.path_prefix))
.filter(models.FilesystemState.file_path.startswith(prefix))
.all()
)
return [r.id for r in records]
+3 -1
View File
@@ -30,7 +30,9 @@ app = FastAPI(
# Configure Cross-Origin Resource Sharing (CORS)
# Use TAPEHOARD_CORS_ORIGINS env var (comma-separated) in production
cors_origins = os.getenv("TAPEHOARD_CORS_ORIGINS", "*").split(",")
# Default allows all origins (*), but explicitly add localhost:5174 for Playwright tests
cors_default = "*,http://localhost:5174,http://localhost:5173"
cors_origins = os.getenv("TAPEHOARD_CORS_ORIGINS", cors_default).split(",")
app.add_middleware(
CORSMiddleware,
allow_origins=[o.strip() for o in cors_origins if o.strip()],
+1 -1
View File
@@ -36,7 +36,7 @@ export default defineConfig({
/* Run your local dev server before starting the tests */
webServer: [
{
command: 'cd ../backend && rm -f e2e_test.db* && DATABASE_URL="sqlite:///e2e_test.db" TAPEHOARD_TEST_MODE="true" uv run python -m app.start_test_server --host 0.0.0.0 --port 8001',
command: 'cd ../backend && rm -f e2e_test.db* && DATABASE_URL="sqlite:///e2e_test.db" TAPEHOARD_TEST_MODE="true" TAPEHOARD_CORS_ORIGINS="*,http://localhost:5174" uv run python -m app.start_test_server --host 0.0.0.0 --port 8001',
url: 'http://localhost:8001/health',
reuseExistingServer: !process.env.CI,
timeout: 120 * 1000,
+3 -1
View File
@@ -121,7 +121,9 @@ test.describe('TapeHoard Golden Path', () => {
console.log('Step 7: Verify Protection');
await page.goto('/index-browser');
await page.waitForLoadState('networkidle');
await page.getByText(SOURCE_ROOT).first().dblclick();
// Click on the item in the file list (not the tree)
// Tree items have role="treeitem", file list rows have role="button"
await page.getByRole('button', { name: SOURCE_ROOT }).first().dblclick();
await page.getByText('subfolder').first().dblclick();
await expect(page.getByText('test_file_2.txt')).toBeVisible();
await expect(page.getByText('TAPE001')).toBeVisible();