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>
2391 lines
77 KiB
Markdown
2391 lines
77 KiB
Markdown
# 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:
|
||
```python
|
||
CheckConstraint(
|
||
"tree_type IN ('troubleshooting', 'procedural')",
|
||
name='ck_trees_tree_type'
|
||
),
|
||
```
|
||
to:
|
||
```python
|
||
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:
|
||
```python
|
||
TreeType = Literal['troubleshooting', 'procedural']
|
||
```
|
||
to:
|
||
```python
|
||
TreeType = Literal['troubleshooting', 'procedural', 'maintenance']
|
||
```
|
||
|
||
**Step 3: Generate Alembic migration**
|
||
|
||
```bash
|
||
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:
|
||
|
||
```python
|
||
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**
|
||
|
||
```bash
|
||
cd backend && alembic upgrade head
|
||
```
|
||
|
||
Expected: `Running upgrade ... -> <revision_id>` with no errors.
|
||
|
||
**Step 5: Verify manually**
|
||
|
||
```bash
|
||
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`:
|
||
|
||
```python
|
||
"""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**
|
||
|
||
```bash
|
||
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**
|
||
|
||
```bash
|
||
DATABASE_URL_SYNC=postgresql://postgres:postgres@localhost:5432/patherly_test alembic upgrade head
|
||
```
|
||
|
||
**Step 9: Run the tests again to verify they pass**
|
||
|
||
```bash
|
||
cd backend && pytest tests/test_maintenance_tree_type.py -v --override-ini="addopts="
|
||
```
|
||
|
||
Expected: PASS.
|
||
|
||
**Step 10: Commit**
|
||
|
||
```bash
|
||
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`:
|
||
|
||
```python
|
||
"""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**
|
||
|
||
```bash
|
||
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`:
|
||
|
||
```python
|
||
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`:
|
||
|
||
```python
|
||
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**
|
||
|
||
```bash
|
||
cd backend && alembic revision --autogenerate -m "add target_lists table"
|
||
```
|
||
|
||
Review the generated migration to ensure it creates `target_lists` with correct columns. Then:
|
||
|
||
```bash
|
||
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`:
|
||
|
||
```python
|
||
"""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:
|
||
```python
|
||
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**
|
||
|
||
```bash
|
||
cd backend && pytest tests/test_target_lists.py -v --override-ini="addopts="
|
||
```
|
||
|
||
Expected: PASS (4 tests).
|
||
|
||
**Step 9: Commit**
|
||
|
||
```bash
|
||
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 — find `SessionCreate` or `SessionResponse`)
|
||
- 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`:
|
||
|
||
```python
|
||
"""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**
|
||
|
||
```bash
|
||
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:
|
||
|
||
```python
|
||
# 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**
|
||
|
||
```bash
|
||
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:
|
||
|
||
```bash
|
||
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:
|
||
|
||
```python
|
||
# 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:
|
||
|
||
```python
|
||
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**
|
||
|
||
```bash
|
||
cd backend && pytest tests/test_batch_sessions.py -v --override-ini="addopts="
|
||
```
|
||
|
||
Expected: PASS (2 tests).
|
||
|
||
**Step 8: Commit**
|
||
|
||
```bash
|
||
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`:
|
||
|
||
```python
|
||
"""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**
|
||
|
||
```bash
|
||
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`:
|
||
|
||
```python
|
||
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`:
|
||
|
||
```python
|
||
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`:
|
||
|
||
```python
|
||
"""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**
|
||
|
||
```bash
|
||
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:
|
||
```python
|
||
from app.api.endpoints import maintenance_schedules
|
||
# ...
|
||
api_router.include_router(maintenance_schedules.router)
|
||
```
|
||
|
||
**Step 8: Generate + apply migration**
|
||
|
||
```bash
|
||
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**
|
||
|
||
```bash
|
||
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**
|
||
|
||
```bash
|
||
cd backend && pytest --override-ini="addopts="
|
||
```
|
||
|
||
Expected: All pass (or same failures as before this feature).
|
||
|
||
**Step 11: Commit**
|
||
|
||
```bash
|
||
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`:
|
||
|
||
```typescript
|
||
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:
|
||
```typescript
|
||
export type {
|
||
TargetEntry,
|
||
TargetList,
|
||
TargetListCreate,
|
||
MaintenanceSchedule,
|
||
MaintenanceScheduleCreate,
|
||
MaintenanceScheduleUpdate,
|
||
BatchLaunchRequest,
|
||
BatchLaunchResponse,
|
||
} from './maintenance'
|
||
```
|
||
|
||
**Step 3: Create API clients**
|
||
|
||
Create `frontend/src/api/targetLists.ts`:
|
||
|
||
```typescript
|
||
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`:
|
||
|
||
```typescript
|
||
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:
|
||
```typescript
|
||
export { targetListsApi } from './targetLists'
|
||
export { maintenanceSchedulesApi, batchLaunchApi } from './maintenanceSchedules'
|
||
```
|
||
|
||
**Step 5: Update routing helper**
|
||
|
||
In `frontend/src/lib/routing.ts`, update `getTreeNavigatePath`:
|
||
|
||
```typescript
|
||
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**
|
||
|
||
```bash
|
||
cd frontend && npm run build
|
||
```
|
||
|
||
Expected: No TypeScript errors.
|
||
|
||
**Step 7: Commit**
|
||
|
||
```bash
|
||
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):
|
||
```typescript
|
||
const [treeCounts, setTreeCounts] = useState({ total: 0, troubleshooting: 0, procedural: 0, maintenance: 0 })
|
||
```
|
||
|
||
In the `fetchData` function, update the counts:
|
||
```typescript
|
||
const maintenance = allTrees.filter(t => t.tree_type === 'maintenance').length
|
||
setTreeCounts({ total, troubleshooting, procedural, maintenance })
|
||
```
|
||
|
||
Update the nav sub-items (line ~145):
|
||
```typescript
|
||
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):
|
||
```typescript
|
||
const [typeFilter, setTypeFilter] = useState<'all' | 'troubleshooting' | 'procedural' | 'maintenance'>(
|
||
urlType === 'troubleshooting' || urlType === 'procedural' || urlType === 'maintenance' ? urlType : 'all'
|
||
)
|
||
```
|
||
|
||
Update the sync effect (line ~47):
|
||
```typescript
|
||
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:
|
||
```tsx
|
||
{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**
|
||
|
||
```bash
|
||
cd frontend && npm run build
|
||
```
|
||
|
||
Expected: No errors.
|
||
|
||
**Step 5: Commit**
|
||
|
||
```bash
|
||
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`:
|
||
|
||
```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**
|
||
|
||
```bash
|
||
cd frontend && npm run build
|
||
```
|
||
|
||
Expected: No errors.
|
||
|
||
**Step 3: Commit**
|
||
|
||
```bash
|
||
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`:
|
||
|
||
```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:
|
||
|
||
```typescript
|
||
const MaintenanceFlowDetailPage = lazy(() => import('@/pages/MaintenanceFlowDetailPage'))
|
||
```
|
||
|
||
Then in the routes array, inside the `ProtectedRoute`/`AppLayout` children, add alongside the existing `/flows/:id/navigate` route:
|
||
|
||
```tsx
|
||
{
|
||
path: '/flows/:id/maintenance',
|
||
element: (
|
||
<Suspense fallback={<PageLoader />}>
|
||
<MaintenanceFlowDetailPage />
|
||
</Suspense>
|
||
),
|
||
},
|
||
```
|
||
|
||
**Step 3: Build check**
|
||
|
||
```bash
|
||
cd frontend && npm run build
|
||
```
|
||
|
||
Expected: No errors.
|
||
|
||
**Step 4: Commit**
|
||
|
||
```bash
|
||
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`:
|
||
|
||
```python
|
||
"""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:
|
||
|
||
```python
|
||
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.py` for the correct session factory name. It may be `AsyncSessionLocal`, `async_session`, etc. Use whatever name is already exported there. If there's no standalone session factory export, add one:
|
||
> ```python
|
||
> 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:
|
||
```python
|
||
from app.core.scheduler import register_schedule
|
||
register_schedule(schedule)
|
||
```
|
||
|
||
After the `update_schedule` endpoint commits, add:
|
||
```python
|
||
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:
|
||
```bash
|
||
cd backend && uvicorn app.main:app --reload
|
||
```
|
||
|
||
Expected in logs: `Loaded N maintenance schedule(s)` with no exceptions.
|
||
|
||
**Step 5: Commit**
|
||
|
||
```bash
|
||
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`:
|
||
|
||
```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:
|
||
```typescript
|
||
const TargetListsPage = lazy(() => import('@/pages/account/TargetListsPage'))
|
||
```
|
||
|
||
Then inside the account section routes (alongside `TeamCategoriesPage`):
|
||
```tsx
|
||
{
|
||
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:
|
||
```tsx
|
||
<NavLink to="/account/target-lists">Target Lists</NavLink>
|
||
```
|
||
|
||
(Match the exact pattern used for existing links in that file.)
|
||
|
||
**Step 4: Build check**
|
||
|
||
```bash
|
||
cd frontend && npm run build
|
||
```
|
||
|
||
Expected: No errors.
|
||
|
||
**Step 5: Run all backend tests one final time**
|
||
|
||
```bash
|
||
cd backend && pytest --override-ini="addopts="
|
||
```
|
||
|
||
Expected: All pass.
|
||
|
||
**Step 6: Final commit**
|
||
|
||
```bash
|
||
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.
|