Merge branch 'feat/maintenance-flows'
This commit is contained in:
@@ -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'))"
|
||||
)
|
||||
@@ -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')
|
||||
@@ -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')
|
||||
@@ -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')
|
||||
153
backend/app/api/endpoints/maintenance_schedules.py
Normal file
153
backend/app/api/endpoints/maintenance_schedules.py
Normal 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
|
||||
@@ -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
|
||||
],
|
||||
)
|
||||
|
||||
119
backend/app/api/endpoints/target_lists.py
Normal file
119
backend/app/api/endpoints/target_lists.py
Normal 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()
|
||||
@@ -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)
|
||||
|
||||
144
backend/app/core/scheduler.py
Normal file
144
backend/app/core/scheduler.py
Normal 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")
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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...")
|
||||
|
||||
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
45
backend/app/models/maintenance_schedule.py
Normal file
45
backend/app/models/maintenance_schedule.py
Normal 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),
|
||||
)
|
||||
@@ -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
|
||||
)
|
||||
|
||||
38
backend/app/models/target_list.py
Normal file
38
backend/app/models/target_list.py
Normal 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),
|
||||
)
|
||||
@@ -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'
|
||||
),
|
||||
)
|
||||
|
||||
34
backend/app/schemas/maintenance_schedule.py
Normal file
34
backend/app/schemas/maintenance_schedule.py
Normal 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}
|
||||
@@ -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
|
||||
|
||||
|
||||
34
backend/app/schemas/target_list.py
Normal file
34
backend/app/schemas/target_list.py
Normal 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}
|
||||
@@ -7,7 +7,7 @@ import re
|
||||
|
||||
# --- Tree Type ---
|
||||
|
||||
TreeType = Literal['troubleshooting', 'procedural']
|
||||
TreeType = Literal['troubleshooting', 'procedural', 'maintenance']
|
||||
|
||||
# --- Intake Form Schemas ---
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
132
backend/tests/test_batch_sessions.py
Normal file
132
backend/tests/test_batch_sessions.py
Normal 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
|
||||
140
backend/tests/test_maintenance_schedules.py
Normal file
140
backend/tests/test_maintenance_schedules.py
Normal 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
|
||||
73
backend/tests/test_maintenance_tree_type.py
Normal file
73
backend/tests/test_maintenance_tree_type.py
Normal 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
|
||||
153
backend/tests/test_target_lists.py
Normal file
153
backend/tests/test_target_lists.py
Normal 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
|
||||
@@ -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'
|
||||
|
||||
24
frontend/src/api/maintenanceSchedules.ts
Normal file
24
frontend/src/api/maintenanceSchedules.ts
Normal 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),
|
||||
}
|
||||
19
frontend/src/api/targetLists.ts
Normal file
19
frontend/src/api/targetLists.ts
Normal 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),
|
||||
}
|
||||
@@ -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" />
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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" />
|
||||
|
||||
202
frontend/src/components/maintenance/BatchLaunchModal.tsx
Normal file
202
frontend/src/components/maintenance/BatchLaunchModal.tsx
Normal 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 → 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>
|
||||
)
|
||||
}
|
||||
@@ -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`
|
||||
|
||||
@@ -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">→</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">
|
||||
|
||||
198
frontend/src/pages/MaintenanceFlowDetailPage.tsx
Normal file
198
frontend/src/pages/MaintenanceFlowDetailPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
251
frontend/src/pages/account/TargetListsPage.tsx
Normal file
251
frontend/src/pages/account/TargetListsPage.tsx
Normal 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 && (
|
||||
<> · {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 — 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
@@ -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'
|
||||
|
||||
65
frontend/src/types/maintenance.ts
Normal file
65
frontend/src/types/maintenance.ts
Normal 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
|
||||
}>
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user