artifacts-dashboard/backend/app/api/logs.py
Paweł Orzech 75313b83c0
Add multi-user workflows/pipelines and error tracking
Add multi-user automation features and per-user error tracking.

- Database migrations: add workflow_configs/workflow_runs (004), app_errors (005), pipeline_configs/pipeline_runs (006), and add user_token_hash to app_errors (007).
- Backend: introduce per-request token handling (X-API-Token) via app.api.deps and update many API routes (auth, automations, bank, characters, dashboard, events, exchange, logs) to use user-scoped Artifacts client and character scoping. Auth endpoints no longer store tokens server-side (validate-only); clear is a no-op on server.
- New Errors API and services: endpoint to list, filter, resolve, and report errors scoped to the requesting user; add error models, schemas, middleware/error handler and error_service for recording/hashing tokens.
- Pipelines & Workflows: add API routers, models, schemas and engine modules (pipeline/worker/coordinator, workflow runner/conditions) and action_executor updates to support workflow/pipeline execution.
- Logs: logs endpoint now prefers fetching recent action logs from the game API (with fallback to local DB), supports paging and filtering, and scopes results to the user.
- Frontend: add pipeline/workflow builders, lists, progress components and hooks (use-errors, use-pipelines, use-workflows), sentry client config, and updates to API client/constants/types.
- Misc: add middleware error handler, various engine strategy tweaks, tests adjusted.

Overall this change enables per-user API tokens, scopes DB queries to each user, introduces pipelines/workflows runtime support, and centralizes application error tracking.
2026-03-01 23:02:34 +01:00

235 lines
8.3 KiB
Python

"""Character logs and analytics API router."""
import logging
from typing import Any
from fastapi import APIRouter, Query, Request
from sqlalchemy import select
from app.api.deps import get_user_character_names, get_user_client
from app.database import async_session_factory
from app.models.automation import AutomationConfig, AutomationLog, AutomationRun
from app.services.analytics_service import AnalyticsService
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/logs", tags=["logs"])
@router.get("/")
async def get_logs(
request: Request,
character: str = Query(default="", description="Character name to filter logs"),
type: str = Query(default="", description="Action type to filter (e.g. fight, gathering)"),
page: int = Query(default=1, ge=1, description="Page number"),
size: int = Query(default=50, ge=1, le=100, description="Page size"),
) -> dict[str, Any]:
"""Get action logs from the Artifacts game API.
Fetches the last 5000 character actions directly from the game server.
Falls back to local automation logs if the game API is unavailable.
"""
client = get_user_client(request)
try:
if character:
result = await client.get_character_logs(character, page=page, size=size)
else:
result = await client.get_logs(page=page, size=size)
raw_logs = result.get("data", [])
total = result.get("total", 0)
pages = result.get("pages", 1)
# Filter by type if specified
if type:
raw_logs = [log for log in raw_logs if log.get("type") == type]
logs = []
for entry in raw_logs:
content = entry.get("content", {})
action_type = entry.get("type", "unknown")
# Build details - description is the main human-readable field
details: dict[str, Any] = {}
description = entry.get("description", "")
if description:
details["description"] = description
# Extract structured data per action type
if "fight" in content:
fight = content["fight"]
details["monster"] = fight.get("opponent", "")
details["result"] = fight.get("result", "")
details["turns"] = fight.get("turns", 0)
if "gathering" in content:
g = content["gathering"]
details["resource"] = g.get("resource", "")
details["skill"] = g.get("skill", "")
details["xp"] = g.get("xp_gained", 0)
if "drops" in content:
items = content["drops"].get("items", [])
if items:
details["drops"] = [
f"{i.get('quantity', 1)}x {i.get('code', '?')}" for i in items
]
if "map" in content:
m = content["map"]
details["x"] = m.get("x")
details["y"] = m.get("y")
details["map_name"] = m.get("name", "")
if "crafting" in content:
c = content["crafting"]
details["item"] = c.get("code", "")
details["skill"] = c.get("skill", "")
details["xp"] = c.get("xp_gained", 0)
if "hp_restored" in content:
details["hp_restored"] = content["hp_restored"]
logs.append({
"id": hash(f"{entry.get('character', '')}-{entry.get('created_at', '')}") & 0x7FFFFFFF,
"character_name": entry.get("character", ""),
"action_type": action_type,
"details": details,
"success": True,
"created_at": entry.get("created_at", ""),
"cooldown": entry.get("cooldown", 0),
})
return {
"logs": logs,
"total": total,
"page": page,
"pages": pages,
}
except Exception:
logger.warning("Failed to fetch logs from game API, falling back to local DB", exc_info=True)
user_chars = await get_user_character_names(request)
return await _get_local_logs(character, type, page, size, user_chars)
async def _get_local_logs(
character: str,
type: str,
page: int,
size: int,
user_characters: list[str] | None = None,
) -> dict[str, Any]:
"""Fallback: get automation logs from local database."""
offset = (page - 1) * size
async with async_session_factory() as db:
stmt = (
select(
AutomationLog.id,
AutomationLog.action_type,
AutomationLog.details,
AutomationLog.success,
AutomationLog.created_at,
AutomationConfig.character_name,
)
.join(AutomationRun, AutomationLog.run_id == AutomationRun.id)
.join(AutomationConfig, AutomationRun.config_id == AutomationConfig.id)
.order_by(AutomationLog.created_at.desc())
)
# Scope to the current user's characters
if user_characters is not None:
stmt = stmt.where(AutomationConfig.character_name.in_(user_characters))
if character:
stmt = stmt.where(AutomationConfig.character_name == character)
if type:
stmt = stmt.where(AutomationLog.action_type == type)
stmt = stmt.offset(offset).limit(size)
result = await db.execute(stmt)
rows = result.all()
return {
"logs": [
{
"id": row.id,
"character_name": row.character_name,
"action_type": row.action_type,
"details": row.details,
"success": row.success,
"created_at": row.created_at.isoformat(),
}
for row in rows
],
"total": len(rows),
"page": page,
"pages": 1,
}
@router.get("/analytics")
async def get_analytics(
request: Request,
character: str = Query(default="", description="Character name (empty for all)"),
hours: int = Query(default=24, ge=1, le=168, description="Hours of history"),
) -> dict[str, Any]:
"""Get analytics aggregations for a character.
Returns XP history, gold history, and estimated actions per hour.
If no character is specified, aggregates across the current user's characters.
"""
analytics = AnalyticsService()
user_chars = await get_user_character_names(request)
async with async_session_factory() as db:
if character:
# Verify the requested character belongs to the current user
if character not in user_chars:
return {"xp_history": [], "gold_history": [], "actions_per_hour": 0}
characters = [character]
else:
# Only aggregate characters belonging to the current user
tracked = await analytics.get_tracked_characters(db)
characters = [c for c in tracked if c in user_chars]
all_xp: list[dict[str, Any]] = []
all_gold: list[dict[str, Any]] = []
total_actions_per_hour = 0.0
for char_name in characters:
xp_history = await analytics.get_xp_history(db, char_name, hours)
gold_history = await analytics.get_gold_history(db, char_name, hours)
actions_rate = await analytics.get_actions_per_hour(db, char_name)
# Transform xp_history to TimeSeriesPoint format
for point in xp_history:
all_xp.append({
"timestamp": point["timestamp"],
"value": point["xp"],
"label": f"{char_name} XP" if not character else "XP",
})
# Transform gold_history to TimeSeriesPoint format
for point in gold_history:
all_gold.append({
"timestamp": point["timestamp"],
"value": point["gold"],
"label": char_name if not character else None,
})
total_actions_per_hour += actions_rate.get("estimated_actions_per_hour", 0)
# Sort by timestamp
all_xp.sort(key=lambda p: p["timestamp"] or "")
all_gold.sort(key=lambda p: p["timestamp"] or "")
return {
"xp_history": all_xp,
"gold_history": all_gold,
"actions_per_hour": round(total_actions_per_hour, 1),
}