Import common/
This commit is contained in:
293
common/README.md
Normal file
293
common/README.md
Normal file
@@ -0,0 +1,293 @@
|
||||
# artdag-common
|
||||
|
||||
Shared components for Art-DAG L1 (celery) and L2 (activity-pub) servers.
|
||||
|
||||
## Features
|
||||
|
||||
- **Jinja2 Templating**: Unified template environment with shared base templates
|
||||
- **Reusable Components**: Cards, tables, pagination, DAG visualization, media preview
|
||||
- **Authentication Middleware**: Cookie and JWT token parsing
|
||||
- **Content Negotiation**: HTML/JSON/ActivityPub format detection
|
||||
- **Utility Functions**: Hash truncation, file size formatting, status colors
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
pip install -e /path/to/artdag-common
|
||||
|
||||
# Or add to requirements.txt
|
||||
-e file:../common
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
```python
|
||||
from fastapi import FastAPI, Request
|
||||
from artdag_common import create_jinja_env, render
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
# Initialize templates with app-specific directory
|
||||
templates = create_jinja_env("app/templates")
|
||||
|
||||
@app.get("/")
|
||||
async def home(request: Request):
|
||||
return render(templates, "home.html", request, title="Home")
|
||||
```
|
||||
|
||||
## Package Structure
|
||||
|
||||
```
|
||||
artdag_common/
|
||||
├── __init__.py # Package exports
|
||||
├── constants.py # CDN URLs, colors, configs
|
||||
├── rendering.py # Jinja2 environment and helpers
|
||||
├── middleware/
|
||||
│ ├── auth.py # Authentication utilities
|
||||
│ └── content_negotiation.py # Accept header parsing
|
||||
├── models/
|
||||
│ ├── requests.py # Shared request models
|
||||
│ └── responses.py # Shared response models
|
||||
├── utils/
|
||||
│ ├── formatting.py # Text/date formatting
|
||||
│ ├── media.py # Media type detection
|
||||
│ └── pagination.py # Pagination helpers
|
||||
└── templates/
|
||||
├── base.html # Base layout template
|
||||
└── components/
|
||||
├── badge.html # Status/type badges
|
||||
├── card.html # Info cards
|
||||
├── dag.html # Cytoscape DAG visualization
|
||||
├── media_preview.html # Video/image/audio preview
|
||||
├── pagination.html # HTMX pagination
|
||||
└── table.html # Styled tables
|
||||
```
|
||||
|
||||
## Jinja2 Templates
|
||||
|
||||
### Base Template
|
||||
|
||||
The `base.html` template provides:
|
||||
- Dark theme with Tailwind CSS
|
||||
- HTMX integration
|
||||
- Navigation slot
|
||||
- Content block
|
||||
- Optional Cytoscape.js block
|
||||
|
||||
```html
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}My Page{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1>Hello World</h1>
|
||||
{% endblock %}
|
||||
```
|
||||
|
||||
### Reusable Components
|
||||
|
||||
#### Card
|
||||
|
||||
```html
|
||||
{% include "components/card.html" %}
|
||||
```
|
||||
|
||||
```html
|
||||
<!-- Usage in your template -->
|
||||
<div class="...card styles...">
|
||||
{% block card_title %}Title{% endblock %}
|
||||
{% block card_content %}Content{% endblock %}
|
||||
</div>
|
||||
```
|
||||
|
||||
#### Badge
|
||||
|
||||
Status and type badges with appropriate colors:
|
||||
|
||||
```html
|
||||
{% from "components/badge.html" import status_badge, type_badge %}
|
||||
|
||||
{{ status_badge("completed") }} <!-- Green -->
|
||||
{{ status_badge("failed") }} <!-- Red -->
|
||||
{{ type_badge("video") }}
|
||||
```
|
||||
|
||||
#### DAG Visualization
|
||||
|
||||
Interactive Cytoscape.js graph:
|
||||
|
||||
```html
|
||||
{% include "components/dag.html" %}
|
||||
```
|
||||
|
||||
Requires passing `nodes` and `edges` data to template context.
|
||||
|
||||
#### Media Preview
|
||||
|
||||
Responsive media preview with format detection:
|
||||
|
||||
```html
|
||||
{% include "components/media_preview.html" %}
|
||||
```
|
||||
|
||||
Supports video, audio, and image formats.
|
||||
|
||||
#### Pagination
|
||||
|
||||
HTMX-powered infinite scroll pagination:
|
||||
|
||||
```html
|
||||
{% include "components/pagination.html" %}
|
||||
```
|
||||
|
||||
## Template Rendering
|
||||
|
||||
### Full Page Render
|
||||
|
||||
```python
|
||||
from artdag_common import render
|
||||
|
||||
@app.get("/runs/{run_id}")
|
||||
async def run_detail(run_id: str, request: Request):
|
||||
run = get_run(run_id)
|
||||
return render(templates, "runs/detail.html", request, run=run)
|
||||
```
|
||||
|
||||
### Fragment Render (HTMX)
|
||||
|
||||
```python
|
||||
from artdag_common import render_fragment
|
||||
|
||||
@app.get("/runs/{run_id}/status")
|
||||
async def run_status_fragment(run_id: str):
|
||||
run = get_run(run_id)
|
||||
html = render_fragment(templates, "components/status.html", status=run.status)
|
||||
return HTMLResponse(html)
|
||||
```
|
||||
|
||||
## Authentication Middleware
|
||||
|
||||
### UserContext
|
||||
|
||||
```python
|
||||
from artdag_common.middleware.auth import UserContext, get_user_from_cookie
|
||||
|
||||
@app.get("/profile")
|
||||
async def profile(request: Request):
|
||||
user = get_user_from_cookie(request)
|
||||
if not user:
|
||||
return RedirectResponse("/login")
|
||||
return {"username": user.username, "actor_id": user.actor_id}
|
||||
```
|
||||
|
||||
### Token Parsing
|
||||
|
||||
```python
|
||||
from artdag_common.middleware.auth import get_user_from_header, decode_jwt_claims
|
||||
|
||||
@app.get("/api/me")
|
||||
async def api_me(request: Request):
|
||||
user = get_user_from_header(request)
|
||||
if not user:
|
||||
raise HTTPException(401, "Not authenticated")
|
||||
return {"user": user.username}
|
||||
```
|
||||
|
||||
## Content Negotiation
|
||||
|
||||
Detect what response format the client wants:
|
||||
|
||||
```python
|
||||
from artdag_common.middleware.content_negotiation import wants_html, wants_json, wants_activity_json
|
||||
|
||||
@app.get("/users/{username}")
|
||||
async def user_profile(username: str, request: Request):
|
||||
user = get_user(username)
|
||||
|
||||
if wants_activity_json(request):
|
||||
return ActivityPubActor(user)
|
||||
elif wants_json(request):
|
||||
return user.dict()
|
||||
else:
|
||||
return render(templates, "users/profile.html", request, user=user)
|
||||
```
|
||||
|
||||
## Constants
|
||||
|
||||
### CDN URLs
|
||||
|
||||
```python
|
||||
from artdag_common import TAILWIND_CDN, HTMX_CDN, CYTOSCAPE_CDN
|
||||
|
||||
# Available in templates as globals:
|
||||
# {{ TAILWIND_CDN }}
|
||||
# {{ HTMX_CDN }}
|
||||
# {{ CYTOSCAPE_CDN }}
|
||||
```
|
||||
|
||||
### Node Colors
|
||||
|
||||
```python
|
||||
from artdag_common import NODE_COLORS
|
||||
|
||||
# {
|
||||
# "SOURCE": "#3b82f6", # Blue
|
||||
# "EFFECT": "#22c55e", # Green
|
||||
# "OUTPUT": "#a855f7", # Purple
|
||||
# "ANALYSIS": "#f59e0b", # Amber
|
||||
# "_LIST": "#6366f1", # Indigo
|
||||
# "default": "#6b7280", # Gray
|
||||
# }
|
||||
```
|
||||
|
||||
### Status Colors
|
||||
|
||||
```python
|
||||
STATUS_COLORS = {
|
||||
"completed": "bg-green-600",
|
||||
"cached": "bg-blue-600",
|
||||
"running": "bg-yellow-600",
|
||||
"pending": "bg-gray-600",
|
||||
"failed": "bg-red-600",
|
||||
}
|
||||
```
|
||||
|
||||
## Custom Jinja2 Filters
|
||||
|
||||
The following filters are available in all templates:
|
||||
|
||||
| Filter | Usage | Description |
|
||||
|--------|-------|-------------|
|
||||
| `truncate_hash` | `{{ hash\|truncate_hash }}` | Shorten hash to 16 chars with ellipsis |
|
||||
| `format_size` | `{{ bytes\|format_size }}` | Format bytes as KB/MB/GB |
|
||||
| `status_color` | `{{ status\|status_color }}` | Get Tailwind class for status |
|
||||
|
||||
Example:
|
||||
|
||||
```html
|
||||
<span class="{{ run.status|status_color }}">
|
||||
{{ run.status }}
|
||||
</span>
|
||||
|
||||
<code>{{ content_hash|truncate_hash }}</code>
|
||||
|
||||
<span>{{ file_size|format_size }}</span>
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
cd /root/art-dag/common
|
||||
|
||||
# Install in development mode
|
||||
pip install -e .
|
||||
|
||||
# Run tests
|
||||
pytest
|
||||
```
|
||||
|
||||
## Dependencies
|
||||
|
||||
- `fastapi>=0.100.0` - Web framework
|
||||
- `jinja2>=3.1.0` - Templating engine
|
||||
- `pydantic>=2.0.0` - Data validation
|
||||
18
common/artdag_common/__init__.py
Normal file
18
common/artdag_common/__init__.py
Normal file
@@ -0,0 +1,18 @@
|
||||
"""
|
||||
Art-DAG Common Library
|
||||
|
||||
Shared components for L1 (celery) and L2 (activity-pub) servers.
|
||||
"""
|
||||
|
||||
from .constants import NODE_COLORS, TAILWIND_CDN, HTMX_CDN, CYTOSCAPE_CDN
|
||||
from .rendering import create_jinja_env, render, render_fragment
|
||||
|
||||
__all__ = [
|
||||
"NODE_COLORS",
|
||||
"TAILWIND_CDN",
|
||||
"HTMX_CDN",
|
||||
"CYTOSCAPE_CDN",
|
||||
"create_jinja_env",
|
||||
"render",
|
||||
"render_fragment",
|
||||
]
|
||||
76
common/artdag_common/constants.py
Normal file
76
common/artdag_common/constants.py
Normal file
@@ -0,0 +1,76 @@
|
||||
"""
|
||||
Shared constants for Art-DAG servers.
|
||||
"""
|
||||
|
||||
# CDN URLs
|
||||
TAILWIND_CDN = "https://cdn.tailwindcss.com?plugins=typography"
|
||||
HTMX_CDN = "https://unpkg.com/htmx.org@1.9.10"
|
||||
CYTOSCAPE_CDN = "https://cdnjs.cloudflare.com/ajax/libs/cytoscape/3.28.1/cytoscape.min.js"
|
||||
DAGRE_CDN = "https://cdnjs.cloudflare.com/ajax/libs/dagre/0.8.5/dagre.min.js"
|
||||
CYTOSCAPE_DAGRE_CDN = "https://cdn.jsdelivr.net/npm/cytoscape-dagre@2.5.0/cytoscape-dagre.min.js"
|
||||
|
||||
# Node colors for DAG visualization
|
||||
NODE_COLORS = {
|
||||
"SOURCE": "#3b82f6", # Blue - input sources
|
||||
"EFFECT": "#22c55e", # Green - processing effects
|
||||
"OUTPUT": "#a855f7", # Purple - final outputs
|
||||
"ANALYSIS": "#f59e0b", # Amber - analysis nodes
|
||||
"_LIST": "#6366f1", # Indigo - list aggregation
|
||||
"default": "#6b7280", # Gray - unknown types
|
||||
}
|
||||
|
||||
# Status colors
|
||||
STATUS_COLORS = {
|
||||
"completed": "bg-green-600",
|
||||
"cached": "bg-blue-600",
|
||||
"running": "bg-yellow-600",
|
||||
"pending": "bg-gray-600",
|
||||
"failed": "bg-red-600",
|
||||
}
|
||||
|
||||
# Tailwind dark theme configuration
|
||||
TAILWIND_CONFIG = """
|
||||
<script>
|
||||
tailwind.config = {
|
||||
darkMode: 'class',
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
dark: {
|
||||
600: '#374151',
|
||||
700: '#1f2937',
|
||||
800: '#111827',
|
||||
900: '#030712',
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<style type="text/tailwindcss">
|
||||
@layer utilities {
|
||||
.prose-invert {
|
||||
--tw-prose-body: #d1d5db;
|
||||
--tw-prose-headings: #f9fafb;
|
||||
--tw-prose-lead: #9ca3af;
|
||||
--tw-prose-links: #60a5fa;
|
||||
--tw-prose-bold: #f9fafb;
|
||||
--tw-prose-counters: #9ca3af;
|
||||
--tw-prose-bullets: #6b7280;
|
||||
--tw-prose-hr: #374151;
|
||||
--tw-prose-quotes: #f3f4f6;
|
||||
--tw-prose-quote-borders: #374151;
|
||||
--tw-prose-captions: #9ca3af;
|
||||
--tw-prose-code: #f9fafb;
|
||||
--tw-prose-pre-code: #e5e7eb;
|
||||
--tw-prose-pre-bg: #1f2937;
|
||||
--tw-prose-th-borders: #4b5563;
|
||||
--tw-prose-td-borders: #374151;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
"""
|
||||
|
||||
# Default pagination settings
|
||||
DEFAULT_PAGE_SIZE = 20
|
||||
MAX_PAGE_SIZE = 100
|
||||
91
common/artdag_common/fragments.py
Normal file
91
common/artdag_common/fragments.py
Normal file
@@ -0,0 +1,91 @@
|
||||
"""Fragment client for fetching HTML fragments from coop apps.
|
||||
|
||||
Lightweight httpx-based client (no Quart dependency) for Art-DAG to consume
|
||||
coop app fragments like nav-tree, auth-menu, and cart-mini.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
from typing import Sequence
|
||||
|
||||
import httpx
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
FRAGMENT_HEADER = "X-Fragment-Request"
|
||||
|
||||
_client: httpx.AsyncClient | None = None
|
||||
_DEFAULT_TIMEOUT = 2.0
|
||||
|
||||
|
||||
def _get_client() -> httpx.AsyncClient:
|
||||
global _client
|
||||
if _client is None or _client.is_closed:
|
||||
_client = httpx.AsyncClient(
|
||||
timeout=httpx.Timeout(_DEFAULT_TIMEOUT),
|
||||
follow_redirects=False,
|
||||
)
|
||||
return _client
|
||||
|
||||
|
||||
def _internal_url(app_name: str) -> str:
|
||||
"""Resolve internal base URL for a coop app.
|
||||
|
||||
Looks up ``INTERNAL_URL_{APP}`` first, falls back to ``http://{app}:8000``.
|
||||
"""
|
||||
env_key = f"INTERNAL_URL_{app_name.upper()}"
|
||||
return os.getenv(env_key, f"http://{app_name}:8000").rstrip("/")
|
||||
|
||||
|
||||
async def fetch_fragment(
|
||||
app_name: str,
|
||||
fragment_type: str,
|
||||
*,
|
||||
params: dict | None = None,
|
||||
timeout: float = _DEFAULT_TIMEOUT,
|
||||
required: bool = False,
|
||||
) -> str:
|
||||
"""Fetch an HTML fragment from a coop app.
|
||||
|
||||
Returns empty string on failure by default (required=False).
|
||||
"""
|
||||
base = _internal_url(app_name)
|
||||
url = f"{base}/internal/fragments/{fragment_type}"
|
||||
try:
|
||||
resp = await _get_client().get(
|
||||
url,
|
||||
params=params,
|
||||
headers={FRAGMENT_HEADER: "1"},
|
||||
timeout=timeout,
|
||||
)
|
||||
if resp.status_code == 200:
|
||||
return resp.text
|
||||
msg = f"Fragment {app_name}/{fragment_type} returned {resp.status_code}"
|
||||
log.warning(msg)
|
||||
if required:
|
||||
raise RuntimeError(msg)
|
||||
return ""
|
||||
except RuntimeError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
msg = f"Fragment {app_name}/{fragment_type} failed: {exc}"
|
||||
log.warning(msg)
|
||||
if required:
|
||||
raise RuntimeError(msg) from exc
|
||||
return ""
|
||||
|
||||
|
||||
async def fetch_fragments(
|
||||
requests: Sequence[tuple[str, str, dict | None]],
|
||||
*,
|
||||
timeout: float = _DEFAULT_TIMEOUT,
|
||||
required: bool = False,
|
||||
) -> list[str]:
|
||||
"""Fetch multiple fragments concurrently."""
|
||||
return list(await asyncio.gather(*(
|
||||
fetch_fragment(app, ftype, params=params, timeout=timeout, required=required)
|
||||
for app, ftype, params in requests
|
||||
)))
|
||||
16
common/artdag_common/middleware/__init__.py
Normal file
16
common/artdag_common/middleware/__init__.py
Normal file
@@ -0,0 +1,16 @@
|
||||
"""
|
||||
Middleware and FastAPI dependencies for Art-DAG servers.
|
||||
"""
|
||||
|
||||
from .auth import UserContext, get_user_from_cookie, get_user_from_header, require_auth
|
||||
from .content_negotiation import wants_html, wants_json, ContentType
|
||||
|
||||
__all__ = [
|
||||
"UserContext",
|
||||
"get_user_from_cookie",
|
||||
"get_user_from_header",
|
||||
"require_auth",
|
||||
"wants_html",
|
||||
"wants_json",
|
||||
"ContentType",
|
||||
]
|
||||
BIN
common/artdag_common/middleware/__pycache__/auth.cpython-310.pyc
Normal file
BIN
common/artdag_common/middleware/__pycache__/auth.cpython-310.pyc
Normal file
Binary file not shown.
276
common/artdag_common/middleware/auth.py
Normal file
276
common/artdag_common/middleware/auth.py
Normal file
@@ -0,0 +1,276 @@
|
||||
"""
|
||||
Authentication middleware and dependencies.
|
||||
|
||||
Provides common authentication patterns for L1 and L2 servers.
|
||||
Each server can extend or customize these as needed.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Callable, Optional, Awaitable, Any
|
||||
import base64
|
||||
import json
|
||||
|
||||
from fastapi import Request, HTTPException, Depends
|
||||
from fastapi.responses import RedirectResponse
|
||||
|
||||
|
||||
@dataclass
|
||||
class UserContext:
|
||||
"""User context extracted from authentication."""
|
||||
username: str
|
||||
actor_id: str # Full actor ID like "@user@server.com"
|
||||
token: Optional[str] = None
|
||||
l2_server: Optional[str] = None # L2 server URL for this user
|
||||
email: Optional[str] = None # User's email address
|
||||
|
||||
@property
|
||||
def display_name(self) -> str:
|
||||
"""Get display name (username without @ prefix)."""
|
||||
return self.username.lstrip("@")
|
||||
|
||||
|
||||
def get_user_from_cookie(request: Request) -> Optional[UserContext]:
|
||||
"""
|
||||
Extract user context from session cookie.
|
||||
|
||||
Supports two cookie formats:
|
||||
1. artdag_session: base64-encoded JSON {"username": "user", "actor_id": "@user@server.com"}
|
||||
2. auth_token: raw JWT token (used by L1 servers)
|
||||
|
||||
Args:
|
||||
request: FastAPI request
|
||||
|
||||
Returns:
|
||||
UserContext if valid cookie found, None otherwise
|
||||
"""
|
||||
# Try artdag_session cookie first (base64-encoded JSON)
|
||||
cookie = request.cookies.get("artdag_session")
|
||||
if cookie:
|
||||
try:
|
||||
data = json.loads(base64.b64decode(cookie))
|
||||
username = data.get("username", "")
|
||||
actor_id = data.get("actor_id", "")
|
||||
if not actor_id and username:
|
||||
actor_id = f"@{username}"
|
||||
return UserContext(
|
||||
username=username,
|
||||
actor_id=actor_id,
|
||||
email=data.get("email", ""),
|
||||
)
|
||||
except (json.JSONDecodeError, ValueError, KeyError):
|
||||
pass
|
||||
|
||||
# Try auth_token cookie (raw JWT, used by L1)
|
||||
token = request.cookies.get("auth_token")
|
||||
if token:
|
||||
claims = decode_jwt_claims(token)
|
||||
if claims:
|
||||
username = claims.get("username") or claims.get("sub", "")
|
||||
actor_id = claims.get("actor_id") or claims.get("actor")
|
||||
if not actor_id and username:
|
||||
actor_id = f"@{username}"
|
||||
return UserContext(
|
||||
username=username,
|
||||
actor_id=actor_id or "",
|
||||
token=token,
|
||||
email=claims.get("email", ""),
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def get_user_from_header(request: Request) -> Optional[UserContext]:
|
||||
"""
|
||||
Extract user context from Authorization header.
|
||||
|
||||
Supports:
|
||||
- Bearer <token> format (JWT or opaque token)
|
||||
- Basic <base64(user:pass)> format
|
||||
|
||||
Args:
|
||||
request: FastAPI request
|
||||
|
||||
Returns:
|
||||
UserContext if valid header found, None otherwise
|
||||
"""
|
||||
auth_header = request.headers.get("Authorization", "")
|
||||
|
||||
if auth_header.startswith("Bearer "):
|
||||
token = auth_header[7:]
|
||||
# Attempt to decode JWT claims
|
||||
claims = decode_jwt_claims(token)
|
||||
if claims:
|
||||
username = claims.get("username") or claims.get("sub", "")
|
||||
actor_id = claims.get("actor_id") or claims.get("actor")
|
||||
# Default actor_id to @username if not provided
|
||||
if not actor_id and username:
|
||||
actor_id = f"@{username}"
|
||||
return UserContext(
|
||||
username=username,
|
||||
actor_id=actor_id or "",
|
||||
token=token,
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def decode_jwt_claims(token: str) -> Optional[dict]:
|
||||
"""
|
||||
Decode JWT claims without verification.
|
||||
|
||||
This is useful for extracting user info from a token
|
||||
when full verification is handled elsewhere.
|
||||
|
||||
Args:
|
||||
token: JWT token string
|
||||
|
||||
Returns:
|
||||
Claims dict if valid JWT format, None otherwise
|
||||
"""
|
||||
try:
|
||||
parts = token.split(".")
|
||||
if len(parts) != 3:
|
||||
return None
|
||||
|
||||
# Decode payload (second part)
|
||||
payload = parts[1]
|
||||
# Add padding if needed
|
||||
padding = 4 - len(payload) % 4
|
||||
if padding != 4:
|
||||
payload += "=" * padding
|
||||
|
||||
return json.loads(base64.urlsafe_b64decode(payload))
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def create_auth_dependency(
|
||||
token_validator: Optional[Callable[[str], Awaitable[Optional[dict]]]] = None,
|
||||
allow_cookie: bool = True,
|
||||
allow_header: bool = True,
|
||||
):
|
||||
"""
|
||||
Create a customized auth dependency for a specific server.
|
||||
|
||||
Args:
|
||||
token_validator: Optional async function to validate tokens with backend
|
||||
allow_cookie: Whether to check cookies for auth
|
||||
allow_header: Whether to check Authorization header
|
||||
|
||||
Returns:
|
||||
FastAPI dependency function
|
||||
"""
|
||||
async def get_current_user(request: Request) -> Optional[UserContext]:
|
||||
ctx = None
|
||||
|
||||
# Try header first (API clients)
|
||||
if allow_header:
|
||||
ctx = get_user_from_header(request)
|
||||
if ctx and token_validator:
|
||||
# Validate token with backend
|
||||
validated = await token_validator(ctx.token)
|
||||
if not validated:
|
||||
ctx = None
|
||||
|
||||
# Fall back to cookie (browser)
|
||||
if ctx is None and allow_cookie:
|
||||
ctx = get_user_from_cookie(request)
|
||||
|
||||
return ctx
|
||||
|
||||
return get_current_user
|
||||
|
||||
|
||||
async def require_auth(request: Request) -> UserContext:
|
||||
"""
|
||||
Dependency that requires authentication.
|
||||
|
||||
Raises HTTPException 401 if not authenticated.
|
||||
Use with Depends() in route handlers.
|
||||
|
||||
Example:
|
||||
@app.get("/protected")
|
||||
async def protected_route(user: UserContext = Depends(require_auth)):
|
||||
return {"user": user.username}
|
||||
"""
|
||||
# Try header first
|
||||
ctx = get_user_from_header(request)
|
||||
if ctx is None:
|
||||
ctx = get_user_from_cookie(request)
|
||||
|
||||
if ctx is None:
|
||||
# Check Accept header to determine response type
|
||||
accept = request.headers.get("accept", "")
|
||||
if "text/html" in accept:
|
||||
raise HTTPException(
|
||||
status_code=302,
|
||||
headers={"Location": "/login"}
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail="Authentication required"
|
||||
)
|
||||
|
||||
return ctx
|
||||
|
||||
|
||||
def require_owner(resource_owner_field: str = "username"):
|
||||
"""
|
||||
Dependency factory that requires the user to own the resource.
|
||||
|
||||
Args:
|
||||
resource_owner_field: Field name on the resource that contains owner username
|
||||
|
||||
Returns:
|
||||
Dependency function
|
||||
|
||||
Example:
|
||||
@app.delete("/items/{item_id}")
|
||||
async def delete_item(
|
||||
item: Item = Depends(get_item),
|
||||
user: UserContext = Depends(require_owner("created_by"))
|
||||
):
|
||||
...
|
||||
"""
|
||||
async def check_ownership(
|
||||
request: Request,
|
||||
user: UserContext = Depends(require_auth),
|
||||
) -> UserContext:
|
||||
# The actual ownership check must be done in the route
|
||||
# after fetching the resource
|
||||
return user
|
||||
|
||||
return check_ownership
|
||||
|
||||
|
||||
def set_auth_cookie(response: Any, user: UserContext, max_age: int = 86400 * 30) -> None:
|
||||
"""
|
||||
Set authentication cookie on response.
|
||||
|
||||
Args:
|
||||
response: FastAPI response object
|
||||
user: User context to store
|
||||
max_age: Cookie max age in seconds (default 30 days)
|
||||
"""
|
||||
cookie_data = {
|
||||
"username": user.username,
|
||||
"actor_id": user.actor_id,
|
||||
}
|
||||
if user.email:
|
||||
cookie_data["email"] = user.email
|
||||
data = json.dumps(cookie_data)
|
||||
cookie_value = base64.b64encode(data.encode()).decode()
|
||||
|
||||
response.set_cookie(
|
||||
key="artdag_session",
|
||||
value=cookie_value,
|
||||
max_age=max_age,
|
||||
httponly=True,
|
||||
samesite="lax",
|
||||
secure=True, # Require HTTPS in production
|
||||
)
|
||||
|
||||
|
||||
def clear_auth_cookie(response: Any) -> None:
|
||||
"""Clear authentication cookie."""
|
||||
response.delete_cookie(key="artdag_session")
|
||||
174
common/artdag_common/middleware/content_negotiation.py
Normal file
174
common/artdag_common/middleware/content_negotiation.py
Normal file
@@ -0,0 +1,174 @@
|
||||
"""
|
||||
Content negotiation utilities.
|
||||
|
||||
Helps determine what response format the client wants.
|
||||
"""
|
||||
|
||||
from enum import Enum
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import Request
|
||||
|
||||
|
||||
class ContentType(Enum):
|
||||
"""Response content types."""
|
||||
HTML = "text/html"
|
||||
JSON = "application/json"
|
||||
ACTIVITY_JSON = "application/activity+json"
|
||||
XML = "application/xml"
|
||||
|
||||
|
||||
def wants_html(request: Request) -> bool:
|
||||
"""
|
||||
Check if the client wants HTML response.
|
||||
|
||||
Returns True if:
|
||||
- Accept header contains text/html
|
||||
- Accept header contains application/xhtml+xml
|
||||
- No Accept header (browser default)
|
||||
|
||||
Args:
|
||||
request: FastAPI request
|
||||
|
||||
Returns:
|
||||
True if HTML is preferred
|
||||
"""
|
||||
accept = request.headers.get("accept", "")
|
||||
|
||||
# No accept header usually means browser
|
||||
if not accept:
|
||||
return True
|
||||
|
||||
# Check for HTML preferences
|
||||
if "text/html" in accept:
|
||||
return True
|
||||
if "application/xhtml" in accept:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def wants_json(request: Request) -> bool:
|
||||
"""
|
||||
Check if the client wants JSON response.
|
||||
|
||||
Returns True if:
|
||||
- Accept header contains application/json
|
||||
- Accept header does NOT contain text/html
|
||||
- Request has .json suffix (convention)
|
||||
|
||||
Args:
|
||||
request: FastAPI request
|
||||
|
||||
Returns:
|
||||
True if JSON is preferred
|
||||
"""
|
||||
accept = request.headers.get("accept", "")
|
||||
|
||||
# Explicit JSON preference
|
||||
if "application/json" in accept:
|
||||
# But not if HTML is also requested (browsers often send both)
|
||||
if "text/html" not in accept:
|
||||
return True
|
||||
|
||||
# Check URL suffix convention
|
||||
if request.url.path.endswith(".json"):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def wants_activity_json(request: Request) -> bool:
|
||||
"""
|
||||
Check if the client wants ActivityPub JSON-LD response.
|
||||
|
||||
Used for federation with other ActivityPub servers.
|
||||
|
||||
Args:
|
||||
request: FastAPI request
|
||||
|
||||
Returns:
|
||||
True if ActivityPub format is preferred
|
||||
"""
|
||||
accept = request.headers.get("accept", "")
|
||||
|
||||
if "application/activity+json" in accept:
|
||||
return True
|
||||
if "application/ld+json" in accept:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def get_preferred_type(request: Request) -> ContentType:
|
||||
"""
|
||||
Determine the preferred content type from Accept header.
|
||||
|
||||
Args:
|
||||
request: FastAPI request
|
||||
|
||||
Returns:
|
||||
ContentType enum value
|
||||
"""
|
||||
if wants_activity_json(request):
|
||||
return ContentType.ACTIVITY_JSON
|
||||
if wants_json(request):
|
||||
return ContentType.JSON
|
||||
return ContentType.HTML
|
||||
|
||||
|
||||
def is_htmx_request(request: Request) -> bool:
|
||||
"""
|
||||
Check if this is an HTMX request (partial page update).
|
||||
|
||||
HTMX requests set the HX-Request header.
|
||||
|
||||
Args:
|
||||
request: FastAPI request
|
||||
|
||||
Returns:
|
||||
True if this is an HTMX request
|
||||
"""
|
||||
return request.headers.get("HX-Request") == "true"
|
||||
|
||||
|
||||
def get_htmx_target(request: Request) -> Optional[str]:
|
||||
"""
|
||||
Get the HTMX target element ID.
|
||||
|
||||
Args:
|
||||
request: FastAPI request
|
||||
|
||||
Returns:
|
||||
Target element ID or None
|
||||
"""
|
||||
return request.headers.get("HX-Target")
|
||||
|
||||
|
||||
def get_htmx_trigger(request: Request) -> Optional[str]:
|
||||
"""
|
||||
Get the HTMX trigger element ID.
|
||||
|
||||
Args:
|
||||
request: FastAPI request
|
||||
|
||||
Returns:
|
||||
Trigger element ID or None
|
||||
"""
|
||||
return request.headers.get("HX-Trigger")
|
||||
|
||||
|
||||
def is_ios_request(request: Request) -> bool:
|
||||
"""
|
||||
Check if request is from iOS device.
|
||||
|
||||
Useful for video format selection (iOS prefers MP4).
|
||||
|
||||
Args:
|
||||
request: FastAPI request
|
||||
|
||||
Returns:
|
||||
True if iOS user agent detected
|
||||
"""
|
||||
user_agent = request.headers.get("user-agent", "").lower()
|
||||
return "iphone" in user_agent or "ipad" in user_agent
|
||||
25
common/artdag_common/models/__init__.py
Normal file
25
common/artdag_common/models/__init__.py
Normal file
@@ -0,0 +1,25 @@
|
||||
"""
|
||||
Shared Pydantic models for Art-DAG servers.
|
||||
"""
|
||||
|
||||
from .requests import (
|
||||
PaginationParams,
|
||||
PublishRequest,
|
||||
StorageConfigRequest,
|
||||
MetadataUpdateRequest,
|
||||
)
|
||||
from .responses import (
|
||||
PaginatedResponse,
|
||||
ErrorResponse,
|
||||
SuccessResponse,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"PaginationParams",
|
||||
"PublishRequest",
|
||||
"StorageConfigRequest",
|
||||
"MetadataUpdateRequest",
|
||||
"PaginatedResponse",
|
||||
"ErrorResponse",
|
||||
"SuccessResponse",
|
||||
]
|
||||
74
common/artdag_common/models/requests.py
Normal file
74
common/artdag_common/models/requests.py
Normal file
@@ -0,0 +1,74 @@
|
||||
"""
|
||||
Request models shared across L1 and L2 servers.
|
||||
"""
|
||||
|
||||
from typing import Optional, List, Dict, Any
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from ..constants import DEFAULT_PAGE_SIZE, MAX_PAGE_SIZE
|
||||
|
||||
|
||||
class PaginationParams(BaseModel):
|
||||
"""Common pagination parameters."""
|
||||
page: int = Field(default=1, ge=1, description="Page number (1-indexed)")
|
||||
limit: int = Field(
|
||||
default=DEFAULT_PAGE_SIZE,
|
||||
ge=1,
|
||||
le=MAX_PAGE_SIZE,
|
||||
description="Items per page"
|
||||
)
|
||||
|
||||
@property
|
||||
def offset(self) -> int:
|
||||
"""Calculate offset for database queries."""
|
||||
return (self.page - 1) * self.limit
|
||||
|
||||
|
||||
class PublishRequest(BaseModel):
|
||||
"""Request to publish content to L2/storage."""
|
||||
name: str = Field(..., min_length=1, max_length=255)
|
||||
description: Optional[str] = Field(default=None, max_length=2000)
|
||||
tags: List[str] = Field(default_factory=list)
|
||||
storage_id: Optional[str] = Field(default=None, description="Target storage provider")
|
||||
|
||||
|
||||
class MetadataUpdateRequest(BaseModel):
|
||||
"""Request to update content metadata."""
|
||||
name: Optional[str] = Field(default=None, max_length=255)
|
||||
description: Optional[str] = Field(default=None, max_length=2000)
|
||||
tags: Optional[List[str]] = Field(default=None)
|
||||
metadata: Optional[Dict[str, Any]] = Field(default=None)
|
||||
|
||||
|
||||
class StorageConfigRequest(BaseModel):
|
||||
"""Request to configure a storage provider."""
|
||||
provider_type: str = Field(..., description="Provider type (pinata, web3storage, local, etc.)")
|
||||
name: str = Field(..., min_length=1, max_length=100)
|
||||
api_key: Optional[str] = Field(default=None)
|
||||
api_secret: Optional[str] = Field(default=None)
|
||||
endpoint: Optional[str] = Field(default=None)
|
||||
config: Optional[Dict[str, Any]] = Field(default_factory=dict)
|
||||
is_default: bool = Field(default=False)
|
||||
|
||||
|
||||
class RecipeRunRequest(BaseModel):
|
||||
"""Request to run a recipe."""
|
||||
recipe_id: str = Field(..., description="Recipe content hash or ID")
|
||||
inputs: Dict[str, str] = Field(..., description="Map of input name to content hash")
|
||||
features: List[str] = Field(
|
||||
default=["beats", "energy"],
|
||||
description="Analysis features to extract"
|
||||
)
|
||||
|
||||
|
||||
class PlanRequest(BaseModel):
|
||||
"""Request to generate an execution plan."""
|
||||
recipe_yaml: str = Field(..., description="Recipe YAML content")
|
||||
input_hashes: Dict[str, str] = Field(..., description="Map of input name to content hash")
|
||||
features: List[str] = Field(default=["beats", "energy"])
|
||||
|
||||
|
||||
class ExecutePlanRequest(BaseModel):
|
||||
"""Request to execute a generated plan."""
|
||||
plan_json: str = Field(..., description="JSON-serialized execution plan")
|
||||
run_id: Optional[str] = Field(default=None, description="Optional run ID for tracking")
|
||||
96
common/artdag_common/models/responses.py
Normal file
96
common/artdag_common/models/responses.py
Normal file
@@ -0,0 +1,96 @@
|
||||
"""
|
||||
Response models shared across L1 and L2 servers.
|
||||
"""
|
||||
|
||||
from typing import Optional, List, Dict, Any, Generic, TypeVar
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
class PaginatedResponse(BaseModel, Generic[T]):
|
||||
"""Generic paginated response."""
|
||||
data: List[Any] = Field(default_factory=list)
|
||||
pagination: Dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
@classmethod
|
||||
def create(
|
||||
cls,
|
||||
items: List[Any],
|
||||
page: int,
|
||||
limit: int,
|
||||
total: int,
|
||||
) -> "PaginatedResponse":
|
||||
"""Create a paginated response."""
|
||||
return cls(
|
||||
data=items,
|
||||
pagination={
|
||||
"page": page,
|
||||
"limit": limit,
|
||||
"total": total,
|
||||
"has_more": page * limit < total,
|
||||
"total_pages": (total + limit - 1) // limit,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class ErrorResponse(BaseModel):
|
||||
"""Standard error response."""
|
||||
error: str = Field(..., description="Error message")
|
||||
detail: Optional[str] = Field(default=None, description="Detailed error info")
|
||||
code: Optional[str] = Field(default=None, description="Error code")
|
||||
|
||||
|
||||
class SuccessResponse(BaseModel):
|
||||
"""Standard success response."""
|
||||
success: bool = Field(default=True)
|
||||
message: Optional[str] = Field(default=None)
|
||||
data: Optional[Dict[str, Any]] = Field(default=None)
|
||||
|
||||
|
||||
class RunStatus(BaseModel):
|
||||
"""Run execution status."""
|
||||
run_id: str
|
||||
status: str = Field(..., description="pending, running, completed, failed")
|
||||
recipe: Optional[str] = None
|
||||
plan_id: Optional[str] = None
|
||||
output_hash: Optional[str] = None
|
||||
output_ipfs_cid: Optional[str] = None
|
||||
total_steps: int = 0
|
||||
cached_steps: int = 0
|
||||
completed_steps: int = 0
|
||||
error: Optional[str] = None
|
||||
|
||||
|
||||
class CacheItemResponse(BaseModel):
|
||||
"""Cached content item response."""
|
||||
content_hash: str
|
||||
media_type: Optional[str] = None
|
||||
size: Optional[int] = None
|
||||
name: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
tags: List[str] = Field(default_factory=list)
|
||||
ipfs_cid: Optional[str] = None
|
||||
created_at: Optional[str] = None
|
||||
|
||||
|
||||
class RecipeResponse(BaseModel):
|
||||
"""Recipe response."""
|
||||
recipe_id: str
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
inputs: List[Dict[str, Any]] = Field(default_factory=list)
|
||||
outputs: List[str] = Field(default_factory=list)
|
||||
node_count: int = 0
|
||||
created_at: Optional[str] = None
|
||||
|
||||
|
||||
class StorageProviderResponse(BaseModel):
|
||||
"""Storage provider configuration response."""
|
||||
storage_id: str
|
||||
provider_type: str
|
||||
name: str
|
||||
is_default: bool = False
|
||||
is_connected: bool = False
|
||||
usage_bytes: Optional[int] = None
|
||||
pin_count: int = 0
|
||||
160
common/artdag_common/rendering.py
Normal file
160
common/artdag_common/rendering.py
Normal file
@@ -0,0 +1,160 @@
|
||||
"""
|
||||
Jinja2 template rendering system for Art-DAG servers.
|
||||
|
||||
Provides a unified template environment that can load from:
|
||||
1. The shared artdag_common/templates directory
|
||||
2. App-specific template directories
|
||||
|
||||
Usage:
|
||||
from artdag_common import create_jinja_env, render
|
||||
|
||||
# In app initialization
|
||||
templates = create_jinja_env("app/templates")
|
||||
|
||||
# In route handler
|
||||
return render(templates, "runs/detail.html", request, run=run, user=user)
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional, Union
|
||||
|
||||
from fastapi import Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
from jinja2 import Environment, ChoiceLoader, FileSystemLoader, PackageLoader, select_autoescape
|
||||
|
||||
from .constants import (
|
||||
TAILWIND_CDN,
|
||||
HTMX_CDN,
|
||||
CYTOSCAPE_CDN,
|
||||
DAGRE_CDN,
|
||||
CYTOSCAPE_DAGRE_CDN,
|
||||
TAILWIND_CONFIG,
|
||||
NODE_COLORS,
|
||||
STATUS_COLORS,
|
||||
)
|
||||
|
||||
|
||||
def create_jinja_env(*template_dirs: Union[str, Path]) -> Environment:
|
||||
"""
|
||||
Create a Jinja2 environment with the shared templates and optional app-specific dirs.
|
||||
|
||||
Args:
|
||||
*template_dirs: Additional template directories to search (app-specific)
|
||||
|
||||
Returns:
|
||||
Configured Jinja2 Environment
|
||||
|
||||
Example:
|
||||
env = create_jinja_env("/app/templates", "/app/custom")
|
||||
"""
|
||||
loaders = []
|
||||
|
||||
# Add app-specific directories first (higher priority)
|
||||
for template_dir in template_dirs:
|
||||
path = Path(template_dir)
|
||||
if path.exists():
|
||||
loaders.append(FileSystemLoader(str(path)))
|
||||
|
||||
# Add shared templates from this package (lower priority, fallback)
|
||||
loaders.append(PackageLoader("artdag_common", "templates"))
|
||||
|
||||
env = Environment(
|
||||
loader=ChoiceLoader(loaders),
|
||||
autoescape=select_autoescape(["html", "xml"]),
|
||||
trim_blocks=True,
|
||||
lstrip_blocks=True,
|
||||
)
|
||||
|
||||
# Add global context available to all templates
|
||||
env.globals.update({
|
||||
"TAILWIND_CDN": TAILWIND_CDN,
|
||||
"HTMX_CDN": HTMX_CDN,
|
||||
"CYTOSCAPE_CDN": CYTOSCAPE_CDN,
|
||||
"DAGRE_CDN": DAGRE_CDN,
|
||||
"CYTOSCAPE_DAGRE_CDN": CYTOSCAPE_DAGRE_CDN,
|
||||
"TAILWIND_CONFIG": TAILWIND_CONFIG,
|
||||
"NODE_COLORS": NODE_COLORS,
|
||||
"STATUS_COLORS": STATUS_COLORS,
|
||||
})
|
||||
|
||||
# Add custom filters
|
||||
env.filters["truncate_hash"] = truncate_hash
|
||||
env.filters["format_size"] = format_size
|
||||
env.filters["status_color"] = status_color
|
||||
|
||||
return env
|
||||
|
||||
|
||||
def render(
|
||||
env: Environment,
|
||||
template_name: str,
|
||||
request: Request,
|
||||
status_code: int = 200,
|
||||
**context: Any,
|
||||
) -> HTMLResponse:
|
||||
"""
|
||||
Render a template to an HTMLResponse.
|
||||
|
||||
Args:
|
||||
env: Jinja2 environment
|
||||
template_name: Template file path (e.g., "runs/detail.html")
|
||||
request: FastAPI request object
|
||||
status_code: HTTP status code (default 200)
|
||||
**context: Template context variables
|
||||
|
||||
Returns:
|
||||
HTMLResponse with rendered content
|
||||
"""
|
||||
template = env.get_template(template_name)
|
||||
html = template.render(request=request, **context)
|
||||
return HTMLResponse(html, status_code=status_code)
|
||||
|
||||
|
||||
def render_fragment(
|
||||
env: Environment,
|
||||
template_name: str,
|
||||
**context: Any,
|
||||
) -> str:
|
||||
"""
|
||||
Render a template fragment to a string (for HTMX partial updates).
|
||||
|
||||
Args:
|
||||
env: Jinja2 environment
|
||||
template_name: Template file path
|
||||
**context: Template context variables
|
||||
|
||||
Returns:
|
||||
Rendered HTML string
|
||||
"""
|
||||
template = env.get_template(template_name)
|
||||
return template.render(**context)
|
||||
|
||||
|
||||
# Custom Jinja2 filters
|
||||
|
||||
def truncate_hash(value: str, length: int = 16) -> str:
|
||||
"""Truncate a hash to specified length with ellipsis."""
|
||||
if not value:
|
||||
return ""
|
||||
if len(value) <= length:
|
||||
return value
|
||||
return f"{value[:length]}..."
|
||||
|
||||
|
||||
def format_size(size_bytes: Optional[int]) -> str:
|
||||
"""Format file size in human-readable form."""
|
||||
if size_bytes is None:
|
||||
return "Unknown"
|
||||
if size_bytes < 1024:
|
||||
return f"{size_bytes} B"
|
||||
elif size_bytes < 1024 * 1024:
|
||||
return f"{size_bytes / 1024:.1f} KB"
|
||||
elif size_bytes < 1024 * 1024 * 1024:
|
||||
return f"{size_bytes / (1024 * 1024):.1f} MB"
|
||||
else:
|
||||
return f"{size_bytes / (1024 * 1024 * 1024):.1f} GB"
|
||||
|
||||
|
||||
def status_color(status: str) -> str:
|
||||
"""Get Tailwind CSS class for a status."""
|
||||
return STATUS_COLORS.get(status, STATUS_COLORS["pending"])
|
||||
91
common/artdag_common/templates/_base.html
Normal file
91
common/artdag_common/templates/_base.html
Normal file
@@ -0,0 +1,91 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{% block title %}Art-DAG{% endblock %}</title>
|
||||
|
||||
<!-- Tailwind CSS (same CDN as coop) -->
|
||||
<script src="https://cdn.tailwindcss.com?plugins=typography"></script>
|
||||
<script>
|
||||
tailwind.config = {
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
dark: {
|
||||
600: '#374151',
|
||||
700: '#1f2937',
|
||||
800: '#111827',
|
||||
900: '#030712',
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<!-- HTMX -->
|
||||
<script src="https://unpkg.com/htmx.org@2.0.8"></script>
|
||||
<!-- Hyperscript (for nav-tree scrolling arrows) -->
|
||||
<script src="https://unpkg.com/hyperscript.org@0.9.12"></script>
|
||||
<!-- Font Awesome (for auth-menu + nav icons) -->
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
|
||||
|
||||
{% block head %}{% endblock %}
|
||||
|
||||
<style>
|
||||
/* HTMX loading indicator */
|
||||
.htmx-indicator { display: none; }
|
||||
.htmx-request .htmx-indicator { display: inline-flex; }
|
||||
img { max-width: 100%; height: auto; }
|
||||
.no-scrollbar::-webkit-scrollbar { display: none; }
|
||||
.no-scrollbar { -ms-overflow-style: none; scrollbar-width: none; }
|
||||
.scrollbar-hide::-webkit-scrollbar { display: none; }
|
||||
.scrollbar-hide { -ms-overflow-style: none; scrollbar-width: none; }
|
||||
</style>
|
||||
<script>
|
||||
if (matchMedia('(hover: hover) and (pointer: fine)').matches) {
|
||||
document.documentElement.classList.add('hover-capable');
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
<body class="bg-stone-50 text-stone-900 min-h-screen">
|
||||
{% block header %}
|
||||
{# Coop-style header: sky banner with title, nav-tree, auth-menu, cart-mini #}
|
||||
<div class="w-full">
|
||||
<div class="flex flex-col items-center md:flex-row justify-center md:justify-between w-full p-1 bg-sky-500">
|
||||
<div class="w-full flex flex-row items-top">
|
||||
{# Cart mini #}
|
||||
{% block cart_mini %}{% endblock %}
|
||||
|
||||
{# Site title #}
|
||||
<div class="font-bold text-5xl flex-1">
|
||||
<a href="/" class="flex justify-center md:justify-start">
|
||||
<h1>{% block brand %}Art-DAG{% endblock %}</h1>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{# Desktop nav: nav-tree + auth-menu #}
|
||||
<nav class="hidden md:flex gap-4 text-sm ml-2 justify-end items-center flex-0">
|
||||
{% block nav_tree %}{% endblock %}
|
||||
{% block auth_menu %}{% endblock %}
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
{# Mobile auth #}
|
||||
<div class="block md:hidden text-md font-bold">
|
||||
{% block auth_menu_mobile %}{% endblock %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{# App-specific sub-nav (Runs, Recipes, Effects, etc.) #}
|
||||
{% block sub_nav %}{% endblock %}
|
||||
|
||||
<main class="max-w-screen-2xl mx-auto px-4 py-4">
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
|
||||
{% block footer %}{% endblock %}
|
||||
{% block scripts %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
64
common/artdag_common/templates/components/badge.html
Normal file
64
common/artdag_common/templates/components/badge.html
Normal file
@@ -0,0 +1,64 @@
|
||||
{#
|
||||
Badge component for status and type indicators.
|
||||
|
||||
Usage:
|
||||
{% from "components/badge.html" import badge, status_badge, type_badge %}
|
||||
|
||||
{{ badge("Active", "green") }}
|
||||
{{ status_badge("completed") }}
|
||||
{{ type_badge("EFFECT") }}
|
||||
#}
|
||||
|
||||
{% macro badge(text, color="gray", class="") %}
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-{{ color }}-600/20 text-{{ color }}-400 {{ class }}">
|
||||
{{ text }}
|
||||
</span>
|
||||
{% endmacro %}
|
||||
|
||||
{% macro status_badge(status, class="") %}
|
||||
{% set colors = {
|
||||
"completed": "green",
|
||||
"cached": "blue",
|
||||
"running": "yellow",
|
||||
"pending": "gray",
|
||||
"failed": "red",
|
||||
"active": "green",
|
||||
"inactive": "gray",
|
||||
} %}
|
||||
{% set color = colors.get(status, "gray") %}
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-{{ color }}-600/20 text-{{ color }}-400 {{ class }}">
|
||||
{% if status == "running" %}
|
||||
<svg class="animate-spin -ml-0.5 mr-1.5 h-3 w-3" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
{% endif %}
|
||||
{{ status | capitalize }}
|
||||
</span>
|
||||
{% endmacro %}
|
||||
|
||||
{% macro type_badge(node_type, class="") %}
|
||||
{% set colors = {
|
||||
"SOURCE": "blue",
|
||||
"EFFECT": "green",
|
||||
"OUTPUT": "purple",
|
||||
"ANALYSIS": "amber",
|
||||
"_LIST": "indigo",
|
||||
} %}
|
||||
{% set color = colors.get(node_type, "gray") %}
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-{{ color }}-600/20 text-{{ color }}-400 {{ class }}">
|
||||
{{ node_type }}
|
||||
</span>
|
||||
{% endmacro %}
|
||||
|
||||
{% macro role_badge(role, class="") %}
|
||||
{% set colors = {
|
||||
"input": "blue",
|
||||
"output": "purple",
|
||||
"intermediate": "gray",
|
||||
} %}
|
||||
{% set color = colors.get(role, "gray") %}
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-{{ color }}-600/20 text-{{ color }}-400 {{ class }}">
|
||||
{{ role | capitalize }}
|
||||
</span>
|
||||
{% endmacro %}
|
||||
45
common/artdag_common/templates/components/card.html
Normal file
45
common/artdag_common/templates/components/card.html
Normal file
@@ -0,0 +1,45 @@
|
||||
{#
|
||||
Card component for displaying information.
|
||||
|
||||
Usage:
|
||||
{% include "components/card.html" with title="Status", content="Active", class="col-span-2" %}
|
||||
|
||||
Or as a block:
|
||||
{% call card(title="Details") %}
|
||||
<p>Card content here</p>
|
||||
{% endcall %}
|
||||
#}
|
||||
|
||||
{% macro card(title=None, class="") %}
|
||||
<div class="bg-dark-600 rounded-lg p-4 {{ class }}">
|
||||
{% if title %}
|
||||
<h3 class="text-sm font-medium text-gray-400 mb-2">{{ title }}</h3>
|
||||
{% endif %}
|
||||
<div class="text-white">
|
||||
{{ caller() if caller else "" }}
|
||||
</div>
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
{% macro stat_card(title, value, color="white", class="") %}
|
||||
<div class="bg-dark-600 rounded-lg p-4 text-center {{ class }}">
|
||||
<div class="text-2xl font-bold text-{{ color }}-400">{{ value }}</div>
|
||||
<div class="text-sm text-gray-400">{{ title }}</div>
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
{% macro info_card(title, items, class="") %}
|
||||
<div class="bg-dark-600 rounded-lg p-4 {{ class }}">
|
||||
{% if title %}
|
||||
<h3 class="text-sm font-medium text-gray-400 mb-3">{{ title }}</h3>
|
||||
{% endif %}
|
||||
<dl class="space-y-2">
|
||||
{% for label, value in items %}
|
||||
<div class="flex justify-between">
|
||||
<dt class="text-gray-400">{{ label }}</dt>
|
||||
<dd class="text-white font-mono text-sm">{{ value }}</dd>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</dl>
|
||||
</div>
|
||||
{% endmacro %}
|
||||
176
common/artdag_common/templates/components/dag.html
Normal file
176
common/artdag_common/templates/components/dag.html
Normal file
@@ -0,0 +1,176 @@
|
||||
{#
|
||||
Cytoscape.js DAG visualization component.
|
||||
|
||||
Usage:
|
||||
{% from "components/dag.html" import dag_container, dag_scripts, dag_legend %}
|
||||
|
||||
{# In head block #}
|
||||
{{ dag_scripts() }}
|
||||
|
||||
{# In content #}
|
||||
{{ dag_container(id="plan-dag", height="400px") }}
|
||||
{{ dag_legend() }}
|
||||
|
||||
{# In scripts block #}
|
||||
<script>
|
||||
initDag('plan-dag', {{ nodes | tojson }}, {{ edges | tojson }});
|
||||
</script>
|
||||
#}
|
||||
|
||||
{% macro dag_scripts() %}
|
||||
<script src="{{ CYTOSCAPE_CDN }}"></script>
|
||||
<script src="{{ DAGRE_CDN }}"></script>
|
||||
<script src="{{ CYTOSCAPE_DAGRE_CDN }}"></script>
|
||||
<script>
|
||||
// Global Cytoscape instance for WebSocket updates
|
||||
window.artdagCy = null;
|
||||
|
||||
function initDag(containerId, nodes, edges) {
|
||||
const nodeColors = {{ NODE_COLORS | tojson }};
|
||||
|
||||
window.artdagCy = cytoscape({
|
||||
container: document.getElementById(containerId),
|
||||
elements: {
|
||||
nodes: nodes,
|
||||
edges: edges
|
||||
},
|
||||
style: [
|
||||
{
|
||||
selector: 'node',
|
||||
style: {
|
||||
'label': 'data(label)',
|
||||
'text-valign': 'center',
|
||||
'text-halign': 'center',
|
||||
'background-color': function(ele) {
|
||||
return nodeColors[ele.data('nodeType')] || nodeColors['default'];
|
||||
},
|
||||
'color': '#fff',
|
||||
'font-size': '10px',
|
||||
'width': 80,
|
||||
'height': 40,
|
||||
'shape': 'round-rectangle',
|
||||
'text-wrap': 'wrap',
|
||||
'text-max-width': '70px',
|
||||
}
|
||||
},
|
||||
{
|
||||
selector: 'node[status="cached"], node[status="completed"]',
|
||||
style: {
|
||||
'border-width': 3,
|
||||
'border-color': '#22c55e'
|
||||
}
|
||||
},
|
||||
{
|
||||
selector: 'node[status="running"]',
|
||||
style: {
|
||||
'border-width': 3,
|
||||
'border-color': '#eab308',
|
||||
'border-style': 'dashed'
|
||||
}
|
||||
},
|
||||
{
|
||||
selector: 'node:selected',
|
||||
style: {
|
||||
'border-width': 3,
|
||||
'border-color': '#3b82f6'
|
||||
}
|
||||
},
|
||||
{
|
||||
selector: 'edge',
|
||||
style: {
|
||||
'width': 2,
|
||||
'line-color': '#6b7280',
|
||||
'target-arrow-color': '#6b7280',
|
||||
'target-arrow-shape': 'triangle',
|
||||
'curve-style': 'bezier'
|
||||
}
|
||||
}
|
||||
],
|
||||
layout: {
|
||||
name: 'dagre',
|
||||
rankDir: 'TB',
|
||||
nodeSep: 50,
|
||||
rankSep: 80,
|
||||
padding: 20
|
||||
},
|
||||
userZoomingEnabled: true,
|
||||
userPanningEnabled: true,
|
||||
boxSelectionEnabled: false
|
||||
});
|
||||
|
||||
// Click handler for node details
|
||||
window.artdagCy.on('tap', 'node', function(evt) {
|
||||
const node = evt.target;
|
||||
const data = node.data();
|
||||
showNodeDetails(data);
|
||||
});
|
||||
|
||||
return window.artdagCy;
|
||||
}
|
||||
|
||||
function showNodeDetails(data) {
|
||||
const panel = document.getElementById('node-details');
|
||||
if (!panel) return;
|
||||
|
||||
panel.innerHTML = `
|
||||
<h4 class="font-medium text-white mb-2">${data.label || data.id}</h4>
|
||||
<dl class="space-y-1 text-sm">
|
||||
<div class="flex justify-between">
|
||||
<dt class="text-gray-400">Type</dt>
|
||||
<dd class="text-white">${data.nodeType || 'Unknown'}</dd>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<dt class="text-gray-400">Status</dt>
|
||||
<dd class="text-white">${data.status || 'pending'}</dd>
|
||||
</div>
|
||||
${data.cacheId ? `
|
||||
<div class="flex justify-between">
|
||||
<dt class="text-gray-400">Cache ID</dt>
|
||||
<dd class="text-white font-mono text-xs">${data.cacheId.substring(0, 16)}...</dd>
|
||||
</div>
|
||||
` : ''}
|
||||
${data.level !== undefined ? `
|
||||
<div class="flex justify-between">
|
||||
<dt class="text-gray-400">Level</dt>
|
||||
<dd class="text-white">${data.level}</dd>
|
||||
</div>
|
||||
` : ''}
|
||||
</dl>
|
||||
`;
|
||||
panel.classList.remove('hidden');
|
||||
}
|
||||
|
||||
// Future WebSocket support: update node status in real-time
|
||||
function updateNodeStatus(stepId, status, cacheId) {
|
||||
if (!window.artdagCy) return;
|
||||
const node = window.artdagCy.getElementById(stepId);
|
||||
if (node && node.length > 0) {
|
||||
node.data('status', status);
|
||||
if (cacheId) {
|
||||
node.data('cacheId', cacheId);
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
{% endmacro %}
|
||||
|
||||
{% macro dag_container(id="dag-container", height="400px", class="") %}
|
||||
<div id="{{ id }}" class="w-full bg-dark-700 rounded-lg {{ class }}" style="height: {{ height }};"></div>
|
||||
<div id="node-details" class="hidden mt-4 p-4 bg-dark-600 rounded-lg"></div>
|
||||
{% endmacro %}
|
||||
|
||||
{% macro dag_legend(node_types=None) %}
|
||||
{% set types = node_types or ["SOURCE", "EFFECT", "_LIST"] %}
|
||||
<div class="flex gap-4 text-sm flex-wrap mt-4">
|
||||
{% for type in types %}
|
||||
<span class="flex items-center gap-2">
|
||||
<span class="w-4 h-4 rounded" style="background-color: {{ NODE_COLORS.get(type, NODE_COLORS.default) }}"></span>
|
||||
{{ type }}
|
||||
</span>
|
||||
{% endfor %}
|
||||
<span class="flex items-center gap-2">
|
||||
<span class="w-4 h-4 rounded border-2 border-green-500 bg-dark-600"></span>
|
||||
Cached
|
||||
</span>
|
||||
</div>
|
||||
{% endmacro %}
|
||||
98
common/artdag_common/templates/components/media_preview.html
Normal file
98
common/artdag_common/templates/components/media_preview.html
Normal file
@@ -0,0 +1,98 @@
|
||||
{#
|
||||
Media preview component for videos, images, and audio.
|
||||
|
||||
Usage:
|
||||
{% from "components/media_preview.html" import media_preview, video_player, image_preview, audio_player %}
|
||||
|
||||
{{ media_preview(content_hash, media_type, title="Preview") }}
|
||||
{{ video_player(src="/cache/abc123/mp4", poster="/cache/abc123/thumb") }}
|
||||
#}
|
||||
|
||||
{% macro media_preview(content_hash, media_type, title=None, class="", show_download=True) %}
|
||||
<div class="bg-dark-600 rounded-lg overflow-hidden {{ class }}">
|
||||
{% if title %}
|
||||
<div class="px-4 py-2 border-b border-dark-500">
|
||||
<h3 class="text-sm font-medium text-gray-400">{{ title }}</h3>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="aspect-video bg-dark-700 flex items-center justify-center">
|
||||
{% if media_type == "video" %}
|
||||
{{ video_player("/cache/" + content_hash + "/mp4") }}
|
||||
{% elif media_type == "image" %}
|
||||
{{ image_preview("/cache/" + content_hash + "/raw") }}
|
||||
{% elif media_type == "audio" %}
|
||||
{{ audio_player("/cache/" + content_hash + "/raw") }}
|
||||
{% else %}
|
||||
<div class="text-gray-400 text-center p-4">
|
||||
<svg class="w-12 h-12 mx-auto mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
|
||||
</svg>
|
||||
<p>Preview not available</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if show_download %}
|
||||
<div class="px-4 py-2 border-t border-dark-500">
|
||||
<a href="/cache/{{ content_hash }}/raw" download
|
||||
class="text-blue-400 hover:text-blue-300 text-sm">
|
||||
Download original
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
{% macro video_player(src, poster=None, autoplay=False, muted=True, loop=False, class="") %}
|
||||
<video
|
||||
class="w-full h-full object-contain {{ class }}"
|
||||
controls
|
||||
playsinline
|
||||
{% if poster %}poster="{{ poster }}"{% endif %}
|
||||
{% if autoplay %}autoplay{% endif %}
|
||||
{% if muted %}muted{% endif %}
|
||||
{% if loop %}loop{% endif %}
|
||||
>
|
||||
<source src="{{ src }}" type="video/mp4">
|
||||
Your browser does not support the video tag.
|
||||
</video>
|
||||
{% endmacro %}
|
||||
|
||||
{% macro image_preview(src, alt="", class="") %}
|
||||
<img
|
||||
src="{{ src }}"
|
||||
alt="{{ alt }}"
|
||||
class="w-full h-full object-contain {{ class }}"
|
||||
loading="lazy"
|
||||
>
|
||||
{% endmacro %}
|
||||
|
||||
{% macro audio_player(src, class="") %}
|
||||
<div class="w-full px-4 {{ class }}">
|
||||
<audio controls class="w-full">
|
||||
<source src="{{ src }}">
|
||||
Your browser does not support the audio element.
|
||||
</audio>
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
{% macro thumbnail(content_hash, media_type, size="w-24 h-24", class="") %}
|
||||
<div class="bg-dark-700 rounded {{ size }} flex items-center justify-center overflow-hidden {{ class }}">
|
||||
{% if media_type == "image" %}
|
||||
<img src="/cache/{{ content_hash }}/raw" alt="" class="w-full h-full object-cover" loading="lazy">
|
||||
{% elif media_type == "video" %}
|
||||
<svg class="w-8 h-8 text-gray-400" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M8 5v14l11-7z"/>
|
||||
</svg>
|
||||
{% elif media_type == "audio" %}
|
||||
<svg class="w-8 h-8 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" 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>
|
||||
{% else %}
|
||||
<svg class="w-8 h-8 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
|
||||
</svg>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endmacro %}
|
||||
82
common/artdag_common/templates/components/pagination.html
Normal file
82
common/artdag_common/templates/components/pagination.html
Normal file
@@ -0,0 +1,82 @@
|
||||
{#
|
||||
Pagination component with HTMX infinite scroll support.
|
||||
|
||||
Usage:
|
||||
{% from "components/pagination.html" import infinite_scroll_trigger, page_links %}
|
||||
|
||||
{# Infinite scroll (HTMX) #}
|
||||
{{ infinite_scroll_trigger(url="/items?page=2", colspan=3, has_more=True) }}
|
||||
|
||||
{# Traditional pagination #}
|
||||
{{ page_links(current_page=1, total_pages=5, base_url="/items") }}
|
||||
#}
|
||||
|
||||
{% macro infinite_scroll_trigger(url, colspan=1, has_more=True, target=None) %}
|
||||
{% if has_more %}
|
||||
<tr hx-get="{{ url }}"
|
||||
hx-trigger="revealed"
|
||||
hx-swap="afterend"
|
||||
{% if target %}hx-target="{{ target }}"{% endif %}
|
||||
class="htmx-indicator-row">
|
||||
<td colspan="{{ colspan }}" class="text-center py-4">
|
||||
<span class="text-gray-400 htmx-indicator">
|
||||
<svg class="animate-spin h-5 w-5 inline mr-2" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
Loading more...
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% endmacro %}
|
||||
|
||||
{% macro page_links(current_page, total_pages, base_url, class="") %}
|
||||
<nav class="flex items-center justify-center space-x-2 {{ class }}">
|
||||
{# Previous button #}
|
||||
{% if current_page > 1 %}
|
||||
<a href="{{ base_url }}?page={{ current_page - 1 }}"
|
||||
class="px-3 py-2 rounded-lg bg-dark-600 text-gray-300 hover:bg-dark-500 transition-colors">
|
||||
← Previous
|
||||
</a>
|
||||
{% else %}
|
||||
<span class="px-3 py-2 rounded-lg bg-dark-700 text-gray-500 cursor-not-allowed">
|
||||
← Previous
|
||||
</span>
|
||||
{% endif %}
|
||||
|
||||
{# Page numbers #}
|
||||
<div class="flex items-center space-x-1">
|
||||
{% for page in range(1, total_pages + 1) %}
|
||||
{% if page == current_page %}
|
||||
<span class="px-3 py-2 rounded-lg bg-blue-600 text-white">{{ page }}</span>
|
||||
{% elif page == 1 or page == total_pages or (page >= current_page - 2 and page <= current_page + 2) %}
|
||||
<a href="{{ base_url }}?page={{ page }}"
|
||||
class="px-3 py-2 rounded-lg bg-dark-600 text-gray-300 hover:bg-dark-500 transition-colors">
|
||||
{{ page }}
|
||||
</a>
|
||||
{% elif page == current_page - 3 or page == current_page + 3 %}
|
||||
<span class="px-2 text-gray-500">...</span>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{# Next button #}
|
||||
{% if current_page < total_pages %}
|
||||
<a href="{{ base_url }}?page={{ current_page + 1 }}"
|
||||
class="px-3 py-2 rounded-lg bg-dark-600 text-gray-300 hover:bg-dark-500 transition-colors">
|
||||
Next →
|
||||
</a>
|
||||
{% else %}
|
||||
<span class="px-3 py-2 rounded-lg bg-dark-700 text-gray-500 cursor-not-allowed">
|
||||
Next →
|
||||
</span>
|
||||
{% endif %}
|
||||
</nav>
|
||||
{% endmacro %}
|
||||
|
||||
{% macro page_info(page, limit, total) %}
|
||||
<div class="text-sm text-gray-400">
|
||||
Showing {{ (page - 1) * limit + 1 }}-{{ [page * limit, total] | min }} of {{ total }}
|
||||
</div>
|
||||
{% endmacro %}
|
||||
51
common/artdag_common/templates/components/table.html
Normal file
51
common/artdag_common/templates/components/table.html
Normal file
@@ -0,0 +1,51 @@
|
||||
{#
|
||||
Table component with dark theme styling.
|
||||
|
||||
Usage:
|
||||
{% from "components/table.html" import table, table_row %}
|
||||
|
||||
{% call table(columns=["Name", "Status", "Actions"]) %}
|
||||
{% for item in items %}
|
||||
{{ table_row([item.name, item.status, actions_html]) }}
|
||||
{% endfor %}
|
||||
{% endcall %}
|
||||
#}
|
||||
|
||||
{% macro table(columns, class="", id="") %}
|
||||
<div class="overflow-x-auto {{ class }}" {% if id %}id="{{ id }}"{% endif %}>
|
||||
<table class="w-full text-sm">
|
||||
<thead class="text-gray-400 border-b border-dark-600">
|
||||
<tr>
|
||||
{% for col in columns %}
|
||||
<th class="text-left py-3 px-4 font-medium">{{ col }}</th>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-dark-600">
|
||||
{{ caller() }}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
{% macro table_row(cells, class="", href=None) %}
|
||||
<tr class="hover:bg-dark-600/50 transition-colors {{ class }}">
|
||||
{% for cell in cells %}
|
||||
<td class="py-3 px-4">
|
||||
{% if href and loop.first %}
|
||||
<a href="{{ href }}" class="text-blue-400 hover:text-blue-300">{{ cell }}</a>
|
||||
{% else %}
|
||||
{{ cell | safe }}
|
||||
{% endif %}
|
||||
</td>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
{% endmacro %}
|
||||
|
||||
{% macro empty_row(colspan, message="No items found") %}
|
||||
<tr>
|
||||
<td colspan="{{ colspan }}" class="py-8 text-center text-gray-400">
|
||||
{{ message }}
|
||||
</td>
|
||||
</tr>
|
||||
{% endmacro %}
|
||||
19
common/artdag_common/utils/__init__.py
Normal file
19
common/artdag_common/utils/__init__.py
Normal file
@@ -0,0 +1,19 @@
|
||||
"""
|
||||
Utility functions shared across Art-DAG servers.
|
||||
"""
|
||||
|
||||
from .pagination import paginate, get_pagination_params
|
||||
from .media import detect_media_type, get_media_extension, is_streamable
|
||||
from .formatting import format_date, format_size, truncate_hash, format_duration
|
||||
|
||||
__all__ = [
|
||||
"paginate",
|
||||
"get_pagination_params",
|
||||
"detect_media_type",
|
||||
"get_media_extension",
|
||||
"is_streamable",
|
||||
"format_date",
|
||||
"format_size",
|
||||
"truncate_hash",
|
||||
"format_duration",
|
||||
]
|
||||
165
common/artdag_common/utils/formatting.py
Normal file
165
common/artdag_common/utils/formatting.py
Normal file
@@ -0,0 +1,165 @@
|
||||
"""
|
||||
Formatting utilities for display.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional, Union
|
||||
|
||||
|
||||
def format_date(
|
||||
value: Optional[Union[str, datetime]],
|
||||
length: int = 10,
|
||||
include_time: bool = False,
|
||||
) -> str:
|
||||
"""
|
||||
Format a date/datetime for display.
|
||||
|
||||
Args:
|
||||
value: Date string or datetime object
|
||||
length: Length to truncate to (default 10 for YYYY-MM-DD)
|
||||
include_time: Whether to include time portion
|
||||
|
||||
Returns:
|
||||
Formatted date string
|
||||
"""
|
||||
if value is None:
|
||||
return ""
|
||||
|
||||
if isinstance(value, str):
|
||||
# Parse ISO format string
|
||||
try:
|
||||
if "T" in value:
|
||||
dt = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||
else:
|
||||
return value[:length]
|
||||
except ValueError:
|
||||
return value[:length]
|
||||
else:
|
||||
dt = value
|
||||
|
||||
if include_time:
|
||||
return dt.strftime("%Y-%m-%d %H:%M")
|
||||
return dt.strftime("%Y-%m-%d")
|
||||
|
||||
|
||||
def format_size(size_bytes: Optional[int]) -> str:
|
||||
"""
|
||||
Format file size in human-readable form.
|
||||
|
||||
Args:
|
||||
size_bytes: Size in bytes
|
||||
|
||||
Returns:
|
||||
Human-readable size string (e.g., "1.5 MB")
|
||||
"""
|
||||
if size_bytes is None:
|
||||
return "Unknown"
|
||||
if size_bytes < 0:
|
||||
return "Unknown"
|
||||
if size_bytes == 0:
|
||||
return "0 B"
|
||||
|
||||
units = ["B", "KB", "MB", "GB", "TB"]
|
||||
unit_index = 0
|
||||
size = float(size_bytes)
|
||||
|
||||
while size >= 1024 and unit_index < len(units) - 1:
|
||||
size /= 1024
|
||||
unit_index += 1
|
||||
|
||||
if unit_index == 0:
|
||||
return f"{int(size)} {units[unit_index]}"
|
||||
return f"{size:.1f} {units[unit_index]}"
|
||||
|
||||
|
||||
def truncate_hash(value: str, length: int = 16, suffix: str = "...") -> str:
|
||||
"""
|
||||
Truncate a hash or long string with ellipsis.
|
||||
|
||||
Args:
|
||||
value: String to truncate
|
||||
length: Maximum length before truncation
|
||||
suffix: Suffix to add when truncated
|
||||
|
||||
Returns:
|
||||
Truncated string
|
||||
"""
|
||||
if not value:
|
||||
return ""
|
||||
if len(value) <= length:
|
||||
return value
|
||||
return f"{value[:length]}{suffix}"
|
||||
|
||||
|
||||
def format_duration(seconds: Optional[float]) -> str:
|
||||
"""
|
||||
Format duration in human-readable form.
|
||||
|
||||
Args:
|
||||
seconds: Duration in seconds
|
||||
|
||||
Returns:
|
||||
Human-readable duration string (e.g., "2m 30s")
|
||||
"""
|
||||
if seconds is None or seconds < 0:
|
||||
return "Unknown"
|
||||
|
||||
if seconds < 1:
|
||||
return f"{int(seconds * 1000)}ms"
|
||||
|
||||
if seconds < 60:
|
||||
return f"{seconds:.1f}s"
|
||||
|
||||
minutes = int(seconds // 60)
|
||||
remaining_seconds = int(seconds % 60)
|
||||
|
||||
if minutes < 60:
|
||||
if remaining_seconds:
|
||||
return f"{minutes}m {remaining_seconds}s"
|
||||
return f"{minutes}m"
|
||||
|
||||
hours = minutes // 60
|
||||
remaining_minutes = minutes % 60
|
||||
|
||||
if remaining_minutes:
|
||||
return f"{hours}h {remaining_minutes}m"
|
||||
return f"{hours}h"
|
||||
|
||||
|
||||
def format_count(count: int) -> str:
|
||||
"""
|
||||
Format a count with abbreviation for large numbers.
|
||||
|
||||
Args:
|
||||
count: Number to format
|
||||
|
||||
Returns:
|
||||
Formatted string (e.g., "1.2K", "3.5M")
|
||||
"""
|
||||
if count < 1000:
|
||||
return str(count)
|
||||
if count < 1000000:
|
||||
return f"{count / 1000:.1f}K"
|
||||
if count < 1000000000:
|
||||
return f"{count / 1000000:.1f}M"
|
||||
return f"{count / 1000000000:.1f}B"
|
||||
|
||||
|
||||
def format_percentage(value: float, decimals: int = 1) -> str:
|
||||
"""
|
||||
Format a percentage value.
|
||||
|
||||
Args:
|
||||
value: Percentage value (0-100 or 0-1)
|
||||
decimals: Number of decimal places
|
||||
|
||||
Returns:
|
||||
Formatted percentage string
|
||||
"""
|
||||
# Assume 0-1 if less than 1
|
||||
if value <= 1:
|
||||
value *= 100
|
||||
|
||||
if decimals == 0:
|
||||
return f"{int(value)}%"
|
||||
return f"{value:.{decimals}f}%"
|
||||
166
common/artdag_common/utils/media.py
Normal file
166
common/artdag_common/utils/media.py
Normal file
@@ -0,0 +1,166 @@
|
||||
"""
|
||||
Media type detection and handling utilities.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
import mimetypes
|
||||
|
||||
# Initialize mimetypes database
|
||||
mimetypes.init()
|
||||
|
||||
# Media type categories
|
||||
VIDEO_TYPES = {"video/mp4", "video/webm", "video/quicktime", "video/x-msvideo", "video/avi"}
|
||||
IMAGE_TYPES = {"image/jpeg", "image/png", "image/gif", "image/webp", "image/svg+xml"}
|
||||
AUDIO_TYPES = {"audio/mpeg", "audio/wav", "audio/ogg", "audio/flac", "audio/aac", "audio/mp3"}
|
||||
|
||||
# File extension mappings
|
||||
EXTENSION_TO_CATEGORY = {
|
||||
# Video
|
||||
".mp4": "video",
|
||||
".webm": "video",
|
||||
".mov": "video",
|
||||
".avi": "video",
|
||||
".mkv": "video",
|
||||
# Image
|
||||
".jpg": "image",
|
||||
".jpeg": "image",
|
||||
".png": "image",
|
||||
".gif": "image",
|
||||
".webp": "image",
|
||||
".svg": "image",
|
||||
# Audio
|
||||
".mp3": "audio",
|
||||
".wav": "audio",
|
||||
".ogg": "audio",
|
||||
".flac": "audio",
|
||||
".aac": "audio",
|
||||
".m4a": "audio",
|
||||
}
|
||||
|
||||
|
||||
def detect_media_type(path: Path) -> str:
|
||||
"""
|
||||
Detect the media category for a file.
|
||||
|
||||
Args:
|
||||
path: Path to the file
|
||||
|
||||
Returns:
|
||||
Category string: "video", "image", "audio", or "unknown"
|
||||
"""
|
||||
if not path:
|
||||
return "unknown"
|
||||
|
||||
# Try extension first
|
||||
ext = path.suffix.lower()
|
||||
if ext in EXTENSION_TO_CATEGORY:
|
||||
return EXTENSION_TO_CATEGORY[ext]
|
||||
|
||||
# Try mimetypes
|
||||
mime_type, _ = mimetypes.guess_type(str(path))
|
||||
if mime_type:
|
||||
if mime_type in VIDEO_TYPES or mime_type.startswith("video/"):
|
||||
return "video"
|
||||
if mime_type in IMAGE_TYPES or mime_type.startswith("image/"):
|
||||
return "image"
|
||||
if mime_type in AUDIO_TYPES or mime_type.startswith("audio/"):
|
||||
return "audio"
|
||||
|
||||
return "unknown"
|
||||
|
||||
|
||||
def get_mime_type(path: Path) -> str:
|
||||
"""
|
||||
Get the MIME type for a file.
|
||||
|
||||
Args:
|
||||
path: Path to the file
|
||||
|
||||
Returns:
|
||||
MIME type string or "application/octet-stream"
|
||||
"""
|
||||
mime_type, _ = mimetypes.guess_type(str(path))
|
||||
return mime_type or "application/octet-stream"
|
||||
|
||||
|
||||
def get_media_extension(media_type: str) -> str:
|
||||
"""
|
||||
Get the typical file extension for a media type.
|
||||
|
||||
Args:
|
||||
media_type: Media category or MIME type
|
||||
|
||||
Returns:
|
||||
File extension with dot (e.g., ".mp4")
|
||||
"""
|
||||
if media_type == "video":
|
||||
return ".mp4"
|
||||
if media_type == "image":
|
||||
return ".png"
|
||||
if media_type == "audio":
|
||||
return ".mp3"
|
||||
|
||||
# Try as MIME type
|
||||
ext = mimetypes.guess_extension(media_type)
|
||||
return ext or ""
|
||||
|
||||
|
||||
def is_streamable(path: Path) -> bool:
|
||||
"""
|
||||
Check if a file type is streamable (video/audio).
|
||||
|
||||
Args:
|
||||
path: Path to the file
|
||||
|
||||
Returns:
|
||||
True if the file can be streamed
|
||||
"""
|
||||
media_type = detect_media_type(path)
|
||||
return media_type in ("video", "audio")
|
||||
|
||||
|
||||
def needs_conversion(path: Path, target_format: str = "mp4") -> bool:
|
||||
"""
|
||||
Check if a video file needs format conversion.
|
||||
|
||||
Args:
|
||||
path: Path to the file
|
||||
target_format: Target format (default mp4)
|
||||
|
||||
Returns:
|
||||
True if conversion is needed
|
||||
"""
|
||||
media_type = detect_media_type(path)
|
||||
if media_type != "video":
|
||||
return False
|
||||
|
||||
ext = path.suffix.lower().lstrip(".")
|
||||
return ext != target_format
|
||||
|
||||
|
||||
def get_video_src(
|
||||
content_hash: str,
|
||||
original_path: Optional[Path] = None,
|
||||
is_ios: bool = False,
|
||||
) -> str:
|
||||
"""
|
||||
Get the appropriate video source URL.
|
||||
|
||||
For iOS devices, prefer MP4 format.
|
||||
|
||||
Args:
|
||||
content_hash: Content hash for the video
|
||||
original_path: Optional original file path
|
||||
is_ios: Whether the client is iOS
|
||||
|
||||
Returns:
|
||||
URL path for the video source
|
||||
"""
|
||||
if is_ios:
|
||||
return f"/cache/{content_hash}/mp4"
|
||||
|
||||
if original_path and original_path.suffix.lower() in (".mp4", ".webm"):
|
||||
return f"/cache/{content_hash}/raw"
|
||||
|
||||
return f"/cache/{content_hash}/mp4"
|
||||
85
common/artdag_common/utils/pagination.py
Normal file
85
common/artdag_common/utils/pagination.py
Normal file
@@ -0,0 +1,85 @@
|
||||
"""
|
||||
Pagination utilities.
|
||||
"""
|
||||
|
||||
from typing import List, Any, Tuple, Optional
|
||||
|
||||
from fastapi import Request
|
||||
|
||||
from ..constants import DEFAULT_PAGE_SIZE, MAX_PAGE_SIZE
|
||||
|
||||
|
||||
def get_pagination_params(request: Request) -> Tuple[int, int]:
|
||||
"""
|
||||
Extract pagination parameters from request query string.
|
||||
|
||||
Args:
|
||||
request: FastAPI request
|
||||
|
||||
Returns:
|
||||
Tuple of (page, limit)
|
||||
"""
|
||||
try:
|
||||
page = int(request.query_params.get("page", 1))
|
||||
page = max(1, page)
|
||||
except ValueError:
|
||||
page = 1
|
||||
|
||||
try:
|
||||
limit = int(request.query_params.get("limit", DEFAULT_PAGE_SIZE))
|
||||
limit = max(1, min(limit, MAX_PAGE_SIZE))
|
||||
except ValueError:
|
||||
limit = DEFAULT_PAGE_SIZE
|
||||
|
||||
return page, limit
|
||||
|
||||
|
||||
def paginate(
|
||||
items: List[Any],
|
||||
page: int = 1,
|
||||
limit: int = DEFAULT_PAGE_SIZE,
|
||||
) -> Tuple[List[Any], dict]:
|
||||
"""
|
||||
Paginate a list of items.
|
||||
|
||||
Args:
|
||||
items: Full list of items
|
||||
page: Page number (1-indexed)
|
||||
limit: Items per page
|
||||
|
||||
Returns:
|
||||
Tuple of (paginated items, pagination info dict)
|
||||
"""
|
||||
total = len(items)
|
||||
start = (page - 1) * limit
|
||||
end = start + limit
|
||||
|
||||
paginated = items[start:end]
|
||||
|
||||
return paginated, {
|
||||
"page": page,
|
||||
"limit": limit,
|
||||
"total": total,
|
||||
"has_more": end < total,
|
||||
"total_pages": (total + limit - 1) // limit if total > 0 else 1,
|
||||
}
|
||||
|
||||
|
||||
def calculate_offset(page: int, limit: int) -> int:
|
||||
"""Calculate database offset from page and limit."""
|
||||
return (page - 1) * limit
|
||||
|
||||
|
||||
def build_pagination_info(
|
||||
page: int,
|
||||
limit: int,
|
||||
total: int,
|
||||
) -> dict:
|
||||
"""Build pagination info dictionary."""
|
||||
return {
|
||||
"page": page,
|
||||
"limit": limit,
|
||||
"total": total,
|
||||
"has_more": page * limit < total,
|
||||
"total_pages": (total + limit - 1) // limit if total > 0 else 1,
|
||||
}
|
||||
22
common/pyproject.toml
Normal file
22
common/pyproject.toml
Normal file
@@ -0,0 +1,22 @@
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "artdag-common"
|
||||
version = "0.1.3"
|
||||
description = "Shared components for Art-DAG L1 and L2 servers"
|
||||
requires-python = ">=3.10"
|
||||
dependencies = [
|
||||
"fastapi>=0.100.0",
|
||||
"jinja2>=3.1.0",
|
||||
"pydantic>=2.0.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"pytest>=7.0.0",
|
||||
]
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["artdag_common"]
|
||||
Reference in New Issue
Block a user