Fix recipe listing, effects count, and add nav counts to all pages

- Fix list_by_type to return node_id (IPFS CID) instead of local hash
- Fix effects count on home page (count from _effects/ directory)
- Add nav_counts to all page templates (recipes, effects, runs, media, storage)
- Add editable metadata section to cache/media detail page
- Show more metadata on recipe detail page (ID, IPFS CID, step count)
- Update tests for new list_by_type behavior

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
gilesb
2026-01-12 13:30:11 +00:00
parent 9bb1c4278e
commit 98ca2a6c81
11 changed files with 172 additions and 18 deletions

View File

@@ -170,9 +170,14 @@ async def get_nav_counts(actor_id: Optional[str] = None) -> dict:
pass
try:
# Effects are stored in _effects/ directory, not in cache
from pathlib import Path
cache_mgr = get_cache_manager()
effects = cache_mgr.list_by_type('effect')
counts["effects"] = len(effects)
effects_dir = Path(cache_mgr.cache_dir) / "_effects"
if effects_dir.exists():
counts["effects"] = len([d for d in effects_dir.iterdir() if d.is_dir()])
else:
counts["effects"] = 0
except Exception:
pass

View File

@@ -75,10 +75,14 @@ async def get_cached(
if not has_access:
raise HTTPException(403, "Access denied")
from ..dependencies import get_nav_counts
nav_counts = await get_nav_counts(ctx.actor_id)
templates = get_templates(request)
return render(templates, "cache/detail.html", request,
cache=cache_item,
user=ctx,
nav_counts=nav_counts,
active_tab="media",
)
@@ -260,10 +264,14 @@ async def list_media(
if wants_json(request):
return {"items": items, "offset": offset, "limit": limit, "has_more": has_more}
from ..dependencies import get_nav_counts
nav_counts = await get_nav_counts(ctx.actor_id)
templates = get_templates(request)
return render(templates, "cache/media_list.html", request,
items=items,
user=ctx,
nav_counts=nav_counts,
offset=offset,
limit=limit,
has_more=has_more,

View File

@@ -248,10 +248,14 @@ async def get_effect(
return meta
# HTML response
from ..dependencies import get_nav_counts
nav_counts = await get_nav_counts(ctx.actor_id)
templates = get_templates(request)
return render(templates, "effects/detail.html", request,
effect=meta,
user=ctx,
nav_counts=nav_counts,
active_tab="effects",
)
@@ -308,10 +312,14 @@ async def list_effects(
if wants_json(request):
return {"effects": effects}
from ..dependencies import get_nav_counts
nav_counts = await get_nav_counts(ctx.actor_id)
templates = get_templates(request)
return render(templates, "effects/list.html", request,
effects=effects,
user=ctx,
nav_counts=nav_counts,
active_tab="effects",
)

View File

@@ -66,9 +66,14 @@ async def home(request: Request):
except Exception:
pass
try:
# Effects are stored in _effects/ directory, not in cache
from pathlib import Path
from ..dependencies import get_cache_manager
effects = get_cache_manager().list_by_type('effect')
stats["effects"] = len(effects)
effects_dir = Path(get_cache_manager().cache_dir) / "_effects"
if effects_dir.exists():
stats["effects"] = len([d for d in effects_dir.iterdir() if d.is_dir()])
else:
stats["effects"] = 0
except Exception:
pass

View File

@@ -330,10 +330,14 @@ async def list_recipes(
if wants_json(request):
return {"recipes": recipes, "offset": offset, "limit": limit}
from ..dependencies import get_nav_counts
nav_counts = await get_nav_counts(ctx.actor_id)
templates = get_templates(request)
return render(templates, "recipes/list.html", request,
recipes=recipes,
user=ctx,
nav_counts=nav_counts,
active_tab="recipes",
)
@@ -456,11 +460,15 @@ async def get_recipe(
if "sexp" not in recipe:
recipe["sexp"] = "; No S-expression source available"
from ..dependencies import get_nav_counts
nav_counts = await get_nav_counts(ctx.actor_id)
templates = get_templates(request)
return render(templates, "recipes/detail.html", request,
recipe=recipe,
dag_elements=dag_elements,
user=ctx,
nav_counts=nav_counts,
active_tab="recipes",
)

View File

@@ -323,6 +323,10 @@ async def get_run(
if not plan_sexp and plan:
plan_sexp = plan_to_sexp(plan, run.get("recipe_name"))
from ..dependencies import get_nav_counts
user = await get_current_user(request)
nav_counts = await get_nav_counts(user.actor_id if user else None)
templates = get_templates(request)
return render(templates, "runs/detail.html", request,
run=run,
@@ -333,6 +337,7 @@ async def get_run(
output_media_type=output_media_type,
plan_sexp=plan_sexp,
recipe_ipfs_cid=recipe_ipfs_cid,
nav_counts=nav_counts,
active_tab="runs",
)
@@ -418,10 +423,14 @@ async def list_runs(
input_previews.append(preview)
run["input_previews"] = input_previews
from ..dependencies import get_nav_counts
nav_counts = await get_nav_counts(ctx.actor_id)
templates = get_templates(request)
return render(templates, "runs/list.html", request,
runs=runs,
user=ctx,
nav_counts=nav_counts,
offset=offset,
limit=limit,
has_more=has_more,

View File

@@ -66,11 +66,16 @@ async def list_storage(
return {"storages": storages}
# Render HTML template
from ..dependencies import get_nav_counts
nav_counts = await get_nav_counts(ctx.actor_id)
templates = get_templates(request)
return render(templates, "storage/list.html", request,
storages=storages,
user=ctx,
nav_counts=nav_counts,
providers_info=STORAGE_PROVIDERS_INFO,
active_tab="storage",
)
@@ -274,10 +279,15 @@ async def storage_type_page(
storages = await storage_service.list_by_type(ctx.actor_id, provider_type)
provider_info = STORAGE_PROVIDERS_INFO[provider_type]
from ..dependencies import get_nav_counts
nav_counts = await get_nav_counts(ctx.actor_id)
templates = get_templates(request)
return render(templates, "storage/type.html", request,
provider_type=provider_type,
provider_info=provider_info,
storages=storages,
user=ctx,
nav_counts=nav_counts,
active_tab="storage",
)

View File

@@ -39,7 +39,43 @@
{% endif %}
</div>
<!-- Metadata -->
<!-- User Metadata (editable) -->
<div id="metadata-section" class="bg-gray-800 rounded-lg border border-gray-700 p-4 mb-6">
<div class="flex items-center justify-between mb-3">
<h3 class="text-lg font-semibold">Details</h3>
<button hx-get="/cache/{{ cache.cid }}/meta-form"
hx-target="#metadata-section"
hx-swap="innerHTML"
class="text-blue-400 hover:text-blue-300 text-sm">
Edit
</button>
</div>
{% if cache.title or cache.description %}
<div class="space-y-2 mb-4">
{% if cache.title %}
<h4 class="text-white font-medium">{{ cache.title }}</h4>
{% endif %}
{% if cache.description %}
<p class="text-gray-400">{{ cache.description }}</p>
{% endif %}
</div>
{% endif %}
{% if cache.tags %}
<div class="flex flex-wrap gap-2 mb-4">
{% for tag in cache.tags %}
<span class="bg-gray-700 text-gray-300 px-2 py-1 rounded text-sm">{{ tag }}</span>
{% endfor %}
</div>
{% endif %}
{% if cache.source_type or cache.source_note %}
<div class="text-sm text-gray-500">
{% if cache.source_type %}Source: {{ cache.source_type }}{% endif %}
{% if cache.source_note %} - {{ cache.source_note }}{% endif %}
</div>
{% endif %}
</div>
<!-- Technical Metadata -->
<div class="grid grid-cols-2 gap-4 mb-6">
<div class="bg-gray-800 rounded-lg p-4">
<div class="text-gray-500 text-sm">CID</div>

View File

@@ -14,16 +14,42 @@
<!-- Header -->
<div class="flex items-center space-x-4 mb-6">
<a href="/recipes" class="text-gray-400 hover:text-white">&larr; Recipes</a>
<h1 class="text-2xl font-bold">{{ recipe.name }}</h1>
<h1 class="text-2xl font-bold">{{ recipe.name or 'Unnamed Recipe' }}</h1>
{% if recipe.version %}
<span class="text-gray-500">v{{ recipe.version }}</span>
{% endif %}
</div>
{% if recipe.description %}
<p class="text-gray-400 mb-6">{{ recipe.description }}</p>
<p class="text-gray-400 mb-4">{{ recipe.description }}</p>
{% endif %}
<!-- Metadata -->
<div class="bg-gray-800 rounded-lg p-4 border border-gray-700 mb-6">
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
<div>
<span class="text-gray-500">Recipe ID</span>
<p class="text-gray-300 font-mono text-xs truncate" title="{{ recipe.recipe_id }}">{{ recipe.recipe_id[:16] }}...</p>
</div>
{% if recipe.ipfs_cid %}
<div>
<span class="text-gray-500">IPFS CID</span>
<p class="text-gray-300 font-mono text-xs truncate" title="{{ recipe.ipfs_cid }}">{{ recipe.ipfs_cid[:16] }}...</p>
</div>
{% endif %}
<div>
<span class="text-gray-500">Steps</span>
<p class="text-gray-300">{{ recipe.step_count or recipe.steps|length }}</p>
</div>
{% if recipe.author %}
<div>
<span class="text-gray-500">Author</span>
<p class="text-gray-300">{{ recipe.author }}</p>
</div>
{% endif %}
</div>
</div>
<!-- DAG Visualization -->
<div class="bg-gray-800 rounded-lg border border-gray-700 mb-6">
<div class="border-b border-gray-700 px-4 py-2 flex items-center justify-between">

View File

@@ -519,19 +519,20 @@ class L1CacheManager:
def list_by_type(self, node_type: str) -> List[str]:
"""
List content hashes of all cached files of a specific type.
List CIDs of all cached files of a specific type.
Args:
node_type: Type to filter by (e.g., "recipe", "upload", "effect")
Returns:
List of content hashes
List of CIDs (IPFS CID if available, otherwise node_id)
"""
hashes = []
cids = []
for entry in self.cache.list_entries():
if entry.node_type == node_type and entry.cid:
hashes.append(entry.cid)
return hashes
if entry.node_type == node_type:
# Return node_id which is the IPFS CID for uploaded content
cids.append(entry.node_id)
return cids
# ============ Activity Tracking ============

View File

@@ -20,14 +20,14 @@ class TestRecipeListingFlow:
assert 'def list_by_type' in content, \
"L1CacheManager should have list_by_type method"
def test_list_by_type_returns_cid(self) -> None:
"""list_by_type should return entry.cid values."""
def test_list_by_type_returns_node_id(self) -> None:
"""list_by_type should return entry.node_id values (IPFS CID)."""
path = Path('/home/giles/art/art-celery/cache_manager.py')
content = path.read_text()
# Find list_by_type function and verify it appends entry.cid
assert 'hashes.append(entry.cid)' in content, \
"list_by_type should append entry.cid to results"
# Find list_by_type function and verify it appends entry.node_id
assert 'cids.append(entry.node_id)' in content, \
"list_by_type should append entry.node_id (IPFS CID) to results"
def test_recipe_service_uses_cache_list_by_type(self) -> None:
"""Recipe service should use cache.list_by_type('recipe')."""
@@ -110,3 +110,41 @@ class TestCacheEntryHasCid:
source = inspect.getsource(Cache.put)
assert 'cid=' in source, \
"Cache.put should set cid on entry"
class TestListByTypeReturnsEntries:
"""Tests for list_by_type returning cached entries."""
def test_list_by_type_iterates_cache_entries(self) -> None:
"""list_by_type should iterate self.cache.list_entries()."""
path = Path('/home/giles/art/art-celery/cache_manager.py')
content = path.read_text()
assert 'self.cache.list_entries()' in content, \
"list_by_type should iterate cache entries"
def test_list_by_type_filters_by_node_type(self) -> None:
"""list_by_type should filter entries by node_type."""
path = Path('/home/giles/art/art-celery/cache_manager.py')
content = path.read_text()
assert 'entry.node_type == node_type' in content, \
"list_by_type should filter by node_type"
def test_list_by_type_returns_node_id(self) -> None:
"""list_by_type should return entry.node_id (IPFS CID)."""
path = Path('/home/giles/art/art-celery/cache_manager.py')
content = path.read_text()
assert 'cids.append(entry.node_id)' in content, \
"list_by_type should append entry.node_id (IPFS CID)"
def test_artdag_cache_list_entries_returns_all(self) -> None:
"""artdag Cache.list_entries should return all entries."""
from artdag import Cache
import inspect
source = inspect.getsource(Cache.list_entries)
# Should return self._entries.values()
assert '_entries' in source, \
"list_entries should access _entries dict"