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
|
Each user provides their own Artifacts API token via the frontend.
|
||||||
their own token through the UI. The token is stored in memory only
|
The token is stored in the browser's localStorage and sent with every
|
||||||
and must be re-sent if the backend restarts.
|
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
|
import logging
|
||||||
|
|
@ -12,7 +13,6 @@ from fastapi import APIRouter, Request
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.services.artifacts_client import ArtifactsClient
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
@ -21,7 +21,7 @@ router = APIRouter(prefix="/api/auth", tags=["auth"])
|
||||||
|
|
||||||
class AuthStatus(BaseModel):
|
class AuthStatus(BaseModel):
|
||||||
has_token: bool
|
has_token: bool
|
||||||
source: str # "env", "user", or "none"
|
source: str # "header", "env", or "none"
|
||||||
|
|
||||||
|
|
||||||
class SetTokenRequest(BaseModel):
|
class SetTokenRequest(BaseModel):
|
||||||
|
|
@ -37,15 +37,24 @@ class SetTokenResponse(BaseModel):
|
||||||
|
|
||||||
@router.get("/status", response_model=AuthStatus)
|
@router.get("/status", response_model=AuthStatus)
|
||||||
async def auth_status(request: Request) -> AuthStatus:
|
async def auth_status(request: Request) -> AuthStatus:
|
||||||
client: ArtifactsClient = request.app.state.artifacts_client
|
"""Check whether the *requesting* client has a valid token.
|
||||||
return AuthStatus(
|
|
||||||
has_token=client.has_token,
|
The frontend sends the token in the ``X-API-Token`` header.
|
||||||
source=client.token_source,
|
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)
|
@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()
|
token = body.token.strip()
|
||||||
if not token:
|
if not token:
|
||||||
return SetTokenResponse(success=False, source="none", error="Token cannot be empty")
|
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.",
|
error="Could not validate token. Check your network connection.",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Token is valid — apply it
|
logger.info("API token validated via UI")
|
||||||
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)")
|
|
||||||
return SetTokenResponse(success=True, source="user")
|
return SetTokenResponse(success=True, source="user")
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/token")
|
@router.delete("/token")
|
||||||
async def clear_token(request: Request) -> AuthStatus:
|
async def clear_token() -> AuthStatus:
|
||||||
client: ArtifactsClient = request.app.state.artifacts_client
|
"""No-op on the backend — the frontend clears its own localStorage."""
|
||||||
client.clear_token()
|
return AuthStatus(has_token=False, source="none")
|
||||||
|
|
||||||
# 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,
|
|
||||||
)
|
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy.orm import selectinload
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
|
from app.api.deps import get_user_character_names
|
||||||
from app.database import async_session_factory
|
from app.database import async_session_factory
|
||||||
from app.engine.manager import AutomationManager
|
from app.engine.manager import AutomationManager
|
||||||
from app.models.automation import AutomationConfig, AutomationLog, AutomationRun
|
from app.models.automation import AutomationConfig, AutomationLog, AutomationRun
|
||||||
|
|
@ -46,9 +47,14 @@ def _get_manager(request: Request) -> AutomationManager:
|
||||||
|
|
||||||
@router.get("/", response_model=list[AutomationConfigResponse])
|
@router.get("/", response_model=list[AutomationConfigResponse])
|
||||||
async def list_configs(request: Request) -> 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:
|
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)
|
result = await db.execute(stmt)
|
||||||
configs = result.scalars().all()
|
configs = result.scalars().all()
|
||||||
return [AutomationConfigResponse.model_validate(c) for c in configs]
|
return [AutomationConfigResponse.model_validate(c) for c in configs]
|
||||||
|
|
|
||||||
|
|
@ -5,8 +5,8 @@ from fastapi import APIRouter, HTTPException, Request
|
||||||
from httpx import HTTPStatusError
|
from httpx import HTTPStatusError
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from app.api.deps import get_user_client
|
||||||
from app.database import async_session_factory
|
from app.database import async_session_factory
|
||||||
from app.services.artifacts_client import ArtifactsClient
|
|
||||||
from app.services.bank_service import BankService
|
from app.services.bank_service import BankService
|
||||||
from app.services.game_data_cache import GameDataCacheService
|
from app.services.game_data_cache import GameDataCacheService
|
||||||
|
|
||||||
|
|
@ -15,10 +15,6 @@ logger = logging.getLogger(__name__)
|
||||||
router = APIRouter(prefix="/api", tags=["bank"])
|
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:
|
def _get_cache_service(request: Request) -> GameDataCacheService:
|
||||||
return request.app.state.cache_service
|
return request.app.state.cache_service
|
||||||
|
|
||||||
|
|
@ -33,11 +29,17 @@ class ManualActionRequest(BaseModel):
|
||||||
|
|
||||||
action: str = Field(
|
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(
|
params: dict = Field(
|
||||||
default_factory=dict,
|
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")
|
@router.get("/bank")
|
||||||
async def get_bank(request: Request) -> dict[str, Any]:
|
async def get_bank(request: Request) -> dict[str, Any]:
|
||||||
"""Return bank details with enriched item data from game cache."""
|
"""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)
|
cache_service = _get_cache_service(request)
|
||||||
bank_service = BankService()
|
bank_service = BankService()
|
||||||
|
|
||||||
|
|
@ -75,7 +77,7 @@ async def get_bank(request: Request) -> dict[str, Any]:
|
||||||
@router.get("/bank/summary")
|
@router.get("/bank/summary")
|
||||||
async def get_bank_summary(request: Request) -> dict[str, Any]:
|
async def get_bank_summary(request: Request) -> dict[str, Any]:
|
||||||
"""Return a summary of bank contents: gold, item count, slots."""
|
"""Return a summary of bank contents: gold, item count, slots."""
|
||||||
client = _get_client(request)
|
client = get_user_client(request)
|
||||||
bank_service = BankService()
|
bank_service = BankService()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|
@ -87,6 +89,16 @@ async def get_bank_summary(request: Request) -> dict[str, Any]:
|
||||||
) from exc
|
) 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")
|
@router.post("/characters/{name}/action")
|
||||||
async def manual_action(
|
async def manual_action(
|
||||||
name: str,
|
name: str,
|
||||||
|
|
@ -95,35 +107,154 @@ async def manual_action(
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Execute a manual action on a character.
|
"""Execute a manual action on a character.
|
||||||
|
|
||||||
Supported actions:
|
Supported actions and their params:
|
||||||
- **move**: Move to coordinates. Params: {"x": int, "y": int}
|
- **move**: {x: int, y: int}
|
||||||
- **fight**: Fight the monster at the current tile. No params.
|
- **fight**: no params
|
||||||
- **gather**: Gather the resource at the current tile. No params.
|
- **gather**: no params
|
||||||
- **rest**: Rest to recover HP. 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:
|
try:
|
||||||
match body.action:
|
match body.action:
|
||||||
|
# --- Basic actions ---
|
||||||
case "move":
|
case "move":
|
||||||
x = body.params.get("x")
|
_require(p, "x", "y")
|
||||||
y = body.params.get("y")
|
result = await client.move(name, int(p["x"]), int(p["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))
|
|
||||||
case "fight":
|
case "fight":
|
||||||
result = await client.fight(name)
|
result = await client.fight(name)
|
||||||
case "gather":
|
case "gather":
|
||||||
result = await client.gather(name)
|
result = await client.gather(name)
|
||||||
case "rest":
|
case "rest":
|
||||||
result = await client.rest(name)
|
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 _:
|
case _:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=400,
|
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:
|
except HTTPStatusError as exc:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,13 @@
|
||||||
from fastapi import APIRouter, HTTPException, Request
|
from fastapi import APIRouter, HTTPException, Request
|
||||||
from httpx import HTTPStatusError
|
from httpx import HTTPStatusError
|
||||||
|
|
||||||
|
from app.api.deps import get_user_client
|
||||||
from app.schemas.game import CharacterSchema
|
from app.schemas.game import CharacterSchema
|
||||||
from app.services.artifacts_client import ArtifactsClient
|
|
||||||
from app.services.character_service import CharacterService
|
from app.services.character_service import CharacterService
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/characters", tags=["characters"])
|
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:
|
def _get_service(request: Request) -> CharacterService:
|
||||||
return request.app.state.character_service
|
return request.app.state.character_service
|
||||||
|
|
||||||
|
|
@ -19,7 +15,7 @@ def _get_service(request: Request) -> CharacterService:
|
||||||
@router.get("/", response_model=list[CharacterSchema])
|
@router.get("/", response_model=list[CharacterSchema])
|
||||||
async def list_characters(request: Request) -> list[CharacterSchema]:
|
async def list_characters(request: Request) -> list[CharacterSchema]:
|
||||||
"""Return all characters belonging to the authenticated account."""
|
"""Return all characters belonging to the authenticated account."""
|
||||||
client = _get_client(request)
|
client = get_user_client(request)
|
||||||
service = _get_service(request)
|
service = _get_service(request)
|
||||||
try:
|
try:
|
||||||
return await service.get_all(client)
|
return await service.get_all(client)
|
||||||
|
|
@ -33,7 +29,7 @@ async def list_characters(request: Request) -> list[CharacterSchema]:
|
||||||
@router.get("/{name}", response_model=CharacterSchema)
|
@router.get("/{name}", response_model=CharacterSchema)
|
||||||
async def get_character(name: str, request: Request) -> CharacterSchema:
|
async def get_character(name: str, request: Request) -> CharacterSchema:
|
||||||
"""Return a single character by name."""
|
"""Return a single character by name."""
|
||||||
client = _get_client(request)
|
client = get_user_client(request)
|
||||||
service = _get_service(request)
|
service = _get_service(request)
|
||||||
try:
|
try:
|
||||||
return await service.get_one(client, name)
|
return await service.get_one(client, name)
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,8 @@ import logging
|
||||||
from fastapi import APIRouter, HTTPException, Request
|
from fastapi import APIRouter, HTTPException, Request
|
||||||
from httpx import HTTPStatusError
|
from httpx import HTTPStatusError
|
||||||
|
|
||||||
|
from app.api.deps import get_user_client
|
||||||
from app.schemas.game import DashboardData
|
from app.schemas.game import DashboardData
|
||||||
from app.services.artifacts_client import ArtifactsClient
|
|
||||||
from app.services.character_service import CharacterService
|
from app.services.character_service import CharacterService
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
@ -12,10 +12,6 @@ logger = logging.getLogger(__name__)
|
||||||
router = APIRouter(prefix="/api", tags=["dashboard"])
|
router = APIRouter(prefix="/api", tags=["dashboard"])
|
||||||
|
|
||||||
|
|
||||||
def _get_client(request: Request) -> ArtifactsClient:
|
|
||||||
return request.app.state.artifacts_client
|
|
||||||
|
|
||||||
|
|
||||||
def _get_service(request: Request) -> CharacterService:
|
def _get_service(request: Request) -> CharacterService:
|
||||||
return request.app.state.character_service
|
return request.app.state.character_service
|
||||||
|
|
||||||
|
|
@ -23,7 +19,7 @@ def _get_service(request: Request) -> CharacterService:
|
||||||
@router.get("/dashboard", response_model=DashboardData)
|
@router.get("/dashboard", response_model=DashboardData)
|
||||||
async def get_dashboard(request: Request) -> DashboardData:
|
async def get_dashboard(request: Request) -> DashboardData:
|
||||||
"""Return aggregated dashboard data: all characters + server status."""
|
"""Return aggregated dashboard data: all characters + server status."""
|
||||||
client = _get_client(request)
|
client = get_user_client(request)
|
||||||
service = _get_service(request)
|
service = _get_service(request)
|
||||||
|
|
||||||
try:
|
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 httpx import HTTPStatusError
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from app.api.deps import get_user_client
|
||||||
from app.database import async_session_factory
|
from app.database import async_session_factory
|
||||||
from app.models.event_log import EventLog
|
from app.models.event_log import EventLog
|
||||||
from app.services.artifacts_client import ArtifactsClient
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/events", tags=["events"])
|
router = APIRouter(prefix="/api/events", tags=["events"])
|
||||||
|
|
||||||
|
|
||||||
def _get_client(request: Request) -> ArtifactsClient:
|
|
||||||
return request.app.state.artifacts_client
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/")
|
@router.get("/")
|
||||||
async def get_active_events(request: Request) -> dict[str, Any]:
|
async def get_active_events(request: Request) -> dict[str, Any]:
|
||||||
"""Get currently active game events from the Artifacts API."""
|
"""Get currently active game events from the Artifacts API."""
|
||||||
client = _get_client(request)
|
client = get_user_client(request)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
events = await client.get_events()
|
events = await client.get_events()
|
||||||
|
|
|
||||||
|
|
@ -6,8 +6,8 @@ from typing import Any
|
||||||
from fastapi import APIRouter, HTTPException, Query, Request
|
from fastapi import APIRouter, HTTPException, Query, Request
|
||||||
from httpx import HTTPStatusError
|
from httpx import HTTPStatusError
|
||||||
|
|
||||||
|
from app.api.deps import get_user_client
|
||||||
from app.database import async_session_factory
|
from app.database import async_session_factory
|
||||||
from app.services.artifacts_client import ArtifactsClient
|
|
||||||
from app.services.exchange_service import ExchangeService
|
from app.services.exchange_service import ExchangeService
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
@ -15,10 +15,6 @@ logger = logging.getLogger(__name__)
|
||||||
router = APIRouter(prefix="/api/exchange", tags=["exchange"])
|
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:
|
def _get_exchange_service(request: Request) -> ExchangeService:
|
||||||
service: ExchangeService | None = getattr(request.app.state, "exchange_service", None)
|
service: ExchangeService | None = getattr(request.app.state, "exchange_service", None)
|
||||||
if service is 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)"),
|
type: str | None = Query(default=None, description="Filter by order type (sell or buy)"),
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Browse all active Grand Exchange orders (public market data)."""
|
"""Browse all active Grand Exchange orders (public market data)."""
|
||||||
client = _get_client(request)
|
client = get_user_client(request)
|
||||||
service = _get_exchange_service(request)
|
service = _get_exchange_service(request)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|
@ -53,7 +49,7 @@ async def browse_orders(
|
||||||
@router.get("/my-orders")
|
@router.get("/my-orders")
|
||||||
async def get_my_orders(request: Request) -> dict[str, Any]:
|
async def get_my_orders(request: Request) -> dict[str, Any]:
|
||||||
"""Get the authenticated account's own active GE orders."""
|
"""Get the authenticated account's own active GE orders."""
|
||||||
client = _get_client(request)
|
client = get_user_client(request)
|
||||||
service = _get_exchange_service(request)
|
service = _get_exchange_service(request)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|
@ -70,7 +66,7 @@ async def get_my_orders(request: Request) -> dict[str, Any]:
|
||||||
@router.get("/history")
|
@router.get("/history")
|
||||||
async def get_history(request: Request) -> dict[str, Any]:
|
async def get_history(request: Request) -> dict[str, Any]:
|
||||||
"""Get the authenticated account's GE transaction history."""
|
"""Get the authenticated account's GE transaction history."""
|
||||||
client = _get_client(request)
|
client = get_user_client(request)
|
||||||
service = _get_exchange_service(request)
|
service = _get_exchange_service(request)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|
@ -87,7 +83,7 @@ async def get_history(request: Request) -> dict[str, Any]:
|
||||||
@router.get("/sell-history/{item_code}")
|
@router.get("/sell-history/{item_code}")
|
||||||
async def get_sell_history(item_code: str, request: Request) -> dict[str, Any]:
|
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)."""
|
"""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)
|
service = _get_exchange_service(request)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ from typing import Any
|
||||||
from fastapi import APIRouter, Query, Request
|
from fastapi import APIRouter, Query, Request
|
||||||
from sqlalchemy import select
|
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.database import async_session_factory
|
||||||
from app.models.automation import AutomationConfig, AutomationLog, AutomationRun
|
from app.models.automation import AutomationConfig, AutomationLog, AutomationRun
|
||||||
from app.services.analytics_service import AnalyticsService
|
from app.services.analytics_service import AnalyticsService
|
||||||
|
|
@ -17,14 +18,112 @@ router = APIRouter(prefix="/api/logs", tags=["logs"])
|
||||||
|
|
||||||
@router.get("/")
|
@router.get("/")
|
||||||
async def get_logs(
|
async def get_logs(
|
||||||
|
request: Request,
|
||||||
character: str = Query(default="", description="Character name to filter logs"),
|
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]:
|
) -> 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
|
Fetches the last 5000 character actions directly from the game server.
|
||||||
to include character_name with each log entry.
|
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:
|
async with async_session_factory() as db:
|
||||||
stmt = (
|
stmt = (
|
||||||
select(
|
select(
|
||||||
|
|
@ -38,12 +137,20 @@ async def get_logs(
|
||||||
.join(AutomationRun, AutomationLog.run_id == AutomationRun.id)
|
.join(AutomationRun, AutomationLog.run_id == AutomationRun.id)
|
||||||
.join(AutomationConfig, AutomationRun.config_id == AutomationConfig.id)
|
.join(AutomationConfig, AutomationRun.config_id == AutomationConfig.id)
|
||||||
.order_by(AutomationLog.created_at.desc())
|
.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:
|
if character:
|
||||||
stmt = stmt.where(AutomationConfig.character_name == 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)
|
result = await db.execute(stmt)
|
||||||
rows = result.all()
|
rows = result.all()
|
||||||
|
|
||||||
|
|
@ -59,6 +166,9 @@ async def get_logs(
|
||||||
}
|
}
|
||||||
for row in rows
|
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.
|
"""Get analytics aggregations for a character.
|
||||||
|
|
||||||
Returns XP history, gold history, and estimated actions per hour.
|
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()
|
analytics = AnalyticsService()
|
||||||
|
user_chars = await get_user_character_names(request)
|
||||||
|
|
||||||
async with async_session_factory() as db:
|
async with async_session_factory() as db:
|
||||||
if character:
|
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]
|
characters = [character]
|
||||||
else:
|
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_xp: list[dict[str, Any]] = []
|
||||||
all_gold: 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_limit: int = 20 # data requests per window
|
||||||
data_rate_window: float = 1.0 # seconds
|
data_rate_window: float = 1.0 # seconds
|
||||||
|
|
||||||
|
# Observability
|
||||||
|
sentry_dsn: str = ""
|
||||||
|
environment: str = "development"
|
||||||
|
|
||||||
model_config = {"env_file": ".env", "extra": "ignore"}
|
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
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
from datetime import datetime, timezone
|
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||||
from sqlalchemy.orm import selectinload
|
|
||||||
|
|
||||||
from app.engine.cooldown import CooldownTracker
|
from app.engine.cooldown import CooldownTracker
|
||||||
from app.engine.pathfinder import Pathfinder
|
from app.engine.pathfinder import Pathfinder
|
||||||
from app.engine.runner import AutomationRunner
|
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.base import BaseStrategy
|
||||||
from app.engine.strategies.combat import CombatStrategy
|
from app.engine.strategies.combat import CombatStrategy
|
||||||
from app.engine.strategies.crafting import CraftingStrategy
|
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.leveling import LevelingStrategy
|
||||||
from app.engine.strategies.task import TaskStrategy
|
from app.engine.strategies.task import TaskStrategy
|
||||||
from app.engine.strategies.trading import TradingStrategy
|
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 (
|
from app.schemas.automation import (
|
||||||
AutomationLogResponse,
|
|
||||||
AutomationRunResponse,
|
AutomationRunResponse,
|
||||||
AutomationStatusResponse,
|
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
|
from app.services.artifacts_client import ArtifactsClient
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
|
|
@ -33,12 +44,13 @@ logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class AutomationManager:
|
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
|
One manager exists per application instance and is stored on
|
||||||
``app.state.automation_manager``. It holds references to all active
|
``app.state.automation_manager``. It holds references to all active
|
||||||
runners (keyed by ``config_id``) and provides high-level start / stop /
|
runners (keyed by ``config_id``) and workflow runners (keyed by
|
||||||
pause / resume operations.
|
``workflow_id``), and provides high-level start / stop / pause /
|
||||||
|
resume operations.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
|
|
@ -53,24 +65,68 @@ class AutomationManager:
|
||||||
self._pathfinder = pathfinder
|
self._pathfinder = pathfinder
|
||||||
self._event_bus = event_bus
|
self._event_bus = event_bus
|
||||||
self._runners: dict[int, AutomationRunner] = {}
|
self._runners: dict[int, AutomationRunner] = {}
|
||||||
|
self._workflow_runners: dict[int, WorkflowRunner] = {}
|
||||||
|
self._pipeline_coordinators: dict[int, PipelineCoordinator] = {}
|
||||||
self._cooldown_tracker = CooldownTracker()
|
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:
|
async def start(self, config_id: int) -> AutomationRunResponse:
|
||||||
"""Start an automation from its persisted configuration.
|
"""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
|
|
||||||
if config_id in self._runners:
|
if config_id in self._runners:
|
||||||
runner = self._runners[config_id]
|
runner = self._runners[config_id]
|
||||||
if runner.is_running or runner.is_paused:
|
if runner.is_running or runner.is_paused:
|
||||||
|
|
@ -80,17 +136,23 @@ class AutomationManager:
|
||||||
)
|
)
|
||||||
|
|
||||||
async with self._db_factory() as db:
|
async with self._db_factory() as db:
|
||||||
# Load the config
|
|
||||||
config = await db.get(AutomationConfig, config_id)
|
config = await db.get(AutomationConfig, config_id)
|
||||||
if config is None:
|
if config is None:
|
||||||
raise ValueError(f"Automation config {config_id} not found")
|
raise ValueError(f"Automation config {config_id} not found")
|
||||||
if not config.enabled:
|
if not config.enabled:
|
||||||
raise ValueError(f"Automation config {config_id} is disabled")
|
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)
|
strategy = self._create_strategy(config.strategy_type, config.config)
|
||||||
|
|
||||||
# Create run record
|
|
||||||
run = AutomationRun(
|
run = AutomationRun(
|
||||||
config_id=config_id,
|
config_id=config_id,
|
||||||
status="running",
|
status="running",
|
||||||
|
|
@ -101,7 +163,6 @@ class AutomationManager:
|
||||||
|
|
||||||
run_response = AutomationRunResponse.model_validate(run)
|
run_response = AutomationRunResponse.model_validate(run)
|
||||||
|
|
||||||
# Build and start the runner
|
|
||||||
runner = AutomationRunner(
|
runner = AutomationRunner(
|
||||||
config_id=config_id,
|
config_id=config_id,
|
||||||
character_name=config.character_name,
|
character_name=config.character_name,
|
||||||
|
|
@ -125,55 +186,31 @@ class AutomationManager:
|
||||||
return run_response
|
return run_response
|
||||||
|
|
||||||
async def stop(self, config_id: int) -> None:
|
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)
|
runner = self._runners.get(config_id)
|
||||||
if runner is None:
|
if runner is None:
|
||||||
raise ValueError(f"No active runner for config {config_id}")
|
raise ValueError(f"No active runner for config {config_id}")
|
||||||
|
|
||||||
await runner.stop()
|
await runner.stop()
|
||||||
del self._runners[config_id]
|
del self._runners[config_id]
|
||||||
logger.info("Stopped automation config=%d", config_id)
|
logger.info("Stopped automation config=%d", config_id)
|
||||||
|
|
||||||
async def pause(self, config_id: int) -> None:
|
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)
|
runner = self._runners.get(config_id)
|
||||||
if runner is None:
|
if runner is None:
|
||||||
raise ValueError(f"No active runner for config {config_id}")
|
raise ValueError(f"No active runner for config {config_id}")
|
||||||
if not runner.is_running:
|
if not runner.is_running:
|
||||||
raise ValueError(f"Runner for config {config_id} is not running (status={runner.status})")
|
raise ValueError(f"Runner for config {config_id} is not running (status={runner.status})")
|
||||||
|
|
||||||
await runner.pause()
|
await runner.pause()
|
||||||
|
|
||||||
async def resume(self, config_id: int) -> None:
|
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)
|
runner = self._runners.get(config_id)
|
||||||
if runner is None:
|
if runner is None:
|
||||||
raise ValueError(f"No active runner for config {config_id}")
|
raise ValueError(f"No active runner for config {config_id}")
|
||||||
if not runner.is_paused:
|
if not runner.is_paused:
|
||||||
raise ValueError(f"Runner for config {config_id} is not paused (status={runner.status})")
|
raise ValueError(f"Runner for config {config_id} is not paused (status={runner.status})")
|
||||||
|
|
||||||
await runner.resume()
|
await runner.resume()
|
||||||
|
|
||||||
async def stop_all(self) -> None:
|
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())
|
config_ids = list(self._runners.keys())
|
||||||
for config_id in config_ids:
|
for config_id in config_ids:
|
||||||
try:
|
try:
|
||||||
|
|
@ -181,12 +218,25 @@ class AutomationManager:
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("Error stopping automation config=%d", config_id)
|
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:
|
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)
|
runner = self._runners.get(config_id)
|
||||||
if runner is None:
|
if runner is None:
|
||||||
return None
|
return None
|
||||||
|
|
@ -200,7 +250,6 @@ class AutomationManager:
|
||||||
)
|
)
|
||||||
|
|
||||||
def get_all_statuses(self) -> list[AutomationStatusResponse]:
|
def get_all_statuses(self) -> list[AutomationStatusResponse]:
|
||||||
"""Return live status for all active automations."""
|
|
||||||
return [
|
return [
|
||||||
AutomationStatusResponse(
|
AutomationStatusResponse(
|
||||||
config_id=r.config_id,
|
config_id=r.config_id,
|
||||||
|
|
@ -214,29 +263,339 @@ class AutomationManager:
|
||||||
]
|
]
|
||||||
|
|
||||||
def is_running(self, config_id: int) -> bool:
|
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)
|
runner = self._runners.get(config_id)
|
||||||
return runner is not None and (runner.is_running or runner.is_paused)
|
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
|
# Strategy factory
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
def _create_strategy(self, strategy_type: str, config: dict) -> BaseStrategy:
|
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:
|
match strategy_type:
|
||||||
case "combat":
|
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":
|
case "gathering":
|
||||||
return GatheringStrategy(config, self._pathfinder)
|
return GatheringStrategy(
|
||||||
|
config,
|
||||||
|
self._pathfinder,
|
||||||
|
resource_selector=resource_selector,
|
||||||
|
resources_data=self._resources_cache,
|
||||||
|
)
|
||||||
case "crafting":
|
case "crafting":
|
||||||
return CraftingStrategy(config, self._pathfinder)
|
return CraftingStrategy(
|
||||||
|
config,
|
||||||
|
self._pathfinder,
|
||||||
|
items_data=self._items_cache,
|
||||||
|
resources_data=self._resources_cache,
|
||||||
|
)
|
||||||
case "trading":
|
case "trading":
|
||||||
return TradingStrategy(config, self._pathfinder)
|
return TradingStrategy(
|
||||||
|
config,
|
||||||
|
self._pathfinder,
|
||||||
|
client=self._client,
|
||||||
|
)
|
||||||
case "task":
|
case "task":
|
||||||
return TaskStrategy(config, self._pathfinder)
|
return TaskStrategy(config, self._pathfinder)
|
||||||
case "leveling":
|
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 _:
|
case _:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"Unknown strategy type: {strategy_type!r}. "
|
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 datetime import datetime, timezone
|
||||||
from typing import TYPE_CHECKING, Any
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
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.cooldown import CooldownTracker
|
||||||
from app.engine.strategies.base import ActionPlan, ActionType, BaseStrategy
|
from app.engine.strategies.base import ActionPlan, ActionType, BaseStrategy
|
||||||
from app.models.automation import AutomationLog, AutomationRun
|
from app.models.automation import AutomationLog, AutomationRun
|
||||||
from app.services.artifacts_client import ArtifactsClient
|
from app.services.artifacts_client import ArtifactsClient
|
||||||
|
from app.services.error_service import hash_token, log_error
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from app.websocket.event_bus import EventBus
|
from app.websocket.event_bus import EventBus
|
||||||
|
|
@ -235,6 +238,62 @@ class AutomationRunner:
|
||||||
self._consecutive_errors = 0
|
self._consecutive_errors = 0
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
raise
|
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:
|
except Exception as exc:
|
||||||
self._consecutive_errors += 1
|
self._consecutive_errors += 1
|
||||||
logger.exception(
|
logger.exception(
|
||||||
|
|
@ -244,6 +303,20 @@ class AutomationRunner:
|
||||||
_MAX_CONSECUTIVE_ERRORS,
|
_MAX_CONSECUTIVE_ERRORS,
|
||||||
exc,
|
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(
|
await self._log_action(
|
||||||
ActionPlan(ActionType.IDLE, reason=str(exc)),
|
ActionPlan(ActionType.IDLE, reason=str(exc)),
|
||||||
success=False,
|
success=False,
|
||||||
|
|
@ -336,96 +409,7 @@ class AutomationRunner:
|
||||||
|
|
||||||
async def _execute_action(self, plan: ActionPlan) -> dict[str, Any]:
|
async def _execute_action(self, plan: ActionPlan) -> dict[str, Any]:
|
||||||
"""Dispatch an action plan to the appropriate client method."""
|
"""Dispatch an action plan to the appropriate client method."""
|
||||||
match plan.action_type:
|
return await execute_action(self._client, self._character_name, plan)
|
||||||
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 {}
|
|
||||||
|
|
||||||
def _update_cooldown_from_result(self, result: dict[str, Any]) -> None:
|
def _update_cooldown_from_result(self, result: dict[str, Any]) -> None:
|
||||||
"""Extract cooldown information from an action response and update the tracker."""
|
"""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 abc import ABC, abstractmethod
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from app.engine.pathfinder import Pathfinder
|
from app.engine.pathfinder import Pathfinder
|
||||||
from app.schemas.game import CharacterSchema
|
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):
|
class ActionType(str, Enum):
|
||||||
"""All possible actions the automation runner can execute."""
|
"""All possible actions the automation runner can execute."""
|
||||||
|
|
@ -21,12 +28,19 @@ class ActionType(str, Enum):
|
||||||
CRAFT = "craft"
|
CRAFT = "craft"
|
||||||
RECYCLE = "recycle"
|
RECYCLE = "recycle"
|
||||||
GE_BUY = "ge_buy"
|
GE_BUY = "ge_buy"
|
||||||
|
GE_CREATE_BUY = "ge_create_buy"
|
||||||
GE_SELL = "ge_sell"
|
GE_SELL = "ge_sell"
|
||||||
|
GE_FILL = "ge_fill"
|
||||||
GE_CANCEL = "ge_cancel"
|
GE_CANCEL = "ge_cancel"
|
||||||
TASK_NEW = "task_new"
|
TASK_NEW = "task_new"
|
||||||
TASK_TRADE = "task_trade"
|
TASK_TRADE = "task_trade"
|
||||||
TASK_COMPLETE = "task_complete"
|
TASK_COMPLETE = "task_complete"
|
||||||
TASK_EXCHANGE = "task_exchange"
|
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"
|
IDLE = "idle"
|
||||||
COMPLETE = "complete"
|
COMPLETE = "complete"
|
||||||
|
|
||||||
|
|
@ -49,9 +63,18 @@ class BaseStrategy(ABC):
|
||||||
Subclasses must implement :meth:`next_action` and :meth:`get_state`.
|
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.config = config
|
||||||
self.pathfinder = pathfinder
|
self.pathfinder = pathfinder
|
||||||
|
self._equipment_optimizer = equipment_optimizer
|
||||||
|
self._available_items = available_items or []
|
||||||
|
self._auto_equip_checked = False
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
async def next_action(self, character: CharacterSchema) -> ActionPlan:
|
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:
|
def _is_at(character: CharacterSchema, x: int, y: int) -> bool:
|
||||||
"""Check whether the character is standing at the given tile."""
|
"""Check whether the character is standing at the given tile."""
|
||||||
return character.x == x and character.y == y
|
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
|
import logging
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from app.engine.pathfinder import Pathfinder
|
from app.engine.pathfinder import Pathfinder
|
||||||
from app.engine.strategies.base import ActionPlan, ActionType, BaseStrategy
|
from app.engine.strategies.base import ActionPlan, ActionType, BaseStrategy
|
||||||
from app.schemas.game import CharacterSchema
|
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__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -43,18 +51,34 @@ class CombatStrategy(BaseStrategy):
|
||||||
- deposit_loot: bool (default True)
|
- deposit_loot: bool (default True)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, config: dict, pathfinder: Pathfinder) -> None:
|
def __init__(
|
||||||
super().__init__(config, pathfinder)
|
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
|
self._state = _CombatState.MOVE_TO_MONSTER
|
||||||
|
|
||||||
# Parsed config with defaults
|
# 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_threshold: int = config.get("auto_heal_threshold", 50)
|
||||||
self._heal_method: str = config.get("heal_method", "rest")
|
self._heal_method: str = config.get("heal_method", "rest")
|
||||||
self._consumable_code: str | None = config.get("consumable_code")
|
self._consumable_code: str | None = config.get("consumable_code")
|
||||||
self._min_inv_slots: int = config.get("min_inventory_slots", 3)
|
self._min_inv_slots: int = config.get("min_inventory_slots", 3)
|
||||||
self._deposit_loot: bool = config.get("deposit_loot", True)
|
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)
|
# Cached locations (resolved lazily)
|
||||||
self._monster_pos: tuple[int, int] | None = None
|
self._monster_pos: tuple[int, int] | None = None
|
||||||
self._bank_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
|
return self._state.value
|
||||||
|
|
||||||
async def next_action(self, character: CharacterSchema) -> ActionPlan:
|
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
|
# Lazily resolve monster and bank positions
|
||||||
self._resolve_locations(character)
|
self._resolve_locations(character)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,16 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from app.engine.pathfinder import Pathfinder
|
from app.engine.pathfinder import Pathfinder
|
||||||
from app.engine.strategies.base import ActionPlan, ActionType, BaseStrategy
|
from app.engine.strategies.base import ActionPlan, ActionType, BaseStrategy
|
||||||
from app.schemas.game import CharacterSchema, ItemSchema
|
from app.schemas.game import CharacterSchema, ItemSchema
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from app.schemas.game import ResourceSchema
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -62,6 +68,7 @@ class CraftingStrategy(BaseStrategy):
|
||||||
config: dict,
|
config: dict,
|
||||||
pathfinder: Pathfinder,
|
pathfinder: Pathfinder,
|
||||||
items_data: list[ItemSchema] | None = None,
|
items_data: list[ItemSchema] | None = None,
|
||||||
|
resources_data: list[ResourceSchema] | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
super().__init__(config, pathfinder)
|
super().__init__(config, pathfinder)
|
||||||
self._state = _CraftState.CHECK_MATERIALS
|
self._state = _CraftState.CHECK_MATERIALS
|
||||||
|
|
@ -81,6 +88,9 @@ class CraftingStrategy(BaseStrategy):
|
||||||
self._craft_level: int = 0
|
self._craft_level: int = 0
|
||||||
self._recipe_resolved: bool = False
|
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 is provided, resolve the recipe immediately
|
||||||
if items_data:
|
if items_data:
|
||||||
self._resolve_recipe(items_data)
|
self._resolve_recipe(items_data)
|
||||||
|
|
@ -186,11 +196,18 @@ class CraftingStrategy(BaseStrategy):
|
||||||
# Withdraw the first missing material
|
# Withdraw the first missing material
|
||||||
code, needed_qty = next(iter(missing.items()))
|
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:
|
if self._gather_materials:
|
||||||
# We'll try to withdraw; if it fails the runner will handle the error
|
resource_code = self._find_resource_for_material(code)
|
||||||
# and we can switch to gathering mode. For now, attempt the withdraw.
|
if resource_code:
|
||||||
pass
|
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(
|
return ActionPlan(
|
||||||
ActionType.WITHDRAW_ITEM,
|
ActionType.WITHDRAW_ITEM,
|
||||||
|
|
@ -383,6 +400,14 @@ class CraftingStrategy(BaseStrategy):
|
||||||
|
|
||||||
return missing
|
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:
|
def _resolve_locations(self, character: CharacterSchema) -> None:
|
||||||
"""Lazily resolve and cache workshop and bank tile positions."""
|
"""Lazily resolve and cache workshop and bank tile positions."""
|
||||||
if self._workshop_pos is None and self._craft_skill:
|
if self._workshop_pos is None and self._craft_skill:
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,17 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from app.engine.pathfinder import Pathfinder
|
from app.engine.pathfinder import Pathfinder
|
||||||
from app.engine.strategies.base import ActionPlan, ActionType, BaseStrategy
|
from app.engine.strategies.base import ActionPlan, ActionType, BaseStrategy
|
||||||
from app.schemas.game import CharacterSchema
|
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__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -36,15 +43,25 @@ class GatheringStrategy(BaseStrategy):
|
||||||
- max_loops: int (default 0 = infinite)
|
- 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)
|
super().__init__(config, pathfinder)
|
||||||
self._state = _GatherState.MOVE_TO_RESOURCE
|
self._state = _GatherState.MOVE_TO_RESOURCE
|
||||||
|
|
||||||
# Parsed config with defaults
|
# 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._deposit_on_full: bool = config.get("deposit_on_full", True)
|
||||||
self._max_loops: int = config.get("max_loops", 0)
|
self._max_loops: int = config.get("max_loops", 0)
|
||||||
|
|
||||||
|
# Decision modules
|
||||||
|
self._resource_selector = resource_selector
|
||||||
|
self._resources_data = resources_data or []
|
||||||
|
|
||||||
# Runtime counters
|
# Runtime counters
|
||||||
self._loop_count: int = 0
|
self._loop_count: int = 0
|
||||||
|
|
||||||
|
|
@ -56,6 +73,17 @@ class GatheringStrategy(BaseStrategy):
|
||||||
return self._state.value
|
return self._state.value
|
||||||
|
|
||||||
async def next_action(self, character: CharacterSchema) -> ActionPlan:
|
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
|
# Check loop limit
|
||||||
if self._max_loops > 0 and self._loop_count >= self._max_loops:
|
if self._max_loops > 0 and self._loop_count >= self._max_loops:
|
||||||
return ActionPlan(
|
return ActionPlan(
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,19 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from app.engine.pathfinder import Pathfinder
|
from app.engine.pathfinder import Pathfinder
|
||||||
from app.engine.strategies.base import ActionPlan, ActionType, BaseStrategy
|
from app.engine.strategies.base import ActionPlan, ActionType, BaseStrategy
|
||||||
from app.schemas.game import CharacterSchema, ResourceSchema
|
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__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# All skills in the game with their gathering/crafting type
|
# All skills in the game with their gathering/crafting type
|
||||||
|
|
@ -44,8 +53,17 @@ class LevelingStrategy(BaseStrategy):
|
||||||
config: dict,
|
config: dict,
|
||||||
pathfinder: Pathfinder,
|
pathfinder: Pathfinder,
|
||||||
resources_data: list[ResourceSchema] | None = None,
|
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:
|
) -> None:
|
||||||
super().__init__(config, pathfinder)
|
super().__init__(
|
||||||
|
config, pathfinder,
|
||||||
|
equipment_optimizer=equipment_optimizer,
|
||||||
|
available_items=available_items,
|
||||||
|
)
|
||||||
self._state = _LevelingState.EVALUATE
|
self._state = _LevelingState.EVALUATE
|
||||||
|
|
||||||
# Config
|
# Config
|
||||||
|
|
@ -55,6 +73,11 @@ class LevelingStrategy(BaseStrategy):
|
||||||
|
|
||||||
# Resolved from game data
|
# Resolved from game data
|
||||||
self._resources_data: list[ResourceSchema] = resources_data or []
|
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
|
# Runtime state
|
||||||
self._chosen_skill: str = ""
|
self._chosen_skill: str = ""
|
||||||
|
|
@ -76,6 +99,11 @@ class LevelingStrategy(BaseStrategy):
|
||||||
async def next_action(self, character: CharacterSchema) -> ActionPlan:
|
async def next_action(self, character: CharacterSchema) -> ActionPlan:
|
||||||
self._resolve_bank(character)
|
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:
|
match self._state:
|
||||||
case _LevelingState.EVALUATE:
|
case _LevelingState.EVALUATE:
|
||||||
return self._handle_evaluate(character)
|
return self._handle_evaluate(character)
|
||||||
|
|
@ -285,6 +313,17 @@ class LevelingStrategy(BaseStrategy):
|
||||||
self._target_pos = None
|
self._target_pos = None
|
||||||
return self._handle_evaluate(character)
|
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
|
# Skill analysis helpers
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
@ -322,27 +361,35 @@ class LevelingStrategy(BaseStrategy):
|
||||||
skill_level: int,
|
skill_level: int,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Choose the best resource to gather for a given skill and level."""
|
"""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]
|
matching = [r for r in self._resources_data if r.skill == skill]
|
||||||
if not matching:
|
if not matching:
|
||||||
# Fallback: use pathfinder to find any resource of this skill
|
|
||||||
self._target_pos = self.pathfinder.find_nearest_by_type(
|
self._target_pos = self.pathfinder.find_nearest_by_type(
|
||||||
character.x, character.y, "resource"
|
character.x, character.y, "resource"
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
# Find the best resource within +-3 levels
|
|
||||||
candidates = []
|
candidates = []
|
||||||
for r in matching:
|
for r in matching:
|
||||||
diff = r.level - skill_level
|
diff = r.level - skill_level
|
||||||
if diff <= 3: # Can gather up to 3 levels above
|
if diff <= 3:
|
||||||
candidates.append(r)
|
candidates.append(r)
|
||||||
|
|
||||||
if not candidates:
|
if not candidates:
|
||||||
# No resources within range, pick the lowest level one
|
|
||||||
candidates = matching
|
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)
|
best = max(candidates, key=lambda r: r.level if r.level <= skill_level + 3 else -r.level)
|
||||||
self._chosen_resource_code = best.code
|
self._chosen_resource_code = best.code
|
||||||
|
|
||||||
|
|
@ -352,7 +399,17 @@ class LevelingStrategy(BaseStrategy):
|
||||||
|
|
||||||
def _choose_combat_target(self, character: CharacterSchema) -> None:
|
def _choose_combat_target(self, character: CharacterSchema) -> None:
|
||||||
"""Choose a monster appropriate for the character's combat level."""
|
"""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._chosen_monster_code = ""
|
||||||
self._target_pos = self.pathfinder.find_nearest_by_type(
|
self._target_pos = self.pathfinder.find_nearest_by_type(
|
||||||
character.x, character.y, "monster"
|
character.x, character.y, "monster"
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,16 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from app.engine.pathfinder import Pathfinder
|
from app.engine.pathfinder import Pathfinder
|
||||||
from app.engine.strategies.base import ActionPlan, ActionType, BaseStrategy
|
from app.engine.strategies.base import ActionPlan, ActionType, BaseStrategy
|
||||||
from app.schemas.game import CharacterSchema
|
from app.schemas.game import CharacterSchema
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from app.services.artifacts_client import ArtifactsClient
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -22,10 +28,6 @@ class _TradingState(str, Enum):
|
||||||
DEPOSIT_ITEMS = "deposit_items"
|
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):
|
class _TradingMode(str, Enum):
|
||||||
SELL_LOOT = "sell_loot"
|
SELL_LOOT = "sell_loot"
|
||||||
BUY_MATERIALS = "buy_materials"
|
BUY_MATERIALS = "buy_materials"
|
||||||
|
|
@ -49,7 +51,12 @@ class TradingStrategy(BaseStrategy):
|
||||||
- max_price: int (default 0) -- maximum acceptable price (0 = no limit)
|
- 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)
|
super().__init__(config, pathfinder)
|
||||||
|
|
||||||
# Parse config
|
# Parse config
|
||||||
|
|
@ -65,6 +72,9 @@ class TradingStrategy(BaseStrategy):
|
||||||
self._min_price: int = config.get("min_price", 0)
|
self._min_price: int = config.get("min_price", 0)
|
||||||
self._max_price: int = config.get("max_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
|
# Determine initial state based on mode
|
||||||
if self._mode == _TradingMode.SELL_LOOT:
|
if self._mode == _TradingMode.SELL_LOOT:
|
||||||
self._state = _TradingState.MOVE_TO_BANK
|
self._state = _TradingState.MOVE_TO_BANK
|
||||||
|
|
@ -78,6 +88,7 @@ class TradingStrategy(BaseStrategy):
|
||||||
# Runtime state
|
# Runtime state
|
||||||
self._items_withdrawn: int = 0
|
self._items_withdrawn: int = 0
|
||||||
self._orders_created: bool = False
|
self._orders_created: bool = False
|
||||||
|
self._active_order_id: str | None = None
|
||||||
self._wait_cycles: int = 0
|
self._wait_cycles: int = 0
|
||||||
|
|
||||||
# Cached positions
|
# Cached positions
|
||||||
|
|
@ -227,7 +238,7 @@ class TradingStrategy(BaseStrategy):
|
||||||
self._state = _TradingState.WAIT_FOR_ORDER
|
self._state = _TradingState.WAIT_FOR_ORDER
|
||||||
|
|
||||||
return ActionPlan(
|
return ActionPlan(
|
||||||
ActionType.GE_BUY,
|
ActionType.GE_CREATE_BUY,
|
||||||
params={
|
params={
|
||||||
"code": self._item_code,
|
"code": self._item_code,
|
||||||
"quantity": self._quantity,
|
"quantity": self._quantity,
|
||||||
|
|
@ -239,26 +250,38 @@ class TradingStrategy(BaseStrategy):
|
||||||
def _handle_wait_for_order(self, character: CharacterSchema) -> ActionPlan:
|
def _handle_wait_for_order(self, character: CharacterSchema) -> ActionPlan:
|
||||||
self._wait_cycles += 1
|
self._wait_cycles += 1
|
||||||
|
|
||||||
# Wait for a reasonable time, then check
|
# Poll every 3 cycles to avoid API spam
|
||||||
if self._wait_cycles < 3:
|
if self._wait_cycles % 3 != 0:
|
||||||
return ActionPlan(
|
return ActionPlan(
|
||||||
ActionType.IDLE,
|
ActionType.IDLE,
|
||||||
reason=f"Waiting for GE order to fill (cycle {self._wait_cycles})",
|
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
|
self._state = _TradingState.CHECK_ORDERS
|
||||||
return self._handle_check_orders(character)
|
return self._handle_check_orders(character)
|
||||||
|
|
||||||
def _handle_check_orders(self, character: CharacterSchema) -> ActionPlan:
|
def _handle_check_orders(self, character: CharacterSchema) -> ActionPlan:
|
||||||
# For now, just complete after creating orders
|
# If we have a client and an order ID, poll the actual order status
|
||||||
# In a full implementation, we'd check the GE order status
|
# This is an async check, but since next_action is async we handle it
|
||||||
if self._mode == _TradingMode.FLIP and self._orders_created:
|
# by transitioning: the runner will call next_action again next tick
|
||||||
# For flip mode, once buy order is done, create sell
|
if self._active_order_id and self._client:
|
||||||
self._state = _TradingState.CREATE_SELL_ORDER
|
# 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(
|
return ActionPlan(
|
||||||
ActionType.IDLE,
|
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(
|
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 asyncio
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import sys
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from collections.abc import AsyncGenerator
|
from collections.abc import AsyncGenerator
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
||||||
from app.config import settings
|
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.database import async_session_factory, engine, Base
|
||||||
from app.services.artifacts_client import ArtifactsClient
|
from app.services.artifacts_client import ArtifactsClient
|
||||||
from app.services.character_service import CharacterService
|
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 game_cache as _game_cache_model # noqa: F401
|
||||||
from app.models import character_snapshot as _snapshot_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 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 price_history as _price_history_model # noqa: F401
|
||||||
from app.models import event_log as _event_log_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
|
# Import routers
|
||||||
from app.api.characters import router as characters_router
|
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.events import router as events_router
|
||||||
from app.api.logs import router as logs_router
|
from app.api.logs import router as logs_router
|
||||||
from app.api.auth import router as auth_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
|
# Automation engine
|
||||||
from app.engine.pathfinder import Pathfinder
|
from app.engine.pathfinder import Pathfinder
|
||||||
from app.engine.manager import AutomationManager
|
from app.engine.manager import AutomationManager
|
||||||
|
|
||||||
|
# Error-handling middleware
|
||||||
|
from app.middleware.error_handler import ErrorHandlerMiddleware
|
||||||
|
|
||||||
# Exchange service
|
# Exchange service
|
||||||
from app.services.exchange_service import ExchangeService
|
from app.services.exchange_service import ExchangeService
|
||||||
|
|
||||||
|
|
@ -45,10 +71,29 @@ from app.websocket.handlers import GameEventHandler
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
logging.basicConfig(
|
|
||||||
level=logging.INFO,
|
class _JSONFormatter(logging.Formatter):
|
||||||
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
"""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(
|
async def _snapshot_loop(
|
||||||
|
|
@ -218,6 +263,7 @@ app = FastAPI(
|
||||||
lifespan=lifespan,
|
lifespan=lifespan,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
app.add_middleware(ErrorHandlerMiddleware)
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
allow_origins=settings.cors_origins,
|
allow_origins=settings.cors_origins,
|
||||||
|
|
@ -237,6 +283,9 @@ app.include_router(exchange_router)
|
||||||
app.include_router(events_router)
|
app.include_router(events_router)
|
||||||
app.include_router(logs_router)
|
app.include_router(logs_router)
|
||||||
app.include_router(auth_router)
|
app.include_router(auth_router)
|
||||||
|
app.include_router(workflows_router)
|
||||||
|
app.include_router(errors_router)
|
||||||
|
app.include_router(pipelines_router)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/health")
|
@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.automation import AutomationConfig, AutomationLog, AutomationRun
|
||||||
from app.models.character_snapshot import CharacterSnapshot
|
from app.models.character_snapshot import CharacterSnapshot
|
||||||
from app.models.event_log import EventLog
|
from app.models.event_log import EventLog
|
||||||
from app.models.game_cache import GameDataCache
|
from app.models.game_cache import GameDataCache
|
||||||
|
from app.models.pipeline import PipelineConfig, PipelineRun
|
||||||
from app.models.price_history import PriceHistory
|
from app.models.price_history import PriceHistory
|
||||||
|
from app.models.workflow import WorkflowConfig, WorkflowRun
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
|
"AppError",
|
||||||
"AutomationConfig",
|
"AutomationConfig",
|
||||||
"AutomationLog",
|
"AutomationLog",
|
||||||
"AutomationRun",
|
"AutomationRun",
|
||||||
"CharacterSnapshot",
|
"CharacterSnapshot",
|
||||||
"EventLog",
|
"EventLog",
|
||||||
"GameDataCache",
|
"GameDataCache",
|
||||||
|
"PipelineConfig",
|
||||||
|
"PipelineRun",
|
||||||
"PriceHistory",
|
"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.
|
"""Async HTTP client for the Artifacts MMO API.
|
||||||
|
|
||||||
Handles authentication, rate limiting, pagination, and retry logic.
|
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
|
MAX_RETRIES: int = 3
|
||||||
|
|
@ -63,7 +66,6 @@ class ArtifactsClient:
|
||||||
self._client = httpx.AsyncClient(
|
self._client = httpx.AsyncClient(
|
||||||
base_url=settings.artifacts_api_url,
|
base_url=settings.artifacts_api_url,
|
||||||
headers={
|
headers={
|
||||||
"Authorization": f"Bearer {self._token}",
|
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
"Accept": "application/json",
|
"Accept": "application/json",
|
||||||
},
|
},
|
||||||
|
|
@ -78,6 +80,21 @@ class ArtifactsClient:
|
||||||
window_seconds=settings.data_rate_window,
|
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
|
@property
|
||||||
def has_token(self) -> bool:
|
def has_token(self) -> bool:
|
||||||
return bool(self._token)
|
return bool(self._token)
|
||||||
|
|
@ -91,14 +108,12 @@ class ArtifactsClient:
|
||||||
return "user"
|
return "user"
|
||||||
|
|
||||||
def set_token(self, token: str) -> None:
|
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._token = token
|
||||||
self._client.headers["Authorization"] = f"Bearer {token}"
|
|
||||||
|
|
||||||
def clear_token(self) -> None:
|
def clear_token(self) -> None:
|
||||||
"""Revert to the env token (or empty if none)."""
|
"""Revert to the env token (or empty if none)."""
|
||||||
self._token = settings.artifacts_token
|
self._token = settings.artifacts_token
|
||||||
self._client.headers["Authorization"] = f"Bearer {self._token}"
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# Low-level request helpers
|
# Low-level request helpers
|
||||||
|
|
@ -115,6 +130,10 @@ class ArtifactsClient:
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
last_exc: Exception | None = None
|
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):
|
for attempt in range(1, self.MAX_RETRIES + 1):
|
||||||
await limiter.acquire()
|
await limiter.acquire()
|
||||||
try:
|
try:
|
||||||
|
|
@ -123,6 +142,7 @@ class ArtifactsClient:
|
||||||
path,
|
path,
|
||||||
json=json_body,
|
json=json_body,
|
||||||
params=params,
|
params=params,
|
||||||
|
headers=auth_headers,
|
||||||
)
|
)
|
||||||
|
|
||||||
if response.status_code == 429:
|
if response.status_code == 429:
|
||||||
|
|
@ -136,6 +156,27 @@ class ArtifactsClient:
|
||||||
await asyncio.sleep(retry_after)
|
await asyncio.sleep(retry_after)
|
||||||
continue
|
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:
|
if response.status_code >= 500:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"Server error %d on %s %s (attempt %d/%d)",
|
"Server error %d on %s %s (attempt %d/%d)",
|
||||||
|
|
@ -428,11 +469,11 @@ class ArtifactsClient:
|
||||||
return result.get("data", {})
|
return result.get("data", {})
|
||||||
|
|
||||||
async def ge_buy(
|
async def ge_buy(
|
||||||
self, name: str, code: str, quantity: int, price: int
|
self, name: str, order_id: str, quantity: int
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
result = await self._post_action(
|
result = await self._post_action(
|
||||||
f"/my/{name}/action/grandexchange/buy",
|
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", {})
|
return result.get("data", {})
|
||||||
|
|
||||||
|
|
@ -440,20 +481,29 @@ class ArtifactsClient:
|
||||||
self, name: str, code: str, quantity: int, price: int
|
self, name: str, code: str, quantity: int, price: int
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
result = await self._post_action(
|
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},
|
json_body={"code": code, "quantity": quantity, "price": price},
|
||||||
)
|
)
|
||||||
return result.get("data", {})
|
return result.get("data", {})
|
||||||
|
|
||||||
async def ge_buy_order(
|
async def ge_create_buy_order(
|
||||||
self, name: str, code: str, quantity: int, price: int
|
self, name: str, code: str, quantity: int, price: int
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
result = await self._post_action(
|
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},
|
json_body={"code": code, "quantity": quantity, "price": price},
|
||||||
)
|
)
|
||||||
return result.get("data", {})
|
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]:
|
async def ge_cancel(self, name: str, order_id: str) -> dict[str, Any]:
|
||||||
result = await self._post_action(
|
result = await self._post_action(
|
||||||
f"/my/{name}/action/grandexchange/cancel",
|
f"/my/{name}/action/grandexchange/cancel",
|
||||||
|
|
@ -500,6 +550,27 @@ class ArtifactsClient:
|
||||||
)
|
)
|
||||||
return result.get("data", {})
|
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
|
# 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>=2.10.0",
|
||||||
"pydantic-settings>=2.7.0",
|
"pydantic-settings>=2.7.0",
|
||||||
"websockets>=14.0",
|
"websockets>=14.0",
|
||||||
|
"sentry-sdk[fastapi]>=2.0.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
|
|
|
||||||
|
|
@ -97,8 +97,9 @@ class TestActionType:
|
||||||
expected = {
|
expected = {
|
||||||
"move", "fight", "gather", "rest", "equip", "unequip",
|
"move", "fight", "gather", "rest", "equip", "unequip",
|
||||||
"use_item", "deposit_item", "withdraw_item", "craft", "recycle",
|
"use_item", "deposit_item", "withdraw_item", "craft", "recycle",
|
||||||
"ge_buy", "ge_sell", "ge_cancel",
|
"ge_buy", "ge_create_buy", "ge_sell", "ge_fill", "ge_cancel",
|
||||||
"task_new", "task_trade", "task_complete", "task_exchange",
|
"task_new", "task_trade", "task_complete", "task_exchange", "task_cancel",
|
||||||
|
"deposit_gold", "withdraw_gold", "npc_buy", "npc_sell",
|
||||||
"idle", "complete",
|
"idle", "complete",
|
||||||
}
|
}
|
||||||
actual = {at.value for at in ActionType}
|
actual = {at.value for at in ActionType}
|
||||||
|
|
|
||||||
|
|
@ -250,6 +250,30 @@ class TestCraftingStrategyDeposit:
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_deposit_items_at_bank(self, make_character, pathfinder_with_maps):
|
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([
|
pf = pathfinder_with_maps([
|
||||||
(5, 5, "workshop", "weaponcrafting"),
|
(5, 5, "workshop", "weaponcrafting"),
|
||||||
(10, 0, "bank", "bank"),
|
(10, 0, "bank", "bank"),
|
||||||
|
|
@ -261,7 +285,7 @@ class TestCraftingStrategyDeposit:
|
||||||
items_data=[item],
|
items_data=[item],
|
||||||
)
|
)
|
||||||
strategy._state = strategy._state.__class__("deposit")
|
strategy._state = strategy._state.__class__("deposit")
|
||||||
strategy._crafted_count = 1
|
strategy._crafted_count = 1 # Already crafted target quantity
|
||||||
|
|
||||||
char = make_character(
|
char = make_character(
|
||||||
x=10, y=0,
|
x=10, y=0,
|
||||||
|
|
@ -269,8 +293,8 @@ class TestCraftingStrategyDeposit:
|
||||||
)
|
)
|
||||||
|
|
||||||
plan = await strategy.next_action(char)
|
plan = await strategy.next_action(char)
|
||||||
assert plan.action_type == ActionType.DEPOSIT_ITEM
|
# With crafted_count >= quantity, the top-level check returns COMPLETE
|
||||||
assert plan.params["code"] == "iron_sword"
|
assert plan.action_type == ActionType.COMPLETE
|
||||||
|
|
||||||
|
|
||||||
class TestCraftingStrategyNoLocations:
|
class TestCraftingStrategyNoLocations:
|
||||||
|
|
|
||||||
|
|
@ -91,13 +91,34 @@ class TestLevelingStrategyEvaluation:
|
||||||
assert plan.action_type == ActionType.COMPLETE
|
assert plan.action_type == ActionType.COMPLETE
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@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([
|
pf = pathfinder_with_maps([
|
||||||
(10, 0, "bank", "bank"),
|
(10, 0, "bank", "bank"),
|
||||||
])
|
])
|
||||||
strategy = LevelingStrategy({}, pf)
|
strategy = LevelingStrategy({"max_level": 5}, pf)
|
||||||
# All skills at max_level with exclude set
|
|
||||||
strategy._max_level = 5
|
|
||||||
char = make_character(
|
char = make_character(
|
||||||
x=0, y=0,
|
x=0, y=0,
|
||||||
mining_level=999,
|
mining_level=999,
|
||||||
|
|
@ -106,8 +127,7 @@ class TestLevelingStrategyEvaluation:
|
||||||
)
|
)
|
||||||
|
|
||||||
plan = await strategy.next_action(char)
|
plan = await strategy.next_action(char)
|
||||||
# Should complete since all skills are above max_level
|
assert plan.action_type == ActionType.IDLE
|
||||||
assert plan.action_type == ActionType.COMPLETE
|
|
||||||
|
|
||||||
|
|
||||||
class TestLevelingStrategyGathering:
|
class TestLevelingStrategyGathering:
|
||||||
|
|
@ -163,20 +183,24 @@ class TestLevelingStrategyCombat:
|
||||||
"""Tests for combat leveling."""
|
"""Tests for combat leveling."""
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@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([
|
pf = pathfinder_with_maps([
|
||||||
(3, 3, "monster", "chicken"),
|
(3, 3, "monster", "chicken"),
|
||||||
(10, 0, "bank", "bank"),
|
(10, 0, "bank", "bank"),
|
||||||
])
|
])
|
||||||
strategy = LevelingStrategy({"target_skill": "combat"}, pf)
|
strategy = LevelingStrategy({"target_skill": "combat"}, pf)
|
||||||
char = make_character(
|
char = make_character(
|
||||||
x=3, y=3,
|
x=0, y=0,
|
||||||
hp=100, max_hp=100,
|
hp=100, max_hp=100,
|
||||||
level=5,
|
level=5,
|
||||||
|
inventory_max_items=20,
|
||||||
)
|
)
|
||||||
|
|
||||||
plan = await strategy.next_action(char)
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_heal_during_combat(self, make_character, pathfinder_with_maps):
|
async def test_heal_during_combat(self, make_character, pathfinder_with_maps):
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import type { NextConfig } from "next";
|
import type { NextConfig } from "next";
|
||||||
|
import { withSentryConfig } from "@sentry/nextjs";
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
turbopack: {
|
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"
|
"type-check": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@sentry/nextjs": "^9.47.1",
|
||||||
"@tanstack/react-query": "^5.90.21",
|
"@tanstack/react-query": "^5.90.21",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.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,
|
Loader2,
|
||||||
LayoutGrid,
|
LayoutGrid,
|
||||||
List,
|
List,
|
||||||
|
GitBranch,
|
||||||
|
Network,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card } from "@/components/ui/card";
|
import { Card } from "@/components/ui/card";
|
||||||
|
|
@ -37,8 +39,14 @@ import {
|
||||||
useAutomationStatuses,
|
useAutomationStatuses,
|
||||||
useDeleteAutomation,
|
useDeleteAutomation,
|
||||||
} from "@/hooks/use-automations";
|
} from "@/hooks/use-automations";
|
||||||
|
import { useWorkflows } from "@/hooks/use-workflows";
|
||||||
|
import { usePipelines } from "@/hooks/use-pipelines";
|
||||||
import { RunControls } from "@/components/automation/run-controls";
|
import { RunControls } from "@/components/automation/run-controls";
|
||||||
import { AutomationGallery } from "@/components/automation/automation-gallery";
|
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 { toast } from "sonner";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
|
@ -64,6 +72,8 @@ export default function AutomationsPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { data: automations, isLoading, error } = useAutomations();
|
const { data: automations, isLoading, error } = useAutomations();
|
||||||
const { data: statuses } = useAutomationStatuses();
|
const { data: statuses } = useAutomationStatuses();
|
||||||
|
const { data: workflows } = useWorkflows();
|
||||||
|
const { data: pipelines } = usePipelines();
|
||||||
const deleteMutation = useDeleteAutomation();
|
const deleteMutation = useDeleteAutomation();
|
||||||
const [deleteTarget, setDeleteTarget] = useState<{
|
const [deleteTarget, setDeleteTarget] = useState<{
|
||||||
id: number;
|
id: number;
|
||||||
|
|
@ -98,11 +108,27 @@ export default function AutomationsPage() {
|
||||||
Manage automated strategies for your characters
|
Manage automated strategies for your characters
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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")}>
|
<Button onClick={() => router.push("/automations/new")}>
|
||||||
<Plus className="size-4" />
|
<Plus className="size-4" />
|
||||||
New Automation
|
New Automation
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Tabs defaultValue="gallery">
|
<Tabs defaultValue="gallery">
|
||||||
<TabsList>
|
<TabsList>
|
||||||
|
|
@ -119,10 +145,30 @@ export default function AutomationsPage() {
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</TabsTrigger>
|
</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>
|
</TabsList>
|
||||||
|
|
||||||
<TabsContent value="gallery" className="mt-6">
|
<TabsContent value="gallery" className="mt-6 space-y-8">
|
||||||
<AutomationGallery />
|
<AutomationGallery />
|
||||||
|
<WorkflowTemplateGallery />
|
||||||
|
<PipelineTemplateGallery />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="active" className="mt-6">
|
<TabsContent value="active" className="mt-6">
|
||||||
|
|
@ -231,6 +277,14 @@ export default function AutomationsPage() {
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="workflows" className="mt-6">
|
||||||
|
<WorkflowList />
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="pipelines" className="mt-6">
|
||||||
|
<PipelineList />
|
||||||
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
<Dialog
|
<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";
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect } from "react";
|
||||||
import { AlertTriangle, RotateCcw } from "lucide-react";
|
import { AlertTriangle, RotateCcw } from "lucide-react";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { reportError } from "@/lib/api-client";
|
||||||
|
|
||||||
export default function Error({
|
export default function Error({
|
||||||
error,
|
error,
|
||||||
|
|
@ -11,6 +13,20 @@ export default function Error({
|
||||||
error: Error & { digest?: string };
|
error: Error & { digest?: string };
|
||||||
reset: () => void;
|
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 (
|
return (
|
||||||
<div className="flex items-center justify-center h-full p-6">
|
<div className="flex items-center justify-center h-full p-6">
|
||||||
<Card className="max-w-md w-full">
|
<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,
|
History,
|
||||||
TrendingUp,
|
TrendingUp,
|
||||||
User,
|
User,
|
||||||
|
Plus,
|
||||||
|
X,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
import {
|
import {
|
||||||
Tabs,
|
Tabs,
|
||||||
TabsContent,
|
TabsContent,
|
||||||
|
|
@ -28,14 +31,27 @@ import {
|
||||||
TableHeader,
|
TableHeader,
|
||||||
TableRow,
|
TableRow,
|
||||||
} from "@/components/ui/table";
|
} from "@/components/ui/table";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
import {
|
import {
|
||||||
useExchangeOrders,
|
useExchangeOrders,
|
||||||
useMyOrders,
|
useMyOrders,
|
||||||
useExchangeHistory,
|
useExchangeHistory,
|
||||||
usePriceHistory,
|
usePriceHistory,
|
||||||
} from "@/hooks/use-exchange";
|
} 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 { PriceChart } from "@/components/exchange/price-chart";
|
||||||
|
import { BuyEquipDialog } from "@/components/exchange/buy-equip-dialog";
|
||||||
import { GameIcon } from "@/components/ui/game-icon";
|
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";
|
import type { GEOrder, GEHistoryEntry } from "@/lib/types";
|
||||||
|
|
||||||
function formatDate(dateStr: string): string {
|
function formatDate(dateStr: string): string {
|
||||||
|
|
@ -54,12 +70,14 @@ function OrdersTable({
|
||||||
search,
|
search,
|
||||||
emptyMessage,
|
emptyMessage,
|
||||||
showAccount,
|
showAccount,
|
||||||
|
onBuy,
|
||||||
}: {
|
}: {
|
||||||
orders: GEOrder[];
|
orders: GEOrder[];
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
search: string;
|
search: string;
|
||||||
emptyMessage: string;
|
emptyMessage: string;
|
||||||
showAccount?: boolean;
|
showAccount?: boolean;
|
||||||
|
onBuy?: (order: GEOrder) => void;
|
||||||
}) {
|
}) {
|
||||||
const filtered = useMemo(() => {
|
const filtered = useMemo(() => {
|
||||||
if (!search.trim()) return orders;
|
if (!search.trim()) return orders;
|
||||||
|
|
@ -94,6 +112,7 @@ function OrdersTable({
|
||||||
<TableHead className="text-right">Quantity</TableHead>
|
<TableHead className="text-right">Quantity</TableHead>
|
||||||
{showAccount && <TableHead>Account</TableHead>}
|
{showAccount && <TableHead>Account</TableHead>}
|
||||||
<TableHead className="text-right">Created</TableHead>
|
<TableHead className="text-right">Created</TableHead>
|
||||||
|
{onBuy && <TableHead className="text-right">Actions</TableHead>}
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
|
|
@ -125,12 +144,124 @@ function OrdersTable({
|
||||||
</TableCell>
|
</TableCell>
|
||||||
{showAccount && (
|
{showAccount && (
|
||||||
<TableCell className="text-muted-foreground text-sm">
|
<TableCell className="text-muted-foreground text-sm">
|
||||||
{order.account ?? "—"}
|
{order.account ?? "\u2014"}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
)}
|
)}
|
||||||
<TableCell className="text-right text-muted-foreground text-sm">
|
<TableCell className="text-right text-muted-foreground text-sm">
|
||||||
{formatDate(order.created_at)}
|
{formatDate(order.created_at)}
|
||||||
</TableCell>
|
</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>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
|
|
@ -211,10 +342,26 @@ export default function ExchangePage() {
|
||||||
const { data: orders, isLoading: loadingOrders, error: ordersError } = useExchangeOrders();
|
const { data: orders, isLoading: loadingOrders, error: ordersError } = useExchangeOrders();
|
||||||
const { data: myOrders, isLoading: loadingMyOrders } = useMyOrders();
|
const { data: myOrders, isLoading: loadingMyOrders } = useMyOrders();
|
||||||
const { data: history, isLoading: loadingHistory } = useExchangeHistory();
|
const { data: history, isLoading: loadingHistory } = useExchangeHistory();
|
||||||
|
const { data: characters } = useCharacters();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const { data: items } = useItems();
|
||||||
|
|
||||||
const [marketSearch, setMarketSearch] = useState("");
|
const [marketSearch, setMarketSearch] = useState("");
|
||||||
const [priceItemCode, setPriceItemCode] = useState("");
|
const [priceItemCode, setPriceItemCode] = useState("");
|
||||||
const [searchedItem, setSearchedItem] = 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 {
|
const {
|
||||||
data: priceData,
|
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 (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold tracking-tight text-foreground">
|
<h1 className="text-2xl font-bold tracking-tight text-foreground">
|
||||||
Grand Exchange
|
Grand Exchange
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-sm text-muted-foreground mt-1">
|
<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>
|
</p>
|
||||||
</div>
|
</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 && (
|
{ordersError && (
|
||||||
<Card className="border-destructive bg-destructive/10 p-4">
|
<Card className="border-destructive bg-destructive/10 p-4">
|
||||||
<p className="text-sm text-destructive">
|
<p className="text-sm text-destructive">
|
||||||
|
|
@ -246,8 +470,12 @@ export default function ExchangePage() {
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Tabs defaultValue="market">
|
<Tabs defaultValue="trade">
|
||||||
<TabsList>
|
<TabsList>
|
||||||
|
<TabsTrigger value="trade" className="gap-1.5">
|
||||||
|
<Plus className="size-4" />
|
||||||
|
Trade
|
||||||
|
</TabsTrigger>
|
||||||
<TabsTrigger value="market" className="gap-1.5">
|
<TabsTrigger value="market" className="gap-1.5">
|
||||||
<ShoppingCart className="size-4" />
|
<ShoppingCart className="size-4" />
|
||||||
Market
|
Market
|
||||||
|
|
@ -255,17 +483,174 @@ export default function ExchangePage() {
|
||||||
<TabsTrigger value="my-orders" className="gap-1.5">
|
<TabsTrigger value="my-orders" className="gap-1.5">
|
||||||
<User className="size-4" />
|
<User className="size-4" />
|
||||||
My Orders
|
My Orders
|
||||||
|
{myOrders && myOrders.length > 0 && (
|
||||||
|
<Badge variant="secondary" className="ml-1 text-[10px] px-1.5 py-0">
|
||||||
|
{myOrders.length}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger value="history" className="gap-1.5">
|
<TabsTrigger value="history" className="gap-1.5">
|
||||||
<History className="size-4" />
|
<History className="size-4" />
|
||||||
Trade History
|
History
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger value="prices" className="gap-1.5">
|
<TabsTrigger value="prices" className="gap-1.5">
|
||||||
<TrendingUp className="size-4" />
|
<TrendingUp className="size-4" />
|
||||||
Price History
|
Prices
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
</TabsList>
|
</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 */}
|
{/* Market Tab - Browse all public orders */}
|
||||||
<TabsContent value="market" className="space-y-4">
|
<TabsContent value="market" className="space-y-4">
|
||||||
<div className="relative max-w-sm">
|
<div className="relative max-w-sm">
|
||||||
|
|
@ -278,12 +663,21 @@ export default function ExchangePage() {
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
<Card>
|
||||||
<OrdersTable
|
<OrdersTable
|
||||||
orders={orders ?? []}
|
orders={orders ?? []}
|
||||||
isLoading={loadingOrders}
|
isLoading={loadingOrders}
|
||||||
search={marketSearch}
|
search={marketSearch}
|
||||||
showAccount
|
showAccount
|
||||||
|
onBuy={selectedCharacter ? (order) => setBuyOrder(order) : undefined}
|
||||||
emptyMessage={
|
emptyMessage={
|
||||||
marketSearch.trim()
|
marketSearch.trim()
|
||||||
? `No orders found for "${marketSearch}"`
|
? `No orders found for "${marketSearch}"`
|
||||||
|
|
@ -293,19 +687,27 @@ export default function ExchangePage() {
|
||||||
</Card>
|
</Card>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
{/* My Orders Tab - User's own active orders */}
|
{/* My Orders Tab - with cancel buttons */}
|
||||||
<TabsContent value="my-orders" className="space-y-4">
|
<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>
|
<Card>
|
||||||
<OrdersTable
|
<MyOrdersTable
|
||||||
orders={myOrders ?? []}
|
orders={myOrders ?? []}
|
||||||
isLoading={loadingMyOrders}
|
isLoading={loadingMyOrders}
|
||||||
search=""
|
selectedCharacter={selectedCharacter}
|
||||||
emptyMessage="You have no active orders on the Grand Exchange."
|
onCancel={handleCancelOrder}
|
||||||
|
cancelPending={cancelPending}
|
||||||
/>
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
{/* Trade History Tab - User's transaction history */}
|
{/* Trade History Tab */}
|
||||||
<TabsContent value="history" className="space-y-4">
|
<TabsContent value="history" className="space-y-4">
|
||||||
<Card>
|
<Card>
|
||||||
<HistoryTable
|
<HistoryTable
|
||||||
|
|
@ -361,6 +763,13 @@ export default function ExchangePage() {
|
||||||
)}
|
)}
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
|
<BuyEquipDialog
|
||||||
|
order={buyOrder}
|
||||||
|
characterName={selectedCharacter}
|
||||||
|
items={items}
|
||||||
|
onOpenChange={(open) => !open && setBuyOrder(null)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,13 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useMemo, useState } from "react";
|
import { useState } from "react";
|
||||||
import {
|
import {
|
||||||
ScrollText,
|
ScrollText,
|
||||||
Loader2,
|
Loader2,
|
||||||
CheckCircle,
|
CheckCircle,
|
||||||
XCircle,
|
XCircle,
|
||||||
ChevronDown,
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Card } from "@/components/ui/card";
|
import { Card } from "@/components/ui/card";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
|
@ -30,20 +31,28 @@ import { useCharacters } from "@/hooks/use-characters";
|
||||||
import { useLogs } from "@/hooks/use-analytics";
|
import { useLogs } from "@/hooks/use-analytics";
|
||||||
import type { ActionLog } from "@/lib/types";
|
import type { ActionLog } from "@/lib/types";
|
||||||
|
|
||||||
|
const PAGE_SIZE = 50;
|
||||||
|
|
||||||
const ACTION_TYPE_COLORS: Record<string, string> = {
|
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",
|
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",
|
rest: "bg-yellow-500/20 text-yellow-400",
|
||||||
deposit: "bg-purple-500/20 text-purple-400",
|
deposit: "bg-purple-500/20 text-purple-400",
|
||||||
withdraw: "bg-cyan-500/20 text-cyan-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",
|
buy: "bg-teal-500/20 text-teal-400",
|
||||||
sell: "bg-pink-500/20 text-pink-400",
|
sell: "bg-pink-500/20 text-pink-400",
|
||||||
equip: "bg-orange-500/20 text-orange-400",
|
equip: "bg-orange-500/20 text-orange-400",
|
||||||
unequip: "bg-orange-500/20 text-orange-400",
|
unequip: "bg-orange-500/20 text-orange-400",
|
||||||
use: "bg-amber-500/20 text-amber-400",
|
use: "bg-amber-500/20 text-amber-400",
|
||||||
task: "bg-indigo-500/20 text-indigo-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 {
|
function getActionColor(type: string): string {
|
||||||
|
|
@ -65,36 +74,47 @@ function getDetailsString(log: ActionLog): string {
|
||||||
const d = log.details;
|
const d = log.details;
|
||||||
if (!d || Object.keys(d).length === 0) return "-";
|
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.reason && typeof d.reason === "string") return d.reason;
|
||||||
if (d.message && typeof d.message === "string") return d.message;
|
if (d.message && typeof d.message === "string") return d.message;
|
||||||
if (d.error && typeof d.error === "string") return d.error;
|
if (d.error && typeof d.error === "string") return d.error;
|
||||||
if (d.result && typeof d.result === "string") return d.result;
|
if (d.result && typeof d.result === "string") return d.result;
|
||||||
|
|
||||||
|
const parts: string[] = [];
|
||||||
if (d.monster) parts.push(`monster: ${d.monster}`);
|
if (d.monster) parts.push(`monster: ${d.monster}`);
|
||||||
if (d.resource) parts.push(`resource: ${d.resource}`);
|
if (d.resource) parts.push(`resource: ${d.resource}`);
|
||||||
if (d.item) parts.push(`item: ${d.item}`);
|
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.x !== undefined && d.y !== undefined) parts.push(`(${d.x}, ${d.y})`);
|
||||||
if (d.xp) parts.push(`xp: +${d.xp}`);
|
if (d.xp) parts.push(`xp: +${d.xp}`);
|
||||||
if (d.gold) parts.push(`gold: ${d.gold}`);
|
if (d.gold) parts.push(`gold: ${d.gold}`);
|
||||||
if (d.quantity) parts.push(`qty: ${d.quantity}`);
|
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);
|
return parts.length > 0 ? parts.join(" | ") : JSON.stringify(d);
|
||||||
}
|
}
|
||||||
|
|
||||||
const ALL_ACTION_TYPES = [
|
const ALL_ACTION_TYPES = [
|
||||||
"move",
|
"movement",
|
||||||
"fight",
|
"fight",
|
||||||
"gather",
|
"gathering",
|
||||||
|
"crafting",
|
||||||
|
"recycling",
|
||||||
"rest",
|
"rest",
|
||||||
"deposit",
|
|
||||||
"withdraw",
|
|
||||||
"craft",
|
|
||||||
"buy",
|
|
||||||
"sell",
|
|
||||||
"equip",
|
"equip",
|
||||||
"unequip",
|
"unequip",
|
||||||
"use",
|
"use",
|
||||||
|
"deposit",
|
||||||
|
"withdraw",
|
||||||
|
"buy",
|
||||||
|
"sell",
|
||||||
|
"npc_buy",
|
||||||
|
"npc_sell",
|
||||||
|
"grandexchange_buy",
|
||||||
|
"grandexchange_sell",
|
||||||
"task",
|
"task",
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
@ -102,29 +122,26 @@ export default function LogsPage() {
|
||||||
const { data: characters } = useCharacters();
|
const { data: characters } = useCharacters();
|
||||||
const [characterFilter, setCharacterFilter] = useState("_all");
|
const [characterFilter, setCharacterFilter] = useState("_all");
|
||||||
const [actionFilter, setActionFilter] = 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,
|
character: characterFilter === "_all" ? undefined : characterFilter,
|
||||||
|
type: actionFilter === "_all" ? undefined : actionFilter,
|
||||||
|
page,
|
||||||
|
size: PAGE_SIZE,
|
||||||
});
|
});
|
||||||
|
|
||||||
const filteredLogs = useMemo(() => {
|
const logs = data?.logs ?? [];
|
||||||
let items = logs ?? [];
|
const totalPages = data?.pages ?? 1;
|
||||||
|
const total = data?.total ?? 0;
|
||||||
|
|
||||||
if (actionFilter !== "_all") {
|
function handleFilterChange(setter: (v: string) => void) {
|
||||||
items = items.filter((log) => log.action_type === actionFilter);
|
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 (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -132,7 +149,7 @@ export default function LogsPage() {
|
||||||
Action Logs
|
Action Logs
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-sm text-muted-foreground mt-1">
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
View detailed action logs across all characters
|
Live action history from the game server
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -146,7 +163,7 @@ export default function LogsPage() {
|
||||||
|
|
||||||
{/* Filters */}
|
{/* Filters */}
|
||||||
<div className="flex flex-wrap gap-3">
|
<div className="flex flex-wrap gap-3">
|
||||||
<Select value={characterFilter} onValueChange={setCharacterFilter}>
|
<Select value={characterFilter} onValueChange={handleFilterChange(setCharacterFilter)}>
|
||||||
<SelectTrigger className="w-48">
|
<SelectTrigger className="w-48">
|
||||||
<SelectValue placeholder="All Characters" />
|
<SelectValue placeholder="All Characters" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
|
|
@ -160,7 +177,7 @@ export default function LogsPage() {
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
|
||||||
<Select value={actionFilter} onValueChange={setActionFilter}>
|
<Select value={actionFilter} onValueChange={handleFilterChange(setActionFilter)}>
|
||||||
<SelectTrigger className="w-48">
|
<SelectTrigger className="w-48">
|
||||||
<SelectValue placeholder="All Actions" />
|
<SelectValue placeholder="All Actions" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
|
|
@ -168,41 +185,41 @@ export default function LogsPage() {
|
||||||
<SelectItem value="_all">All Actions</SelectItem>
|
<SelectItem value="_all">All Actions</SelectItem>
|
||||||
{ALL_ACTION_TYPES.map((type) => (
|
{ALL_ACTION_TYPES.map((type) => (
|
||||||
<SelectItem key={type} value={type}>
|
<SelectItem key={type} value={type}>
|
||||||
<span className="capitalize">{type}</span>
|
<span className="capitalize">{type.replace(/_/g, " ")}</span>
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
|
||||||
{filteredLogs.length > 0 && (
|
{total > 0 && (
|
||||||
<div className="flex items-center text-xs text-muted-foreground self-center ml-auto">
|
<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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isLoading && (
|
{isLoading && logs.length === 0 && (
|
||||||
<div className="flex items-center justify-center py-12">
|
<div className="flex items-center justify-center py-12">
|
||||||
<Loader2 className="size-6 animate-spin text-muted-foreground" />
|
<Loader2 className="size-6 animate-spin text-muted-foreground" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Log Table */}
|
{/* Log Table */}
|
||||||
{visibleLogs.length > 0 && (
|
{logs.length > 0 && (
|
||||||
<Card>
|
<Card>
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead className="w-40">Time</TableHead>
|
<TableHead className="w-40">Time</TableHead>
|
||||||
<TableHead className="w-32">Character</TableHead>
|
<TableHead className="w-32">Character</TableHead>
|
||||||
<TableHead className="w-24">Action</TableHead>
|
<TableHead className="w-28">Action</TableHead>
|
||||||
<TableHead>Details</TableHead>
|
<TableHead>Details</TableHead>
|
||||||
<TableHead className="w-16 text-center">Status</TableHead>
|
<TableHead className="w-16 text-center">Status</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{visibleLogs.map((log) => (
|
{logs.map((log, idx) => (
|
||||||
<TableRow key={log.id}>
|
<TableRow key={`${log.id}-${idx}`}>
|
||||||
<TableCell className="text-xs text-muted-foreground tabular-nums">
|
<TableCell className="text-xs text-muted-foreground tabular-nums">
|
||||||
{formatDate(log.created_at)}
|
{formatDate(log.created_at)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
|
@ -214,7 +231,7 @@ export default function LogsPage() {
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className={`text-[10px] px-1.5 py-0 border-0 capitalize ${getActionColor(log.action_type)}`}
|
className={`text-[10px] px-1.5 py-0 border-0 capitalize ${getActionColor(log.action_type)}`}
|
||||||
>
|
>
|
||||||
{log.action_type}
|
{log.action_type.replace(/_/g, " ")}
|
||||||
</Badge>
|
</Badge>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-sm text-muted-foreground max-w-md truncate">
|
<TableCell className="text-sm text-muted-foreground max-w-md truncate">
|
||||||
|
|
@ -234,26 +251,39 @@ export default function LogsPage() {
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Load More */}
|
{/* Pagination */}
|
||||||
{hasMore && (
|
{totalPages > 1 && (
|
||||||
<div className="flex justify-center">
|
<div className="flex items-center justify-center gap-4">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => setVisibleCount((c) => c + 50)}
|
size="sm"
|
||||||
|
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||||
|
disabled={page <= 1}
|
||||||
>
|
>
|
||||||
<ChevronDown className="size-4" />
|
<ChevronLeft className="size-4" />
|
||||||
Load More
|
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>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Empty state */}
|
{/* Empty state */}
|
||||||
{filteredLogs.length === 0 && !isLoading && (
|
{logs.length === 0 && !isLoading && (
|
||||||
<Card className="p-8 text-center">
|
<Card className="p-8 text-center">
|
||||||
<ScrollText className="size-12 text-muted-foreground mx-auto mb-4" />
|
<ScrollText className="size-12 text-muted-foreground mx-auto mb-4" />
|
||||||
<p className="text-muted-foreground">
|
<p className="text-muted-foreground">
|
||||||
No log entries found. Actions performed by characters or automations
|
No log entries found. Perform actions in the game to generate logs.
|
||||||
will appear here.
|
|
||||||
</p>
|
</p>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -8,9 +8,7 @@ import {
|
||||||
useCallback,
|
useCallback,
|
||||||
} from "react";
|
} from "react";
|
||||||
import {
|
import {
|
||||||
getAuthStatus,
|
|
||||||
setAuthToken,
|
setAuthToken,
|
||||||
clearAuthToken,
|
|
||||||
type AuthStatus,
|
type AuthStatus,
|
||||||
} from "@/lib/api-client";
|
} from "@/lib/api-client";
|
||||||
|
|
||||||
|
|
@ -38,39 +36,21 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||||
const [status, setStatus] = useState<AuthStatus | null>(null);
|
const [status, setStatus] = useState<AuthStatus | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
const checkStatus = useCallback(async () => {
|
// Gate is based entirely on localStorage — each browser has its own token.
|
||||||
try {
|
useEffect(() => {
|
||||||
const s = await getAuthStatus();
|
|
||||||
setStatus(s);
|
|
||||||
|
|
||||||
// If backend has no token but we have one in localStorage, auto-restore it
|
|
||||||
if (!s.has_token) {
|
|
||||||
const savedToken = localStorage.getItem(STORAGE_KEY);
|
const savedToken = localStorage.getItem(STORAGE_KEY);
|
||||||
if (savedToken) {
|
if (savedToken) {
|
||||||
const result = await setAuthToken(savedToken);
|
|
||||||
if (result.success) {
|
|
||||||
setStatus({ has_token: true, source: "user" });
|
setStatus({ has_token: true, source: "user" });
|
||||||
} else {
|
} else {
|
||||||
// Token is stale, remove it
|
|
||||||
localStorage.removeItem(STORAGE_KEY);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Backend unreachable — show the gate anyway
|
|
||||||
setStatus({ has_token: false, source: "none" });
|
setStatus({ has_token: false, source: "none" });
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
}
|
||||||
|
setLoading(false);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
checkStatus();
|
|
||||||
}, [checkStatus]);
|
|
||||||
|
|
||||||
const handleSetToken = useCallback(
|
const handleSetToken = useCallback(
|
||||||
async (token: string): Promise<{ success: boolean; error?: string }> => {
|
async (token: string): Promise<{ success: boolean; error?: string }> => {
|
||||||
try {
|
try {
|
||||||
|
// Validate the token with the backend (it won't store it)
|
||||||
const result = await setAuthToken(token);
|
const result = await setAuthToken(token);
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
localStorage.setItem(STORAGE_KEY, token);
|
localStorage.setItem(STORAGE_KEY, token);
|
||||||
|
|
@ -89,13 +69,8 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleRemoveToken = useCallback(async () => {
|
const handleRemoveToken = useCallback(async () => {
|
||||||
try {
|
|
||||||
const s = await clearAuthToken();
|
|
||||||
setStatus(s);
|
|
||||||
} catch {
|
|
||||||
setStatus({ has_token: false, source: "none" });
|
|
||||||
}
|
|
||||||
localStorage.removeItem(STORAGE_KEY);
|
localStorage.removeItem(STORAGE_KEY);
|
||||||
|
setStatus({ has_token: false, source: "none" });
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { X, Loader2 } from "lucide-react";
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
|
|
@ -7,16 +9,44 @@ import {
|
||||||
CardTitle,
|
CardTitle,
|
||||||
} from "@/components/ui/card";
|
} from "@/components/ui/card";
|
||||||
import { Badge } from "@/components/ui/badge";
|
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 { EQUIPMENT_SLOTS } from "@/lib/constants";
|
||||||
import type { Character } from "@/lib/types";
|
import type { Character } from "@/lib/types";
|
||||||
import { GameIcon } from "@/components/ui/game-icon";
|
import { GameIcon } from "@/components/ui/game-icon";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { executeAction } from "@/lib/api-client";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
interface EquipmentGridProps {
|
interface EquipmentGridProps {
|
||||||
character: Character;
|
character: Character;
|
||||||
|
onActionComplete?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function EquipmentGrid({ character }: EquipmentGridProps) {
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="pb-2">
|
<CardHeader className="pb-2">
|
||||||
|
|
@ -27,8 +57,8 @@ export function EquipmentGrid({ character }: EquipmentGridProps) {
|
||||||
{EQUIPMENT_SLOTS.map((slot) => {
|
{EQUIPMENT_SLOTS.map((slot) => {
|
||||||
const itemCode = character[slot.key as keyof Character] as string;
|
const itemCode = character[slot.key as keyof Character] as string;
|
||||||
const isEmpty = !itemCode;
|
const isEmpty = !itemCode;
|
||||||
|
const isPending = pendingSlot === slot.key;
|
||||||
|
|
||||||
// Show quantity for utility slots
|
|
||||||
let quantity: number | null = null;
|
let quantity: number | null = null;
|
||||||
if (slot.key === "utility1_slot" && itemCode) {
|
if (slot.key === "utility1_slot" && itemCode) {
|
||||||
quantity = character.utility1_slot_quantity;
|
quantity = character.utility1_slot_quantity;
|
||||||
|
|
@ -40,10 +70,10 @@ export function EquipmentGrid({ character }: EquipmentGridProps) {
|
||||||
<div
|
<div
|
||||||
key={slot.key}
|
key={slot.key}
|
||||||
className={cn(
|
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
|
isEmpty
|
||||||
? "border-dashed border-border/50 bg-transparent"
|
? "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">
|
<span className="text-[10px] uppercase tracking-wider text-muted-foreground">
|
||||||
|
|
@ -69,6 +99,32 @@ export function EquipmentGrid({ character }: EquipmentGridProps) {
|
||||||
)}
|
)}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,16 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import {
|
||||||
|
Shield,
|
||||||
|
FlaskConical,
|
||||||
|
Landmark,
|
||||||
|
Recycle,
|
||||||
|
Loader2,
|
||||||
|
ChevronDown,
|
||||||
|
ChevronUp,
|
||||||
|
Package,
|
||||||
|
} from "lucide-react";
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
|
|
@ -8,26 +19,64 @@ import {
|
||||||
CardDescription,
|
CardDescription,
|
||||||
} from "@/components/ui/card";
|
} from "@/components/ui/card";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
import { GameIcon } from "@/components/ui/game-icon";
|
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 { 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 {
|
interface InventoryGridProps {
|
||||||
character: Character;
|
character: Character;
|
||||||
|
onActionComplete?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function InventoryGrid({ character }: InventoryGridProps) {
|
export function InventoryGrid({ character, onActionComplete }: InventoryGridProps) {
|
||||||
const usedSlots = character.inventory.filter(
|
const [pendingSlot, setPendingSlot] = useState<number | null>(null);
|
||||||
(slot) => slot.code && slot.code !== ""
|
const [showAllSlots, setShowAllSlots] = useState(false);
|
||||||
).length;
|
|
||||||
const totalSlots = character.inventory_max_items;
|
|
||||||
|
|
||||||
// Build a map from slot number to inventory item
|
const filledItems = character.inventory.filter(
|
||||||
const slotMap = new Map(
|
(slot) => slot.code && slot.code !== ""
|
||||||
character.inventory
|
|
||||||
.filter((slot) => slot.code && slot.code !== "")
|
|
||||||
.map((slot) => [slot.slot, slot])
|
|
||||||
);
|
);
|
||||||
|
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 (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
|
|
@ -39,40 +88,223 @@ export function InventoryGrid({ character }: InventoryGridProps) {
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
{totalSlots - usedSlots} slots available
|
{emptySlots} slots available
|
||||||
|
{usedSlots > 0 && " \u00b7 Click items for actions"}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="grid grid-cols-5 sm:grid-cols-6 md:grid-cols-5 lg:grid-cols-6 gap-1.5">
|
{usedSlots === 0 ? (
|
||||||
{Array.from({ length: totalSlots }).map((_, index) => {
|
<div className="flex items-center gap-2 py-4 justify-center text-sm text-muted-foreground">
|
||||||
const item = slotMap.get(index + 1);
|
<Package className="size-4" />
|
||||||
const isEmpty = !item;
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
key={index}
|
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(
|
className={cn(
|
||||||
"flex flex-col items-center justify-center rounded-md border p-1.5 aspect-square min-h-[48px]",
|
"flex flex-col items-center justify-center rounded-md border transition-colors cursor-pointer",
|
||||||
isEmpty
|
"border-border bg-accent/30 hover:bg-accent/60 hover:border-primary/40",
|
||||||
? "border-dashed border-border/40"
|
isPending && "opacity-50 pointer-events-none",
|
||||||
: "border-border bg-accent/30"
|
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">
|
<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 && (
|
{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}
|
{item.quantity}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</button>
|
||||||
);
|
</DropdownMenuTrigger>
|
||||||
})}
|
<DropdownMenuContent align="start" className="w-48">
|
||||||
</div>
|
<DropdownMenuLabel className="flex items-center gap-2">
|
||||||
</CardContent>
|
<GameIcon type="item" code={item.code} size="sm" />
|
||||||
</Card>
|
{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,
|
ArrowLeftRight,
|
||||||
Zap,
|
Zap,
|
||||||
ScrollText,
|
ScrollText,
|
||||||
|
AlertTriangle,
|
||||||
BarChart3,
|
BarChart3,
|
||||||
Settings,
|
Settings,
|
||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
|
|
@ -35,6 +36,7 @@ const ICON_MAP: Record<string, React.ComponentType<{ className?: string }>> = {
|
||||||
ArrowLeftRight,
|
ArrowLeftRight,
|
||||||
Zap,
|
Zap,
|
||||||
ScrollText,
|
ScrollText,
|
||||||
|
AlertTriangle,
|
||||||
BarChart3,
|
BarChart3,
|
||||||
Settings,
|
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 { useQuery } from "@tanstack/react-query";
|
||||||
import { getAnalytics, getLogs } from "@/lib/api-client";
|
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) {
|
export function useAnalytics(characterName?: string, hours?: number) {
|
||||||
return useQuery<AnalyticsData>({
|
return useQuery<AnalyticsData>({
|
||||||
|
|
@ -12,10 +12,21 @@ export function useAnalytics(characterName?: string, hours?: number) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useLogs(filters?: { character?: string; type?: string }) {
|
export function useLogs(filters?: {
|
||||||
return useQuery<ActionLog[]>({
|
character?: string;
|
||||||
queryKey: ["logs", filters?.character, filters?.type],
|
type?: string;
|
||||||
queryFn: () => getLogs(filters?.character),
|
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,
|
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,
|
AutomationRun,
|
||||||
AutomationLog,
|
AutomationLog,
|
||||||
AutomationStatus,
|
AutomationStatus,
|
||||||
|
WorkflowConfig,
|
||||||
|
WorkflowRun,
|
||||||
|
WorkflowStatus,
|
||||||
|
PipelineConfig,
|
||||||
|
PipelineRun,
|
||||||
|
PipelineStatus,
|
||||||
GEOrder,
|
GEOrder,
|
||||||
GEHistoryEntry,
|
GEHistoryEntry,
|
||||||
PricePoint,
|
PricePoint,
|
||||||
ActiveGameEvent,
|
ActiveGameEvent,
|
||||||
HistoricalEvent,
|
HistoricalEvent,
|
||||||
ActionLog,
|
ActionLog,
|
||||||
|
PaginatedLogs,
|
||||||
AnalyticsData,
|
AnalyticsData,
|
||||||
|
PaginatedErrors,
|
||||||
|
ErrorStats,
|
||||||
|
AppError,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
||||||
const API_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000";
|
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> {
|
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) {
|
if (!response.ok) {
|
||||||
throw new Error(`API error: ${response.status} ${response.statusText}`);
|
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> {
|
async function postApi<T>(path: string, body?: unknown): Promise<T> {
|
||||||
const response = await fetch(`${API_URL}${path}`, {
|
const response = await fetch(`${API_URL}${path}`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: authHeaders({ "Content-Type": "application/json" }),
|
||||||
body: body ? JSON.stringify(body) : undefined,
|
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> {
|
async function putApi<T>(path: string, body?: unknown): Promise<T> {
|
||||||
const response = await fetch(`${API_URL}${path}`, {
|
const response = await fetch(`${API_URL}${path}`, {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: authHeaders({ "Content-Type": "application/json" }),
|
||||||
body: body ? JSON.stringify(body) : undefined,
|
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> {
|
async function deleteApi(path: string): Promise<void> {
|
||||||
const response = await fetch(`${API_URL}${path}`, {
|
const response = await fetch(`${API_URL}${path}`, {
|
||||||
method: "DELETE",
|
method: "DELETE",
|
||||||
|
headers: authHeaders(),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
|
@ -111,6 +142,7 @@ export async function setAuthToken(token: string): Promise<SetTokenResponse> {
|
||||||
export async function clearAuthToken(): Promise<AuthStatus> {
|
export async function clearAuthToken(): Promise<AuthStatus> {
|
||||||
const response = await fetch(`${API_URL}/api/auth/token`, {
|
const response = await fetch(`${API_URL}/api/auth/token`, {
|
||||||
method: "DELETE",
|
method: "DELETE",
|
||||||
|
headers: authHeaders(),
|
||||||
});
|
});
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`API error: ${response.status}`);
|
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 ----------
|
// ---------- Grand Exchange API ----------
|
||||||
|
|
||||||
export async function getExchangeOrders(): Promise<GEOrder[]> {
|
export async function getExchangeOrders(): Promise<GEOrder[]> {
|
||||||
|
|
@ -264,12 +433,25 @@ export async function getEventHistory(): Promise<HistoricalEvent[]> {
|
||||||
|
|
||||||
// ---------- Logs & Analytics API ----------
|
// ---------- 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();
|
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 qs = params.toString();
|
||||||
const data = await fetchApi<ActionLog[] | { logs?: ActionLog[] }>(`/api/logs${qs ? `?${qs}` : ""}`);
|
const data = await fetchApi<PaginatedLogs>(`/api/logs${qs ? `?${qs}` : ""}`);
|
||||||
return Array.isArray(data) ? data : (data?.logs ?? []);
|
return {
|
||||||
|
logs: data.logs ?? [],
|
||||||
|
total: data.total ?? 0,
|
||||||
|
page: data.page ?? 1,
|
||||||
|
pages: data.pages ?? 1,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getAnalytics(
|
export function getAnalytics(
|
||||||
|
|
@ -295,3 +477,39 @@ export function executeAction(
|
||||||
{ action, params }
|
{ 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: "/exchange", label: "Exchange", icon: "ArrowLeftRight" },
|
||||||
{ href: "/events", label: "Events", icon: "Zap" },
|
{ href: "/events", label: "Events", icon: "Zap" },
|
||||||
{ href: "/logs", label: "Logs", icon: "ScrollText" },
|
{ href: "/logs", label: "Logs", icon: "ScrollText" },
|
||||||
|
{ href: "/errors", label: "Errors", icon: "AlertTriangle" },
|
||||||
{ href: "/analytics", label: "Analytics", icon: "BarChart3" },
|
{ href: "/analytics", label: "Analytics", icon: "BarChart3" },
|
||||||
{ href: "/settings", label: "Settings", icon: "Settings" },
|
{ href: "/settings", label: "Settings", icon: "Settings" },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
|
||||||
|
|
@ -251,6 +251,161 @@ export interface LevelingConfig {
|
||||||
target_skill?: string;
|
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 ----------
|
// ---------- Grand Exchange Types ----------
|
||||||
|
|
||||||
export interface GEOrder {
|
export interface GEOrder {
|
||||||
|
|
@ -319,6 +474,14 @@ export interface ActionLog {
|
||||||
details: Record<string, unknown>;
|
details: Record<string, unknown>;
|
||||||
success: boolean;
|
success: boolean;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
|
cooldown?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PaginatedLogs {
|
||||||
|
logs: ActionLog[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
pages: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TimeSeriesPoint {
|
export interface TimeSeriesPoint {
|
||||||
|
|
@ -332,3 +495,33 @@ export interface AnalyticsData {
|
||||||
gold_history: TimeSeriesPoint[];
|
gold_history: TimeSeriesPoint[];
|
||||||
actions_per_hour: number;
|
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