Complete L1 router and template migration
- Full implementation of runs, recipes, cache routers with templates - Auth and storage routers fully migrated - Jinja2 templates for all L1 pages - Service layer for auth and storage 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -33,14 +33,44 @@ def create_app() -> FastAPI:
|
||||
# Include routers
|
||||
from .routers import auth, storage, api, recipes, cache, runs, home
|
||||
|
||||
# API routers
|
||||
# Home and auth routers (root level)
|
||||
app.include_router(home.router, tags=["home"])
|
||||
app.include_router(auth.router, prefix="/auth", tags=["auth"])
|
||||
|
||||
# Feature routers
|
||||
app.include_router(storage.router, prefix="/storage", tags=["storage"])
|
||||
app.include_router(api.router, prefix="/api", tags=["api"])
|
||||
app.include_router(recipes.router, tags=["recipes"])
|
||||
app.include_router(cache.router, tags=["cache"])
|
||||
app.include_router(runs.router, tags=["runs"])
|
||||
app.include_router(home.router, tags=["home"])
|
||||
|
||||
# Runs router - handles both /runs and /run/{id} patterns
|
||||
app.include_router(runs.router, prefix="/runs", tags=["runs"])
|
||||
# Also mount at /run for single-run detail URLs
|
||||
from fastapi import APIRouter
|
||||
run_detail_router = APIRouter()
|
||||
@run_detail_router.get("/{run_id}")
|
||||
async def run_detail_redirect(run_id: str, request):
|
||||
from .routers.runs import run_detail
|
||||
return await run_detail(run_id, request)
|
||||
app.include_router(run_detail_router, prefix="/run", tags=["runs"])
|
||||
|
||||
# Recipes router - handles both /recipes and /recipe/{id} patterns
|
||||
app.include_router(recipes.router, prefix="/recipes", tags=["recipes"])
|
||||
recipe_detail_router = APIRouter()
|
||||
@recipe_detail_router.get("/{recipe_id}")
|
||||
async def recipe_detail_redirect(recipe_id: str, request):
|
||||
from .routers.recipes import get_recipe
|
||||
return await get_recipe(recipe_id, request)
|
||||
app.include_router(recipe_detail_router, prefix="/recipe", tags=["recipes"])
|
||||
|
||||
# Cache router - handles /cache and /media
|
||||
app.include_router(cache.router, prefix="/cache", tags=["cache"])
|
||||
# Also mount media list at /media for convenience
|
||||
from fastapi import APIRouter as MediaRouter
|
||||
media_router = MediaRouter()
|
||||
@media_router.get("")
|
||||
async def media_list_redirect(request, offset: int = 0, limit: int = 24):
|
||||
from .routers.cache import list_media
|
||||
return await list_media(request, offset, limit)
|
||||
app.include_router(media_router, prefix="/media", tags=["media"])
|
||||
|
||||
return app
|
||||
|
||||
|
||||
@@ -4,37 +4,258 @@
|
||||
Provides the plan/execute/run-recipe endpoints for programmatic access.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Request, Depends, HTTPException
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from artdag_common.models.requests import PlanRequest, ExecutePlanRequest, RecipeRunRequest
|
||||
import yaml
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
|
||||
from ..dependencies import require_auth
|
||||
from ..dependencies import require_auth, get_redis_client, get_cache_manager
|
||||
from ..services.auth_service import UserContext
|
||||
|
||||
router = APIRouter()
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Redis key prefix
|
||||
RUNS_KEY_PREFIX = "artdag:run:"
|
||||
|
||||
|
||||
# TODO: Migrate routes from server.py lines 6036-6241
|
||||
# - POST /plan - Generate execution plan
|
||||
# - POST /execute - Execute a plan
|
||||
# - POST /run-recipe - Run complete recipe
|
||||
class PlanRequest(BaseModel):
|
||||
recipe_yaml: str
|
||||
input_hashes: Dict[str, str]
|
||||
features: List[str] = ["beats", "energy"]
|
||||
|
||||
|
||||
class ExecutePlanRequest(BaseModel):
|
||||
plan_json: str
|
||||
run_id: Optional[str] = None
|
||||
|
||||
|
||||
class RecipeRunRequest(BaseModel):
|
||||
recipe_yaml: str
|
||||
input_hashes: Dict[str, str]
|
||||
features: List[str] = ["beats", "energy"]
|
||||
|
||||
|
||||
def compute_run_id(input_hashes: List[str], recipe: str, recipe_hash: str = None) -> str:
|
||||
"""Compute deterministic run_id from inputs and recipe."""
|
||||
data = {
|
||||
"inputs": sorted(input_hashes),
|
||||
"recipe": recipe_hash or f"effect:{recipe}",
|
||||
"version": "1",
|
||||
}
|
||||
json_str = json.dumps(data, sort_keys=True, separators=(",", ":"))
|
||||
return hashlib.sha3_256(json_str.encode()).hexdigest()
|
||||
|
||||
|
||||
@router.post("/plan")
|
||||
async def generate_plan(request: PlanRequest):
|
||||
"""Generate an execution plan from recipe without executing."""
|
||||
# TODO: Implement
|
||||
raise HTTPException(501, "Not yet migrated")
|
||||
async def generate_plan_endpoint(
|
||||
request: PlanRequest,
|
||||
ctx: UserContext = Depends(require_auth),
|
||||
):
|
||||
"""
|
||||
Generate an execution plan without executing it.
|
||||
|
||||
Phase 1 (Analyze) + Phase 2 (Plan) of the 3-phase model.
|
||||
Returns the plan with cache status for each step.
|
||||
"""
|
||||
from tasks.orchestrate import generate_plan
|
||||
|
||||
try:
|
||||
task = generate_plan.delay(
|
||||
recipe_yaml=request.recipe_yaml,
|
||||
input_hashes=request.input_hashes,
|
||||
features=request.features,
|
||||
)
|
||||
|
||||
# Wait for result (plan generation is usually fast)
|
||||
result = task.get(timeout=60)
|
||||
|
||||
return {
|
||||
"status": result.get("status"),
|
||||
"recipe": result.get("recipe"),
|
||||
"plan_id": result.get("plan_id"),
|
||||
"total_steps": result.get("total_steps"),
|
||||
"cached_steps": result.get("cached_steps"),
|
||||
"pending_steps": result.get("pending_steps"),
|
||||
"steps": result.get("steps"),
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Plan generation failed: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/execute")
|
||||
async def execute_plan(request: ExecutePlanRequest):
|
||||
"""Execute a previously generated plan."""
|
||||
# TODO: Implement
|
||||
raise HTTPException(501, "Not yet migrated")
|
||||
async def execute_plan_endpoint(
|
||||
request: ExecutePlanRequest,
|
||||
ctx: UserContext = Depends(require_auth),
|
||||
):
|
||||
"""
|
||||
Execute a pre-generated execution plan.
|
||||
|
||||
Phase 3 (Execute) of the 3-phase model.
|
||||
Submits the plan to Celery for parallel execution.
|
||||
"""
|
||||
from tasks.orchestrate import run_plan
|
||||
|
||||
run_id = request.run_id or str(uuid.uuid4())
|
||||
|
||||
try:
|
||||
task = run_plan.delay(
|
||||
plan_json=request.plan_json,
|
||||
run_id=run_id,
|
||||
)
|
||||
|
||||
return {
|
||||
"status": "submitted",
|
||||
"run_id": run_id,
|
||||
"celery_task_id": task.id,
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Plan execution failed: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/run-recipe")
|
||||
async def run_recipe(request: RecipeRunRequest):
|
||||
"""Run a complete recipe through all 3 phases."""
|
||||
# TODO: Implement
|
||||
raise HTTPException(501, "Not yet migrated")
|
||||
async def run_recipe_endpoint(
|
||||
request: RecipeRunRequest,
|
||||
ctx: UserContext = Depends(require_auth),
|
||||
):
|
||||
"""
|
||||
Run a complete recipe through all 3 phases.
|
||||
|
||||
1. Analyze: Extract features from inputs
|
||||
2. Plan: Generate execution plan with cache IDs
|
||||
3. Execute: Run steps with parallel execution
|
||||
|
||||
Returns immediately with run_id. Poll /api/run/{run_id} for status.
|
||||
"""
|
||||
from tasks.orchestrate import run_recipe
|
||||
import database
|
||||
|
||||
redis = get_redis_client()
|
||||
cache = get_cache_manager()
|
||||
|
||||
# Parse recipe name
|
||||
try:
|
||||
recipe_data = yaml.safe_load(request.recipe_yaml)
|
||||
recipe_name = recipe_data.get("name", "unknown")
|
||||
except Exception:
|
||||
recipe_name = "unknown"
|
||||
|
||||
# Compute deterministic run_id
|
||||
run_id = compute_run_id(
|
||||
list(request.input_hashes.values()),
|
||||
recipe_name,
|
||||
hashlib.sha3_256(request.recipe_yaml.encode()).hexdigest()
|
||||
)
|
||||
|
||||
# Check if already completed
|
||||
cached = await database.get_run_cache(run_id)
|
||||
if cached:
|
||||
output_hash = cached.get("output_hash")
|
||||
if cache.has_content(output_hash):
|
||||
return {
|
||||
"status": "completed",
|
||||
"run_id": run_id,
|
||||
"output_hash": output_hash,
|
||||
"output_ipfs_cid": cache.get_ipfs_cid(output_hash),
|
||||
"cached": True,
|
||||
}
|
||||
|
||||
# Submit to Celery
|
||||
try:
|
||||
task = run_recipe.delay(
|
||||
recipe_yaml=request.recipe_yaml,
|
||||
input_hashes=request.input_hashes,
|
||||
features=request.features,
|
||||
run_id=run_id,
|
||||
)
|
||||
|
||||
# Store run status in Redis
|
||||
run_data = {
|
||||
"run_id": run_id,
|
||||
"status": "pending",
|
||||
"recipe": recipe_name,
|
||||
"inputs": list(request.input_hashes.values()),
|
||||
"celery_task_id": task.id,
|
||||
"created_at": datetime.now(timezone.utc).isoformat(),
|
||||
"username": ctx.actor_id,
|
||||
}
|
||||
redis.setex(
|
||||
f"{RUNS_KEY_PREFIX}{run_id}",
|
||||
86400,
|
||||
json.dumps(run_data)
|
||||
)
|
||||
|
||||
return {
|
||||
"status": "submitted",
|
||||
"run_id": run_id,
|
||||
"celery_task_id": task.id,
|
||||
"recipe": recipe_name,
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Recipe run failed: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/run/{run_id}")
|
||||
async def get_run_status(
|
||||
run_id: str,
|
||||
ctx: UserContext = Depends(require_auth),
|
||||
):
|
||||
"""Get status of a recipe execution run."""
|
||||
import database
|
||||
from celery.result import AsyncResult
|
||||
|
||||
redis = get_redis_client()
|
||||
|
||||
# Check Redis for run status
|
||||
run_data = redis.get(f"{RUNS_KEY_PREFIX}{run_id}")
|
||||
if run_data:
|
||||
data = json.loads(run_data)
|
||||
|
||||
# If pending, check Celery task status
|
||||
if data.get("status") == "pending" and data.get("celery_task_id"):
|
||||
result = AsyncResult(data["celery_task_id"])
|
||||
|
||||
if result.ready():
|
||||
if result.successful():
|
||||
task_result = result.get()
|
||||
data["status"] = task_result.get("status", "completed")
|
||||
data["output_hash"] = task_result.get("output_cache_id")
|
||||
data["output_ipfs_cid"] = task_result.get("output_ipfs_cid")
|
||||
data["total_steps"] = task_result.get("total_steps")
|
||||
data["cached"] = task_result.get("cached")
|
||||
data["executed"] = task_result.get("executed")
|
||||
|
||||
# Update Redis
|
||||
redis.setex(
|
||||
f"{RUNS_KEY_PREFIX}{run_id}",
|
||||
86400,
|
||||
json.dumps(data)
|
||||
)
|
||||
else:
|
||||
data["status"] = "failed"
|
||||
data["error"] = str(result.result)
|
||||
else:
|
||||
data["celery_status"] = result.status
|
||||
|
||||
return data
|
||||
|
||||
# Check database cache
|
||||
cached = await database.get_run_cache(run_id)
|
||||
if cached:
|
||||
return {
|
||||
"run_id": run_id,
|
||||
"status": "completed",
|
||||
"output_hash": cached.get("output_hash"),
|
||||
"cached": True,
|
||||
}
|
||||
|
||||
raise HTTPException(status_code=404, detail="Run not found")
|
||||
|
||||
@@ -10,16 +10,18 @@ from fastapi.responses import RedirectResponse
|
||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||
from pydantic import BaseModel
|
||||
|
||||
# Import auth utilities from existing server module
|
||||
# TODO: Move these to a service
|
||||
import sys
|
||||
import os
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
|
||||
from ..dependencies import get_redis_client
|
||||
from ..services.auth_service import AuthService
|
||||
|
||||
router = APIRouter()
|
||||
security = HTTPBearer(auto_error=False)
|
||||
|
||||
|
||||
def get_auth_service():
|
||||
"""Get auth service instance."""
|
||||
return AuthService(get_redis_client())
|
||||
|
||||
|
||||
class RevokeUserRequest(BaseModel):
|
||||
"""Request to revoke all tokens for a user."""
|
||||
username: str
|
||||
@@ -27,26 +29,27 @@ class RevokeUserRequest(BaseModel):
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def auth_callback(auth_token: str = None):
|
||||
async def auth_callback(
|
||||
request: Request,
|
||||
auth_token: str = None,
|
||||
auth_service: AuthService = Depends(get_auth_service),
|
||||
):
|
||||
"""
|
||||
Receive auth token from L2 redirect and set local cookie.
|
||||
|
||||
This enables cross-subdomain auth on iOS Safari which blocks shared cookies.
|
||||
L2 redirects here with ?auth_token=... after user logs in.
|
||||
"""
|
||||
# Import here to avoid circular imports
|
||||
from server import get_verified_user_context, register_user_token
|
||||
|
||||
if not auth_token:
|
||||
return RedirectResponse(url="/", status_code=302)
|
||||
|
||||
# Verify the token is valid
|
||||
ctx = await get_verified_user_context(auth_token)
|
||||
ctx = await auth_service.verify_token_with_l2(auth_token)
|
||||
if not ctx:
|
||||
return RedirectResponse(url="/", status_code=302)
|
||||
|
||||
# Register token for this user (for revocation by username later)
|
||||
register_user_token(ctx.username, auth_token)
|
||||
auth_service.register_user_token(ctx.username, auth_token)
|
||||
|
||||
# Set local first-party cookie and redirect to runs
|
||||
response = RedirectResponse(url="/runs", status_code=302)
|
||||
@@ -76,41 +79,41 @@ async def logout():
|
||||
@router.post("/revoke")
|
||||
async def revoke_token(
|
||||
credentials: HTTPAuthorizationCredentials = Depends(security),
|
||||
auth_service: AuthService = Depends(get_auth_service),
|
||||
):
|
||||
"""
|
||||
Revoke a token. Called by L2 when user logs out.
|
||||
|
||||
The token to revoke is passed in the Authorization header.
|
||||
"""
|
||||
from server import get_user_context_from_token, revoke_token as do_revoke_token
|
||||
|
||||
if not credentials:
|
||||
raise HTTPException(401, "No token provided")
|
||||
|
||||
token = credentials.credentials
|
||||
|
||||
# Verify token is valid before revoking (ensures caller has the token)
|
||||
ctx = get_user_context_from_token(token)
|
||||
ctx = auth_service.get_user_context_from_token(token)
|
||||
if not ctx:
|
||||
raise HTTPException(401, "Invalid token")
|
||||
|
||||
# Revoke the token
|
||||
newly_revoked = do_revoke_token(token)
|
||||
newly_revoked = auth_service.revoke_token(token)
|
||||
|
||||
return {"revoked": True, "newly_revoked": newly_revoked}
|
||||
|
||||
|
||||
@router.post("/revoke-user")
|
||||
async def revoke_user_tokens(request: RevokeUserRequest):
|
||||
async def revoke_user_tokens(
|
||||
request: RevokeUserRequest,
|
||||
auth_service: AuthService = Depends(get_auth_service),
|
||||
):
|
||||
"""
|
||||
Revoke all tokens for a user. Called by L2 when user logs out.
|
||||
|
||||
This handles the case where L2 issued scoped tokens that differ from L2's own token.
|
||||
"""
|
||||
from server import revoke_all_user_tokens
|
||||
|
||||
# Revoke all tokens registered for this user
|
||||
count = revoke_all_user_tokens(request.username)
|
||||
count = auth_service.revoke_all_user_tokens(request.username)
|
||||
|
||||
return {
|
||||
"revoked": True,
|
||||
|
||||
@@ -4,52 +4,333 @@ Cache and media routes for L1 server.
|
||||
Handles content retrieval, metadata, media preview, and publishing.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Request, Depends, HTTPException
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Optional, Dict, Any
|
||||
|
||||
from fastapi import APIRouter, Request, Depends, HTTPException, UploadFile, File
|
||||
from fastapi.responses import HTMLResponse, FileResponse
|
||||
from pydantic import BaseModel
|
||||
|
||||
from artdag_common.middleware import UserContext, wants_html
|
||||
from artdag_common import render
|
||||
from artdag_common.middleware import wants_html, wants_json
|
||||
|
||||
from ..dependencies import require_auth, get_templates, get_current_user
|
||||
from ..dependencies import (
|
||||
require_auth, get_templates, get_redis_client,
|
||||
get_cache_manager, get_current_user
|
||||
)
|
||||
from ..services.auth_service import UserContext, AuthService
|
||||
from ..services.cache_service import CacheService
|
||||
|
||||
router = APIRouter()
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# TODO: Migrate routes from server.py lines 2767-4200
|
||||
# - GET /cache/{content_hash} - Get content details
|
||||
# - GET /cache/{content_hash}/raw - Download raw file
|
||||
# - GET /cache/{content_hash}/mp4 - Video conversion
|
||||
# - GET /cache/{content_hash}/meta - Get metadata
|
||||
# - PATCH /cache/{content_hash}/meta - Update metadata
|
||||
# - POST /cache/{content_hash}/publish - Publish to L2
|
||||
# - PATCH /cache/{content_hash}/republish - Republish
|
||||
# - DELETE /cache/{content_hash} - Delete content
|
||||
# - POST /cache/import - Import from IPFS
|
||||
# - POST /cache/upload - Upload content
|
||||
# - GET /media - Media list
|
||||
class UpdateMetadataRequest(BaseModel):
|
||||
title: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
tags: Optional[list] = None
|
||||
custom: Optional[Dict[str, Any]] = None
|
||||
|
||||
|
||||
@router.get("/cache/{content_hash}")
|
||||
async def get_cache_item(
|
||||
def get_cache_service():
|
||||
"""Get cache service instance."""
|
||||
import database
|
||||
return CacheService(database, get_cache_manager())
|
||||
|
||||
|
||||
@router.get("/{content_hash}")
|
||||
async def get_cached(
|
||||
content_hash: str,
|
||||
request: Request,
|
||||
cache_service: CacheService = Depends(get_cache_service),
|
||||
):
|
||||
"""Get cached content details or serve the file."""
|
||||
# TODO: Implement with content negotiation
|
||||
raise HTTPException(501, "Not yet migrated")
|
||||
"""Get cached content by hash. Content negotiation: HTML for browsers, JSON for APIs."""
|
||||
auth_service = AuthService(get_redis_client())
|
||||
ctx = auth_service.get_user_from_cookie(request)
|
||||
|
||||
cache_item = await cache_service.get_cache_item(content_hash)
|
||||
if not cache_item:
|
||||
if wants_html(request):
|
||||
templates = get_templates(request)
|
||||
return render(templates, "cache/not_found.html", request,
|
||||
content_hash=content_hash,
|
||||
user=ctx,
|
||||
active_tab="media",
|
||||
)
|
||||
raise HTTPException(404, f"Content {content_hash} not in cache")
|
||||
|
||||
# JSON response
|
||||
if wants_json(request):
|
||||
return cache_item
|
||||
|
||||
# HTML response
|
||||
if not ctx:
|
||||
from fastapi.responses import RedirectResponse
|
||||
return RedirectResponse(url="/auth", status_code=302)
|
||||
|
||||
# Check access
|
||||
has_access = await cache_service.check_access(content_hash, ctx.actor_id, ctx.username)
|
||||
if not has_access:
|
||||
raise HTTPException(403, "Access denied")
|
||||
|
||||
templates = get_templates(request)
|
||||
return render(templates, "cache/detail.html", request,
|
||||
cache=cache_item,
|
||||
user=ctx,
|
||||
active_tab="media",
|
||||
)
|
||||
|
||||
|
||||
@router.get("/cache/{content_hash}/raw")
|
||||
async def download_raw(content_hash: str):
|
||||
"""Download the raw cached file."""
|
||||
# TODO: Implement
|
||||
raise HTTPException(501, "Not yet migrated")
|
||||
@router.get("/{content_hash}/raw")
|
||||
async def get_cached_raw(
|
||||
content_hash: str,
|
||||
cache_service: CacheService = Depends(get_cache_service),
|
||||
):
|
||||
"""Get raw cached content (file download)."""
|
||||
file_path, media_type, filename = await cache_service.get_raw_file(content_hash)
|
||||
|
||||
if not file_path:
|
||||
raise HTTPException(404, f"Content {content_hash} not in cache")
|
||||
|
||||
return FileResponse(file_path, media_type=media_type, filename=filename)
|
||||
|
||||
|
||||
@router.get("/media")
|
||||
@router.get("/{content_hash}/mp4")
|
||||
async def get_cached_mp4(
|
||||
content_hash: str,
|
||||
cache_service: CacheService = Depends(get_cache_service),
|
||||
):
|
||||
"""Get cached content as MP4 (transcodes MKV on first request)."""
|
||||
mp4_path, error = await cache_service.get_as_mp4(content_hash)
|
||||
|
||||
if error:
|
||||
raise HTTPException(400 if "not a video" in error else 404, error)
|
||||
|
||||
return FileResponse(mp4_path, media_type="video/mp4")
|
||||
|
||||
|
||||
@router.get("/{content_hash}/meta")
|
||||
async def get_metadata(
|
||||
content_hash: str,
|
||||
ctx: UserContext = Depends(require_auth),
|
||||
cache_service: CacheService = Depends(get_cache_service),
|
||||
):
|
||||
"""Get content metadata."""
|
||||
meta = await cache_service.get_metadata(content_hash, ctx.actor_id)
|
||||
if meta is None:
|
||||
raise HTTPException(404, "Content not found")
|
||||
return meta
|
||||
|
||||
|
||||
@router.patch("/{content_hash}/meta")
|
||||
async def update_metadata(
|
||||
content_hash: str,
|
||||
req: UpdateMetadataRequest,
|
||||
ctx: UserContext = Depends(require_auth),
|
||||
cache_service: CacheService = Depends(get_cache_service),
|
||||
):
|
||||
"""Update content metadata."""
|
||||
success, error = await cache_service.update_metadata(
|
||||
content_hash=content_hash,
|
||||
actor_id=ctx.actor_id,
|
||||
title=req.title,
|
||||
description=req.description,
|
||||
tags=req.tags,
|
||||
custom=req.custom,
|
||||
)
|
||||
|
||||
if error:
|
||||
raise HTTPException(400, error)
|
||||
|
||||
return {"updated": True}
|
||||
|
||||
|
||||
@router.post("/{content_hash}/publish")
|
||||
async def publish_content(
|
||||
content_hash: str,
|
||||
request: Request,
|
||||
ctx: UserContext = Depends(require_auth),
|
||||
cache_service: CacheService = Depends(get_cache_service),
|
||||
):
|
||||
"""Publish content to L2 and IPFS."""
|
||||
ipfs_cid, error = await cache_service.publish_to_l2(
|
||||
content_hash=content_hash,
|
||||
actor_id=ctx.actor_id,
|
||||
l2_server=ctx.l2_server,
|
||||
auth_token=request.cookies.get("auth_token"),
|
||||
)
|
||||
|
||||
if error:
|
||||
if wants_html(request):
|
||||
return HTMLResponse(f'<span class="text-red-400">{error}</span>')
|
||||
raise HTTPException(400, error)
|
||||
|
||||
if wants_html(request):
|
||||
return HTMLResponse(f'<span class="text-green-400">Published: {ipfs_cid[:16]}...</span>')
|
||||
|
||||
return {"ipfs_cid": ipfs_cid, "published": True}
|
||||
|
||||
|
||||
@router.delete("/{content_hash}")
|
||||
async def delete_content(
|
||||
content_hash: str,
|
||||
ctx: UserContext = Depends(require_auth),
|
||||
cache_service: CacheService = Depends(get_cache_service),
|
||||
):
|
||||
"""Delete content from cache."""
|
||||
success, error = await cache_service.delete_content(content_hash, ctx.actor_id)
|
||||
|
||||
if error:
|
||||
raise HTTPException(400 if "Cannot" in error or "pinned" in error else 404, error)
|
||||
|
||||
return {"deleted": True}
|
||||
|
||||
|
||||
@router.post("/import")
|
||||
async def import_from_ipfs(
|
||||
ipfs_cid: str,
|
||||
ctx: UserContext = Depends(require_auth),
|
||||
cache_service: CacheService = Depends(get_cache_service),
|
||||
):
|
||||
"""Import content from IPFS."""
|
||||
content_hash, error = await cache_service.import_from_ipfs(ipfs_cid, ctx.actor_id)
|
||||
|
||||
if error:
|
||||
raise HTTPException(400, error)
|
||||
|
||||
return {"content_hash": content_hash, "imported": True}
|
||||
|
||||
|
||||
@router.post("/upload")
|
||||
async def upload_content(
|
||||
file: UploadFile = File(...),
|
||||
ctx: UserContext = Depends(require_auth),
|
||||
cache_service: CacheService = Depends(get_cache_service),
|
||||
):
|
||||
"""Upload content to cache."""
|
||||
content = await file.read()
|
||||
content_hash, error = await cache_service.upload_content(
|
||||
content=content,
|
||||
filename=file.filename,
|
||||
actor_id=ctx.actor_id,
|
||||
)
|
||||
|
||||
if error:
|
||||
raise HTTPException(400, error)
|
||||
|
||||
return {"content_hash": content_hash, "uploaded": True}
|
||||
|
||||
|
||||
# Media listing endpoint
|
||||
@router.get("")
|
||||
async def list_media(
|
||||
request: Request,
|
||||
user: UserContext = Depends(require_auth),
|
||||
offset: int = 0,
|
||||
limit: int = 24,
|
||||
media_type: Optional[str] = None,
|
||||
cache_service: CacheService = Depends(get_cache_service),
|
||||
):
|
||||
"""List all media in cache."""
|
||||
# TODO: Implement
|
||||
raise HTTPException(501, "Not yet migrated")
|
||||
auth_service = AuthService(get_redis_client())
|
||||
ctx = auth_service.get_user_from_cookie(request)
|
||||
|
||||
if not ctx:
|
||||
if wants_json(request):
|
||||
raise HTTPException(401, "Authentication required")
|
||||
from fastapi.responses import RedirectResponse
|
||||
return RedirectResponse(url="/auth", status_code=302)
|
||||
|
||||
items = await cache_service.list_media(
|
||||
actor_id=ctx.actor_id,
|
||||
username=ctx.username,
|
||||
offset=offset,
|
||||
limit=limit,
|
||||
media_type=media_type,
|
||||
)
|
||||
has_more = len(items) >= limit
|
||||
|
||||
if wants_json(request):
|
||||
return {"items": items, "offset": offset, "limit": limit, "has_more": has_more}
|
||||
|
||||
templates = get_templates(request)
|
||||
return render(templates, "cache/media_list.html", request,
|
||||
items=items,
|
||||
user=ctx,
|
||||
offset=offset,
|
||||
limit=limit,
|
||||
has_more=has_more,
|
||||
active_tab="media",
|
||||
)
|
||||
|
||||
|
||||
# HTMX metadata form
|
||||
@router.get("/{content_hash}/meta-form", response_class=HTMLResponse)
|
||||
async def get_metadata_form(
|
||||
content_hash: str,
|
||||
request: Request,
|
||||
cache_service: CacheService = Depends(get_cache_service),
|
||||
):
|
||||
"""Get metadata editing form (HTMX)."""
|
||||
auth_service = AuthService(get_redis_client())
|
||||
ctx = auth_service.get_user_from_cookie(request)
|
||||
|
||||
if not ctx:
|
||||
return HTMLResponse('<div class="text-red-400">Login required</div>')
|
||||
|
||||
meta = await cache_service.get_metadata(content_hash, ctx.actor_id)
|
||||
|
||||
return HTMLResponse(f'''
|
||||
<h2 class="text-lg font-semibold mb-4">Metadata</h2>
|
||||
<form hx-patch="/cache/{content_hash}/meta"
|
||||
hx-target="#metadata-section"
|
||||
hx-swap="innerHTML"
|
||||
class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-gray-400 text-sm mb-1">Title</label>
|
||||
<input type="text" name="title" value="{meta.get('title', '') if meta else ''}"
|
||||
class="w-full bg-gray-900 border border-gray-600 rounded px-3 py-2 text-white">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-gray-400 text-sm mb-1">Description</label>
|
||||
<textarea name="description" rows="3"
|
||||
class="w-full bg-gray-900 border border-gray-600 rounded px-3 py-2 text-white"
|
||||
>{meta.get('description', '') if meta else ''}</textarea>
|
||||
</div>
|
||||
<button type="submit"
|
||||
class="bg-blue-600 hover:bg-blue-700 px-4 py-2 rounded font-medium">
|
||||
Save Metadata
|
||||
</button>
|
||||
</form>
|
||||
''')
|
||||
|
||||
|
||||
@router.patch("/{content_hash}/meta", response_class=HTMLResponse)
|
||||
async def update_metadata_htmx(
|
||||
content_hash: str,
|
||||
request: Request,
|
||||
cache_service: CacheService = Depends(get_cache_service),
|
||||
):
|
||||
"""Update metadata (HTMX form handler)."""
|
||||
auth_service = AuthService(get_redis_client())
|
||||
ctx = auth_service.get_user_from_cookie(request)
|
||||
|
||||
if not ctx:
|
||||
return HTMLResponse('<div class="text-red-400">Login required</div>')
|
||||
|
||||
form_data = await request.form()
|
||||
|
||||
success, error = await cache_service.update_metadata(
|
||||
content_hash=content_hash,
|
||||
actor_id=ctx.actor_id,
|
||||
title=form_data.get("title"),
|
||||
description=form_data.get("description"),
|
||||
)
|
||||
|
||||
if error:
|
||||
return HTMLResponse(f'<div class="text-red-400">{error}</div>')
|
||||
|
||||
return HTMLResponse('''
|
||||
<div class="text-green-400 mb-4">Metadata saved!</div>
|
||||
<script>setTimeout(() => location.reload(), 1000);</script>
|
||||
''')
|
||||
|
||||
@@ -4,44 +4,246 @@ Recipe management routes for L1 server.
|
||||
Handles recipe upload, listing, viewing, and execution.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Request, Depends, HTTPException
|
||||
import logging
|
||||
from typing import List, Optional
|
||||
|
||||
from fastapi import APIRouter, Request, Depends, HTTPException, UploadFile, File
|
||||
from fastapi.responses import HTMLResponse
|
||||
from pydantic import BaseModel
|
||||
|
||||
from artdag_common.middleware import UserContext, wants_html
|
||||
from artdag_common import render
|
||||
from artdag_common.middleware import wants_html, wants_json
|
||||
|
||||
from ..dependencies import require_auth, get_templates
|
||||
from ..dependencies import require_auth, get_templates, get_redis_client
|
||||
from ..services.auth_service import UserContext, AuthService
|
||||
from ..services.recipe_service import RecipeService
|
||||
|
||||
router = APIRouter()
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# TODO: Migrate routes from server.py lines 1990-2767
|
||||
# - POST /recipes/upload - Upload recipe YAML
|
||||
# - GET /recipes - List recipes
|
||||
# - GET /recipes/{recipe_id} - Get recipe details
|
||||
# - DELETE /recipes/{recipe_id} - Delete recipe
|
||||
# - POST /recipes/{recipe_id}/run - Run recipe
|
||||
# - GET /recipe/{recipe_id} - Recipe detail page
|
||||
# - GET /recipe/{recipe_id}/dag - Recipe DAG visualization
|
||||
# - POST /ui/recipes/{recipe_id}/run - Run from UI
|
||||
# - GET /ui/recipes-list - Recipes list UI
|
||||
class RecipeUploadRequest(BaseModel):
|
||||
yaml_content: str
|
||||
name: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
|
||||
|
||||
@router.get("/recipes")
|
||||
def get_recipe_service():
|
||||
"""Get recipe service instance."""
|
||||
return RecipeService(get_redis_client())
|
||||
|
||||
|
||||
@router.post("/upload")
|
||||
async def upload_recipe(
|
||||
req: RecipeUploadRequest,
|
||||
ctx: UserContext = Depends(require_auth),
|
||||
recipe_service: RecipeService = Depends(get_recipe_service),
|
||||
):
|
||||
"""Upload a new recipe from YAML."""
|
||||
recipe_id, error = await recipe_service.upload_recipe(
|
||||
yaml_content=req.yaml_content,
|
||||
uploader=ctx.actor_id,
|
||||
name=req.name,
|
||||
description=req.description,
|
||||
)
|
||||
|
||||
if error:
|
||||
raise HTTPException(400, error)
|
||||
|
||||
return {"recipe_id": recipe_id, "message": "Recipe uploaded successfully"}
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_recipes(
|
||||
request: Request,
|
||||
user: UserContext = Depends(require_auth),
|
||||
offset: int = 0,
|
||||
limit: int = 20,
|
||||
recipe_service: RecipeService = Depends(get_recipe_service),
|
||||
):
|
||||
"""List available recipes."""
|
||||
# TODO: Implement
|
||||
raise HTTPException(501, "Not yet migrated")
|
||||
auth_service = AuthService(get_redis_client())
|
||||
ctx = auth_service.get_user_from_cookie(request)
|
||||
|
||||
if not ctx:
|
||||
if wants_json(request):
|
||||
raise HTTPException(401, "Authentication required")
|
||||
from fastapi.responses import RedirectResponse
|
||||
return RedirectResponse(url="/auth", status_code=302)
|
||||
|
||||
recipes = await recipe_service.list_recipes(ctx.actor_id, offset=offset, limit=limit)
|
||||
|
||||
if wants_json(request):
|
||||
return {"recipes": recipes, "offset": offset, "limit": limit}
|
||||
|
||||
templates = get_templates(request)
|
||||
return render(templates, "recipes/list.html", request,
|
||||
recipes=recipes,
|
||||
user=ctx,
|
||||
active_tab="recipes",
|
||||
)
|
||||
|
||||
|
||||
@router.get("/recipe/{recipe_id}")
|
||||
async def recipe_detail(
|
||||
@router.get("/{recipe_id}")
|
||||
async def get_recipe(
|
||||
recipe_id: str,
|
||||
request: Request,
|
||||
user: UserContext = Depends(require_auth),
|
||||
recipe_service: RecipeService = Depends(get_recipe_service),
|
||||
):
|
||||
"""Recipe detail page with DAG visualization."""
|
||||
# TODO: Implement
|
||||
raise HTTPException(501, "Not yet migrated")
|
||||
"""Get recipe details."""
|
||||
auth_service = AuthService(get_redis_client())
|
||||
ctx = auth_service.get_user_from_cookie(request)
|
||||
|
||||
if not ctx:
|
||||
if wants_json(request):
|
||||
raise HTTPException(401, "Authentication required")
|
||||
from fastapi.responses import RedirectResponse
|
||||
return RedirectResponse(url="/auth", status_code=302)
|
||||
|
||||
recipe = await recipe_service.get_recipe(recipe_id)
|
||||
if not recipe:
|
||||
raise HTTPException(404, "Recipe not found")
|
||||
|
||||
if wants_json(request):
|
||||
return recipe
|
||||
|
||||
# Build DAG elements for visualization
|
||||
dag_elements = []
|
||||
node_colors = {
|
||||
"input": "#3b82f6",
|
||||
"effect": "#8b5cf6",
|
||||
"analyze": "#ec4899",
|
||||
"transform": "#10b981",
|
||||
"output": "#f59e0b",
|
||||
}
|
||||
|
||||
for i, step in enumerate(recipe.get("steps", [])):
|
||||
step_id = step.get("id", f"step-{i}")
|
||||
dag_elements.append({
|
||||
"data": {
|
||||
"id": step_id,
|
||||
"label": step.get("name", f"Step {i+1}"),
|
||||
"color": node_colors.get(step.get("type", "effect"), "#6b7280"),
|
||||
}
|
||||
})
|
||||
for inp in step.get("inputs", []):
|
||||
dag_elements.append({
|
||||
"data": {"source": inp, "target": step_id}
|
||||
})
|
||||
|
||||
templates = get_templates(request)
|
||||
return render(templates, "recipes/detail.html", request,
|
||||
recipe=recipe,
|
||||
dag_elements=dag_elements,
|
||||
user=ctx,
|
||||
active_tab="recipes",
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/{recipe_id}")
|
||||
async def delete_recipe(
|
||||
recipe_id: str,
|
||||
ctx: UserContext = Depends(require_auth),
|
||||
recipe_service: RecipeService = Depends(get_recipe_service),
|
||||
):
|
||||
"""Delete a recipe."""
|
||||
success, error = await recipe_service.delete_recipe(recipe_id, ctx.actor_id)
|
||||
if error:
|
||||
raise HTTPException(400 if "Cannot" in error else 404, error)
|
||||
return {"deleted": True, "recipe_id": recipe_id}
|
||||
|
||||
|
||||
@router.post("/{recipe_id}/run")
|
||||
async def run_recipe(
|
||||
recipe_id: str,
|
||||
inputs: List[str],
|
||||
ctx: UserContext = Depends(require_auth),
|
||||
recipe_service: RecipeService = Depends(get_recipe_service),
|
||||
):
|
||||
"""Run a recipe with given inputs."""
|
||||
from ..services.run_service import RunService
|
||||
from ..dependencies import get_cache_manager
|
||||
import database
|
||||
|
||||
recipe = await recipe_service.get_recipe(recipe_id)
|
||||
if not recipe:
|
||||
raise HTTPException(404, "Recipe not found")
|
||||
|
||||
# Create run using run service
|
||||
run_service = RunService(database, get_redis_client(), get_cache_manager())
|
||||
run, error = await run_service.create_run(
|
||||
recipe=recipe.get("name", recipe_id),
|
||||
inputs=inputs,
|
||||
use_dag=True,
|
||||
actor_id=ctx.actor_id,
|
||||
l2_server=ctx.l2_server,
|
||||
)
|
||||
|
||||
if error:
|
||||
raise HTTPException(400, error)
|
||||
|
||||
return {
|
||||
"run_id": run.run_id,
|
||||
"status": run.status,
|
||||
"message": "Recipe execution started",
|
||||
}
|
||||
|
||||
|
||||
@router.get("/{recipe_id}/dag")
|
||||
async def recipe_dag(
|
||||
recipe_id: str,
|
||||
request: Request,
|
||||
recipe_service: RecipeService = Depends(get_recipe_service),
|
||||
):
|
||||
"""Get recipe DAG visualization data."""
|
||||
recipe = await recipe_service.get_recipe(recipe_id)
|
||||
if not recipe:
|
||||
raise HTTPException(404, "Recipe not found")
|
||||
|
||||
dag_elements = []
|
||||
node_colors = {
|
||||
"input": "#3b82f6",
|
||||
"effect": "#8b5cf6",
|
||||
"analyze": "#ec4899",
|
||||
"transform": "#10b981",
|
||||
"output": "#f59e0b",
|
||||
}
|
||||
|
||||
for i, step in enumerate(recipe.get("steps", [])):
|
||||
step_id = step.get("id", f"step-{i}")
|
||||
dag_elements.append({
|
||||
"data": {
|
||||
"id": step_id,
|
||||
"label": step.get("name", f"Step {i+1}"),
|
||||
"color": node_colors.get(step.get("type", "effect"), "#6b7280"),
|
||||
}
|
||||
})
|
||||
for inp in step.get("inputs", []):
|
||||
dag_elements.append({
|
||||
"data": {"source": inp, "target": step_id}
|
||||
})
|
||||
|
||||
return {"elements": dag_elements}
|
||||
|
||||
|
||||
@router.delete("/{recipe_id}/ui", response_class=HTMLResponse)
|
||||
async def ui_discard_recipe(
|
||||
recipe_id: str,
|
||||
request: Request,
|
||||
recipe_service: RecipeService = Depends(get_recipe_service),
|
||||
):
|
||||
"""HTMX handler: discard a recipe."""
|
||||
auth_service = AuthService(get_redis_client())
|
||||
ctx = auth_service.get_user_from_cookie(request)
|
||||
|
||||
if not ctx:
|
||||
return HTMLResponse('<div class="text-red-400">Login required</div>', status_code=401)
|
||||
|
||||
success, error = await recipe_service.delete_recipe(recipe_id, ctx.actor_id)
|
||||
|
||||
if error:
|
||||
return HTMLResponse(f'<div class="text-red-400">{error}</div>')
|
||||
|
||||
return HTMLResponse(
|
||||
'<div class="text-green-400">Recipe deleted</div>'
|
||||
'<script>setTimeout(() => window.location.href = "/recipes", 1500);</script>'
|
||||
)
|
||||
|
||||
@@ -4,54 +4,332 @@ Run management routes for L1 server.
|
||||
Handles run creation, status, listing, and detail views.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from typing import List, Optional, Dict, Any
|
||||
|
||||
from fastapi import APIRouter, Request, Depends, HTTPException
|
||||
from fastapi.responses import HTMLResponse
|
||||
from pydantic import BaseModel
|
||||
|
||||
from artdag_common.middleware import UserContext, wants_html
|
||||
from artdag_common import render
|
||||
from artdag_common.middleware import wants_html, wants_json
|
||||
|
||||
from ..dependencies import require_auth, get_templates, get_current_user
|
||||
from ..dependencies import (
|
||||
require_auth, get_templates, get_current_user,
|
||||
get_redis_client, get_cache_manager
|
||||
)
|
||||
from ..services.auth_service import UserContext
|
||||
from ..services.run_service import RunService
|
||||
|
||||
router = APIRouter()
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
RUNS_KEY_PREFIX = "artdag:run:"
|
||||
|
||||
|
||||
# TODO: Migrate routes from server.py lines 675-1789
|
||||
# - POST /runs - Create run
|
||||
# - GET /runs/{run_id} - Get run status
|
||||
# - DELETE /runs/{run_id} - Delete run
|
||||
# - GET /runs - List runs
|
||||
# - GET /run/{run_id} - Run detail page
|
||||
# - GET /run/{run_id}/plan - Plan visualization
|
||||
# - GET /run/{run_id}/analysis - Analysis results
|
||||
# - GET /run/{run_id}/artifacts - Artifacts list
|
||||
class RunRequest(BaseModel):
|
||||
recipe: str
|
||||
inputs: List[str]
|
||||
output_name: Optional[str] = None
|
||||
use_dag: bool = True
|
||||
dag_json: Optional[str] = None
|
||||
|
||||
|
||||
@router.get("/runs")
|
||||
class RunStatus(BaseModel):
|
||||
run_id: str
|
||||
status: str
|
||||
recipe: str
|
||||
inputs: List[str]
|
||||
output_name: Optional[str] = None
|
||||
created_at: Optional[str] = None
|
||||
completed_at: Optional[str] = None
|
||||
output_hash: Optional[str] = None
|
||||
username: Optional[str] = None
|
||||
provenance_cid: Optional[str] = None
|
||||
celery_task_id: Optional[str] = None
|
||||
error: Optional[str] = None
|
||||
plan_id: Optional[str] = None
|
||||
plan_name: Optional[str] = None
|
||||
step_results: Optional[Dict[str, Any]] = None
|
||||
all_outputs: Optional[List[str]] = None
|
||||
effects_commit: Optional[str] = None
|
||||
effect_url: Optional[str] = None
|
||||
infrastructure: Optional[Dict[str, Any]] = None
|
||||
|
||||
|
||||
def get_run_service():
|
||||
"""Get run service instance."""
|
||||
import database
|
||||
return RunService(database, get_redis_client(), get_cache_manager())
|
||||
|
||||
|
||||
@router.post("", response_model=RunStatus)
|
||||
async def create_run(
|
||||
request: RunRequest,
|
||||
ctx: UserContext = Depends(require_auth),
|
||||
run_service: RunService = Depends(get_run_service),
|
||||
):
|
||||
"""Start a new rendering run. Checks cache before executing."""
|
||||
run, error = await run_service.create_run(
|
||||
recipe=request.recipe,
|
||||
inputs=request.inputs,
|
||||
output_name=request.output_name,
|
||||
use_dag=request.use_dag,
|
||||
dag_json=request.dag_json,
|
||||
actor_id=ctx.actor_id,
|
||||
l2_server=ctx.l2_server,
|
||||
)
|
||||
|
||||
if error:
|
||||
raise HTTPException(400, error)
|
||||
|
||||
return run
|
||||
|
||||
|
||||
@router.get("/{run_id}", response_model=RunStatus)
|
||||
async def get_run(
|
||||
run_id: str,
|
||||
run_service: RunService = Depends(get_run_service),
|
||||
):
|
||||
"""Get status of a run."""
|
||||
run = await run_service.get_run(run_id)
|
||||
if not run:
|
||||
raise HTTPException(404, f"Run {run_id} not found")
|
||||
return run
|
||||
|
||||
|
||||
@router.delete("/{run_id}")
|
||||
async def discard_run(
|
||||
run_id: str,
|
||||
ctx: UserContext = Depends(require_auth),
|
||||
run_service: RunService = Depends(get_run_service),
|
||||
):
|
||||
"""Discard (delete) a run and its outputs."""
|
||||
success, error = await run_service.discard_run(run_id, ctx.actor_id, ctx.username)
|
||||
if error:
|
||||
raise HTTPException(400 if "Cannot" in error else 404, error)
|
||||
return {"discarded": True, "run_id": run_id}
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_runs(
|
||||
request: Request,
|
||||
user: UserContext = Depends(require_auth),
|
||||
offset: int = 0,
|
||||
limit: int = 20,
|
||||
run_service: RunService = Depends(get_run_service),
|
||||
):
|
||||
"""List all runs for the current user."""
|
||||
# TODO: Implement
|
||||
raise HTTPException(501, "Not yet migrated")
|
||||
from ..services.auth_service import AuthService
|
||||
|
||||
auth_service = AuthService(get_redis_client())
|
||||
ctx = auth_service.get_user_from_cookie(request)
|
||||
|
||||
if not ctx:
|
||||
if wants_json(request):
|
||||
raise HTTPException(401, "Authentication required")
|
||||
from fastapi.responses import RedirectResponse
|
||||
return RedirectResponse(url="/auth", status_code=302)
|
||||
|
||||
runs = await run_service.list_runs(ctx.actor_id, offset=offset, limit=limit)
|
||||
has_more = len(runs) >= limit
|
||||
|
||||
if wants_json(request):
|
||||
return {"runs": runs, "offset": offset, "limit": limit, "has_more": has_more}
|
||||
|
||||
templates = get_templates(request)
|
||||
return render(templates, "runs/list.html", request,
|
||||
runs=runs,
|
||||
user=ctx,
|
||||
offset=offset,
|
||||
limit=limit,
|
||||
has_more=has_more,
|
||||
active_tab="runs",
|
||||
)
|
||||
|
||||
|
||||
@router.get("/run/{run_id}")
|
||||
@router.get("/{run_id}/detail")
|
||||
async def run_detail(
|
||||
run_id: str,
|
||||
request: Request,
|
||||
user: UserContext = Depends(require_auth),
|
||||
run_service: RunService = Depends(get_run_service),
|
||||
):
|
||||
"""Run detail page with tabs for plan/analysis/artifacts."""
|
||||
# TODO: Implement
|
||||
raise HTTPException(501, "Not yet migrated")
|
||||
from ..services.auth_service import AuthService
|
||||
|
||||
auth_service = AuthService(get_redis_client())
|
||||
ctx = auth_service.get_user_from_cookie(request)
|
||||
|
||||
if not ctx:
|
||||
if wants_json(request):
|
||||
raise HTTPException(401, "Authentication required")
|
||||
from fastapi.responses import RedirectResponse
|
||||
return RedirectResponse(url="/auth", status_code=302)
|
||||
|
||||
run = await run_service.get_run(run_id)
|
||||
if not run:
|
||||
raise HTTPException(404, f"Run {run_id} not found")
|
||||
|
||||
# Get plan and artifacts
|
||||
plan = await run_service.get_run_plan(run_id)
|
||||
artifacts = await run_service.get_run_artifacts(run_id)
|
||||
|
||||
# Build DAG elements for visualization
|
||||
dag_elements = []
|
||||
if plan and plan.get("steps"):
|
||||
node_colors = {
|
||||
"input": "#3b82f6",
|
||||
"effect": "#8b5cf6",
|
||||
"analyze": "#ec4899",
|
||||
"transform": "#10b981",
|
||||
"output": "#f59e0b",
|
||||
}
|
||||
for i, step in enumerate(plan["steps"]):
|
||||
dag_elements.append({
|
||||
"data": {
|
||||
"id": step.get("id", f"step-{i}"),
|
||||
"label": step.get("name", f"Step {i+1}"),
|
||||
"color": node_colors.get(step.get("type", "effect"), "#6b7280"),
|
||||
}
|
||||
})
|
||||
# Add edges from inputs
|
||||
for inp in step.get("inputs", []):
|
||||
dag_elements.append({
|
||||
"data": {
|
||||
"source": inp,
|
||||
"target": step.get("id", f"step-{i}"),
|
||||
}
|
||||
})
|
||||
|
||||
if wants_json(request):
|
||||
return {
|
||||
"run": run,
|
||||
"plan": plan,
|
||||
"artifacts": artifacts,
|
||||
}
|
||||
|
||||
templates = get_templates(request)
|
||||
return render(templates, "runs/detail.html", request,
|
||||
run=run,
|
||||
plan=plan,
|
||||
artifacts=artifacts,
|
||||
dag_elements=dag_elements,
|
||||
user=ctx,
|
||||
active_tab="runs",
|
||||
)
|
||||
|
||||
|
||||
@router.get("/run/{run_id}/plan")
|
||||
@router.get("/{run_id}/plan")
|
||||
async def run_plan(
|
||||
run_id: str,
|
||||
request: Request,
|
||||
user: UserContext = Depends(require_auth),
|
||||
run_service: RunService = Depends(get_run_service),
|
||||
):
|
||||
"""Plan visualization as interactive DAG."""
|
||||
# TODO: Implement
|
||||
raise HTTPException(501, "Not yet migrated")
|
||||
from ..services.auth_service import AuthService
|
||||
|
||||
auth_service = AuthService(get_redis_client())
|
||||
ctx = auth_service.get_user_from_cookie(request)
|
||||
|
||||
if not ctx:
|
||||
raise HTTPException(401, "Authentication required")
|
||||
|
||||
plan = await run_service.get_run_plan(run_id)
|
||||
if not plan:
|
||||
raise HTTPException(404, "Plan not found for this run")
|
||||
|
||||
if wants_json(request):
|
||||
return plan
|
||||
|
||||
# Build DAG elements
|
||||
dag_elements = []
|
||||
node_colors = {
|
||||
"input": "#3b82f6",
|
||||
"effect": "#8b5cf6",
|
||||
"analyze": "#ec4899",
|
||||
"transform": "#10b981",
|
||||
"output": "#f59e0b",
|
||||
}
|
||||
|
||||
for i, step in enumerate(plan.get("steps", [])):
|
||||
step_id = step.get("id", f"step-{i}")
|
||||
dag_elements.append({
|
||||
"data": {
|
||||
"id": step_id,
|
||||
"label": step.get("name", f"Step {i+1}"),
|
||||
"color": node_colors.get(step.get("type", "effect"), "#6b7280"),
|
||||
}
|
||||
})
|
||||
for inp in step.get("inputs", []):
|
||||
dag_elements.append({
|
||||
"data": {"source": inp, "target": step_id}
|
||||
})
|
||||
|
||||
templates = get_templates(request)
|
||||
return render(templates, "runs/plan.html", request,
|
||||
run_id=run_id,
|
||||
plan=plan,
|
||||
dag_elements=dag_elements,
|
||||
user=ctx,
|
||||
active_tab="runs",
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{run_id}/artifacts")
|
||||
async def run_artifacts(
|
||||
run_id: str,
|
||||
request: Request,
|
||||
run_service: RunService = Depends(get_run_service),
|
||||
):
|
||||
"""Get artifacts list for a run."""
|
||||
from ..services.auth_service import AuthService
|
||||
|
||||
auth_service = AuthService(get_redis_client())
|
||||
ctx = auth_service.get_user_from_cookie(request)
|
||||
|
||||
if not ctx:
|
||||
raise HTTPException(401, "Authentication required")
|
||||
|
||||
artifacts = await run_service.get_run_artifacts(run_id)
|
||||
|
||||
if wants_json(request):
|
||||
return {"artifacts": artifacts}
|
||||
|
||||
templates = get_templates(request)
|
||||
return render(templates, "runs/artifacts.html", request,
|
||||
run_id=run_id,
|
||||
artifacts=artifacts,
|
||||
user=ctx,
|
||||
active_tab="runs",
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/{run_id}/ui", response_class=HTMLResponse)
|
||||
async def ui_discard_run(
|
||||
run_id: str,
|
||||
request: Request,
|
||||
run_service: RunService = Depends(get_run_service),
|
||||
):
|
||||
"""HTMX handler: discard a run."""
|
||||
from ..services.auth_service import AuthService
|
||||
|
||||
auth_service = AuthService(get_redis_client())
|
||||
ctx = auth_service.get_user_from_cookie(request)
|
||||
|
||||
if not ctx:
|
||||
return HTMLResponse(
|
||||
'<div class="text-red-400">Login required</div>',
|
||||
status_code=401
|
||||
)
|
||||
|
||||
success, error = await run_service.discard_run(run_id, ctx.actor_id, ctx.username)
|
||||
|
||||
if error:
|
||||
return HTMLResponse(f'<div class="text-red-400">{error}</div>')
|
||||
|
||||
return HTMLResponse(
|
||||
'<div class="text-green-400">Run discarded</div>'
|
||||
'<script>setTimeout(() => window.location.href = "/runs", 1500);</script>'
|
||||
)
|
||||
|
||||
@@ -4,32 +4,280 @@ Storage provider routes for L1 server.
|
||||
Manages user storage backends (Pinata, web3.storage, local, etc.)
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Request, Depends, HTTPException
|
||||
from fastapi.responses import HTMLResponse
|
||||
from typing import Optional, Dict, Any
|
||||
|
||||
from artdag_common.middleware import UserContext, wants_html
|
||||
from fastapi import APIRouter, Request, Depends, HTTPException, Form
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
from pydantic import BaseModel
|
||||
|
||||
from ..dependencies import require_auth, get_templates
|
||||
from artdag_common import render
|
||||
from artdag_common.middleware import wants_html, wants_json
|
||||
|
||||
from ..dependencies import get_database, get_current_user, require_auth, get_templates
|
||||
from ..services.auth_service import UserContext
|
||||
from ..services.storage_service import StorageService, STORAGE_PROVIDERS_INFO, VALID_PROVIDER_TYPES
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# TODO: Migrate routes from server.py lines 5473-5761
|
||||
# - GET /storage - List storage providers
|
||||
# - POST /storage - Add storage provider
|
||||
# - POST /storage/add - Add via form
|
||||
# - GET /storage/{storage_id} - Get storage details
|
||||
# - PATCH /storage/{storage_id} - Update storage
|
||||
# - DELETE /storage/{storage_id} - Delete storage
|
||||
# - POST /storage/{storage_id}/test - Test connection
|
||||
# - GET /storage/type/{provider_type} - Get provider config
|
||||
# Import storage_providers module
|
||||
import storage_providers as sp_module
|
||||
|
||||
|
||||
def get_storage_service():
|
||||
"""Get storage service instance."""
|
||||
import database
|
||||
return StorageService(database, sp_module)
|
||||
|
||||
|
||||
class AddStorageRequest(BaseModel):
|
||||
provider_type: str
|
||||
config: Dict[str, Any]
|
||||
capacity_gb: int = 5
|
||||
provider_name: Optional[str] = None
|
||||
|
||||
|
||||
class UpdateStorageRequest(BaseModel):
|
||||
config: Optional[Dict[str, Any]] = None
|
||||
capacity_gb: Optional[int] = None
|
||||
is_active: Optional[bool] = None
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_storage(
|
||||
request: Request,
|
||||
user: UserContext = Depends(require_auth),
|
||||
storage_service: StorageService = Depends(get_storage_service),
|
||||
):
|
||||
"""List user's storage providers."""
|
||||
# TODO: Implement
|
||||
raise HTTPException(501, "Not yet migrated")
|
||||
"""List user's storage providers. HTML for browsers, JSON for API."""
|
||||
from ..services.auth_service import AuthService
|
||||
from ..dependencies import get_redis_client
|
||||
|
||||
auth_service = AuthService(get_redis_client())
|
||||
ctx = auth_service.get_user_from_cookie(request)
|
||||
|
||||
if not ctx:
|
||||
if wants_json(request):
|
||||
raise HTTPException(401, "Authentication required")
|
||||
return RedirectResponse(url="/auth", status_code=302)
|
||||
|
||||
storages = await storage_service.list_storages(ctx.actor_id)
|
||||
|
||||
if wants_json(request):
|
||||
return {"storages": storages}
|
||||
|
||||
# Render HTML template
|
||||
templates = get_templates(request)
|
||||
return render(templates, "storage/list.html", request,
|
||||
storages=storages,
|
||||
user=ctx,
|
||||
providers_info=STORAGE_PROVIDERS_INFO,
|
||||
)
|
||||
|
||||
|
||||
@router.post("")
|
||||
async def add_storage(
|
||||
req: AddStorageRequest,
|
||||
request: Request,
|
||||
storage_service: StorageService = Depends(get_storage_service),
|
||||
):
|
||||
"""Add a storage provider via API."""
|
||||
ctx = await require_auth(request)
|
||||
|
||||
storage_id, error = await storage_service.add_storage(
|
||||
actor_id=ctx.actor_id,
|
||||
provider_type=req.provider_type,
|
||||
config=req.config,
|
||||
capacity_gb=req.capacity_gb,
|
||||
provider_name=req.provider_name,
|
||||
)
|
||||
|
||||
if error:
|
||||
raise HTTPException(400, error)
|
||||
|
||||
return {"id": storage_id, "message": "Storage provider added"}
|
||||
|
||||
|
||||
@router.post("/add")
|
||||
async def add_storage_form(
|
||||
request: Request,
|
||||
provider_type: str = Form(...),
|
||||
provider_name: Optional[str] = Form(None),
|
||||
description: Optional[str] = Form(None),
|
||||
capacity_gb: int = Form(5),
|
||||
api_key: Optional[str] = Form(None),
|
||||
secret_key: Optional[str] = Form(None),
|
||||
api_token: Optional[str] = Form(None),
|
||||
project_id: Optional[str] = Form(None),
|
||||
project_secret: Optional[str] = Form(None),
|
||||
access_key: Optional[str] = Form(None),
|
||||
bucket: Optional[str] = Form(None),
|
||||
path: Optional[str] = Form(None),
|
||||
storage_service: StorageService = Depends(get_storage_service),
|
||||
):
|
||||
"""Add a storage provider via HTML form."""
|
||||
from ..services.auth_service import AuthService
|
||||
from ..dependencies import get_redis_client
|
||||
|
||||
auth_service = AuthService(get_redis_client())
|
||||
ctx = auth_service.get_user_from_cookie(request)
|
||||
|
||||
if not ctx:
|
||||
return HTMLResponse('<div class="text-red-400">Not authenticated</div>', status_code=401)
|
||||
|
||||
# Build config from form
|
||||
form_data = {
|
||||
"api_key": api_key,
|
||||
"secret_key": secret_key,
|
||||
"api_token": api_token,
|
||||
"project_id": project_id,
|
||||
"project_secret": project_secret,
|
||||
"access_key": access_key,
|
||||
"bucket": bucket,
|
||||
"path": path,
|
||||
}
|
||||
config, error = storage_service.build_config_from_form(provider_type, form_data)
|
||||
|
||||
if error:
|
||||
return HTMLResponse(f'<div class="text-red-400">{error}</div>')
|
||||
|
||||
storage_id, error = await storage_service.add_storage(
|
||||
actor_id=ctx.actor_id,
|
||||
provider_type=provider_type,
|
||||
config=config,
|
||||
capacity_gb=capacity_gb,
|
||||
provider_name=provider_name,
|
||||
description=description,
|
||||
)
|
||||
|
||||
if error:
|
||||
return HTMLResponse(f'<div class="text-red-400">{error}</div>')
|
||||
|
||||
return HTMLResponse(f'''
|
||||
<div class="text-green-400 mb-2">Storage provider added successfully!</div>
|
||||
<script>setTimeout(() => window.location.href = '/storage/type/{provider_type}', 1500);</script>
|
||||
''')
|
||||
|
||||
|
||||
@router.get("/{storage_id}")
|
||||
async def get_storage(
|
||||
storage_id: int,
|
||||
request: Request,
|
||||
storage_service: StorageService = Depends(get_storage_service),
|
||||
):
|
||||
"""Get a specific storage provider."""
|
||||
ctx = await require_auth(request)
|
||||
|
||||
storage = await storage_service.get_storage(storage_id, ctx.actor_id)
|
||||
if not storage:
|
||||
raise HTTPException(404, "Storage provider not found")
|
||||
|
||||
return storage
|
||||
|
||||
|
||||
@router.patch("/{storage_id}")
|
||||
async def update_storage(
|
||||
storage_id: int,
|
||||
req: UpdateStorageRequest,
|
||||
request: Request,
|
||||
storage_service: StorageService = Depends(get_storage_service),
|
||||
):
|
||||
"""Update a storage provider."""
|
||||
ctx = await require_auth(request)
|
||||
|
||||
success, error = await storage_service.update_storage(
|
||||
storage_id=storage_id,
|
||||
actor_id=ctx.actor_id,
|
||||
config=req.config,
|
||||
capacity_gb=req.capacity_gb,
|
||||
is_active=req.is_active,
|
||||
)
|
||||
|
||||
if error:
|
||||
raise HTTPException(400, error)
|
||||
|
||||
return {"message": "Storage provider updated"}
|
||||
|
||||
|
||||
@router.delete("/{storage_id}")
|
||||
async def delete_storage(
|
||||
storage_id: int,
|
||||
request: Request,
|
||||
storage_service: StorageService = Depends(get_storage_service),
|
||||
):
|
||||
"""Remove a storage provider."""
|
||||
from ..services.auth_service import AuthService
|
||||
from ..dependencies import get_redis_client
|
||||
|
||||
auth_service = AuthService(get_redis_client())
|
||||
ctx = auth_service.get_user_from_cookie(request)
|
||||
|
||||
if not ctx:
|
||||
raise HTTPException(401, "Not authenticated")
|
||||
|
||||
success, error = await storage_service.delete_storage(storage_id, ctx.actor_id)
|
||||
|
||||
if error:
|
||||
raise HTTPException(400, error)
|
||||
|
||||
if wants_html(request):
|
||||
return HTMLResponse("")
|
||||
|
||||
return {"message": "Storage provider removed"}
|
||||
|
||||
|
||||
@router.post("/{storage_id}/test")
|
||||
async def test_storage(
|
||||
storage_id: int,
|
||||
request: Request,
|
||||
storage_service: StorageService = Depends(get_storage_service),
|
||||
):
|
||||
"""Test storage provider connectivity."""
|
||||
from ..services.auth_service import AuthService
|
||||
from ..dependencies import get_redis_client
|
||||
|
||||
auth_service = AuthService(get_redis_client())
|
||||
ctx = auth_service.get_user_from_cookie(request)
|
||||
|
||||
if not ctx:
|
||||
if wants_html(request):
|
||||
return HTMLResponse('<span class="text-red-400">Not authenticated</span>', status_code=401)
|
||||
raise HTTPException(401, "Not authenticated")
|
||||
|
||||
success, message = await storage_service.test_storage(storage_id, ctx.actor_id)
|
||||
|
||||
if wants_html(request):
|
||||
color = "green" if success else "red"
|
||||
return HTMLResponse(f'<span class="text-{color}-400">{message}</span>')
|
||||
|
||||
return {"success": success, "message": message}
|
||||
|
||||
|
||||
@router.get("/type/{provider_type}")
|
||||
async def storage_type_page(
|
||||
provider_type: str,
|
||||
request: Request,
|
||||
storage_service: StorageService = Depends(get_storage_service),
|
||||
):
|
||||
"""Page for managing storage configs of a specific type."""
|
||||
from ..services.auth_service import AuthService
|
||||
from ..dependencies import get_redis_client
|
||||
|
||||
auth_service = AuthService(get_redis_client())
|
||||
ctx = auth_service.get_user_from_cookie(request)
|
||||
|
||||
if not ctx:
|
||||
return RedirectResponse(url="/auth", status_code=302)
|
||||
|
||||
if provider_type not in STORAGE_PROVIDERS_INFO:
|
||||
raise HTTPException(404, "Invalid provider type")
|
||||
|
||||
storages = await storage_service.list_by_type(ctx.actor_id, provider_type)
|
||||
provider_info = STORAGE_PROVIDERS_INFO[provider_type]
|
||||
|
||||
templates = get_templates(request)
|
||||
return render(templates, "storage/type.html", request,
|
||||
provider_type=provider_type,
|
||||
provider_info=provider_info,
|
||||
storages=storages,
|
||||
user=ctx,
|
||||
)
|
||||
|
||||
141
app/services/auth_service.py
Normal file
141
app/services/auth_service.py
Normal file
@@ -0,0 +1,141 @@
|
||||
"""
|
||||
Auth Service - token management and user verification.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import base64
|
||||
import json
|
||||
from typing import Optional
|
||||
from dataclasses import dataclass
|
||||
|
||||
import httpx
|
||||
|
||||
from ..config import settings
|
||||
|
||||
|
||||
# Token expiry (30 days to match token lifetime)
|
||||
TOKEN_EXPIRY_SECONDS = 60 * 60 * 24 * 30
|
||||
|
||||
# Redis key prefixes
|
||||
REVOKED_KEY_PREFIX = "artdag:revoked:"
|
||||
USER_TOKENS_PREFIX = "artdag:user_tokens:"
|
||||
|
||||
|
||||
@dataclass
|
||||
class UserContext:
|
||||
"""User context from token."""
|
||||
username: str
|
||||
actor_id: str
|
||||
token: Optional[str] = None
|
||||
|
||||
|
||||
class AuthService:
|
||||
"""Service for authentication and token management."""
|
||||
|
||||
def __init__(self, redis_client):
|
||||
self.redis = redis_client
|
||||
|
||||
def register_user_token(self, username: str, token: str) -> None:
|
||||
"""Track a token for a user (for later revocation by username)."""
|
||||
token_hash = hashlib.sha256(token.encode()).hexdigest()
|
||||
key = f"{USER_TOKENS_PREFIX}{username}"
|
||||
self.redis.sadd(key, token_hash)
|
||||
self.redis.expire(key, TOKEN_EXPIRY_SECONDS)
|
||||
|
||||
def revoke_token(self, token: str) -> bool:
|
||||
"""Add token to revocation set. Returns True if newly revoked."""
|
||||
token_hash = hashlib.sha256(token.encode()).hexdigest()
|
||||
key = f"{REVOKED_KEY_PREFIX}{token_hash}"
|
||||
result = self.redis.set(key, "1", ex=TOKEN_EXPIRY_SECONDS, nx=True)
|
||||
return result is not None
|
||||
|
||||
def revoke_token_hash(self, token_hash: str) -> bool:
|
||||
"""Add token hash to revocation set. Returns True if newly revoked."""
|
||||
key = f"{REVOKED_KEY_PREFIX}{token_hash}"
|
||||
result = self.redis.set(key, "1", ex=TOKEN_EXPIRY_SECONDS, nx=True)
|
||||
return result is not None
|
||||
|
||||
def revoke_all_user_tokens(self, username: str) -> int:
|
||||
"""Revoke all tokens for a user. Returns count revoked."""
|
||||
key = f"{USER_TOKENS_PREFIX}{username}"
|
||||
token_hashes = self.redis.smembers(key)
|
||||
count = 0
|
||||
for token_hash in token_hashes:
|
||||
if self.revoke_token_hash(
|
||||
token_hash.decode() if isinstance(token_hash, bytes) else token_hash
|
||||
):
|
||||
count += 1
|
||||
self.redis.delete(key)
|
||||
return count
|
||||
|
||||
def is_token_revoked(self, token: str) -> bool:
|
||||
"""Check if token has been revoked."""
|
||||
token_hash = hashlib.sha256(token.encode()).hexdigest()
|
||||
key = f"{REVOKED_KEY_PREFIX}{token_hash}"
|
||||
return self.redis.exists(key) > 0
|
||||
|
||||
def decode_token_claims(self, token: str) -> Optional[dict]:
|
||||
"""Decode JWT claims without verification."""
|
||||
try:
|
||||
parts = token.split(".")
|
||||
if len(parts) != 3:
|
||||
return None
|
||||
payload = parts[1]
|
||||
# Add padding
|
||||
padding = 4 - len(payload) % 4
|
||||
if padding != 4:
|
||||
payload += "=" * padding
|
||||
return json.loads(base64.urlsafe_b64decode(payload))
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
return None
|
||||
|
||||
def get_user_context_from_token(self, token: str) -> Optional[UserContext]:
|
||||
"""Extract user context from a token."""
|
||||
if self.is_token_revoked(token):
|
||||
return None
|
||||
|
||||
claims = self.decode_token_claims(token)
|
||||
if not claims:
|
||||
return None
|
||||
|
||||
username = claims.get("username") or claims.get("sub")
|
||||
actor_id = claims.get("actor_id") or claims.get("actor")
|
||||
|
||||
if not username:
|
||||
return None
|
||||
|
||||
return UserContext(
|
||||
username=username,
|
||||
actor_id=actor_id or f"@{username}",
|
||||
token=token,
|
||||
)
|
||||
|
||||
async def verify_token_with_l2(self, token: str) -> Optional[UserContext]:
|
||||
"""Verify token with L2 server."""
|
||||
ctx = self.get_user_context_from_token(token)
|
||||
if not ctx:
|
||||
return None
|
||||
|
||||
# If L2 server configured, verify token
|
||||
if settings.l2_server:
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
resp = await client.get(
|
||||
f"{settings.l2_server}/auth/verify",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5.0,
|
||||
)
|
||||
if resp.status_code != 200:
|
||||
return None
|
||||
except httpx.RequestError:
|
||||
# L2 unavailable, trust the token
|
||||
pass
|
||||
|
||||
return ctx
|
||||
|
||||
def get_user_from_cookie(self, request) -> Optional[UserContext]:
|
||||
"""Extract user context from auth cookie."""
|
||||
token = request.cookies.get("auth_token")
|
||||
if not token:
|
||||
return None
|
||||
return self.get_user_context_from_token(token)
|
||||
228
app/services/storage_service.py
Normal file
228
app/services/storage_service.py
Normal file
@@ -0,0 +1,228 @@
|
||||
"""
|
||||
Storage Service - business logic for storage provider management.
|
||||
"""
|
||||
|
||||
import json
|
||||
from typing import Optional, List, Dict, Any
|
||||
|
||||
|
||||
STORAGE_PROVIDERS_INFO = {
|
||||
"pinata": {"name": "Pinata", "desc": "1GB free, IPFS pinning", "color": "blue"},
|
||||
"web3storage": {"name": "web3.storage", "desc": "IPFS + Filecoin", "color": "green"},
|
||||
"nftstorage": {"name": "NFT.Storage", "desc": "Free for NFTs", "color": "pink"},
|
||||
"infura": {"name": "Infura IPFS", "desc": "5GB free", "color": "orange"},
|
||||
"filebase": {"name": "Filebase", "desc": "5GB free, S3+IPFS", "color": "cyan"},
|
||||
"storj": {"name": "Storj", "desc": "25GB free", "color": "indigo"},
|
||||
"local": {"name": "Local Storage", "desc": "Your own disk", "color": "purple"},
|
||||
}
|
||||
|
||||
VALID_PROVIDER_TYPES = list(STORAGE_PROVIDERS_INFO.keys())
|
||||
|
||||
|
||||
class StorageService:
|
||||
"""Service for managing user storage providers."""
|
||||
|
||||
def __init__(self, database, storage_providers_module):
|
||||
self.db = database
|
||||
self.providers = storage_providers_module
|
||||
|
||||
async def list_storages(self, actor_id: str) -> List[Dict[str, Any]]:
|
||||
"""List all storage providers for a user with usage stats."""
|
||||
storages = await self.db.get_user_storage(actor_id)
|
||||
|
||||
for storage in storages:
|
||||
usage = await self.db.get_storage_usage(storage["id"])
|
||||
storage["used_bytes"] = usage["used_bytes"]
|
||||
storage["pin_count"] = usage["pin_count"]
|
||||
storage["donated_gb"] = storage["capacity_gb"] // 2
|
||||
|
||||
# Mask sensitive config keys for display
|
||||
if storage.get("config"):
|
||||
config = storage["config"] if isinstance(storage["config"], dict) else json.loads(storage["config"])
|
||||
masked = {}
|
||||
for k, v in config.items():
|
||||
if "key" in k.lower() or "token" in k.lower() or "secret" in k.lower():
|
||||
masked[k] = v[:4] + "..." + v[-4:] if len(str(v)) > 8 else "****"
|
||||
else:
|
||||
masked[k] = v
|
||||
storage["config_display"] = masked
|
||||
|
||||
return storages
|
||||
|
||||
async def get_storage(self, storage_id: int, actor_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""Get a specific storage provider."""
|
||||
storage = await self.db.get_storage_by_id(storage_id)
|
||||
if not storage:
|
||||
return None
|
||||
if storage["actor_id"] != actor_id:
|
||||
return None
|
||||
|
||||
usage = await self.db.get_storage_usage(storage_id)
|
||||
storage["used_bytes"] = usage["used_bytes"]
|
||||
storage["pin_count"] = usage["pin_count"]
|
||||
storage["donated_gb"] = storage["capacity_gb"] // 2
|
||||
|
||||
return storage
|
||||
|
||||
async def add_storage(
|
||||
self,
|
||||
actor_id: str,
|
||||
provider_type: str,
|
||||
config: Dict[str, Any],
|
||||
capacity_gb: int = 5,
|
||||
provider_name: Optional[str] = None,
|
||||
description: Optional[str] = None,
|
||||
) -> 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}"
|
||||
|
||||
# Test connection before saving
|
||||
provider = self.providers.create_provider(provider_type, {
|
||||
**config,
|
||||
"capacity_gb": capacity_gb
|
||||
})
|
||||
if not provider:
|
||||
return None, "Failed to create provider with given config"
|
||||
|
||||
success, message = await provider.test_connection()
|
||||
if not success:
|
||||
return None, f"Provider connection failed: {message}"
|
||||
|
||||
# Generate name if not provided
|
||||
if not provider_name:
|
||||
existing = await self.db.get_user_storage_by_type(actor_id, provider_type)
|
||||
provider_name = f"{provider_type}-{len(existing) + 1}"
|
||||
|
||||
storage_id = await self.db.add_user_storage(
|
||||
actor_id=actor_id,
|
||||
provider_type=provider_type,
|
||||
provider_name=provider_name,
|
||||
config=config,
|
||||
capacity_gb=capacity_gb,
|
||||
description=description
|
||||
)
|
||||
|
||||
if not storage_id:
|
||||
return None, "Failed to save storage provider"
|
||||
|
||||
return storage_id, None
|
||||
|
||||
async def update_storage(
|
||||
self,
|
||||
storage_id: int,
|
||||
actor_id: str,
|
||||
config: Optional[Dict[str, Any]] = None,
|
||||
capacity_gb: Optional[int] = None,
|
||||
is_active: Optional[bool] = None,
|
||||
) -> 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:
|
||||
return False, "Storage provider not found"
|
||||
if storage["actor_id"] != actor_id:
|
||||
return False, "Not authorized"
|
||||
|
||||
# Test new config if provided
|
||||
if config:
|
||||
existing_config = storage["config"] if isinstance(storage["config"], dict) else json.loads(storage["config"])
|
||||
new_config = {**existing_config, **config}
|
||||
provider = self.providers.create_provider(storage["provider_type"], {
|
||||
**new_config,
|
||||
"capacity_gb": capacity_gb or storage["capacity_gb"]
|
||||
})
|
||||
if provider:
|
||||
success, message = await provider.test_connection()
|
||||
if not success:
|
||||
return False, f"Provider connection failed: {message}"
|
||||
|
||||
success = await self.db.update_user_storage(
|
||||
storage_id,
|
||||
config=config,
|
||||
capacity_gb=capacity_gb,
|
||||
is_active=is_active
|
||||
)
|
||||
|
||||
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]]:
|
||||
"""Delete a storage provider. Returns (success, error_message)."""
|
||||
storage = await self.db.get_storage_by_id(storage_id)
|
||||
if not storage:
|
||||
return False, "Storage provider not found"
|
||||
if storage["actor_id"] != actor_id:
|
||||
return False, "Not authorized"
|
||||
|
||||
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]:
|
||||
"""Test storage provider connectivity. Returns (success, message)."""
|
||||
storage = await self.db.get_storage_by_id(storage_id)
|
||||
if not storage:
|
||||
return False, "Storage not found"
|
||||
if storage["actor_id"] != actor_id:
|
||||
return False, "Not authorized"
|
||||
|
||||
config = storage["config"] if isinstance(storage["config"], dict) else json.loads(storage["config"])
|
||||
provider = self.providers.create_provider(storage["provider_type"], {
|
||||
**config,
|
||||
"capacity_gb": storage["capacity_gb"]
|
||||
})
|
||||
|
||||
if not provider:
|
||||
return False, "Failed to create provider"
|
||||
|
||||
return await provider.test_connection()
|
||||
|
||||
async def list_by_type(self, actor_id: str, provider_type: str) -> List[Dict[str, Any]]:
|
||||
"""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]]:
|
||||
"""Build provider config from form data. Returns (config, error)."""
|
||||
api_key = form_data.get("api_key")
|
||||
secret_key = form_data.get("secret_key")
|
||||
api_token = form_data.get("api_token")
|
||||
project_id = form_data.get("project_id")
|
||||
project_secret = form_data.get("project_secret")
|
||||
access_key = form_data.get("access_key")
|
||||
bucket = form_data.get("bucket")
|
||||
path = form_data.get("path")
|
||||
|
||||
if provider_type == "pinata":
|
||||
if not api_key or not secret_key:
|
||||
return None, "Pinata requires API Key and Secret Key"
|
||||
return {"api_key": api_key, "secret_key": secret_key}, None
|
||||
|
||||
elif provider_type == "web3storage":
|
||||
if not api_token:
|
||||
return None, "web3.storage requires API Token"
|
||||
return {"api_token": api_token}, None
|
||||
|
||||
elif provider_type == "nftstorage":
|
||||
if not api_token:
|
||||
return None, "NFT.Storage requires API Token"
|
||||
return {"api_token": api_token}, None
|
||||
|
||||
elif provider_type == "infura":
|
||||
if not project_id or not project_secret:
|
||||
return None, "Infura requires Project ID and Project Secret"
|
||||
return {"project_id": project_id, "project_secret": project_secret}, None
|
||||
|
||||
elif provider_type == "filebase":
|
||||
if not access_key or not secret_key or not bucket:
|
||||
return None, "Filebase requires Access Key, Secret Key, and Bucket"
|
||||
return {"access_key": access_key, "secret_key": secret_key, "bucket": bucket}, None
|
||||
|
||||
elif provider_type == "storj":
|
||||
if not access_key or not secret_key or not bucket:
|
||||
return None, "Storj requires Access Key, Secret Key, and Bucket"
|
||||
return {"access_key": access_key, "secret_key": secret_key, "bucket": bucket}, None
|
||||
|
||||
elif provider_type == "local":
|
||||
if not path:
|
||||
return None, "Local storage requires a path"
|
||||
return {"path": path}, None
|
||||
|
||||
return None, f"Unknown provider type: {provider_type}"
|
||||
23
app/templates/base.html
Normal file
23
app/templates/base.html
Normal file
@@ -0,0 +1,23 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block brand %}Art-DAG L1{% endblock %}
|
||||
|
||||
{% block nav_items %}
|
||||
<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="/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>
|
||||
</nav>
|
||||
{% endblock %}
|
||||
|
||||
{% block nav_right %}
|
||||
{% if user %}
|
||||
<div class="flex items-center space-x-4">
|
||||
<span class="text-gray-400">{{ user.username }}</span>
|
||||
<a href="/auth/logout" class="text-gray-300 hover:text-white">Logout</a>
|
||||
</div>
|
||||
{% else %}
|
||||
<a href="/login" class="text-gray-300 hover:text-white">Login</a>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
110
app/templates/cache/detail.html
vendored
Normal file
110
app/templates/cache/detail.html
vendored
Normal file
@@ -0,0 +1,110 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ cache.hash[:16] }} - Cache - Art-DAG L1{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="max-w-4xl mx-auto">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center space-x-4 mb-6">
|
||||
<a href="/media" class="text-gray-400 hover:text-white">← Media</a>
|
||||
<h1 class="text-xl font-bold font-mono">{{ cache.hash[:24] }}...</h1>
|
||||
</div>
|
||||
|
||||
<!-- Preview -->
|
||||
<div class="bg-gray-800 rounded-lg border border-gray-700 mb-6 overflow-hidden">
|
||||
{% if cache.media_type and cache.media_type.startswith('image/') %}
|
||||
<img src="/cache/{{ cache.hash }}/raw" alt=""
|
||||
class="w-full max-h-96 object-contain bg-gray-900">
|
||||
|
||||
{% elif cache.media_type and cache.media_type.startswith('video/') %}
|
||||
<video src="/cache/{{ cache.hash }}/raw" controls
|
||||
class="w-full max-h-96 bg-gray-900">
|
||||
</video>
|
||||
|
||||
{% elif cache.media_type and cache.media_type.startswith('audio/') %}
|
||||
<div class="p-8 bg-gray-900">
|
||||
<audio src="/cache/{{ cache.hash }}/raw" controls class="w-full"></audio>
|
||||
</div>
|
||||
|
||||
{% elif cache.media_type == 'application/json' %}
|
||||
<div class="p-4 bg-gray-900 max-h-96 overflow-auto">
|
||||
<pre class="text-sm text-gray-300">{{ cache.content_preview }}</pre>
|
||||
</div>
|
||||
|
||||
{% else %}
|
||||
<div class="p-8 bg-gray-900 text-center text-gray-500">
|
||||
<div class="text-4xl mb-2">{{ cache.media_type or 'Unknown type' }}</div>
|
||||
<div>{{ cache.size_bytes | filesizeformat if cache.size_bytes else 'Unknown size' }}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- 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">Hash</div>
|
||||
<div class="font-mono text-sm text-white break-all">{{ cache.hash }}</div>
|
||||
</div>
|
||||
<div class="bg-gray-800 rounded-lg p-4">
|
||||
<div class="text-gray-500 text-sm">Content Type</div>
|
||||
<div class="text-white">{{ cache.media_type or 'Unknown' }}</div>
|
||||
</div>
|
||||
<div class="bg-gray-800 rounded-lg p-4">
|
||||
<div class="text-gray-500 text-sm">Size</div>
|
||||
<div class="text-white">{{ cache.size_bytes | filesizeformat if cache.size_bytes else 'Unknown' }}</div>
|
||||
</div>
|
||||
<div class="bg-gray-800 rounded-lg p-4">
|
||||
<div class="text-gray-500 text-sm">Created</div>
|
||||
<div class="text-white">{{ cache.created_at or 'Unknown' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- IPFS -->
|
||||
{% if cache.ipfs_cid %}
|
||||
<div class="bg-gray-800 rounded-lg p-4 mb-6">
|
||||
<div class="text-gray-500 text-sm mb-1">IPFS CID</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="font-mono text-sm text-white">{{ cache.ipfs_cid }}</span>
|
||||
<a href="https://ipfs.io/ipfs/{{ cache.ipfs_cid }}"
|
||||
target="_blank"
|
||||
class="text-blue-400 hover:text-blue-300 text-sm">
|
||||
View on IPFS Gateway →
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Related Runs -->
|
||||
{% if cache.runs %}
|
||||
<h2 class="text-lg font-semibold mb-4">Related Runs</h2>
|
||||
<div class="space-y-2">
|
||||
{% for run in cache.runs %}
|
||||
<a href="/run/{{ run.run_id }}"
|
||||
class="block bg-gray-800 rounded p-3 hover:bg-gray-750 transition-colors">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="font-mono text-sm">{{ run.run_id[:16] }}...</span>
|
||||
<span class="text-gray-500 text-sm">{{ run.created_at }}</span>
|
||||
</div>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex items-center space-x-4 mt-8">
|
||||
<a href="/cache/{{ cache.hash }}/raw"
|
||||
download
|
||||
class="bg-blue-600 hover:bg-blue-700 px-4 py-2 rounded font-medium">
|
||||
Download
|
||||
</a>
|
||||
{% if not cache.ipfs_cid %}
|
||||
<button hx-post="/cache/{{ cache.hash }}/publish"
|
||||
hx-target="#publish-result"
|
||||
class="bg-gray-700 hover:bg-gray-600 px-4 py-2 rounded font-medium">
|
||||
Publish to IPFS
|
||||
</button>
|
||||
<span id="publish-result"></span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
110
app/templates/cache/media_list.html
vendored
Normal file
110
app/templates/cache/media_list.html
vendored
Normal file
@@ -0,0 +1,110 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Media - 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">Media</h1>
|
||||
<div class="flex items-center space-x-4">
|
||||
<select id="type-filter" onchange="filterMedia()"
|
||||
class="bg-gray-800 border border-gray-600 rounded px-3 py-2 text-white">
|
||||
<option value="">All Types</option>
|
||||
<option value="image">Images</option>
|
||||
<option value="video">Videos</option>
|
||||
<option value="audio">Audio</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if items %}
|
||||
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4" id="media-grid">
|
||||
{% for item in items %}
|
||||
<a href="/cache/{{ item.hash }}"
|
||||
class="media-item bg-gray-800 rounded-lg overflow-hidden hover:ring-2 hover:ring-blue-500 transition-all"
|
||||
data-type="{{ item.media_type.split('/')[0] if item.media_type else 'other' }}">
|
||||
|
||||
{% if item.media_type and item.media_type.startswith('image/') %}
|
||||
<img src="/cache/{{ item.hash }}/raw"
|
||||
alt=""
|
||||
loading="lazy"
|
||||
class="w-full h-40 object-cover">
|
||||
|
||||
{% elif item.media_type and item.media_type.startswith('video/') %}
|
||||
<div class="relative">
|
||||
<video src="/cache/{{ item.hash }}/raw"
|
||||
class="w-full h-40 object-cover"
|
||||
muted
|
||||
onmouseover="this.play()"
|
||||
onmouseout="this.pause(); this.currentTime=0;">
|
||||
</video>
|
||||
<div class="absolute inset-0 flex items-center justify-center pointer-events-none">
|
||||
<div class="bg-black bg-opacity-50 rounded-full p-2">
|
||||
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M6.3 2.841A1.5 1.5 0 004 4.11V15.89a1.5 1.5 0 002.3 1.269l9.344-5.89a1.5 1.5 0 000-2.538L6.3 2.84z"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% elif item.media_type and item.media_type.startswith('audio/') %}
|
||||
<div class="w-full h-40 bg-gray-900 flex flex-col items-center justify-center">
|
||||
<svg class="w-12 h-12 text-gray-600 mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
|
||||
d="M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM9 10l12-3"/>
|
||||
</svg>
|
||||
<span class="text-gray-500 text-sm">Audio</span>
|
||||
</div>
|
||||
|
||||
{% else %}
|
||||
<div class="w-full h-40 bg-gray-900 flex items-center justify-center">
|
||||
<span class="text-gray-600 text-sm">{{ item.media_type or 'Unknown' }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="p-3">
|
||||
<div class="font-mono text-xs text-gray-500 truncate">{{ item.hash[:16] }}...</div>
|
||||
{% if item.size_bytes %}
|
||||
<div class="text-xs text-gray-600">{{ item.size_bytes | filesizeformat }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{% if has_more %}
|
||||
<div hx-get="/media?offset={{ offset + limit }}"
|
||||
hx-trigger="revealed"
|
||||
hx-swap="beforeend"
|
||||
hx-target="#media-grid"
|
||||
hx-select=".media-item"
|
||||
class="h-20 flex items-center justify-center text-gray-500 mt-4">
|
||||
Loading more...
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% else %}
|
||||
<div class="bg-gray-800 border border-gray-700 rounded-lg p-12 text-center">
|
||||
<svg class="w-16 h-16 mx-auto mb-4 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
|
||||
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"/>
|
||||
</svg>
|
||||
<p class="text-gray-500 mb-4">No media files yet</p>
|
||||
<p class="text-gray-600 text-sm">Run a recipe to generate media artifacts.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function filterMedia() {
|
||||
const filter = document.getElementById('type-filter').value;
|
||||
document.querySelectorAll('.media-item').forEach(item => {
|
||||
if (!filter || item.dataset.type === filter) {
|
||||
item.classList.remove('hidden');
|
||||
} else {
|
||||
item.classList.add('hidden');
|
||||
}
|
||||
});
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
40
app/templates/home.html
Normal file
40
app/templates/home.html
Normal file
@@ -0,0 +1,40 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Art-DAG L1{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="max-w-4xl mx-auto text-center py-12">
|
||||
<h1 class="text-4xl font-bold mb-4">Art-DAG L1</h1>
|
||||
<p class="text-xl text-gray-400 mb-8">Content-Addressable Media Processing</p>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 max-w-2xl mx-auto mb-12">
|
||||
<a href="/runs"
|
||||
class="bg-gray-800 border border-gray-700 rounded-lg p-6 hover:border-blue-500 transition-colors">
|
||||
<div class="text-blue-400 text-3xl font-bold mb-2">{{ stats.runs or 0 }}</div>
|
||||
<div class="text-gray-400">Execution Runs</div>
|
||||
</a>
|
||||
<a href="/recipes"
|
||||
class="bg-gray-800 border border-gray-700 rounded-lg p-6 hover:border-green-500 transition-colors">
|
||||
<div class="text-green-400 text-3xl font-bold mb-2">{{ stats.recipes or 0 }}</div>
|
||||
<div class="text-gray-400">Recipes</div>
|
||||
</a>
|
||||
<a href="/media"
|
||||
class="bg-gray-800 border border-gray-700 rounded-lg p-6 hover:border-purple-500 transition-colors">
|
||||
<div class="text-purple-400 text-3xl font-bold mb-2">{{ stats.media or 0 }}</div>
|
||||
<div class="text-gray-400">Media Files</div>
|
||||
</a>
|
||||
<a href="/storage"
|
||||
class="bg-gray-800 border border-gray-700 rounded-lg p-6 hover:border-orange-500 transition-colors">
|
||||
<div class="text-orange-400 text-3xl font-bold mb-2">{{ stats.storage or 0 }}</div>
|
||||
<div class="text-gray-400">Storage Providers</div>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{% if not user %}
|
||||
<div class="bg-gray-800 border border-gray-700 rounded-lg p-8 max-w-md mx-auto">
|
||||
<p class="text-gray-400 mb-4">Sign in through your L2 server to access all features.</p>
|
||||
<a href="/auth" class="text-blue-400 hover:text-blue-300">Sign In →</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
112
app/templates/recipes/detail.html
Normal file
112
app/templates/recipes/detail.html
Normal file
@@ -0,0 +1,112 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ recipe.name }} - Recipe - Art-DAG L1{% endblock %}
|
||||
|
||||
{% block head %}
|
||||
{{ super() }}
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/cytoscape/3.23.0/cytoscape.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="/recipes" class="text-gray-400 hover:text-white">← Recipes</a>
|
||||
<h1 class="text-2xl font-bold">{{ recipe.name }}</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>
|
||||
{% endif %}
|
||||
|
||||
<!-- 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">
|
||||
<span class="text-gray-400 text-sm">Pipeline DAG</span>
|
||||
<span class="text-gray-500 text-sm">{{ recipe.steps | length }} steps</span>
|
||||
</div>
|
||||
<div id="dag-container" class="h-80"></div>
|
||||
</div>
|
||||
|
||||
<!-- Steps -->
|
||||
<h2 class="text-lg font-semibold mb-4">Steps</h2>
|
||||
<div class="space-y-3 mb-8">
|
||||
{% for step in recipe.steps %}
|
||||
{% set colors = {
|
||||
'effect': 'blue',
|
||||
'analyze': 'purple',
|
||||
'transform': 'green',
|
||||
'combine': 'orange',
|
||||
'output': 'cyan'
|
||||
} %}
|
||||
{% set color = colors.get(step.type, 'gray') %}
|
||||
|
||||
<div class="bg-gray-800 rounded-lg p-4 border border-gray-700">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<div class="flex items-center space-x-3">
|
||||
<span class="w-8 h-8 rounded bg-{{ color }}-900 text-{{ color }}-300 flex items-center justify-center font-mono text-sm">
|
||||
{{ loop.index }}
|
||||
</span>
|
||||
<span class="font-medium">{{ step.name }}</span>
|
||||
<span class="bg-{{ color }}-900 text-{{ color }}-300 px-2 py-0.5 rounded text-xs">
|
||||
{{ step.type }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if step.inputs %}
|
||||
<div class="text-sm text-gray-400 mb-1">
|
||||
Inputs: {{ step.inputs | join(', ') }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if step.params %}
|
||||
<div class="mt-2 bg-gray-900 rounded p-2">
|
||||
<code class="text-xs text-gray-400">{{ step.params | tojson }}</code>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<!-- YAML Source -->
|
||||
<h2 class="text-lg font-semibold mb-4">Source</h2>
|
||||
<div class="bg-gray-900 rounded-lg p-4 border border-gray-700">
|
||||
<pre class="text-sm text-gray-300 overflow-x-auto whitespace-pre-wrap">{{ recipe.yaml }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const cy = cytoscape({
|
||||
container: document.getElementById('dag-container'),
|
||||
style: [
|
||||
{ selector: 'node', style: {
|
||||
'label': 'data(label)',
|
||||
'background-color': 'data(color)',
|
||||
'color': '#fff',
|
||||
'text-valign': 'center',
|
||||
'text-halign': 'center',
|
||||
'font-size': '11px',
|
||||
'width': 'label',
|
||||
'height': 35,
|
||||
'padding': '10px',
|
||||
'shape': 'round-rectangle'
|
||||
}},
|
||||
{ selector: 'edge', style: {
|
||||
'width': 2,
|
||||
'line-color': '#4b5563',
|
||||
'target-arrow-color': '#4b5563',
|
||||
'target-arrow-shape': 'triangle',
|
||||
'curve-style': 'bezier'
|
||||
}}
|
||||
],
|
||||
elements: {{ dag_elements | tojson }},
|
||||
layout: { name: 'dagre', rankDir: 'LR', padding: 30 }
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
55
app/templates/recipes/list.html
Normal file
55
app/templates/recipes/list.html
Normal file
@@ -0,0 +1,55 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Recipes - 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">Recipes</h1>
|
||||
</div>
|
||||
|
||||
<p class="text-gray-400 mb-8">
|
||||
Recipes define processing pipelines for audio and media. Each recipe is a DAG of effects.
|
||||
</p>
|
||||
|
||||
{% if recipes %}
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{% for recipe in recipes %}
|
||||
<a href="/recipe/{{ recipe.id }}"
|
||||
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">{{ recipe.name }}</span>
|
||||
{% if recipe.version %}
|
||||
<span class="text-gray-500 text-sm">v{{ recipe.version }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if recipe.description %}
|
||||
<p class="text-gray-400 text-sm mb-3 line-clamp-2">{{ recipe.description }}</p>
|
||||
{% endif %}
|
||||
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<span class="text-gray-500">{{ recipe.step_count or 0 }} steps</span>
|
||||
{% if recipe.last_run %}
|
||||
<span class="text-gray-500">Last run: {{ recipe.last_run }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if recipe.tags %}
|
||||
<div class="mt-2 flex flex-wrap gap-1">
|
||||
{% for tag in recipe.tags %}
|
||||
<span class="bg-gray-700 text-gray-300 px-2 py-0.5 rounded text-xs">{{ tag }}</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</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 recipes available.</p>
|
||||
<p class="text-gray-600 text-sm">Recipes are defined in YAML format and submitted via API.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
48
app/templates/runs/_run_card.html
Normal file
48
app/templates/runs/_run_card.html
Normal file
@@ -0,0 +1,48 @@
|
||||
{# Run card partial - expects 'run' variable #}
|
||||
{% set status_colors = {
|
||||
'completed': 'green',
|
||||
'running': 'blue',
|
||||
'pending': 'yellow',
|
||||
'failed': 'red',
|
||||
'cached': 'purple'
|
||||
} %}
|
||||
{% set color = status_colors.get(run.status, 'gray') %}
|
||||
|
||||
<a href="/run/{{ run.run_id }}"
|
||||
class="block 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">
|
||||
<div class="flex items-center space-x-3">
|
||||
<span class="font-mono text-sm text-gray-400">{{ run.run_id[:12] }}...</span>
|
||||
<span class="bg-{{ color }}-900 text-{{ color }}-300 px-2 py-0.5 rounded text-xs uppercase">
|
||||
{{ run.status }}
|
||||
</span>
|
||||
{% if run.cached %}
|
||||
<span class="bg-purple-900 text-purple-300 px-2 py-0.5 rounded text-xs">cached</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<span class="text-gray-500 text-sm">{{ run.created_at }}</span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center space-x-4 text-sm">
|
||||
<span class="text-gray-400">
|
||||
Recipe: <span class="text-white">{{ run.recipe or 'Unknown' }}</span>
|
||||
</span>
|
||||
{% if run.total_steps %}
|
||||
<span class="text-gray-400">
|
||||
Steps: <span class="text-white">{{ run.executed or 0 }}/{{ run.total_steps }}</span>
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if run.output_hash %}
|
||||
<span class="font-mono text-xs text-gray-500">{{ run.output_hash[:16] }}...</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if run.inputs %}
|
||||
<div class="mt-2 text-xs text-gray-500">
|
||||
Inputs: {{ run.inputs | length }} file(s)
|
||||
</div>
|
||||
{% endif %}
|
||||
</a>
|
||||
219
app/templates/runs/detail.html
Normal file
219
app/templates/runs/detail.html
Normal file
@@ -0,0 +1,219 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Run {{ run.run_id[:12] }} - Art-DAG L1{% endblock %}
|
||||
|
||||
{% block head %}
|
||||
{{ super() }}
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/cytoscape/3.23.0/cytoscape.min.js"></script>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% set status_colors = {'completed': 'green', 'running': 'blue', 'pending': 'yellow', 'failed': 'red'} %}
|
||||
{% set color = status_colors.get(run.status, 'gray') %}
|
||||
|
||||
<div class="max-w-6xl mx-auto">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center space-x-4 mb-6">
|
||||
<a href="/runs" class="text-gray-400 hover:text-white">← Runs</a>
|
||||
<h1 class="text-2xl font-bold font-mono">{{ run.run_id[:16] }}...</h1>
|
||||
<span class="bg-{{ color }}-900 text-{{ color }}-300 px-3 py-1 rounded text-sm uppercase">
|
||||
{{ run.status }}
|
||||
</span>
|
||||
{% if run.cached %}
|
||||
<span class="bg-purple-900 text-purple-300 px-3 py-1 rounded text-sm">Cached</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Info Grid -->
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
|
||||
<div class="bg-gray-800 rounded-lg p-4">
|
||||
<div class="text-gray-500 text-sm">Recipe</div>
|
||||
<div class="text-white font-medium">{{ run.recipe or 'Unknown' }}</div>
|
||||
</div>
|
||||
<div class="bg-gray-800 rounded-lg p-4">
|
||||
<div class="text-gray-500 text-sm">Steps</div>
|
||||
<div class="text-white font-medium">
|
||||
{{ run.executed or 0 }} / {{ run.total_steps or '?' }}
|
||||
{% if run.cached_steps %}
|
||||
<span class="text-purple-400 text-sm">({{ run.cached_steps }} cached)</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-gray-800 rounded-lg p-4">
|
||||
<div class="text-gray-500 text-sm">Created</div>
|
||||
<div class="text-white font-medium">{{ run.created_at }}</div>
|
||||
</div>
|
||||
<div class="bg-gray-800 rounded-lg p-4">
|
||||
<div class="text-gray-500 text-sm">User</div>
|
||||
<div class="text-white font-medium">{{ run.username or 'Unknown' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tabs -->
|
||||
<div class="border-b border-gray-700 mb-6">
|
||||
<nav class="flex space-x-8">
|
||||
<a href="#plan" class="tab-link border-b-2 border-blue-500 text-white pb-3 px-1"
|
||||
onclick="showTab('plan')">Plan</a>
|
||||
<a href="#artifacts" class="tab-link border-b-2 border-transparent text-gray-400 hover:text-white pb-3 px-1"
|
||||
onclick="showTab('artifacts')">Artifacts</a>
|
||||
{% if run.analysis %}
|
||||
<a href="#analysis" class="tab-link border-b-2 border-transparent text-gray-400 hover:text-white pb-3 px-1"
|
||||
onclick="showTab('analysis')">Analysis</a>
|
||||
{% endif %}
|
||||
<a href="#inputs" class="tab-link border-b-2 border-transparent text-gray-400 hover:text-white pb-3 px-1"
|
||||
onclick="showTab('inputs')">Inputs</a>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<!-- Plan Tab -->
|
||||
<div id="tab-plan" class="tab-content">
|
||||
{% if plan %}
|
||||
<div id="dag-container" class="bg-gray-900 rounded-lg border border-gray-700 h-96 mb-4"></div>
|
||||
|
||||
<div class="space-y-2">
|
||||
{% for step in plan.steps %}
|
||||
{% set step_color = 'green' if step.cached else ('blue' if step.status == 'running' else 'gray') %}
|
||||
<div class="bg-gray-800 rounded p-3 flex items-center justify-between">
|
||||
<div class="flex items-center space-x-3">
|
||||
<span class="w-6 h-6 rounded-full bg-{{ step_color }}-600 flex items-center justify-center text-xs">
|
||||
{{ loop.index }}
|
||||
</span>
|
||||
<span class="font-medium">{{ step.name }}</span>
|
||||
<span class="text-gray-500 text-sm">{{ step.type }}</span>
|
||||
</div>
|
||||
<div class="flex items-center space-x-3">
|
||||
{% if step.cached %}
|
||||
<span class="text-purple-400 text-sm">cached</span>
|
||||
{% endif %}
|
||||
{% if step.cache_id %}
|
||||
<a href="/cache/{{ step.cache_id }}" class="font-mono text-xs text-gray-500 hover:text-white">
|
||||
{{ step.cache_id[:12] }}...
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-gray-500">No plan available for this run.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Artifacts Tab -->
|
||||
<div id="tab-artifacts" class="tab-content hidden">
|
||||
{% if artifacts %}
|
||||
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
|
||||
{% for artifact in artifacts %}
|
||||
<a href="/cache/{{ artifact.hash }}"
|
||||
class="bg-gray-800 rounded-lg p-4 hover:bg-gray-750 transition-colors">
|
||||
{% if artifact.media_type and artifact.media_type.startswith('image/') %}
|
||||
<img src="/cache/{{ artifact.hash }}/raw" alt=""
|
||||
class="w-full h-32 object-cover rounded mb-2">
|
||||
{% elif artifact.media_type and artifact.media_type.startswith('video/') %}
|
||||
<video src="/cache/{{ artifact.hash }}/raw"
|
||||
class="w-full h-32 object-cover rounded mb-2" muted></video>
|
||||
{% else %}
|
||||
<div class="w-full h-32 bg-gray-900 rounded mb-2 flex items-center justify-center text-gray-600">
|
||||
{{ artifact.media_type or 'Unknown' }}
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="font-mono text-xs text-gray-500 truncate">{{ artifact.hash[:16] }}...</div>
|
||||
<div class="text-sm text-gray-400">{{ artifact.step_name }}</div>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-gray-500">No artifacts generated yet.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Analysis Tab -->
|
||||
<div id="tab-analysis" class="tab-content hidden">
|
||||
{% if run.analysis %}
|
||||
<div class="bg-gray-800 rounded-lg p-6">
|
||||
<h3 class="text-lg font-semibold mb-4">Audio Analysis</h3>
|
||||
<pre class="text-sm text-gray-300 overflow-x-auto">{{ run.analysis | tojson(indent=2) }}</pre>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-gray-500">No analysis data available.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Inputs Tab -->
|
||||
<div id="tab-inputs" class="tab-content hidden">
|
||||
{% if run.inputs %}
|
||||
<div class="space-y-2">
|
||||
{% for input_hash in run.inputs %}
|
||||
<a href="/cache/{{ input_hash }}"
|
||||
class="block bg-gray-800 rounded p-3 font-mono text-sm hover:bg-gray-750">
|
||||
{{ input_hash }}
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-gray-500">No inputs recorded.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Output -->
|
||||
{% if run.output_hash %}
|
||||
<div class="mt-8 bg-gray-800 rounded-lg p-6">
|
||||
<h3 class="text-lg font-semibold mb-4">Output</h3>
|
||||
<div class="flex items-center justify-between">
|
||||
<a href="/cache/{{ run.output_hash }}" class="font-mono text-blue-400 hover:text-blue-300">
|
||||
{{ run.output_hash }}
|
||||
</a>
|
||||
{% if run.output_ipfs_cid %}
|
||||
<a href="https://ipfs.io/ipfs/{{ run.output_ipfs_cid }}"
|
||||
target="_blank"
|
||||
class="text-gray-400 hover:text-white text-sm">
|
||||
IPFS: {{ run.output_ipfs_cid[:16] }}...
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function showTab(name) {
|
||||
document.querySelectorAll('.tab-content').forEach(el => el.classList.add('hidden'));
|
||||
document.querySelectorAll('.tab-link').forEach(el => {
|
||||
el.classList.remove('border-blue-500', 'text-white');
|
||||
el.classList.add('border-transparent', 'text-gray-400');
|
||||
});
|
||||
document.getElementById('tab-' + name).classList.remove('hidden');
|
||||
event.target.classList.add('border-blue-500', 'text-white');
|
||||
event.target.classList.remove('border-transparent', 'text-gray-400');
|
||||
}
|
||||
|
||||
{% if plan %}
|
||||
// Initialize DAG
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const cy = cytoscape({
|
||||
container: document.getElementById('dag-container'),
|
||||
style: [
|
||||
{ selector: 'node', style: {
|
||||
'label': 'data(label)',
|
||||
'background-color': 'data(color)',
|
||||
'color': '#fff',
|
||||
'text-valign': 'center',
|
||||
'font-size': '10px',
|
||||
'width': 40,
|
||||
'height': 40
|
||||
}},
|
||||
{ selector: 'edge', style: {
|
||||
'width': 2,
|
||||
'line-color': '#4b5563',
|
||||
'target-arrow-color': '#4b5563',
|
||||
'target-arrow-shape': 'triangle',
|
||||
'curve-style': 'bezier'
|
||||
}}
|
||||
],
|
||||
elements: {{ dag_elements | tojson }},
|
||||
layout: { name: 'dagre', rankDir: 'LR', padding: 30 }
|
||||
});
|
||||
});
|
||||
{% endif %}
|
||||
</script>
|
||||
{% endblock %}
|
||||
45
app/templates/runs/list.html
Normal file
45
app/templates/runs/list.html
Normal file
@@ -0,0 +1,45 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Runs - 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">Execution Runs</h1>
|
||||
<a href="/recipes" class="text-gray-400 hover:text-white">Browse Recipes →</a>
|
||||
</div>
|
||||
|
||||
{% if runs %}
|
||||
<div class="space-y-4" id="runs-list">
|
||||
{% for run in runs %}
|
||||
{% include "runs/_run_card.html" %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{% if has_more %}
|
||||
<div hx-get="/runs?offset={{ offset + limit }}"
|
||||
hx-trigger="revealed"
|
||||
hx-swap="afterend"
|
||||
hx-select="#runs-list > *"
|
||||
class="h-20 flex items-center justify-center text-gray-500">
|
||||
Loading more...
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% else %}
|
||||
<div class="bg-gray-800 border border-gray-700 rounded-lg p-12 text-center">
|
||||
<div class="text-gray-500 mb-4">
|
||||
<svg class="w-16 h-16 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
|
||||
d="M13 10V3L4 14h7v7l9-11h-7z"/>
|
||||
</svg>
|
||||
<p class="text-xl">No runs yet</p>
|
||||
</div>
|
||||
<p class="text-gray-600 mb-6">Execute a recipe to see your runs here.</p>
|
||||
<a href="/recipes" class="bg-blue-600 hover:bg-blue-700 px-6 py-2 rounded font-medium">
|
||||
Browse Recipes
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
90
app/templates/storage/list.html
Normal file
90
app/templates/storage/list.html
Normal file
@@ -0,0 +1,90 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Storage Providers - Art-DAG L1{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="max-w-6xl mx-auto">
|
||||
<h1 class="text-3xl font-bold mb-6">Storage Providers</h1>
|
||||
|
||||
<p class="text-gray-400 mb-8">
|
||||
Configure your IPFS pinning services. Data is pinned to your accounts, giving you full control.
|
||||
</p>
|
||||
|
||||
<!-- Provider Grid -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 mb-8">
|
||||
{% for key, info in providers_info.items() %}
|
||||
<a href="/storage/type/{{ key }}"
|
||||
class="bg-gray-800 border border-gray-700 rounded-lg p-4 hover:border-{{ info.color }}-500 transition-colors">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span class="text-lg font-medium text-{{ info.color }}-400">{{ info.name }}</span>
|
||||
{% set count = storages | selectattr('provider_type', 'equalto', key) | list | length %}
|
||||
{% if count > 0 %}
|
||||
<span class="bg-{{ info.color }}-900 text-{{ info.color }}-300 px-2 py-0.5 rounded text-sm">
|
||||
{{ count }} configured
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<p class="text-gray-400 text-sm">{{ info.desc }}</p>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<!-- Configured Providers -->
|
||||
{% if storages %}
|
||||
<h2 class="text-xl font-semibold mb-4">Your Storage Providers</h2>
|
||||
<div class="space-y-4">
|
||||
{% for storage in storages %}
|
||||
{% set info = providers_info.get(storage.provider_type, {'name': storage.provider_type, 'color': 'gray'}) %}
|
||||
<div class="bg-gray-800 border border-gray-700 rounded-lg p-4" id="storage-{{ storage.id }}">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<div class="flex items-center space-x-3">
|
||||
<span class="text-{{ info.color }}-400 font-medium">{{ storage.provider_name or info.name }}</span>
|
||||
{% if storage.is_active %}
|
||||
<span class="bg-green-900 text-green-300 px-2 py-0.5 rounded text-xs">Active</span>
|
||||
{% else %}
|
||||
<span class="bg-gray-700 text-gray-400 px-2 py-0.5 rounded text-xs">Inactive</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<button hx-post="/storage/{{ storage.id }}/test"
|
||||
hx-target="#test-result-{{ storage.id }}"
|
||||
class="text-gray-400 hover:text-white text-sm">
|
||||
Test
|
||||
</button>
|
||||
<button hx-delete="/storage/{{ storage.id }}"
|
||||
hx-target="#storage-{{ storage.id }}"
|
||||
hx-swap="outerHTML"
|
||||
hx-confirm="Remove this storage provider?"
|
||||
class="text-red-400 hover:text-red-300 text-sm">
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-3 gap-4 text-sm">
|
||||
<div>
|
||||
<span class="text-gray-500">Capacity:</span>
|
||||
<span class="text-gray-300">{{ storage.capacity_gb }} GB</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-gray-500">Used:</span>
|
||||
<span class="text-gray-300">{{ (storage.used_bytes / 1024 / 1024 / 1024) | round(2) }} GB</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-gray-500">Pins:</span>
|
||||
<span class="text-gray-300">{{ storage.pin_count }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="test-result-{{ storage.id }}" class="mt-2 text-sm"></div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="bg-gray-800 border border-gray-700 rounded-lg p-8 text-center">
|
||||
<p class="text-gray-400 mb-4">No storage providers configured yet.</p>
|
||||
<p class="text-gray-500 text-sm">Click on a provider above to add your first one.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
152
app/templates/storage/type.html
Normal file
152
app/templates/storage/type.html
Normal file
@@ -0,0 +1,152 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ provider_info.name }} - Storage - Art-DAG L1{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="max-w-4xl mx-auto">
|
||||
<div class="flex items-center space-x-4 mb-6">
|
||||
<a href="/storage" class="text-gray-400 hover:text-white">← All Providers</a>
|
||||
<h1 class="text-2xl font-bold text-{{ provider_info.color }}-400">{{ provider_info.name }}</h1>
|
||||
</div>
|
||||
|
||||
<p class="text-gray-400 mb-8">{{ provider_info.desc }}</p>
|
||||
|
||||
<!-- Add New -->
|
||||
<div class="bg-gray-800 border border-gray-700 rounded-lg p-6 mb-8">
|
||||
<h2 class="text-lg font-semibold mb-4">Add {{ provider_info.name }} Account</h2>
|
||||
|
||||
<form hx-post="/storage/add"
|
||||
hx-target="#add-result"
|
||||
class="space-y-4">
|
||||
<input type="hidden" name="provider_type" value="{{ provider_type }}">
|
||||
|
||||
<div>
|
||||
<label class="block text-gray-400 text-sm mb-1">Name (optional)</label>
|
||||
<input type="text" name="provider_name"
|
||||
placeholder="{{ provider_type }}-1"
|
||||
class="w-full bg-gray-900 border border-gray-600 rounded px-3 py-2 text-white">
|
||||
</div>
|
||||
|
||||
{% if provider_type == 'pinata' %}
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-gray-400 text-sm mb-1">API Key *</label>
|
||||
<input type="text" name="api_key" required
|
||||
class="w-full bg-gray-900 border border-gray-600 rounded px-3 py-2 text-white">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-gray-400 text-sm mb-1">Secret Key *</label>
|
||||
<input type="password" name="secret_key" required
|
||||
class="w-full bg-gray-900 border border-gray-600 rounded px-3 py-2 text-white">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% elif provider_type in ['web3storage', 'nftstorage'] %}
|
||||
<div>
|
||||
<label class="block text-gray-400 text-sm mb-1">API Token *</label>
|
||||
<input type="password" name="api_token" required
|
||||
class="w-full bg-gray-900 border border-gray-600 rounded px-3 py-2 text-white">
|
||||
</div>
|
||||
|
||||
{% elif provider_type == 'infura' %}
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-gray-400 text-sm mb-1">Project ID *</label>
|
||||
<input type="text" name="project_id" required
|
||||
class="w-full bg-gray-900 border border-gray-600 rounded px-3 py-2 text-white">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-gray-400 text-sm mb-1">Project Secret *</label>
|
||||
<input type="password" name="project_secret" required
|
||||
class="w-full bg-gray-900 border border-gray-600 rounded px-3 py-2 text-white">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% elif provider_type in ['filebase', 'storj'] %}
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-gray-400 text-sm mb-1">Access Key *</label>
|
||||
<input type="text" name="access_key" required
|
||||
class="w-full bg-gray-900 border border-gray-600 rounded px-3 py-2 text-white">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-gray-400 text-sm mb-1">Secret Key *</label>
|
||||
<input type="password" name="secret_key" required
|
||||
class="w-full bg-gray-900 border border-gray-600 rounded px-3 py-2 text-white">
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-gray-400 text-sm mb-1">Bucket *</label>
|
||||
<input type="text" name="bucket" required
|
||||
class="w-full bg-gray-900 border border-gray-600 rounded px-3 py-2 text-white">
|
||||
</div>
|
||||
|
||||
{% elif provider_type == 'local' %}
|
||||
<div>
|
||||
<label class="block text-gray-400 text-sm mb-1">Path *</label>
|
||||
<input type="text" name="path" required placeholder="/data/ipfs"
|
||||
class="w-full bg-gray-900 border border-gray-600 rounded px-3 py-2 text-white">
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div>
|
||||
<label class="block text-gray-400 text-sm mb-1">Capacity (GB)</label>
|
||||
<input type="number" name="capacity_gb" value="5" min="1"
|
||||
class="w-32 bg-gray-900 border border-gray-600 rounded px-3 py-2 text-white">
|
||||
</div>
|
||||
|
||||
<div class="pt-2">
|
||||
<button type="submit"
|
||||
class="bg-{{ provider_info.color }}-600 hover:bg-{{ provider_info.color }}-700 px-4 py-2 rounded font-medium">
|
||||
Add Provider
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="add-result"></div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Existing Configs -->
|
||||
{% if storages %}
|
||||
<h2 class="text-lg font-semibold mb-4">Configured Accounts</h2>
|
||||
<div class="space-y-4">
|
||||
{% for storage in storages %}
|
||||
<div class="bg-gray-800 border border-gray-700 rounded-lg p-4" id="storage-{{ storage.id }}">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<div class="flex items-center space-x-3">
|
||||
<span class="font-medium">{{ storage.provider_name }}</span>
|
||||
{% if storage.is_active %}
|
||||
<span class="bg-green-900 text-green-300 px-2 py-0.5 rounded text-xs">Active</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="flex items-center space-x-3">
|
||||
<button hx-post="/storage/{{ storage.id }}/test"
|
||||
hx-target="#test-{{ storage.id }}"
|
||||
class="text-gray-400 hover:text-white text-sm">
|
||||
Test Connection
|
||||
</button>
|
||||
<button hx-delete="/storage/{{ storage.id }}"
|
||||
hx-target="#storage-{{ storage.id }}"
|
||||
hx-swap="outerHTML"
|
||||
hx-confirm="Remove this storage provider?"
|
||||
class="text-red-400 hover:text-red-300 text-sm">
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if storage.config_display %}
|
||||
<div class="text-sm text-gray-400 space-x-4">
|
||||
{% for key, value in storage.config_display.items() %}
|
||||
<span>{{ key }}: <code class="text-gray-300">{{ value }}</code></span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div id="test-{{ storage.id }}" class="mt-2 text-sm"></div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user