Merge branch 'feat/maintenance-flows'

This commit is contained in:
chihlasm
2026-02-17 16:40:43 -05:00
43 changed files with 2193 additions and 21 deletions

View File

@@ -0,0 +1,33 @@
"""add maintenance tree type
Revision ID: 0f1ca2af3647
Revises: 039
Create Date: 2026-02-17 10:25:54.959861
"""
from typing import Sequence, Union
from alembic import op
# revision identifiers, used by Alembic.
revision: str = '0f1ca2af3647'
down_revision: Union[str, None] = '039'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.execute("ALTER TABLE trees DROP CONSTRAINT ck_trees_tree_type")
op.execute(
"ALTER TABLE trees ADD CONSTRAINT ck_trees_tree_type "
"CHECK (tree_type IN ('troubleshooting', 'procedural', 'maintenance'))"
)
def downgrade() -> None:
op.execute("UPDATE trees SET tree_type = 'procedural' WHERE tree_type = 'maintenance'")
op.execute("ALTER TABLE trees DROP CONSTRAINT ck_trees_tree_type")
op.execute(
"ALTER TABLE trees ADD CONSTRAINT ck_trees_tree_type "
"CHECK (tree_type IN ('troubleshooting', 'procedural'))"
)

View File

@@ -0,0 +1,44 @@
"""add maintenance_schedules table
Revision ID: 0fd2a90a9c2c
Revises: 6e8128ef2aa8
Create Date: 2026-02-17
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision = '0fd2a90a9c2c'
down_revision = '6e8128ef2aa8'
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
'maintenance_schedules',
sa.Column('id', postgresql.UUID(as_uuid=True), primary_key=True),
sa.Column('tree_id', postgresql.UUID(as_uuid=True),
sa.ForeignKey('trees.id', ondelete='CASCADE'), nullable=False),
sa.Column('created_by', postgresql.UUID(as_uuid=True),
sa.ForeignKey('users.id', ondelete='SET NULL'), nullable=True),
sa.Column('cron_expression', sa.String(100), nullable=False),
sa.Column('timezone', sa.String(100), nullable=False, server_default='UTC'),
sa.Column('target_list_id', postgresql.UUID(as_uuid=True),
sa.ForeignKey('target_lists.id', ondelete='SET NULL'), nullable=True),
sa.Column('is_active', sa.Boolean(), nullable=False, server_default='true'),
sa.Column('next_run_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('last_run_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
)
op.create_index('ix_maintenance_schedules_tree_id', 'maintenance_schedules', ['tree_id'])
op.create_unique_constraint('uq_maintenance_schedules_tree_id', 'maintenance_schedules', ['tree_id'])
def downgrade() -> None:
op.drop_constraint('uq_maintenance_schedules_tree_id', 'maintenance_schedules', type_='unique')
op.drop_index('ix_maintenance_schedules_tree_id', table_name='maintenance_schedules')
op.drop_table('maintenance_schedules')

View File

@@ -0,0 +1,39 @@
"""add target_lists table
Revision ID: 5812e7df852f
Revises: 0f1ca2af3647
Create Date: 2026-02-17 11:20:42.919564
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = '5812e7df852f'
down_revision: Union[str, None] = '0f1ca2af3647'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
'target_lists',
sa.Column('id', postgresql.UUID(as_uuid=True), primary_key=True),
sa.Column('team_id', postgresql.UUID(as_uuid=True), sa.ForeignKey('teams.id', ondelete='CASCADE'), nullable=False),
sa.Column('created_by', postgresql.UUID(as_uuid=True), sa.ForeignKey('users.id', ondelete='SET NULL'), nullable=True),
sa.Column('name', sa.String(255), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('targets', postgresql.JSONB(), nullable=False, server_default='[]'),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
)
op.create_index('ix_target_lists_team_id', 'target_lists', ['team_id'])
def downgrade() -> None:
op.drop_index('ix_target_lists_team_id', table_name='target_lists')
op.drop_table('target_lists')

View File

@@ -0,0 +1,28 @@
"""add batch_id and target_label to sessions
Revision ID: 6e8128ef2aa8
Revises: 5812e7df852f
Create Date: 2026-02-17
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision = '6e8128ef2aa8'
down_revision = '5812e7df852f'
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column('sessions', sa.Column('batch_id', postgresql.UUID(as_uuid=True), nullable=True))
op.add_column('sessions', sa.Column('target_label', sa.String(255), nullable=True))
op.create_index('ix_sessions_batch_id', 'sessions', ['batch_id'])
def downgrade() -> None:
op.drop_index('ix_sessions_batch_id', table_name='sessions')
op.drop_column('sessions', 'target_label')
op.drop_column('sessions', 'batch_id')

View File

@@ -0,0 +1,153 @@
"""Maintenance schedule CRUD endpoints."""
from typing import Annotated
from uuid import UUID
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from croniter import croniter
import pytz
from app.api.deps import get_current_active_user, get_db, require_engineer_or_admin
from app.models.maintenance_schedule import MaintenanceSchedule
from app.models.tree import Tree
from app.models.user import User
from app.schemas.maintenance_schedule import (
MaintenanceScheduleCreate,
MaintenanceScheduleUpdate,
MaintenanceScheduleResponse,
)
router = APIRouter(prefix="/maintenance-schedules", tags=["maintenance-schedules"])
def _compute_next_run(cron_expression: str, tz_name: str) -> datetime:
"""Compute next run time from cron expression, returned as UTC."""
tz = pytz.timezone(tz_name)
now = datetime.now(tz)
cron = croniter(cron_expression, now)
return cron.get_next(datetime).astimezone(timezone.utc)
async def _get_tree_or_403(tree_id: UUID, current_user: User, db: AsyncSession) -> "Tree":
"""Fetch tree and verify the current user's team owns it."""
result = await db.execute(select(Tree).where(Tree.id == tree_id))
tree = result.scalar_one_or_none()
if not tree:
raise HTTPException(status_code=404, detail="Tree not found")
# Super admins can access any tree; regular users must be on the same team
if not getattr(current_user, 'is_super_admin', False):
if tree.team_id != current_user.team_id:
raise HTTPException(status_code=403, detail="Access denied")
return tree
@router.post("", response_model=MaintenanceScheduleResponse, status_code=201)
async def create_schedule(
data: MaintenanceScheduleCreate,
current_user: Annotated[User, Depends(get_current_active_user)],
db: Annotated[AsyncSession, Depends(get_db)],
_: None = Depends(require_engineer_or_admin),
):
"""Create a cron schedule for a maintenance flow. One per flow."""
# Verify user's team owns the tree
await _get_tree_or_403(data.tree_id, current_user, db)
# Check no existing schedule for this tree
existing = await db.execute(
select(MaintenanceSchedule).where(MaintenanceSchedule.tree_id == data.tree_id)
)
if existing.scalar_one_or_none():
raise HTTPException(status_code=409, detail="Schedule already exists for this tree")
try:
next_run = _compute_next_run(data.cron_expression, data.timezone)
except (ValueError, KeyError) as e:
raise HTTPException(status_code=422, detail=f"Invalid cron expression or timezone: {e}")
schedule = MaintenanceSchedule(
tree_id=data.tree_id,
created_by=current_user.id,
cron_expression=data.cron_expression,
timezone=data.timezone,
target_list_id=data.target_list_id,
is_active=True,
next_run_at=next_run,
)
db.add(schedule)
await db.commit()
await db.refresh(schedule)
from app.core.scheduler import register_schedule
register_schedule(schedule)
return schedule
@router.get("/tree/{tree_id}", response_model=MaintenanceScheduleResponse)
async def get_schedule_for_tree(
tree_id: UUID,
current_user: Annotated[User, Depends(get_current_active_user)],
db: Annotated[AsyncSession, Depends(get_db)],
):
"""Get the schedule for a specific maintenance flow."""
# Verify user's team owns the tree before returning schedule data
await _get_tree_or_403(tree_id, current_user, db)
result = await db.execute(
select(MaintenanceSchedule).where(MaintenanceSchedule.tree_id == tree_id)
)
schedule = result.scalar_one_or_none()
if not schedule:
raise HTTPException(status_code=404, detail="No schedule found for this tree")
return schedule
@router.patch("/{schedule_id}", response_model=MaintenanceScheduleResponse)
async def update_schedule(
schedule_id: UUID,
data: MaintenanceScheduleUpdate,
current_user: Annotated[User, Depends(get_current_active_user)],
db: Annotated[AsyncSession, Depends(get_db)],
_: None = Depends(require_engineer_or_admin),
):
"""Update a schedule (disable, change cron, change timezone, change target list)."""
result = await db.execute(
select(MaintenanceSchedule).where(MaintenanceSchedule.id == schedule_id)
)
schedule = result.scalar_one_or_none()
if not schedule:
raise HTTPException(status_code=404, detail="Schedule not found")
# Verify user's team owns the tree this schedule belongs to
await _get_tree_or_403(schedule.tree_id, current_user, db)
update_fields = data.model_fields_set
was_active = schedule.is_active
if "cron_expression" in update_fields and data.cron_expression is not None:
schedule.cron_expression = data.cron_expression
if "timezone" in update_fields and data.timezone is not None:
schedule.timezone = data.timezone
if "target_list_id" in update_fields:
schedule.target_list_id = data.target_list_id
if "is_active" in update_fields and data.is_active is not None:
schedule.is_active = data.is_active
# Recompute next_run_at if schedule timing changed or schedule is being re-activated
reactivating = "is_active" in update_fields and data.is_active is True and not was_active
if "cron_expression" in update_fields or "timezone" in update_fields or reactivating:
try:
schedule.next_run_at = _compute_next_run(schedule.cron_expression, schedule.timezone)
except (ValueError, KeyError) as e:
raise HTTPException(status_code=422, detail=f"Invalid cron expression or timezone: {e}")
await db.commit()
await db.refresh(schedule)
from app.core.scheduler import register_schedule, unregister_schedule
if schedule.is_active:
register_schedule(schedule)
else:
unregister_schedule(str(schedule.id))
return schedule

View File

@@ -1,8 +1,10 @@
from datetime import datetime, timezone
from typing import Annotated, Optional
from uuid import UUID
import uuid
from fastapi import APIRouter, Depends, HTTPException, status, Query
from fastapi.responses import PlainTextResponse
from pydantic import BaseModel, Field as PydanticField
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
@@ -485,3 +487,95 @@ async def save_session_as_tree(
tree_name=new_tree.name,
message=f"Session saved as {'published' if request_data.status == 'published' else 'draft'} tree"
)
# ── Batch Launch (Maintenance Flows) ──────────────────────────────────────
class _BatchTarget(BaseModel):
label: str = PydanticField(..., min_length=1, max_length=255)
class _BatchLaunchRequest(BaseModel):
tree_id: UUID
targets: list[_BatchTarget] = PydanticField(..., min_length=1, max_length=100)
class _BatchLaunchResponse(BaseModel):
batch_id: str
count: int
sessions: list[dict]
@router.post("/batch", status_code=201, response_model=_BatchLaunchResponse)
async def batch_launch_sessions(
data: _BatchLaunchRequest,
current_user: Annotated[User, Depends(get_current_active_user)],
db: Annotated[AsyncSession, Depends(get_db)],
):
"""Create one session per target for a maintenance flow batch run."""
tree_result = await db.execute(select(Tree).where(Tree.id == data.tree_id))
tree = tree_result.scalar_one_or_none()
if not tree:
raise HTTPException(status_code=404, detail="Tree not found")
if not can_access_tree(current_user, tree):
raise HTTPException(status_code=403, detail="Access denied")
if not tree.is_active:
raise HTTPException(status_code=400, detail="Cannot batch-launch an inactive flow")
if tree.status == 'draft':
raise HTTPException(status_code=400, detail="Cannot batch-launch a draft flow")
if not current_user.is_super_admin and tree.team_id != current_user.team_id:
raise HTTPException(status_code=403, detail="Access denied")
if tree.tree_type != "maintenance":
raise HTTPException(status_code=400, detail="Batch launch is only for maintenance flows")
batch_id = uuid.uuid4()
created_sessions = []
# Hoist snapshot computation out of the loop — same tree for all targets
tree_snapshot = {
**tree.tree_structure,
"name": tree.name,
"description": tree.description,
"tree_type": tree.tree_type,
}
for target in data.targets:
session = Session(
tree_id=tree.id,
user_id=current_user.id,
tree_snapshot=tree_snapshot,
path_taken=[],
decisions=[],
custom_steps=[],
session_variables={},
batch_id=batch_id,
target_label=target.label,
)
db.add(session)
created_sessions.append(session)
await db.flush()
session_ids = [s.id for s in created_sessions]
result = await db.execute(select(Session).where(Session.id.in_(session_ids)))
created_sessions = result.scalars().all()
await db.commit()
return _BatchLaunchResponse(
batch_id=str(batch_id),
count=len(created_sessions),
sessions=[
{
"id": str(s.id),
"batch_id": str(s.batch_id),
"target_label": s.target_label,
"tree_id": str(s.tree_id),
}
for s in created_sessions
],
)

View File

@@ -0,0 +1,119 @@
"""Target lists CRUD endpoints."""
from typing import Annotated
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_current_active_user, get_db, require_engineer_or_admin
from app.models.target_list import TargetList
from app.models.user import User
from app.schemas.target_list import TargetListCreate, TargetListUpdate, TargetListResponse
router = APIRouter(prefix="/target-lists", tags=["target-lists"])
@router.get("/", response_model=list[TargetListResponse])
async def list_target_lists(
current_user: Annotated[User, Depends(get_current_active_user)],
db: Annotated[AsyncSession, Depends(get_db)],
):
"""List all target lists for the current user's team."""
if not current_user.team_id:
return []
result = await db.execute(
select(TargetList)
.where(TargetList.team_id == current_user.team_id)
.order_by(TargetList.name)
)
return result.scalars().all()
@router.post("/", response_model=TargetListResponse, status_code=201)
async def create_target_list(
data: TargetListCreate,
current_user: Annotated[User, Depends(get_current_active_user)],
db: Annotated[AsyncSession, Depends(get_db)],
_: None = Depends(require_engineer_or_admin),
):
"""Create a new target list for the current team."""
if not current_user.team_id:
raise HTTPException(status_code=400, detail="User must belong to a team")
target_list = TargetList(
team_id=current_user.team_id,
created_by=current_user.id,
name=data.name,
description=data.description,
targets=[t.model_dump() for t in data.targets],
)
db.add(target_list)
await db.commit()
await db.refresh(target_list)
return target_list
@router.get("/{list_id}", response_model=TargetListResponse)
async def get_target_list(
list_id: UUID,
current_user: Annotated[User, Depends(get_current_active_user)],
db: Annotated[AsyncSession, Depends(get_db)],
):
result = await db.execute(
select(TargetList).where(
TargetList.id == list_id,
TargetList.team_id == current_user.team_id,
)
)
target_list = result.scalar_one_or_none()
if not target_list:
raise HTTPException(status_code=404, detail="Target list not found")
return target_list
@router.put("/{list_id}", response_model=TargetListResponse)
async def update_target_list(
list_id: UUID,
data: TargetListUpdate,
current_user: Annotated[User, Depends(get_current_active_user)],
db: Annotated[AsyncSession, Depends(get_db)],
_: None = Depends(require_engineer_or_admin),
):
result = await db.execute(
select(TargetList).where(
TargetList.id == list_id,
TargetList.team_id == current_user.team_id,
)
)
target_list = result.scalar_one_or_none()
if not target_list:
raise HTTPException(status_code=404, detail="Target list not found")
update_fields = data.model_fields_set
if "name" in update_fields and data.name is not None:
target_list.name = data.name
if "description" in update_fields:
target_list.description = data.description # allow setting to None
if "targets" in update_fields and data.targets is not None:
target_list.targets = [t.model_dump() for t in data.targets]
await db.commit()
await db.refresh(target_list)
return target_list
@router.delete("/{list_id}", status_code=204)
async def delete_target_list(
list_id: UUID,
current_user: Annotated[User, Depends(get_current_active_user)],
db: Annotated[AsyncSession, Depends(get_db)],
_: None = Depends(require_engineer_or_admin),
):
result = await db.execute(
select(TargetList).where(
TargetList.id == list_id,
TargetList.team_id == current_user.team_id,
)
)
target_list = result.scalar_one_or_none()
if not target_list:
raise HTTPException(status_code=404, detail="Target list not found")
await db.delete(target_list)
await db.commit()

View File

@@ -2,6 +2,8 @@ from fastapi import APIRouter
from app.api.endpoints import auth, trees, sessions, invite, categories, tags, folders, step_categories, steps, admin, accounts, webhooks, shares, shared, tree_markdown
from app.api.endpoints import admin_dashboard, admin_audit, admin_plan_limits, admin_feature_flags, admin_settings, admin_categories
from app.api.endpoints import ratings, analytics
from app.api.endpoints import target_lists
from app.api.endpoints import maintenance_schedules
api_router = APIRouter()
@@ -28,3 +30,5 @@ api_router.include_router(shared.router) # Public endpoints (no auth)
api_router.include_router(tree_markdown.router)
api_router.include_router(ratings.router)
api_router.include_router(analytics.router)
api_router.include_router(target_lists.router)
api_router.include_router(maintenance_schedules.router)

View File

@@ -0,0 +1,144 @@
"""APScheduler integration for maintenance flow auto-session creation."""
import logging
import uuid
from datetime import datetime, timezone
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from apscheduler.schedulers.base import SchedulerNotRunningError
from apscheduler.jobstores.base import JobLookupError
from apscheduler.triggers.cron import CronTrigger
import pytz
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
logger = logging.getLogger(__name__)
scheduler = AsyncIOScheduler()
async def _fire_maintenance_schedule(schedule_id: str) -> None:
"""Create batch sessions for a scheduled maintenance run."""
# Import all models first to ensure SQLAlchemy mapper relationships resolve
import app.models # noqa: F401
from app.core.database import async_session_maker
from app.models.maintenance_schedule import MaintenanceSchedule
from app.models.session import Session
from app.models.target_list import TargetList
from app.models.tree import Tree
async with async_session_maker() as db:
try:
result = await db.execute(
select(MaintenanceSchedule).where(
MaintenanceSchedule.id == uuid.UUID(schedule_id),
MaintenanceSchedule.is_active == True,
)
)
schedule = result.scalar_one_or_none()
if not schedule:
logger.warning(f"Schedule {schedule_id} not found or inactive")
return
tree_result = await db.execute(
select(Tree).where(Tree.id == schedule.tree_id)
)
tree = tree_result.scalar_one_or_none()
if not tree:
logger.error(f"Tree {schedule.tree_id} not found for schedule {schedule_id}")
return
# Resolve targets
targets: list[dict] = []
if schedule.target_list_id:
list_result = await db.execute(
select(TargetList).where(TargetList.id == schedule.target_list_id)
)
target_list = list_result.scalar_one_or_none()
if target_list:
targets = list(target_list.targets)
if not targets:
targets = [{"label": "Unassigned"}]
batch_id = uuid.uuid4()
tree_snapshot = tree.tree_structure
sessions_to_add = []
for target in targets:
session = Session(
tree_id=tree.id,
user_id=schedule.created_by,
tree_snapshot=tree_snapshot,
path_taken=[],
decisions=[],
custom_steps=[],
session_variables={},
batch_id=batch_id,
target_label=target.get("label", ""),
)
sessions_to_add.append(session)
for s in sessions_to_add:
db.add(s)
# Update schedule tracking
schedule.last_run_at = datetime.now(timezone.utc)
from croniter import croniter
tz = pytz.timezone(schedule.timezone)
now = datetime.now(tz)
cron = croniter(schedule.cron_expression, now)
schedule.next_run_at = cron.get_next(datetime).astimezone(timezone.utc)
await db.commit()
logger.info(
f"Schedule {schedule_id} fired: created {len(sessions_to_add)} sessions "
f"(batch {batch_id}) for tree '{tree.name}'"
)
except Exception:
logger.exception(f"Error firing maintenance schedule {schedule_id}")
await db.rollback()
async def load_all_schedules(db: AsyncSession) -> None:
"""Load all active schedules into APScheduler on startup."""
# Import all models to ensure SQLAlchemy mapper relationships resolve
# before any ORM queries are executed.
import app.models # noqa: F401
from app.models.maintenance_schedule import MaintenanceSchedule
result = await db.execute(
select(MaintenanceSchedule).where(MaintenanceSchedule.is_active == True)
)
schedules = result.scalars().all()
for schedule in schedules:
register_schedule(schedule)
logger.info(f"Loaded {len(schedules)} active maintenance schedule(s)")
def register_schedule(schedule) -> None:
"""Register or update a schedule in APScheduler."""
job_id = f"maintenance_{schedule.id}"
try:
tz = pytz.timezone(schedule.timezone)
trigger = CronTrigger.from_crontab(schedule.cron_expression, timezone=tz)
scheduler.add_job(
_fire_maintenance_schedule,
trigger=trigger,
id=job_id,
args=[str(schedule.id)],
replace_existing=True,
misfire_grace_time=3600,
)
logger.info(f"Registered schedule {schedule.id} ({schedule.cron_expression})")
except Exception:
logger.exception(f"Failed to register schedule {schedule.id}")
def unregister_schedule(schedule_id: str) -> None:
"""Remove a schedule from APScheduler."""
job_id = f"maintenance_{schedule_id}"
if scheduler.get_job(job_id):
try:
scheduler.remove_job(job_id)
logger.info(f"Unregistered schedule {schedule_id}")
except (SchedulerNotRunningError, JobLookupError):
logger.warning(f"Could not remove job {job_id}: scheduler not running or job already removed")

View File

@@ -1,6 +1,8 @@
"""Tree validation helper module for draft/published workflow."""
from typing import Any
PROCEDURAL_TREE_TYPES = {"procedural", "maintenance"}
class TreeValidationError(Exception):
"""Custom exception for tree validation errors."""
@@ -224,14 +226,14 @@ def can_publish_tree(
errors.append({"field": "name", "message": "Tree must have a name to be published"})
# Validate structure based on tree type
if tree_type == "procedural":
if tree_type in PROCEDURAL_TREE_TYPES:
structure_valid, structure_errors = validate_procedural_structure(tree_structure)
else:
structure_valid, structure_errors = validate_tree_structure(tree_structure)
errors.extend(structure_errors)
# Validate intake form if present (procedural only)
if intake_form and tree_type == "procedural":
if intake_form and tree_type in PROCEDURAL_TREE_TYPES:
form_valid, form_errors = _validate_intake_form(intake_form)
errors.extend(form_errors)

View File

@@ -6,11 +6,12 @@ from slowapi import _rate_limit_exceeded_handler
from slowapi.errors import RateLimitExceeded
from app.core.config import settings
from app.core.database import init_db
from app.core.database import init_db, async_session_maker
from app.core.logging_config import setup_logging
from app.core.middleware import RequestLoggingMiddleware, ErrorLoggingMiddleware
from app.core.rate_limit import limiter
from app.api.router import api_router
from app.core.scheduler import scheduler, load_all_schedules
# Initialize logging configuration
setup_logging()
@@ -26,8 +27,16 @@ async def lifespan(app: FastAPI):
logger.info(f"ALLOW_RAILWAY_ORIGINS: {settings.ALLOW_RAILWAY_ORIGINS}")
# Note: In production, use Alembic migrations instead of init_db
# await init_db()
# Start maintenance schedule runner
scheduler.start()
async with async_session_maker() as db:
await load_all_schedules(db)
yield
# Shutdown
scheduler.shutdown(wait=False)
logger.info("Shutting down ResolutionFlow API server...")

View File

@@ -5,6 +5,7 @@ from .subscription import Subscription
from .plan_limits import PlanLimits
from .account_invite import AccountInvite
from .tree import Tree
from .tree_share import TreeShare
from .session import Session
from .attachment import Attachment
from .invite_code import InviteCode
@@ -22,6 +23,8 @@ from .account_limit_override import AccountLimitOverride
from .feature_flag import FeatureFlag, PlanFeatureDefault, AccountFeatureOverride
from .platform_setting import PlatformSetting
from .user_pinned_tree import UserPinnedTree
from .target_list import TargetList
from .maintenance_schedule import MaintenanceSchedule
__all__ = [
"User",
@@ -31,6 +34,7 @@ __all__ = [
"PlanLimits",
"AccountInvite",
"Tree",
"TreeShare",
"Session",
"Attachment",
"InviteCode",
@@ -55,4 +59,6 @@ __all__ = [
"AccountFeatureOverride",
"PlatformSetting",
"UserPinnedTree",
"TargetList",
"MaintenanceSchedule",
]

View File

@@ -0,0 +1,45 @@
import uuid
from datetime import datetime, timezone
from typing import Optional
from sqlalchemy import String, DateTime, ForeignKey, Boolean, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.dialects.postgresql import UUID
from app.core.database import Base
class MaintenanceSchedule(Base):
__tablename__ = "maintenance_schedules"
__table_args__ = (
UniqueConstraint("tree_id", name="uq_maintenance_schedules_tree_id"),
)
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
tree_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("trees.id", ondelete="CASCADE"),
nullable=False, index=True
)
created_by: Mapped[Optional[uuid.UUID]] = mapped_column(
UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True
)
cron_expression: Mapped[str] = mapped_column(String(100), nullable=False)
timezone: Mapped[str] = mapped_column(String(100), nullable=False, default="UTC")
target_list_id: Mapped[Optional[uuid.UUID]] = mapped_column(
UUID(as_uuid=True), ForeignKey("target_lists.id", ondelete="SET NULL"), nullable=True
)
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
next_run_at: Mapped[Optional[datetime]] = mapped_column(
DateTime(timezone=True), nullable=True
)
last_run_at: Mapped[Optional[datetime]] = mapped_column(
DateTime(timezone=True), nullable=True
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(timezone.utc),
onupdate=lambda: datetime.now(timezone.utc),
)

View File

@@ -65,3 +65,11 @@ class Session(Base):
user: Mapped["User"] = relationship("User", back_populates="sessions")
attachments: Mapped[list["Attachment"]] = relationship("Attachment", back_populates="session")
shares: Mapped[list["SessionShare"]] = relationship("SessionShare", back_populates="session", cascade="all, delete-orphan")
# Batch tracking (maintenance flows)
batch_id: Mapped[Optional[uuid.UUID]] = mapped_column(
UUID(as_uuid=True), nullable=True, index=True
)
target_label: Mapped[Optional[str]] = mapped_column(
String(255), nullable=True
)

View File

@@ -0,0 +1,38 @@
import uuid
from datetime import datetime, timezone
from typing import Optional, TYPE_CHECKING
from sqlalchemy import String, Text, DateTime, ForeignKey
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.dialects.postgresql import UUID, JSONB
from app.core.database import Base
if TYPE_CHECKING:
from app.models.user import User
from app.models.team import Team
class TargetList(Base):
__tablename__ = "target_lists"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
team_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("teams.id", ondelete="CASCADE"),
nullable=False, index=True
)
created_by: Mapped[Optional[uuid.UUID]] = mapped_column(
UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True
)
name: Mapped[str] = mapped_column(String(255), nullable=False)
description: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
# targets: [{"label": "RDS-01", "notes": "optional notes"}, ...]
targets: Mapped[list] = mapped_column(JSONB, nullable=False, default=list)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(timezone.utc),
onupdate=lambda: datetime.now(timezone.utc),
)

View File

@@ -29,7 +29,7 @@ class Tree(Base):
name='ck_trees_status'
),
CheckConstraint(
"tree_type IN ('troubleshooting', 'procedural')",
"tree_type IN ('troubleshooting', 'procedural', 'maintenance')",
name='ck_trees_tree_type'
),
)

View File

@@ -0,0 +1,34 @@
from datetime import datetime
from typing import Optional
from uuid import UUID
from pydantic import BaseModel, Field
class MaintenanceScheduleCreate(BaseModel):
tree_id: UUID
cron_expression: str = Field(..., min_length=9, max_length=100)
timezone: str = Field("UTC", max_length=100)
target_list_id: Optional[UUID] = None
class MaintenanceScheduleUpdate(BaseModel):
cron_expression: Optional[str] = Field(None, min_length=9, max_length=100)
timezone: Optional[str] = Field(None, max_length=100)
target_list_id: Optional[UUID] = None
is_active: Optional[bool] = None
class MaintenanceScheduleResponse(BaseModel):
id: UUID
tree_id: UUID
created_by: Optional[UUID]
cron_expression: str
timezone: str
target_list_id: Optional[UUID]
is_active: bool
next_run_at: Optional[datetime]
last_run_at: Optional[datetime]
created_at: datetime
updated_at: datetime
model_config = {"from_attributes": True}

View File

@@ -78,6 +78,9 @@ class SessionResponse(BaseModel):
def normalize_text_fields(cls, v):
return v or ""
batch_id: Optional[UUID] = None
target_label: Optional[str] = None
class Config:
from_attributes = True

View File

@@ -0,0 +1,34 @@
from datetime import datetime
from typing import Optional
from uuid import UUID
from pydantic import BaseModel, Field
class TargetEntry(BaseModel):
label: str = Field(..., min_length=1, max_length=255)
notes: Optional[str] = Field(None, max_length=500)
class TargetListCreate(BaseModel):
name: str = Field(..., min_length=1, max_length=255)
description: Optional[str] = None
targets: list[TargetEntry] = Field(..., min_length=1)
class TargetListUpdate(BaseModel):
name: Optional[str] = Field(None, min_length=1, max_length=255)
description: Optional[str] = None
targets: Optional[list[TargetEntry]] = Field(None, min_length=1)
class TargetListResponse(BaseModel):
id: UUID
team_id: UUID
created_by: Optional[UUID]
name: str
description: Optional[str]
targets: list[TargetEntry]
created_at: datetime
updated_at: datetime
model_config = {"from_attributes": True}

View File

@@ -7,7 +7,7 @@ import re
# --- Tree Type ---
TreeType = Literal['troubleshooting', 'procedural']
TreeType = Literal['troubleshooting', 'procedural', 'maintenance']
# --- Intake Form Schemas ---

View File

@@ -30,3 +30,6 @@ resend==2.21.0
# Utilities
python-dotenv==1.0.1
croniter>=2.0.0
pytz>=2024.1
apscheduler>=3.10.4

View File

@@ -0,0 +1,132 @@
"""Tests for batch session launching (maintenance flows)."""
import pytest
from httpx import AsyncClient
async def _create_maintenance_tree(client, headers):
"""Helper: create a published maintenance tree."""
resp = await client.post(
"/api/v1/trees",
json={
"name": "Patch RDS Servers",
"tree_type": "maintenance",
"tree_structure": {
"steps": [
{"id": "s1", "type": "procedure_step", "title": "Install patch",
"description": "Run installer", "content_type": "action"},
{"id": "end", "type": "procedure_end", "title": "Done"},
]
},
},
headers=headers,
)
assert resp.status_code == 201, resp.text
return resp.json()["id"]
@pytest.mark.asyncio
async def test_batch_launch_creates_one_session_per_target(client: AsyncClient, auth_headers: dict):
tree_id = await _create_maintenance_tree(client, auth_headers)
resp = await client.post(
"/api/v1/sessions/batch",
json={
"tree_id": tree_id,
"targets": [
{"label": "RDS-01"},
{"label": "RDS-02"},
{"label": "RDS-03"},
],
},
headers=auth_headers,
)
assert resp.status_code == 201, resp.text
data = resp.json()
assert data["count"] == 3
assert data["batch_id"] is not None
sessions = data["sessions"]
assert len(sessions) == 3
# All share the same batch_id
batch_ids = {s["batch_id"] for s in sessions}
assert len(batch_ids) == 1
# Each has a distinct target_label
labels = {s["target_label"] for s in sessions}
assert labels == {"RDS-01", "RDS-02", "RDS-03"}
@pytest.mark.asyncio
async def test_batch_launch_rejects_empty_targets(client: AsyncClient, auth_headers: dict):
tree_id = await _create_maintenance_tree(client, auth_headers)
resp = await client.post(
"/api/v1/sessions/batch",
json={"tree_id": tree_id, "targets": []},
headers=auth_headers,
)
assert resp.status_code == 422
@pytest.mark.asyncio
async def test_batch_launch_rejects_non_maintenance_tree(client: AsyncClient, auth_headers: dict):
"""Batch launch only works for maintenance flows."""
# Create a procedural tree
resp = await client.post(
"/api/v1/trees",
json={
"name": "Regular Project",
"tree_type": "procedural",
"tree_structure": {
"steps": [
{"id": "s1", "type": "procedure_step", "title": "Step",
"description": "Do it", "content_type": "action"},
{"id": "end", "type": "procedure_end", "title": "Done"},
]
},
},
headers=auth_headers,
)
tree_id = resp.json()["id"]
batch_resp = await client.post(
"/api/v1/sessions/batch",
json={"tree_id": tree_id, "targets": [{"label": "SRV-01"}]},
headers=auth_headers,
)
assert batch_resp.status_code == 400
@pytest.mark.asyncio
async def test_batch_launch_requires_auth(client: AsyncClient):
"""Unauthenticated batch launch returns 401."""
resp = await client.post(
"/api/v1/sessions/batch",
json={"tree_id": "00000000-0000-0000-0000-000000000000", "targets": [{"label": "SRV-01"}]},
)
assert resp.status_code == 401
@pytest.mark.asyncio
async def test_batch_launch_rejects_draft_tree(client: AsyncClient, auth_headers: dict):
"""Batch launch against a draft maintenance tree returns 400."""
# Create a maintenance tree — trees default to 'published', so we explicitly set draft
resp = await client.post(
"/api/v1/trees",
json={
"name": "Draft Maintenance",
"tree_type": "maintenance",
"status": "draft",
"tree_structure": {
"steps": [
{"id": "s1", "type": "procedure_step", "title": "Step",
"description": "Do it", "content_type": "action"},
{"id": "end", "type": "procedure_end", "title": "Done"},
]
},
},
headers=auth_headers,
)
assert resp.status_code == 201, resp.text
tree_id = resp.json()["id"]
batch_resp = await client.post(
"/api/v1/sessions/batch",
json={"tree_id": tree_id, "targets": [{"label": "SRV-01"}]},
headers=auth_headers,
)
assert batch_resp.status_code == 400

View File

@@ -0,0 +1,140 @@
"""Tests for maintenance schedule CRUD."""
import pytest
from httpx import AsyncClient
async def _create_maintenance_tree(client, headers):
resp = await client.post(
"/api/v1/trees",
json={
"name": "Scheduled Patch",
"tree_type": "maintenance",
"tree_structure": {
"steps": [
{"id": "s1", "type": "procedure_step", "title": "Step",
"description": "Do it", "content_type": "action"},
{"id": "end", "type": "procedure_end", "title": "Done"},
]
},
},
headers=headers,
)
assert resp.status_code == 201, resp.text
return resp.json()["id"]
@pytest.mark.asyncio
async def test_create_schedule(client: AsyncClient, auth_headers: dict):
tree_id = await _create_maintenance_tree(client, auth_headers)
resp = await client.post(
"/api/v1/maintenance-schedules",
json={
"tree_id": tree_id,
"cron_expression": "0 9 15 * *",
"timezone": "America/New_York",
},
headers=auth_headers,
)
assert resp.status_code == 201, resp.text
data = resp.json()
assert data["cron_expression"] == "0 9 15 * *"
assert data["timezone"] == "America/New_York"
assert data["is_active"] is True
assert data["next_run_at"] is not None
@pytest.mark.asyncio
async def test_duplicate_schedule_rejected(client: AsyncClient, auth_headers: dict):
"""Cannot create two schedules for the same tree."""
tree_id = await _create_maintenance_tree(client, auth_headers)
await client.post(
"/api/v1/maintenance-schedules",
json={"tree_id": tree_id, "cron_expression": "0 0 1 * *", "timezone": "UTC"},
headers=auth_headers,
)
resp = await client.post(
"/api/v1/maintenance-schedules",
json={"tree_id": tree_id, "cron_expression": "0 6 1 * *", "timezone": "UTC"},
headers=auth_headers,
)
assert resp.status_code == 409
@pytest.mark.asyncio
async def test_get_schedule_for_tree(client: AsyncClient, auth_headers: dict):
tree_id = await _create_maintenance_tree(client, auth_headers)
await client.post(
"/api/v1/maintenance-schedules",
json={"tree_id": tree_id, "cron_expression": "0 0 1 * *", "timezone": "UTC"},
headers=auth_headers,
)
resp = await client.get(f"/api/v1/maintenance-schedules/tree/{tree_id}", headers=auth_headers)
assert resp.status_code == 200
assert resp.json()["cron_expression"] == "0 0 1 * *"
@pytest.mark.asyncio
async def test_disable_schedule(client: AsyncClient, auth_headers: dict):
tree_id = await _create_maintenance_tree(client, auth_headers)
create = await client.post(
"/api/v1/maintenance-schedules",
json={"tree_id": tree_id, "cron_expression": "0 6 * * 1", "timezone": "UTC"},
headers=auth_headers,
)
sched_id = create.json()["id"]
resp = await client.patch(
f"/api/v1/maintenance-schedules/{sched_id}",
json={"is_active": False},
headers=auth_headers,
)
assert resp.status_code == 200
assert resp.json()["is_active"] is False
@pytest.mark.asyncio
async def test_get_schedule_not_found(client: AsyncClient, auth_headers: dict):
tree_id = await _create_maintenance_tree(client, auth_headers)
resp = await client.get(f"/api/v1/maintenance-schedules/tree/{tree_id}", headers=auth_headers)
assert resp.status_code == 404
@pytest.mark.asyncio
async def test_cannot_schedule_other_teams_tree(client: AsyncClient, auth_headers: dict, test_db):
"""User cannot create a schedule for a tree belonging to another team."""
import uuid as _uuid
from app.models.team import Team
from app.models.tree import Tree
# Create a tree belonging to a DIFFERENT team directly in DB
other_team = Team(name=f"Other Team {_uuid.uuid4()}")
test_db.add(other_team)
await test_db.flush()
other_tree = Tree(
name="Other Team Tree",
tree_type="maintenance",
team_id=other_team.id,
tree_structure={
"steps": [
{"id": "s1", "type": "procedure_step", "title": "Step",
"description": "Do it", "content_type": "action"},
{"id": "end", "type": "procedure_end", "title": "Done"},
]
},
status="published",
visibility="team",
)
test_db.add(other_tree)
await test_db.flush()
# Current user (from auth_headers) tries to schedule it
resp = await client.post(
"/api/v1/maintenance-schedules",
json={
"tree_id": str(other_tree.id),
"cron_expression": "0 9 1 * *",
"timezone": "UTC",
},
headers=auth_headers,
)
assert resp.status_code in (403, 404) # either is acceptable

View File

@@ -0,0 +1,73 @@
"""Tests for maintenance tree type."""
import pytest
from httpx import AsyncClient
@pytest.mark.asyncio
async def test_create_maintenance_tree(client: AsyncClient, auth_headers: dict):
"""Maintenance tree type is accepted by the API."""
resp = await client.post(
"/api/v1/trees",
json={
"name": "Update FSLogix",
"description": "Monthly FSLogix update procedure",
"tree_type": "maintenance",
"tree_structure": {
"steps": [
{"id": "step-1", "type": "procedure_step", "title": "Download installer",
"description": "Get latest FSLogix from Microsoft", "content_type": "action"},
{"id": "step-end", "type": "procedure_end", "title": "Complete"},
]
},
},
headers=auth_headers,
)
assert resp.status_code == 201, resp.text
data = resp.json()
assert data["tree_type"] == "maintenance"
@pytest.mark.asyncio
async def test_list_maintenance_trees_filter(client: AsyncClient, auth_headers: dict):
"""Filtering by tree_type=maintenance returns only maintenance trees."""
await client.post(
"/api/v1/trees",
json={
"name": "Maintenance Only",
"tree_type": "maintenance",
"tree_structure": {
"steps": [
{"id": "s1", "type": "procedure_step", "title": "Step",
"description": "Do it", "content_type": "action"},
{"id": "end", "type": "procedure_end", "title": "Done"},
]
},
},
headers=auth_headers,
)
resp = await client.get("/api/v1/trees?tree_type=maintenance", headers=auth_headers)
assert resp.status_code == 200
trees = resp.json()
assert all(t["tree_type"] == "maintenance" for t in trees)
assert len(trees) >= 1
@pytest.mark.asyncio
async def test_invalid_tree_type_rejected(client: AsyncClient, auth_headers: dict):
"""An unrecognized tree_type value is rejected with 422."""
resp = await client.post(
"/api/v1/trees",
json={
"name": "Bad Type",
"tree_type": "garbage",
"tree_structure": {
"steps": [
{"id": "s1", "type": "procedure_step", "title": "Step",
"description": "Do it", "content_type": "action"},
{"id": "end", "type": "procedure_end", "title": "Done"},
]
},
},
headers=auth_headers,
)
assert resp.status_code == 422

View File

@@ -0,0 +1,153 @@
"""Tests for target lists CRUD."""
import pytest
from httpx import AsyncClient
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.team import Team
from app.models.user import User
from sqlalchemy import select
@pytest.fixture
async def auth_headers(client: AsyncClient, test_db: AsyncSession, test_user: dict):
"""Override auth_headers to ensure the test user has a team_id assigned."""
# Fetch the user from DB and assign a team
result = await test_db.execute(select(User).where(User.email == test_user["email"]))
user = result.scalar_one()
# Create a team and assign the user to it
team = Team(name="Test Team")
test_db.add(team)
await test_db.flush()
user.team_id = team.id
await test_db.commit()
# Re-login to get a fresh token
login_data = {
"email": test_user["email"],
"password": test_user["password"],
}
resp = await client.post("/api/v1/auth/login/json", json=login_data)
assert resp.status_code == 200
token_data = resp.json()
return {"Authorization": f"Bearer {token_data['access_token']}"}
@pytest.mark.asyncio
async def test_create_target_list(client: AsyncClient, auth_headers: dict):
resp = await client.post(
"/api/v1/target-lists/",
json={
"name": "RDS Farm A",
"description": "Production RDS servers",
"targets": [
{"label": "RDS-01", "notes": "192.168.1.10"},
{"label": "RDS-02", "notes": "192.168.1.11"},
],
},
headers=auth_headers,
)
assert resp.status_code == 201, resp.text
data = resp.json()
assert data["name"] == "RDS Farm A"
assert len(data["targets"]) == 2
@pytest.mark.asyncio
async def test_list_target_lists(client: AsyncClient, auth_headers: dict):
resp = await client.get("/api/v1/target-lists/", headers=auth_headers)
assert resp.status_code == 200
assert isinstance(resp.json(), list)
@pytest.mark.asyncio
async def test_get_target_list(client: AsyncClient, auth_headers: dict):
create = await client.post(
"/api/v1/target-lists/",
json={"name": "Get Test", "targets": [{"label": "SRV-01"}]},
headers=auth_headers,
)
list_id = create.json()["id"]
resp = await client.get(f"/api/v1/target-lists/{list_id}", headers=auth_headers)
assert resp.status_code == 200
assert resp.json()["name"] == "Get Test"
@pytest.mark.asyncio
async def test_update_target_list(client: AsyncClient, auth_headers: dict):
create = await client.post(
"/api/v1/target-lists/",
json={"name": "Old Name", "targets": [{"label": "SRV-01"}]},
headers=auth_headers,
)
list_id = create.json()["id"]
resp = await client.put(
f"/api/v1/target-lists/{list_id}",
json={"name": "New Name", "targets": [{"label": "SRV-01"}, {"label": "SRV-02"}]},
headers=auth_headers,
)
assert resp.status_code == 200
assert resp.json()["name"] == "New Name"
assert len(resp.json()["targets"]) == 2
@pytest.mark.asyncio
async def test_delete_target_list(client: AsyncClient, auth_headers: dict):
create = await client.post(
"/api/v1/target-lists/",
json={"name": "To Delete", "targets": [{"label": "X"}]},
headers=auth_headers,
)
list_id = create.json()["id"]
resp = await client.delete(f"/api/v1/target-lists/{list_id}", headers=auth_headers)
assert resp.status_code == 204
get = await client.get(f"/api/v1/target-lists/{list_id}", headers=auth_headers)
assert get.status_code == 404
@pytest.mark.asyncio
async def test_cannot_access_other_teams_list(client: AsyncClient, auth_headers: dict, test_db):
"""User from team B cannot access team A's list."""
import uuid
from app.models.team import Team
from app.models.user import User
from app.core.security import get_password_hash
# Create team A list using existing auth_headers
create = await client.post(
"/api/v1/target-lists/",
json={"name": "Team A List", "targets": [{"label": "SRV-A"}]},
headers=auth_headers,
)
assert create.status_code == 201
list_id = create.json()["id"]
# Create a separate team B with its own user
team_b = Team(name=f"Team B {uuid.uuid4()}")
test_db.add(team_b)
await test_db.flush()
user_b = User(
email=f"userb_{uuid.uuid4()}@test.com",
password_hash=get_password_hash("password123"),
name="User B",
is_active=True,
team_id=team_b.id,
role="engineer",
)
test_db.add(user_b)
await test_db.flush()
# Get auth token for user B
login = await client.post(
"/api/v1/auth/login/json",
json={"email": user_b.email, "password": "password123"},
)
assert login.status_code == 200
token_b = login.json()["access_token"]
headers_b = {"Authorization": f"Bearer {token_b}"}
# Team B cannot access Team A's list
resp = await client.get(f"/api/v1/target-lists/{list_id}", headers=headers_b)
assert resp.status_code == 404

View File

@@ -13,3 +13,5 @@ export { default as adminApi } from './admin'
export { treeMarkdownApi } from './treeMarkdown'
export { default as pinnedFlowsApi } from './pinnedFlows'
export { default as analyticsApi } from './analytics'
export { targetListsApi } from './targetLists'
export { maintenanceSchedulesApi, batchLaunchApi } from './maintenanceSchedules'

View File

@@ -0,0 +1,24 @@
import { apiClient } from './client'
import type {
MaintenanceSchedule,
MaintenanceScheduleCreate,
MaintenanceScheduleUpdate,
BatchLaunchRequest,
BatchLaunchResponse,
} from '@/types'
export const maintenanceSchedulesApi = {
getForTree: (treeId: string): Promise<MaintenanceSchedule> =>
apiClient.get(`/maintenance-schedules/tree/${treeId}`).then(r => r.data),
create: (data: MaintenanceScheduleCreate): Promise<MaintenanceSchedule> =>
apiClient.post('/maintenance-schedules', data).then(r => r.data),
update: (id: string, data: MaintenanceScheduleUpdate): Promise<MaintenanceSchedule> =>
apiClient.patch(`/maintenance-schedules/${id}`, data).then(r => r.data),
}
export const batchLaunchApi = {
launch: (data: BatchLaunchRequest): Promise<BatchLaunchResponse> =>
apiClient.post('/sessions/batch', data).then(r => r.data),
}

View File

@@ -0,0 +1,19 @@
import { apiClient } from './client'
import type { TargetList, TargetListCreate } from '@/types'
export const targetListsApi = {
list: (): Promise<TargetList[]> =>
apiClient.get('/target-lists/').then(r => r.data),
get: (id: string): Promise<TargetList> =>
apiClient.get(`/target-lists/${id}`).then(r => r.data),
create: (data: TargetListCreate): Promise<TargetList> =>
apiClient.post('/target-lists/', data).then(r => r.data),
update: (id: string, data: Partial<TargetListCreate>): Promise<TargetList> =>
apiClient.put(`/target-lists/${id}`, data).then(r => r.data),
delete: (id: string): Promise<void> =>
apiClient.delete(`/target-lists/${id}`).then(() => undefined),
}

View File

@@ -29,7 +29,7 @@ export function Sidebar() {
const [activeTags, setActiveTags] = useState<string[]>([])
const [activeSessionCount, setActiveSessionCount] = useState(0)
const [pinnedFlows, setPinnedFlows] = useState<PinnedFlow[]>([])
const [treeCounts, setTreeCounts] = useState({ total: 0, troubleshooting: 0, procedural: 0 })
const [treeCounts, setTreeCounts] = useState({ total: 0, troubleshooting: 0, procedural: 0, maintenance: 0 })
// Fetch sidebar data on mount
useEffect(() => {
@@ -55,7 +55,8 @@ export function Sidebar() {
const total = allTrees.length
const troubleshooting = allTrees.filter(t => t.tree_type === 'troubleshooting').length
const procedural = allTrees.filter(t => t.tree_type === 'procedural').length
setTreeCounts({ total, troubleshooting, procedural })
const maintenance = allTrees.filter(t => t.tree_type === 'maintenance').length
setTreeCounts({ total, troubleshooting, procedural, maintenance })
} catch {
// Silently handle errors
}
@@ -145,6 +146,7 @@ export function Sidebar() {
children={[
{ href: '/trees?type=troubleshooting', label: 'Troubleshooting', count: treeCounts.troubleshooting || undefined },
{ href: '/trees?type=procedural', label: 'Projects', count: treeCounts.procedural || undefined },
{ href: '/trees?type=maintenance', label: 'Maintenance', count: treeCounts.maintenance || undefined },
]}
/>
<NavItem href="/my-trees" icon={PenLine} label="Flow Editor" />

View File

@@ -1,5 +1,5 @@
import { Link } from 'react-router-dom'
import { Pencil, Globe, Lock, Trash2, GitBranch, FileText } from 'lucide-react'
import { Pencil, Globe, Lock, Trash2, GitBranch, FileText, Wrench } from 'lucide-react'
import type { TreeListItem } from '@/types'
import { TagBadges } from '@/components/common/TagBadges'
import { AddToFolderMenu } from './AddToFolderMenu'
@@ -41,6 +41,12 @@ export function TreeGridView({
Draft
</span>
)}
{tree.tree_type === 'maintenance' && (
<span className="inline-flex items-center gap-1 rounded-full border border-amber-500/30 bg-amber-500/10 px-2 py-0.5 font-label text-[0.625rem] uppercase tracking-wide text-amber-400">
<Wrench className="h-3 w-3" />
Maintenance
</span>
)}
</div>
<div className="flex items-center gap-2">
{tree.is_public ? (

View File

@@ -1,5 +1,5 @@
import { Link } from 'react-router-dom'
import { Pencil, Globe, Lock, GitBranch, FileText, Trash2 } from 'lucide-react'
import { Pencil, Globe, Lock, GitBranch, FileText, Trash2, Wrench } from 'lucide-react'
import type { TreeListItem } from '@/types'
import { TagBadges } from '@/components/common/TagBadges'
import { AddToFolderMenu } from './AddToFolderMenu'
@@ -42,6 +42,12 @@ export function TreeListView({
Draft
</span>
)}
{tree.tree_type === 'maintenance' && (
<span className="inline-flex items-center gap-1 rounded-full border border-amber-500/30 bg-amber-500/10 px-2 py-0.5 font-label text-[0.625rem] uppercase tracking-wide text-amber-400 flex-shrink-0">
<Wrench className="h-3 w-3" />
Maintenance
</span>
)}
{tree.is_public ? (
<span title="Public tree">
<Globe className="h-3.5 w-3.5 text-muted-foreground flex-shrink-0" />

View File

@@ -1,6 +1,6 @@
import { useState } from 'react'
import { Link } from 'react-router-dom'
import { Pencil, Globe, Lock, ChevronUp, ChevronDown, GitBranch, FileText, Trash2 } from 'lucide-react'
import { Pencil, Globe, Lock, ChevronUp, ChevronDown, GitBranch, FileText, Trash2, Wrench } from 'lucide-react'
import type { TreeListItem } from '@/types'
import { TagBadges } from '@/components/common/TagBadges'
import { AddToFolderMenu } from './AddToFolderMenu'
@@ -144,6 +144,12 @@ export function TreeTableView({
Draft
</span>
)}
{tree.tree_type === 'maintenance' && (
<span className="inline-flex items-center gap-1 rounded-full border border-amber-500/30 bg-amber-500/10 px-2 py-0.5 font-label text-[0.625rem] uppercase tracking-wide text-amber-400 flex-shrink-0">
<Wrench className="h-3 w-3" />
Maintenance
</span>
)}
{tree.is_public ? (
<span title="Public tree">
<Globe className="h-3.5 w-3.5 text-muted-foreground flex-shrink-0" />

View File

@@ -0,0 +1,202 @@
import { useState, useEffect } from 'react'
import { X, List, Clock, PenLine, ExternalLink } from 'lucide-react'
import { cn } from '@/lib/utils'
import { toast } from '@/lib/toast'
import { targetListsApi, batchLaunchApi } from '@/api'
import type { TargetList, TargetEntry } from '@/types'
interface BatchLaunchModalProps {
treeId: string
treeName: string
onClose: () => void
onLaunched: (batchId: string, count: number) => void
}
type TabId = 'manual' | 'saved' | 'previous' | 'psa'
export function BatchLaunchModal({ treeId, treeName, onClose, onLaunched }: BatchLaunchModalProps) {
const [activeTab, setActiveTab] = useState<TabId>('manual')
const [savedLists, setSavedLists] = useState<TargetList[] | null>(null)
const [selectedListId, setSelectedListId] = useState<string | null>(null)
const [manualInput, setManualInput] = useState('')
const [isLaunching, setIsLaunching] = useState(false)
useEffect(() => {
if (activeTab === 'saved' && savedLists === null) {
targetListsApi.list()
.then(setSavedLists)
.catch(() => toast.error('Failed to load saved lists'))
}
}, [activeTab, savedLists])
const getTargets = (): TargetEntry[] => {
if (activeTab === 'saved' && selectedListId && savedLists) {
const list = savedLists.find(l => l.id === selectedListId)
return list?.targets ?? []
}
if (activeTab === 'manual') {
return manualInput
.split('\n')
.map(l => l.trim())
.filter(Boolean)
.map(label => ({ label }))
}
return []
}
const targets = getTargets()
const handleLaunch = async () => {
if (targets.length === 0) {
toast.error('Add at least one target before launching')
return
}
if (targets.length > 100) {
toast.error('Maximum 100 targets per batch')
return
}
setIsLaunching(true)
try {
const result = await batchLaunchApi.launch({ tree_id: treeId, targets })
toast.success(`${result.count} sessions created`)
onLaunched(result.batch_id, result.count)
} catch {
toast.error('Failed to launch batch')
} finally {
setIsLaunching(false)
}
}
const tabs: { id: TabId; label: string; icon: React.ReactNode }[] = [
{ id: 'manual', label: 'Manual Entry', icon: <PenLine className="h-3.5 w-3.5" /> },
{ id: 'saved', label: 'Saved List', icon: <List className="h-3.5 w-3.5" /> },
{ id: 'previous', label: 'Previous Run', icon: <Clock className="h-3.5 w-3.5" /> },
{ id: 'psa', label: 'PSA / RMM', icon: <ExternalLink className="h-3.5 w-3.5" /> },
]
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
<div className="w-full max-w-lg rounded-xl border border-border bg-card shadow-2xl">
{/* Header */}
<div className="flex items-center justify-between border-b border-border px-6 py-4">
<div>
<h2 className="text-base font-semibold text-foreground">Batch Launch</h2>
<p className="text-[0.8125rem] text-muted-foreground">{treeName}</p>
</div>
<button onClick={onClose} className="rounded-lg p-1.5 text-muted-foreground hover:bg-accent hover:text-foreground">
<X className="h-4 w-4" />
</button>
</div>
{/* Tabs */}
<div className="flex border-b border-border px-4 pt-2">
{tabs.map(tab => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={cn(
"flex items-center gap-1.5 px-3 py-2 font-label text-[0.6875rem] uppercase tracking-wide transition-colors",
activeTab === tab.id
? "border-b-2 border-primary text-foreground"
: "text-muted-foreground hover:text-foreground"
)}
>
{tab.icon}
{tab.label}
</button>
))}
</div>
{/* Content */}
<div className="p-6">
{activeTab === 'manual' && (
<div className="space-y-2">
<label className="font-label text-[0.6875rem] uppercase tracking-wide text-muted-foreground">
Server names (one per line)
</label>
<textarea
className="h-40 w-full rounded-lg border border-border bg-card px-3 py-2 text-[0.875rem] text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary/20"
placeholder={"RDS-01\nRDS-02\nRDS-03"}
value={manualInput}
onChange={e => setManualInput(e.target.value)}
/>
</div>
)}
{activeTab === 'saved' && (
<div className="space-y-2">
{savedLists === null ? (
<div className="flex h-24 items-center justify-center">
<div className="h-4 w-4 animate-spin rounded-full border-2 border-primary border-t-transparent" />
</div>
) : savedLists.length === 0 ? (
<p className="text-[0.875rem] text-muted-foreground">
No saved lists yet. Create one in Team Settings &rarr; Target Lists.
</p>
) : (
savedLists.map(list => (
<button
key={list.id}
onClick={() => setSelectedListId(list.id)}
className={cn(
"w-full rounded-lg border px-4 py-3 text-left transition-colors",
selectedListId === list.id
? "border-primary/30 bg-primary/10 text-foreground"
: "border-border text-muted-foreground hover:bg-accent hover:text-foreground"
)}
>
<div className="font-medium">{list.name}</div>
<div className="text-[0.8125rem]">{list.targets.length} targets</div>
</button>
))
)}
</div>
)}
{activeTab === 'previous' && (
<p className="text-[0.875rem] text-muted-foreground">Previous run history coming soon.</p>
)}
{activeTab === 'psa' && (
<div className="rounded-lg border border-border bg-accent/30 p-6 text-center">
<ExternalLink className="mx-auto mb-2 h-8 w-8 text-muted-foreground" />
<p className="font-medium text-foreground">PSA / RMM Import</p>
<p className="mt-1 text-[0.8125rem] text-muted-foreground">
ConnectWise, Kaseya, and RMM integrations coming soon.
</p>
</div>
)}
</div>
{/* Preview */}
{targets.length > 0 && (
<div className="border-t border-border px-6 py-3">
<p className="text-[0.8125rem] text-muted-foreground">
Will create{' '}
<span className="font-semibold text-foreground">{targets.length} sessions</span>:{' '}
{targets.slice(0, 5).map(t => t.label).join(', ')}
{targets.length > 5 && ` +${targets.length - 5} more`}
</p>
</div>
)}
{/* Footer */}
<div className="flex justify-end gap-2 border-t border-border px-6 py-4">
<button
onClick={onClose}
className="rounded-lg border border-border px-4 py-2 text-[0.875rem] text-muted-foreground hover:bg-accent hover:text-foreground"
>
Cancel
</button>
<button
onClick={handleLaunch}
disabled={isLaunching || targets.length === 0}
className="rounded-lg bg-gradient-brand px-4 py-2 text-[0.875rem] font-medium text-white shadow-lg shadow-primary/20 hover:opacity-90 disabled:opacity-50"
>
{isLaunching ? 'Launching\u2026' : targets.length > 0 ? `Launch ${targets.length} Sessions` : 'Launch'}
</button>
</div>
</div>
</div>
)
}

View File

@@ -1,7 +1,7 @@
/**
* Shared routing helpers for tree/session navigation.
* Centralizes the logic for determining the correct navigation path
* based on tree type (troubleshooting vs procedural).
* based on tree type (troubleshooting vs procedural vs maintenance).
*/
/**
@@ -14,6 +14,9 @@ export function getTreeNavigatePath(
if (treeType === 'procedural') {
return `/flows/${treeId}/navigate`
}
if (treeType === 'maintenance') {
return `/flows/${treeId}/maintenance`
}
return `/trees/${treeId}/navigate`
}
@@ -24,7 +27,7 @@ export function getTreeEditorPath(
treeId: string,
treeType?: string
): string {
if (treeType === 'procedural') {
if (treeType === 'procedural' || treeType === 'maintenance') {
return `/flows/${treeId}/edit`
}
return `/trees/${treeId}/edit`

View File

@@ -1,6 +1,6 @@
import { useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
import { Building2, Users, Mail, Crown, Loader2, AlertCircle, Check, X, Settings, FolderTree, RefreshCw } from 'lucide-react'
import { Building2, Users, Mail, Crown, Loader2, AlertCircle, Check, X, Settings, FolderTree, Server, RefreshCw } from 'lucide-react'
import { accountsApi } from '@/api/accounts'
import type { Account, AccountMember, AccountInvite } from '@/types'
import { cn } from '@/lib/utils'
@@ -496,6 +496,23 @@ export function AccountSettingsPage() {
</Link>
)}
{/* Target Lists Link (owners only) */}
{isAccountOwner && (
<Link
to="/account/target-lists"
className="bg-card border border-border rounded-xl p-4 sm:p-6 flex items-center justify-between group hover:border-border transition-all"
>
<div className="flex items-center gap-3">
<Server className="h-5 w-5 text-muted-foreground" />
<div>
<h2 className="text-lg font-semibold text-foreground">Target Lists</h2>
<p className="text-sm text-muted-foreground">Saved server lists for maintenance flow batch launching</p>
</div>
</div>
<span className="text-muted-foreground group-hover:text-foreground transition-colors">&rarr;</span>
</Link>
)}
{/* Preferences Section */}
<div className="bg-card border border-border rounded-xl p-4 sm:p-6">
<div className="flex items-center gap-2">

View File

@@ -0,0 +1,198 @@
import { useEffect, useState } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import { Wrench, Calendar, Play, Settings, Clock, CheckCircle, AlertCircle } from 'lucide-react'
import { treesApi } from '@/api/trees'
import { sessionsApi } from '@/api/sessions'
import { maintenanceSchedulesApi } from '@/api/maintenanceSchedules'
import { BatchLaunchModal } from '@/components/maintenance/BatchLaunchModal'
import { toast } from '@/lib/toast'
import { cn } from '@/lib/utils'
import type { Tree, MaintenanceSchedule, Session } from '@/types'
export default function MaintenanceFlowDetailPage() {
const { id } = useParams<{ id: string }>()
const navigate = useNavigate()
const [tree, setTree] = useState<Tree | null>(null)
const [schedule, setSchedule] = useState<MaintenanceSchedule | null>(null)
const [recentSessions, setRecentSessions] = useState<Session[]>([])
const [showBatchModal, setShowBatchModal] = useState(false)
const [isLoading, setIsLoading] = useState(true)
useEffect(() => {
if (!id) return
const load = async () => {
try {
const treeData = await treesApi.get(id)
setTree(treeData)
// Load recent sessions for this tree
try {
const sessionData = await sessionsApi.list({ tree_id: id, size: 30 })
setRecentSessions(Array.isArray(sessionData) ? sessionData : [])
} catch {
// Sessions load is optional
}
// Try to load schedule (404 is fine)
try {
const sched = await maintenanceSchedulesApi.getForTree(id)
setSchedule(sched)
} catch {
// No schedule yet is fine
}
} catch {
toast.error('Failed to load maintenance flow')
navigate('/trees?type=maintenance')
} finally {
setIsLoading(false)
}
}
load()
}, [id, navigate])
const handleLaunched = (_batchId: string, count: number) => {
setShowBatchModal(false)
toast.success(`${count} sessions created — view them in Sessions`)
navigate('/sessions')
}
if (isLoading) {
return (
<div className="flex h-64 items-center justify-center">
<div className="h-6 w-6 animate-spin rounded-full border-2 border-primary border-t-transparent" />
</div>
)
}
if (!tree) return null
// Group sessions by batch_id for run history
const batchMap = new Map<string, Session[]>()
for (const s of recentSessions) {
const key = s.batch_id ?? s.id
const existing = batchMap.get(key) ?? []
batchMap.set(key, [...existing, s])
}
const batches = Array.from(batchMap.entries()).slice(0, 10)
return (
<div className="mx-auto max-w-4xl space-y-6 p-6">
{/* Header */}
<div className="flex items-start justify-between">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-amber-500/10 text-amber-400">
<Wrench className="h-5 w-5" />
</div>
<div>
<h1 className="text-xl font-semibold text-foreground">{tree.name}</h1>
{tree.description && (
<p className="text-[0.8125rem] text-muted-foreground">{tree.description}</p>
)}
</div>
</div>
<div className="flex gap-2">
<button
onClick={() => navigate(`/flows/${id}/edit`)}
className="flex items-center gap-1.5 rounded-lg border border-border px-3 py-2 text-[0.875rem] text-muted-foreground hover:bg-accent hover:text-foreground"
>
<Settings className="h-3.5 w-3.5" />
Edit Flow
</button>
<button
onClick={() => setShowBatchModal(true)}
className="flex items-center gap-1.5 rounded-lg bg-gradient-brand px-4 py-2 text-[0.875rem] font-medium text-white shadow-lg shadow-primary/20 hover:opacity-90"
>
<Play className="h-3.5 w-3.5" />
Batch Launch
</button>
</div>
</div>
{/* Schedule Panel */}
<div className="rounded-xl border border-border bg-card p-5">
<div className="flex items-center gap-2 mb-3">
<Calendar className="h-4 w-4 text-muted-foreground" />
<h2 className="font-semibold text-foreground">Schedule</h2>
</div>
{schedule ? (
<div className="space-y-2 text-[0.875rem]">
<div className="flex flex-wrap items-center gap-2">
<span className={cn(
"inline-flex items-center gap-1 rounded-full px-2 py-0.5 font-label text-[0.625rem] uppercase tracking-wide",
schedule.is_active
? "bg-emerald-500/10 text-emerald-400"
: "bg-muted text-muted-foreground"
)}>
{schedule.is_active
? <CheckCircle className="h-3 w-3" />
: <AlertCircle className="h-3 w-3" />}
{schedule.is_active ? 'Active' : 'Paused'}
</span>
<code className="rounded bg-accent px-1.5 py-0.5 text-[0.8125rem] text-foreground">
{schedule.cron_expression}
</code>
<span className="text-muted-foreground">({schedule.timezone})</span>
</div>
{schedule.next_run_at && (
<p className="text-muted-foreground">
Next run: {new Date(schedule.next_run_at).toLocaleString()}
</p>
)}
</div>
) : (
<p className="text-[0.875rem] text-muted-foreground">
No schedule configured. Sessions can still be launched manually via Batch Launch.
</p>
)}
</div>
{/* Run History */}
<div className="rounded-xl border border-border bg-card p-5">
<div className="flex items-center gap-2 mb-4">
<Clock className="h-4 w-4 text-muted-foreground" />
<h2 className="font-semibold text-foreground">Run History</h2>
</div>
{batches.length === 0 ? (
<p className="text-[0.875rem] text-muted-foreground">No runs yet. Launch a batch to get started.</p>
) : (
<div className="space-y-2">
{batches.map(([batchKey, sessions]) => {
const completed = sessions.filter(s => s.completed_at).length
const total = sessions.length
const date = sessions[0]?.started_at
return (
<div key={batchKey} className="flex items-center justify-between rounded-lg border border-border px-4 py-3">
<div>
<p className="text-[0.875rem] font-medium text-foreground">
{total} target{total !== 1 ? 's' : ''}
</p>
{date && (
<p className="text-[0.8125rem] text-muted-foreground">
{new Date(date).toLocaleDateString()}
</p>
)}
</div>
<span className={cn(
"font-label text-[0.75rem] uppercase tracking-wide",
completed === total ? "text-emerald-400" : "text-amber-400"
)}>
{completed}/{total} complete
</span>
</div>
)
})}
</div>
)}
</div>
{showBatchModal && (
<BatchLaunchModal
treeId={id!}
treeName={tree.name}
onClose={() => setShowBatchModal(false)}
onLaunched={handleLaunched}
/>
)}
</div>
)
}

View File

@@ -37,14 +37,14 @@ export function TreeLibraryPage() {
// Read type filter from URL query params (e.g. /trees?type=procedural)
const urlType = searchParams.get('type')
const [typeFilter, setTypeFilter] = useState<'all' | 'troubleshooting' | 'procedural'>(
urlType === 'troubleshooting' || urlType === 'procedural' ? urlType : 'all'
const [typeFilter, setTypeFilter] = useState<'all' | 'troubleshooting' | 'procedural' | 'maintenance'>(
urlType === 'troubleshooting' || urlType === 'procedural' || urlType === 'maintenance' ? urlType : 'all'
)
// Sync filters when URL changes (e.g. clicking sidebar categories/tags or nav sub-items)
useEffect(() => {
const t = searchParams.get('type')
if (t === 'troubleshooting' || t === 'procedural') {
if (t === 'troubleshooting' || t === 'procedural' || t === 'maintenance') {
setTypeFilter(t)
} else {
setTypeFilter('all')
@@ -253,14 +253,16 @@ export function TreeLibraryPage() {
<div className="mb-6 flex flex-col gap-4 sm:mb-8 sm:flex-row sm:items-start sm:justify-between">
<div>
<h1 className="text-2xl font-bold font-heading text-foreground sm:text-3xl">
{typeFilter === 'procedural' ? 'Projects' : typeFilter === 'troubleshooting' ? 'Troubleshooting Flows' : 'Flow Library'}
{typeFilter === 'procedural' ? 'Projects' : typeFilter === 'troubleshooting' ? 'Troubleshooting Flows' : typeFilter === 'maintenance' ? 'Maintenance Flows' : 'Flow Library'}
</h1>
<p className="mt-2 text-muted-foreground">
{typeFilter === 'procedural'
? 'Step-by-step projects and runbooks'
: typeFilter === 'troubleshooting'
? 'Branching decision flows for troubleshooting'
: 'Browse and start troubleshooting flows and projects'}
: typeFilter === 'maintenance'
? 'Scheduled maintenance procedures run across targets'
: 'Browse and start troubleshooting flows and projects'}
</p>
</div>
{canCreateTrees && (
@@ -326,7 +328,7 @@ export function TreeLibraryPage() {
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-4">
<div className="flex rounded-lg border border-border p-0.5">
{(['all', 'troubleshooting', 'procedural'] as const).map((t) => (
{(['all', 'troubleshooting', 'procedural', 'maintenance'] as const).map((t) => (
<button
key={t}
onClick={() => setTypeFilter(t)}
@@ -337,7 +339,7 @@ export function TreeLibraryPage() {
: 'text-muted-foreground hover:text-foreground'
)}
>
{t === 'all' ? 'All' : t === 'troubleshooting' ? 'Troubleshooting' : 'Projects'}
{t === 'all' ? 'All' : t === 'troubleshooting' ? 'Troubleshooting' : t === 'procedural' ? 'Projects' : 'Maintenance'}
</button>
))}
</div>

View File

@@ -0,0 +1,251 @@
import { useEffect, useState } from 'react'
import { Plus, Pencil, Trash2, Server } from 'lucide-react'
import { targetListsApi } from '@/api'
import type { TargetList, TargetListCreate, TargetEntry } from '@/types'
import { toast } from '@/lib/toast'
import { ConfirmDialog } from '@/components/common/ConfirmDialog'
export default function TargetListsPage() {
const [lists, setLists] = useState<TargetList[]>([])
const [isLoading, setIsLoading] = useState(true)
const [showEditor, setShowEditor] = useState(false)
const [editingList, setEditingList] = useState<TargetList | null>(null)
const [deleteTarget, setDeleteTarget] = useState<TargetList | null>(null)
// Editor state
const [editorName, setEditorName] = useState('')
const [editorDescription, setEditorDescription] = useState('')
const [editorTargets, setEditorTargets] = useState('')
const [isSaving, setIsSaving] = useState(false)
const load = async () => {
try {
const data = await targetListsApi.list()
setLists(data)
} catch {
toast.error('Failed to load target lists')
} finally {
setIsLoading(false)
}
}
useEffect(() => { load() }, [])
const openEditor = (list?: TargetList) => {
if (list) {
setEditingList(list)
setEditorName(list.name)
setEditorDescription(list.description ?? '')
setEditorTargets(list.targets.map(t => t.notes ? `${t.label} # ${t.notes}` : t.label).join('\n'))
} else {
setEditingList(null)
setEditorName('')
setEditorDescription('')
setEditorTargets('')
}
setShowEditor(true)
}
const parseTargets = (raw: string): TargetEntry[] =>
raw.split('\n')
.map(l => l.trim())
.filter(Boolean)
.map(line => {
const hashIdx = line.indexOf('#')
if (hashIdx === -1) return { label: line }
return {
label: line.slice(0, hashIdx).trim(),
notes: line.slice(hashIdx + 1).trim() || undefined,
}
})
const handleSave = async () => {
if (!editorName.trim()) { toast.error('Name is required'); return }
const targets = parseTargets(editorTargets)
if (targets.length === 0) { toast.error('Add at least one target'); return }
setIsSaving(true)
try {
const payload: TargetListCreate = {
name: editorName.trim(),
description: editorDescription.trim() || undefined,
targets,
}
if (editingList) {
const updated = await targetListsApi.update(editingList.id, payload)
setLists(prev => prev.map(l => l.id === updated.id ? updated : l))
toast.success('Target list updated')
} else {
const created = await targetListsApi.create(payload)
setLists(prev => [...prev, created])
toast.success('Target list created')
}
setShowEditor(false)
} catch {
toast.error('Failed to save target list')
} finally {
setIsSaving(false)
}
}
const handleDelete = async () => {
if (!deleteTarget) return
try {
await targetListsApi.delete(deleteTarget.id)
setLists(prev => prev.filter(l => l.id !== deleteTarget.id))
toast.success('Target list deleted')
} catch {
toast.error('Failed to delete target list')
} finally {
setDeleteTarget(null)
}
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl font-semibold text-foreground">Target Lists</h1>
<p className="text-[0.8125rem] text-muted-foreground">
Saved server lists for maintenance flow batch launching
</p>
</div>
<button
onClick={() => openEditor()}
className="flex items-center gap-1.5 rounded-lg bg-gradient-brand px-4 py-2 text-[0.875rem] font-medium text-white shadow-lg shadow-primary/20 hover:opacity-90"
>
<Plus className="h-4 w-4" />
New List
</button>
</div>
{isLoading ? (
<div className="flex h-32 items-center justify-center">
<div className="h-5 w-5 animate-spin rounded-full border-2 border-primary border-t-transparent" />
</div>
) : lists.length === 0 ? (
<div className="flex flex-col items-center justify-center rounded-xl border border-border bg-card py-12 text-center">
<Server className="mb-3 h-10 w-10 text-muted-foreground" />
<p className="font-medium text-foreground">No target lists yet</p>
<p className="mt-1 text-[0.8125rem] text-muted-foreground">
Create lists of servers to reuse across maintenance runs
</p>
</div>
) : (
<div className="space-y-3">
{lists.map(list => (
<div
key={list.id}
className="flex items-center justify-between rounded-xl border border-border bg-card px-5 py-4"
>
<div>
<p className="font-medium text-foreground">{list.name}</p>
{list.description && (
<p className="text-[0.8125rem] text-muted-foreground">{list.description}</p>
)}
<p className="mt-0.5 font-label text-[0.6875rem] uppercase tracking-wide text-muted-foreground">
{list.targets.length} target{list.targets.length !== 1 ? 's' : ''}
{list.targets.length > 0 && (
<> &middot; {list.targets.slice(0, 3).map(t => t.label).join(', ')}{list.targets.length > 3 ? '\u2026' : ''}</>
)}
</p>
</div>
<div className="flex items-center gap-1">
<button
onClick={() => openEditor(list)}
className="rounded-lg p-2 text-muted-foreground hover:bg-accent hover:text-foreground"
title="Edit"
>
<Pencil className="h-4 w-4" />
</button>
<button
onClick={() => setDeleteTarget(list)}
className="rounded-lg p-2 text-muted-foreground hover:bg-accent hover:text-red-400"
title="Delete"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
</div>
))}
</div>
)}
{/* Editor Modal */}
{showEditor && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
<div className="w-full max-w-md rounded-xl border border-border bg-card p-6 shadow-2xl">
<h2 className="mb-4 text-base font-semibold text-foreground">
{editingList ? 'Edit Target List' : 'New Target List'}
</h2>
<div className="space-y-4">
<div>
<label className="mb-1 block font-label text-[0.6875rem] uppercase tracking-wide text-muted-foreground">
Name
</label>
<input
type="text"
value={editorName}
onChange={e => setEditorName(e.target.value)}
className="w-full rounded-lg border border-border bg-card px-3 py-2 text-[0.875rem] text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary/20"
placeholder="e.g. RDS Farm A"
/>
</div>
<div>
<label className="mb-1 block font-label text-[0.6875rem] uppercase tracking-wide text-muted-foreground">
Description (optional)
</label>
<input
type="text"
value={editorDescription}
onChange={e => setEditorDescription(e.target.value)}
className="w-full rounded-lg border border-border bg-card px-3 py-2 text-[0.875rem] text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary/20"
placeholder="e.g. Production RDS servers"
/>
</div>
<div>
<label className="mb-1 block font-label text-[0.6875rem] uppercase tracking-wide text-muted-foreground">
Targets &mdash; one per line (add notes after #)
</label>
<textarea
value={editorTargets}
onChange={e => setEditorTargets(e.target.value)}
rows={6}
className="w-full rounded-lg border border-border bg-card px-3 py-2 text-[0.875rem] text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary/20"
placeholder={"RDS-01 # 192.168.1.10\nRDS-02\nRDS-03 # Backup server"}
/>
</div>
</div>
<div className="mt-6 flex justify-end gap-2">
<button
onClick={() => setShowEditor(false)}
className="rounded-lg border border-border px-4 py-2 text-[0.875rem] text-muted-foreground hover:bg-accent hover:text-foreground"
>
Cancel
</button>
<button
onClick={handleSave}
disabled={isSaving}
className="rounded-lg bg-gradient-brand px-4 py-2 text-[0.875rem] font-medium text-white shadow-lg shadow-primary/20 hover:opacity-90 disabled:opacity-50"
>
{isSaving ? 'Saving\u2026' : 'Save'}
</button>
</div>
</div>
</div>
)}
{deleteTarget && (
<ConfirmDialog
isOpen={!!deleteTarget}
onClose={() => setDeleteTarget(null)}
onConfirm={handleDelete}
title="Delete Target List"
message={`Delete "${deleteTarget.name}"? This cannot be undone.`}
confirmLabel="Delete"
confirmVariant="destructive"
/>
)}
</div>
)
}

View File

@@ -24,6 +24,7 @@ const TreeNavigationPage = lazy(() => import('@/pages/TreeNavigationPage'))
const TreeEditorPage = lazy(() => import('@/pages/TreeEditorPage'))
const ProceduralEditorPage = lazy(() => import('@/pages/ProceduralEditorPage'))
const ProceduralNavigationPage = lazy(() => import('@/pages/ProceduralNavigationPage'))
const MaintenanceFlowDetailPage = lazy(() => import('@/pages/MaintenanceFlowDetailPage'))
const SessionHistoryPage = lazy(() => import('@/pages/SessionHistoryPage'))
const SessionDetailPage = lazy(() => import('@/pages/SessionDetailPage'))
const MySharesPage = lazy(() => import('@/pages/MySharesPage'))
@@ -45,6 +46,7 @@ const AdminGlobalCategoriesPage = lazy(() => import('@/pages/admin/GlobalCategor
// Account pages
const AccountLayout = lazy(() => import('@/components/account/AccountLayout'))
const TeamCategoriesPage = lazy(() => import('@/pages/account/TeamCategoriesPage'))
const TargetListsPage = lazy(() => import('@/pages/account/TargetListsPage'))
export const router = createBrowserRouter([
{
@@ -168,6 +170,14 @@ export const router = createBrowserRouter([
</Suspense>
),
},
{
path: 'flows/:id/maintenance',
element: (
<Suspense fallback={<PageLoader />}>
<MaintenanceFlowDetailPage />
</Suspense>
),
},
{
path: 'trees/:id/navigate',
element: (
@@ -328,6 +338,14 @@ export const router = createBrowserRouter([
</Suspense>
),
},
{
path: 'target-lists',
element: (
<Suspense fallback={<PageLoader />}>
<TargetListsPage />
</Suspense>
),
},
],
},
],

View File

@@ -23,3 +23,14 @@ export interface PaginatedResponse<T> {
export interface ApiError {
detail: string
}
export type {
TargetEntry,
TargetList,
TargetListCreate,
MaintenanceSchedule,
MaintenanceScheduleCreate,
MaintenanceScheduleUpdate,
BatchLaunchRequest,
BatchLaunchResponse,
} from './maintenance'

View File

@@ -0,0 +1,65 @@
export interface TargetEntry {
label: string
notes?: string
}
export interface TargetList {
id: string
team_id: string
created_by?: string
name: string
description?: string
targets: TargetEntry[]
created_at: string
updated_at: string
}
export interface TargetListCreate {
name: string
description?: string
targets: TargetEntry[]
}
export interface MaintenanceSchedule {
id: string
tree_id: string
created_by?: string
cron_expression: string
timezone: string
target_list_id?: string
is_active: boolean
next_run_at?: string
last_run_at?: string
created_at: string
updated_at: string
}
export interface MaintenanceScheduleCreate {
tree_id: string
cron_expression: string
timezone: string
target_list_id?: string
}
export interface MaintenanceScheduleUpdate {
cron_expression?: string
timezone?: string
target_list_id?: string
is_active?: boolean
}
export interface BatchLaunchRequest {
tree_id: string
targets: TargetEntry[]
}
export interface BatchLaunchResponse {
batch_id: string
count: number
sessions: Array<{
id: string
batch_id: string
target_label: string
tree_id: string
}>
}

View File

@@ -60,6 +60,8 @@ export interface Session {
scratchpad: string
next_steps: string
session_variables: Record<string, string>
batch_id?: string
target_label?: string
}
export interface SessionCreate {

View File

@@ -58,7 +58,7 @@ export interface TreeStructure {
// --- Procedural Flow Types ---
export type TreeType = 'troubleshooting' | 'procedural'
export type TreeType = 'troubleshooting' | 'procedural' | 'maintenance'
export type IntakeFieldType =
| 'text' | 'textarea' | 'number' | 'ip_address' | 'email' | 'url'