Fix item visibility bugs and add effects web UI

- Fix recipe filter to allow owner=None (S-expression compiled recipes)
- Fix media uploads to use category (video/image/audio) not MIME type
- Fix IPFS imports to detect and store correct media type
- Add Effects navigation link between Recipes and Media
- Create effects list and detail templates with upload functionality
- Add cache/not_found.html template (was missing)
- Add type annotations to service classes
- Add tests for item visibility and effects web UI (30 tests)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
gilesb
2026-01-12 12:01:54 +00:00
parent 19e2277155
commit 585c75e846
12 changed files with 1090 additions and 53 deletions

View File

@@ -5,13 +5,17 @@ Auth Service - token management and user verification.
import hashlib
import base64
import json
from typing import Optional
from typing import Optional, Dict, Any, TYPE_CHECKING
import httpx
from artdag_common.middleware.auth import UserContext
from ..config import settings
if TYPE_CHECKING:
import redis
from starlette.requests import Request
# Token expiry (30 days to match token lifetime)
TOKEN_EXPIRY_SECONDS = 60 * 60 * 24 * 30
@@ -24,7 +28,7 @@ USER_TOKENS_PREFIX = "artdag:user_tokens:"
class AuthService:
"""Service for authentication and token management."""
def __init__(self, redis_client):
def __init__(self, redis_client: "redis.Redis[bytes]") -> None:
self.redis = redis_client
def register_user_token(self, username: str, token: str) -> None:
@@ -66,7 +70,7 @@ class AuthService:
key = f"{REVOKED_KEY_PREFIX}{token_hash}"
return self.redis.exists(key) > 0
def decode_token_claims(self, token: str) -> Optional[dict]:
def decode_token_claims(self, token: str) -> Optional[Dict[str, Any]]:
"""Decode JWT claims without verification."""
try:
parts = token.split(".")
@@ -126,7 +130,7 @@ class AuthService:
return ctx
def get_user_from_cookie(self, request) -> Optional[UserContext]:
def get_user_from_cookie(self, request: "Request") -> Optional[UserContext]:
"""Extract user context from auth cookie."""
token = request.cookies.get("auth_token")
if not token:

View File

@@ -7,10 +7,14 @@ import json
import os
import subprocess
from pathlib import Path
from typing import Optional, List, Dict, Any, Tuple
from typing import Optional, List, Dict, Any, Tuple, TYPE_CHECKING
import httpx
if TYPE_CHECKING:
from database import Database
from cache_manager import L1CacheManager
def detect_media_type(cache_path: Path) -> str:
"""Detect if file is image, video, or audio based on magic bytes."""
@@ -86,7 +90,7 @@ class CacheService:
Handles content retrieval, metadata, and media type detection.
"""
def __init__(self, database, cache_manager):
def __init__(self, database: "Database", cache_manager: "L1CacheManager") -> None:
self.db = database
self.cache = cache_manager
self.cache_dir = Path(os.environ.get("CACHE_DIR", "/tmp/artdag-cache"))
@@ -293,10 +297,10 @@ class CacheService:
self,
cid: str,
actor_id: str,
title: str = None,
description: str = None,
tags: List[str] = None,
custom: Dict[str, Any] = None,
title: Optional[str] = None,
description: Optional[str] = None,
tags: Optional[List[str]] = None,
custom: Optional[Dict[str, Any]] = None,
) -> Tuple[bool, Optional[str]]:
"""Update content metadata. Returns (success, error)."""
if not self.cache.has_content(cid):
@@ -431,16 +435,19 @@ class CacheService:
if not ipfs_client.get_file(ipfs_cid, str(tmp_path)):
return None, f"Could not fetch CID {ipfs_cid} from IPFS"
# Store in cache
cached, ipfs_cid = self.cache.put(tmp_path, node_type="import", move=True)
cid = ipfs_cid or cached.cid # Prefer IPFS CID
# Detect media type before storing
media_type = detect_media_type(tmp_path)
# Save to database
await self.db.create_cache_item(cid, ipfs_cid)
# Store in cache
cached, new_ipfs_cid = self.cache.put(tmp_path, node_type="import", move=True)
cid = new_ipfs_cid or cached.cid # Prefer IPFS CID
# Save to database with detected media type
await self.db.create_cache_item(cid, new_ipfs_cid)
await self.db.save_item_metadata(
cid=cid,
actor_id=actor_id,
item_type="media",
item_type=media_type, # Use detected type for filtering
filename=f"ipfs-{ipfs_cid[:16]}"
)
@@ -463,19 +470,21 @@ class CacheService:
tmp.write(content)
tmp_path = Path(tmp.name)
# Detect MIME type before moving file
mime_type = get_mime_type(tmp_path)
# Detect media type (video/image/audio) before moving file
media_type = detect_media_type(tmp_path)
# Store in cache (also stores in IPFS)
cached, ipfs_cid = self.cache.put(tmp_path, node_type="upload", move=True)
cid = ipfs_cid or cached.cid # Prefer IPFS CID
# Save to database with detected MIME type
# Save to database with media category type
# Using media_type ("video", "image", "audio") not mime_type ("video/mp4")
# so list_media filtering works correctly
await self.db.create_cache_item(cid, ipfs_cid)
await self.db.save_item_metadata(
cid=cid,
actor_id=actor_id,
item_type=mime_type, # Store actual MIME type
item_type=media_type, # Store media category for filtering
filename=filename
)
@@ -485,11 +494,11 @@ class CacheService:
async def list_media(
self,
actor_id: str = None,
username: str = None,
actor_id: Optional[str] = None,
username: Optional[str] = None,
offset: int = 0,
limit: int = 24,
media_type: str = None,
media_type: Optional[str] = None,
) -> List[Dict[str, Any]]:
"""List media items in cache."""
# Get items from database (uses item_types table)

View File

@@ -7,10 +7,16 @@ The recipe ID is the content hash of the file.
import tempfile
from pathlib import Path
from typing import Optional, List, Dict, Any, Tuple
from typing import Optional, List, Dict, Any, Tuple, TYPE_CHECKING
from artdag.sexp import compile_string, parse, serialize, CompileError, ParseError
if TYPE_CHECKING:
import redis
from cache_manager import L1CacheManager
from ..types import Recipe, CompiledDAG, VisualizationDAG, VisNode, VisEdge
class RecipeService:
"""
@@ -19,12 +25,12 @@ class RecipeService:
Recipes are S-expressions stored in the content-addressed cache.
"""
def __init__(self, redis, cache):
def __init__(self, redis: "redis.Redis", cache: "L1CacheManager") -> None:
# Redis kept for compatibility but not used for recipe storage
self.redis = redis
self.cache = cache
async def get_recipe(self, recipe_id: str) -> Optional[Dict[str, Any]]:
async def get_recipe(self, recipe_id: str) -> Optional[Recipe]:
"""Get a recipe by ID (content hash)."""
# Get from cache (content-addressed storage)
path = self.cache.get_by_cid(recipe_id)
@@ -56,7 +62,7 @@ class RecipeService:
return recipe_data
async def list_recipes(self, actor_id: str = None, offset: int = 0, limit: int = 20) -> list:
async def list_recipes(self, actor_id: Optional[str] = None, offset: int = 0, limit: int = 20) -> List[Recipe]:
"""
List available recipes for a user.
@@ -75,7 +81,9 @@ class RecipeService:
if recipe and not recipe.get("error"):
owner = recipe.get("owner")
# Filter by actor - L1 is per-user
if actor_id is None or owner == actor_id:
# Note: S-expression recipes don't have owner field, so owner=None
# means the recipe is shared/public and visible to all users
if actor_id is None or owner is None or owner == actor_id:
recipes.append(recipe)
else:
logger.warning("Cache does not have list_by_type method")
@@ -153,19 +161,19 @@ class RecipeService:
except Exception as e:
return False, f"Failed to delete: {e}"
def parse_recipe(self, content: str) -> Dict[str, Any]:
def parse_recipe(self, content: str) -> CompiledDAG:
"""Parse recipe S-expression content."""
compiled = compile_string(content)
return compiled.to_dict()
def build_dag(self, recipe: Dict[str, Any]) -> Dict[str, Any]:
def build_dag(self, recipe: Recipe) -> VisualizationDAG:
"""
Build DAG visualization data from recipe.
Returns nodes and edges for Cytoscape.js.
"""
vis_nodes = []
edges = []
vis_nodes: List[VisNode] = []
edges: List[VisEdge] = []
dag = recipe.get("dag", {})
dag_nodes = dag.get("nodes", [])

View File

@@ -11,10 +11,17 @@ import json
import os
from datetime import datetime, timezone
from pathlib import Path
from typing import Optional, List, Dict, Any, Tuple
from typing import Optional, List, Dict, Any, Tuple, Union, TYPE_CHECKING
if TYPE_CHECKING:
import redis
from cache_manager import L1CacheManager
from database import Database
from ..types import RunResult
def compute_run_id(input_hashes: list, recipe: str, recipe_hash: str = None) -> str:
def compute_run_id(input_hashes: Union[List[str], Dict[str, str]], recipe: str, recipe_hash: Optional[str] = None) -> str:
"""
Compute a deterministic run_id from inputs and recipe.
@@ -89,14 +96,14 @@ class RunService:
Redis is only used for task_id mapping (ephemeral).
"""
def __init__(self, database, redis, cache):
def __init__(self, database: "Database", redis: "redis.Redis[bytes]", cache: "L1CacheManager") -> None:
self.db = database
self.redis = redis # Only for task_id mapping
self.cache = cache
self.task_key_prefix = "artdag:task:" # run_id -> task_id mapping only
self.cache_dir = Path(os.environ.get("CACHE_DIR", "/tmp/artdag-cache"))
def _ensure_inputs_list(self, inputs) -> list:
def _ensure_inputs_list(self, inputs: Any) -> List[str]:
"""Ensure inputs is a list, parsing JSON string if needed."""
if inputs is None:
return []
@@ -112,7 +119,7 @@ class RunService:
return []
return []
async def get_run(self, run_id: str) -> Optional[Dict[str, Any]]:
async def get_run(self, run_id: str) -> Optional[RunResult]:
"""Get a run by ID. Checks database first, then Celery task state."""
# Check database for completed run
cached = await self.db.get_run_cache(run_id)
@@ -267,7 +274,7 @@ class RunService:
return None
async def list_runs(self, actor_id: str, offset: int = 0, limit: int = 20) -> list:
async def list_runs(self, actor_id: str, offset: int = 0, limit: int = 20) -> List[RunResult]:
"""List runs for a user. Returns completed and pending runs from database."""
# Get completed runs from database
completed_runs = await self.db.list_runs_by_actor(actor_id, offset=0, limit=limit + 50)
@@ -297,14 +304,14 @@ class RunService:
async def create_run(
self,
recipe: str,
inputs: list,
output_name: str = None,
inputs: Union[List[str], Dict[str, str]],
output_name: Optional[str] = None,
use_dag: bool = True,
dag_json: str = None,
actor_id: str = None,
l2_server: str = None,
recipe_name: str = None,
) -> Tuple[Optional[Dict[str, Any]], Optional[str]]:
dag_json: Optional[str] = None,
actor_id: Optional[str] = None,
l2_server: Optional[str] = None,
recipe_name: Optional[str] = None,
) -> Tuple[Optional[RunResult], Optional[str]]:
"""
Create a new rendering run. Checks cache before executing.
@@ -604,7 +611,7 @@ class RunService:
"""Detect media type for a file path."""
return detect_media_type(path)
async def recover_pending_runs(self) -> Dict[str, int]:
async def recover_pending_runs(self) -> Dict[str, Union[int, str]]:
"""
Recover pending runs after restart.

View File

@@ -3,7 +3,11 @@ Storage Service - business logic for storage provider management.
"""
import json
from typing import Optional, List, Dict, Any
from typing import Optional, List, Dict, Any, Tuple, TYPE_CHECKING
if TYPE_CHECKING:
from database import Database
from storage_providers import StorageProvidersModule
STORAGE_PROVIDERS_INFO = {
@@ -22,7 +26,7 @@ VALID_PROVIDER_TYPES = list(STORAGE_PROVIDERS_INFO.keys())
class StorageService:
"""Service for managing user storage providers."""
def __init__(self, database, storage_providers_module):
def __init__(self, database: "Database", storage_providers_module: "StorageProvidersModule") -> None:
self.db = database
self.providers = storage_providers_module
@@ -72,7 +76,7 @@ class StorageService:
capacity_gb: int = 5,
provider_name: Optional[str] = None,
description: Optional[str] = None,
) -> tuple[Optional[int], Optional[str]]:
) -> Tuple[Optional[int], Optional[str]]:
"""Add a new storage provider. Returns (storage_id, error_message)."""
if provider_type not in VALID_PROVIDER_TYPES:
return None, f"Invalid provider type: {provider_type}"
@@ -115,7 +119,7 @@ class StorageService:
config: Optional[Dict[str, Any]] = None,
capacity_gb: Optional[int] = None,
is_active: Optional[bool] = None,
) -> tuple[bool, Optional[str]]:
) -> Tuple[bool, Optional[str]]:
"""Update a storage provider. Returns (success, error_message)."""
storage = await self.db.get_storage_by_id(storage_id)
if not storage:
@@ -145,7 +149,7 @@ class StorageService:
return success, None if success else "Failed to update storage provider"
async def delete_storage(self, storage_id: int, actor_id: str) -> tuple[bool, Optional[str]]:
async def delete_storage(self, storage_id: int, actor_id: str) -> Tuple[bool, Optional[str]]:
"""Delete a storage provider. Returns (success, error_message)."""
storage = await self.db.get_storage_by_id(storage_id)
if not storage:
@@ -156,7 +160,7 @@ class StorageService:
success = await self.db.remove_user_storage(storage_id)
return success, None if success else "Failed to remove storage provider"
async def test_storage(self, storage_id: int, actor_id: str) -> tuple[bool, str]:
async def test_storage(self, storage_id: int, actor_id: str) -> Tuple[bool, str]:
"""Test storage provider connectivity. Returns (success, message)."""
storage = await self.db.get_storage_by_id(storage_id)
if not storage:
@@ -179,7 +183,7 @@ class StorageService:
"""List storage providers of a specific type."""
return await self.db.get_user_storage_by_type(actor_id, provider_type)
def build_config_from_form(self, provider_type: str, form_data: Dict[str, Any]) -> tuple[Optional[Dict], Optional[str]]:
def build_config_from_form(self, provider_type: str, form_data: Dict[str, Any]) -> Tuple[Optional[Dict[str, Any]], Optional[str]]:
"""Build provider config from form data. Returns (config, error)."""
api_key = form_data.get("api_key")
secret_key = form_data.get("secret_key")

View File

@@ -6,6 +6,7 @@
<nav class="flex items-center space-x-6">
<a href="/runs" class="text-gray-300 hover:text-white {% if active_tab == 'runs' %}text-white font-medium{% endif %}">Runs</a>
<a href="/recipes" class="text-gray-300 hover:text-white {% if active_tab == 'recipes' %}text-white font-medium{% endif %}">Recipes</a>
<a href="/effects" class="text-gray-300 hover:text-white {% if active_tab == 'effects' %}text-white font-medium{% endif %}">Effects</a>
<a href="/media" class="text-gray-300 hover:text-white {% if active_tab == 'media' %}text-white font-medium{% endif %}">Media</a>
<a href="/storage" class="text-gray-300 hover:text-white {% if active_tab == 'storage' %}text-white font-medium{% endif %}">Storage</a>
<a href="/download/client" class="text-gray-300 hover:text-white" title="Download CLI client">Client</a>

21
app/templates/cache/not_found.html vendored Normal file
View File

@@ -0,0 +1,21 @@
{% extends "base.html" %}
{% block title %}Content Not Found - Art-DAG L1{% endblock %}
{% block content %}
<div class="max-w-2xl mx-auto text-center py-16">
<h1 class="text-6xl font-bold text-gray-400 mb-4">404</h1>
<h2 class="text-2xl font-semibold mb-4">Content Not Found</h2>
<p class="text-gray-400 mb-8">
The content with hash <code class="bg-gray-800 px-2 py-1 rounded">{{ cid[:24] if cid else 'unknown' }}...</code> was not found in the cache.
</p>
<div class="flex justify-center gap-4">
<a href="/cache/" class="bg-blue-600 hover:bg-blue-700 px-6 py-3 rounded-lg font-medium">
Browse Media
</a>
<a href="/" class="bg-gray-700 hover:bg-gray-600 px-6 py-3 rounded-lg font-medium">
Go Home
</a>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,195 @@
{% extends "base.html" %}
{% set meta = effect.meta or effect %}
{% block title %}{{ meta.name or 'Effect' }} - Effects - Art-DAG L1{% endblock %}
{% block head %}
{{ super() }}
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github-dark.min.css">
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/languages/python.min.js"></script>
{% endblock %}
{% block content %}
<div class="max-w-6xl mx-auto">
<!-- Header -->
<div class="flex items-center space-x-4 mb-6">
<a href="/effects" class="text-gray-400 hover:text-white">&larr; Effects</a>
<h1 class="text-2xl font-bold">{{ meta.name or 'Unnamed Effect' }}</h1>
<span class="text-gray-500">v{{ meta.version or '1.0.0' }}</span>
{% if meta.temporal %}
<span class="bg-purple-900 text-purple-300 px-2 py-1 rounded text-sm">temporal</span>
{% endif %}
</div>
{% if meta.author %}
<p class="text-gray-500 mb-2">by {{ meta.author }}</p>
{% endif %}
{% if meta.description %}
<p class="text-gray-400 mb-6">{{ meta.description }}</p>
{% endif %}
<!-- CID Info -->
<div class="bg-gray-800 rounded-lg p-4 border border-gray-700 mb-6">
<div class="flex items-center justify-between">
<div>
<span class="text-gray-500 text-sm">Content ID (CID)</span>
<p class="font-mono text-sm text-gray-300 mt-1" id="effect-cid">{{ effect.cid }}</p>
</div>
<button onclick="copyToClipboard('{{ effect.cid }}')"
class="bg-gray-700 hover:bg-gray-600 px-3 py-1 rounded text-sm">
Copy
</button>
</div>
{% if effect.uploaded_at %}
<div class="mt-3 text-gray-500 text-sm">
Uploaded: {{ effect.uploaded_at }}
{% if effect.uploader %}
by {{ effect.uploader }}
{% endif %}
</div>
{% endif %}
</div>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<!-- Left Column: Parameters & Dependencies -->
<div class="lg:col-span-1 space-y-6">
<!-- Parameters -->
{% if meta.params %}
<div class="bg-gray-800 rounded-lg border border-gray-700">
<div class="border-b border-gray-700 px-4 py-2">
<span class="text-gray-400 text-sm font-medium">Parameters</span>
</div>
<div class="p-4 space-y-4">
{% for param in meta.params %}
<div>
<div class="flex items-center space-x-2 mb-1">
<span class="font-medium text-white">{{ param.name }}</span>
<span class="bg-blue-900 text-blue-300 px-2 py-0.5 rounded text-xs">{{ param.type }}</span>
</div>
{% if param.description %}
<p class="text-gray-400 text-sm">{{ param.description }}</p>
{% endif %}
<div class="flex flex-wrap gap-2 mt-1 text-xs">
{% if param.range %}
<span class="text-gray-500">range: {{ param.range[0] }} - {{ param.range[1] }}</span>
{% endif %}
{% if param.default is defined %}
<span class="text-gray-500">default: {{ param.default }}</span>
{% endif %}
</div>
</div>
{% endfor %}
</div>
</div>
{% endif %}
<!-- Dependencies -->
{% if meta.dependencies %}
<div class="bg-gray-800 rounded-lg border border-gray-700">
<div class="border-b border-gray-700 px-4 py-2">
<span class="text-gray-400 text-sm font-medium">Dependencies</span>
</div>
<div class="p-4">
<div class="flex flex-wrap gap-2">
{% for dep in meta.dependencies %}
<span class="bg-gray-700 text-gray-300 px-3 py-1 rounded">{{ dep }}</span>
{% endfor %}
</div>
{% if meta.requires_python %}
<p class="text-gray-500 text-sm mt-3">Python {{ meta.requires_python }}</p>
{% endif %}
</div>
</div>
{% endif %}
<!-- Usage in Recipe -->
<div class="bg-gray-800 rounded-lg border border-gray-700">
<div class="border-b border-gray-700 px-4 py-2">
<span class="text-gray-400 text-sm font-medium">Usage in Recipe</span>
</div>
<div class="p-4">
<pre class="text-sm text-gray-300 bg-gray-900 rounded p-3 overflow-x-auto"><code class="language-lisp">(effect {{ meta.name or 'effect' }} :cid "{{ effect.cid }}")</code></pre>
<p class="text-gray-500 text-xs mt-2">
Reference this effect in your recipe S-expression.
</p>
</div>
</div>
</div>
<!-- Right Column: Source Code -->
<div class="lg:col-span-2">
<div class="bg-gray-800 rounded-lg border border-gray-700">
<div class="border-b border-gray-700 px-4 py-2 flex items-center justify-between">
<span class="text-gray-400 text-sm font-medium">Source Code</span>
<div class="flex items-center space-x-2">
<a href="/effects/{{ effect.cid }}/source"
class="text-gray-400 hover:text-white text-sm"
download="{{ meta.name or 'effect' }}.py">
Download
</a>
</div>
</div>
<div class="p-4">
<pre class="text-sm overflow-x-auto rounded bg-gray-900"><code class="language-python" id="source-code">Loading...</code></pre>
</div>
</div>
</div>
</div>
<!-- Actions -->
<div class="flex items-center space-x-4 mt-8">
<button onclick="deleteEffect('{{ effect.cid }}')"
class="bg-red-600 hover:bg-red-700 px-4 py-2 rounded font-medium">
Delete
</button>
<span id="action-result"></span>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
// Load source code
fetch('/effects/{{ effect.cid }}/source')
.then(response => response.text())
.then(source => {
const codeEl = document.getElementById('source-code');
codeEl.textContent = source;
hljs.highlightElement(codeEl);
})
.catch(error => {
document.getElementById('source-code').textContent = 'Failed to load source code';
});
});
function copyToClipboard(text) {
navigator.clipboard.writeText(text).then(() => {
const btn = event.target;
const originalText = btn.textContent;
btn.textContent = 'Copied!';
setTimeout(() => { btn.textContent = originalText; }, 1500);
});
}
function deleteEffect(cid) {
if (!confirm('Delete this effect from local cache? IPFS copies will persist.')) return;
fetch('/effects/' + cid, { method: 'DELETE' })
.then(response => {
if (!response.ok) throw new Error('Delete failed');
return response.json();
})
.then(data => {
document.getElementById('action-result').innerHTML =
'<span class="text-green-400">Deleted. Redirecting...</span>';
setTimeout(() => { window.location.href = '/effects'; }, 1000);
})
.catch(error => {
document.getElementById('action-result').innerHTML =
'<span class="text-red-400">' + error.message + '</span>';
});
}
</script>
{% endblock %}

View File

@@ -0,0 +1,134 @@
{% extends "base.html" %}
{% block title %}Effects - Art-DAG L1{% endblock %}
{% block content %}
<div class="max-w-6xl mx-auto">
<div class="flex items-center justify-between mb-6">
<h1 class="text-3xl font-bold">Effects</h1>
<label class="bg-blue-600 hover:bg-blue-700 px-4 py-2 rounded font-medium cursor-pointer">
Upload Effect
<input type="file" accept=".py" class="hidden" id="effect-upload" />
</label>
</div>
<p class="text-gray-400 mb-8">
Effects are Python scripts that process video frames or whole videos.
Each effect is stored in IPFS and can be referenced by CID in recipes.
</p>
{% if effects %}
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{% for effect in effects %}
{% set meta = effect.meta or effect %}
<a href="/effects/{{ effect.cid }}"
class="bg-gray-800 border border-gray-700 rounded-lg p-4 hover:border-gray-600 transition-colors">
<div class="flex items-center justify-between mb-2">
<span class="font-medium text-white">{{ meta.name or 'Unnamed' }}</span>
<span class="text-gray-500 text-sm">v{{ meta.version or '1.0.0' }}</span>
</div>
{% if meta.description %}
<p class="text-gray-400 text-sm mb-3 line-clamp-2">{{ meta.description }}</p>
{% endif %}
<div class="flex items-center justify-between text-sm mb-2">
{% if meta.author %}
<span class="text-gray-500">by {{ meta.author }}</span>
{% else %}
<span></span>
{% endif %}
{% if meta.temporal %}
<span class="bg-purple-900 text-purple-300 px-2 py-0.5 rounded text-xs">temporal</span>
{% endif %}
</div>
{% if meta.params %}
<div class="text-gray-500 text-sm">
{{ meta.params | length }} parameter{{ 's' if meta.params | length != 1 else '' }}
</div>
{% endif %}
{% if meta.dependencies %}
<div class="mt-2 flex flex-wrap gap-1">
{% for dep in meta.dependencies[:3] %}
<span class="bg-gray-700 text-gray-300 px-2 py-0.5 rounded text-xs">{{ dep }}</span>
{% endfor %}
{% if meta.dependencies | length > 3 %}
<span class="text-gray-500 text-xs">+{{ meta.dependencies | length - 3 }} more</span>
{% endif %}
</div>
{% endif %}
<div class="mt-3 text-gray-600 text-xs font-mono truncate">
{{ effect.cid[:24] }}...
</div>
</a>
{% endfor %}
</div>
{% else %}
<div class="bg-gray-800 border border-gray-700 rounded-lg p-12 text-center">
<p class="text-gray-500 mb-4">No effects uploaded yet.</p>
<p class="text-gray-600 text-sm mb-6">
Effects are Python files with @effect metadata in a docstring.
</p>
<label class="bg-blue-600 hover:bg-blue-700 px-6 py-3 rounded font-medium cursor-pointer inline-block">
Upload Your First Effect
<input type="file" accept=".py" class="hidden" id="effect-upload-empty" />
</label>
</div>
{% endif %}
</div>
<div id="upload-result" class="fixed bottom-4 right-4 max-w-sm"></div>
<script>
function handleEffectUpload(input) {
const file = input.files[0];
if (!file) return;
const formData = new FormData();
formData.append('file', file);
fetch('/effects/upload', {
method: 'POST',
body: formData
})
.then(response => {
if (!response.ok) throw new Error('Upload failed');
return response.json();
})
.then(data => {
const resultDiv = document.getElementById('upload-result');
resultDiv.innerHTML = `
<div class="bg-green-900 border border-green-700 rounded-lg p-4">
<p class="text-green-300 font-medium">Effect uploaded!</p>
<p class="text-green-400 text-sm mt-1">${data.name} v${data.version}</p>
<p class="text-gray-400 text-xs mt-2 font-mono">${data.cid}</p>
</div>
`;
setTimeout(() => {
window.location.reload();
}, 1500);
})
.catch(error => {
const resultDiv = document.getElementById('upload-result');
resultDiv.innerHTML = `
<div class="bg-red-900 border border-red-700 rounded-lg p-4">
<p class="text-red-300 font-medium">Upload failed</p>
<p class="text-red-400 text-sm mt-1">${error.message}</p>
</div>
`;
});
input.value = '';
}
document.getElementById('effect-upload')?.addEventListener('change', function() {
handleEffectUpload(this);
});
document.getElementById('effect-upload-empty')?.addEventListener('change', function() {
handleEffectUpload(this);
});
</script>
{% endblock %}

View File

@@ -100,6 +100,38 @@ class Registry(TypedDict):
effects: Dict[str, EffectEntry]
# === Visualization Types ===
class VisNodeData(TypedDict, total=False):
"""Data for a visualization node (Cytoscape.js format)."""
id: str
label: str
nodeType: str
isOutput: bool
class VisNode(TypedDict):
"""Visualization node wrapper."""
data: VisNodeData
class VisEdgeData(TypedDict):
"""Data for a visualization edge."""
source: str
target: str
class VisEdge(TypedDict):
"""Visualization edge wrapper."""
data: VisEdgeData
class VisualizationDAG(TypedDict):
"""DAG structure for Cytoscape.js visualization."""
nodes: List[VisNode]
edges: List[VisEdge]
# === Recipe Types ===
class Recipe(TypedDict, total=False):
@@ -130,12 +162,17 @@ class RunResult(TypedDict, total=False):
run_id: str
status: str # "pending", "running", "completed", "failed"
recipe: str
recipe_name: str
inputs: List[str]
output_cid: str
ipfs_cid: str
provenance_cid: str
error: str
created_at: str
completed_at: str
actor_id: str
celery_task_id: str
output_name: str
# === Helper functions for type narrowing ===