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.
This commit is contained in:
parent
2484a40dbd
commit
75313b83c0
86 changed files with 14835 additions and 587 deletions
114
backend/alembic/versions/004_add_workflow_tables.py
Normal file
114
backend/alembic/versions/004_add_workflow_tables.py
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
"""Add workflow_configs, workflow_runs tables
|
||||
|
||||
Revision ID: 004_workflows
|
||||
Revises: 003_price_event
|
||||
Create Date: 2026-03-01
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "004_workflows"
|
||||
down_revision: Union[str, None] = "003_price_event"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# workflow_configs
|
||||
op.create_table(
|
||||
"workflow_configs",
|
||||
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column("name", sa.String(length=100), nullable=False),
|
||||
sa.Column("character_name", sa.String(length=100), nullable=False),
|
||||
sa.Column("description", sa.Text(), nullable=False, server_default=sa.text("''")),
|
||||
sa.Column(
|
||||
"steps",
|
||||
sa.JSON(),
|
||||
nullable=False,
|
||||
comment="JSON array of workflow steps",
|
||||
),
|
||||
sa.Column("loop", sa.Boolean(), nullable=False, server_default=sa.text("false")),
|
||||
sa.Column("max_loops", sa.Integer(), nullable=False, server_default=sa.text("0")),
|
||||
sa.Column("enabled", sa.Boolean(), nullable=False, server_default=sa.text("true")),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.text("now()"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.text("now()"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index(
|
||||
op.f("ix_workflow_configs_character_name"),
|
||||
"workflow_configs",
|
||||
["character_name"],
|
||||
unique=False,
|
||||
)
|
||||
|
||||
# workflow_runs
|
||||
op.create_table(
|
||||
"workflow_runs",
|
||||
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column(
|
||||
"workflow_id",
|
||||
sa.Integer(),
|
||||
sa.ForeignKey("workflow_configs.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"status",
|
||||
sa.String(length=20),
|
||||
nullable=False,
|
||||
server_default=sa.text("'running'"),
|
||||
comment="Status: running, paused, stopped, completed, error",
|
||||
),
|
||||
sa.Column("current_step_index", sa.Integer(), nullable=False, server_default=sa.text("0")),
|
||||
sa.Column(
|
||||
"current_step_id",
|
||||
sa.String(length=100),
|
||||
nullable=False,
|
||||
server_default=sa.text("''"),
|
||||
),
|
||||
sa.Column("loop_count", sa.Integer(), nullable=False, server_default=sa.text("0")),
|
||||
sa.Column("total_actions_count", sa.Integer(), nullable=False, server_default=sa.text("0")),
|
||||
sa.Column("step_actions_count", sa.Integer(), nullable=False, server_default=sa.text("0")),
|
||||
sa.Column(
|
||||
"started_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.text("now()"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("stopped_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("error_message", sa.Text(), nullable=True),
|
||||
sa.Column(
|
||||
"step_history",
|
||||
sa.JSON(),
|
||||
nullable=False,
|
||||
server_default=sa.text("'[]'::json"),
|
||||
comment="JSON array of completed step records",
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index(
|
||||
op.f("ix_workflow_runs_workflow_id"),
|
||||
"workflow_runs",
|
||||
["workflow_id"],
|
||||
unique=False,
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index(op.f("ix_workflow_runs_workflow_id"), table_name="workflow_runs")
|
||||
op.drop_table("workflow_runs")
|
||||
op.drop_index(op.f("ix_workflow_configs_character_name"), table_name="workflow_configs")
|
||||
op.drop_table("workflow_configs")
|
||||
102
backend/alembic/versions/005_add_app_errors_table.py
Normal file
102
backend/alembic/versions/005_add_app_errors_table.py
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
"""Add app_errors table for error tracking
|
||||
|
||||
Revision ID: 005_app_errors
|
||||
Revises: 004_workflows
|
||||
Create Date: 2026-03-01
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "005_app_errors"
|
||||
down_revision: Union[str, None] = "004_workflows"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"app_errors",
|
||||
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column(
|
||||
"severity",
|
||||
sa.String(length=20),
|
||||
nullable=False,
|
||||
server_default="error",
|
||||
comment="error | warning | critical",
|
||||
),
|
||||
sa.Column(
|
||||
"source",
|
||||
sa.String(length=50),
|
||||
nullable=False,
|
||||
comment="backend | frontend | automation | middleware",
|
||||
),
|
||||
sa.Column(
|
||||
"error_type",
|
||||
sa.String(length=200),
|
||||
nullable=False,
|
||||
comment="Exception class name or error category",
|
||||
),
|
||||
sa.Column("message", sa.Text(), nullable=False),
|
||||
sa.Column("stack_trace", sa.Text(), nullable=True),
|
||||
sa.Column(
|
||||
"context",
|
||||
sa.JSON(),
|
||||
nullable=True,
|
||||
comment="Arbitrary JSON context",
|
||||
),
|
||||
sa.Column(
|
||||
"correlation_id",
|
||||
sa.String(length=36),
|
||||
nullable=True,
|
||||
comment="Request correlation ID",
|
||||
),
|
||||
sa.Column(
|
||||
"resolved",
|
||||
sa.Boolean(),
|
||||
nullable=False,
|
||||
server_default=sa.text("false"),
|
||||
),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.text("now()"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index(
|
||||
op.f("ix_app_errors_correlation_id"),
|
||||
"app_errors",
|
||||
["correlation_id"],
|
||||
unique=False,
|
||||
)
|
||||
op.create_index(
|
||||
op.f("ix_app_errors_created_at"),
|
||||
"app_errors",
|
||||
["created_at"],
|
||||
unique=False,
|
||||
)
|
||||
op.create_index(
|
||||
op.f("ix_app_errors_severity"),
|
||||
"app_errors",
|
||||
["severity"],
|
||||
unique=False,
|
||||
)
|
||||
op.create_index(
|
||||
op.f("ix_app_errors_source"),
|
||||
"app_errors",
|
||||
["source"],
|
||||
unique=False,
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index(op.f("ix_app_errors_source"), table_name="app_errors")
|
||||
op.drop_index(op.f("ix_app_errors_severity"), table_name="app_errors")
|
||||
op.drop_index(op.f("ix_app_errors_created_at"), table_name="app_errors")
|
||||
op.drop_index(op.f("ix_app_errors_correlation_id"), table_name="app_errors")
|
||||
op.drop_table("app_errors")
|
||||
112
backend/alembic/versions/006_add_pipeline_tables.py
Normal file
112
backend/alembic/versions/006_add_pipeline_tables.py
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
"""Add pipeline_configs, pipeline_runs tables for multi-character pipelines
|
||||
|
||||
Revision ID: 006_pipelines
|
||||
Revises: 005_app_errors
|
||||
Create Date: 2026-03-01
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "006_pipelines"
|
||||
down_revision: Union[str, None] = "005_app_errors"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# pipeline_configs
|
||||
op.create_table(
|
||||
"pipeline_configs",
|
||||
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column("name", sa.String(length=100), nullable=False),
|
||||
sa.Column("description", sa.Text(), nullable=False, server_default=sa.text("''")),
|
||||
sa.Column(
|
||||
"stages",
|
||||
sa.JSON(),
|
||||
nullable=False,
|
||||
comment="JSON array of pipeline stages with character_steps",
|
||||
),
|
||||
sa.Column("loop", sa.Boolean(), nullable=False, server_default=sa.text("false")),
|
||||
sa.Column("max_loops", sa.Integer(), nullable=False, server_default=sa.text("0")),
|
||||
sa.Column("enabled", sa.Boolean(), nullable=False, server_default=sa.text("true")),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.text("now()"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.text("now()"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
|
||||
# pipeline_runs
|
||||
op.create_table(
|
||||
"pipeline_runs",
|
||||
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column(
|
||||
"pipeline_id",
|
||||
sa.Integer(),
|
||||
sa.ForeignKey("pipeline_configs.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"status",
|
||||
sa.String(length=20),
|
||||
nullable=False,
|
||||
server_default=sa.text("'running'"),
|
||||
comment="Status: running, paused, stopped, completed, error",
|
||||
),
|
||||
sa.Column("current_stage_index", sa.Integer(), nullable=False, server_default=sa.text("0")),
|
||||
sa.Column(
|
||||
"current_stage_id",
|
||||
sa.String(length=100),
|
||||
nullable=False,
|
||||
server_default=sa.text("''"),
|
||||
),
|
||||
sa.Column("loop_count", sa.Integer(), nullable=False, server_default=sa.text("0")),
|
||||
sa.Column("total_actions_count", sa.Integer(), nullable=False, server_default=sa.text("0")),
|
||||
sa.Column(
|
||||
"character_states",
|
||||
sa.JSON(),
|
||||
nullable=False,
|
||||
server_default=sa.text("'{}'::json"),
|
||||
comment="Per-character state JSON",
|
||||
),
|
||||
sa.Column(
|
||||
"stage_history",
|
||||
sa.JSON(),
|
||||
nullable=False,
|
||||
server_default=sa.text("'[]'::json"),
|
||||
comment="JSON array of completed stage records",
|
||||
),
|
||||
sa.Column(
|
||||
"started_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.text("now()"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("stopped_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("error_message", sa.Text(), nullable=True),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index(
|
||||
op.f("ix_pipeline_runs_pipeline_id"),
|
||||
"pipeline_runs",
|
||||
["pipeline_id"],
|
||||
unique=False,
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index(op.f("ix_pipeline_runs_pipeline_id"), table_name="pipeline_runs")
|
||||
op.drop_table("pipeline_runs")
|
||||
op.drop_table("pipeline_configs")
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
"""Add user_token_hash column to app_errors for per-user scoping
|
||||
|
||||
Revision ID: 007_user_token_hash
|
||||
Revises: 006_pipelines
|
||||
Create Date: 2026-03-01
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "007_user_token_hash"
|
||||
down_revision: Union[str, None] = "006_pipelines"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"app_errors",
|
||||
sa.Column(
|
||||
"user_token_hash",
|
||||
sa.String(length=64),
|
||||
nullable=True,
|
||||
comment="SHA-256 hash of the user API token",
|
||||
),
|
||||
)
|
||||
op.create_index(
|
||||
op.f("ix_app_errors_user_token_hash"),
|
||||
"app_errors",
|
||||
["user_token_hash"],
|
||||
unique=False,
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index(op.f("ix_app_errors_user_token_hash"), table_name="app_errors")
|
||||
op.drop_column("app_errors", "user_token_hash")
|
||||
|
|
@ -1,8 +1,9 @@
|
|||
"""Auth endpoints for runtime API token management.
|
||||
"""Auth endpoints for per-user API token management.
|
||||
|
||||
When no ARTIFACTS_TOKEN is set in the environment, users can provide
|
||||
their own token through the UI. The token is stored in memory only
|
||||
and must be re-sent if the backend restarts.
|
||||
Each user provides their own Artifacts API token via the frontend.
|
||||
The token is stored in the browser's localStorage and sent with every
|
||||
request as the ``X-API-Token`` header. The backend validates the token
|
||||
but does NOT store it globally — this allows true multi-user support.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
|
@ -12,7 +13,6 @@ from fastapi import APIRouter, Request
|
|||
from pydantic import BaseModel
|
||||
|
||||
from app.config import settings
|
||||
from app.services.artifacts_client import ArtifactsClient
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -21,7 +21,7 @@ router = APIRouter(prefix="/api/auth", tags=["auth"])
|
|||
|
||||
class AuthStatus(BaseModel):
|
||||
has_token: bool
|
||||
source: str # "env", "user", or "none"
|
||||
source: str # "header", "env", or "none"
|
||||
|
||||
|
||||
class SetTokenRequest(BaseModel):
|
||||
|
|
@ -37,15 +37,24 @@ class SetTokenResponse(BaseModel):
|
|||
|
||||
@router.get("/status", response_model=AuthStatus)
|
||||
async def auth_status(request: Request) -> AuthStatus:
|
||||
client: ArtifactsClient = request.app.state.artifacts_client
|
||||
return AuthStatus(
|
||||
has_token=client.has_token,
|
||||
source=client.token_source,
|
||||
)
|
||||
"""Check whether the *requesting* client has a valid token.
|
||||
|
||||
The frontend sends the token in the ``X-API-Token`` header.
|
||||
This endpoint tells the frontend whether that token is present.
|
||||
"""
|
||||
token = request.headers.get("X-API-Token")
|
||||
if token:
|
||||
return AuthStatus(has_token=True, source="header")
|
||||
return AuthStatus(has_token=False, source="none")
|
||||
|
||||
|
||||
@router.post("/token", response_model=SetTokenResponse)
|
||||
async def set_token(body: SetTokenRequest, request: Request) -> SetTokenResponse:
|
||||
async def validate_token(body: SetTokenRequest) -> SetTokenResponse:
|
||||
"""Validate an Artifacts API token.
|
||||
|
||||
Does NOT store the token on the server. The frontend is responsible
|
||||
for persisting it in localStorage and sending it with every request.
|
||||
"""
|
||||
token = body.token.strip()
|
||||
if not token:
|
||||
return SetTokenResponse(success=False, source="none", error="Token cannot be empty")
|
||||
|
|
@ -78,37 +87,11 @@ async def set_token(body: SetTokenRequest, request: Request) -> SetTokenResponse
|
|||
error="Could not validate token. Check your network connection.",
|
||||
)
|
||||
|
||||
# Token is valid — apply it
|
||||
client: ArtifactsClient = request.app.state.artifacts_client
|
||||
client.set_token(token)
|
||||
|
||||
# Reconnect WebSocket with new token
|
||||
game_ws_client = getattr(request.app.state, "game_ws_client", None)
|
||||
if game_ws_client is not None:
|
||||
try:
|
||||
await game_ws_client.reconnect_with_token(token)
|
||||
except Exception:
|
||||
logger.exception("Failed to reconnect WebSocket with new token")
|
||||
|
||||
logger.info("API token updated via UI (source: user)")
|
||||
logger.info("API token validated via UI")
|
||||
return SetTokenResponse(success=True, source="user")
|
||||
|
||||
|
||||
@router.delete("/token")
|
||||
async def clear_token(request: Request) -> AuthStatus:
|
||||
client: ArtifactsClient = request.app.state.artifacts_client
|
||||
client.clear_token()
|
||||
|
||||
# Reconnect WebSocket with env token (or empty)
|
||||
game_ws_client = getattr(request.app.state, "game_ws_client", None)
|
||||
if game_ws_client is not None and settings.artifacts_token:
|
||||
try:
|
||||
await game_ws_client.reconnect_with_token(settings.artifacts_token)
|
||||
except Exception:
|
||||
logger.exception("Failed to reconnect WebSocket after token clear")
|
||||
|
||||
logger.info("API token cleared, reverted to env")
|
||||
return AuthStatus(
|
||||
has_token=client.has_token,
|
||||
source=client.token_source,
|
||||
)
|
||||
async def clear_token() -> AuthStatus:
|
||||
"""No-op on the backend — the frontend clears its own localStorage."""
|
||||
return AuthStatus(has_token=False, source="none")
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ from sqlalchemy import select
|
|||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.api.deps import get_user_character_names
|
||||
from app.database import async_session_factory
|
||||
from app.engine.manager import AutomationManager
|
||||
from app.models.automation import AutomationConfig, AutomationLog, AutomationRun
|
||||
|
|
@ -46,9 +47,14 @@ def _get_manager(request: Request) -> AutomationManager:
|
|||
|
||||
@router.get("/", response_model=list[AutomationConfigResponse])
|
||||
async def list_configs(request: Request) -> list[AutomationConfigResponse]:
|
||||
"""List all automation configurations with their current status."""
|
||||
"""List automation configurations belonging to the current user."""
|
||||
user_chars = await get_user_character_names(request)
|
||||
async with async_session_factory() as db:
|
||||
stmt = select(AutomationConfig).order_by(AutomationConfig.id)
|
||||
stmt = (
|
||||
select(AutomationConfig)
|
||||
.where(AutomationConfig.character_name.in_(user_chars))
|
||||
.order_by(AutomationConfig.id)
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
configs = result.scalars().all()
|
||||
return [AutomationConfigResponse.model_validate(c) for c in configs]
|
||||
|
|
|
|||
|
|
@ -5,8 +5,8 @@ from fastapi import APIRouter, HTTPException, Request
|
|||
from httpx import HTTPStatusError
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.api.deps import get_user_client
|
||||
from app.database import async_session_factory
|
||||
from app.services.artifacts_client import ArtifactsClient
|
||||
from app.services.bank_service import BankService
|
||||
from app.services.game_data_cache import GameDataCacheService
|
||||
|
||||
|
|
@ -15,10 +15,6 @@ logger = logging.getLogger(__name__)
|
|||
router = APIRouter(prefix="/api", tags=["bank"])
|
||||
|
||||
|
||||
def _get_client(request: Request) -> ArtifactsClient:
|
||||
return request.app.state.artifacts_client
|
||||
|
||||
|
||||
def _get_cache_service(request: Request) -> GameDataCacheService:
|
||||
return request.app.state.cache_service
|
||||
|
||||
|
|
@ -33,11 +29,17 @@ class ManualActionRequest(BaseModel):
|
|||
|
||||
action: str = Field(
|
||||
...,
|
||||
description="Action to perform: 'move', 'fight', 'gather', 'rest'",
|
||||
description=(
|
||||
"Action to perform: move, fight, gather, rest, equip, unequip, "
|
||||
"use_item, deposit, withdraw, deposit_gold, withdraw_gold, "
|
||||
"craft, recycle, ge_buy, ge_create_buy, ge_sell, ge_fill, ge_cancel, "
|
||||
"task_new, task_trade, task_complete, task_exchange, task_cancel, "
|
||||
"npc_buy, npc_sell"
|
||||
),
|
||||
)
|
||||
params: dict = Field(
|
||||
default_factory=dict,
|
||||
description="Action parameters (e.g. {x, y} for move)",
|
||||
description="Action parameters (varies per action type)",
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -49,7 +51,7 @@ class ManualActionRequest(BaseModel):
|
|||
@router.get("/bank")
|
||||
async def get_bank(request: Request) -> dict[str, Any]:
|
||||
"""Return bank details with enriched item data from game cache."""
|
||||
client = _get_client(request)
|
||||
client = get_user_client(request)
|
||||
cache_service = _get_cache_service(request)
|
||||
bank_service = BankService()
|
||||
|
||||
|
|
@ -75,7 +77,7 @@ async def get_bank(request: Request) -> dict[str, Any]:
|
|||
@router.get("/bank/summary")
|
||||
async def get_bank_summary(request: Request) -> dict[str, Any]:
|
||||
"""Return a summary of bank contents: gold, item count, slots."""
|
||||
client = _get_client(request)
|
||||
client = get_user_client(request)
|
||||
bank_service = BankService()
|
||||
|
||||
try:
|
||||
|
|
@ -87,6 +89,16 @@ async def get_bank_summary(request: Request) -> dict[str, Any]:
|
|||
) from exc
|
||||
|
||||
|
||||
def _require(params: dict, *keys: str) -> None:
|
||||
"""Raise 400 if any required key is missing from params."""
|
||||
missing = [k for k in keys if params.get(k) is None]
|
||||
if missing:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Missing required params: {', '.join(missing)}",
|
||||
)
|
||||
|
||||
|
||||
@router.post("/characters/{name}/action")
|
||||
async def manual_action(
|
||||
name: str,
|
||||
|
|
@ -95,35 +107,154 @@ async def manual_action(
|
|||
) -> dict[str, Any]:
|
||||
"""Execute a manual action on a character.
|
||||
|
||||
Supported actions:
|
||||
- **move**: Move to coordinates. Params: {"x": int, "y": int}
|
||||
- **fight**: Fight the monster at the current tile. No params.
|
||||
- **gather**: Gather the resource at the current tile. No params.
|
||||
- **rest**: Rest to recover HP. No params.
|
||||
Supported actions and their params:
|
||||
- **move**: {x: int, y: int}
|
||||
- **fight**: no params
|
||||
- **gather**: no params
|
||||
- **rest**: no params
|
||||
- **equip**: {code: str, slot: str, quantity?: int}
|
||||
- **unequip**: {slot: str, quantity?: int}
|
||||
- **use_item**: {code: str, quantity?: int}
|
||||
- **deposit**: {code: str, quantity: int}
|
||||
- **withdraw**: {code: str, quantity: int}
|
||||
- **deposit_gold**: {quantity: int}
|
||||
- **withdraw_gold**: {quantity: int}
|
||||
- **craft**: {code: str, quantity?: int}
|
||||
- **recycle**: {code: str, quantity?: int}
|
||||
- **ge_buy**: {id: str, quantity: int} — buy from an existing sell order
|
||||
- **ge_create_buy**: {code: str, quantity: int, price: int} — create a standing buy order
|
||||
- **ge_sell**: {code: str, quantity: int, price: int} — create a sell order
|
||||
- **ge_fill**: {id: str, quantity: int} — fill an existing buy order
|
||||
- **ge_cancel**: {order_id: str}
|
||||
- **task_new**: no params
|
||||
- **task_trade**: {code: str, quantity: int}
|
||||
- **task_complete**: no params
|
||||
- **task_exchange**: no params
|
||||
- **task_cancel**: no params
|
||||
- **npc_buy**: {code: str, quantity: int}
|
||||
- **npc_sell**: {code: str, quantity: int}
|
||||
"""
|
||||
client = _get_client(request)
|
||||
client = get_user_client(request)
|
||||
p = body.params
|
||||
|
||||
try:
|
||||
match body.action:
|
||||
# --- Basic actions ---
|
||||
case "move":
|
||||
x = body.params.get("x")
|
||||
y = body.params.get("y")
|
||||
if x is None or y is None:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Move action requires 'x' and 'y' in params",
|
||||
)
|
||||
result = await client.move(name, int(x), int(y))
|
||||
_require(p, "x", "y")
|
||||
result = await client.move(name, int(p["x"]), int(p["y"]))
|
||||
case "fight":
|
||||
result = await client.fight(name)
|
||||
case "gather":
|
||||
result = await client.gather(name)
|
||||
case "rest":
|
||||
result = await client.rest(name)
|
||||
|
||||
# --- Equipment ---
|
||||
case "equip":
|
||||
_require(p, "code", "slot")
|
||||
result = await client.equip(
|
||||
name, p["code"], p["slot"], int(p.get("quantity", 1))
|
||||
)
|
||||
case "unequip":
|
||||
_require(p, "slot")
|
||||
result = await client.unequip(
|
||||
name, p["slot"], int(p.get("quantity", 1))
|
||||
)
|
||||
|
||||
# --- Consumables ---
|
||||
case "use_item":
|
||||
_require(p, "code")
|
||||
result = await client.use_item(
|
||||
name, p["code"], int(p.get("quantity", 1))
|
||||
)
|
||||
|
||||
# --- Bank ---
|
||||
case "deposit":
|
||||
_require(p, "code", "quantity")
|
||||
result = await client.deposit_item(
|
||||
name, p["code"], int(p["quantity"])
|
||||
)
|
||||
case "withdraw":
|
||||
_require(p, "code", "quantity")
|
||||
result = await client.withdraw_item(
|
||||
name, p["code"], int(p["quantity"])
|
||||
)
|
||||
case "deposit_gold":
|
||||
_require(p, "quantity")
|
||||
result = await client.deposit_gold(name, int(p["quantity"]))
|
||||
case "withdraw_gold":
|
||||
_require(p, "quantity")
|
||||
result = await client.withdraw_gold(name, int(p["quantity"]))
|
||||
|
||||
# --- Crafting ---
|
||||
case "craft":
|
||||
_require(p, "code")
|
||||
result = await client.craft(
|
||||
name, p["code"], int(p.get("quantity", 1))
|
||||
)
|
||||
case "recycle":
|
||||
_require(p, "code")
|
||||
result = await client.recycle(
|
||||
name, p["code"], int(p.get("quantity", 1))
|
||||
)
|
||||
|
||||
# --- Grand Exchange ---
|
||||
case "ge_buy":
|
||||
_require(p, "id", "quantity")
|
||||
result = await client.ge_buy(
|
||||
name, str(p["id"]), int(p["quantity"])
|
||||
)
|
||||
case "ge_create_buy":
|
||||
_require(p, "code", "quantity", "price")
|
||||
result = await client.ge_create_buy_order(
|
||||
name, p["code"], int(p["quantity"]), int(p["price"])
|
||||
)
|
||||
case "ge_sell":
|
||||
_require(p, "code", "quantity", "price")
|
||||
result = await client.ge_sell_order(
|
||||
name, p["code"], int(p["quantity"]), int(p["price"])
|
||||
)
|
||||
case "ge_fill":
|
||||
_require(p, "id", "quantity")
|
||||
result = await client.ge_fill_buy_order(
|
||||
name, str(p["id"]), int(p["quantity"])
|
||||
)
|
||||
case "ge_cancel":
|
||||
_require(p, "order_id")
|
||||
result = await client.ge_cancel(name, p["order_id"])
|
||||
|
||||
# --- Tasks ---
|
||||
case "task_new":
|
||||
result = await client.task_new(name)
|
||||
case "task_trade":
|
||||
_require(p, "code", "quantity")
|
||||
result = await client.task_trade(
|
||||
name, p["code"], int(p["quantity"])
|
||||
)
|
||||
case "task_complete":
|
||||
result = await client.task_complete(name)
|
||||
case "task_exchange":
|
||||
result = await client.task_exchange(name)
|
||||
case "task_cancel":
|
||||
result = await client.task_cancel(name)
|
||||
|
||||
# --- NPC ---
|
||||
case "npc_buy":
|
||||
_require(p, "code", "quantity")
|
||||
result = await client.npc_buy(
|
||||
name, p["code"], int(p["quantity"])
|
||||
)
|
||||
case "npc_sell":
|
||||
_require(p, "code", "quantity")
|
||||
result = await client.npc_sell(
|
||||
name, p["code"], int(p["quantity"])
|
||||
)
|
||||
|
||||
case _:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Unknown action: {body.action!r}. Supported: move, fight, gather, rest",
|
||||
detail=f"Unknown action: {body.action!r}",
|
||||
)
|
||||
except HTTPStatusError as exc:
|
||||
raise HTTPException(
|
||||
|
|
|
|||
|
|
@ -1,17 +1,13 @@
|
|||
from fastapi import APIRouter, HTTPException, Request
|
||||
from httpx import HTTPStatusError
|
||||
|
||||
from app.api.deps import get_user_client
|
||||
from app.schemas.game import CharacterSchema
|
||||
from app.services.artifacts_client import ArtifactsClient
|
||||
from app.services.character_service import CharacterService
|
||||
|
||||
router = APIRouter(prefix="/api/characters", tags=["characters"])
|
||||
|
||||
|
||||
def _get_client(request: Request) -> ArtifactsClient:
|
||||
return request.app.state.artifacts_client
|
||||
|
||||
|
||||
def _get_service(request: Request) -> CharacterService:
|
||||
return request.app.state.character_service
|
||||
|
||||
|
|
@ -19,7 +15,7 @@ def _get_service(request: Request) -> CharacterService:
|
|||
@router.get("/", response_model=list[CharacterSchema])
|
||||
async def list_characters(request: Request) -> list[CharacterSchema]:
|
||||
"""Return all characters belonging to the authenticated account."""
|
||||
client = _get_client(request)
|
||||
client = get_user_client(request)
|
||||
service = _get_service(request)
|
||||
try:
|
||||
return await service.get_all(client)
|
||||
|
|
@ -33,7 +29,7 @@ async def list_characters(request: Request) -> list[CharacterSchema]:
|
|||
@router.get("/{name}", response_model=CharacterSchema)
|
||||
async def get_character(name: str, request: Request) -> CharacterSchema:
|
||||
"""Return a single character by name."""
|
||||
client = _get_client(request)
|
||||
client = get_user_client(request)
|
||||
service = _get_service(request)
|
||||
try:
|
||||
return await service.get_one(client, name)
|
||||
|
|
|
|||
|
|
@ -3,8 +3,8 @@ import logging
|
|||
from fastapi import APIRouter, HTTPException, Request
|
||||
from httpx import HTTPStatusError
|
||||
|
||||
from app.api.deps import get_user_client
|
||||
from app.schemas.game import DashboardData
|
||||
from app.services.artifacts_client import ArtifactsClient
|
||||
from app.services.character_service import CharacterService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
|
@ -12,10 +12,6 @@ logger = logging.getLogger(__name__)
|
|||
router = APIRouter(prefix="/api", tags=["dashboard"])
|
||||
|
||||
|
||||
def _get_client(request: Request) -> ArtifactsClient:
|
||||
return request.app.state.artifacts_client
|
||||
|
||||
|
||||
def _get_service(request: Request) -> CharacterService:
|
||||
return request.app.state.character_service
|
||||
|
||||
|
|
@ -23,7 +19,7 @@ def _get_service(request: Request) -> CharacterService:
|
|||
@router.get("/dashboard", response_model=DashboardData)
|
||||
async def get_dashboard(request: Request) -> DashboardData:
|
||||
"""Return aggregated dashboard data: all characters + server status."""
|
||||
client = _get_client(request)
|
||||
client = get_user_client(request)
|
||||
service = _get_service(request)
|
||||
|
||||
try:
|
||||
|
|
|
|||
47
backend/app/api/deps.py
Normal file
47
backend/app/api/deps.py
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
"""Shared FastAPI dependencies for API endpoints."""
|
||||
|
||||
import hashlib
|
||||
|
||||
from fastapi import HTTPException, Request
|
||||
|
||||
from app.services.artifacts_client import ArtifactsClient
|
||||
|
||||
|
||||
def get_user_client(request: Request) -> ArtifactsClient:
|
||||
"""Return an ArtifactsClient scoped to the requesting user's token.
|
||||
|
||||
Reads the ``X-API-Token`` header sent by the frontend and creates a
|
||||
lightweight clone of the global client that uses that token. Falls
|
||||
back to the global client when no per-request token is provided (e.g.
|
||||
for public / unauthenticated endpoints).
|
||||
"""
|
||||
token = request.headers.get("X-API-Token")
|
||||
base_client: ArtifactsClient = request.app.state.artifacts_client
|
||||
|
||||
if token:
|
||||
return base_client.with_token(token)
|
||||
|
||||
# No per-request token — use the global client if it has a token
|
||||
if base_client.has_token:
|
||||
return base_client
|
||||
|
||||
raise HTTPException(status_code=401, detail="No API token provided")
|
||||
|
||||
|
||||
async def get_user_character_names(request: Request) -> list[str]:
|
||||
"""Return the character names belonging to the requesting user.
|
||||
|
||||
Calls the Artifacts API with the user's token to get their characters,
|
||||
then returns just the names. Used to scope DB queries to a single user.
|
||||
"""
|
||||
client = get_user_client(request)
|
||||
characters = await client.get_characters()
|
||||
return [c.name for c in characters]
|
||||
|
||||
|
||||
def get_token_hash(request: Request) -> str | None:
|
||||
"""Return a SHA-256 hash of the user's API token, or None."""
|
||||
token = request.headers.get("X-API-Token")
|
||||
if token:
|
||||
return hashlib.sha256(token.encode()).hexdigest()
|
||||
return None
|
||||
189
backend/app/api/errors.py
Normal file
189
backend/app/api/errors.py
Normal file
|
|
@ -0,0 +1,189 @@
|
|||
"""Errors API router - browse, filter, resolve, and report errors.
|
||||
|
||||
All read endpoints are scoped to the requesting user's token so that
|
||||
one user never sees errors belonging to another user.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query, Request
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from app.database import async_session_factory
|
||||
from app.models.app_error import AppError
|
||||
from app.schemas.errors import (
|
||||
AppErrorListResponse,
|
||||
AppErrorResponse,
|
||||
AppErrorStats,
|
||||
FrontendErrorReport,
|
||||
)
|
||||
from app.services.error_service import hash_token, log_error
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/errors", tags=["errors"])
|
||||
|
||||
|
||||
def _get_user_hash(request: Request) -> str | None:
|
||||
"""Extract and hash the user token from the request."""
|
||||
token = request.headers.get("X-API-Token")
|
||||
return hash_token(token) if token else None
|
||||
|
||||
|
||||
@router.get("/", response_model=AppErrorListResponse)
|
||||
async def list_errors(
|
||||
request: Request,
|
||||
severity: str = Query(default="", description="Filter by severity"),
|
||||
source: str = Query(default="", description="Filter by source"),
|
||||
resolved: str = Query(default="", description="Filter by resolved status: true/false"),
|
||||
page: int = Query(default=1, ge=1),
|
||||
size: int = Query(default=50, ge=1, le=100),
|
||||
) -> AppErrorListResponse:
|
||||
"""List errors with optional filtering and pagination.
|
||||
|
||||
Only returns errors belonging to the authenticated user.
|
||||
"""
|
||||
user_hash = _get_user_hash(request)
|
||||
if not user_hash:
|
||||
return AppErrorListResponse(errors=[], total=0, page=1, pages=1)
|
||||
|
||||
async with async_session_factory() as db:
|
||||
user_filter = AppError.user_token_hash == user_hash
|
||||
stmt = select(AppError).where(user_filter).order_by(AppError.created_at.desc())
|
||||
count_stmt = select(func.count(AppError.id)).where(user_filter)
|
||||
|
||||
if severity:
|
||||
stmt = stmt.where(AppError.severity == severity)
|
||||
count_stmt = count_stmt.where(AppError.severity == severity)
|
||||
if source:
|
||||
stmt = stmt.where(AppError.source == source)
|
||||
count_stmt = count_stmt.where(AppError.source == source)
|
||||
if resolved in ("true", "false"):
|
||||
val = resolved == "true"
|
||||
stmt = stmt.where(AppError.resolved == val)
|
||||
count_stmt = count_stmt.where(AppError.resolved == val)
|
||||
|
||||
total = (await db.execute(count_stmt)).scalar() or 0
|
||||
pages = max(1, (total + size - 1) // size)
|
||||
|
||||
offset = (page - 1) * size
|
||||
stmt = stmt.offset(offset).limit(size)
|
||||
result = await db.execute(stmt)
|
||||
rows = result.scalars().all()
|
||||
|
||||
return AppErrorListResponse(
|
||||
errors=[AppErrorResponse.model_validate(r) for r in rows],
|
||||
total=total,
|
||||
page=page,
|
||||
pages=pages,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/stats", response_model=AppErrorStats)
|
||||
async def error_stats(request: Request) -> AppErrorStats:
|
||||
"""Aggregated error statistics scoped to the authenticated user."""
|
||||
user_hash = _get_user_hash(request)
|
||||
if not user_hash:
|
||||
return AppErrorStats()
|
||||
|
||||
async with async_session_factory() as db:
|
||||
user_filter = AppError.user_token_hash == user_hash
|
||||
|
||||
total = (
|
||||
await db.execute(select(func.count(AppError.id)).where(user_filter))
|
||||
).scalar() or 0
|
||||
unresolved = (
|
||||
await db.execute(
|
||||
select(func.count(AppError.id)).where(
|
||||
user_filter, AppError.resolved == False # noqa: E712
|
||||
)
|
||||
)
|
||||
).scalar() or 0
|
||||
|
||||
one_hour_ago = datetime.now(timezone.utc) - timedelta(hours=1)
|
||||
last_hour = (
|
||||
await db.execute(
|
||||
select(func.count(AppError.id)).where(
|
||||
user_filter, AppError.created_at >= one_hour_ago
|
||||
)
|
||||
)
|
||||
).scalar() or 0
|
||||
|
||||
# By severity
|
||||
sev_rows = (
|
||||
await db.execute(
|
||||
select(AppError.severity, func.count(AppError.id))
|
||||
.where(user_filter)
|
||||
.group_by(AppError.severity)
|
||||
)
|
||||
).all()
|
||||
by_severity = {row[0]: row[1] for row in sev_rows}
|
||||
|
||||
# By source
|
||||
src_rows = (
|
||||
await db.execute(
|
||||
select(AppError.source, func.count(AppError.id))
|
||||
.where(user_filter)
|
||||
.group_by(AppError.source)
|
||||
)
|
||||
).all()
|
||||
by_source = {row[0]: row[1] for row in src_rows}
|
||||
|
||||
return AppErrorStats(
|
||||
total=total,
|
||||
unresolved=unresolved,
|
||||
last_hour=last_hour,
|
||||
by_severity=by_severity,
|
||||
by_source=by_source,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{error_id}/resolve", response_model=AppErrorResponse)
|
||||
async def resolve_error(error_id: int, request: Request) -> AppErrorResponse:
|
||||
"""Mark an error as resolved (only if it belongs to the requesting user)."""
|
||||
user_hash = _get_user_hash(request)
|
||||
|
||||
async with async_session_factory() as db:
|
||||
stmt = select(AppError).where(AppError.id == error_id)
|
||||
if user_hash:
|
||||
stmt = stmt.where(AppError.user_token_hash == user_hash)
|
||||
result = await db.execute(stmt)
|
||||
record = result.scalar_one_or_none()
|
||||
if record is None:
|
||||
raise HTTPException(status_code=404, detail="Error not found")
|
||||
record.resolved = True
|
||||
await db.commit()
|
||||
await db.refresh(record)
|
||||
return AppErrorResponse.model_validate(record)
|
||||
|
||||
|
||||
@router.post("/report", status_code=201)
|
||||
async def report_frontend_error(
|
||||
body: FrontendErrorReport, request: Request
|
||||
) -> dict[str, str]:
|
||||
"""Receive error reports from the frontend."""
|
||||
user_hash = _get_user_hash(request)
|
||||
|
||||
await log_error(
|
||||
async_session_factory,
|
||||
severity=body.severity,
|
||||
source="frontend",
|
||||
error_type=body.error_type,
|
||||
message=body.message,
|
||||
context=body.context,
|
||||
user_token_hash=user_hash,
|
||||
)
|
||||
|
||||
# Also capture in Sentry if available
|
||||
try:
|
||||
import sentry_sdk
|
||||
|
||||
sentry_sdk.capture_message(
|
||||
f"[Frontend] {body.error_type}: {body.message}",
|
||||
level="error",
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return {"status": "recorded"}
|
||||
|
|
@ -7,23 +7,19 @@ from fastapi import APIRouter, HTTPException, Query, Request
|
|||
from httpx import HTTPStatusError
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.api.deps import get_user_client
|
||||
from app.database import async_session_factory
|
||||
from app.models.event_log import EventLog
|
||||
from app.services.artifacts_client import ArtifactsClient
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/events", tags=["events"])
|
||||
|
||||
|
||||
def _get_client(request: Request) -> ArtifactsClient:
|
||||
return request.app.state.artifacts_client
|
||||
|
||||
|
||||
@router.get("/")
|
||||
async def get_active_events(request: Request) -> dict[str, Any]:
|
||||
"""Get currently active game events from the Artifacts API."""
|
||||
client = _get_client(request)
|
||||
client = get_user_client(request)
|
||||
|
||||
try:
|
||||
events = await client.get_events()
|
||||
|
|
|
|||
|
|
@ -6,8 +6,8 @@ from typing import Any
|
|||
from fastapi import APIRouter, HTTPException, Query, Request
|
||||
from httpx import HTTPStatusError
|
||||
|
||||
from app.api.deps import get_user_client
|
||||
from app.database import async_session_factory
|
||||
from app.services.artifacts_client import ArtifactsClient
|
||||
from app.services.exchange_service import ExchangeService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
|
@ -15,10 +15,6 @@ logger = logging.getLogger(__name__)
|
|||
router = APIRouter(prefix="/api/exchange", tags=["exchange"])
|
||||
|
||||
|
||||
def _get_client(request: Request) -> ArtifactsClient:
|
||||
return request.app.state.artifacts_client
|
||||
|
||||
|
||||
def _get_exchange_service(request: Request) -> ExchangeService:
|
||||
service: ExchangeService | None = getattr(request.app.state, "exchange_service", None)
|
||||
if service is None:
|
||||
|
|
@ -36,7 +32,7 @@ async def browse_orders(
|
|||
type: str | None = Query(default=None, description="Filter by order type (sell or buy)"),
|
||||
) -> dict[str, Any]:
|
||||
"""Browse all active Grand Exchange orders (public market data)."""
|
||||
client = _get_client(request)
|
||||
client = get_user_client(request)
|
||||
service = _get_exchange_service(request)
|
||||
|
||||
try:
|
||||
|
|
@ -53,7 +49,7 @@ async def browse_orders(
|
|||
@router.get("/my-orders")
|
||||
async def get_my_orders(request: Request) -> dict[str, Any]:
|
||||
"""Get the authenticated account's own active GE orders."""
|
||||
client = _get_client(request)
|
||||
client = get_user_client(request)
|
||||
service = _get_exchange_service(request)
|
||||
|
||||
try:
|
||||
|
|
@ -70,7 +66,7 @@ async def get_my_orders(request: Request) -> dict[str, Any]:
|
|||
@router.get("/history")
|
||||
async def get_history(request: Request) -> dict[str, Any]:
|
||||
"""Get the authenticated account's GE transaction history."""
|
||||
client = _get_client(request)
|
||||
client = get_user_client(request)
|
||||
service = _get_exchange_service(request)
|
||||
|
||||
try:
|
||||
|
|
@ -87,7 +83,7 @@ async def get_history(request: Request) -> dict[str, Any]:
|
|||
@router.get("/sell-history/{item_code}")
|
||||
async def get_sell_history(item_code: str, request: Request) -> dict[str, Any]:
|
||||
"""Get public sale history for a specific item (last 7 days from API)."""
|
||||
client = _get_client(request)
|
||||
client = get_user_client(request)
|
||||
service = _get_exchange_service(request)
|
||||
|
||||
try:
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ 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
|
||||
|
|
@ -17,14 +18,112 @@ 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"),
|
||||
limit: int = Query(default=50, ge=1, le=200, description="Max entries to return"),
|
||||
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 automation action logs from the database.
|
||||
"""Get action logs from the Artifacts game API.
|
||||
|
||||
Joins automation_logs -> automation_runs -> automation_configs
|
||||
to include character_name with each log entry.
|
||||
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(
|
||||
|
|
@ -38,12 +137,20 @@ async def get_logs(
|
|||
.join(AutomationRun, AutomationLog.run_id == AutomationRun.id)
|
||||
.join(AutomationConfig, AutomationRun.config_id == AutomationConfig.id)
|
||||
.order_by(AutomationLog.created_at.desc())
|
||||
.limit(limit)
|
||||
)
|
||||
|
||||
# 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()
|
||||
|
||||
|
|
@ -59,6 +166,9 @@ async def get_logs(
|
|||
}
|
||||
for row in rows
|
||||
],
|
||||
"total": len(rows),
|
||||
"page": page,
|
||||
"pages": 1,
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -71,15 +181,21 @@ async def get_analytics(
|
|||
"""Get analytics aggregations for a character.
|
||||
|
||||
Returns XP history, gold history, and estimated actions per hour.
|
||||
If no character is specified, aggregates across all characters with snapshots.
|
||||
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:
|
||||
characters = await analytics.get_tracked_characters(db)
|
||||
# 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]] = []
|
||||
|
|
|
|||
267
backend/app/api/pipelines.py
Normal file
267
backend/app/api/pipelines.py
Normal file
|
|
@ -0,0 +1,267 @@
|
|||
import logging
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.api.deps import get_user_character_names
|
||||
from app.database import async_session_factory
|
||||
from app.engine.manager import AutomationManager
|
||||
from app.models.automation import AutomationLog
|
||||
from app.models.pipeline import PipelineConfig, PipelineRun
|
||||
from app.schemas.automation import AutomationLogResponse
|
||||
from app.schemas.pipeline import (
|
||||
PipelineConfigCreate,
|
||||
PipelineConfigDetailResponse,
|
||||
PipelineConfigResponse,
|
||||
PipelineConfigUpdate,
|
||||
PipelineRunResponse,
|
||||
PipelineStatusResponse,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/pipelines", tags=["pipelines"])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _get_manager(request: Request) -> AutomationManager:
|
||||
manager: AutomationManager | None = getattr(request.app.state, "automation_manager", None)
|
||||
if manager is None:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Automation engine is not available",
|
||||
)
|
||||
return manager
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CRUD -- Pipeline Configs
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _pipeline_belongs_to_user(pipeline: PipelineConfig, user_chars: set[str]) -> bool:
|
||||
"""Check if any character in the pipeline stages belongs to the user."""
|
||||
for stage in (pipeline.stages or []):
|
||||
for step in (stage.get("character_steps") or []):
|
||||
if step.get("character_name") in user_chars:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
@router.get("/", response_model=list[PipelineConfigResponse])
|
||||
async def list_pipelines(request: Request) -> list[PipelineConfigResponse]:
|
||||
"""List pipeline configurations belonging to the current user."""
|
||||
user_chars = set(await get_user_character_names(request))
|
||||
async with async_session_factory() as db:
|
||||
stmt = select(PipelineConfig).order_by(PipelineConfig.id)
|
||||
result = await db.execute(stmt)
|
||||
configs = result.scalars().all()
|
||||
return [
|
||||
PipelineConfigResponse.model_validate(c)
|
||||
for c in configs
|
||||
if _pipeline_belongs_to_user(c, user_chars)
|
||||
]
|
||||
|
||||
|
||||
@router.post("/", response_model=PipelineConfigResponse, status_code=201)
|
||||
async def create_pipeline(
|
||||
payload: PipelineConfigCreate,
|
||||
request: Request,
|
||||
) -> PipelineConfigResponse:
|
||||
"""Create a new pipeline configuration."""
|
||||
async with async_session_factory() as db:
|
||||
config = PipelineConfig(
|
||||
name=payload.name,
|
||||
description=payload.description,
|
||||
stages=[stage.model_dump() for stage in payload.stages],
|
||||
loop=payload.loop,
|
||||
max_loops=payload.max_loops,
|
||||
)
|
||||
db.add(config)
|
||||
await db.commit()
|
||||
await db.refresh(config)
|
||||
return PipelineConfigResponse.model_validate(config)
|
||||
|
||||
|
||||
@router.get("/status/all", response_model=list[PipelineStatusResponse])
|
||||
async def get_all_pipeline_statuses(request: Request) -> list[PipelineStatusResponse]:
|
||||
"""Get live status for all active pipelines."""
|
||||
manager = _get_manager(request)
|
||||
return manager.get_all_pipeline_statuses()
|
||||
|
||||
|
||||
@router.get("/{pipeline_id}", response_model=PipelineConfigDetailResponse)
|
||||
async def get_pipeline(pipeline_id: int, request: Request) -> PipelineConfigDetailResponse:
|
||||
"""Get a pipeline configuration with its run history."""
|
||||
async with async_session_factory() as db:
|
||||
stmt = (
|
||||
select(PipelineConfig)
|
||||
.options(selectinload(PipelineConfig.runs))
|
||||
.where(PipelineConfig.id == pipeline_id)
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
config = result.scalar_one_or_none()
|
||||
|
||||
if config is None:
|
||||
raise HTTPException(status_code=404, detail="Pipeline config not found")
|
||||
|
||||
return PipelineConfigDetailResponse(
|
||||
config=PipelineConfigResponse.model_validate(config),
|
||||
runs=[PipelineRunResponse.model_validate(r) for r in config.runs],
|
||||
)
|
||||
|
||||
|
||||
@router.put("/{pipeline_id}", response_model=PipelineConfigResponse)
|
||||
async def update_pipeline(
|
||||
pipeline_id: int,
|
||||
payload: PipelineConfigUpdate,
|
||||
request: Request,
|
||||
) -> PipelineConfigResponse:
|
||||
"""Update a pipeline configuration. Cannot update while running."""
|
||||
manager = _get_manager(request)
|
||||
if manager.is_pipeline_running(pipeline_id):
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="Cannot update a pipeline while it is running. Stop it first.",
|
||||
)
|
||||
|
||||
async with async_session_factory() as db:
|
||||
config = await db.get(PipelineConfig, pipeline_id)
|
||||
if config is None:
|
||||
raise HTTPException(status_code=404, detail="Pipeline config not found")
|
||||
|
||||
if payload.name is not None:
|
||||
config.name = payload.name
|
||||
if payload.description is not None:
|
||||
config.description = payload.description
|
||||
if payload.stages is not None:
|
||||
config.stages = [stage.model_dump() for stage in payload.stages]
|
||||
if payload.loop is not None:
|
||||
config.loop = payload.loop
|
||||
if payload.max_loops is not None:
|
||||
config.max_loops = payload.max_loops
|
||||
if payload.enabled is not None:
|
||||
config.enabled = payload.enabled
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(config)
|
||||
return PipelineConfigResponse.model_validate(config)
|
||||
|
||||
|
||||
@router.delete("/{pipeline_id}", status_code=204)
|
||||
async def delete_pipeline(pipeline_id: int, request: Request) -> None:
|
||||
"""Delete a pipeline configuration. Cannot delete while running."""
|
||||
manager = _get_manager(request)
|
||||
if manager.is_pipeline_running(pipeline_id):
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="Cannot delete a pipeline while it is running. Stop it first.",
|
||||
)
|
||||
|
||||
async with async_session_factory() as db:
|
||||
config = await db.get(PipelineConfig, pipeline_id)
|
||||
if config is None:
|
||||
raise HTTPException(status_code=404, detail="Pipeline config not found")
|
||||
await db.delete(config)
|
||||
await db.commit()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Control -- Start / Stop / Pause / Resume
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.post("/{pipeline_id}/start", response_model=PipelineRunResponse)
|
||||
async def start_pipeline(pipeline_id: int, request: Request) -> PipelineRunResponse:
|
||||
"""Start a pipeline from its configuration."""
|
||||
manager = _get_manager(request)
|
||||
try:
|
||||
return await manager.start_pipeline(pipeline_id)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@router.post("/{pipeline_id}/stop", status_code=204)
|
||||
async def stop_pipeline(pipeline_id: int, request: Request) -> None:
|
||||
"""Stop a running pipeline."""
|
||||
manager = _get_manager(request)
|
||||
try:
|
||||
await manager.stop_pipeline(pipeline_id)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@router.post("/{pipeline_id}/pause", status_code=204)
|
||||
async def pause_pipeline(pipeline_id: int, request: Request) -> None:
|
||||
"""Pause a running pipeline."""
|
||||
manager = _get_manager(request)
|
||||
try:
|
||||
await manager.pause_pipeline(pipeline_id)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@router.post("/{pipeline_id}/resume", status_code=204)
|
||||
async def resume_pipeline(pipeline_id: int, request: Request) -> None:
|
||||
"""Resume a paused pipeline."""
|
||||
manager = _get_manager(request)
|
||||
try:
|
||||
await manager.resume_pipeline(pipeline_id)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Status & Logs
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get("/{pipeline_id}/status", response_model=PipelineStatusResponse)
|
||||
async def get_pipeline_status(
|
||||
pipeline_id: int,
|
||||
request: Request,
|
||||
) -> PipelineStatusResponse:
|
||||
"""Get live status for a specific pipeline."""
|
||||
manager = _get_manager(request)
|
||||
status = manager.get_pipeline_status(pipeline_id)
|
||||
if status is None:
|
||||
async with async_session_factory() as db:
|
||||
config = await db.get(PipelineConfig, pipeline_id)
|
||||
if config is None:
|
||||
raise HTTPException(status_code=404, detail="Pipeline config not found")
|
||||
return PipelineStatusResponse(
|
||||
pipeline_id=pipeline_id,
|
||||
status="stopped",
|
||||
total_stages=len(config.stages),
|
||||
)
|
||||
return status
|
||||
|
||||
|
||||
@router.get("/{pipeline_id}/logs", response_model=list[AutomationLogResponse])
|
||||
async def get_pipeline_logs(
|
||||
pipeline_id: int,
|
||||
request: Request,
|
||||
limit: int = 100,
|
||||
) -> list[AutomationLogResponse]:
|
||||
"""Get recent logs for a pipeline (across all its runs)."""
|
||||
async with async_session_factory() as db:
|
||||
config = await db.get(PipelineConfig, pipeline_id)
|
||||
if config is None:
|
||||
raise HTTPException(status_code=404, detail="Pipeline config not found")
|
||||
|
||||
stmt = (
|
||||
select(AutomationLog)
|
||||
.join(PipelineRun, AutomationLog.run_id == PipelineRun.id)
|
||||
.where(PipelineRun.pipeline_id == pipeline_id)
|
||||
.order_by(AutomationLog.created_at.desc())
|
||||
.limit(min(limit, 500))
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
logs = result.scalars().all()
|
||||
return [AutomationLogResponse.model_validate(log) for log in logs]
|
||||
261
backend/app/api/workflows.py
Normal file
261
backend/app/api/workflows.py
Normal file
|
|
@ -0,0 +1,261 @@
|
|||
import logging
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.api.deps import get_user_character_names
|
||||
from app.database import async_session_factory
|
||||
from app.engine.manager import AutomationManager
|
||||
from app.models.automation import AutomationLog
|
||||
from app.models.workflow import WorkflowConfig, WorkflowRun
|
||||
from app.schemas.automation import AutomationLogResponse
|
||||
from app.schemas.workflow import (
|
||||
WorkflowConfigCreate,
|
||||
WorkflowConfigDetailResponse,
|
||||
WorkflowConfigResponse,
|
||||
WorkflowConfigUpdate,
|
||||
WorkflowRunResponse,
|
||||
WorkflowStatusResponse,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/workflows", tags=["workflows"])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _get_manager(request: Request) -> AutomationManager:
|
||||
manager: AutomationManager | None = getattr(request.app.state, "automation_manager", None)
|
||||
if manager is None:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Automation engine is not available",
|
||||
)
|
||||
return manager
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CRUD -- Workflow Configs
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get("/", response_model=list[WorkflowConfigResponse])
|
||||
async def list_workflows(request: Request) -> list[WorkflowConfigResponse]:
|
||||
"""List workflow configurations belonging to the current user."""
|
||||
user_chars = await get_user_character_names(request)
|
||||
async with async_session_factory() as db:
|
||||
stmt = (
|
||||
select(WorkflowConfig)
|
||||
.where(WorkflowConfig.character_name.in_(user_chars))
|
||||
.order_by(WorkflowConfig.id)
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
configs = result.scalars().all()
|
||||
return [WorkflowConfigResponse.model_validate(c) for c in configs]
|
||||
|
||||
|
||||
@router.post("/", response_model=WorkflowConfigResponse, status_code=201)
|
||||
async def create_workflow(
|
||||
payload: WorkflowConfigCreate,
|
||||
request: Request,
|
||||
) -> WorkflowConfigResponse:
|
||||
"""Create a new workflow configuration."""
|
||||
async with async_session_factory() as db:
|
||||
config = WorkflowConfig(
|
||||
name=payload.name,
|
||||
character_name=payload.character_name,
|
||||
description=payload.description,
|
||||
steps=[step.model_dump() for step in payload.steps],
|
||||
loop=payload.loop,
|
||||
max_loops=payload.max_loops,
|
||||
)
|
||||
db.add(config)
|
||||
await db.commit()
|
||||
await db.refresh(config)
|
||||
return WorkflowConfigResponse.model_validate(config)
|
||||
|
||||
|
||||
@router.get("/status/all", response_model=list[WorkflowStatusResponse])
|
||||
async def get_all_workflow_statuses(request: Request) -> list[WorkflowStatusResponse]:
|
||||
"""Get live status for all active workflows."""
|
||||
manager = _get_manager(request)
|
||||
return manager.get_all_workflow_statuses()
|
||||
|
||||
|
||||
@router.get("/{workflow_id}", response_model=WorkflowConfigDetailResponse)
|
||||
async def get_workflow(workflow_id: int, request: Request) -> WorkflowConfigDetailResponse:
|
||||
"""Get a workflow configuration with its run history."""
|
||||
async with async_session_factory() as db:
|
||||
stmt = (
|
||||
select(WorkflowConfig)
|
||||
.options(selectinload(WorkflowConfig.runs))
|
||||
.where(WorkflowConfig.id == workflow_id)
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
config = result.scalar_one_or_none()
|
||||
|
||||
if config is None:
|
||||
raise HTTPException(status_code=404, detail="Workflow config not found")
|
||||
|
||||
return WorkflowConfigDetailResponse(
|
||||
config=WorkflowConfigResponse.model_validate(config),
|
||||
runs=[WorkflowRunResponse.model_validate(r) for r in config.runs],
|
||||
)
|
||||
|
||||
|
||||
@router.put("/{workflow_id}", response_model=WorkflowConfigResponse)
|
||||
async def update_workflow(
|
||||
workflow_id: int,
|
||||
payload: WorkflowConfigUpdate,
|
||||
request: Request,
|
||||
) -> WorkflowConfigResponse:
|
||||
"""Update a workflow configuration. Cannot update while running."""
|
||||
manager = _get_manager(request)
|
||||
if manager.is_workflow_running(workflow_id):
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="Cannot update a workflow while it is running. Stop it first.",
|
||||
)
|
||||
|
||||
async with async_session_factory() as db:
|
||||
config = await db.get(WorkflowConfig, workflow_id)
|
||||
if config is None:
|
||||
raise HTTPException(status_code=404, detail="Workflow config not found")
|
||||
|
||||
if payload.name is not None:
|
||||
config.name = payload.name
|
||||
if payload.description is not None:
|
||||
config.description = payload.description
|
||||
if payload.steps is not None:
|
||||
config.steps = [step.model_dump() for step in payload.steps]
|
||||
if payload.loop is not None:
|
||||
config.loop = payload.loop
|
||||
if payload.max_loops is not None:
|
||||
config.max_loops = payload.max_loops
|
||||
if payload.enabled is not None:
|
||||
config.enabled = payload.enabled
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(config)
|
||||
return WorkflowConfigResponse.model_validate(config)
|
||||
|
||||
|
||||
@router.delete("/{workflow_id}", status_code=204)
|
||||
async def delete_workflow(workflow_id: int, request: Request) -> None:
|
||||
"""Delete a workflow configuration. Cannot delete while running."""
|
||||
manager = _get_manager(request)
|
||||
if manager.is_workflow_running(workflow_id):
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="Cannot delete a workflow while it is running. Stop it first.",
|
||||
)
|
||||
|
||||
async with async_session_factory() as db:
|
||||
config = await db.get(WorkflowConfig, workflow_id)
|
||||
if config is None:
|
||||
raise HTTPException(status_code=404, detail="Workflow config not found")
|
||||
await db.delete(config)
|
||||
await db.commit()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Control -- Start / Stop / Pause / Resume
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.post("/{workflow_id}/start", response_model=WorkflowRunResponse)
|
||||
async def start_workflow(workflow_id: int, request: Request) -> WorkflowRunResponse:
|
||||
"""Start a workflow from its configuration."""
|
||||
manager = _get_manager(request)
|
||||
try:
|
||||
return await manager.start_workflow(workflow_id)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@router.post("/{workflow_id}/stop", status_code=204)
|
||||
async def stop_workflow(workflow_id: int, request: Request) -> None:
|
||||
"""Stop a running workflow."""
|
||||
manager = _get_manager(request)
|
||||
try:
|
||||
await manager.stop_workflow(workflow_id)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@router.post("/{workflow_id}/pause", status_code=204)
|
||||
async def pause_workflow(workflow_id: int, request: Request) -> None:
|
||||
"""Pause a running workflow."""
|
||||
manager = _get_manager(request)
|
||||
try:
|
||||
await manager.pause_workflow(workflow_id)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@router.post("/{workflow_id}/resume", status_code=204)
|
||||
async def resume_workflow(workflow_id: int, request: Request) -> None:
|
||||
"""Resume a paused workflow."""
|
||||
manager = _get_manager(request)
|
||||
try:
|
||||
await manager.resume_workflow(workflow_id)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Status & Logs
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get("/{workflow_id}/status", response_model=WorkflowStatusResponse)
|
||||
async def get_workflow_status(
|
||||
workflow_id: int,
|
||||
request: Request,
|
||||
) -> WorkflowStatusResponse:
|
||||
"""Get live status for a specific workflow."""
|
||||
manager = _get_manager(request)
|
||||
status = manager.get_workflow_status(workflow_id)
|
||||
if status is None:
|
||||
async with async_session_factory() as db:
|
||||
config = await db.get(WorkflowConfig, workflow_id)
|
||||
if config is None:
|
||||
raise HTTPException(status_code=404, detail="Workflow config not found")
|
||||
return WorkflowStatusResponse(
|
||||
workflow_id=workflow_id,
|
||||
character_name=config.character_name,
|
||||
status="stopped",
|
||||
total_steps=len(config.steps),
|
||||
)
|
||||
return status
|
||||
|
||||
|
||||
@router.get("/{workflow_id}/logs", response_model=list[AutomationLogResponse])
|
||||
async def get_workflow_logs(
|
||||
workflow_id: int,
|
||||
request: Request,
|
||||
limit: int = 100,
|
||||
) -> list[AutomationLogResponse]:
|
||||
"""Get recent logs for a workflow (across all its runs)."""
|
||||
async with async_session_factory() as db:
|
||||
config = await db.get(WorkflowConfig, workflow_id)
|
||||
if config is None:
|
||||
raise HTTPException(status_code=404, detail="Workflow config not found")
|
||||
|
||||
# Fetch logs for all runs belonging to this workflow
|
||||
stmt = (
|
||||
select(AutomationLog)
|
||||
.join(WorkflowRun, AutomationLog.run_id == WorkflowRun.id)
|
||||
.where(WorkflowRun.workflow_id == workflow_id)
|
||||
.order_by(AutomationLog.created_at.desc())
|
||||
.limit(min(limit, 500))
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
logs = result.scalars().all()
|
||||
return [AutomationLogResponse.model_validate(log) for log in logs]
|
||||
|
|
@ -15,6 +15,10 @@ class Settings(BaseSettings):
|
|||
data_rate_limit: int = 20 # data requests per window
|
||||
data_rate_window: float = 1.0 # seconds
|
||||
|
||||
# Observability
|
||||
sentry_dsn: str = ""
|
||||
environment: str = "development"
|
||||
|
||||
model_config = {"env_file": ".env", "extra": "ignore"}
|
||||
|
||||
|
||||
|
|
|
|||
150
backend/app/engine/action_executor.py
Normal file
150
backend/app/engine/action_executor.py
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
"""Shared action execution logic.
|
||||
|
||||
Dispatches an ``ActionPlan`` to the appropriate ``ArtifactsClient`` method.
|
||||
Used by ``AutomationRunner``, ``WorkflowRunner``, and ``CharacterWorker``
|
||||
so the match statement is defined in exactly one place.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from app.engine.strategies.base import ActionPlan, ActionType
|
||||
from app.services.artifacts_client import ArtifactsClient
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def execute_action(
|
||||
client: ArtifactsClient,
|
||||
character_name: str,
|
||||
plan: ActionPlan,
|
||||
) -> dict[str, Any]:
|
||||
"""Execute an action plan against the game API and return the raw result."""
|
||||
match plan.action_type:
|
||||
case ActionType.MOVE:
|
||||
return await client.move(
|
||||
character_name,
|
||||
plan.params["x"],
|
||||
plan.params["y"],
|
||||
)
|
||||
case ActionType.FIGHT:
|
||||
return await client.fight(character_name)
|
||||
case ActionType.GATHER:
|
||||
return await client.gather(character_name)
|
||||
case ActionType.REST:
|
||||
return await client.rest(character_name)
|
||||
case ActionType.EQUIP:
|
||||
return await client.equip(
|
||||
character_name,
|
||||
plan.params["code"],
|
||||
plan.params["slot"],
|
||||
plan.params.get("quantity", 1),
|
||||
)
|
||||
case ActionType.UNEQUIP:
|
||||
return await client.unequip(
|
||||
character_name,
|
||||
plan.params["slot"],
|
||||
plan.params.get("quantity", 1),
|
||||
)
|
||||
case ActionType.USE_ITEM:
|
||||
return await client.use_item(
|
||||
character_name,
|
||||
plan.params["code"],
|
||||
plan.params.get("quantity", 1),
|
||||
)
|
||||
case ActionType.DEPOSIT_ITEM:
|
||||
return await client.deposit_item(
|
||||
character_name,
|
||||
plan.params["code"],
|
||||
plan.params["quantity"],
|
||||
)
|
||||
case ActionType.WITHDRAW_ITEM:
|
||||
return await client.withdraw_item(
|
||||
character_name,
|
||||
plan.params["code"],
|
||||
plan.params["quantity"],
|
||||
)
|
||||
case ActionType.CRAFT:
|
||||
return await client.craft(
|
||||
character_name,
|
||||
plan.params["code"],
|
||||
plan.params.get("quantity", 1),
|
||||
)
|
||||
case ActionType.RECYCLE:
|
||||
return await client.recycle(
|
||||
character_name,
|
||||
plan.params["code"],
|
||||
plan.params.get("quantity", 1),
|
||||
)
|
||||
case ActionType.GE_BUY:
|
||||
return await client.ge_buy(
|
||||
character_name,
|
||||
plan.params["id"],
|
||||
plan.params["quantity"],
|
||||
)
|
||||
case ActionType.GE_CREATE_BUY:
|
||||
return await client.ge_create_buy_order(
|
||||
character_name,
|
||||
plan.params["code"],
|
||||
plan.params["quantity"],
|
||||
plan.params["price"],
|
||||
)
|
||||
case ActionType.GE_SELL:
|
||||
return await client.ge_sell_order(
|
||||
character_name,
|
||||
plan.params["code"],
|
||||
plan.params["quantity"],
|
||||
plan.params["price"],
|
||||
)
|
||||
case ActionType.GE_FILL:
|
||||
return await client.ge_fill_buy_order(
|
||||
character_name,
|
||||
plan.params["id"],
|
||||
plan.params["quantity"],
|
||||
)
|
||||
case ActionType.GE_CANCEL:
|
||||
return await client.ge_cancel(
|
||||
character_name,
|
||||
plan.params["order_id"],
|
||||
)
|
||||
case ActionType.TASK_NEW:
|
||||
return await client.task_new(character_name)
|
||||
case ActionType.TASK_TRADE:
|
||||
return await client.task_trade(
|
||||
character_name,
|
||||
plan.params["code"],
|
||||
plan.params["quantity"],
|
||||
)
|
||||
case ActionType.TASK_COMPLETE:
|
||||
return await client.task_complete(character_name)
|
||||
case ActionType.TASK_EXCHANGE:
|
||||
return await client.task_exchange(character_name)
|
||||
case ActionType.TASK_CANCEL:
|
||||
return await client.task_cancel(character_name)
|
||||
case ActionType.DEPOSIT_GOLD:
|
||||
return await client.deposit_gold(
|
||||
character_name,
|
||||
plan.params["quantity"],
|
||||
)
|
||||
case ActionType.WITHDRAW_GOLD:
|
||||
return await client.withdraw_gold(
|
||||
character_name,
|
||||
plan.params["quantity"],
|
||||
)
|
||||
case ActionType.NPC_BUY:
|
||||
return await client.npc_buy(
|
||||
character_name,
|
||||
plan.params["code"],
|
||||
plan.params["quantity"],
|
||||
)
|
||||
case ActionType.NPC_SELL:
|
||||
return await client.npc_sell(
|
||||
character_name,
|
||||
plan.params["code"],
|
||||
plan.params["quantity"],
|
||||
)
|
||||
case _:
|
||||
logger.warning("Unhandled action type: %s", plan.action_type)
|
||||
return {}
|
||||
|
|
@ -1,16 +1,17 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.engine.cooldown import CooldownTracker
|
||||
from app.engine.pathfinder import Pathfinder
|
||||
from app.engine.runner import AutomationRunner
|
||||
from app.engine.decision.equipment_optimizer import EquipmentOptimizer
|
||||
from app.engine.decision.monster_selector import MonsterSelector
|
||||
from app.engine.decision.resource_selector import ResourceSelector
|
||||
from app.engine.strategies.base import BaseStrategy
|
||||
from app.engine.strategies.combat import CombatStrategy
|
||||
from app.engine.strategies.crafting import CraftingStrategy
|
||||
|
|
@ -18,12 +19,22 @@ from app.engine.strategies.gathering import GatheringStrategy
|
|||
from app.engine.strategies.leveling import LevelingStrategy
|
||||
from app.engine.strategies.task import TaskStrategy
|
||||
from app.engine.strategies.trading import TradingStrategy
|
||||
from app.models.automation import AutomationConfig, AutomationLog, AutomationRun
|
||||
from app.engine.pipeline.coordinator import PipelineCoordinator
|
||||
from app.engine.workflow.runner import WorkflowRunner
|
||||
from app.models.automation import AutomationConfig, AutomationRun
|
||||
from app.models.pipeline import PipelineConfig, PipelineRun
|
||||
from app.models.workflow import WorkflowConfig, WorkflowRun
|
||||
from app.schemas.automation import (
|
||||
AutomationLogResponse,
|
||||
AutomationRunResponse,
|
||||
AutomationStatusResponse,
|
||||
)
|
||||
from app.schemas.game import ItemSchema, MonsterSchema, ResourceSchema
|
||||
from app.schemas.pipeline import (
|
||||
CharacterStateResponse,
|
||||
PipelineRunResponse,
|
||||
PipelineStatusResponse,
|
||||
)
|
||||
from app.schemas.workflow import WorkflowRunResponse, WorkflowStatusResponse
|
||||
from app.services.artifacts_client import ArtifactsClient
|
||||
|
||||
if TYPE_CHECKING:
|
||||
|
|
@ -33,12 +44,13 @@ logger = logging.getLogger(__name__)
|
|||
|
||||
|
||||
class AutomationManager:
|
||||
"""Central manager that orchestrates all automation runners.
|
||||
"""Central manager that orchestrates all automation runners and workflow runners.
|
||||
|
||||
One manager exists per application instance and is stored on
|
||||
``app.state.automation_manager``. It holds references to all active
|
||||
runners (keyed by ``config_id``) and provides high-level start / stop /
|
||||
pause / resume operations.
|
||||
runners (keyed by ``config_id``) and workflow runners (keyed by
|
||||
``workflow_id``), and provides high-level start / stop / pause /
|
||||
resume operations.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
|
|
@ -53,24 +65,68 @@ class AutomationManager:
|
|||
self._pathfinder = pathfinder
|
||||
self._event_bus = event_bus
|
||||
self._runners: dict[int, AutomationRunner] = {}
|
||||
self._workflow_runners: dict[int, WorkflowRunner] = {}
|
||||
self._pipeline_coordinators: dict[int, PipelineCoordinator] = {}
|
||||
self._cooldown_tracker = CooldownTracker()
|
||||
|
||||
# Lazy-loaded game data caches for smart strategies
|
||||
self._monsters_cache: list[MonsterSchema] | None = None
|
||||
self._resources_cache: list[ResourceSchema] | None = None
|
||||
self._items_cache: list[ItemSchema] | None = None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Lifecycle
|
||||
# Game data cache
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def _ensure_game_data(self) -> None:
|
||||
"""Load game data caches lazily on first use."""
|
||||
if self._monsters_cache is None:
|
||||
try:
|
||||
raw = await self._client.get_all_monsters()
|
||||
self._monsters_cache = [MonsterSchema(**m) for m in raw]
|
||||
except Exception:
|
||||
logger.exception("Failed to load monsters cache")
|
||||
self._monsters_cache = []
|
||||
|
||||
if self._resources_cache is None:
|
||||
try:
|
||||
raw = await self._client.get_all_resources()
|
||||
self._resources_cache = [ResourceSchema(**r) for r in raw]
|
||||
except Exception:
|
||||
logger.exception("Failed to load resources cache")
|
||||
self._resources_cache = []
|
||||
|
||||
if self._items_cache is None:
|
||||
try:
|
||||
raw = await self._client.get_all_items()
|
||||
self._items_cache = [ItemSchema(**i) for i in raw]
|
||||
except Exception:
|
||||
logger.exception("Failed to load items cache")
|
||||
self._items_cache = []
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Character busy check
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def is_character_busy(self, character_name: str) -> bool:
|
||||
"""Return True if the character is running any automation, workflow, or pipeline."""
|
||||
for runner in self._runners.values():
|
||||
if runner.character_name == character_name and (runner.is_running or runner.is_paused):
|
||||
return True
|
||||
for wf_runner in self._workflow_runners.values():
|
||||
if wf_runner.character_name == character_name and (wf_runner.is_running or wf_runner.is_paused):
|
||||
return True
|
||||
for coord in self._pipeline_coordinators.values():
|
||||
if (coord.is_running or coord.is_paused) and character_name in coord.all_characters:
|
||||
return True
|
||||
return False
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Automation Lifecycle
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def start(self, config_id: int) -> AutomationRunResponse:
|
||||
"""Start an automation from its persisted configuration.
|
||||
|
||||
Creates a new :class:`AutomationRun` record and spawns an
|
||||
:class:`AutomationRunner` task.
|
||||
|
||||
Raises
|
||||
------
|
||||
ValueError
|
||||
If the config does not exist, is disabled, or is already running.
|
||||
"""
|
||||
# Prevent duplicate runners
|
||||
"""Start an automation from its persisted configuration."""
|
||||
if config_id in self._runners:
|
||||
runner = self._runners[config_id]
|
||||
if runner.is_running or runner.is_paused:
|
||||
|
|
@ -80,17 +136,23 @@ class AutomationManager:
|
|||
)
|
||||
|
||||
async with self._db_factory() as db:
|
||||
# Load the config
|
||||
config = await db.get(AutomationConfig, config_id)
|
||||
if config is None:
|
||||
raise ValueError(f"Automation config {config_id} not found")
|
||||
if not config.enabled:
|
||||
raise ValueError(f"Automation config {config_id} is disabled")
|
||||
|
||||
# Create strategy
|
||||
# Check character busy
|
||||
if self.is_character_busy(config.character_name):
|
||||
raise ValueError(
|
||||
f"Character {config.character_name!r} is already running an automation or workflow"
|
||||
)
|
||||
|
||||
# Ensure game data is loaded for smart strategies
|
||||
await self._ensure_game_data()
|
||||
|
||||
strategy = self._create_strategy(config.strategy_type, config.config)
|
||||
|
||||
# Create run record
|
||||
run = AutomationRun(
|
||||
config_id=config_id,
|
||||
status="running",
|
||||
|
|
@ -101,7 +163,6 @@ class AutomationManager:
|
|||
|
||||
run_response = AutomationRunResponse.model_validate(run)
|
||||
|
||||
# Build and start the runner
|
||||
runner = AutomationRunner(
|
||||
config_id=config_id,
|
||||
character_name=config.character_name,
|
||||
|
|
@ -125,55 +186,31 @@ class AutomationManager:
|
|||
return run_response
|
||||
|
||||
async def stop(self, config_id: int) -> None:
|
||||
"""Stop a running automation.
|
||||
|
||||
Raises
|
||||
------
|
||||
ValueError
|
||||
If no runner exists for the given config.
|
||||
"""
|
||||
runner = self._runners.get(config_id)
|
||||
if runner is None:
|
||||
raise ValueError(f"No active runner for config {config_id}")
|
||||
|
||||
await runner.stop()
|
||||
del self._runners[config_id]
|
||||
logger.info("Stopped automation config=%d", config_id)
|
||||
|
||||
async def pause(self, config_id: int) -> None:
|
||||
"""Pause a running automation.
|
||||
|
||||
Raises
|
||||
------
|
||||
ValueError
|
||||
If no runner exists for the given config or it is not running.
|
||||
"""
|
||||
runner = self._runners.get(config_id)
|
||||
if runner is None:
|
||||
raise ValueError(f"No active runner for config {config_id}")
|
||||
if not runner.is_running:
|
||||
raise ValueError(f"Runner for config {config_id} is not running (status={runner.status})")
|
||||
|
||||
await runner.pause()
|
||||
|
||||
async def resume(self, config_id: int) -> None:
|
||||
"""Resume a paused automation.
|
||||
|
||||
Raises
|
||||
------
|
||||
ValueError
|
||||
If no runner exists for the given config or it is not paused.
|
||||
"""
|
||||
runner = self._runners.get(config_id)
|
||||
if runner is None:
|
||||
raise ValueError(f"No active runner for config {config_id}")
|
||||
if not runner.is_paused:
|
||||
raise ValueError(f"Runner for config {config_id} is not paused (status={runner.status})")
|
||||
|
||||
await runner.resume()
|
||||
|
||||
async def stop_all(self) -> None:
|
||||
"""Stop all running automations (used during shutdown)."""
|
||||
"""Stop all running automations, workflows, and pipelines (used during shutdown)."""
|
||||
config_ids = list(self._runners.keys())
|
||||
for config_id in config_ids:
|
||||
try:
|
||||
|
|
@ -181,12 +218,25 @@ class AutomationManager:
|
|||
except Exception:
|
||||
logger.exception("Error stopping automation config=%d", config_id)
|
||||
|
||||
workflow_ids = list(self._workflow_runners.keys())
|
||||
for wf_id in workflow_ids:
|
||||
try:
|
||||
await self.stop_workflow(wf_id)
|
||||
except Exception:
|
||||
logger.exception("Error stopping workflow=%d", wf_id)
|
||||
|
||||
pipeline_ids = list(self._pipeline_coordinators.keys())
|
||||
for pid in pipeline_ids:
|
||||
try:
|
||||
await self.stop_pipeline(pid)
|
||||
except Exception:
|
||||
logger.exception("Error stopping pipeline=%d", pid)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Status queries
|
||||
# Automation Status queries
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def get_status(self, config_id: int) -> AutomationStatusResponse | None:
|
||||
"""Return the live status of a single automation, or ``None``."""
|
||||
runner = self._runners.get(config_id)
|
||||
if runner is None:
|
||||
return None
|
||||
|
|
@ -200,7 +250,6 @@ class AutomationManager:
|
|||
)
|
||||
|
||||
def get_all_statuses(self) -> list[AutomationStatusResponse]:
|
||||
"""Return live status for all active automations."""
|
||||
return [
|
||||
AutomationStatusResponse(
|
||||
config_id=r.config_id,
|
||||
|
|
@ -214,29 +263,339 @@ class AutomationManager:
|
|||
]
|
||||
|
||||
def is_running(self, config_id: int) -> bool:
|
||||
"""Return True if there is an active runner for the config."""
|
||||
runner = self._runners.get(config_id)
|
||||
return runner is not None and (runner.is_running or runner.is_paused)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Workflow Lifecycle
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def start_workflow(self, workflow_id: int) -> WorkflowRunResponse:
|
||||
"""Start a workflow from its persisted configuration."""
|
||||
if workflow_id in self._workflow_runners:
|
||||
runner = self._workflow_runners[workflow_id]
|
||||
if runner.is_running or runner.is_paused:
|
||||
raise ValueError(
|
||||
f"Workflow {workflow_id} is already running "
|
||||
f"(run_id={runner.run_id}, status={runner.status})"
|
||||
)
|
||||
|
||||
async with self._db_factory() as db:
|
||||
config = await db.get(WorkflowConfig, workflow_id)
|
||||
if config is None:
|
||||
raise ValueError(f"Workflow config {workflow_id} not found")
|
||||
if not config.enabled:
|
||||
raise ValueError(f"Workflow config {workflow_id} is disabled")
|
||||
if not config.steps:
|
||||
raise ValueError(f"Workflow config {workflow_id} has no steps")
|
||||
|
||||
# Check character busy
|
||||
if self.is_character_busy(config.character_name):
|
||||
raise ValueError(
|
||||
f"Character {config.character_name!r} is already running an automation or workflow"
|
||||
)
|
||||
|
||||
# Ensure game data for smart strategies
|
||||
await self._ensure_game_data()
|
||||
|
||||
# Create workflow run record
|
||||
run = WorkflowRun(
|
||||
workflow_id=workflow_id,
|
||||
status="running",
|
||||
current_step_index=0,
|
||||
current_step_id=config.steps[0].get("id", "") if config.steps else "",
|
||||
)
|
||||
db.add(run)
|
||||
await db.commit()
|
||||
await db.refresh(run)
|
||||
|
||||
run_response = WorkflowRunResponse.model_validate(run)
|
||||
|
||||
runner = WorkflowRunner(
|
||||
workflow_id=workflow_id,
|
||||
character_name=config.character_name,
|
||||
steps=config.steps,
|
||||
loop=config.loop,
|
||||
max_loops=config.max_loops,
|
||||
strategy_factory=self._create_strategy,
|
||||
client=self._client,
|
||||
cooldown_tracker=self._cooldown_tracker,
|
||||
db_factory=self._db_factory,
|
||||
run_id=run.id,
|
||||
event_bus=self._event_bus,
|
||||
)
|
||||
self._workflow_runners[workflow_id] = runner
|
||||
await runner.start()
|
||||
|
||||
logger.info(
|
||||
"Started workflow=%d character=%s steps=%d run=%d",
|
||||
workflow_id,
|
||||
config.character_name,
|
||||
len(config.steps),
|
||||
run.id,
|
||||
)
|
||||
return run_response
|
||||
|
||||
async def stop_workflow(self, workflow_id: int) -> None:
|
||||
runner = self._workflow_runners.get(workflow_id)
|
||||
if runner is None:
|
||||
raise ValueError(f"No active runner for workflow {workflow_id}")
|
||||
await runner.stop()
|
||||
del self._workflow_runners[workflow_id]
|
||||
logger.info("Stopped workflow=%d", workflow_id)
|
||||
|
||||
async def pause_workflow(self, workflow_id: int) -> None:
|
||||
runner = self._workflow_runners.get(workflow_id)
|
||||
if runner is None:
|
||||
raise ValueError(f"No active runner for workflow {workflow_id}")
|
||||
if not runner.is_running:
|
||||
raise ValueError(f"Workflow runner {workflow_id} is not running (status={runner.status})")
|
||||
await runner.pause()
|
||||
|
||||
async def resume_workflow(self, workflow_id: int) -> None:
|
||||
runner = self._workflow_runners.get(workflow_id)
|
||||
if runner is None:
|
||||
raise ValueError(f"No active runner for workflow {workflow_id}")
|
||||
if not runner.is_paused:
|
||||
raise ValueError(f"Workflow runner {workflow_id} is not paused (status={runner.status})")
|
||||
await runner.resume()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Workflow Status queries
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def get_workflow_status(self, workflow_id: int) -> WorkflowStatusResponse | None:
|
||||
runner = self._workflow_runners.get(workflow_id)
|
||||
if runner is None:
|
||||
return None
|
||||
return WorkflowStatusResponse(
|
||||
workflow_id=runner.workflow_id,
|
||||
character_name=runner.character_name,
|
||||
status=runner.status,
|
||||
run_id=runner.run_id,
|
||||
current_step_index=runner.current_step_index,
|
||||
current_step_id=runner.current_step_id,
|
||||
total_steps=len(runner._steps),
|
||||
loop_count=runner.loop_count,
|
||||
total_actions_count=runner.total_actions_count,
|
||||
step_actions_count=runner.step_actions_count,
|
||||
strategy_state=runner.strategy_state,
|
||||
)
|
||||
|
||||
def get_all_workflow_statuses(self) -> list[WorkflowStatusResponse]:
|
||||
return [
|
||||
WorkflowStatusResponse(
|
||||
workflow_id=r.workflow_id,
|
||||
character_name=r.character_name,
|
||||
status=r.status,
|
||||
run_id=r.run_id,
|
||||
current_step_index=r.current_step_index,
|
||||
current_step_id=r.current_step_id,
|
||||
total_steps=len(r._steps),
|
||||
loop_count=r.loop_count,
|
||||
total_actions_count=r.total_actions_count,
|
||||
step_actions_count=r.step_actions_count,
|
||||
strategy_state=r.strategy_state,
|
||||
)
|
||||
for r in self._workflow_runners.values()
|
||||
]
|
||||
|
||||
def is_workflow_running(self, workflow_id: int) -> bool:
|
||||
runner = self._workflow_runners.get(workflow_id)
|
||||
return runner is not None and (runner.is_running or runner.is_paused)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Pipeline Lifecycle
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def start_pipeline(self, pipeline_id: int) -> PipelineRunResponse:
|
||||
"""Start a pipeline from its persisted configuration."""
|
||||
if pipeline_id in self._pipeline_coordinators:
|
||||
coord = self._pipeline_coordinators[pipeline_id]
|
||||
if coord.is_running or coord.is_paused:
|
||||
raise ValueError(
|
||||
f"Pipeline {pipeline_id} is already running "
|
||||
f"(run_id={coord.run_id}, status={coord.status})"
|
||||
)
|
||||
|
||||
async with self._db_factory() as db:
|
||||
config = await db.get(PipelineConfig, pipeline_id)
|
||||
if config is None:
|
||||
raise ValueError(f"Pipeline config {pipeline_id} not found")
|
||||
if not config.enabled:
|
||||
raise ValueError(f"Pipeline config {pipeline_id} is disabled")
|
||||
if not config.stages:
|
||||
raise ValueError(f"Pipeline config {pipeline_id} has no stages")
|
||||
|
||||
# Collect all characters and verify none are busy
|
||||
all_chars: set[str] = set()
|
||||
for stage in config.stages:
|
||||
for cs in stage.get("character_steps", []):
|
||||
all_chars.add(cs["character_name"])
|
||||
|
||||
busy = [c for c in all_chars if self.is_character_busy(c)]
|
||||
if busy:
|
||||
raise ValueError(
|
||||
f"Characters already busy: {', '.join(sorted(busy))}"
|
||||
)
|
||||
|
||||
# Ensure game data for strategies
|
||||
await self._ensure_game_data()
|
||||
|
||||
run = PipelineRun(
|
||||
pipeline_id=pipeline_id,
|
||||
status="running",
|
||||
current_stage_index=0,
|
||||
current_stage_id=config.stages[0].get("id", "") if config.stages else "",
|
||||
)
|
||||
db.add(run)
|
||||
await db.commit()
|
||||
await db.refresh(run)
|
||||
|
||||
run_response = PipelineRunResponse.model_validate(run)
|
||||
|
||||
coord = PipelineCoordinator(
|
||||
pipeline_id=pipeline_id,
|
||||
stages=config.stages,
|
||||
loop=config.loop,
|
||||
max_loops=config.max_loops,
|
||||
strategy_factory=self._create_strategy,
|
||||
client=self._client,
|
||||
cooldown_tracker=self._cooldown_tracker,
|
||||
db_factory=self._db_factory,
|
||||
run_id=run.id,
|
||||
event_bus=self._event_bus,
|
||||
)
|
||||
self._pipeline_coordinators[pipeline_id] = coord
|
||||
await coord.start()
|
||||
|
||||
logger.info(
|
||||
"Started pipeline=%d stages=%d characters=%s run=%d",
|
||||
pipeline_id,
|
||||
len(config.stages),
|
||||
sorted(all_chars),
|
||||
run.id,
|
||||
)
|
||||
return run_response
|
||||
|
||||
async def stop_pipeline(self, pipeline_id: int) -> None:
|
||||
coord = self._pipeline_coordinators.get(pipeline_id)
|
||||
if coord is None:
|
||||
raise ValueError(f"No active coordinator for pipeline {pipeline_id}")
|
||||
await coord.stop()
|
||||
del self._pipeline_coordinators[pipeline_id]
|
||||
logger.info("Stopped pipeline=%d", pipeline_id)
|
||||
|
||||
async def pause_pipeline(self, pipeline_id: int) -> None:
|
||||
coord = self._pipeline_coordinators.get(pipeline_id)
|
||||
if coord is None:
|
||||
raise ValueError(f"No active coordinator for pipeline {pipeline_id}")
|
||||
if not coord.is_running:
|
||||
raise ValueError(f"Pipeline {pipeline_id} is not running (status={coord.status})")
|
||||
await coord.pause()
|
||||
|
||||
async def resume_pipeline(self, pipeline_id: int) -> None:
|
||||
coord = self._pipeline_coordinators.get(pipeline_id)
|
||||
if coord is None:
|
||||
raise ValueError(f"No active coordinator for pipeline {pipeline_id}")
|
||||
if not coord.is_paused:
|
||||
raise ValueError(f"Pipeline {pipeline_id} is not paused (status={coord.status})")
|
||||
await coord.resume()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Pipeline Status queries
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def get_pipeline_status(self, pipeline_id: int) -> PipelineStatusResponse | None:
|
||||
coord = self._pipeline_coordinators.get(pipeline_id)
|
||||
if coord is None:
|
||||
return None
|
||||
return PipelineStatusResponse(
|
||||
pipeline_id=coord.pipeline_id,
|
||||
status=coord.status,
|
||||
run_id=coord.run_id,
|
||||
current_stage_index=coord.current_stage_index,
|
||||
current_stage_id=coord.current_stage_id,
|
||||
total_stages=len(coord._stages),
|
||||
loop_count=coord.loop_count,
|
||||
total_actions_count=coord.total_actions_count,
|
||||
character_states=[
|
||||
CharacterStateResponse(
|
||||
character_name=name,
|
||||
status=state.get("status", "idle"),
|
||||
step_id=state.get("step_id", ""),
|
||||
actions_count=state.get("actions_count", 0),
|
||||
strategy_state=state.get("strategy_state", ""),
|
||||
error=state.get("error"),
|
||||
)
|
||||
for name, state in coord.character_states.items()
|
||||
],
|
||||
)
|
||||
|
||||
def get_all_pipeline_statuses(self) -> list[PipelineStatusResponse]:
|
||||
return [
|
||||
self.get_pipeline_status(pid)
|
||||
for pid in self._pipeline_coordinators
|
||||
if self.get_pipeline_status(pid) is not None
|
||||
]
|
||||
|
||||
def is_pipeline_running(self, pipeline_id: int) -> bool:
|
||||
coord = self._pipeline_coordinators.get(pipeline_id)
|
||||
return coord is not None and (coord.is_running or coord.is_paused)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Strategy factory
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _create_strategy(self, strategy_type: str, config: dict) -> BaseStrategy:
|
||||
"""Instantiate a strategy by type name."""
|
||||
"""Instantiate a strategy by type name, injecting game data and decision modules."""
|
||||
monster_selector = MonsterSelector()
|
||||
resource_selector = ResourceSelector()
|
||||
equipment_optimizer = EquipmentOptimizer()
|
||||
|
||||
match strategy_type:
|
||||
case "combat":
|
||||
return CombatStrategy(config, self._pathfinder)
|
||||
return CombatStrategy(
|
||||
config,
|
||||
self._pathfinder,
|
||||
monster_selector=monster_selector,
|
||||
monsters_data=self._monsters_cache,
|
||||
equipment_optimizer=equipment_optimizer,
|
||||
available_items=self._items_cache,
|
||||
)
|
||||
case "gathering":
|
||||
return GatheringStrategy(config, self._pathfinder)
|
||||
return GatheringStrategy(
|
||||
config,
|
||||
self._pathfinder,
|
||||
resource_selector=resource_selector,
|
||||
resources_data=self._resources_cache,
|
||||
)
|
||||
case "crafting":
|
||||
return CraftingStrategy(config, self._pathfinder)
|
||||
return CraftingStrategy(
|
||||
config,
|
||||
self._pathfinder,
|
||||
items_data=self._items_cache,
|
||||
resources_data=self._resources_cache,
|
||||
)
|
||||
case "trading":
|
||||
return TradingStrategy(config, self._pathfinder)
|
||||
return TradingStrategy(
|
||||
config,
|
||||
self._pathfinder,
|
||||
client=self._client,
|
||||
)
|
||||
case "task":
|
||||
return TaskStrategy(config, self._pathfinder)
|
||||
case "leveling":
|
||||
return LevelingStrategy(config, self._pathfinder)
|
||||
return LevelingStrategy(
|
||||
config,
|
||||
self._pathfinder,
|
||||
resources_data=self._resources_cache,
|
||||
monsters_data=self._monsters_cache,
|
||||
resource_selector=resource_selector,
|
||||
monster_selector=monster_selector,
|
||||
equipment_optimizer=equipment_optimizer,
|
||||
available_items=self._items_cache,
|
||||
)
|
||||
case _:
|
||||
raise ValueError(
|
||||
f"Unknown strategy type: {strategy_type!r}. "
|
||||
|
|
|
|||
4
backend/app/engine/pipeline/__init__.py
Normal file
4
backend/app/engine/pipeline/__init__.py
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
from app.engine.pipeline.coordinator import PipelineCoordinator
|
||||
from app.engine.pipeline.worker import CharacterWorker
|
||||
|
||||
__all__ = ["PipelineCoordinator", "CharacterWorker"]
|
||||
444
backend/app/engine/pipeline/coordinator.py
Normal file
444
backend/app/engine/pipeline/coordinator.py
Normal file
|
|
@ -0,0 +1,444 @@
|
|||
"""PipelineCoordinator — orchestrates stages sequentially with parallel character workers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||
|
||||
from app.engine.cooldown import CooldownTracker
|
||||
from app.engine.pipeline.worker import CharacterWorker
|
||||
from app.engine.strategies.base import BaseStrategy
|
||||
from app.models.pipeline import PipelineRun
|
||||
from app.services.artifacts_client import ArtifactsClient
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.websocket.event_bus import EventBus
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PipelineCoordinator:
|
||||
"""Orchestrates a multi-character pipeline.
|
||||
|
||||
Iterates stages sequentially. For each stage, spawns a
|
||||
``CharacterWorker`` per character-step and waits for all of them to
|
||||
complete their transition (or error). Then advances to the next stage.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
pipeline_id: int,
|
||||
stages: list[dict],
|
||||
loop: bool,
|
||||
max_loops: int,
|
||||
strategy_factory: Any, # callable(strategy_type, config) -> BaseStrategy
|
||||
client: ArtifactsClient,
|
||||
cooldown_tracker: CooldownTracker,
|
||||
db_factory: async_sessionmaker[AsyncSession],
|
||||
run_id: int,
|
||||
event_bus: EventBus | None = None,
|
||||
) -> None:
|
||||
self._pipeline_id = pipeline_id
|
||||
self._stages = stages
|
||||
self._loop = loop
|
||||
self._max_loops = max_loops
|
||||
self._strategy_factory = strategy_factory
|
||||
self._client = client
|
||||
self._cooldown = cooldown_tracker
|
||||
self._db_factory = db_factory
|
||||
self._run_id = run_id
|
||||
self._event_bus = event_bus
|
||||
|
||||
self._running = False
|
||||
self._paused = False
|
||||
self._task: asyncio.Task[None] | None = None
|
||||
|
||||
# Runtime state
|
||||
self._current_stage_index: int = 0
|
||||
self._loop_count: int = 0
|
||||
self._total_actions: int = 0
|
||||
self._stage_history: list[dict] = []
|
||||
self._workers: list[CharacterWorker] = []
|
||||
|
||||
# Track ALL characters across ALL stages for busy-checking
|
||||
self._all_characters: set[str] = set()
|
||||
for stage in stages:
|
||||
for cs in stage.get("character_steps", []):
|
||||
self._all_characters.add(cs["character_name"])
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Public properties
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@property
|
||||
def pipeline_id(self) -> int:
|
||||
return self._pipeline_id
|
||||
|
||||
@property
|
||||
def run_id(self) -> int:
|
||||
return self._run_id
|
||||
|
||||
@property
|
||||
def all_characters(self) -> set[str]:
|
||||
return self._all_characters
|
||||
|
||||
@property
|
||||
def active_characters(self) -> set[str]:
|
||||
"""Characters currently executing in the active stage."""
|
||||
return {w.character_name for w in self._workers if w.is_running}
|
||||
|
||||
@property
|
||||
def current_stage_index(self) -> int:
|
||||
return self._current_stage_index
|
||||
|
||||
@property
|
||||
def current_stage_id(self) -> str:
|
||||
if 0 <= self._current_stage_index < len(self._stages):
|
||||
return self._stages[self._current_stage_index].get("id", "")
|
||||
return ""
|
||||
|
||||
@property
|
||||
def loop_count(self) -> int:
|
||||
return self._loop_count
|
||||
|
||||
@property
|
||||
def total_actions_count(self) -> int:
|
||||
return self._total_actions + sum(w.actions_count for w in self._workers)
|
||||
|
||||
@property
|
||||
def is_running(self) -> bool:
|
||||
return self._running and not self._paused
|
||||
|
||||
@property
|
||||
def is_paused(self) -> bool:
|
||||
return self._running and self._paused
|
||||
|
||||
@property
|
||||
def status(self) -> str:
|
||||
if not self._running:
|
||||
return "stopped"
|
||||
if self._paused:
|
||||
return "paused"
|
||||
return "running"
|
||||
|
||||
@property
|
||||
def character_states(self) -> dict[str, dict]:
|
||||
"""Current state of each worker for status reporting."""
|
||||
result: dict[str, dict] = {}
|
||||
for w in self._workers:
|
||||
result[w.character_name] = {
|
||||
"status": w.status,
|
||||
"step_id": w.step_id,
|
||||
"actions_count": w.actions_count,
|
||||
"strategy_state": w.strategy_state,
|
||||
"error": w.error_message,
|
||||
}
|
||||
return result
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Event bus helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def _publish(self, event_type: str, data: dict) -> None:
|
||||
if self._event_bus is not None:
|
||||
try:
|
||||
await self._event_bus.publish(event_type, data)
|
||||
except Exception:
|
||||
logger.exception("Failed to publish event %s", event_type)
|
||||
|
||||
async def _publish_status(self, status: str) -> None:
|
||||
await self._publish(
|
||||
"pipeline_status_changed",
|
||||
{
|
||||
"pipeline_id": self._pipeline_id,
|
||||
"status": status,
|
||||
"run_id": self._run_id,
|
||||
"current_stage_index": self._current_stage_index,
|
||||
"loop_count": self._loop_count,
|
||||
"character_states": self.character_states,
|
||||
},
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Lifecycle
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def start(self) -> None:
|
||||
if self._running:
|
||||
return
|
||||
self._running = True
|
||||
self._paused = False
|
||||
self._task = asyncio.create_task(
|
||||
self._run_loop(),
|
||||
name=f"pipeline-coord-{self._pipeline_id}",
|
||||
)
|
||||
logger.info(
|
||||
"Started pipeline coordinator pipeline=%d run=%d stages=%d characters=%s",
|
||||
self._pipeline_id,
|
||||
self._run_id,
|
||||
len(self._stages),
|
||||
sorted(self._all_characters),
|
||||
)
|
||||
await self._publish_status("running")
|
||||
|
||||
async def stop(self, error_message: str | None = None) -> None:
|
||||
self._running = False
|
||||
# Stop all active workers
|
||||
for w in self._workers:
|
||||
await w.stop()
|
||||
if self._task is not None and not self._task.done():
|
||||
self._task.cancel()
|
||||
try:
|
||||
await self._task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
self._task = None
|
||||
|
||||
final_status = "error" if error_message else "stopped"
|
||||
await self._finalize_run(status=final_status, error_message=error_message)
|
||||
logger.info("Stopped pipeline %d (actions=%d)", self._pipeline_id, self.total_actions_count)
|
||||
await self._publish_status(final_status)
|
||||
|
||||
async def pause(self) -> None:
|
||||
self._paused = True
|
||||
for w in self._workers:
|
||||
await w.stop()
|
||||
await self._update_run_status("paused")
|
||||
await self._publish_status("paused")
|
||||
|
||||
async def resume(self) -> None:
|
||||
self._paused = False
|
||||
# Workers will be re-created by the main loop on next iteration
|
||||
await self._update_run_status("running")
|
||||
await self._publish_status("running")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Main loop
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def _run_loop(self) -> None:
|
||||
try:
|
||||
while self._running:
|
||||
if self._paused:
|
||||
await asyncio.sleep(1)
|
||||
continue
|
||||
|
||||
try:
|
||||
completed = await self._run_stage(self._current_stage_index)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
logger.exception(
|
||||
"Error running stage %d of pipeline %d: %s",
|
||||
self._current_stage_index,
|
||||
self._pipeline_id,
|
||||
exc,
|
||||
)
|
||||
await self._finalize_run(
|
||||
status="error",
|
||||
error_message=f"Stage {self._current_stage_index} error: {exc}",
|
||||
)
|
||||
self._running = False
|
||||
await self._publish_status("error")
|
||||
return
|
||||
|
||||
if not self._running:
|
||||
return
|
||||
|
||||
if not completed:
|
||||
# Stage had errors
|
||||
await self._finalize_run(
|
||||
status="error",
|
||||
error_message="Stage workers encountered errors",
|
||||
)
|
||||
self._running = False
|
||||
await self._publish_status("error")
|
||||
return
|
||||
|
||||
# Stage completed — record and advance
|
||||
stage = self._stages[self._current_stage_index]
|
||||
self._total_actions += sum(w.actions_count for w in self._workers)
|
||||
self._stage_history.append({
|
||||
"stage_id": stage.get("id", ""),
|
||||
"stage_name": stage.get("name", ""),
|
||||
"character_actions": {
|
||||
w.character_name: w.actions_count for w in self._workers
|
||||
},
|
||||
"completed_at": datetime.now(timezone.utc).isoformat(),
|
||||
})
|
||||
self._workers = []
|
||||
|
||||
logger.info(
|
||||
"Pipeline %d stage %d/%d completed (%s)",
|
||||
self._pipeline_id,
|
||||
self._current_stage_index + 1,
|
||||
len(self._stages),
|
||||
stage.get("name", ""),
|
||||
)
|
||||
|
||||
next_index = self._current_stage_index + 1
|
||||
if next_index >= len(self._stages):
|
||||
# End of pipeline
|
||||
if self._loop:
|
||||
self._loop_count += 1
|
||||
if self._max_loops > 0 and self._loop_count >= self._max_loops:
|
||||
await self._finalize_run(status="completed")
|
||||
self._running = False
|
||||
await self._publish_status("completed")
|
||||
return
|
||||
# Loop back to stage 0
|
||||
logger.info("Pipeline %d looping (loop %d)", self._pipeline_id, self._loop_count)
|
||||
self._current_stage_index = 0
|
||||
else:
|
||||
await self._finalize_run(status="completed")
|
||||
self._running = False
|
||||
await self._publish_status("completed")
|
||||
return
|
||||
else:
|
||||
self._current_stage_index = next_index
|
||||
|
||||
await self._update_run_progress()
|
||||
await self._publish_status("running")
|
||||
|
||||
except asyncio.CancelledError:
|
||||
logger.info("Pipeline coordinator %d cancelled", self._pipeline_id)
|
||||
|
||||
async def _run_stage(self, stage_index: int) -> bool:
|
||||
"""Run all character-steps in a stage in parallel.
|
||||
|
||||
Returns True if all workers completed successfully, False if any errored.
|
||||
"""
|
||||
if stage_index < 0 or stage_index >= len(self._stages):
|
||||
return False
|
||||
|
||||
stage = self._stages[stage_index]
|
||||
character_steps = stage.get("character_steps", [])
|
||||
|
||||
logger.info(
|
||||
"Pipeline %d starting stage %d/%d: %s (%d workers)",
|
||||
self._pipeline_id,
|
||||
stage_index + 1,
|
||||
len(self._stages),
|
||||
stage.get("name", ""),
|
||||
len(character_steps),
|
||||
)
|
||||
|
||||
# Create workers for each character-step
|
||||
self._workers = []
|
||||
for cs in character_steps:
|
||||
try:
|
||||
strategy = self._strategy_factory(
|
||||
cs["strategy_type"],
|
||||
cs.get("config", {}),
|
||||
)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Failed to create strategy for pipeline %d character %s",
|
||||
self._pipeline_id,
|
||||
cs.get("character_name", "?"),
|
||||
)
|
||||
return False
|
||||
|
||||
worker = CharacterWorker(
|
||||
pipeline_id=self._pipeline_id,
|
||||
stage_id=stage.get("id", ""),
|
||||
step=cs,
|
||||
strategy=strategy,
|
||||
client=self._client,
|
||||
cooldown_tracker=self._cooldown,
|
||||
event_bus=self._event_bus,
|
||||
)
|
||||
self._workers.append(worker)
|
||||
|
||||
# Start all workers in parallel
|
||||
for w in self._workers:
|
||||
await w.start()
|
||||
|
||||
# Wait for all workers to complete or error
|
||||
while self._running and not self._paused:
|
||||
all_done = all(w.is_completed or w.is_errored for w in self._workers)
|
||||
if all_done:
|
||||
break
|
||||
await asyncio.sleep(0.5)
|
||||
|
||||
if self._paused or not self._running:
|
||||
return False
|
||||
|
||||
# Check if any worker errored
|
||||
errored = [w for w in self._workers if w.is_errored]
|
||||
if errored:
|
||||
error_msgs = "; ".join(
|
||||
f"{w.character_name}: {w.error_message}" for w in errored
|
||||
)
|
||||
logger.error(
|
||||
"Pipeline %d stage %d had worker errors: %s",
|
||||
self._pipeline_id,
|
||||
stage_index,
|
||||
error_msgs,
|
||||
)
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Database helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def _update_run_status(self, status: str) -> None:
|
||||
try:
|
||||
async with self._db_factory() as db:
|
||||
stmt = select(PipelineRun).where(PipelineRun.id == self._run_id)
|
||||
result = await db.execute(stmt)
|
||||
run = result.scalar_one_or_none()
|
||||
if run is not None:
|
||||
run.status = status
|
||||
await db.commit()
|
||||
except Exception:
|
||||
logger.exception("Failed to update pipeline run %d status", self._run_id)
|
||||
|
||||
async def _update_run_progress(self) -> None:
|
||||
try:
|
||||
async with self._db_factory() as db:
|
||||
stmt = select(PipelineRun).where(PipelineRun.id == self._run_id)
|
||||
result = await db.execute(stmt)
|
||||
run = result.scalar_one_or_none()
|
||||
if run is not None:
|
||||
run.current_stage_index = self._current_stage_index
|
||||
run.current_stage_id = self.current_stage_id
|
||||
run.loop_count = self._loop_count
|
||||
run.total_actions_count = self.total_actions_count
|
||||
run.character_states = self.character_states
|
||||
run.stage_history = self._stage_history
|
||||
await db.commit()
|
||||
except Exception:
|
||||
logger.exception("Failed to update pipeline run %d progress", self._run_id)
|
||||
|
||||
async def _finalize_run(
|
||||
self,
|
||||
status: str,
|
||||
error_message: str | None = None,
|
||||
) -> None:
|
||||
try:
|
||||
async with self._db_factory() as db:
|
||||
stmt = select(PipelineRun).where(PipelineRun.id == self._run_id)
|
||||
result = await db.execute(stmt)
|
||||
run = result.scalar_one_or_none()
|
||||
if run is not None:
|
||||
run.status = status
|
||||
run.stopped_at = datetime.now(timezone.utc)
|
||||
run.current_stage_index = self._current_stage_index
|
||||
run.current_stage_id = self.current_stage_id
|
||||
run.loop_count = self._loop_count
|
||||
run.total_actions_count = self.total_actions_count
|
||||
run.character_states = self.character_states
|
||||
run.stage_history = self._stage_history
|
||||
if error_message:
|
||||
run.error_message = error_message
|
||||
await db.commit()
|
||||
except Exception:
|
||||
logger.exception("Failed to finalize pipeline run %d", self._run_id)
|
||||
241
backend/app/engine/pipeline/worker.py
Normal file
241
backend/app/engine/pipeline/worker.py
Normal file
|
|
@ -0,0 +1,241 @@
|
|||
"""CharacterWorker — runs one character's strategy within a pipeline stage.
|
||||
|
||||
Same tick loop pattern as WorkflowRunner._tick(): wait cooldown -> get
|
||||
character -> get action -> check transition -> execute action. Reuses the
|
||||
shared ``execute_action`` helper and the existing ``TransitionEvaluator``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from app.engine.action_executor import execute_action
|
||||
from app.engine.cooldown import CooldownTracker
|
||||
from app.engine.strategies.base import ActionPlan, ActionType, BaseStrategy
|
||||
from app.engine.workflow.conditions import TransitionEvaluator
|
||||
from app.services.artifacts_client import ArtifactsClient
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.websocket.event_bus import EventBus
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_ERROR_RETRY_DELAY: float = 2.0
|
||||
_MAX_CONSECUTIVE_ERRORS: int = 10
|
||||
|
||||
|
||||
class CharacterWorker:
|
||||
"""Drives a single character's strategy within a pipeline stage."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
pipeline_id: int,
|
||||
stage_id: str,
|
||||
step: dict,
|
||||
strategy: BaseStrategy,
|
||||
client: ArtifactsClient,
|
||||
cooldown_tracker: CooldownTracker,
|
||||
event_bus: EventBus | None = None,
|
||||
) -> None:
|
||||
self._pipeline_id = pipeline_id
|
||||
self._stage_id = stage_id
|
||||
self._step = step
|
||||
self._character_name: str = step["character_name"]
|
||||
self._step_id: str = step["id"]
|
||||
self._strategy = strategy
|
||||
self._client = client
|
||||
self._cooldown = cooldown_tracker
|
||||
self._event_bus = event_bus
|
||||
|
||||
self._transition_evaluator = TransitionEvaluator(client)
|
||||
self._running = False
|
||||
self._completed = False
|
||||
self._errored = False
|
||||
self._error_message: str | None = None
|
||||
self._task: asyncio.Task[None] | None = None
|
||||
self._actions_count: int = 0
|
||||
self._step_start_time: float = 0.0
|
||||
self._consecutive_errors: int = 0
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Public properties
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@property
|
||||
def character_name(self) -> str:
|
||||
return self._character_name
|
||||
|
||||
@property
|
||||
def step_id(self) -> str:
|
||||
return self._step_id
|
||||
|
||||
@property
|
||||
def is_completed(self) -> bool:
|
||||
return self._completed
|
||||
|
||||
@property
|
||||
def is_errored(self) -> bool:
|
||||
return self._errored
|
||||
|
||||
@property
|
||||
def is_running(self) -> bool:
|
||||
return self._running and not self._completed and not self._errored
|
||||
|
||||
@property
|
||||
def actions_count(self) -> int:
|
||||
return self._actions_count
|
||||
|
||||
@property
|
||||
def strategy_state(self) -> str:
|
||||
return self._strategy.get_state() if self._strategy else ""
|
||||
|
||||
@property
|
||||
def error_message(self) -> str | None:
|
||||
return self._error_message
|
||||
|
||||
@property
|
||||
def status(self) -> str:
|
||||
if self._errored:
|
||||
return "error"
|
||||
if self._completed:
|
||||
return "completed"
|
||||
if self._running:
|
||||
return "running"
|
||||
return "idle"
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Lifecycle
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def start(self) -> None:
|
||||
if self._running:
|
||||
return
|
||||
self._running = True
|
||||
self._step_start_time = time.time()
|
||||
self._transition_evaluator.reset()
|
||||
self._task = asyncio.create_task(
|
||||
self._run_loop(),
|
||||
name=f"pipeline-{self._pipeline_id}-{self._character_name}",
|
||||
)
|
||||
|
||||
async def stop(self) -> None:
|
||||
self._running = False
|
||||
if self._task is not None and not self._task.done():
|
||||
self._task.cancel()
|
||||
try:
|
||||
await self._task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
self._task = None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Main loop
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def _run_loop(self) -> None:
|
||||
try:
|
||||
while self._running and not self._completed:
|
||||
try:
|
||||
await self._tick()
|
||||
self._consecutive_errors = 0
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
self._consecutive_errors += 1
|
||||
logger.exception(
|
||||
"Error in pipeline worker %s/%s (error %d/%d): %s",
|
||||
self._pipeline_id,
|
||||
self._character_name,
|
||||
self._consecutive_errors,
|
||||
_MAX_CONSECUTIVE_ERRORS,
|
||||
exc,
|
||||
)
|
||||
if self._consecutive_errors >= _MAX_CONSECUTIVE_ERRORS:
|
||||
self._errored = True
|
||||
self._error_message = (
|
||||
f"Stopped after {_MAX_CONSECUTIVE_ERRORS} "
|
||||
f"consecutive errors. Last: {exc}"
|
||||
)
|
||||
self._running = False
|
||||
return
|
||||
await asyncio.sleep(_ERROR_RETRY_DELAY)
|
||||
except asyncio.CancelledError:
|
||||
logger.info(
|
||||
"Pipeline worker %s/%s cancelled",
|
||||
self._pipeline_id,
|
||||
self._character_name,
|
||||
)
|
||||
|
||||
async def _tick(self) -> None:
|
||||
if self._strategy is None:
|
||||
self._errored = True
|
||||
self._error_message = "No strategy configured"
|
||||
self._running = False
|
||||
return
|
||||
|
||||
# 1. Wait for cooldown
|
||||
await self._cooldown.wait(self._character_name)
|
||||
|
||||
# 2. Fetch character state
|
||||
character = await self._client.get_character(self._character_name)
|
||||
|
||||
# 3. Ask strategy for next action
|
||||
plan = await self._strategy.next_action(character)
|
||||
strategy_completed = plan.action_type == ActionType.COMPLETE
|
||||
|
||||
# 4. Check transition condition
|
||||
transition = self._step.get("transition")
|
||||
if transition is not None:
|
||||
should_advance = await self._transition_evaluator.should_transition(
|
||||
transition,
|
||||
character,
|
||||
actions_count=self._actions_count,
|
||||
step_start_time=self._step_start_time,
|
||||
strategy_completed=strategy_completed,
|
||||
)
|
||||
if should_advance:
|
||||
self._completed = True
|
||||
self._running = False
|
||||
return
|
||||
|
||||
# 5. If strategy completed and no transition, treat as done
|
||||
if strategy_completed:
|
||||
if transition is None:
|
||||
self._completed = True
|
||||
self._running = False
|
||||
return
|
||||
# Strategy completed but transition not met yet -- idle
|
||||
await asyncio.sleep(1)
|
||||
return
|
||||
|
||||
if plan.action_type == ActionType.IDLE:
|
||||
await asyncio.sleep(1)
|
||||
return
|
||||
|
||||
# 6. Execute the action
|
||||
result = await execute_action(self._client, self._character_name, plan)
|
||||
|
||||
# 7. Update cooldown
|
||||
cooldown = result.get("cooldown")
|
||||
if cooldown:
|
||||
self._cooldown.update(
|
||||
self._character_name,
|
||||
cooldown.get("total_seconds", 0),
|
||||
cooldown.get("expiration"),
|
||||
)
|
||||
|
||||
# 8. Record
|
||||
self._actions_count += 1
|
||||
|
||||
# 9. Publish character update
|
||||
if self._event_bus is not None:
|
||||
try:
|
||||
await self._event_bus.publish(
|
||||
"character_update",
|
||||
{"character_name": self._character_name},
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
|
@ -5,13 +5,16 @@ import logging
|
|||
from datetime import datetime, timezone
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import httpx
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||
|
||||
from app.engine.action_executor import execute_action
|
||||
from app.engine.cooldown import CooldownTracker
|
||||
from app.engine.strategies.base import ActionPlan, ActionType, BaseStrategy
|
||||
from app.models.automation import AutomationLog, AutomationRun
|
||||
from app.services.artifacts_client import ArtifactsClient
|
||||
from app.services.error_service import hash_token, log_error
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.websocket.event_bus import EventBus
|
||||
|
|
@ -235,6 +238,62 @@ class AutomationRunner:
|
|||
self._consecutive_errors = 0
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except httpx.HTTPStatusError as exc:
|
||||
status = exc.response.status_code
|
||||
# 498 = character in cooldown – not a real error,
|
||||
# just wait and retry without incrementing the counter.
|
||||
if status == 498:
|
||||
logger.info(
|
||||
"Cooldown error for config %d, will retry",
|
||||
self._config_id,
|
||||
)
|
||||
await asyncio.sleep(_ERROR_RETRY_DELAY)
|
||||
continue
|
||||
# Other HTTP errors – treat as real failures
|
||||
self._consecutive_errors += 1
|
||||
logger.exception(
|
||||
"HTTP %d in automation loop for config %d (error %d/%d): %s",
|
||||
status,
|
||||
self._config_id,
|
||||
self._consecutive_errors,
|
||||
_MAX_CONSECUTIVE_ERRORS,
|
||||
exc,
|
||||
)
|
||||
await log_error(
|
||||
self._db_factory,
|
||||
severity="error",
|
||||
source="automation",
|
||||
exc=exc,
|
||||
context={
|
||||
"config_id": self._config_id,
|
||||
"character": self._character_name,
|
||||
"run_id": self._run_id,
|
||||
"consecutive_errors": self._consecutive_errors,
|
||||
"http_status": status,
|
||||
},
|
||||
)
|
||||
await self._log_action(
|
||||
ActionPlan(ActionType.IDLE, reason=str(exc)),
|
||||
success=False,
|
||||
)
|
||||
await self._publish_action(
|
||||
"error",
|
||||
success=False,
|
||||
details={"error": str(exc)},
|
||||
)
|
||||
if self._consecutive_errors >= _MAX_CONSECUTIVE_ERRORS:
|
||||
logger.error(
|
||||
"Too many consecutive errors for config %d, stopping",
|
||||
self._config_id,
|
||||
)
|
||||
await self._finalize_run(
|
||||
status="error",
|
||||
error_message=f"Stopped after {_MAX_CONSECUTIVE_ERRORS} consecutive errors. Last: {exc}",
|
||||
)
|
||||
self._running = False
|
||||
await self._publish_status("error")
|
||||
return
|
||||
await asyncio.sleep(_ERROR_RETRY_DELAY)
|
||||
except Exception as exc:
|
||||
self._consecutive_errors += 1
|
||||
logger.exception(
|
||||
|
|
@ -244,6 +303,20 @@ class AutomationRunner:
|
|||
_MAX_CONSECUTIVE_ERRORS,
|
||||
exc,
|
||||
)
|
||||
token_hash = hash_token(self._client._token) if self._client._token else None
|
||||
await log_error(
|
||||
self._db_factory,
|
||||
severity="error",
|
||||
source="automation",
|
||||
exc=exc,
|
||||
context={
|
||||
"config_id": self._config_id,
|
||||
"character": self._character_name,
|
||||
"run_id": self._run_id,
|
||||
"consecutive_errors": self._consecutive_errors,
|
||||
},
|
||||
user_token_hash=token_hash,
|
||||
)
|
||||
await self._log_action(
|
||||
ActionPlan(ActionType.IDLE, reason=str(exc)),
|
||||
success=False,
|
||||
|
|
@ -336,96 +409,7 @@ class AutomationRunner:
|
|||
|
||||
async def _execute_action(self, plan: ActionPlan) -> dict[str, Any]:
|
||||
"""Dispatch an action plan to the appropriate client method."""
|
||||
match plan.action_type:
|
||||
case ActionType.MOVE:
|
||||
return await self._client.move(
|
||||
self._character_name,
|
||||
plan.params["x"],
|
||||
plan.params["y"],
|
||||
)
|
||||
case ActionType.FIGHT:
|
||||
return await self._client.fight(self._character_name)
|
||||
case ActionType.GATHER:
|
||||
return await self._client.gather(self._character_name)
|
||||
case ActionType.REST:
|
||||
return await self._client.rest(self._character_name)
|
||||
case ActionType.EQUIP:
|
||||
return await self._client.equip(
|
||||
self._character_name,
|
||||
plan.params["code"],
|
||||
plan.params["slot"],
|
||||
plan.params.get("quantity", 1),
|
||||
)
|
||||
case ActionType.UNEQUIP:
|
||||
return await self._client.unequip(
|
||||
self._character_name,
|
||||
plan.params["slot"],
|
||||
plan.params.get("quantity", 1),
|
||||
)
|
||||
case ActionType.USE_ITEM:
|
||||
return await self._client.use_item(
|
||||
self._character_name,
|
||||
plan.params["code"],
|
||||
plan.params.get("quantity", 1),
|
||||
)
|
||||
case ActionType.DEPOSIT_ITEM:
|
||||
return await self._client.deposit_item(
|
||||
self._character_name,
|
||||
plan.params["code"],
|
||||
plan.params["quantity"],
|
||||
)
|
||||
case ActionType.WITHDRAW_ITEM:
|
||||
return await self._client.withdraw_item(
|
||||
self._character_name,
|
||||
plan.params["code"],
|
||||
plan.params["quantity"],
|
||||
)
|
||||
case ActionType.CRAFT:
|
||||
return await self._client.craft(
|
||||
self._character_name,
|
||||
plan.params["code"],
|
||||
plan.params.get("quantity", 1),
|
||||
)
|
||||
case ActionType.RECYCLE:
|
||||
return await self._client.recycle(
|
||||
self._character_name,
|
||||
plan.params["code"],
|
||||
plan.params.get("quantity", 1),
|
||||
)
|
||||
case ActionType.GE_BUY:
|
||||
return await self._client.ge_buy(
|
||||
self._character_name,
|
||||
plan.params["code"],
|
||||
plan.params["quantity"],
|
||||
plan.params["price"],
|
||||
)
|
||||
case ActionType.GE_SELL:
|
||||
return await self._client.ge_sell_order(
|
||||
self._character_name,
|
||||
plan.params["code"],
|
||||
plan.params["quantity"],
|
||||
plan.params["price"],
|
||||
)
|
||||
case ActionType.GE_CANCEL:
|
||||
return await self._client.ge_cancel(
|
||||
self._character_name,
|
||||
plan.params["order_id"],
|
||||
)
|
||||
case ActionType.TASK_NEW:
|
||||
return await self._client.task_new(self._character_name)
|
||||
case ActionType.TASK_TRADE:
|
||||
return await self._client.task_trade(
|
||||
self._character_name,
|
||||
plan.params["code"],
|
||||
plan.params["quantity"],
|
||||
)
|
||||
case ActionType.TASK_COMPLETE:
|
||||
return await self._client.task_complete(self._character_name)
|
||||
case ActionType.TASK_EXCHANGE:
|
||||
return await self._client.task_exchange(self._character_name)
|
||||
case _:
|
||||
logger.warning("Unhandled action type: %s", plan.action_type)
|
||||
return {}
|
||||
return await execute_action(self._client, self._character_name, plan)
|
||||
|
||||
def _update_cooldown_from_result(self, result: dict[str, Any]) -> None:
|
||||
"""Extract cooldown information from an action response and update the tracker."""
|
||||
|
|
|
|||
|
|
@ -1,10 +1,17 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from app.engine.pathfinder import Pathfinder
|
||||
from app.schemas.game import CharacterSchema
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.engine.decision.equipment_optimizer import EquipmentOptimizer
|
||||
from app.schemas.game import ItemSchema
|
||||
|
||||
|
||||
class ActionType(str, Enum):
|
||||
"""All possible actions the automation runner can execute."""
|
||||
|
|
@ -21,12 +28,19 @@ class ActionType(str, Enum):
|
|||
CRAFT = "craft"
|
||||
RECYCLE = "recycle"
|
||||
GE_BUY = "ge_buy"
|
||||
GE_CREATE_BUY = "ge_create_buy"
|
||||
GE_SELL = "ge_sell"
|
||||
GE_FILL = "ge_fill"
|
||||
GE_CANCEL = "ge_cancel"
|
||||
TASK_NEW = "task_new"
|
||||
TASK_TRADE = "task_trade"
|
||||
TASK_COMPLETE = "task_complete"
|
||||
TASK_EXCHANGE = "task_exchange"
|
||||
TASK_CANCEL = "task_cancel"
|
||||
DEPOSIT_GOLD = "deposit_gold"
|
||||
WITHDRAW_GOLD = "withdraw_gold"
|
||||
NPC_BUY = "npc_buy"
|
||||
NPC_SELL = "npc_sell"
|
||||
IDLE = "idle"
|
||||
COMPLETE = "complete"
|
||||
|
||||
|
|
@ -49,9 +63,18 @@ class BaseStrategy(ABC):
|
|||
Subclasses must implement :meth:`next_action` and :meth:`get_state`.
|
||||
"""
|
||||
|
||||
def __init__(self, config: dict, pathfinder: Pathfinder) -> None:
|
||||
def __init__(
|
||||
self,
|
||||
config: dict,
|
||||
pathfinder: Pathfinder,
|
||||
equipment_optimizer: EquipmentOptimizer | None = None,
|
||||
available_items: list[ItemSchema] | None = None,
|
||||
) -> None:
|
||||
self.config = config
|
||||
self.pathfinder = pathfinder
|
||||
self._equipment_optimizer = equipment_optimizer
|
||||
self._available_items = available_items or []
|
||||
self._auto_equip_checked = False
|
||||
|
||||
@abstractmethod
|
||||
async def next_action(self, character: CharacterSchema) -> ActionPlan:
|
||||
|
|
@ -97,3 +120,27 @@ class BaseStrategy(ABC):
|
|||
def _is_at(character: CharacterSchema, x: int, y: int) -> bool:
|
||||
"""Check whether the character is standing at the given tile."""
|
||||
return character.x == x and character.y == y
|
||||
|
||||
def _check_auto_equip(self, character: CharacterSchema) -> ActionPlan | None:
|
||||
"""Return an EQUIP action if better gear is available, else None.
|
||||
|
||||
Only runs once per strategy lifetime to avoid re-checking every tick.
|
||||
"""
|
||||
if self._auto_equip_checked:
|
||||
return None
|
||||
self._auto_equip_checked = True
|
||||
|
||||
if self._equipment_optimizer is None or not self._available_items:
|
||||
return None
|
||||
|
||||
analysis = self._equipment_optimizer.suggest_equipment(
|
||||
character, self._available_items
|
||||
)
|
||||
if analysis.suggestions:
|
||||
best = analysis.suggestions[0]
|
||||
return ActionPlan(
|
||||
ActionType.EQUIP,
|
||||
params={"code": best.suggested_item_code, "slot": best.slot},
|
||||
reason=f"Auto-equip: {best.reason}",
|
||||
)
|
||||
return None
|
||||
|
|
|
|||
|
|
@ -1,10 +1,18 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from enum import Enum
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from app.engine.pathfinder import Pathfinder
|
||||
from app.engine.strategies.base import ActionPlan, ActionType, BaseStrategy
|
||||
from app.schemas.game import CharacterSchema
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.engine.decision.equipment_optimizer import EquipmentOptimizer
|
||||
from app.engine.decision.monster_selector import MonsterSelector
|
||||
from app.schemas.game import ItemSchema, MonsterSchema
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
|
|
@ -43,18 +51,34 @@ class CombatStrategy(BaseStrategy):
|
|||
- deposit_loot: bool (default True)
|
||||
"""
|
||||
|
||||
def __init__(self, config: dict, pathfinder: Pathfinder) -> None:
|
||||
super().__init__(config, pathfinder)
|
||||
def __init__(
|
||||
self,
|
||||
config: dict,
|
||||
pathfinder: Pathfinder,
|
||||
monster_selector: MonsterSelector | None = None,
|
||||
monsters_data: list[MonsterSchema] | None = None,
|
||||
equipment_optimizer: EquipmentOptimizer | None = None,
|
||||
available_items: list[ItemSchema] | None = None,
|
||||
) -> None:
|
||||
super().__init__(
|
||||
config, pathfinder,
|
||||
equipment_optimizer=equipment_optimizer,
|
||||
available_items=available_items,
|
||||
)
|
||||
self._state = _CombatState.MOVE_TO_MONSTER
|
||||
|
||||
# Parsed config with defaults
|
||||
self._monster_code: str = config["monster_code"]
|
||||
self._monster_code: str = config.get("monster_code", "")
|
||||
self._heal_threshold: int = config.get("auto_heal_threshold", 50)
|
||||
self._heal_method: str = config.get("heal_method", "rest")
|
||||
self._consumable_code: str | None = config.get("consumable_code")
|
||||
self._min_inv_slots: int = config.get("min_inventory_slots", 3)
|
||||
self._deposit_loot: bool = config.get("deposit_loot", True)
|
||||
|
||||
# Decision modules
|
||||
self._monster_selector = monster_selector
|
||||
self._monsters_data = monsters_data or []
|
||||
|
||||
# Cached locations (resolved lazily)
|
||||
self._monster_pos: tuple[int, int] | None = None
|
||||
self._bank_pos: tuple[int, int] | None = None
|
||||
|
|
@ -63,6 +87,18 @@ class CombatStrategy(BaseStrategy):
|
|||
return self._state.value
|
||||
|
||||
async def next_action(self, character: CharacterSchema) -> ActionPlan:
|
||||
# Auto-select monster if code is empty or "auto"
|
||||
if (not self._monster_code or self._monster_code == "auto") and self._monster_selector and self._monsters_data:
|
||||
selected = self._monster_selector.select_optimal(character, self._monsters_data)
|
||||
if selected:
|
||||
self._monster_code = selected.code
|
||||
logger.info("Auto-selected monster %s for character %s", selected.code, character.name)
|
||||
|
||||
# Check auto-equip on first tick
|
||||
equip_action = self._check_auto_equip(character)
|
||||
if equip_action is not None:
|
||||
return equip_action
|
||||
|
||||
# Lazily resolve monster and bank positions
|
||||
self._resolve_locations(character)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,10 +1,16 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from enum import Enum
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from app.engine.pathfinder import Pathfinder
|
||||
from app.engine.strategies.base import ActionPlan, ActionType, BaseStrategy
|
||||
from app.schemas.game import CharacterSchema, ItemSchema
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.schemas.game import ResourceSchema
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
|
|
@ -62,6 +68,7 @@ class CraftingStrategy(BaseStrategy):
|
|||
config: dict,
|
||||
pathfinder: Pathfinder,
|
||||
items_data: list[ItemSchema] | None = None,
|
||||
resources_data: list[ResourceSchema] | None = None,
|
||||
) -> None:
|
||||
super().__init__(config, pathfinder)
|
||||
self._state = _CraftState.CHECK_MATERIALS
|
||||
|
|
@ -81,6 +88,9 @@ class CraftingStrategy(BaseStrategy):
|
|||
self._craft_level: int = 0
|
||||
self._recipe_resolved: bool = False
|
||||
|
||||
# Game data for gathering resolution
|
||||
self._resources_data: list[ResourceSchema] = resources_data or []
|
||||
|
||||
# If items data is provided, resolve the recipe immediately
|
||||
if items_data:
|
||||
self._resolve_recipe(items_data)
|
||||
|
|
@ -186,11 +196,18 @@ class CraftingStrategy(BaseStrategy):
|
|||
# Withdraw the first missing material
|
||||
code, needed_qty = next(iter(missing.items()))
|
||||
|
||||
# If we should gather and we can't withdraw, switch to gather mode
|
||||
# If gather_materials is enabled and we can determine a resource for this material,
|
||||
# try gathering instead of just hoping the bank has it
|
||||
if self._gather_materials:
|
||||
# We'll try to withdraw; if it fails the runner will handle the error
|
||||
# and we can switch to gathering mode. For now, attempt the withdraw.
|
||||
pass
|
||||
resource_code = self._find_resource_for_material(code)
|
||||
if resource_code:
|
||||
self._gather_resource_code = resource_code
|
||||
self._gather_pos = self.pathfinder.find_nearest(
|
||||
character.x, character.y, "resource", resource_code
|
||||
)
|
||||
if self._gather_pos:
|
||||
self._state = _CraftState.GATHER_MATERIALS
|
||||
return self._handle_gather_materials(character)
|
||||
|
||||
return ActionPlan(
|
||||
ActionType.WITHDRAW_ITEM,
|
||||
|
|
@ -383,6 +400,14 @@ class CraftingStrategy(BaseStrategy):
|
|||
|
||||
return missing
|
||||
|
||||
def _find_resource_for_material(self, material_code: str) -> str | None:
|
||||
"""Look up which resource drops the needed material."""
|
||||
for resource in self._resources_data:
|
||||
for drop in resource.drops:
|
||||
if drop.code == material_code:
|
||||
return resource.code
|
||||
return None
|
||||
|
||||
def _resolve_locations(self, character: CharacterSchema) -> None:
|
||||
"""Lazily resolve and cache workshop and bank tile positions."""
|
||||
if self._workshop_pos is None and self._craft_skill:
|
||||
|
|
|
|||
|
|
@ -1,10 +1,17 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from enum import Enum
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from app.engine.pathfinder import Pathfinder
|
||||
from app.engine.strategies.base import ActionPlan, ActionType, BaseStrategy
|
||||
from app.schemas.game import CharacterSchema
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.engine.decision.resource_selector import ResourceSelector
|
||||
from app.schemas.game import ResourceSchema
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
|
|
@ -36,15 +43,25 @@ class GatheringStrategy(BaseStrategy):
|
|||
- max_loops: int (default 0 = infinite)
|
||||
"""
|
||||
|
||||
def __init__(self, config: dict, pathfinder: Pathfinder) -> None:
|
||||
def __init__(
|
||||
self,
|
||||
config: dict,
|
||||
pathfinder: Pathfinder,
|
||||
resource_selector: ResourceSelector | None = None,
|
||||
resources_data: list[ResourceSchema] | None = None,
|
||||
) -> None:
|
||||
super().__init__(config, pathfinder)
|
||||
self._state = _GatherState.MOVE_TO_RESOURCE
|
||||
|
||||
# Parsed config with defaults
|
||||
self._resource_code: str = config["resource_code"]
|
||||
self._resource_code: str = config.get("resource_code", "")
|
||||
self._deposit_on_full: bool = config.get("deposit_on_full", True)
|
||||
self._max_loops: int = config.get("max_loops", 0)
|
||||
|
||||
# Decision modules
|
||||
self._resource_selector = resource_selector
|
||||
self._resources_data = resources_data or []
|
||||
|
||||
# Runtime counters
|
||||
self._loop_count: int = 0
|
||||
|
||||
|
|
@ -56,6 +73,17 @@ class GatheringStrategy(BaseStrategy):
|
|||
return self._state.value
|
||||
|
||||
async def next_action(self, character: CharacterSchema) -> ActionPlan:
|
||||
# Auto-select resource if code is empty or "auto"
|
||||
if (not self._resource_code or self._resource_code == "auto") and self._resource_selector and self._resources_data:
|
||||
# Determine the skill from the resource_code config or default to mining
|
||||
skill = config.get("skill", "") if (config := self.config) else ""
|
||||
if not skill:
|
||||
skill = "mining"
|
||||
selection = self._resource_selector.select_optimal(character, self._resources_data, skill)
|
||||
if selection:
|
||||
self._resource_code = selection.resource.code
|
||||
logger.info("Auto-selected resource %s for character %s", selection.resource.code, character.name)
|
||||
|
||||
# Check loop limit
|
||||
if self._max_loops > 0 and self._loop_count >= self._max_loops:
|
||||
return ActionPlan(
|
||||
|
|
|
|||
|
|
@ -1,10 +1,19 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from enum import Enum
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from app.engine.pathfinder import Pathfinder
|
||||
from app.engine.strategies.base import ActionPlan, ActionType, BaseStrategy
|
||||
from app.schemas.game import CharacterSchema, ResourceSchema
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.engine.decision.equipment_optimizer import EquipmentOptimizer
|
||||
from app.engine.decision.monster_selector import MonsterSelector
|
||||
from app.engine.decision.resource_selector import ResourceSelector
|
||||
from app.schemas.game import ItemSchema, MonsterSchema
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# All skills in the game with their gathering/crafting type
|
||||
|
|
@ -44,8 +53,17 @@ class LevelingStrategy(BaseStrategy):
|
|||
config: dict,
|
||||
pathfinder: Pathfinder,
|
||||
resources_data: list[ResourceSchema] | None = None,
|
||||
monsters_data: list[MonsterSchema] | None = None,
|
||||
resource_selector: ResourceSelector | None = None,
|
||||
monster_selector: MonsterSelector | None = None,
|
||||
equipment_optimizer: EquipmentOptimizer | None = None,
|
||||
available_items: list[ItemSchema] | None = None,
|
||||
) -> None:
|
||||
super().__init__(config, pathfinder)
|
||||
super().__init__(
|
||||
config, pathfinder,
|
||||
equipment_optimizer=equipment_optimizer,
|
||||
available_items=available_items,
|
||||
)
|
||||
self._state = _LevelingState.EVALUATE
|
||||
|
||||
# Config
|
||||
|
|
@ -55,6 +73,11 @@ class LevelingStrategy(BaseStrategy):
|
|||
|
||||
# Resolved from game data
|
||||
self._resources_data: list[ResourceSchema] = resources_data or []
|
||||
self._monsters_data: list[MonsterSchema] = monsters_data or []
|
||||
|
||||
# Decision modules
|
||||
self._resource_selector = resource_selector
|
||||
self._monster_selector = monster_selector
|
||||
|
||||
# Runtime state
|
||||
self._chosen_skill: str = ""
|
||||
|
|
@ -76,6 +99,11 @@ class LevelingStrategy(BaseStrategy):
|
|||
async def next_action(self, character: CharacterSchema) -> ActionPlan:
|
||||
self._resolve_bank(character)
|
||||
|
||||
# Check auto-equip before combat
|
||||
equip_action = self._check_auto_equip(character)
|
||||
if equip_action is not None:
|
||||
return equip_action
|
||||
|
||||
match self._state:
|
||||
case _LevelingState.EVALUATE:
|
||||
return self._handle_evaluate(character)
|
||||
|
|
@ -285,6 +313,17 @@ class LevelingStrategy(BaseStrategy):
|
|||
self._target_pos = None
|
||||
return self._handle_evaluate(character)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Location resolution
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _resolve_bank(self, character: CharacterSchema) -> None:
|
||||
"""Lazily resolve and cache the nearest bank tile position."""
|
||||
if self._bank_pos is None:
|
||||
self._bank_pos = self.pathfinder.find_nearest_by_type(
|
||||
character.x, character.y, "bank"
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Skill analysis helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
|
@ -322,27 +361,35 @@ class LevelingStrategy(BaseStrategy):
|
|||
skill_level: int,
|
||||
) -> None:
|
||||
"""Choose the best resource to gather for a given skill and level."""
|
||||
# Filter resources matching the skill
|
||||
# Try the ResourceSelector decision module first
|
||||
if self._resource_selector and self._resources_data:
|
||||
selection = self._resource_selector.select_optimal(
|
||||
character, self._resources_data, skill
|
||||
)
|
||||
if selection:
|
||||
self._chosen_resource_code = selection.resource.code
|
||||
self._target_pos = self.pathfinder.find_nearest(
|
||||
character.x, character.y, "resource", selection.resource.code
|
||||
)
|
||||
return
|
||||
|
||||
# Fallback: inline logic using resources_data
|
||||
matching = [r for r in self._resources_data if r.skill == skill]
|
||||
if not matching:
|
||||
# Fallback: use pathfinder to find any resource of this skill
|
||||
self._target_pos = self.pathfinder.find_nearest_by_type(
|
||||
character.x, character.y, "resource"
|
||||
)
|
||||
return
|
||||
|
||||
# Find the best resource within +-3 levels
|
||||
candidates = []
|
||||
for r in matching:
|
||||
diff = r.level - skill_level
|
||||
if diff <= 3: # Can gather up to 3 levels above
|
||||
if diff <= 3:
|
||||
candidates.append(r)
|
||||
|
||||
if not candidates:
|
||||
# No resources within range, pick the lowest level one
|
||||
candidates = matching
|
||||
|
||||
# Among candidates, prefer higher level for better XP
|
||||
best = max(candidates, key=lambda r: r.level if r.level <= skill_level + 3 else -r.level)
|
||||
self._chosen_resource_code = best.code
|
||||
|
||||
|
|
@ -352,7 +399,17 @@ class LevelingStrategy(BaseStrategy):
|
|||
|
||||
def _choose_combat_target(self, character: CharacterSchema) -> None:
|
||||
"""Choose a monster appropriate for the character's combat level."""
|
||||
# Find a monster near the character's level
|
||||
# Try the MonsterSelector decision module first
|
||||
if self._monster_selector and self._monsters_data:
|
||||
selected = self._monster_selector.select_optimal(character, self._monsters_data)
|
||||
if selected:
|
||||
self._chosen_monster_code = selected.code
|
||||
self._target_pos = self.pathfinder.find_nearest(
|
||||
character.x, character.y, "monster", selected.code
|
||||
)
|
||||
return
|
||||
|
||||
# Fallback: find any nearby monster
|
||||
self._chosen_monster_code = ""
|
||||
self._target_pos = self.pathfinder.find_nearest_by_type(
|
||||
character.x, character.y, "monster"
|
||||
|
|
|
|||
|
|
@ -1,10 +1,16 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from enum import Enum
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from app.engine.pathfinder import Pathfinder
|
||||
from app.engine.strategies.base import ActionPlan, ActionType, BaseStrategy
|
||||
from app.schemas.game import CharacterSchema
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.services.artifacts_client import ArtifactsClient
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
|
|
@ -22,10 +28,6 @@ class _TradingState(str, Enum):
|
|||
DEPOSIT_ITEMS = "deposit_items"
|
||||
|
||||
|
||||
# ActionType extensions for GE operations (handled via params in the runner)
|
||||
# We reuse CRAFT action type slot to send GE-specific actions; the runner
|
||||
# dispatches based on action_type enum. We add new action types to base.
|
||||
|
||||
class _TradingMode(str, Enum):
|
||||
SELL_LOOT = "sell_loot"
|
||||
BUY_MATERIALS = "buy_materials"
|
||||
|
|
@ -49,7 +51,12 @@ class TradingStrategy(BaseStrategy):
|
|||
- max_price: int (default 0) -- maximum acceptable price (0 = no limit)
|
||||
"""
|
||||
|
||||
def __init__(self, config: dict, pathfinder: Pathfinder) -> None:
|
||||
def __init__(
|
||||
self,
|
||||
config: dict,
|
||||
pathfinder: Pathfinder,
|
||||
client: ArtifactsClient | None = None,
|
||||
) -> None:
|
||||
super().__init__(config, pathfinder)
|
||||
|
||||
# Parse config
|
||||
|
|
@ -65,6 +72,9 @@ class TradingStrategy(BaseStrategy):
|
|||
self._min_price: int = config.get("min_price", 0)
|
||||
self._max_price: int = config.get("max_price", 0)
|
||||
|
||||
# Client for GE order polling
|
||||
self._client = client
|
||||
|
||||
# Determine initial state based on mode
|
||||
if self._mode == _TradingMode.SELL_LOOT:
|
||||
self._state = _TradingState.MOVE_TO_BANK
|
||||
|
|
@ -78,6 +88,7 @@ class TradingStrategy(BaseStrategy):
|
|||
# Runtime state
|
||||
self._items_withdrawn: int = 0
|
||||
self._orders_created: bool = False
|
||||
self._active_order_id: str | None = None
|
||||
self._wait_cycles: int = 0
|
||||
|
||||
# Cached positions
|
||||
|
|
@ -227,7 +238,7 @@ class TradingStrategy(BaseStrategy):
|
|||
self._state = _TradingState.WAIT_FOR_ORDER
|
||||
|
||||
return ActionPlan(
|
||||
ActionType.GE_BUY,
|
||||
ActionType.GE_CREATE_BUY,
|
||||
params={
|
||||
"code": self._item_code,
|
||||
"quantity": self._quantity,
|
||||
|
|
@ -239,26 +250,38 @@ class TradingStrategy(BaseStrategy):
|
|||
def _handle_wait_for_order(self, character: CharacterSchema) -> ActionPlan:
|
||||
self._wait_cycles += 1
|
||||
|
||||
# Wait for a reasonable time, then check
|
||||
if self._wait_cycles < 3:
|
||||
# Poll every 3 cycles to avoid API spam
|
||||
if self._wait_cycles % 3 != 0:
|
||||
return ActionPlan(
|
||||
ActionType.IDLE,
|
||||
reason=f"Waiting for GE order to fill (cycle {self._wait_cycles})",
|
||||
)
|
||||
|
||||
# After waiting, check orders
|
||||
# Check if the order is still active
|
||||
self._state = _TradingState.CHECK_ORDERS
|
||||
return self._handle_check_orders(character)
|
||||
|
||||
def _handle_check_orders(self, character: CharacterSchema) -> ActionPlan:
|
||||
# For now, just complete after creating orders
|
||||
# In a full implementation, we'd check the GE order status
|
||||
if self._mode == _TradingMode.FLIP and self._orders_created:
|
||||
# For flip mode, once buy order is done, create sell
|
||||
self._state = _TradingState.CREATE_SELL_ORDER
|
||||
# If we have a client and an order ID, poll the actual order status
|
||||
# This is an async check, but since next_action is async we handle it
|
||||
# by transitioning: the runner will call next_action again next tick
|
||||
if self._active_order_id and self._client:
|
||||
# We'll check on the next tick since we can't await here easily
|
||||
# For now, just keep waiting unless we've waited a long time
|
||||
if self._wait_cycles < 30:
|
||||
self._state = _TradingState.WAIT_FOR_ORDER
|
||||
return ActionPlan(
|
||||
ActionType.IDLE,
|
||||
reason="Checking order status for flip trade",
|
||||
reason=f"Checking order {self._active_order_id} status (cycle {self._wait_cycles})",
|
||||
)
|
||||
|
||||
# After enough waiting or no client, assume order is done
|
||||
if self._mode == _TradingMode.FLIP and self._orders_created:
|
||||
self._state = _TradingState.CREATE_SELL_ORDER
|
||||
self._orders_created = False # Reset for sell phase
|
||||
return ActionPlan(
|
||||
ActionType.IDLE,
|
||||
reason="Buy order assumed filled, preparing sell order",
|
||||
)
|
||||
|
||||
return ActionPlan(
|
||||
|
|
|
|||
4
backend/app/engine/workflow/__init__.py
Normal file
4
backend/app/engine/workflow/__init__.py
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
from app.engine.workflow.conditions import TransitionEvaluator, TransitionType
|
||||
from app.engine.workflow.runner import WorkflowRunner
|
||||
|
||||
__all__ = ["TransitionEvaluator", "TransitionType", "WorkflowRunner"]
|
||||
159
backend/app/engine/workflow/conditions.py
Normal file
159
backend/app/engine/workflow/conditions.py
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
from enum import Enum
|
||||
from typing import Any
|
||||
|
||||
from app.schemas.game import CharacterSchema
|
||||
from app.services.artifacts_client import ArtifactsClient
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class TransitionType(str, Enum):
|
||||
STRATEGY_COMPLETE = "strategy_complete"
|
||||
LOOPS_COMPLETED = "loops_completed"
|
||||
INVENTORY_FULL = "inventory_full"
|
||||
INVENTORY_ITEM_COUNT = "inventory_item_count"
|
||||
BANK_ITEM_COUNT = "bank_item_count"
|
||||
SKILL_LEVEL = "skill_level"
|
||||
GOLD_AMOUNT = "gold_amount"
|
||||
ACTIONS_COUNT = "actions_count"
|
||||
TIMER = "timer"
|
||||
|
||||
|
||||
def _compare(actual: int | float, operator: str, target: int | float) -> bool:
|
||||
"""Compare a value using a string operator."""
|
||||
match operator:
|
||||
case ">=":
|
||||
return actual >= target
|
||||
case "<=":
|
||||
return actual <= target
|
||||
case "==":
|
||||
return actual == target
|
||||
case ">":
|
||||
return actual > target
|
||||
case "<":
|
||||
return actual < target
|
||||
case _:
|
||||
return actual >= target
|
||||
|
||||
|
||||
class TransitionEvaluator:
|
||||
"""Evaluates transition conditions for workflow steps."""
|
||||
|
||||
def __init__(self, client: ArtifactsClient) -> None:
|
||||
self._client = client
|
||||
self._bank_cache: list[dict[str, Any]] | None = None
|
||||
self._bank_cache_tick: int = 0
|
||||
self._tick_counter: int = 0
|
||||
|
||||
async def should_transition(
|
||||
self,
|
||||
condition: dict,
|
||||
character: CharacterSchema,
|
||||
*,
|
||||
actions_count: int = 0,
|
||||
step_start_time: float = 0.0,
|
||||
strategy_completed: bool = False,
|
||||
) -> bool:
|
||||
"""Check whether the transition condition is met.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
condition:
|
||||
The transition condition dict with keys: type, operator, value,
|
||||
item_code, skill, seconds.
|
||||
character:
|
||||
Current character state.
|
||||
actions_count:
|
||||
Number of actions executed in the current step.
|
||||
step_start_time:
|
||||
Timestamp when the current step started.
|
||||
strategy_completed:
|
||||
True if the underlying strategy returned COMPLETE.
|
||||
"""
|
||||
self._tick_counter += 1
|
||||
cond_type = condition.get("type", "")
|
||||
operator = condition.get("operator", ">=")
|
||||
target_value = condition.get("value", 0)
|
||||
|
||||
try:
|
||||
match cond_type:
|
||||
case TransitionType.STRATEGY_COMPLETE:
|
||||
return strategy_completed
|
||||
|
||||
case TransitionType.LOOPS_COMPLETED:
|
||||
# This is handled externally by the workflow runner
|
||||
return False
|
||||
|
||||
case TransitionType.INVENTORY_FULL:
|
||||
free_slots = character.inventory_max_items - len(character.inventory)
|
||||
return free_slots == 0
|
||||
|
||||
case TransitionType.INVENTORY_ITEM_COUNT:
|
||||
item_code = condition.get("item_code", "")
|
||||
count = sum(
|
||||
s.quantity
|
||||
for s in character.inventory
|
||||
if s.code == item_code
|
||||
)
|
||||
return _compare(count, operator, target_value)
|
||||
|
||||
case TransitionType.BANK_ITEM_COUNT:
|
||||
item_code = condition.get("item_code", "")
|
||||
bank_count = await self._get_bank_item_count(item_code)
|
||||
return _compare(bank_count, operator, target_value)
|
||||
|
||||
case TransitionType.SKILL_LEVEL:
|
||||
skill = condition.get("skill", "")
|
||||
level = getattr(character, f"{skill}_level", 0)
|
||||
return _compare(level, operator, target_value)
|
||||
|
||||
case TransitionType.GOLD_AMOUNT:
|
||||
return _compare(character.gold, operator, target_value)
|
||||
|
||||
case TransitionType.ACTIONS_COUNT:
|
||||
return _compare(actions_count, operator, target_value)
|
||||
|
||||
case TransitionType.TIMER:
|
||||
seconds = condition.get("seconds", 0)
|
||||
if step_start_time <= 0:
|
||||
return False
|
||||
elapsed = time.time() - step_start_time
|
||||
return elapsed >= seconds
|
||||
|
||||
case _:
|
||||
logger.warning("Unknown transition type: %s", cond_type)
|
||||
return False
|
||||
|
||||
except Exception:
|
||||
logger.exception("Error evaluating transition condition: %s", condition)
|
||||
return False
|
||||
|
||||
async def _get_bank_item_count(self, item_code: str) -> int:
|
||||
"""Get item count from bank, with rate-limited caching (every 10 ticks)."""
|
||||
if (
|
||||
self._bank_cache is None
|
||||
or self._tick_counter - self._bank_cache_tick >= 10
|
||||
):
|
||||
try:
|
||||
self._bank_cache = await self._client.get_bank_items()
|
||||
self._bank_cache_tick = self._tick_counter
|
||||
except Exception:
|
||||
logger.exception("Failed to fetch bank items for transition check")
|
||||
return 0
|
||||
|
||||
if self._bank_cache is None:
|
||||
return 0
|
||||
|
||||
for item in self._bank_cache:
|
||||
if isinstance(item, dict) and item.get("code") == item_code:
|
||||
return item.get("quantity", 0)
|
||||
return 0
|
||||
|
||||
def reset(self) -> None:
|
||||
"""Reset caches when advancing to a new step."""
|
||||
self._bank_cache = None
|
||||
self._bank_cache_tick = 0
|
||||
543
backend/app/engine/workflow/runner.py
Normal file
543
backend/app/engine/workflow/runner.py
Normal file
|
|
@ -0,0 +1,543 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||
|
||||
from app.engine.action_executor import execute_action
|
||||
from app.engine.cooldown import CooldownTracker
|
||||
from app.engine.pathfinder import Pathfinder
|
||||
from app.engine.strategies.base import ActionPlan, ActionType, BaseStrategy
|
||||
from app.engine.workflow.conditions import TransitionEvaluator
|
||||
from app.models.automation import AutomationLog
|
||||
from app.models.workflow import WorkflowRun
|
||||
from app.services.artifacts_client import ArtifactsClient
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.websocket.event_bus import EventBus
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_ERROR_RETRY_DELAY: float = 2.0
|
||||
_MAX_CONSECUTIVE_ERRORS: int = 10
|
||||
|
||||
|
||||
class WorkflowRunner:
|
||||
"""Runs a multi-step workflow pipeline for a single character.
|
||||
|
||||
Each step contains a strategy that is driven in a loop identical to
|
||||
:class:`AutomationRunner`. After each tick the runner evaluates the
|
||||
step's transition condition; when met it advances to the next step.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
workflow_id: int,
|
||||
character_name: str,
|
||||
steps: list[dict],
|
||||
loop: bool,
|
||||
max_loops: int,
|
||||
strategy_factory: Any, # callable(strategy_type, config) -> BaseStrategy
|
||||
client: ArtifactsClient,
|
||||
cooldown_tracker: CooldownTracker,
|
||||
db_factory: async_sessionmaker[AsyncSession],
|
||||
run_id: int,
|
||||
event_bus: EventBus | None = None,
|
||||
) -> None:
|
||||
self._workflow_id = workflow_id
|
||||
self._character_name = character_name
|
||||
self._steps = steps
|
||||
self._loop = loop
|
||||
self._max_loops = max_loops
|
||||
self._strategy_factory = strategy_factory
|
||||
self._client = client
|
||||
self._cooldown = cooldown_tracker
|
||||
self._db_factory = db_factory
|
||||
self._run_id = run_id
|
||||
self._event_bus = event_bus
|
||||
|
||||
self._running = False
|
||||
self._paused = False
|
||||
self._task: asyncio.Task[None] | None = None
|
||||
|
||||
# Runtime state
|
||||
self._current_step_index: int = 0
|
||||
self._loop_count: int = 0
|
||||
self._total_actions: int = 0
|
||||
self._step_actions: int = 0
|
||||
self._step_start_time: float = 0.0
|
||||
self._step_history: list[dict] = []
|
||||
self._consecutive_errors: int = 0
|
||||
|
||||
# Current strategy
|
||||
self._strategy: BaseStrategy | None = None
|
||||
self._transition_evaluator = TransitionEvaluator(client)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Public properties
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@property
|
||||
def workflow_id(self) -> int:
|
||||
return self._workflow_id
|
||||
|
||||
@property
|
||||
def character_name(self) -> str:
|
||||
return self._character_name
|
||||
|
||||
@property
|
||||
def run_id(self) -> int:
|
||||
return self._run_id
|
||||
|
||||
@property
|
||||
def current_step_index(self) -> int:
|
||||
return self._current_step_index
|
||||
|
||||
@property
|
||||
def current_step_id(self) -> str:
|
||||
if 0 <= self._current_step_index < len(self._steps):
|
||||
return self._steps[self._current_step_index].get("id", "")
|
||||
return ""
|
||||
|
||||
@property
|
||||
def loop_count(self) -> int:
|
||||
return self._loop_count
|
||||
|
||||
@property
|
||||
def total_actions_count(self) -> int:
|
||||
return self._total_actions
|
||||
|
||||
@property
|
||||
def step_actions_count(self) -> int:
|
||||
return self._step_actions
|
||||
|
||||
@property
|
||||
def is_running(self) -> bool:
|
||||
return self._running and not self._paused
|
||||
|
||||
@property
|
||||
def is_paused(self) -> bool:
|
||||
return self._running and self._paused
|
||||
|
||||
@property
|
||||
def status(self) -> str:
|
||||
if not self._running:
|
||||
return "stopped"
|
||||
if self._paused:
|
||||
return "paused"
|
||||
return "running"
|
||||
|
||||
@property
|
||||
def strategy_state(self) -> str:
|
||||
if self._strategy is not None:
|
||||
return self._strategy.get_state()
|
||||
return ""
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Event bus helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def _publish(self, event_type: str, data: dict) -> None:
|
||||
if self._event_bus is not None:
|
||||
try:
|
||||
await self._event_bus.publish(event_type, data)
|
||||
except Exception:
|
||||
logger.exception("Failed to publish event %s", event_type)
|
||||
|
||||
async def _publish_status(self, status: str) -> None:
|
||||
await self._publish(
|
||||
"workflow_status_changed",
|
||||
{
|
||||
"workflow_id": self._workflow_id,
|
||||
"character_name": self._character_name,
|
||||
"status": status,
|
||||
"run_id": self._run_id,
|
||||
"current_step_index": self._current_step_index,
|
||||
"loop_count": self._loop_count,
|
||||
},
|
||||
)
|
||||
|
||||
async def _publish_action(
|
||||
self,
|
||||
action_type: str,
|
||||
success: bool,
|
||||
details: dict | None = None,
|
||||
) -> None:
|
||||
await self._publish(
|
||||
"workflow_action",
|
||||
{
|
||||
"workflow_id": self._workflow_id,
|
||||
"character_name": self._character_name,
|
||||
"action_type": action_type,
|
||||
"success": success,
|
||||
"details": details or {},
|
||||
"total_actions_count": self._total_actions,
|
||||
"step_index": self._current_step_index,
|
||||
},
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Lifecycle
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def start(self) -> None:
|
||||
if self._running:
|
||||
return
|
||||
self._running = True
|
||||
self._paused = False
|
||||
|
||||
# Initialize strategy for the first step
|
||||
self._init_step(self._current_step_index)
|
||||
|
||||
self._task = asyncio.create_task(
|
||||
self._run_loop(),
|
||||
name=f"workflow-{self._workflow_id}-{self._character_name}",
|
||||
)
|
||||
logger.info(
|
||||
"Started workflow runner workflow=%d character=%s run=%d",
|
||||
self._workflow_id,
|
||||
self._character_name,
|
||||
self._run_id,
|
||||
)
|
||||
await self._publish_status("running")
|
||||
|
||||
async def stop(self, error_message: str | None = None) -> None:
|
||||
self._running = False
|
||||
if self._task is not None and not self._task.done():
|
||||
self._task.cancel()
|
||||
try:
|
||||
await self._task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
self._task = None
|
||||
|
||||
final_status = "error" if error_message else "stopped"
|
||||
await self._finalize_run(status=final_status, error_message=error_message)
|
||||
logger.info(
|
||||
"Stopped workflow runner workflow=%d (actions=%d)",
|
||||
self._workflow_id,
|
||||
self._total_actions,
|
||||
)
|
||||
await self._publish_status(final_status)
|
||||
|
||||
async def pause(self) -> None:
|
||||
self._paused = True
|
||||
await self._update_run_status("paused")
|
||||
await self._publish_status("paused")
|
||||
|
||||
async def resume(self) -> None:
|
||||
self._paused = False
|
||||
await self._update_run_status("running")
|
||||
await self._publish_status("running")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Main loop
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def _run_loop(self) -> None:
|
||||
try:
|
||||
while self._running:
|
||||
if self._paused:
|
||||
await asyncio.sleep(1)
|
||||
continue
|
||||
|
||||
try:
|
||||
await self._tick()
|
||||
self._consecutive_errors = 0
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
self._consecutive_errors += 1
|
||||
logger.exception(
|
||||
"Error in workflow loop workflow=%d (error %d/%d): %s",
|
||||
self._workflow_id,
|
||||
self._consecutive_errors,
|
||||
_MAX_CONSECUTIVE_ERRORS,
|
||||
exc,
|
||||
)
|
||||
await self._log_action(
|
||||
ActionPlan(ActionType.IDLE, reason=str(exc)),
|
||||
success=False,
|
||||
)
|
||||
if self._consecutive_errors >= _MAX_CONSECUTIVE_ERRORS:
|
||||
logger.error(
|
||||
"Too many consecutive errors for workflow %d, stopping",
|
||||
self._workflow_id,
|
||||
)
|
||||
await self._finalize_run(
|
||||
status="error",
|
||||
error_message=f"Stopped after {_MAX_CONSECUTIVE_ERRORS} consecutive errors. Last: {exc}",
|
||||
)
|
||||
self._running = False
|
||||
await self._publish_status("error")
|
||||
return
|
||||
await asyncio.sleep(_ERROR_RETRY_DELAY)
|
||||
|
||||
except asyncio.CancelledError:
|
||||
logger.info("Workflow loop for %d was cancelled", self._workflow_id)
|
||||
|
||||
async def _tick(self) -> None:
|
||||
"""Execute a single iteration of the workflow loop."""
|
||||
if self._strategy is None:
|
||||
logger.error("No strategy for workflow %d step %d", self._workflow_id, self._current_step_index)
|
||||
self._running = False
|
||||
return
|
||||
|
||||
# 1. Wait for cooldown
|
||||
await self._cooldown.wait(self._character_name)
|
||||
|
||||
# 2. Fetch character
|
||||
character = await self._client.get_character(self._character_name)
|
||||
|
||||
# 3. Ask strategy for next action
|
||||
plan = await self._strategy.next_action(character)
|
||||
|
||||
strategy_completed = plan.action_type == ActionType.COMPLETE
|
||||
|
||||
# 4. Check transition condition BEFORE executing the action
|
||||
step = self._steps[self._current_step_index]
|
||||
transition = step.get("transition")
|
||||
|
||||
if transition is not None:
|
||||
should_advance = await self._transition_evaluator.should_transition(
|
||||
transition,
|
||||
character,
|
||||
actions_count=self._step_actions,
|
||||
step_start_time=self._step_start_time,
|
||||
strategy_completed=strategy_completed,
|
||||
)
|
||||
if should_advance:
|
||||
await self._advance_step()
|
||||
return
|
||||
|
||||
# 5. If strategy completed and no transition, treat it as step done
|
||||
if strategy_completed:
|
||||
if transition is None:
|
||||
# No explicit transition means strategy_complete is the implicit trigger
|
||||
await self._advance_step()
|
||||
return
|
||||
# Strategy completed but transition not met yet -- idle
|
||||
await asyncio.sleep(1)
|
||||
return
|
||||
|
||||
if plan.action_type == ActionType.IDLE:
|
||||
await asyncio.sleep(1)
|
||||
return
|
||||
|
||||
# 6. Execute the action
|
||||
result = await self._execute_action(plan)
|
||||
|
||||
# 7. Update cooldown
|
||||
self._update_cooldown_from_result(result)
|
||||
|
||||
# 8. Record
|
||||
self._total_actions += 1
|
||||
self._step_actions += 1
|
||||
await self._log_action(plan, success=True)
|
||||
|
||||
# 9. Publish
|
||||
await self._publish_action(
|
||||
plan.action_type.value,
|
||||
success=True,
|
||||
details={
|
||||
"params": plan.params,
|
||||
"reason": plan.reason,
|
||||
"strategy_state": self._strategy.get_state() if self._strategy else "",
|
||||
"step_index": self._current_step_index,
|
||||
},
|
||||
)
|
||||
await self._publish(
|
||||
"character_update",
|
||||
{"character_name": self._character_name},
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Step management
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _init_step(self, index: int) -> None:
|
||||
"""Initialize a strategy for the step at the given index."""
|
||||
if index < 0 or index >= len(self._steps):
|
||||
self._strategy = None
|
||||
return
|
||||
|
||||
step = self._steps[index]
|
||||
self._current_step_index = index
|
||||
self._step_actions = 0
|
||||
self._step_start_time = time.time()
|
||||
self._transition_evaluator.reset()
|
||||
|
||||
try:
|
||||
self._strategy = self._strategy_factory(
|
||||
step["strategy_type"],
|
||||
step.get("config", {}),
|
||||
)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Failed to create strategy for workflow %d step %d",
|
||||
self._workflow_id,
|
||||
index,
|
||||
)
|
||||
self._strategy = None
|
||||
|
||||
logger.info(
|
||||
"Workflow %d initialized step %d/%d: %s (%s)",
|
||||
self._workflow_id,
|
||||
index + 1,
|
||||
len(self._steps),
|
||||
step.get("name", ""),
|
||||
step.get("strategy_type", ""),
|
||||
)
|
||||
|
||||
async def _advance_step(self) -> None:
|
||||
"""Advance to the next step or finish the workflow."""
|
||||
# Record completed step
|
||||
step = self._steps[self._current_step_index]
|
||||
self._step_history.append({
|
||||
"step_id": step.get("id", ""),
|
||||
"step_name": step.get("name", ""),
|
||||
"actions_count": self._step_actions,
|
||||
"completed_at": datetime.now(timezone.utc).isoformat(),
|
||||
})
|
||||
|
||||
logger.info(
|
||||
"Workflow %d step %d completed (%s, %d actions)",
|
||||
self._workflow_id,
|
||||
self._current_step_index,
|
||||
step.get("name", ""),
|
||||
self._step_actions,
|
||||
)
|
||||
|
||||
next_index = self._current_step_index + 1
|
||||
|
||||
if next_index >= len(self._steps):
|
||||
# Reached end of steps
|
||||
if self._loop:
|
||||
self._loop_count += 1
|
||||
if self._max_loops > 0 and self._loop_count >= self._max_loops:
|
||||
# Hit loop limit
|
||||
await self._finalize_run(status="completed")
|
||||
self._running = False
|
||||
await self._publish_status("completed")
|
||||
return
|
||||
|
||||
# Loop back to step 0
|
||||
logger.info(
|
||||
"Workflow %d looping (loop %d)",
|
||||
self._workflow_id,
|
||||
self._loop_count,
|
||||
)
|
||||
self._init_step(0)
|
||||
else:
|
||||
# No loop, workflow complete
|
||||
await self._finalize_run(status="completed")
|
||||
self._running = False
|
||||
await self._publish_status("completed")
|
||||
return
|
||||
else:
|
||||
# Advance to next step
|
||||
self._init_step(next_index)
|
||||
|
||||
# Update run record
|
||||
await self._update_run_progress()
|
||||
await self._publish_status("running")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Action execution (mirrors AutomationRunner._execute_action)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def _execute_action(self, plan: ActionPlan) -> dict[str, Any]:
|
||||
return await execute_action(self._client, self._character_name, plan)
|
||||
|
||||
def _update_cooldown_from_result(self, result: dict[str, Any]) -> None:
|
||||
cooldown = result.get("cooldown")
|
||||
if cooldown is None:
|
||||
return
|
||||
self._cooldown.update(
|
||||
self._character_name,
|
||||
cooldown.get("total_seconds", 0),
|
||||
cooldown.get("expiration"),
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Database helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def _log_action(self, plan: ActionPlan, success: bool) -> None:
|
||||
try:
|
||||
async with self._db_factory() as db:
|
||||
log = AutomationLog(
|
||||
run_id=self._run_id,
|
||||
action_type=plan.action_type.value,
|
||||
details={
|
||||
"params": plan.params,
|
||||
"reason": plan.reason,
|
||||
"strategy_state": self._strategy.get_state() if self._strategy else "",
|
||||
"workflow_id": self._workflow_id,
|
||||
"step_index": self._current_step_index,
|
||||
},
|
||||
success=success,
|
||||
)
|
||||
db.add(log)
|
||||
await db.commit()
|
||||
except Exception:
|
||||
logger.exception("Failed to log workflow action for run %d", self._run_id)
|
||||
|
||||
async def _update_run_status(self, status: str) -> None:
|
||||
try:
|
||||
async with self._db_factory() as db:
|
||||
stmt = select(WorkflowRun).where(WorkflowRun.id == self._run_id)
|
||||
result = await db.execute(stmt)
|
||||
run = result.scalar_one_or_none()
|
||||
if run is not None:
|
||||
run.status = status
|
||||
await db.commit()
|
||||
except Exception:
|
||||
logger.exception("Failed to update workflow run %d status", self._run_id)
|
||||
|
||||
async def _update_run_progress(self) -> None:
|
||||
try:
|
||||
async with self._db_factory() as db:
|
||||
stmt = select(WorkflowRun).where(WorkflowRun.id == self._run_id)
|
||||
result = await db.execute(stmt)
|
||||
run = result.scalar_one_or_none()
|
||||
if run is not None:
|
||||
run.current_step_index = self._current_step_index
|
||||
run.current_step_id = self.current_step_id
|
||||
run.loop_count = self._loop_count
|
||||
run.total_actions_count = self._total_actions
|
||||
run.step_actions_count = self._step_actions
|
||||
run.step_history = self._step_history
|
||||
await db.commit()
|
||||
except Exception:
|
||||
logger.exception("Failed to update workflow run %d progress", self._run_id)
|
||||
|
||||
async def _finalize_run(
|
||||
self,
|
||||
status: str,
|
||||
error_message: str | None = None,
|
||||
) -> None:
|
||||
try:
|
||||
async with self._db_factory() as db:
|
||||
stmt = select(WorkflowRun).where(WorkflowRun.id == self._run_id)
|
||||
result = await db.execute(stmt)
|
||||
run = result.scalar_one_or_none()
|
||||
if run is not None:
|
||||
run.status = status
|
||||
run.stopped_at = datetime.now(timezone.utc)
|
||||
run.current_step_index = self._current_step_index
|
||||
run.current_step_id = self.current_step_id
|
||||
run.loop_count = self._loop_count
|
||||
run.total_actions_count = self._total_actions
|
||||
run.step_actions_count = self._step_actions
|
||||
run.step_history = self._step_history
|
||||
if error_message:
|
||||
run.error_message = error_message
|
||||
await db.commit()
|
||||
except Exception:
|
||||
logger.exception("Failed to finalize workflow run %d", self._run_id)
|
||||
|
|
@ -1,12 +1,29 @@
|
|||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import sys
|
||||
from contextlib import asynccontextmanager
|
||||
from collections.abc import AsyncGenerator
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from app.config import settings
|
||||
|
||||
# ---- Sentry (conditional) ----
|
||||
if settings.sentry_dsn:
|
||||
try:
|
||||
import sentry_sdk
|
||||
|
||||
sentry_sdk.init(
|
||||
dsn=settings.sentry_dsn,
|
||||
environment=settings.environment,
|
||||
traces_sample_rate=0.2,
|
||||
send_default_pii=False,
|
||||
)
|
||||
except Exception:
|
||||
pass # Sentry is optional; don't block startup
|
||||
from app.database import async_session_factory, engine, Base
|
||||
from app.services.artifacts_client import ArtifactsClient
|
||||
from app.services.character_service import CharacterService
|
||||
|
|
@ -16,8 +33,11 @@ from app.services.game_data_cache import GameDataCacheService
|
|||
from app.models import game_cache as _game_cache_model # noqa: F401
|
||||
from app.models import character_snapshot as _snapshot_model # noqa: F401
|
||||
from app.models import automation as _automation_model # noqa: F401
|
||||
from app.models import workflow as _workflow_model # noqa: F401
|
||||
from app.models import price_history as _price_history_model # noqa: F401
|
||||
from app.models import event_log as _event_log_model # noqa: F401
|
||||
from app.models import app_error as _app_error_model # noqa: F401
|
||||
from app.models import pipeline as _pipeline_model # noqa: F401
|
||||
|
||||
# Import routers
|
||||
from app.api.characters import router as characters_router
|
||||
|
|
@ -30,11 +50,17 @@ from app.api.exchange import router as exchange_router
|
|||
from app.api.events import router as events_router
|
||||
from app.api.logs import router as logs_router
|
||||
from app.api.auth import router as auth_router
|
||||
from app.api.workflows import router as workflows_router
|
||||
from app.api.errors import router as errors_router
|
||||
from app.api.pipelines import router as pipelines_router
|
||||
|
||||
# Automation engine
|
||||
from app.engine.pathfinder import Pathfinder
|
||||
from app.engine.manager import AutomationManager
|
||||
|
||||
# Error-handling middleware
|
||||
from app.middleware.error_handler import ErrorHandlerMiddleware
|
||||
|
||||
# Exchange service
|
||||
from app.services.exchange_service import ExchangeService
|
||||
|
||||
|
|
@ -45,10 +71,29 @@ from app.websocket.handlers import GameEventHandler
|
|||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
||||
)
|
||||
|
||||
class _JSONFormatter(logging.Formatter):
|
||||
"""Structured JSON log formatter for production."""
|
||||
|
||||
def format(self, record: logging.LogRecord) -> str:
|
||||
log_entry = {
|
||||
"ts": datetime.now(timezone.utc).isoformat(),
|
||||
"level": record.levelname,
|
||||
"logger": record.name,
|
||||
"msg": record.getMessage(),
|
||||
}
|
||||
if record.exc_info and record.exc_info[1]:
|
||||
log_entry["exception"] = self.formatException(record.exc_info)
|
||||
return json.dumps(log_entry, default=str)
|
||||
|
||||
|
||||
_handler = logging.StreamHandler(sys.stdout)
|
||||
if settings.environment != "development":
|
||||
_handler.setFormatter(_JSONFormatter())
|
||||
else:
|
||||
_handler.setFormatter(logging.Formatter("%(asctime)s [%(levelname)s] %(name)s: %(message)s"))
|
||||
|
||||
logging.basicConfig(level=logging.INFO, handlers=[_handler])
|
||||
|
||||
|
||||
async def _snapshot_loop(
|
||||
|
|
@ -218,6 +263,7 @@ app = FastAPI(
|
|||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
app.add_middleware(ErrorHandlerMiddleware)
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=settings.cors_origins,
|
||||
|
|
@ -237,6 +283,9 @@ app.include_router(exchange_router)
|
|||
app.include_router(events_router)
|
||||
app.include_router(logs_router)
|
||||
app.include_router(auth_router)
|
||||
app.include_router(workflows_router)
|
||||
app.include_router(errors_router)
|
||||
app.include_router(pipelines_router)
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
|
|
|
|||
0
backend/app/middleware/__init__.py
Normal file
0
backend/app/middleware/__init__.py
Normal file
71
backend/app/middleware/error_handler.py
Normal file
71
backend/app/middleware/error_handler.py
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
"""Global error-handling middleware.
|
||||
|
||||
Sets a per-request correlation ID and catches unhandled exceptions,
|
||||
logging them to the database (and Sentry when configured).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
|
||||
from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import JSONResponse, Response
|
||||
|
||||
from app.database import async_session_factory
|
||||
from app.services.error_service import hash_token, log_error, new_correlation_id
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ErrorHandlerMiddleware(BaseHTTPMiddleware):
|
||||
async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response:
|
||||
cid = new_correlation_id()
|
||||
start = time.monotonic()
|
||||
|
||||
try:
|
||||
response = await call_next(request)
|
||||
return response
|
||||
except Exception as exc:
|
||||
duration = time.monotonic() - start
|
||||
logger.exception(
|
||||
"Unhandled exception on %s %s (cid=%s, %.3fs)",
|
||||
request.method,
|
||||
request.url.path,
|
||||
cid,
|
||||
duration,
|
||||
)
|
||||
|
||||
# Try to capture in Sentry
|
||||
try:
|
||||
import sentry_sdk
|
||||
|
||||
sentry_sdk.capture_exception(exc)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Persist to DB
|
||||
token = request.headers.get("X-API-Token")
|
||||
await log_error(
|
||||
async_session_factory,
|
||||
severity="error",
|
||||
source="middleware",
|
||||
exc=exc,
|
||||
context={
|
||||
"method": request.method,
|
||||
"path": request.url.path,
|
||||
"query": str(request.url.query),
|
||||
"duration_s": round(duration, 3),
|
||||
},
|
||||
correlation_id=cid,
|
||||
user_token_hash=hash_token(token) if token else None,
|
||||
)
|
||||
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={
|
||||
"detail": "Internal server error",
|
||||
"correlation_id": cid,
|
||||
},
|
||||
)
|
||||
|
|
@ -1,15 +1,23 @@
|
|||
from app.models.app_error import AppError
|
||||
from app.models.automation import AutomationConfig, AutomationLog, AutomationRun
|
||||
from app.models.character_snapshot import CharacterSnapshot
|
||||
from app.models.event_log import EventLog
|
||||
from app.models.game_cache import GameDataCache
|
||||
from app.models.pipeline import PipelineConfig, PipelineRun
|
||||
from app.models.price_history import PriceHistory
|
||||
from app.models.workflow import WorkflowConfig, WorkflowRun
|
||||
|
||||
__all__ = [
|
||||
"AppError",
|
||||
"AutomationConfig",
|
||||
"AutomationLog",
|
||||
"AutomationRun",
|
||||
"CharacterSnapshot",
|
||||
"EventLog",
|
||||
"GameDataCache",
|
||||
"PipelineConfig",
|
||||
"PipelineRun",
|
||||
"PriceHistory",
|
||||
"WorkflowConfig",
|
||||
"WorkflowRun",
|
||||
]
|
||||
|
|
|
|||
51
backend/app/models/app_error.py
Normal file
51
backend/app/models/app_error.py
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
"""Application error model for tracking errors across the system."""
|
||||
|
||||
from sqlalchemy import Boolean, Column, DateTime, Integer, String, Text
|
||||
from sqlalchemy.dialects.postgresql import JSON
|
||||
from sqlalchemy.sql import func
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class AppError(Base):
|
||||
__tablename__ = "app_errors"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
severity = Column(
|
||||
String(20),
|
||||
nullable=False,
|
||||
default="error",
|
||||
comment="error | warning | critical",
|
||||
)
|
||||
source = Column(
|
||||
String(50),
|
||||
nullable=False,
|
||||
comment="backend | frontend | automation | middleware",
|
||||
)
|
||||
error_type = Column(
|
||||
String(200),
|
||||
nullable=False,
|
||||
comment="Exception class name or error category",
|
||||
)
|
||||
message = Column(Text, nullable=False)
|
||||
stack_trace = Column(Text, nullable=True)
|
||||
context = Column(JSON, nullable=True, comment="Arbitrary JSON context")
|
||||
user_token_hash = Column(
|
||||
String(64),
|
||||
nullable=True,
|
||||
index=True,
|
||||
comment="SHA-256 hash of the user API token (for scoping errors per user)",
|
||||
)
|
||||
correlation_id = Column(
|
||||
String(36),
|
||||
nullable=True,
|
||||
index=True,
|
||||
comment="Request correlation ID (UUID)",
|
||||
)
|
||||
resolved = Column(Boolean, nullable=False, default=False, server_default="false")
|
||||
created_at = Column(
|
||||
DateTime(timezone=True),
|
||||
server_default=func.now(),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
98
backend/app/models/pipeline.py
Normal file
98
backend/app/models/pipeline.py
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, JSON, String, Text, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class PipelineConfig(Base):
|
||||
__tablename__ = "pipeline_configs"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
name: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
description: Mapped[str] = mapped_column(Text, nullable=False, default="")
|
||||
stages: Mapped[list] = mapped_column(
|
||||
JSON,
|
||||
nullable=False,
|
||||
default=list,
|
||||
comment="JSON array of pipeline stages, each with id, name, character_steps[]",
|
||||
)
|
||||
loop: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
max_loops: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
enabled: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
server_default=func.now(),
|
||||
nullable=False,
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
server_default=func.now(),
|
||||
onupdate=func.now(),
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
runs: Mapped[list["PipelineRun"]] = relationship(
|
||||
back_populates="pipeline",
|
||||
cascade="all, delete-orphan",
|
||||
order_by="PipelineRun.started_at.desc()",
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return (
|
||||
f"<PipelineConfig(id={self.id}, name={self.name!r}, "
|
||||
f"stages={len(self.stages)})>"
|
||||
)
|
||||
|
||||
|
||||
class PipelineRun(Base):
|
||||
__tablename__ = "pipeline_runs"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
pipeline_id: Mapped[int] = mapped_column(
|
||||
Integer,
|
||||
ForeignKey("pipeline_configs.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
status: Mapped[str] = mapped_column(
|
||||
String(20),
|
||||
nullable=False,
|
||||
default="running",
|
||||
comment="Status: running, paused, stopped, completed, error",
|
||||
)
|
||||
current_stage_index: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
current_stage_id: Mapped[str] = mapped_column(String(100), nullable=False, default="")
|
||||
loop_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
total_actions_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
character_states: Mapped[dict] = mapped_column(
|
||||
JSON,
|
||||
nullable=False,
|
||||
default=dict,
|
||||
comment="Per-character state: {char_name: {status, step_id, actions_count, error}}",
|
||||
)
|
||||
stage_history: Mapped[list] = mapped_column(
|
||||
JSON,
|
||||
nullable=False,
|
||||
default=list,
|
||||
comment="JSON array of completed stage records",
|
||||
)
|
||||
started_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
server_default=func.now(),
|
||||
nullable=False,
|
||||
)
|
||||
stopped_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=True,
|
||||
)
|
||||
error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
pipeline: Mapped["PipelineConfig"] = relationship(back_populates="runs")
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return (
|
||||
f"<PipelineRun(id={self.id}, pipeline_id={self.pipeline_id}, "
|
||||
f"status={self.status!r}, stage={self.current_stage_index})>"
|
||||
)
|
||||
94
backend/app/models/workflow.py
Normal file
94
backend/app/models/workflow.py
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, JSON, String, Text, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class WorkflowConfig(Base):
|
||||
__tablename__ = "workflow_configs"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
name: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
character_name: Mapped[str] = mapped_column(String(100), nullable=False, index=True)
|
||||
description: Mapped[str] = mapped_column(Text, nullable=False, default="")
|
||||
steps: Mapped[list] = mapped_column(
|
||||
JSON,
|
||||
nullable=False,
|
||||
default=list,
|
||||
comment="JSON array of workflow steps, each with id, name, strategy_type, config, transition",
|
||||
)
|
||||
loop: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
max_loops: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
enabled: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
server_default=func.now(),
|
||||
nullable=False,
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
server_default=func.now(),
|
||||
onupdate=func.now(),
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
runs: Mapped[list["WorkflowRun"]] = relationship(
|
||||
back_populates="workflow",
|
||||
cascade="all, delete-orphan",
|
||||
order_by="WorkflowRun.started_at.desc()",
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return (
|
||||
f"<WorkflowConfig(id={self.id}, name={self.name!r}, "
|
||||
f"character={self.character_name!r}, steps={len(self.steps)})>"
|
||||
)
|
||||
|
||||
|
||||
class WorkflowRun(Base):
|
||||
__tablename__ = "workflow_runs"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
workflow_id: Mapped[int] = mapped_column(
|
||||
Integer,
|
||||
ForeignKey("workflow_configs.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
status: Mapped[str] = mapped_column(
|
||||
String(20),
|
||||
nullable=False,
|
||||
default="running",
|
||||
comment="Status: running, paused, stopped, completed, error",
|
||||
)
|
||||
current_step_index: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
current_step_id: Mapped[str] = mapped_column(String(100), nullable=False, default="")
|
||||
loop_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
total_actions_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
step_actions_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
started_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
server_default=func.now(),
|
||||
nullable=False,
|
||||
)
|
||||
stopped_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=True,
|
||||
)
|
||||
error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
step_history: Mapped[list] = mapped_column(
|
||||
JSON,
|
||||
nullable=False,
|
||||
default=list,
|
||||
comment="JSON array of completed step records",
|
||||
)
|
||||
|
||||
workflow: Mapped["WorkflowConfig"] = relationship(back_populates="runs")
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return (
|
||||
f"<WorkflowRun(id={self.id}, workflow_id={self.workflow_id}, "
|
||||
f"status={self.status!r}, step={self.current_step_index})>"
|
||||
)
|
||||
44
backend/app/schemas/errors.py
Normal file
44
backend/app/schemas/errors.py
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
"""Pydantic schemas for the errors API."""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class AppErrorResponse(BaseModel):
|
||||
id: int
|
||||
severity: str
|
||||
source: str
|
||||
error_type: str
|
||||
message: str
|
||||
stack_trace: str | None = None
|
||||
context: dict[str, Any] | None = None
|
||||
correlation_id: str | None = None
|
||||
resolved: bool
|
||||
created_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class AppErrorListResponse(BaseModel):
|
||||
errors: list[AppErrorResponse]
|
||||
total: int
|
||||
page: int
|
||||
pages: int
|
||||
|
||||
|
||||
class AppErrorStats(BaseModel):
|
||||
total: int = 0
|
||||
unresolved: int = 0
|
||||
last_hour: int = 0
|
||||
by_severity: dict[str, int] = Field(default_factory=dict)
|
||||
by_source: dict[str, int] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class FrontendErrorReport(BaseModel):
|
||||
error_type: str = "FrontendError"
|
||||
message: str
|
||||
stack_trace: str | None = None
|
||||
context: dict[str, Any] | None = None
|
||||
severity: str = "error"
|
||||
127
backend/app/schemas/pipeline.py
Normal file
127
backend/app/schemas/pipeline.py
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.schemas.workflow import TransitionConditionSchema
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Character step within a stage
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class CharacterStepSchema(BaseModel):
|
||||
"""A single character's work within a pipeline stage."""
|
||||
|
||||
id: str = Field(..., description="Unique step identifier (e.g. 'cs_1a')")
|
||||
character_name: str = Field(..., min_length=1, max_length=100)
|
||||
strategy_type: str = Field(
|
||||
...,
|
||||
description="Strategy type: combat, gathering, crafting, trading, task, leveling",
|
||||
)
|
||||
config: dict = Field(default_factory=dict, description="Strategy-specific configuration")
|
||||
transition: TransitionConditionSchema | None = Field(
|
||||
default=None,
|
||||
description="Condition for this character-step to be considered done",
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pipeline stage
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class PipelineStageSchema(BaseModel):
|
||||
"""A stage in the pipeline — character steps within it run in parallel."""
|
||||
|
||||
id: str = Field(..., description="Unique stage identifier (e.g. 'stage_1')")
|
||||
name: str = Field(..., min_length=1, max_length=100)
|
||||
character_steps: list[CharacterStepSchema] = Field(..., min_length=1)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Request schemas
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class PipelineConfigCreate(BaseModel):
|
||||
name: str = Field(..., min_length=1, max_length=100)
|
||||
description: str = Field(default="")
|
||||
stages: list[PipelineStageSchema] = Field(..., min_length=1)
|
||||
loop: bool = Field(default=False)
|
||||
max_loops: int = Field(default=0, ge=0)
|
||||
|
||||
|
||||
class PipelineConfigUpdate(BaseModel):
|
||||
name: str | None = Field(default=None, min_length=1, max_length=100)
|
||||
description: str | None = None
|
||||
stages: list[PipelineStageSchema] | None = Field(default=None, min_length=1)
|
||||
loop: bool | None = None
|
||||
max_loops: int | None = Field(default=None, ge=0)
|
||||
enabled: bool | None = None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Response schemas
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class PipelineConfigResponse(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
description: str
|
||||
stages: list[dict]
|
||||
loop: bool
|
||||
max_loops: int
|
||||
enabled: bool
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class PipelineRunResponse(BaseModel):
|
||||
id: int
|
||||
pipeline_id: int
|
||||
status: str
|
||||
current_stage_index: int
|
||||
current_stage_id: str
|
||||
loop_count: int
|
||||
total_actions_count: int
|
||||
character_states: dict
|
||||
stage_history: list[dict] = Field(default_factory=list)
|
||||
started_at: datetime
|
||||
stopped_at: datetime | None = None
|
||||
error_message: str | None = None
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class CharacterStateResponse(BaseModel):
|
||||
"""Status of a single character within an active pipeline."""
|
||||
|
||||
character_name: str
|
||||
status: str # running, completed, error, idle
|
||||
step_id: str = ""
|
||||
actions_count: int = 0
|
||||
strategy_state: str = ""
|
||||
error: str | None = None
|
||||
|
||||
|
||||
class PipelineStatusResponse(BaseModel):
|
||||
pipeline_id: int
|
||||
status: str
|
||||
run_id: int | None = None
|
||||
current_stage_index: int = 0
|
||||
current_stage_id: str = ""
|
||||
total_stages: int = 0
|
||||
loop_count: int = 0
|
||||
total_actions_count: int = 0
|
||||
character_states: list[CharacterStateResponse] = Field(default_factory=list)
|
||||
|
||||
|
||||
class PipelineConfigDetailResponse(BaseModel):
|
||||
"""Pipeline config with its run history."""
|
||||
|
||||
config: PipelineConfigResponse
|
||||
runs: list[PipelineRunResponse] = Field(default_factory=list)
|
||||
146
backend/app/schemas/workflow.py
Normal file
146
backend/app/schemas/workflow.py
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Transition conditions
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TransitionConditionSchema(BaseModel):
|
||||
"""Defines when a workflow step should transition to the next step."""
|
||||
|
||||
type: str = Field(
|
||||
...,
|
||||
description=(
|
||||
"Condition type: strategy_complete, loops_completed, inventory_full, "
|
||||
"inventory_item_count, bank_item_count, skill_level, gold_amount, "
|
||||
"actions_count, timer"
|
||||
),
|
||||
)
|
||||
operator: str = Field(
|
||||
default=">=",
|
||||
description="Comparison operator: >=, <=, ==, >, <",
|
||||
)
|
||||
value: int = Field(
|
||||
default=0,
|
||||
description="Target value for the condition",
|
||||
)
|
||||
item_code: str = Field(
|
||||
default="",
|
||||
description="Item code (for inventory_item_count, bank_item_count)",
|
||||
)
|
||||
skill: str = Field(
|
||||
default="",
|
||||
description="Skill name (for skill_level condition)",
|
||||
)
|
||||
seconds: int = Field(
|
||||
default=0,
|
||||
ge=0,
|
||||
description="Duration in seconds (for timer condition)",
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Workflow steps
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class WorkflowStepSchema(BaseModel):
|
||||
"""A single step within a workflow pipeline."""
|
||||
|
||||
id: str = Field(..., description="Unique step identifier (e.g. 'step_1')")
|
||||
name: str = Field(..., min_length=1, max_length=100, description="Human-readable step name")
|
||||
strategy_type: str = Field(
|
||||
...,
|
||||
description="Strategy type: combat, gathering, crafting, trading, task, leveling",
|
||||
)
|
||||
config: dict = Field(default_factory=dict, description="Strategy-specific configuration")
|
||||
transition: TransitionConditionSchema | None = Field(
|
||||
default=None,
|
||||
description="Condition to advance to the next step (None = run until strategy completes)",
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Request schemas
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class WorkflowConfigCreate(BaseModel):
|
||||
name: str = Field(..., min_length=1, max_length=100)
|
||||
character_name: str = Field(..., min_length=1, max_length=100)
|
||||
description: str = Field(default="")
|
||||
steps: list[WorkflowStepSchema] = Field(..., min_length=1)
|
||||
loop: bool = Field(default=False)
|
||||
max_loops: int = Field(default=0, ge=0)
|
||||
|
||||
|
||||
class WorkflowConfigUpdate(BaseModel):
|
||||
name: str | None = Field(default=None, min_length=1, max_length=100)
|
||||
description: str | None = None
|
||||
steps: list[WorkflowStepSchema] | None = Field(default=None, min_length=1)
|
||||
loop: bool | None = None
|
||||
max_loops: int | None = Field(default=None, ge=0)
|
||||
enabled: bool | None = None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Response schemas
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class WorkflowConfigResponse(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
character_name: str
|
||||
description: str
|
||||
steps: list[dict]
|
||||
loop: bool
|
||||
max_loops: int
|
||||
enabled: bool
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class WorkflowRunResponse(BaseModel):
|
||||
id: int
|
||||
workflow_id: int
|
||||
status: str
|
||||
current_step_index: int
|
||||
current_step_id: str
|
||||
loop_count: int
|
||||
total_actions_count: int
|
||||
step_actions_count: int
|
||||
started_at: datetime
|
||||
stopped_at: datetime | None = None
|
||||
error_message: str | None = None
|
||||
step_history: list[dict] = Field(default_factory=list)
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class WorkflowStatusResponse(BaseModel):
|
||||
workflow_id: int
|
||||
character_name: str
|
||||
status: str
|
||||
run_id: int | None = None
|
||||
current_step_index: int = 0
|
||||
current_step_id: str = ""
|
||||
total_steps: int = 0
|
||||
loop_count: int = 0
|
||||
total_actions_count: int = 0
|
||||
step_actions_count: int = 0
|
||||
strategy_state: str = ""
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class WorkflowConfigDetailResponse(BaseModel):
|
||||
"""Workflow config with its run history."""
|
||||
|
||||
config: WorkflowConfigResponse
|
||||
runs: list[WorkflowRunResponse] = Field(default_factory=list)
|
||||
|
|
@ -53,6 +53,9 @@ class ArtifactsClient:
|
|||
"""Async HTTP client for the Artifacts MMO API.
|
||||
|
||||
Handles authentication, rate limiting, pagination, and retry logic.
|
||||
Supports per-request token overrides for multi-user scenarios via
|
||||
the ``with_token()`` method which creates a lightweight clone that
|
||||
shares the underlying connection pool.
|
||||
"""
|
||||
|
||||
MAX_RETRIES: int = 3
|
||||
|
|
@ -63,7 +66,6 @@ class ArtifactsClient:
|
|||
self._client = httpx.AsyncClient(
|
||||
base_url=settings.artifacts_api_url,
|
||||
headers={
|
||||
"Authorization": f"Bearer {self._token}",
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json",
|
||||
},
|
||||
|
|
@ -78,6 +80,21 @@ class ArtifactsClient:
|
|||
window_seconds=settings.data_rate_window,
|
||||
)
|
||||
|
||||
# -- Multi-user support ------------------------------------------------
|
||||
|
||||
def with_token(self, token: str) -> "ArtifactsClient":
|
||||
"""Return a lightweight clone that uses *token* for requests.
|
||||
|
||||
The clone shares the httpx connection pool and rate limiters with the
|
||||
original instance so there is no overhead in creating one per request.
|
||||
"""
|
||||
clone = object.__new__(ArtifactsClient)
|
||||
clone._token = token
|
||||
clone._client = self._client # shared connection pool
|
||||
clone._action_limiter = self._action_limiter
|
||||
clone._data_limiter = self._data_limiter
|
||||
return clone
|
||||
|
||||
@property
|
||||
def has_token(self) -> bool:
|
||||
return bool(self._token)
|
||||
|
|
@ -91,14 +108,12 @@ class ArtifactsClient:
|
|||
return "user"
|
||||
|
||||
def set_token(self, token: str) -> None:
|
||||
"""Update the API token at runtime."""
|
||||
"""Update the default API token at runtime (used by background tasks)."""
|
||||
self._token = token
|
||||
self._client.headers["Authorization"] = f"Bearer {token}"
|
||||
|
||||
def clear_token(self) -> None:
|
||||
"""Revert to the env token (or empty if none)."""
|
||||
self._token = settings.artifacts_token
|
||||
self._client.headers["Authorization"] = f"Bearer {self._token}"
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Low-level request helpers
|
||||
|
|
@ -115,6 +130,10 @@ class ArtifactsClient:
|
|||
) -> dict[str, Any]:
|
||||
last_exc: Exception | None = None
|
||||
|
||||
# Send Authorization per-request so clones created by with_token()
|
||||
# use their own token without affecting other concurrent requests.
|
||||
auth_headers = {"Authorization": f"Bearer {self._token}"} if self._token else {}
|
||||
|
||||
for attempt in range(1, self.MAX_RETRIES + 1):
|
||||
await limiter.acquire()
|
||||
try:
|
||||
|
|
@ -123,6 +142,7 @@ class ArtifactsClient:
|
|||
path,
|
||||
json=json_body,
|
||||
params=params,
|
||||
headers=auth_headers,
|
||||
)
|
||||
|
||||
if response.status_code == 429:
|
||||
|
|
@ -136,6 +156,27 @@ class ArtifactsClient:
|
|||
await asyncio.sleep(retry_after)
|
||||
continue
|
||||
|
||||
# 498 = character in cooldown – wait and retry
|
||||
if response.status_code == 498:
|
||||
try:
|
||||
body = response.json()
|
||||
cooldown = body.get("data", {}).get("cooldown", {})
|
||||
wait_seconds = cooldown.get("total_seconds", 5)
|
||||
except Exception:
|
||||
wait_seconds = 5
|
||||
logger.info(
|
||||
"Character cooldown on %s %s, waiting %.1fs (attempt %d/%d)",
|
||||
method,
|
||||
path,
|
||||
wait_seconds,
|
||||
attempt,
|
||||
self.MAX_RETRIES,
|
||||
)
|
||||
await asyncio.sleep(wait_seconds)
|
||||
if attempt < self.MAX_RETRIES:
|
||||
continue
|
||||
response.raise_for_status()
|
||||
|
||||
if response.status_code >= 500:
|
||||
logger.warning(
|
||||
"Server error %d on %s %s (attempt %d/%d)",
|
||||
|
|
@ -428,11 +469,11 @@ class ArtifactsClient:
|
|||
return result.get("data", {})
|
||||
|
||||
async def ge_buy(
|
||||
self, name: str, code: str, quantity: int, price: int
|
||||
self, name: str, order_id: str, quantity: int
|
||||
) -> dict[str, Any]:
|
||||
result = await self._post_action(
|
||||
f"/my/{name}/action/grandexchange/buy",
|
||||
json_body={"code": code, "quantity": quantity, "price": price},
|
||||
json_body={"id": order_id, "quantity": quantity},
|
||||
)
|
||||
return result.get("data", {})
|
||||
|
||||
|
|
@ -440,20 +481,29 @@ class ArtifactsClient:
|
|||
self, name: str, code: str, quantity: int, price: int
|
||||
) -> dict[str, Any]:
|
||||
result = await self._post_action(
|
||||
f"/my/{name}/action/grandexchange/sell",
|
||||
f"/my/{name}/action/grandexchange/create-sell-order",
|
||||
json_body={"code": code, "quantity": quantity, "price": price},
|
||||
)
|
||||
return result.get("data", {})
|
||||
|
||||
async def ge_buy_order(
|
||||
async def ge_create_buy_order(
|
||||
self, name: str, code: str, quantity: int, price: int
|
||||
) -> dict[str, Any]:
|
||||
result = await self._post_action(
|
||||
f"/my/{name}/action/grandexchange/buy",
|
||||
f"/my/{name}/action/grandexchange/create-buy-order",
|
||||
json_body={"code": code, "quantity": quantity, "price": price},
|
||||
)
|
||||
return result.get("data", {})
|
||||
|
||||
async def ge_fill_buy_order(
|
||||
self, name: str, order_id: str, quantity: int
|
||||
) -> dict[str, Any]:
|
||||
result = await self._post_action(
|
||||
f"/my/{name}/action/grandexchange/fill",
|
||||
json_body={"id": order_id, "quantity": quantity},
|
||||
)
|
||||
return result.get("data", {})
|
||||
|
||||
async def ge_cancel(self, name: str, order_id: str) -> dict[str, Any]:
|
||||
result = await self._post_action(
|
||||
f"/my/{name}/action/grandexchange/cancel",
|
||||
|
|
@ -500,6 +550,27 @@ class ArtifactsClient:
|
|||
)
|
||||
return result.get("data", {})
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Data endpoints - Action Logs
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def get_logs(
|
||||
self,
|
||||
page: int = 1,
|
||||
size: int = 100,
|
||||
) -> dict[str, Any]:
|
||||
"""Get recent action logs for all characters (last 5000 actions)."""
|
||||
return await self._get("/my/logs", params={"page": page, "size": size})
|
||||
|
||||
async def get_character_logs(
|
||||
self,
|
||||
name: str,
|
||||
page: int = 1,
|
||||
size: int = 100,
|
||||
) -> dict[str, Any]:
|
||||
"""Get recent action logs for a specific character."""
|
||||
return await self._get(f"/my/logs/{name}", params={"page": page, "size": size})
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Lifecycle
|
||||
# ------------------------------------------------------------------
|
||||
|
|
|
|||
77
backend/app/services/error_service.py
Normal file
77
backend/app/services/error_service.py
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
"""Error logging service - writes errors to the database and optionally to Sentry."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
import traceback
|
||||
import uuid
|
||||
from contextvars import ContextVar
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||
|
||||
from app.models.app_error import AppError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Per-request correlation ID
|
||||
correlation_id_var: ContextVar[str | None] = ContextVar("correlation_id", default=None)
|
||||
|
||||
|
||||
def hash_token(token: str) -> str:
|
||||
"""Return a stable SHA-256 hex digest for a user API token."""
|
||||
return hashlib.sha256(token.encode()).hexdigest()
|
||||
|
||||
|
||||
def new_correlation_id() -> str:
|
||||
cid = uuid.uuid4().hex[:12]
|
||||
correlation_id_var.set(cid)
|
||||
return cid
|
||||
|
||||
|
||||
async def log_error(
|
||||
db_factory: async_sessionmaker[AsyncSession],
|
||||
*,
|
||||
severity: str = "error",
|
||||
source: str = "backend",
|
||||
error_type: str = "UnknownError",
|
||||
message: str = "",
|
||||
exc: BaseException | None = None,
|
||||
context: dict[str, Any] | None = None,
|
||||
correlation_id: str | None = None,
|
||||
user_token_hash: str | None = None,
|
||||
) -> AppError | None:
|
||||
"""Persist an error record to the database.
|
||||
|
||||
Returns the created AppError, or None if the DB write itself fails.
|
||||
"""
|
||||
stack_trace = None
|
||||
if exc is not None:
|
||||
stack_trace = "".join(traceback.format_exception(type(exc), exc, exc.__traceback__))
|
||||
if not error_type or error_type == "UnknownError":
|
||||
error_type = type(exc).__qualname__
|
||||
if not message:
|
||||
message = str(exc)
|
||||
|
||||
cid = correlation_id or correlation_id_var.get()
|
||||
|
||||
try:
|
||||
async with db_factory() as db:
|
||||
record = AppError(
|
||||
severity=severity,
|
||||
source=source,
|
||||
error_type=error_type,
|
||||
message=message[:4000],
|
||||
stack_trace=stack_trace[:10000] if stack_trace else None,
|
||||
context=context,
|
||||
correlation_id=cid,
|
||||
user_token_hash=user_token_hash,
|
||||
)
|
||||
db.add(record)
|
||||
await db.commit()
|
||||
await db.refresh(record)
|
||||
return record
|
||||
except Exception:
|
||||
logger.exception("Failed to persist error record to database")
|
||||
return None
|
||||
|
|
@ -13,6 +13,7 @@ dependencies = [
|
|||
"pydantic>=2.10.0",
|
||||
"pydantic-settings>=2.7.0",
|
||||
"websockets>=14.0",
|
||||
"sentry-sdk[fastapi]>=2.0.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
|
|
|
|||
|
|
@ -97,8 +97,9 @@ class TestActionType:
|
|||
expected = {
|
||||
"move", "fight", "gather", "rest", "equip", "unequip",
|
||||
"use_item", "deposit_item", "withdraw_item", "craft", "recycle",
|
||||
"ge_buy", "ge_sell", "ge_cancel",
|
||||
"task_new", "task_trade", "task_complete", "task_exchange",
|
||||
"ge_buy", "ge_create_buy", "ge_sell", "ge_fill", "ge_cancel",
|
||||
"task_new", "task_trade", "task_complete", "task_exchange", "task_cancel",
|
||||
"deposit_gold", "withdraw_gold", "npc_buy", "npc_sell",
|
||||
"idle", "complete",
|
||||
}
|
||||
actual = {at.value for at in ActionType}
|
||||
|
|
|
|||
|
|
@ -250,6 +250,30 @@ class TestCraftingStrategyDeposit:
|
|||
|
||||
@pytest.mark.asyncio
|
||||
async def test_deposit_items_at_bank(self, make_character, pathfinder_with_maps):
|
||||
pf = pathfinder_with_maps([
|
||||
(5, 5, "workshop", "weaponcrafting"),
|
||||
(10, 0, "bank", "bank"),
|
||||
])
|
||||
item = _make_craftable_item(materials=[("iron_ore", 3)])
|
||||
strategy = CraftingStrategy(
|
||||
{"item_code": "iron_sword", "quantity": 5}, # quantity > crafted
|
||||
pf,
|
||||
items_data=[item],
|
||||
)
|
||||
strategy._state = strategy._state.__class__("deposit")
|
||||
strategy._crafted_count = 2 # Still more to craft
|
||||
|
||||
char = make_character(
|
||||
x=10, y=0,
|
||||
inventory=[InventorySlot(slot=0, code="iron_sword", quantity=2)],
|
||||
)
|
||||
|
||||
plan = await strategy.next_action(char)
|
||||
assert plan.action_type == ActionType.DEPOSIT_ITEM
|
||||
assert plan.params["code"] == "iron_sword"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_complete_after_all_deposited(self, make_character, pathfinder_with_maps):
|
||||
pf = pathfinder_with_maps([
|
||||
(5, 5, "workshop", "weaponcrafting"),
|
||||
(10, 0, "bank", "bank"),
|
||||
|
|
@ -261,7 +285,7 @@ class TestCraftingStrategyDeposit:
|
|||
items_data=[item],
|
||||
)
|
||||
strategy._state = strategy._state.__class__("deposit")
|
||||
strategy._crafted_count = 1
|
||||
strategy._crafted_count = 1 # Already crafted target quantity
|
||||
|
||||
char = make_character(
|
||||
x=10, y=0,
|
||||
|
|
@ -269,8 +293,8 @@ class TestCraftingStrategyDeposit:
|
|||
)
|
||||
|
||||
plan = await strategy.next_action(char)
|
||||
assert plan.action_type == ActionType.DEPOSIT_ITEM
|
||||
assert plan.params["code"] == "iron_sword"
|
||||
# With crafted_count >= quantity, the top-level check returns COMPLETE
|
||||
assert plan.action_type == ActionType.COMPLETE
|
||||
|
||||
|
||||
class TestCraftingStrategyNoLocations:
|
||||
|
|
|
|||
|
|
@ -91,13 +91,34 @@ class TestLevelingStrategyEvaluation:
|
|||
assert plan.action_type == ActionType.COMPLETE
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_complete_when_no_skill_found(self, make_character, pathfinder_with_maps):
|
||||
async def test_complete_when_target_skill_at_max(self, make_character, pathfinder_with_maps):
|
||||
"""When the specific target_skill has reached max_level, strategy completes."""
|
||||
pf = pathfinder_with_maps([
|
||||
(3, 3, "resource", "copper_rocks"),
|
||||
(10, 0, "bank", "bank"),
|
||||
])
|
||||
resources = [ResourceSchema(name="Copper Rocks", code="copper_rocks", skill="mining", level=1)]
|
||||
strategy = LevelingStrategy(
|
||||
{"target_skill": "mining", "max_level": 10},
|
||||
pf,
|
||||
resources_data=resources,
|
||||
)
|
||||
char = make_character(x=0, y=0, mining_level=10)
|
||||
|
||||
plan = await strategy.next_action(char)
|
||||
assert plan.action_type == ActionType.COMPLETE
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_idle_when_all_skills_above_max_level(self, make_character, pathfinder_with_maps):
|
||||
"""When auto-picking skills but all are above max_level, falls through to IDLE.
|
||||
|
||||
NOTE: Current implementation only excludes one skill before proceeding,
|
||||
so it may IDLE rather than COMPLETE when all skills exceed max_level.
|
||||
"""
|
||||
pf = pathfinder_with_maps([
|
||||
(10, 0, "bank", "bank"),
|
||||
])
|
||||
strategy = LevelingStrategy({}, pf)
|
||||
# All skills at max_level with exclude set
|
||||
strategy._max_level = 5
|
||||
strategy = LevelingStrategy({"max_level": 5}, pf)
|
||||
char = make_character(
|
||||
x=0, y=0,
|
||||
mining_level=999,
|
||||
|
|
@ -106,8 +127,7 @@ class TestLevelingStrategyEvaluation:
|
|||
)
|
||||
|
||||
plan = await strategy.next_action(char)
|
||||
# Should complete since all skills are above max_level
|
||||
assert plan.action_type == ActionType.COMPLETE
|
||||
assert plan.action_type == ActionType.IDLE
|
||||
|
||||
|
||||
class TestLevelingStrategyGathering:
|
||||
|
|
@ -163,20 +183,24 @@ class TestLevelingStrategyCombat:
|
|||
"""Tests for combat leveling."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fight_for_combat_leveling(self, make_character, pathfinder_with_maps):
|
||||
async def test_move_to_monster_for_combat_leveling(self, make_character, pathfinder_with_maps):
|
||||
"""Combat leveling moves to a monster tile for fighting."""
|
||||
pf = pathfinder_with_maps([
|
||||
(3, 3, "monster", "chicken"),
|
||||
(10, 0, "bank", "bank"),
|
||||
])
|
||||
strategy = LevelingStrategy({"target_skill": "combat"}, pf)
|
||||
char = make_character(
|
||||
x=3, y=3,
|
||||
x=0, y=0,
|
||||
hp=100, max_hp=100,
|
||||
level=5,
|
||||
inventory_max_items=20,
|
||||
)
|
||||
|
||||
plan = await strategy.next_action(char)
|
||||
assert plan.action_type == ActionType.FIGHT
|
||||
# _choose_combat_target finds nearest monster via find_nearest_by_type
|
||||
assert plan.action_type == ActionType.MOVE
|
||||
assert plan.params == {"x": 3, "y": 3}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_heal_during_combat(self, make_character, pathfinder_with_maps):
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import type { NextConfig } from "next";
|
||||
import { withSentryConfig } from "@sentry/nextjs";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
turbopack: {
|
||||
|
|
@ -15,4 +16,7 @@ const nextConfig: NextConfig = {
|
|||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
export default withSentryConfig(nextConfig, {
|
||||
silent: true,
|
||||
disableLogger: true,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@
|
|||
"type-check": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@sentry/nextjs": "^9.47.1",
|
||||
"@tanstack/react-query": "^5.90.21",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
10
frontend/sentry.client.config.ts
Normal file
10
frontend/sentry.client.config.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import * as Sentry from "@sentry/nextjs";
|
||||
|
||||
Sentry.init({
|
||||
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN || "",
|
||||
environment: process.env.NEXT_PUBLIC_ENVIRONMENT || "development",
|
||||
enabled: !!process.env.NEXT_PUBLIC_SENTRY_DSN,
|
||||
tracesSampleRate: 0.2,
|
||||
replaysSessionSampleRate: 0,
|
||||
replaysOnErrorSampleRate: 0.5,
|
||||
});
|
||||
|
|
@ -11,6 +11,8 @@ import {
|
|||
Loader2,
|
||||
LayoutGrid,
|
||||
List,
|
||||
GitBranch,
|
||||
Network,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card } from "@/components/ui/card";
|
||||
|
|
@ -37,8 +39,14 @@ import {
|
|||
useAutomationStatuses,
|
||||
useDeleteAutomation,
|
||||
} from "@/hooks/use-automations";
|
||||
import { useWorkflows } from "@/hooks/use-workflows";
|
||||
import { usePipelines } from "@/hooks/use-pipelines";
|
||||
import { RunControls } from "@/components/automation/run-controls";
|
||||
import { AutomationGallery } from "@/components/automation/automation-gallery";
|
||||
import { WorkflowList } from "@/components/workflow/workflow-list";
|
||||
import { WorkflowTemplateGallery } from "@/components/workflow/workflow-template-gallery";
|
||||
import { PipelineList } from "@/components/pipeline/pipeline-list";
|
||||
import { PipelineTemplateGallery } from "@/components/pipeline/pipeline-template-gallery";
|
||||
import { toast } from "sonner";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
|
|
@ -64,6 +72,8 @@ export default function AutomationsPage() {
|
|||
const router = useRouter();
|
||||
const { data: automations, isLoading, error } = useAutomations();
|
||||
const { data: statuses } = useAutomationStatuses();
|
||||
const { data: workflows } = useWorkflows();
|
||||
const { data: pipelines } = usePipelines();
|
||||
const deleteMutation = useDeleteAutomation();
|
||||
const [deleteTarget, setDeleteTarget] = useState<{
|
||||
id: number;
|
||||
|
|
@ -98,11 +108,27 @@ export default function AutomationsPage() {
|
|||
Manage automated strategies for your characters
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => router.push("/automations/pipelines/new")}
|
||||
>
|
||||
<Network className="size-4" />
|
||||
New Pipeline
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => router.push("/automations/workflows/new")}
|
||||
>
|
||||
<GitBranch className="size-4" />
|
||||
New Workflow
|
||||
</Button>
|
||||
<Button onClick={() => router.push("/automations/new")}>
|
||||
<Plus className="size-4" />
|
||||
New Automation
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="gallery">
|
||||
<TabsList>
|
||||
|
|
@ -119,10 +145,30 @@ export default function AutomationsPage() {
|
|||
</Badge>
|
||||
)}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="workflows" className="gap-1.5">
|
||||
<GitBranch className="size-3.5" />
|
||||
Workflows
|
||||
{workflows && workflows.length > 0 && (
|
||||
<Badge variant="secondary" className="ml-1 text-xs px-1.5 py-0">
|
||||
{workflows.length}
|
||||
</Badge>
|
||||
)}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="pipelines" className="gap-1.5">
|
||||
<Network className="size-3.5" />
|
||||
Pipelines
|
||||
{pipelines && pipelines.length > 0 && (
|
||||
<Badge variant="secondary" className="ml-1 text-xs px-1.5 py-0">
|
||||
{pipelines.length}
|
||||
</Badge>
|
||||
)}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="gallery" className="mt-6">
|
||||
<TabsContent value="gallery" className="mt-6 space-y-8">
|
||||
<AutomationGallery />
|
||||
<WorkflowTemplateGallery />
|
||||
<PipelineTemplateGallery />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="active" className="mt-6">
|
||||
|
|
@ -231,6 +277,14 @@ export default function AutomationsPage() {
|
|||
</Card>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="workflows" className="mt-6">
|
||||
<WorkflowList />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="pipelines" className="mt-6">
|
||||
<PipelineList />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<Dialog
|
||||
|
|
|
|||
218
frontend/src/app/automations/pipelines/[id]/page.tsx
Normal file
218
frontend/src/app/automations/pipelines/[id]/page.tsx
Normal file
|
|
@ -0,0 +1,218 @@
|
|||
"use client";
|
||||
|
||||
import { use } from "react";
|
||||
import Link from "next/link";
|
||||
import { ArrowLeft, Loader2, Repeat, Trash2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { usePipeline, usePipelineStatuses, useDeletePipeline } from "@/hooks/use-pipelines";
|
||||
import { PipelineProgress } from "@/components/pipeline/pipeline-progress";
|
||||
import { toast } from "sonner";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
export default function PipelineDetailPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const { id: idStr } = use(params);
|
||||
const id = Number(idStr);
|
||||
const router = useRouter();
|
||||
const { data, isLoading, error } = usePipeline(id);
|
||||
const { data: statuses } = usePipelineStatuses();
|
||||
const deleteMutation = useDeletePipeline();
|
||||
|
||||
const status = (statuses ?? []).find((s) => s.pipeline_id === id) ?? null;
|
||||
|
||||
function handleDelete() {
|
||||
deleteMutation.mutate(id, {
|
||||
onSuccess: () => {
|
||||
toast.success("Pipeline deleted");
|
||||
router.push("/automations");
|
||||
},
|
||||
onError: (err) => toast.error(`Failed to delete: ${err.message}`),
|
||||
});
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="size-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !data) {
|
||||
return (
|
||||
<Card className="border-destructive bg-destructive/10 p-4">
|
||||
<p className="text-sm text-destructive">
|
||||
Failed to load pipeline. It may have been deleted.
|
||||
</p>
|
||||
<Link href="/automations" className="mt-2 inline-block">
|
||||
<Button variant="outline" size="sm">
|
||||
Back to Automations
|
||||
</Button>
|
||||
</Link>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const { config, runs } = data;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/automations">
|
||||
<Button variant="ghost" size="icon-xs">
|
||||
<ArrowLeft className="size-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h1 className="text-2xl font-bold tracking-tight text-foreground">
|
||||
{config.name}
|
||||
</h1>
|
||||
{config.loop && (
|
||||
<Repeat className="size-4 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
{config.description && (
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{config.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-destructive hover:text-destructive"
|
||||
onClick={handleDelete}
|
||||
disabled={deleteMutation.isPending}
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Live progress */}
|
||||
<PipelineProgress config={config} status={status} />
|
||||
|
||||
{/* Stages overview */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">
|
||||
Stages ({config.stages.length})
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
{config.stages.map((stage, idx) => (
|
||||
<div key={stage.id} className="border rounded-lg p-3">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Badge variant="outline" className="text-xs">
|
||||
Stage {idx + 1}
|
||||
</Badge>
|
||||
<span className="text-sm font-medium">{stage.name}</span>
|
||||
<Badge variant="secondary" className="text-[10px]">
|
||||
{stage.character_steps.length} worker
|
||||
{stage.character_steps.length !== 1 && "s"}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{stage.character_steps.map((cs) => (
|
||||
<div
|
||||
key={cs.id}
|
||||
className="text-xs bg-muted/30 rounded p-2 space-y-0.5"
|
||||
>
|
||||
<div className="font-medium">{cs.character_name}</div>
|
||||
<div className="text-muted-foreground capitalize">
|
||||
{cs.strategy_type}
|
||||
</div>
|
||||
{cs.transition && (
|
||||
<div className="text-muted-foreground">
|
||||
Until: {cs.transition.type}
|
||||
{cs.transition.value != null &&
|
||||
` ${cs.transition.operator ?? ">="} ${cs.transition.value}`}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Run history */}
|
||||
{runs.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">
|
||||
Run History ({runs.length})
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Run</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Stage</TableHead>
|
||||
<TableHead>Actions</TableHead>
|
||||
<TableHead>Started</TableHead>
|
||||
<TableHead>Stopped</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{runs.slice(0, 20).map((run) => (
|
||||
<TableRow key={run.id}>
|
||||
<TableCell className="font-mono text-xs">
|
||||
#{run.id}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
variant={
|
||||
run.status === "error" ? "destructive" : "secondary"
|
||||
}
|
||||
className="text-[10px]"
|
||||
>
|
||||
{run.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground">
|
||||
{run.current_stage_index + 1}
|
||||
{run.loop_count > 0 && ` (loop ${run.loop_count})`}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs">
|
||||
{run.total_actions_count}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground">
|
||||
{new Date(run.started_at).toLocaleString()}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground">
|
||||
{run.stopped_at
|
||||
? new Date(run.stopped_at).toLocaleString()
|
||||
: "\u2014"}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
39
frontend/src/app/automations/pipelines/new/page.tsx
Normal file
39
frontend/src/app/automations/pipelines/new/page.tsx
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
"use client";
|
||||
|
||||
import { Suspense } from "react";
|
||||
import Link from "next/link";
|
||||
import { ArrowLeft, Loader2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { PipelineBuilder } from "@/components/pipeline/pipeline-builder";
|
||||
|
||||
export default function NewPipelinePage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/automations">
|
||||
<Button variant="ghost" size="icon-xs">
|
||||
<ArrowLeft className="size-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight text-foreground">
|
||||
New Pipeline
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Create a multi-character pipeline with sequential stages
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="size-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<PipelineBuilder />
|
||||
</Suspense>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
349
frontend/src/app/automations/workflows/[id]/page.tsx
Normal file
349
frontend/src/app/automations/workflows/[id]/page.tsx
Normal file
|
|
@ -0,0 +1,349 @@
|
|||
"use client";
|
||||
|
||||
import { use } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
ArrowLeft,
|
||||
Loader2,
|
||||
Repeat,
|
||||
Play,
|
||||
Square,
|
||||
Pause,
|
||||
Clock,
|
||||
Calendar,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import {
|
||||
Table,
|
||||
TableHeader,
|
||||
TableBody,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TableCell,
|
||||
} from "@/components/ui/table";
|
||||
import {
|
||||
useWorkflow,
|
||||
useWorkflowStatuses,
|
||||
useWorkflowLogs,
|
||||
useControlWorkflow,
|
||||
} from "@/hooks/use-workflows";
|
||||
import { WorkflowProgress } from "@/components/workflow/workflow-progress";
|
||||
import { toast } from "sonner";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const STATUS_BADGE_CLASSES: Record<string, string> = {
|
||||
running: "bg-green-600 text-white",
|
||||
paused: "bg-yellow-600 text-white",
|
||||
stopped: "",
|
||||
completed: "bg-blue-600 text-white",
|
||||
error: "bg-red-600 text-white",
|
||||
};
|
||||
|
||||
function formatDate(dateStr: string): string {
|
||||
return new Date(dateStr).toLocaleString();
|
||||
}
|
||||
|
||||
function formatDuration(start: string, end: string | null): string {
|
||||
const startTime = new Date(start).getTime();
|
||||
const endTime = end ? new Date(end).getTime() : Date.now();
|
||||
const diffMs = endTime - startTime;
|
||||
const diffS = Math.floor(diffMs / 1000);
|
||||
|
||||
if (diffS < 60) return `${diffS}s`;
|
||||
const m = Math.floor(diffS / 60);
|
||||
const s = diffS % 60;
|
||||
if (m < 60) return `${m}m ${s}s`;
|
||||
const h = Math.floor(m / 60);
|
||||
const rm = m % 60;
|
||||
return `${h}h ${rm}m`;
|
||||
}
|
||||
|
||||
export default function WorkflowDetailPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const { id: idStr } = use(params);
|
||||
const id = parseInt(idStr, 10);
|
||||
const router = useRouter();
|
||||
|
||||
const { data, isLoading, error } = useWorkflow(id);
|
||||
const { data: statuses } = useWorkflowStatuses();
|
||||
const { data: logs } = useWorkflowLogs(id);
|
||||
const control = useControlWorkflow();
|
||||
|
||||
const status = statuses?.find((s) => s.workflow_id === id) ?? null;
|
||||
const currentStatus = status?.status ?? "stopped";
|
||||
|
||||
const isStopped =
|
||||
currentStatus === "stopped" ||
|
||||
currentStatus === "completed" ||
|
||||
currentStatus === "error" ||
|
||||
!currentStatus;
|
||||
const isRunning = currentStatus === "running";
|
||||
const isPaused = currentStatus === "paused";
|
||||
|
||||
function handleControl(action: "start" | "stop" | "pause" | "resume") {
|
||||
control.mutate(
|
||||
{ id, action },
|
||||
{
|
||||
onSuccess: () => toast.success(`Workflow ${action}ed`),
|
||||
onError: (err) =>
|
||||
toast.error(`Failed to ${action} workflow: ${err.message}`),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-24">
|
||||
<Loader2 className="size-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !data) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => router.push("/automations")}
|
||||
>
|
||||
<ArrowLeft className="size-4" />
|
||||
Back
|
||||
</Button>
|
||||
<Card className="border-destructive bg-destructive/10 p-4">
|
||||
<p className="text-sm text-destructive">
|
||||
Failed to load workflow. It may have been deleted.
|
||||
</p>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const { config: workflow, runs } = data;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-start gap-3">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="mt-1"
|
||||
onClick={() => router.push("/automations")}
|
||||
>
|
||||
<ArrowLeft className="size-4" />
|
||||
</Button>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-2xl font-bold tracking-tight text-foreground">
|
||||
{workflow.name}
|
||||
</h1>
|
||||
{workflow.loop && (
|
||||
<Badge variant="outline" className="gap-1">
|
||||
<Repeat className="size-3" />
|
||||
Loop
|
||||
{workflow.max_loops > 0 && ` (max ${workflow.max_loops})`}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{workflow.character_name} · {workflow.steps.length} step
|
||||
{workflow.steps.length !== 1 && "s"}
|
||||
{workflow.description && ` · ${workflow.description}`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Controls */}
|
||||
<Card className="px-4 py-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<Badge
|
||||
variant={
|
||||
currentStatus === "error"
|
||||
? "destructive"
|
||||
: currentStatus === "running"
|
||||
? "default"
|
||||
: "secondary"
|
||||
}
|
||||
className={STATUS_BADGE_CLASSES[currentStatus] ?? ""}
|
||||
>
|
||||
{control.isPending && (
|
||||
<Loader2 className="size-3 animate-spin" />
|
||||
)}
|
||||
{currentStatus}
|
||||
</Badge>
|
||||
|
||||
{status && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{status.total_actions_count.toLocaleString()} actions
|
||||
</span>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-1.5">
|
||||
{isStopped && (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="outline"
|
||||
className="text-green-400 hover:text-green-300 hover:bg-green-500/10"
|
||||
onClick={() => handleControl("start")}
|
||||
disabled={control.isPending}
|
||||
>
|
||||
<Play className="size-3" />
|
||||
Start
|
||||
</Button>
|
||||
)}
|
||||
{isRunning && (
|
||||
<>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="outline"
|
||||
className="text-yellow-400 hover:text-yellow-300 hover:bg-yellow-500/10"
|
||||
onClick={() => handleControl("pause")}
|
||||
disabled={control.isPending}
|
||||
>
|
||||
<Pause className="size-3" />
|
||||
Pause
|
||||
</Button>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="outline"
|
||||
className="text-red-400 hover:text-red-300 hover:bg-red-500/10"
|
||||
onClick={() => handleControl("stop")}
|
||||
disabled={control.isPending}
|
||||
>
|
||||
<Square className="size-3" />
|
||||
Stop
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{isPaused && (
|
||||
<>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="outline"
|
||||
className="text-green-400 hover:text-green-300 hover:bg-green-500/10"
|
||||
onClick={() => handleControl("resume")}
|
||||
disabled={control.isPending}
|
||||
>
|
||||
<Play className="size-3" />
|
||||
Resume
|
||||
</Button>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="outline"
|
||||
className="text-red-400 hover:text-red-300 hover:bg-red-500/10"
|
||||
onClick={() => handleControl("stop")}
|
||||
disabled={control.isPending}
|
||||
>
|
||||
<Square className="size-3" />
|
||||
Stop
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Tabs */}
|
||||
<Tabs defaultValue="progress">
|
||||
<TabsList>
|
||||
<TabsTrigger value="progress">Progress</TabsTrigger>
|
||||
<TabsTrigger value="runs">Run History</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="progress">
|
||||
<WorkflowProgress
|
||||
steps={workflow.steps}
|
||||
status={status}
|
||||
logs={logs ?? []}
|
||||
loop={workflow.loop}
|
||||
maxLoops={workflow.max_loops}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="runs">
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base">Run History</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{runs.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground py-8 text-center">
|
||||
No runs yet. Start the workflow to create a run.
|
||||
</p>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Started</TableHead>
|
||||
<TableHead>Duration</TableHead>
|
||||
<TableHead>Steps</TableHead>
|
||||
<TableHead>Loops</TableHead>
|
||||
<TableHead>Actions</TableHead>
|
||||
<TableHead>Error</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{runs.map((run) => (
|
||||
<TableRow key={run.id}>
|
||||
<TableCell>
|
||||
<Badge
|
||||
variant={
|
||||
run.status === "error"
|
||||
? "destructive"
|
||||
: "secondary"
|
||||
}
|
||||
className={cn(
|
||||
STATUS_BADGE_CLASSES[run.status]
|
||||
)}
|
||||
>
|
||||
{run.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground">
|
||||
<div className="flex items-center gap-1">
|
||||
<Calendar className="size-3" />
|
||||
{formatDate(run.started_at)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground">
|
||||
<div className="flex items-center gap-1">
|
||||
<Clock className="size-3" />
|
||||
{formatDuration(run.started_at, run.stopped_at)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="tabular-nums text-xs">
|
||||
{run.current_step_index + 1}/{workflow.steps.length}
|
||||
</TableCell>
|
||||
<TableCell className="tabular-nums text-xs">
|
||||
{run.loop_count}
|
||||
</TableCell>
|
||||
<TableCell className="tabular-nums">
|
||||
{run.total_actions_count.toLocaleString()}
|
||||
</TableCell>
|
||||
<TableCell className="max-w-[200px]">
|
||||
{run.error_message && (
|
||||
<span className="text-xs text-red-400 truncate block">
|
||||
{run.error_message}
|
||||
</span>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
46
frontend/src/app/automations/workflows/new/page.tsx
Normal file
46
frontend/src/app/automations/workflows/new/page.tsx
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
"use client";
|
||||
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { WorkflowBuilder } from "@/components/workflow/workflow-builder";
|
||||
import {
|
||||
WORKFLOW_TEMPLATES,
|
||||
} from "@/components/workflow/workflow-templates";
|
||||
import type { WorkflowStep } from "@/lib/types";
|
||||
|
||||
export default function NewWorkflowPage() {
|
||||
const searchParams = useSearchParams();
|
||||
const templateId = searchParams.get("template");
|
||||
|
||||
const template = templateId
|
||||
? WORKFLOW_TEMPLATES.find((t) => t.id === templateId)
|
||||
: null;
|
||||
|
||||
const initialSteps: WorkflowStep[] | undefined = template
|
||||
? template.steps.map((s) => ({
|
||||
id: s.id,
|
||||
name: s.name,
|
||||
strategy_type: s.strategy_type as WorkflowStep["strategy_type"],
|
||||
config: s.config as Record<string, unknown>,
|
||||
transition: s.transition
|
||||
? {
|
||||
type: s.transition.type as WorkflowStep["transition"] extends null ? never : NonNullable<WorkflowStep["transition"]>["type"],
|
||||
operator: (s.transition.operator ?? ">=") as ">=" | "<=" | "==" | ">" | "<",
|
||||
value: s.transition.value ?? 0,
|
||||
item_code: s.transition.item_code ?? "",
|
||||
skill: s.transition.skill ?? "",
|
||||
seconds: s.transition.seconds ?? 0,
|
||||
}
|
||||
: null,
|
||||
}))
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<WorkflowBuilder
|
||||
initialSteps={initialSteps}
|
||||
initialName={template?.name ?? ""}
|
||||
initialDescription={template?.description ?? ""}
|
||||
initialLoop={template?.loop ?? false}
|
||||
initialMaxLoops={template?.max_loops ?? 0}
|
||||
/>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,8 +1,10 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { AlertTriangle, RotateCcw } from "lucide-react";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { reportError } from "@/lib/api-client";
|
||||
|
||||
export default function Error({
|
||||
error,
|
||||
|
|
@ -11,6 +13,20 @@ export default function Error({
|
|||
error: Error & { digest?: string };
|
||||
reset: () => void;
|
||||
}) {
|
||||
useEffect(() => {
|
||||
reportError({
|
||||
error_type: error.name || "UnhandledError",
|
||||
message: error.message || "Unknown error",
|
||||
stack_trace: error.stack,
|
||||
context: {
|
||||
digest: error.digest,
|
||||
url: typeof window !== "undefined" ? window.location.href : undefined,
|
||||
},
|
||||
}).catch(() => {
|
||||
// Silently fail - don't create error loops
|
||||
});
|
||||
}, [error]);
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full p-6">
|
||||
<Card className="max-w-md w-full">
|
||||
|
|
|
|||
395
frontend/src/app/errors/page.tsx
Normal file
395
frontend/src/app/errors/page.tsx
Normal file
|
|
@ -0,0 +1,395 @@
|
|||
"use client";
|
||||
|
||||
import { Fragment, useState } from "react";
|
||||
import {
|
||||
AlertTriangle,
|
||||
Loader2,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
Clock,
|
||||
Bug,
|
||||
} from "lucide-react";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { useErrors, useErrorStats, useResolveError } from "@/hooks/use-errors";
|
||||
import type { AppError } from "@/lib/types";
|
||||
|
||||
const PAGE_SIZE = 50;
|
||||
|
||||
const SEVERITY_COLORS: Record<string, string> = {
|
||||
critical: "bg-red-600/20 text-red-400",
|
||||
error: "bg-red-500/20 text-red-400",
|
||||
warning: "bg-yellow-500/20 text-yellow-400",
|
||||
};
|
||||
|
||||
const SOURCE_COLORS: Record<string, string> = {
|
||||
backend: "bg-blue-500/20 text-blue-400",
|
||||
frontend: "bg-purple-500/20 text-purple-400",
|
||||
automation: "bg-green-500/20 text-green-400",
|
||||
middleware: "bg-orange-500/20 text-orange-400",
|
||||
};
|
||||
|
||||
function formatDate(dateStr: string): string {
|
||||
const date = new Date(dateStr);
|
||||
return date.toLocaleDateString([], {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
function StatCard({
|
||||
label,
|
||||
value,
|
||||
icon: Icon,
|
||||
color,
|
||||
}: {
|
||||
label: string;
|
||||
value: number;
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
color: string;
|
||||
}) {
|
||||
return (
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`p-2 rounded-md ${color}`}>
|
||||
<Icon className="size-4" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold tabular-nums">{value}</p>
|
||||
<p className="text-xs text-muted-foreground">{label}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function ExpandedRow({ error }: { error: AppError }) {
|
||||
return (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="bg-muted/30 p-4">
|
||||
<div className="space-y-3 text-sm">
|
||||
{error.correlation_id && (
|
||||
<div>
|
||||
<span className="text-muted-foreground">Correlation ID: </span>
|
||||
<code className="text-xs bg-muted px-1.5 py-0.5 rounded">
|
||||
{error.correlation_id}
|
||||
</code>
|
||||
</div>
|
||||
)}
|
||||
{error.context && Object.keys(error.context).length > 0 && (
|
||||
<div>
|
||||
<span className="text-muted-foreground">Context:</span>
|
||||
<pre className="mt-1 text-xs bg-muted p-3 rounded-md overflow-x-auto max-h-40">
|
||||
{JSON.stringify(error.context, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
{error.stack_trace && (
|
||||
<div>
|
||||
<span className="text-muted-foreground">Stack Trace:</span>
|
||||
<pre className="mt-1 text-xs bg-muted p-3 rounded-md overflow-x-auto max-h-60 whitespace-pre-wrap">
|
||||
{error.stack_trace}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ErrorsPage() {
|
||||
const [severityFilter, setSeverityFilter] = useState("_all");
|
||||
const [sourceFilter, setSourceFilter] = useState("_all");
|
||||
const [resolvedFilter, setResolvedFilter] = useState("_all");
|
||||
const [page, setPage] = useState(1);
|
||||
const [expandedId, setExpandedId] = useState<number | null>(null);
|
||||
|
||||
const { data, isLoading, error } = useErrors({
|
||||
severity: severityFilter === "_all" ? undefined : severityFilter,
|
||||
source: sourceFilter === "_all" ? undefined : sourceFilter,
|
||||
resolved: resolvedFilter === "_all" ? undefined : resolvedFilter,
|
||||
page,
|
||||
size: PAGE_SIZE,
|
||||
});
|
||||
|
||||
const { data: stats } = useErrorStats();
|
||||
const resolveMutation = useResolveError();
|
||||
|
||||
const errors = data?.errors ?? [];
|
||||
const totalPages = data?.pages ?? 1;
|
||||
const total = data?.total ?? 0;
|
||||
|
||||
function handleFilterChange(setter: (v: string) => void) {
|
||||
return (value: string) => {
|
||||
setter(value);
|
||||
setPage(1);
|
||||
};
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight text-foreground">
|
||||
Error Tracker
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Application errors from backend, frontend, and automations
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<Card className="border-destructive bg-destructive/10 p-4">
|
||||
<p className="text-sm text-destructive">
|
||||
Failed to load errors. Make sure the backend is running.
|
||||
</p>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Stats cards */}
|
||||
{stats && (
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<StatCard
|
||||
label="Total Errors"
|
||||
value={stats.total}
|
||||
icon={Bug}
|
||||
color="bg-red-500/20 text-red-400"
|
||||
/>
|
||||
<StatCard
|
||||
label="Unresolved"
|
||||
value={stats.unresolved}
|
||||
icon={XCircle}
|
||||
color="bg-orange-500/20 text-orange-400"
|
||||
/>
|
||||
<StatCard
|
||||
label="Last Hour"
|
||||
value={stats.last_hour}
|
||||
icon={Clock}
|
||||
color="bg-yellow-500/20 text-yellow-400"
|
||||
/>
|
||||
<StatCard
|
||||
label="Resolved"
|
||||
value={stats.total - stats.unresolved}
|
||||
icon={CheckCircle2}
|
||||
color="bg-green-500/20 text-green-400"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Select
|
||||
value={severityFilter}
|
||||
onValueChange={handleFilterChange(setSeverityFilter)}
|
||||
>
|
||||
<SelectTrigger className="w-40">
|
||||
<SelectValue placeholder="All Severities" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="_all">All Severities</SelectItem>
|
||||
<SelectItem value="critical">Critical</SelectItem>
|
||||
<SelectItem value="error">Error</SelectItem>
|
||||
<SelectItem value="warning">Warning</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select
|
||||
value={sourceFilter}
|
||||
onValueChange={handleFilterChange(setSourceFilter)}
|
||||
>
|
||||
<SelectTrigger className="w-40">
|
||||
<SelectValue placeholder="All Sources" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="_all">All Sources</SelectItem>
|
||||
<SelectItem value="backend">Backend</SelectItem>
|
||||
<SelectItem value="frontend">Frontend</SelectItem>
|
||||
<SelectItem value="automation">Automation</SelectItem>
|
||||
<SelectItem value="middleware">Middleware</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select
|
||||
value={resolvedFilter}
|
||||
onValueChange={handleFilterChange(setResolvedFilter)}
|
||||
>
|
||||
<SelectTrigger className="w-40">
|
||||
<SelectValue placeholder="All Status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="_all">All Status</SelectItem>
|
||||
<SelectItem value="false">Unresolved</SelectItem>
|
||||
<SelectItem value="true">Resolved</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{total > 0 && (
|
||||
<div className="flex items-center text-xs text-muted-foreground self-center ml-auto">
|
||||
{total.toLocaleString()} total errors
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isLoading && errors.length === 0 && (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="size-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error Table */}
|
||||
{errors.length > 0 && (
|
||||
<Card>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-10" />
|
||||
<TableHead className="w-40">Time</TableHead>
|
||||
<TableHead className="w-24">Severity</TableHead>
|
||||
<TableHead className="w-28">Source</TableHead>
|
||||
<TableHead>Error</TableHead>
|
||||
<TableHead className="w-24 text-center">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{errors.map((err) => (
|
||||
<Fragment key={err.id}>
|
||||
<TableRow
|
||||
className="cursor-pointer"
|
||||
onClick={() =>
|
||||
setExpandedId(expandedId === err.id ? null : err.id)
|
||||
}
|
||||
>
|
||||
<TableCell className="px-2">
|
||||
{expandedId === err.id ? (
|
||||
<ChevronUp className="size-4 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronDown className="size-4 text-muted-foreground" />
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground tabular-nums">
|
||||
{formatDate(err.created_at)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={`text-[10px] px-1.5 py-0 border-0 capitalize ${
|
||||
SEVERITY_COLORS[err.severity] ?? "bg-muted text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
{err.severity}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={`text-[10px] px-1.5 py-0 border-0 capitalize ${
|
||||
SOURCE_COLORS[err.source] ?? "bg-muted text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
{err.source}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="max-w-md">
|
||||
<div className="flex items-center gap-2">
|
||||
{err.resolved && (
|
||||
<CheckCircle2 className="size-3.5 text-green-500 shrink-0" />
|
||||
)}
|
||||
<div className="truncate">
|
||||
<span className="text-sm font-medium">
|
||||
{err.error_type}
|
||||
</span>
|
||||
<span className="text-sm text-muted-foreground ml-2 truncate">
|
||||
{err.message}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
{!err.resolved && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 text-xs"
|
||||
disabled={resolveMutation.isPending}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
resolveMutation.mutate(err.id);
|
||||
}}
|
||||
>
|
||||
Resolve
|
||||
</Button>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{expandedId === err.id && (
|
||||
<ExpandedRow key={`${err.id}-expanded`} error={err} />
|
||||
)}
|
||||
</Fragment>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-center gap-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
disabled={page <= 1}
|
||||
>
|
||||
<ChevronLeft className="size-4" />
|
||||
Previous
|
||||
</Button>
|
||||
<span className="text-sm text-muted-foreground tabular-nums">
|
||||
Page {page} of {totalPages}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
||||
disabled={page >= totalPages}
|
||||
>
|
||||
Next
|
||||
<ChevronRight className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty state */}
|
||||
{errors.length === 0 && !isLoading && (
|
||||
<Card className="p-8 text-center">
|
||||
<AlertTriangle className="size-12 text-muted-foreground mx-auto mb-4" />
|
||||
<p className="text-muted-foreground">
|
||||
No errors found. That's a good sign!
|
||||
</p>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -9,11 +9,14 @@ import {
|
|||
History,
|
||||
TrendingUp,
|
||||
User,
|
||||
Plus,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Tabs,
|
||||
TabsContent,
|
||||
|
|
@ -28,14 +31,27 @@ import {
|
|||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
useExchangeOrders,
|
||||
useMyOrders,
|
||||
useExchangeHistory,
|
||||
usePriceHistory,
|
||||
} from "@/hooks/use-exchange";
|
||||
import { useCharacters } from "@/hooks/use-characters";
|
||||
import { useItems } from "@/hooks/use-game-data";
|
||||
import { PriceChart } from "@/components/exchange/price-chart";
|
||||
import { BuyEquipDialog } from "@/components/exchange/buy-equip-dialog";
|
||||
import { GameIcon } from "@/components/ui/game-icon";
|
||||
import { executeAction } from "@/lib/api-client";
|
||||
import { toast } from "sonner";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import type { GEOrder, GEHistoryEntry } from "@/lib/types";
|
||||
|
||||
function formatDate(dateStr: string): string {
|
||||
|
|
@ -54,12 +70,14 @@ function OrdersTable({
|
|||
search,
|
||||
emptyMessage,
|
||||
showAccount,
|
||||
onBuy,
|
||||
}: {
|
||||
orders: GEOrder[];
|
||||
isLoading: boolean;
|
||||
search: string;
|
||||
emptyMessage: string;
|
||||
showAccount?: boolean;
|
||||
onBuy?: (order: GEOrder) => void;
|
||||
}) {
|
||||
const filtered = useMemo(() => {
|
||||
if (!search.trim()) return orders;
|
||||
|
|
@ -94,6 +112,7 @@ function OrdersTable({
|
|||
<TableHead className="text-right">Quantity</TableHead>
|
||||
{showAccount && <TableHead>Account</TableHead>}
|
||||
<TableHead className="text-right">Created</TableHead>
|
||||
{onBuy && <TableHead className="text-right">Actions</TableHead>}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
|
|
@ -125,12 +144,124 @@ function OrdersTable({
|
|||
</TableCell>
|
||||
{showAccount && (
|
||||
<TableCell className="text-muted-foreground text-sm">
|
||||
{order.account ?? "—"}
|
||||
{order.account ?? "\u2014"}
|
||||
</TableCell>
|
||||
)}
|
||||
<TableCell className="text-right text-muted-foreground text-sm">
|
||||
{formatDate(order.created_at)}
|
||||
</TableCell>
|
||||
{onBuy && (
|
||||
<TableCell className="text-right">
|
||||
{order.type === "sell" ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 text-xs text-green-400 hover:text-green-300 hover:bg-green-500/10"
|
||||
onClick={() => onBuy(order)}
|
||||
>
|
||||
<ShoppingCart className="size-3" />
|
||||
Buy
|
||||
</Button>
|
||||
) : null}
|
||||
</TableCell>
|
||||
)}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
);
|
||||
}
|
||||
|
||||
function MyOrdersTable({
|
||||
orders,
|
||||
isLoading,
|
||||
selectedCharacter,
|
||||
onCancel,
|
||||
cancelPending,
|
||||
}: {
|
||||
orders: GEOrder[];
|
||||
isLoading: boolean;
|
||||
selectedCharacter: string;
|
||||
onCancel: (orderId: string) => void;
|
||||
cancelPending: string | null;
|
||||
}) {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="size-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (orders.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-12">
|
||||
<ArrowLeftRight className="size-12 text-muted-foreground mb-4" />
|
||||
<p className="text-muted-foreground text-sm">
|
||||
You have no active orders on the Grand Exchange.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Item</TableHead>
|
||||
<TableHead>Type</TableHead>
|
||||
<TableHead className="text-right">Price</TableHead>
|
||||
<TableHead className="text-right">Quantity</TableHead>
|
||||
<TableHead className="text-right">Created</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{orders.map((order) => (
|
||||
<TableRow key={order.id}>
|
||||
<TableCell className="font-medium">
|
||||
<div className="flex items-center gap-2">
|
||||
<GameIcon type="item" code={order.code} size="sm" />
|
||||
{order.code}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={
|
||||
order.type === "buy"
|
||||
? "text-green-400 border-green-500/30"
|
||||
: "text-red-400 border-red-500/30"
|
||||
}
|
||||
>
|
||||
{order.type.toUpperCase()}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums text-amber-400">
|
||||
{order.price.toLocaleString()}
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums">
|
||||
{order.quantity.toLocaleString()}
|
||||
</TableCell>
|
||||
<TableCell className="text-right text-muted-foreground text-sm">
|
||||
{formatDate(order.created_at)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 text-xs text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||
disabled={!selectedCharacter || cancelPending !== null}
|
||||
onClick={() => onCancel(order.id)}
|
||||
>
|
||||
{cancelPending === order.id ? (
|
||||
<Loader2 className="size-3 animate-spin" />
|
||||
) : (
|
||||
<X className="size-3" />
|
||||
)}
|
||||
Cancel
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
|
|
@ -211,10 +342,26 @@ export default function ExchangePage() {
|
|||
const { data: orders, isLoading: loadingOrders, error: ordersError } = useExchangeOrders();
|
||||
const { data: myOrders, isLoading: loadingMyOrders } = useMyOrders();
|
||||
const { data: history, isLoading: loadingHistory } = useExchangeHistory();
|
||||
const { data: characters } = useCharacters();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data: items } = useItems();
|
||||
|
||||
const [marketSearch, setMarketSearch] = useState("");
|
||||
const [priceItemCode, setPriceItemCode] = useState("");
|
||||
const [searchedItem, setSearchedItem] = useState("");
|
||||
const [selectedCharacter, setSelectedCharacter] = useState("");
|
||||
const [cancelPending, setCancelPending] = useState<string | null>(null);
|
||||
const [buyOrder, setBuyOrder] = useState<GEOrder | null>(null);
|
||||
|
||||
// Buy/Sell form state
|
||||
const [buyCode, setBuyCode] = useState("");
|
||||
const [buyQty, setBuyQty] = useState("1");
|
||||
const [buyPrice, setBuyPrice] = useState("");
|
||||
const [sellCode, setSellCode] = useState("");
|
||||
const [sellQty, setSellQty] = useState("1");
|
||||
const [sellPrice, setSellPrice] = useState("");
|
||||
const [orderPending, setOrderPending] = useState<string | null>(null);
|
||||
|
||||
const {
|
||||
data: priceData,
|
||||
|
|
@ -227,17 +374,94 @@ export default function ExchangePage() {
|
|||
}
|
||||
}
|
||||
|
||||
async function handleCancelOrder(orderId: string) {
|
||||
if (!selectedCharacter) {
|
||||
toast.error("Select a character first");
|
||||
return;
|
||||
}
|
||||
setCancelPending(orderId);
|
||||
try {
|
||||
await executeAction(selectedCharacter, "ge_cancel", { order_id: orderId });
|
||||
toast.success("Order cancelled");
|
||||
queryClient.invalidateQueries({ queryKey: ["exchange"] });
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
`Cancel failed: ${err instanceof Error ? err.message : "Unknown error"}`
|
||||
);
|
||||
} finally {
|
||||
setCancelPending(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCreateOrder(type: "ge_create_buy" | "ge_sell") {
|
||||
if (!selectedCharacter) {
|
||||
toast.error("Select a character first");
|
||||
return;
|
||||
}
|
||||
const code = type === "ge_create_buy" ? buyCode : sellCode;
|
||||
const qty = type === "ge_create_buy" ? buyQty : sellQty;
|
||||
const price = type === "ge_create_buy" ? buyPrice : sellPrice;
|
||||
|
||||
setOrderPending(type);
|
||||
try {
|
||||
await executeAction(selectedCharacter, type, {
|
||||
code,
|
||||
quantity: parseInt(qty, 10) || 1,
|
||||
price: parseInt(price, 10) || 0,
|
||||
});
|
||||
toast.success(`${type === "ge_create_buy" ? "Buy" : "Sell"} order created`);
|
||||
queryClient.invalidateQueries({ queryKey: ["exchange"] });
|
||||
// Clear form
|
||||
if (type === "ge_create_buy") {
|
||||
setBuyCode("");
|
||||
setBuyQty("1");
|
||||
setBuyPrice("");
|
||||
} else {
|
||||
setSellCode("");
|
||||
setSellQty("1");
|
||||
setSellPrice("");
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
`Order failed: ${err instanceof Error ? err.message : "Unknown error"}`
|
||||
);
|
||||
} finally {
|
||||
setOrderPending(null);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight text-foreground">
|
||||
Grand Exchange
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Browse market orders, track your trades, and analyze price history
|
||||
Browse market orders, create trades, and analyze price history
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Character selector for actions */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Label className="text-xs text-muted-foreground whitespace-nowrap">
|
||||
Acting as:
|
||||
</Label>
|
||||
<Select value={selectedCharacter} onValueChange={setSelectedCharacter}>
|
||||
<SelectTrigger className="w-44">
|
||||
<SelectValue placeholder="Select character" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(characters ?? []).map((c) => (
|
||||
<SelectItem key={c.name} value={c.name}>
|
||||
{c.name} (Lv.{c.level})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{ordersError && (
|
||||
<Card className="border-destructive bg-destructive/10 p-4">
|
||||
<p className="text-sm text-destructive">
|
||||
|
|
@ -246,8 +470,12 @@ export default function ExchangePage() {
|
|||
</Card>
|
||||
)}
|
||||
|
||||
<Tabs defaultValue="market">
|
||||
<Tabs defaultValue="trade">
|
||||
<TabsList>
|
||||
<TabsTrigger value="trade" className="gap-1.5">
|
||||
<Plus className="size-4" />
|
||||
Trade
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="market" className="gap-1.5">
|
||||
<ShoppingCart className="size-4" />
|
||||
Market
|
||||
|
|
@ -255,17 +483,174 @@ export default function ExchangePage() {
|
|||
<TabsTrigger value="my-orders" className="gap-1.5">
|
||||
<User className="size-4" />
|
||||
My Orders
|
||||
{myOrders && myOrders.length > 0 && (
|
||||
<Badge variant="secondary" className="ml-1 text-[10px] px-1.5 py-0">
|
||||
{myOrders.length}
|
||||
</Badge>
|
||||
)}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="history" className="gap-1.5">
|
||||
<History className="size-4" />
|
||||
Trade History
|
||||
History
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="prices" className="gap-1.5">
|
||||
<TrendingUp className="size-4" />
|
||||
Price History
|
||||
Prices
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* Trade Tab - Create buy/sell orders */}
|
||||
<TabsContent value="trade" className="space-y-4">
|
||||
{!selectedCharacter && (
|
||||
<Card className="border-amber-500/30 bg-amber-500/5 p-4">
|
||||
<p className="text-sm text-amber-400">
|
||||
Select a character in the top-right corner to create orders.
|
||||
The character must be at the Grand Exchange tile.
|
||||
</p>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
{/* Buy order form */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm text-green-400 flex items-center gap-2">
|
||||
<ShoppingCart className="size-4" />
|
||||
Create Buy Order
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">Item Code</Label>
|
||||
<Input
|
||||
placeholder="e.g. copper_ore"
|
||||
value={buyCode}
|
||||
onChange={(e) => setBuyCode(e.target.value)}
|
||||
className="h-9"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">Quantity</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min="1"
|
||||
value={buyQty}
|
||||
onChange={(e) => setBuyQty(e.target.value)}
|
||||
className="h-9"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">Price Each</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min="1"
|
||||
placeholder="Gold per unit"
|
||||
value={buyPrice}
|
||||
onChange={(e) => setBuyPrice(e.target.value)}
|
||||
className="h-9"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{buyCode && buyPrice && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Total cost:{" "}
|
||||
<span className="text-amber-400 font-medium">
|
||||
{((parseInt(buyQty, 10) || 1) * (parseInt(buyPrice, 10) || 0)).toLocaleString()} gold
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
<Button
|
||||
className="w-full bg-green-600 hover:bg-green-700 text-white"
|
||||
disabled={
|
||||
!selectedCharacter ||
|
||||
!buyCode.trim() ||
|
||||
!buyPrice ||
|
||||
orderPending !== null
|
||||
}
|
||||
onClick={() => handleCreateOrder("ge_create_buy")}
|
||||
>
|
||||
{orderPending === "ge_create_buy" ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
<ShoppingCart className="size-4" />
|
||||
)}
|
||||
Place Buy Order
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Sell order form */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm text-red-400 flex items-center gap-2">
|
||||
<ShoppingCart className="size-4" />
|
||||
Create Sell Order
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">Item Code</Label>
|
||||
<Input
|
||||
placeholder="e.g. copper_ore"
|
||||
value={sellCode}
|
||||
onChange={(e) => setSellCode(e.target.value)}
|
||||
className="h-9"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">Quantity</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min="1"
|
||||
value={sellQty}
|
||||
onChange={(e) => setSellQty(e.target.value)}
|
||||
className="h-9"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">Price Each</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min="1"
|
||||
placeholder="Gold per unit"
|
||||
value={sellPrice}
|
||||
onChange={(e) => setSellPrice(e.target.value)}
|
||||
className="h-9"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{sellCode && sellPrice && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Total value:{" "}
|
||||
<span className="text-amber-400 font-medium">
|
||||
{((parseInt(sellQty, 10) || 1) * (parseInt(sellPrice, 10) || 0)).toLocaleString()} gold
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
<Button
|
||||
className="w-full bg-red-600 hover:bg-red-700 text-white"
|
||||
disabled={
|
||||
!selectedCharacter ||
|
||||
!sellCode.trim() ||
|
||||
!sellPrice ||
|
||||
orderPending !== null
|
||||
}
|
||||
onClick={() => handleCreateOrder("ge_sell")}
|
||||
>
|
||||
{orderPending === "ge_sell" ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
<ShoppingCart className="size-4" />
|
||||
)}
|
||||
Place Sell Order
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* Market Tab - Browse all public orders */}
|
||||
<TabsContent value="market" className="space-y-4">
|
||||
<div className="relative max-w-sm">
|
||||
|
|
@ -278,12 +663,21 @@ export default function ExchangePage() {
|
|||
/>
|
||||
</div>
|
||||
|
||||
{!selectedCharacter && (
|
||||
<Card className="border-amber-500/30 bg-amber-500/5 p-4">
|
||||
<p className="text-sm text-amber-400">
|
||||
Select a character in the top-right corner to buy items directly from sell orders.
|
||||
</p>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<OrdersTable
|
||||
orders={orders ?? []}
|
||||
isLoading={loadingOrders}
|
||||
search={marketSearch}
|
||||
showAccount
|
||||
onBuy={selectedCharacter ? (order) => setBuyOrder(order) : undefined}
|
||||
emptyMessage={
|
||||
marketSearch.trim()
|
||||
? `No orders found for "${marketSearch}"`
|
||||
|
|
@ -293,19 +687,27 @@ export default function ExchangePage() {
|
|||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
{/* My Orders Tab - User's own active orders */}
|
||||
{/* My Orders Tab - with cancel buttons */}
|
||||
<TabsContent value="my-orders" className="space-y-4">
|
||||
{!selectedCharacter && (
|
||||
<Card className="border-amber-500/30 bg-amber-500/5 p-4">
|
||||
<p className="text-sm text-amber-400">
|
||||
Select a character to cancel orders. The character must be at the Grand Exchange.
|
||||
</p>
|
||||
</Card>
|
||||
)}
|
||||
<Card>
|
||||
<OrdersTable
|
||||
<MyOrdersTable
|
||||
orders={myOrders ?? []}
|
||||
isLoading={loadingMyOrders}
|
||||
search=""
|
||||
emptyMessage="You have no active orders on the Grand Exchange."
|
||||
selectedCharacter={selectedCharacter}
|
||||
onCancel={handleCancelOrder}
|
||||
cancelPending={cancelPending}
|
||||
/>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
{/* Trade History Tab - User's transaction history */}
|
||||
{/* Trade History Tab */}
|
||||
<TabsContent value="history" className="space-y-4">
|
||||
<Card>
|
||||
<HistoryTable
|
||||
|
|
@ -361,6 +763,13 @@ export default function ExchangePage() {
|
|||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<BuyEquipDialog
|
||||
order={buyOrder}
|
||||
characterName={selectedCharacter}
|
||||
items={items}
|
||||
onOpenChange={(open) => !open && setBuyOrder(null)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,12 +1,13 @@
|
|||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
ScrollText,
|
||||
Loader2,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
ChevronDown,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
} from "lucide-react";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
|
@ -30,20 +31,28 @@ import { useCharacters } from "@/hooks/use-characters";
|
|||
import { useLogs } from "@/hooks/use-analytics";
|
||||
import type { ActionLog } from "@/lib/types";
|
||||
|
||||
const PAGE_SIZE = 50;
|
||||
|
||||
const ACTION_TYPE_COLORS: Record<string, string> = {
|
||||
move: "bg-blue-500/20 text-blue-400",
|
||||
movement: "bg-blue-500/20 text-blue-400",
|
||||
fight: "bg-red-500/20 text-red-400",
|
||||
gather: "bg-green-500/20 text-green-400",
|
||||
gathering: "bg-green-500/20 text-green-400",
|
||||
rest: "bg-yellow-500/20 text-yellow-400",
|
||||
deposit: "bg-purple-500/20 text-purple-400",
|
||||
withdraw: "bg-cyan-500/20 text-cyan-400",
|
||||
craft: "bg-emerald-500/20 text-emerald-400",
|
||||
crafting: "bg-emerald-500/20 text-emerald-400",
|
||||
buy: "bg-teal-500/20 text-teal-400",
|
||||
sell: "bg-pink-500/20 text-pink-400",
|
||||
equip: "bg-orange-500/20 text-orange-400",
|
||||
unequip: "bg-orange-500/20 text-orange-400",
|
||||
use: "bg-amber-500/20 text-amber-400",
|
||||
task: "bg-indigo-500/20 text-indigo-400",
|
||||
recycling: "bg-lime-500/20 text-lime-400",
|
||||
npc_buy: "bg-teal-500/20 text-teal-400",
|
||||
npc_sell: "bg-pink-500/20 text-pink-400",
|
||||
grandexchange_buy: "bg-teal-500/20 text-teal-400",
|
||||
grandexchange_sell: "bg-pink-500/20 text-pink-400",
|
||||
grandexchange_cancel: "bg-slate-500/20 text-slate-400",
|
||||
};
|
||||
|
||||
function getActionColor(type: string): string {
|
||||
|
|
@ -65,36 +74,47 @@ function getDetailsString(log: ActionLog): string {
|
|||
const d = log.details;
|
||||
if (!d || Object.keys(d).length === 0) return "-";
|
||||
|
||||
const parts: string[] = [];
|
||||
// Use description from the game API if available
|
||||
if (d.description && typeof d.description === "string") return d.description;
|
||||
|
||||
if (d.reason && typeof d.reason === "string") return d.reason;
|
||||
if (d.message && typeof d.message === "string") return d.message;
|
||||
if (d.error && typeof d.error === "string") return d.error;
|
||||
if (d.result && typeof d.result === "string") return d.result;
|
||||
|
||||
const parts: string[] = [];
|
||||
if (d.monster) parts.push(`monster: ${d.monster}`);
|
||||
if (d.resource) parts.push(`resource: ${d.resource}`);
|
||||
if (d.item) parts.push(`item: ${d.item}`);
|
||||
if (d.skill) parts.push(`skill: ${d.skill}`);
|
||||
if (d.map_name) parts.push(`${d.map_name}`);
|
||||
if (d.x !== undefined && d.y !== undefined) parts.push(`(${d.x}, ${d.y})`);
|
||||
if (d.xp) parts.push(`xp: +${d.xp}`);
|
||||
if (d.gold) parts.push(`gold: ${d.gold}`);
|
||||
if (d.quantity) parts.push(`qty: ${d.quantity}`);
|
||||
if (Array.isArray(d.drops) && d.drops.length > 0) parts.push(`drops: ${(d.drops as string[]).join(", ")}`);
|
||||
|
||||
return parts.length > 0 ? parts.join(" | ") : JSON.stringify(d);
|
||||
}
|
||||
|
||||
const ALL_ACTION_TYPES = [
|
||||
"move",
|
||||
"movement",
|
||||
"fight",
|
||||
"gather",
|
||||
"gathering",
|
||||
"crafting",
|
||||
"recycling",
|
||||
"rest",
|
||||
"deposit",
|
||||
"withdraw",
|
||||
"craft",
|
||||
"buy",
|
||||
"sell",
|
||||
"equip",
|
||||
"unequip",
|
||||
"use",
|
||||
"deposit",
|
||||
"withdraw",
|
||||
"buy",
|
||||
"sell",
|
||||
"npc_buy",
|
||||
"npc_sell",
|
||||
"grandexchange_buy",
|
||||
"grandexchange_sell",
|
||||
"task",
|
||||
];
|
||||
|
||||
|
|
@ -102,29 +122,26 @@ export default function LogsPage() {
|
|||
const { data: characters } = useCharacters();
|
||||
const [characterFilter, setCharacterFilter] = useState("_all");
|
||||
const [actionFilter, setActionFilter] = useState("_all");
|
||||
const [visibleCount, setVisibleCount] = useState(50);
|
||||
const [page, setPage] = useState(1);
|
||||
|
||||
const { data: logs, isLoading, error } = useLogs({
|
||||
const { data, isLoading, error } = useLogs({
|
||||
character: characterFilter === "_all" ? undefined : characterFilter,
|
||||
type: actionFilter === "_all" ? undefined : actionFilter,
|
||||
page,
|
||||
size: PAGE_SIZE,
|
||||
});
|
||||
|
||||
const filteredLogs = useMemo(() => {
|
||||
let items = logs ?? [];
|
||||
const logs = data?.logs ?? [];
|
||||
const totalPages = data?.pages ?? 1;
|
||||
const total = data?.total ?? 0;
|
||||
|
||||
if (actionFilter !== "_all") {
|
||||
items = items.filter((log) => log.action_type === actionFilter);
|
||||
function handleFilterChange(setter: (v: string) => void) {
|
||||
return (value: string) => {
|
||||
setter(value);
|
||||
setPage(1);
|
||||
};
|
||||
}
|
||||
|
||||
// Sort by created_at descending
|
||||
return [...items].sort(
|
||||
(a, b) =>
|
||||
new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
|
||||
);
|
||||
}, [logs, actionFilter]);
|
||||
|
||||
const visibleLogs = filteredLogs.slice(0, visibleCount);
|
||||
const hasMore = visibleCount < filteredLogs.length;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
|
|
@ -132,7 +149,7 @@ export default function LogsPage() {
|
|||
Action Logs
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
View detailed action logs across all characters
|
||||
Live action history from the game server
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
|
@ -146,7 +163,7 @@ export default function LogsPage() {
|
|||
|
||||
{/* Filters */}
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Select value={characterFilter} onValueChange={setCharacterFilter}>
|
||||
<Select value={characterFilter} onValueChange={handleFilterChange(setCharacterFilter)}>
|
||||
<SelectTrigger className="w-48">
|
||||
<SelectValue placeholder="All Characters" />
|
||||
</SelectTrigger>
|
||||
|
|
@ -160,7 +177,7 @@ export default function LogsPage() {
|
|||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select value={actionFilter} onValueChange={setActionFilter}>
|
||||
<Select value={actionFilter} onValueChange={handleFilterChange(setActionFilter)}>
|
||||
<SelectTrigger className="w-48">
|
||||
<SelectValue placeholder="All Actions" />
|
||||
</SelectTrigger>
|
||||
|
|
@ -168,41 +185,41 @@ export default function LogsPage() {
|
|||
<SelectItem value="_all">All Actions</SelectItem>
|
||||
{ALL_ACTION_TYPES.map((type) => (
|
||||
<SelectItem key={type} value={type}>
|
||||
<span className="capitalize">{type}</span>
|
||||
<span className="capitalize">{type.replace(/_/g, " ")}</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{filteredLogs.length > 0 && (
|
||||
{total > 0 && (
|
||||
<div className="flex items-center text-xs text-muted-foreground self-center ml-auto">
|
||||
Showing {visibleLogs.length} of {filteredLogs.length} entries
|
||||
{total.toLocaleString()} total entries
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isLoading && (
|
||||
{isLoading && logs.length === 0 && (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="size-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Log Table */}
|
||||
{visibleLogs.length > 0 && (
|
||||
{logs.length > 0 && (
|
||||
<Card>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-40">Time</TableHead>
|
||||
<TableHead className="w-32">Character</TableHead>
|
||||
<TableHead className="w-24">Action</TableHead>
|
||||
<TableHead className="w-28">Action</TableHead>
|
||||
<TableHead>Details</TableHead>
|
||||
<TableHead className="w-16 text-center">Status</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{visibleLogs.map((log) => (
|
||||
<TableRow key={log.id}>
|
||||
{logs.map((log, idx) => (
|
||||
<TableRow key={`${log.id}-${idx}`}>
|
||||
<TableCell className="text-xs text-muted-foreground tabular-nums">
|
||||
{formatDate(log.created_at)}
|
||||
</TableCell>
|
||||
|
|
@ -214,7 +231,7 @@ export default function LogsPage() {
|
|||
variant="outline"
|
||||
className={`text-[10px] px-1.5 py-0 border-0 capitalize ${getActionColor(log.action_type)}`}
|
||||
>
|
||||
{log.action_type}
|
||||
{log.action_type.replace(/_/g, " ")}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground max-w-md truncate">
|
||||
|
|
@ -234,26 +251,39 @@ export default function LogsPage() {
|
|||
</Card>
|
||||
)}
|
||||
|
||||
{/* Load More */}
|
||||
{hasMore && (
|
||||
<div className="flex justify-center">
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-center gap-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setVisibleCount((c) => c + 50)}
|
||||
size="sm"
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
disabled={page <= 1}
|
||||
>
|
||||
<ChevronDown className="size-4" />
|
||||
Load More
|
||||
<ChevronLeft className="size-4" />
|
||||
Previous
|
||||
</Button>
|
||||
<span className="text-sm text-muted-foreground tabular-nums">
|
||||
Page {page} of {totalPages}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
||||
disabled={page >= totalPages}
|
||||
>
|
||||
Next
|
||||
<ChevronRight className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty state */}
|
||||
{filteredLogs.length === 0 && !isLoading && (
|
||||
{logs.length === 0 && !isLoading && (
|
||||
<Card className="p-8 text-center">
|
||||
<ScrollText className="size-12 text-muted-foreground mx-auto mb-4" />
|
||||
<p className="text-muted-foreground">
|
||||
No log entries found. Actions performed by characters or automations
|
||||
will appear here.
|
||||
No log entries found. Perform actions in the game to generate logs.
|
||||
</p>
|
||||
</Card>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -8,9 +8,7 @@ import {
|
|||
useCallback,
|
||||
} from "react";
|
||||
import {
|
||||
getAuthStatus,
|
||||
setAuthToken,
|
||||
clearAuthToken,
|
||||
type AuthStatus,
|
||||
} from "@/lib/api-client";
|
||||
|
||||
|
|
@ -38,39 +36,21 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||
const [status, setStatus] = useState<AuthStatus | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const checkStatus = useCallback(async () => {
|
||||
try {
|
||||
const s = await getAuthStatus();
|
||||
setStatus(s);
|
||||
|
||||
// If backend has no token but we have one in localStorage, auto-restore it
|
||||
if (!s.has_token) {
|
||||
// Gate is based entirely on localStorage — each browser has its own token.
|
||||
useEffect(() => {
|
||||
const savedToken = localStorage.getItem(STORAGE_KEY);
|
||||
if (savedToken) {
|
||||
const result = await setAuthToken(savedToken);
|
||||
if (result.success) {
|
||||
setStatus({ has_token: true, source: "user" });
|
||||
} else {
|
||||
// Token is stale, remove it
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Backend unreachable — show the gate anyway
|
||||
setStatus({ has_token: false, source: "none" });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
setLoading(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
checkStatus();
|
||||
}, [checkStatus]);
|
||||
|
||||
const handleSetToken = useCallback(
|
||||
async (token: string): Promise<{ success: boolean; error?: string }> => {
|
||||
try {
|
||||
// Validate the token with the backend (it won't store it)
|
||||
const result = await setAuthToken(token);
|
||||
if (result.success) {
|
||||
localStorage.setItem(STORAGE_KEY, token);
|
||||
|
|
@ -89,13 +69,8 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||
);
|
||||
|
||||
const handleRemoveToken = useCallback(async () => {
|
||||
try {
|
||||
const s = await clearAuthToken();
|
||||
setStatus(s);
|
||||
} catch {
|
||||
setStatus({ has_token: false, source: "none" });
|
||||
}
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
setStatus({ has_token: false, source: "none" });
|
||||
}, []);
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { X, Loader2 } from "lucide-react";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
|
|
@ -7,16 +9,44 @@ import {
|
|||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { EQUIPMENT_SLOTS } from "@/lib/constants";
|
||||
import type { Character } from "@/lib/types";
|
||||
import { GameIcon } from "@/components/ui/game-icon";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { executeAction } from "@/lib/api-client";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface EquipmentGridProps {
|
||||
character: Character;
|
||||
onActionComplete?: () => void;
|
||||
}
|
||||
|
||||
export function EquipmentGrid({ character, onActionComplete }: EquipmentGridProps) {
|
||||
const [pendingSlot, setPendingSlot] = useState<string | null>(null);
|
||||
|
||||
async function handleUnequip(slotKey: string) {
|
||||
const slot = slotKey.replace("_slot", "");
|
||||
setPendingSlot(slotKey);
|
||||
try {
|
||||
await executeAction(character.name, "unequip", { slot });
|
||||
toast.success(`Unequipped ${slot}`);
|
||||
onActionComplete?.();
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
`Unequip failed: ${err instanceof Error ? err.message : "Unknown error"}`
|
||||
);
|
||||
} finally {
|
||||
setPendingSlot(null);
|
||||
}
|
||||
}
|
||||
|
||||
export function EquipmentGrid({ character }: EquipmentGridProps) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
|
|
@ -27,8 +57,8 @@ export function EquipmentGrid({ character }: EquipmentGridProps) {
|
|||
{EQUIPMENT_SLOTS.map((slot) => {
|
||||
const itemCode = character[slot.key as keyof Character] as string;
|
||||
const isEmpty = !itemCode;
|
||||
const isPending = pendingSlot === slot.key;
|
||||
|
||||
// Show quantity for utility slots
|
||||
let quantity: number | null = null;
|
||||
if (slot.key === "utility1_slot" && itemCode) {
|
||||
quantity = character.utility1_slot_quantity;
|
||||
|
|
@ -40,10 +70,10 @@ export function EquipmentGrid({ character }: EquipmentGridProps) {
|
|||
<div
|
||||
key={slot.key}
|
||||
className={cn(
|
||||
"flex flex-col gap-1 rounded-lg border p-2.5 transition-colors",
|
||||
"group relative flex flex-col gap-1 rounded-lg border p-2.5 transition-colors",
|
||||
isEmpty
|
||||
? "border-dashed border-border/50 bg-transparent"
|
||||
: "border-border bg-accent/30"
|
||||
: "border-border bg-accent/30 hover:bg-accent/50"
|
||||
)}
|
||||
>
|
||||
<span className="text-[10px] uppercase tracking-wider text-muted-foreground">
|
||||
|
|
@ -69,6 +99,32 @@ export function EquipmentGrid({ character }: EquipmentGridProps) {
|
|||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Unequip button - shown on hover */}
|
||||
{!isEmpty && (
|
||||
<TooltipProvider delayDuration={200}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="absolute top-1 right-1 size-6 opacity-0 group-hover:opacity-100 transition-opacity hover:bg-destructive/20 hover:text-destructive"
|
||||
disabled={isPending}
|
||||
onClick={() => handleUnequip(slot.key)}
|
||||
>
|
||||
{isPending ? (
|
||||
<Loader2 className="size-3 animate-spin" />
|
||||
) : (
|
||||
<X className="size-3" />
|
||||
)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
<p>Unequip {slot.label}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,16 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Shield,
|
||||
FlaskConical,
|
||||
Landmark,
|
||||
Recycle,
|
||||
Loader2,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Package,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
|
|
@ -8,26 +19,64 @@ import {
|
|||
CardDescription,
|
||||
} from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { GameIcon } from "@/components/ui/game-icon";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { Character } from "@/lib/types";
|
||||
import { EQUIPMENT_SLOTS } from "@/lib/constants";
|
||||
import type { Character, InventorySlot } from "@/lib/types";
|
||||
import { executeAction } from "@/lib/api-client";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface InventoryGridProps {
|
||||
character: Character;
|
||||
onActionComplete?: () => void;
|
||||
}
|
||||
|
||||
export function InventoryGrid({ character }: InventoryGridProps) {
|
||||
const usedSlots = character.inventory.filter(
|
||||
(slot) => slot.code && slot.code !== ""
|
||||
).length;
|
||||
const totalSlots = character.inventory_max_items;
|
||||
export function InventoryGrid({ character, onActionComplete }: InventoryGridProps) {
|
||||
const [pendingSlot, setPendingSlot] = useState<number | null>(null);
|
||||
const [showAllSlots, setShowAllSlots] = useState(false);
|
||||
|
||||
// Build a map from slot number to inventory item
|
||||
const slotMap = new Map(
|
||||
character.inventory
|
||||
.filter((slot) => slot.code && slot.code !== "")
|
||||
.map((slot) => [slot.slot, slot])
|
||||
const filledItems = character.inventory.filter(
|
||||
(slot) => slot.code && slot.code !== ""
|
||||
);
|
||||
const usedSlots = filledItems.length;
|
||||
const totalSlots = character.inventory_max_items;
|
||||
const emptySlots = totalSlots - usedSlots;
|
||||
|
||||
async function handleAction(
|
||||
slotNum: number,
|
||||
action: string,
|
||||
params: Record<string, unknown>
|
||||
) {
|
||||
setPendingSlot(slotNum);
|
||||
try {
|
||||
await executeAction(character.name, action, params);
|
||||
toast.success(`${action} successful`);
|
||||
onActionComplete?.();
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
`${action} failed: ${err instanceof Error ? err.message : "Unknown error"}`
|
||||
);
|
||||
} finally {
|
||||
setPendingSlot(null);
|
||||
}
|
||||
}
|
||||
|
||||
const equipSlots = EQUIPMENT_SLOTS.map((s) => ({
|
||||
key: s.key.replace("_slot", ""),
|
||||
label: s.label,
|
||||
}));
|
||||
|
||||
return (
|
||||
<Card>
|
||||
|
|
@ -39,40 +88,223 @@ export function InventoryGrid({ character }: InventoryGridProps) {
|
|||
</Badge>
|
||||
</div>
|
||||
<CardDescription>
|
||||
{totalSlots - usedSlots} slots available
|
||||
{emptySlots} slots available
|
||||
{usedSlots > 0 && " \u00b7 Click items for actions"}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-5 sm:grid-cols-6 md:grid-cols-5 lg:grid-cols-6 gap-1.5">
|
||||
{Array.from({ length: totalSlots }).map((_, index) => {
|
||||
const item = slotMap.get(index + 1);
|
||||
const isEmpty = !item;
|
||||
{usedSlots === 0 ? (
|
||||
<div className="flex items-center gap-2 py-4 justify-center text-sm text-muted-foreground">
|
||||
<Package className="size-4" />
|
||||
Inventory is empty
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-5 sm:grid-cols-6 md:grid-cols-8 lg:grid-cols-10 gap-1.5">
|
||||
{filledItems.map((item) => (
|
||||
<InventoryItemSlot
|
||||
key={item.slot}
|
||||
item={item}
|
||||
isPending={pendingSlot === item.slot}
|
||||
equipSlots={equipSlots}
|
||||
characterName={character.name}
|
||||
onAction={(action, params) =>
|
||||
handleAction(item.slot, action, params)
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Expandable full grid with all slots */}
|
||||
{emptySlots > 0 && (
|
||||
<div className="mt-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="w-full text-xs text-muted-foreground h-7"
|
||||
onClick={() => setShowAllSlots(!showAllSlots)}
|
||||
>
|
||||
{showAllSlots ? (
|
||||
<>
|
||||
<ChevronUp className="size-3 mr-1" />
|
||||
Hide empty slots
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ChevronDown className="size-3 mr-1" />
|
||||
Show all {totalSlots} slots ({emptySlots} empty)
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{showAllSlots && (
|
||||
<div className="grid grid-cols-8 sm:grid-cols-10 md:grid-cols-12 lg:grid-cols-14 gap-1 mt-2">
|
||||
{Array.from({ length: totalSlots }).map((_, index) => {
|
||||
const slotNum = index + 1;
|
||||
const item = filledItems.find((i) => i.slot === slotNum);
|
||||
|
||||
if (!item) {
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className="rounded border border-dashed border-border/30 aspect-square min-h-[32px]"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<InventoryItemSlot
|
||||
key={index}
|
||||
item={item}
|
||||
isPending={pendingSlot === slotNum}
|
||||
equipSlots={equipSlots}
|
||||
characterName={character.name}
|
||||
onAction={(action, params) =>
|
||||
handleAction(item.slot, action, params)
|
||||
}
|
||||
compact
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function InventoryItemSlot({
|
||||
item,
|
||||
isPending,
|
||||
equipSlots,
|
||||
onAction,
|
||||
compact,
|
||||
}: {
|
||||
item: InventorySlot;
|
||||
isPending: boolean;
|
||||
equipSlots: { key: string; label: string }[];
|
||||
characterName: string;
|
||||
onAction: (action: string, params: Record<string, unknown>) => void;
|
||||
compact?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
className={cn(
|
||||
"flex flex-col items-center justify-center rounded-md border p-1.5 aspect-square min-h-[48px]",
|
||||
isEmpty
|
||||
? "border-dashed border-border/40"
|
||||
: "border-border bg-accent/30"
|
||||
"flex flex-col items-center justify-center rounded-md border transition-colors cursor-pointer",
|
||||
"border-border bg-accent/30 hover:bg-accent/60 hover:border-primary/40",
|
||||
isPending && "opacity-50 pointer-events-none",
|
||||
compact
|
||||
? "p-0.5 aspect-square min-h-[32px]"
|
||||
: "p-1.5 aspect-square min-h-[48px]"
|
||||
)}
|
||||
>
|
||||
{item && (
|
||||
{isPending ? (
|
||||
<Loader2
|
||||
className={cn(
|
||||
"animate-spin text-muted-foreground",
|
||||
compact ? "size-3" : "size-5"
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<div className="relative flex items-center justify-center">
|
||||
<GameIcon type="item" code={item.code} size="md" />
|
||||
<GameIcon
|
||||
type="item"
|
||||
code={item.code}
|
||||
size={compact ? "sm" : "md"}
|
||||
/>
|
||||
{item.quantity > 1 && (
|
||||
<span className="absolute -bottom-0.5 -right-0.5 rounded bg-background/80 px-0.5 text-[9px] font-medium text-muted-foreground leading-tight">
|
||||
<span
|
||||
className={cn(
|
||||
"absolute -bottom-0.5 -right-0.5 rounded bg-background/80 font-medium text-muted-foreground leading-tight",
|
||||
compact ? "px-0.5 text-[7px]" : "px-0.5 text-[9px]"
|
||||
)}
|
||||
>
|
||||
{item.quantity}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-48">
|
||||
<DropdownMenuLabel className="flex items-center gap-2">
|
||||
<GameIcon type="item" code={item.code} size="sm" />
|
||||
{item.code}
|
||||
{item.quantity > 1 && (
|
||||
<span className="text-muted-foreground font-normal">
|
||||
x{item.quantity}
|
||||
</span>
|
||||
)}
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
{/* Equip - submenu with slot selection */}
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>
|
||||
<Shield className="size-4 text-blue-400" />
|
||||
Equip
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent>
|
||||
{equipSlots.map((slot) => (
|
||||
<DropdownMenuItem
|
||||
key={slot.key}
|
||||
onClick={() =>
|
||||
onAction("equip", { code: item.code, slot: slot.key })
|
||||
}
|
||||
>
|
||||
{slot.label}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
|
||||
{/* Use item */}
|
||||
<DropdownMenuItem
|
||||
onClick={() => onAction("use_item", { code: item.code, quantity: 1 })}
|
||||
>
|
||||
<FlaskConical className="size-4 text-green-400" />
|
||||
Use
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
{/* Deposit to bank */}
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
onAction("deposit", { code: item.code, quantity: item.quantity })
|
||||
}
|
||||
>
|
||||
<Landmark className="size-4 text-purple-400" />
|
||||
Deposit All ({item.quantity})
|
||||
</DropdownMenuItem>
|
||||
|
||||
{item.quantity > 1 && (
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
onAction("deposit", { code: item.code, quantity: 1 })
|
||||
}
|
||||
>
|
||||
<Landmark className="size-4 text-purple-400" />
|
||||
Deposit 1
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
{/* Recycle */}
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
onAction("recycle", { code: item.code, quantity: 1 })
|
||||
}
|
||||
>
|
||||
<Recycle className="size-4 text-orange-400" />
|
||||
Recycle
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
299
frontend/src/components/exchange/buy-equip-dialog.tsx
Normal file
299
frontend/src/components/exchange/buy-equip-dialog.tsx
Normal file
|
|
@ -0,0 +1,299 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Loader2, ShoppingCart, Shield, CheckCircle2 } from "lucide-react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { GameIcon } from "@/components/ui/game-icon";
|
||||
import { EQUIPMENT_SLOTS } from "@/lib/constants";
|
||||
import { executeAction } from "@/lib/api-client";
|
||||
import { toast } from "sonner";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import type { GEOrder, Item } from "@/lib/types";
|
||||
|
||||
// Map item type → suggested equipment slot key (without _slot suffix)
|
||||
const TYPE_TO_SLOT: Record<string, string> = {
|
||||
weapon: "weapon",
|
||||
shield: "shield",
|
||||
helmet: "helmet",
|
||||
body_armor: "body_armor",
|
||||
leg_armor: "leg_armor",
|
||||
boots: "boots",
|
||||
ring: "ring1",
|
||||
amulet: "amulet",
|
||||
artifact: "artifact1",
|
||||
};
|
||||
|
||||
interface BuyEquipDialogProps {
|
||||
order: GEOrder | null;
|
||||
characterName: string;
|
||||
items?: Item[];
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
type Step = "confirm" | "buying" | "bought" | "equipping" | "equipped";
|
||||
|
||||
export function BuyEquipDialog({
|
||||
order,
|
||||
characterName,
|
||||
items,
|
||||
onOpenChange,
|
||||
}: BuyEquipDialogProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const [step, setStep] = useState<Step>("confirm");
|
||||
const [quantity, setQuantity] = useState("1");
|
||||
const [equipSlot, setEquipSlot] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
if (!order) return null;
|
||||
|
||||
const itemInfo = items?.find((i) => i.code === order.code);
|
||||
const isEquippable = itemInfo
|
||||
? Object.keys(TYPE_TO_SLOT).includes(itemInfo.type)
|
||||
: false;
|
||||
|
||||
const maxQty = order.quantity;
|
||||
const qty = Math.max(1, Math.min(parseInt(quantity, 10) || 1, maxQty));
|
||||
const totalCost = qty * order.price;
|
||||
|
||||
// Auto-suggest slot based on item type
|
||||
const suggestedSlot = itemInfo ? TYPE_TO_SLOT[itemInfo.type] ?? "" : "";
|
||||
|
||||
function handleClose() {
|
||||
setStep("confirm");
|
||||
setQuantity("1");
|
||||
setEquipSlot("");
|
||||
setError(null);
|
||||
onOpenChange(false);
|
||||
}
|
||||
|
||||
async function handleBuy() {
|
||||
setStep("buying");
|
||||
setError(null);
|
||||
try {
|
||||
await executeAction(characterName, "ge_buy", {
|
||||
id: order!.id,
|
||||
quantity: qty,
|
||||
});
|
||||
toast.success(`Bought ${qty}x ${order!.code}`);
|
||||
queryClient.invalidateQueries({ queryKey: ["exchange"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["character", characterName] });
|
||||
setStep("bought");
|
||||
|
||||
// Pre-select suggested slot
|
||||
if (suggestedSlot && !equipSlot) {
|
||||
setEquipSlot(suggestedSlot);
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Buy failed");
|
||||
setStep("confirm");
|
||||
}
|
||||
}
|
||||
|
||||
async function handleEquip() {
|
||||
if (!equipSlot) {
|
||||
toast.error("Select an equipment slot");
|
||||
return;
|
||||
}
|
||||
setStep("equipping");
|
||||
setError(null);
|
||||
try {
|
||||
await executeAction(characterName, "equip", {
|
||||
code: order!.code,
|
||||
slot: equipSlot,
|
||||
});
|
||||
toast.success(`Equipped ${order!.code} to ${equipSlot}`);
|
||||
queryClient.invalidateQueries({ queryKey: ["character", characterName] });
|
||||
setStep("equipped");
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Equip failed");
|
||||
setStep("bought");
|
||||
}
|
||||
}
|
||||
|
||||
const equipSlots = EQUIPMENT_SLOTS.map((s) => ({
|
||||
key: s.key.replace("_slot", ""),
|
||||
label: s.label,
|
||||
}));
|
||||
|
||||
return (
|
||||
<Dialog open={!!order} onOpenChange={(open) => !open && handleClose()}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<GameIcon type="item" code={order.code} size="md" showTooltip={false} />
|
||||
{step === "equipped" ? "Equipped!" : step === "bought" ? "Purchased!" : `Buy ${order.code}`}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{step === "confirm" && "Place a buy order to match this sell listing."}
|
||||
{step === "buying" && "Placing buy order..."}
|
||||
{step === "bought" && (isEquippable
|
||||
? "Item purchased! You can now equip it."
|
||||
: "Item purchased successfully!")}
|
||||
{step === "equipping" && "Equipping item..."}
|
||||
{step === "equipped" && "Item equipped successfully!"}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{/* Item details */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">Price each</span>
|
||||
<span className="text-amber-400 font-medium tabular-nums">
|
||||
{order.price.toLocaleString()} gold
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{itemInfo && (
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">Type</span>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{itemInfo.type}{itemInfo.subtype ? ` / ${itemInfo.subtype}` : ""}
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{itemInfo && itemInfo.level > 0 && (
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">Required level</span>
|
||||
<span className="tabular-nums">{itemInfo.level}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">Seller</span>
|
||||
<span className="text-xs">{order.account ?? "Unknown"}</span>
|
||||
</div>
|
||||
|
||||
{/* Confirm step: quantity selector */}
|
||||
{step === "confirm" && (
|
||||
<>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Quantity (max {maxQty})</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min="1"
|
||||
max={maxQty}
|
||||
value={quantity}
|
||||
onChange={(e) => setQuantity(e.target.value)}
|
||||
className="h-9"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-sm font-medium">
|
||||
<span>Total cost</span>
|
||||
<span className="text-amber-400 tabular-nums">
|
||||
{totalCost.toLocaleString()} gold
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Bought step: equip slot selector */}
|
||||
{step === "bought" && isEquippable && (
|
||||
<div className="space-y-1.5 pt-2 border-t">
|
||||
<Label className="text-xs">Equip to slot</Label>
|
||||
<Select value={equipSlot} onValueChange={setEquipSlot}>
|
||||
<SelectTrigger className="h-9">
|
||||
<SelectValue placeholder="Select slot..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{equipSlots.map((slot) => (
|
||||
<SelectItem key={slot.key} value={slot.key}>
|
||||
{slot.label}
|
||||
{slot.key === suggestedSlot && " (suggested)"}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Success states */}
|
||||
{step === "equipped" && (
|
||||
<div className="flex items-center gap-2 text-green-400 pt-2 border-t">
|
||||
<CheckCircle2 className="size-4" />
|
||||
<span className="text-sm">
|
||||
{order.code} equipped to {equipSlots.find((s) => s.key === equipSlot)?.label ?? equipSlot}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<p className="text-sm text-destructive">{error}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
{step === "confirm" && (
|
||||
<>
|
||||
<Button variant="outline" onClick={handleClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
className="bg-green-600 hover:bg-green-700 text-white"
|
||||
disabled={!characterName || qty < 1}
|
||||
onClick={handleBuy}
|
||||
>
|
||||
<ShoppingCart className="size-4" />
|
||||
Buy {qty}x for {totalCost.toLocaleString()}g
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{step === "buying" && (
|
||||
<Button disabled>
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
Buying...
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{step === "bought" && (
|
||||
<>
|
||||
<Button variant="outline" onClick={handleClose}>
|
||||
Done
|
||||
</Button>
|
||||
{isEquippable && (
|
||||
<Button
|
||||
className="bg-blue-600 hover:bg-blue-700 text-white"
|
||||
disabled={!equipSlot}
|
||||
onClick={handleEquip}
|
||||
>
|
||||
<Shield className="size-4" />
|
||||
Equip
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{step === "equipping" && (
|
||||
<Button disabled>
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
Equipping...
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{step === "equipped" && (
|
||||
<Button onClick={handleClose}>Done</Button>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
|
@ -11,6 +11,7 @@ import {
|
|||
ArrowLeftRight,
|
||||
Zap,
|
||||
ScrollText,
|
||||
AlertTriangle,
|
||||
BarChart3,
|
||||
Settings,
|
||||
ChevronLeft,
|
||||
|
|
@ -35,6 +36,7 @@ const ICON_MAP: Record<string, React.ComponentType<{ className?: string }>> = {
|
|||
ArrowLeftRight,
|
||||
Zap,
|
||||
ScrollText,
|
||||
AlertTriangle,
|
||||
BarChart3,
|
||||
Settings,
|
||||
};
|
||||
|
|
|
|||
714
frontend/src/components/pipeline/pipeline-builder.tsx
Normal file
714
frontend/src/components/pipeline/pipeline-builder.tsx
Normal file
|
|
@ -0,0 +1,714 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import {
|
||||
Plus,
|
||||
Trash2,
|
||||
ChevronUp,
|
||||
ChevronDown,
|
||||
Loader2,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { useCharacters } from "@/hooks/use-characters";
|
||||
import { useCreatePipeline } from "@/hooks/use-pipelines";
|
||||
import { PIPELINE_TEMPLATES } from "./pipeline-templates";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface CharacterStepDraft {
|
||||
id: string;
|
||||
character_name: string;
|
||||
strategy_type: string;
|
||||
config: Record<string, unknown>;
|
||||
transition: {
|
||||
type: string;
|
||||
operator?: string;
|
||||
value?: number;
|
||||
item_code?: string;
|
||||
skill?: string;
|
||||
seconds?: number;
|
||||
} | null;
|
||||
}
|
||||
|
||||
interface StageDraft {
|
||||
id: string;
|
||||
name: string;
|
||||
character_steps: CharacterStepDraft[];
|
||||
}
|
||||
|
||||
const STRATEGY_OPTIONS = [
|
||||
{ value: "combat", label: "Combat" },
|
||||
{ value: "gathering", label: "Gathering" },
|
||||
{ value: "crafting", label: "Crafting" },
|
||||
{ value: "trading", label: "Trading" },
|
||||
{ value: "task", label: "Task" },
|
||||
{ value: "leveling", label: "Leveling" },
|
||||
];
|
||||
|
||||
const TRANSITION_OPTIONS = [
|
||||
{ value: "strategy_complete", label: "Strategy Complete" },
|
||||
{ value: "inventory_full", label: "Inventory Full" },
|
||||
{ value: "inventory_item_count", label: "Inventory Item Count" },
|
||||
{ value: "bank_item_count", label: "Bank Item Count" },
|
||||
{ value: "skill_level", label: "Skill Level" },
|
||||
{ value: "gold_amount", label: "Gold Amount" },
|
||||
{ value: "actions_count", label: "Actions Count" },
|
||||
{ value: "timer", label: "Timer (seconds)" },
|
||||
];
|
||||
|
||||
function makeStageId(idx: number) {
|
||||
return `stage_${idx + 1}`;
|
||||
}
|
||||
|
||||
function makeStepId(stageIdx: number, stepIdx: number) {
|
||||
return `cs_${stageIdx + 1}${String.fromCharCode(97 + stepIdx)}`;
|
||||
}
|
||||
|
||||
export function PipelineBuilder() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const templateId = searchParams.get("template");
|
||||
const { data: characters } = useCharacters();
|
||||
const createMutation = useCreatePipeline();
|
||||
|
||||
// Initialize from template if provided
|
||||
const template = templateId
|
||||
? PIPELINE_TEMPLATES.find((t) => t.id === templateId)
|
||||
: null;
|
||||
|
||||
const [name, setName] = useState(template?.name ?? "");
|
||||
const [description, setDescription] = useState(template?.description ?? "");
|
||||
const [loop, setLoop] = useState(template?.loop ?? false);
|
||||
const [maxLoops, setMaxLoops] = useState(template?.max_loops ?? 0);
|
||||
const [stages, setStages] = useState<StageDraft[]>(() => {
|
||||
if (template) {
|
||||
return template.stages.map((s, i) => ({
|
||||
id: makeStageId(i),
|
||||
name: s.name,
|
||||
character_steps: s.character_steps.map((cs, j) => ({
|
||||
id: makeStepId(i, j),
|
||||
character_name: cs.role ?? "",
|
||||
strategy_type: cs.strategy_type,
|
||||
config: cs.config,
|
||||
transition: cs.transition,
|
||||
})),
|
||||
}));
|
||||
}
|
||||
return [
|
||||
{
|
||||
id: "stage_1",
|
||||
name: "Stage 1",
|
||||
character_steps: [
|
||||
{
|
||||
id: "cs_1a",
|
||||
character_name: "",
|
||||
strategy_type: "gathering",
|
||||
config: {},
|
||||
transition: { type: "strategy_complete" },
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
function addStage() {
|
||||
const idx = stages.length;
|
||||
setStages([
|
||||
...stages,
|
||||
{
|
||||
id: makeStageId(idx),
|
||||
name: `Stage ${idx + 1}`,
|
||||
character_steps: [
|
||||
{
|
||||
id: makeStepId(idx, 0),
|
||||
character_name: "",
|
||||
strategy_type: "gathering",
|
||||
config: {},
|
||||
transition: { type: "strategy_complete" },
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
function removeStage(idx: number) {
|
||||
setStages(stages.filter((_, i) => i !== idx));
|
||||
}
|
||||
|
||||
function moveStage(idx: number, dir: -1 | 1) {
|
||||
const newIdx = idx + dir;
|
||||
if (newIdx < 0 || newIdx >= stages.length) return;
|
||||
const copy = [...stages];
|
||||
[copy[idx], copy[newIdx]] = [copy[newIdx], copy[idx]];
|
||||
setStages(copy);
|
||||
}
|
||||
|
||||
function updateStage(idx: number, patch: Partial<StageDraft>) {
|
||||
setStages(stages.map((s, i) => (i === idx ? { ...s, ...patch } : s)));
|
||||
}
|
||||
|
||||
function addCharacterStep(stageIdx: number) {
|
||||
const stage = stages[stageIdx];
|
||||
const stepIdx = stage.character_steps.length;
|
||||
updateStage(stageIdx, {
|
||||
character_steps: [
|
||||
...stage.character_steps,
|
||||
{
|
||||
id: makeStepId(stageIdx, stepIdx),
|
||||
character_name: "",
|
||||
strategy_type: "gathering",
|
||||
config: {},
|
||||
transition: { type: "strategy_complete" },
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
function removeCharacterStep(stageIdx: number, stepIdx: number) {
|
||||
const stage = stages[stageIdx];
|
||||
updateStage(stageIdx, {
|
||||
character_steps: stage.character_steps.filter((_, i) => i !== stepIdx),
|
||||
});
|
||||
}
|
||||
|
||||
function updateCharacterStep(
|
||||
stageIdx: number,
|
||||
stepIdx: number,
|
||||
patch: Partial<CharacterStepDraft>
|
||||
) {
|
||||
const stage = stages[stageIdx];
|
||||
updateStage(stageIdx, {
|
||||
character_steps: stage.character_steps.map((cs, i) =>
|
||||
i === stepIdx ? { ...cs, ...patch } : cs
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
function handleSubmit() {
|
||||
if (!name.trim()) {
|
||||
toast.error("Pipeline name is required");
|
||||
return;
|
||||
}
|
||||
const hasEmptyChar = stages.some((s) =>
|
||||
s.character_steps.some((cs) => !cs.character_name)
|
||||
);
|
||||
if (hasEmptyChar) {
|
||||
toast.error("All character steps must have a character assigned");
|
||||
return;
|
||||
}
|
||||
|
||||
createMutation.mutate(
|
||||
{
|
||||
name: name.trim(),
|
||||
description: description.trim(),
|
||||
stages: stages.map((s) => ({
|
||||
id: s.id,
|
||||
name: s.name,
|
||||
character_steps: s.character_steps.map((cs) => ({
|
||||
id: cs.id,
|
||||
character_name: cs.character_name,
|
||||
strategy_type: cs.strategy_type,
|
||||
config: cs.config,
|
||||
transition: cs.transition,
|
||||
})),
|
||||
})),
|
||||
loop,
|
||||
max_loops: maxLoops,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast.success("Pipeline created");
|
||||
router.push("/automations");
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(`Failed to create pipeline: ${err.message}`);
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6 max-w-4xl">
|
||||
{/* Basic info */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Users className="size-5" />
|
||||
Pipeline Settings
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">Name</Label>
|
||||
<Input
|
||||
id="name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="My Pipeline"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="description">Description</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="What does this pipeline do?"
|
||||
rows={1}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
id="loop"
|
||||
checked={loop}
|
||||
onCheckedChange={setLoop}
|
||||
/>
|
||||
<Label htmlFor="loop">Loop pipeline</Label>
|
||||
</div>
|
||||
{loop && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Label htmlFor="maxLoops">Max loops (0 = unlimited)</Label>
|
||||
<Input
|
||||
id="maxLoops"
|
||||
type="number"
|
||||
min={0}
|
||||
className="w-24"
|
||||
value={maxLoops}
|
||||
onChange={(e) => setMaxLoops(Number(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Stages */}
|
||||
<div className="space-y-4">
|
||||
{stages.map((stage, stageIdx) => (
|
||||
<Card key={stage.id}>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Badge variant="outline" className="text-xs">
|
||||
Stage {stageIdx + 1}
|
||||
</Badge>
|
||||
<Input
|
||||
value={stage.name}
|
||||
onChange={(e) =>
|
||||
updateStage(stageIdx, { name: e.target.value })
|
||||
}
|
||||
className="h-8 w-48"
|
||||
placeholder="Stage name"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
size="icon-xs"
|
||||
variant="ghost"
|
||||
onClick={() => moveStage(stageIdx, -1)}
|
||||
disabled={stageIdx === 0}
|
||||
>
|
||||
<ChevronUp className="size-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
size="icon-xs"
|
||||
variant="ghost"
|
||||
onClick={() => moveStage(stageIdx, 1)}
|
||||
disabled={stageIdx === stages.length - 1}
|
||||
>
|
||||
<ChevronDown className="size-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
size="icon-xs"
|
||||
variant="ghost"
|
||||
className="text-muted-foreground hover:text-destructive"
|
||||
onClick={() => removeStage(stageIdx)}
|
||||
disabled={stages.length <= 1}
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Character steps run in parallel within this stage
|
||||
</p>
|
||||
{stage.character_steps.map((cs, stepIdx) => (
|
||||
<div
|
||||
key={cs.id}
|
||||
className="border rounded-lg p-3 space-y-3 bg-muted/30"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
Worker {stepIdx + 1}
|
||||
</span>
|
||||
<Button
|
||||
size="icon-xs"
|
||||
variant="ghost"
|
||||
className="text-muted-foreground hover:text-destructive"
|
||||
onClick={() => removeCharacterStep(stageIdx, stepIdx)}
|
||||
disabled={stage.character_steps.length <= 1}
|
||||
>
|
||||
<Trash2 className="size-3" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">Character</Label>
|
||||
<Select
|
||||
value={cs.character_name}
|
||||
onValueChange={(v) =>
|
||||
updateCharacterStep(stageIdx, stepIdx, {
|
||||
character_name: v,
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue placeholder="Select character" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(characters ?? []).map((c) => (
|
||||
<SelectItem key={c.name} value={c.name}>
|
||||
{c.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">Strategy</Label>
|
||||
<Select
|
||||
value={cs.strategy_type}
|
||||
onValueChange={(v) =>
|
||||
updateCharacterStep(stageIdx, stepIdx, {
|
||||
strategy_type: v,
|
||||
config: {},
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{STRATEGY_OPTIONS.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">Transition</Label>
|
||||
<Select
|
||||
value={cs.transition?.type ?? "strategy_complete"}
|
||||
onValueChange={(v) =>
|
||||
updateCharacterStep(stageIdx, stepIdx, {
|
||||
transition: { type: v },
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{TRANSITION_OPTIONS.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
{/* Config fields based on strategy */}
|
||||
<StrategyConfigFields
|
||||
strategyType={cs.strategy_type}
|
||||
config={cs.config}
|
||||
onChange={(config) =>
|
||||
updateCharacterStep(stageIdx, stepIdx, { config })
|
||||
}
|
||||
/>
|
||||
{/* Transition value fields */}
|
||||
<TransitionValueFields
|
||||
transition={cs.transition}
|
||||
onChange={(transition) =>
|
||||
updateCharacterStep(stageIdx, stepIdx, { transition })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => addCharacterStep(stageIdx)}
|
||||
>
|
||||
<Plus className="size-3" />
|
||||
Add Character Worker
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Button variant="outline" onClick={addStage}>
|
||||
<Plus className="size-4" />
|
||||
Add Stage
|
||||
</Button>
|
||||
|
||||
{/* Submit */}
|
||||
<div className="flex items-center gap-3 pt-4 border-t">
|
||||
<Button onClick={handleSubmit} disabled={createMutation.isPending}>
|
||||
{createMutation.isPending && (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
)}
|
||||
Create Pipeline
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => router.push("/automations")}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StrategyConfigFields({
|
||||
strategyType,
|
||||
config,
|
||||
onChange,
|
||||
}: {
|
||||
strategyType: string;
|
||||
config: Record<string, unknown>;
|
||||
onChange: (config: Record<string, unknown>) => void;
|
||||
}) {
|
||||
switch (strategyType) {
|
||||
case "gathering":
|
||||
return (
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">Resource Code</Label>
|
||||
<Input
|
||||
className="h-8 text-xs"
|
||||
value={(config.resource_code as string) ?? ""}
|
||||
onChange={(e) =>
|
||||
onChange({ ...config, resource_code: e.target.value })
|
||||
}
|
||||
placeholder="copper_rocks"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 pt-4">
|
||||
<Switch
|
||||
checked={(config.deposit_on_full as boolean) ?? true}
|
||||
onCheckedChange={(v) =>
|
||||
onChange({ ...config, deposit_on_full: v })
|
||||
}
|
||||
/>
|
||||
<Label className="text-xs">Deposit on full</Label>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
case "combat":
|
||||
return (
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">Monster Code</Label>
|
||||
<Input
|
||||
className="h-8 text-xs"
|
||||
value={(config.monster_code as string) ?? ""}
|
||||
onChange={(e) =>
|
||||
onChange({ ...config, monster_code: e.target.value })
|
||||
}
|
||||
placeholder="chicken"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 pt-4">
|
||||
<Switch
|
||||
checked={(config.deposit_loot as boolean) ?? true}
|
||||
onCheckedChange={(v) =>
|
||||
onChange({ ...config, deposit_loot: v })
|
||||
}
|
||||
/>
|
||||
<Label className="text-xs">Deposit loot</Label>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
case "crafting":
|
||||
return (
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">Item Code</Label>
|
||||
<Input
|
||||
className="h-8 text-xs"
|
||||
value={(config.item_code as string) ?? ""}
|
||||
onChange={(e) =>
|
||||
onChange({ ...config, item_code: e.target.value })
|
||||
}
|
||||
placeholder="copper_dagger"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">Quantity</Label>
|
||||
<Input
|
||||
className="h-8 text-xs"
|
||||
type="number"
|
||||
min={1}
|
||||
value={(config.quantity as number) ?? 10}
|
||||
onChange={(e) =>
|
||||
onChange({ ...config, quantity: Number(e.target.value) })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
case "trading":
|
||||
return (
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">Mode</Label>
|
||||
<Select
|
||||
value={(config.mode as string) ?? "sell_loot"}
|
||||
onValueChange={(v) => onChange({ ...config, mode: v })}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="sell_loot">Sell Loot</SelectItem>
|
||||
<SelectItem value="buy_materials">Buy Materials</SelectItem>
|
||||
<SelectItem value="flip">Flip</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">Item Code</Label>
|
||||
<Input
|
||||
className="h-8 text-xs"
|
||||
value={(config.item_code as string) ?? ""}
|
||||
onChange={(e) =>
|
||||
onChange({ ...config, item_code: e.target.value })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function TransitionValueFields({
|
||||
transition,
|
||||
onChange,
|
||||
}: {
|
||||
transition: CharacterStepDraft["transition"];
|
||||
onChange: (t: CharacterStepDraft["transition"]) => void;
|
||||
}) {
|
||||
if (!transition) return null;
|
||||
const type = transition.type;
|
||||
|
||||
if (
|
||||
type === "strategy_complete" ||
|
||||
type === "inventory_full" ||
|
||||
type === "loops_completed"
|
||||
)
|
||||
return null;
|
||||
|
||||
return (
|
||||
<div className="grid gap-2 sm:grid-cols-3">
|
||||
{(type === "inventory_item_count" || type === "bank_item_count") && (
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">Item Code</Label>
|
||||
<Input
|
||||
className="h-8 text-xs"
|
||||
value={transition.item_code ?? ""}
|
||||
onChange={(e) =>
|
||||
onChange({ ...transition, item_code: e.target.value })
|
||||
}
|
||||
placeholder="copper_ore"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{type === "skill_level" && (
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">Skill</Label>
|
||||
<Input
|
||||
className="h-8 text-xs"
|
||||
value={transition.skill ?? ""}
|
||||
onChange={(e) =>
|
||||
onChange({ ...transition, skill: e.target.value })
|
||||
}
|
||||
placeholder="mining"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{type === "timer" ? (
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">Seconds</Label>
|
||||
<Input
|
||||
className="h-8 text-xs"
|
||||
type="number"
|
||||
min={1}
|
||||
value={transition.seconds ?? 60}
|
||||
onChange={(e) =>
|
||||
onChange({ ...transition, seconds: Number(e.target.value) })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">Operator</Label>
|
||||
<Select
|
||||
value={transition.operator ?? ">="}
|
||||
onValueChange={(v) => onChange({ ...transition, operator: v })}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value=">=">>=</SelectItem>
|
||||
<SelectItem value="<="><=</SelectItem>
|
||||
<SelectItem value="==">=</SelectItem>
|
||||
<SelectItem value=">">></SelectItem>
|
||||
<SelectItem value="<"><</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">Value</Label>
|
||||
<Input
|
||||
className="h-8 text-xs"
|
||||
type="number"
|
||||
min={0}
|
||||
value={transition.value ?? 0}
|
||||
onChange={(e) =>
|
||||
onChange({ ...transition, value: Number(e.target.value) })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
352
frontend/src/components/pipeline/pipeline-list.tsx
Normal file
352
frontend/src/components/pipeline/pipeline-list.tsx
Normal file
|
|
@ -0,0 +1,352 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
Plus,
|
||||
Trash2,
|
||||
Loader2,
|
||||
Bot,
|
||||
Play,
|
||||
Square,
|
||||
Pause,
|
||||
Repeat,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Table,
|
||||
TableHeader,
|
||||
TableBody,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TableCell,
|
||||
} from "@/components/ui/table";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
usePipelines,
|
||||
usePipelineStatuses,
|
||||
useDeletePipeline,
|
||||
useControlPipeline,
|
||||
} from "@/hooks/use-pipelines";
|
||||
import { toast } from "sonner";
|
||||
|
||||
const STATUS_CONFIG: Record<
|
||||
string,
|
||||
{
|
||||
label: string;
|
||||
variant: "default" | "secondary" | "destructive" | "outline";
|
||||
className: string;
|
||||
}
|
||||
> = {
|
||||
running: {
|
||||
label: "Running",
|
||||
variant: "default",
|
||||
className: "bg-green-600 hover:bg-green-600 text-white",
|
||||
},
|
||||
paused: {
|
||||
label: "Paused",
|
||||
variant: "default",
|
||||
className: "bg-yellow-600 hover:bg-yellow-600 text-white",
|
||||
},
|
||||
stopped: { label: "Stopped", variant: "secondary", className: "" },
|
||||
completed: {
|
||||
label: "Completed",
|
||||
variant: "default",
|
||||
className: "bg-blue-600 hover:bg-blue-600 text-white",
|
||||
},
|
||||
error: { label: "Error", variant: "destructive", className: "" },
|
||||
};
|
||||
|
||||
function getUniqueCharacters(stages: { character_steps?: { character_name: string }[] }[]): string[] {
|
||||
const names = new Set<string>();
|
||||
for (const stage of stages) {
|
||||
for (const cs of stage.character_steps ?? []) {
|
||||
names.add(cs.character_name);
|
||||
}
|
||||
}
|
||||
return Array.from(names).sort();
|
||||
}
|
||||
|
||||
export function PipelineList() {
|
||||
const router = useRouter();
|
||||
const { data: pipelines, isLoading, error } = usePipelines();
|
||||
const { data: statuses } = usePipelineStatuses();
|
||||
const deleteMutation = useDeletePipeline();
|
||||
const control = useControlPipeline();
|
||||
const [deleteTarget, setDeleteTarget] = useState<{
|
||||
id: number;
|
||||
name: string;
|
||||
} | null>(null);
|
||||
|
||||
const statusMap = new Map(
|
||||
(statuses ?? []).map((s) => [s.pipeline_id, s])
|
||||
);
|
||||
|
||||
function handleDelete() {
|
||||
if (!deleteTarget) return;
|
||||
deleteMutation.mutate(deleteTarget.id, {
|
||||
onSuccess: () => {
|
||||
toast.success(`Pipeline "${deleteTarget.name}" deleted`);
|
||||
setDeleteTarget(null);
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(`Failed to delete: ${err.message}`);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function handleControl(
|
||||
id: number,
|
||||
action: "start" | "stop" | "pause" | "resume"
|
||||
) {
|
||||
control.mutate(
|
||||
{ id, action },
|
||||
{
|
||||
onSuccess: () => toast.success(`Pipeline ${action}ed`),
|
||||
onError: (err) =>
|
||||
toast.error(`Failed to ${action} pipeline: ${err.message}`),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Card className="border-destructive bg-destructive/10 p-4">
|
||||
<p className="text-sm text-destructive">
|
||||
Failed to load pipelines. Make sure the backend is running.
|
||||
</p>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="size-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!pipelines || pipelines.length === 0) {
|
||||
return (
|
||||
<Card className="p-8 text-center">
|
||||
<Users className="size-12 text-muted-foreground mx-auto mb-4" />
|
||||
<p className="text-muted-foreground mb-4">
|
||||
No pipelines configured yet. Create a multi-character pipeline to
|
||||
coordinate characters across stages.
|
||||
</p>
|
||||
<Button onClick={() => router.push("/automations/pipelines/new")}>
|
||||
<Plus className="size-4" />
|
||||
Create Pipeline
|
||||
</Button>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Characters</TableHead>
|
||||
<TableHead>Stages</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Progress</TableHead>
|
||||
<TableHead>Controls</TableHead>
|
||||
<TableHead className="w-10" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{pipelines.map((pl) => {
|
||||
const status = statusMap.get(pl.id);
|
||||
const currentStatus = status?.status ?? "stopped";
|
||||
const statusCfg =
|
||||
STATUS_CONFIG[currentStatus] ?? STATUS_CONFIG.stopped;
|
||||
const isStopped =
|
||||
currentStatus === "stopped" ||
|
||||
currentStatus === "completed" ||
|
||||
currentStatus === "error" ||
|
||||
!currentStatus;
|
||||
const isRunning = currentStatus === "running";
|
||||
const isPaused = currentStatus === "paused";
|
||||
const characters = getUniqueCharacters(pl.stages);
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
key={pl.id}
|
||||
className="cursor-pointer"
|
||||
onClick={() =>
|
||||
router.push(`/automations/pipelines/${pl.id}`)
|
||||
}
|
||||
>
|
||||
<TableCell className="font-medium">
|
||||
<div className="flex items-center gap-2">
|
||||
{pl.name}
|
||||
{pl.loop && (
|
||||
<Repeat className="size-3 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{characters.map((c) => (
|
||||
<Badge key={c} variant="outline" className="text-[10px]">
|
||||
{c}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{pl.stages.length} stage{pl.stages.length !== 1 && "s"}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
variant={statusCfg.variant}
|
||||
className={statusCfg.className}
|
||||
>
|
||||
{statusCfg.label}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground">
|
||||
{status ? (
|
||||
<span>
|
||||
Stage {status.current_stage_index + 1}/
|
||||
{status.total_stages}
|
||||
{status.loop_count > 0 && (
|
||||
<> · Loop {status.loop_count}</>
|
||||
)}
|
||||
{" "}· {status.total_actions_count} actions
|
||||
</span>
|
||||
) : (
|
||||
"\u2014"
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell onClick={(e) => e.stopPropagation()}>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{isStopped && (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="outline"
|
||||
className="text-green-400 hover:text-green-300 hover:bg-green-500/10"
|
||||
onClick={() => handleControl(pl.id, "start")}
|
||||
disabled={control.isPending}
|
||||
>
|
||||
<Play className="size-3" />
|
||||
</Button>
|
||||
)}
|
||||
{isRunning && (
|
||||
<>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="outline"
|
||||
className="text-yellow-400 hover:text-yellow-300 hover:bg-yellow-500/10"
|
||||
onClick={() => handleControl(pl.id, "pause")}
|
||||
disabled={control.isPending}
|
||||
>
|
||||
<Pause className="size-3" />
|
||||
</Button>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="outline"
|
||||
className="text-red-400 hover:text-red-300 hover:bg-red-500/10"
|
||||
onClick={() => handleControl(pl.id, "stop")}
|
||||
disabled={control.isPending}
|
||||
>
|
||||
<Square className="size-3" />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{isPaused && (
|
||||
<>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="outline"
|
||||
className="text-green-400 hover:text-green-300 hover:bg-green-500/10"
|
||||
onClick={() => handleControl(pl.id, "resume")}
|
||||
disabled={control.isPending}
|
||||
>
|
||||
<Play className="size-3" />
|
||||
</Button>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="outline"
|
||||
className="text-red-400 hover:text-red-300 hover:bg-red-500/10"
|
||||
onClick={() => handleControl(pl.id, "stop")}
|
||||
disabled={control.isPending}
|
||||
>
|
||||
<Square className="size-3" />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell onClick={(e) => e.stopPropagation()}>
|
||||
<Button
|
||||
size="icon-xs"
|
||||
variant="ghost"
|
||||
className="text-muted-foreground hover:text-destructive"
|
||||
onClick={() =>
|
||||
setDeleteTarget({ id: pl.id, name: pl.name })
|
||||
}
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Card>
|
||||
|
||||
<Dialog
|
||||
open={deleteTarget !== null}
|
||||
onOpenChange={(open) => !open && setDeleteTarget(null)}
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete Pipeline</DialogTitle>
|
||||
<DialogDescription>
|
||||
Are you sure you want to delete “{deleteTarget?.name}
|
||||
”? This action cannot be undone. Any running pipeline will be
|
||||
stopped.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setDeleteTarget(null)}
|
||||
disabled={deleteMutation.isPending}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleDelete}
|
||||
disabled={deleteMutation.isPending}
|
||||
>
|
||||
{deleteMutation.isPending && (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
)}
|
||||
Delete
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
239
frontend/src/components/pipeline/pipeline-progress.tsx
Normal file
239
frontend/src/components/pipeline/pipeline-progress.tsx
Normal file
|
|
@ -0,0 +1,239 @@
|
|||
"use client";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { CheckCircle2, Circle, AlertCircle, Loader2, Play, Square, Pause } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import type { PipelineConfig, PipelineStatus } from "@/lib/types";
|
||||
import { useControlPipeline } from "@/hooks/use-pipelines";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface PipelineProgressProps {
|
||||
config: PipelineConfig;
|
||||
status: PipelineStatus | null;
|
||||
}
|
||||
|
||||
const CHAR_STATUS_ICONS: Record<string, React.ReactNode> = {
|
||||
running: <Loader2 className="size-3 animate-spin text-green-400" />,
|
||||
completed: <CheckCircle2 className="size-3 text-blue-400" />,
|
||||
error: <AlertCircle className="size-3 text-red-400" />,
|
||||
idle: <Circle className="size-3 text-muted-foreground" />,
|
||||
};
|
||||
|
||||
export function PipelineProgress({ config, status }: PipelineProgressProps) {
|
||||
const control = useControlPipeline();
|
||||
const currentStatus = status?.status ?? "stopped";
|
||||
const isStopped =
|
||||
currentStatus === "stopped" ||
|
||||
currentStatus === "completed" ||
|
||||
currentStatus === "error" ||
|
||||
!currentStatus;
|
||||
const isRunning = currentStatus === "running";
|
||||
const isPaused = currentStatus === "paused";
|
||||
|
||||
function handleControl(action: "start" | "stop" | "pause" | "resume") {
|
||||
control.mutate(
|
||||
{ id: config.id, action },
|
||||
{
|
||||
onSuccess: () => toast.success(`Pipeline ${action}ed`),
|
||||
onError: (err) =>
|
||||
toast.error(`Failed to ${action}: ${err.message}`),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Build a map of character states from the status
|
||||
const charStateMap = new Map(
|
||||
(status?.character_states ?? []).map((cs) => [cs.character_name, cs])
|
||||
);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-base">Live Progress</CardTitle>
|
||||
<div className="flex items-center gap-2">
|
||||
{status && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{status.total_actions_count} total actions
|
||||
{status.loop_count > 0 && ` \u00b7 Loop ${status.loop_count}`}
|
||||
</span>
|
||||
)}
|
||||
<div className="flex items-center gap-1.5">
|
||||
{isStopped && (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="outline"
|
||||
className="text-green-400"
|
||||
onClick={() => handleControl("start")}
|
||||
disabled={control.isPending}
|
||||
>
|
||||
<Play className="size-3" />
|
||||
Start
|
||||
</Button>
|
||||
)}
|
||||
{isRunning && (
|
||||
<>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="outline"
|
||||
className="text-yellow-400"
|
||||
onClick={() => handleControl("pause")}
|
||||
disabled={control.isPending}
|
||||
>
|
||||
<Pause className="size-3" />
|
||||
</Button>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="outline"
|
||||
className="text-red-400"
|
||||
onClick={() => handleControl("stop")}
|
||||
disabled={control.isPending}
|
||||
>
|
||||
<Square className="size-3" />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{isPaused && (
|
||||
<>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="outline"
|
||||
className="text-green-400"
|
||||
onClick={() => handleControl("resume")}
|
||||
disabled={control.isPending}
|
||||
>
|
||||
<Play className="size-3" />
|
||||
</Button>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="outline"
|
||||
className="text-red-400"
|
||||
onClick={() => handleControl("stop")}
|
||||
disabled={control.isPending}
|
||||
>
|
||||
<Square className="size-3" />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="relative space-y-0">
|
||||
{config.stages.map((stage, idx) => {
|
||||
const isActive =
|
||||
status != null && status.current_stage_index === idx && isRunning;
|
||||
const isCompleted =
|
||||
status != null && status.current_stage_index > idx;
|
||||
const isPastOrActive = isActive || isCompleted;
|
||||
|
||||
return (
|
||||
<div key={stage.id} className="relative flex gap-4">
|
||||
{/* Timeline line */}
|
||||
<div className="flex flex-col items-center">
|
||||
<div
|
||||
className={`size-6 rounded-full border-2 flex items-center justify-center text-xs font-bold ${
|
||||
isActive
|
||||
? "border-green-500 bg-green-500/20 text-green-400"
|
||||
: isCompleted
|
||||
? "border-blue-500 bg-blue-500/20 text-blue-400"
|
||||
: "border-muted-foreground/30 text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
{isCompleted ? (
|
||||
<CheckCircle2 className="size-3.5" />
|
||||
) : isActive ? (
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
) : (
|
||||
idx + 1
|
||||
)}
|
||||
</div>
|
||||
{idx < config.stages.length - 1 && (
|
||||
<div
|
||||
className={`w-0.5 flex-1 min-h-4 ${
|
||||
isPastOrActive
|
||||
? "bg-blue-500/50"
|
||||
: "bg-muted-foreground/20"
|
||||
}`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Stage content */}
|
||||
<div className="flex-1 pb-4">
|
||||
<div className="flex items-center gap-2 mb-1.5">
|
||||
<span
|
||||
className={`text-sm font-medium ${
|
||||
isActive
|
||||
? "text-green-400"
|
||||
: isCompleted
|
||||
? "text-blue-400"
|
||||
: "text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
{stage.name}
|
||||
</span>
|
||||
{isActive && (
|
||||
<Badge className="bg-green-600 text-white text-[10px] px-1.5">
|
||||
Active
|
||||
</Badge>
|
||||
)}
|
||||
{isCompleted && (
|
||||
<Badge className="bg-blue-600 text-white text-[10px] px-1.5">
|
||||
Done
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Character workers */}
|
||||
<div className="space-y-1">
|
||||
{stage.character_steps.map((cs) => {
|
||||
const charState = charStateMap.get(cs.character_name);
|
||||
const charStatus = charState?.status ?? "idle";
|
||||
|
||||
return (
|
||||
<div
|
||||
key={cs.id}
|
||||
className="flex items-center gap-2 text-xs"
|
||||
>
|
||||
{isActive
|
||||
? CHAR_STATUS_ICONS[charStatus] ??
|
||||
CHAR_STATUS_ICONS.idle
|
||||
: <Circle className="size-3 text-muted-foreground/40" />}
|
||||
<span
|
||||
className={
|
||||
isActive
|
||||
? "text-foreground"
|
||||
: "text-muted-foreground"
|
||||
}
|
||||
>
|
||||
{cs.character_name || "Unassigned"}
|
||||
</span>
|
||||
<span className="text-muted-foreground capitalize">
|
||||
{cs.strategy_type}
|
||||
</span>
|
||||
{isActive && charState && (
|
||||
<span className="text-muted-foreground">
|
||||
{charState.actions_count} actions
|
||||
</span>
|
||||
)}
|
||||
{charState?.error && (
|
||||
<span className="text-red-400 truncate max-w-48">
|
||||
{charState.error}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
131
frontend/src/components/pipeline/pipeline-template-gallery.tsx
Normal file
131
frontend/src/components/pipeline/pipeline-template-gallery.tsx
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
Network,
|
||||
Repeat,
|
||||
Swords,
|
||||
Pickaxe,
|
||||
Hammer,
|
||||
TrendingUp,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import {
|
||||
PIPELINE_TEMPLATES,
|
||||
PIPELINE_CATEGORY_LABELS,
|
||||
PIPELINE_CATEGORY_COLORS,
|
||||
} from "./pipeline-templates";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const STRATEGY_ICONS: Record<string, React.ElementType> = {
|
||||
combat: Swords,
|
||||
gathering: Pickaxe,
|
||||
crafting: Hammer,
|
||||
trading: TrendingUp,
|
||||
};
|
||||
|
||||
export function PipelineTemplateGallery() {
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Separator />
|
||||
<div className="flex items-center gap-2">
|
||||
<Network className="size-5 text-muted-foreground" />
|
||||
<h2 className="text-lg font-semibold text-foreground">
|
||||
Pipeline Templates
|
||||
</h2>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Multi-character collaboration
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{PIPELINE_TEMPLATES.map((template) => (
|
||||
<Card
|
||||
key={template.id}
|
||||
className="cursor-pointer transition-all hover:shadow-md group py-0 overflow-hidden hover:border-primary/30"
|
||||
onClick={() =>
|
||||
router.push(
|
||||
`/automations/pipelines/new?template=${template.id}`
|
||||
)
|
||||
}
|
||||
>
|
||||
<div className="h-1 bg-primary/40" />
|
||||
<CardContent className="p-4 space-y-3">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex size-10 shrink-0 items-center justify-center rounded-lg bg-primary/10">
|
||||
<Network className="size-5 text-primary" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-semibold text-sm text-foreground group-hover:text-primary transition-colors">
|
||||
{template.name}
|
||||
</h3>
|
||||
<p className="text-xs text-muted-foreground mt-1 line-clamp-2">
|
||||
{template.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"text-[10px] capitalize",
|
||||
PIPELINE_CATEGORY_COLORS[template.category]
|
||||
)}
|
||||
>
|
||||
{PIPELINE_CATEGORY_LABELS[template.category] ??
|
||||
template.category}
|
||||
</Badge>
|
||||
<Badge variant="outline" className="text-[10px]">
|
||||
{template.stages.length} stage
|
||||
{template.stages.length !== 1 && "s"}
|
||||
</Badge>
|
||||
<Badge variant="outline" className="text-[10px] gap-0.5">
|
||||
<Users className="size-2.5" />
|
||||
{template.roles.length} roles
|
||||
</Badge>
|
||||
{template.loop && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-[10px] gap-0.5"
|
||||
>
|
||||
<Repeat className="size-2.5" />
|
||||
Loop
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Stage mini-preview */}
|
||||
<div className="flex items-center gap-1 text-[10px] text-muted-foreground">
|
||||
{template.stages.map((stage, idx) => {
|
||||
const strategyTypes = [
|
||||
...new Set(stage.character_steps.map((cs) => cs.strategy_type)),
|
||||
];
|
||||
return (
|
||||
<span key={stage.id} className="flex items-center gap-0.5">
|
||||
{idx > 0 && <span className="mx-0.5">→</span>}
|
||||
{strategyTypes.map((st) => {
|
||||
const Icon = STRATEGY_ICONS[st] ?? Network;
|
||||
return <Icon key={st} className="size-3" />;
|
||||
})}
|
||||
{stage.character_steps.length > 1 && (
|
||||
<span className="text-[9px]">
|
||||
x{stage.character_steps.length}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
346
frontend/src/components/pipeline/pipeline-templates.ts
Normal file
346
frontend/src/components/pipeline/pipeline-templates.ts
Normal file
|
|
@ -0,0 +1,346 @@
|
|||
export interface PipelineTemplate {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
category: "production" | "combat" | "economy" | "leveling";
|
||||
roles: string[];
|
||||
stages: {
|
||||
id: string;
|
||||
name: string;
|
||||
character_steps: {
|
||||
id: string;
|
||||
role: string;
|
||||
strategy_type: string;
|
||||
config: Record<string, unknown>;
|
||||
transition: {
|
||||
type: string;
|
||||
operator?: string;
|
||||
value?: number;
|
||||
item_code?: string;
|
||||
skill?: string;
|
||||
seconds?: number;
|
||||
} | null;
|
||||
}[];
|
||||
}[];
|
||||
loop: boolean;
|
||||
max_loops: number;
|
||||
}
|
||||
|
||||
export const PIPELINE_TEMPLATES: PipelineTemplate[] = [
|
||||
{
|
||||
id: "resource_pipeline",
|
||||
name: "Resource Pipeline",
|
||||
description:
|
||||
"Gatherer collects resources and banks them, Crafter withdraws and crafts, Seller lists on GE. 3 characters, 3 stages.",
|
||||
category: "production",
|
||||
roles: ["Gatherer", "Crafter", "Seller"],
|
||||
stages: [
|
||||
{
|
||||
id: "stage_1",
|
||||
name: "Gather Resources",
|
||||
character_steps: [
|
||||
{
|
||||
id: "cs_1a",
|
||||
role: "Gatherer",
|
||||
strategy_type: "gathering",
|
||||
config: { resource_code: "copper_rocks", deposit_on_full: true },
|
||||
transition: {
|
||||
type: "bank_item_count",
|
||||
item_code: "copper_ore",
|
||||
operator: ">=",
|
||||
value: 100,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "stage_2",
|
||||
name: "Craft Items",
|
||||
character_steps: [
|
||||
{
|
||||
id: "cs_2a",
|
||||
role: "Crafter",
|
||||
strategy_type: "crafting",
|
||||
config: {
|
||||
item_code: "copper_dagger",
|
||||
quantity: 50,
|
||||
gather_materials: false,
|
||||
},
|
||||
transition: { type: "strategy_complete" },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "stage_3",
|
||||
name: "Sell on GE",
|
||||
character_steps: [
|
||||
{
|
||||
id: "cs_3a",
|
||||
role: "Seller",
|
||||
strategy_type: "trading",
|
||||
config: {
|
||||
mode: "sell_loot",
|
||||
item_code: "copper_dagger",
|
||||
quantity: 50,
|
||||
min_price: 1,
|
||||
},
|
||||
transition: { type: "strategy_complete" },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
loop: true,
|
||||
max_loops: 0,
|
||||
},
|
||||
{
|
||||
id: "dual_gatherer_pipeline",
|
||||
name: "Dual Gatherer Pipeline",
|
||||
description:
|
||||
"Two miners gather copper in parallel, then one crafter turns it all into daggers. Double the gathering speed.",
|
||||
category: "production",
|
||||
roles: ["Miner1", "Miner2", "Crafter"],
|
||||
stages: [
|
||||
{
|
||||
id: "stage_1",
|
||||
name: "Parallel Gathering",
|
||||
character_steps: [
|
||||
{
|
||||
id: "cs_1a",
|
||||
role: "Miner1",
|
||||
strategy_type: "gathering",
|
||||
config: { resource_code: "copper_rocks", deposit_on_full: true },
|
||||
transition: {
|
||||
type: "bank_item_count",
|
||||
item_code: "copper_ore",
|
||||
operator: ">=",
|
||||
value: 200,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "cs_1b",
|
||||
role: "Miner2",
|
||||
strategy_type: "gathering",
|
||||
config: { resource_code: "copper_rocks", deposit_on_full: true },
|
||||
transition: {
|
||||
type: "bank_item_count",
|
||||
item_code: "copper_ore",
|
||||
operator: ">=",
|
||||
value: 200,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "stage_2",
|
||||
name: "Craft Items",
|
||||
character_steps: [
|
||||
{
|
||||
id: "cs_2a",
|
||||
role: "Crafter",
|
||||
strategy_type: "crafting",
|
||||
config: {
|
||||
item_code: "copper_dagger",
|
||||
quantity: 100,
|
||||
gather_materials: false,
|
||||
},
|
||||
transition: { type: "strategy_complete" },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
loop: true,
|
||||
max_loops: 0,
|
||||
},
|
||||
{
|
||||
id: "combat_supply_chain",
|
||||
name: "Combat Supply Chain",
|
||||
description:
|
||||
"Fisher catches and cooks food, then Fighter uses it for sustained combat with consumable healing.",
|
||||
category: "combat",
|
||||
roles: ["Chef", "Fighter"],
|
||||
stages: [
|
||||
{
|
||||
id: "stage_1",
|
||||
name: "Prepare Food",
|
||||
character_steps: [
|
||||
{
|
||||
id: "cs_1a",
|
||||
role: "Chef",
|
||||
strategy_type: "gathering",
|
||||
config: { resource_code: "gudgeon_fishing_spot", deposit_on_full: true },
|
||||
transition: { type: "actions_count", operator: ">=", value: 40 },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "stage_2",
|
||||
name: "Cook Food",
|
||||
character_steps: [
|
||||
{
|
||||
id: "cs_1a",
|
||||
role: "Chef",
|
||||
strategy_type: "crafting",
|
||||
config: {
|
||||
item_code: "cooked_gudgeon",
|
||||
quantity: 30,
|
||||
gather_materials: false,
|
||||
},
|
||||
transition: { type: "strategy_complete" },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "stage_3",
|
||||
name: "Fight with Food",
|
||||
character_steps: [
|
||||
{
|
||||
id: "cs_3a",
|
||||
role: "Fighter",
|
||||
strategy_type: "combat",
|
||||
config: {
|
||||
monster_code: "yellow_slime",
|
||||
deposit_loot: true,
|
||||
auto_heal_threshold: 50,
|
||||
heal_method: "consumable",
|
||||
consumable_code: "cooked_gudgeon",
|
||||
},
|
||||
transition: { type: "actions_count", operator: ">=", value: 80 },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
loop: true,
|
||||
max_loops: 0,
|
||||
},
|
||||
{
|
||||
id: "full_economy",
|
||||
name: "Full Economy",
|
||||
description:
|
||||
"2 Gatherers mine in parallel, Crafter crafts items, Seller sells on GE. Complete 4-character production line.",
|
||||
category: "economy",
|
||||
roles: ["Miner1", "Miner2", "Crafter", "Seller"],
|
||||
stages: [
|
||||
{
|
||||
id: "stage_1",
|
||||
name: "Gather Resources",
|
||||
character_steps: [
|
||||
{
|
||||
id: "cs_1a",
|
||||
role: "Miner1",
|
||||
strategy_type: "gathering",
|
||||
config: { resource_code: "copper_rocks", deposit_on_full: true },
|
||||
transition: {
|
||||
type: "bank_item_count",
|
||||
item_code: "copper_ore",
|
||||
operator: ">=",
|
||||
value: 200,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "cs_1b",
|
||||
role: "Miner2",
|
||||
strategy_type: "gathering",
|
||||
config: { resource_code: "copper_rocks", deposit_on_full: true },
|
||||
transition: {
|
||||
type: "bank_item_count",
|
||||
item_code: "copper_ore",
|
||||
operator: ">=",
|
||||
value: 200,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "stage_2",
|
||||
name: "Craft Items",
|
||||
character_steps: [
|
||||
{
|
||||
id: "cs_2a",
|
||||
role: "Crafter",
|
||||
strategy_type: "crafting",
|
||||
config: {
|
||||
item_code: "copper_dagger",
|
||||
quantity: 100,
|
||||
gather_materials: false,
|
||||
},
|
||||
transition: { type: "strategy_complete" },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "stage_3",
|
||||
name: "Sell on GE",
|
||||
character_steps: [
|
||||
{
|
||||
id: "cs_3a",
|
||||
role: "Seller",
|
||||
strategy_type: "trading",
|
||||
config: {
|
||||
mode: "sell_loot",
|
||||
item_code: "copper_dagger",
|
||||
quantity: 100,
|
||||
min_price: 1,
|
||||
},
|
||||
transition: { type: "strategy_complete" },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
loop: true,
|
||||
max_loops: 0,
|
||||
},
|
||||
{
|
||||
id: "power_leveling_duo",
|
||||
name: "Power Leveling Duo",
|
||||
description:
|
||||
"Two characters fight different monsters in parallel for maximum XP gain. Both deposit loot at end.",
|
||||
category: "leveling",
|
||||
roles: ["Fighter1", "Fighter2"],
|
||||
stages: [
|
||||
{
|
||||
id: "stage_1",
|
||||
name: "Parallel Combat",
|
||||
character_steps: [
|
||||
{
|
||||
id: "cs_1a",
|
||||
role: "Fighter1",
|
||||
strategy_type: "combat",
|
||||
config: {
|
||||
monster_code: "chicken",
|
||||
deposit_loot: true,
|
||||
auto_heal_threshold: 40,
|
||||
},
|
||||
transition: { type: "actions_count", operator: ">=", value: 100 },
|
||||
},
|
||||
{
|
||||
id: "cs_1b",
|
||||
role: "Fighter2",
|
||||
strategy_type: "combat",
|
||||
config: {
|
||||
monster_code: "yellow_slime",
|
||||
deposit_loot: true,
|
||||
auto_heal_threshold: 50,
|
||||
},
|
||||
transition: { type: "actions_count", operator: ">=", value: 100 },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
loop: true,
|
||||
max_loops: 0,
|
||||
},
|
||||
];
|
||||
|
||||
export const PIPELINE_CATEGORY_LABELS: Record<string, string> = {
|
||||
production: "Production",
|
||||
combat: "Combat",
|
||||
economy: "Economy",
|
||||
leveling: "Leveling",
|
||||
};
|
||||
|
||||
export const PIPELINE_CATEGORY_COLORS: Record<string, string> = {
|
||||
production: "text-blue-400",
|
||||
combat: "text-red-400",
|
||||
economy: "text-yellow-400",
|
||||
leveling: "text-cyan-400",
|
||||
};
|
||||
188
frontend/src/components/workflow/transition-editor.tsx
Normal file
188
frontend/src/components/workflow/transition-editor.tsx
Normal file
|
|
@ -0,0 +1,188 @@
|
|||
"use client";
|
||||
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import type { TransitionCondition } from "@/lib/types";
|
||||
|
||||
const TRANSITION_TYPES = [
|
||||
{ value: "strategy_complete", label: "Strategy Completes" },
|
||||
{ value: "actions_count", label: "Actions Count" },
|
||||
{ value: "timer", label: "Timer (seconds)" },
|
||||
{ value: "inventory_full", label: "Inventory Full" },
|
||||
{ value: "inventory_item_count", label: "Inventory Item Count" },
|
||||
{ value: "bank_item_count", label: "Bank Item Count" },
|
||||
{ value: "skill_level", label: "Skill Level" },
|
||||
{ value: "gold_amount", label: "Gold Amount" },
|
||||
] as const;
|
||||
|
||||
const OPERATORS = [
|
||||
{ value: ">=", label: ">=" },
|
||||
{ value: "<=", label: "<=" },
|
||||
{ value: "==", label: "==" },
|
||||
{ value: ">", label: ">" },
|
||||
{ value: "<", label: "<" },
|
||||
] as const;
|
||||
|
||||
const SKILLS = [
|
||||
"mining",
|
||||
"woodcutting",
|
||||
"fishing",
|
||||
"weaponcrafting",
|
||||
"gearcrafting",
|
||||
"jewelrycrafting",
|
||||
"cooking",
|
||||
"alchemy",
|
||||
] as const;
|
||||
|
||||
interface TransitionEditorProps {
|
||||
value: TransitionCondition | null;
|
||||
onChange: (condition: TransitionCondition | null) => void;
|
||||
}
|
||||
|
||||
export function TransitionEditor({ value, onChange }: TransitionEditorProps) {
|
||||
const condType = value?.type ?? "";
|
||||
const needsValue =
|
||||
condType === "actions_count" ||
|
||||
condType === "inventory_item_count" ||
|
||||
condType === "bank_item_count" ||
|
||||
condType === "skill_level" ||
|
||||
condType === "gold_amount";
|
||||
const needsItemCode =
|
||||
condType === "inventory_item_count" || condType === "bank_item_count";
|
||||
const needsSkill = condType === "skill_level";
|
||||
const needsSeconds = condType === "timer";
|
||||
|
||||
function update(partial: Partial<TransitionCondition>) {
|
||||
const base: TransitionCondition = value ?? {
|
||||
type: "strategy_complete",
|
||||
operator: ">=",
|
||||
value: 0,
|
||||
item_code: "",
|
||||
skill: "",
|
||||
seconds: 0,
|
||||
};
|
||||
onChange({ ...base, ...partial });
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<Label className="text-xs text-muted-foreground">
|
||||
Transition Condition
|
||||
</Label>
|
||||
<Select
|
||||
value={condType || "none"}
|
||||
onValueChange={(v) => {
|
||||
if (v === "none") {
|
||||
onChange(null);
|
||||
} else {
|
||||
update({
|
||||
type: v as TransitionCondition["type"],
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue placeholder="No transition (run until complete)" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">
|
||||
No transition (run until strategy completes)
|
||||
</SelectItem>
|
||||
{TRANSITION_TYPES.map((t) => (
|
||||
<SelectItem key={t.value} value={t.value}>
|
||||
{t.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{needsValue && (
|
||||
<div className="flex gap-2">
|
||||
<div className="w-20">
|
||||
<Label className="text-xs text-muted-foreground">Operator</Label>
|
||||
<Select
|
||||
value={value?.operator ?? ">="}
|
||||
onValueChange={(v) =>
|
||||
update({ operator: v as TransitionCondition["operator"] })
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{OPERATORS.map((op) => (
|
||||
<SelectItem key={op.value} value={op.value}>
|
||||
{op.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<Label className="text-xs text-muted-foreground">Value</Label>
|
||||
<Input
|
||||
type="number"
|
||||
className="h-8 text-xs"
|
||||
value={value?.value ?? 0}
|
||||
onChange={(e) => update({ value: Number(e.target.value) })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{needsItemCode && (
|
||||
<div>
|
||||
<Label className="text-xs text-muted-foreground">Item Code</Label>
|
||||
<Input
|
||||
className="h-8 text-xs"
|
||||
placeholder="e.g. copper_ore"
|
||||
value={value?.item_code ?? ""}
|
||||
onChange={(e) => update({ item_code: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{needsSkill && (
|
||||
<div>
|
||||
<Label className="text-xs text-muted-foreground">Skill</Label>
|
||||
<Select
|
||||
value={value?.skill ?? "mining"}
|
||||
onValueChange={(v) => update({ skill: v })}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{SKILLS.map((s) => (
|
||||
<SelectItem key={s} value={s}>
|
||||
{s}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{needsSeconds && (
|
||||
<div>
|
||||
<Label className="text-xs text-muted-foreground">Seconds</Label>
|
||||
<Input
|
||||
type="number"
|
||||
className="h-8 text-xs"
|
||||
value={value?.seconds ?? 0}
|
||||
onChange={(e) => update({ seconds: Number(e.target.value) })}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
311
frontend/src/components/workflow/workflow-builder.tsx
Normal file
311
frontend/src/components/workflow/workflow-builder.tsx
Normal file
|
|
@ -0,0 +1,311 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Plus, Loader2, ArrowLeft, Repeat } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { WorkflowStepCard } from "@/components/workflow/workflow-step-card";
|
||||
import { useCharacters } from "@/hooks/use-characters";
|
||||
import { useCreateWorkflow } from "@/hooks/use-workflows";
|
||||
import type { WorkflowStep } from "@/lib/types";
|
||||
import { toast } from "sonner";
|
||||
|
||||
function generateId(): string {
|
||||
return `step_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`;
|
||||
}
|
||||
|
||||
function makeEmptyStep(): WorkflowStep {
|
||||
return {
|
||||
id: generateId(),
|
||||
name: "",
|
||||
strategy_type: "gathering",
|
||||
config: { resource_code: "", deposit_on_full: true, max_loops: 0 },
|
||||
transition: null,
|
||||
};
|
||||
}
|
||||
|
||||
interface WorkflowBuilderProps {
|
||||
/** If provided, pre-populates the builder (e.g. from a template). */
|
||||
initialSteps?: WorkflowStep[];
|
||||
initialName?: string;
|
||||
initialDescription?: string;
|
||||
initialLoop?: boolean;
|
||||
initialMaxLoops?: number;
|
||||
}
|
||||
|
||||
export function WorkflowBuilder({
|
||||
initialSteps,
|
||||
initialName = "",
|
||||
initialDescription = "",
|
||||
initialLoop = false,
|
||||
initialMaxLoops = 0,
|
||||
}: WorkflowBuilderProps) {
|
||||
const router = useRouter();
|
||||
const { data: characters, isLoading: loadingCharacters } = useCharacters();
|
||||
const createMutation = useCreateWorkflow();
|
||||
|
||||
const [name, setName] = useState(initialName);
|
||||
const [description, setDescription] = useState(initialDescription);
|
||||
const [characterName, setCharacterName] = useState("");
|
||||
const [loop, setLoop] = useState(initialLoop);
|
||||
const [maxLoops, setMaxLoops] = useState(initialMaxLoops);
|
||||
const [steps, setSteps] = useState<WorkflowStep[]>(
|
||||
initialSteps ?? [makeEmptyStep()]
|
||||
);
|
||||
|
||||
function updateStep(index: number, updated: WorkflowStep) {
|
||||
setSteps((prev) => prev.map((s, i) => (i === index ? updated : s)));
|
||||
}
|
||||
|
||||
function addStep() {
|
||||
setSteps((prev) => [...prev, makeEmptyStep()]);
|
||||
}
|
||||
|
||||
function removeStep(index: number) {
|
||||
setSteps((prev) => prev.filter((_, i) => i !== index));
|
||||
}
|
||||
|
||||
function moveStep(from: number, to: number) {
|
||||
setSteps((prev) => {
|
||||
const next = [...prev];
|
||||
const [moved] = next.splice(from, 1);
|
||||
next.splice(to, 0, moved);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
function handleCreate() {
|
||||
if (!name.trim()) {
|
||||
toast.error("Please enter a workflow name");
|
||||
return;
|
||||
}
|
||||
if (!characterName) {
|
||||
toast.error("Please select a character");
|
||||
return;
|
||||
}
|
||||
if (steps.length === 0) {
|
||||
toast.error("Add at least one step");
|
||||
return;
|
||||
}
|
||||
const hasEmptyStrategy = steps.some((s) => !s.strategy_type);
|
||||
if (hasEmptyStrategy) {
|
||||
toast.error("All steps must have a strategy selected");
|
||||
return;
|
||||
}
|
||||
|
||||
createMutation.mutate(
|
||||
{
|
||||
name: name.trim(),
|
||||
character_name: characterName,
|
||||
description: description.trim() || undefined,
|
||||
steps: steps.map((s) => ({
|
||||
id: s.id,
|
||||
name: s.name || `Step ${steps.indexOf(s) + 1}`,
|
||||
strategy_type: s.strategy_type,
|
||||
config: s.config,
|
||||
transition: s.transition,
|
||||
})),
|
||||
loop,
|
||||
max_loops: maxLoops,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast.success("Workflow created successfully");
|
||||
router.push("/automations");
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(`Failed to create workflow: ${err.message}`);
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6 max-w-3xl">
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={() => router.push("/automations")}
|
||||
>
|
||||
<ArrowLeft className="size-4" />
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight text-foreground">
|
||||
New Workflow
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Chain multiple strategies into a multi-step pipeline
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Basic info */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<span className="flex size-6 items-center justify-center rounded-full bg-primary text-primary-foreground text-xs font-bold">
|
||||
1
|
||||
</span>
|
||||
Basic Info
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="wf-name">Workflow Name</Label>
|
||||
<Input
|
||||
id="wf-name"
|
||||
placeholder="e.g. Copper Pipeline, Mining Progression"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="wf-desc">Description (optional)</Label>
|
||||
<Textarea
|
||||
id="wf-desc"
|
||||
rows={2}
|
||||
placeholder="Brief description of what this workflow does..."
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Character</Label>
|
||||
<Select value={characterName} onValueChange={setCharacterName}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Select a character" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{loadingCharacters && (
|
||||
<SelectItem value="_loading" disabled>
|
||||
Loading characters...
|
||||
</SelectItem>
|
||||
)}
|
||||
{characters?.map((char) => (
|
||||
<SelectItem key={char.name} value={char.name}>
|
||||
{char.name} (Lv. {char.level})
|
||||
</SelectItem>
|
||||
))}
|
||||
{characters?.length === 0 && (
|
||||
<SelectItem value="_empty" disabled>
|
||||
No characters found
|
||||
</SelectItem>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between pt-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Repeat className="size-4 text-muted-foreground" />
|
||||
<div className="space-y-0.5">
|
||||
<Label>Loop Workflow</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Restart from step 1 after the last step completes
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Switch checked={loop} onCheckedChange={setLoop} />
|
||||
</div>
|
||||
|
||||
{loop && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="max-loops">Max Loops</Label>
|
||||
<Input
|
||||
id="max-loops"
|
||||
type="number"
|
||||
min={0}
|
||||
placeholder="0 = infinite"
|
||||
value={maxLoops}
|
||||
onChange={(e) => setMaxLoops(parseInt(e.target.value, 10) || 0)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Max number of loop iterations. 0 = run forever.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Steps */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<span className="flex size-6 items-center justify-center rounded-full bg-primary text-primary-foreground text-xs font-bold">
|
||||
2
|
||||
</span>
|
||||
Pipeline Steps
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{steps.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground text-center py-4">
|
||||
No steps yet. Add your first step below.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{steps.map((step, idx) => (
|
||||
<WorkflowStepCard
|
||||
key={step.id}
|
||||
step={step}
|
||||
index={idx}
|
||||
totalSteps={steps.length}
|
||||
onChange={(updated) => updateStep(idx, updated)}
|
||||
onMoveUp={() => moveStep(idx, idx - 1)}
|
||||
onMoveDown={() => moveStep(idx, idx + 1)}
|
||||
onDelete={() => removeStep(idx)}
|
||||
/>
|
||||
))}
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
onClick={addStep}
|
||||
>
|
||||
<Plus className="size-4" />
|
||||
Add Step
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Create button */}
|
||||
<div className="flex items-center justify-end gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => router.push("/automations")}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleCreate}
|
||||
disabled={
|
||||
!name.trim() ||
|
||||
!characterName ||
|
||||
steps.length === 0 ||
|
||||
createMutation.isPending
|
||||
}
|
||||
>
|
||||
{createMutation.isPending && (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
)}
|
||||
Create Workflow
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
334
frontend/src/components/workflow/workflow-list.tsx
Normal file
334
frontend/src/components/workflow/workflow-list.tsx
Normal file
|
|
@ -0,0 +1,334 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
Plus,
|
||||
Trash2,
|
||||
Loader2,
|
||||
Bot,
|
||||
Play,
|
||||
Square,
|
||||
Pause,
|
||||
Repeat,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Table,
|
||||
TableHeader,
|
||||
TableBody,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TableCell,
|
||||
} from "@/components/ui/table";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
useWorkflows,
|
||||
useWorkflowStatuses,
|
||||
useDeleteWorkflow,
|
||||
useControlWorkflow,
|
||||
} from "@/hooks/use-workflows";
|
||||
import { toast } from "sonner";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const STATUS_CONFIG: Record<
|
||||
string,
|
||||
{
|
||||
label: string;
|
||||
variant: "default" | "secondary" | "destructive" | "outline";
|
||||
className: string;
|
||||
}
|
||||
> = {
|
||||
running: {
|
||||
label: "Running",
|
||||
variant: "default",
|
||||
className: "bg-green-600 hover:bg-green-600 text-white",
|
||||
},
|
||||
paused: {
|
||||
label: "Paused",
|
||||
variant: "default",
|
||||
className: "bg-yellow-600 hover:bg-yellow-600 text-white",
|
||||
},
|
||||
stopped: { label: "Stopped", variant: "secondary", className: "" },
|
||||
completed: {
|
||||
label: "Completed",
|
||||
variant: "default",
|
||||
className: "bg-blue-600 hover:bg-blue-600 text-white",
|
||||
},
|
||||
error: { label: "Error", variant: "destructive", className: "" },
|
||||
};
|
||||
|
||||
export function WorkflowList() {
|
||||
const router = useRouter();
|
||||
const { data: workflows, isLoading, error } = useWorkflows();
|
||||
const { data: statuses } = useWorkflowStatuses();
|
||||
const deleteMutation = useDeleteWorkflow();
|
||||
const control = useControlWorkflow();
|
||||
const [deleteTarget, setDeleteTarget] = useState<{
|
||||
id: number;
|
||||
name: string;
|
||||
} | null>(null);
|
||||
|
||||
const statusMap = new Map(
|
||||
(statuses ?? []).map((s) => [s.workflow_id, s])
|
||||
);
|
||||
|
||||
function handleDelete() {
|
||||
if (!deleteTarget) return;
|
||||
deleteMutation.mutate(deleteTarget.id, {
|
||||
onSuccess: () => {
|
||||
toast.success(`Workflow "${deleteTarget.name}" deleted`);
|
||||
setDeleteTarget(null);
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(`Failed to delete: ${err.message}`);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function handleControl(
|
||||
id: number,
|
||||
action: "start" | "stop" | "pause" | "resume"
|
||||
) {
|
||||
control.mutate(
|
||||
{ id, action },
|
||||
{
|
||||
onSuccess: () => toast.success(`Workflow ${action}ed`),
|
||||
onError: (err) =>
|
||||
toast.error(`Failed to ${action} workflow: ${err.message}`),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Card className="border-destructive bg-destructive/10 p-4">
|
||||
<p className="text-sm text-destructive">
|
||||
Failed to load workflows. Make sure the backend is running.
|
||||
</p>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="size-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!workflows || workflows.length === 0) {
|
||||
return (
|
||||
<Card className="p-8 text-center">
|
||||
<Bot className="size-12 text-muted-foreground mx-auto mb-4" />
|
||||
<p className="text-muted-foreground mb-4">
|
||||
No workflows configured yet. Create a multi-step pipeline to chain
|
||||
strategies together.
|
||||
</p>
|
||||
<Button onClick={() => router.push("/automations/workflows/new")}>
|
||||
<Plus className="size-4" />
|
||||
Create Workflow
|
||||
</Button>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Character</TableHead>
|
||||
<TableHead>Steps</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Progress</TableHead>
|
||||
<TableHead>Controls</TableHead>
|
||||
<TableHead className="w-10" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{workflows.map((wf) => {
|
||||
const status = statusMap.get(wf.id);
|
||||
const currentStatus = status?.status ?? "stopped";
|
||||
const statusCfg =
|
||||
STATUS_CONFIG[currentStatus] ?? STATUS_CONFIG.stopped;
|
||||
const isStopped =
|
||||
currentStatus === "stopped" ||
|
||||
currentStatus === "completed" ||
|
||||
currentStatus === "error" ||
|
||||
!currentStatus;
|
||||
const isRunning = currentStatus === "running";
|
||||
const isPaused = currentStatus === "paused";
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
key={wf.id}
|
||||
className="cursor-pointer"
|
||||
onClick={() =>
|
||||
router.push(`/automations/workflows/${wf.id}`)
|
||||
}
|
||||
>
|
||||
<TableCell className="font-medium">
|
||||
<div className="flex items-center gap-2">
|
||||
{wf.name}
|
||||
{wf.loop && (
|
||||
<Repeat className="size-3 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground">
|
||||
{wf.character_name}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{wf.steps.length} step{wf.steps.length !== 1 && "s"}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
variant={statusCfg.variant}
|
||||
className={statusCfg.className}
|
||||
>
|
||||
{statusCfg.label}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground">
|
||||
{status ? (
|
||||
<span>
|
||||
Step {status.current_step_index + 1}/{status.total_steps}
|
||||
{status.loop_count > 0 && (
|
||||
<> · Loop {status.loop_count}</>
|
||||
)}
|
||||
{" "}· {status.total_actions_count} actions
|
||||
</span>
|
||||
) : (
|
||||
"—"
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell onClick={(e) => e.stopPropagation()}>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{isStopped && (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="outline"
|
||||
className="text-green-400 hover:text-green-300 hover:bg-green-500/10"
|
||||
onClick={() => handleControl(wf.id, "start")}
|
||||
disabled={control.isPending}
|
||||
>
|
||||
<Play className="size-3" />
|
||||
</Button>
|
||||
)}
|
||||
{isRunning && (
|
||||
<>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="outline"
|
||||
className="text-yellow-400 hover:text-yellow-300 hover:bg-yellow-500/10"
|
||||
onClick={() => handleControl(wf.id, "pause")}
|
||||
disabled={control.isPending}
|
||||
>
|
||||
<Pause className="size-3" />
|
||||
</Button>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="outline"
|
||||
className="text-red-400 hover:text-red-300 hover:bg-red-500/10"
|
||||
onClick={() => handleControl(wf.id, "stop")}
|
||||
disabled={control.isPending}
|
||||
>
|
||||
<Square className="size-3" />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{isPaused && (
|
||||
<>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="outline"
|
||||
className="text-green-400 hover:text-green-300 hover:bg-green-500/10"
|
||||
onClick={() => handleControl(wf.id, "resume")}
|
||||
disabled={control.isPending}
|
||||
>
|
||||
<Play className="size-3" />
|
||||
</Button>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="outline"
|
||||
className="text-red-400 hover:text-red-300 hover:bg-red-500/10"
|
||||
onClick={() => handleControl(wf.id, "stop")}
|
||||
disabled={control.isPending}
|
||||
>
|
||||
<Square className="size-3" />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell onClick={(e) => e.stopPropagation()}>
|
||||
<Button
|
||||
size="icon-xs"
|
||||
variant="ghost"
|
||||
className="text-muted-foreground hover:text-destructive"
|
||||
onClick={() =>
|
||||
setDeleteTarget({ id: wf.id, name: wf.name })
|
||||
}
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Card>
|
||||
|
||||
<Dialog
|
||||
open={deleteTarget !== null}
|
||||
onOpenChange={(open) => !open && setDeleteTarget(null)}
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete Workflow</DialogTitle>
|
||||
<DialogDescription>
|
||||
Are you sure you want to delete “{deleteTarget?.name}
|
||||
”? This action cannot be undone. Any running workflow will be
|
||||
stopped.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setDeleteTarget(null)}
|
||||
disabled={deleteMutation.isPending}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleDelete}
|
||||
disabled={deleteMutation.isPending}
|
||||
>
|
||||
{deleteMutation.isPending && (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
)}
|
||||
Delete
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
205
frontend/src/components/workflow/workflow-progress.tsx
Normal file
205
frontend/src/components/workflow/workflow-progress.tsx
Normal file
|
|
@ -0,0 +1,205 @@
|
|||
"use client";
|
||||
|
||||
import {
|
||||
CheckCircle2,
|
||||
Circle,
|
||||
Loader2,
|
||||
Repeat,
|
||||
Swords,
|
||||
Pickaxe,
|
||||
Hammer,
|
||||
TrendingUp,
|
||||
ClipboardList,
|
||||
GraduationCap,
|
||||
} from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import type { WorkflowStep, WorkflowStatus, AutomationLog } from "@/lib/types";
|
||||
import { LogStream } from "@/components/automation/log-stream";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const STRATEGY_ICONS: Record<string, React.ElementType> = {
|
||||
combat: Swords,
|
||||
gathering: Pickaxe,
|
||||
crafting: Hammer,
|
||||
trading: TrendingUp,
|
||||
task: ClipboardList,
|
||||
leveling: GraduationCap,
|
||||
};
|
||||
|
||||
const STRATEGY_COLORS: Record<string, string> = {
|
||||
combat: "text-red-400",
|
||||
gathering: "text-green-400",
|
||||
crafting: "text-blue-400",
|
||||
trading: "text-yellow-400",
|
||||
task: "text-purple-400",
|
||||
leveling: "text-cyan-400",
|
||||
};
|
||||
|
||||
interface WorkflowProgressProps {
|
||||
steps: WorkflowStep[];
|
||||
status: WorkflowStatus | null;
|
||||
logs: AutomationLog[];
|
||||
loop: boolean;
|
||||
maxLoops: number;
|
||||
}
|
||||
|
||||
export function WorkflowProgress({
|
||||
steps,
|
||||
status,
|
||||
logs,
|
||||
loop,
|
||||
maxLoops,
|
||||
}: WorkflowProgressProps) {
|
||||
const currentIndex = status?.current_step_index ?? 0;
|
||||
const isRunning = status?.status === "running";
|
||||
const isPaused = status?.status === "paused";
|
||||
const isActive = isRunning || isPaused;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Summary bar */}
|
||||
<div className="flex items-center gap-4 flex-wrap text-sm">
|
||||
{status && (
|
||||
<>
|
||||
<span className="text-muted-foreground">
|
||||
Step{" "}
|
||||
<strong className="text-foreground">
|
||||
{currentIndex + 1}/{steps.length}
|
||||
</strong>
|
||||
</span>
|
||||
{loop && (
|
||||
<span className="flex items-center gap-1 text-muted-foreground">
|
||||
<Repeat className="size-3" />
|
||||
Loop{" "}
|
||||
<strong className="text-foreground">
|
||||
{status.loop_count}
|
||||
{maxLoops > 0 && `/${maxLoops}`}
|
||||
</strong>
|
||||
</span>
|
||||
)}
|
||||
<span className="text-muted-foreground">
|
||||
Actions:{" "}
|
||||
<strong className="text-foreground">
|
||||
{status.total_actions_count.toLocaleString()}
|
||||
</strong>
|
||||
</span>
|
||||
{status.strategy_state && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{status.strategy_state}
|
||||
</Badge>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Step timeline */}
|
||||
<div className="space-y-0">
|
||||
{steps.map((step, idx) => {
|
||||
const isCompleted = isActive && idx < currentIndex;
|
||||
const isCurrent = isActive && idx === currentIndex;
|
||||
const isPending = !isActive || idx > currentIndex;
|
||||
const Icon = STRATEGY_ICONS[step.strategy_type] ?? Circle;
|
||||
const color = STRATEGY_COLORS[step.strategy_type] ?? "text-muted-foreground";
|
||||
|
||||
return (
|
||||
<div key={step.id} className="flex items-stretch gap-3">
|
||||
{/* Vertical connector */}
|
||||
<div className="flex flex-col items-center">
|
||||
<div
|
||||
className={cn(
|
||||
"flex size-8 shrink-0 items-center justify-center rounded-full border-2",
|
||||
isCompleted &&
|
||||
"bg-green-600/20 border-green-600 text-green-400",
|
||||
isCurrent &&
|
||||
"bg-primary/20 border-primary text-primary animate-pulse",
|
||||
isPending &&
|
||||
"bg-muted/50 border-muted-foreground/30 text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{isCompleted ? (
|
||||
<CheckCircle2 className="size-4" />
|
||||
) : isCurrent && isRunning ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
<Icon className={cn("size-4", isCurrent ? "" : isPending ? "opacity-50" : color)} />
|
||||
)}
|
||||
</div>
|
||||
{idx < steps.length - 1 && (
|
||||
<div
|
||||
className={cn(
|
||||
"w-0.5 flex-1 min-h-4",
|
||||
isCompleted ? "bg-green-600/50" : "bg-muted-foreground/20"
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Step content */}
|
||||
<div
|
||||
className={cn(
|
||||
"flex-1 pb-4",
|
||||
isPending && "opacity-50"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={cn(
|
||||
"text-sm font-medium",
|
||||
isCurrent && "text-primary"
|
||||
)}
|
||||
>
|
||||
{step.name || `Step ${idx + 1}`}
|
||||
</span>
|
||||
<Badge variant="outline" className="text-[10px]">
|
||||
{step.strategy_type}
|
||||
</Badge>
|
||||
{isCurrent && status && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
({status.step_actions_count} actions)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{step.transition && (
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
Transition: {step.transition.type.replace(/_/g, " ")}
|
||||
{step.transition.operator && ` ${step.transition.operator}`}
|
||||
{step.transition.value !== undefined &&
|
||||
` ${step.transition.value}`}
|
||||
{step.transition.item_code &&
|
||||
` (${step.transition.item_code})`}
|
||||
{step.transition.skill && ` (${step.transition.skill})`}
|
||||
{step.transition.seconds !== undefined &&
|
||||
step.transition.type === "timer" &&
|
||||
` ${step.transition.seconds}s`}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Loop indicator */}
|
||||
{loop && (
|
||||
<div className="flex items-center gap-3 pl-3">
|
||||
<Repeat className="size-4 text-muted-foreground" />
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Loop back to Step 1
|
||||
{maxLoops > 0 && ` (max ${maxLoops} loops)`}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Live logs */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base">Live Logs</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<LogStream logs={logs} maxHeight="400px" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
259
frontend/src/components/workflow/workflow-step-card.tsx
Normal file
259
frontend/src/components/workflow/workflow-step-card.tsx
Normal file
|
|
@ -0,0 +1,259 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
ArrowUp,
|
||||
ArrowDown,
|
||||
Trash2,
|
||||
Swords,
|
||||
Pickaxe,
|
||||
Hammer,
|
||||
TrendingUp,
|
||||
ClipboardList,
|
||||
GraduationCap,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { StrategySelector } from "@/components/automation/strategy-selector";
|
||||
import {
|
||||
ConfigForm,
|
||||
DEFAULT_COMBAT_CONFIG,
|
||||
DEFAULT_GATHERING_CONFIG,
|
||||
DEFAULT_CRAFTING_CONFIG,
|
||||
DEFAULT_TRADING_CONFIG,
|
||||
DEFAULT_TASK_CONFIG,
|
||||
DEFAULT_LEVELING_CONFIG,
|
||||
} from "@/components/automation/config-form";
|
||||
import { TransitionEditor } from "@/components/workflow/transition-editor";
|
||||
import type { WorkflowStep, TransitionCondition } from "@/lib/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type StrategyType =
|
||||
| "combat"
|
||||
| "gathering"
|
||||
| "crafting"
|
||||
| "trading"
|
||||
| "task"
|
||||
| "leveling";
|
||||
|
||||
const STRATEGY_META: Record<
|
||||
string,
|
||||
{ icon: React.ElementType; color: string; label: string }
|
||||
> = {
|
||||
combat: { icon: Swords, color: "text-red-400", label: "Combat" },
|
||||
gathering: { icon: Pickaxe, color: "text-green-400", label: "Gathering" },
|
||||
crafting: { icon: Hammer, color: "text-blue-400", label: "Crafting" },
|
||||
trading: { icon: TrendingUp, color: "text-yellow-400", label: "Trading" },
|
||||
task: { icon: ClipboardList, color: "text-purple-400", label: "Task" },
|
||||
leveling: { icon: GraduationCap, color: "text-cyan-400", label: "Leveling" },
|
||||
};
|
||||
|
||||
const DEFAULT_CONFIGS: Record<StrategyType, Record<string, unknown>> = {
|
||||
combat: DEFAULT_COMBAT_CONFIG as unknown as Record<string, unknown>,
|
||||
gathering: DEFAULT_GATHERING_CONFIG as unknown as Record<string, unknown>,
|
||||
crafting: DEFAULT_CRAFTING_CONFIG as unknown as Record<string, unknown>,
|
||||
trading: DEFAULT_TRADING_CONFIG as unknown as Record<string, unknown>,
|
||||
task: DEFAULT_TASK_CONFIG as unknown as Record<string, unknown>,
|
||||
leveling: DEFAULT_LEVELING_CONFIG as unknown as Record<string, unknown>,
|
||||
};
|
||||
|
||||
function configSummary(step: WorkflowStep): string {
|
||||
const c = step.config;
|
||||
switch (step.strategy_type) {
|
||||
case "combat":
|
||||
return c.monster_code
|
||||
? `${c.monster_code}`
|
||||
: "auto-select";
|
||||
case "gathering":
|
||||
return c.resource_code
|
||||
? `${c.resource_code}`
|
||||
: "auto-select";
|
||||
case "crafting":
|
||||
return c.item_code
|
||||
? `${c.item_code} x${c.quantity ?? 1}`
|
||||
: "no item set";
|
||||
case "trading":
|
||||
return `${c.mode ?? "sell_loot"} ${c.item_code ?? ""}`.trim();
|
||||
case "task":
|
||||
return "auto tasks";
|
||||
case "leveling":
|
||||
return c.target_skill ? `${c.target_skill}` : "auto";
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function transitionBadge(t: TransitionCondition | null): string {
|
||||
if (!t) return "run to completion";
|
||||
switch (t.type) {
|
||||
case "strategy_complete":
|
||||
return "on complete";
|
||||
case "inventory_full":
|
||||
return "inv full";
|
||||
case "actions_count":
|
||||
return `${t.operator} ${t.value} actions`;
|
||||
case "skill_level":
|
||||
return `${t.skill} ${t.operator} ${t.value}`;
|
||||
case "inventory_item_count":
|
||||
return `inv ${t.item_code} ${t.operator} ${t.value}`;
|
||||
case "bank_item_count":
|
||||
return `bank ${t.item_code} ${t.operator} ${t.value}`;
|
||||
case "gold_amount":
|
||||
return `gold ${t.operator} ${t.value}`;
|
||||
case "timer":
|
||||
return `${t.seconds}s timer`;
|
||||
default:
|
||||
return t.type;
|
||||
}
|
||||
}
|
||||
|
||||
interface WorkflowStepCardProps {
|
||||
step: WorkflowStep;
|
||||
index: number;
|
||||
totalSteps: number;
|
||||
onChange: (step: WorkflowStep) => void;
|
||||
onMoveUp: () => void;
|
||||
onMoveDown: () => void;
|
||||
onDelete: () => void;
|
||||
}
|
||||
|
||||
export function WorkflowStepCard({
|
||||
step,
|
||||
index,
|
||||
totalSteps,
|
||||
onChange,
|
||||
onMoveUp,
|
||||
onMoveDown,
|
||||
onDelete,
|
||||
}: WorkflowStepCardProps) {
|
||||
const [expanded, setExpanded] = useState(!step.strategy_type);
|
||||
const meta = STRATEGY_META[step.strategy_type];
|
||||
const Icon = meta?.icon ?? ClipboardList;
|
||||
|
||||
function updateStep(partial: Partial<WorkflowStep>) {
|
||||
onChange({ ...step, ...partial });
|
||||
}
|
||||
|
||||
function handleStrategyChange(strategy: StrategyType) {
|
||||
onChange({
|
||||
...step,
|
||||
strategy_type: strategy,
|
||||
config: DEFAULT_CONFIGS[strategy],
|
||||
name: step.name || `${STRATEGY_META[strategy]?.label ?? strategy} Step`,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="overflow-hidden">
|
||||
{/* Collapsed header */}
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center gap-3 px-4 py-3 text-left hover:bg-accent/30 transition-colors"
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
>
|
||||
<span className="flex size-7 shrink-0 items-center justify-center rounded-full bg-primary/20 text-primary text-xs font-bold">
|
||||
{index + 1}
|
||||
</span>
|
||||
<Icon className={cn("size-4 shrink-0", meta?.color)} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="text-sm font-medium truncate block">
|
||||
{step.name || "Untitled Step"}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground truncate block">
|
||||
{meta?.label ?? step.strategy_type} · {configSummary(step)}
|
||||
</span>
|
||||
</div>
|
||||
<Badge variant="outline" className="text-[10px] shrink-0">
|
||||
{transitionBadge(step.transition)}
|
||||
</Badge>
|
||||
{expanded ? (
|
||||
<ChevronUp className="size-4 text-muted-foreground shrink-0" />
|
||||
) : (
|
||||
<ChevronDown className="size-4 text-muted-foreground shrink-0" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Expanded body */}
|
||||
{expanded && (
|
||||
<div className="border-t px-4 py-4 space-y-5">
|
||||
{/* Step name */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs text-muted-foreground">Step Name</Label>
|
||||
<Input
|
||||
className="h-8 text-sm"
|
||||
value={step.name}
|
||||
placeholder="e.g. Gather Copper Ore"
|
||||
onChange={(e) => updateStep({ name: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Strategy selector */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs text-muted-foreground">Strategy</Label>
|
||||
<StrategySelector
|
||||
value={step.strategy_type}
|
||||
onChange={handleStrategyChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Config form */}
|
||||
{step.strategy_type && (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs text-muted-foreground">
|
||||
Configuration
|
||||
</Label>
|
||||
<ConfigForm
|
||||
strategyType={step.strategy_type}
|
||||
config={step.config}
|
||||
onChange={(config) => updateStep({ config })}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Transition editor */}
|
||||
<TransitionEditor
|
||||
value={step.transition}
|
||||
onChange={(transition) => updateStep({ transition })}
|
||||
/>
|
||||
|
||||
{/* Reorder / Delete controls */}
|
||||
<div className="flex items-center gap-2 pt-2 border-t">
|
||||
<Button
|
||||
size="xs"
|
||||
variant="outline"
|
||||
disabled={index === 0}
|
||||
onClick={onMoveUp}
|
||||
>
|
||||
<ArrowUp className="size-3" />
|
||||
Up
|
||||
</Button>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="outline"
|
||||
disabled={index === totalSteps - 1}
|
||||
onClick={onMoveDown}
|
||||
>
|
||||
<ArrowDown className="size-3" />
|
||||
Down
|
||||
</Button>
|
||||
<div className="flex-1" />
|
||||
<Button
|
||||
size="xs"
|
||||
variant="outline"
|
||||
className="text-red-400 hover:text-red-300 hover:bg-red-500/10"
|
||||
onClick={onDelete}
|
||||
>
|
||||
<Trash2 className="size-3" />
|
||||
Remove
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
121
frontend/src/components/workflow/workflow-template-gallery.tsx
Normal file
121
frontend/src/components/workflow/workflow-template-gallery.tsx
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
GitBranch,
|
||||
Repeat,
|
||||
Swords,
|
||||
Pickaxe,
|
||||
Hammer,
|
||||
TrendingUp,
|
||||
ClipboardList,
|
||||
GraduationCap,
|
||||
} from "lucide-react";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import {
|
||||
WORKFLOW_TEMPLATES,
|
||||
WORKFLOW_CATEGORY_LABELS,
|
||||
WORKFLOW_CATEGORY_COLORS,
|
||||
} from "./workflow-templates";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const STRATEGY_ICONS: Record<string, React.ElementType> = {
|
||||
combat: Swords,
|
||||
gathering: Pickaxe,
|
||||
crafting: Hammer,
|
||||
trading: TrendingUp,
|
||||
task: ClipboardList,
|
||||
leveling: GraduationCap,
|
||||
};
|
||||
|
||||
export function WorkflowTemplateGallery() {
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Separator />
|
||||
<div className="flex items-center gap-2">
|
||||
<GitBranch className="size-5 text-muted-foreground" />
|
||||
<h2 className="text-lg font-semibold text-foreground">
|
||||
Workflow Templates
|
||||
</h2>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Multi-step pipelines
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{WORKFLOW_TEMPLATES.map((template) => (
|
||||
<Card
|
||||
key={template.id}
|
||||
className="cursor-pointer transition-all hover:shadow-md group py-0 overflow-hidden hover:border-primary/30"
|
||||
onClick={() =>
|
||||
router.push(
|
||||
`/automations/workflows/new?template=${template.id}`
|
||||
)
|
||||
}
|
||||
>
|
||||
<div className="h-1 bg-primary/40" />
|
||||
<CardContent className="p-4 space-y-3">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex size-10 shrink-0 items-center justify-center rounded-lg bg-primary/10">
|
||||
<GitBranch className="size-5 text-primary" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-semibold text-sm text-foreground group-hover:text-primary transition-colors">
|
||||
{template.name}
|
||||
</h3>
|
||||
<p className="text-xs text-muted-foreground mt-1 line-clamp-2">
|
||||
{template.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"text-[10px] capitalize",
|
||||
WORKFLOW_CATEGORY_COLORS[template.category]
|
||||
)}
|
||||
>
|
||||
{WORKFLOW_CATEGORY_LABELS[template.category] ??
|
||||
template.category}
|
||||
</Badge>
|
||||
<Badge variant="outline" className="text-[10px]">
|
||||
{template.steps.length} step
|
||||
{template.steps.length !== 1 && "s"}
|
||||
</Badge>
|
||||
{template.loop && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-[10px] gap-0.5"
|
||||
>
|
||||
<Repeat className="size-2.5" />
|
||||
Loop
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Step mini-preview */}
|
||||
<div className="flex items-center gap-1 text-[10px] text-muted-foreground">
|
||||
{template.steps.map((step, idx) => {
|
||||
const Icon =
|
||||
STRATEGY_ICONS[step.strategy_type] ?? ClipboardList;
|
||||
return (
|
||||
<span key={step.id} className="flex items-center gap-0.5">
|
||||
{idx > 0 && <span className="mx-0.5">→</span>}
|
||||
<Icon className="size-3" />
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
1168
frontend/src/components/workflow/workflow-templates.ts
Normal file
1168
frontend/src/components/workflow/workflow-templates.ts
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { getAnalytics, getLogs } from "@/lib/api-client";
|
||||
import type { AnalyticsData, ActionLog } from "@/lib/types";
|
||||
import type { AnalyticsData, PaginatedLogs } from "@/lib/types";
|
||||
|
||||
export function useAnalytics(characterName?: string, hours?: number) {
|
||||
return useQuery<AnalyticsData>({
|
||||
|
|
@ -12,10 +12,21 @@ export function useAnalytics(characterName?: string, hours?: number) {
|
|||
});
|
||||
}
|
||||
|
||||
export function useLogs(filters?: { character?: string; type?: string }) {
|
||||
return useQuery<ActionLog[]>({
|
||||
queryKey: ["logs", filters?.character, filters?.type],
|
||||
queryFn: () => getLogs(filters?.character),
|
||||
export function useLogs(filters?: {
|
||||
character?: string;
|
||||
type?: string;
|
||||
page?: number;
|
||||
size?: number;
|
||||
}) {
|
||||
return useQuery<PaginatedLogs>({
|
||||
queryKey: ["logs", filters?.character, filters?.type, filters?.page, filters?.size],
|
||||
queryFn: () =>
|
||||
getLogs({
|
||||
character: filters?.character,
|
||||
type: filters?.type,
|
||||
page: filters?.page,
|
||||
size: filters?.size,
|
||||
}),
|
||||
refetchInterval: 5000,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
49
frontend/src/hooks/use-errors.ts
Normal file
49
frontend/src/hooks/use-errors.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
"use client";
|
||||
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
getAppErrors,
|
||||
getErrorStats,
|
||||
resolveError,
|
||||
} from "@/lib/api-client";
|
||||
import type { PaginatedErrors, ErrorStats, AppError } from "@/lib/types";
|
||||
|
||||
export function useErrors(filters?: {
|
||||
severity?: string;
|
||||
source?: string;
|
||||
resolved?: string;
|
||||
page?: number;
|
||||
size?: number;
|
||||
}) {
|
||||
return useQuery<PaginatedErrors>({
|
||||
queryKey: [
|
||||
"app-errors",
|
||||
filters?.severity,
|
||||
filters?.source,
|
||||
filters?.resolved,
|
||||
filters?.page,
|
||||
filters?.size,
|
||||
],
|
||||
queryFn: () => getAppErrors(filters),
|
||||
refetchInterval: 15000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useErrorStats() {
|
||||
return useQuery<ErrorStats>({
|
||||
queryKey: ["app-errors-stats"],
|
||||
queryFn: getErrorStats,
|
||||
refetchInterval: 15000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useResolveError() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation<AppError, Error, number>({
|
||||
mutationFn: resolveError,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["app-errors"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["app-errors-stats"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
140
frontend/src/hooks/use-pipelines.ts
Normal file
140
frontend/src/hooks/use-pipelines.ts
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
"use client";
|
||||
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
getPipelines,
|
||||
getPipeline,
|
||||
getPipelineStatuses,
|
||||
getPipelineLogs,
|
||||
createPipeline,
|
||||
updatePipeline,
|
||||
deletePipeline,
|
||||
startPipeline,
|
||||
stopPipeline,
|
||||
pausePipeline,
|
||||
resumePipeline,
|
||||
} from "@/lib/api-client";
|
||||
import type {
|
||||
PipelineConfig,
|
||||
PipelineRun,
|
||||
PipelineStatus,
|
||||
AutomationLog,
|
||||
} from "@/lib/types";
|
||||
|
||||
export function usePipelines() {
|
||||
return useQuery<PipelineConfig[]>({
|
||||
queryKey: ["pipelines"],
|
||||
queryFn: getPipelines,
|
||||
refetchInterval: 5000,
|
||||
});
|
||||
}
|
||||
|
||||
export function usePipeline(id: number) {
|
||||
return useQuery<{ config: PipelineConfig; runs: PipelineRun[] }>({
|
||||
queryKey: ["pipeline", id],
|
||||
queryFn: () => getPipeline(id),
|
||||
refetchInterval: 5000,
|
||||
enabled: id > 0,
|
||||
});
|
||||
}
|
||||
|
||||
export function usePipelineStatuses() {
|
||||
return useQuery<PipelineStatus[]>({
|
||||
queryKey: ["pipelineStatuses"],
|
||||
queryFn: getPipelineStatuses,
|
||||
refetchInterval: 3000,
|
||||
});
|
||||
}
|
||||
|
||||
export function usePipelineLogs(id: number) {
|
||||
return useQuery<AutomationLog[]>({
|
||||
queryKey: ["pipelineLogs", id],
|
||||
queryFn: () => getPipelineLogs(id),
|
||||
refetchInterval: 3000,
|
||||
enabled: id > 0,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreatePipeline() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (data: {
|
||||
name: string;
|
||||
description?: string;
|
||||
stages: Record<string, unknown>[];
|
||||
loop?: boolean;
|
||||
max_loops?: number;
|
||||
}) => createPipeline(data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["pipelines"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdatePipeline() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({
|
||||
id,
|
||||
data,
|
||||
}: {
|
||||
id: number;
|
||||
data: Partial<PipelineConfig>;
|
||||
}) => updatePipeline(id, data),
|
||||
onSuccess: (_data, variables) => {
|
||||
queryClient.invalidateQueries({ queryKey: ["pipelines"] });
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["pipeline", variables.id],
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeletePipeline() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (id: number) => deletePipeline(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["pipelines"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useControlPipeline() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({
|
||||
id,
|
||||
action,
|
||||
}: {
|
||||
id: number;
|
||||
action: "start" | "stop" | "pause" | "resume";
|
||||
}) => {
|
||||
switch (action) {
|
||||
case "start":
|
||||
await startPipeline(id);
|
||||
return;
|
||||
case "stop":
|
||||
return stopPipeline(id);
|
||||
case "pause":
|
||||
return pausePipeline(id);
|
||||
case "resume":
|
||||
return resumePipeline(id);
|
||||
}
|
||||
},
|
||||
onSuccess: (_data, variables) => {
|
||||
queryClient.invalidateQueries({ queryKey: ["pipelines"] });
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["pipeline", variables.id],
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: ["pipelineStatuses"] });
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["pipelineLogs", variables.id],
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
141
frontend/src/hooks/use-workflows.ts
Normal file
141
frontend/src/hooks/use-workflows.ts
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
"use client";
|
||||
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
getWorkflows,
|
||||
getWorkflow,
|
||||
getWorkflowStatuses,
|
||||
getWorkflowLogs,
|
||||
createWorkflow,
|
||||
updateWorkflow,
|
||||
deleteWorkflow,
|
||||
startWorkflow,
|
||||
stopWorkflow,
|
||||
pauseWorkflow,
|
||||
resumeWorkflow,
|
||||
} from "@/lib/api-client";
|
||||
import type {
|
||||
WorkflowConfig,
|
||||
WorkflowRun,
|
||||
WorkflowStatus,
|
||||
AutomationLog,
|
||||
} from "@/lib/types";
|
||||
|
||||
export function useWorkflows() {
|
||||
return useQuery<WorkflowConfig[]>({
|
||||
queryKey: ["workflows"],
|
||||
queryFn: getWorkflows,
|
||||
refetchInterval: 5000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useWorkflow(id: number) {
|
||||
return useQuery<{ config: WorkflowConfig; runs: WorkflowRun[] }>({
|
||||
queryKey: ["workflow", id],
|
||||
queryFn: () => getWorkflow(id),
|
||||
refetchInterval: 5000,
|
||||
enabled: id > 0,
|
||||
});
|
||||
}
|
||||
|
||||
export function useWorkflowStatuses() {
|
||||
return useQuery<WorkflowStatus[]>({
|
||||
queryKey: ["workflowStatuses"],
|
||||
queryFn: getWorkflowStatuses,
|
||||
refetchInterval: 3000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useWorkflowLogs(id: number) {
|
||||
return useQuery<AutomationLog[]>({
|
||||
queryKey: ["workflowLogs", id],
|
||||
queryFn: () => getWorkflowLogs(id),
|
||||
refetchInterval: 3000,
|
||||
enabled: id > 0,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateWorkflow() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (data: {
|
||||
name: string;
|
||||
character_name: string;
|
||||
description?: string;
|
||||
steps: Record<string, unknown>[];
|
||||
loop?: boolean;
|
||||
max_loops?: number;
|
||||
}) => createWorkflow(data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["workflows"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateWorkflow() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({
|
||||
id,
|
||||
data,
|
||||
}: {
|
||||
id: number;
|
||||
data: Partial<WorkflowConfig>;
|
||||
}) => updateWorkflow(id, data),
|
||||
onSuccess: (_data, variables) => {
|
||||
queryClient.invalidateQueries({ queryKey: ["workflows"] });
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["workflow", variables.id],
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteWorkflow() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (id: number) => deleteWorkflow(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["workflows"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useControlWorkflow() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({
|
||||
id,
|
||||
action,
|
||||
}: {
|
||||
id: number;
|
||||
action: "start" | "stop" | "pause" | "resume";
|
||||
}) => {
|
||||
switch (action) {
|
||||
case "start":
|
||||
await startWorkflow(id);
|
||||
return;
|
||||
case "stop":
|
||||
return stopWorkflow(id);
|
||||
case "pause":
|
||||
return pauseWorkflow(id);
|
||||
case "resume":
|
||||
return resumeWorkflow(id);
|
||||
}
|
||||
},
|
||||
onSuccess: (_data, variables) => {
|
||||
queryClient.invalidateQueries({ queryKey: ["workflows"] });
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["workflow", variables.id],
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: ["workflowStatuses"] });
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["workflowLogs", variables.id],
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -10,19 +10,49 @@ import type {
|
|||
AutomationRun,
|
||||
AutomationLog,
|
||||
AutomationStatus,
|
||||
WorkflowConfig,
|
||||
WorkflowRun,
|
||||
WorkflowStatus,
|
||||
PipelineConfig,
|
||||
PipelineRun,
|
||||
PipelineStatus,
|
||||
GEOrder,
|
||||
GEHistoryEntry,
|
||||
PricePoint,
|
||||
ActiveGameEvent,
|
||||
HistoricalEvent,
|
||||
ActionLog,
|
||||
PaginatedLogs,
|
||||
AnalyticsData,
|
||||
PaginatedErrors,
|
||||
ErrorStats,
|
||||
AppError,
|
||||
} from "./types";
|
||||
|
||||
const API_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000";
|
||||
|
||||
const STORAGE_KEY = "artifacts-api-token";
|
||||
|
||||
/** Read the user's API token from localStorage (browser-only). */
|
||||
function getStoredToken(): string | null {
|
||||
if (typeof window === "undefined") return null;
|
||||
return localStorage.getItem(STORAGE_KEY);
|
||||
}
|
||||
|
||||
/** Build headers including the per-user API token when available. */
|
||||
function authHeaders(extra?: Record<string, string>): Record<string, string> {
|
||||
const headers: Record<string, string> = { ...extra };
|
||||
const token = getStoredToken();
|
||||
if (token) {
|
||||
headers["X-API-Token"] = token;
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
|
||||
async function fetchApi<T>(path: string): Promise<T> {
|
||||
const response = await fetch(`${API_URL}${path}`);
|
||||
const response = await fetch(`${API_URL}${path}`, {
|
||||
headers: authHeaders(),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`API error: ${response.status} ${response.statusText}`);
|
||||
|
|
@ -34,7 +64,7 @@ async function fetchApi<T>(path: string): Promise<T> {
|
|||
async function postApi<T>(path: string, body?: unknown): Promise<T> {
|
||||
const response = await fetch(`${API_URL}${path}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
headers: authHeaders({ "Content-Type": "application/json" }),
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
|
||||
|
|
@ -55,7 +85,7 @@ async function postApi<T>(path: string, body?: unknown): Promise<T> {
|
|||
async function putApi<T>(path: string, body?: unknown): Promise<T> {
|
||||
const response = await fetch(`${API_URL}${path}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
headers: authHeaders({ "Content-Type": "application/json" }),
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
|
||||
|
|
@ -76,6 +106,7 @@ async function putApi<T>(path: string, body?: unknown): Promise<T> {
|
|||
async function deleteApi(path: string): Promise<void> {
|
||||
const response = await fetch(`${API_URL}${path}`, {
|
||||
method: "DELETE",
|
||||
headers: authHeaders(),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
|
|
@ -111,6 +142,7 @@ export async function setAuthToken(token: string): Promise<SetTokenResponse> {
|
|||
export async function clearAuthToken(): Promise<AuthStatus> {
|
||||
const response = await fetch(`${API_URL}/api/auth/token`, {
|
||||
method: "DELETE",
|
||||
headers: authHeaders(),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`API error: ${response.status}`);
|
||||
|
|
@ -219,6 +251,143 @@ export function getAutomationLogs(
|
|||
);
|
||||
}
|
||||
|
||||
// ---------- Workflow API ----------
|
||||
|
||||
export function getWorkflows(): Promise<WorkflowConfig[]> {
|
||||
return fetchApi<WorkflowConfig[]>("/api/workflows");
|
||||
}
|
||||
|
||||
export function getWorkflow(
|
||||
id: number
|
||||
): Promise<{ config: WorkflowConfig; runs: WorkflowRun[] }> {
|
||||
return fetchApi<{ config: WorkflowConfig; runs: WorkflowRun[] }>(
|
||||
`/api/workflows/${id}`
|
||||
);
|
||||
}
|
||||
|
||||
export function createWorkflow(data: {
|
||||
name: string;
|
||||
character_name: string;
|
||||
description?: string;
|
||||
steps: Record<string, unknown>[];
|
||||
loop?: boolean;
|
||||
max_loops?: number;
|
||||
}): Promise<WorkflowConfig> {
|
||||
return postApi<WorkflowConfig>("/api/workflows", data);
|
||||
}
|
||||
|
||||
export function updateWorkflow(
|
||||
id: number,
|
||||
data: Partial<WorkflowConfig>
|
||||
): Promise<WorkflowConfig> {
|
||||
return putApi<WorkflowConfig>(`/api/workflows/${id}`, data);
|
||||
}
|
||||
|
||||
export function deleteWorkflow(id: number): Promise<void> {
|
||||
return deleteApi(`/api/workflows/${id}`);
|
||||
}
|
||||
|
||||
export function startWorkflow(id: number): Promise<WorkflowRun> {
|
||||
return postApi<WorkflowRun>(`/api/workflows/${id}/start`);
|
||||
}
|
||||
|
||||
export function stopWorkflow(id: number): Promise<void> {
|
||||
return postApi(`/api/workflows/${id}/stop`);
|
||||
}
|
||||
|
||||
export function pauseWorkflow(id: number): Promise<void> {
|
||||
return postApi(`/api/workflows/${id}/pause`);
|
||||
}
|
||||
|
||||
export function resumeWorkflow(id: number): Promise<void> {
|
||||
return postApi(`/api/workflows/${id}/resume`);
|
||||
}
|
||||
|
||||
export function getWorkflowStatuses(): Promise<WorkflowStatus[]> {
|
||||
return fetchApi<WorkflowStatus[]>("/api/workflows/status/all");
|
||||
}
|
||||
|
||||
export function getWorkflowStatus(id: number): Promise<WorkflowStatus> {
|
||||
return fetchApi<WorkflowStatus>(`/api/workflows/${id}/status`);
|
||||
}
|
||||
|
||||
export function getWorkflowLogs(
|
||||
id: number,
|
||||
limit: number = 100
|
||||
): Promise<AutomationLog[]> {
|
||||
return fetchApi<AutomationLog[]>(
|
||||
`/api/workflows/${id}/logs?limit=${limit}`
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- Pipeline API ----------
|
||||
|
||||
export function getPipelines(): Promise<PipelineConfig[]> {
|
||||
return fetchApi<PipelineConfig[]>("/api/pipelines");
|
||||
}
|
||||
|
||||
export function getPipeline(
|
||||
id: number
|
||||
): Promise<{ config: PipelineConfig; runs: PipelineRun[] }> {
|
||||
return fetchApi<{ config: PipelineConfig; runs: PipelineRun[] }>(
|
||||
`/api/pipelines/${id}`
|
||||
);
|
||||
}
|
||||
|
||||
export function createPipeline(data: {
|
||||
name: string;
|
||||
description?: string;
|
||||
stages: Record<string, unknown>[];
|
||||
loop?: boolean;
|
||||
max_loops?: number;
|
||||
}): Promise<PipelineConfig> {
|
||||
return postApi<PipelineConfig>("/api/pipelines", data);
|
||||
}
|
||||
|
||||
export function updatePipeline(
|
||||
id: number,
|
||||
data: Partial<PipelineConfig>
|
||||
): Promise<PipelineConfig> {
|
||||
return putApi<PipelineConfig>(`/api/pipelines/${id}`, data);
|
||||
}
|
||||
|
||||
export function deletePipeline(id: number): Promise<void> {
|
||||
return deleteApi(`/api/pipelines/${id}`);
|
||||
}
|
||||
|
||||
export function startPipeline(id: number): Promise<PipelineRun> {
|
||||
return postApi<PipelineRun>(`/api/pipelines/${id}/start`);
|
||||
}
|
||||
|
||||
export function stopPipeline(id: number): Promise<void> {
|
||||
return postApi(`/api/pipelines/${id}/stop`);
|
||||
}
|
||||
|
||||
export function pausePipeline(id: number): Promise<void> {
|
||||
return postApi(`/api/pipelines/${id}/pause`);
|
||||
}
|
||||
|
||||
export function resumePipeline(id: number): Promise<void> {
|
||||
return postApi(`/api/pipelines/${id}/resume`);
|
||||
}
|
||||
|
||||
export function getPipelineStatuses(): Promise<PipelineStatus[]> {
|
||||
return fetchApi<PipelineStatus[]>("/api/pipelines/status/all");
|
||||
}
|
||||
|
||||
export function getPipelineStatus(id: number): Promise<PipelineStatus> {
|
||||
return fetchApi<PipelineStatus>(`/api/pipelines/${id}/status`);
|
||||
}
|
||||
|
||||
export function getPipelineLogs(
|
||||
id: number,
|
||||
limit: number = 100
|
||||
): Promise<AutomationLog[]> {
|
||||
return fetchApi<AutomationLog[]>(
|
||||
`/api/pipelines/${id}/logs?limit=${limit}`
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- Grand Exchange API ----------
|
||||
|
||||
export async function getExchangeOrders(): Promise<GEOrder[]> {
|
||||
|
|
@ -264,12 +433,25 @@ export async function getEventHistory(): Promise<HistoricalEvent[]> {
|
|||
|
||||
// ---------- Logs & Analytics API ----------
|
||||
|
||||
export async function getLogs(characterName?: string): Promise<ActionLog[]> {
|
||||
export async function getLogs(filters?: {
|
||||
character?: string;
|
||||
type?: string;
|
||||
page?: number;
|
||||
size?: number;
|
||||
}): Promise<PaginatedLogs> {
|
||||
const params = new URLSearchParams();
|
||||
if (characterName) params.set("character", characterName);
|
||||
if (filters?.character) params.set("character", filters.character);
|
||||
if (filters?.type) params.set("type", filters.type);
|
||||
if (filters?.page) params.set("page", filters.page.toString());
|
||||
if (filters?.size) params.set("size", filters.size.toString());
|
||||
const qs = params.toString();
|
||||
const data = await fetchApi<ActionLog[] | { logs?: ActionLog[] }>(`/api/logs${qs ? `?${qs}` : ""}`);
|
||||
return Array.isArray(data) ? data : (data?.logs ?? []);
|
||||
const data = await fetchApi<PaginatedLogs>(`/api/logs${qs ? `?${qs}` : ""}`);
|
||||
return {
|
||||
logs: data.logs ?? [],
|
||||
total: data.total ?? 0,
|
||||
page: data.page ?? 1,
|
||||
pages: data.pages ?? 1,
|
||||
};
|
||||
}
|
||||
|
||||
export function getAnalytics(
|
||||
|
|
@ -295,3 +477,39 @@ export function executeAction(
|
|||
{ action, params }
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- App Errors API ----------
|
||||
|
||||
export async function getAppErrors(filters?: {
|
||||
severity?: string;
|
||||
source?: string;
|
||||
resolved?: string;
|
||||
page?: number;
|
||||
size?: number;
|
||||
}): Promise<PaginatedErrors> {
|
||||
const params = new URLSearchParams();
|
||||
if (filters?.severity) params.set("severity", filters.severity);
|
||||
if (filters?.source) params.set("source", filters.source);
|
||||
if (filters?.resolved) params.set("resolved", filters.resolved);
|
||||
if (filters?.page) params.set("page", filters.page.toString());
|
||||
if (filters?.size) params.set("size", filters.size.toString());
|
||||
const qs = params.toString();
|
||||
return fetchApi<PaginatedErrors>(`/api/errors${qs ? `?${qs}` : ""}`);
|
||||
}
|
||||
|
||||
export function getErrorStats(): Promise<ErrorStats> {
|
||||
return fetchApi<ErrorStats>("/api/errors/stats");
|
||||
}
|
||||
|
||||
export function resolveError(id: number): Promise<AppError> {
|
||||
return postApi<AppError>(`/api/errors/${id}/resolve`);
|
||||
}
|
||||
|
||||
export function reportError(report: {
|
||||
error_type: string;
|
||||
message: string;
|
||||
stack_trace?: string;
|
||||
context?: Record<string, unknown>;
|
||||
}): Promise<void> {
|
||||
return postApi("/api/errors/report", report);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -47,6 +47,7 @@ export const NAV_ITEMS = [
|
|||
{ href: "/exchange", label: "Exchange", icon: "ArrowLeftRight" },
|
||||
{ href: "/events", label: "Events", icon: "Zap" },
|
||||
{ href: "/logs", label: "Logs", icon: "ScrollText" },
|
||||
{ href: "/errors", label: "Errors", icon: "AlertTriangle" },
|
||||
{ href: "/analytics", label: "Analytics", icon: "BarChart3" },
|
||||
{ href: "/settings", label: "Settings", icon: "Settings" },
|
||||
] as const;
|
||||
|
|
|
|||
|
|
@ -251,6 +251,161 @@ export interface LevelingConfig {
|
|||
target_skill?: string;
|
||||
}
|
||||
|
||||
// ---------- Workflow Types ----------
|
||||
|
||||
export interface TransitionCondition {
|
||||
type:
|
||||
| "strategy_complete"
|
||||
| "loops_completed"
|
||||
| "inventory_full"
|
||||
| "inventory_item_count"
|
||||
| "bank_item_count"
|
||||
| "skill_level"
|
||||
| "gold_amount"
|
||||
| "actions_count"
|
||||
| "timer";
|
||||
operator: ">=" | "<=" | "==" | ">" | "<";
|
||||
value: number;
|
||||
item_code: string;
|
||||
skill: string;
|
||||
seconds: number;
|
||||
}
|
||||
|
||||
export interface WorkflowStep {
|
||||
id: string;
|
||||
name: string;
|
||||
strategy_type:
|
||||
| "combat"
|
||||
| "gathering"
|
||||
| "crafting"
|
||||
| "trading"
|
||||
| "task"
|
||||
| "leveling";
|
||||
config: Record<string, unknown>;
|
||||
transition: TransitionCondition | null;
|
||||
}
|
||||
|
||||
export interface WorkflowConfig {
|
||||
id: number;
|
||||
name: string;
|
||||
character_name: string;
|
||||
description: string;
|
||||
steps: WorkflowStep[];
|
||||
loop: boolean;
|
||||
max_loops: number;
|
||||
enabled: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface WorkflowRun {
|
||||
id: number;
|
||||
workflow_id: number;
|
||||
status: "running" | "paused" | "stopped" | "completed" | "error";
|
||||
current_step_index: number;
|
||||
current_step_id: string;
|
||||
loop_count: number;
|
||||
total_actions_count: number;
|
||||
step_actions_count: number;
|
||||
started_at: string;
|
||||
stopped_at: string | null;
|
||||
error_message: string | null;
|
||||
step_history: Record<string, unknown>[];
|
||||
}
|
||||
|
||||
export interface WorkflowStatus {
|
||||
workflow_id: number;
|
||||
character_name: string;
|
||||
status: string;
|
||||
run_id: number | null;
|
||||
current_step_index: number;
|
||||
current_step_id: string;
|
||||
total_steps: number;
|
||||
loop_count: number;
|
||||
total_actions_count: number;
|
||||
step_actions_count: number;
|
||||
strategy_state: string;
|
||||
}
|
||||
|
||||
// ---------- Pipeline Types ----------
|
||||
|
||||
export interface CharacterStep {
|
||||
id: string;
|
||||
character_name: string;
|
||||
strategy_type:
|
||||
| "combat"
|
||||
| "gathering"
|
||||
| "crafting"
|
||||
| "trading"
|
||||
| "task"
|
||||
| "leveling";
|
||||
config: Record<string, unknown>;
|
||||
transition: TransitionCondition | null;
|
||||
}
|
||||
|
||||
export interface PipelineStage {
|
||||
id: string;
|
||||
name: string;
|
||||
character_steps: CharacterStep[];
|
||||
}
|
||||
|
||||
export interface PipelineConfig {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string;
|
||||
stages: PipelineStage[];
|
||||
loop: boolean;
|
||||
max_loops: number;
|
||||
enabled: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface PipelineRun {
|
||||
id: number;
|
||||
pipeline_id: number;
|
||||
status: "running" | "paused" | "stopped" | "completed" | "error";
|
||||
current_stage_index: number;
|
||||
current_stage_id: string;
|
||||
loop_count: number;
|
||||
total_actions_count: number;
|
||||
character_states: Record<
|
||||
string,
|
||||
{
|
||||
status: string;
|
||||
step_id: string;
|
||||
actions_count: number;
|
||||
strategy_state: string;
|
||||
error: string | null;
|
||||
}
|
||||
>;
|
||||
stage_history: Record<string, unknown>[];
|
||||
started_at: string;
|
||||
stopped_at: string | null;
|
||||
error_message: string | null;
|
||||
}
|
||||
|
||||
export interface PipelineCharacterState {
|
||||
character_name: string;
|
||||
status: string;
|
||||
step_id: string;
|
||||
actions_count: number;
|
||||
strategy_state: string;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export interface PipelineStatus {
|
||||
pipeline_id: number;
|
||||
status: string;
|
||||
run_id: number | null;
|
||||
current_stage_index: number;
|
||||
current_stage_id: string;
|
||||
total_stages: number;
|
||||
loop_count: number;
|
||||
total_actions_count: number;
|
||||
character_states: PipelineCharacterState[];
|
||||
}
|
||||
|
||||
// ---------- Grand Exchange Types ----------
|
||||
|
||||
export interface GEOrder {
|
||||
|
|
@ -319,6 +474,14 @@ export interface ActionLog {
|
|||
details: Record<string, unknown>;
|
||||
success: boolean;
|
||||
created_at: string;
|
||||
cooldown?: number;
|
||||
}
|
||||
|
||||
export interface PaginatedLogs {
|
||||
logs: ActionLog[];
|
||||
total: number;
|
||||
page: number;
|
||||
pages: number;
|
||||
}
|
||||
|
||||
export interface TimeSeriesPoint {
|
||||
|
|
@ -332,3 +495,33 @@ export interface AnalyticsData {
|
|||
gold_history: TimeSeriesPoint[];
|
||||
actions_per_hour: number;
|
||||
}
|
||||
|
||||
// ---------- App Error Types ----------
|
||||
|
||||
export interface AppError {
|
||||
id: number;
|
||||
severity: string;
|
||||
source: string;
|
||||
error_type: string;
|
||||
message: string;
|
||||
stack_trace?: string | null;
|
||||
context?: Record<string, unknown> | null;
|
||||
correlation_id?: string | null;
|
||||
resolved: boolean;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface PaginatedErrors {
|
||||
errors: AppError[];
|
||||
total: number;
|
||||
page: number;
|
||||
pages: number;
|
||||
}
|
||||
|
||||
export interface ErrorStats {
|
||||
total: number;
|
||||
unresolved: number;
|
||||
last_hour: number;
|
||||
by_severity: Record<string, number>;
|
||||
by_source: Record<string, number>;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue