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 datetime import datetime, timezone
|
||||||
from typing import Annotated, Optional
|
from typing import Annotated, Optional
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
import uuid
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status, Query
|
from fastapi import APIRouter, Depends, HTTPException, status, Query
|
||||||
from fastapi.responses import PlainTextResponse
|
from fastapi.responses import PlainTextResponse
|
||||||
|
from pydantic import BaseModel, Field as PydanticField
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
|
||||||
@@ -485,3 +487,95 @@ async def save_session_as_tree(
|
|||||||
tree_name=new_tree.name,
|
tree_name=new_tree.name,
|
||||||
message=f"Session saved as {'published' if request_data.status == 'published' else 'draft'} tree"
|
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 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 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 ratings, analytics
|
||||||
|
from app.api.endpoints import target_lists
|
||||||
|
from app.api.endpoints import maintenance_schedules
|
||||||
|
|
||||||
api_router = APIRouter()
|
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(tree_markdown.router)
|
||||||
api_router.include_router(ratings.router)
|
api_router.include_router(ratings.router)
|
||||||
api_router.include_router(analytics.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."""
|
"""Tree validation helper module for draft/published workflow."""
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
PROCEDURAL_TREE_TYPES = {"procedural", "maintenance"}
|
||||||
|
|
||||||
|
|
||||||
class TreeValidationError(Exception):
|
class TreeValidationError(Exception):
|
||||||
"""Custom exception for tree validation errors."""
|
"""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"})
|
errors.append({"field": "name", "message": "Tree must have a name to be published"})
|
||||||
|
|
||||||
# Validate structure based on tree type
|
# 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)
|
structure_valid, structure_errors = validate_procedural_structure(tree_structure)
|
||||||
else:
|
else:
|
||||||
structure_valid, structure_errors = validate_tree_structure(tree_structure)
|
structure_valid, structure_errors = validate_tree_structure(tree_structure)
|
||||||
errors.extend(structure_errors)
|
errors.extend(structure_errors)
|
||||||
|
|
||||||
# Validate intake form if present (procedural only)
|
# 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)
|
form_valid, form_errors = _validate_intake_form(intake_form)
|
||||||
errors.extend(form_errors)
|
errors.extend(form_errors)
|
||||||
|
|
||||||
|
|||||||
@@ -6,11 +6,12 @@ from slowapi import _rate_limit_exceeded_handler
|
|||||||
from slowapi.errors import RateLimitExceeded
|
from slowapi.errors import RateLimitExceeded
|
||||||
|
|
||||||
from app.core.config import settings
|
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.logging_config import setup_logging
|
||||||
from app.core.middleware import RequestLoggingMiddleware, ErrorLoggingMiddleware
|
from app.core.middleware import RequestLoggingMiddleware, ErrorLoggingMiddleware
|
||||||
from app.core.rate_limit import limiter
|
from app.core.rate_limit import limiter
|
||||||
from app.api.router import api_router
|
from app.api.router import api_router
|
||||||
|
from app.core.scheduler import scheduler, load_all_schedules
|
||||||
|
|
||||||
# Initialize logging configuration
|
# Initialize logging configuration
|
||||||
setup_logging()
|
setup_logging()
|
||||||
@@ -26,8 +27,16 @@ async def lifespan(app: FastAPI):
|
|||||||
logger.info(f"ALLOW_RAILWAY_ORIGINS: {settings.ALLOW_RAILWAY_ORIGINS}")
|
logger.info(f"ALLOW_RAILWAY_ORIGINS: {settings.ALLOW_RAILWAY_ORIGINS}")
|
||||||
# Note: In production, use Alembic migrations instead of init_db
|
# Note: In production, use Alembic migrations instead of init_db
|
||||||
# await init_db()
|
# await init_db()
|
||||||
|
|
||||||
|
# Start maintenance schedule runner
|
||||||
|
scheduler.start()
|
||||||
|
async with async_session_maker() as db:
|
||||||
|
await load_all_schedules(db)
|
||||||
|
|
||||||
yield
|
yield
|
||||||
|
|
||||||
# Shutdown
|
# Shutdown
|
||||||
|
scheduler.shutdown(wait=False)
|
||||||
logger.info("Shutting down ResolutionFlow API server...")
|
logger.info("Shutting down ResolutionFlow API server...")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ from .subscription import Subscription
|
|||||||
from .plan_limits import PlanLimits
|
from .plan_limits import PlanLimits
|
||||||
from .account_invite import AccountInvite
|
from .account_invite import AccountInvite
|
||||||
from .tree import Tree
|
from .tree import Tree
|
||||||
|
from .tree_share import TreeShare
|
||||||
from .session import Session
|
from .session import Session
|
||||||
from .attachment import Attachment
|
from .attachment import Attachment
|
||||||
from .invite_code import InviteCode
|
from .invite_code import InviteCode
|
||||||
@@ -22,6 +23,8 @@ from .account_limit_override import AccountLimitOverride
|
|||||||
from .feature_flag import FeatureFlag, PlanFeatureDefault, AccountFeatureOverride
|
from .feature_flag import FeatureFlag, PlanFeatureDefault, AccountFeatureOverride
|
||||||
from .platform_setting import PlatformSetting
|
from .platform_setting import PlatformSetting
|
||||||
from .user_pinned_tree import UserPinnedTree
|
from .user_pinned_tree import UserPinnedTree
|
||||||
|
from .target_list import TargetList
|
||||||
|
from .maintenance_schedule import MaintenanceSchedule
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"User",
|
"User",
|
||||||
@@ -31,6 +34,7 @@ __all__ = [
|
|||||||
"PlanLimits",
|
"PlanLimits",
|
||||||
"AccountInvite",
|
"AccountInvite",
|
||||||
"Tree",
|
"Tree",
|
||||||
|
"TreeShare",
|
||||||
"Session",
|
"Session",
|
||||||
"Attachment",
|
"Attachment",
|
||||||
"InviteCode",
|
"InviteCode",
|
||||||
@@ -55,4 +59,6 @@ __all__ = [
|
|||||||
"AccountFeatureOverride",
|
"AccountFeatureOverride",
|
||||||
"PlatformSetting",
|
"PlatformSetting",
|
||||||
"UserPinnedTree",
|
"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")
|
user: Mapped["User"] = relationship("User", back_populates="sessions")
|
||||||
attachments: Mapped[list["Attachment"]] = relationship("Attachment", back_populates="session")
|
attachments: Mapped[list["Attachment"]] = relationship("Attachment", back_populates="session")
|
||||||
shares: Mapped[list["SessionShare"]] = relationship("SessionShare", back_populates="session", cascade="all, delete-orphan")
|
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'
|
name='ck_trees_status'
|
||||||
),
|
),
|
||||||
CheckConstraint(
|
CheckConstraint(
|
||||||
"tree_type IN ('troubleshooting', 'procedural')",
|
"tree_type IN ('troubleshooting', 'procedural', 'maintenance')",
|
||||||
name='ck_trees_tree_type'
|
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):
|
def normalize_text_fields(cls, v):
|
||||||
return v or ""
|
return v or ""
|
||||||
|
|
||||||
|
batch_id: Optional[UUID] = None
|
||||||
|
target_label: Optional[str] = None
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
from_attributes = True
|
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 ---
|
# --- Tree Type ---
|
||||||
|
|
||||||
TreeType = Literal['troubleshooting', 'procedural']
|
TreeType = Literal['troubleshooting', 'procedural', 'maintenance']
|
||||||
|
|
||||||
# --- Intake Form Schemas ---
|
# --- Intake Form Schemas ---
|
||||||
|
|
||||||
|
|||||||
@@ -30,3 +30,6 @@ resend==2.21.0
|
|||||||
|
|
||||||
# Utilities
|
# Utilities
|
||||||
python-dotenv==1.0.1
|
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 { treeMarkdownApi } from './treeMarkdown'
|
||||||
export { default as pinnedFlowsApi } from './pinnedFlows'
|
export { default as pinnedFlowsApi } from './pinnedFlows'
|
||||||
export { default as analyticsApi } from './analytics'
|
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 [activeTags, setActiveTags] = useState<string[]>([])
|
||||||
const [activeSessionCount, setActiveSessionCount] = useState(0)
|
const [activeSessionCount, setActiveSessionCount] = useState(0)
|
||||||
const [pinnedFlows, setPinnedFlows] = useState<PinnedFlow[]>([])
|
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
|
// Fetch sidebar data on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -55,7 +55,8 @@ export function Sidebar() {
|
|||||||
const total = allTrees.length
|
const total = allTrees.length
|
||||||
const troubleshooting = allTrees.filter(t => t.tree_type === 'troubleshooting').length
|
const troubleshooting = allTrees.filter(t => t.tree_type === 'troubleshooting').length
|
||||||
const procedural = allTrees.filter(t => t.tree_type === 'procedural').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 {
|
} catch {
|
||||||
// Silently handle errors
|
// Silently handle errors
|
||||||
}
|
}
|
||||||
@@ -145,6 +146,7 @@ export function Sidebar() {
|
|||||||
children={[
|
children={[
|
||||||
{ href: '/trees?type=troubleshooting', label: 'Troubleshooting', count: treeCounts.troubleshooting || undefined },
|
{ href: '/trees?type=troubleshooting', label: 'Troubleshooting', count: treeCounts.troubleshooting || undefined },
|
||||||
{ href: '/trees?type=procedural', label: 'Projects', count: treeCounts.procedural || 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" />
|
<NavItem href="/my-trees" icon={PenLine} label="Flow Editor" />
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Link } from 'react-router-dom'
|
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 type { TreeListItem } from '@/types'
|
||||||
import { TagBadges } from '@/components/common/TagBadges'
|
import { TagBadges } from '@/components/common/TagBadges'
|
||||||
import { AddToFolderMenu } from './AddToFolderMenu'
|
import { AddToFolderMenu } from './AddToFolderMenu'
|
||||||
@@ -41,6 +41,12 @@ export function TreeGridView({
|
|||||||
Draft
|
Draft
|
||||||
</span>
|
</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>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{tree.is_public ? (
|
{tree.is_public ? (
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Link } from 'react-router-dom'
|
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 type { TreeListItem } from '@/types'
|
||||||
import { TagBadges } from '@/components/common/TagBadges'
|
import { TagBadges } from '@/components/common/TagBadges'
|
||||||
import { AddToFolderMenu } from './AddToFolderMenu'
|
import { AddToFolderMenu } from './AddToFolderMenu'
|
||||||
@@ -42,6 +42,12 @@ export function TreeListView({
|
|||||||
Draft
|
Draft
|
||||||
</span>
|
</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 ? (
|
{tree.is_public ? (
|
||||||
<span title="Public tree">
|
<span title="Public tree">
|
||||||
<Globe className="h-3.5 w-3.5 text-muted-foreground flex-shrink-0" />
|
<Globe className="h-3.5 w-3.5 text-muted-foreground flex-shrink-0" />
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { Link } from 'react-router-dom'
|
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 type { TreeListItem } from '@/types'
|
||||||
import { TagBadges } from '@/components/common/TagBadges'
|
import { TagBadges } from '@/components/common/TagBadges'
|
||||||
import { AddToFolderMenu } from './AddToFolderMenu'
|
import { AddToFolderMenu } from './AddToFolderMenu'
|
||||||
@@ -144,6 +144,12 @@ export function TreeTableView({
|
|||||||
Draft
|
Draft
|
||||||
</span>
|
</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 ? (
|
{tree.is_public ? (
|
||||||
<span title="Public tree">
|
<span title="Public tree">
|
||||||
<Globe className="h-3.5 w-3.5 text-muted-foreground flex-shrink-0" />
|
<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.
|
* Shared routing helpers for tree/session navigation.
|
||||||
* Centralizes the logic for determining the correct navigation path
|
* 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') {
|
if (treeType === 'procedural') {
|
||||||
return `/flows/${treeId}/navigate`
|
return `/flows/${treeId}/navigate`
|
||||||
}
|
}
|
||||||
|
if (treeType === 'maintenance') {
|
||||||
|
return `/flows/${treeId}/maintenance`
|
||||||
|
}
|
||||||
return `/trees/${treeId}/navigate`
|
return `/trees/${treeId}/navigate`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -24,7 +27,7 @@ export function getTreeEditorPath(
|
|||||||
treeId: string,
|
treeId: string,
|
||||||
treeType?: string
|
treeType?: string
|
||||||
): string {
|
): string {
|
||||||
if (treeType === 'procedural') {
|
if (treeType === 'procedural' || treeType === 'maintenance') {
|
||||||
return `/flows/${treeId}/edit`
|
return `/flows/${treeId}/edit`
|
||||||
}
|
}
|
||||||
return `/trees/${treeId}/edit`
|
return `/trees/${treeId}/edit`
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { Link } from 'react-router-dom'
|
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 { accountsApi } from '@/api/accounts'
|
||||||
import type { Account, AccountMember, AccountInvite } from '@/types'
|
import type { Account, AccountMember, AccountInvite } from '@/types'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
@@ -496,6 +496,23 @@ export function AccountSettingsPage() {
|
|||||||
</Link>
|
</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 */}
|
{/* Preferences Section */}
|
||||||
<div className="bg-card border border-border rounded-xl p-4 sm:p-6">
|
<div className="bg-card border border-border rounded-xl p-4 sm:p-6">
|
||||||
<div className="flex items-center gap-2">
|
<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)
|
// Read type filter from URL query params (e.g. /trees?type=procedural)
|
||||||
const urlType = searchParams.get('type')
|
const urlType = searchParams.get('type')
|
||||||
const [typeFilter, setTypeFilter] = useState<'all' | 'troubleshooting' | 'procedural'>(
|
const [typeFilter, setTypeFilter] = useState<'all' | 'troubleshooting' | 'procedural' | 'maintenance'>(
|
||||||
urlType === 'troubleshooting' || urlType === 'procedural' ? urlType : 'all'
|
urlType === 'troubleshooting' || urlType === 'procedural' || urlType === 'maintenance' ? urlType : 'all'
|
||||||
)
|
)
|
||||||
|
|
||||||
// Sync filters when URL changes (e.g. clicking sidebar categories/tags or nav sub-items)
|
// Sync filters when URL changes (e.g. clicking sidebar categories/tags or nav sub-items)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const t = searchParams.get('type')
|
const t = searchParams.get('type')
|
||||||
if (t === 'troubleshooting' || t === 'procedural') {
|
if (t === 'troubleshooting' || t === 'procedural' || t === 'maintenance') {
|
||||||
setTypeFilter(t)
|
setTypeFilter(t)
|
||||||
} else {
|
} else {
|
||||||
setTypeFilter('all')
|
setTypeFilter('all')
|
||||||
@@ -253,13 +253,15 @@ 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 className="mb-6 flex flex-col gap-4 sm:mb-8 sm:flex-row sm:items-start sm:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold font-heading text-foreground sm:text-3xl">
|
<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>
|
</h1>
|
||||||
<p className="mt-2 text-muted-foreground">
|
<p className="mt-2 text-muted-foreground">
|
||||||
{typeFilter === 'procedural'
|
{typeFilter === 'procedural'
|
||||||
? 'Step-by-step projects and runbooks'
|
? 'Step-by-step projects and runbooks'
|
||||||
: typeFilter === 'troubleshooting'
|
: typeFilter === 'troubleshooting'
|
||||||
? 'Branching decision flows for troubleshooting'
|
? 'Branching decision flows for troubleshooting'
|
||||||
|
: typeFilter === 'maintenance'
|
||||||
|
? 'Scheduled maintenance procedures run across targets'
|
||||||
: 'Browse and start troubleshooting flows and projects'}
|
: 'Browse and start troubleshooting flows and projects'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -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 flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<div className="flex rounded-lg border border-border p-0.5">
|
<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
|
<button
|
||||||
key={t}
|
key={t}
|
||||||
onClick={() => setTypeFilter(t)}
|
onClick={() => setTypeFilter(t)}
|
||||||
@@ -337,7 +339,7 @@ export function TreeLibraryPage() {
|
|||||||
: 'text-muted-foreground hover:text-foreground'
|
: 'text-muted-foreground hover:text-foreground'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{t === 'all' ? 'All' : t === 'troubleshooting' ? 'Troubleshooting' : 'Projects'}
|
{t === 'all' ? 'All' : t === 'troubleshooting' ? 'Troubleshooting' : t === 'procedural' ? 'Projects' : 'Maintenance'}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</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 TreeEditorPage = lazy(() => import('@/pages/TreeEditorPage'))
|
||||||
const ProceduralEditorPage = lazy(() => import('@/pages/ProceduralEditorPage'))
|
const ProceduralEditorPage = lazy(() => import('@/pages/ProceduralEditorPage'))
|
||||||
const ProceduralNavigationPage = lazy(() => import('@/pages/ProceduralNavigationPage'))
|
const ProceduralNavigationPage = lazy(() => import('@/pages/ProceduralNavigationPage'))
|
||||||
|
const MaintenanceFlowDetailPage = lazy(() => import('@/pages/MaintenanceFlowDetailPage'))
|
||||||
const SessionHistoryPage = lazy(() => import('@/pages/SessionHistoryPage'))
|
const SessionHistoryPage = lazy(() => import('@/pages/SessionHistoryPage'))
|
||||||
const SessionDetailPage = lazy(() => import('@/pages/SessionDetailPage'))
|
const SessionDetailPage = lazy(() => import('@/pages/SessionDetailPage'))
|
||||||
const MySharesPage = lazy(() => import('@/pages/MySharesPage'))
|
const MySharesPage = lazy(() => import('@/pages/MySharesPage'))
|
||||||
@@ -45,6 +46,7 @@ const AdminGlobalCategoriesPage = lazy(() => import('@/pages/admin/GlobalCategor
|
|||||||
// Account pages
|
// Account pages
|
||||||
const AccountLayout = lazy(() => import('@/components/account/AccountLayout'))
|
const AccountLayout = lazy(() => import('@/components/account/AccountLayout'))
|
||||||
const TeamCategoriesPage = lazy(() => import('@/pages/account/TeamCategoriesPage'))
|
const TeamCategoriesPage = lazy(() => import('@/pages/account/TeamCategoriesPage'))
|
||||||
|
const TargetListsPage = lazy(() => import('@/pages/account/TargetListsPage'))
|
||||||
|
|
||||||
export const router = createBrowserRouter([
|
export const router = createBrowserRouter([
|
||||||
{
|
{
|
||||||
@@ -168,6 +170,14 @@ export const router = createBrowserRouter([
|
|||||||
</Suspense>
|
</Suspense>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'flows/:id/maintenance',
|
||||||
|
element: (
|
||||||
|
<Suspense fallback={<PageLoader />}>
|
||||||
|
<MaintenanceFlowDetailPage />
|
||||||
|
</Suspense>
|
||||||
|
),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'trees/:id/navigate',
|
path: 'trees/:id/navigate',
|
||||||
element: (
|
element: (
|
||||||
@@ -328,6 +338,14 @@ export const router = createBrowserRouter([
|
|||||||
</Suspense>
|
</Suspense>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'target-lists',
|
||||||
|
element: (
|
||||||
|
<Suspense fallback={<PageLoader />}>
|
||||||
|
<TargetListsPage />
|
||||||
|
</Suspense>
|
||||||
|
),
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -23,3 +23,14 @@ export interface PaginatedResponse<T> {
|
|||||||
export interface ApiError {
|
export interface ApiError {
|
||||||
detail: string
|
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
|
scratchpad: string
|
||||||
next_steps: string
|
next_steps: string
|
||||||
session_variables: Record<string, string>
|
session_variables: Record<string, string>
|
||||||
|
batch_id?: string
|
||||||
|
target_label?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SessionCreate {
|
export interface SessionCreate {
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ export interface TreeStructure {
|
|||||||
|
|
||||||
// --- Procedural Flow Types ---
|
// --- Procedural Flow Types ---
|
||||||
|
|
||||||
export type TreeType = 'troubleshooting' | 'procedural'
|
export type TreeType = 'troubleshooting' | 'procedural' | 'maintenance'
|
||||||
|
|
||||||
export type IntakeFieldType =
|
export type IntakeFieldType =
|
||||||
| 'text' | 'textarea' | 'number' | 'ip_address' | 'email' | 'url'
|
| 'text' | 'textarea' | 'number' | 'ip_address' | 'email' | 'url'
|
||||||
|
|||||||
Reference in New Issue
Block a user