10-task plan across 4 phases: DB/API, frontend UI, APScheduler integration, and target lists settings. TDD throughout. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
77 KiB
Maintenance Flows Implementation Plan
For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
Goal: Add maintenance as a first-class flow type with batch multi-target session launching, saved target lists, and APScheduler-based automatic session creation on a cron schedule.
Architecture: New tree_type='maintenance' (DB migration), two new tables (target_lists, maintenance_schedules), batch_id + target_label on sessions, APScheduler in-process, and a new maintenance detail page in the frontend that owns scheduling + batch launch. Execution of individual sessions reuses ProceduralNavigationPage.
Tech Stack: FastAPI + SQLAlchemy async + Alembic (backend), APScheduler 3.x (apscheduler), React 19 + TypeScript + Zustand (frontend), Tailwind CSS v3, Lucide React icons.
Design doc: docs/plans/2026-02-17-maintenance-flows-design.md
Phase 1: Database + API
Task 1: Expand tree_type check constraint
Files:
- Modify:
backend/app/models/tree.py:22-35 - Modify:
backend/app/schemas/tree.py:10
Step 1: Update the model check constraint
In backend/app/models/tree.py, change:
CheckConstraint(
"tree_type IN ('troubleshooting', 'procedural')",
name='ck_trees_tree_type'
),
to:
CheckConstraint(
"tree_type IN ('troubleshooting', 'procedural', 'maintenance')",
name='ck_trees_tree_type'
),
Step 2: Update the TreeType literal in schemas
In backend/app/schemas/tree.py, change:
TreeType = Literal['troubleshooting', 'procedural']
to:
TreeType = Literal['troubleshooting', 'procedural', 'maintenance']
Step 3: Generate Alembic migration
cd backend && alembic revision --autogenerate -m "add maintenance tree type"
Open the generated file in backend/alembic/versions/. The autogenerate will likely produce an empty migration because check constraints aren't always detected. Replace the upgrade() and downgrade() functions with:
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:
# First set any maintenance trees back to procedural
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'))"
)
Step 4: Apply migration
cd backend && alembic upgrade head
Expected: Running upgrade ... -> <revision_id> with no errors.
Step 5: Verify manually
docker exec -it patherly_postgres psql -U postgres -d patherly -c "\d+ trees" | grep tree_type
Expected: constraint shown as CHECK (tree_type = ANY (ARRAY['troubleshooting'::text, 'procedural'::text, 'maintenance'::text])).
Step 6: Write a failing test
Create backend/tests/test_maintenance_tree_type.py:
"""Tests for maintenance tree type."""
import pytest
from httpx import AsyncClient
@pytest.mark.asyncio
async def test_create_maintenance_tree(client: AsyncClient, engineer_headers: dict):
"""Maintenance tree type is accepted by the API."""
resp = await client.post(
"/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=engineer_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, engineer_headers: dict):
"""Filtering by tree_type=maintenance returns only maintenance trees."""
# Create one maintenance tree
await client.post(
"/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=engineer_headers,
)
resp = await client.get("/trees/?tree_type=maintenance", headers=engineer_headers)
assert resp.status_code == 200
trees = resp.json()
assert all(t["tree_type"] == "maintenance" for t in trees)
assert len(trees) >= 1
Step 7: Run the test to verify it fails
cd backend && pytest tests/test_maintenance_tree_type.py -v --override-ini="addopts="
Expected: FAIL — the check constraint hasn't been applied yet to the test DB (or tree_type is rejected). If you haven't created the test DB migration, run alembic -x testing=true upgrade head or apply manually.
Step 8: Apply migration to test DB
DATABASE_URL_SYNC=postgresql://postgres:postgres@localhost:5432/patherly_test alembic upgrade head
Step 9: Run the tests again to verify they pass
cd backend && pytest tests/test_maintenance_tree_type.py -v --override-ini="addopts="
Expected: PASS.
Step 10: Commit
git add backend/app/models/tree.py backend/app/schemas/tree.py backend/alembic/versions/ backend/tests/test_maintenance_tree_type.py
git commit -m "feat: add maintenance tree_type with db migration and tests"
Task 2: Add target_lists table and API
Files:
- Create:
backend/app/models/target_list.py - Create:
backend/app/schemas/target_list.py - Create:
backend/app/api/endpoints/target_lists.py - Modify:
backend/app/api/router.py - Create:
backend/tests/test_target_lists.py
Step 1: Write the failing tests first
Create backend/tests/test_target_lists.py:
"""Tests for target lists CRUD."""
import pytest
from httpx import AsyncClient
@pytest.mark.asyncio
async def test_create_target_list(client: AsyncClient, engineer_headers: dict):
resp = await client.post(
"/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=engineer_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, engineer_headers: dict):
resp = await client.get("/target-lists/", headers=engineer_headers)
assert resp.status_code == 200
assert isinstance(resp.json(), list)
@pytest.mark.asyncio
async def test_update_target_list(client: AsyncClient, engineer_headers: dict):
create = await client.post(
"/target-lists/",
json={"name": "Old Name", "targets": [{"label": "SRV-01"}]},
headers=engineer_headers,
)
list_id = create.json()["id"]
resp = await client.put(
f"/target-lists/{list_id}",
json={"name": "New Name", "targets": [{"label": "SRV-01"}, {"label": "SRV-02"}]},
headers=engineer_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, engineer_headers: dict):
create = await client.post(
"/target-lists/",
json={"name": "To Delete", "targets": [{"label": "X"}]},
headers=engineer_headers,
)
list_id = create.json()["id"]
resp = await client.delete(f"/target-lists/{list_id}", headers=engineer_headers)
assert resp.status_code == 204
# Verify gone
get = await client.get(f"/target-lists/{list_id}", headers=engineer_headers)
assert get.status_code == 404
Step 2: Run to verify tests fail
cd backend && pytest tests/test_target_lists.py -v --override-ini="addopts="
Expected: FAIL — /target-lists/ route doesn't exist yet.
Step 3: Create the model
Create backend/app/models/target_list.py:
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, relationship
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),
)
Step 4: Create schemas
Create backend/app/schemas/target_list.py:
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]] = None
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}
Step 5: Generate + apply Alembic migration for target_lists
cd backend && alembic revision --autogenerate -m "add target_lists table"
Review the generated migration to ensure it creates target_lists with correct columns. Then:
alembic upgrade head
# Also apply to test DB:
DATABASE_URL_SYNC=postgresql://postgres:postgres@localhost:5432/patherly_test alembic upgrade head
Step 6: Create the endpoint
Create backend/app/api/endpoints/target_lists.py:
"""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
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)],
):
"""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)],
):
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")
if data.name is not None:
target_list.name = data.name
if data.description is not None:
target_list.description = data.description
if 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)],
):
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()
Step 7: Register in router
In backend/app/api/router.py, add:
from app.api.endpoints import target_lists
# ...
api_router.include_router(target_lists.router)
Also add the import of TargetList to backend/app/models/__init__.py if that file exists (so Alembic detects it), or just ensure the model is imported somewhere in the startup path. Check backend/app/core/database.py — if it imports models explicitly, add TargetList there.
Step 8: Run the tests to verify they pass
cd backend && pytest tests/test_target_lists.py -v --override-ini="addopts="
Expected: PASS (4 tests).
Step 9: Commit
git add backend/app/models/target_list.py backend/app/schemas/target_list.py \
backend/app/api/endpoints/target_lists.py backend/app/api/router.py \
backend/alembic/versions/ backend/tests/test_target_lists.py
git commit -m "feat: add target_lists table, schema, and CRUD endpoints"
Task 3: Add batch_id + target_label to sessions + batch launch endpoint
Files:
- Modify:
backend/app/models/session.py - Modify:
backend/app/schemas/(session schemas — findSessionCreateorSessionResponse) - Modify:
backend/app/api/endpoints/sessions.py - Create:
backend/tests/test_batch_sessions.py
Step 1: Write failing tests
Create backend/tests/test_batch_sessions.py:
"""Tests for batch session launching."""
import pytest
from httpx import AsyncClient
async def _create_maintenance_tree(client, headers):
resp = await client.post(
"/trees/",
json={
"name": "Patch RDS",
"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
return resp.json()["id"]
@pytest.mark.asyncio
async def test_batch_launch_creates_one_session_per_target(
client: AsyncClient, engineer_headers: dict
):
tree_id = await _create_maintenance_tree(client, engineer_headers)
resp = await client.post(
"/sessions/batch",
json={
"tree_id": tree_id,
"targets": [
{"label": "RDS-01", "notes": "192.168.1.10"},
{"label": "RDS-02", "notes": "192.168.1.11"},
{"label": "RDS-03"},
],
},
headers=engineer_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, engineer_headers: dict
):
tree_id = await _create_maintenance_tree(client, engineer_headers)
resp = await client.post(
"/sessions/batch",
json={"tree_id": tree_id, "targets": []},
headers=engineer_headers,
)
assert resp.status_code == 422
Step 2: Run to verify tests fail
cd backend && pytest tests/test_batch_sessions.py -v --override-ini="addopts="
Expected: FAIL — /sessions/batch doesn't exist.
Step 3: Add columns to the Session model
In backend/app/models/session.py, after the existing columns, add:
# 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
)
Step 4: Generate + apply migration
cd backend && alembic revision --autogenerate -m "add batch_id and target_label to sessions"
alembic upgrade head
DATABASE_URL_SYNC=postgresql://postgres:postgres@localhost:5432/patherly_test alembic upgrade head
Step 5: Find and update the SessionResponse schema
Search for where SessionResponse or the session response schema is defined:
grep -r "class Session" backend/app/schemas/ -l
Open that file and add batch_id and target_label fields to both the create/response schemas:
# In the response schema:
batch_id: Optional[UUID] = None
target_label: Optional[str] = None
Step 6: Add the batch launch endpoint
In backend/app/api/endpoints/sessions.py, find where other session routes are defined and add at the end:
from pydantic import BaseModel, Field
import uuid as uuid_mod
class BatchTarget(BaseModel):
label: str = Field(..., min_length=1, max_length=255)
notes: Optional[str] = None
class BatchLaunchRequest(BaseModel):
tree_id: UUID
targets: list[BatchTarget] = Field(..., min_length=1)
class BatchLaunchResponse(BaseModel):
batch_id: UUID
count: int
sessions: list # list of session response dicts
@router.post("/batch", status_code=201)
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."""
# Verify tree exists and belongs to user's team / is accessible
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 tree.tree_type != "maintenance":
raise HTTPException(status_code=400, detail="Batch launch is only for maintenance flows")
batch_id = uuid_mod.uuid4()
created_sessions = []
for target in data.targets:
session = Session(
tree_id=tree.id,
user_id=current_user.id,
tree_snapshot=tree.tree_structure,
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()
for s in created_sessions:
await db.refresh(s)
await db.commit()
return {
"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
],
}
Make sure Tree is imported in sessions.py — add from app.models.tree import Tree if not already present.
Step 7: Run the tests
cd backend && pytest tests/test_batch_sessions.py -v --override-ini="addopts="
Expected: PASS (2 tests).
Step 8: Commit
git add backend/app/models/session.py backend/app/schemas/ backend/app/api/endpoints/sessions.py \
backend/alembic/versions/ backend/tests/test_batch_sessions.py
git commit -m "feat: add batch_id/target_label to sessions and batch launch endpoint"
Task 4: maintenance_schedules table + API
Files:
- Create:
backend/app/models/maintenance_schedule.py - Create:
backend/app/schemas/maintenance_schedule.py - Create:
backend/app/api/endpoints/maintenance_schedules.py - Modify:
backend/app/api/router.py - Create:
backend/tests/test_maintenance_schedules.py
Step 1: Write failing tests
Create backend/tests/test_maintenance_schedules.py:
"""Tests for maintenance schedule CRUD."""
import pytest
from httpx import AsyncClient
async def _create_maintenance_tree(client, headers):
resp = await client.post(
"/trees/",
json={
"name": "Scheduled Maintenance",
"tree_type": "maintenance",
"tree_structure": {
"steps": [
{"id": "s1", "type": "procedure_step", "title": "Step 1",
"description": "Do it", "content_type": "action"},
{"id": "end", "type": "procedure_end", "title": "Done"},
]
},
},
headers=headers,
)
return resp.json()["id"]
@pytest.mark.asyncio
async def test_create_schedule(client: AsyncClient, engineer_headers: dict):
tree_id = await _create_maintenance_tree(client, engineer_headers)
resp = await client.post(
f"/maintenance-schedules/",
json={
"tree_id": tree_id,
"cron_expression": "0 9 15 * *",
"timezone": "America/New_York",
},
headers=engineer_headers,
)
assert resp.status_code == 201, resp.text
data = resp.json()
assert data["cron_expression"] == "0 9 15 * *"
assert data["is_active"] is True
assert data["next_run_at"] is not None
@pytest.mark.asyncio
async def test_get_schedule_for_tree(client: AsyncClient, engineer_headers: dict):
tree_id = await _create_maintenance_tree(client, engineer_headers)
await client.post(
"/maintenance-schedules/",
json={"tree_id": tree_id, "cron_expression": "0 0 1 * *", "timezone": "UTC"},
headers=engineer_headers,
)
resp = await client.get(f"/maintenance-schedules/tree/{tree_id}", headers=engineer_headers)
assert resp.status_code == 200
assert resp.json()["cron_expression"] == "0 0 1 * *"
@pytest.mark.asyncio
async def test_disable_schedule(client: AsyncClient, engineer_headers: dict):
tree_id = await _create_maintenance_tree(client, engineer_headers)
create = await client.post(
"/maintenance-schedules/",
json={"tree_id": tree_id, "cron_expression": "0 6 * * 1", "timezone": "UTC"},
headers=engineer_headers,
)
sched_id = create.json()["id"]
resp = await client.patch(
f"/maintenance-schedules/{sched_id}",
json={"is_active": False},
headers=engineer_headers,
)
assert resp.status_code == 200
assert resp.json()["is_active"] is False
Step 2: Run to verify they fail
cd backend && pytest tests/test_maintenance_schedules.py -v --override-ini="addopts="
Expected: FAIL.
Step 3: Create the model
Create backend/app/models/maintenance_schedule.py:
import uuid
from datetime import datetime, timezone
from typing import Optional, TYPE_CHECKING
from sqlalchemy import String, DateTime, ForeignKey, Boolean
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"
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, unique=True # one schedule per tree
)
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),
)
Step 4: Create schemas
Create backend/app/schemas/maintenance_schedule.py:
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}
Step 5: Create endpoint
Create backend/app/api/endpoints/maintenance_schedules.py:
"""Maintenance schedule CRUD endpoints."""
from typing import Annotated, Optional
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
from app.api.deps import get_current_active_user, get_db
from app.models.maintenance_schedule import MaintenanceSchedule
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."""
import pytz
tz = pytz.timezone(tz_name)
now = datetime.now(tz)
cron = croniter(cron_expression, now)
return cron.get_next(datetime).astimezone(timezone.utc)
@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)],
):
# 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. Update the existing one.")
next_run = _compute_next_run(data.cron_expression, data.timezone)
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)
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)],
):
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)],
):
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")
if data.cron_expression is not None:
schedule.cron_expression = data.cron_expression
if data.timezone is not None:
schedule.timezone = data.timezone
if data.target_list_id is not None:
schedule.target_list_id = data.target_list_id
if data.is_active is not None:
schedule.is_active = data.is_active
# Recompute next_run_at if cron or timezone changed
if data.cron_expression or data.timezone:
schedule.next_run_at = _compute_next_run(schedule.cron_expression, schedule.timezone)
await db.commit()
await db.refresh(schedule)
return schedule
Step 6: Install dependencies
cd backend && pip install croniter pytz apscheduler
Add to backend/requirements.txt:
croniter>=2.0.0
pytz>=2024.1
apscheduler>=3.10.0
Step 7: Register in router
In backend/app/api/router.py, add:
from app.api.endpoints import maintenance_schedules
# ...
api_router.include_router(maintenance_schedules.router)
Step 8: Generate + apply migration
cd backend && alembic revision --autogenerate -m "add maintenance_schedules table"
alembic upgrade head
DATABASE_URL_SYNC=postgresql://postgres:postgres@localhost:5432/patherly_test alembic upgrade head
Step 9: Run tests
cd backend && pytest tests/test_maintenance_schedules.py -v --override-ini="addopts="
Expected: PASS (3 tests).
Step 10: Run all backend tests to check nothing broke
cd backend && pytest --override-ini="addopts="
Expected: All pass (or same failures as before this feature).
Step 11: Commit
git add backend/app/models/maintenance_schedule.py backend/app/schemas/maintenance_schedule.py \
backend/app/api/endpoints/maintenance_schedules.py backend/app/api/router.py \
backend/alembic/versions/ backend/requirements.txt backend/tests/test_maintenance_schedules.py
git commit -m "feat: add maintenance_schedules table, schema, and CRUD endpoints"
Phase 2: Core Frontend UI
Task 5: Types, API clients, and routing
Files:
- Create:
frontend/src/types/maintenance.ts - Modify:
frontend/src/types/index.ts - Create:
frontend/src/api/targetLists.ts - Create:
frontend/src/api/maintenanceSchedules.ts - Modify:
frontend/src/api/index.ts - Modify:
frontend/src/lib/routing.ts
Step 1: Create TypeScript types
Create frontend/src/types/maintenance.ts:
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
}>
}
Step 2: Export from index
In frontend/src/types/index.ts, add at the end:
export type {
TargetEntry,
TargetList,
TargetListCreate,
MaintenanceSchedule,
MaintenanceScheduleCreate,
MaintenanceScheduleUpdate,
BatchLaunchRequest,
BatchLaunchResponse,
} from './maintenance'
Step 3: Create API clients
Create frontend/src/api/targetLists.ts:
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),
}
Create frontend/src/api/maintenanceSchedules.ts:
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),
}
Step 4: Export from api/index.ts
In frontend/src/api/index.ts, add:
export { targetListsApi } from './targetLists'
export { maintenanceSchedulesApi, batchLaunchApi } from './maintenanceSchedules'
Step 5: Update routing helper
In frontend/src/lib/routing.ts, update getTreeNavigatePath:
export function getTreeNavigatePath(
treeId: string,
treeType?: string
): string {
if (treeType === 'procedural') {
return `/flows/${treeId}/navigate`
}
if (treeType === 'maintenance') {
return `/flows/${treeId}/maintenance`
}
return `/trees/${treeId}/navigate`
}
export function getTreeEditorPath(
treeId: string,
treeType?: string
): string {
if (treeType === 'procedural' || treeType === 'maintenance') {
return `/flows/${treeId}/edit`
}
return `/trees/${treeId}/edit`
}
Step 6: Build check
cd frontend && npm run build
Expected: No TypeScript errors.
Step 7: Commit
git add frontend/src/types/maintenance.ts frontend/src/types/index.ts \
frontend/src/api/targetLists.ts frontend/src/api/maintenanceSchedules.ts \
frontend/src/api/index.ts frontend/src/lib/routing.ts
git commit -m "feat: add maintenance types, API clients, and routing for maintenance flows"
Task 6: Sidebar + TreeLibraryPage updates
Files:
- Modify:
frontend/src/components/layout/Sidebar.tsx - Modify:
frontend/src/pages/TreeLibraryPage.tsx
Step 1: Update Sidebar to count and show maintenance flows
In frontend/src/components/layout/Sidebar.tsx:
Change treeCounts state (line ~32):
const [treeCounts, setTreeCounts] = useState({ total: 0, troubleshooting: 0, procedural: 0, maintenance: 0 })
In the fetchData function, update the counts:
const maintenance = allTrees.filter(t => t.tree_type === 'maintenance').length
setTreeCounts({ total, troubleshooting, procedural, maintenance })
Update the nav sub-items (line ~145):
children={[
{ href: '/trees?type=troubleshooting', label: 'Troubleshooting', count: treeCounts.troubleshooting || undefined },
{ href: '/trees?type=procedural', label: 'Projects', count: treeCounts.procedural || undefined },
{ href: '/trees?type=maintenance', label: 'Maintenance', count: treeCounts.maintenance || undefined },
]}
Step 2: Update TreeLibraryPage type filter
In frontend/src/pages/TreeLibraryPage.tsx:
Change the typeFilter state type and initialization (line ~40):
const [typeFilter, setTypeFilter] = useState<'all' | 'troubleshooting' | 'procedural' | 'maintenance'>(
urlType === 'troubleshooting' || urlType === 'procedural' || urlType === 'maintenance' ? urlType : 'all'
)
Update the sync effect (line ~47):
if (t === 'troubleshooting' || t === 'procedural' || t === 'maintenance') {
setTypeFilter(t)
} else {
setTypeFilter('all')
}
Step 3: Add maintenance badge to tree cards
Find the tree card component(s) in frontend/src/components/library/ (likely TreeGridView.tsx, TreeListView.tsx, TreeTableView.tsx). In each, find where the tree_type badge is rendered and add the maintenance case:
Look for something like tree_type === 'procedural' and add:
{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>
)}
Import Wrench from lucide-react at the top of those files.
Step 4: Build check
cd frontend && npm run build
Expected: No errors.
Step 5: Commit
git add frontend/src/components/layout/Sidebar.tsx frontend/src/pages/TreeLibraryPage.tsx \
frontend/src/components/library/
git commit -m "feat: add maintenance flows to sidebar nav and library type filter"
Task 7: BatchLaunchModal component
Files:
- Create:
frontend/src/components/maintenance/BatchLaunchModal.tsx
Step 1: Create the component
Create frontend/src/components/maintenance/BatchLaunchModal.tsx:
import { useState } 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 = 'saved' | 'previous' | 'manual' | '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 [isLoading, setIsLoading] = useState(false)
const [isLaunching, setIsLaunching] = useState(false)
const loadSavedLists = async () => {
if (savedLists !== null) return
try {
const lists = await targetListsApi.list()
setSavedLists(lists)
} catch {
toast.error('Failed to load saved lists')
}
}
const handleTabChange = (tab: TabId) => {
setActiveTab(tab)
if (tab === 'saved') loadSavedLists()
}
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
}
setIsLaunching(true)
try {
const result = await batchLaunchApi.launch({ tree_id: treeId, targets })
toast.success(`Launched ${result.count} sessions`)
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 Import', 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 gap-1 border-b border-border px-4 pt-3">
{tabs.map(tab => (
<button
key={tab.id}
onClick={() => handleTabChange(tab.id)}
className={cn(
"flex items-center gap-1.5 rounded-t-md px-3 py-2 font-label text-[0.75rem] 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>
{/* Tab 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 ? (
<p className="text-sm text-muted-foreground">Loading...</p>
) : savedLists.length === 0 ? (
<p className="text-sm text-muted-foreground">No saved lists yet. Create one in Team Settings → Target Lists.</p>
) : (
<div className="space-y-2">
{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:border-border/80 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>
)}
</div>
)}
{activeTab === 'previous' && (
<p className="text-sm text-muted-foreground">Previous run history will appear here.</p>
)}
{activeTab === 'psa' && (
<div className="rounded-lg border border-border bg-accent/30 p-4 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 + Footer */}
{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>
)}
<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...' : `Launch ${targets.length > 0 ? targets.length + ' Sessions' : ''}`}
</button>
</div>
</div>
</div>
)
}
Step 2: Build check
cd frontend && npm run build
Expected: No errors.
Step 3: Commit
git add frontend/src/components/maintenance/
git commit -m "feat: add BatchLaunchModal for maintenance flow multi-target launching"
Task 8: MaintenanceFlowDetailPage
Files:
- Create:
frontend/src/pages/MaintenanceFlowDetailPage.tsx - Modify:
frontend/src/router.tsx
Step 1: Create the page
Create frontend/src/pages/MaintenanceFlowDetailPage.tsx:
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 type { Tree, MaintenanceSchedule } from '@/types'
import { BatchLaunchModal } from '@/components/maintenance/BatchLaunchModal'
import { toast } from '@/lib/toast'
import { cn } from '@/lib/utils'
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<any[]>([])
const [showBatchModal, setShowBatchModal] = useState(false)
const [isLoading, setIsLoading] = useState(true)
useEffect(() => {
if (!id) return
const load = async () => {
try {
const [treeData, sessionsData] = await Promise.all([
treesApi.get(id),
sessionsApi.list({ tree_id: id, size: 20 }).catch(() => []),
])
setTree(treeData)
setRecentSessions(sessionsData)
// Try to load schedule (404 is fine — no schedule set yet)
try {
const sched = await maintenanceSchedulesApi.getForTree(id)
setSchedule(sched)
} catch {
// No schedule yet
}
} 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 — check Sessions to run them`)
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
const batches = recentSessions.reduce((acc: Record<string, any[]>, s: any) => {
const key = s.batch_id ?? s.id
if (!acc[key]) acc[key] = []
acc[key].push(s)
return acc
}, {})
const batchList = Object.entries(batches).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>
<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 justify-between">
<div className="flex items-center gap-2">
<Calendar className="h-4 w-4 text-muted-foreground" />
<h2 className="font-semibold text-foreground">Schedule</h2>
</div>
</div>
{schedule ? (
<div className="mt-3 space-y-2 text-[0.875rem]">
<div className="flex 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>
<span className="text-muted-foreground">·</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="mt-3 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>
{batchList.length === 0 ? (
<p className="text-[0.875rem] text-muted-foreground">No runs yet.</p>
) : (
<div className="space-y-2">
{batchList.map(([batchId, sessions]) => {
const completed = sessions.filter((s: any) => s.completed_at).length
const total = sessions.length
const date = sessions[0]?.started_at
return (
<div key={batchId} 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>
)
}
Step 2: Register the route
In frontend/src/router.tsx, add after the existing ProceduralNavigationPage import:
const MaintenanceFlowDetailPage = lazy(() => import('@/pages/MaintenanceFlowDetailPage'))
Then in the routes array, inside the ProtectedRoute/AppLayout children, add alongside the existing /flows/:id/navigate route:
{
path: '/flows/:id/maintenance',
element: (
<Suspense fallback={<PageLoader />}>
<MaintenanceFlowDetailPage />
</Suspense>
),
},
Step 3: Build check
cd frontend && npm run build
Expected: No errors.
Step 4: Commit
git add frontend/src/pages/MaintenanceFlowDetailPage.tsx frontend/src/router.tsx
git commit -m "feat: add MaintenanceFlowDetailPage with schedule panel, batch launch, and run history"
Phase 3: APScheduler Integration
Task 9: Scheduler core + auto-session creation
Files:
- Create:
backend/app/core/scheduler.py - Modify:
backend/app/main.py
Step 1: Create the scheduler module
Create backend/app/core/scheduler.py:
"""APScheduler integration for maintenance flow auto-session creation."""
import logging
import uuid
from datetime import datetime, timezone
from typing import Optional
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from apscheduler.triggers.cron import CronTrigger
import pytz
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
from app.core.config import settings
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
logger = logging.getLogger(__name__)
scheduler = AsyncIOScheduler()
def _make_session_factory():
engine = create_async_engine(settings.DATABASE_URL, pool_pre_ping=True)
return async_sessionmaker(engine, expire_on_commit=False)
async def _fire_maintenance_schedule(schedule_id: str) -> None:
"""Create batch sessions for a scheduled maintenance run."""
session_factory = _make_session_factory()
async with session_factory() 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, skipping")
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 = target_list.targets
if not targets:
logger.info(f"Schedule {schedule_id} has no targets — creating single session without target")
targets = [{"label": "Unassigned"}]
batch_id = uuid.uuid4()
created_count = 0
for target in targets:
session = Session(
tree_id=tree.id,
user_id=schedule.created_by, # attribute to the schedule creator
tree_snapshot=tree.tree_structure,
path_taken=[],
decisions=[],
custom_steps=[],
session_variables={},
batch_id=batch_id,
target_label=target.get("label", ""),
)
db.add(session)
created_count += 1
# Update schedule tracking
schedule.last_run_at = datetime.now(timezone.utc)
# Compute next_run_at
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 {created_count} 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."""
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)} maintenance schedule(s)")
def register_schedule(schedule: MaintenanceSchedule) -> 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)
if scheduler.get_job(job_id):
scheduler.reschedule_job(job_id, trigger=trigger)
else:
scheduler.add_job(
_fire_maintenance_schedule,
trigger=trigger,
id=job_id,
args=[str(schedule.id)],
replace_existing=True,
misfire_grace_time=3600, # 1 hour grace for missed fires
)
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):
scheduler.remove_job(job_id)
logger.info(f"Unregistered schedule {schedule_id}")
Step 2: Wire scheduler into FastAPI lifespan
In backend/app/main.py, update the lifespan handler:
from app.core.scheduler import scheduler, load_all_schedules
from app.core.database import get_db_context # or however you get a DB session outside a request
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Application lifespan handler."""
logger.info("Starting ResolutionFlow API server...")
logger.info(f"Environment: {'Development' if settings.DEBUG else 'Production'}")
logger.info(f"ALLOW_RAILWAY_ORIGINS: {settings.ALLOW_RAILWAY_ORIGINS}")
# Start scheduler and load active maintenance schedules
scheduler.start()
from app.core.database import async_session_factory # import your session factory
async with async_session_factory() as db:
await load_all_schedules(db)
yield
# Shutdown
scheduler.shutdown(wait=False)
logger.info("Shutting down ResolutionFlow API server...")
Note: Check
backend/app/core/database.pyfor the correct session factory name. It may beAsyncSessionLocal,async_session, etc. Use whatever name is already exported there. If there's no standalone session factory export, add one:async_session_factory = async_sessionmaker(engine, expire_on_commit=False)
Step 3: Wire register/unregister into schedule endpoints
In backend/app/api/endpoints/maintenance_schedules.py, import and call the scheduler functions when a schedule is created, updated, or toggled:
After the create_schedule endpoint commits, add:
from app.core.scheduler import register_schedule
register_schedule(schedule)
After the update_schedule endpoint commits, add:
from app.core.scheduler import register_schedule, unregister_schedule
if schedule.is_active:
register_schedule(schedule)
else:
unregister_schedule(str(schedule.id))
Step 4: Verify scheduler starts cleanly
Start the backend:
cd backend && uvicorn app.main:app --reload
Expected in logs: Loaded N maintenance schedule(s) with no exceptions.
Step 5: Commit
git add backend/app/core/scheduler.py backend/app/main.py backend/app/api/endpoints/maintenance_schedules.py
git commit -m "feat: APScheduler integration for maintenance flow auto-session creation"
Phase 4: Target Lists Settings Page
Task 10: TargetListsPage + account routing
Files:
- Create:
frontend/src/pages/account/TargetListsPage.tsx - Modify:
frontend/src/router.tsx - Modify:
frontend/src/components/account/AccountLayout.tsx(add nav link)
Step 1: Create the settings page
Create frontend/src/pages/account/TargetListsPage.tsx:
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 [label, ...rest] = line.split('#')
return { label: label.trim(), notes: rest.join('#').trim() || undefined }
})
const handleSave = async () => {
const targets = parseTargets(editorTargets)
if (!editorName.trim()) { toast.error('Name is required'); return }
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 a list 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.slice(0, 3).map(t => t.label).join(', ')}{list.targets.length > 3 ? '...' : ''}
</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">
<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">
<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 — optionally 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...' : 'Save'}
</button>
</div>
</div>
</div>
)}
{deleteTarget && (
<ConfirmDialog
title="Delete Target List"
message={`Delete "${deleteTarget.name}"? This cannot be undone.`}
confirmLabel="Delete"
onConfirm={handleDelete}
onCancel={() => setDeleteTarget(null)}
destructive
/>
)}
</div>
)
}
Step 2: Register the route
In frontend/src/router.tsx, add:
const TargetListsPage = lazy(() => import('@/pages/account/TargetListsPage'))
Then inside the account section routes (alongside TeamCategoriesPage):
{
path: '/account/target-lists',
element: (
<Suspense fallback={<PageLoader />}>
<TargetListsPage />
</Suspense>
),
},
Step 3: Add nav link in AccountLayout
Open frontend/src/components/account/AccountLayout.tsx. Find the nav links and add:
<NavLink to="/account/target-lists">Target Lists</NavLink>
(Match the exact pattern used for existing links in that file.)
Step 4: Build check
cd frontend && npm run build
Expected: No errors.
Step 5: Run all backend tests one final time
cd backend && pytest --override-ini="addopts="
Expected: All pass.
Step 6: Final commit
git add frontend/src/pages/account/TargetListsPage.tsx frontend/src/router.tsx \
frontend/src/components/account/AccountLayout.tsx
git commit -m "feat: add Target Lists settings page under Team account"
Summary
| Phase | Tasks | Key deliverables |
|---|---|---|
| 1 — DB + API | 1–4 | maintenance tree_type, target_lists table, maintenance_schedules table, batch session endpoint |
| 2 — Frontend | 5–8 | Types/API clients, sidebar nav, library filter, BatchLaunchModal, MaintenanceFlowDetailPage |
| 3 — Scheduler | 9 | APScheduler, auto-session creation, schedule lifecycle wired to API |
| 4 — Settings | 10 | TargetListsPage in account settings |
Each task ends with a commit. Each phase is independently deployable.