Compare commits
72 Commits
feat/netwo
...
19cfd71995
| Author | SHA1 | Date | |
|---|---|---|---|
| 19cfd71995 | |||
| 3b55697c77 | |||
| 851966966d | |||
| 66968e4c59 | |||
| b0622f5511 | |||
| f3c3ee5b57 | |||
| b49772f1a1 | |||
| 210d310fb2 | |||
| 92fadfb90a | |||
| 3f0a132058 | |||
| da93ae55c3 | |||
| 56fd440b16 | |||
| b3be66652e | |||
| 0fbc1e0a57 | |||
| 46291f30b9 | |||
| f0ccf313a4 | |||
| 0d9babb986 | |||
| 567985402f | |||
| 08a4c6600d | |||
| 29fa48e71b | |||
| 908a867986 | |||
| 346576a730 | |||
| b18072e24b | |||
| e0f44e2985 | |||
| adfbb39297 | |||
| 6bae205a8c | |||
| ee2b2c2399 | |||
| 37bc47b75b | |||
| c8bdd0014e | |||
| 2a2b770405 | |||
| d6d0e9f3c1 | |||
| ab4bf3b32f | |||
|
|
d3c93cd006 | ||
|
|
4037a5213e | ||
|
|
0ed5977fee | ||
|
|
c5b8229ef6 | ||
|
|
eba50e1f95 | ||
|
|
8eb814283d | ||
|
|
b433b232dc | ||
|
|
015df1fe5f | ||
|
|
cf9c258f9e | ||
|
|
c063952f12 | ||
|
|
36721eb5af | ||
|
|
3cd4084f78 | ||
|
|
ed763d1cea | ||
|
|
c37e216e0b | ||
|
|
91cc9a4170 | ||
|
|
2a4220b496 | ||
|
|
c8f571db39 | ||
|
|
7efa22454d | ||
|
|
05421fc65c | ||
|
|
dfcad531e2 | ||
|
|
684fb07e47 | ||
|
|
4a12c9b37d | ||
|
|
e41d7bd960 | ||
|
|
f2c3bd7a9b | ||
|
|
9786c6b1fb | ||
|
|
4529955f7d | ||
|
|
b7b0d41f92 | ||
|
|
a4512dcf90 | ||
|
|
764db79060 | ||
|
|
f90e2c956f | ||
|
|
bdaea68dd3 | ||
|
|
02c19a7580 | ||
|
|
a392d24101 | ||
|
|
b9c9bb548d | ||
|
|
662df2907d | ||
|
|
b9547e6ce1 | ||
|
|
760e0f77f8 | ||
|
|
a71f082e25 | ||
|
|
abd79bc763 | ||
|
|
af5ceea7f9 |
154
.gitea/workflows/ci.yml
Normal file
154
.gitea/workflows/ci.yml
Normal file
@@ -0,0 +1,154 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
backend:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: pgvector/pgvector:pg16
|
||||
env:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
POSTGRES_DB: resolutionflow_test
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: >-
|
||||
--health-cmd pg_isready
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
|
||||
env:
|
||||
DATABASE_URL: postgresql+asyncpg://postgres:postgres@postgres:5432/resolutionflow_test
|
||||
DATABASE_URL_SYNC: postgresql://postgres:postgres@postgres:5432/resolutionflow_test
|
||||
SECRET_KEY: ci-test-secret-key-not-for-production
|
||||
DEBUG: "true"
|
||||
APP_NAME: ResolutionFlow
|
||||
TEST_DB_NAME: resolutionflow_test
|
||||
DB_APP_ROLE_PASSWORD: app_secret_ci
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install dependencies
|
||||
run: pip install --break-system-packages -r backend/requirements.txt -r backend/requirements-dev.txt
|
||||
|
||||
- name: Run Alembic migrations
|
||||
run: cd backend && alembic upgrade head
|
||||
|
||||
- name: Check tenant filter enforcement
|
||||
run: cd backend && python scripts/check_tenant_filters.py
|
||||
|
||||
- name: Run tests with coverage
|
||||
run: cd backend && python -m pytest --override-ini="addopts=" --cov=app --cov-report=term-missing --cov-report=json:coverage.json --cov-fail-under=50
|
||||
|
||||
- name: Display coverage summary
|
||||
if: always()
|
||||
run: |
|
||||
cd backend
|
||||
python -c "
|
||||
import json
|
||||
with open('coverage.json') as f:
|
||||
data = json.load(f)
|
||||
total = data['totals']['percent_covered_display']
|
||||
print(f'Total coverage: {total}%')
|
||||
print()
|
||||
print('Module coverage:')
|
||||
for fname, fdata in sorted(data['files'].items()):
|
||||
pct = fdata['summary']['percent_covered_display']
|
||||
if float(pct) < 80:
|
||||
print(f' WARNING {fname}: {pct}%')
|
||||
else:
|
||||
print(f' OK {fname}: {pct}%')
|
||||
"
|
||||
|
||||
frontend:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install dependencies
|
||||
run: cd frontend && npm ci
|
||||
|
||||
- name: Lint
|
||||
run: cd frontend && npm run lint
|
||||
|
||||
- name: Test with coverage
|
||||
run: cd frontend && npm run test:coverage
|
||||
|
||||
- name: Build
|
||||
run: cd frontend && NODE_OPTIONS="--max-old-space-size=4096" npm run build
|
||||
|
||||
- name: Upload build artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: frontend-dist
|
||||
path: frontend/dist
|
||||
retention-days: 1
|
||||
|
||||
e2e:
|
||||
needs: [frontend]
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: pgvector/pgvector:pg16
|
||||
env:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
POSTGRES_DB: resolutionflow_test
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: >-
|
||||
--health-cmd pg_isready
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
|
||||
env:
|
||||
PLAYWRIGHT_DATABASE_URL: postgresql+asyncpg://postgres:postgres@postgres:5432/resolutionflow_test
|
||||
PLAYWRIGHT_DATABASE_URL_SYNC: postgresql://postgres:postgres@postgres:5432/resolutionflow_test
|
||||
PLAYWRIGHT_API_ORIGIN: http://127.0.0.1:8000
|
||||
PLAYWRIGHT_BASE_URL: http://127.0.0.1:4173
|
||||
PLAYWRIGHT_SECRET_KEY: ci-playwright-secret-key
|
||||
PLAYWRIGHT_TEST_EMAIL: teamadmin@resolutionflow.example.com
|
||||
PLAYWRIGHT_TEST_PASSWORD: TestPass123!
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install backend dependencies
|
||||
run: pip install --break-system-packages -r backend/requirements.txt -r backend/requirements-dev.txt
|
||||
|
||||
- name: Install frontend dependencies
|
||||
run: cd frontend && npm ci
|
||||
|
||||
- name: Download frontend build
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: frontend-dist
|
||||
path: frontend/dist
|
||||
|
||||
- name: Install Playwright browser
|
||||
run: cd frontend && npx playwright install --with-deps chromium
|
||||
|
||||
- name: Run Playwright smoke tests
|
||||
run: cd frontend && npm run test:e2e
|
||||
|
||||
- name: Upload Playwright report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: playwright-report
|
||||
path: |
|
||||
frontend/playwright-report
|
||||
frontend/test-results
|
||||
if-no-files-found: ignore
|
||||
19
.gitea/workflows/mirror-to-github.yml
Normal file
19
.gitea/workflows/mirror-to-github.yml
Normal file
@@ -0,0 +1,19 @@
|
||||
name: Mirror to GitHub
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- '**'
|
||||
|
||||
jobs:
|
||||
mirror:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Push to GitHub
|
||||
run: |
|
||||
cd /tmp
|
||||
git clone --mirror https://gitea.resolutionflow.com/chihlasm/resolutionflow.git repo
|
||||
cd repo
|
||||
git remote add github https://x-access-token:${{ secrets.GH_MIRROR_TOKEN }}@github.com/${{ secrets.GH_MIRROR_REPO }}
|
||||
git push github --all --force
|
||||
git push github --tags --force
|
||||
43
.gitea/workflows/runner-probe.yml
Normal file
43
.gitea/workflows/runner-probe.yml
Normal file
@@ -0,0 +1,43 @@
|
||||
name: Runner Probe
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
probe:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Runner labels and OS
|
||||
run: |
|
||||
echo "=== OS ==="
|
||||
uname -a
|
||||
cat /etc/os-release 2>/dev/null || true
|
||||
|
||||
- name: Python versions
|
||||
run: |
|
||||
echo "=== Python ==="
|
||||
which python3 && python3 --version || echo "python3 not found"
|
||||
which python && python --version || echo "python not found"
|
||||
ls /usr/bin/python* 2>/dev/null || true
|
||||
|
||||
- name: Node versions
|
||||
run: |
|
||||
echo "=== Node ==="
|
||||
which node && node --version || echo "node not found"
|
||||
which npm && npm --version || echo "npm not found"
|
||||
ls /usr/bin/node* 2>/dev/null || true
|
||||
ls ~/.nvm/versions/node/ 2>/dev/null || echo "no nvm versions"
|
||||
|
||||
- name: Docker
|
||||
run: |
|
||||
echo "=== Docker ==="
|
||||
which docker && docker --version || echo "docker not found"
|
||||
docker info 2>/dev/null | grep -E "Server Version|Operating System" || true
|
||||
|
||||
- name: User and home
|
||||
run: |
|
||||
echo "=== User ==="
|
||||
whoami
|
||||
echo "HOME=$HOME"
|
||||
echo "PATH=$PATH"
|
||||
@@ -11,6 +11,7 @@ All notable changes to ResolutionFlow are documented here.
|
||||
- **Tenant Isolation Phase 0** — multi-tenant data isolation (#132) with app-layer filtering helpers (`tenant_filter()`, `get_tenant_context`), cross-tenant access audit (analytics, categories, AI sessions, trees), UUID endpoint isolation with 404 responses for unauthorized access, ownership checks on all sensitive operations, and CI grep gate for missing tenant filters
|
||||
- **Tenant Isolation Phase 2** — PostgreSQL Row Level Security (RLS) on 11 session-related tables (ai_sessions, session_steps, session_tags, etc.), account_id NOT NULL enforcement on all write paths, Alembic migrations with dual-env support (Railway native vars + explicit DATABASE_URL_SYNC), RLS test coverage with cross-account isolation verification, migration CI/CD integration
|
||||
- **Tenant Isolation Phase 3** — RLS on audit_logs and tree_shares tables, cross-tenant session access for public shares (via get_admin_db), complete account_id propagation across PSA integration write paths, final RLS policy enforcement
|
||||
- **Tenant Isolation Phase 4** (#136) — RLS enforcement on all 31 remaining tables (users, trees, teams, integrations, scripts, categories, templates, surveys, etc.), BYPASSRLS session pattern for auth deps and background jobs, admin session factory for startup routines (service accounts, seed data), global table exclusions (platform_steps, template_trees, script_categories, accounts), RLS tests with complete cross-tenant isolation verification, proper tree_shares ownership checks using tree owner's account_id
|
||||
- **Script Library default view** — "All Scripts" tab now displays all accessible scripts (team + library)
|
||||
- **Session documentation overhaul** — reformatted PSA resolution/escalation notes with cleaner headers, inline engineer responses, decimal hour display (0.25 hrs), follow-up recommendations, and improved "What We Know" section from evidence items
|
||||
- **Client communication improvements** — new `request_info` audience type for client-facing information requests, improved status update and email draft prompts with per-context guidance
|
||||
@@ -33,6 +34,7 @@ All notable changes to ResolutionFlow are documented here.
|
||||
- **Category tree counts** — cross-tenant row count leakage via tree_count field in GET `/categories/{id}`. Now scoped to requesting account.
|
||||
- **PSA retry ownership check** — retry-psa-push had no ownership validation (CRITICAL). Now validates user ownership before allowing retry.
|
||||
- **Task Lane save operation** — invalid task_lane_item UUIDs returned 403 revealing existence. Now returns 404 and uses query-level filtering.
|
||||
- **Phase 4 RLS enforcement** — fixed auth deps, user-mutation endpoints, background jobs, and lifespan routines to use BYPASSRLS sessions for reading/writing tenant-isolated tables; fixed seed scripts to use ADMIN_DATABASE_URL; bootstrap service account now initializes correctly with proper BYPASSRLS context
|
||||
- Dark text rendering on blue accent step-number badges across all flow types
|
||||
- Script Library tab ownership filter now preserved across category and search changes
|
||||
- Race conditions in script builder session creation and slug generation
|
||||
|
||||
762
CLAUDE.md
762
CLAUDE.md
@@ -1,628 +1,264 @@
|
||||
# CLAUDE.md - Patherly / ResolutionFlow Project Context
|
||||
# CLAUDE.md — ResolutionFlow
|
||||
|
||||
> **Last Updated:** April 6, 2026
|
||||
> SaaS troubleshooting platform for MSPs. Last reviewed 2026-04-19.
|
||||
|
||||
**Naming:** Canonical product name is **ResolutionFlow**. `patherly` is the legacy internal name — still present in DB name (`patherly` on Railway, `resolutionflow` locally), some Railway service names, and historical paths. Treat as aliases, not canonical. Docker containers are `resolutionflow_*`.
|
||||
|
||||
**User terminology:** "Flows" (not Trees), "Projects" (not Procedures), "Solutions Library" (not Step Library). Maintenance flows hidden from pilot UI (backend retains them). DB column `tree_type` values unchanged.
|
||||
|
||||
**SaaS shape:** Multi-tenant by account. Roles: `super_admin` > `team_admin` > `engineer` > `viewer`. Team admin = `role='engineer'` + `is_team_admin=True` + valid `team_id`. Never `role=='admin'` — use `is_super_admin`. Backend deps in `app/api/deps.py`: `get_current_active_user`, `require_engineer_or_admin`, `require_admin`. Frontend: `usePermissions()` hook. Central logic in `backend/app/core/permissions.py` + `frontend/src/hooks/usePermissions.ts`.
|
||||
|
||||
**Status:** Go-to-Market Validation (pre-PMF). Backend feature-complete (55+ endpoints, 100+ tests). Phase 0.5 FlowPilot telemetry baseline accruing. See `CURRENT-STATE.md` for live status, `03-DEVELOPMENT-ROADMAP.md` for phases.
|
||||
|
||||
**Principle:** Prefer correct architecture over minimal diff. Flag "simpler approach" tradeoffs for review before taking them.
|
||||
|
||||
---
|
||||
|
||||
## Project Overview
|
||||
## Tech stack
|
||||
|
||||
**Patherly** (user-facing brand: **ResolutionFlow**) is a **SaaS product for MSP professionals**. It provides troubleshooting decision trees that guide engineers through proven troubleshooting paths, capture decisions and notes, and generate professional ticket documentation.
|
||||
|
||||
**Target Market:** MSP companies — IT service providers managing infrastructure and support for multiple clients.
|
||||
|
||||
**SaaS Context:** Multi-tenant design — teams represent MSP companies, trees shared within teams, tiered access (super_admin, team_admin, engineer, viewer).
|
||||
|
||||
### Branding
|
||||
|
||||
| Context | Name Used |
|
||||
|---------|-----------|
|
||||
| Repository / directory / database | `patherly` (internal name) |
|
||||
| Docker containers | `resolutionflow_postgres`, `resolutionflow_frontend`, `resolutionflow_backend` |
|
||||
| Backend, frontend UI, production URLs | **ResolutionFlow** |
|
||||
|
||||
- **Design system:** [DESIGN-SYSTEM.md](DESIGN-SYSTEM.md) — THE source of truth for all design decisions
|
||||
- **Design aesthetic:** Flat, high-contrast dark theme (Sentry/PostHog-inspired). No glass morphism, no gradients on surfaces, no ambient effects. Light mode planned.
|
||||
- **Accent color:** Electric blue (#60a5fa dark / #2563eb light). Used sparingly — ≤5% of the UI. Warning is amber (#fbbf24), info is cyan (#67e8f9).
|
||||
- **Fonts:** IBM Plex Sans (`font-sans`, body), Bricolage Grotesque (`font-heading`, headings), JetBrains Mono (`font-mono`, code) — loaded via Google Fonts
|
||||
- **Logo:** 30px gradient square (ember orange) + "ResolutionFlow" in Bricolage Grotesque 700
|
||||
- **Layout:** Icon rail sidebar (72px default) with hover flyout panels. Pinnable to full 260px sidebar. See [DESIGN-SYSTEM.md](DESIGN-SYSTEM.md)
|
||||
- **Brand assets:** `brand-assets/` (source SVGs), `frontend/src/assets/brand/` (app assets), `frontend/public/icons/` (favicon)
|
||||
- **Terminology:** User-facing label is "Flows" (not "Trees"). Procedural flows are called "Projects" in the UI. Step Library is called "Solutions Library" in the UI. Maintenance flows are hidden from UI for pilot (backend still supports them). `tree_type` column values unchanged in DB.
|
||||
- **Reference mockups:** `docs/mockups/` (HTML files, open in browser)
|
||||
|
||||
**Component styling:** See Design System section below and [DESIGN-SYSTEM.md](DESIGN-SYSTEM.md). All colors via CSS variables. Use "Flows" not "Trees" in user-facing text; use "Projects" not "Procedures" for procedural flows.
|
||||
|
||||
## Implementation Principles
|
||||
|
||||
- Prefer correct architecture over minimal diff
|
||||
- If two approaches exist, implement the one that scales, not the one that's faster to write
|
||||
- Flag any "simpler approach" tradeoffs for product owner review before proceeding
|
||||
- **Backend:** Python 3.11 + FastAPI, SQLAlchemy 2.0 async (asyncpg), Alembic, Pydantic v2, JWT (python-jose + bcrypt, JTI refresh rotation), APScheduler (in-process with FastAPI lifespan).
|
||||
- **Frontend:** React 19 + Vite + TypeScript, Tailwind v4 (CSS-only config in `index.css`), Zustand (immer + zundo), React Router v7, Axios (token-refresh interceptor), Lucide.
|
||||
- **DB:** PostgreSQL 16 (RLS enabled Phase 4, pgvector).
|
||||
|
||||
---
|
||||
|
||||
## Current State
|
||||
|
||||
- **Phase:** Go-to-Market Validation (Pre-PMF)
|
||||
- **Backend:** Complete (55+ API endpoints, 100+ integration tests)
|
||||
- **Frontend:** Core features complete, Tree Editor functional
|
||||
- **Database:** PostgreSQL with Docker, 101 migrations
|
||||
- **Detailed status:** [CURRENT-STATE.md](CURRENT-STATE.md)
|
||||
|
||||
### What's In Progress
|
||||
|
||||
- GTM validation: Shadow & Ship — founder dogfooding for 2 weeks, then 5 colleague pilot
|
||||
- Solutions Library spec written (`docs/plans/2026-03-23-solutions-library-design.md`), implementation post-pilot
|
||||
- Remaining open issues: #66 Templates + Import/Export, #60 Recurring Issue Detection, #58 Step Feedback Flag
|
||||
|
||||
---
|
||||
|
||||
## Tech Stack
|
||||
|
||||
### Backend
|
||||
|
||||
- **Framework:** Python FastAPI
|
||||
- **Database:** PostgreSQL 16 (async via SQLAlchemy 2.0 + asyncpg)
|
||||
- **Migrations:** Alembic
|
||||
- **Auth:** JWT (python-jose) + bcrypt, refresh token rotation (JTI-based)
|
||||
- **Validation:** Pydantic v2
|
||||
- **Scheduling:** APScheduler 3.x (async, in-process with FastAPI lifespan) + croniter + pytz
|
||||
|
||||
### Frontend
|
||||
|
||||
- **Framework:** React 19 + Vite + TypeScript
|
||||
- **Styling:** Tailwind CSS v4 (`@tailwindcss/vite` plugin, CSS-only config in `index.css`) — flat dark theme with ember orange accent (see [DESIGN-SYSTEM.md](DESIGN-SYSTEM.md))
|
||||
- **State:** Zustand (with immer + zundo for undo/redo)
|
||||
- **Routing:** React Router v7
|
||||
- **API Client:** Axios with token refresh interceptor
|
||||
- **Icons:** Lucide React
|
||||
|
||||
---
|
||||
|
||||
## Key Project Structure
|
||||
## Project structure
|
||||
|
||||
```
|
||||
patherly/
|
||||
resolutionflow/
|
||||
├── backend/
|
||||
│ ├── app/
|
||||
│ │ ├── main.py # FastAPI entry point
|
||||
│ │ ├── api/endpoints/ # Route handlers (auth, trees, sessions, admin, steps, survey, copilot, assistant_chat, integrations)
|
||||
│ │ │ ├── flow_proposals.py # Knowledge Flywheel review queue CRUD
|
||||
│ │ │ └── flowpilot_analytics.py # FlowPilot dashboard metrics
|
||||
│ │ ├── api/deps.py # Auth dependencies (includes require_team_admin)
|
||||
│ │ ├── api/router.py # Route registration
|
||||
│ │ ├── core/ # config, database, permissions, security, audit, rate_limit
|
||||
│ │ ├── models/ # SQLAlchemy models (includes FlowProposal)
|
||||
│ │ ├── schemas/ # Pydantic schemas
|
||||
│ │ ├── services/psa/ # PSA provider abstraction (base, connectwise/, autotask/, halopsa/, cache, encryption, registry, types)
|
||||
│ │ ├── services/knowledge_flywheel.py # AI session analysis → flow proposals
|
||||
│ │ ├── services/knowledge_flywheel_scheduler.py # APScheduler job for batch analysis
|
||||
│ │ └── services/knowledge_gap_service.py # Weak options & escalation signal detection
|
||||
│ ├── alembic/ # Database migrations (001-070 sequential, then hash IDs)
|
||||
│ ├── scripts/ # seed_data.py, seed_trees.py
|
||||
│ └── tests/ # pytest integration tests
|
||||
│ │ ├── main.py # FastAPI entry
|
||||
│ │ ├── api/endpoints/ # auth, trees, sessions, admin, steps, survey, copilot, assistant_chat, integrations, flow_proposals, flowpilot_analytics
|
||||
│ │ ├── api/deps.py # auth deps (incl. require_team_admin)
|
||||
│ │ ├── api/router.py # registration
|
||||
│ │ ├── core/ # config, database, permissions, security, audit, rate_limit
|
||||
│ │ ├── models/ # SQLAlchemy (incl. FlowProposal)
|
||||
│ │ ├── schemas/ # Pydantic
|
||||
│ │ ├── services/psa/ # PSA provider pattern (base, connectwise/, autotask/, halopsa/, cache, encryption, registry, types)
|
||||
│ │ ├── services/knowledge_flywheel.py + _scheduler.py
|
||||
│ │ └── services/knowledge_gap_service.py
|
||||
│ ├── alembic/versions/ # 001-070 sequential, then hex hash
|
||||
│ ├── scripts/ # seed_data, seed_trees, seed_test_users
|
||||
│ └── tests/ # pytest integration
|
||||
├── frontend/
|
||||
│ ├── src/
|
||||
│ │ ├── api/ # Axios client + endpoint modules
|
||||
│ │ ├── components/ # common, layout, dashboard, tree-editor, session, procedural, procedural-editor, library, step-library, ui, flowpilot
|
||||
│ │ ├── hooks/ # usePermissions, useSessionTimer, useKeyboardShortcuts
|
||||
│ │ ├── pages/ # All page components
|
||||
│ │ ├── store/ # Zustand stores (auth, treeEditor, proceduralEditor, userPreferences, scriptGeneratorStore)
|
||||
│ │ └── types/ # TypeScript interfaces
|
||||
│ └── (Tailwind v4: CSS-only config in src/index.css)
|
||||
├── docs/plans/archive/ # Archived design/impl docs (pre-March 2026)
|
||||
├── CLAUDE.md # This file
|
||||
├── CURRENT-STATE.md # Detailed feature status
|
||||
├── LESSONS-LEARNED.md # (Deprecated — consolidated into CLAUDE.md)
|
||||
└── docs/plans/ # Design docs & implementation plans
|
||||
│ │ ├── api/ # Axios client + endpoint modules
|
||||
│ │ ├── components/ # common, layout, dashboard, tree-editor, session, procedural, procedural-editor, library, step-library, ui, flowpilot
|
||||
│ │ ├── hooks/ # usePermissions, useSessionTimer, useKeyboardShortcuts
|
||||
│ │ ├── pages/
|
||||
│ │ ├── store/ # Zustand (auth, treeEditor, proceduralEditor, userPreferences, scriptGeneratorStore)
|
||||
│ │ └── types/
|
||||
│ └── (Tailwind v4 CSS-only config in src/index.css)
|
||||
├── docs/plans/archive/ # pre-March 2026 plans
|
||||
├── docs/connectwise/ # CW API reference + best-practices guides
|
||||
├── docs/LESSONS-ARCHIVE.md # archived lessons (fixes in code)
|
||||
├── CLAUDE.md · CURRENT-STATE.md · DESIGN-SYSTEM.md · DEV-ENV.md
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Environment Variables
|
||||
## Design system
|
||||
|
||||
### Backend (`backend/.env`)
|
||||
**Source of truth: [DESIGN-SYSTEM.md](DESIGN-SYSTEM.md).** Read before any visual change.
|
||||
|
||||
- Flat high-contrast dark theme, Sentry/PostHog-inspired. **No** glass, backdrop blur, ambient orbs, gradient surfaces.
|
||||
- Accent **electric blue** (#60a5fa dark / #2563eb light) — ≤5% of UI, interactive elements only. Warning amber (#fbbf24), info cyan (#67e8f9), success green (#34d399), danger red (#f87171). Each with `-dim` at 10% opacity.
|
||||
- Backgrounds: `bg-sidebar` (#0e1016) → `bg-page` (#16181f) → `bg-card` (#1e2028) → `bg-elevated` (#2a2d38). Borders `border-default` / `border-hover`.
|
||||
- Text: `text-heading` → `text-primary` → `text-muted-foreground` → `text-muted`.
|
||||
- Fonts: IBM Plex Sans (body), Bricolage Grotesque (heading, 700 weight for logo), JetBrains Mono (code).
|
||||
- Logo: 30px gradient square (ember orange) + "ResolutionFlow" in Bricolage Grotesque. Assets in `brand-assets/`, `frontend/src/assets/brand/`, `frontend/public/icons/`.
|
||||
- Mockups: `docs/mockups/` (HTML).
|
||||
- **Deprecated — do not use:** glass-card, glass-stat, `bg-gradient-brand`, `backdrop-filter: blur()`, ambient orbs, purple gradients, ember orange as accent, cyan as accent (cyan is info only).
|
||||
|
||||
---
|
||||
|
||||
## ConnectWise PSA
|
||||
|
||||
Reference: `docs/connectwise/` — start with `CONNECTWISE-API-REFERENCE.md`, then the `best-practices/` guides. Extracted OpenAPI spec in `connectwise-psa-resolutionflow-reference.json` (670 endpoints, v2025.16); full spec in `connectwise-psa-openapi-full.json`.
|
||||
|
||||
- **Auth:** API Key (Base64 `companyId+publicKey:privateKey`) + `clientId` header every request. `clientId` is server-side (`CW_CLIENT_ID` in `config.py`) — identifies ResolutionFlow, not per-tenant. Per-connection: `company_id`, `public_key`, `private_key`, `server_url`.
|
||||
- **Architecture:** `services/psa/` provider pattern — `PSAProvider` base, `ConnectWiseProvider` impl, `PsaProviderRegistry` for multi-PSA dispatch. Credentials encrypted at rest via `services/psa/encryption.py` (Fernet). Per-team credentials, never per-user. Endpoints in `api/endpoints/integrations.py`. In-memory TTL cache in `services/psa/cache.py`.
|
||||
- **Integration flows:** session docs → ticket notes (`POST /service/tickets/{id}/notes`, markdown supported); ticket context → FlowPilot; callbacks via `/system/callbacks` with HMAC verification.
|
||||
- **API rules:** pin version via Accept header `application/vnd.connectwise.com+json; version=2025.16`. Paginate ≤1000/page. Dynamic base URL via `/login/companyinfo/{companyId}`. Request minimal permissions (MY, not ALL).
|
||||
|
||||
---
|
||||
|
||||
## Dev commands
|
||||
|
||||
Full setup in [DEV-ENV.md](DEV-ENV.md) (host-agnostic, with homelab Proxmox reference topology). Day-to-day:
|
||||
|
||||
```bash
|
||||
APP_NAME=ResolutionFlow
|
||||
DEBUG=true
|
||||
DATABASE_URL=postgresql+asyncpg://postgres:postgres@localhost:5432/patherly
|
||||
DATABASE_URL_SYNC=postgresql://postgres:postgres@localhost:5432/patherly
|
||||
SECRET_KEY=<openssl rand -hex 32>
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES=5
|
||||
REFRESH_TOKEN_EXPIRE_DAYS=7
|
||||
REQUIRE_INVITE_CODE=true
|
||||
```
|
||||
|
||||
### Frontend (`frontend/.env.local` - optional)
|
||||
|
||||
```bash
|
||||
VITE_API_URL=http://localhost:8000
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ConnectWise PSA Integration
|
||||
|
||||
ResolutionFlow integrates with ConnectWise PSA (formerly Manage) as the primary PSA integration. All ConnectWise API reference materials live in `docs/connectwise/`.
|
||||
|
||||
### Best Practices Documentation
|
||||
|
||||
Official ConnectWise developer guides live in `docs/connectwise/best-practices/`. Read these BEFORE implementing any CW API integration code:
|
||||
|
||||
- `PSA-API-Requests.md` — HTTP methods, response codes, condition query syntax, PATCH format, URL encoding, partial responses, custom fields. READ FIRST.
|
||||
- `PSA-Callbacks.md` — Callback type/level matrix, retry behavior, URL parameter gotcha, HMAC signature verification.
|
||||
- `PSA-Pagination.md` — Navigable vs Forward-Only pagination, Link headers, while-loop pattern.
|
||||
- `PSA-Service-Tickets.md` — Ticket field philosophy, recommended field mappings.
|
||||
- `PSA-Versioning.md` — Pin API version via Accept header. Use `application/vnd.connectwise.com+json; version=2025.16`.
|
||||
- `PSA-Cloud-URL-Formatting.md` — Dynamic base URL construction via `/login/companyinfo/{companyId}`.
|
||||
- `Bundled-Requests.md` — Batch multiple API calls into one request via `/system/bundles`.
|
||||
- `PSA-Markdown.md` — Ticket notes support markdown. Format session documentation output accordingly.
|
||||
- `PSA-Company-Synchronization.md` — Filter companies by Status/Type for mapping UI.
|
||||
- `PSA-Data-Protection.md` — Security role model, request minimal permissions (MY not ALL).
|
||||
|
||||
### Reference Files (read in this order)
|
||||
|
||||
1. `docs/connectwise/CONNECTWISE-API-REFERENCE.md` — Read FIRST. Quick reference covering auth patterns, tiered endpoint map, key field mappings, and integration architecture flows.
|
||||
2. `docs/connectwise/connectwise-psa-resolutionflow-reference.json` — Extracted OpenAPI 3.0.1 spec (v2025.16) with only the 670 endpoints and 342 schemas relevant to ResolutionFlow. Use for exact field types, request/response shapes, and parameter details.
|
||||
3. `docs/connectwise/connectwise-psa-openapi-full.json` — Complete ConnectWise PSA OpenAPI spec (1838 endpoints, 842 schemas). Only consult if you need an endpoint outside the extracted subset.
|
||||
|
||||
### Integration Architecture
|
||||
|
||||
- **Session → Ticket Notes:** Post auto-generated session documentation to ConnectWise tickets as internal analysis notes via `POST /service/tickets/{id}/notes`
|
||||
- **Ticket Context → Session Runner:** Pull ticket details, company info, and attached configurations to give FlowPilot AI real-world context
|
||||
- **Callbacks:** Register webhooks via `/system/callbacks` for real-time ticket event notifications to suggest relevant Flows
|
||||
|
||||
### Key Implementation Rules
|
||||
|
||||
- Auth: API Key auth (Base64 of `companyId+publicKey:privateKey`) + `clientId` header on every request
|
||||
- `clientId` is server-side config (`CW_CLIENT_ID` in `config.py`) — identifies the ResolutionFlow app, NOT per-tenant. Per-connection credentials: `company_id`, `public_key`, `private_key`, `server_url`
|
||||
- All PSA integration code in `services/psa/` — provider pattern with `PSAProvider` abstract base class, `ConnectWiseProvider` implementation, `PsaProviderRegistry` for multi-PSA dispatch
|
||||
- PSA endpoints in `api/endpoints/integrations.py` — connection CRUD, ticket ops, member mapping
|
||||
- Credentials encrypted at rest via `services/psa/encryption.py` (Fernet)
|
||||
- Each MSP tenant provides their own CW credentials — ResolutionFlow stores these per-team, never per-user
|
||||
- Design for the Autotask integration following the same service layer pattern (future PSA)
|
||||
- In-memory TTL cache in `services/psa/cache.py` for board/status/priority lookups
|
||||
- Respect CW API: paginate with max 1000 per page, handle retries gracefully
|
||||
|
||||
---
|
||||
|
||||
## Development Commands
|
||||
|
||||
```powershell
|
||||
# Start PostgreSQL (run from VPS SSH — docker not available inside code-server, see Lesson 103)
|
||||
docker start resolutionflow_postgres
|
||||
|
||||
# Backend (from backend/)
|
||||
source venv/bin/activate # Linux/Mac
|
||||
# .\venv\Scripts\Activate # Windows
|
||||
uvicorn app.main:app --reload
|
||||
|
||||
# Frontend (from frontend/)
|
||||
npm run dev
|
||||
|
||||
# Run tests (from backend/)
|
||||
pytest --override-ini="addopts="
|
||||
|
||||
# First time only: create test database
|
||||
docker exec -it resolutionflow_postgres psql -U postgres -c "CREATE DATABASE resolutionflow_test;"
|
||||
|
||||
# Frontend build (IMPORTANT: stricter than tsc --noEmit — always use as final check)
|
||||
cd frontend && npm run build
|
||||
|
||||
# Database migrations
|
||||
cd backend && alembic upgrade head
|
||||
alembic revision --autogenerate -m "Description"
|
||||
# Sequential 3-digit IDs (001–070) were used historically. New migrations use Alembic's default hex hash IDs.
|
||||
# Do NOT pass --rev-id — let Alembic generate the hash automatically.
|
||||
|
||||
# Access PostgreSQL (run from VPS SSH — docker not available inside code-server, see Lesson 103)
|
||||
docker compose -f docker-compose.dev.yml up -d # start stack
|
||||
cd backend && source venv/bin/activate && uvicorn app.main:app --reload
|
||||
cd frontend && npm run dev
|
||||
pytest --override-ini="addopts=" # tests (first time: CREATE DATABASE resolutionflow_test)
|
||||
cd backend && alembic upgrade head # migrate
|
||||
cd backend && alembic revision -m "desc" # manual migration (preferred per Lesson 77)
|
||||
cd backend && alembic revision --autogenerate -m "desc" # picks up drift; review carefully
|
||||
cd frontend && npm run build # stricter than tsc --noEmit — final check
|
||||
cd frontend && npx tsc -b # TS-only check when dist/ has EACCES
|
||||
docker exec -it resolutionflow_postgres psql -U postgres -d resolutionflow
|
||||
|
||||
# Seed data
|
||||
cd backend && pip install httpx && python -m scripts.seed_trees
|
||||
|
||||
# CI/CD debugging
|
||||
gh run list --limit 5 # Recent CI runs
|
||||
gh run view <id> --log-failed # Failed job logs
|
||||
gh run view <id> --json jobs --jq '.jobs[] | {name: .name, conclusion: .conclusion}'
|
||||
# NEVER use `gh run watch` — it holds context open and burns tokens while waiting
|
||||
python -m scripts.seed_trees # seed (from backend/)
|
||||
```
|
||||
|
||||
### URLs
|
||||
**URLs:** Frontend <http://localhost:5173>, backend <http://localhost:8000>, API docs <http://localhost:8000/api/docs>.
|
||||
|
||||
- Frontend: <http://localhost:5173>
|
||||
- Backend API: <http://localhost:8000>
|
||||
- API Docs: <http://localhost:8000/api/docs>
|
||||
**Test users** (all password `TestPass123!`): `admin@resolutionflow.example.com` (super_admin), `teamadmin@resolutionflow.example.com`, `engineer@resolutionflow.example.com`, `pro@resolutionflow.example.com`.
|
||||
|
||||
### Test Users (seeded via `scripts/seed_test_users.py`)
|
||||
**CI:** Gitea (`gitea.resolutionflow.com/chihlasm/resolutionflow/actions`). `gh` CLI works for issues/PRs on the GitHub mirror, but not CI runs.
|
||||
|
||||
- All share password: `TestPass123!`
|
||||
- `admin@resolutionflow.example.com` (super_admin), `teamadmin@resolutionflow.example.com` (team_admin), `engineer@resolutionflow.example.com` (engineer), `pro@resolutionflow.example.com` (solo pro)
|
||||
**Never pass `--rev-id`** to alembic — let it generate the hex hash.
|
||||
|
||||
---
|
||||
|
||||
## Critical Lessons Learned
|
||||
|
||||
> Lessons 1-40 archived to `docs/LESSONS-ARCHIVE.md` — fixes are baked into the codebase. Consult if you hit a regression.
|
||||
|
||||
### Active Lessons (41+)
|
||||
|
||||
**41. Assistant chat uses local React state, not Zustand:** `AssistantChatPage.tsx` uses `useState` for `chats`, `messages`, `input`, `loading`. No store.
|
||||
|
||||
**42. Public pages use raw `fetch()`, not `apiClient`:** Survey, shared sessions, and no-auth pages use `fetch()` with full URL. `apiClient` requires auth tokens.
|
||||
|
||||
**43. Adding new email types:** Add static async method to `EmailService` in `core/email.py`. Fire-and-forget from endpoints (log errors, don't fail).
|
||||
|
||||
**44. AI Chat Builder is flow-type-aware:** `ai_chat_service.py` dispatches by `flow_type`. Troubleshooting: `[TREE_UPDATE]` markers. Procedural: `[STEPS_UPDATE]` markers. Both support `[METADATA]`.
|
||||
|
||||
**45. Intake form field schema:** Uses `variable_name` and `field_type` (NOT `name` and `type`).
|
||||
|
||||
**46. `CreateFlowDropdown` uses `AIPromptDialog`:** Opens prompt modal, starts AI session, generates flow, navigates to editor with `{ state: { aiPanelOpen: true, sessionId } }`.
|
||||
|
||||
**47. Editor-Embedded Flow Assist:** `EditorAIPanel` (320px side panel) + `useEditorAI` hook. Ghost nodes use `_suggestion: true` flag. Actions route to model tiers via `settings.get_model_for_action()`. Delta responses use `[DELTA]...[/DELTA]` markers.
|
||||
|
||||
**48. Tree orphan validation uses dynamic root ID:** Orphan check compares against `state.treeStructure?.id` (NOT hardcoded `'root'`).
|
||||
|
||||
**49. Full-stack features — verify both ends:** Check the full data flow: schema → endpoint → API client → hook → store → UI.
|
||||
|
||||
**50. Anthropic SDK retry:** Set `max_retries=1` to fail fast. Default `max_retries=2` can take 3× timeout.
|
||||
|
||||
**51. AI model tier routing:** Use `settings.get_model_for_action(action_type)`. Model IDs: use alias form (`claude-sonnet-4-6`).
|
||||
|
||||
**52. Mobile scroll-to-top:** Use `ref.current.scrollIntoView()`, not `window.scrollTo()`. Trigger via `useEffect`.
|
||||
|
||||
**53. Flex height chain:** Every ancestor must be a flex container for `flex-1` to work. Missing `flex` class collapses React Flow to 0 height.
|
||||
|
||||
**54. React Flow CSS in Tailwind v4:** Import in `index.css`, not component JS. Override dark theme using `--xy-*` CSS custom properties.
|
||||
|
||||
**55. App shell height chain:** Every wrapper between `.main-content` and canvas needs `flex` + `flex-1` + `min-h-0` or `h-full`.
|
||||
|
||||
**56. Railway backend service name is `patherly`:** Production DB name is `railway`. Public Postgres proxy: `interchange.proxy.rlwy.net:45797`.
|
||||
|
||||
**57. Node field priority:** `title` → `question` → `description` → `content` → `label`. See `copilot_service.py`.
|
||||
|
||||
**58. `scriptGeneratorStore.generate()` optional param:** Always wrap: `onClick={() => generate()}`, never `onClick={generate}`.
|
||||
|
||||
**59. ConnectWise `clientId` is server-side config:** Set in `config.py` as `CW_CLIENT_ID`. Per-connection: `company_id`, `public_key`, `private_key`, `server_url`.
|
||||
|
||||
**60. Dockerfile build args for Vite env vars:** Any new `VITE_*` or `VITE_PUBLIC_*` env var must be added as `ARG` + `ENV` in `frontend/Dockerfile` for Railway deploys. Railway env vars are runtime-only unless explicitly passed through as Docker build args. Without this, `import.meta.env.VITE_*` resolves to `undefined` in production builds.
|
||||
|
||||
**61. Procedural sessions auto-start on page load:** `ProceduralNavigationPage` calls `startSession()` immediately in `loadTree()` — there is no intake form screen or "Start" button. Variables are filled inline during execution. Troubleshooting flows DO have a start screen with ticket/client fields. Don't write tests or UI that assume a Start button on procedural flows.
|
||||
|
||||
**62. Playwright strict mode — scope selectors to avoid ambiguity:** Step titles appear in both the sidebar checklist and main content heading. Use `getByRole('heading', { name })` for the main content, or scope with `page.locator('.animate-scale-in')` for command palette items. `getByText()` frequently matches multiple elements due to the sidebar + main content layout.
|
||||
|
||||
**63. Node 20 required for frontend builds:** Vite 7+ requires Node 20.19+. The system Node may be v18; use nvm: `export NVM_DIR="$HOME/.nvm" && source "$NVM_DIR/nvm.sh" && nvm use 20`. For direct binary access without nvm sourcing: `PATH="$HOME/.nvm/versions/node/v20.19.0/bin:$PATH"`.
|
||||
|
||||
**64. PostHog product analytics:** Initialized via `PostHogProvider` in `main.tsx` with explicit `posthog.init()` + `client` prop pattern. Event helpers in `lib/analytics.ts` — use `analytics.eventName(props)` to track. `identifyUser()` called in `authStore.fetchUser()`, `resetAnalytics()` on logout. Env vars: `VITE_PUBLIC_POSTHOG_KEY`, `VITE_PUBLIC_POSTHOG_HOST`. Autocapture enabled.
|
||||
|
||||
**65. Local Docker Compose uses `resolutionflow` database on port 5433:** Container name is `resolutionflow_postgres`, database is `resolutionflow` (not `patherly`), port mapped to `5433` (not `5432`). The `POSTGRES_PORT` env var controls this. Playwright config defaults must match: `postgresql+asyncpg://postgres:postgres@127.0.0.1:5433/resolutionflow`.
|
||||
|
||||
**66. Dev environment runs on Hostinger VPS (46.202.92.250), not localhost:** Code-server runs in Docker on a VPS (previously devserver01/192.168.0.9). Frontend/backend are accessed via `46.202.92.250`, not `localhost`. CORS must include the VPS IP in `CORS_ORIGINS` and `FRONTEND_URL`. Frontend `.env` must set `VITE_API_URL` to the VPS backend URL. See [DEV-ENV.md](DEV-ENV.md) for full setup, Docker config, networking, and known issues.
|
||||
|
||||
**67. Tree editor route is `/trees/new`:** NOT `/editor/new`. Check `router.tsx` line 156 for the canonical path. Use `getTreeEditorPath()` from `@/lib/routing` when navigating programmatically.
|
||||
|
||||
**68. APScheduler jobs need `max_instances=1`:** Without it, overlapping scheduler runs can process the same records twice (TOCTOU race). Always set `max_instances=1` on interval jobs in `main.py`.
|
||||
|
||||
**69. PostgreSQL `func.sum(case(...))` returns `Decimal` via asyncpg:** Cast to `int()` before storing in Pydantic `dict[str, Any]` fields, or JSON serialization may produce unexpected types.
|
||||
|
||||
**70. Toast library uses `toast.warning()` not `toast.warn()`:** Import from `@/lib/toast`. Methods: `success`, `error`, `warning`, `info`. See `frontend/src/lib/toast.ts`.
|
||||
|
||||
**71. Enhancement/branch_addition proposals cannot be directly approved:** Backend returns 400 — they require `modified_flow_data` via "Edit & Publish" flow. Only `new_flow` proposals support direct approve.
|
||||
|
||||
**72. `ai_sessions.status` column is `VARCHAR(30)`:** Must fit `requesting_escalation` (23 chars). If adding new status values, verify length. Migration `f0aad74ea51b` widened from 20→30.
|
||||
|
||||
**73. `get_db` rolls back on exception:** The dependency does `await session.rollback()` on error to prevent `InFailedSQLTransaction` cascade. Never remove this — without it, one failed request poisons subsequent requests on the same connection.
|
||||
|
||||
**74. FlowPilot action bar height chain:** The action bar (Resolve/Escalate/Pause) requires every ancestor from `app-shell` grid down to have proper flex constraints. Key fix: `ViewTransitionOutlet` wrapper needs `flex flex-col`. If action bar disappears, check height chain with DevTools `getBoundingClientRect()` walk.
|
||||
|
||||
**75. Dashboard prefill auto-submits:** `StartSessionInput` navigates to `/pilot` or `/assistant` with `{ state: { prefill } }`. `FlowPilotSessionPage` auto-submits via `useEffect` + `prefillHandledRef` guard — no double-enter. `AssistantChatPage` does the same pattern.
|
||||
|
||||
**76. Active session navigation guard:** `FlowPilotSessionPage` uses `useBlocker` (same as `TreeEditorPage`) to intercept navigation during active sessions. "Pause & Leave" auto-pauses before proceeding.
|
||||
|
||||
**77. Prefer manual Alembic migrations for targeted changes:** `alembic revision --autogenerate` picks up drift from all tables. For single-column fixes, use `alembic revision -m "desc"` and write `op.alter_column()` manually.
|
||||
|
||||
**78. Landing page subtitle is "AI-Powered Troubleshooting for MSPs":** Not "Decision Tree Platform". This tagline appears on login, register, and the HTML `<title>`. The old "Decision Tree Platform" was internal jargon misaligned with user-facing branding.
|
||||
|
||||
**79. Custom modals must be mobile-responsive:** Use `items-end sm:items-center` (bottom-sheet on mobile, centered on desktop) and `max-w-full sm:max-w-lg` (full-width on mobile). The shared `Modal.tsx` does this correctly — custom modal implementations must follow the same pattern. See `PrepareSessionModal.tsx` for the fix pattern.
|
||||
|
||||
**80. TopBar search collapses to icon on mobile:** Full search bar (`hidden sm:block`) shows on desktop; magnifying glass icon button (`sm:hidden`) shows on mobile (<640px). Both open the same CommandPalette. Don't add `w-full` search bar without the mobile icon fallback.
|
||||
|
||||
**81. Never use `transition: all` in landing.css:** Specify exact properties: `transition: background 0.3s, border-color 0.3s, box-shadow 0.3s, transform 0.3s, opacity 0.3s`. `transition: all` animates layout properties and causes jank.
|
||||
|
||||
**82. `bun` requires PATH setup on devserver01:** `export BUN_INSTALL="$HOME/.bun" && export PATH="$BUN_INSTALL/bin:$PATH"`. The gstack browse binary and Playwright need this. Chromium system deps: `libatk1.0-0 libatk-bridge2.0-0 libcups2 libxkbcommon0 libatspi2.0-0 libxcomposite1 libxdamage1 libxfixes3 libxrandr2 libgbm1 libasound2`.
|
||||
|
||||
**83. ~~FlowPilot ActionBar fixed bottom~~ (Superseded by Lesson 93):** Actions moved to the page header. `FlowPilotActionBar` component exists but is no longer used in the main session flow. The only fixed-bottom element is the message input.
|
||||
|
||||
**84. AI session `abandoned` status is fully wired:** `POST /ai-sessions/{id}/abandon` sets status to `abandoned` with optional `reason` param. Frontend: `aiSessionsApi.abandonSession()`, `useFlowPilotSession().abandonSession()`, "Close" button in `FlowPilotActionBar`. Redirects to `/sessions` after closing.
|
||||
|
||||
**85. Date range filter end dates must use end-of-day:** `toDate.toISOString()` sends midnight (start of day), excluding items created later that day. Always set `toDate.setHours(23, 59, 59, 999)` before sending. For string-based date inputs (AI sessions), append `T23:59:59.999Z`. See `SessionHistoryPage.tsx`.
|
||||
|
||||
**86. Script Builder system:** AI-powered script generation at `/script-builder`. Chat-style interface generates PowerShell/Bash/Python scripts from natural language. Backend: `ScriptBuilderSession` model, `script_builder_service.py`, endpoints at `/scripts/builder/`. Frontend: `ScriptBuilderPage`, `ScriptCodeBlock`, `ScriptPreviewModal`, `SaveToLibraryDialog`. FlowPilot can hand off to Script Builder via `action_type: "open_script_builder"` with `sessionStorage` context passing.
|
||||
|
||||
**87. FlowPilot must ask GUI vs script preference:** When a task can be done via GUI or script (e.g., creating AD users), FlowPilot must ask the engineer which approach they prefer BEFORE suggesting either. Never assume the user wants a script. See `FLOWPILOT_SYSTEM_PROMPT` rules in `flowpilot_engine.py`.
|
||||
|
||||
**88. Charcoal palette — sidebar-darkest approach:** Sidebar `#0e1016`, page `#16181f`, cards `#1e2028`, borders `#2a2e3a`. This gives more contrast range than true-dark. All colors via CSS variables in `index.css` `@theme` block. Accent is electric blue (#60a5fa), not orange or cyan.
|
||||
|
||||
*(Lessons 89–91 were retracted.)*
|
||||
|
||||
**92. `tsc -b` in Dockerfile is stricter than `npx tsc --noEmit`:** The production build (`tsc -b && vite build`) enforces `noUnusedLocals` and `noUnusedParameters` as hard errors. After any refactor that moves logic between components or removes features, trace every import and destructured prop to remove orphans. IDE warnings (yellow squiggles) flag these — check them before pushing.
|
||||
|
||||
**93. FlowPilot actions live in the page header, not a bottom bar:** `FlowPilotSessionPage` renders Resolve/Escalate/Share Update in the header bar. Desktop: inline buttons + `⋯` overflow (Pause/Close). Mobile: single `⋯` menu. The bottom only has the message input. `FlowPilotActionBar` component still exists but is no longer used in the main session flow.
|
||||
|
||||
**94. Frontend chat uses unified_chat_service, not assistant_chat_service:** `AssistantChatPage` calls `/ai-sessions/{id}/chat` → `unified_chat_service.py`. The old `assistant_chat_service` endpoints were removed (only retention settings remain at `/assistant/retention`). When tracing chat features, start from `aiSessionsApi.sendChatMessage` → `ai_sessions.py` → `unified_chat_service.py`. Never wire chat features into `assistant_chat.py`.
|
||||
|
||||
**95. Image upload → AI vision pipeline:** Paste/attach images → upload to Railway S3 bucket via `uploadsApi.upload()` → send `upload_ids` with chat message → backend fetches from S3 via `storage_service.download_file()` → resized via `storage_service.resize_image_for_vision()` (Pillow, 1568px max, PNG→JPEG) → base64-encoded → sent as Claude multimodal content blocks. Max 3 images/message. Images are NOT stored in conversation history (text-only). Vision helpers live in `storage_service.py`.
|
||||
|
||||
**96. `bg-accent` is electric blue — never use for code/kbd elements:** In Tailwind v4, `bg-accent` maps to `--color-accent: #60a5fa` (dark) / `#2563eb` (light). Use `bg-code` for code blocks, `bg-white/[0.12] border border-white/[0.06]` for inline code/badges, `bg-white/[0.08]` for kbd shortcuts. Blue accent is reserved for interactive elements only (buttons, active nav, links). Ember orange (#f97316) is deprecated — do not use.
|
||||
|
||||
**97. Railway Object Storage (S3 bucket) is provisioned:** Bucket `resolutionflow-uploads` on Railway canvas. Variables: `STORAGE_ENDPOINT`, `STORAGE_ACCESS_KEY`, `STORAGE_SECRET_KEY`, `STORAGE_BUCKET_NAME`, `STORAGE_REGION` — mapped via variable references on the `patherly` backend service. Accessed via boto3 in `storage_service.py`. Pillow (`Pillow>=10.0.0`) + `libjpeg-dev`/`zlib1g-dev` in Dockerfile for image resize.
|
||||
|
||||
**98. `lazyWithRetry` for stale chunk errors:** All lazy-loaded routes use `lazyWithRetry` from `@/lib/lazyWithRetry.ts` instead of `React.lazy`. Auto-reloads the page on chunk load failures (stale deploys). Uses sessionStorage debounce (10s) to prevent loops. When adding new lazy routes, use `lazyWithRetry`, not `lazy`.
|
||||
|
||||
**99. Tailwind v4 `text-secondary` renders invisible on dark backgrounds:** `text-secondary` maps to `--color-secondary: #2e3140` (a dark surface color), NOT `--color-text-secondary`. For readable secondary text, use `text-muted-foreground` (`#848b9b`). Also avoid `text-muted` (`#4f5666`) for body text — it's for labels only. This applies to ALL new components.
|
||||
|
||||
**100. Hover pop-out card pattern:** For cards that expand on hover "in front of everything": use `pointer-events-none` on the scrim (`fixed inset-0 z-40 bg-black/30`), absolute-position the expanded card at `z-50` with its own `onClick` handler, and dismiss via `onMouseLeave` on the wrapper div. Never put interactive event handlers on the scrim — it blocks clicks on sibling elements.
|
||||
|
||||
**101. AI marker format compliance:** The AI assistant uses `[QUESTIONS]`, `[ACTIONS]`, and `[FORK]` markers in responses. Parsed by `unified_chat_service.py` (`_parse_*_marker` functions), returned as structured data in the API response. System prompt in `assistant_chat_service.py` has a final reminder section, and each user message gets an invisible `[SYSTEM: ...]` reminder appended in `_call_anthropic_cached()`. If markers stop appearing: check conversation history stores `display_content` (stripped), verify system prompt final reminder exists, check user message reminder injection is active.
|
||||
|
||||
**102. TaskLane activation must happen in ALL chat response paths:** `AssistantChatPage.tsx` has three code paths calling `sendChatMessage`: `handleSend` (regular messages), `sendPrefill` (dashboard handoff), `handleResumeNew` (resume from concluded session). ALL three must check `response.actions`/`response.questions` and call `setShowTaskLane(true)`. Missing this in any path causes TaskLane to not appear on first message.
|
||||
|
||||
**103. Docker not available in code-server container:** The dev environment runs code-server inside Docker on the VPS. The `docker` CLI is not available inside the code-server container. To query the database, use the VPS SSH session: `docker exec resolutionflow_postgres psql -U postgres -d resolutionflow -t -c "SQL"`. Python is also not available in the container.
|
||||
|
||||
**104. `landing.css` uses self-contained `--lp-*` color variables:** The landing page defines its own color palette at the top of `landing.css` (`--lp-bg`, `--lp-accent`, `--lp-text-*`, etc.). Never use `var(--color-*)` theme tokens in `landing.css` — they may resolve incorrectly outside the app shell context. Extend the `--lp-*` palette for any new landing page colors.
|
||||
|
||||
**105. `npm run build` fails with `EACCES: permission denied` on `dist/` in code-server:** This is a filesystem permission issue in the Docker environment, not a TypeScript error — the TS compilation completes successfully. Use `npx tsc -b` to verify TypeScript cleanly without needing to write to `dist/`.
|
||||
|
||||
**106. Guard async "select item → load data → apply state" flows with a ref:** When a component lets the user switch between items (chat sessions, flows, scripts) and loads data asynchronously on each switch, the load for item A can complete *after* the user has already switched to item B — overwriting B's state with A's stale data. Fix pattern: keep a `currentSelectionRef = useRef(initialId)` and update it synchronously whenever the selection changes (in every creation/switch path). After every `await`, bail out if `currentSelectionRef.current !== thisItemId`. See `AssistantChatPage.tsx` `selectChat` for the reference implementation (`currentChatRef`).
|
||||
|
||||
**107. Startup routines must use `_admin_session_factory()` after Phase 4 RLS:** Any code that runs at startup (lifespan, `ensure_service_account`, seed scripts) and touches tenant-isolated tables (`users`, etc.) must use `_admin_session_factory()` — not `get_db()`. Phase 4 enabled RLS on `users`; a tenant-scoped session has no `app.current_account_id` set at startup, so all queries return 0 rows or fail. `get_service_account_id` in `deps.py` is safe — it reads from `app.state` cached at startup, never hits the DB per-request.
|
||||
|
||||
**108. Tables with no `account_id` column (never add to RLS migrations):** `script_categories`, `platform_steps`, `template_trees`, `plan_feature_defaults`, `accounts` — global/platform tables documented with "No account_id. No RLS." in their model files. When writing RLS migrations, scan at the class level (check for `account_id: Mapped` within the class block), not the file level — multiple classes in one `.py` file can have different columns (e.g. `ScriptCategory` vs `ScriptTemplate` in `script_template.py`).
|
||||
|
||||
**109. `tree_shares.account_id` must equal `tree.account_id`, not the actor's account:** When creating a `TreeShare`, always use `account_id=tree.account_id` (tree owner's tenant). A super admin in tenant A sharing tenant B's tree must produce a share row in tenant B's RLS context — using `current_user.account_id` instead makes the share invisible to the tree owner after RLS is enforced.
|
||||
|
||||
## RBAC & Permissions
|
||||
|
||||
- **Role hierarchy:** super_admin > team_admin > engineer > viewer
|
||||
- **Team Admin:** `role='engineer'` + `is_team_admin=True` + valid `team_id`
|
||||
- **Backend deps:** `get_current_active_user(user, db)` (any active + auto-downgrades expired trials), `require_engineer_or_admin` (blocks viewers), `require_admin` (super admin only)
|
||||
- **Never use** `role == "admin"` — use `is_super_admin` instead
|
||||
- **Frontend:** `usePermissions()` hook for all permission checks
|
||||
- **Centralized:** `backend/app/core/permissions.py`, `frontend/src/hooks/usePermissions.ts`
|
||||
## Common tasks
|
||||
|
||||
- **New endpoint:** `endpoints/` → `router.py` → `schemas/` → tests → frontend API client.
|
||||
- **New page:** `pages/` → route in `router.tsx` → nav in `AppLayout.tsx`.
|
||||
- **New public route:** top-level in `router.tsx` alongside `/login`, not inside `ProtectedRoute`.
|
||||
- **New frontend API module:** types in `types/` → export from `types/index.ts` → client in `api/` → export from `api/index.ts`.
|
||||
- **Schema change:** update model → `alembic revision -m "desc"` → review → `alembic upgrade head`.
|
||||
- **New `VITE_*` env var:** add as `ARG` + `ENV` in `frontend/Dockerfile` for Railway builds (Lesson 60 — Railway env vars are runtime-only, Vite bakes at build time).
|
||||
- **Account sub-page:** add route in `router.tsx` under `account` children + add link card in `AccountSettingsPage.tsx` — `AccountLayout` has NO sidebar nav.
|
||||
|
||||
---
|
||||
|
||||
## Design System
|
||||
## Coding standards
|
||||
|
||||
**Source of truth:** [DESIGN-SYSTEM.md](DESIGN-SYSTEM.md) — always read this before making visual or UI decisions.
|
||||
- **Python:** type hints everywhere, async/await for DB, Pydantic v2, `DateTime(timezone=True)` always.
|
||||
- **TypeScript:** interfaces for all data, `const` over `let`, functional components + hooks, shared logic in custom hooks.
|
||||
- **Git:** feature branch before committing (`git checkout -b feat/feature-name`). Format: `type: description` (feat/fix/refactor/docs/test/chore). Always `Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>`. Large features: commit per phase with `npm run build` validation. Push to Gitea — auto-mirrors to GitHub (`.gitea/workflows/mirror-to-github.yml`); never push GitHub directly.
|
||||
|
||||
- **Theme:** Flat, high-contrast dark theme (Sentry/PostHog-inspired). No glass morphism, no backdrop blur, no ambient orbs, no gradient backgrounds on surfaces. Light mode fully specified (v6).
|
||||
- **Backgrounds:** `bg-page` (`#16181f`), `bg-sidebar` (`#0e1016`), `bg-card` (`#1e2028`), `bg-elevated` (`#2a2d38`)
|
||||
- **Cards:** `bg-card` with 1px `border-default` (`#2a2e3a`), 8px radius. No shadows, no blur, no gradients. Hover: `border-hover` (`#3d4252`)
|
||||
- **Buttons:** Primary: solid `accent` (#60a5fa dark / #2563eb light), white text, 5px radius. Ghost: transparent + 1px border, hover `bg-elevated`
|
||||
- **Inputs:** `bg-input` (`#252830`) with 1px `border-default`, 5px radius. Focus: `border-color: accent` + `box-shadow: 0 0 0 2px accent-dim`
|
||||
- **Text:** `text-heading` (`#f0f2f5`) → `text-primary` (`#e2e5eb`) → `text-muted-foreground` (`#848b9b`) → `text-muted` (`#4f5666`). NEVER use `text-secondary` — in Tailwind v4 it maps to a surface color, not a text color.
|
||||
- **Borders:** `border-default` (`#2a2e3a`), `border-hover` (`#3d4252`)
|
||||
- **Functional colors:** `#34d399` (success), `#fbbf24` (warning/amber), `#f87171` (danger), `#67e8f9` (info/cyan) — each with `-dim` variant at 10% opacity
|
||||
- **Accent:** Electric blue `#60a5fa` (dark) / `#2563eb` (light) — used sparingly (≤5% of UI). `accent-dim` = `rgba(96,165,250,0.10)`, `accent-text` = `#93c5fd`
|
||||
- **Deprecated:** Do NOT use `glass-card`, `glass-stat`, `bg-gradient-brand`, `text-gradient-brand`, `backdrop-filter: blur()`, ambient orbs, purple gradients, ember orange (`#f97316`), or cyan (`#22d3ee`) as accent — cyan is now the info color only
|
||||
**After shipping:** update `CURRENT-STATE.md` + `03-DEVELOPMENT-ROADMAP.md`, `gh issue close #N` for resolved issues, add lessons here only for non-obvious traps (otherwise let the code speak).
|
||||
|
||||
---
|
||||
|
||||
## Frontend Patterns
|
||||
## Frontend patterns
|
||||
|
||||
- **Component guidelines:** Use `cn()` from `@/lib/utils`, Lucide icons (wrap in `<span>` for title), modals with fixed header/footer
|
||||
- **Type organization:** Create in `types/`, export from `types/index.ts`, import with `import type { T } from '@/types'`
|
||||
- **Scratchpad overlay:** `position: fixed`, `onOpenChange` callback for parent padding adjustment, `right-2` positioning
|
||||
- **Custom step flow:** `CustomStepModal` → `PostStepActionModal` → `ContinuationModal` → custom step view. Key state: `pendingStep`, `pendingContinuationNodeId`, `customBranchMode`, `branchOriginNodeId`. Use `findCustomStep()` not `findNode()` for custom step UUIDs.
|
||||
- **Session sharing:** `ShareSessionModal` manages share links, `SharedSessionPage` renders public/account views. Helper utils in `lib/sessionShare.ts`. Share URLs use `/shared/sessions/:token`.
|
||||
- **Procedural navigation:** `ProceduralNavigationPage` handles intake forms, step-by-step execution, and resume via `location.state.sessionId`. Uses `StepChecklist`, `StepDetail`, `ProgressBar`, `CompletionSummary` components.
|
||||
- **Routing helper:** Use `getTreeNavigatePath()` and `getTreeEditorPath()` from `@/lib/routing` for all tree/session navigation.
|
||||
- **Account section layout:** `AccountLayout` has NO sidebar nav. Account sub-pages (categories, target-lists) are reached via link cards on `AccountSettingsPage.tsx`. New account pages: add route in `router.tsx` under `account` children + add a link card in `AccountSettingsPage`.
|
||||
- **Dashboard cockpit:** `QuickStartPage` is the copilot-first launchpad. Greeting + "What are you troubleshooting?" + ChatGPT-style `StartSessionInput` (auto-growing textarea, paste images, drag-drop files, attach button, paste logs, suggestion chips). Below: `PendingEscalations`, `ActiveFlowPilotSessions`, `RecentFlowPilotSessions`. Collapsible "Dashboard" section for `PerformanceCards`, `KnowledgeBaseCards`, `TeamSummary`.
|
||||
- **Sidebar sections:** Amber "New Session" button → Home → RESOLVE (History) → KNOWLEDGE (Flows with Solutions Library sub-item, Scripts) → INSIGHTS (Data). Footer: Account, Pin/Unpin. No help/guides/feedback in sidebar — accessible via TopBar.
|
||||
- **Component basics:** `cn()` from `@/lib/utils`, Lucide icons, `Modal.tsx` for modals (mobile-responsive `items-end sm:items-center` + `max-w-full sm:max-w-lg`).
|
||||
- **Types:** Create in `types/`, export from `types/index.ts`, `import type { T } from '@/types'`.
|
||||
- **Routing:** `getTreeNavigatePath()` / `getTreeEditorPath()` from `@/lib/routing`. Tree editor is `/trees/new`. All dashboard session clicks → `/pilot/:id` regardless of `session_type`.
|
||||
- **Lazy routes:** `lazyWithRetry` from `@/lib/lazyWithRetry.ts`, not `React.lazy` (auto-reload on stale chunks).
|
||||
- **Public pages:** raw `fetch()` with full URL, NOT `apiClient` (which requires auth tokens).
|
||||
- **Toast:** `toast.warning()` not `toast.warn()`. Import from `@/lib/toast` — methods: `success`, `error`, `warning`, `info`.
|
||||
- **Assistant chat:** uses local React `useState`, not Zustand. All three send paths (`handleSend`, `sendPrefill`, `handleResumeNew`) must call `setShowTaskLane(true)` when response has actions/questions.
|
||||
- **Chat backend wiring:** `aiSessionsApi.sendChatMessage` → `/ai-sessions/{id}/chat` → `unified_chat_service.py`. NOT `assistant_chat_service.py` (removed except retention settings).
|
||||
- **FlowPilot:** Actions live in page header (Resolve/Escalate/Share Update + overflow). `useBlocker` for active-session nav guard. "Pause & Leave" auto-pauses.
|
||||
- **AI markers:** `[QUESTIONS]`, `[ACTIONS]`, `[FORK]`, `[DELTA]...[/DELTA]` (editor), `[TREE_UPDATE]` (troubleshooting builder), `[STEPS_UPDATE]` (procedural builder), `[METADATA]`. Parsed in `unified_chat_service.py`; conversation history stores stripped `display_content`. If markers disappear: check system-prompt final reminder + per-user-message `[SYSTEM: ...]` injection in `_call_anthropic_cached()`.
|
||||
- **Image uploads:** paste/attach → Railway S3 via `uploadsApi.upload()` → resized by `storage_service.resize_image_for_vision()` (Pillow, 1568px max, PNG→JPEG) → base64 → Claude multimodal blocks. Max 3/msg. Images NOT stored in history.
|
||||
- **Async select-load-apply:** guard with a ref (pattern in `AssistantChatPage` `currentChatRef`). Update synchronously on every selection change; after every `await`, bail out if `ref.current !== thisId`.
|
||||
- **Editor-Embedded Flow Assist:** `EditorAIPanel` (320px side panel) + `useEditorAI`. Ghost nodes via `_suggestion: true`. Route actions via `settings.get_model_for_action()`.
|
||||
- **Script Builder:** `/script-builder`, chat-style. Backend `ScriptBuilderSession`, `script_builder_service.py`, endpoints `/scripts/builder/`. FlowPilot handoff via `action_type: "open_script_builder"` + `sessionStorage`.
|
||||
- **Intake form field schema:** `variable_name` + `field_type` (NOT `name` / `type`).
|
||||
- **Node field priority** (copilot, summaries): `title` → `question` → `description` → `content` → `label`.
|
||||
- **Procedural sessions auto-start** on page load (no intake/Start screen). Troubleshooting flows DO have a start screen.
|
||||
|
||||
---
|
||||
|
||||
## Common Tasks
|
||||
## Critical lessons
|
||||
|
||||
- **New endpoint:** Create in `endpoints/` → add to `router.py` → schema in `schemas/` → tests → frontend API client
|
||||
- **New page:** Create in `pages/` → add route in `router.tsx` → nav link in `AppLayout.tsx`
|
||||
- **New public route (no auth):** Add at top level in `router.tsx` alongside `/login`, `/register` — NOT inside the `ProtectedRoute`/`AppLayout` children.
|
||||
- **Schema change:** Update model → `alembic revision --autogenerate -m "desc" --rev-id=NNN` (NNN = next sequential number, e.g., 068 → 069) → review → `alembic upgrade head`
|
||||
- **New frontend API module:** Types in `types/` → export from `types/index.ts` → client in `api/` → export from `api/index.ts`
|
||||
> Lessons 1-40 archived to `docs/LESSONS-ARCHIVE.md` — fixes baked into the codebase. **Grep the archive when an error message or symptom is unfamiliar, or after two failed attempts at resolving an issue.** Don't pre-load for routine work.
|
||||
|
||||
### Backend / data
|
||||
|
||||
- **APScheduler interval jobs always `max_instances=1`** — without it, overlapping runs reprocess records (TOCTOU).
|
||||
- **`get_db` rolls back on exception** — never remove the `await session.rollback()`, or one failed request poisons the connection with `InFailedSQLTransaction` cascading.
|
||||
- **Startup routines on tenant-isolated tables must use `_admin_session_factory()`, not `get_db()`.** Phase 4 RLS has no `app.current_account_id` set at startup. `get_service_account_id` is safe (reads cached `app.state`).
|
||||
- **Backfill migrations adding `account_id`:** grep ALL `ModelClass(` sites in service code to verify `account_id=` is passed. SQLAlchemy accepts `None` silently — Phase 4 RLS WITH CHECK surfaces the problem at runtime as `InsufficientPrivilegeError: new row violates row-level security policy`.
|
||||
- **`tree_shares.account_id = tree.account_id`**, never `current_user.account_id`. A super_admin sharing another tenant's tree must produce the share in the tree owner's tenant, or it becomes invisible post-RLS.
|
||||
- **Global tables (no `account_id`, never in RLS migrations):** `script_categories`, `platform_steps`, `template_trees`, `plan_feature_defaults`, `accounts`. Scan at class level — one `.py` file can hold multiple classes with different columns (e.g. `ScriptCategory` vs `ScriptTemplate`).
|
||||
- **`ai_sessions.status` is VARCHAR(30)** — fits `requesting_escalation` (23 chars). Migration `f0aad74ea51b` widened from 20.
|
||||
- **PostgreSQL `func.sum(case(...))` returns `Decimal` via asyncpg** — cast to `int()` before Pydantic `dict[str, Any]`.
|
||||
- **Enhancement / branch_addition proposals need `modified_flow_data` via "Edit & Publish"** — backend 400 on direct approve. Only `new_flow` supports direct approve.
|
||||
- **Adding email types:** static async method on `EmailService` in `core/email.py`. Fire-and-forget from endpoints (log errors, don't fail the request).
|
||||
|
||||
### AI / FlowPilot
|
||||
|
||||
- **Anthropic SDK `max_retries=1`** — default of 2 can take 3× the timeout.
|
||||
- **Model tier routing:** `settings.get_model_for_action(action_type)`. Always alias form (`claude-sonnet-4-6`).
|
||||
- **FlowPilot must ask GUI-vs-script before suggesting either** when both are viable — see `FLOWPILOT_SYSTEM_PROMPT` in `flowpilot_engine.py`.
|
||||
- **Telemetry events to grep:** `anthropic.cache` (prompt-cache hit/create), `mcp.turn` (per-turn MCP availability), `mcp.fallback` (MCP silent-retry fired).
|
||||
|
||||
### Frontend / UI
|
||||
|
||||
- **Flex height chain:** every ancestor from `app-shell` grid to React Flow canvas needs `flex` + `flex-1` + `min-h-0` or `h-full`. Missing `flex` collapses to 0. Same rule for FlowPilot action bar and any tall scroller.
|
||||
- **React Flow CSS in Tailwind v4:** import in `index.css`, not component JS. Override dark theme via `--xy-*` CSS vars.
|
||||
- **`text-secondary` renders invisible on dark** — Tailwind v4 maps it to `--color-secondary` (a surface color). Use `text-muted-foreground` for readable secondary text. Avoid `text-muted` for body — labels only.
|
||||
- **`bg-accent` is electric blue — never for code/kbd.** Use `bg-white/[0.12] border border-white/[0.06]` for inline code, `bg-white/[0.08]` for kbd. Accent reserved for interactive elements.
|
||||
- **`landing.css` uses self-contained `--lp-*` vars** — never `var(--color-*)` theme tokens (they resolve incorrectly outside the app shell).
|
||||
- **Never `transition: all`** — list properties explicitly, or layout props animate and jank.
|
||||
- **Date range filter end dates:** `setHours(23, 59, 59, 999)` before sending, or the day's items are excluded. For string-based date inputs, append `T23:59:59.999Z`.
|
||||
- **TopBar search:** full bar `hidden sm:block`, icon button `sm:hidden` — both open CommandPalette.
|
||||
- **Hover pop-out cards:** scrim `pointer-events-none`, expanded card has its own click handler at `z-50`, dismiss via `onMouseLeave` on wrapper. Never put handlers on the scrim.
|
||||
- **`tsc -b` in Dockerfile is stricter than `tsc --noEmit`** — enforces `noUnusedLocals` / `noUnusedParameters` as hard errors. Check IDE yellow squiggles before pushing.
|
||||
- **Dashboard prefill auto-submits** via `useEffect` + `prefillHandledRef` guard — no double-enter.
|
||||
- **Global Axios 5xx interceptor fires before component `.catch()`** — fix optional-data endpoints at the source (return `[]` / `{}` on provider failure), not in the component.
|
||||
- **Playwright strict mode:** scope selectors to avoid sidebar/main ambiguity. Use `getByRole('heading', { name })` or `.animate-scale-in` locators, not bare `getByText()`.
|
||||
|
||||
### Env / infra
|
||||
|
||||
- **Node 20.19+ required** (Vite 7). `nvm use 20` or `PATH="$HOME/.nvm/versions/node/v20.19.0/bin:$PATH"`.
|
||||
- **Railway backend service is `patherly`, DB name `railway`.** Public Postgres proxy: `interchange.proxy.rlwy.net:45797`.
|
||||
- **Railway Object Storage bucket `resolutionflow-uploads`.** Env vars `STORAGE_*`. boto3 in `storage_service.py`. Dockerfile needs Pillow + `libjpeg-dev` / `zlib1g-dev`.
|
||||
- **PostHog:** `PostHogProvider` + `posthog.init()` in `main.tsx`. Helpers in `lib/analytics.ts`. Env: `VITE_PUBLIC_POSTHOG_KEY`, `VITE_PUBLIC_POSTHOG_HOST`. `identifyUser()` in `authStore.fetchUser()`, `resetAnalytics()` on logout.
|
||||
- **bun PATH on devserver01:** `BUN_INSTALL="$HOME/.bun"`, `PATH="$BUN_INSTALL/bin:$PATH"`. Playwright Chromium needs `libatk1.0-0 libatk-bridge2.0-0 libcups2 libxkbcommon0 libatspi2.0-0 libxcomposite1 libxdamage1 libxfixes3 libxrandr2 libgbm1 libasound2`.
|
||||
- **Full-stack change:** trace schema → endpoint → API client → hook → store → UI. Don't assume one end proves the other.
|
||||
- **Dev env** — see DEV-ENV.md for current topology, `REPO_ROOT` requirement when compose runs inside a container, Vite `allowedHosts`, linuxserver.io `group_add` + custom-cont-init.d workaround, `docker compose up` no-op-on-unchanged-hash gotcha.
|
||||
|
||||
---
|
||||
|
||||
## Coding Standards
|
||||
## GitNexus code intelligence
|
||||
|
||||
### Python
|
||||
Indexed as `resolutionflow`. Earns its cost on cross-cutting work only.
|
||||
|
||||
- Type hints everywhere, async/await for DB, Pydantic for validation, `DateTime(timezone=True)` always
|
||||
| Tool | When |
|
||||
|---|---|
|
||||
| `gitnexus_query({query})` | Find code by concept when you don't know where to look |
|
||||
| `gitnexus_context({name})` | Callers/callees of a symbol before touching it |
|
||||
| `gitnexus_impact({target, direction})` | Blast radius before editing shared symbols |
|
||||
| `gitnexus_rename({symbol_name, new_name, dry_run: true})` | Safe multi-file rename |
|
||||
|
||||
### TypeScript
|
||||
**Use for:** core shared symbols (`flowpilot_engine`, `unified_chat_service`, auth middleware, `get_db`, shared hooks), cross-file renames, unfamiliar bug traces, refactor safety. **Skip for:** new endpoints, isolated fixes, changes you can read in one file.
|
||||
|
||||
- Interfaces for all data, `const` over `let`, functional components + hooks, reusable logic in custom hooks
|
||||
|
||||
### Git
|
||||
|
||||
- Format: `type: description` (feat, fix, refactor, docs, test, chore)
|
||||
- Always include `Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>`
|
||||
- Always create feature branch BEFORE committing: `git checkout -b feat/feature-name`
|
||||
- Large features: commit per phase with `npm run build` validation
|
||||
|
||||
### After Completing Work
|
||||
|
||||
When a feature, fix, or significant piece of work is finished and merged/committed:
|
||||
|
||||
1. **Update `CURRENT-STATE.md`** — move completed items, update "In Progress" and "What's Next" sections
|
||||
2. **Update `03-DEVELOPMENT-ROADMAP.md`** — check off completed work, update phase status
|
||||
3. **Close related GitHub Issues** — use `gh issue close #N` for any issues resolved by the work
|
||||
4. **Update `CLAUDE.md`** if the work introduced new patterns, lessons learned, or changed project structure
|
||||
Re-indexes automatically on commit (PostToolUse hook). Manual refresh if stale: `npx gitnexus analyze`.
|
||||
|
||||
---
|
||||
|
||||
## gstack (Browser & Workflow Skills)
|
||||
## gstack skills
|
||||
|
||||
**Web browsing:** Always use the `/browse` skill from gstack for all web browsing needs. Never use `mcp__claude-in-chrome__*` tools.
|
||||
Always use `/browse` for web, never `mcp__claude-in-chrome__*`. Most-used:
|
||||
|
||||
**Available skills:**
|
||||
|
||||
| Skill | Purpose |
|
||||
|-------|---------|
|
||||
| `/office-hours` | Brainstorm new ideas (YC-style office hours) |
|
||||
| `/plan-ceo-review` | CEO/founder-mode plan review (scope, ambition) |
|
||||
| `/plan-eng-review` | Engineering plan review (architecture, edge cases) |
|
||||
| `/plan-design-review` | Design plan review (UI/UX critique) |
|
||||
| `/design-consultation` | Create a design system / DESIGN.md |
|
||||
| `/review` | Pre-landing PR code review |
|
||||
| `/ship` | Ship workflow (tests, review, PR creation) |
|
||||
| `/browse` | Headless browser for QA testing and site dogfooding |
|
||||
| `/qa` | Systematic QA testing + auto-fix bugs found |
|
||||
| `/qa-only` | QA report only (no fixes) |
|
||||
| `/design-review` | Visual QA — find and fix design inconsistencies |
|
||||
| `/setup-browser-cookies` | Import cookies from real browser for authenticated testing |
|
||||
| `/retro` | Weekly engineering retrospective |
|
||||
| `/investigate` | Systematic debugging with root cause analysis |
|
||||
| `/document-release` | Post-ship documentation updates |
|
||||
| `/codex` | Second opinion via OpenAI Codex CLI |
|
||||
| `/careful` | Safety guardrails for destructive commands |
|
||||
| `/freeze` | Restrict edits to a specific directory |
|
||||
| `/guard` | Full safety mode (careful + freeze) |
|
||||
| `/unfreeze` | Remove edit restrictions |
|
||||
| `/gstack-upgrade` | Upgrade gstack to latest version |
|
||||
- `/review` — pre-land PR review
|
||||
- `/ship` — tests + review + PR creation
|
||||
- `/browse` + `/qa` / `/qa-only` — headless browser testing (setup: Lesson 82)
|
||||
- `/design-review` — visual QA
|
||||
- `/investigate` — systematic debug with root cause
|
||||
- `/codex` — OpenAI Codex second opinion
|
||||
- `/plan-eng-review` / `/plan-design-review` / `/plan-ceo-review` — plan critiques
|
||||
|
||||
---
|
||||
|
||||
## Deployment (Railway)
|
||||
|
||||
- **Production:** `resolutionflow.com` (frontend), `api.resolutionflow.com` (backend)
|
||||
- Auto-deploys on push to `main`
|
||||
- PR environments auto-created (need manual domain generation in Railway dashboard)
|
||||
- PR envs need `VITE_API_URL` set with `https://` prefix on frontend service
|
||||
- `ALLOW_RAILWAY_ORIGINS=true` enables CORS for `*.up.railway.app`
|
||||
- Shared Variables (project-level in Railway dashboard) auto-propagate to all environments including PR envs — use for secrets like `ANTHROPIC_API_KEY`
|
||||
- Super admin utility: `backend/make_superadmin_simple.py list|<email>`
|
||||
- **Prod:** `resolutionflow.com` (frontend), `api.resolutionflow.com` (backend).
|
||||
- Auto-deploy: Gitea push → GitHub mirror → Railway follows GitHub `main`.
|
||||
- PR environments auto-created; need manual domain generation + `VITE_API_URL` with `https://` prefix.
|
||||
- `ALLOW_RAILWAY_ORIGINS=true` for `*.up.railway.app` CORS.
|
||||
- Shared Variables (Railway project-level) auto-propagate to PR envs — use for secrets like `ANTHROPIC_API_KEY`.
|
||||
- Super admin utility: `backend/make_superadmin_simple.py list|<email>`.
|
||||
|
||||
---
|
||||
|
||||
## Future Roadmap
|
||||
|
||||
- **Phase 3:** PSA integrations (ConnectWise in progress), file attachments, client context, analytics
|
||||
- **Phase 4:** Additional PSA integrations (Autotask/Kaseya), PowerShell automation, enterprise SSO
|
||||
|
||||
---
|
||||
|
||||
## Quick Reference
|
||||
## Quick reference
|
||||
|
||||
| What | Where |
|
||||
|------|-------|
|
||||
| API Docs | <http://localhost:8000/api/docs> |
|
||||
| Detailed Status | [CURRENT-STATE.md](CURRENT-STATE.md) |
|
||||
| Development Roadmap | [03-DEVELOPMENT-ROADMAP.md](03-DEVELOPMENT-ROADMAP.md) |
|
||||
| GitHub Issues | `gh issue list --state open` |
|
||||
| Bugs & Fixes | CLAUDE.md → Critical Lessons Learned section |
|
||||
| Design System | [DESIGN-SYSTEM.md](DESIGN-SYSTEM.md) |
|
||||
| Dev Environment | [DEV-ENV.md](DEV-ENV.md) — 46.202.92.250 setup, Docker, CORS, networking |
|
||||
|
||||
<!-- gitnexus:start -->
|
||||
# GitNexus — Code Intelligence
|
||||
|
||||
This project is indexed by GitNexus as **resolutionflow** (16703 symbols, 35922 relationships, 300 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
|
||||
|
||||
> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first.
|
||||
|
||||
## Always Do
|
||||
|
||||
- **MUST run impact analysis before editing any symbol.** Before modifying a function, class, or method, run `gitnexus_impact({target: "symbolName", direction: "upstream"})` and report the blast radius (direct callers, affected processes, risk level) to the user.
|
||||
- **MUST run `gitnexus_detect_changes()` before committing** to verify your changes only affect expected symbols and execution flows.
|
||||
- **MUST warn the user** if impact analysis returns HIGH or CRITICAL risk before proceeding with edits.
|
||||
- When exploring unfamiliar code, use `gitnexus_query({query: "concept"})` to find execution flows instead of grepping. It returns process-grouped results ranked by relevance.
|
||||
- When you need full context on a specific symbol — callers, callees, which execution flows it participates in — use `gitnexus_context({name: "symbolName"})`.
|
||||
|
||||
## When Debugging
|
||||
|
||||
1. `gitnexus_query({query: "<error or symptom>"})` — find execution flows related to the issue
|
||||
2. `gitnexus_context({name: "<suspect function>"})` — see all callers, callees, and process participation
|
||||
3. `READ gitnexus://repo/resolutionflow/process/{processName}` — trace the full execution flow step by step
|
||||
4. For regressions: `gitnexus_detect_changes({scope: "compare", base_ref: "main"})` — see what your branch changed
|
||||
|
||||
## When Refactoring
|
||||
|
||||
- **Renaming**: MUST use `gitnexus_rename({symbol_name: "old", new_name: "new", dry_run: true})` first. Review the preview — graph edits are safe, text_search edits need manual review. Then run with `dry_run: false`.
|
||||
- **Extracting/Splitting**: MUST run `gitnexus_context({name: "target"})` to see all incoming/outgoing refs, then `gitnexus_impact({target: "target", direction: "upstream"})` to find all external callers before moving code.
|
||||
- After any refactor: run `gitnexus_detect_changes({scope: "all"})` to verify only expected files changed.
|
||||
|
||||
## Never Do
|
||||
|
||||
- NEVER edit a function, class, or method without first running `gitnexus_impact` on it.
|
||||
- NEVER ignore HIGH or CRITICAL risk warnings from impact analysis.
|
||||
- NEVER rename symbols with find-and-replace — use `gitnexus_rename` which understands the call graph.
|
||||
- NEVER commit changes without running `gitnexus_detect_changes()` to check affected scope.
|
||||
|
||||
## Tools Quick Reference
|
||||
|
||||
| Tool | When to use | Command |
|
||||
|------|-------------|---------|
|
||||
| `query` | Find code by concept | `gitnexus_query({query: "auth validation"})` |
|
||||
| `context` | 360-degree view of one symbol | `gitnexus_context({name: "validateUser"})` |
|
||||
| `impact` | Blast radius before editing | `gitnexus_impact({target: "X", direction: "upstream"})` |
|
||||
| `detect_changes` | Pre-commit scope check | `gitnexus_detect_changes({scope: "staged"})` |
|
||||
| `rename` | Safe multi-file rename | `gitnexus_rename({symbol_name: "old", new_name: "new", dry_run: true})` |
|
||||
| `cypher` | Custom graph queries | `gitnexus_cypher({query: "MATCH ..."})` |
|
||||
|
||||
## Impact Risk Levels
|
||||
|
||||
| Depth | Meaning | Action |
|
||||
|-------|---------|--------|
|
||||
| d=1 | WILL BREAK — direct callers/importers | MUST update these |
|
||||
| d=2 | LIKELY AFFECTED — indirect deps | Should test |
|
||||
| d=3 | MAY NEED TESTING — transitive | Test if critical path |
|
||||
|
||||
## Resources
|
||||
|
||||
| Resource | Use for |
|
||||
|----------|---------|
|
||||
| `gitnexus://repo/resolutionflow/context` | Codebase overview, check index freshness |
|
||||
| `gitnexus://repo/resolutionflow/clusters` | All functional areas |
|
||||
| `gitnexus://repo/resolutionflow/processes` | All execution flows |
|
||||
| `gitnexus://repo/resolutionflow/process/{name}` | Step-by-step execution trace |
|
||||
|
||||
## Self-Check Before Finishing
|
||||
|
||||
Before completing any code modification task, verify:
|
||||
1. `gitnexus_impact` was run for all modified symbols
|
||||
2. No HIGH/CRITICAL risk warnings were ignored
|
||||
3. `gitnexus_detect_changes()` confirms changes match expected scope
|
||||
4. All d=1 (WILL BREAK) dependents were updated
|
||||
|
||||
## Keeping the Index Fresh
|
||||
|
||||
After committing code changes, the GitNexus index becomes stale. Re-run analyze to update it:
|
||||
|
||||
```bash
|
||||
npx gitnexus analyze
|
||||
```
|
||||
|
||||
If the index previously included embeddings, preserve them by adding `--embeddings`:
|
||||
|
||||
```bash
|
||||
npx gitnexus analyze --embeddings
|
||||
```
|
||||
|
||||
To check whether embeddings exist, inspect `.gitnexus/meta.json` — the `stats.embeddings` field shows the count (0 means no embeddings). **Running analyze without `--embeddings` will delete any previously generated embeddings.**
|
||||
|
||||
> Claude Code users: A PostToolUse hook handles this automatically after `git commit` and `git merge`.
|
||||
|
||||
## CLI
|
||||
|
||||
| Task | Read this skill file |
|
||||
|------|---------------------|
|
||||
| Understand architecture / "How does X work?" | `.claude/skills/gitnexus/gitnexus-exploring/SKILL.md` |
|
||||
| Blast radius / "What breaks if I change X?" | `.claude/skills/gitnexus/gitnexus-impact-analysis/SKILL.md` |
|
||||
| Trace bugs / "Why is X failing?" | `.claude/skills/gitnexus/gitnexus-debugging/SKILL.md` |
|
||||
| Rename / extract / split / refactor | `.claude/skills/gitnexus/gitnexus-refactoring/SKILL.md` |
|
||||
| Tools, resources, schema reference | `.claude/skills/gitnexus/gitnexus-guide/SKILL.md` |
|
||||
| Index, status, clean, wiki CLI commands | `.claude/skills/gitnexus/gitnexus-cli/SKILL.md` |
|
||||
|
||||
<!-- gitnexus:end -->
|
||||
|---|---|
|
||||
| Detailed status | [CURRENT-STATE.md](CURRENT-STATE.md) |
|
||||
| Roadmap | [03-DEVELOPMENT-ROADMAP.md](03-DEVELOPMENT-ROADMAP.md) |
|
||||
| Design system | [DESIGN-SYSTEM.md](DESIGN-SYSTEM.md) |
|
||||
| Dev env | [DEV-ENV.md](DEV-ENV.md) |
|
||||
| Archived lessons | [docs/LESSONS-ARCHIVE.md](docs/LESSONS-ARCHIVE.md) |
|
||||
| ConnectWise API | `docs/connectwise/` |
|
||||
| GitHub issues | `gh issue list --state open` |
|
||||
| Local API docs | <http://localhost:8000/api/docs> |
|
||||
|
||||
783
DEV-ENV.md
783
DEV-ENV.md
@@ -1,262 +1,671 @@
|
||||
# ResolutionFlow Dev Environment Setup & Operations Guide
|
||||
# ResolutionFlow — Dev Environment Setup & Operations Guide
|
||||
|
||||
## Server Overview
|
||||
> **Scope:** Stand up a working ResolutionFlow dev environment from scratch on any Linux host (VPS, on-prem Proxmox LXC/VM, bare metal). Self-contained — do not read another doc to get the dev stack running.
|
||||
> **Last rewritten:** April 2026, post-Hostinger-VPS deprecation, ahead of Proxmox migration.
|
||||
> **Audience:** You (returning to the project), a teammate, or a fresh Claude Code session.
|
||||
|
||||
- **Provider:** Hostinger KVM VPS (srv1522117)
|
||||
- **IP Address:** 46.202.92.250
|
||||
- **OS:** Ubuntu 24.04 LTS
|
||||
- **CPU:** 2 vCPU cores
|
||||
- **RAM:** 8GB
|
||||
- **Disk:** 100GB NVMe SSD
|
||||
- **Swap:** 4GB (`/swapfile`, swappiness=10)
|
||||
If you're picking up mid-migration and need to know what code state is on the current branch, read `docs/FlowAssist_Migration/MIGRATION-HANDOFF.md` first.
|
||||
|
||||
## Architecture
|
||||
---
|
||||
|
||||
All services run as Docker containers on the host, managed via SSH or from the VS Code Server integrated terminal.
|
||||
## 1. What this project needs, regardless of host
|
||||
|
||||
```
|
||||
Host (root@srv1522117)
|
||||
├── Traefik → reverse proxy + auto SSL (Let's Encrypt)
|
||||
├── VS Code Server → browser IDE at https://code.resolutionflow.com
|
||||
└── ResolutionFlow Stack
|
||||
├── resolutionflow_frontend → Vite/React on port 5173
|
||||
├── resolutionflow_backend → FastAPI/Uvicorn on port 8000
|
||||
└── resolutionflow_postgres → PostgreSQL 16 + pgvector on port 5432
|
||||
```
|
||||
These are non-negotiable. If your host can't provide them, fix that before anything else.
|
||||
|
||||
## Access URLs
|
||||
| Component | Required version | Notes |
|
||||
|---|---|---|
|
||||
| **Linux** | any mainstream distro | Ubuntu 22.04+ / Debian 12+ tested; Alpine fine for containers |
|
||||
| **Python** | 3.11+ | Backend and migrations |
|
||||
| **Node.js** | 20.19+ | Vite 7 fails on older versions — CLAUDE.md Lesson 63 |
|
||||
| **PostgreSQL** | 16 | `gen_random_uuid()` + `jsonb` + RLS are all leaned on |
|
||||
| **Docker + Docker Compose** | recent | Only if you are running Postgres and/or backend as containers |
|
||||
| **Git** | recent | |
|
||||
|
||||
| Service | URL |
|
||||
Optional but recommended:
|
||||
|
||||
| Tool | Why |
|
||||
|---|---|
|
||||
| VS Code Server | https://code.resolutionflow.com |
|
||||
| Frontend (dev) | http://46.202.92.250:5173 |
|
||||
| Backend API | http://46.202.92.250:8000 |
|
||||
| API Docs | http://46.202.92.250:8000/docs |
|
||||
| **code-server** | Browser-based VS Code; how this project has historically been edited |
|
||||
| **`gh` CLI** | Mirror repo is on GitHub via Gitea; `gh` reads issues and PRs |
|
||||
| **bun** | Required for the gstack `/browse` + `/qa` skills (CLAUDE.md Lesson 82) |
|
||||
| **`npx gitnexus analyze`** | Code-graph for Phase 2+ work that touches `unified_chat_service` |
|
||||
| **Claude Code CLI** | If you want to run Claude Code locally on the host |
|
||||
|
||||
## Docker Layout
|
||||
---
|
||||
|
||||
## 2. Architectural shape
|
||||
|
||||
The project is three services plus your editor. Keep these facts in mind regardless of topology:
|
||||
|
||||
```
|
||||
/docker/
|
||||
├── traefik/
|
||||
│ ├── docker-compose.yml → Traefik reverse proxy
|
||||
│ └── .env → ACME_EMAIL for Let's Encrypt
|
||||
└── vscode/
|
||||
├── docker-compose.yml → VS Code Server
|
||||
└── .env → CODE_PASSWORD
|
||||
Your browser
|
||||
├─► code-server (editor, optional — usually port 8080 or behind TLS)
|
||||
├─► frontend (Vite) (dev server, port 5173)
|
||||
└─► backend (FastAPI) (dev server, port 8000)
|
||||
│
|
||||
└─► PostgreSQL (port 5432)
|
||||
```
|
||||
|
||||
Project lives inside the VS Code Server Docker volume:
|
||||
**The frontend calls the backend by URL at runtime.** The frontend does not proxy through the backend. Whatever URL your browser uses to reach the backend is what `VITE_API_URL` must be set to, **baked in at build time**. Changing `VITE_API_URL` requires rebuilding the frontend.
|
||||
|
||||
**The backend calls the database by URL at runtime.** The URL depends on where Postgres is relative to the backend — Docker service name if both are in the same compose network, `localhost` if Postgres is native on the same host, or a DNS name if they're in separate containers/VMs.
|
||||
|
||||
**CORS is configured explicitly.** The backend's `CORS_ORIGINS` list must include every origin your browser will use to reach the frontend. A missing origin shows up as failed preflight requests.
|
||||
|
||||
---
|
||||
|
||||
## 3. Topology choices — pick one before you start
|
||||
|
||||
The project is agnostic to topology, but each shape has different setup steps.
|
||||
|
||||
### Option A — all-in-one LXC/VM/host (simplest)
|
||||
|
||||
Postgres, backend, and frontend all run on one Linux host. code-server runs on the same host or a sibling. No Docker required. Best for a single-developer Proxmox LXC.
|
||||
|
||||
### Option B — Docker Compose on one host
|
||||
|
||||
Postgres, backend, and frontend run as Docker containers on one host. code-server runs outside the compose network (on the host or in another container). This is how the old Hostinger VPS was configured. Best if you want reproducible container images.
|
||||
|
||||
### Option C — split services across containers/VMs
|
||||
|
||||
Postgres in one container/VM, backend and frontend in another, code-server in a third. Most complex; requires explicit networking between them. Use only if you have a specific reason.
|
||||
|
||||
**Pick one and stick with it for the entire setup.** Mixing Options A and B halfway through is where setup runs off the rails.
|
||||
|
||||
---
|
||||
|
||||
## 4. Per-host configuration
|
||||
|
||||
These values are specific to your host. Fill them in once and reference them by name throughout the rest of the doc.
|
||||
|
||||
```
|
||||
/var/lib/docker/volumes/vscode_vscode-data/_data/resolutionflow/
|
||||
DEV_HOST = <hostname or IP your browser uses, e.g. dev.internal, 10.0.0.42>
|
||||
DEV_HOST_SCHEME = <http or https; http is fine for internal dev, https if behind a TLS proxy>
|
||||
FRONTEND_PORT = 5173
|
||||
BACKEND_PORT = 8000
|
||||
POSTGRES_PORT = 5433 # host-side port. 5433 is the recommended default on any shared host to avoid collision with a host-level Postgres. The container's internal port stays 5432.
|
||||
POSTGRES_DB_NAME = resolutionflow
|
||||
POSTGRES_USER = postgres
|
||||
POSTGRES_PASSWORD = <local-dev-password; anything, this is not prod>
|
||||
SECRET_KEY = <openssl rand -hex 32 — generate fresh per host, do not reuse>
|
||||
ANTHROPIC_API_KEY = <from https://console.anthropic.com>
|
||||
GOOGLE_AI_API_KEY = <optional, only if using Gemini as a fallback>
|
||||
```
|
||||
|
||||
## VS Code Server
|
||||
Store these somewhere you can copy from during setup. Do not commit them.
|
||||
|
||||
- **Container user:** `coder` (UID 1000)
|
||||
- **Home directory:** `/home/coder`
|
||||
- **Project location:** `/home/coder/resolutionflow`
|
||||
- **Host volume path:** `/var/lib/docker/volumes/vscode_vscode-data/_data`
|
||||
- **Access URL:** `https://code.resolutionflow.com`
|
||||
- **HTTPS:** Auto-provisioned via Traefik + Let's Encrypt
|
||||
> **Naming note:** the canonical database name is `resolutionflow`. If you see `patherly` in a config file, that's drift from an earlier rename and is being swept in a separate commit — use `resolutionflow`. CLAUDE.md tracks the live-code files that still reference `patherly`.
|
||||
|
||||
### Compose File Location
|
||||
`/docker/vscode/docker-compose.yml`
|
||||
---
|
||||
|
||||
## Traefik
|
||||
## 5. Setup procedure
|
||||
|
||||
Handles reverse proxying and automatic SSL for all services. HTTP automatically redirects to HTTPS.
|
||||
Run these in order. Stop at the first failure and investigate.
|
||||
|
||||
### Adding A New Service Behind Traefik
|
||||
|
||||
Add these labels to any new Docker service:
|
||||
|
||||
```yaml
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.<n>.rule=Host(`subdomain.resolutionflow.com`)"
|
||||
- "traefik.http.routers.<n>.entrypoints=websecure"
|
||||
- "traefik.http.routers.<n>.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.services.<n>.loadbalancer.server.port=<port>"
|
||||
```
|
||||
|
||||
Also create an A record in DNS pointing the subdomain to `46.202.92.250`.
|
||||
|
||||
## ResolutionFlow Dev Stack
|
||||
|
||||
### Important: No Docker Inside VS Code Container
|
||||
|
||||
The VS Code Server container does NOT have Docker. All `docker compose` commands must be run via SSH as root on the host.
|
||||
|
||||
### Environment Files
|
||||
|
||||
| File | Purpose |
|
||||
|---|---|
|
||||
| `.env` | Root — Docker Compose interpolation (`SECRET_KEY`, `ANTHROPIC_API_KEY`, `GOOGLE_AI_API_KEY`, `POSTGRES_PORT`) |
|
||||
| `backend/.env` | Backend source of truth — all FastAPI settings, API keys, DB URLs, CORS |
|
||||
| `frontend/.env` | Frontend — `VITE_API_URL` pointing to backend |
|
||||
|
||||
### Critical Remote Access Config
|
||||
|
||||
**`frontend/.env`:**
|
||||
```
|
||||
VITE_API_URL=http://46.202.92.250:8000
|
||||
```
|
||||
|
||||
**`backend/.env`:**
|
||||
```
|
||||
CORS_ORIGINS=["http://localhost:3000","http://localhost:5173","http://127.0.0.1:3000","http://127.0.0.1:5173","http://46.202.92.250:5173","http://46.202.92.250:3000","https://resolutionflow.com","https://www.resolutionflow.com"]
|
||||
FRONTEND_URL=http://46.202.92.250:5173
|
||||
DATABASE_URL=postgresql+asyncpg://postgres:postgres@db:5432/resolutionflow
|
||||
DATABASE_URL_SYNC=postgresql://postgres:postgres@db:5432/resolutionflow
|
||||
```
|
||||
|
||||
Note: `DATABASE_URL` uses `@db:5432` (Docker service name), not `@localhost`.
|
||||
|
||||
**`docker-compose.dev.yml`:**
|
||||
```yaml
|
||||
- VITE_API_URL=http://46.202.92.250:8000
|
||||
```
|
||||
|
||||
### Starting the Dev Environment
|
||||
|
||||
SSH into host as root:
|
||||
### 5.1 Install system dependencies
|
||||
|
||||
```bash
|
||||
cd /var/lib/docker/volumes/vscode_vscode-data/_data/resolutionflow
|
||||
docker compose -f docker-compose.dev.yml up -d
|
||||
# Ubuntu / Debian
|
||||
sudo apt update && sudo apt install -y \
|
||||
git curl build-essential \
|
||||
python3.11 python3.11-venv python3-pip \
|
||||
postgresql-client # not the server — only if running Postgres natively
|
||||
|
||||
# Node 20 via nvm (survives container rebuilds if stored in a volume)
|
||||
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash
|
||||
export NVM_DIR="$HOME/.nvm" && source "$NVM_DIR/nvm.sh"
|
||||
nvm install 20
|
||||
nvm alias default 20
|
||||
```
|
||||
|
||||
### Running Migrations (Fresh Database)
|
||||
For Option B (Docker Compose), also:
|
||||
|
||||
```bash
|
||||
cd /var/lib/docker/volumes/vscode_vscode-data/_data/resolutionflow
|
||||
curl -fsSL https://get.docker.com | sh
|
||||
sudo usermod -aG docker $USER # log out and back in for this to take effect
|
||||
```
|
||||
|
||||
### 5.2 Clone the repo
|
||||
|
||||
```bash
|
||||
git clone https://gitea.resolutionflow.com/chihlasm/resolutionflow.git
|
||||
# or the GitHub mirror:
|
||||
# git clone https://github.com/chihlasm/resolutionflow.git
|
||||
cd resolutionflow
|
||||
|
||||
# Check out the working branch if you're continuing mid-migration.
|
||||
git fetch origin
|
||||
git checkout feat/flowpilot-migration
|
||||
```
|
||||
|
||||
### 5.3 Start PostgreSQL
|
||||
|
||||
**Option A (native Postgres on the host):**
|
||||
|
||||
```bash
|
||||
sudo apt install -y postgresql-16
|
||||
sudo -u postgres psql -c "CREATE DATABASE resolutionflow;"
|
||||
sudo -u postgres psql -c "ALTER USER postgres PASSWORD 'postgres';"
|
||||
# Adjust pg_hba.conf if you need non-local connections.
|
||||
```
|
||||
|
||||
**Option B (Postgres via Docker Compose):** The repo has a `docker-compose.dev.yml` at the root. Check its Postgres service for the container name, port mapping, and volume. The local compose defaults use container name `resolutionflow_postgres`, database `resolutionflow`, and host-side port `5433` (mapped to the container's internal `5432`) — see CLAUDE.md Lesson 65. The host-side `5433` is the recommended default on any shared host: it keeps the port free for a host-level Postgres if you ever need one. The compose file also defines explicit `command:` directives on both `backend` and `frontend` to force `--host 0.0.0.0`, and expects the caller to pass `REPO_ROOT` (see 5.4) for bind-mount resolution. Confirm what the compose file actually says on your branch before trusting these values.
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.dev.yml up -d db
|
||||
docker compose -f docker-compose.dev.yml logs db # wait for "ready to accept connections"
|
||||
```
|
||||
|
||||
**Verify:**
|
||||
|
||||
```bash
|
||||
# From the host (Option A) or the backend container/LXC (Option B):
|
||||
psql -h <db-host> -p <POSTGRES_PORT> -U postgres -d resolutionflow -c "SELECT now();"
|
||||
```
|
||||
|
||||
### 5.4 Write the `.env` files
|
||||
|
||||
The repo expects three env files. Create each one:
|
||||
|
||||
**`backend/.env`** — backend source of truth:
|
||||
|
||||
```bash
|
||||
APP_NAME=ResolutionFlow
|
||||
DEBUG=true
|
||||
|
||||
# DB URLs — `<db-host>` is `localhost` for Option A, the Docker service name
|
||||
# (e.g. `db`) for Option B, or the DB container/VM hostname for Option C.
|
||||
DATABASE_URL=postgresql+asyncpg://postgres:postgres@<db-host>:<POSTGRES_PORT>/resolutionflow
|
||||
DATABASE_URL_SYNC=postgresql://postgres:postgres@<db-host>:<POSTGRES_PORT>/resolutionflow
|
||||
|
||||
# Auth
|
||||
SECRET_KEY=<SECRET_KEY>
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES=5
|
||||
REFRESH_TOKEN_EXPIRE_DAYS=7
|
||||
REQUIRE_INVITE_CODE=true
|
||||
|
||||
# AI providers
|
||||
AI_PROVIDER=anthropic
|
||||
ANTHROPIC_API_KEY=<ANTHROPIC_API_KEY>
|
||||
GOOGLE_AI_API_KEY=<GOOGLE_AI_API_KEY or leave unset>
|
||||
|
||||
# FlowPilot MCP telemetry — leave on so the Phase 0.5 baseline data keeps accruing
|
||||
ENABLE_MCP_MICROSOFT_LEARN=true
|
||||
|
||||
# CORS + frontend URL
|
||||
FRONTEND_URL=<DEV_HOST_SCHEME>://<DEV_HOST>:<FRONTEND_PORT>
|
||||
CORS_ORIGINS=["http://localhost:5173","http://127.0.0.1:5173","<DEV_HOST_SCHEME>://<DEV_HOST>:<FRONTEND_PORT>"]
|
||||
```
|
||||
|
||||
**`frontend/.env.local`** — frontend build-time config:
|
||||
|
||||
```bash
|
||||
VITE_API_URL=<DEV_HOST_SCHEME>://<DEV_HOST>:<BACKEND_PORT>
|
||||
```
|
||||
|
||||
Optional PostHog (CLAUDE.md Lesson 64 — enables product analytics locally):
|
||||
|
||||
```bash
|
||||
VITE_PUBLIC_POSTHOG_KEY=<from PostHog project settings>
|
||||
VITE_PUBLIC_POSTHOG_HOST=https://us.i.posthog.com
|
||||
```
|
||||
|
||||
**Repo root `.env`** — only needed for Option B (Docker Compose interpolation):
|
||||
|
||||
```bash
|
||||
SECRET_KEY=<SECRET_KEY>
|
||||
ANTHROPIC_API_KEY=<ANTHROPIC_API_KEY>
|
||||
GOOGLE_AI_API_KEY=<GOOGLE_AI_API_KEY or leave unset>
|
||||
POSTGRES_PORT=<POSTGRES_PORT>
|
||||
# Absolute host-side path to the repo root. REQUIRED whenever docker-compose is
|
||||
# invoked from inside a container (e.g. a code-server container with the host
|
||||
# Docker socket mounted in). Without it, the bind mounts in
|
||||
# docker-compose.dev.yml (`${REPO_ROOT}/backend:/app`, `${REPO_ROOT}/frontend:/app`)
|
||||
# resolve against the CLI's CWD — a path the host daemon cannot see — and
|
||||
# Docker silently creates empty directories there instead of mounting the code.
|
||||
# If you run docker compose directly on the host shell, you can set this to `.`
|
||||
# or the absolute path of the repo; being explicit is safer either way.
|
||||
REPO_ROOT=/absolute/path/to/resolutionflow
|
||||
```
|
||||
|
||||
> **Never commit any `.env` file.** The `.gitignore` already covers this.
|
||||
|
||||
### 5.5 Run the backend setup
|
||||
|
||||
**Option A (native):**
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
python3.11 -m venv venv
|
||||
source venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
|
||||
# Migrate the DB to head.
|
||||
alembic upgrade head
|
||||
```
|
||||
|
||||
**Option B (Docker):**
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.dev.yml up -d backend
|
||||
docker compose -f docker-compose.dev.yml run --rm backend alembic upgrade head
|
||||
```
|
||||
|
||||
### Seeding Test Users
|
||||
**Expected alembic head** (as of `feat/flowpilot-migration`): `f07010f17b01`. If `alembic current` shows anything else after `upgrade head`, something has gone wrong — stop and investigate.
|
||||
|
||||
### 5.6 Seed test users
|
||||
|
||||
```bash
|
||||
# Option A
|
||||
cd backend && source venv/bin/activate
|
||||
python -m scripts.seed_test_users
|
||||
|
||||
# Option B
|
||||
docker exec resolutionflow_backend python -m scripts.seed_test_users
|
||||
```
|
||||
|
||||
Test accounts (password: `TestPass123!`):
|
||||
Test users (all share password `TestPass123!`):
|
||||
|
||||
| Email | Role | Plan |
|
||||
|---|---|---|
|
||||
| admin@resolutionflow.example.com | Owner | Team |
|
||||
| pro@resolutionflow.example.com | Owner | Pro |
|
||||
| teamadmin@resolutionflow.example.com | Owner | Team |
|
||||
| engineer@resolutionflow.example.com | Engineer | Shared |
|
||||
| Email | Role |
|
||||
|---|---|
|
||||
| `admin@resolutionflow.example.com` | super admin |
|
||||
| `teamadmin@resolutionflow.example.com` | team admin |
|
||||
| `engineer@resolutionflow.example.com` | engineer |
|
||||
| `pro@resolutionflow.example.com` | solo pro |
|
||||
|
||||
### Rebuilding After Config Changes
|
||||
### 5.7 Run the backend
|
||||
|
||||
**Option A:**
|
||||
|
||||
```bash
|
||||
cd backend && source venv/bin/activate
|
||||
uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
|
||||
```
|
||||
|
||||
**Option B:** Already running from `docker compose up -d backend`. Tail logs:
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.dev.yml logs -f backend
|
||||
```
|
||||
|
||||
**Verify:** `curl <DEV_HOST_SCHEME>://<DEV_HOST>:<BACKEND_PORT>/api/docs` — OpenAPI docs page loads.
|
||||
|
||||
### 5.8 Run the frontend
|
||||
|
||||
**Option A:**
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm install
|
||||
npm run dev -- --host 0.0.0.0 --port 5173
|
||||
```
|
||||
|
||||
**Option B:**
|
||||
|
||||
**Frontend** (Vite bakes env vars at build time — requires rebuild):
|
||||
```bash
|
||||
cd /var/lib/docker/volumes/vscode_vscode-data/_data/resolutionflow
|
||||
docker compose -f docker-compose.dev.yml up -d --build frontend
|
||||
```
|
||||
|
||||
**Backend** (restart only):
|
||||
**Verify:** Open `<DEV_HOST_SCHEME>://<DEV_HOST>:<FRONTEND_PORT>` in your browser. Log in with one of the test users. Navigate to `/pilot` — the FlowPilot session page should render.
|
||||
|
||||
---
|
||||
|
||||
## 6. Verification — proof the env actually works
|
||||
|
||||
Run these after setup. Every item has a concrete expected outcome.
|
||||
|
||||
### 6.1 Database schema is at the right version
|
||||
|
||||
```bash
|
||||
# Option A
|
||||
cd backend && source venv/bin/activate && alembic current
|
||||
# Option B
|
||||
docker compose -f docker-compose.dev.yml run --rm backend alembic current
|
||||
```
|
||||
|
||||
Expected: `f07010f17b01 (head)` on the `feat/flowpilot-migration` branch. On `main`, expected: `074 (head)`.
|
||||
|
||||
### 6.2 Alembic reversibility
|
||||
|
||||
```bash
|
||||
alembic downgrade -1 # should complete cleanly
|
||||
alembic upgrade head # should return to f07010f17b01
|
||||
```
|
||||
|
||||
If either step fails, the migration has a bug and Phase 2 cannot start.
|
||||
|
||||
### 6.3 Prompt-cache hit verification (the deferred Phase 0 TODO)
|
||||
|
||||
`backend/app/core/ai_provider.py` module docstring has a `TODO(phase0-verify)` note describing this. Procedure:
|
||||
|
||||
1. Confirm `AI_PROVIDER=anthropic` and `ANTHROPIC_API_KEY` is set in `backend/.env`.
|
||||
2. Start the backend with log level INFO or lower.
|
||||
3. In the UI, open `/pilot` and send a chat message. Wait a few seconds for the response.
|
||||
4. Send a second chat message in the same session, within 5 minutes of the first.
|
||||
5. In backend logs, grep for lines containing `anthropic.cache`:
|
||||
|
||||
```bash
|
||||
# Option A
|
||||
grep 'anthropic.cache' <log-path>
|
||||
# Option B
|
||||
docker compose -f docker-compose.dev.yml logs backend | grep 'anthropic.cache'
|
||||
```
|
||||
|
||||
6. Expected: two `anthropic.cache` log events. First has `cache_creation_input_tokens > 0`. Second has `cache_read_input_tokens > 0`.
|
||||
7. If the second shows zero reads, inspect the prompt prefix for silent invalidators (timestamps, unsorted JSON keys, varying tool list ordering). Fix before proceeding with any Phase 2 work.
|
||||
|
||||
### 6.4 Frontend build is TypeScript-clean
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npx tsc -b # no errors
|
||||
npm run build # no errors
|
||||
```
|
||||
|
||||
CLAUDE.md Lesson 105 notes that `npm run build` may fail with an `EACCES` on `dist/` inside code-server — that is a Docker filesystem permission issue, not a real build error. Use `npx tsc -b` to verify TypeScript cleanliness in that case.
|
||||
|
||||
### 6.5 `/assistant` → `/pilot` redirect
|
||||
|
||||
Open `<DEV_HOST_SCHEME>://<DEV_HOST>:<FRONTEND_PORT>/assistant/<some-real-session-id>` in the browser. Expected: URL changes to `/pilot/<that-id>`; the FlowPilot session page renders. Bare `/assistant` redirects to bare `/pilot`.
|
||||
|
||||
### 6.6 Dispatcher de-branching
|
||||
|
||||
Navigate to the dashboard. Click a session in `ActiveFlowPilotSessions` or `RecentFlowPilotSessions`. Expected: routes to `/pilot/:id` regardless of the session's `session_type` value. (Check the browser URL bar.)
|
||||
|
||||
### 6.7 CORS
|
||||
|
||||
Open the browser DevTools Network tab, navigate to any backend-hitting page. Expected: no CORS errors. If you see "blocked by CORS policy," the missing origin needs adding to `backend/.env`'s `CORS_ORIGINS`.
|
||||
|
||||
---
|
||||
|
||||
## 7. Runbook
|
||||
|
||||
Day-to-day commands after setup is complete.
|
||||
|
||||
### Restart services
|
||||
|
||||
```bash
|
||||
# Option A
|
||||
# backend — Ctrl-C and re-run uvicorn
|
||||
# frontend — Ctrl-C and re-run npm run dev
|
||||
|
||||
# Option B
|
||||
docker compose -f docker-compose.dev.yml restart backend
|
||||
docker compose -f docker-compose.dev.yml up -d --build frontend # rebuild required if VITE_* changed
|
||||
docker compose -f docker-compose.dev.yml down && docker compose -f docker-compose.dev.yml up -d # full restart
|
||||
```
|
||||
|
||||
**Full restart:**
|
||||
```bash
|
||||
docker compose -f docker-compose.dev.yml down
|
||||
docker compose -f docker-compose.dev.yml up -d
|
||||
```
|
||||
|
||||
## Installed Tools (Inside VS Code Server Container)
|
||||
|
||||
Installed in `/home/coder` — persists via Docker volume:
|
||||
|
||||
- **nvm** — Node version manager
|
||||
- **Node.js 20.x** — via nvm, default alias set
|
||||
- **npm** — latest
|
||||
- **GitHub CLI (gh)** — authenticated via personal access token
|
||||
- **Claude Code CLI** — `@anthropic-ai/claude-code` (global npm)
|
||||
|
||||
### Permanent Tool Installs
|
||||
|
||||
Tools installed via `apt` inside the container do NOT survive container rebuilds. To add permanently, modify the VS Code Server Docker image and rebuild.
|
||||
|
||||
Temporary (session only):
|
||||
```bash
|
||||
sudo apt update && sudo apt install -y <tool>
|
||||
```
|
||||
|
||||
## SSH Access
|
||||
### Apply a new migration
|
||||
|
||||
```bash
|
||||
ssh root@46.202.92.250
|
||||
# Option A
|
||||
cd backend && source venv/bin/activate && alembic upgrade head
|
||||
# Option B
|
||||
docker compose -f docker-compose.dev.yml run --rm backend alembic upgrade head
|
||||
```
|
||||
|
||||
Key auth configured via `~/.ssh/authorized_keys` on host.
|
||||
### Create a new migration
|
||||
|
||||
## Useful Commands
|
||||
|
||||
### Check all running containers
|
||||
```bash
|
||||
docker ps --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}"
|
||||
# Option A
|
||||
cd backend && source venv/bin/activate
|
||||
alembic revision -m "short description" # manual, preferred per CLAUDE.md Lesson 77
|
||||
# OR
|
||||
alembic revision --autogenerate -m "description" # pulls in drift; review carefully
|
||||
```
|
||||
|
||||
### View container logs
|
||||
Never pass `--rev-id` — let Alembic generate the hex hash.
|
||||
|
||||
### Inspect the database
|
||||
|
||||
```bash
|
||||
docker logs <container_name> --tail 30 -f
|
||||
# Option A (native Postgres)
|
||||
psql -h localhost -p 5432 -U postgres -d resolutionflow
|
||||
|
||||
# Option B (Docker)
|
||||
docker exec -it resolutionflow_postgres psql -U postgres -d resolutionflow
|
||||
```
|
||||
|
||||
### Restart VS Code Server
|
||||
### Run tests
|
||||
|
||||
```bash
|
||||
cd /docker/vscode && docker compose restart
|
||||
# Option A
|
||||
cd backend && source venv/bin/activate
|
||||
pytest --override-ini="addopts="
|
||||
|
||||
# Option B
|
||||
docker compose -f docker-compose.dev.yml run --rm backend pytest --override-ini="addopts="
|
||||
```
|
||||
|
||||
### Restart Traefik
|
||||
First time only, create the test database:
|
||||
|
||||
```bash
|
||||
cd /docker/traefik && docker compose restart
|
||||
# Option A
|
||||
sudo -u postgres psql -c "CREATE DATABASE resolutionflow_test;"
|
||||
|
||||
# Option B
|
||||
docker exec -it resolutionflow_postgres psql -U postgres -c "CREATE DATABASE resolutionflow_test;"
|
||||
```
|
||||
|
||||
### Restart dev stack
|
||||
### View backend logs
|
||||
|
||||
```bash
|
||||
cd /var/lib/docker/volumes/vscode_vscode-data/_data/resolutionflow
|
||||
docker compose -f docker-compose.dev.yml down
|
||||
docker compose -f docker-compose.dev.yml up -d
|
||||
# Option A: wherever you ran uvicorn
|
||||
# Option B
|
||||
docker compose -f docker-compose.dev.yml logs -f --tail=100 backend
|
||||
```
|
||||
|
||||
### Check swap
|
||||
Structured events to grep for:
|
||||
- `anthropic.cache` — prompt-cache hit/creation telemetry (Phase 0.1)
|
||||
- `mcp.turn` — per-turn MCP availability/invocation (Phase 0.5)
|
||||
- `mcp.fallback` — MCP silent-retry fallback fired (Phase 0.5)
|
||||
|
||||
---
|
||||
|
||||
## 8. Troubleshooting
|
||||
|
||||
### CORS errors in the browser
|
||||
|
||||
The backend did not accept the origin your browser used. Check `backend/.env`'s `CORS_ORIGINS` — it must include the exact scheme + host + port the browser sent. Restart the backend after editing.
|
||||
|
||||
### `VITE_API_URL` points at the wrong place
|
||||
|
||||
The frontend was built with a stale value. Rebuild the frontend. Option B: `docker compose up -d --build frontend`. Option A: restart `npm run dev`.
|
||||
|
||||
### `alembic upgrade head` fails with "target database is not up to date"
|
||||
|
||||
Your DB migration chain is out of sync with the code. On a dev box, the safe recovery is to drop the DB and re-migrate from scratch:
|
||||
|
||||
```bash
|
||||
free -h && swapon --show
|
||||
# Option A
|
||||
sudo -u postgres psql -c "DROP DATABASE resolutionflow;" -c "CREATE DATABASE resolutionflow;"
|
||||
cd backend && source venv/bin/activate && alembic upgrade head
|
||||
|
||||
# Option B
|
||||
docker exec resolutionflow_postgres psql -U postgres -c "DROP DATABASE resolutionflow;" -c "CREATE DATABASE resolutionflow;"
|
||||
docker compose -f docker-compose.dev.yml run --rm backend alembic upgrade head
|
||||
```
|
||||
|
||||
### Check disk
|
||||
Only do this on a dev box — it destroys all local data.
|
||||
|
||||
### `alembic heads` shows more than one head
|
||||
|
||||
Only on a local branch that has diverged from `origin/main`. Production `main` has a single head. If this happens on a fresh clone, one of your local migration files has the wrong `down_revision`. Inspect each file's `down_revision` and reconnect the chain.
|
||||
|
||||
### Frontend build fails with "EACCES: permission denied" on `dist/`
|
||||
|
||||
Filesystem permission issue inside the code-server container (CLAUDE.md Lesson 105). TypeScript compilation itself completes — use `npx tsc -b` to verify cleanliness without needing to write to `dist/`.
|
||||
|
||||
### Backend/frontend containers start but `/app` is empty (no code mounted)
|
||||
|
||||
Almost always a `REPO_ROOT` problem. `docker-compose.dev.yml` uses `${REPO_ROOT}/backend:/app` and `${REPO_ROOT}/frontend:/app` bind mounts. If `REPO_ROOT` is unset, or set to a path that doesn't exist *on the Docker host* (not inside the code-server container), Docker silently creates an empty directory at that path and mounts it — the containers come up but have no source code. Symptom: backend returns import errors, or frontend serves a default Vite page. Fix: set `REPO_ROOT` in the repo-root `.env` to the absolute host-side path to the repo, then `docker compose down && docker compose up -d`. See 5.4 for the full note. This matters specifically when `docker compose` is invoked from inside a container (e.g. code-server with the host Docker socket mounted) — the CLI's CWD is container-local but the daemon resolves paths against the host filesystem.
|
||||
|
||||
### Frontend shows "Blocked request. This host is not allowed" in the browser
|
||||
|
||||
Vite 5+ ships DNS-rebinding protection that rejects any `Host:` header not in `server.allowedHosts`. The browser's hostname must be in that list. Edit `frontend/vite.config.ts` — the `server.allowedHosts` array should include every hostname you reach the dev server from (e.g. `'docker-01'`, `'localhost'`, `.ts.net` as a wildcard for Tailscale MagicDNS). Restart the Vite dev server (for Option B: `docker compose restart frontend`). This is unrelated to CORS — Vite blocks the request before any app code runs.
|
||||
|
||||
### `docker` command not found inside code-server
|
||||
|
||||
If your code-server is itself inside a container, Docker is probably not exposed to it. CLAUDE.md Lesson 103 was written for this case on the old VPS. On Proxmox, the fix depends on topology — either SSH to the host to run Docker commands, or mount the host's Docker socket into the code-server container.
|
||||
|
||||
### Backend returns 500 with `InsufficientPrivilegeError: new row violates row-level security policy`
|
||||
|
||||
RLS is enabled on a table your code wrote to without the right `account_id`. CLAUDE.md Lessons 107, 108, 110 cover this family of bugs. The fix is always at the service layer: make sure every model creation passes `account_id=` explicitly, and that startup routines that touch tenant-isolated tables use `_admin_session_factory()` rather than `get_db()`.
|
||||
|
||||
### Anthropic cache reads are zero on the second turn
|
||||
|
||||
Something in the cached prefix is changing between turns. Inspect the system-block list and the first N history messages for timestamps, `datetime.now()`, unsorted dict keys in JSON prompts, or varying tool-list order. The `anthropic.cache` telemetry shows exactly how many tokens were read vs created — use it to narrow down the invalidator.
|
||||
|
||||
---
|
||||
|
||||
## 9. Security posture for dev environments
|
||||
|
||||
This doc is about dev, not production. But:
|
||||
|
||||
- Never commit `.env` files. The `.gitignore` covers this.
|
||||
- `SECRET_KEY` should be generated per-host, not reused across environments.
|
||||
- `ANTHROPIC_API_KEY` is billable — rotate if leaked into logs or chat.
|
||||
- Postgres on a dev host should not be exposed to the internet. Bind it to `127.0.0.1` or to a private network interface only.
|
||||
- If you expose the frontend or backend publicly (for teammates to test against), put it behind TLS with a real certificate. Do not let dev credentials travel over plain HTTP on the public internet.
|
||||
|
||||
---
|
||||
|
||||
## 10. What's not in this doc
|
||||
|
||||
- **Production deployment.** This is a dev-env doc. Production lives on Railway — see `CLAUDE.md`'s Deployment section.
|
||||
- **How to set up Traefik or any particular reverse proxy.** Whichever proxy you use is your choice; the dev stack just needs something that routes `<host>:5173` and `<host>:8000` to the right services. **Direct port exposure over a private network** (Tailscale, WireGuard, a VPN, or a LAN behind a firewall) is a fully supported option for dev and is what the homelab reference topology in Section 11 uses — no reverse proxy, no TLS, just `http://<host>:5173` and `http://<host>:8000` reachable only from the private network. That's a perfectly reasonable choice; it's just not the only one.
|
||||
- **How to configure code-server itself.** Install it however you prefer (native, Docker, LXC); point it at the repo, and the rest of this doc applies.
|
||||
- **Where to host the Proxmox instance.** Up to you.
|
||||
|
||||
If something in this doc turns out to be wrong on your host, fix the doc. This is a living document — the whole point of rewriting it from the Hostinger-specific version was to make it survive host changes.
|
||||
|
||||
---
|
||||
|
||||
## 11. Reference topology: homelab Proxmox + code-server (Option B)
|
||||
|
||||
This section documents the first concrete host instantiation since the April 2026 host-agnostic rewrite. It's a worked example, not the canonical topology — Section 3's Option A/B/C framing still stands. If your setup looks different, follow Sections 1–10 and ignore this appendix.
|
||||
|
||||
### 11.1 Host
|
||||
|
||||
- **Hypervisor:** Proxmox (homelab).
|
||||
- **VM:** `docker-01`, Debian 13, running Docker Engine + Docker Compose natively.
|
||||
- **Tailscale IP:** `100.64.78.44`. MagicDNS hostname: `docker-01` (and the full `.ts.net` FQDN).
|
||||
- **code-server:** runs on the same VM in its own container, with the host's Docker socket mounted in so it can drive `docker compose`. Its workspace bind-mounts the repo at `/opt/docker/code-server/workspace/resolutionflow`.
|
||||
|
||||
This is a concrete instance of Option B from Section 3: Postgres, backend, and frontend all run as containers from `docker-compose.dev.yml`; the editor lives outside that compose network.
|
||||
|
||||
### 11.2 Access pattern — direct port over Tailscale, no reverse proxy
|
||||
|
||||
The browser reaches the dev stack directly:
|
||||
|
||||
- Frontend: `http://docker-01:5173`
|
||||
- Backend: `http://docker-01:8000`
|
||||
- Backend API docs: `http://docker-01:8000/api/docs`
|
||||
|
||||
There is **no Caddy, no Traefik, no nginx, no TLS, no basic auth** in front of either service. The tailnet provides the wire encryption and access control — only devices on the tailnet can resolve `docker-01` or reach `100.64.78.44`, and Tailscale ACLs decide which of those devices are allowed to connect.
|
||||
|
||||
Why this choice:
|
||||
|
||||
- **Zero routing config to maintain.** There is no proxy rulebook to keep in sync with new services. Add a container, expose a port, you're done.
|
||||
- **Backend-to-backend services stay private.** Redis, Celery workers, the planned ConnectWise proxy, the MCP server — none of them need to be reachable from the browser, so none of them need proxy rules. They stay inside the `resolutionflow` Docker network and talk by service name. The proxy would only ever have carried frontend and backend traffic, so the proxy's value was small relative to its maintenance cost.
|
||||
- **Debuggability.** `curl http://docker-01:8000/api/docs` from any tailnet device works without auth headers, TLS handshakes, or DNS shenanigans.
|
||||
|
||||
Tradeoff: **this only works because every client device is on the tailnet.** If someone needed to test from a non-tailnet device, they'd either join the tailnet or we'd need to front the stack with a proxy. For the current single-developer setup, the tailnet-only assumption holds.
|
||||
|
||||
### 11.3 Per-host config values (as actually configured on `docker-01`)
|
||||
|
||||
Plugging these into Section 4's template:
|
||||
|
||||
```
|
||||
DEV_HOST = docker-01
|
||||
DEV_HOST_SCHEME = http
|
||||
FRONTEND_PORT = 5173
|
||||
BACKEND_PORT = 8000
|
||||
POSTGRES_PORT = 5433 # host-side; container-internal stays 5432
|
||||
POSTGRES_DB_NAME = resolutionflow
|
||||
POSTGRES_USER = postgres
|
||||
POSTGRES_PASSWORD = postgres # local-dev only
|
||||
SECRET_KEY = <generated per host; do not reuse>
|
||||
ANTHROPIC_API_KEY = <from console.anthropic.com>
|
||||
GOOGLE_AI_API_KEY = <unset; Anthropic is sole provider in dev>
|
||||
```
|
||||
|
||||
And the repo-root `.env` that `docker-compose.dev.yml` interpolates from:
|
||||
|
||||
```bash
|
||||
df -h
|
||||
SECRET_KEY=<redacted>
|
||||
ANTHROPIC_API_KEY=<redacted>
|
||||
POSTGRES_PORT=5433
|
||||
REPO_ROOT=/opt/docker/code-server/workspace/resolutionflow
|
||||
```
|
||||
|
||||
### Check memory + container usage
|
||||
### 11.4 Why `REPO_ROOT` is non-optional on this host
|
||||
|
||||
code-server runs inside a container. When you open a terminal in code-server and run `docker compose -f docker-compose.dev.yml up -d`, the Docker CLI talks to the *host* daemon via the mounted socket — but the CWD it reports (`/config/workspace/resolutionflow`) is a path that only exists inside the code-server container. The host daemon has never heard of it.
|
||||
|
||||
Relative bind mounts like `./backend:/app` therefore resolve against a path the host can't see, and Docker silently creates empty directories there rather than erroring out. The containers come up, but `/app` is empty.
|
||||
|
||||
`docker-compose.dev.yml` sidesteps this by using `${REPO_ROOT}/backend:/app` and `${REPO_ROOT}/frontend:/app`. `REPO_ROOT` must be set to the absolute path **on the host** (`/opt/docker/code-server/workspace/resolutionflow`), not the path inside the code-server container. Same contents, different mount point, different name.
|
||||
|
||||
If you ever run `docker compose` directly from a host shell (SSH'd into `docker-01`), set `REPO_ROOT` to `.` or the absolute host path. Being explicit is always safe; leaving it unset is the failure mode.
|
||||
|
||||
### 11.5 Vite `server.allowedHosts` — required for `docker-01` to resolve
|
||||
|
||||
Vite 5+ rejects any `Host:` header not in `server.allowedHosts` (DNS-rebinding protection). `frontend/vite.config.ts` has:
|
||||
|
||||
```ts
|
||||
server: {
|
||||
host: '0.0.0.0',
|
||||
allowedHosts: ['docker-01', '.ts.net', 'localhost'],
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
- `docker-01` — the MagicDNS short name the browser uses day-to-day.
|
||||
- `.ts.net` — wildcard for the full Tailscale MagicDNS FQDN, in case anyone uses it.
|
||||
- `localhost` — for the "am I serving anything at all" smoke-test from inside the container.
|
||||
|
||||
If you move this setup to a different host, add that host's hostname to `allowedHosts` or the browser will see "Blocked request. This host is not allowed." See Section 8's troubleshooting entry for the full symptom/fix.
|
||||
|
||||
### 11.6 CORS origins on this host
|
||||
|
||||
The `backend` service's `CORS_ORIGINS` environment variable is pinned in the compose file to:
|
||||
|
||||
```
|
||||
["http://localhost:5173","http://127.0.0.1:5173","http://docker-01:5173","http://100.64.78.44:5173"]
|
||||
```
|
||||
|
||||
The last two are what make browser calls from tailnet clients work — they cover both MagicDNS (`docker-01`) and the raw Tailscale IP. If you add a new hostname to reach the frontend from, also add the matching origin here and restart the backend.
|
||||
|
||||
### 11.7 Compose file shape (as of this writing)
|
||||
|
||||
`docker-compose.dev.yml` has been through a round of cleanup for this topology. Specifics worth knowing if you're comparing against older revisions of the file:
|
||||
|
||||
- **No Traefik labels.** They were removed — nothing in this topology uses Traefik.
|
||||
- **No Hostinger-VPS-era origins** in `CORS_ORIGINS`.
|
||||
- `Dockerfile.dev` for both `backend` and `frontend` is still the build source — this didn't change.
|
||||
- Explicit `command:` directives on both `backend` (`uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload`) and `frontend` (`npm run dev -- --host 0.0.0.0 --port 5173`) — this guarantees `--host 0.0.0.0` regardless of what's baked into the image, so the services listen on all interfaces and are reachable from outside the container.
|
||||
- `REPO_ROOT` is interpolated into both service volume mounts (see 11.4).
|
||||
|
||||
If you're adapting the file for a different host, the things most likely to need editing are `REPO_ROOT` (see 11.4), `CORS_ORIGINS` (see 11.6), `FRONTEND_URL`, `VITE_API_URL`, and `POSTGRES_PORT` if you want something other than `5433`.
|
||||
|
||||
### 11.8 End-to-end sanity check for this topology
|
||||
|
||||
From any device on the tailnet:
|
||||
|
||||
```bash
|
||||
free -h && docker stats --no-stream
|
||||
# Backend reachable
|
||||
curl -sSf http://docker-01:8000/api/docs >/dev/null && echo OK
|
||||
|
||||
# Frontend reachable
|
||||
curl -sSf http://docker-01:5173 >/dev/null && echo OK
|
||||
|
||||
# Alembic head matches the branch expectation
|
||||
docker exec resolutionflow_backend alembic current
|
||||
# expect f07010f17b01 on feat/flowpilot-migration, 074 on main
|
||||
|
||||
# Postgres is alive inside the compose network
|
||||
docker exec resolutionflow_postgres psql -U postgres -d resolutionflow -c "SELECT now();"
|
||||
```
|
||||
|
||||
## DNS Records (resolutionflow.com)
|
||||
|
||||
| Type | Name | Value | Purpose |
|
||||
|---|---|---|---|
|
||||
| A | code | 46.202.92.250 | VS Code Server |
|
||||
|
||||
## Security Notes
|
||||
|
||||
- UFW is inactive — Traefik and Docker manage port exposure
|
||||
- All public-facing services run through Traefik with valid HTTPS certs
|
||||
- PostgreSQL port 5432 is exposed on all interfaces — restrict if needed in production
|
||||
- Rotate API keys (Anthropic, Voyage) if ever exposed in logs or chat
|
||||
- Never commit `.env` files to Git
|
||||
|
||||
## VS Code Server Browser Tips
|
||||
|
||||
- **Command Palette:** `F1`
|
||||
- **Terminal:** Ctrl+`
|
||||
- **Rename file:** `F2`
|
||||
- **Go to definition:** `F12`
|
||||
- **Find references:** `Shift+F12`
|
||||
- **Context Menu:** `Alt + Right Click`
|
||||
All four passing = the dev environment is live end-to-end.
|
||||
|
||||
404
backend/alembic/versions/f07010f17b01_flowpilot_phase1_schema.py
Normal file
404
backend/alembic/versions/f07010f17b01_flowpilot_phase1_schema.py
Normal file
@@ -0,0 +1,404 @@
|
||||
"""FlowPilot migration Phase 1 — schema for the unified session surface.
|
||||
|
||||
Revision ID: f07010f17b01
|
||||
Revises: 074
|
||||
Create Date: 2026-04-17
|
||||
|
||||
Creates the backing store for the FlowPilot unified session surface:
|
||||
|
||||
- `session_facts` — "What we know" facts, keyed to a session, with a polymorphic
|
||||
`source_ref` pointing at a task-lane item inside `ai_sessions.pending_task_lane`
|
||||
(no DB-level FK; integrity enforced at the service layer per the design doc).
|
||||
- `session_suggested_fixes` — AI-proposed resolution paths. Only one active
|
||||
(`superseded_at IS NULL`) per session at a time.
|
||||
- `draft_templates` — scripts pending post-resolve templatization
|
||||
(Option 2 in the three-option dialog).
|
||||
- `account_settings` — new per-account key/value settings table with a JSONB
|
||||
`preferences` grab-bag. Rows are created lazily on first write.
|
||||
- Column additions to `ai_sessions` — resolution/escalation markdown + external IDs,
|
||||
plus `state_version` (incremented by any write that invalidates the resolution
|
||||
note preview cache).
|
||||
- Column additions to `script_templates` — provenance fields for templates
|
||||
promoted from draft_templates.
|
||||
|
||||
All four new tenant-scoped tables have RLS enabled + forced with a
|
||||
`tenant_isolation` policy matching the repo pattern (USING + WITH CHECK on
|
||||
`account_id = app.current_account_id`). Downgrade is reversible: drops in the
|
||||
inverse order of creation.
|
||||
|
||||
Chained from `074` (add_network_diagrams_table) per the single-head state of
|
||||
production; the other local heads on feat/flowpilot-migration are branch
|
||||
artifacts not present in production.
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects.postgresql import UUID, JSONB
|
||||
|
||||
|
||||
revision = "f07010f17b01"
|
||||
down_revision = "074"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
_CURRENT_ACCOUNT = (
|
||||
"COALESCE("
|
||||
"NULLIF(current_setting('app.current_account_id', TRUE), ''), "
|
||||
"'00000000-0000-0000-0000-000000000000'"
|
||||
")::uuid"
|
||||
)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ── ai_sessions: resolution / escalation columns + state_version ───────
|
||||
op.add_column(
|
||||
"ai_sessions",
|
||||
sa.Column("resolution_note_markdown", sa.Text(), nullable=True),
|
||||
)
|
||||
op.add_column(
|
||||
"ai_sessions",
|
||||
sa.Column("resolution_note_posted_at", sa.DateTime(timezone=True), nullable=True),
|
||||
)
|
||||
op.add_column(
|
||||
"ai_sessions",
|
||||
sa.Column("resolution_note_external_id", sa.String(128), nullable=True),
|
||||
)
|
||||
op.add_column(
|
||||
"ai_sessions",
|
||||
sa.Column("escalation_package_markdown", sa.Text(), nullable=True),
|
||||
)
|
||||
op.add_column(
|
||||
"ai_sessions",
|
||||
sa.Column("escalation_package_posted_at", sa.DateTime(timezone=True), nullable=True),
|
||||
)
|
||||
op.add_column(
|
||||
"ai_sessions",
|
||||
sa.Column("escalation_package_external_id", sa.String(128), nullable=True),
|
||||
)
|
||||
op.add_column(
|
||||
"ai_sessions",
|
||||
sa.Column(
|
||||
"state_version",
|
||||
sa.Integer(),
|
||||
nullable=False,
|
||||
server_default=sa.text("0"),
|
||||
),
|
||||
)
|
||||
|
||||
# ── script_templates: provenance for post-resolve promotion ────────────
|
||||
op.add_column(
|
||||
"script_templates",
|
||||
sa.Column(
|
||||
"source_session_id",
|
||||
UUID(as_uuid=True),
|
||||
sa.ForeignKey("ai_sessions.id"),
|
||||
nullable=True,
|
||||
),
|
||||
)
|
||||
op.add_column(
|
||||
"script_templates",
|
||||
sa.Column(
|
||||
"source_user_id",
|
||||
UUID(as_uuid=True),
|
||||
sa.ForeignKey("users.id"),
|
||||
nullable=True,
|
||||
),
|
||||
)
|
||||
op.add_column(
|
||||
"script_templates",
|
||||
sa.Column("source_ticket_ref", sa.String(64), nullable=True),
|
||||
)
|
||||
|
||||
# ── session_facts ──────────────────────────────────────────────────────
|
||||
op.create_table(
|
||||
"session_facts",
|
||||
sa.Column(
|
||||
"id",
|
||||
UUID(as_uuid=True),
|
||||
primary_key=True,
|
||||
server_default=sa.text("gen_random_uuid()"),
|
||||
),
|
||||
sa.Column(
|
||||
"session_id",
|
||||
UUID(as_uuid=True),
|
||||
sa.ForeignKey("ai_sessions.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"account_id",
|
||||
UUID(as_uuid=True),
|
||||
sa.ForeignKey("accounts.id"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("text", sa.Text(), nullable=False),
|
||||
sa.Column("source_type", sa.String(32), nullable=False),
|
||||
# `source_ref` is a polymorphic pointer to a task-lane item inside
|
||||
# ai_sessions.pending_task_lane JSON, NOT a FK to any table.
|
||||
# Integrity enforced at the service layer per Section 4.2 of the
|
||||
# migration design doc.
|
||||
sa.Column("source_ref", UUID(as_uuid=True), nullable=True),
|
||||
sa.Column("source_summary", sa.Text(), nullable=True),
|
||||
sa.Column(
|
||||
"created_by",
|
||||
UUID(as_uuid=True),
|
||||
sa.ForeignKey("users.id"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=sa.text("now()"),
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at",
|
||||
sa.DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=sa.text("now()"),
|
||||
),
|
||||
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.CheckConstraint(
|
||||
"source_type IN ('question', 'diagnostic_check', 'user_note', 'ai_synthesis')",
|
||||
name="ck_session_facts_source_type",
|
||||
),
|
||||
)
|
||||
# Active-facts-per-session; partial index excludes soft-deleted rows.
|
||||
op.create_index(
|
||||
"idx_session_facts_session",
|
||||
"session_facts",
|
||||
["session_id"],
|
||||
postgresql_where=sa.text("deleted_at IS NULL"),
|
||||
)
|
||||
op.create_index(
|
||||
"idx_session_facts_account",
|
||||
"session_facts",
|
||||
["account_id"],
|
||||
)
|
||||
op.execute("ALTER TABLE session_facts ENABLE ROW LEVEL SECURITY")
|
||||
op.execute("ALTER TABLE session_facts FORCE ROW LEVEL SECURITY")
|
||||
op.execute(f"""
|
||||
CREATE POLICY tenant_isolation ON session_facts
|
||||
USING (account_id = {_CURRENT_ACCOUNT})
|
||||
WITH CHECK (account_id = {_CURRENT_ACCOUNT})
|
||||
""")
|
||||
|
||||
# ── session_suggested_fixes ────────────────────────────────────────────
|
||||
op.create_table(
|
||||
"session_suggested_fixes",
|
||||
sa.Column(
|
||||
"id",
|
||||
UUID(as_uuid=True),
|
||||
primary_key=True,
|
||||
server_default=sa.text("gen_random_uuid()"),
|
||||
),
|
||||
sa.Column(
|
||||
"session_id",
|
||||
UUID(as_uuid=True),
|
||||
sa.ForeignKey("ai_sessions.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"account_id",
|
||||
UUID(as_uuid=True),
|
||||
sa.ForeignKey("accounts.id"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("title", sa.String(200), nullable=False),
|
||||
sa.Column("description", sa.Text(), nullable=False),
|
||||
sa.Column("confidence_pct", sa.Integer(), nullable=False),
|
||||
sa.Column(
|
||||
"script_template_id",
|
||||
UUID(as_uuid=True),
|
||||
sa.ForeignKey("script_templates.id"),
|
||||
nullable=True,
|
||||
),
|
||||
sa.Column("ai_drafted_script", sa.Text(), nullable=True),
|
||||
sa.Column("ai_drafted_parameters", JSONB(), nullable=True),
|
||||
sa.Column("user_decision", sa.String(32), nullable=True),
|
||||
sa.Column("superseded_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=sa.text("now()"),
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"confidence_pct BETWEEN 0 AND 100",
|
||||
name="ck_session_suggested_fixes_confidence_pct",
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"user_decision IS NULL OR user_decision IN ("
|
||||
"'one_off', 'draft_template', 'build_template', 'dismissed')",
|
||||
name="ck_session_suggested_fixes_user_decision",
|
||||
),
|
||||
)
|
||||
# Only-one-active-per-session is enforced by service-layer supersession;
|
||||
# this partial index serves the "find active fix" query.
|
||||
op.create_index(
|
||||
"idx_session_suggested_fixes_session_active",
|
||||
"session_suggested_fixes",
|
||||
["session_id"],
|
||||
postgresql_where=sa.text("superseded_at IS NULL"),
|
||||
)
|
||||
op.execute("ALTER TABLE session_suggested_fixes ENABLE ROW LEVEL SECURITY")
|
||||
op.execute("ALTER TABLE session_suggested_fixes FORCE ROW LEVEL SECURITY")
|
||||
op.execute(f"""
|
||||
CREATE POLICY tenant_isolation ON session_suggested_fixes
|
||||
USING (account_id = {_CURRENT_ACCOUNT})
|
||||
WITH CHECK (account_id = {_CURRENT_ACCOUNT})
|
||||
""")
|
||||
|
||||
# ── draft_templates ────────────────────────────────────────────────────
|
||||
op.create_table(
|
||||
"draft_templates",
|
||||
sa.Column(
|
||||
"id",
|
||||
UUID(as_uuid=True),
|
||||
primary_key=True,
|
||||
server_default=sa.text("gen_random_uuid()"),
|
||||
),
|
||||
sa.Column(
|
||||
"account_id",
|
||||
UUID(as_uuid=True),
|
||||
sa.ForeignKey("accounts.id"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"source_session_id",
|
||||
UUID(as_uuid=True),
|
||||
sa.ForeignKey("ai_sessions.id"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"source_user_id",
|
||||
UUID(as_uuid=True),
|
||||
sa.ForeignKey("users.id"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("script_body", sa.Text(), nullable=False),
|
||||
sa.Column("proposed_parameters", JSONB(), nullable=False),
|
||||
sa.Column("proposed_name", sa.String(200), nullable=True),
|
||||
sa.Column(
|
||||
"proposed_category_id",
|
||||
UUID(as_uuid=True),
|
||||
sa.ForeignKey("script_categories.id"),
|
||||
nullable=True,
|
||||
),
|
||||
sa.Column(
|
||||
"status",
|
||||
sa.String(32),
|
||||
nullable=False,
|
||||
server_default=sa.text("'pending'"),
|
||||
),
|
||||
sa.Column("resolved_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column(
|
||||
"promoted_template_id",
|
||||
UUID(as_uuid=True),
|
||||
sa.ForeignKey("script_templates.id"),
|
||||
nullable=True,
|
||||
),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=sa.text("now()"),
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"status IN ('pending', 'accepted', 'rejected')",
|
||||
name="ck_draft_templates_status",
|
||||
),
|
||||
)
|
||||
# Supports the Script Library "N scripts ready to review" badge.
|
||||
op.create_index(
|
||||
"idx_draft_templates_account_pending",
|
||||
"draft_templates",
|
||||
["account_id"],
|
||||
postgresql_where=sa.text("status = 'pending'"),
|
||||
)
|
||||
op.execute("ALTER TABLE draft_templates ENABLE ROW LEVEL SECURITY")
|
||||
op.execute("ALTER TABLE draft_templates FORCE ROW LEVEL SECURITY")
|
||||
op.execute(f"""
|
||||
CREATE POLICY tenant_isolation ON draft_templates
|
||||
USING (account_id = {_CURRENT_ACCOUNT})
|
||||
WITH CHECK (account_id = {_CURRENT_ACCOUNT})
|
||||
""")
|
||||
|
||||
# ── account_settings ───────────────────────────────────────────────────
|
||||
# One row per account, created lazily on first write. The `preferences`
|
||||
# JSONB is a grab-bag for simple settings (e.g. templatize_prompt_enabled).
|
||||
# Settings graduate to typed columns via future migrations when they meet
|
||||
# the promotion criteria in Section 4.6 of the design doc (hot path /
|
||||
# validation / joins).
|
||||
op.create_table(
|
||||
"account_settings",
|
||||
sa.Column(
|
||||
"account_id",
|
||||
UUID(as_uuid=True),
|
||||
sa.ForeignKey("accounts.id", ondelete="CASCADE"),
|
||||
primary_key=True,
|
||||
),
|
||||
sa.Column(
|
||||
"preferences",
|
||||
JSONB(),
|
||||
nullable=False,
|
||||
server_default=sa.text("'{}'::jsonb"),
|
||||
),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=sa.text("now()"),
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at",
|
||||
sa.DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=sa.text("now()"),
|
||||
),
|
||||
)
|
||||
op.execute("ALTER TABLE account_settings ENABLE ROW LEVEL SECURITY")
|
||||
op.execute("ALTER TABLE account_settings FORCE ROW LEVEL SECURITY")
|
||||
op.execute(f"""
|
||||
CREATE POLICY tenant_isolation ON account_settings
|
||||
USING (account_id = {_CURRENT_ACCOUNT})
|
||||
WITH CHECK (account_id = {_CURRENT_ACCOUNT})
|
||||
""")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Drop in reverse order so FK dependencies unwind cleanly.
|
||||
op.execute("DROP POLICY IF EXISTS tenant_isolation ON account_settings")
|
||||
op.execute("ALTER TABLE account_settings DISABLE ROW LEVEL SECURITY")
|
||||
op.drop_table("account_settings")
|
||||
|
||||
op.execute("DROP POLICY IF EXISTS tenant_isolation ON draft_templates")
|
||||
op.execute("ALTER TABLE draft_templates DISABLE ROW LEVEL SECURITY")
|
||||
op.drop_index("idx_draft_templates_account_pending", table_name="draft_templates")
|
||||
op.drop_table("draft_templates")
|
||||
|
||||
op.execute("DROP POLICY IF EXISTS tenant_isolation ON session_suggested_fixes")
|
||||
op.execute("ALTER TABLE session_suggested_fixes DISABLE ROW LEVEL SECURITY")
|
||||
op.drop_index(
|
||||
"idx_session_suggested_fixes_session_active",
|
||||
table_name="session_suggested_fixes",
|
||||
)
|
||||
op.drop_table("session_suggested_fixes")
|
||||
|
||||
op.execute("DROP POLICY IF EXISTS tenant_isolation ON session_facts")
|
||||
op.execute("ALTER TABLE session_facts DISABLE ROW LEVEL SECURITY")
|
||||
op.drop_index("idx_session_facts_account", table_name="session_facts")
|
||||
op.drop_index("idx_session_facts_session", table_name="session_facts")
|
||||
op.drop_table("session_facts")
|
||||
|
||||
op.drop_column("script_templates", "source_ticket_ref")
|
||||
op.drop_column("script_templates", "source_user_id")
|
||||
op.drop_column("script_templates", "source_session_id")
|
||||
|
||||
op.drop_column("ai_sessions", "state_version")
|
||||
op.drop_column("ai_sessions", "escalation_package_external_id")
|
||||
op.drop_column("ai_sessions", "escalation_package_posted_at")
|
||||
op.drop_column("ai_sessions", "escalation_package_markdown")
|
||||
op.drop_column("ai_sessions", "resolution_note_external_id")
|
||||
op.drop_column("ai_sessions", "resolution_note_posted_at")
|
||||
op.drop_column("ai_sessions", "resolution_note_markdown")
|
||||
@@ -5,8 +5,8 @@ from typing import Annotated, Optional
|
||||
from uuid import UUID
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func
|
||||
from sqlalchemy.orm import selectinload
|
||||
from sqlalchemy import select, func, or_
|
||||
from sqlalchemy.orm import selectinload, aliased
|
||||
|
||||
from app.core.admin_database import get_admin_db
|
||||
from app.core.audit import log_audit
|
||||
@@ -24,21 +24,44 @@ from app.models.invite_code import InviteCode
|
||||
from app.models.account_invite import AccountInvite
|
||||
from app.models.tree import Tree
|
||||
from app.schemas.user import UserResponse, RoleUpdate, AccountRoleUpdate
|
||||
from app.schemas.admin import MoveUserAccount, AdminUserCreate, AdminUserCreateResponse, AdminPasswordReset, AdminPasswordResetResponse, HardDeleteCheckResponse
|
||||
from app.schemas.admin import (
|
||||
MoveUserAccount,
|
||||
AdminUserCreate,
|
||||
AdminUserCreateResponse,
|
||||
AdminPasswordReset,
|
||||
AdminPasswordResetResponse,
|
||||
HardDeleteCheckResponse,
|
||||
AdminUserListItem,
|
||||
AdminUserListResponse,
|
||||
AdminAccountMember,
|
||||
AdminAccountListItem,
|
||||
AdminAccountListResponse,
|
||||
AdminAccountOwnerSummary,
|
||||
AdminAccountSubscriptionSummary,
|
||||
AdminAccountUsageSummary,
|
||||
AdminAccountDetailResponse,
|
||||
AdminAccountInviteSummary,
|
||||
AdminAccountCreate,
|
||||
AdminAccountUpdate,
|
||||
)
|
||||
from app.schemas.subscription import SubscriptionPlanUpdate, ExtendTrialRequest
|
||||
from app.schemas.user_detail import (
|
||||
UserDetailResponse, AccountSummary, SubscriptionSummary,
|
||||
SessionSummary, AuditLogSummary, InviteCodeUsedSummary,
|
||||
)
|
||||
from app.api.deps import require_admin
|
||||
from app.core.subscriptions import get_account_usage
|
||||
|
||||
router = APIRouter(prefix="/admin", tags=["admin"])
|
||||
|
||||
|
||||
@router.get("/users", response_model=list[UserResponse])
|
||||
@router.get("/users", response_model=AdminUserListResponse)
|
||||
async def list_users(
|
||||
db: Annotated[AsyncSession, Depends(get_admin_db)],
|
||||
current_user: Annotated[User, Depends(require_admin)],
|
||||
page: Optional[int] = Query(None, ge=1),
|
||||
size: Optional[int] = Query(None, ge=1, le=100),
|
||||
search: Optional[str] = Query(None, description="Search by user or account fields"),
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(100, ge=1, le=100),
|
||||
is_active: Optional[bool] = Query(None, description="Filter by active status"),
|
||||
@@ -46,23 +69,240 @@ async def list_users(
|
||||
account_id: Optional[UUID] = Query(None, description="Filter by account"),
|
||||
include_archived: bool = Query(False, description="Include archived (soft-deleted) users"),
|
||||
):
|
||||
"""List all users (super admin only)."""
|
||||
query = select(User)
|
||||
"""List users for super admin global people search."""
|
||||
resolved_limit = size or limit
|
||||
resolved_skip = skip
|
||||
current_page = 1
|
||||
|
||||
if page is not None:
|
||||
resolved_skip = (page - 1) * resolved_limit
|
||||
current_page = page
|
||||
elif resolved_limit > 0:
|
||||
current_page = (resolved_skip // resolved_limit) + 1
|
||||
|
||||
count_query = (
|
||||
select(func.count())
|
||||
.select_from(User)
|
||||
.outerjoin(Account, User.account_id == Account.id)
|
||||
)
|
||||
query = (
|
||||
select(
|
||||
User,
|
||||
Account.name.label("account_name"),
|
||||
Account.display_code.label("account_display_code"),
|
||||
)
|
||||
.outerjoin(Account, User.account_id == Account.id)
|
||||
)
|
||||
|
||||
if not include_archived:
|
||||
query = query.where(User.deleted_at.is_(None))
|
||||
count_query = count_query.where(User.deleted_at.is_(None))
|
||||
if is_active is not None:
|
||||
query = query.where(User.is_active == is_active)
|
||||
count_query = count_query.where(User.is_active == is_active)
|
||||
if role:
|
||||
query = query.where(User.role == role)
|
||||
count_query = count_query.where(User.role == role)
|
||||
if account_id:
|
||||
query = query.where(User.account_id == account_id)
|
||||
count_query = count_query.where(User.account_id == account_id)
|
||||
if search:
|
||||
search_term = f"%{search.strip()}%"
|
||||
search_filter = or_(
|
||||
User.name.ilike(search_term),
|
||||
User.email.ilike(search_term),
|
||||
Account.name.ilike(search_term),
|
||||
Account.display_code.ilike(search_term),
|
||||
)
|
||||
query = query.where(search_filter)
|
||||
count_query = count_query.where(search_filter)
|
||||
|
||||
query = query.order_by(User.created_at.desc()).offset(skip).limit(limit)
|
||||
total_result = await db.execute(count_query)
|
||||
total = total_result.scalar() or 0
|
||||
|
||||
query = query.order_by(User.created_at.desc()).offset(resolved_skip).limit(resolved_limit)
|
||||
result = await db.execute(query)
|
||||
users = result.scalars().all()
|
||||
return users
|
||||
rows = result.all()
|
||||
|
||||
items = [
|
||||
AdminUserListItem(
|
||||
id=user.id,
|
||||
email=user.email,
|
||||
name=user.name,
|
||||
role=user.role,
|
||||
is_super_admin=user.is_super_admin,
|
||||
is_active=user.is_active,
|
||||
account_id=user.account_id,
|
||||
account_role=user.account_role,
|
||||
account_name=account_name,
|
||||
account_display_code=account_display_code,
|
||||
created_at=user.created_at,
|
||||
last_login=user.last_login,
|
||||
deleted_at=user.deleted_at,
|
||||
)
|
||||
for user, account_name, account_display_code in rows
|
||||
]
|
||||
|
||||
return AdminUserListResponse(
|
||||
items=items,
|
||||
total=total,
|
||||
page=current_page,
|
||||
per_page=resolved_limit,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/accounts", response_model=AdminAccountListResponse)
|
||||
async def list_accounts(
|
||||
db: Annotated[AsyncSession, Depends(get_admin_db)],
|
||||
current_user: Annotated[User, Depends(require_admin)],
|
||||
page: int = Query(1, ge=1),
|
||||
size: int = Query(12, ge=1, le=100),
|
||||
search: Optional[str] = Query(None, description="Search by account, display code, or owner"),
|
||||
plan: Optional[str] = Query(None, description="Filter by subscription plan"),
|
||||
status: Optional[str] = Query(None, description="Filter by subscription status"),
|
||||
include_archived: bool = Query(False, description="Include archived users in account member lists"),
|
||||
):
|
||||
"""List accounts with embedded members for the admin panel."""
|
||||
owner_user = aliased(User)
|
||||
|
||||
count_query = (
|
||||
select(func.count(func.distinct(Account.id)))
|
||||
.select_from(Account)
|
||||
.outerjoin(owner_user, Account.owner_id == owner_user.id)
|
||||
.outerjoin(Subscription, Subscription.account_id == Account.id)
|
||||
)
|
||||
accounts_query = (
|
||||
select(
|
||||
Account,
|
||||
owner_user.id.label("owner_user_id"),
|
||||
owner_user.name.label("owner_name"),
|
||||
owner_user.email.label("owner_email"),
|
||||
Subscription.id.label("subscription_id"),
|
||||
Subscription.plan.label("subscription_plan"),
|
||||
Subscription.status.label("subscription_status"),
|
||||
Subscription.billing_interval.label("subscription_billing_interval"),
|
||||
Subscription.current_period_end.label("subscription_current_period_end"),
|
||||
Subscription.cancel_at_period_end.label("subscription_cancel_at_period_end"),
|
||||
)
|
||||
.outerjoin(owner_user, Account.owner_id == owner_user.id)
|
||||
.outerjoin(Subscription, Subscription.account_id == Account.id)
|
||||
)
|
||||
|
||||
if search:
|
||||
search_term = f"%{search.strip()}%"
|
||||
search_filter = or_(
|
||||
Account.name.ilike(search_term),
|
||||
Account.display_code.ilike(search_term),
|
||||
owner_user.name.ilike(search_term),
|
||||
owner_user.email.ilike(search_term),
|
||||
)
|
||||
count_query = count_query.where(search_filter)
|
||||
accounts_query = accounts_query.where(search_filter)
|
||||
if plan:
|
||||
count_query = count_query.where(Subscription.plan == plan)
|
||||
accounts_query = accounts_query.where(Subscription.plan == plan)
|
||||
if status:
|
||||
count_query = count_query.where(Subscription.status == status)
|
||||
accounts_query = accounts_query.where(Subscription.status == status)
|
||||
|
||||
total_result = await db.execute(count_query)
|
||||
total = total_result.scalar() or 0
|
||||
|
||||
accounts_result = await db.execute(
|
||||
accounts_query
|
||||
.order_by(Account.created_at.desc())
|
||||
.offset((page - 1) * size)
|
||||
.limit(size)
|
||||
)
|
||||
rows = accounts_result.all()
|
||||
accounts = [row.Account for row in rows]
|
||||
|
||||
account_ids = [account.id for account in accounts]
|
||||
members_by_account: dict[UUID, list[AdminAccountMember]] = {account_id: [] for account_id in account_ids}
|
||||
pending_invites_by_account: dict[UUID, int] = {account_id: 0 for account_id in account_ids}
|
||||
usage_by_account: dict[UUID, AdminAccountUsageSummary] = {}
|
||||
|
||||
if account_ids:
|
||||
members_query = select(User).where(User.account_id.in_(account_ids))
|
||||
if not include_archived:
|
||||
members_query = members_query.where(User.deleted_at.is_(None))
|
||||
members_query = members_query.order_by(User.created_at.asc())
|
||||
|
||||
members_result = await db.execute(members_query)
|
||||
for member in members_result.scalars().all():
|
||||
members_by_account.setdefault(member.account_id, []).append(
|
||||
AdminAccountMember(
|
||||
id=member.id,
|
||||
email=member.email,
|
||||
name=member.name,
|
||||
role=member.role,
|
||||
is_super_admin=member.is_super_admin,
|
||||
is_active=member.is_active,
|
||||
account_role=member.account_role,
|
||||
created_at=member.created_at,
|
||||
last_login=member.last_login,
|
||||
deleted_at=member.deleted_at,
|
||||
)
|
||||
)
|
||||
|
||||
pending_invites_result = await db.execute(
|
||||
select(AccountInvite.account_id, func.count(AccountInvite.id))
|
||||
.where(
|
||||
AccountInvite.account_id.in_(account_ids),
|
||||
AccountInvite.used_at.is_(None),
|
||||
)
|
||||
.group_by(AccountInvite.account_id)
|
||||
)
|
||||
pending_invites_by_account.update({row[0]: row[1] for row in pending_invites_result.all()})
|
||||
|
||||
for account_id in account_ids:
|
||||
usage = await get_account_usage(account_id, db)
|
||||
usage_by_account[account_id] = AdminAccountUsageSummary(
|
||||
tree_count=usage.get("tree_count", 0),
|
||||
session_count_this_month=usage.get("session_count_this_month", 0),
|
||||
)
|
||||
|
||||
items = [
|
||||
AdminAccountListItem(
|
||||
id=row.Account.id,
|
||||
name=row.Account.name,
|
||||
display_code=row.Account.display_code,
|
||||
created_at=row.Account.created_at,
|
||||
owner_id=row.Account.owner_id,
|
||||
owner=(
|
||||
AdminAccountOwnerSummary(
|
||||
id=row.owner_user_id,
|
||||
name=row.owner_name,
|
||||
email=row.owner_email,
|
||||
) if row.owner_user_id and row.owner_name and row.owner_email else None
|
||||
),
|
||||
subscription=(
|
||||
AdminAccountSubscriptionSummary(
|
||||
id=row.subscription_id,
|
||||
plan=row.subscription_plan,
|
||||
status=row.subscription_status,
|
||||
billing_interval=row.subscription_billing_interval,
|
||||
current_period_end=row.subscription_current_period_end,
|
||||
cancel_at_period_end=row.subscription_cancel_at_period_end or False,
|
||||
) if row.subscription_id and row.subscription_plan and row.subscription_status else None
|
||||
),
|
||||
usage=usage_by_account.get(row.Account.id, AdminAccountUsageSummary()),
|
||||
member_count=len(members_by_account.get(row.Account.id, [])),
|
||||
active_member_count=sum(1 for member in members_by_account.get(row.Account.id, []) if member.is_active),
|
||||
pending_invite_count=pending_invites_by_account.get(row.Account.id, 0),
|
||||
sso_enabled=row.Account.sso_enabled,
|
||||
branding_company_name=row.Account.branding_company_name,
|
||||
members=members_by_account.get(row.Account.id, []),
|
||||
)
|
||||
for row in rows
|
||||
]
|
||||
|
||||
return AdminAccountListResponse(
|
||||
items=items,
|
||||
total=total,
|
||||
page=page,
|
||||
per_page=size,
|
||||
)
|
||||
|
||||
|
||||
def _generate_display_code() -> str:
|
||||
@@ -71,6 +311,192 @@ def _generate_display_code() -> str:
|
||||
return ''.join(secrets.choice(chars) for _ in range(8))
|
||||
|
||||
|
||||
async def _generate_unique_display_code(db: AsyncSession) -> str:
|
||||
"""Generate a unique display code for a new account."""
|
||||
while True:
|
||||
display_code = _generate_display_code()
|
||||
existing = await db.execute(select(Account.id).where(Account.display_code == display_code))
|
||||
if existing.scalar_one_or_none() is None:
|
||||
return display_code
|
||||
|
||||
|
||||
async def _get_account_detail_payload(
|
||||
account_id: UUID,
|
||||
db: AsyncSession,
|
||||
include_archived: bool = False,
|
||||
) -> AdminAccountDetailResponse:
|
||||
owner_user = aliased(User)
|
||||
result = await db.execute(
|
||||
select(
|
||||
Account,
|
||||
owner_user.id.label("owner_user_id"),
|
||||
owner_user.name.label("owner_name"),
|
||||
owner_user.email.label("owner_email"),
|
||||
Subscription.id.label("subscription_id"),
|
||||
Subscription.plan.label("subscription_plan"),
|
||||
Subscription.status.label("subscription_status"),
|
||||
Subscription.billing_interval.label("subscription_billing_interval"),
|
||||
Subscription.current_period_end.label("subscription_current_period_end"),
|
||||
Subscription.cancel_at_period_end.label("subscription_cancel_at_period_end"),
|
||||
)
|
||||
.outerjoin(owner_user, Account.owner_id == owner_user.id)
|
||||
.outerjoin(Subscription, Subscription.account_id == Account.id)
|
||||
.where(Account.id == account_id)
|
||||
)
|
||||
row = result.one_or_none()
|
||||
if not row:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Account not found")
|
||||
|
||||
members_query = select(User).where(User.account_id == account_id).order_by(User.created_at.asc())
|
||||
if not include_archived:
|
||||
members_query = members_query.where(User.deleted_at.is_(None))
|
||||
members_result = await db.execute(members_query)
|
||||
members = [
|
||||
AdminAccountMember(
|
||||
id=member.id,
|
||||
email=member.email,
|
||||
name=member.name,
|
||||
role=member.role,
|
||||
is_super_admin=member.is_super_admin,
|
||||
is_active=member.is_active,
|
||||
account_role=member.account_role,
|
||||
created_at=member.created_at,
|
||||
last_login=member.last_login,
|
||||
deleted_at=member.deleted_at,
|
||||
)
|
||||
for member in members_result.scalars().all()
|
||||
]
|
||||
|
||||
invites_result = await db.execute(
|
||||
select(AccountInvite)
|
||||
.where(AccountInvite.account_id == account_id)
|
||||
.order_by(AccountInvite.created_at.desc())
|
||||
)
|
||||
invites = [
|
||||
AdminAccountInviteSummary(
|
||||
id=invite.id,
|
||||
email=invite.email,
|
||||
role=invite.role,
|
||||
expires_at=invite.expires_at,
|
||||
created_at=invite.created_at,
|
||||
used_at=invite.used_at,
|
||||
)
|
||||
for invite in invites_result.scalars().all()
|
||||
if invite.used_at is None
|
||||
]
|
||||
|
||||
usage = await get_account_usage(account_id, db)
|
||||
|
||||
return AdminAccountDetailResponse(
|
||||
id=row.Account.id,
|
||||
name=row.Account.name,
|
||||
display_code=row.Account.display_code,
|
||||
created_at=row.Account.created_at,
|
||||
owner_id=row.Account.owner_id,
|
||||
owner=(
|
||||
AdminAccountOwnerSummary(
|
||||
id=row.owner_user_id,
|
||||
name=row.owner_name,
|
||||
email=row.owner_email,
|
||||
) if row.owner_user_id and row.owner_name and row.owner_email else None
|
||||
),
|
||||
subscription=(
|
||||
AdminAccountSubscriptionSummary(
|
||||
id=row.subscription_id,
|
||||
plan=row.subscription_plan,
|
||||
status=row.subscription_status,
|
||||
billing_interval=row.subscription_billing_interval,
|
||||
current_period_end=row.subscription_current_period_end,
|
||||
cancel_at_period_end=row.subscription_cancel_at_period_end or False,
|
||||
) if row.subscription_id and row.subscription_plan and row.subscription_status else None
|
||||
),
|
||||
usage=AdminAccountUsageSummary(
|
||||
tree_count=usage.get("tree_count", 0),
|
||||
session_count_this_month=usage.get("session_count_this_month", 0),
|
||||
),
|
||||
member_count=len(members),
|
||||
active_member_count=sum(1 for member in members if member.is_active),
|
||||
pending_invite_count=len(invites),
|
||||
sso_enabled=row.Account.sso_enabled,
|
||||
branding_company_name=row.Account.branding_company_name,
|
||||
members=members,
|
||||
invites=invites,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/accounts", response_model=AdminAccountDetailResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_account(
|
||||
data: AdminAccountCreate,
|
||||
db: Annotated[AsyncSession, Depends(get_admin_db)],
|
||||
current_user: Annotated[User, Depends(require_admin)],
|
||||
):
|
||||
"""Create a new account without requiring an initial user."""
|
||||
owner_id = None
|
||||
if data.owner_email:
|
||||
result = await db.execute(select(User).where(User.email == data.owner_email.strip()))
|
||||
owner = result.scalar_one_or_none()
|
||||
if not owner:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"No user found with email '{data.owner_email}'")
|
||||
owner_id = owner.id
|
||||
|
||||
display_code = await _generate_unique_display_code(db)
|
||||
new_account = Account(
|
||||
name=data.name.strip(),
|
||||
display_code=display_code,
|
||||
owner_id=owner_id,
|
||||
)
|
||||
db.add(new_account)
|
||||
await db.flush()
|
||||
|
||||
new_subscription = Subscription(
|
||||
account_id=new_account.id,
|
||||
plan=data.plan,
|
||||
status="active",
|
||||
)
|
||||
db.add(new_subscription)
|
||||
|
||||
await log_audit(
|
||||
db, current_user.id, "account.create_admin", "account", new_account.id,
|
||||
{"name": new_account.name, "plan": data.plan, "owner_email": data.owner_email},
|
||||
)
|
||||
await db.commit()
|
||||
return await _get_account_detail_payload(new_account.id, db)
|
||||
|
||||
|
||||
@router.get("/accounts/{account_id}", response_model=AdminAccountDetailResponse)
|
||||
async def get_account_detail(
|
||||
account_id: UUID,
|
||||
db: Annotated[AsyncSession, Depends(get_admin_db)],
|
||||
current_user: Annotated[User, Depends(require_admin)],
|
||||
include_archived: bool = Query(False),
|
||||
):
|
||||
"""Get detailed account information for admin management."""
|
||||
return await _get_account_detail_payload(account_id, db, include_archived=include_archived)
|
||||
|
||||
|
||||
@router.put("/accounts/{account_id}", response_model=AdminAccountDetailResponse)
|
||||
async def update_account(
|
||||
account_id: UUID,
|
||||
data: AdminAccountUpdate,
|
||||
db: Annotated[AsyncSession, Depends(get_admin_db)],
|
||||
current_user: Annotated[User, Depends(require_admin)],
|
||||
):
|
||||
"""Update account settings from the admin panel."""
|
||||
result = await db.execute(select(Account).where(Account.id == account_id))
|
||||
account = result.scalar_one_or_none()
|
||||
if not account:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Account not found")
|
||||
|
||||
old_name = account.name
|
||||
account.name = data.name.strip()
|
||||
await log_audit(
|
||||
db, current_user.id, "account.update_admin", "account", account.id,
|
||||
{"old_name": old_name, "new_name": account.name},
|
||||
)
|
||||
await db.commit()
|
||||
return await _get_account_detail_payload(account.id, db)
|
||||
|
||||
|
||||
@router.post("/users", response_model=AdminUserCreateResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_user(
|
||||
data: AdminUserCreate,
|
||||
@@ -516,6 +942,28 @@ async def _get_user_subscription(user_id: UUID, db: AsyncSession) -> tuple[User,
|
||||
return user, subscription
|
||||
|
||||
|
||||
async def _get_account_subscription(account_id: UUID, db: AsyncSession) -> tuple[Account, Subscription]:
|
||||
"""Helper to load account and its subscription."""
|
||||
account_result = await db.execute(select(Account).where(Account.id == account_id))
|
||||
account = account_result.scalar_one_or_none()
|
||||
if not account:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Account not found")
|
||||
|
||||
sub_result = await db.execute(
|
||||
select(Subscription).where(Subscription.account_id == account.id)
|
||||
)
|
||||
subscription = sub_result.scalar_one_or_none()
|
||||
if not subscription:
|
||||
subscription = Subscription(
|
||||
account_id=account.id,
|
||||
plan="free",
|
||||
status="active",
|
||||
)
|
||||
db.add(subscription)
|
||||
await db.flush()
|
||||
return account, subscription
|
||||
|
||||
|
||||
@router.put("/users/{user_id}/subscription/plan")
|
||||
async def update_user_plan(
|
||||
user_id: UUID,
|
||||
@@ -535,6 +983,31 @@ async def update_user_plan(
|
||||
return {"plan": subscription.plan, "status": subscription.status}
|
||||
|
||||
|
||||
@router.put("/accounts/{account_id}/subscription/plan")
|
||||
async def update_account_plan(
|
||||
account_id: UUID,
|
||||
data: SubscriptionPlanUpdate,
|
||||
db: Annotated[AsyncSession, Depends(get_admin_db)],
|
||||
current_user: Annotated[User, Depends(require_admin)],
|
||||
):
|
||||
"""Change an account subscription plan (super admin only)."""
|
||||
if data.plan not in ("free", "pro", "team"):
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid plan")
|
||||
account, subscription = await _get_account_subscription(account_id, db)
|
||||
old_plan = subscription.plan
|
||||
subscription.plan = data.plan
|
||||
await log_audit(
|
||||
db,
|
||||
current_user.id,
|
||||
"subscription.plan_change",
|
||||
"subscription",
|
||||
subscription.id,
|
||||
{"old_plan": old_plan, "new_plan": data.plan, "account_id": str(account_id)},
|
||||
)
|
||||
await db.commit()
|
||||
return {"plan": subscription.plan, "status": subscription.status}
|
||||
|
||||
|
||||
@router.put("/users/{user_id}/subscription/extend-trial")
|
||||
async def extend_user_trial(
|
||||
user_id: UUID,
|
||||
@@ -565,6 +1038,43 @@ async def extend_user_trial(
|
||||
"current_period_end": subscription.current_period_end}
|
||||
|
||||
|
||||
@router.put("/accounts/{account_id}/subscription/extend-trial")
|
||||
async def extend_account_trial(
|
||||
account_id: UUID,
|
||||
data: ExtendTrialRequest,
|
||||
db: Annotated[AsyncSession, Depends(get_admin_db)],
|
||||
current_user: Annotated[User, Depends(require_admin)],
|
||||
):
|
||||
"""Extend or start a trial for an account subscription (super admin only)."""
|
||||
if data.days < 1 or data.days > 90:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Days must be 1-90")
|
||||
account, subscription = await _get_account_subscription(account_id, db)
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
if subscription.status == "trialing" and subscription.current_period_end:
|
||||
new_end = subscription.current_period_end + timedelta(days=data.days)
|
||||
else:
|
||||
subscription.status = "trialing"
|
||||
subscription.current_period_start = now
|
||||
new_end = now + timedelta(days=data.days)
|
||||
|
||||
subscription.current_period_end = new_end
|
||||
await log_audit(
|
||||
db,
|
||||
current_user.id,
|
||||
"subscription.extend_trial",
|
||||
"subscription",
|
||||
subscription.id,
|
||||
{"days": data.days, "new_end": new_end.isoformat(), "account_id": str(account.id)},
|
||||
)
|
||||
await db.commit()
|
||||
return {
|
||||
"plan": subscription.plan,
|
||||
"status": subscription.status,
|
||||
"current_period_end": subscription.current_period_end,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/users/{user_id}/password-reset", response_model=AdminPasswordResetResponse)
|
||||
async def admin_reset_password(
|
||||
user_id: UUID,
|
||||
|
||||
@@ -27,6 +27,7 @@ from app.schemas.psa_connection import (
|
||||
PsaMemberMappingSaveRequest,
|
||||
PsaMemberResponse,
|
||||
AutoMatchResult,
|
||||
PSABoardResponse,
|
||||
)
|
||||
from app.core.config import settings
|
||||
from app.services.psa.encryption import (
|
||||
@@ -345,26 +346,103 @@ async def update_flowpilot_settings(
|
||||
# ── ticket / status / company endpoints ──────────────────────────
|
||||
|
||||
|
||||
@router.get("/tickets/search", response_model=list[PSATicketSearchResult])
|
||||
async def search_tickets(
|
||||
@router.get("/boards", response_model=list[PSABoardResponse])
|
||||
async def list_boards(
|
||||
current_user: Annotated[User, Depends(require_engineer_or_admin)],
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
query: str = "",
|
||||
board_id: int | None = None,
|
||||
status_id: int | None = None,
|
||||
include_closed: bool = False,
|
||||
):
|
||||
"""Search ConnectWise tickets."""
|
||||
"""List PSA service boards."""
|
||||
if not current_user.account_id:
|
||||
raise HTTPException(status_code=400, detail="User has no account")
|
||||
|
||||
from app.services.psa.registry import get_provider_for_account
|
||||
from app.services.psa.exceptions import PSAError
|
||||
|
||||
try:
|
||||
provider = await get_provider_for_account(current_user.account_id, db)
|
||||
boards = await provider.list_boards()
|
||||
return [PSABoardResponse(id=b.id, name=b.name) for b in boards]
|
||||
except PSAError:
|
||||
# Boards are optional UI chrome — degrade gracefully rather than surfacing a toast
|
||||
return []
|
||||
|
||||
|
||||
@router.get("/tickets/search", response_model=list[PSATicketSearchResult])
|
||||
async def search_tickets(
|
||||
current_user: Annotated[User, Depends(require_engineer_or_admin)],
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
query: str = "",
|
||||
board_id: int | None = None,
|
||||
status_id: int | None = None,
|
||||
include_closed: bool = False,
|
||||
assigned_to_me: bool = False,
|
||||
unassigned: bool = False,
|
||||
board_ids: str = "",
|
||||
page: int = 1,
|
||||
page_size: int = 10,
|
||||
):
|
||||
"""Search ConnectWise tickets."""
|
||||
if not current_user.account_id:
|
||||
raise HTTPException(status_code=400, detail="User has no account")
|
||||
|
||||
from app.services.psa.registry import get_provider_for_account
|
||||
from app.services.psa.exceptions import PSAError
|
||||
|
||||
# Resolve assigned_to_me → member_identifier (CW login name for resources contains filter)
|
||||
member_identifier: str | None = None
|
||||
if assigned_to_me:
|
||||
conn_result = await db.execute(
|
||||
select(PsaConnection).where(
|
||||
PsaConnection.account_id == current_user.account_id,
|
||||
PsaConnection.is_active.is_(True),
|
||||
)
|
||||
)
|
||||
conn = conn_result.scalar_one_or_none()
|
||||
if conn:
|
||||
mapping_result = await db.execute(
|
||||
select(PsaMemberMapping).where(
|
||||
PsaMemberMapping.psa_connection_id == conn.id,
|
||||
PsaMemberMapping.user_id == current_user.id,
|
||||
)
|
||||
)
|
||||
mapping = mapping_result.scalar_one_or_none()
|
||||
if not mapping:
|
||||
# No mapping for this user — return empty list
|
||||
return []
|
||||
|
||||
from app.services.psa.registry import get_provider_for_account as _get_provider
|
||||
from app.services.psa.exceptions import PSAError as _PSAError
|
||||
try:
|
||||
_provider = await _get_provider(current_user.account_id, db)
|
||||
cw_members = await _provider.list_members()
|
||||
matched = next((m for m in cw_members if m.id == mapping.external_member_id), None)
|
||||
if matched:
|
||||
member_identifier = matched.identifier
|
||||
else:
|
||||
return []
|
||||
except _PSAError:
|
||||
return []
|
||||
|
||||
# Parse comma-separated board_ids
|
||||
parsed_board_ids: list[int] = []
|
||||
if board_ids:
|
||||
try:
|
||||
parsed_board_ids = [int(bid.strip()) for bid in board_ids.split(",") if bid.strip()]
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail="board_ids must be comma-separated integers")
|
||||
|
||||
try:
|
||||
provider = await get_provider_for_account(current_user.account_id, db)
|
||||
tickets = await provider.search_tickets(
|
||||
query, board_id=board_id, status_id=status_id, include_closed=include_closed
|
||||
query,
|
||||
board_id=board_id,
|
||||
status_id=status_id,
|
||||
include_closed=include_closed,
|
||||
member_identifier=member_identifier,
|
||||
unassigned=unassigned,
|
||||
board_ids=parsed_board_ids,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
)
|
||||
return [
|
||||
PSATicketSearchResult(
|
||||
@@ -517,31 +595,37 @@ async def get_member_mappings(
|
||||
current_user: Annotated[User, Depends(require_account_owner)],
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
):
|
||||
"""Get all member mappings for the account."""
|
||||
"""Get all account users with their PSA member mappings (unmapped users included)."""
|
||||
conn = await _get_account_connection(current_user.account_id, db)
|
||||
if not conn:
|
||||
return []
|
||||
|
||||
result = await db.execute(
|
||||
# Fetch all active account users
|
||||
users_result = await db.execute(
|
||||
select(User).where(User.account_id == current_user.account_id, User.is_active.is_(True))
|
||||
)
|
||||
users = users_result.scalars().all()
|
||||
|
||||
# Fetch all existing mappings keyed by user_id for O(1) lookup
|
||||
mappings_result = await db.execute(
|
||||
select(PsaMemberMapping).where(PsaMemberMapping.psa_connection_id == conn.id)
|
||||
)
|
||||
mappings = result.scalars().all()
|
||||
mapping_by_user: dict[str, PsaMemberMapping] = {
|
||||
str(m.user_id): m for m in mappings_result.scalars().all()
|
||||
}
|
||||
|
||||
response = []
|
||||
for m in mappings:
|
||||
user_result = await db.execute(select(User).where(User.id == m.user_id))
|
||||
user = user_result.scalar_one_or_none()
|
||||
if user:
|
||||
response.append(PsaMemberMappingResponse(
|
||||
id=str(m.id),
|
||||
user_id=str(m.user_id),
|
||||
user_email=user.email,
|
||||
user_name=user.name,
|
||||
external_member_id=m.external_member_id,
|
||||
external_member_name=m.external_member_name,
|
||||
matched_by=m.matched_by,
|
||||
))
|
||||
return response
|
||||
return [
|
||||
PsaMemberMappingResponse(
|
||||
id=str(m.id) if (m := mapping_by_user.get(str(user.id))) else None,
|
||||
user_id=str(user.id),
|
||||
user_email=user.email,
|
||||
user_name=user.name,
|
||||
external_member_id=m.external_member_id if m else None,
|
||||
external_member_name=m.external_member_name if m else None,
|
||||
matched_by=m.matched_by if m else None,
|
||||
)
|
||||
for user in users
|
||||
]
|
||||
|
||||
|
||||
@router.post("/member-mappings", response_model=list[PsaMemberMappingResponse])
|
||||
@@ -564,6 +648,7 @@ async def save_member_mappings(
|
||||
for m in mappings:
|
||||
mapping = PsaMemberMapping(
|
||||
psa_connection_id=conn.id,
|
||||
account_id=current_user.account_id,
|
||||
user_id=UUID(m.user_id),
|
||||
external_member_id=m.external_member_id,
|
||||
external_member_name=m.external_member_name,
|
||||
@@ -624,6 +709,7 @@ async def auto_match_members(
|
||||
if not existing.scalar_one_or_none():
|
||||
mapping = PsaMemberMapping(
|
||||
psa_connection_id=conn.id,
|
||||
account_id=current_user.account_id,
|
||||
user_id=user.id,
|
||||
external_member_id=cw_member.id,
|
||||
external_member_name=cw_member.name,
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
"""Network diagrams API endpoints."""
|
||||
import base64
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from typing import Annotated
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import select, or_
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
@@ -27,7 +29,7 @@ from app.schemas.network_diagram import (
|
||||
DiagramNode,
|
||||
DiagramEdge,
|
||||
)
|
||||
from app.services import network_diagram_ai_service
|
||||
from app.services import network_diagram_ai_service, storage_service
|
||||
|
||||
# Maps system device-type slugs to their category — mirrors frontend deviceRegistry.ts
|
||||
_SLUG_CATEGORY: dict[str, str] = {
|
||||
@@ -83,6 +85,7 @@ def _diagram_to_list_item(
|
||||
description=diagram.description,
|
||||
node_count=len(nodes),
|
||||
category_counts=category_counts,
|
||||
thumbnail_url=diagram.thumbnail_url,
|
||||
created_by=diagram.created_by,
|
||||
created_at=diagram.created_at,
|
||||
updated_at=diagram.updated_at,
|
||||
@@ -305,6 +308,34 @@ async def import_diagram(
|
||||
)
|
||||
|
||||
|
||||
class ThumbnailUploadRequest(BaseModel):
|
||||
data_url: str # base64 PNG data URL: "data:image/png;base64,..."
|
||||
|
||||
|
||||
@router.post("/{diagram_id}/thumbnail", status_code=204)
|
||||
async def upload_thumbnail(
|
||||
diagram_id: UUID,
|
||||
body: ThumbnailUploadRequest,
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||
) -> None:
|
||||
diagram = await _get_diagram_or_404(diagram_id, current_user.account_id, db)
|
||||
try:
|
||||
header, encoded = body.data_url.split(",", 1)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=422, detail="Invalid data URL format")
|
||||
image_bytes = base64.b64decode(encoded)
|
||||
storage_key = await storage_service.upload_file(
|
||||
file_data=image_bytes,
|
||||
filename=f"thumbnail-{diagram_id}.png",
|
||||
content_type="image/png",
|
||||
account_id=str(current_user.account_id),
|
||||
)
|
||||
presigned_url = storage_service.get_presigned_url(storage_key)
|
||||
diagram.thumbnail_url = presigned_url
|
||||
await db.commit()
|
||||
|
||||
|
||||
@router.post("/ai-generate", response_model=AIGenerateResponse)
|
||||
async def ai_generate_diagram(
|
||||
data: AIGenerateRequest,
|
||||
|
||||
@@ -199,7 +199,10 @@ async def generate_fixes(
|
||||
|
||||
try:
|
||||
text, in_tok, out_tok = await provider.generate_json(
|
||||
system_prompt=FIX_SYSTEM_PROMPT,
|
||||
system_prompt=[
|
||||
{"type": "text", "text": FIX_SYSTEM_PROMPT},
|
||||
# cacheable: stable constant across all fix attempts
|
||||
],
|
||||
messages=messages,
|
||||
max_tokens=2048,
|
||||
)
|
||||
@@ -232,7 +235,11 @@ async def generate_fixes(
|
||||
|
||||
try:
|
||||
text2, in_tok2, out_tok2 = await provider.generate_json(
|
||||
system_prompt=FIX_SYSTEM_PROMPT,
|
||||
system_prompt=[
|
||||
{"type": "text", "text": FIX_SYSTEM_PROMPT},
|
||||
# cacheable: stable constant; retry reads the cached
|
||||
# system block from the first attempt above
|
||||
],
|
||||
messages=messages,
|
||||
max_tokens=2048,
|
||||
)
|
||||
|
||||
@@ -3,16 +3,169 @@ AI Provider abstraction layer.
|
||||
|
||||
Supports Gemini (google-genai) and Anthropic (anthropic) as interchangeable
|
||||
backends for JSON generation used by the AI Flow Builder.
|
||||
|
||||
## Prompt caching (Anthropic only)
|
||||
|
||||
Callers may pass `system_prompt` as either:
|
||||
|
||||
- `str` — backward-compatible, uncached.
|
||||
- `list[SystemBlock]` — Anthropic structured system blocks. Each block is a
|
||||
dict of shape `{"type": "text", "text": str, "cache_control": {...}?}`.
|
||||
|
||||
Caching policy (policy α, per Phase 0.1 design):
|
||||
- If any block in the list carries an explicit `cache_control` key, that
|
||||
caller-authored configuration is honored verbatim.
|
||||
- If no block carries `cache_control`, the provider applies
|
||||
`cache_control: {"type": "ephemeral"}` to the first block only. First block
|
||||
is the common "large static prefix" case (e.g. system prompt, reference data).
|
||||
|
||||
Gemini ignores cache_control and concatenates list blocks into one system
|
||||
string — callers should not rely on Gemini for cache-hit behavior.
|
||||
|
||||
TODO(phase0-verify): When a dev environment is available, verify cache-hit
|
||||
behavior by hitting any FlowPilot endpoint twice within the 5-minute
|
||||
ephemeral TTL. First call should emit `anthropic.cache` with
|
||||
`cache_creation_input_tokens > 0`; second call with `cache_read_input_tokens > 0`.
|
||||
If the second call returns zero reads, inspect the prefix for silent
|
||||
invalidators (timestamps, unsorted JSON keys, varying tool list ordering).
|
||||
"""
|
||||
|
||||
import logging
|
||||
from abc import ABC, abstractmethod
|
||||
from collections.abc import AsyncIterator
|
||||
from typing import Any
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Anthropic structured system block. See module docstring for caching policy.
|
||||
SystemBlock = dict[str, Any]
|
||||
|
||||
|
||||
def _normalize_system_for_anthropic(
|
||||
system_prompt: str | list[SystemBlock],
|
||||
) -> str | list[SystemBlock]:
|
||||
"""Return the value to pass as the `system=` parameter to the Anthropic API.
|
||||
|
||||
- Plain strings pass through untouched (uncached path).
|
||||
- Lists are returned as structured system blocks. If no block in the list
|
||||
carries an explicit `cache_control`, `cache_control: {"type": "ephemeral"}`
|
||||
is applied to the FIRST block only (policy α).
|
||||
- Caller-authored `cache_control` is never overwritten.
|
||||
"""
|
||||
if isinstance(system_prompt, str):
|
||||
return system_prompt
|
||||
|
||||
if not system_prompt:
|
||||
# Empty list is not a meaningful system prompt — pass empty string so
|
||||
# Anthropic treats this as "no system prompt" rather than erroring.
|
||||
return ""
|
||||
|
||||
blocks = [dict(b) for b in system_prompt]
|
||||
already_cached = any("cache_control" in b for b in blocks)
|
||||
|
||||
if not already_cached:
|
||||
blocks[0]["cache_control"] = {"type": "ephemeral"}
|
||||
|
||||
return blocks
|
||||
|
||||
|
||||
def _flatten_system_for_gemini(
|
||||
system_prompt: str | list[SystemBlock],
|
||||
) -> str:
|
||||
"""Gemini has no structured system blocks; concatenate list entries."""
|
||||
if isinstance(system_prompt, str):
|
||||
return system_prompt
|
||||
return "\n\n".join(b.get("text", "") for b in system_prompt)
|
||||
|
||||
|
||||
def build_anthropic_chat_messages(
|
||||
history: list[dict[str, Any]],
|
||||
new_message: str,
|
||||
images: list[dict[str, Any]] | None = None,
|
||||
format_reminder: str | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Construct the Anthropic `messages` payload for a cached multi-turn chat.
|
||||
|
||||
Responsibilities:
|
||||
- Copy the valid history messages in order.
|
||||
- Apply `cache_control: ephemeral` to the LAST history message so the entire
|
||||
conversation prefix is cached across turns. The new user message stays
|
||||
uncached (it changes each turn).
|
||||
- Append `format_reminder` to the new user message if provided. The reminder
|
||||
is invisible to storage (caller's concern) but helps enforce structured
|
||||
output compliance at generation time.
|
||||
- If `images` are provided, render the new user message as a multimodal
|
||||
content block list (images first, then text). Otherwise, render it as
|
||||
a plain string.
|
||||
|
||||
This helper is Anthropic-specific: the cache-breakpoint pattern, ephemeral
|
||||
cache_control, and multimodal block shape are all Anthropic conventions.
|
||||
Do not call it from Gemini code paths.
|
||||
"""
|
||||
messages: list[dict[str, Any]] = []
|
||||
for msg in history:
|
||||
messages.append({"role": msg["role"], "content": msg["content"]})
|
||||
|
||||
# Cache breakpoint on the last existing history message so the entire
|
||||
# conversation prefix is cached across turns. Safe only when there IS a
|
||||
# history message; otherwise the new message is the only message.
|
||||
if messages:
|
||||
last = messages[-1]
|
||||
messages[-1] = {
|
||||
"role": last["role"],
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": last["content"],
|
||||
"cache_control": {"type": "ephemeral"},
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
effective_text = new_message + (format_reminder or "")
|
||||
|
||||
if images:
|
||||
content_blocks: list[dict[str, Any]] = []
|
||||
for img in images:
|
||||
content_blocks.append(
|
||||
{
|
||||
"type": "image",
|
||||
"source": {
|
||||
"type": "base64",
|
||||
"media_type": img["media_type"],
|
||||
"data": img["data"],
|
||||
},
|
||||
}
|
||||
)
|
||||
content_blocks.append({"type": "text", "text": effective_text})
|
||||
messages.append({"role": "user", "content": content_blocks})
|
||||
else:
|
||||
messages.append({"role": "user", "content": effective_text})
|
||||
|
||||
return messages
|
||||
|
||||
|
||||
def _log_anthropic_cache_usage(usage: Any, model: str) -> None:
|
||||
"""Emit a structured log line capturing cache_read / cache_creation tokens."""
|
||||
cache_read = getattr(usage, "cache_read_input_tokens", 0) or 0
|
||||
cache_creation = getattr(usage, "cache_creation_input_tokens", 0) or 0
|
||||
input_tokens = getattr(usage, "input_tokens", 0) or 0
|
||||
output_tokens = getattr(usage, "output_tokens", 0) or 0
|
||||
if cache_read or cache_creation:
|
||||
logger.info(
|
||||
"anthropic.cache",
|
||||
extra={
|
||||
"event": "anthropic.cache",
|
||||
"model": model,
|
||||
"cache_read_input_tokens": cache_read,
|
||||
"cache_creation_input_tokens": cache_creation,
|
||||
"input_tokens": input_tokens,
|
||||
"output_tokens": output_tokens,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class AIProvider(ABC):
|
||||
"""Abstract base class for AI providers."""
|
||||
@@ -20,14 +173,16 @@ class AIProvider(ABC):
|
||||
@abstractmethod
|
||||
async def generate_json(
|
||||
self,
|
||||
system_prompt: str,
|
||||
messages: list[dict[str, str]],
|
||||
system_prompt: str | list[SystemBlock],
|
||||
messages: list[dict[str, Any]],
|
||||
max_tokens: int = 4096,
|
||||
) -> tuple[str, int, int]:
|
||||
"""Generate a JSON response from the AI model.
|
||||
|
||||
Args:
|
||||
system_prompt: System-level instruction for the model.
|
||||
system_prompt: System-level instruction. Plain `str` is uncached
|
||||
(Anthropic) or used as-is (Gemini). `list[SystemBlock]` enables
|
||||
Anthropic prompt caching per module-docstring policy.
|
||||
messages: List of message dicts with "role" and "content" keys.
|
||||
max_tokens: Maximum output tokens.
|
||||
|
||||
@@ -39,37 +194,25 @@ class AIProvider(ABC):
|
||||
@abstractmethod
|
||||
async def generate_text(
|
||||
self,
|
||||
system_prompt: str,
|
||||
messages: list[dict[str, str]],
|
||||
system_prompt: str | list[SystemBlock],
|
||||
messages: list[dict[str, Any]],
|
||||
max_tokens: int = 4096,
|
||||
) -> tuple[str, int, int]:
|
||||
"""Generate a text response from the AI model (no JSON constraint).
|
||||
|
||||
Args:
|
||||
system_prompt: System-level instruction for the model.
|
||||
messages: List of message dicts with "role" and "content" keys.
|
||||
max_tokens: Maximum output tokens.
|
||||
|
||||
Returns:
|
||||
Tuple of (response_text, input_tokens, output_tokens).
|
||||
See `generate_json` for argument semantics.
|
||||
"""
|
||||
...
|
||||
|
||||
async def generate_text_stream(
|
||||
self,
|
||||
system_prompt: str,
|
||||
messages: list[dict[str, str]],
|
||||
system_prompt: str | list[SystemBlock],
|
||||
messages: list[dict[str, Any]],
|
||||
max_tokens: int = 4096,
|
||||
) -> "AsyncIterator[str]":
|
||||
"""Stream a text response token by token.
|
||||
|
||||
Args:
|
||||
system_prompt: System-level instruction for the model.
|
||||
messages: List of message dicts with "role" and "content" keys.
|
||||
max_tokens: Maximum output tokens.
|
||||
|
||||
Yields:
|
||||
Text chunks as they are generated.
|
||||
See `generate_json` for argument semantics.
|
||||
"""
|
||||
raise NotImplementedError("Streaming not supported for this provider")
|
||||
# Make this an async generator to satisfy type checker
|
||||
@@ -85,14 +228,15 @@ class GeminiProvider(AIProvider):
|
||||
|
||||
async def generate_json(
|
||||
self,
|
||||
system_prompt: str,
|
||||
messages: list[dict[str, str]],
|
||||
system_prompt: str | list[SystemBlock],
|
||||
messages: list[dict[str, Any]],
|
||||
max_tokens: int = 4096,
|
||||
) -> tuple[str, int, int]:
|
||||
from google import genai
|
||||
from google.genai import types as genai_types
|
||||
|
||||
client = genai.Client(api_key=self._api_key)
|
||||
system_text = _flatten_system_for_gemini(system_prompt)
|
||||
|
||||
# Convert messages to Gemini Content format
|
||||
contents: list[genai_types.Content] = []
|
||||
@@ -106,7 +250,7 @@ class GeminiProvider(AIProvider):
|
||||
)
|
||||
|
||||
config = genai_types.GenerateContentConfig(
|
||||
system_instruction=system_prompt,
|
||||
system_instruction=system_text,
|
||||
max_output_tokens=max_tokens,
|
||||
response_mime_type="application/json",
|
||||
)
|
||||
@@ -137,14 +281,15 @@ class GeminiProvider(AIProvider):
|
||||
|
||||
async def generate_text(
|
||||
self,
|
||||
system_prompt: str,
|
||||
messages: list[dict[str, str]],
|
||||
system_prompt: str | list[SystemBlock],
|
||||
messages: list[dict[str, Any]],
|
||||
max_tokens: int = 4096,
|
||||
) -> tuple[str, int, int]:
|
||||
from google import genai
|
||||
from google.genai import types as genai_types
|
||||
|
||||
client = genai.Client(api_key=self._api_key)
|
||||
system_text = _flatten_system_for_gemini(system_prompt)
|
||||
|
||||
contents: list[genai_types.Content] = []
|
||||
for msg in messages:
|
||||
@@ -157,7 +302,7 @@ class GeminiProvider(AIProvider):
|
||||
)
|
||||
|
||||
config = genai_types.GenerateContentConfig(
|
||||
system_instruction=system_prompt,
|
||||
system_instruction=system_text,
|
||||
max_output_tokens=max_tokens,
|
||||
# No response_mime_type — allow free-form text
|
||||
)
|
||||
@@ -214,16 +359,17 @@ class AnthropicProvider(AIProvider):
|
||||
|
||||
async def generate_json(
|
||||
self,
|
||||
system_prompt: str,
|
||||
messages: list[dict[str, str]],
|
||||
system_prompt: str | list[SystemBlock],
|
||||
messages: list[dict[str, Any]],
|
||||
max_tokens: int = 4096,
|
||||
) -> tuple[str, int, int]:
|
||||
client = _get_anthropic_client(self._api_key, self._timeout)
|
||||
normalized_system = _normalize_system_for_anthropic(system_prompt)
|
||||
|
||||
response = await client.messages.create(
|
||||
model=self._model,
|
||||
max_tokens=max_tokens,
|
||||
system=system_prompt,
|
||||
system=normalized_system,
|
||||
messages=messages,
|
||||
)
|
||||
|
||||
@@ -231,12 +377,14 @@ class AnthropicProvider(AIProvider):
|
||||
input_tokens = response.usage.input_tokens
|
||||
output_tokens = response.usage.output_tokens
|
||||
|
||||
_log_anthropic_cache_usage(response.usage, self._model)
|
||||
|
||||
return text, input_tokens, output_tokens
|
||||
|
||||
async def generate_text(
|
||||
self,
|
||||
system_prompt: str,
|
||||
messages: list[dict[str, str]],
|
||||
system_prompt: str | list[SystemBlock],
|
||||
messages: list[dict[str, Any]],
|
||||
max_tokens: int = 4096,
|
||||
) -> tuple[str, int, int]:
|
||||
# Anthropic doesn't differentiate between JSON and text mode
|
||||
@@ -244,20 +392,28 @@ class AnthropicProvider(AIProvider):
|
||||
|
||||
async def generate_text_stream(
|
||||
self,
|
||||
system_prompt: str,
|
||||
messages: list[dict[str, str]],
|
||||
system_prompt: str | list[SystemBlock],
|
||||
messages: list[dict[str, Any]],
|
||||
max_tokens: int = 4096,
|
||||
) -> AsyncIterator[str]:
|
||||
client = _get_anthropic_client(self._api_key, self._timeout)
|
||||
normalized_system = _normalize_system_for_anthropic(system_prompt)
|
||||
|
||||
async with client.messages.stream(
|
||||
model=self._model,
|
||||
max_tokens=max_tokens,
|
||||
system=system_prompt,
|
||||
system=normalized_system,
|
||||
messages=messages,
|
||||
) as stream:
|
||||
async for text in stream.text_stream:
|
||||
yield text
|
||||
# Per Anthropic SDK, get_final_message() resolves the stream's
|
||||
# final usage object (including cache_read/cache_creation tokens).
|
||||
try:
|
||||
final = await stream.get_final_message()
|
||||
_log_anthropic_cache_usage(final.usage, self._model)
|
||||
except Exception as exc: # best-effort telemetry, never fail the stream
|
||||
logger.debug("anthropic.cache streaming usage unavailable: %s", exc)
|
||||
|
||||
|
||||
def get_ai_provider(model: str | None = None) -> AIProvider:
|
||||
|
||||
@@ -146,7 +146,10 @@ async def scaffold_branches(
|
||||
user_message += f"Environment: {', '.join(tags)}\n"
|
||||
|
||||
raw_text, input_tokens, output_tokens = await provider.generate_json(
|
||||
system_prompt=SCAFFOLD_SYSTEM_PROMPT,
|
||||
system_prompt=[
|
||||
{"type": "text", "text": SCAFFOLD_SYSTEM_PROMPT},
|
||||
# cacheable: stable constant across all scaffold calls
|
||||
],
|
||||
messages=[{"role": "user", "content": user_message}],
|
||||
max_tokens=2048,
|
||||
)
|
||||
@@ -207,7 +210,13 @@ async def generate_branch_detail(
|
||||
|
||||
for attempt in range(3):
|
||||
raw_text, input_tokens, output_tokens = await provider.generate_json(
|
||||
system_prompt=BRANCH_DETAIL_SYSTEM_PROMPT,
|
||||
system_prompt=[
|
||||
{"type": "text", "text": BRANCH_DETAIL_SYSTEM_PROMPT},
|
||||
# cacheable: stable constant. Retries in this loop re-read the
|
||||
# cached system block rather than paying full input cost each
|
||||
# attempt — the ~2.5k-token prompt with few-shot example is
|
||||
# the dominant cost here.
|
||||
],
|
||||
messages=messages,
|
||||
max_tokens=8192,
|
||||
)
|
||||
|
||||
@@ -425,7 +425,12 @@ async def convert_document(
|
||||
|
||||
try:
|
||||
raw_text, input_tokens, output_tokens = await provider.generate_json(
|
||||
system_prompt=system_prompt,
|
||||
system_prompt=[
|
||||
{"type": "text", "text": system_prompt},
|
||||
# cacheable: one of two stable constants (TROUBLESHOOTING_SYSTEM_PROMPT
|
||||
# or PROCEDURAL_SYSTEM_PROMPT) selected by target_type. Each
|
||||
# variant caches independently by text content.
|
||||
],
|
||||
messages=[{"role": "user", "content": user_message}],
|
||||
max_tokens=16384,
|
||||
)
|
||||
|
||||
@@ -58,6 +58,10 @@ from .template_tree import TemplateTree
|
||||
from .platform_step import PlatformStep
|
||||
from .device_type import DeviceType
|
||||
from .network_diagram import NetworkDiagram
|
||||
from .session_fact import SessionFact
|
||||
from .session_suggested_fix import SessionSuggestedFix
|
||||
from .draft_template import DraftTemplate
|
||||
from .account_settings import AccountSettings
|
||||
|
||||
__all__ = [
|
||||
"User",
|
||||
@@ -130,4 +134,8 @@ __all__ = [
|
||||
"PlatformStep",
|
||||
"DeviceType",
|
||||
"NetworkDiagram",
|
||||
"SessionFact",
|
||||
"SessionSuggestedFix",
|
||||
"DraftTemplate",
|
||||
"AccountSettings",
|
||||
]
|
||||
|
||||
99
backend/app/models/account_settings.py
Normal file
99
backend/app/models/account_settings.py
Normal file
@@ -0,0 +1,99 @@
|
||||
"""Per-account settings with a JSONB preferences grab-bag.
|
||||
|
||||
Rows are created lazily on first write. Reads of a missing row return the
|
||||
caller-supplied default — no upfront row creation per account.
|
||||
|
||||
Settings live in `preferences` until they meet the promotion criteria in
|
||||
Section 4.6 of FLOWPILOT-MIGRATION.md (hot path / validation / joins), at
|
||||
which point a future migration adds a typed column and the helpers prefer it.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, TYPE_CHECKING
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, text
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from sqlalchemy.dialects.postgresql import UUID, JSONB, insert as pg_insert
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.sql import select
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.models.account import Account
|
||||
|
||||
|
||||
class AccountSettings(Base):
|
||||
"""One row per account. Created lazily on first `set_setting` call."""
|
||||
__tablename__ = "account_settings"
|
||||
|
||||
account_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("accounts.id", ondelete="CASCADE"),
|
||||
primary_key=True,
|
||||
)
|
||||
preferences: Mapped[dict[str, Any]] = mapped_column(
|
||||
JSONB, nullable=False, default=dict, server_default=text("'{}'::jsonb")
|
||||
)
|
||||
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),
|
||||
)
|
||||
|
||||
account: Mapped["Account"] = relationship("Account", foreign_keys=[account_id])
|
||||
|
||||
@classmethod
|
||||
async def get_setting(
|
||||
cls,
|
||||
db: AsyncSession,
|
||||
account_id: uuid.UUID,
|
||||
key: str,
|
||||
default: Any = None,
|
||||
) -> Any:
|
||||
"""Return preferences[key] for the account, or `default` if no row/no key.
|
||||
|
||||
Never creates a row — this is the pure-read path.
|
||||
"""
|
||||
result = await db.execute(
|
||||
select(cls.preferences).where(cls.account_id == account_id)
|
||||
)
|
||||
prefs = result.scalar_one_or_none()
|
||||
if prefs is None:
|
||||
return default
|
||||
return prefs.get(key, default)
|
||||
|
||||
@classmethod
|
||||
async def set_setting(
|
||||
cls,
|
||||
db: AsyncSession,
|
||||
account_id: uuid.UUID,
|
||||
key: str,
|
||||
value: Any,
|
||||
) -> None:
|
||||
"""Upsert preferences[key] = value for the account.
|
||||
|
||||
Creates the row on first write; on subsequent writes, merges the key
|
||||
into the existing preferences JSON without clobbering other keys.
|
||||
Uses PostgreSQL's `||` jsonb merge operator via ON CONFLICT DO UPDATE.
|
||||
"""
|
||||
stmt = pg_insert(cls).values(
|
||||
account_id=account_id,
|
||||
preferences={key: value},
|
||||
)
|
||||
stmt = stmt.on_conflict_do_update(
|
||||
index_elements=[cls.account_id],
|
||||
set_={
|
||||
# Merge the new {key: value} into the existing preferences.
|
||||
# The `||` operator on jsonb overwrites matching keys and keeps
|
||||
# all other keys intact.
|
||||
"preferences": cls.preferences.op("||")(stmt.excluded.preferences),
|
||||
"updated_at": text("now()"),
|
||||
},
|
||||
)
|
||||
await db.execute(stmt)
|
||||
@@ -214,6 +214,38 @@ class AISession(Base):
|
||||
comment="Current task lane state: {questions: [...], actions: [...]}",
|
||||
)
|
||||
|
||||
# ── Resolution / Escalation artifacts (Phase 1 — FlowPilot migration) ──
|
||||
# Markdown of the posted note + PSA external ID for round-trip traceability.
|
||||
resolution_note_markdown: Mapped[Optional[str]] = mapped_column(
|
||||
Text, nullable=True,
|
||||
comment="Final Resolve note markdown, as posted to the PSA",
|
||||
)
|
||||
resolution_note_posted_at: Mapped[Optional[datetime]] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True,
|
||||
)
|
||||
resolution_note_external_id: Mapped[Optional[str]] = mapped_column(
|
||||
String(128), nullable=True,
|
||||
comment="PSA (e.g. CW) ticket-note ID returned at post time",
|
||||
)
|
||||
escalation_package_markdown: Mapped[Optional[str]] = mapped_column(
|
||||
Text, nullable=True,
|
||||
comment="Final Escalate handoff package markdown, as posted to the PSA",
|
||||
)
|
||||
escalation_package_posted_at: Mapped[Optional[datetime]] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True,
|
||||
)
|
||||
escalation_package_external_id: Mapped[Optional[str]] = mapped_column(
|
||||
String(128), nullable=True,
|
||||
comment="PSA ticket-note ID for the escalation package",
|
||||
)
|
||||
# Incremented atomically by any write that invalidates the resolution
|
||||
# note preview cache (facts, suggested fixes, script generations).
|
||||
# See FLOWPILOT-MIGRATION.md Section 5.5.
|
||||
state_version: Mapped[int] = mapped_column(
|
||||
Integer, nullable=False, default=0, server_default=sa.text("0"),
|
||||
comment="Monotonic preview-cache version; bumped on state-changing writes",
|
||||
)
|
||||
|
||||
# ── Branching ──
|
||||
is_branching: Mapped[bool] = mapped_column(
|
||||
default=False,
|
||||
|
||||
91
backend/app/models/draft_template.py
Normal file
91
backend/app/models/draft_template.py
Normal file
@@ -0,0 +1,91 @@
|
||||
"""Draft template model — scripts generated during a session, pending templatization.
|
||||
|
||||
Created when an engineer picks "Run now, templatize after resolve" in the
|
||||
three-option dialog. Post-resolve, the TemplatizePrompt component reads pending
|
||||
drafts and lets the engineer accept (promotes to `script_templates`) or reject.
|
||||
"""
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, TYPE_CHECKING
|
||||
|
||||
from sqlalchemy import (
|
||||
Text, DateTime, ForeignKey, String, CheckConstraint,
|
||||
)
|
||||
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.account import Account
|
||||
from app.models.ai_session import AISession
|
||||
from app.models.user import User
|
||||
from app.models.script_template import ScriptCategory, ScriptTemplate
|
||||
|
||||
|
||||
class DraftTemplate(Base):
|
||||
"""A session-generated script pending conversion to a reusable template."""
|
||||
__tablename__ = "draft_templates"
|
||||
__table_args__ = (
|
||||
CheckConstraint(
|
||||
"status IN ('pending', 'accepted', 'rejected')",
|
||||
name="ck_draft_templates_status",
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||
)
|
||||
account_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("accounts.id"),
|
||||
nullable=False,
|
||||
)
|
||||
source_session_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("ai_sessions.id"),
|
||||
nullable=False,
|
||||
)
|
||||
source_user_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("users.id"),
|
||||
nullable=False,
|
||||
)
|
||||
script_body: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
proposed_parameters: Mapped[dict[str, Any]] = mapped_column(
|
||||
JSONB, nullable=False
|
||||
)
|
||||
proposed_name: Mapped[str | None] = mapped_column(String(200), nullable=True)
|
||||
proposed_category_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("script_categories.id"),
|
||||
nullable=True,
|
||||
)
|
||||
status: Mapped[str] = mapped_column(
|
||||
String(32), nullable=False, default="pending"
|
||||
)
|
||||
resolved_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
# Set when status transitions to 'accepted' and the draft is promoted
|
||||
# to a real script_templates row.
|
||||
promoted_template_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("script_templates.id"),
|
||||
nullable=True,
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
|
||||
)
|
||||
|
||||
account: Mapped["Account"] = relationship("Account", foreign_keys=[account_id])
|
||||
source_session: Mapped["AISession"] = relationship(
|
||||
"AISession", foreign_keys=[source_session_id]
|
||||
)
|
||||
source_user: Mapped["User"] = relationship("User", foreign_keys=[source_user_id])
|
||||
proposed_category: Mapped["ScriptCategory | None"] = relationship(
|
||||
"ScriptCategory", foreign_keys=[proposed_category_id]
|
||||
)
|
||||
promoted_template: Mapped["ScriptTemplate | None"] = relationship(
|
||||
"ScriptTemplate", foreign_keys=[promoted_template_id]
|
||||
)
|
||||
@@ -78,6 +78,20 @@ class ScriptTemplate(Base):
|
||||
is_gallery_featured: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default=text("false"), index=True)
|
||||
gallery_sort_order: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default=text("0"))
|
||||
usage_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default=text("0"))
|
||||
# ── Provenance (Phase 1 — FlowPilot migration) ──
|
||||
# Populated when a template is promoted from a post-resolve draft_templates row.
|
||||
# Powers the Script Library provenance chip:
|
||||
# "generated from CW #X · resolved by Y · used N times"
|
||||
source_session_id: Mapped[Optional[uuid.UUID]] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("ai_sessions.id"), nullable=True,
|
||||
)
|
||||
source_user_id: Mapped[Optional[uuid.UUID]] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("users.id"), nullable=True,
|
||||
)
|
||||
source_ticket_ref: Mapped[Optional[str]] = mapped_column(
|
||||
String(64), nullable=True,
|
||||
comment="Human-readable PSA ticket ref for display, e.g. 'CW #48307'",
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
|
||||
)
|
||||
|
||||
79
backend/app/models/session_fact.py
Normal file
79
backend/app/models/session_fact.py
Normal file
@@ -0,0 +1,79 @@
|
||||
"""Session fact model — the "What we know" backing store for a FlowPilot session.
|
||||
|
||||
A fact is an atomic, engineer-readable statement of what has been confirmed
|
||||
during troubleshooting. Facts accumulate across the session and drive the
|
||||
resolution note preview.
|
||||
|
||||
`source_ref` is a polymorphic pointer to a task-lane item inside
|
||||
`ai_sessions.pending_task_lane` JSON — it is NOT a FK. Integrity is enforced
|
||||
at the service layer per the FLOWPILOT-MIGRATION design doc Section 4.2.
|
||||
Phase 2 assigns stable UUIDs to those task-lane items so `source_ref` has
|
||||
something reliable to point to.
|
||||
"""
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from sqlalchemy import Text, DateTime, ForeignKey, String, CheckConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.models.ai_session import AISession
|
||||
from app.models.account import Account
|
||||
from app.models.user import User
|
||||
|
||||
|
||||
class SessionFact(Base):
|
||||
"""A single fact in the What-we-know section of a session's task lane."""
|
||||
__tablename__ = "session_facts"
|
||||
__table_args__ = (
|
||||
CheckConstraint(
|
||||
"source_type IN ('question', 'diagnostic_check', 'user_note', 'ai_synthesis')",
|
||||
name="ck_session_facts_source_type",
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||
)
|
||||
session_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("ai_sessions.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
)
|
||||
account_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("accounts.id"),
|
||||
nullable=False,
|
||||
)
|
||||
text: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
source_type: Mapped[str] = mapped_column(String(32), nullable=False)
|
||||
# Pointer to a task-lane item UUID inside ai_sessions.pending_task_lane.
|
||||
# NOT a FK. Null for `user_note` and `ai_synthesis` sources.
|
||||
source_ref: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True), nullable=True
|
||||
)
|
||||
source_summary: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
created_by: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("users.id"),
|
||||
nullable=False,
|
||||
)
|
||||
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),
|
||||
)
|
||||
deleted_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
|
||||
session: Mapped["AISession"] = relationship("AISession", foreign_keys=[session_id])
|
||||
account: Mapped["Account"] = relationship("Account", foreign_keys=[account_id])
|
||||
creator: Mapped["User"] = relationship("User", foreign_keys=[created_by])
|
||||
80
backend/app/models/session_suggested_fix.py
Normal file
80
backend/app/models/session_suggested_fix.py
Normal file
@@ -0,0 +1,80 @@
|
||||
"""Session suggested-fix model — AI-proposed resolution path for a session.
|
||||
|
||||
A session can have multiple suggested fixes over its lifetime as the AI's
|
||||
understanding evolves. Only one is active at a time (superseded_at IS NULL);
|
||||
emitting a new [SUGGEST_FIX] marker supersedes the prior active one.
|
||||
"""
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, TYPE_CHECKING
|
||||
|
||||
from sqlalchemy import (
|
||||
Text, DateTime, ForeignKey, String, Integer, CheckConstraint,
|
||||
)
|
||||
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.ai_session import AISession
|
||||
from app.models.account import Account
|
||||
from app.models.script_template import ScriptTemplate
|
||||
|
||||
|
||||
class SessionSuggestedFix(Base):
|
||||
"""One AI-proposed fix for a FlowPilot session."""
|
||||
__tablename__ = "session_suggested_fixes"
|
||||
__table_args__ = (
|
||||
CheckConstraint(
|
||||
"confidence_pct BETWEEN 0 AND 100",
|
||||
name="ck_session_suggested_fixes_confidence_pct",
|
||||
),
|
||||
CheckConstraint(
|
||||
"user_decision IS NULL OR user_decision IN ("
|
||||
"'one_off', 'draft_template', 'build_template', 'dismissed')",
|
||||
name="ck_session_suggested_fixes_user_decision",
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||
)
|
||||
session_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("ai_sessions.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
)
|
||||
account_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("accounts.id"),
|
||||
nullable=False,
|
||||
)
|
||||
title: Mapped[str] = mapped_column(String(200), nullable=False)
|
||||
description: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
confidence_pct: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
script_template_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("script_templates.id"),
|
||||
nullable=True,
|
||||
)
|
||||
# Populated only when there's no matching template and the AI has
|
||||
# drafted a session-specific script.
|
||||
ai_drafted_script: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
ai_drafted_parameters: Mapped[dict[str, Any] | None] = mapped_column(
|
||||
JSONB, nullable=True
|
||||
)
|
||||
user_decision: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
||||
# Set when a newer suggested fix supersedes this one.
|
||||
superseded_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
|
||||
)
|
||||
|
||||
session: Mapped["AISession"] = relationship("AISession", foreign_keys=[session_id])
|
||||
account: Mapped["Account"] = relationship("Account", foreign_keys=[account_id])
|
||||
script_template: Mapped["ScriptTemplate | None"] = relationship(
|
||||
"ScriptTemplate", foreign_keys=[script_template_id]
|
||||
)
|
||||
@@ -20,6 +20,7 @@ from .psa_connection import (
|
||||
PSATicketSearchResult, PSATicketStatusItem,
|
||||
PsaPostRequest, PsaPostResponse, PsaPreviewResponse, PsaPostLogResponse,
|
||||
PsaMemberMappingResponse, PsaMemberMappingSaveRequest, PsaMemberResponse, AutoMatchResult,
|
||||
PSABoardResponse,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
@@ -50,4 +51,5 @@ __all__ = [
|
||||
"PSATicketSearchResult", "PSATicketStatusItem",
|
||||
"PsaPostRequest", "PsaPostResponse", "PsaPreviewResponse", "PsaPostLogResponse",
|
||||
"PsaMemberMappingResponse", "PsaMemberMappingSaveRequest", "PsaMemberResponse", "AutoMatchResult",
|
||||
"PSABoardResponse",
|
||||
]
|
||||
|
||||
@@ -28,6 +28,111 @@ class ActivityEntry(BaseModel):
|
||||
from_attributes = True
|
||||
|
||||
|
||||
# --- Admin Accounts & People Search ---
|
||||
|
||||
class AdminUserListItem(BaseModel):
|
||||
id: UUID
|
||||
email: EmailStr
|
||||
name: str
|
||||
role: str
|
||||
is_super_admin: bool = False
|
||||
is_active: bool = True
|
||||
account_id: Optional[UUID] = None
|
||||
account_role: Optional[str] = None
|
||||
account_name: Optional[str] = None
|
||||
account_display_code: Optional[str] = None
|
||||
created_at: datetime
|
||||
last_login: Optional[datetime] = None
|
||||
deleted_at: Optional[datetime] = None
|
||||
|
||||
|
||||
class AdminUserListResponse(BaseModel):
|
||||
items: list[AdminUserListItem]
|
||||
total: int
|
||||
page: int
|
||||
per_page: int
|
||||
|
||||
|
||||
class AdminAccountMember(BaseModel):
|
||||
id: UUID
|
||||
email: EmailStr
|
||||
name: str
|
||||
role: str
|
||||
is_super_admin: bool = False
|
||||
is_active: bool = True
|
||||
account_role: Optional[str] = None
|
||||
created_at: datetime
|
||||
last_login: Optional[datetime] = None
|
||||
deleted_at: Optional[datetime] = None
|
||||
|
||||
|
||||
class AdminAccountOwnerSummary(BaseModel):
|
||||
id: UUID
|
||||
name: str
|
||||
email: EmailStr
|
||||
|
||||
|
||||
class AdminAccountSubscriptionSummary(BaseModel):
|
||||
id: UUID
|
||||
plan: str
|
||||
status: str
|
||||
billing_interval: Optional[str] = None
|
||||
current_period_end: Optional[datetime] = None
|
||||
cancel_at_period_end: bool = False
|
||||
|
||||
|
||||
class AdminAccountUsageSummary(BaseModel):
|
||||
tree_count: int = 0
|
||||
session_count_this_month: int = 0
|
||||
|
||||
|
||||
class AdminAccountInviteSummary(BaseModel):
|
||||
id: UUID
|
||||
email: EmailStr
|
||||
role: str
|
||||
expires_at: Optional[datetime] = None
|
||||
created_at: datetime
|
||||
used_at: Optional[datetime] = None
|
||||
|
||||
|
||||
class AdminAccountListItem(BaseModel):
|
||||
id: UUID
|
||||
name: str
|
||||
display_code: str
|
||||
created_at: datetime
|
||||
owner_id: Optional[UUID] = None
|
||||
owner: Optional[AdminAccountOwnerSummary] = None
|
||||
subscription: Optional[AdminAccountSubscriptionSummary] = None
|
||||
usage: AdminAccountUsageSummary = Field(default_factory=AdminAccountUsageSummary)
|
||||
member_count: int = 0
|
||||
active_member_count: int = 0
|
||||
pending_invite_count: int = 0
|
||||
sso_enabled: bool = False
|
||||
branding_company_name: Optional[str] = None
|
||||
members: list[AdminAccountMember] = Field(default_factory=list)
|
||||
|
||||
|
||||
class AdminAccountListResponse(BaseModel):
|
||||
items: list[AdminAccountListItem]
|
||||
total: int
|
||||
page: int
|
||||
per_page: int
|
||||
|
||||
|
||||
class AdminAccountDetailResponse(AdminAccountListItem):
|
||||
invites: list[AdminAccountInviteSummary] = Field(default_factory=list)
|
||||
|
||||
|
||||
class AdminAccountCreate(BaseModel):
|
||||
name: str = Field(..., min_length=1, max_length=255)
|
||||
plan: Literal["free", "pro", "team"] = "free"
|
||||
owner_email: Optional[EmailStr] = Field(None, description="Email of an existing user to set as owner")
|
||||
|
||||
|
||||
class AdminAccountUpdate(BaseModel):
|
||||
name: str = Field(..., min_length=1, max_length=255)
|
||||
|
||||
|
||||
# --- Audit Logs ---
|
||||
|
||||
class AuditLogEntry(BaseModel):
|
||||
@@ -215,7 +320,7 @@ class AdminUserCreate(BaseModel):
|
||||
name: str = Field(..., min_length=1, max_length=255)
|
||||
account_mode: Literal["existing", "personal"]
|
||||
account_display_code: Optional[str] = Field(None, description="Required when account_mode='existing'")
|
||||
account_role: Optional[Literal["engineer", "viewer"]] = Field(None, description="Required when account_mode='existing'")
|
||||
account_role: Optional[Literal["owner", "admin", "engineer", "viewer"]] = Field(None, description="Required when account_mode='existing'")
|
||||
send_email: bool = True
|
||||
|
||||
|
||||
|
||||
@@ -22,12 +22,20 @@ class DeviceProperties(BaseModel):
|
||||
status: str = Field(default="unknown", pattern=r"^(unknown|online|offline|degraded)$")
|
||||
|
||||
|
||||
class NodeStyle(BaseModel):
|
||||
width: float | None = None
|
||||
height: float | None = None
|
||||
|
||||
|
||||
class DiagramNode(BaseModel):
|
||||
id: str
|
||||
type: str
|
||||
label: str
|
||||
position: Position
|
||||
properties: DeviceProperties = Field(default_factory=DeviceProperties)
|
||||
nodeType: str | None = None
|
||||
style: NodeStyle | None = None
|
||||
parentId: str | None = None
|
||||
|
||||
|
||||
class DiagramEdge(BaseModel):
|
||||
@@ -84,6 +92,7 @@ class NetworkDiagramListItem(BaseModel):
|
||||
description: str | None = None
|
||||
node_count: int = 0
|
||||
category_counts: dict[str, int] = Field(default_factory=dict)
|
||||
thumbnail_url: str | None = None
|
||||
created_by: UUID | None = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
@@ -111,13 +111,13 @@ class PsaPostLogResponse(BaseModel):
|
||||
|
||||
|
||||
class PsaMemberMappingResponse(BaseModel):
|
||||
id: str
|
||||
id: str | None = None # None for users without a mapping
|
||||
user_id: str
|
||||
user_email: str
|
||||
user_name: str
|
||||
external_member_id: str
|
||||
external_member_name: str
|
||||
matched_by: str
|
||||
external_member_id: str | None = None
|
||||
external_member_name: str | None = None
|
||||
matched_by: str | None = None
|
||||
|
||||
|
||||
class PsaMemberMappingSaveRequest(BaseModel):
|
||||
@@ -136,3 +136,8 @@ class PsaMemberResponse(BaseModel):
|
||||
class AutoMatchResult(BaseModel):
|
||||
matched: list[PsaMemberMappingResponse]
|
||||
unmatched_users: int
|
||||
|
||||
|
||||
class PSABoardResponse(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
|
||||
@@ -68,4 +68,4 @@ class RoleUpdate(BaseModel):
|
||||
|
||||
|
||||
class AccountRoleUpdate(BaseModel):
|
||||
account_role: str = Field(..., pattern="^(engineer|viewer)$")
|
||||
account_role: str = Field(..., pattern="^(owner|admin|engineer|viewer)$")
|
||||
|
||||
@@ -10,10 +10,32 @@ Uses Anthropic prompt caching to reduce cost on multi-turn conversations:
|
||||
|
||||
Optionally connects to Microsoft Learn via Anthropic's MCP connector
|
||||
for real-time documentation lookups (controlled by ENABLE_MCP_MICROSOFT_LEARN).
|
||||
|
||||
## Architectural note — this module is the one MCP/beta chat caller
|
||||
|
||||
`chat_call_cached` below is the ONLY caller in the codebase that uses
|
||||
Anthropic's `client.beta.messages.create` endpoint, MCP servers, multimodal
|
||||
user messages, and the retry-without-MCP fallback. It is deliberately NOT
|
||||
routed through `AnthropicProvider` — MCP/beta/images are features of exactly
|
||||
one optional Anthropic beta endpoint and do not belong in a provider-agnostic
|
||||
abstraction that also serves Gemini.
|
||||
|
||||
If a new caller needs the same (MCP, beta, images, history caching) bundle,
|
||||
call `chat_call_cached` directly rather than pushing those concerns into
|
||||
`AnthropicProvider`. Cached-system-block plumbing is shared with the provider
|
||||
via `_normalize_system_for_anthropic` / `build_anthropic_chat_messages` /
|
||||
`_log_anthropic_cache_usage` in `app.core.ai_provider` — cache primitives are
|
||||
reusable, but the MCP/beta orchestration stays here.
|
||||
"""
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from app.core.ai_provider import (
|
||||
_get_anthropic_client,
|
||||
_log_anthropic_cache_usage,
|
||||
_normalize_system_for_anthropic,
|
||||
build_anthropic_chat_messages,
|
||||
)
|
||||
from app.core.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -184,7 +206,7 @@ async def _call_ai(
|
||||
to include alongside the new_message as vision content.
|
||||
"""
|
||||
if settings.AI_PROVIDER == "anthropic" and settings.ANTHROPIC_API_KEY:
|
||||
return await _call_anthropic_cached(
|
||||
return await chat_call_cached(
|
||||
system_base, rag_context, history, new_message, max_tokens,
|
||||
images=images,
|
||||
)
|
||||
@@ -202,7 +224,18 @@ async def _call_ai(
|
||||
)
|
||||
|
||||
|
||||
async def _call_anthropic_cached(
|
||||
# Appended to every chat turn's user message immediately before generation.
|
||||
# Invisible to storage (unified_chat_service strips markers before persisting),
|
||||
# but critical for structured output compliance — the model emits invalid
|
||||
# responses often enough without it that removing this reminder regresses UX.
|
||||
_CHAT_FORMAT_REMINDER = (
|
||||
"\n\n[SYSTEM: Remember — your response MUST end with [QUESTIONS] "
|
||||
"and/or [ACTIONS] markers containing valid JSON arrays. "
|
||||
"Responses without markers break the UI.]"
|
||||
)
|
||||
|
||||
|
||||
async def chat_call_cached(
|
||||
system_base: str,
|
||||
rag_context: str,
|
||||
history: list[dict[str, Any]],
|
||||
@@ -210,79 +243,56 @@ async def _call_anthropic_cached(
|
||||
max_tokens: int,
|
||||
images: list[dict[str, Any]] | None = None,
|
||||
) -> tuple[str, int, int]:
|
||||
"""Call Anthropic with prompt caching on system prompt and history.
|
||||
"""Call Anthropic's chat surface with caching, MCP, images, and retry-without-MCP.
|
||||
|
||||
Uses structured system blocks so the static base prompt is cached
|
||||
independently from the per-query RAG context. Optionally connects
|
||||
to Microsoft Learn via MCP for real-time documentation lookups.
|
||||
This is the ONE MCP/beta/multimodal chat caller. It is deliberately NOT
|
||||
routed through `AnthropicProvider`. See module docstring for rationale.
|
||||
|
||||
Responsibilities unique to this function (not in the provider):
|
||||
- Anthropic beta endpoint (`client.beta.messages.create`)
|
||||
- Microsoft Learn MCP connector wiring (optional via ENABLE_MCP_MICROSOFT_LEARN)
|
||||
- Retry-without-MCP fallback when the MCP server misbehaves
|
||||
- Multimodal image blocks in the user message
|
||||
- Format-reminder append for structured-output compliance
|
||||
- Telemetry (`mcp.turn`, `mcp.fallback`) for Phase 0.5 MCP usage signal
|
||||
|
||||
Cache plumbing is shared with the provider via helpers in `ai_provider`:
|
||||
`_normalize_system_for_anthropic` (policy α — ephemeral on first block if
|
||||
none specified), `build_anthropic_chat_messages` (history cache breakpoint +
|
||||
multimodal user message + format reminder), `_log_anthropic_cache_usage`.
|
||||
"""
|
||||
import anthropic
|
||||
|
||||
client = anthropic.AsyncAnthropic(
|
||||
api_key=settings.ANTHROPIC_API_KEY,
|
||||
client = _get_anthropic_client(
|
||||
settings.ANTHROPIC_API_KEY,
|
||||
timeout=settings.AI_REQUEST_TIMEOUT_SECONDS,
|
||||
)
|
||||
|
||||
# System prompt as structured blocks:
|
||||
# Block 1: static base prompt (cached)
|
||||
# Block 2: RAG context (changes per query, not cached)
|
||||
# System prompt as structured blocks. The static base is cacheable; the
|
||||
# RAG context changes per query and must NOT be cached — so we mark the
|
||||
# base explicitly and leave the RAG block unmarked. `_normalize_system`
|
||||
# honors caller-authored cache_control verbatim (policy α).
|
||||
system_blocks: list[dict[str, Any]] = [
|
||||
{
|
||||
"type": "text",
|
||||
"text": system_base,
|
||||
"cache_control": {"type": "ephemeral"},
|
||||
# cacheable: static system prompt, stable across all turns of all sessions
|
||||
},
|
||||
]
|
||||
if rag_context:
|
||||
system_blocks.append({"type": "text", "text": rag_context})
|
||||
system_blocks.append(
|
||||
{"type": "text", "text": rag_context}
|
||||
# uncached: RAG retrieval varies per query
|
||||
)
|
||||
normalized_system = _normalize_system_for_anthropic(system_blocks)
|
||||
|
||||
# Build messages with cache breakpoint on conversation history
|
||||
messages: list[dict[str, Any]] = []
|
||||
for msg in history:
|
||||
messages.append({"role": msg["role"], "content": msg["content"]})
|
||||
|
||||
# Place cache breakpoint on the last history message so the entire
|
||||
# conversation prefix is cached across turns
|
||||
if messages:
|
||||
last = messages[-1]
|
||||
messages[-1] = {
|
||||
"role": last["role"],
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": last["content"],
|
||||
"cache_control": {"type": "ephemeral"},
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
# Add the new user message (uncached — it's new each turn)
|
||||
# Append a format reminder to the user message so the model sees it
|
||||
# immediately before generating. This is invisible to the user (stripped
|
||||
# before storage) but critical for structured output compliance.
|
||||
format_reminder = (
|
||||
"\n\n[SYSTEM: Remember — your response MUST end with [QUESTIONS] "
|
||||
"and/or [ACTIONS] markers containing valid JSON arrays. "
|
||||
"Responses without markers break the UI.]"
|
||||
messages = build_anthropic_chat_messages(
|
||||
history=history,
|
||||
new_message=new_message,
|
||||
images=images,
|
||||
format_reminder=_CHAT_FORMAT_REMINDER,
|
||||
)
|
||||
reminded_message = new_message + format_reminder
|
||||
|
||||
# If images are attached, build multimodal content blocks
|
||||
if images:
|
||||
content_blocks: list[dict[str, Any]] = []
|
||||
for img in images:
|
||||
content_blocks.append({
|
||||
"type": "image",
|
||||
"source": {
|
||||
"type": "base64",
|
||||
"media_type": img["media_type"],
|
||||
"data": img["data"],
|
||||
},
|
||||
})
|
||||
content_blocks.append({"type": "text", "text": reminded_message})
|
||||
messages.append({"role": "user", "content": content_blocks})
|
||||
else:
|
||||
messages.append({"role": "user", "content": reminded_message})
|
||||
|
||||
# MCP server config (optional — controlled by settings)
|
||||
mcp_servers = anthropic.NOT_GIVEN
|
||||
@@ -304,12 +314,13 @@ async def _call_anthropic_cached(
|
||||
]
|
||||
|
||||
_mcp_active = mcp_servers is not anthropic.NOT_GIVEN
|
||||
_mcp_fallback_triggered = False
|
||||
|
||||
try:
|
||||
response = await client.beta.messages.create(
|
||||
model=settings.AI_MODEL_ANTHROPIC,
|
||||
max_tokens=max_tokens,
|
||||
system=system_blocks,
|
||||
system=normalized_system,
|
||||
messages=messages,
|
||||
mcp_servers=mcp_servers,
|
||||
tools=tools,
|
||||
@@ -326,14 +337,24 @@ async def _call_anthropic_cached(
|
||||
or isinstance(e, (anthropic.BadRequestError, anthropic.APIStatusError))
|
||||
)
|
||||
if _is_mcp_error:
|
||||
_mcp_fallback_triggered = True
|
||||
logger.warning(
|
||||
"MCP server error (%s), retrying without MCP: %s",
|
||||
type(e).__name__, e,
|
||||
)
|
||||
# Phase 0.5 telemetry: per-turn fallback event.
|
||||
logger.info(
|
||||
"mcp.fallback",
|
||||
extra={
|
||||
"event": "mcp.fallback",
|
||||
"mcp_error_type": type(e).__name__,
|
||||
"mcp_error_message": str(e)[:500],
|
||||
},
|
||||
)
|
||||
response = await client.messages.create(
|
||||
model=settings.AI_MODEL_ANTHROPIC,
|
||||
max_tokens=max_tokens,
|
||||
system=system_blocks,
|
||||
system=normalized_system,
|
||||
messages=messages,
|
||||
)
|
||||
else:
|
||||
@@ -355,18 +376,27 @@ async def _call_anthropic_cached(
|
||||
input_tokens = usage.input_tokens
|
||||
output_tokens = usage.output_tokens
|
||||
|
||||
# Log MCP tool usage
|
||||
# Phase 0.5 telemetry: per-turn MCP event. Emitted for every turn that
|
||||
# reached this code path (i.e., AI_PROVIDER=anthropic chat). `mcp_available`
|
||||
# reflects whether MCP was actually wired into the request (scope (ii) from
|
||||
# the Phase 0.5 design — Anthropic code path AND flag on). `mcp_invoked`
|
||||
# reflects whether the model chose to call an MCP tool on this turn.
|
||||
logger.info(
|
||||
"mcp.turn",
|
||||
extra={
|
||||
"event": "mcp.turn",
|
||||
"mcp_available": _mcp_active,
|
||||
"mcp_invoked": bool(mcp_tools_used),
|
||||
"mcp_tools": mcp_tools_used,
|
||||
"mcp_fallback_triggered": _mcp_fallback_triggered,
|
||||
},
|
||||
)
|
||||
|
||||
# Human-readable log retained for grep-based inspection.
|
||||
if mcp_tools_used:
|
||||
logger.info("MCP tools used: %s", ", ".join(mcp_tools_used))
|
||||
|
||||
# Log cache performance
|
||||
cache_read = getattr(usage, "cache_read_input_tokens", 0) or 0
|
||||
cache_creation = getattr(usage, "cache_creation_input_tokens", 0) or 0
|
||||
if cache_read or cache_creation:
|
||||
logger.info(
|
||||
"Anthropic cache: read=%d creation=%d input=%d output=%d",
|
||||
cache_read, cache_creation, input_tokens, output_tokens,
|
||||
)
|
||||
_log_anthropic_cache_usage(usage, settings.AI_MODEL_ANTHROPIC)
|
||||
|
||||
return text, input_tokens, output_tokens
|
||||
|
||||
|
||||
@@ -330,6 +330,7 @@ async def start_session(
|
||||
# 7. Create first step
|
||||
step = _create_step_from_parsed(
|
||||
session_id=session.id,
|
||||
account_id=session.account_id,
|
||||
step_order=0,
|
||||
parsed=parsed,
|
||||
input_tokens=input_tokens,
|
||||
@@ -433,6 +434,7 @@ async def process_response(
|
||||
# Create new step
|
||||
step = _create_step_from_parsed(
|
||||
session_id=session.id,
|
||||
account_id=session.account_id,
|
||||
step_order=session.step_count - 1,
|
||||
parsed=parsed,
|
||||
input_tokens=input_tokens,
|
||||
@@ -694,6 +696,7 @@ async def pickup_session(
|
||||
briefing_step = AISessionStep(
|
||||
id=uuid.uuid4(),
|
||||
session_id=session.id,
|
||||
account_id=session.account_id,
|
||||
branch_id=session.active_branch_id if session.is_branching else None,
|
||||
step_order=session.step_count,
|
||||
step_type="action",
|
||||
@@ -765,6 +768,7 @@ async def pickup_session(
|
||||
|
||||
next_step = _create_step_from_parsed(
|
||||
session_id=session.id,
|
||||
account_id=session.account_id,
|
||||
step_order=session.step_count - 1,
|
||||
parsed=parsed,
|
||||
input_tokens=input_tokens,
|
||||
@@ -997,6 +1001,7 @@ async def generate_status_update(
|
||||
step = AISessionStep(
|
||||
id=uuid.uuid4(),
|
||||
session_id=session.id,
|
||||
account_id=session.account_id,
|
||||
branch_id=session.active_branch_id if session.is_branching else None,
|
||||
step_order=session.step_count,
|
||||
step_type="status_update",
|
||||
@@ -1440,6 +1445,7 @@ def _format_engineer_response(request: StepResponseRequest) -> str:
|
||||
|
||||
def _create_step_from_parsed(
|
||||
session_id: UUID,
|
||||
account_id: UUID,
|
||||
step_order: int,
|
||||
parsed: dict[str, Any],
|
||||
input_tokens: int,
|
||||
@@ -1487,6 +1493,7 @@ def _create_step_from_parsed(
|
||||
return AISessionStep(
|
||||
id=uuid.uuid4(),
|
||||
session_id=session_id,
|
||||
account_id=account_id,
|
||||
branch_id=branch_id,
|
||||
step_order=step_order,
|
||||
step_type=step_type if parsed["type"] != "resolution_suggestion" else "action",
|
||||
|
||||
@@ -11,6 +11,7 @@ from app.services.psa.types import (
|
||||
PSAMember,
|
||||
PSAConfiguration,
|
||||
PSATimeEntry,
|
||||
PSABoard,
|
||||
)
|
||||
|
||||
|
||||
@@ -58,6 +59,9 @@ class AutotaskProvider(PSAProvider):
|
||||
async def list_members(self) -> list[PSAMember]:
|
||||
raise NotImplementedError("Autotask integration coming soon")
|
||||
|
||||
async def list_boards(self) -> list[PSABoard]:
|
||||
raise NotImplementedError("list_boards not implemented for this provider")
|
||||
|
||||
async def get_ticket_configurations(self, ticket_id: str) -> list[PSAConfiguration]:
|
||||
raise NotImplementedError("Autotask integration coming soon")
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ from .types import (
|
||||
PSAMember,
|
||||
PSAConfiguration,
|
||||
PSATimeEntry,
|
||||
PSABoard,
|
||||
)
|
||||
|
||||
|
||||
@@ -64,6 +65,10 @@ class PSAProvider(ABC):
|
||||
async def list_members(self) -> list[PSAMember]:
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
async def list_boards(self) -> list[PSABoard]:
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
async def get_ticket_configurations(self, ticket_id: str) -> list[PSAConfiguration]:
|
||||
...
|
||||
|
||||
@@ -16,6 +16,7 @@ from app.services.psa.types import (
|
||||
PSAMember,
|
||||
PSAConfiguration,
|
||||
PSATimeEntry,
|
||||
PSABoard,
|
||||
)
|
||||
from .client import ConnectWiseClient
|
||||
|
||||
@@ -55,11 +56,16 @@ class ConnectWiseProvider(PSAProvider):
|
||||
return self._map_ticket(data)
|
||||
|
||||
async def search_tickets(self, query: str, **filters) -> list[PSATicket]:
|
||||
"""Search CW tickets by summary. Supports board_id and status_id filters."""
|
||||
"""Search CW tickets by summary. Supports board_id, status_id, member_id,
|
||||
unassigned, board_ids, page, and page_size filters."""
|
||||
page_size = filters.get("page_size", 10)
|
||||
page = filters.get("page", 1)
|
||||
|
||||
params: dict = {
|
||||
"fields": "id,summary,company,board,status,priority,closedFlag",
|
||||
"orderBy": "id desc",
|
||||
"pageSize": 25,
|
||||
"pageSize": page_size,
|
||||
"page": page,
|
||||
}
|
||||
|
||||
# Build CW condition query
|
||||
@@ -72,6 +78,14 @@ class ConnectWiseProvider(PSAProvider):
|
||||
conditions.append(f"status/id = {filters['status_id']}")
|
||||
if not filters.get("include_closed", False):
|
||||
conditions.append("closedFlag = false")
|
||||
if filters.get("member_identifier") is not None:
|
||||
conditions.append(f"resources contains '{filters['member_identifier']}'")
|
||||
if filters.get("unassigned", False):
|
||||
conditions.append("resources = null")
|
||||
board_ids: list[int] = filters.get("board_ids") or []
|
||||
if board_ids:
|
||||
board_list = ", ".join(str(bid) for bid in board_ids)
|
||||
conditions.append(f"board/id in ({board_list})")
|
||||
|
||||
if conditions:
|
||||
params["conditions"] = " and ".join(conditions)
|
||||
@@ -270,6 +284,32 @@ class ConnectWiseProvider(PSAProvider):
|
||||
psa_cache.set(cache_key, result, ttl_seconds=900)
|
||||
return result
|
||||
|
||||
async def list_boards(self) -> list[PSABoard]:
|
||||
"""List active CW service boards (cached 1 hour)."""
|
||||
cache_key = "boards"
|
||||
cached = psa_cache.get(cache_key)
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
data = await self.client.get(
|
||||
"/service/boards",
|
||||
params={
|
||||
"fields": "id,name,inactiveFlag",
|
||||
"conditions": "inactiveFlag = false",
|
||||
"pageSize": 100,
|
||||
},
|
||||
)
|
||||
result = [
|
||||
PSABoard(
|
||||
id=b["id"],
|
||||
name=b["name"],
|
||||
inactive=b.get("inactiveFlag", False),
|
||||
)
|
||||
for b in (data if isinstance(data, list) else [])
|
||||
]
|
||||
psa_cache.set(cache_key, result, ttl_seconds=3600)
|
||||
return result
|
||||
|
||||
# ── Ticket Context ────────────────────────────────────────────────
|
||||
|
||||
async def get_ticket_context(
|
||||
@@ -536,7 +576,7 @@ class ConnectWiseProvider(PSAProvider):
|
||||
if work_type:
|
||||
payload["workType"] = {"name": work_type}
|
||||
|
||||
data = await self._client.post("/time/entries", payload)
|
||||
data = await self.client.post("/time/entries", payload)
|
||||
return PSATimeEntry(
|
||||
id=str(data["id"]),
|
||||
ticket_id=ticket_id,
|
||||
|
||||
@@ -11,6 +11,7 @@ from app.services.psa.types import (
|
||||
PSAMember,
|
||||
PSAConfiguration,
|
||||
PSATimeEntry,
|
||||
PSABoard,
|
||||
)
|
||||
|
||||
|
||||
@@ -58,6 +59,9 @@ class HaloPSAProvider(PSAProvider):
|
||||
async def list_members(self) -> list[PSAMember]:
|
||||
raise NotImplementedError("Halo PSA integration coming soon")
|
||||
|
||||
async def list_boards(self) -> list[PSABoard]:
|
||||
raise NotImplementedError("list_boards not implemented for this provider")
|
||||
|
||||
async def get_ticket_configurations(self, ticket_id: str) -> list[PSAConfiguration]:
|
||||
raise NotImplementedError("Halo PSA integration coming soon")
|
||||
|
||||
|
||||
@@ -67,6 +67,12 @@ class PSATimeEntry(BaseModel):
|
||||
created_at: str | None = None
|
||||
|
||||
|
||||
class PSABoard(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
inactive: bool = False
|
||||
|
||||
|
||||
class NoteType:
|
||||
INTERNAL_ANALYSIS = "internal_analysis"
|
||||
RESOLUTION = "resolution"
|
||||
|
||||
@@ -220,7 +220,15 @@ async def send_message(
|
||||
model = settings.get_model_for_action("script_build")
|
||||
provider = get_ai_provider(model=model)
|
||||
ai_text, input_tokens, output_tokens = await provider.generate_text(
|
||||
system_prompt=system_prompt,
|
||||
system_prompt=[
|
||||
{"type": "text", "text": system_prompt},
|
||||
# cacheable: SYSTEM_PROMPT_TEMPLATE with a per-session language
|
||||
# substitution. Two sessions on the same language share a cache
|
||||
# entry; different languages cache independently. Conversation
|
||||
# history (ai_messages) is NOT cached at this layer — if that
|
||||
# becomes a cost driver, route script_builder through the chat
|
||||
# wrapper (0.4) which handles history caching.
|
||||
],
|
||||
messages=ai_messages,
|
||||
max_tokens=8192,
|
||||
)
|
||||
|
||||
@@ -19,8 +19,116 @@ class TestAdminEndpoints:
|
||||
"/api/v1/admin/users", headers=admin_auth_headers
|
||||
)
|
||||
assert response.status_code == 200
|
||||
users = response.json()
|
||||
assert len(users) >= 2 # admin + test_user
|
||||
payload = response.json()
|
||||
assert payload["total"] >= 2 # admin + test_user
|
||||
assert len(payload["items"]) >= 2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_users_supports_search(
|
||||
self, client: AsyncClient, admin_auth_headers: dict, test_user: dict
|
||||
):
|
||||
"""Test admin people search by user email."""
|
||||
response = await client.get(
|
||||
"/api/v1/admin/users",
|
||||
params={"search": test_user["email"]},
|
||||
headers=admin_auth_headers,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
assert payload["total"] >= 1
|
||||
assert any(item["email"] == test_user["email"] for item in payload["items"])
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_accounts_as_admin(
|
||||
self, client: AsyncClient, admin_auth_headers: dict
|
||||
):
|
||||
"""Test listing accounts with member data."""
|
||||
response = await client.get(
|
||||
"/api/v1/admin/accounts", headers=admin_auth_headers
|
||||
)
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
assert payload["total"] >= 1
|
||||
assert len(payload["items"]) >= 1
|
||||
assert "members" in payload["items"][0]
|
||||
assert "subscription" in payload["items"][0]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_account_as_admin(
|
||||
self, client: AsyncClient, admin_auth_headers: dict
|
||||
):
|
||||
"""Test creating an empty account from admin."""
|
||||
response = await client.post(
|
||||
"/api/v1/admin/accounts",
|
||||
json={"name": "Acme Customer", "plan": "pro"},
|
||||
headers=admin_auth_headers,
|
||||
)
|
||||
assert response.status_code == 201
|
||||
payload = response.json()
|
||||
assert payload["name"] == "Acme Customer"
|
||||
assert payload["subscription"]["plan"] == "pro"
|
||||
assert payload["display_code"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_account_detail_as_admin(
|
||||
self, client: AsyncClient, admin_auth_headers: dict, test_user: dict
|
||||
):
|
||||
"""Test fetching account detail for management view."""
|
||||
account_id = test_user["user_data"]["account_id"]
|
||||
response = await client.get(
|
||||
f"/api/v1/admin/accounts/{account_id}",
|
||||
headers=admin_auth_headers,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
assert payload["id"] == account_id
|
||||
assert "members" in payload
|
||||
assert "invites" in payload
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_account_name_as_admin(
|
||||
self, client: AsyncClient, admin_auth_headers: dict, test_user: dict
|
||||
):
|
||||
"""Test renaming an account from admin detail view."""
|
||||
account_id = test_user["user_data"]["account_id"]
|
||||
response = await client.put(
|
||||
f"/api/v1/admin/accounts/{account_id}",
|
||||
json={"name": "Renamed Customer Account"},
|
||||
headers=admin_auth_headers,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
assert payload["id"] == account_id
|
||||
assert payload["name"] == "Renamed Customer Account"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_account_plan(
|
||||
self, client: AsyncClient, admin_auth_headers: dict, test_user: dict
|
||||
):
|
||||
"""Test changing an account's subscription plan."""
|
||||
account_id = test_user["user_data"]["account_id"]
|
||||
response = await client.put(
|
||||
f"/api/v1/admin/accounts/{account_id}/subscription/plan",
|
||||
json={"plan": "pro"},
|
||||
headers=admin_auth_headers,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["plan"] == "pro"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_extend_account_trial(
|
||||
self, client: AsyncClient, admin_auth_headers: dict, test_user: dict
|
||||
):
|
||||
"""Test starting or extending an account trial."""
|
||||
account_id = test_user["user_data"]["account_id"]
|
||||
response = await client.put(
|
||||
f"/api/v1/admin/accounts/{account_id}/subscription/extend-trial",
|
||||
json={"days": 14},
|
||||
headers=admin_auth_headers,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["status"] == "trialing"
|
||||
assert response.json()["current_period_end"] is not None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_users_as_non_admin(
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
name: resolutionflow
|
||||
|
||||
services:
|
||||
db:
|
||||
image: pgvector/pgvector:pg16
|
||||
@@ -8,7 +9,7 @@ services:
|
||||
POSTGRES_PASSWORD: postgres
|
||||
POSTGRES_DB: resolutionflow
|
||||
ports:
|
||||
- "${POSTGRES_PORT:-5432}:5432"
|
||||
- "${POSTGRES_PORT:-5433}:5432"
|
||||
volumes:
|
||||
- rf_postgres_data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
@@ -22,8 +23,11 @@ services:
|
||||
context: ./backend
|
||||
dockerfile: Dockerfile.dev
|
||||
container_name: resolutionflow_backend
|
||||
command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
|
||||
ports:
|
||||
- "8000:8000"
|
||||
volumes:
|
||||
- ./backend:/app
|
||||
- ${REPO_ROOT}/backend:/app
|
||||
environment:
|
||||
- APP_NAME=ResolutionFlow
|
||||
- DEBUG=true
|
||||
@@ -33,42 +37,34 @@ services:
|
||||
- ALGORITHM=HS256
|
||||
- ACCESS_TOKEN_EXPIRE_MINUTES=15
|
||||
- REFRESH_TOKEN_EXPIRE_DAYS=7
|
||||
- REQUIRE_INVITE_CODE=true
|
||||
- FEEDBACK_EMAIL=feedback@resolutionflow.com
|
||||
- AI_PROVIDER=anthropic
|
||||
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY}
|
||||
- GOOGLE_AI_API_KEY=${GOOGLE_AI_API_KEY}
|
||||
- CORS_ORIGINS=["http://localhost:3000","http://localhost:5173","http://127.0.0.1:3000","http://127.0.0.1:5173","http://46.202.92.250:5173","http://46.202.92.250:3000","https://resolutionflow.com","https://www.resolutionflow.com"]
|
||||
- GOOGLE_AI_API_KEY=${GOOGLE_AI_API_KEY:-}
|
||||
- ENABLE_MCP_MICROSOFT_LEARN=true
|
||||
- FRONTEND_URL=http://docker-01:5173
|
||||
- CORS_ORIGINS=["http://localhost:5173","http://127.0.0.1:5173","http://docker-01:5173","http://100.64.78.44:5173"]
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.rf-api.rule=Host(`dev.resolutionflow.com`) && PathPrefix(`/api`)"
|
||||
- "traefik.http.routers.rf-api.entrypoints=websecure"
|
||||
- "traefik.http.routers.rf-api.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.services.rf-api.loadbalancer.server.port=8000"
|
||||
|
||||
frontend:
|
||||
build:
|
||||
context: ./frontend
|
||||
dockerfile: Dockerfile.dev
|
||||
container_name: resolutionflow_frontend
|
||||
command: npm run dev -- --host 0.0.0.0 --port 5173
|
||||
ports:
|
||||
- "5173:5173"
|
||||
volumes:
|
||||
- ./frontend:/app
|
||||
- ${REPO_ROOT}/frontend:/app
|
||||
- /app/node_modules
|
||||
environment:
|
||||
- VITE_API_URL=https://dev.resolutionflow.com/
|
||||
- VITE_API_URL=http://docker-01:8000
|
||||
- CHOKIDAR_USEPOLLING=true
|
||||
depends_on:
|
||||
- backend
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.rf-frontend.rule=Host(`dev.resolutionflow.com`)"
|
||||
- "traefik.http.routers.rf-frontend.middlewares=dev-auth"
|
||||
- "traefik.http.middlewares.dev-auth.basicauth.users=chihlasm:$$apr1$$dJXUAZ3Y$$SsJm.K8fOjCeNMe4B70Bi0"
|
||||
- "traefik.http.routers.rf-frontend.entrypoints=websecure"
|
||||
- "traefik.http.routers.rf-frontend.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.services.rf-frontend.loadbalancer.server.port=5173"
|
||||
|
||||
volumes:
|
||||
rf_postgres_data:
|
||||
|
||||
rf_postgres_data:
|
||||
163
docs/FLOWPILOT-AND-RESOLUTIONASSIST.md
Normal file
163
docs/FLOWPILOT-AND-RESOLUTIONASSIST.md
Normal file
@@ -0,0 +1,163 @@
|
||||
# FlowPilot & ResolutionAssist
|
||||
|
||||
> ResolutionFlow offers two AI-driven troubleshooting modes that share the same session backend but present very different interaction styles. Both work standalone and become richer when paired with a PSA connection.
|
||||
|
||||
---
|
||||
|
||||
## At a glance
|
||||
|
||||
| | **FlowPilot** | **ResolutionAssist** |
|
||||
|---|---|---|
|
||||
| **Style** | Guided, structured | Conversational, freeform |
|
||||
| **Entry** | `/pilot` | `/assistant` |
|
||||
| **Interaction** | Questions → Actions → Resolution, one step at a time | Natural chat with inline questions/actions |
|
||||
| **Best for** | Reproducible workflows, low-context engineers, handoffs | Exploratory problems, quick lookups, rubber-ducking |
|
||||
| **Lifecycle** | Active → Paused → Resolved / Escalated / Abandoned | Active → Resolved / Abandoned (lightweight) |
|
||||
| **Confidence tracking** | Yes — drives tier transitions | No — always responsive to user direction |
|
||||
| **Navigation guard** | Yes — prevents accidental loss | No — free to leave and return |
|
||||
|
||||
Both modes share the `ai_sessions` table (discriminated by `session_type`), the same multimodal AI backend (image uploads, markdown, cached prompts), and the same `[QUESTIONS]` / `[ACTIONS]` / `[FORK]` marker vocabulary that renders inline TaskLane elements.
|
||||
|
||||
---
|
||||
|
||||
## FlowPilot — guided troubleshooting
|
||||
|
||||
FlowPilot is a wizard-style AI engineer that walks you through a problem one diagnostic step at a time. It runs on confidence tiers:
|
||||
|
||||
- **Discovery** (confidence < 0.4) — asking broad, open-ended questions to characterize the problem
|
||||
- **Exploring** (0.4–0.8) — proposing targeted actions and narrowing hypotheses
|
||||
- **Guided** (≥ 0.8) — recommending a specific fix with steps to verify
|
||||
|
||||
### The FlowPilot session flow
|
||||
|
||||
1. **Intake.** You start from `/pilot` or from the dashboard "New Session" button. The intake screen accepts free-text description, PSA ticket context, screenshots, or log pastes.
|
||||
2. **Preference check.** Before suggesting any fix, the AI asks whether you want a **GUI** or **script** approach. This is enforced in the system prompt so you never get steps you can't execute.
|
||||
3. **Step-by-step progression.** Each AI response is either a question (with clickable options), an action (with "Done" / "Didn't work" buttons), a `[FORK]` (two distinct paths to try), or a final resolution suggestion. You respond, the AI updates its confidence, and the next step is generated.
|
||||
4. **Action bar.** The session header always shows **Pause & Leave**, **Resolve**, **Escalate**, **Share Update**, and **Close**. Pausing freezes the session; resuming restores the full context.
|
||||
5. **Resolve / Escalate.** *Resolve* marks the ticket fixed and generates a clean summary of what worked. *Escalate* packages the problem summary and steps tried into an **escalation package** that the next engineer (or the PSA ticket) inherits.
|
||||
|
||||
### Why FlowPilot exists
|
||||
|
||||
- **New engineers** get senior-engineer-level diagnostic rigor without needing the experience to know what to ask next.
|
||||
- **Documented resolutions** — every step is captured, so the generated note on the ticket is substantive (not just "fixed it").
|
||||
- **Handoff-friendly** — escalation packages mean the next person doesn't start from zero.
|
||||
|
||||
---
|
||||
|
||||
## ResolutionAssist — conversational AI
|
||||
|
||||
ResolutionAssist is a chat with an expert IT systems engineer. It's less structured than FlowPilot but still surfaces interactive elements when the AI wants structured input.
|
||||
|
||||
### The ResolutionAssist flow
|
||||
|
||||
1. **Open a chat.** From `/assistant` or the dashboard. Sessions show up in the left sidebar just like any messaging app.
|
||||
2. **Send a message.** Freeform prose. Attach up to 3 images per message (screenshots, error dialogs, network diagrams). Paste logs, code, or PowerShell output.
|
||||
3. **AI responds.** The response is prose, but any `[QUESTIONS]` or `[ACTIONS]` blocks render as a **TaskLane** — a side panel with clickable options and action buttons. You can answer via chat or click the TaskLane elements.
|
||||
4. **Branching (`[FORK]`).** If the AI proposes two paths ("check cable or restart switch?"), the fork renders as a choice. Picking one continues the conversation down that path.
|
||||
5. **Resume later.** Unlike FlowPilot, there's no navigation guard. Leave mid-conversation; every message is stored.
|
||||
|
||||
### Why ResolutionAssist exists
|
||||
|
||||
- **Unstructured problems** — "I have no idea where to start, here's a screenshot" works great.
|
||||
- **Reference lookups** — "what's the right PowerShell command to check Exchange health" is faster in chat than through an intake form.
|
||||
- **Senior engineers** — when you already know what you're doing and just want a second opinion or a syntax check.
|
||||
|
||||
---
|
||||
|
||||
## Without a PSA connection
|
||||
|
||||
Both modes work standalone. Without ConnectWise connected:
|
||||
|
||||
- Sessions live entirely in ResolutionFlow. They're listed in your session history, searchable, and shareable via public share links (`/shared/sessions/:token`).
|
||||
- Summaries generated on Resolve are saved to the session record but **not** written anywhere else. You can copy/paste into whatever ticketing or documentation system you use.
|
||||
- Escalating a FlowPilot session routes the escalation package to another ResolutionFlow engineer on your team — not to an external PSA ticket.
|
||||
- No ticket context is injected into the AI prompt, so the AI starts cold with only what you provide in the intake or first message.
|
||||
|
||||
**Standalone use cases:**
|
||||
- Evaluating ResolutionFlow before committing to a PSA integration
|
||||
- Troubleshooting internal IT issues that aren't client-facing
|
||||
- Teams using a PSA ResolutionFlow doesn't integrate with yet
|
||||
- Knowledge-base research ("what are my options for X") that don't map to a ticket
|
||||
|
||||
---
|
||||
|
||||
## With a PSA connection (ConnectWise)
|
||||
|
||||
When ConnectWise is connected, both modes become ticket-aware and write back to the PSA as a first-class client.
|
||||
|
||||
### FlowPilot + PSA
|
||||
|
||||
**Starting from a ticket:**
|
||||
- Click a ticket row (from `/tickets` or the dashboard queue) and pick "Start FlowPilot." The ticket's problem description, recent notes, configurations, company details, and related tickets are auto-injected into the AI's context. No manual retyping.
|
||||
- The session shows the linked ticket badge in the header.
|
||||
|
||||
**During the session:**
|
||||
- **Share Update** — posts an interim note to the CW ticket with the current AI summary, so stakeholders can see progress without interrupting you.
|
||||
- **Status changes** — the detail panel and session header let you move the ticket through statuses (New → In Progress → Waiting on Customer → Resolved) directly from ResolutionFlow. Status writes are verified against CW so you're never told "success" when CW silently rejected the change.
|
||||
- **Resource assignment** — add yourself or a teammate as a co-assignee without touching the owner. If the ticket has no owner yet, assigning sets owner; if there's already an owner, you're added as an additional resource via a CW schedule entry.
|
||||
|
||||
**On Resolve:**
|
||||
- Final summary is posted as a ticket note.
|
||||
- Ticket status can auto-update to Resolved (per your team's settings).
|
||||
|
||||
**On Escalate:**
|
||||
- The escalation package (problem summary + steps tried) is posted as a note.
|
||||
- The ticket can be routed via CW's normal escalation rules.
|
||||
- The next engineer picking up the ticket can auto-start a new session with the full escalation context pre-filled.
|
||||
|
||||
**Spin-off tickets (new):**
|
||||
- During any session, if you discover a separate issue, the AI can propose `create_spin_off_ticket`. Accepting opens the New Ticket modal pre-filled with the current ticket's company and board, so a second ticket is one click away without leaving your session.
|
||||
|
||||
### ResolutionAssist + PSA
|
||||
|
||||
**Starting from a ticket:**
|
||||
- Same ticket-context injection as FlowPilot. When opened with a linked ticket, the AI sees company, configs, notes, and related tickets.
|
||||
- A "New Ticket" button appears in the header — lets you spawn a separate ticket mid-conversation (same flow as FlowPilot's spin-off).
|
||||
|
||||
**During the chat:**
|
||||
- Ask the AI about the ticket directly: *"Summarize what's been tried," "What configs does this company have?"* — the AI already has that context loaded.
|
||||
- `[ACTIONS]` can include `create_spin_off_ticket` when the AI detects a separate issue surfaced in the conversation.
|
||||
|
||||
**Writing back:**
|
||||
- ResolutionAssist is a lighter-weight mode, so it doesn't auto-post on resolve. You can manually copy the conversation summary to a ticket note if useful.
|
||||
- Status updates and resource assignment are done via the `/tickets` page rather than the chat UI.
|
||||
|
||||
---
|
||||
|
||||
## Choosing between them
|
||||
|
||||
| I want to… | Use |
|
||||
|---|---|
|
||||
| Walk through a known issue type with step-by-step rigor | **FlowPilot** |
|
||||
| Document every action taken for audit or handoff | **FlowPilot** |
|
||||
| Escalate with a full context package | **FlowPilot** |
|
||||
| Ask a question, get an answer, move on | **ResolutionAssist** |
|
||||
| Paste a screenshot and say "what's wrong here?" | **ResolutionAssist** |
|
||||
| Stay on the ticket for 2 minutes, not 20 | **ResolutionAssist** |
|
||||
| Troubleshoot without breaking flow to switch pages | Either, with the linked ticket panel open alongside |
|
||||
|
||||
The two modes aren't competitive. A common workflow is to start in ResolutionAssist to scope the problem, then kick off a FlowPilot session when you realize the issue is going to take real diagnosis. Both show up in the unified session history.
|
||||
|
||||
---
|
||||
|
||||
## Tickets page — the PSA hub
|
||||
|
||||
`/tickets` is the CW ticket manager built into ResolutionFlow. With a PSA connection:
|
||||
|
||||
- Search and filter tickets by assignment (me / unassigned / specific member via searchable picker), board, status, priority, company, open/closed.
|
||||
- Slide-out detail panel shows notes, configurations, related tickets, and assignees — all fetched in parallel for fast hydration.
|
||||
- From the detail panel: change status, add/remove assignees, post notes, or "Start FlowPilot" / "Open in ResolutionAssist" with full context.
|
||||
- New Ticket modal offers both AI-parse ("Create a high-priority ticket for Acme — Outlook not syncing for jsmith") and a traditional form.
|
||||
|
||||
Without a PSA connection, `/tickets` is hidden from the sidebar entirely — there's nothing to show.
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
- **FlowPilot** = guided, structured, lifecycle-heavy, ideal for resolvable issues and handoffs.
|
||||
- **ResolutionAssist** = freeform chat, ideal for scoping and quick answers.
|
||||
- **Without PSA** = both work, sessions live in ResolutionFlow, summaries are yours to export.
|
||||
- **With PSA** = both become ticket-aware, write back to CW (notes, status, resources), and can spawn spin-off tickets mid-session.
|
||||
|
||||
The AI is the same under the hood. The difference is how much structure you want around the conversation — and how deeply the result needs to integrate with your ticketing system.
|
||||
178
docs/FlowAssist_Migration/CODEX-FlowAssist-Migration-PLAN.md
Normal file
178
docs/FlowAssist_Migration/CODEX-FlowAssist-Migration-PLAN.md
Normal file
@@ -0,0 +1,178 @@
|
||||
# FlowPilot Migration Plan: Phase 0 Through Phase 7
|
||||
|
||||
## Summary
|
||||
- Stay code-change-free until execution is explicitly requested.
|
||||
- Implement in commit-sized phases, with Phase 0 as a prerequisite for AI-heavy Phases 2+.
|
||||
- Use this repo’s existing `/api/v1/ai-sessions` API namespace instead of the doc’s generic `/sessions` path.
|
||||
- Move the existing chat-first `AssistantChatPage` to `/pilot`; `/assistant` becomes a permanent redirect.
|
||||
- Keep `ai_sessions.session_type` for compatibility, but the user-facing product becomes one FlowPilot surface.
|
||||
|
||||
## Phase 0: Prompt Caching Infrastructure
|
||||
- Consolidate Anthropic prompt caching into `backend/app/core/ai_provider.py`, then route all Anthropic calls through that provider.
|
||||
- Preserve the existing cached behavior from `assistant_chat_service`, but remove the private duplicate cached implementation once provider parity exists.
|
||||
- Add cache-control blocks for static system prompt sections and stable tool/context prefixes; keep volatile user messages outside the cached prefix.
|
||||
- Update one-shot AI generators and `/tickets/ai-parse` to separate stable context from changing request content.
|
||||
- Instrument every Anthropic response with `cache_read_input_tokens` and `cache_creation_input_tokens`.
|
||||
- Acceptance: two identical FlowPilot/provider-backed calls within 5 minutes show creation tokens on the first call and read tokens on the second.
|
||||
|
||||
## Phase 1: Schema + Route Rename
|
||||
- Add Alembic migration after current repo head with:
|
||||
- `session_facts`
|
||||
- `session_suggested_fixes`
|
||||
- `draft_templates`
|
||||
- `account_settings`
|
||||
- new artifact columns on `ai_sessions`
|
||||
- provenance columns on `script_templates`
|
||||
- Create `account_settings` as one lazy row per account:
|
||||
- `account_id` primary key, FK to `accounts(id)` with cascade delete
|
||||
- `preferences JSONB NOT NULL DEFAULT '{}'`
|
||||
- timestamps
|
||||
- `get_setting(key, default)` helper on the SQLAlchemy model
|
||||
- `templatize_prompt_enabled` default read as `true` when the row/key is absent
|
||||
- Apply RLS to all new tenant-scoped tables using the repo’s `app.current_account_id` policy pattern.
|
||||
- Route `/pilot` and `/pilot/:sessionId` to the existing chat UI; redirect `/assistant` and `/assistant/:sessionId` permanently.
|
||||
- Update sidebar, command palette, dashboard cards, session list links, and visible labels from “AI Assistant”/ResolutionAssist to “FlowPilot” where they describe the troubleshooting surface.
|
||||
- Acceptance: `/pilot` renders the chat UI, `/assistant` redirects, RLS grep/check passes, and no Phase 2 UI is introduced yet.
|
||||
|
||||
## Phase 2: What We Know
|
||||
- Add `FactSynthesisService` for conservative conversion of answers/check outputs into facts.
|
||||
- Add fact APIs under `/api/v1/ai-sessions/{id}/facts`:
|
||||
- list, create manual note, update editable fact, soft-delete, promote source item.
|
||||
- Extend `unified_chat_service` marker parsing with `[PROMOTE]`; do not create a separate marker pipeline.
|
||||
- Because current questions/checks live in `ai_sessions.pending_task_lane` JSON, Phase 2 must assign stable UUIDs to task-lane questions/actions/checks when they are first persisted. `session_facts.source_ref` points to those stable JSON item IDs; it remains polymorphic and unconstrained at the DB level.
|
||||
- Add frontend task lane components under the new FlowPilot component namespace:
|
||||
- `TaskLane`
|
||||
- `WhatWeKnow`
|
||||
- `WhatWeKnowItem`
|
||||
- `AddNoteButton`
|
||||
- moved/refactored Questions and Diagnostic Checks sections
|
||||
- Place What We Know above Questions. Facts from questions/checks are read-only at the fact card level; manual and AI-synthesis facts are editable.
|
||||
- Acceptance: answering a question or completing a check can promote a fact within 2 seconds; manual notes persist; page reload preserves facts; cross-account access is blocked.
|
||||
|
||||
## Phase 3: Suggested Fix + Resolve Preview
|
||||
- Add suggested-fix APIs under `/api/v1/ai-sessions/{id}/suggested-fixes`:
|
||||
- get active suggested fix
|
||||
- record decision for one-off/draft-template/build-template/dismissed
|
||||
- Extend `unified_chat_service` marker parsing with `[SUGGEST_FIX]`; supersede the prior active fix when a new one is persisted.
|
||||
- Add `ResolutionNoteGeneratorService` that builds the fixed markdown shape:
|
||||
- Problem
|
||||
- What we confirmed
|
||||
- Root cause
|
||||
- Resolution
|
||||
- Add preview endpoint at `/api/v1/ai-sessions/{id}/resolution-note/preview`.
|
||||
- Generate the preview from `ai_sessions`, `session_facts`, active suggested fix, and linked script generations; redact sensitive script parameters.
|
||||
- Cache preview output by session-state version or content hash; invalidate on fact/suggested-fix/script-generation writes.
|
||||
- Add `SuggestedFix`, `ResolveButton`, and `ResolutionNotePreview` popover. Debounce preview refresh to 500ms.
|
||||
- Acceptance: a session with facts and a suggested fix shows a four-section preview; editing a fact refreshes preview; human review confirms no unsupported claims.
|
||||
|
||||
## Phase 4: Resolve + Escalate Writebacks
|
||||
- Add `EscalationPackageGeneratorService` with handoff-oriented markdown:
|
||||
- Problem
|
||||
- What we’ve confirmed
|
||||
- What we’ve tried
|
||||
- Current hypothesis
|
||||
- Suggested next steps
|
||||
- Add preview/post endpoints under `/api/v1/ai-sessions/{id}`:
|
||||
- `/resolution-note/preview`
|
||||
- `/resolution-note/post`
|
||||
- `/escalation-package/preview`
|
||||
- `/escalation-package/post`
|
||||
- Extend PSA writeback service using the existing PSA provider registry and `post_note` seam.
|
||||
- Implement “confirm and fire”: engineer edits preview, clicks Confirm & post, then server posts to PSA and stores result metadata.
|
||||
- Ticket status transitions must verify by re-fetching status; failed verification is surfaced as an error, not silent success.
|
||||
- Resolving without a linked PSA ticket stores markdown and marks the session resolved without external posting.
|
||||
- Acceptance: ConnectWise test ticket receives the note/package, status verification works, and unlinked sessions resolve locally.
|
||||
|
||||
## Phase 5: Inline Script Generator Integration
|
||||
- Add inline Script Generator components:
|
||||
- `TemplateMatchPanel`
|
||||
- `NoTemplateDialog`
|
||||
- `ParameterizationPreview`
|
||||
- For template matches, clicking the suggested fix opens the existing Script Generator flow with parameters prefilled from facts, ticket context, account/PSA config, and AI-suggested values.
|
||||
- For no-template matches, show the three-option dialog:
|
||||
- Run as one-off
|
||||
- Run now, templatize after resolve
|
||||
- Build as template now
|
||||
- Persist the selected path on `session_suggested_fixes.user_decision`.
|
||||
- Add `TemplateExtractionService` for converting concrete scripts into proposed parameter schemas and templated bodies.
|
||||
- Link every script generation back to `ai_sessions` via existing `script_generations.ai_session_id`.
|
||||
- `Cmd+K → script` opens the inline generator from the FlowPilot session; no Resolve keyboard shortcut is added.
|
||||
- Acceptance: matched templates prefill parameters; no-match flow shows three options; all options produce the correct session/template side effects.
|
||||
|
||||
## Phase 6: Post-Resolve Templatize Prompt
|
||||
- Add `TemplatizePrompt` after successful Resolve only when:
|
||||
- the account setting allows prompts
|
||||
- the session has pending `draft_templates`
|
||||
- the user chose “Run now, templatize after resolve”
|
||||
- Accept flow creates a real `script_templates` row with:
|
||||
- `source_session_id`
|
||||
- `source_user_id`
|
||||
- `source_ticket_ref`
|
||||
- accepted parameter schema/body edits
|
||||
- Skip flow marks the draft rejected.
|
||||
- “Don’t ask me again for this team” writes `{"templatize_prompt_enabled": false}` to `account_settings.preferences`.
|
||||
- Script Library shows a pending-drafts badge/count for the account.
|
||||
- Acceptance: accept creates a visible template with provenance; skip creates no template; disabled prompt is respected on the next resolve.
|
||||
|
||||
## Phase 7: Polish
|
||||
- Match the authoritative mockup HTML for spacing, colors, typography, and component structure; use PNGs for visual target confirmation.
|
||||
- Add loading states for fact synthesis, preview generation, template extraction, PSA post/verify, and script generation.
|
||||
- Add empty states for:
|
||||
- no facts
|
||||
- no questions
|
||||
- no checks
|
||||
- no suggested fix
|
||||
- no pending draft templates
|
||||
- Add keyboard shortcuts except Resolve:
|
||||
- `Cmd+K` command palette
|
||||
- `Cmd+Enter` send composer
|
||||
- `Cmd+G` script generator
|
||||
- At widths below 1200px, collapse the task lane into a bottom drawer.
|
||||
- Use existing design tokens where present; add missing tokens only if needed to match the mockups.
|
||||
- Acceptance: major screens visually compare within the doc’s tolerance, no horizontal scroll at 1280px, mobile task lane works, and shortcuts do not conflict with browser reload.
|
||||
|
||||
## Public Interfaces
|
||||
- New backend routes use `/api/v1/ai-sessions/{id}/...`, not `/api/v1/sessions/{id}/...`.
|
||||
- Existing chat creation/message APIs remain compatible.
|
||||
- `session_type` remains queryable and stored, but frontend routing no longer sends chat sessions to `/assistant`.
|
||||
- New persistent entities:
|
||||
- `session_facts`
|
||||
- `session_suggested_fixes`
|
||||
- `draft_templates`
|
||||
- `account_settings`
|
||||
- New persisted artifact columns on `ai_sessions` store resolution/escalation markdown and PSA post metadata.
|
||||
|
||||
## Test Plan
|
||||
- Migration tests:
|
||||
- fresh DB upgrade succeeds
|
||||
- downgrade succeeds if the repo expects reversible migrations
|
||||
- new tables have RLS enabled/forced
|
||||
- tenant policy includes `app.current_account_id`
|
||||
- Backend tests:
|
||||
- fact CRUD and promotion authorization
|
||||
- suggested-fix supersession and decision persistence
|
||||
- preview generation cache invalidation
|
||||
- Resolve/Escalate local-only behavior without PSA
|
||||
- PSA status verification failure path
|
||||
- draft-template accept/reject behavior
|
||||
- Frontend tests:
|
||||
- route redirects
|
||||
- task lane rendering and persistence
|
||||
- inline editing and preview refresh
|
||||
- script generator option flows
|
||||
- templatize prompt settings behavior
|
||||
- responsive drawer behavior
|
||||
- Manual QA:
|
||||
- run through one ConnectWise linked Resolve
|
||||
- run through one Escalate
|
||||
- run one template-match script path
|
||||
- run one no-template draft-template path through post-resolve save
|
||||
|
||||
## Assumptions
|
||||
- Phase 0 is included and must be complete before Phase 2 begins.
|
||||
- No Resolve keyboard shortcut in this migration.
|
||||
- Templatize prompt defaults to enabled.
|
||||
- Resolution notes use engineer review plus Confirm & post, not supervisor staging.
|
||||
- Existing component folders may be renamed opportunistically, but behavior and route migration matter more than directory-name purity.
|
||||
- No backfill of What We Know for old sessions.
|
||||
- Team Wiki compilation, SharePoint integration, marketplace sharing, and confidence-tier UI are out of scope.
|
||||
809
docs/FlowAssist_Migration/FLOWPILOT-MIGRATION-v1.md
Normal file
809
docs/FlowAssist_Migration/FLOWPILOT-MIGRATION-v1.md
Normal file
@@ -0,0 +1,809 @@
|
||||
# FlowPilot Migration — Design & Implementation Doc
|
||||
|
||||
> **Target:** Transform `/assistant` (ResolutionAssist) into the new unified `/pilot` (FlowPilot) surface.
|
||||
> **Audience:** Claude Code (implementation) reviewed by Michael (owner).
|
||||
> **Status:** Design locked. Ready for phased implementation.
|
||||
> **Last updated:** April 17, 2026
|
||||
|
||||
---
|
||||
|
||||
## 0. Prerequisite reading for Claude Code
|
||||
|
||||
Before writing any code, read these in order:
|
||||
|
||||
1. This document end-to-end.
|
||||
2. `mockups/01-session-primary.png` — the target state for the main session UI.
|
||||
3. `mockups/02-script-template-match.png`, `03-script-three-options.png`, `04-script-templatize-prompt.png` — Script Generator integration states.
|
||||
4. The source HTML files `mockups/01-session-primary.html` and `mockups/02-04-script-integration.html` — authoritative for spacing, colors, and component structure. When CSS or layout questions arise during implementation, these files are the tiebreaker.
|
||||
|
||||
Do not proceed to implementation until you have confirmed you understand the following three architectural claims. If any of them are unclear, stop and ask.
|
||||
|
||||
1. **There is one AI troubleshooting surface, not two.** The existing split between FlowPilot (guided) and ResolutionAssist (chat) is collapsed into a single chat-primary product called FlowPilot at `/pilot`. The `ai_sessions.session_type` discriminator column is retained for data, but the product shows one unified UI.
|
||||
2. **The task lane is the load-bearing structural feature.** It is not a sidebar of metadata. It actively tracks diagnostic state: *What we know*, *Questions*, *Diagnostic checks*, *Suggested fix*. Engineers interact with it; facts flow between sections.
|
||||
3. **Resolve and Escalate are deterministic artifact generators, not free-text prompts.** When an engineer clicks Resolve, a structured summary is generated from task lane state (not from the chat transcript alone) and posted to CW. The summary structure is fixed: *Problem / What we confirmed / Root cause / Resolution*.
|
||||
|
||||
---
|
||||
|
||||
## 1. Why this change
|
||||
|
||||
### The current state
|
||||
|
||||
- `/assistant` is a chat-primary AI session with a `[QUESTIONS]` and `[DIAGNOSTIC_CHECKS]` task lane.
|
||||
- `/pilot` was specced as a separate guided, confidence-tiered wizard with a different UI and lifecycle.
|
||||
- The `FLOWPILOT-AND-RESOLUTIONASSIST.md` design document treated them as two products sharing a backend.
|
||||
|
||||
### The problems with the current state
|
||||
|
||||
- Two sidebar entries, two session histories, two mental models for engineers to learn.
|
||||
- The PSA integration scope doubles (writebacks for lifecycle events must be built twice, or built for Pilot and bolted onto Assist).
|
||||
- The Team Wiki moat depends on structured session artifacts with explicit resolutions — a chat-only mode produces weaker artifacts.
|
||||
- The cockpit positioning (the core ResolutionFlow brand promise) does not map to a blank chat window.
|
||||
- Branching into two modes forces a decision onto the engineer ("which mode for this ticket?") that has no right answer.
|
||||
|
||||
### The resolution
|
||||
|
||||
The existing `/assistant` UI already does most of what `/pilot` was supposed to do — structured questions, diagnostic checks, lifecycle actions in the header. It is closer to the right product than the doc anticipated. Rather than building Pilot as a second surface, we extend Assist with the missing structural features (*What we know*, auto-generated summaries, escalation packages) and rename it FlowPilot.
|
||||
|
||||
### The strategic move
|
||||
|
||||
FlowPilot becomes the single canonical troubleshooting surface. Every PSA writeback, every Wiki compilation path, every Script Generator invocation points here. One session shape, one lifecycle, one integration surface.
|
||||
|
||||
---
|
||||
|
||||
## 2. Terminology used in this document
|
||||
|
||||
| Term | Meaning |
|
||||
|---|---|
|
||||
| **Session** | A single `ai_sessions` row representing one troubleshooting conversation. |
|
||||
| **Task lane** | The right-side panel containing What we know, Questions, Diagnostic checks, Suggested fix. |
|
||||
| **Fact** | An item in the What we know section. Has `text`, `source_type` (`question` / `diagnostic_check` / `user_note`), and `source_ref` (FK to the originating question/check, or null for user notes). |
|
||||
| **Suggested fix** | The AI's current best-guess resolution path. Has a confidence score and, optionally, a reference to a Script Library template. |
|
||||
| **Promotion** | The act of a question answer or diagnostic check result being converted into a fact in What we know. Triggered by AI, confirmed/editable by engineer. |
|
||||
| **Resolution note** | The structured document generated when the engineer clicks Resolve. Posted to CW as a ticket note. |
|
||||
| **Escalation package** | The structured handoff document generated when the engineer clicks Escalate. Posted to CW and attached to the session for the next engineer. |
|
||||
|
||||
---
|
||||
|
||||
## 3. Target UI — annotated
|
||||
|
||||
### 3.1 Primary session view
|
||||
|
||||

|
||||
|
||||
The session UI is a four-column layout:
|
||||
|
||||
1. **Icon rail** (64px wide) — primary app navigation. FlowPilot / Tickets / Trees / Scripts / Wiki. Avatar at bottom.
|
||||
2. **Session list** (260px wide) — all sessions grouped by state (Active / Recent). Each row shows title, state dot, PSA ticket number, and client name.
|
||||
3. **Conversation column** (fluid) — the chat thread, composer, and incident header.
|
||||
4. **Task lane** (380px wide) — *What we know*, *Questions*, *Diagnostic checks*, *Suggested fix*, and the Resolve action at the bottom.
|
||||
|
||||
Key visual and behavioral elements numbered against the mockup:
|
||||
|
||||
**Incident header (top of conversation column)**
|
||||
- PSA chip showing `CW #48291` in cyan, monospaced
|
||||
- Client / contact / priority meta line
|
||||
- Incident title in Bricolage Grotesque 19px
|
||||
- Four lifecycle buttons right-aligned: **Pause** (ghost), **Share update** (neutral), **Escalate** (amber), **Resolve** (green)
|
||||
|
||||
**Conversation column**
|
||||
- Standard chat thread with pilot and user avatars
|
||||
- Pilot uses cyan gradient avatar; user uses purple gradient
|
||||
- AI messages in `bg-2` bubbles with subtle border; user messages in cyan-tinted bubbles
|
||||
- Composer at bottom with inline action chips (Attach / Paste logs / Ticket context) and a send button
|
||||
|
||||
**Task lane sections, in order:**
|
||||
|
||||
1. **What we know** (NEW)
|
||||
- Header: `WHAT WE KNOW · 4` (section title + count)
|
||||
- Each fact is a card: `bg-2` background, dashed circular green check, fact text, and a provenance line (`from question · rules out tenant/license`)
|
||||
- "+ Add a note" button at the bottom for manual facts from the engineer
|
||||
- Background has a subtle green-to-transparent gradient to visually distinguish from the rest of the lane
|
||||
|
||||
2. **Questions**
|
||||
- Header: `QUESTIONS · 2 unanswered`
|
||||
- Each unanswered question: title, AI hint text, Answer / Skip buttons
|
||||
- Answered questions dim to 55% opacity with a dashed border and show the resolution inline (`Answered · isolated to jsmith (promoted to What we know)`)
|
||||
|
||||
3. **Diagnostic checks**
|
||||
- Header: `DIAGNOSTIC CHECKS · 1 / 3 run`
|
||||
- "Run remaining 2 checks" button at top when applicable
|
||||
- Each check: icon + command name (monospaced), description
|
||||
- Completed checks dim and show "Complete · findings promoted to What we know" in green
|
||||
|
||||
4. **Suggested fix**
|
||||
- Header: `SUGGESTED FIX · 94% confidence`
|
||||
- Amber-accented card with fix title and description
|
||||
- Clicking opens the Script Generator flow (Section 5)
|
||||
|
||||
**Resolve action bar (bottom of task lane)**
|
||||
- Small hint text ("Summary preview is open →")
|
||||
- Full-width "Resolve & post to CW" button in green
|
||||
|
||||
**Resolution note preview (floating, anchored to Resolve button)**
|
||||
- A persistent popover, NOT a modal
|
||||
- Shows the draft resolution note with Problem / What we confirmed / Root cause / Resolution sections
|
||||
- Displays the target ticket (`CW #48291`) and status change (`Resolved`)
|
||||
- Edit button opens an inline editor; Confirm & post fires the PSA writeback
|
||||
|
||||
### 3.2 Script Generator integration — template match
|
||||
|
||||

|
||||
|
||||
When the suggested fix references an existing Script Library template, clicking the fix opens the Script Generator panel in place of (or sliding over) the task lane. Key behavior:
|
||||
|
||||
- A **Verified template** badge appears above the parameter form
|
||||
- Parameters pre-filled from session context get a cyan `from session` tag and a cyan-tinted input background
|
||||
- Each pre-filled parameter has a hint line explaining the source: *"Pulled from CW company config for Acme Corp"*
|
||||
- The engineer can adjust any pre-filled value before generating
|
||||
- `⌘K` → "script" invokes the generator mid-conversation from anywhere in the session
|
||||
|
||||
### 3.3 Script Generator integration — no template match (three-option dialog)
|
||||
|
||||

|
||||
|
||||
When no template matches the suggested fix, FlowPilot drafts a session-specific script and presents three paths:
|
||||
|
||||
1. **Run as one-off** (neutral outline CTA)
|
||||
- Script generated and captured in session documentation, discarded after
|
||||
- Tradeoffs: fastest, but team won't benefit next time
|
||||
|
||||
2. **Run now, templatize after resolve** (RECOMMENDED, cyan primary CTA)
|
||||
- Script generated for this ticket; draft template queued
|
||||
- Post-resolve prompt offers to templatize (Section 5.3)
|
||||
- Tradeoffs: zero cognitive overhead now, only templatize what works, ~30s review later
|
||||
|
||||
3. **Build as template now** (purple outline CTA)
|
||||
- Full parameterization upfront
|
||||
- Tradeoffs: immediate team benefit, but adds time mid-ticket
|
||||
|
||||
The drafted script renders as a code preview above the option cards with the AI's proposed parameters highlighted in amber.
|
||||
|
||||
### 3.4 Script Generator integration — post-resolve templatization prompt
|
||||
|
||||

|
||||
|
||||
If the engineer picked Option 2 in the three-option dialog and Resolve succeeds, this prompt appears after the resolution note is posted to CW:
|
||||
|
||||
- Success banner confirms the resolution posted
|
||||
- Templatize card shows the script with AI-proposed parameters substituted in as `{{ gateway_host }}`, etc.
|
||||
- Right pane lists extracted parameters with remove buttons (engineer can adjust)
|
||||
- Provenance note: *"generated from CW #48307 · resolved by M. Davis"*
|
||||
- Three actions: Skip / Edit parameters / Save as team template
|
||||
- "Don't ask me again for this team" opt-out in footer
|
||||
|
||||
---
|
||||
|
||||
## 4. Data model changes
|
||||
|
||||
### 4.1 New columns on `ai_sessions`
|
||||
|
||||
```sql
|
||||
ALTER TABLE ai_sessions
|
||||
ADD COLUMN resolution_note_markdown TEXT NULL,
|
||||
ADD COLUMN resolution_note_posted_at TIMESTAMPTZ NULL,
|
||||
ADD COLUMN resolution_note_external_id VARCHAR(128) NULL, -- CW note ID after posting
|
||||
ADD COLUMN escalation_package_markdown TEXT NULL,
|
||||
ADD COLUMN escalation_package_posted_at TIMESTAMPTZ NULL;
|
||||
```
|
||||
|
||||
No migration of `session_type` — the column stays. New sessions all default to the unified FlowPilot type.
|
||||
|
||||
### 4.2 New `session_facts` table (the What we know backing store)
|
||||
|
||||
```sql
|
||||
CREATE TABLE session_facts (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
session_id UUID NOT NULL REFERENCES ai_sessions(id) ON DELETE CASCADE,
|
||||
account_id UUID NOT NULL REFERENCES accounts(id), -- for RLS, per multi-tenant architecture
|
||||
text TEXT NOT NULL,
|
||||
source_type VARCHAR(32) NOT NULL CHECK (source_type IN ('question', 'diagnostic_check', 'user_note', 'ai_synthesis')),
|
||||
source_ref UUID NULL, -- FK to session_questions.id or session_diagnostic_checks.id, null for user_note
|
||||
source_summary TEXT NULL, -- free-text provenance label, e.g. "rules out tenant/license"
|
||||
created_by UUID NOT NULL REFERENCES users(id),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
deleted_at TIMESTAMPTZ NULL
|
||||
);
|
||||
CREATE INDEX idx_session_facts_session ON session_facts(session_id) WHERE deleted_at IS NULL;
|
||||
CREATE INDEX idx_session_facts_account ON session_facts(account_id);
|
||||
```
|
||||
|
||||
**Important:** `source_ref` is a polymorphic FK and should NOT have a database-level FK constraint. Enforce integrity at the service layer.
|
||||
|
||||
### 4.3 New `session_suggested_fixes` table
|
||||
|
||||
```sql
|
||||
CREATE TABLE session_suggested_fixes (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
session_id UUID NOT NULL REFERENCES ai_sessions(id) ON DELETE CASCADE,
|
||||
account_id UUID NOT NULL REFERENCES accounts(id),
|
||||
title VARCHAR(200) NOT NULL,
|
||||
description TEXT NOT NULL,
|
||||
confidence_pct INTEGER NOT NULL CHECK (confidence_pct BETWEEN 0 AND 100),
|
||||
script_template_id UUID NULL REFERENCES script_templates(id), -- null if no template match
|
||||
ai_drafted_script TEXT NULL, -- populated if no template match
|
||||
ai_drafted_parameters JSONB NULL, -- AI's proposed parameterization
|
||||
user_decision VARCHAR(32) NULL CHECK (user_decision IN ('one_off', 'draft_template', 'build_template', 'dismissed')),
|
||||
superseded_at TIMESTAMPTZ NULL, -- set when a new suggestion replaces this one
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX idx_session_suggested_fixes_session ON session_suggested_fixes(session_id) WHERE superseded_at IS NULL;
|
||||
```
|
||||
|
||||
A session can have multiple suggested fixes over time as the AI's understanding evolves. Only one is active (superseded_at IS NULL) at a time.
|
||||
|
||||
### 4.4 New `draft_templates` table
|
||||
|
||||
Backing store for Option 2 in the three-option dialog — scripts generated during sessions that are pending templatization.
|
||||
|
||||
```sql
|
||||
CREATE TABLE draft_templates (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
account_id UUID NOT NULL REFERENCES accounts(id),
|
||||
source_session_id UUID NOT NULL REFERENCES ai_sessions(id),
|
||||
source_user_id UUID NOT NULL REFERENCES users(id),
|
||||
script_body TEXT NOT NULL,
|
||||
proposed_parameters JSONB NOT NULL, -- {"parameters": [{"key": "...", "label": "...", "type": "..."}]}
|
||||
proposed_name VARCHAR(200) NULL,
|
||||
proposed_category_id UUID NULL REFERENCES script_categories(id),
|
||||
status VARCHAR(32) NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'accepted', 'rejected')),
|
||||
resolved_at TIMESTAMPTZ NULL, -- when the user acted on the draft
|
||||
promoted_template_id UUID NULL REFERENCES script_templates(id), -- if accepted, the created template
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
```
|
||||
|
||||
Accepted draft templates produce a new `script_templates` row and record the source session for provenance display.
|
||||
|
||||
### 4.5 Extension to `script_templates`
|
||||
|
||||
```sql
|
||||
ALTER TABLE script_templates
|
||||
ADD COLUMN source_session_id UUID NULL REFERENCES ai_sessions(id),
|
||||
ADD COLUMN source_user_id UUID NULL REFERENCES users(id),
|
||||
ADD COLUMN source_ticket_ref VARCHAR(64) NULL; -- e.g. "CW #48307" for display
|
||||
```
|
||||
|
||||
These fields power the provenance chip in the Script Library: *"generated from CW #48307 · resolved by M. Davis · used 7 times"*.
|
||||
|
||||
### 4.6 Per-account settings
|
||||
|
||||
```sql
|
||||
ALTER TABLE account_settings
|
||||
ADD COLUMN templatize_prompt_enabled BOOLEAN NOT NULL DEFAULT true;
|
||||
```
|
||||
|
||||
Controls whether the post-resolve templatize prompt appears. Toggleable from the prompt's footer ("Don't ask me again for this team") and from admin settings.
|
||||
|
||||
---
|
||||
|
||||
## 5. API endpoints
|
||||
|
||||
All endpoints follow ResolutionFlow conventions: `/api/v1/` prefix, JWT auth, tenant-scoped via RLS.
|
||||
|
||||
### 5.1 Session facts
|
||||
|
||||
```
|
||||
GET /api/v1/sessions/{id}/facts List facts for a session (ordered by created_at ASC)
|
||||
POST /api/v1/sessions/{id}/facts Create a manual fact (user_note source_type)
|
||||
PATCH /api/v1/sessions/{id}/facts/{fact_id} Edit fact text or summary (only for user_note or AI-synthesized facts)
|
||||
DELETE /api/v1/sessions/{id}/facts/{fact_id} Soft-delete
|
||||
POST /api/v1/sessions/{id}/facts/promote Promote a question answer or check result to a fact
|
||||
Body: { source_type, source_ref, proposed_text, proposed_summary }
|
||||
Returns the created fact. Used by the AI synthesis flow and
|
||||
by the engineer's explicit "promote to What we know" action.
|
||||
```
|
||||
|
||||
### 5.2 Suggested fixes
|
||||
|
||||
```
|
||||
GET /api/v1/sessions/{id}/suggested-fixes/active Returns the current active fix (superseded_at IS NULL) or 404
|
||||
POST /api/v1/sessions/{id}/suggested-fixes/{fix_id}/decision
|
||||
Body: { decision: "one_off" | "draft_template" | "build_template" | "dismissed" }
|
||||
Records the user's path choice. Server-side side effects:
|
||||
- one_off: generates script via ScriptTemplateEngine, returns rendered script
|
||||
- draft_template: same as one_off, plus creates draft_templates row
|
||||
- build_template: redirects to full template creation flow
|
||||
- dismissed: marks fix as superseded
|
||||
```
|
||||
|
||||
### 5.3 Draft templates (post-resolve flow)
|
||||
|
||||
```
|
||||
GET /api/v1/draft-templates List pending drafts for the current user's account
|
||||
(used by the Script Library "X scripts ready to review" notification)
|
||||
GET /api/v1/draft-templates/{id} Get a single draft including its proposed parameterization
|
||||
POST /api/v1/draft-templates/{id}/accept Body: { name, category_id, parameters_schema, edits }
|
||||
Creates a new script_templates row with source_session_id set,
|
||||
sets draft status to 'accepted', returns the new template
|
||||
POST /api/v1/draft-templates/{id}/reject Sets status to 'rejected'
|
||||
```
|
||||
|
||||
### 5.4 Resolution notes and escalation packages
|
||||
|
||||
```
|
||||
POST /api/v1/sessions/{id}/resolution-note/preview Generates the draft resolution note from current session state
|
||||
WITHOUT posting. Returns { markdown, target_ticket_ref }.
|
||||
Called when the task lane renders and refreshed whenever
|
||||
facts/suggested fix change.
|
||||
POST /api/v1/sessions/{id}/resolution-note/post Body: { markdown } (engineer-edited version)
|
||||
Posts to the linked PSA ticket, updates ticket status if configured,
|
||||
marks session resolved.
|
||||
POST /api/v1/sessions/{id}/escalation-package/preview Same pattern for escalation
|
||||
POST /api/v1/sessions/{id}/escalation-package/post Posts and transitions session to escalated state
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Services to implement
|
||||
|
||||
### 6.1 `FactSynthesisService` (new)
|
||||
|
||||
**Location:** `services/fact_synthesis_service.py`
|
||||
|
||||
**Purpose:** Converts question answers and diagnostic check results into candidate facts. Called by the AI pipeline when the LLM emits a `[PROMOTE]` marker, and by explicit engineer action.
|
||||
|
||||
**Key methods:**
|
||||
- `synthesize_from_question(question_id: UUID, raw_answer: str) -> dict` — returns `{proposed_text, proposed_summary}` via LLM call. The summary is the short provenance label ("rules out tenant/license").
|
||||
- `synthesize_from_check(check_id: UUID, check_output: str) -> dict` — same pattern for diagnostic check output.
|
||||
- `create_fact(session_id, source_type, source_ref, text, summary, user_id) -> SessionFact` — persists the fact.
|
||||
|
||||
**Prompt engineering note:** The synthesis prompt should be conservative — short, factual statements. Hallucinated specifics are a trust-killer. The prompt must explicitly instruct: *"Use only information present in the answer/output. If the answer does not contain a substantive fact, return null."*
|
||||
|
||||
### 6.2 `ResolutionNoteGeneratorService` (new)
|
||||
|
||||
**Location:** `services/resolution_note_generator.py`
|
||||
|
||||
**Purpose:** Produces the structured resolution note markdown from session state.
|
||||
|
||||
**Input:** session_id
|
||||
**Output:** `{markdown: str, target_ticket_ref: str | None}`
|
||||
|
||||
**Template structure:**
|
||||
```markdown
|
||||
## Problem
|
||||
{ai-synthesized one-paragraph problem statement, pulling from session description + incident header}
|
||||
|
||||
## What we confirmed
|
||||
{bulleted list of session_facts, grouped by source_type}
|
||||
|
||||
## Root cause
|
||||
{ai-synthesized from the active suggested fix + facts}
|
||||
|
||||
## Resolution
|
||||
{description of the fix applied, parameters used if a script ran, outcome}
|
||||
```
|
||||
|
||||
The service pulls from four data sources: `ai_sessions`, `session_facts`, `session_suggested_fixes` (active), and `script_generations` (if scripts ran during the session). Passwords in script_generations.parameters_used must be redacted (already a Script Generator pattern per the existing plan).
|
||||
|
||||
**Critical:** This service is called on every fact/suggestion change to keep the preview live. Cache aggressively — LLM calls for every keystroke will blow the budget. Invalidate the cache on any write to session_facts or session_suggested_fixes.
|
||||
|
||||
### 6.3 `EscalationPackageGeneratorService` (new)
|
||||
|
||||
**Location:** `services/escalation_package_generator.py`
|
||||
|
||||
Same structure as ResolutionNoteGenerator but with a handoff-oriented template:
|
||||
|
||||
```markdown
|
||||
## Problem
|
||||
...
|
||||
|
||||
## What we've confirmed
|
||||
...
|
||||
|
||||
## What we've tried
|
||||
{list of diagnostic_checks run with their outcomes, scripts generated}
|
||||
|
||||
## Current hypothesis
|
||||
{active suggested fix description}
|
||||
|
||||
## Suggested next steps
|
||||
{ai-synthesized from the gap between facts and a complete resolution}
|
||||
```
|
||||
|
||||
### 6.4 `TemplateExtractionService` (new)
|
||||
|
||||
**Location:** `services/template_extraction_service.py`
|
||||
|
||||
**Purpose:** Given a concrete rendered script and session context, propose a parameterization.
|
||||
|
||||
**Input:** `{script_body: str, session_context: dict, ticket_context: dict}`
|
||||
**Output:** `{parameters: [{key, label, type, inferred_from}], templated_body: str}`
|
||||
|
||||
**Implementation approach:**
|
||||
- LLM call with a structured prompt: "Given this script that resolved a ticket, identify values that would change for a different invocation. Propose a parameter schema following the Script Generator conventions (text / password / select / boolean / multi_text / number / textarea)."
|
||||
- Post-process to ensure the proposed template renders back to the original script when given the extracted parameter values.
|
||||
- Conservative default: prefer fewer parameters. If a value looks environment-agnostic (e.g. a command name), don't parameterize it.
|
||||
|
||||
This service is the engine behind Option 2 and Option 3 of the three-option dialog, and behind the post-resolve templatize prompt.
|
||||
|
||||
### 6.5 Extend `PSAWritebackService` (existing)
|
||||
|
||||
Add methods:
|
||||
- `post_resolution_note(session_id, markdown) -> {external_id, posted_at}`
|
||||
- `post_escalation_package(session_id, markdown) -> {external_id, posted_at}`
|
||||
- `transition_ticket_status(ticket_ref, new_status) -> {success, verified_status}`
|
||||
|
||||
The `transition_ticket_status` method must verify the status change took effect (per the existing ConnectWise integration principle: "never told 'success' when CW silently rejected the change").
|
||||
|
||||
### 6.6 Model selection per service
|
||||
|
||||
Each AI-calling service must use a configurable model string from application settings, not a hardcoded model. Use these defaults:
|
||||
|
||||
```python
|
||||
FACT_SYNTHESIS_MODEL = "claude-haiku-4-5-20251001" # short transformation, latency-sensitive
|
||||
RESOLUTION_NOTE_MODEL = "claude-sonnet-4-6" # customer-facing artifact, quality matters
|
||||
ESCALATION_PACKAGE_MODEL = "claude-sonnet-4-6" # same
|
||||
TEMPLATE_EXTRACTION_MODEL = "claude-sonnet-4-6" # creates persistent library artifact
|
||||
MAIN_CONVERSATION_MODEL = "claude-sonnet-4-6" # primary FlowPilot chat
|
||||
```
|
||||
|
||||
Do not hardcode model strings at call sites. Every new service must read from settings with a service-specific key.
|
||||
|
||||
**Instrumentation requirement:** log a `disputed_fact_rate` metric for fact synthesis — the percentage of AI-synthesized facts that engineers subsequently edit or delete. If this exceeds 10% over a 500-session window, escalate `FACT_SYNTHESIS_MODEL` to `claude-sonnet-4-6`. If under 5%, Haiku is performing correctly.
|
||||
|
||||
Do not use Opus 4.7 for any of these services at current scale.
|
||||
|
||||
---
|
||||
|
||||
## 7. Frontend components
|
||||
|
||||
### 7.1 Routes to change
|
||||
|
||||
| Current route | New route | Action |
|
||||
|---|---|---|
|
||||
| `/assistant` | `/pilot` | **Rename** the route. The existing page moves. `/assistant` permanently redirects to `/pilot` with no sunset date. |
|
||||
| `/pilot` (if it exists as a separate guided flow) | REMOVED | Collapse into the unified surface. |
|
||||
| `/pilot/session/:id` | `/pilot/session/:id` | No change (this is where the unified session UI lives) |
|
||||
|
||||
Sidebar nav entry renames from "ResolutionAssist" to "FlowPilot" with the cockpit icon.
|
||||
|
||||
### 7.2 New React components
|
||||
|
||||
Under `src/components/pilot/`:
|
||||
|
||||
```
|
||||
TaskLane.tsx -- The right-side panel, owns all four sections
|
||||
sections/
|
||||
WhatWeKnow.tsx -- New component for the facts list
|
||||
WhatWeKnowItem.tsx -- Single fact card with provenance line
|
||||
AddNoteButton.tsx -- "+ Add a note" inline composer
|
||||
Questions.tsx -- Existing questions rendering (moved if already present)
|
||||
DiagnosticChecks.tsx -- Existing checks rendering (moved if already present)
|
||||
SuggestedFix.tsx -- New or refactored component for the suggested fix card
|
||||
ResolveButton.tsx -- The Resolve CTA at the bottom of the task lane
|
||||
ResolutionNotePreview.tsx -- Floating popover anchored to Resolve button
|
||||
EscalatePackagePreview.tsx -- Same pattern for Escalate
|
||||
|
||||
ScriptGenInline/ -- Script Generator embedded in session context
|
||||
TemplateMatchPanel.tsx -- Scene 1 mockup: template pre-filled
|
||||
NoTemplateDialog.tsx -- Scene 2 mockup: three-option dialog
|
||||
TemplatizePrompt.tsx -- Scene 3 mockup: post-resolve prompt
|
||||
ParameterizationPreview.tsx -- Shared component: script with highlighted params
|
||||
```
|
||||
|
||||
### 7.3 Component behavior contracts
|
||||
|
||||
**`WhatWeKnowItem`**
|
||||
- Props: `{fact: SessionFact, onEdit, onDelete}`
|
||||
- Renders the fact text, a green checkmark, and the provenance line with source-type color coding
|
||||
- Clicking the fact text opens inline edit (only for `user_note` and `ai_synthesis` sources — question/check facts are read-only, edit the source instead)
|
||||
|
||||
**`TaskLane`**
|
||||
- Subscribes to a session state hook that polls for fact / question / check / suggested-fix updates
|
||||
- On any state change, calls `POST /api/v1/sessions/{id}/resolution-note/preview` to refresh the ResolutionNotePreview
|
||||
- Debounce preview refresh to 500ms to avoid LLM spam
|
||||
|
||||
**`NoTemplateDialog`** (three-option dialog)
|
||||
- Props: `{suggestedFix, onDecision}`
|
||||
- Renders the three cards with the middle (draft_template) marked as recommended
|
||||
- `onDecision` posts to `/api/v1/sessions/{id}/suggested-fixes/{fix_id}/decision` and either opens the Script Generator (one_off / draft_template) or navigates to full template creation (build_template)
|
||||
|
||||
**`TemplatizePrompt`**
|
||||
- Rendered after successful Resolve when a draft template exists for the session
|
||||
- Fetches proposed parameters from the draft template record
|
||||
- Save button posts to `/api/v1/draft-templates/{id}/accept`
|
||||
|
||||
---
|
||||
|
||||
## 8. AI prompt changes
|
||||
|
||||
The existing FlowPilot / ResolutionAssist system prompt needs updates to emit the new markers.
|
||||
|
||||
### 8.1 New marker: `[PROMOTE]`
|
||||
|
||||
Used to surface facts to What we know. Syntax:
|
||||
|
||||
```
|
||||
[PROMOTE]
|
||||
source_type: question
|
||||
source_ref: {question_id}
|
||||
text: OWA login and send/receive confirmed working for jsmith
|
||||
summary: rules out tenant/license
|
||||
[/PROMOTE]
|
||||
```
|
||||
|
||||
The AI should emit `[PROMOTE]` blocks in the same message that answers or processes a question/check, so the fact appears in What we know simultaneously with the chat acknowledgment.
|
||||
|
||||
### 8.2 New marker: `[SUGGEST_FIX]`
|
||||
|
||||
```
|
||||
[SUGGEST_FIX]
|
||||
title: Clear cached credentials + rebuild Outlook profile
|
||||
description: Stale cached credential in Credential Manager is holding the pre-reset token...
|
||||
confidence: 94
|
||||
script_template_slug: clear-outlook-credentials # or omitted if no template match
|
||||
ai_drafted_script: | # only if no template match
|
||||
# Generated by FlowPilot...
|
||||
...
|
||||
[/SUGGEST_FIX]
|
||||
```
|
||||
|
||||
### 8.3 Removed markers
|
||||
|
||||
The old `[FORK]` marker from the ResolutionAssist prompt is removed. Forks were a Guided-mode concept; in the unified model, they're replaced by Questions with mutually exclusive answer options.
|
||||
|
||||
---
|
||||
|
||||
## 9. Implementation phases
|
||||
|
||||
Each phase ends with a git commit and verification step. Do not advance to the next phase until verification passes.
|
||||
|
||||
### Phase 0 — Prompt caching infrastructure (prerequisite)
|
||||
|
||||
A codebase audit revealed that prompt caching is only implemented in `assistant_chat_service.py` (the file being deprecated). Every other Anthropic API call site — including all of FlowPilot's 7 call sites through `AnthropicProvider` — is uncached. Phase 0 must land before Phase 2 starts because new services built in Phase 2 will inherit caching from `AnthropicProvider` automatically once it's fixed.
|
||||
|
||||
**Deliverables:**
|
||||
|
||||
- **0.1** Promote `AnthropicProvider.generate_json()` and `generate_text_stream()` in `ai_provider.py` to the cached pattern currently implemented in `assistant_chat_service.py:_call_anthropic_cached()`. Convert the `system` string parameter to a structured system block list with `cache_control: {"type": "ephemeral"}` on the static portion. Add a second breakpoint on the last history message. For the streaming variant, capture the final usage object via `get_final_message()`. Log `cache_read_input_tokens` and `cache_creation_input_tokens` on every response.
|
||||
- **0.2** Update `integrations.py:557` (`/tickets/ai-parse`) to move the members list and team-stable boards data into a cached system block.
|
||||
|
||||
> **Phase 0.2 — pending target endpoint.** The `/tickets/ai-parse` endpoint described in the original migration doc does not exist in the codebase as of this commit. When this endpoint is built, apply the cached-system-block pattern:
|
||||
>
|
||||
> ```python
|
||||
> system_blocks = [
|
||||
> {"type": "text", "text": members_json, "cache_control": {"type": "ephemeral"}},
|
||||
> # cacheable: team-stable
|
||||
> {"type": "text", "text": boards_json, "cache_control": {"type": "ephemeral"}},
|
||||
> # cacheable: team-stable
|
||||
> {"type": "text", "text": engineer_description},
|
||||
> # uncached: per-request
|
||||
> ]
|
||||
> ```
|
||||
>
|
||||
> Remove this note when the endpoint is implemented and the pattern applied.
|
||||
- **0.3** Add `cache_control` to one-shot generators: `ai_tree_generator`, `kb_conversion`, `ai_fix`, `script_builder`. Same pattern as 0.1.
|
||||
- **0.4** Extract the caching logic from `assistant_chat_service.py:_call_anthropic_cached()` into `AnthropicProvider` and delete `_call_anthropic_cached`. `assistant_chat_service` should call the provider like every other service. This prevents two canonical implementations of the same pattern.
|
||||
|
||||
**Verification:**
|
||||
|
||||
- Hit any FlowPilot endpoint twice within 5 minutes. First call shows `cache_creation_input_tokens > 0`, second call shows `cache_read_input_tokens > 0`.
|
||||
- If the second call returns zero cache reads, inspect the prefix for silent invalidators (timestamps, unsorted JSON keys, varying tool list ordering). Fix before proceeding.
|
||||
|
||||
```
|
||||
git commit -m "feat(ai): promote AnthropicProvider to cached pattern, consolidate caching implementation"
|
||||
```
|
||||
|
||||
**Dependencies:**
|
||||
|
||||
- Phase 1 (route rename and schema) can run in parallel with Phase 0.
|
||||
- Phase 2 (What we know) must not start until Phase 0 is complete and verified.
|
||||
|
||||
### Phase 1 — Data model and route rename (backend + routing only)
|
||||
|
||||
**Deliverables:**
|
||||
- Alembic migration creating `session_facts`, `session_suggested_fixes`, `draft_templates` tables and the column additions to `ai_sessions`, `script_templates`, `account_settings`
|
||||
- All tables include `account_id` and have RLS policies following the multi-tenant architecture (per existing project standard)
|
||||
- `/assistant` → `/pilot` route rename with permanent redirect (stays in place indefinitely; no sunset date)
|
||||
- Sidebar nav entry rename
|
||||
- No UI changes yet beyond the nav label
|
||||
|
||||
**Verification:**
|
||||
- Run migration on a fresh dev database
|
||||
- Confirm RLS policies active via the existing CI grep check for `tenant_filter()`
|
||||
- Navigate to `/assistant` — should 301 to `/pilot`
|
||||
- Navigate to `/pilot` — should render the existing ResolutionAssist UI with the sidebar entry now reading "FlowPilot"
|
||||
|
||||
```
|
||||
git commit -m "feat(pilot): rename /assistant to /pilot, add session_facts + suggested_fixes + draft_templates schema"
|
||||
```
|
||||
|
||||
### Phase 2 — What we know (task lane + service + API)
|
||||
|
||||
**Deliverables:**
|
||||
- `FactSynthesisService` and its LLM prompt
|
||||
- Fact CRUD API endpoints
|
||||
- `WhatWeKnow`, `WhatWeKnowItem`, `AddNoteButton` components
|
||||
- Task lane layout adjustment: What we know section renders above Questions
|
||||
- Counter in task lane header updates to `X / Y answered` format
|
||||
- AI prompt updated to emit `[PROMOTE]` markers; backend parses them and creates facts
|
||||
|
||||
**Verification:**
|
||||
- Open a session, answer a question; within 2 seconds a fact should appear in What we know with correct provenance
|
||||
- Click "+ Add a note", type a manual fact, confirm it appears with `source_type: user_note`
|
||||
- Run a diagnostic check, confirm the check result promotes to a fact
|
||||
- Facts persist across page reloads
|
||||
- RLS: a user from a different account cannot read or write facts for this session
|
||||
|
||||
```
|
||||
git commit -m "feat(pilot): add What we know section with fact synthesis"
|
||||
```
|
||||
|
||||
### Phase 3 — Suggested fix + resolution note preview
|
||||
|
||||
**Deliverables:**
|
||||
- `session_suggested_fixes` API endpoints and data flow
|
||||
- `SuggestedFix` component in the task lane
|
||||
- AI prompt updated to emit `[SUGGEST_FIX]` markers
|
||||
- `ResolutionNoteGeneratorService` and preview endpoint
|
||||
- `ResolutionNotePreview` floating popover anchored to Resolve button
|
||||
- Preview refreshes on fact / suggested-fix changes (debounced)
|
||||
|
||||
**Verification:**
|
||||
- Session with ≥3 facts and an active suggested fix shows a populated Resolve preview
|
||||
- Editing a fact updates the preview within 1 second
|
||||
- Preview markdown renders correctly with all four sections (Problem / What we confirmed / Root cause / Resolution)
|
||||
- Preview contains no hallucinated information not present in session state (human review of 5 real-ish sessions)
|
||||
|
||||
```
|
||||
git commit -m "feat(pilot): add suggested fix tracking and Resolve note preview"
|
||||
```
|
||||
|
||||
### Phase 4 — Resolve and Escalate PSA writebacks
|
||||
|
||||
**Deliverables:**
|
||||
- `transition_ticket_status` method with CW verification
|
||||
- `post_resolution_note` endpoint and CW integration
|
||||
- Resolve button fires: post note → transition status → mark session resolved → show templatize prompt (if applicable)
|
||||
- `EscalationPackageGeneratorService` and parallel flow for Escalate
|
||||
- Escalate button fires: post package → transition status → mark session escalated → route via CW rules
|
||||
|
||||
**Verification:**
|
||||
- Complete a session end-to-end with a ConnectWise test instance
|
||||
- Click Resolve, edit the preview, confirm post — verify the note appears in CW and status changes to Resolved
|
||||
- Click Escalate on a different session — verify the package is posted and the ticket routes correctly
|
||||
- Attempt to Resolve without a linked PSA ticket — should mark the session resolved without erroring, note stored in `resolution_note_markdown` only
|
||||
|
||||
```
|
||||
git commit -m "feat(pilot): wire Resolve and Escalate to ConnectWise writeback"
|
||||
```
|
||||
|
||||
### Phase 5 — Script Generator inline integration
|
||||
|
||||
**Deliverables:**
|
||||
- `ScriptGenInline/TemplateMatchPanel` — when suggested fix has `script_template_id`, clicking the fix opens this panel with pre-filled parameters from session context
|
||||
- Parameter pre-fill logic: pulls from session facts, ticket context (company configs), and AI-suggested values in the `[SUGGEST_FIX]` marker
|
||||
- `ScriptGenInline/NoTemplateDialog` — three-option dialog when no template match
|
||||
- User decision persisted on `session_suggested_fixes.user_decision`
|
||||
- `TemplateExtractionService` for generating parameterization proposals
|
||||
- Script generation flow produces a `script_generations` record linked to the session (existing Script Generator behavior)
|
||||
|
||||
**Verification:**
|
||||
- Session with a template-matched suggested fix: clicking opens generator with ≥2 pre-filled parameters
|
||||
- Session with a custom script suggested fix: dialog appears with three options, script preview shows parameters highlighted
|
||||
- All three paths end correctly: one-off generates and closes, draft creates `draft_templates` row and generates, build_template opens full template creation
|
||||
- `⌘K` → "script" anywhere in a session opens the generator directly
|
||||
|
||||
```
|
||||
git commit -m "feat(pilot): integrate Script Generator inline with suggested fixes"
|
||||
```
|
||||
|
||||
### Phase 6 — Post-resolve templatize prompt
|
||||
|
||||
**Deliverables:**
|
||||
- `TemplatizePrompt` component
|
||||
- Logic: after Resolve success, check for pending `draft_templates` rows for this session; if any, show the prompt
|
||||
- Accept flow creates a new `script_templates` row with `source_session_id`, `source_user_id`, `source_ticket_ref` set
|
||||
- "Don't ask me again" writes to `account_settings.templatize_prompt_enabled`
|
||||
- Script Library sidebar shows a small badge when `draft_templates` with `status='pending'` exist for the current user
|
||||
|
||||
**Verification:**
|
||||
- Resolve a session where the engineer picked Option 2 — templatize prompt appears with AI-proposed parameters
|
||||
- Accept the prompt — new template appears in the Script Library with the provenance chip ("generated from CW #...")
|
||||
- Skip the prompt — draft marked rejected, Script Library shows no new template
|
||||
- Toggle "don't ask me again" — next session Resolve skips the prompt even with a pending draft
|
||||
|
||||
```
|
||||
git commit -m "feat(pilot): add post-resolve templatize prompt for draft templates"
|
||||
```
|
||||
|
||||
### Phase 7 — Polish
|
||||
|
||||
**Deliverables:**
|
||||
- Visual polish against the mockup files (spacing, colors, animations)
|
||||
- Loading states for LLM calls (fact synthesis, preview generation, template extraction)
|
||||
- Empty states (new session with no facts yet, no active suggested fix, no draft templates pending)
|
||||
- Keyboard shortcuts: `⌘K` (command menu), `⌘↵` (send composer), `⌘G` (generator), `⌘R` (resolve with confirm)
|
||||
- Responsive behavior: task lane collapses on <1200px viewports into a bottom drawer
|
||||
|
||||
**Verification:**
|
||||
- Compare each major screen side-by-side with the mockup PNG files — colors, spacing, typography within 5px / exact color match
|
||||
- All flows work on a 1280px viewport without horizontal scroll
|
||||
- Keyboard shortcuts documented in-app via `?` overlay
|
||||
|
||||
```
|
||||
git commit -m "feat(pilot): visual polish and keyboard shortcuts"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. Design system reference
|
||||
|
||||
All components must use the existing ResolutionFlow design system. Pulling the key tokens from the mockup CSS for quick reference — these should already exist in your tokens file; if they don't, add them:
|
||||
|
||||
```css
|
||||
/* Backgrounds */
|
||||
--bg-0: #070b12; /* page background */
|
||||
--bg-1: #0d131c; /* sidebar / chrome */
|
||||
--bg-2: #121a25; /* card / bubble background */
|
||||
--bg-3: #1a2332; /* raised element */
|
||||
|
||||
/* Borders */
|
||||
--border: rgba(148, 163, 184, 0.12);
|
||||
--border-strong: rgba(148, 163, 184, 0.22);
|
||||
|
||||
/* Text */
|
||||
--text-primary: #e2e8f0;
|
||||
--text-secondary: #94a3b8;
|
||||
--text-tertiary: #64748b;
|
||||
|
||||
/* Brand cyan (FlowPilot accent) */
|
||||
--cyan-400: #22d3ee;
|
||||
--cyan-500: #06b6d4;
|
||||
--cyan-600: #0891b2;
|
||||
--cyan-bg: rgba(34, 211, 238, 0.10);
|
||||
--cyan-border: rgba(34, 211, 238, 0.30);
|
||||
|
||||
/* Semantic */
|
||||
--success: #34d399; /* Resolve, facts */
|
||||
--warning: #fbbf24; /* Escalate, proposed parameters */
|
||||
--danger: #f87171;
|
||||
--purple: #a78bfa; /* Script Generator / templates */
|
||||
```
|
||||
|
||||
**Typography:**
|
||||
- Body: IBM Plex Sans, 14px/1.5
|
||||
- Headings: Bricolage Grotesque, 500 weight, -0.01em letter-spacing
|
||||
- Code: JetBrains Mono
|
||||
|
||||
**Icons:** Phosphor Icons (Duotone) per the memory-recorded design decision to migrate off Lucide.
|
||||
|
||||
---
|
||||
|
||||
## 11. Non-goals for this migration
|
||||
|
||||
Do not build these as part of this work. They belong to later phases of the roadmap.
|
||||
|
||||
- **Confidence tiers (Discovery / Exploring / Guided).** We explicitly removed these. The task lane itself is the progress signal.
|
||||
- **Mode toggle between Guided and Quick ask.** There is one mode.
|
||||
- **"Convert to guided" promotion flow.** No longer applicable.
|
||||
- **Team Wiki compilation from resolved sessions.** Tracked separately; depends on this migration but is not part of it.
|
||||
- **SharePoint integration.** Sequenced after ConnectWise per roadmap.
|
||||
- **Template marketplace / sharing across accounts.** Tracked under Client Context System roadmap item.
|
||||
|
||||
---
|
||||
|
||||
## 12. Risks and mitigations
|
||||
|
||||
| Risk | Mitigation |
|
||||
|---|---|
|
||||
| LLM fact synthesis hallucinates specifics not in the answer | Conservative prompt; engineer can edit/delete any AI-synthesized fact; provenance line shows the source so the engineer can verify |
|
||||
| Resolution note preview LLM cost at scale | Cache aggressively, invalidate only on session state write; debounce UI updates to 500ms; consider lower-tier model for preview generation (final post-to-CW version can use the better model) |
|
||||
| ConnectWise silently rejects status change | `transition_ticket_status` must re-fetch and verify; fail loudly if the change didn't stick |
|
||||
| Template extraction proposes bad parameterization | Engineer reviews before saving; draft templates never silently become real templates; provenance chip lets team admins audit |
|
||||
| Users lose muscle memory from `/assistant` → `/pilot` rename | Permanent redirect (no sunset date); inline toast on first `/pilot` visit explaining the rename |
|
||||
| Existing sessions have no `session_facts` entries, so What we know is empty | Acceptable — Phase 2 deliberately does not backfill; facts only accumulate for new or ongoing sessions after deploy. Document in release notes. |
|
||||
|
||||
---
|
||||
|
||||
## 13. Questions for Michael before implementation starts
|
||||
|
||||
These are the decisions Claude Code cannot make unilaterally. Answer these inline in the doc or in chat before kicking off Phase 1.
|
||||
|
||||
1. **Keyboard shortcut for Resolve** — I've proposed `⌘R` (with a confirm). Browsers intercept `⌘R` for page reload. Alternative: `⌘⇧R` or no shortcut. Preference?
|
||||
2. **Default `templatize_prompt_enabled` value** — I defaulted to `true`. If your beta testers find it annoying we'll learn fast, but it's a tradeoff between "every engineer sees the prompt" and "feature gets discovered only by those who know about it".
|
||||
3. **Resolution note posts immediately, or stage for review?** — Current design: engineer edits preview inline, clicks Confirm & post. Alternative: stage in CW as draft note for a supervisor to approve before posting. Affects MSPs with strict compliance.
|
||||
|
||||
---
|
||||
|
||||
## End of document
|
||||
960
docs/FlowAssist_Migration/FLOWPILOT-MIGRATION.md
Normal file
960
docs/FlowAssist_Migration/FLOWPILOT-MIGRATION.md
Normal file
@@ -0,0 +1,960 @@
|
||||
# FlowPilot Migration — Design & Implementation Doc
|
||||
|
||||
> **Target:** Transform `/assistant` (ResolutionAssist) into the new unified `/pilot` (FlowPilot) surface.
|
||||
> **Audience:** Claude Code (implementation) and Codex (review) reviewed by Michael (owner).
|
||||
> **Status:** Phase 0 in progress. Phases 1–7 awaiting Phase 0 completion for the AI-dependent work; Phase 1 can run in parallel.
|
||||
> **Last updated:** April 17, 2026 (post-Codex plan review, reflects Phase 0 audit findings and in-flight implementation decisions)
|
||||
|
||||
---
|
||||
|
||||
## 0. Prerequisite reading for Claude Code
|
||||
|
||||
Before writing any code for Phase 1 or later, read these in order:
|
||||
|
||||
1. This document end-to-end.
|
||||
2. `mockups/01-session-primary.png` — the target state for the main session UI.
|
||||
3. `mockups/02-script-template-match.png`, `03-script-three-options.png`, `04-script-templatize-prompt.png` — Script Generator integration states.
|
||||
4. The source HTML files `mockups/01-session-primary.html` and `mockups/02-04-script-integration.html` — authoritative for spacing, colors, and component structure. When CSS or layout questions arise during implementation, these files are the tiebreaker.
|
||||
|
||||
Do not proceed to implementation until you have confirmed you understand the following three architectural claims. If any of them are unclear, stop and ask.
|
||||
|
||||
1. **There is one AI troubleshooting surface, not two.** The existing split between FlowPilot (guided) and ResolutionAssist (chat) is collapsed into a single chat-primary product called FlowPilot at `/pilot`. The `ai_sessions.session_type` discriminator column is retained for data compatibility, but the product shows one unified UI and no new code branches on `session_type` for UI routing.
|
||||
2. **The task lane is the load-bearing structural feature.** It is not a sidebar of metadata. It actively tracks diagnostic state: *What we know*, *Questions*, *Diagnostic checks*, *Suggested fix*. Engineers interact with it; facts flow between sections.
|
||||
3. **Resolve and Escalate are deterministic artifact generators, not free-text prompts.** When an engineer clicks Resolve, a structured summary is generated from task lane state (not from the chat transcript alone) and posted to CW. The summary structure is fixed: *Problem / What we confirmed / Root cause / Resolution*.
|
||||
|
||||
### 0.1 Spec drift note for reviewers
|
||||
|
||||
This document was originally written against a set of assumptions about the codebase that turned out to be partially incorrect. Two audits (Claude Code's Phase 0 audit and the Codex plan review) surfaced drift. Key corrections already integrated:
|
||||
|
||||
- **API namespace is `/api/v1/ai-sessions/{id}/...`**, not the doc's original `/api/v1/sessions/{id}/...`. All route references below reflect this.
|
||||
- **`pending_task_lane` items do not have stable IDs today.** Phase 2 must assign stable UUIDs when questions/checks are first persisted. `session_facts.source_ref` points to those JSON item IDs.
|
||||
- **`account_settings` table did not exist.** Phase 1 creates it with a JSONB `preferences` column; settings live in `preferences` until they need their own column.
|
||||
- **`/tickets/ai-parse` endpoint does not exist.** Phase 0.2 became a doc-only note; no code change.
|
||||
|
||||
Any further drift found during implementation should be flagged by the implementer and reconciled in this doc before writing code that assumes the drifted spec.
|
||||
|
||||
---
|
||||
|
||||
## 1. Why this change
|
||||
|
||||
### The current state
|
||||
|
||||
- `/assistant` is a chat-primary AI session with a `[QUESTIONS]` and `[DIAGNOSTIC_CHECKS]` task lane.
|
||||
- `/pilot` was specced as a separate guided, confidence-tiered wizard with a different UI and lifecycle.
|
||||
- The `FLOWPILOT-AND-RESOLUTIONASSIST.md` design document treated them as two products sharing a backend.
|
||||
|
||||
### The problems with the current state
|
||||
|
||||
- Two sidebar entries, two session histories, two mental models for engineers to learn.
|
||||
- The PSA integration scope doubles (writebacks for lifecycle events must be built twice, or built for Pilot and bolted onto Assist).
|
||||
- The Team Wiki moat depends on structured session artifacts with explicit resolutions — a chat-only mode produces weaker artifacts.
|
||||
- The cockpit positioning (the core ResolutionFlow brand promise) does not map to a blank chat window.
|
||||
- Branching into two modes forces a decision onto the engineer ("which mode for this ticket?") that has no right answer.
|
||||
|
||||
### The resolution
|
||||
|
||||
The existing `/assistant` UI already does most of what `/pilot` was supposed to do — structured questions, diagnostic checks, lifecycle actions in the header. It is closer to the right product than the original doc anticipated. Rather than building Pilot as a second surface, we extend Assist with the missing structural features (*What we know*, auto-generated summaries, escalation packages) and rename it FlowPilot.
|
||||
|
||||
### The strategic move
|
||||
|
||||
FlowPilot becomes the single canonical troubleshooting surface. Every PSA writeback, every Wiki compilation path, every Script Generator invocation points here. One session shape, one lifecycle, one integration surface.
|
||||
|
||||
---
|
||||
|
||||
## 2. Terminology used in this document
|
||||
|
||||
| Term | Meaning |
|
||||
|---|---|
|
||||
| **Session** | A single `ai_sessions` row representing one troubleshooting conversation. |
|
||||
| **Task lane** | The right-side panel containing What we know, Questions, Diagnostic checks, Suggested fix. |
|
||||
| **Task lane item ID** | A stable UUID assigned to each question / action / check inside `ai_sessions.pending_task_lane` when first persisted. `session_facts.source_ref` points to these. |
|
||||
| **Fact** | An item in the What we know section. Has `text`, `source_type` (`question` / `diagnostic_check` / `user_note` / `ai_synthesis`), and `source_ref` (task lane item ID, or null for `user_note` and `ai_synthesis`). |
|
||||
| **Suggested fix** | The AI's current best-guess resolution path. Has a confidence score and, optionally, a reference to a Script Library template. |
|
||||
| **Promotion** | The act of a question answer or diagnostic check result being converted into a fact in What we know. Triggered by AI (via `[PROMOTE]` marker), confirmed/editable by engineer. |
|
||||
| **Resolution note** | The structured document generated when the engineer clicks Resolve. Posted to CW as a ticket note. |
|
||||
| **Escalation package** | The structured handoff document generated when the engineer clicks Escalate. Posted to CW and attached to the session for the next engineer. |
|
||||
| **Draft template** | A script generated during a session where the engineer chose "Run now, templatize after resolve." Lives in `draft_templates` until accepted or rejected. |
|
||||
|
||||
---
|
||||
|
||||
## 3. Target UI — annotated
|
||||
|
||||
### 3.1 Primary session view
|
||||
|
||||

|
||||
|
||||
The session UI is a four-column layout:
|
||||
|
||||
1. **Icon rail** (64px wide) — primary app navigation. FlowPilot / Tickets / Trees / Scripts / Wiki. Avatar at bottom.
|
||||
2. **Session list** (260px wide) — all sessions grouped by state (Active / Recent). Each row shows title, state dot, PSA ticket number, and client name.
|
||||
3. **Conversation column** (fluid) — the chat thread, composer, and incident header.
|
||||
4. **Task lane** (380px wide) — *What we know*, *Questions*, *Diagnostic checks*, *Suggested fix*, and the Resolve action at the bottom.
|
||||
|
||||
Key visual and behavioral elements numbered against the mockup:
|
||||
|
||||
**Incident header (top of conversation column)**
|
||||
- PSA chip showing `CW #48291` in cyan, monospaced
|
||||
- Client / contact / priority meta line
|
||||
- Incident title in Bricolage Grotesque 19px
|
||||
- Four lifecycle buttons right-aligned: **Pause** (ghost), **Share update** (neutral), **Escalate** (amber), **Resolve** (green)
|
||||
|
||||
**Conversation column**
|
||||
- Standard chat thread with pilot and user avatars
|
||||
- Pilot uses cyan gradient avatar; user uses purple gradient
|
||||
- AI messages in `bg-2` bubbles with subtle border; user messages in cyan-tinted bubbles
|
||||
- Composer at bottom with inline action chips (Attach / Paste logs / Ticket context) and a send button
|
||||
|
||||
**Task lane sections, in order:**
|
||||
|
||||
1. **What we know** (NEW)
|
||||
- Header: `WHAT WE KNOW · 4` (section title + count)
|
||||
- Each fact is a card: `bg-2` background, dashed circular green check, fact text, and a provenance line (`from question · rules out tenant/license`)
|
||||
- "+ Add a note" button at the bottom for manual facts from the engineer
|
||||
- Background has a subtle green-to-transparent gradient to visually distinguish from the rest of the lane
|
||||
- Fact editability: **facts sourced from questions or diagnostic checks are read-only at the fact card level** (edit the source question/check instead); **manual notes and AI-synthesis facts are editable**
|
||||
|
||||
2. **Questions**
|
||||
- Header: `QUESTIONS · 2 unanswered`
|
||||
- Each unanswered question: title, AI hint text, Answer / Skip buttons
|
||||
- Answered questions dim to 55% opacity with a dashed border and show the resolution inline (`Answered · isolated to jsmith (promoted to What we know)`)
|
||||
|
||||
3. **Diagnostic checks**
|
||||
- Header: `DIAGNOSTIC CHECKS · 1 / 3 run`
|
||||
- "Run remaining 2 checks" button at top when applicable
|
||||
- Each check: icon + command name (monospaced), description
|
||||
- Completed checks dim and show "Complete · findings promoted to What we know" in green
|
||||
|
||||
4. **Suggested fix**
|
||||
- Header: `SUGGESTED FIX · 94% confidence`
|
||||
- Amber-accented card with fix title and description
|
||||
- Clicking opens the Script Generator flow (Section 5)
|
||||
|
||||
**Resolve action bar (bottom of task lane)**
|
||||
- Small hint text ("Summary preview is open →")
|
||||
- Full-width "Resolve & post to CW" button in green
|
||||
|
||||
**Resolution note preview (floating, anchored to Resolve button)**
|
||||
- A persistent popover, NOT a modal
|
||||
- Shows the draft resolution note with Problem / What we confirmed / Root cause / Resolution sections
|
||||
- Displays the target ticket (`CW #48291`) and status change (`Resolved`)
|
||||
- Edit button opens an inline editor; Confirm & post fires the PSA writeback
|
||||
|
||||
### 3.2 Script Generator integration — template match
|
||||
|
||||

|
||||
|
||||
When the suggested fix references an existing Script Library template, clicking the fix opens the Script Generator panel in place of (or sliding over) the task lane. Key behavior:
|
||||
|
||||
- A **Verified template** badge appears above the parameter form
|
||||
- Parameters pre-filled from session context get a cyan `from session` tag and a cyan-tinted input background
|
||||
- Each pre-filled parameter has a hint line explaining the source: *"Pulled from CW company config for Acme Corp"*
|
||||
- The engineer can adjust any pre-filled value before generating
|
||||
- `⌘K` → "script" invokes the generator mid-conversation from anywhere in the session
|
||||
|
||||
### 3.3 Script Generator integration — no template match (three-option dialog)
|
||||
|
||||

|
||||
|
||||
When no template matches the suggested fix, FlowPilot drafts a session-specific script and presents three paths:
|
||||
|
||||
1. **Run as one-off** (neutral outline CTA)
|
||||
- Script generated and captured in session documentation, discarded after
|
||||
- Tradeoffs: fastest, but team won't benefit next time
|
||||
|
||||
2. **Run now, templatize after resolve** (RECOMMENDED, cyan primary CTA)
|
||||
- Script generated for this ticket; draft template queued
|
||||
- Post-resolve prompt offers to templatize (Section 3.4)
|
||||
- Tradeoffs: zero cognitive overhead now, only templatize what works, ~30s review later
|
||||
|
||||
3. **Build as template now** (purple outline CTA)
|
||||
- Full parameterization upfront
|
||||
- Tradeoffs: immediate team benefit, but adds time mid-ticket
|
||||
|
||||
The drafted script renders as a code preview above the option cards with the AI's proposed parameters highlighted in amber.
|
||||
|
||||
### 3.4 Script Generator integration — post-resolve templatization prompt
|
||||
|
||||

|
||||
|
||||
If the engineer picked Option 2 in the three-option dialog and Resolve succeeds, this prompt appears after the resolution note is posted to CW:
|
||||
|
||||
- Success banner confirms the resolution posted
|
||||
- Templatize card shows the script with AI-proposed parameters substituted in as `{{ gateway_host }}`, etc.
|
||||
- Right pane lists extracted parameters with remove buttons (engineer can adjust)
|
||||
- Provenance note: *"generated from CW #48307 · resolved by M. Davis"*
|
||||
- Three actions: Skip / Edit parameters / Save as team template
|
||||
- "Don't ask me again for this team" opt-out in footer
|
||||
|
||||
---
|
||||
|
||||
## 4. Data model changes
|
||||
|
||||
### 4.1 New columns on `ai_sessions`
|
||||
|
||||
```sql
|
||||
ALTER TABLE ai_sessions
|
||||
ADD COLUMN resolution_note_markdown TEXT NULL,
|
||||
ADD COLUMN resolution_note_posted_at TIMESTAMPTZ NULL,
|
||||
ADD COLUMN resolution_note_external_id VARCHAR(128) NULL, -- CW note ID after posting
|
||||
ADD COLUMN escalation_package_markdown TEXT NULL,
|
||||
ADD COLUMN escalation_package_posted_at TIMESTAMPTZ NULL,
|
||||
ADD COLUMN escalation_package_external_id VARCHAR(128) NULL,
|
||||
ADD COLUMN state_version INTEGER NOT NULL DEFAULT 0; -- incremented on any write to facts/suggested_fixes/script_generations; drives preview cache invalidation
|
||||
```
|
||||
|
||||
No migration of `session_type` — the column stays for data compatibility. New sessions default to the unified FlowPilot type. Phase 1 does not branch frontend routing on `session_type`.
|
||||
|
||||
### 4.2 New `session_facts` table (the What we know backing store)
|
||||
|
||||
```sql
|
||||
CREATE TABLE session_facts (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
session_id UUID NOT NULL REFERENCES ai_sessions(id) ON DELETE CASCADE,
|
||||
account_id UUID NOT NULL REFERENCES accounts(id), -- for RLS, per multi-tenant architecture
|
||||
text TEXT NOT NULL,
|
||||
source_type VARCHAR(32) NOT NULL CHECK (source_type IN ('question', 'diagnostic_check', 'user_note', 'ai_synthesis')),
|
||||
source_ref UUID NULL, -- task lane item ID (from pending_task_lane JSON), null for user_note and ai_synthesis
|
||||
source_summary TEXT NULL, -- free-text provenance label, e.g. "rules out tenant/license"
|
||||
created_by UUID NOT NULL REFERENCES users(id),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
deleted_at TIMESTAMPTZ NULL
|
||||
);
|
||||
CREATE INDEX idx_session_facts_session ON session_facts(session_id) WHERE deleted_at IS NULL;
|
||||
CREATE INDEX idx_session_facts_account ON session_facts(account_id);
|
||||
```
|
||||
|
||||
**Important:** `source_ref` is a pointer to a JSON item inside `ai_sessions.pending_task_lane` (not a FK to any table). It has no database-level FK constraint. Enforce integrity at the service layer. Phase 2 includes the work of assigning stable UUIDs to task lane items so `source_ref` has something reliable to point to.
|
||||
|
||||
### 4.3 New `session_suggested_fixes` table
|
||||
|
||||
```sql
|
||||
CREATE TABLE session_suggested_fixes (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
session_id UUID NOT NULL REFERENCES ai_sessions(id) ON DELETE CASCADE,
|
||||
account_id UUID NOT NULL REFERENCES accounts(id),
|
||||
title VARCHAR(200) NOT NULL,
|
||||
description TEXT NOT NULL,
|
||||
confidence_pct INTEGER NOT NULL CHECK (confidence_pct BETWEEN 0 AND 100),
|
||||
script_template_id UUID NULL REFERENCES script_templates(id), -- null if no template match
|
||||
ai_drafted_script TEXT NULL, -- populated if no template match
|
||||
ai_drafted_parameters JSONB NULL, -- AI's proposed parameterization
|
||||
user_decision VARCHAR(32) NULL CHECK (user_decision IN ('one_off', 'draft_template', 'build_template', 'dismissed')),
|
||||
superseded_at TIMESTAMPTZ NULL, -- set when a new suggestion replaces this one
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX idx_session_suggested_fixes_session_active ON session_suggested_fixes(session_id) WHERE superseded_at IS NULL;
|
||||
```
|
||||
|
||||
A session can have multiple suggested fixes over time as the AI's understanding evolves. Only one is active (`superseded_at IS NULL`) at a time.
|
||||
|
||||
### 4.4 New `draft_templates` table
|
||||
|
||||
Backing store for Option 2 in the three-option dialog — scripts generated during sessions that are pending templatization.
|
||||
|
||||
```sql
|
||||
CREATE TABLE draft_templates (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
account_id UUID NOT NULL REFERENCES accounts(id),
|
||||
source_session_id UUID NOT NULL REFERENCES ai_sessions(id),
|
||||
source_user_id UUID NOT NULL REFERENCES users(id),
|
||||
script_body TEXT NOT NULL,
|
||||
proposed_parameters JSONB NOT NULL, -- {"parameters": [{"key": "...", "label": "...", "type": "..."}]}
|
||||
proposed_name VARCHAR(200) NULL,
|
||||
proposed_category_id UUID NULL REFERENCES script_categories(id),
|
||||
status VARCHAR(32) NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'accepted', 'rejected')),
|
||||
resolved_at TIMESTAMPTZ NULL, -- when the user acted on the draft
|
||||
promoted_template_id UUID NULL REFERENCES script_templates(id), -- if accepted, the created template
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX idx_draft_templates_account_pending ON draft_templates(account_id) WHERE status = 'pending';
|
||||
```
|
||||
|
||||
Accepted draft templates produce a new `script_templates` row and record the source session for provenance display.
|
||||
|
||||
### 4.5 Extension to `script_templates`
|
||||
|
||||
```sql
|
||||
ALTER TABLE script_templates
|
||||
ADD COLUMN source_session_id UUID NULL REFERENCES ai_sessions(id),
|
||||
ADD COLUMN source_user_id UUID NULL REFERENCES users(id),
|
||||
ADD COLUMN source_ticket_ref VARCHAR(64) NULL; -- e.g. "CW #48307" for display
|
||||
```
|
||||
|
||||
These fields power the provenance chip in the Script Library: *"generated from CW #48307 · resolved by M. Davis · used 7 times"*.
|
||||
|
||||
### 4.6 New `account_settings` table
|
||||
|
||||
The codebase has no existing `account_settings` table. Create it with a JSONB grab-bag column for simple settings, plus room for typed columns as settings graduate to needing their own structure.
|
||||
|
||||
```sql
|
||||
CREATE TABLE account_settings (
|
||||
account_id UUID PRIMARY KEY REFERENCES accounts(id) ON DELETE CASCADE,
|
||||
preferences JSONB NOT NULL DEFAULT '{}',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
```
|
||||
|
||||
**Row lifecycle:** rows are created lazily on first write. A `get_setting(account_id, key, default)` helper returns the default when no row exists — no upfront row creation for every account.
|
||||
|
||||
**Promotion rule:** settings live in `preferences` (keyed JSON) until they meet one of these thresholds:
|
||||
- Accessed in a hot path (frequent reads, latency-sensitive)
|
||||
- Has validation rules that warrant a CHECK constraint
|
||||
- Participates in joins or aggregations
|
||||
|
||||
When a setting graduates, add a typed column in a future migration and update `get_setting` to prefer the typed column over the JSON key.
|
||||
|
||||
**Initial contents:** `templatize_prompt_enabled` lives in `preferences` as `{"templatize_prompt_enabled": true}` (effective default when absent). No column needed.
|
||||
|
||||
---
|
||||
|
||||
## 5. API endpoints
|
||||
|
||||
All endpoints follow ResolutionFlow conventions: `/api/v1/` prefix, JWT auth, tenant-scoped via RLS. **All session-related routes use the `/api/v1/ai-sessions/{id}/...` namespace** to match the existing codebase pattern (not the generic `/sessions/` originally specced).
|
||||
|
||||
### 5.1 Session facts
|
||||
|
||||
```
|
||||
GET /api/v1/ai-sessions/{id}/facts List facts for a session (ordered by created_at ASC)
|
||||
POST /api/v1/ai-sessions/{id}/facts Create a manual fact (user_note source_type)
|
||||
PATCH /api/v1/ai-sessions/{id}/facts/{fact_id} Edit fact text or summary
|
||||
Authorization: only user_note and ai_synthesis facts are editable;
|
||||
question and diagnostic_check facts return 403 (edit the source instead)
|
||||
DELETE /api/v1/ai-sessions/{id}/facts/{fact_id} Soft-delete
|
||||
POST /api/v1/ai-sessions/{id}/facts/promote Promote a question answer or check result to a fact
|
||||
Body: { source_type, source_ref, proposed_text, proposed_summary }
|
||||
Returns the created fact. Used by the AI synthesis flow and by
|
||||
the engineer's explicit "promote to What we know" action.
|
||||
```
|
||||
|
||||
### 5.2 Suggested fixes
|
||||
|
||||
```
|
||||
GET /api/v1/ai-sessions/{id}/suggested-fixes/active Returns the current active fix (superseded_at IS NULL) or 404
|
||||
POST /api/v1/ai-sessions/{id}/suggested-fixes/{fix_id}/decision
|
||||
Body: { decision: "one_off" | "draft_template" | "build_template" | "dismissed" }
|
||||
Records the user's path choice. Server-side side effects:
|
||||
- one_off: generates script via ScriptTemplateEngine, returns rendered script
|
||||
- draft_template: same as one_off, plus creates draft_templates row
|
||||
- build_template: returns redirect payload to full template creation flow
|
||||
- dismissed: marks fix as superseded
|
||||
```
|
||||
|
||||
### 5.3 Draft templates (post-resolve flow)
|
||||
|
||||
```
|
||||
GET /api/v1/draft-templates List pending drafts for the current user's account
|
||||
(used by the Script Library "X scripts ready to review" notification)
|
||||
GET /api/v1/draft-templates/{id} Get a single draft including its proposed parameterization
|
||||
POST /api/v1/draft-templates/{id}/accept Body: { name, category_id, parameters_schema, edits }
|
||||
Creates a new script_templates row with source_session_id set,
|
||||
sets draft status to 'accepted', returns the new template
|
||||
POST /api/v1/draft-templates/{id}/reject Sets status to 'rejected'
|
||||
```
|
||||
|
||||
### 5.4 Resolution notes and escalation packages
|
||||
|
||||
```
|
||||
POST /api/v1/ai-sessions/{id}/resolution-note/preview Generates the draft resolution note from current session state
|
||||
WITHOUT posting. Returns { markdown, target_ticket_ref }.
|
||||
Called when the task lane renders and refreshed whenever
|
||||
facts/suggested fix change. Cached by state_version.
|
||||
POST /api/v1/ai-sessions/{id}/resolution-note/post Body: { markdown } (engineer-edited version)
|
||||
Posts to the linked PSA ticket, updates ticket status if configured,
|
||||
marks session resolved.
|
||||
POST /api/v1/ai-sessions/{id}/escalation-package/preview Same pattern for escalation
|
||||
POST /api/v1/ai-sessions/{id}/escalation-package/post Posts and transitions session to escalated state
|
||||
```
|
||||
|
||||
### 5.5 Preview caching strategy
|
||||
|
||||
The resolution note preview and escalation package preview are LLM-generated and refresh on every fact / suggested-fix / script-generation change. To avoid LLM-per-keystroke cost:
|
||||
|
||||
- **Cache key:** `(session_id, state_version)` where `state_version` is the `ai_sessions.state_version` integer column
|
||||
- **Invalidation:** any write to `session_facts`, `session_suggested_fixes`, or `script_generations` for a session atomically increments `ai_sessions.state_version` (a single SQL UPDATE wrapped into the same transaction)
|
||||
- **Cache backend:** Redis (planned for Session Sharing work; can be in-memory LRU for Phase 3, swapped to Redis when Redis is available)
|
||||
- **Client debounce:** 500ms on the UI side to batch rapid edits before hitting the preview endpoint
|
||||
|
||||
The choice of `state_version` over content hash is deliberate: cheaper to compute (single-integer comparison), easier to debug (logs show explicit version bumps), and makes invalidation failures visible (stale preview would keep showing an old version number).
|
||||
|
||||
---
|
||||
|
||||
## 6. Services to implement
|
||||
|
||||
### 6.1 `FactSynthesisService` (new)
|
||||
|
||||
**Location:** `backend/app/services/fact_synthesis_service.py`
|
||||
|
||||
**Purpose:** Converts question answers and diagnostic check results into candidate facts. Called by `unified_chat_service`'s marker parser when the LLM emits a `[PROMOTE]` marker, and by explicit engineer action.
|
||||
|
||||
**Key methods:**
|
||||
- `synthesize_from_question(question_ref: UUID, raw_answer: str) -> dict` — returns `{proposed_text, proposed_summary}` via LLM call. The summary is the short provenance label ("rules out tenant/license").
|
||||
- `synthesize_from_check(check_ref: UUID, check_output: str) -> dict` — same pattern for diagnostic check output.
|
||||
- `create_fact(session_id, source_type, source_ref, text, summary, user_id) -> SessionFact` — persists the fact, increments `ai_sessions.state_version`.
|
||||
|
||||
**Prompt engineering note:** The synthesis prompt must be conservative. Hallucinated specifics are a trust-killer and would be particularly damaging because facts feed into the resolution note that gets posted to customer tickets. The prompt must explicitly instruct: *"Use only information present in the answer/output. If the answer does not contain a substantive fact, return null."*
|
||||
|
||||
### 6.2 `ResolutionNoteGeneratorService` (new)
|
||||
|
||||
**Location:** `backend/app/services/resolution_note_generator.py`
|
||||
|
||||
**Purpose:** Produces the structured resolution note markdown from session state.
|
||||
|
||||
**Input:** `session_id`
|
||||
**Output:** `{markdown: str, target_ticket_ref: str | None}`
|
||||
|
||||
**Template structure:**
|
||||
```markdown
|
||||
## Problem
|
||||
{ai-synthesized one-paragraph problem statement, pulling from session description + incident header}
|
||||
|
||||
## What we confirmed
|
||||
{bulleted list of session_facts, grouped by source_type}
|
||||
|
||||
## Root cause
|
||||
{ai-synthesized from the active suggested fix + facts}
|
||||
|
||||
## Resolution
|
||||
{description of the fix applied, parameters used if a script ran, outcome}
|
||||
```
|
||||
|
||||
The service pulls from four data sources: `ai_sessions`, `session_facts`, `session_suggested_fixes` (active), and `script_generations` (if scripts ran during the session). Passwords in `script_generations.parameters_used` must be redacted (already an existing Script Generator pattern).
|
||||
|
||||
**Caching:** keyed by `(session_id, ai_sessions.state_version)` per Section 5.5. Debounced client-side at 500ms.
|
||||
|
||||
### 6.3 `EscalationPackageGeneratorService` (new)
|
||||
|
||||
**Location:** `backend/app/services/escalation_package_generator.py`
|
||||
|
||||
Same structure as `ResolutionNoteGenerator` but with a handoff-oriented template:
|
||||
|
||||
```markdown
|
||||
## Problem
|
||||
...
|
||||
|
||||
## What we've confirmed
|
||||
...
|
||||
|
||||
## What we've tried
|
||||
{list of diagnostic_checks run with their outcomes, scripts generated}
|
||||
|
||||
## Current hypothesis
|
||||
{active suggested fix description}
|
||||
|
||||
## Suggested next steps
|
||||
{ai-synthesized from the gap between facts and a complete resolution}
|
||||
```
|
||||
|
||||
Same caching and invalidation model.
|
||||
|
||||
### 6.4 `TemplateExtractionService` (new)
|
||||
|
||||
**Location:** `backend/app/services/template_extraction_service.py`
|
||||
|
||||
**Purpose:** Given a concrete rendered script and session context, propose a parameterization.
|
||||
|
||||
**Input:** `{script_body: str, session_context: dict, ticket_context: dict}`
|
||||
**Output:** `{parameters: [{key, label, type, inferred_from}], templated_body: str}`
|
||||
|
||||
**Implementation approach:**
|
||||
- LLM call with a structured prompt: "Given this script that resolved a ticket, identify values that would change for a different invocation. Propose a parameter schema following the Script Generator conventions (text / password / select / boolean / multi_text / number / textarea)."
|
||||
- Post-process to ensure the proposed template renders back to the original script when given the extracted parameter values.
|
||||
- Conservative default: prefer fewer parameters. If a value looks environment-agnostic (e.g. a command name), don't parameterize it.
|
||||
|
||||
This service is the engine behind Option 2 and Option 3 of the three-option dialog, and behind the post-resolve templatize prompt.
|
||||
|
||||
### 6.5 Extend `PSAWritebackService` (existing)
|
||||
|
||||
Add methods using the existing PSA provider registry and `post_note` seam:
|
||||
- `post_resolution_note(session_id, markdown) -> {external_id, posted_at}`
|
||||
- `post_escalation_package(session_id, markdown) -> {external_id, posted_at}`
|
||||
- `transition_ticket_status(ticket_ref, new_status) -> {success, verified_status}`
|
||||
|
||||
The `transition_ticket_status` method must **verify by re-fetching** the status after the transition attempt. Failed verification is surfaced as an error, not silent success (per the existing ConnectWise integration principle).
|
||||
|
||||
### 6.6 Model and capability selection per service
|
||||
|
||||
Each AI-calling service must use configurable model and MCP strings from application settings, not hardcoded values. Use these defaults:
|
||||
|
||||
```python
|
||||
# Model tier per service
|
||||
FACT_SYNTHESIS_MODEL = "claude-haiku-4-5-20251001" # short transformation, latency-sensitive
|
||||
RESOLUTION_NOTE_MODEL = "claude-sonnet-4-6" # customer-facing artifact, quality matters
|
||||
ESCALATION_PACKAGE_MODEL = "claude-sonnet-4-6" # same
|
||||
TEMPLATE_EXTRACTION_MODEL = "claude-sonnet-4-6" # creates persistent library artifact
|
||||
MAIN_CONVERSATION_MODEL = "claude-sonnet-4-6" # primary FlowPilot chat
|
||||
|
||||
# MCP availability per service (true = this service can use MCP tools when available)
|
||||
FACT_SYNTHESIS_MCP_ENABLED = False # fast transformation, no external lookup needed
|
||||
RESOLUTION_NOTE_MCP_ENABLED = False # summarizing existing state, not researching
|
||||
ESCALATION_PACKAGE_MCP_ENABLED = False # same
|
||||
TEMPLATE_EXTRACTION_MCP_ENABLED = False # purely transforms an existing script
|
||||
MAIN_CONVERSATION_MCP_ENABLED = True # interactive troubleshooting, grounding matters
|
||||
SCRIPT_GENERATOR_MCP_ENABLED = True # Microsoft Learn for documentation grounding
|
||||
```
|
||||
|
||||
Do not hardcode model or MCP strings at call sites. Every new service reads from settings with a service-specific key.
|
||||
|
||||
**Instrumentation:** log a `disputed_fact_rate` metric for fact synthesis — the percentage of AI-synthesized facts that engineers subsequently edit or delete. If this exceeds 10% over a 500-session window, escalate `FACT_SYNTHESIS_MODEL` to `claude-sonnet-4-6`. If under 5%, Haiku is performing correctly.
|
||||
|
||||
**Do not use Opus 4.7 for any of these services at current scale.**
|
||||
|
||||
---
|
||||
|
||||
## 7. Frontend components
|
||||
|
||||
### 7.1 Routes to change
|
||||
|
||||
| Current route | New route | Action |
|
||||
|---|---|---|
|
||||
| `/assistant` | `/pilot` | Move existing `AssistantChatPage` to `/pilot`. |
|
||||
| `/assistant/:sessionId` | `/pilot/:sessionId` | Session-deep-links must redirect with the session ID preserved. |
|
||||
| `/assistant` (bare) | → `/pilot` | **Permanent** 301 redirect. No sunset date. |
|
||||
| `/assistant/:sessionId` (deep) | → `/pilot/:sessionId` | **Permanent** 301 redirect. |
|
||||
|
||||
Sidebar nav entry renames from "ResolutionAssist" to "FlowPilot" with the cockpit icon. Command palette entries, dashboard cards, and session list links that previously pointed to `/assistant` all update to `/pilot`.
|
||||
|
||||
### 7.2 New React components
|
||||
|
||||
Under `src/components/pilot/`:
|
||||
|
||||
```
|
||||
TaskLane.tsx -- The right-side panel, owns all four sections
|
||||
sections/
|
||||
WhatWeKnow.tsx -- New component for the facts list
|
||||
WhatWeKnowItem.tsx -- Single fact card with provenance line
|
||||
AddNoteButton.tsx -- "+ Add a note" inline composer
|
||||
Questions.tsx -- Existing questions rendering (moved/refactored from current location)
|
||||
DiagnosticChecks.tsx -- Existing checks rendering (moved/refactored from current location)
|
||||
SuggestedFix.tsx -- New or refactored component for the suggested fix card
|
||||
ResolveButton.tsx -- The Resolve CTA at the bottom of the task lane
|
||||
ResolutionNotePreview.tsx -- Floating popover anchored to Resolve button
|
||||
EscalatePackagePreview.tsx -- Same pattern for Escalate
|
||||
|
||||
ScriptGenInline/ -- Script Generator embedded in session context
|
||||
TemplateMatchPanel.tsx -- Scene 1 mockup: template pre-filled
|
||||
NoTemplateDialog.tsx -- Scene 2 mockup: three-option dialog
|
||||
TemplatizePrompt.tsx -- Scene 3 mockup: post-resolve prompt
|
||||
ParameterizationPreview.tsx -- Shared component: script with highlighted params
|
||||
```
|
||||
|
||||
Existing component folders (e.g., `src/components/assistant/`) may be renamed opportunistically, but behavior and route migration matter more than directory-name purity.
|
||||
|
||||
### 7.3 Component behavior contracts
|
||||
|
||||
**`WhatWeKnowItem`**
|
||||
- Props: `{fact: SessionFact, onEdit, onDelete}`
|
||||
- Renders the fact text, a green checkmark, and the provenance line with source-type color coding
|
||||
- Edit affordance: only shown when `fact.source_type` is `user_note` or `ai_synthesis`. Question/check facts are read-only at the card level (edit the source question/check instead).
|
||||
- Delete affordance: shown for all facts (soft-delete via DELETE endpoint)
|
||||
|
||||
**`TaskLane`**
|
||||
- Subscribes to a session state hook that polls for fact / question / check / suggested-fix updates
|
||||
- On any state change (state_version increment), calls `POST /api/v1/ai-sessions/{id}/resolution-note/preview` to refresh the `ResolutionNotePreview`
|
||||
- Debounce preview refresh to 500ms to avoid LLM spam
|
||||
|
||||
**`NoTemplateDialog`** (three-option dialog)
|
||||
- Props: `{suggestedFix, onDecision}`
|
||||
- Renders the three cards with the middle (`draft_template`) marked as recommended
|
||||
- `onDecision` posts to `/api/v1/ai-sessions/{id}/suggested-fixes/{fix_id}/decision` and either opens the Script Generator (one_off / draft_template) or navigates to full template creation (build_template)
|
||||
|
||||
**`TemplatizePrompt`**
|
||||
- Rendered after successful Resolve when a draft template exists for the session AND `account_settings.preferences.templatize_prompt_enabled` is not `false`
|
||||
- Fetches proposed parameters from the draft template record
|
||||
- Save button posts to `/api/v1/draft-templates/{id}/accept`
|
||||
|
||||
---
|
||||
|
||||
## 8. AI prompt changes
|
||||
|
||||
The existing FlowPilot / ResolutionAssist system prompt needs updates to emit the new markers. Parser lives in `unified_chat_service` alongside the existing `[QUESTIONS]` / `[DIAGNOSTIC_CHECKS]` parsing — do not create a separate marker pipeline.
|
||||
|
||||
### 8.1 New marker: `[PROMOTE]`
|
||||
|
||||
Used to surface facts to What we know. Syntax:
|
||||
|
||||
```
|
||||
[PROMOTE]
|
||||
source_type: question
|
||||
source_ref: {task_lane_item_uuid}
|
||||
text: OWA login and send/receive confirmed working for jsmith
|
||||
summary: rules out tenant/license
|
||||
[/PROMOTE]
|
||||
```
|
||||
|
||||
The AI should emit `[PROMOTE]` blocks in the same message that answers or processes a question/check, so the fact appears in What we know simultaneously with the chat acknowledgment. `source_ref` points to the stable UUID of the task lane item being promoted (assigned in Phase 2).
|
||||
|
||||
### 8.2 New marker: `[SUGGEST_FIX]`
|
||||
|
||||
```
|
||||
[SUGGEST_FIX]
|
||||
title: Clear cached credentials + rebuild Outlook profile
|
||||
description: Stale cached credential in Credential Manager is holding the pre-reset token...
|
||||
confidence: 94
|
||||
script_template_slug: clear-outlook-credentials # or omitted if no template match
|
||||
ai_drafted_script: | # only if no template match
|
||||
# Generated by FlowPilot...
|
||||
...
|
||||
[/SUGGEST_FIX]
|
||||
```
|
||||
|
||||
Emitting a new `[SUGGEST_FIX]` supersedes any existing active fix for the session (sets `superseded_at` on the old row).
|
||||
|
||||
### 8.3 Removed markers
|
||||
|
||||
The old `[FORK]` marker from the ResolutionAssist prompt is removed. Forks were a Guided-mode concept; in the unified model, they're replaced by Questions with mutually exclusive answer options.
|
||||
|
||||
---
|
||||
|
||||
## 9. Implementation phases
|
||||
|
||||
Each phase ends with a git commit and verification step. Do not advance to the next phase until verification passes (or, for Phase 0, the verification step is explicitly deferred to the new dev environment with a tracking TODO).
|
||||
|
||||
### Phase 0 — Prompt caching infrastructure (prerequisite)
|
||||
|
||||
A codebase audit revealed that prompt caching was only implemented in `assistant_chat_service.py` (the file being deprecated). Every other Anthropic API call site — including all of FlowPilot's 7 call sites through `AnthropicProvider` — was uncached. Phase 0 must land before Phase 2 starts because new services built in Phase 2 will inherit caching from `AnthropicProvider` automatically once it's fixed.
|
||||
|
||||
**Deliverables:**
|
||||
|
||||
- **0.1 — Cached system-block support in `AnthropicProvider`.** Convert `AnthropicProvider.generate_json()` and `generate_text_stream()` signatures to accept `system_prompt: str | list[SystemBlock]`. Plain string = uncached (backward compatible). List = cached using **policy α**: if the caller marks `cache_control` on any block, honor those markers; if no block has `cache_control`, cache the first block only by default. For streaming, capture the final usage object via `get_final_message()`. Log `cache_read_input_tokens` and `cache_creation_input_tokens` on every response.
|
||||
|
||||
- **0.2 — Pending target endpoint.** The `/tickets/ai-parse` endpoint described in the original migration doc does not exist in the codebase. No code change in Phase 0. When this endpoint is built, apply the cached-system-block pattern:
|
||||
|
||||
```python
|
||||
system_blocks = [
|
||||
{"type": "text", "text": members_json, "cache_control": {"type": "ephemeral"}},
|
||||
# cacheable: team-stable
|
||||
{"type": "text", "text": boards_json, "cache_control": {"type": "ephemeral"}},
|
||||
# cacheable: team-stable
|
||||
{"type": "text", "text": engineer_description},
|
||||
# uncached: per-request
|
||||
]
|
||||
```
|
||||
|
||||
Remove this note when the endpoint is implemented and the pattern applied.
|
||||
|
||||
- **0.3 — Opt-in caching for one-shot generators.** Add `cache_control` to the static system prompt in `ai_tree_generator_service`, `kb_conversion_service`, `ai_fix_service`, and `script_builder_service`. Pattern: single-block list with policy α auto-caching the first (and only) block. Per-block inline comment explaining cacheability. For `script_builder` (multi-turn): cache only the system prompt; conversation history stays uncached in this phase. Retries in `ai_tree_generator.generate_branch_detail` inherit the cache automatically — no special handling.
|
||||
|
||||
- **0.4 — Consolidate the MCP-capable chat path.** Rename `_call_anthropic_cached` to `chat_call_cached()` in `assistant_chat_service` (or move to a shared module; implementer's choice based on cleanest structure). Refactor it to delegate cached-system-block plumbing to `AnthropicProvider`. MCP + image + beta-endpoint logic stays inside the chat wrapper — do NOT push MCP into `AnthropicProvider`, which is a provider-agnostic abstraction (Gemini has no MCP). Document that this wrapper is the one MCP-using caller, the exception not the rule. Track MCP unification as a separate future ticket.
|
||||
|
||||
- **0.5 — MCP telemetry.** Add counters for: (a) turns where MCP was available, (b) turns where the model actually invoked an MCP tool, (c) turns where the silent-retry-without-MCP fallback was triggered, (d) which MCP tool names got called. Log to whatever telemetry path exists (PostHog if wired up, otherwise structured logs). This gives us real data by the time Phase 2+ decisions about MCP investment are made. **Do 0.5 first or alongside 0.1 — don't save it for last.**
|
||||
|
||||
**Per-call-site comment pattern for multi-block lists:**
|
||||
|
||||
When a call site passes more than one block to `system_prompt`, add a one-line comment next to EACH block — including uncached ones — explaining why it is or isn't cached. The absence of a marker deserves documentation as much as the presence of one, because it tells the next dev you made a conscious choice.
|
||||
|
||||
**Verification:**
|
||||
|
||||
- Hit any FlowPilot endpoint twice within 5 minutes. First call shows `cache_creation_input_tokens > 0`, second call shows `cache_read_input_tokens > 0`.
|
||||
- If the second call returns zero cache reads, inspect the prefix for silent invalidators (timestamps, unsorted JSON keys, varying tool list ordering). Fix before proceeding.
|
||||
|
||||
**Verification is deferred to the new dev environment.** Phase 0 code commits without live verification because no running environment exists at authoring time. A `TODO(phase-0-verification)` inline comment in the caching module names the verification steps. Execute verification when the new env is up; if it fails, that is a debug task then, not a blocker now.
|
||||
|
||||
```
|
||||
git commit -m "feat(ai): promote AnthropicProvider to cached pattern, consolidate caching implementation"
|
||||
```
|
||||
|
||||
**Dependencies:**
|
||||
|
||||
- Phase 1 (route rename and schema) can run in parallel with Phase 0.
|
||||
- Phase 2 (What we know) must not start until Phase 0 is complete and verification has passed (or been explicitly deferred with a tracked issue).
|
||||
|
||||
### Phase 1 — Data model and route rename (can run in parallel with Phase 0)
|
||||
|
||||
**Deliverables:**
|
||||
|
||||
- Alembic migration after current repo head creating: `session_facts`, `session_suggested_fixes`, `draft_templates`, `account_settings`; column additions to `ai_sessions` (including `state_version`), `script_templates`.
|
||||
- All new tenant-scoped tables have `account_id` and RLS policies using the repo's `app.current_account_id` policy pattern.
|
||||
- SQLAlchemy models for each new table. `AccountSettings` model includes `get_setting(key, default)` and `set_setting(key, value)` helpers; lazy row creation on first write.
|
||||
- Route move: `AssistantChatPage` component mounted at `/pilot` and `/pilot/:sessionId`.
|
||||
- Permanent 301 redirect: `/assistant` → `/pilot`, `/assistant/:sessionId` → `/pilot/:sessionId` (preserving session ID).
|
||||
- Sidebar nav entry renames from "ResolutionAssist" / "AI Assistant" to "FlowPilot". Command palette entries, dashboard cards, and session list links update to `/pilot`.
|
||||
- No Phase 2 UI changes yet (no task lane restructuring, no What we know section).
|
||||
|
||||
**Verification:**
|
||||
|
||||
- Run migration on a fresh dev database — succeeds.
|
||||
- Downgrade migration succeeds (reversibility).
|
||||
- RLS grep/check passes for new tables.
|
||||
- `/assistant` redirects to `/pilot` (301).
|
||||
- `/assistant/:sessionId` redirects to `/pilot/:sessionId` with ID preserved.
|
||||
- `/pilot` renders the existing chat UI with the sidebar now reading "FlowPilot".
|
||||
- No Phase 2 UI introduced.
|
||||
|
||||
```
|
||||
git commit -m "feat(pilot): rename /assistant to /pilot, add session_facts/suggested_fixes/draft_templates/account_settings schema"
|
||||
```
|
||||
|
||||
### Phase 2 — What we know (task lane + service + API)
|
||||
|
||||
**Deliverables:**
|
||||
|
||||
- Stable-UUID assignment for `pending_task_lane` items. When questions/checks are persisted (or when a legacy session is loaded), each item receives a UUID written back into the JSON. This is a prerequisite for `session_facts.source_ref` to point anywhere reliable. Handle in-flight sessions gracefully — sessions open during deploy may have unstable IDs until their next save.
|
||||
- `FactSynthesisService` per Section 6.1, with its LLM prompt.
|
||||
- Fact CRUD API endpoints per Section 5.1.
|
||||
- `WhatWeKnow`, `WhatWeKnowItem`, `AddNoteButton`, `TaskLane` components under `src/components/pilot/`.
|
||||
- Task lane layout adjustment: What we know section renders above Questions.
|
||||
- Counter in task lane header updates to `X / Y answered` format.
|
||||
- AI system prompt updated to emit `[PROMOTE]` markers; `unified_chat_service` marker parser extended to handle them.
|
||||
- Fact editability enforcement: API returns 403 on PATCH of `question` or `diagnostic_check`-sourced facts. UI hides the edit affordance for those facts.
|
||||
|
||||
**Verification:**
|
||||
|
||||
- Open a session, answer a question; within 2 seconds a fact appears in What we know with correct provenance.
|
||||
- Click "+ Add a note", type a manual fact, confirm it persists with `source_type: user_note`.
|
||||
- Run a diagnostic check, confirm the check result promotes to a fact.
|
||||
- Facts persist across page reloads.
|
||||
- RLS: a user from a different account cannot read or write facts for this session.
|
||||
- Attempt to PATCH a question-sourced fact → 403.
|
||||
- PATCH a user_note fact → succeeds.
|
||||
|
||||
```
|
||||
git commit -m "feat(pilot): add What we know section with fact synthesis and stable task-lane item IDs"
|
||||
```
|
||||
|
||||
### Phase 3 — Suggested fix + resolution note preview
|
||||
|
||||
**Deliverables:**
|
||||
|
||||
- `session_suggested_fixes` API endpoints per Section 5.2 and data flow.
|
||||
- `SuggestedFix` component in the task lane.
|
||||
- AI system prompt updated to emit `[SUGGEST_FIX]` markers; parser handles supersession.
|
||||
- `ResolutionNoteGeneratorService` per Section 6.2 and preview endpoint per Section 5.4.
|
||||
- `ResolutionNotePreview` floating popover anchored to Resolve button.
|
||||
- Preview refreshes on fact / suggested-fix / script-generation changes via `state_version` increment. Client-side 500ms debounce.
|
||||
- Preview cache keyed by `(session_id, state_version)` per Section 5.5.
|
||||
|
||||
**Verification:**
|
||||
|
||||
- Session with ≥3 facts and an active suggested fix shows a populated Resolve preview.
|
||||
- Editing a fact updates the preview within 1 second.
|
||||
- Preview markdown renders correctly with all four sections (Problem / What we confirmed / Root cause / Resolution).
|
||||
- Preview contains no hallucinated information not present in session state (human review of 5 real-ish sessions).
|
||||
- Incrementing `state_version` invalidates the preview cache; reading the same version returns the cached markdown.
|
||||
|
||||
```
|
||||
git commit -m "feat(pilot): add suggested fix tracking and Resolve note preview with state_version caching"
|
||||
```
|
||||
|
||||
### Phase 4 — Resolve and Escalate PSA writebacks
|
||||
|
||||
**Deliverables:**
|
||||
|
||||
- `transition_ticket_status` method on `PSAWritebackService` with CW re-fetch verification.
|
||||
- `post_resolution_note` endpoint and CW integration via existing PSA provider registry + `post_note` seam.
|
||||
- Resolve button flow: engineer edits preview → Confirm & post → server posts to PSA → stores `{external_id, posted_at}` → transitions status → verifies status → marks session resolved → shows templatize prompt if applicable.
|
||||
- `EscalationPackageGeneratorService` and parallel flow for Escalate, including CW routing rules.
|
||||
- Local-only path: resolving or escalating a session with no linked PSA ticket stores markdown locally and marks the session state without external posting.
|
||||
|
||||
**Verification:**
|
||||
|
||||
- Complete a session end-to-end with a ConnectWise test instance.
|
||||
- Click Resolve, edit the preview, confirm post — verify the note appears in CW and status changes to Resolved (verified by re-fetch).
|
||||
- Click Escalate on a different session — verify the package is posted and the ticket routes correctly.
|
||||
- Simulate CW silently rejecting a status change — verify the app surfaces an error, not silent success.
|
||||
- Attempt to Resolve without a linked PSA ticket — session marks resolved locally without erroring; markdown stored in `resolution_note_markdown`.
|
||||
|
||||
```
|
||||
git commit -m "feat(pilot): wire Resolve and Escalate to ConnectWise writeback with status verification"
|
||||
```
|
||||
|
||||
### Phase 5 — Script Generator inline integration
|
||||
|
||||
**Deliverables:**
|
||||
|
||||
- `ScriptGenInline/TemplateMatchPanel` — when suggested fix has `script_template_id`, clicking the fix opens this panel with parameters pre-filled from session facts, ticket context (company configs), and AI-suggested values in the `[SUGGEST_FIX]` marker.
|
||||
- `ScriptGenInline/NoTemplateDialog` — three-option dialog when no template match.
|
||||
- User decision persisted on `session_suggested_fixes.user_decision`.
|
||||
- `TemplateExtractionService` for generating parameterization proposals (Section 6.4).
|
||||
- Script generation flow produces a `script_generations` record linked to the session via existing `script_generations.ai_session_id`; increments `state_version`.
|
||||
- `⌘K → "script"` opens the inline generator from the FlowPilot session. **No Resolve keyboard shortcut** is added (browsers intercept `⌘R`; decided against alternatives).
|
||||
- Script Generator inherits MCP access for Microsoft Learn lookups via the `chat_call_cached` wrapper (Phase 0.4), not via `AnthropicProvider` directly.
|
||||
|
||||
**Verification:**
|
||||
|
||||
- Session with a template-matched suggested fix: clicking opens generator with ≥2 pre-filled parameters.
|
||||
- Session with a custom script suggested fix: dialog appears with three options, script preview shows parameters highlighted.
|
||||
- All three paths end correctly: one-off generates and closes, draft_template creates `draft_templates` row and generates, build_template opens full template creation.
|
||||
- `⌘K → "script"` anywhere in a session opens the generator directly.
|
||||
- Edge case: if the suggested fix's `script_template_id` points at a template that has been deleted, show the no-template three-option dialog with the AI-drafted script (do not error).
|
||||
|
||||
```
|
||||
git commit -m "feat(pilot): integrate Script Generator inline with suggested fixes"
|
||||
```
|
||||
|
||||
### Phase 6 — Post-resolve templatize prompt
|
||||
|
||||
**Deliverables:**
|
||||
|
||||
- `TemplatizePrompt` component.
|
||||
- Show logic: after successful Resolve, show only when ALL of:
|
||||
1. `account_settings.preferences.templatize_prompt_enabled` is not `false` (default `true` when absent)
|
||||
2. Session has pending `draft_templates` rows
|
||||
3. The user chose `draft_template` on the original three-option dialog
|
||||
- Accept flow creates a new `script_templates` row with `source_session_id`, `source_user_id`, `source_ticket_ref` set. Updates draft to `status='accepted'`, `promoted_template_id` set.
|
||||
- Reject flow updates draft to `status='rejected'`.
|
||||
- "Don't ask me again for this team" writes `{"templatize_prompt_enabled": false}` to `account_settings.preferences`.
|
||||
- Script Library sidebar shows a pending-drafts badge/count for the account.
|
||||
|
||||
**Verification:**
|
||||
|
||||
- Resolve a session where the engineer picked Option 2 → templatize prompt appears with AI-proposed parameters.
|
||||
- Accept the prompt → new template appears in the Script Library with the provenance chip.
|
||||
- Skip the prompt → draft marked rejected, Script Library shows no new template.
|
||||
- Toggle "don't ask me again" → next session Resolve skips the prompt even with a pending draft.
|
||||
|
||||
```
|
||||
git commit -m "feat(pilot): add post-resolve templatize prompt for draft templates"
|
||||
```
|
||||
|
||||
### Phase 7 — Polish
|
||||
|
||||
**Deliverables:**
|
||||
|
||||
- Visual polish against the mockup HTML source files (spacing, colors, typography, component structure). Use PNGs for visual target confirmation.
|
||||
- Loading states for: fact synthesis, preview generation, template extraction, PSA post/verify, script generation.
|
||||
- Empty states: no facts yet, no questions, no checks, no active suggested fix, no pending draft templates.
|
||||
- Keyboard shortcuts (no Resolve shortcut): `⌘K` (command palette), `⌘↵` (send composer), `⌘G` (script generator).
|
||||
- Responsive: at widths below 1200px, task lane collapses into a bottom drawer.
|
||||
- Use existing design tokens where present; add missing tokens only if needed to match the mockups.
|
||||
|
||||
**Verification:**
|
||||
|
||||
- Major screens visually compare within tolerance against the mockup PNG files.
|
||||
- No horizontal scroll at 1280px viewport.
|
||||
- Keyboard shortcuts documented in-app via `?` overlay.
|
||||
- Shortcuts do not conflict with browser reload.
|
||||
|
||||
```
|
||||
git commit -m "feat(pilot): visual polish, empty/loading states, keyboard shortcuts"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. Design system reference
|
||||
|
||||
All components must use the existing ResolutionFlow design system. Tokens from the mockup CSS for quick reference — these should already exist in your tokens file; if they don't, add them:
|
||||
|
||||
```css
|
||||
/* Backgrounds */
|
||||
--bg-0: #070b12; /* page background */
|
||||
--bg-1: #0d131c; /* sidebar / chrome */
|
||||
--bg-2: #121a25; /* card / bubble background */
|
||||
--bg-3: #1a2332; /* raised element */
|
||||
|
||||
/* Borders */
|
||||
--border: rgba(148, 163, 184, 0.12);
|
||||
--border-strong: rgba(148, 163, 184, 0.22);
|
||||
|
||||
/* Text */
|
||||
--text-primary: #e2e8f0;
|
||||
--text-secondary: #94a3b8;
|
||||
--text-tertiary: #64748b;
|
||||
|
||||
/* Brand cyan (FlowPilot accent) */
|
||||
--cyan-400: #22d3ee;
|
||||
--cyan-500: #06b6d4;
|
||||
--cyan-600: #0891b2;
|
||||
--cyan-bg: rgba(34, 211, 238, 0.10);
|
||||
--cyan-border: rgba(34, 211, 238, 0.30);
|
||||
|
||||
/* Semantic */
|
||||
--success: #34d399; /* Resolve, facts */
|
||||
--warning: #fbbf24; /* Escalate, proposed parameters */
|
||||
--danger: #f87171;
|
||||
--purple: #a78bfa; /* Script Generator / templates */
|
||||
```
|
||||
|
||||
**Typography:**
|
||||
- Body: IBM Plex Sans, 14px/1.5
|
||||
- Headings: Bricolage Grotesque, 500 weight, -0.01em letter-spacing
|
||||
- Code: JetBrains Mono
|
||||
|
||||
**Icons:** Phosphor Icons (Duotone) per the recorded design decision to migrate off Lucide.
|
||||
|
||||
---
|
||||
|
||||
## 11. Test plan
|
||||
|
||||
### Migration tests
|
||||
- Fresh DB upgrade succeeds.
|
||||
- Downgrade succeeds (reversibility).
|
||||
- New tables have RLS enabled/forced.
|
||||
- Tenant policy includes `app.current_account_id`.
|
||||
|
||||
### Backend tests
|
||||
- Fact CRUD authorization (edit allowed on `user_note` / `ai_synthesis`, 403 on `question` / `diagnostic_check`).
|
||||
- Fact promotion: `POST /facts/promote` creates fact and increments `state_version`.
|
||||
- Suggested-fix supersession: emitting a new `[SUGGEST_FIX]` sets `superseded_at` on the prior active one.
|
||||
- Decision persistence on `session_suggested_fixes.user_decision`.
|
||||
- Resolution note preview cache invalidation on `state_version` increment.
|
||||
- Resolve/Escalate local-only behavior without a linked PSA ticket.
|
||||
- PSA status verification failure path (simulated rejection surfaces error).
|
||||
- Draft-template accept/reject behavior.
|
||||
- `AccountSettings.get_setting` returns default when row absent.
|
||||
|
||||
### Frontend tests
|
||||
- Route redirects (`/assistant` → `/pilot`, deep-link ID preservation).
|
||||
- Task lane rendering and persistence across reloads.
|
||||
- Inline fact editing refreshes the Resolve preview.
|
||||
- Script Generator option flows (template match, three-option dialog, post-resolve prompt).
|
||||
- Templatize prompt respects `templatize_prompt_enabled` setting.
|
||||
- Responsive drawer behavior at <1200px.
|
||||
|
||||
### Manual QA
|
||||
- Run one ConnectWise-linked Resolve end-to-end.
|
||||
- Run one Escalate end-to-end.
|
||||
- Run one template-match script generation path.
|
||||
- Run one no-template draft-template path through post-resolve save.
|
||||
|
||||
---
|
||||
|
||||
## 12. Non-goals for this migration
|
||||
|
||||
Do not build these as part of this work. They belong to later phases of the roadmap.
|
||||
|
||||
- **Confidence tiers (Discovery / Exploring / Guided).** Explicitly removed. The task lane itself is the progress signal.
|
||||
- **Mode toggle between Guided and Quick ask.** There is one mode.
|
||||
- **"Convert to guided" promotion flow.** No longer applicable.
|
||||
- **Team Wiki compilation from resolved sessions.** Tracked separately; depends on this migration but is not part of it.
|
||||
- **SharePoint integration.** Sequenced after ConnectWise per roadmap.
|
||||
- **Template marketplace / sharing across accounts.** Tracked under Client Context System roadmap item.
|
||||
- **Backfill of What we know for pre-Phase-2 sessions.** Sessions resolved before Phase 2 ships will not retroactively gain facts. Document in release notes.
|
||||
- **MCP unification into `AnthropicProvider`.** Deferred pending telemetry-driven evaluation. Track as a separate ticket.
|
||||
- **Supervisor staging of resolution notes.** Engineer review + Confirm & post is the committed flow (not compliance-grade draft approval).
|
||||
|
||||
---
|
||||
|
||||
## 13. Risks and mitigations
|
||||
|
||||
| Risk | Mitigation |
|
||||
|---|---|
|
||||
| LLM fact synthesis hallucinates specifics not in the answer | Conservative prompt; engineer can edit/delete any AI-synthesized fact; provenance line shows the source so the engineer can verify. Haiku default + `disputed_fact_rate` telemetry triggers escalation to Sonnet if quality drops. |
|
||||
| Resolution note preview LLM cost at scale | `state_version`-keyed cache prevents re-generation on unchanged state; 500ms client debounce batches rapid edits. |
|
||||
| ConnectWise silently rejects status change | `transition_ticket_status` re-fetches and verifies; fails loudly if the change didn't stick. |
|
||||
| Template extraction proposes bad parameterization | Engineer reviews before saving; draft templates never silently become real templates; provenance chip lets team admins audit. |
|
||||
| Users lose muscle memory from `/assistant` → `/pilot` rename | Permanent 301 redirect (no sunset date); deep-link session IDs preserved through the redirect. |
|
||||
| Existing sessions have no facts at Phase 2 deploy | Acceptable per non-goals. Facts accumulate for new or ongoing sessions after deploy. Document in release notes. |
|
||||
| In-flight sessions during Phase 2 deploy lack stable task-lane item IDs | Sessions open at deploy time may have unstable IDs until the next save cycle re-persists with UUIDs. Facts tied to those sessions may reference IDs that don't resolve. Engineer can manually re-promote if needed. |
|
||||
| Phase 0 cache verification deferred to new env | Tracked via inline TODO in the caching module. If verification fails when executed, debug as a normal bug — do not retroactively block dependent phases. |
|
||||
| MCP usage data unknown, may under- or over-invest | Phase 0.5 telemetry answers this within 30 days of new env being live. Schedule "MCP review" checkpoint at that mark. |
|
||||
|
||||
---
|
||||
|
||||
## 14. Decisions made during migration planning
|
||||
|
||||
These questions were raised during the planning conversation and have been resolved. Captured here so the decisions are traceable.
|
||||
|
||||
1. **Keyboard shortcut for Resolve** — **Decided: no shortcut.** `⌘R` conflicts with browser reload; alternatives add complexity without clear value. Resolve has a button, a preview, and a confirm step. No shortcut needed.
|
||||
2. **Default for `templatize_prompt_enabled`** — **Decided: true.** Feature discovery outweighs annoyance at pre-revenue stage. Opt-out is one click and persistent. Tune surfacing logic rather than the default if feedback indicates over-prompting.
|
||||
3. **Resolution note posting** — **Decided: engineer edits inline, clicks Confirm & post.** Supervisor staging is out of scope for this migration. Revisit if an MSP with strict compliance requirements surfaces the need.
|
||||
4. **Fact synthesis model tier** — **Decided: Haiku 4.5 behind a `FACT_SYNTHESIS_MODEL` config flag.** All other AI services default to Sonnet 4.6. Opus 4.7 not used at current scale. Per-service MCP capability configured via matching flags (Section 6.6).
|
||||
5. **MCP architecture in Phase 0** — **Decided: leave MCP in the chat wrapper.** Option C from the Phase 0 audit. Do not push MCP into the provider-agnostic `AnthropicProvider`. Add telemetry in Phase 0.5 to gather data for a future unification decision.
|
||||
6. **Cache breakpoint policy** — **Decided: policy α.** Caller-marked `cache_control` is honored; if no blocks are marked, the first block is cached by default.
|
||||
7. **API namespace** — **Decided: `/api/v1/ai-sessions/{id}/...`**, matching the existing codebase.
|
||||
8. **`account_settings` structure** — **Decided: new table with JSONB `preferences` column, lazy row creation.** Simple settings live in `preferences`; settings graduate to typed columns when they meet the promotion criteria (hot path / validation / joins).
|
||||
|
||||
---
|
||||
|
||||
## End of document
|
||||
1320
docs/FlowAssist_Migration/mockups/01-session-primary.html
Normal file
1320
docs/FlowAssist_Migration/mockups/01-session-primary.html
Normal file
File diff suppressed because it is too large
Load Diff
BIN
docs/FlowAssist_Migration/mockups/01-session-primary.png
Normal file
BIN
docs/FlowAssist_Migration/mockups/01-session-primary.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 505 KiB |
1516
docs/FlowAssist_Migration/mockups/02-04-script-integration.html
Normal file
1516
docs/FlowAssist_Migration/mockups/02-04-script-integration.html
Normal file
File diff suppressed because it is too large
Load Diff
BIN
docs/FlowAssist_Migration/mockups/02-script-template-match.png
Normal file
BIN
docs/FlowAssist_Migration/mockups/02-script-template-match.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 282 KiB |
BIN
docs/FlowAssist_Migration/mockups/03-script-three-options.png
Normal file
BIN
docs/FlowAssist_Migration/mockups/03-script-three-options.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 341 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 381 KiB |
158
docs/connectwise-psa-testing-checklist.md
Normal file
158
docs/connectwise-psa-testing-checklist.md
Normal file
@@ -0,0 +1,158 @@
|
||||
# ConnectWise PSA Integration — Testing Checklist
|
||||
|
||||
> **Purpose:** Step-by-step guide to connect ResolutionFlow to a ConnectWise developer sandbox and validate each integration feature end-to-end.
|
||||
>
|
||||
> **Date created:** 2026-04-14
|
||||
> **Branch:** main
|
||||
|
||||
---
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Before starting, make sure you have:
|
||||
|
||||
- [ ] ResolutionFlow backend running (`uvicorn app.main:app --reload` from `backend/`)
|
||||
- [ ] ResolutionFlow frontend running (`npm run dev` from `frontend/`)
|
||||
- [ ] A ConnectWise developer sandbox account
|
||||
|
||||
---
|
||||
|
||||
## Step 1 — Get Your ConnectWise Developer Credentials
|
||||
|
||||
You need four pieces of information from your ConnectWise sandbox.
|
||||
|
||||
**Company ID**
|
||||
- This is the company name you log in with on the CW login screen (e.g. if your URL is `na.myconnectwise.net` and your login company is `resolutionflow`, the Company ID is `resolutionflow`)
|
||||
|
||||
**Site URL**
|
||||
- Developer sandboxes are typically `na.myconnectwise.net` or `aus.connectwisedev.com`
|
||||
- Do **not** include `https://` — enter just the hostname (e.g. `na.myconnectwise.net`)
|
||||
|
||||
**API Public Key + Private Key**
|
||||
1. Log into your CW sandbox
|
||||
2. Go to **System → Members** → open your own member record
|
||||
3. Click the **API Keys** tab
|
||||
4. Click **New** → give it a name (e.g. "ResolutionFlow Dev")
|
||||
5. Save — the **Private Key** is shown only once, copy it now
|
||||
6. Note both the **Public Key** (shown on the list) and **Private Key**
|
||||
|
||||
**Client ID** (already configured server-side)
|
||||
- The `CW_CLIENT_ID` is set in `backend/app/core/config.py` — this identifies the ResolutionFlow app to ConnectWise and is shared across all tenants. You do not need to enter this in the UI.
|
||||
|
||||
---
|
||||
|
||||
## Step 2 — Connect ResolutionFlow to ConnectWise
|
||||
|
||||
- [ ] Log into ResolutionFlow as a **Team Admin or Super Admin** user
|
||||
- [ ] Navigate to **Account → Integrations**
|
||||
- [ ] On the **Connection** tab, fill in the form:
|
||||
- Display Name: anything (e.g. `CW Dev Sandbox`)
|
||||
- Site URL: your sandbox hostname (e.g. `na.myconnectwise.net`)
|
||||
- Company ID: your CW company ID
|
||||
- Public Key: from Step 1
|
||||
- Private Key: from Step 1
|
||||
- [ ] Click **Connect** — the backend tests the credentials before saving
|
||||
- [ ] Verify: "Connected" status appears with a green dot
|
||||
- [ ] Click **Test Connection** button and confirm it returns a success message + server version
|
||||
|
||||
---
|
||||
|
||||
## Step 3 — Member Mapping
|
||||
|
||||
Maps ResolutionFlow users to ConnectWise members so that PSA posts are attributed to the right technician.
|
||||
|
||||
- [ ] Click the **Member Mapping** tab
|
||||
- [ ] Click **Auto-Match by Email** — ResolutionFlow matches users to CW members with the same email address
|
||||
- [ ] Verify the matched count in the toast notification
|
||||
- [ ] If any users are unmatched, manually assign them via the dropdown
|
||||
- [ ] Click **Save Mappings** if you made manual changes
|
||||
|
||||
---
|
||||
|
||||
## Step 4 — Ticket Search (via FlowPilot session)
|
||||
|
||||
- [ ] Start a new FlowPilot session (from the Dashboard)
|
||||
- [ ] Look for the **Link Ticket** button in the session header
|
||||
- [ ] Search for a ticket by keyword or ticket number
|
||||
- [ ] Verify: ticket results appear showing summary, board, status, priority
|
||||
- [ ] Select a ticket and confirm it links to the session
|
||||
|
||||
---
|
||||
|
||||
## Step 5 — Ticket Context Injection
|
||||
|
||||
Once a ticket is linked, FlowPilot should enrich its context with CW data.
|
||||
|
||||
- [ ] With a ticket linked, send a message to FlowPilot
|
||||
- [ ] Verify: FlowPilot's response references ticket details (company name, status, configurations, etc.)
|
||||
- [ ] Check backend logs to confirm `GET /integrations/psa/tickets/{id}/context` is being called
|
||||
|
||||
---
|
||||
|
||||
## Step 6 — PSA Post (push session notes to ticket)
|
||||
|
||||
This is the core feature — pushing session documentation back to the ConnectWise ticket.
|
||||
|
||||
- [ ] In the linked session, click **Update** (or the PSA post button in the session header)
|
||||
- [ ] Review the **Preview** — confirm the generated content looks correct
|
||||
- [ ] Select a **Note Type**:
|
||||
- `Internal Analysis` — internal-only note (visible to techs, not clients)
|
||||
- `Resolution` — marks as resolved, notifies client
|
||||
- `Description` — main ticket description note
|
||||
- [ ] Optionally select a **Status** to update the ticket to (e.g. "In Progress" → "Resolved")
|
||||
- [ ] Click **Post to Ticket**
|
||||
- [ ] Verify: success toast appears
|
||||
- [ ] Verify in ConnectWise: open the ticket and confirm the note was posted with correct content and attribution (your member name)
|
||||
|
||||
---
|
||||
|
||||
## Step 7 — FlowPilot Settings
|
||||
|
||||
Configure how FlowPilot behaves with PSA automation.
|
||||
|
||||
- [ ] Go to **Account → Integrations → FlowPilot** tab
|
||||
- [ ] Review each setting:
|
||||
- **Auto Push** — automatically post session doc on session close
|
||||
- **Auto Time Entry** — automatically log hours from session duration
|
||||
- **Time Rounding** — 15min / 30min / exact / none
|
||||
- **Note Visibility** — internal only vs. internal + external
|
||||
- **Include Diagnostic Steps** — whether to include step-by-step notes
|
||||
- **Prompt Status on Resolution** — ask to update CW status when resolving
|
||||
- **Prompt Status on Escalation** — ask to update CW status when escalating
|
||||
- [ ] Adjust to your preference and save
|
||||
|
||||
---
|
||||
|
||||
## Step 8 — End-to-End Smoke Test
|
||||
|
||||
Run a complete session to confirm the full flow works together.
|
||||
|
||||
- [ ] Start a new FlowPilot session with a test ticket in CW
|
||||
- [ ] Link the ticket at session start
|
||||
- [ ] Work through a troubleshooting flow (even a simple one)
|
||||
- [ ] Resolve or escalate the session
|
||||
- [ ] Post the session documentation to the CW ticket
|
||||
- [ ] Open the ticket in ConnectWise and confirm:
|
||||
- [ ] Note content is correct and well-formatted
|
||||
- [ ] Note is attributed to the correct CW member
|
||||
- [ ] Ticket status was updated (if you chose to update)
|
||||
- [ ] Duration / time entry was logged (if auto-time-entry is on)
|
||||
|
||||
---
|
||||
|
||||
## Known Issues / Bugs Fixed
|
||||
|
||||
| Bug | Status | Location |
|
||||
|-----|--------|----------|
|
||||
| `create_time_entry()` used `self._client` instead of `self.client` | Fixed 2026-04-14 | `services/psa/connectwise/provider.py:539` |
|
||||
|
||||
---
|
||||
|
||||
## What's NOT Yet Implemented
|
||||
|
||||
| Feature | Notes |
|
||||
|---------|-------|
|
||||
| Autotask PSA | Schema accepts `autotask` as provider but no implementation exists |
|
||||
| Retry queue for failed posts | `retry_count` / `next_retry_at` columns exist in DB but no background job |
|
||||
| `psa_activity_log` population | Table exists, no endpoints write to it yet |
|
||||
| Post History tab | Currently a placeholder — post history is viewable per-session only |
|
||||
@@ -0,0 +1,757 @@
|
||||
# Network Diagram Editor — Draw.io-Style Implementation Document
|
||||
|
||||
> **Date:** 2026-04-13
|
||||
> **Status:** Proposed
|
||||
> **Audience:** Product, frontend, backend, and agentic workers
|
||||
> **Goal:** Build a production-grade network diagram editor inside ResolutionFlow that feels close to draw.io while staying MSP- and topology-focused.
|
||||
|
||||
---
|
||||
|
||||
## Executive Summary
|
||||
|
||||
ResolutionFlow should implement network diagrams as a first-class editor surface, not as a lightweight canvas utility. The right target is not "clone draw.io exactly," but "deliver draw.io-grade editing quality for MSP network topology work."
|
||||
|
||||
The recommended path is:
|
||||
|
||||
1. **Use the existing network-diagram branch architecture as the foundation**
|
||||
2. **Ship the already-proven CRUD/editor shell as Phase 1**
|
||||
3. **Invest the next phases in interaction quality, editor commands, and interoperability**
|
||||
4. **Keep a ResolutionFlow-native JSON schema as the source of truth**
|
||||
5. **Add draw.io compatibility at import/export boundaries, not at the storage layer**
|
||||
|
||||
This preserves delivery speed while giving the product room to grow into a robust diagramming tool.
|
||||
|
||||
---
|
||||
|
||||
## Product Goal
|
||||
|
||||
Build a network diagram creation tool that:
|
||||
|
||||
- Feels familiar to users who already know draw.io
|
||||
- Supports MSP workflows better than a generic diagramming app
|
||||
- Makes manual editing fast and safe
|
||||
- Supports AI-assisted generation and clean-up without depending on AI for correctness
|
||||
- Fits ResolutionFlow's existing frontend/backend architecture cleanly
|
||||
|
||||
Success is not measured by raw feature count alone. Success means a user can open the editor and confidently:
|
||||
|
||||
- Create a clean network map from scratch
|
||||
- Drag devices from a stencil palette onto a canvas
|
||||
- Connect, label, group, align, copy, duplicate, and organize elements quickly
|
||||
- Save and revisit diagrams safely
|
||||
- Export the result for documentation and client communication
|
||||
|
||||
---
|
||||
|
||||
## Existing Repo Context
|
||||
|
||||
ResolutionFlow already has strong signals for this direction:
|
||||
|
||||
- The main architecture is React 19 + Vite + TypeScript on the frontend, FastAPI + PostgreSQL on the backend.
|
||||
- There are existing design and plan docs for network diagrams:
|
||||
- `docs/superpowers/specs/2026-04-04-react-flow-ui-network-diagrams-design.md`
|
||||
- `docs/superpowers/specs/2026-04-04-network-diagram-ux-improvements-design.md`
|
||||
- `docs/superpowers/plans/2026-04-04-react-flow-ui-network-diagrams.md`
|
||||
- `docs/superpowers/plans/2026-04-04-network-diagram-ux-improvements.md`
|
||||
- Git history shows a substantial prior implementation on `feat/network-map-builder-prod`.
|
||||
|
||||
That branch already included:
|
||||
|
||||
- Backend model, schema, migration, API routes, and AI generation service
|
||||
- Frontend list page and editor page
|
||||
- React Flow-based canvas
|
||||
- Device registry and custom node types
|
||||
- Context menu, copy/paste/duplicate shortcuts, and drag/drop improvements
|
||||
- Inspector/properties panel
|
||||
- Import/export JSON
|
||||
|
||||
This is important: **the best implementation path is to revive and harden that architecture, not to invent a parallel one.**
|
||||
|
||||
---
|
||||
|
||||
## Non-Goals
|
||||
|
||||
The first implementation should not try to become a full whiteboard platform.
|
||||
|
||||
Out of scope for the initial milestone:
|
||||
|
||||
- Real-time multiplayer collaboration
|
||||
- Comments/presence/cursors
|
||||
- Arbitrary slide decks or presentation features
|
||||
- BPMN/UML/general enterprise diagram libraries
|
||||
- Full draw.io parity on day one
|
||||
- Replacing ResolutionFlow's tree editor architecture
|
||||
|
||||
The editor should stay focused on network topology and MSP documentation use cases.
|
||||
|
||||
---
|
||||
|
||||
## User Experience Target
|
||||
|
||||
The editor should feel close to draw.io in the following ways:
|
||||
|
||||
- Fast drag/drop from a left stencil panel
|
||||
- Predictable selection behavior
|
||||
- Context menus and keyboard shortcuts
|
||||
- Snap-to-grid and alignment affordances
|
||||
- Resizable groups and containers
|
||||
- Good edge routing options
|
||||
- Easy text and label editing
|
||||
- Familiar import/export workflows
|
||||
|
||||
The editor should exceed draw.io in MSP-specific workflows:
|
||||
|
||||
- Device types that reflect real client environments
|
||||
- AI-generated starting diagrams from text descriptions
|
||||
- Client and asset metadata
|
||||
- Future hooks into PSA, assets, tickets, and documentation
|
||||
|
||||
---
|
||||
|
||||
## Recommended Architecture
|
||||
|
||||
### Frontend
|
||||
|
||||
Use a dedicated feature area:
|
||||
|
||||
- `frontend/src/pages/NetworkDiagrams/`
|
||||
- `frontend/src/components/network/`
|
||||
- `frontend/src/api/networkDiagrams.ts`
|
||||
- `frontend/src/types/network-diagram.ts`
|
||||
|
||||
Use React Flow as the canvas/rendering engine.
|
||||
|
||||
Why React Flow:
|
||||
|
||||
- Already used conceptually in the codebase
|
||||
- Strong node/edge rendering model
|
||||
- Good selection/dragging/viewport primitives
|
||||
- Enough flexibility for custom nodes, groups, and edge styles
|
||||
- Faster path to production than building custom canvas behavior from scratch
|
||||
|
||||
### Backend
|
||||
|
||||
Use a document-style data model stored in PostgreSQL JSONB:
|
||||
|
||||
- `network_diagrams` table
|
||||
- JSONB `nodes`
|
||||
- JSONB `edges`
|
||||
- Metadata columns for account scoping, names, timestamps, archive state
|
||||
|
||||
Why document storage:
|
||||
|
||||
- Flexible schema evolution
|
||||
- Fast implementation
|
||||
- Simple import/export
|
||||
- Easy autosave
|
||||
- Works well with editor-state persistence
|
||||
|
||||
### Source of Truth
|
||||
|
||||
Use a ResolutionFlow-native schema as the system of record.
|
||||
|
||||
Do not store draw.io XML as the primary database format.
|
||||
|
||||
Instead:
|
||||
|
||||
- Store native JSON internally
|
||||
- Import from draw.io into native JSON
|
||||
- Export native JSON to draw.io-compatible XML when needed
|
||||
|
||||
This keeps the application decoupled from an external vendor format.
|
||||
|
||||
---
|
||||
|
||||
## Recommended File Layout
|
||||
|
||||
### Frontend
|
||||
|
||||
- `frontend/src/pages/NetworkDiagrams/index.tsx`
|
||||
- Diagram list page
|
||||
|
||||
- `frontend/src/pages/NetworkDiagrams/DiagramEditor.tsx`
|
||||
- Editor orchestration layer
|
||||
|
||||
- `frontend/src/components/network/NetworkCanvas.tsx`
|
||||
- React Flow wrapper and viewport surface
|
||||
|
||||
- `frontend/src/components/network/DiagramHeader.tsx`
|
||||
- Save state, title, metadata actions, export/import controls
|
||||
|
||||
- `frontend/src/components/network/ContextMenu.tsx`
|
||||
- Node/canvas context menus
|
||||
|
||||
- `frontend/src/components/network/CanvasEmptyPrompt.tsx`
|
||||
- Empty-state guidance
|
||||
|
||||
- `frontend/src/components/network/panels/DeviceToolbar.tsx`
|
||||
- Stencil palette and searchable device library
|
||||
|
||||
- `frontend/src/components/network/panels/PropertiesPanel.tsx`
|
||||
- Inspector for node/edge editing
|
||||
|
||||
- `frontend/src/components/network/panels/AIAssistPanel.tsx`
|
||||
- AI generate/merge UX
|
||||
|
||||
- `frontend/src/components/network/hooks/useCanvasShortcuts.ts`
|
||||
- Keyboard shortcuts and clipboard behavior
|
||||
|
||||
- `frontend/src/components/network/hooks/useDiagramCommands.ts`
|
||||
- Shared command layer for actions invoked by keyboard, menus, and toolbar
|
||||
|
||||
- `frontend/src/components/network/nodes/*`
|
||||
- Node components, registry, types, and render configuration
|
||||
|
||||
- `frontend/src/components/network/edges/*`
|
||||
- Edge components and routing styles
|
||||
|
||||
- `frontend/src/api/networkDiagrams.ts`
|
||||
- CRUD, import/export, AI generation, duplication
|
||||
|
||||
- `frontend/src/types/network-diagram.ts`
|
||||
- Shared client-side typing
|
||||
|
||||
### Backend
|
||||
|
||||
- `backend/app/models/network_diagram.py`
|
||||
- SQLAlchemy model
|
||||
|
||||
- `backend/app/schemas/network_diagram.py`
|
||||
- Pydantic request/response models
|
||||
|
||||
- `backend/app/api/endpoints/network_diagrams.py`
|
||||
- CRUD + import/export + AI routes
|
||||
|
||||
- `backend/app/services/network_diagram_ai_service.py`
|
||||
- AI generation and later AI clean-up/layout assistance
|
||||
|
||||
- `backend/alembic/versions/074_add_network_diagrams_table.py`
|
||||
- Initial schema migration
|
||||
|
||||
- `backend/app/models/network_diagram_version.py`
|
||||
- Later addition for version history
|
||||
|
||||
- `backend/app/api/endpoints/network_diagram_versions.py`
|
||||
- Later addition for restore/history flows
|
||||
|
||||
---
|
||||
|
||||
## Data Model
|
||||
|
||||
### V1 Database Model
|
||||
|
||||
The existing JSONB storage pattern is good for a first release:
|
||||
|
||||
- `id`
|
||||
- `account_id`
|
||||
- `name`
|
||||
- `client_name`
|
||||
- `asset_name`
|
||||
- `description`
|
||||
- `nodes` JSONB
|
||||
- `edges` JSONB
|
||||
- `thumbnail_url`
|
||||
- `is_archived`
|
||||
- `created_by`
|
||||
- `created_at`
|
||||
- `updated_at`
|
||||
|
||||
### Recommended Node Schema
|
||||
|
||||
Use a richer internal node shape than a minimal device-only object. The schema should be resilient enough to support future features without painful migration.
|
||||
|
||||
Recommended fields:
|
||||
|
||||
- `id`
|
||||
- `kind`
|
||||
- `device | group | text | shape | image`
|
||||
- `type`
|
||||
- device slug or shape subtype
|
||||
- `label`
|
||||
- `position`
|
||||
- `size`
|
||||
- `rotation`
|
||||
- `zIndex`
|
||||
- `parentId`
|
||||
- `ports`
|
||||
- `style`
|
||||
- `data`
|
||||
|
||||
Examples:
|
||||
|
||||
- `kind=device`, `type=router`
|
||||
- `kind=group`, `type=subnet`
|
||||
- `kind=text`, `type=label`
|
||||
|
||||
### Recommended Edge Schema
|
||||
|
||||
- `id`
|
||||
- `source`
|
||||
- `target`
|
||||
- `sourcePort`
|
||||
- `targetPort`
|
||||
- `label`
|
||||
- `routing`
|
||||
- `straight | step | orthogonal | curved`
|
||||
- `waypoints`
|
||||
- `style`
|
||||
- `data`
|
||||
|
||||
### Why This Matters
|
||||
|
||||
The prior branch stored a solid but fairly lean `DiagramNode`/`DiagramEdge`. That is enough to start, but draw.io-like editing will need more than just `position`, `label`, and `connectionType`.
|
||||
|
||||
If we adopt the richer shape early, we reduce future rework in:
|
||||
|
||||
- manual bend-point support
|
||||
- z-ordering
|
||||
- groups/containers
|
||||
- text/shape nodes
|
||||
- port-specific connections
|
||||
|
||||
---
|
||||
|
||||
## Editor State Model
|
||||
|
||||
The editor should be built around four distinct layers of state:
|
||||
|
||||
### 1. Persisted Diagram State
|
||||
|
||||
What is saved to the backend:
|
||||
|
||||
- nodes
|
||||
- edges
|
||||
- metadata
|
||||
|
||||
### 2. Editor UI State
|
||||
|
||||
What lives only in the browser while editing:
|
||||
|
||||
- selected node IDs
|
||||
- selected edge IDs
|
||||
- open context menu
|
||||
- active tool
|
||||
- inspector visibility
|
||||
- drag-over state
|
||||
- clipboard reference
|
||||
|
||||
### 3. Derived View State
|
||||
|
||||
- filtered palette items
|
||||
- current selection bounds
|
||||
- whether paste is available
|
||||
- whether align/distribute commands are valid
|
||||
|
||||
### 4. History State
|
||||
|
||||
- undo stack
|
||||
- redo stack
|
||||
- last autosave timestamp
|
||||
|
||||
The editor page should orchestrate these, but command logic should not be scattered across component trees.
|
||||
|
||||
---
|
||||
|
||||
## Command System
|
||||
|
||||
To make the tool feel like draw.io, add a shared command layer.
|
||||
|
||||
Recommended hook:
|
||||
|
||||
- `frontend/src/components/network/hooks/useDiagramCommands.ts`
|
||||
|
||||
This hook should expose commands like:
|
||||
|
||||
- `copySelection`
|
||||
- `pasteSelection`
|
||||
- `duplicateSelection`
|
||||
- `deleteSelection`
|
||||
- `selectAll`
|
||||
- `fitView`
|
||||
- `bringToFront`
|
||||
- `sendToBack`
|
||||
- `alignLeft`
|
||||
- `alignCenter`
|
||||
- `alignRight`
|
||||
- `alignTop`
|
||||
- `alignMiddle`
|
||||
- `alignBottom`
|
||||
- `distributeHorizontally`
|
||||
- `distributeVertically`
|
||||
- `groupSelection`
|
||||
- `ungroupSelection`
|
||||
- `lockSelection`
|
||||
- `unlockSelection`
|
||||
|
||||
All of the following should call the same command functions:
|
||||
|
||||
- keyboard shortcuts
|
||||
- toolbar buttons
|
||||
- context menu items
|
||||
- future command palette entries
|
||||
|
||||
This avoids duplicate logic and keeps behavior consistent.
|
||||
|
||||
---
|
||||
|
||||
## Phase-by-Phase Delivery Plan
|
||||
|
||||
## Phase 1 — Foundation MVP
|
||||
|
||||
### Objective
|
||||
|
||||
Ship a usable network diagram editor quickly using the existing branch shape.
|
||||
|
||||
### Scope
|
||||
|
||||
- Diagram list page
|
||||
- Create/edit/archive/duplicate
|
||||
- React Flow canvas
|
||||
- Searchable device palette
|
||||
- Device and group nodes
|
||||
- Edge creation
|
||||
- Properties panel
|
||||
- Save + autosave
|
||||
- Import/export ResolutionFlow JSON
|
||||
- Basic AI generation from natural language
|
||||
- Context menu
|
||||
- Keyboard shortcuts:
|
||||
- copy
|
||||
- paste
|
||||
- duplicate
|
||||
- select all
|
||||
- fit view
|
||||
- delete
|
||||
|
||||
### Frontend Work
|
||||
|
||||
- Restore `NetworkDiagrams` page routes and navigation
|
||||
- Restore `DiagramEditor`
|
||||
- Restore `NetworkCanvas`
|
||||
- Restore node/edge registries
|
||||
- Restore clipboard + context menu behavior
|
||||
- Add command-layer extraction if time allows
|
||||
|
||||
### Backend Work
|
||||
|
||||
- Restore migration, model, schemas, endpoints
|
||||
- Validate account scoping and tenant isolation
|
||||
- Restore import/export endpoint
|
||||
- Restore AI generate endpoint
|
||||
|
||||
### Acceptance Criteria
|
||||
|
||||
- User can create and save a network map
|
||||
- User can reopen it later
|
||||
- User can drag devices from palette onto canvas
|
||||
- User can connect nodes and label links
|
||||
- User can copy/paste/duplicate/delete
|
||||
- User can import/export JSON
|
||||
- User can generate a starter diagram from text
|
||||
|
||||
---
|
||||
|
||||
## Phase 2 — Draw.io-Grade Editing Quality
|
||||
|
||||
### Objective
|
||||
|
||||
Close the biggest UX gap between a basic node editor and a real diagramming tool.
|
||||
|
||||
### Scope
|
||||
|
||||
- Snap-to-guides in addition to snap-to-grid
|
||||
- Alignment commands
|
||||
- Distribution commands
|
||||
- Multi-select improvements
|
||||
- Better z-order handling
|
||||
- Inline text editing
|
||||
- Better group/container behavior
|
||||
- Rich edge routing choices
|
||||
- Manual bend points
|
||||
- Port-aware connection handling
|
||||
- Keyboard nudging and modifier behavior
|
||||
|
||||
### New/Expanded Files
|
||||
|
||||
- `hooks/useDiagramCommands.ts`
|
||||
- `hooks/useSelectionBounds.ts`
|
||||
- `components/network/guides/*`
|
||||
- `components/network/edges/*`
|
||||
|
||||
### Acceptance Criteria
|
||||
|
||||
- Multi-select editing feels reliable
|
||||
- Align/distribute work predictably
|
||||
- User can produce a polished topology without fighting the canvas
|
||||
- Connectors can be shaped intentionally rather than only auto-routed
|
||||
|
||||
---
|
||||
|
||||
## Phase 3 — Interoperability and Export
|
||||
|
||||
### Objective
|
||||
|
||||
Let the editor fit real customer and internal documentation workflows.
|
||||
|
||||
### Scope
|
||||
|
||||
- SVG export
|
||||
- PNG export
|
||||
- PDF export
|
||||
- Thumbnail generation
|
||||
- Draw.io XML import
|
||||
- Draw.io XML export
|
||||
|
||||
### Implementation Notes
|
||||
|
||||
Do not try to mirror every draw.io primitive internally.
|
||||
|
||||
Instead:
|
||||
|
||||
- Build a compatible subset for network maps
|
||||
- Translate supported draw.io elements into native nodes/edges/groups/text
|
||||
- Emit supported native diagrams back into draw.io XML
|
||||
- Warn on unsupported constructs during import
|
||||
|
||||
### Acceptance Criteria
|
||||
|
||||
- A diagram can be exported for customer-facing documentation
|
||||
- A supported draw.io network map can be imported into ResolutionFlow
|
||||
- Users can move work between tools without losing essential topology content
|
||||
|
||||
---
|
||||
|
||||
## Phase 4 — ResolutionFlow-Native Differentiation
|
||||
|
||||
### Objective
|
||||
|
||||
Make this better than a generic diagram editor for MSP use cases.
|
||||
|
||||
### Scope
|
||||
|
||||
- AI merge into existing topology
|
||||
- AI tidy-up / auto-layout refinement
|
||||
- Asset-aware device metadata
|
||||
- Client templates
|
||||
- Common MSP topology starter kits
|
||||
- Diagram-to-ticket or diagram-to-flow linking
|
||||
- Version history and restore
|
||||
|
||||
### Acceptance Criteria
|
||||
|
||||
- AI helps users start faster and clean up faster
|
||||
- Diagrams connect to the rest of the ResolutionFlow product
|
||||
- Version history reduces fear of experimentation
|
||||
|
||||
---
|
||||
|
||||
## Draw.io Parity Matrix
|
||||
|
||||
| Capability | Priority | Notes |
|
||||
|-----------|----------|-------|
|
||||
| Drag/drop stencil palette | P0 | Must feel immediate and stable |
|
||||
| Node resize/move/select | P0 | Core editor behavior |
|
||||
| Edge creation and labeling | P0 | Core topology use case |
|
||||
| Copy/paste/duplicate/delete | P0 | Expected baseline |
|
||||
| Context menu + keyboard shortcuts | P0 | Must be fast and familiar |
|
||||
| Snap-to-grid | P0 | Already supported directionally |
|
||||
| Align/distribute | P1 | Big usability leap |
|
||||
| Grouping/containers | P1 | Important for subnets, rooms, racks |
|
||||
| Edge routing modes | P1 | Necessary for professional-looking diagrams |
|
||||
| Inline text editing | P1 | Draw.io expectation |
|
||||
| Layers/lock/hide | P2 | Useful once diagrams get large |
|
||||
| Draw.io import/export | P2 | Important for migration and adoption |
|
||||
| Realtime collaboration | P3 | Valuable, but not early priority |
|
||||
|
||||
---
|
||||
|
||||
## Risks and Mitigations
|
||||
|
||||
### Risk: Editor complexity balloons too quickly
|
||||
|
||||
**Mitigation**
|
||||
|
||||
- Keep the MVP narrow
|
||||
- Use phased delivery
|
||||
- Center everything around the command layer
|
||||
|
||||
### Risk: React Flow abstraction limits parity
|
||||
|
||||
**Mitigation**
|
||||
|
||||
- Validate manual bend points, grouping, and selection ergonomics early
|
||||
- If a specific advanced behavior is awkward, implement it in a focused extension layer instead of abandoning React Flow entirely
|
||||
|
||||
### Risk: Import/export compatibility becomes a trap
|
||||
|
||||
**Mitigation**
|
||||
|
||||
- Support a documented subset of draw.io semantics
|
||||
- Keep native JSON as the canonical internal model
|
||||
- Warn clearly on unsupported import constructs
|
||||
|
||||
### Risk: AI-generated diagrams feel impressive but unreliable
|
||||
|
||||
**Mitigation**
|
||||
|
||||
- Treat AI output as a starting point only
|
||||
- Keep editing UX first-class
|
||||
- Make merge mode explicit and safe
|
||||
|
||||
### Risk: Users lose work through autosave/history gaps
|
||||
|
||||
**Mitigation**
|
||||
|
||||
- Add diagram versioning soon after MVP
|
||||
- Preserve a local dirty-state guard
|
||||
- Add explicit "saved at" feedback
|
||||
|
||||
---
|
||||
|
||||
## Versioning Recommendation
|
||||
|
||||
Version history should be planned early, even if shipped after the MVP.
|
||||
|
||||
Recommended table:
|
||||
|
||||
- `network_diagram_versions`
|
||||
- `id`
|
||||
- `diagram_id`
|
||||
- `account_id`
|
||||
- `snapshot` JSONB
|
||||
- `created_by`
|
||||
- `label`
|
||||
- `created_at`
|
||||
|
||||
Recommended triggers for version creation:
|
||||
|
||||
- explicit "Save Version"
|
||||
- before destructive import-replace
|
||||
- before AI replace mode
|
||||
- optionally every N minutes when dirty changes are substantial
|
||||
|
||||
This is one of the highest-leverage additions for user trust.
|
||||
|
||||
---
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
### Frontend
|
||||
|
||||
- Unit-test command logic
|
||||
- Unit-test serialization/deserialization
|
||||
- Component-test context menu and shortcut behavior
|
||||
- E2E test core editor flows:
|
||||
- create diagram
|
||||
- drag node
|
||||
- connect nodes
|
||||
- save and reload
|
||||
- copy/paste
|
||||
- import/export
|
||||
|
||||
### Backend
|
||||
|
||||
- API tests for CRUD
|
||||
- API tests for tenant isolation
|
||||
- API tests for import/export validation
|
||||
- API tests for duplicate/archive
|
||||
- AI endpoint tests with mocked provider output
|
||||
|
||||
### Manual QA
|
||||
|
||||
Required flows:
|
||||
|
||||
- New blank diagram
|
||||
- Existing diagram edit
|
||||
- Large diagram performance
|
||||
- Multi-select behavior
|
||||
- Keyboard shortcut guard behavior while inputs are focused
|
||||
- Import malformed JSON
|
||||
- AI merge into populated canvas
|
||||
|
||||
---
|
||||
|
||||
## Suggested Delivery Order
|
||||
|
||||
### Slice 1
|
||||
|
||||
- Restore backend migration/model/schema/router
|
||||
- Restore types and API client
|
||||
- Restore list page
|
||||
|
||||
### Slice 2
|
||||
|
||||
- Restore editor shell and canvas
|
||||
- Restore nodes, edges, palette, save/load
|
||||
|
||||
### Slice 3
|
||||
|
||||
- Restore context menu, clipboard, shortcuts, inspector
|
||||
- Validate dirty-state and autosave behavior
|
||||
|
||||
### Slice 4
|
||||
|
||||
- Restore AI generation and merge mode
|
||||
- Tighten import/export UX
|
||||
|
||||
### Slice 5
|
||||
|
||||
- Implement command layer
|
||||
- Add align/distribute/z-order polish
|
||||
|
||||
### Slice 6
|
||||
|
||||
- Add version history
|
||||
- Add export polish and thumbnails
|
||||
|
||||
### Slice 7
|
||||
|
||||
- Add draw.io XML import/export subset
|
||||
|
||||
---
|
||||
|
||||
## Recommended Immediate Next Step
|
||||
|
||||
The best immediate implementation move is:
|
||||
|
||||
1. **Rebase or selectively port `feat/network-map-builder-prod` into the current codebase**
|
||||
2. **Use that as the Phase 1 foundation**
|
||||
3. **Do not start by rewriting the editor architecture**
|
||||
|
||||
That approach is faster, lower risk, and already aligned with the repo's documented direction.
|
||||
|
||||
---
|
||||
|
||||
## Worker Notes
|
||||
|
||||
If agentic workers implement this plan, they should:
|
||||
|
||||
- Reuse the existing network-diagram branch structure where possible
|
||||
- Avoid introducing a second diagram architecture
|
||||
- Keep native JSON as the canonical schema
|
||||
- Treat command centralization as a priority, not an afterthought
|
||||
- Ship MVP behavior first, then polish toward draw.io parity in focused slices
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
ResolutionFlow can support a draw.io-like network diagram editor without fighting its current stack. The prior network-diagram branch already proves the right foundation:
|
||||
|
||||
- React Flow canvas
|
||||
- FastAPI CRUD
|
||||
- JSONB persistence
|
||||
- device registry
|
||||
- AI assist
|
||||
- context menus
|
||||
- keyboard shortcuts
|
||||
- import/export
|
||||
|
||||
The real work now is not deciding whether to build it. The real work is:
|
||||
|
||||
- restoring that foundation cleanly,
|
||||
- formalizing the internal schema,
|
||||
- adding a reusable command system,
|
||||
- and iterating on the editor interactions until the experience feels professional.
|
||||
|
||||
That path gives ResolutionFlow a practical, high-value network topology tool quickly, while preserving a credible route to near-draw.io quality over the next phases.
|
||||
1320
docs/superpowers/plans/2026-04-13-network-diagrams-phase2.md
Normal file
1320
docs/superpowers/plans/2026-04-13-network-diagrams-phase2.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -2,6 +2,11 @@ import api from './client'
|
||||
import type {
|
||||
DashboardMetrics,
|
||||
ActivityEntry,
|
||||
AdminUserListResponse,
|
||||
AdminAccountListResponse,
|
||||
AdminAccountDetailResponse,
|
||||
AdminAccountCreate,
|
||||
AdminAccountUpdate,
|
||||
AuditLogListResponse,
|
||||
PlanLimitConfig,
|
||||
AccountOverrideResponse,
|
||||
@@ -78,7 +83,15 @@ export const adminApi = {
|
||||
createUser: (data: AdminUserCreate) =>
|
||||
api.post<AdminUserCreateResponse>('/admin/users', data).then(r => r.data),
|
||||
listUsers: (params?: Record<string, unknown>) =>
|
||||
api.get('/admin/users', { params }).then(r => r.data),
|
||||
api.get<AdminUserListResponse>('/admin/users', { params }).then(r => r.data),
|
||||
listAccounts: (params?: Record<string, unknown>) =>
|
||||
api.get<AdminAccountListResponse>('/admin/accounts', { params }).then(r => r.data),
|
||||
createAccount: (data: AdminAccountCreate) =>
|
||||
api.post<AdminAccountDetailResponse>('/admin/accounts', data).then(r => r.data),
|
||||
getAccountDetail: (id: string, params?: Record<string, unknown>) =>
|
||||
api.get<AdminAccountDetailResponse>(`/admin/accounts/${id}`, { params }).then(r => r.data),
|
||||
updateAccount: (id: string, data: AdminAccountUpdate) =>
|
||||
api.put<AdminAccountDetailResponse>(`/admin/accounts/${id}`, data).then(r => r.data),
|
||||
getUser: (id: string) =>
|
||||
api.get(`/admin/users/${id}`).then(r => r.data),
|
||||
updateUserRole: (id: string, role: string) =>
|
||||
@@ -119,6 +132,10 @@ export const adminApi = {
|
||||
api.put(`/admin/users/${id}/subscription/plan`, { plan }).then(r => r.data),
|
||||
extendUserTrial: (id: string, days: number) =>
|
||||
api.put(`/admin/users/${id}/subscription/extend-trial`, { days }).then(r => r.data),
|
||||
updateAccountSubscriptionPlan: (id: string, plan: string) =>
|
||||
api.put(`/admin/accounts/${id}/subscription/plan`, { plan }).then(r => r.data),
|
||||
extendAccountTrial: (id: string, days: number) =>
|
||||
api.put(`/admin/accounts/${id}/subscription/extend-trial`, { days }).then(r => r.data),
|
||||
|
||||
// Invite Codes
|
||||
listInviteCodes: (params?: Record<string, unknown>) =>
|
||||
|
||||
@@ -49,9 +49,9 @@ function handleGlobalError(error: AxiosError) {
|
||||
return
|
||||
}
|
||||
|
||||
// Server errors (5xx)
|
||||
// Server errors (5xx) — show backend detail when available, else generic message
|
||||
if (status >= 500) {
|
||||
toast.error('Server error - please try again later')
|
||||
toast.error(detail || 'Server error - please try again later')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { apiClient } from './client'
|
||||
import type { PsaConnectionResponse, PsaConnectionCreate, PsaConnectionUpdate, PsaConnectionTestResponse } from '@/types'
|
||||
import type { TicketLinkResponse, PSATicketSearchResult, PSATicketInfo, PSATicketStatusItem, PsaPreviewResponse, PsaPostResponse, PsaPostLogEntry, PsaMemberResponse, PsaMemberMappingResponse, AutoMatchResult, FlowpilotSettings } from '@/types/integrations'
|
||||
import type { PSABoard, TicketLinkResponse, PSATicketSearchResult, PSATicketInfo, PSATicketStatusItem, PsaPreviewResponse, PsaPostResponse, PsaPostLogEntry, PsaMemberResponse, PsaMemberMappingResponse, AutoMatchResult, FlowpilotSettings } from '@/types/integrations'
|
||||
|
||||
export const integrationsApi = {
|
||||
getConnection: () =>
|
||||
@@ -13,8 +13,18 @@ export const integrationsApi = {
|
||||
apiClient.delete(`/integrations/psa/connections/${id}`),
|
||||
testConnection: (id: string) =>
|
||||
apiClient.post<PsaConnectionTestResponse>(`/integrations/psa/connections/${id}/test`).then(r => r.data),
|
||||
listBoards: () =>
|
||||
apiClient.get<PSABoard[]>('/integrations/psa/boards').then(r => r.data),
|
||||
searchTickets: (params: { query?: string; board_id?: number; include_closed?: boolean }) =>
|
||||
apiClient.get<PSATicketSearchResult[]>('/integrations/psa/tickets/search', { params }).then(r => r.data),
|
||||
searchTicketsQueue: (params: {
|
||||
assigned_to_me?: boolean
|
||||
unassigned?: boolean
|
||||
board_ids?: string
|
||||
page?: number
|
||||
page_size?: number
|
||||
}) =>
|
||||
apiClient.get<PSATicketSearchResult[]>('/integrations/psa/tickets/search', { params }).then(r => r.data),
|
||||
getTicket: (id: string) =>
|
||||
apiClient.get<PSATicketInfo>(`/integrations/psa/tickets/${id}`).then(r => r.data),
|
||||
getTicketStatuses: (ticketId: string) =>
|
||||
|
||||
@@ -51,6 +51,10 @@ export const networkDiagramsApi = {
|
||||
return response.data
|
||||
},
|
||||
|
||||
async uploadThumbnail(id: string, dataUrl: string): Promise<void> {
|
||||
await apiClient.post(`/network-diagrams/${id}/thumbnail`, { data_url: dataUrl })
|
||||
},
|
||||
|
||||
async aiGenerate(data: AIGenerateRequest): Promise<AIGenerateResponse> {
|
||||
const response = await apiClient.post<AIGenerateResponse>('/network-diagrams/ai-generate', data)
|
||||
return response.data
|
||||
|
||||
@@ -54,7 +54,7 @@ export function ActionMenu({ items }: ActionMenuProps) {
|
||||
onClick={() => setOpen(!open)}
|
||||
className={cn(
|
||||
'rounded-md p-1.5 text-muted-foreground transition-colors',
|
||||
'hover:bg-accent hover:text-foreground'
|
||||
'hover:bg-elevated hover:text-foreground'
|
||||
)}
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
@@ -81,7 +81,7 @@ export function ActionMenu({ items }: ActionMenuProps) {
|
||||
'disabled:opacity-50 disabled:pointer-events-none',
|
||||
item.destructive
|
||||
? 'text-red-400 hover:bg-red-400/10'
|
||||
: 'text-muted-foreground hover:bg-accent'
|
||||
: 'text-muted-foreground hover:bg-elevated'
|
||||
)}
|
||||
>
|
||||
{item.icon}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Link, useLocation } from 'react-router-dom'
|
||||
import {
|
||||
LayoutDashboard,
|
||||
Users,
|
||||
Building2,
|
||||
Ticket,
|
||||
FileText,
|
||||
Gauge,
|
||||
@@ -15,18 +15,54 @@ import {
|
||||
} from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const navItems = [
|
||||
{ path: '/admin', label: 'Dashboard', icon: LayoutDashboard, end: true },
|
||||
{ path: '/admin/users', label: 'Users', icon: Users },
|
||||
{ path: '/admin/invite-codes', label: 'Invite Codes', icon: Ticket },
|
||||
{ path: '/admin/audit-logs', label: 'Audit Logs', icon: FileText },
|
||||
{ path: '/admin/plan-limits', label: 'Plan Limits', icon: Gauge },
|
||||
{ path: '/admin/feature-flags', label: 'Feature Flags', icon: ToggleLeft },
|
||||
{ path: '/admin/settings', label: 'Settings', icon: Settings },
|
||||
{ path: '/admin/categories', label: 'Categories', icon: FolderTree },
|
||||
{ path: '/admin/survey-invites', label: 'Survey Invites', icon: ClipboardList },
|
||||
{ path: '/admin/survey-responses', label: 'Survey Responses', icon: MessageSquareText },
|
||||
{ path: '/admin/gallery', label: 'Gallery', icon: LayoutGrid },
|
||||
interface NavItem {
|
||||
path: string
|
||||
label: string
|
||||
icon: typeof LayoutDashboard
|
||||
end?: boolean
|
||||
}
|
||||
|
||||
interface NavSection {
|
||||
label?: string
|
||||
items: NavItem[]
|
||||
}
|
||||
|
||||
const navSections: NavSection[] = [
|
||||
{
|
||||
items: [
|
||||
{ path: '/admin', label: 'Dashboard', icon: LayoutDashboard, end: true },
|
||||
{ path: '/admin/accounts', label: 'Accounts', icon: Building2 },
|
||||
{ path: '/admin/invite-codes', label: 'Invite Codes', icon: Ticket },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Platform',
|
||||
items: [
|
||||
{ path: '/admin/plan-limits', label: 'Plan Limits', icon: Gauge },
|
||||
{ path: '/admin/feature-flags', label: 'Feature Flags', icon: ToggleLeft },
|
||||
{ path: '/admin/settings', label: 'Settings', icon: Settings },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Content',
|
||||
items: [
|
||||
{ path: '/admin/categories', label: 'Categories', icon: FolderTree },
|
||||
{ path: '/admin/gallery', label: 'Gallery', icon: LayoutGrid },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Feedback',
|
||||
items: [
|
||||
{ path: '/admin/survey-invites', label: 'Survey Invites', icon: ClipboardList },
|
||||
{ path: '/admin/survey-responses', label: 'Survey Responses', icon: MessageSquareText },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Audit',
|
||||
items: [
|
||||
{ path: '/admin/audit-logs', label: 'Audit Logs', icon: FileText },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
interface AdminSidebarProps {
|
||||
@@ -47,22 +83,33 @@ export function AdminSidebar({ className, onNavigate }: AdminSidebarProps) {
|
||||
<div className="p-4">
|
||||
<h2 className="text-lg font-bold text-foreground">Admin Panel</h2>
|
||||
</div>
|
||||
<nav className="flex-1 space-y-1 px-3">
|
||||
{navItems.map((item) => (
|
||||
<Link
|
||||
key={item.path}
|
||||
to={item.path}
|
||||
onClick={onNavigate}
|
||||
className={cn(
|
||||
'flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors',
|
||||
isActive(item.path, item.end)
|
||||
? 'bg-accent text-foreground'
|
||||
: 'text-muted-foreground hover:bg-accent hover:text-foreground'
|
||||
<nav className="flex-1 space-y-4 overflow-y-auto px-3">
|
||||
{navSections.map((section, i) => (
|
||||
<div key={i}>
|
||||
{section.label && (
|
||||
<p className="mb-1 px-3 text-[11px] font-medium uppercase tracking-[0.14em] text-muted-foreground">
|
||||
{section.label}
|
||||
</p>
|
||||
)}
|
||||
>
|
||||
<item.icon className="h-4 w-4" />
|
||||
{item.label}
|
||||
</Link>
|
||||
<div className="space-y-0.5">
|
||||
{section.items.map((item) => (
|
||||
<Link
|
||||
key={item.path}
|
||||
to={item.path}
|
||||
onClick={onNavigate}
|
||||
className={cn(
|
||||
'flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors',
|
||||
isActive(item.path, item.end)
|
||||
? 'bg-elevated text-foreground'
|
||||
: 'text-muted-foreground hover:bg-elevated hover:text-foreground'
|
||||
)}
|
||||
>
|
||||
<item.icon className="h-4 w-4" />
|
||||
{item.label}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
<div className="border-t border-border p-3">
|
||||
@@ -71,7 +118,7 @@ export function AdminSidebar({ className, onNavigate }: AdminSidebarProps) {
|
||||
onClick={onNavigate}
|
||||
className={cn(
|
||||
'flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium',
|
||||
'text-muted-foreground hover:bg-accent hover:text-foreground'
|
||||
'text-muted-foreground hover:bg-elevated hover:text-foreground'
|
||||
)}
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
|
||||
@@ -53,7 +53,7 @@ export function DataTable<T>({
|
||||
<div className="overflow-x-auto rounded-lg border border-border">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-accent">
|
||||
<tr className="border-b border-border bg-elevated">
|
||||
{columns.map((col) => (
|
||||
<th
|
||||
key={col.key}
|
||||
@@ -90,7 +90,7 @@ export function DataTable<T>({
|
||||
<tr key={i} className="border-b border-border last:border-0">
|
||||
{columns.map((col) => (
|
||||
<td key={col.key} className="px-4 py-3">
|
||||
<div className="h-4 w-3/4 animate-pulse rounded bg-accent" />
|
||||
<div className="h-4 w-3/4 animate-pulse rounded bg-muted" />
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
@@ -107,7 +107,7 @@ export function DataTable<T>({
|
||||
data.map((item) => (
|
||||
<tr
|
||||
key={keyExtractor(item)}
|
||||
className="border-b border-border last:border-0 hover:bg-accent transition-colors"
|
||||
className="border-b border-border last:border-0 hover:bg-elevated transition-colors"
|
||||
>
|
||||
{columns.map((col) => (
|
||||
<td key={col.key} className={cn('px-4 py-3', col.className)}>
|
||||
|
||||
@@ -43,7 +43,7 @@ export function Pagination({ page, totalPages, total, pageSize, onPageChange }:
|
||||
<button
|
||||
onClick={() => onPageChange(page - 1)}
|
||||
disabled={page <= 1}
|
||||
className={cn(btnBase, 'px-2 text-muted-foreground hover:bg-accent hover:text-foreground')}
|
||||
className={cn(btnBase, 'px-2 text-muted-foreground hover:bg-elevated hover:text-foreground')}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</button>
|
||||
@@ -59,7 +59,7 @@ export function Pagination({ page, totalPages, total, pageSize, onPageChange }:
|
||||
'px-2',
|
||||
p === page
|
||||
? 'bg-primary text-white'
|
||||
: 'text-muted-foreground hover:bg-accent hover:text-foreground'
|
||||
: 'text-muted-foreground hover:bg-elevated hover:text-foreground'
|
||||
)}
|
||||
>
|
||||
{p}
|
||||
@@ -69,7 +69,7 @@ export function Pagination({ page, totalPages, total, pageSize, onPageChange }:
|
||||
<button
|
||||
onClick={() => onPageChange(page + 1)}
|
||||
disabled={page >= totalPages}
|
||||
className={cn(btnBase, 'px-2 text-muted-foreground hover:bg-accent hover:text-foreground')}
|
||||
className={cn(btnBase, 'px-2 text-muted-foreground hover:bg-elevated hover:text-foreground')}
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
@@ -6,22 +6,26 @@ interface StatusBadgeProps {
|
||||
variant?: BadgeVariant
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
title?: string
|
||||
}
|
||||
|
||||
const variantClasses: Record<BadgeVariant, string> = {
|
||||
success: 'bg-emerald-400/10 text-emerald-400',
|
||||
destructive: 'bg-red-400/10 text-red-400',
|
||||
warning: 'bg-yellow-400/10 text-yellow-400',
|
||||
default: 'bg-accent text-muted-foreground',
|
||||
default: 'bg-muted text-muted-foreground',
|
||||
}
|
||||
|
||||
export function StatusBadge({ variant = 'default', children, className }: StatusBadgeProps) {
|
||||
export function StatusBadge({ variant = 'default', children, className, title }: StatusBadgeProps) {
|
||||
return (
|
||||
<span className={cn(
|
||||
'inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium',
|
||||
variantClasses[variant],
|
||||
className
|
||||
)}>
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium',
|
||||
variantClasses[variant],
|
||||
className
|
||||
)}
|
||||
title={title}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
|
||||
69
frontend/src/components/common/ConfirmButton.tsx
Normal file
69
frontend/src/components/common/ConfirmButton.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface ConfirmButtonProps {
|
||||
onConfirm: () => void
|
||||
children: React.ReactNode
|
||||
confirmLabel?: string
|
||||
className?: string
|
||||
confirmClassName?: string
|
||||
timeoutMs?: number
|
||||
'aria-label'?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Two-click inline confirm button.
|
||||
* First click arms the button (shows confirm state).
|
||||
* Second click executes the action.
|
||||
* Auto-resets after timeoutMs (default 3000ms).
|
||||
*/
|
||||
export function ConfirmButton({
|
||||
onConfirm,
|
||||
children,
|
||||
confirmLabel = 'Confirm?',
|
||||
className,
|
||||
confirmClassName,
|
||||
timeoutMs = 3000,
|
||||
'aria-label': ariaLabel,
|
||||
}: ConfirmButtonProps) {
|
||||
const [armed, setArmed] = useState(false)
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
const reset = useCallback(() => {
|
||||
setArmed(false)
|
||||
if (timerRef.current) {
|
||||
clearTimeout(timerRef.current)
|
||||
timerRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (timerRef.current) clearTimeout(timerRef.current)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleClick = () => {
|
||||
if (armed) {
|
||||
reset()
|
||||
onConfirm()
|
||||
} else {
|
||||
setArmed(true)
|
||||
timerRef.current = setTimeout(reset, timeoutMs)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClick}
|
||||
onBlur={reset}
|
||||
aria-label={ariaLabel}
|
||||
className={cn(armed ? confirmClassName : className)}
|
||||
>
|
||||
{armed ? confirmLabel : children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export default ConfirmButton
|
||||
@@ -48,7 +48,7 @@ export function ActiveFlowPilotSessions({ hideHeader = false }: { hideHeader?: b
|
||||
{sessions.map((session) => (
|
||||
<button
|
||||
key={session.id}
|
||||
onClick={() => navigate(session.session_type === 'chat' ? `/assistant/${session.id}` : `/pilot/${session.id}`)}
|
||||
onClick={() => navigate(`/pilot/${session.id}`)}
|
||||
className="card-interactive p-4 text-left"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2 mb-2">
|
||||
|
||||
@@ -52,7 +52,7 @@ export function RecentFlowPilotSessions({ hideHeader = false }: { hideHeader?: b
|
||||
return (
|
||||
<button
|
||||
key={session.id}
|
||||
onClick={() => navigate(session.session_type === 'chat' ? `/assistant/${session.id}` : `/pilot/${session.id}`)}
|
||||
onClick={() => navigate(`/pilot/${session.id}`)}
|
||||
className="flex w-full items-center gap-3 px-5 py-3 text-left hover:bg-[rgba(255,255,255,0.02)] transition-colors"
|
||||
style={{
|
||||
borderBottom: i < sessions.length - 1 ? '1px solid var(--color-border-default)' : undefined,
|
||||
|
||||
@@ -52,7 +52,7 @@ export function StartSessionInput() {
|
||||
if (completedUploadIds.length > 0) {
|
||||
state.uploadIds = completedUploadIds
|
||||
}
|
||||
navigate('/assistant', { state })
|
||||
navigate('/pilot', { state })
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
@@ -63,7 +63,7 @@ export function StartSessionInput() {
|
||||
}
|
||||
|
||||
const handleSuggestionClick = (suggestion: string) => {
|
||||
navigate('/assistant', { state: { prefill: suggestion } })
|
||||
navigate('/pilot', { state: { prefill: suggestion } })
|
||||
}
|
||||
|
||||
// ── File handling ──────────────────────────────
|
||||
|
||||
397
frontend/src/components/dashboard/TicketQueue.tsx
Normal file
397
frontend/src/components/dashboard/TicketQueue.tsx
Normal file
@@ -0,0 +1,397 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Ticket, ChevronDown, Check, Loader2, AlertCircle } from 'lucide-react'
|
||||
import { integrationsApi } from '@/api/integrations'
|
||||
import type { PSABoard, PSATicketSearchResult } from '@/types/integrations'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const PAGE_SIZE = 10
|
||||
|
||||
type Tab = 'mine' | 'unassigned'
|
||||
|
||||
function SkeletonRows() {
|
||||
return (
|
||||
<div className="space-y-0">
|
||||
{[0, 1, 2].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex items-center gap-4 px-5 py-3.5"
|
||||
style={{ borderBottom: '1px solid var(--color-border-default)' }}
|
||||
>
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="h-3 w-1/3 rounded bg-[rgba(255,255,255,0.06)] animate-pulse" />
|
||||
<div className="h-3 w-2/3 rounded bg-[rgba(255,255,255,0.04)] animate-pulse" />
|
||||
<div className="h-2.5 w-1/4 rounded bg-[rgba(255,255,255,0.03)] animate-pulse" />
|
||||
</div>
|
||||
<div className="h-6 w-16 rounded bg-[rgba(255,255,255,0.04)] animate-pulse shrink-0" />
|
||||
<div className="h-7 w-24 rounded bg-[rgba(255,255,255,0.04)] animate-pulse shrink-0" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface BoardSelectorProps {
|
||||
boards: PSABoard[]
|
||||
selectedIds: number[]
|
||||
onChange: (ids: number[]) => void
|
||||
}
|
||||
|
||||
function BoardSelector({ boards, selectedIds, onChange }: BoardSelectorProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
function handleClickOutside(e: MouseEvent) {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) {
|
||||
setOpen(false)
|
||||
}
|
||||
}
|
||||
if (open) {
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
}
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||
}, [open])
|
||||
|
||||
const allSelected = selectedIds.length === 0
|
||||
const label = allSelected
|
||||
? 'All Boards'
|
||||
: selectedIds.length === 1
|
||||
? (boards.find((b) => b.id === selectedIds[0])?.name ?? '1 board')
|
||||
: `${selectedIds.length} boards`
|
||||
|
||||
const handleAllBoards = () => {
|
||||
onChange([])
|
||||
}
|
||||
|
||||
const handleToggleBoard = (id: number) => {
|
||||
if (selectedIds.includes(id)) {
|
||||
const next = selectedIds.filter((x) => x !== id)
|
||||
onChange(next)
|
||||
} else {
|
||||
onChange([...selectedIds, id])
|
||||
}
|
||||
}
|
||||
|
||||
if (boards.length === 0) return null
|
||||
|
||||
return (
|
||||
<div ref={ref} className="relative">
|
||||
<button
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className="flex items-center gap-1.5 rounded-lg border border-[rgba(255,255,255,0.08)] bg-[rgba(255,255,255,0.04)] px-3 py-1.5 text-xs text-muted-foreground hover:text-foreground hover:border-[rgba(255,255,255,0.14)] transition-colors"
|
||||
>
|
||||
{label}
|
||||
<ChevronDown size={12} className={cn('transition-transform', open && 'rotate-180')} />
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="absolute right-0 top-full mt-1 z-50 w-52 rounded-lg border border-[rgba(255,255,255,0.1)] bg-card shadow-lg py-1">
|
||||
{/* All Boards */}
|
||||
<button
|
||||
onClick={handleAllBoards}
|
||||
className="flex w-full items-center gap-2.5 px-3 py-2 text-xs text-foreground hover:bg-[rgba(255,255,255,0.06)] transition-colors"
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
'flex h-3.5 w-3.5 shrink-0 items-center justify-center rounded border',
|
||||
allSelected
|
||||
? 'border-accent bg-accent'
|
||||
: 'border-[rgba(255,255,255,0.2)] bg-transparent',
|
||||
)}
|
||||
>
|
||||
{allSelected && <Check size={9} className="text-white" />}
|
||||
</span>
|
||||
All Boards
|
||||
</button>
|
||||
|
||||
{boards.length > 0 && (
|
||||
<div className="my-1" style={{ borderTop: '1px solid var(--color-border-default)' }} />
|
||||
)}
|
||||
|
||||
{boards.map((board) => {
|
||||
const checked = selectedIds.includes(board.id)
|
||||
return (
|
||||
<button
|
||||
key={board.id}
|
||||
onClick={() => handleToggleBoard(board.id)}
|
||||
className="flex w-full items-center gap-2.5 px-3 py-2 text-xs text-foreground hover:bg-[rgba(255,255,255,0.06)] transition-colors"
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
'flex h-3.5 w-3.5 shrink-0 items-center justify-center rounded border',
|
||||
checked
|
||||
? 'border-accent bg-accent'
|
||||
: 'border-[rgba(255,255,255,0.2)] bg-transparent',
|
||||
)}
|
||||
>
|
||||
{checked && <Check size={9} className="text-white" />}
|
||||
</span>
|
||||
<span className="truncate">{board.name}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface TicketRowProps {
|
||||
ticket: PSATicketSearchResult
|
||||
isLast: boolean
|
||||
onStartSession: (ticket: PSATicketSearchResult) => void
|
||||
}
|
||||
|
||||
function TicketRow({ ticket, isLast, onStartSession }: TicketRowProps) {
|
||||
return (
|
||||
<div
|
||||
className="flex items-center gap-3 px-5 py-3.5"
|
||||
style={{ borderBottom: isLast ? undefined : '1px solid var(--color-border-default)' }}
|
||||
>
|
||||
{/* Left: ticket info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-baseline gap-2 mb-0.5">
|
||||
<span className="font-mono text-xs font-semibold text-accent shrink-0">
|
||||
#{ticket.id}
|
||||
</span>
|
||||
<span className="text-sm text-foreground truncate">{ticket.summary}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 text-[0.6875rem] text-muted-foreground">
|
||||
{ticket.company_name && <span className="truncate">{ticket.company_name}</span>}
|
||||
{ticket.company_name && ticket.priority_name && (
|
||||
<span className="shrink-0">·</span>
|
||||
)}
|
||||
{ticket.priority_name && <span className="shrink-0">{ticket.priority_name}</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right: status badge + action */}
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{ticket.status_name && (
|
||||
<span className="hidden sm:inline-flex items-center rounded-md border border-[rgba(255,255,255,0.08)] bg-[rgba(255,255,255,0.04)] px-2 py-0.5 text-[0.625rem] text-muted-foreground">
|
||||
{ticket.status_name}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
onClick={() => onStartSession(ticket)}
|
||||
className="flex items-center gap-1.5 rounded-lg border border-accent/30 bg-accent-dim px-3 py-1.5 text-xs font-medium text-accent hover:bg-accent/20 hover:border-accent/50 transition-colors"
|
||||
>
|
||||
<Ticket size={11} />
|
||||
Start Session
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function TicketQueue() {
|
||||
const navigate = useNavigate()
|
||||
const [hasConnection, setHasConnection] = useState<boolean | null>(null)
|
||||
const [boards, setBoards] = useState<PSABoard[]>([])
|
||||
const [selectedBoardIds, setSelectedBoardIds] = useState<number[]>([])
|
||||
const [activeTab, setActiveTab] = useState<Tab>('mine')
|
||||
const [tickets, setTickets] = useState<PSATicketSearchResult[]>([])
|
||||
const [page, setPage] = useState(1)
|
||||
const [hasMore, setHasMore] = useState(false)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [loadingMore, setLoadingMore] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// Check connection on mount
|
||||
useEffect(() => {
|
||||
integrationsApi.getConnection()
|
||||
.then((conn) => {
|
||||
const active = !!(conn && conn.is_active)
|
||||
setHasConnection(active)
|
||||
})
|
||||
.catch(() => setHasConnection(false))
|
||||
}, [])
|
||||
|
||||
// Fetch boards once connection confirmed
|
||||
useEffect(() => {
|
||||
if (!hasConnection) return
|
||||
integrationsApi.listBoards()
|
||||
.then(setBoards)
|
||||
.catch(() => {}) // boards are optional — don't block UI
|
||||
}, [hasConnection])
|
||||
|
||||
const fetchTickets = useCallback(
|
||||
async (tab: Tab, boardIds: number[], pageNum: number, append: boolean) => {
|
||||
const params: Parameters<typeof integrationsApi.searchTicketsQueue>[0] = {
|
||||
page: pageNum,
|
||||
page_size: PAGE_SIZE,
|
||||
}
|
||||
if (tab === 'mine') {
|
||||
params.assigned_to_me = true
|
||||
} else {
|
||||
params.unassigned = true
|
||||
}
|
||||
if (boardIds.length > 0) {
|
||||
params.board_ids = boardIds.join(',')
|
||||
}
|
||||
|
||||
try {
|
||||
const results = await integrationsApi.searchTicketsQueue(params)
|
||||
if (append) {
|
||||
setTickets((prev) => [...prev, ...results])
|
||||
} else {
|
||||
setTickets(results)
|
||||
}
|
||||
setHasMore(results.length === PAGE_SIZE)
|
||||
setError(null)
|
||||
} catch {
|
||||
setError('Failed to load tickets. Check your PSA connection.')
|
||||
}
|
||||
},
|
||||
[],
|
||||
)
|
||||
|
||||
// Initial + reset fetch when tab or board selection changes
|
||||
useEffect(() => {
|
||||
if (!hasConnection) return
|
||||
setPage(1)
|
||||
setTickets([])
|
||||
setHasMore(false)
|
||||
setLoading(true)
|
||||
fetchTickets(activeTab, selectedBoardIds, 1, false).finally(() => setLoading(false))
|
||||
}, [activeTab, selectedBoardIds, hasConnection, fetchTickets])
|
||||
|
||||
const handleLoadMore = async () => {
|
||||
const nextPage = page + 1
|
||||
setPage(nextPage)
|
||||
setLoadingMore(true)
|
||||
await fetchTickets(activeTab, selectedBoardIds, nextPage, true)
|
||||
setLoadingMore(false)
|
||||
}
|
||||
|
||||
const handleStartSession = (ticket: PSATicketSearchResult) => {
|
||||
navigate('/pilot', {
|
||||
state: {
|
||||
psaTicketId: ticket.id,
|
||||
psaTicket: {
|
||||
id: ticket.id,
|
||||
summary: ticket.summary,
|
||||
company_name: ticket.company_name,
|
||||
board_name: ticket.board_name,
|
||||
status_name: ticket.status_name,
|
||||
priority_name: ticket.priority_name,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Don't render until we know connection status
|
||||
if (hasConnection === null) return null
|
||||
// No active connection → hide entirely
|
||||
if (!hasConnection) return null
|
||||
|
||||
return (
|
||||
<div className="card-flat overflow-hidden">
|
||||
{/* Header */}
|
||||
<div
|
||||
className="flex items-center justify-between px-5 py-3"
|
||||
style={{ borderBottom: '1px solid var(--color-border-default)' }}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Ticket size={14} className="text-accent" />
|
||||
<h3 className="font-heading text-sm font-bold text-foreground">Ticket Queue</h3>
|
||||
</div>
|
||||
<BoardSelector
|
||||
boards={boards}
|
||||
selectedIds={selectedBoardIds}
|
||||
onChange={(ids) => setSelectedBoardIds(ids)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div
|
||||
className="flex"
|
||||
style={{ borderBottom: '1px solid var(--color-border-default)' }}
|
||||
>
|
||||
{(['mine', 'unassigned'] as Tab[]).map((tab) => (
|
||||
<button
|
||||
key={tab}
|
||||
onClick={() => setActiveTab(tab)}
|
||||
className={cn(
|
||||
'px-5 py-2.5 text-xs font-medium transition-colors border-b-2 -mb-px',
|
||||
activeTab === tab
|
||||
? 'border-accent text-accent'
|
||||
: 'border-transparent text-muted-foreground hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
{tab === 'mine' ? 'My Tickets' : 'Unassigned'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div>
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div className="flex items-center gap-2 px-5 py-4 text-sm text-danger">
|
||||
<AlertCircle size={14} className="shrink-0" />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Loading skeleton */}
|
||||
{!error && loading && <SkeletonRows />}
|
||||
|
||||
{/* Ticket rows */}
|
||||
{!error && !loading && tickets.length > 0 && (
|
||||
<>
|
||||
{tickets.map((ticket, i) => (
|
||||
<TicketRow
|
||||
key={ticket.id}
|
||||
ticket={ticket}
|
||||
isLast={i === tickets.length - 1 && !hasMore}
|
||||
onStartSession={handleStartSession}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Empty states */}
|
||||
{!error && !loading && tickets.length === 0 && (
|
||||
<div className="px-5 py-8 text-center">
|
||||
<Ticket size={24} className="mx-auto mb-2 text-muted-foreground/40" />
|
||||
{activeTab === 'mine' ? (
|
||||
<>
|
||||
<p className="text-sm text-muted-foreground">No open tickets assigned to you</p>
|
||||
<p className="mt-1 text-[0.6875rem] text-muted-foreground/60">
|
||||
Make sure your member mapping is configured in Account → Integrations
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">No unassigned open tickets</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Load more */}
|
||||
{!error && !loading && hasMore && (
|
||||
<div
|
||||
className="px-5 py-3"
|
||||
style={{ borderTop: '1px solid var(--color-border-default)' }}
|
||||
>
|
||||
<button
|
||||
onClick={handleLoadMore}
|
||||
disabled={loadingMore}
|
||||
className="flex w-full items-center justify-center gap-2 rounded-lg border border-[rgba(255,255,255,0.08)] bg-transparent py-2 text-xs text-muted-foreground hover:text-foreground hover:border-[rgba(255,255,255,0.14)] disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{loadingMore ? (
|
||||
<>
|
||||
<Loader2 size={12} className="animate-spin" />
|
||||
Loading...
|
||||
</>
|
||||
) : (
|
||||
'Load more'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -18,9 +18,12 @@ const STATUS_CONFIG = {
|
||||
export function AISessionListItem({ session }: AISessionListItemProps) {
|
||||
const config = STATUS_CONFIG[session.status as keyof typeof STATUS_CONFIG] ?? STATUS_CONFIG.active
|
||||
const StatusIcon = config.icon
|
||||
// Both chat and guided sessions now land on the unified /pilot surface.
|
||||
// session_type is preserved on the DB row for data compatibility but is
|
||||
// no longer used for frontend route selection (Phase 1 FlowPilot migration).
|
||||
const isChat = session.session_type === 'chat'
|
||||
const TypeIcon = isChat ? MessageCircle : Route
|
||||
const linkTo = isChat ? `/assistant/${session.id}` : `/pilot/${session.id}`
|
||||
const linkTo = `/pilot/${session.id}`
|
||||
const displayTitle = isChat
|
||||
? (session.title || session.problem_summary || 'Untitled chat')
|
||||
: (session.problem_summary || 'Untitled session')
|
||||
|
||||
@@ -42,7 +42,7 @@ const PAGES: PaletteItem[] = [
|
||||
{ id: 'page-dashboard', group: 'pages', title: 'Dashboard', path: '/', icon: 'page' },
|
||||
{ id: 'page-flows', group: 'pages', title: 'All Flows', subtitle: 'Browse your flow library', path: '/trees', icon: 'page' },
|
||||
{ id: 'page-sessions', group: 'pages', title: 'Sessions', subtitle: 'View session history', path: '/sessions', icon: 'page' },
|
||||
{ id: 'page-assistant', group: 'pages', title: 'AI Assistant', subtitle: 'FlowPilot chat', path: '/assistant', icon: 'page' },
|
||||
{ id: 'page-flowpilot', group: 'pages', title: 'FlowPilot', subtitle: 'AI troubleshooting', path: '/pilot', icon: 'page' },
|
||||
{ id: 'page-scripts', group: 'pages', title: 'Script Generator', subtitle: 'Generate PowerShell scripts', path: '/scripts', icon: 'page' },
|
||||
{ id: 'page-analytics', group: 'pages', title: 'Analytics', subtitle: 'Team usage & metrics', path: '/analytics', icon: 'page' },
|
||||
{ id: 'page-settings', group: 'pages', title: 'Settings', subtitle: 'Account & preferences', path: '/account', icon: 'page' },
|
||||
@@ -177,7 +177,7 @@ export function CommandPalette({ open, onClose }: CommandPaletteProps) {
|
||||
group: 'flowpilot',
|
||||
title: 'Troubleshoot with FlowPilot',
|
||||
subtitle: trimmed,
|
||||
path: '/assistant',
|
||||
path: '/pilot',
|
||||
icon: 'sparkles',
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { Copy, CopyPlus, Trash2, ClipboardPaste, BoxSelect, Maximize2, BringToFront, SendToBack } from 'lucide-react'
|
||||
import {
|
||||
Copy, CopyPlus, Trash2, ClipboardPaste, BoxSelect, Ungroup, Maximize2, BringToFront, SendToBack,
|
||||
AlignStartVertical, AlignCenterHorizontal, AlignEndVertical,
|
||||
AlignStartHorizontal, AlignCenterVertical, AlignEndHorizontal,
|
||||
AlignHorizontalSpaceAround, AlignVerticalSpaceAround,
|
||||
} from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface MenuAction {
|
||||
@@ -15,9 +20,41 @@ interface ContextMenuProps {
|
||||
position: { x: number; y: number }
|
||||
actions: MenuAction[]
|
||||
onClose: () => void
|
||||
onAlignLeft?: () => void
|
||||
onAlignRight?: () => void
|
||||
onAlignCenterH?: () => void
|
||||
onAlignTop?: () => void
|
||||
onAlignBottom?: () => void
|
||||
onAlignCenterV?: () => void
|
||||
onDistributeH?: () => void
|
||||
onDistributeV?: () => void
|
||||
canAlign?: boolean
|
||||
canDistribute?: boolean
|
||||
onGroupSelection?: () => void
|
||||
onUngroupSelection?: () => void
|
||||
canGroup?: boolean
|
||||
canUngroup?: boolean
|
||||
}
|
||||
|
||||
export function ContextMenu({ position, actions, onClose }: ContextMenuProps) {
|
||||
export function ContextMenu({
|
||||
position,
|
||||
actions,
|
||||
onClose,
|
||||
onAlignLeft,
|
||||
onAlignRight,
|
||||
onAlignCenterH,
|
||||
onAlignTop,
|
||||
onAlignBottom,
|
||||
onAlignCenterV,
|
||||
onDistributeH,
|
||||
onDistributeV,
|
||||
canAlign,
|
||||
canDistribute,
|
||||
onGroupSelection,
|
||||
onUngroupSelection,
|
||||
canGroup,
|
||||
canUngroup,
|
||||
}: ContextMenuProps) {
|
||||
const menuRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const clampedPosition = { ...position }
|
||||
@@ -83,6 +120,59 @@ export function ContextMenu({ position, actions, onClose }: ContextMenuProps) {
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{canAlign && (
|
||||
<>
|
||||
<div className="border-t border-default my-1" />
|
||||
<div className="px-3 py-0.5 text-[10px] font-medium text-muted uppercase tracking-wider">Align</div>
|
||||
<button onClick={() => { onAlignLeft?.(); onClose() }} className="flex w-full items-center gap-2 px-3 py-2 text-xs text-primary hover:bg-elevated">
|
||||
<AlignStartVertical size={13} /> <span>Align Left</span>
|
||||
</button>
|
||||
<button onClick={() => { onAlignCenterH?.(); onClose() }} className="flex w-full items-center gap-2 px-3 py-2 text-xs text-primary hover:bg-elevated">
|
||||
<AlignCenterHorizontal size={13} /> <span>Align Center</span>
|
||||
</button>
|
||||
<button onClick={() => { onAlignRight?.(); onClose() }} className="flex w-full items-center gap-2 px-3 py-2 text-xs text-primary hover:bg-elevated">
|
||||
<AlignEndVertical size={13} /> <span>Align Right</span>
|
||||
</button>
|
||||
<button onClick={() => { onAlignTop?.(); onClose() }} className="flex w-full items-center gap-2 px-3 py-2 text-xs text-primary hover:bg-elevated">
|
||||
<AlignStartHorizontal size={13} /> <span>Align Top</span>
|
||||
</button>
|
||||
<button onClick={() => { onAlignCenterV?.(); onClose() }} className="flex w-full items-center gap-2 px-3 py-2 text-xs text-primary hover:bg-elevated">
|
||||
<AlignCenterVertical size={13} /> <span>Align Middle</span>
|
||||
</button>
|
||||
<button onClick={() => { onAlignBottom?.(); onClose() }} className="flex w-full items-center gap-2 px-3 py-2 text-xs text-primary hover:bg-elevated">
|
||||
<AlignEndHorizontal size={13} /> <span>Align Bottom</span>
|
||||
</button>
|
||||
{canDistribute && (
|
||||
<>
|
||||
<div className="border-t border-default my-1" />
|
||||
<div className="px-3 py-0.5 text-[10px] font-medium text-muted uppercase tracking-wider">Distribute</div>
|
||||
<button onClick={() => { onDistributeH?.(); onClose() }} className="flex w-full items-center gap-2 px-3 py-2 text-xs text-primary hover:bg-elevated">
|
||||
<AlignHorizontalSpaceAround size={13} /> <span>Space Horizontally</span>
|
||||
</button>
|
||||
<button onClick={() => { onDistributeV?.(); onClose() }} className="flex w-full items-center gap-2 px-3 py-2 text-xs text-primary hover:bg-elevated">
|
||||
<AlignVerticalSpaceAround size={13} /> <span>Space Vertically</span>
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{(canGroup || canUngroup) && (
|
||||
<>
|
||||
<div className="border-t border-default my-1" />
|
||||
{canGroup && (
|
||||
<button onClick={() => { onGroupSelection?.(); onClose() }} className="flex w-full items-center gap-2 px-3 py-2 text-xs text-primary hover:bg-elevated">
|
||||
<BoxSelect size={13} /> <span>Group Selection</span>
|
||||
</button>
|
||||
)}
|
||||
{canUngroup && (
|
||||
<button onClick={() => { onUngroupSelection?.(); onClose() }} className="flex w-full items-center gap-2 px-3 py-2 text-xs text-primary hover:bg-elevated">
|
||||
<Ungroup size={13} /> <span>Ungroup</span>
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { useState, useCallback, useRef, useEffect } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { ChevronLeft, Save, Download, FileJson, Image, FileText } from 'lucide-react'
|
||||
import { ChevronLeft, Save, Download, FileJson, FileCode, Image, FileText, Undo2, Redo2, MousePointer2, Hand, FileOutput, Upload, Cable } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export type InteractionMode = 'select' | 'pan' | 'connect'
|
||||
|
||||
interface DiagramHeaderProps {
|
||||
name: string
|
||||
@@ -12,8 +15,17 @@ interface DiagramHeaderProps {
|
||||
onNameChange: (name: string) => void
|
||||
onSave: () => void
|
||||
onExportPng: () => void
|
||||
onExportSvg: () => void
|
||||
onExportPdf: () => void
|
||||
onExportJson: () => void
|
||||
onExportDrawio: () => void
|
||||
onImportDrawio: () => void // draw.io import — triggered from Export menu
|
||||
onUndo: () => void
|
||||
onRedo: () => void
|
||||
canUndo: boolean
|
||||
canRedo: boolean
|
||||
interactionMode: InteractionMode
|
||||
onModeChange: (mode: InteractionMode) => void
|
||||
}
|
||||
|
||||
export function DiagramHeader({
|
||||
@@ -26,8 +38,17 @@ export function DiagramHeader({
|
||||
onNameChange,
|
||||
onSave,
|
||||
onExportPng,
|
||||
onExportSvg,
|
||||
onExportPdf,
|
||||
onExportJson,
|
||||
onExportDrawio,
|
||||
onImportDrawio,
|
||||
onUndo,
|
||||
onRedo,
|
||||
canUndo,
|
||||
canRedo,
|
||||
interactionMode,
|
||||
onModeChange,
|
||||
}: DiagramHeaderProps) {
|
||||
const navigate = useNavigate()
|
||||
const [editing, setEditing] = useState(false)
|
||||
@@ -88,6 +109,72 @@ export function DiagramHeader({
|
||||
|
||||
<div className="mx-2 h-5 w-px bg-border-default" />
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={onUndo}
|
||||
disabled={!canUndo}
|
||||
title="Undo (Ctrl+Z)"
|
||||
className="p-1.5 rounded text-muted-foreground hover:text-primary hover:bg-elevated disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
<Undo2 size={16} />
|
||||
</button>
|
||||
<button
|
||||
onClick={onRedo}
|
||||
disabled={!canRedo}
|
||||
title="Redo (Ctrl+Y)"
|
||||
className="p-1.5 rounded text-muted-foreground hover:text-primary hover:bg-elevated disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
<Redo2 size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mx-2 h-5 w-px bg-border-default" />
|
||||
|
||||
{/* Interaction mode toggle */}
|
||||
<div className="flex items-center overflow-hidden rounded border border-default">
|
||||
<button
|
||||
onClick={() => onModeChange('select')}
|
||||
title="Select (V)"
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 px-2.5 py-1.5 text-xs transition-colors',
|
||||
interactionMode === 'select'
|
||||
? 'bg-elevated text-primary'
|
||||
: 'text-muted-foreground hover:text-primary hover:bg-elevated/50',
|
||||
)}
|
||||
>
|
||||
<MousePointer2 size={15} />
|
||||
<span className="hidden sm:inline">Select</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onModeChange('pan')}
|
||||
title="Pan (H)"
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 border-l border-default px-2.5 py-1.5 text-xs transition-colors',
|
||||
interactionMode === 'pan'
|
||||
? 'bg-elevated text-primary'
|
||||
: 'text-muted-foreground hover:text-primary hover:bg-elevated/50',
|
||||
)}
|
||||
>
|
||||
<Hand size={15} />
|
||||
<span className="hidden sm:inline">Pan</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onModeChange('connect')}
|
||||
title="Connect (C)"
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 border-l border-default px-2.5 py-1.5 text-xs transition-colors',
|
||||
interactionMode === 'connect'
|
||||
? 'bg-elevated text-primary'
|
||||
: 'text-muted-foreground hover:text-primary hover:bg-elevated/50',
|
||||
)}
|
||||
>
|
||||
<Cable size={15} />
|
||||
<span className="hidden sm:inline">Connect</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mx-2 h-5 w-px bg-border-default" />
|
||||
|
||||
{editing ? (
|
||||
<input
|
||||
ref={inputRef}
|
||||
@@ -135,30 +222,51 @@ export function DiagramHeader({
|
||||
className="flex items-center gap-1.5 rounded border border-default px-3 py-1.5 text-xs text-primary hover:border-hover"
|
||||
>
|
||||
<Download size={14} />
|
||||
Export
|
||||
Export / Import
|
||||
</button>
|
||||
{showExportMenu && (
|
||||
<div className="absolute right-0 top-full z-50 mt-1 w-40 rounded border border-default bg-card py-1 shadow-lg">
|
||||
<div className="absolute right-0 top-full z-50 mt-1 w-44 rounded border border-default bg-card py-1 shadow-lg">
|
||||
<div className="px-3 py-1 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">Export as</div>
|
||||
<button
|
||||
onClick={() => { onExportPng(); setShowExportMenu(false) }}
|
||||
className="flex w-full items-center gap-2 px-3 py-1.5 text-xs text-primary hover:bg-elevated"
|
||||
>
|
||||
<Image size={12} /> Export PNG
|
||||
<Image size={12} /> PNG
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { onExportSvg(); setShowExportMenu(false) }}
|
||||
className="flex w-full items-center gap-2 px-3 py-1.5 text-xs text-primary hover:bg-elevated"
|
||||
>
|
||||
<FileCode size={12} /> SVG
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { onExportPdf(); setShowExportMenu(false) }}
|
||||
className="flex w-full items-center gap-2 px-3 py-1.5 text-xs text-primary hover:bg-elevated"
|
||||
>
|
||||
<FileText size={12} /> Export PDF
|
||||
<FileText size={12} /> PDF
|
||||
</button>
|
||||
{diagramId && (
|
||||
<button
|
||||
onClick={() => { onExportJson(); setShowExportMenu(false) }}
|
||||
className="flex w-full items-center gap-2 px-3 py-1.5 text-xs text-primary hover:bg-elevated"
|
||||
>
|
||||
<FileJson size={12} /> Export JSON
|
||||
<FileJson size={12} /> JSON
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => { onExportDrawio(); setShowExportMenu(false) }}
|
||||
className="flex w-full items-center gap-2 px-3 py-1.5 text-xs text-primary hover:bg-elevated"
|
||||
>
|
||||
<FileOutput size={12} /> draw.io
|
||||
</button>
|
||||
<div className="my-1 border-t border-default" />
|
||||
<div className="px-3 py-1 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">Import</div>
|
||||
<button
|
||||
onClick={() => { onImportDrawio(); setShowExportMenu(false) }}
|
||||
className="flex w-full items-center gap-2 px-3 py-1.5 text-xs text-primary hover:bg-elevated"
|
||||
>
|
||||
<Upload size={12} /> draw.io file…
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
129
frontend/src/components/network/KeyboardShortcutsOverlay.tsx
Normal file
129
frontend/src/components/network/KeyboardShortcutsOverlay.tsx
Normal file
@@ -0,0 +1,129 @@
|
||||
import { useEffect } from 'react'
|
||||
import { X } from 'lucide-react'
|
||||
|
||||
interface ShortcutRow {
|
||||
keys: string[]
|
||||
label: string
|
||||
}
|
||||
|
||||
interface ShortcutGroup {
|
||||
title: string
|
||||
rows: ShortcutRow[]
|
||||
}
|
||||
|
||||
const GROUPS: ShortcutGroup[] = [
|
||||
{
|
||||
title: 'Modes',
|
||||
rows: [
|
||||
{ keys: ['V'], label: 'Select mode' },
|
||||
{ keys: ['H'], label: 'Pan mode' },
|
||||
{ keys: ['C'], label: 'Connect mode' },
|
||||
{ keys: ['Space'], label: 'Temporary pan (hold)' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Canvas',
|
||||
rows: [
|
||||
{ keys: ['Ctrl', 'Shift', 'F'], label: 'Fit view' },
|
||||
{ keys: ['Ctrl', 'A'], label: 'Select all' },
|
||||
{ keys: ['Ctrl', 'Z'], label: 'Undo' },
|
||||
{ keys: ['Ctrl', 'Y'], label: 'Redo' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Nodes',
|
||||
rows: [
|
||||
{ keys: ['Ctrl', 'C'], label: 'Copy' },
|
||||
{ keys: ['Ctrl', 'V'], label: 'Paste' },
|
||||
{ keys: ['Ctrl', 'D'], label: 'Duplicate' },
|
||||
{ keys: ['Del'], label: 'Delete selected' },
|
||||
{ keys: [']'], label: 'Bring to front' },
|
||||
{ keys: ['['], label: 'Send to back' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Nudge',
|
||||
rows: [
|
||||
{ keys: ['↑', '↓', '←', '→'], label: 'Move 1px' },
|
||||
{ keys: ['Shift', '↑↓←→'], label: 'Move 10px' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
interface KeyboardShortcutsOverlayProps {
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
function Kbd({ children }: { children: string }) {
|
||||
return (
|
||||
<span className="inline-flex h-5 min-w-[1.25rem] items-center justify-center rounded border border-white/10 bg-white/[0.07] px-1.5 text-[10px] font-mono text-muted-foreground">
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export function KeyboardShortcutsOverlay({ onClose }: KeyboardShortcutsOverlayProps) {
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose()
|
||||
}
|
||||
document.addEventListener('keydown', handler)
|
||||
return () => document.removeEventListener('keydown', handler)
|
||||
}, [onClose])
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4 backdrop-blur-[2px]"
|
||||
onClick={e => { if (e.target === e.currentTarget) onClose() }}
|
||||
>
|
||||
<div className="w-full max-w-xl rounded-lg border border-default bg-card shadow-2xl">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between border-b border-default px-5 py-3.5">
|
||||
<div>
|
||||
<h2 className="font-heading text-sm font-semibold text-heading">Keyboard Shortcuts</h2>
|
||||
<p className="text-[11px] text-muted-foreground">Press <Kbd>?</Kbd> anytime to open this</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="flex h-7 w-7 items-center justify-center rounded border border-default text-muted-foreground hover:border-hover hover:text-primary"
|
||||
>
|
||||
<X size={13} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Shortcut grid */}
|
||||
<div className="grid grid-cols-2 gap-0 divide-x divide-default">
|
||||
{GROUPS.map((group, gi) => (
|
||||
<div key={group.title} className={gi >= 2 ? 'border-t border-default' : ''}>
|
||||
<div className="px-5 pb-2 pt-4">
|
||||
<div className="mb-2 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
{group.title}
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
{group.rows.map(row => (
|
||||
<div key={row.label} className="flex items-center justify-between gap-3">
|
||||
<span className="text-xs text-primary">{row.label}</span>
|
||||
<div className="flex shrink-0 items-center gap-0.5">
|
||||
{row.keys.map((k, i) => (
|
||||
<span key={i} className="flex items-center gap-0.5">
|
||||
{i > 0 && <span className="text-[10px] text-muted-foreground/50">+</span>}
|
||||
<Kbd>{k}</Kbd>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Footer hint */}
|
||||
<div className="border-t border-default px-5 py-2.5 text-[11px] text-muted-foreground">
|
||||
On Mac, <Kbd>Ctrl</Kbd> = <Kbd>⌘ Cmd</Kbd>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
MiniMap,
|
||||
BackgroundVariant,
|
||||
type OnConnect,
|
||||
type OnReconnect,
|
||||
type OnNodesChange,
|
||||
type OnEdgesChange,
|
||||
type Node,
|
||||
@@ -15,6 +16,8 @@ import { nodeTypes } from './nodes/nodeTypes'
|
||||
import { edgeTypes } from './edges/edgeTypes'
|
||||
import { getDeviceRenderConfig } from './nodes/deviceRegistry'
|
||||
import type { DeviceNodeData } from './nodes/DeviceNode'
|
||||
import type { InteractionMode } from './DiagramHeader'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface NetworkCanvasProps {
|
||||
nodes: Node[]
|
||||
@@ -22,6 +25,7 @@ interface NetworkCanvasProps {
|
||||
onNodesChange: OnNodesChange
|
||||
onEdgesChange: OnEdgesChange
|
||||
onConnect: OnConnect
|
||||
onReconnect: OnReconnect<Edge>
|
||||
onNodeSelect: (nodeId: string | null) => void
|
||||
onEdgeSelect: (edgeId: string | null) => void
|
||||
onDrop: (event: React.DragEvent) => void
|
||||
@@ -31,6 +35,7 @@ interface NetworkCanvasProps {
|
||||
onNodeContextMenu?: (event: React.MouseEvent, node: Node) => void
|
||||
onPaneContextMenu?: (event: MouseEvent | React.MouseEvent) => void
|
||||
onPaneClick?: () => void
|
||||
interactionMode?: InteractionMode
|
||||
}
|
||||
|
||||
export function NetworkCanvas({
|
||||
@@ -39,6 +44,7 @@ export function NetworkCanvas({
|
||||
onNodesChange,
|
||||
onEdgesChange,
|
||||
onConnect,
|
||||
onReconnect,
|
||||
onNodeSelect,
|
||||
onEdgeSelect,
|
||||
onDrop,
|
||||
@@ -48,6 +54,7 @@ export function NetworkCanvas({
|
||||
onNodeContextMenu,
|
||||
onPaneContextMenu,
|
||||
onPaneClick: onPaneClickProp,
|
||||
interactionMode = 'select',
|
||||
}: NetworkCanvasProps) {
|
||||
const handleSelectionChange = useCallback(({ nodes: selectedNodes, edges: selectedEdges }: { nodes: Node[]; edges: Edge[] }) => {
|
||||
if (selectedNodes.length === 1) {
|
||||
@@ -75,13 +82,22 @@ export function NetworkCanvas({
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="relative h-full w-full" onDragLeave={onDragLeave}>
|
||||
<div
|
||||
className="relative h-full w-full"
|
||||
onDragLeave={onDragLeave}
|
||||
onMouseDownCapture={(event) => {
|
||||
if (event.button === 1) {
|
||||
event.preventDefault()
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ReactFlow
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
onNodesChange={onNodesChange}
|
||||
onEdgesChange={onEdgesChange}
|
||||
onConnect={onConnect}
|
||||
onReconnect={onReconnect}
|
||||
onSelectionChange={handleSelectionChange}
|
||||
onPaneClick={handlePaneClick}
|
||||
onDrop={onDrop}
|
||||
@@ -91,12 +107,23 @@ export function NetworkCanvas({
|
||||
nodeTypes={nodeTypes}
|
||||
edgeTypes={edgeTypes}
|
||||
defaultEdgeOptions={{ type: 'connection' }}
|
||||
edgesReconnectable
|
||||
connectOnClick={interactionMode === 'connect'}
|
||||
reconnectRadius={20}
|
||||
connectionRadius={24}
|
||||
deleteKeyCode={['Backspace', 'Delete']}
|
||||
multiSelectionKeyCode="Shift"
|
||||
panOnDrag={interactionMode === 'pan' ? [0, 1] : [1]}
|
||||
selectionOnDrag={interactionMode === 'select'}
|
||||
panActivationKeyCode="Space"
|
||||
snapToGrid={true}
|
||||
snapGrid={[20, 20]}
|
||||
fitView
|
||||
className="bg-page"
|
||||
className={cn(
|
||||
'bg-page',
|
||||
interactionMode === 'pan' && 'cursor-grab active:cursor-grabbing',
|
||||
interactionMode === 'connect' && 'rf-connect-mode cursor-crosshair',
|
||||
)}
|
||||
>
|
||||
<Background variant={BackgroundVariant.Dots} color="var(--color-border-default)" gap={20} size={1} />
|
||||
<Controls className="!border-default !bg-card [&>button]:!border-default [&>button]:!bg-card [&>button]:!fill-text-primary" />
|
||||
|
||||
@@ -31,6 +31,7 @@ function getEdgePath(routing: string | null | undefined, props: EdgeProps) {
|
||||
}
|
||||
if (routing === 'curved') return getBezierPath(base)
|
||||
if (routing === 'step') return getSmoothStepPath(base)
|
||||
if (routing === 'orthogonal') return getSmoothStepPath({ ...base, borderRadius: 0 })
|
||||
return getStraightPath(base)
|
||||
}
|
||||
|
||||
@@ -53,7 +54,7 @@ function ConnectionEdgeComponent(props: EdgeProps) {
|
||||
{props.label && (
|
||||
<EdgeLabelRenderer>
|
||||
<div
|
||||
className="nodrag nopan rounded bg-page px-1.5 py-0.5 text-[10px] text-muted-foreground"
|
||||
className="nodrag nopan rounded border border-default bg-card px-1.5 py-0.5 text-[10px] text-muted-foreground shadow-sm"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
transform: `translate(-50%, -50%) translate(${labelX}px, ${labelY}px)`,
|
||||
|
||||
@@ -33,6 +33,11 @@ export function useCanvasShortcuts({
|
||||
setEdges,
|
||||
setIsDirty,
|
||||
canvasRef,
|
||||
onUndo,
|
||||
onRedo,
|
||||
onNudge,
|
||||
onSetMode,
|
||||
onToggleShortcuts,
|
||||
}: {
|
||||
nodes: Node[]
|
||||
edges: Edge[]
|
||||
@@ -40,6 +45,11 @@ export function useCanvasShortcuts({
|
||||
setEdges: React.Dispatch<React.SetStateAction<Edge[]>>
|
||||
setIsDirty: (dirty: boolean) => void
|
||||
canvasRef: React.RefObject<HTMLDivElement | null>
|
||||
onUndo: () => void
|
||||
onRedo: () => void
|
||||
onNudge: (dx: number, dy: number) => void
|
||||
onSetMode: (mode: 'select' | 'pan' | 'connect') => void
|
||||
onToggleShortcuts: () => void
|
||||
}) {
|
||||
const { getNodes, fitView, screenToFlowPosition, setNodes: rfSetNodes } = useReactFlow()
|
||||
const clipboardRef = useRef<ClipboardData | null>(null)
|
||||
@@ -211,6 +221,45 @@ export function useCanvasShortcuts({
|
||||
|
||||
const ctrl = e.ctrlKey || e.metaKey
|
||||
|
||||
// Undo: Ctrl+Z / Cmd+Z
|
||||
if (e.key === 'z' && ctrl && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
onUndo()
|
||||
return
|
||||
}
|
||||
// Redo: Ctrl+Y or Ctrl+Shift+Z
|
||||
if ((e.key === 'y' && ctrl) || (e.key === 'z' && ctrl && e.shiftKey)) {
|
||||
e.preventDefault()
|
||||
onRedo()
|
||||
return
|
||||
}
|
||||
// Arrow key nudging
|
||||
if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(e.key)) {
|
||||
e.preventDefault()
|
||||
const delta = e.shiftKey ? 10 : 1
|
||||
switch (e.key) {
|
||||
case 'ArrowUp': onNudge(0, -delta); break
|
||||
case 'ArrowDown': onNudge(0, delta); break
|
||||
case 'ArrowLeft': onNudge(-delta, 0); break
|
||||
case 'ArrowRight': onNudge( delta, 0); break
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Mode shortcuts: V = select, H = pan, C = connect
|
||||
if (!ctrl && e.key === 'v') {
|
||||
onSetMode('select')
|
||||
return
|
||||
}
|
||||
if (!ctrl && e.key === 'h') {
|
||||
onSetMode('pan')
|
||||
return
|
||||
}
|
||||
if (!ctrl && e.key === 'c') {
|
||||
onSetMode('connect')
|
||||
return
|
||||
}
|
||||
|
||||
if (ctrl && e.key === 'c') {
|
||||
e.preventDefault()
|
||||
copyNodes()
|
||||
@@ -232,12 +281,15 @@ export function useCanvasShortcuts({
|
||||
} else if (e.key === '[' && !ctrl) {
|
||||
e.preventDefault()
|
||||
sendSelectedToBack()
|
||||
} else if (e.key === '?' && !ctrl) {
|
||||
e.preventDefault()
|
||||
onToggleShortcuts()
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
return () => document.removeEventListener('keydown', handleKeyDown)
|
||||
}, [copyNodes, pasteNodes, duplicateNodes, selectAll, fitView, bringSelectedToFront, sendSelectedToBack])
|
||||
}, [copyNodes, pasteNodes, duplicateNodes, selectAll, fitView, bringSelectedToFront, sendSelectedToBack, onUndo, onRedo, onNudge, onSetMode, onToggleShortcuts])
|
||||
|
||||
return {
|
||||
copyNodes,
|
||||
|
||||
206
frontend/src/components/network/hooks/useDiagramCommands.ts
Normal file
206
frontend/src/components/network/hooks/useDiagramCommands.ts
Normal file
@@ -0,0 +1,206 @@
|
||||
import { useCallback } from 'react'
|
||||
import type { Node, Edge } from '@xyflow/react'
|
||||
|
||||
interface UseDiagramCommandsParams {
|
||||
nodes: Node[]
|
||||
edges: Edge[]
|
||||
pushHistory: (nodes: Node[], edges: Edge[]) => void
|
||||
setNodes: React.Dispatch<React.SetStateAction<Node[]>>
|
||||
}
|
||||
|
||||
export function useDiagramCommands({
|
||||
nodes,
|
||||
edges,
|
||||
pushHistory,
|
||||
setNodes,
|
||||
}: UseDiagramCommandsParams) {
|
||||
const selectedNodes = nodes.filter(n => n.selected)
|
||||
|
||||
// ── Alignment ──────────────────────────────────────────────────────────
|
||||
const alignLeft = useCallback(() => {
|
||||
if (selectedNodes.length < 2) return
|
||||
pushHistory(nodes, edges)
|
||||
const minX = Math.min(...selectedNodes.map(n => n.position.x))
|
||||
setNodes(prev => prev.map(n =>
|
||||
n.selected ? { ...n, position: { ...n.position, x: minX } } : n
|
||||
))
|
||||
}, [nodes, edges, selectedNodes, pushHistory, setNodes])
|
||||
|
||||
const alignRight = useCallback(() => {
|
||||
if (selectedNodes.length < 2) return
|
||||
pushHistory(nodes, edges)
|
||||
const maxX = Math.max(...selectedNodes.map(n => n.position.x + (n.measured?.width ?? 100)))
|
||||
setNodes(prev => prev.map(n =>
|
||||
n.selected ? { ...n, position: { ...n.position, x: maxX - (n.measured?.width ?? 100) } } : n
|
||||
))
|
||||
}, [nodes, edges, selectedNodes, pushHistory, setNodes])
|
||||
|
||||
const alignCenterH = useCallback(() => {
|
||||
if (selectedNodes.length < 2) return
|
||||
pushHistory(nodes, edges)
|
||||
const minX = Math.min(...selectedNodes.map(n => n.position.x))
|
||||
const maxX = Math.max(...selectedNodes.map(n => n.position.x + (n.measured?.width ?? 100)))
|
||||
const centerX = (minX + maxX) / 2
|
||||
setNodes(prev => prev.map(n =>
|
||||
n.selected ? { ...n, position: { ...n.position, x: centerX - (n.measured?.width ?? 100) / 2 } } : n
|
||||
))
|
||||
}, [nodes, edges, selectedNodes, pushHistory, setNodes])
|
||||
|
||||
const alignTop = useCallback(() => {
|
||||
if (selectedNodes.length < 2) return
|
||||
pushHistory(nodes, edges)
|
||||
const minY = Math.min(...selectedNodes.map(n => n.position.y))
|
||||
setNodes(prev => prev.map(n =>
|
||||
n.selected ? { ...n, position: { ...n.position, y: minY } } : n
|
||||
))
|
||||
}, [nodes, edges, selectedNodes, pushHistory, setNodes])
|
||||
|
||||
const alignBottom = useCallback(() => {
|
||||
if (selectedNodes.length < 2) return
|
||||
pushHistory(nodes, edges)
|
||||
const maxY = Math.max(...selectedNodes.map(n => n.position.y + (n.measured?.height ?? 100)))
|
||||
setNodes(prev => prev.map(n =>
|
||||
n.selected ? { ...n, position: { ...n.position, y: maxY - (n.measured?.height ?? 100) } } : n
|
||||
))
|
||||
}, [nodes, edges, selectedNodes, pushHistory, setNodes])
|
||||
|
||||
const alignCenterV = useCallback(() => {
|
||||
if (selectedNodes.length < 2) return
|
||||
pushHistory(nodes, edges)
|
||||
const minY = Math.min(...selectedNodes.map(n => n.position.y))
|
||||
const maxY = Math.max(...selectedNodes.map(n => n.position.y + (n.measured?.height ?? 100)))
|
||||
const centerY = (minY + maxY) / 2
|
||||
setNodes(prev => prev.map(n =>
|
||||
n.selected ? { ...n, position: { ...n.position, y: centerY - (n.measured?.height ?? 100) / 2 } } : n
|
||||
))
|
||||
}, [nodes, edges, selectedNodes, pushHistory, setNodes])
|
||||
|
||||
// ── Distribution ───────────────────────────────────────────────────────
|
||||
const distributeHorizontally = useCallback(() => {
|
||||
if (selectedNodes.length < 3) return
|
||||
pushHistory(nodes, edges)
|
||||
const sorted = [...selectedNodes].sort((a, b) => a.position.x - b.position.x)
|
||||
const minX = sorted[0].position.x
|
||||
const maxX = sorted[sorted.length - 1].position.x + (sorted[sorted.length - 1].measured?.width ?? 100)
|
||||
const totalWidth = sorted.reduce((sum, n) => sum + (n.measured?.width ?? 100), 0)
|
||||
const gap = (maxX - minX - totalWidth) / (sorted.length - 1)
|
||||
let cursor = minX
|
||||
const positions: Record<string, number> = {}
|
||||
for (const n of sorted) {
|
||||
positions[n.id] = cursor
|
||||
cursor += (n.measured?.width ?? 100) + gap
|
||||
}
|
||||
setNodes(prev => prev.map(n =>
|
||||
n.selected && positions[n.id] !== undefined
|
||||
? { ...n, position: { ...n.position, x: positions[n.id] } }
|
||||
: n
|
||||
))
|
||||
}, [nodes, edges, selectedNodes, pushHistory, setNodes])
|
||||
|
||||
const distributeVertically = useCallback(() => {
|
||||
if (selectedNodes.length < 3) return
|
||||
pushHistory(nodes, edges)
|
||||
const sorted = [...selectedNodes].sort((a, b) => a.position.y - b.position.y)
|
||||
const minY = sorted[0].position.y
|
||||
const maxY = sorted[sorted.length - 1].position.y + (sorted[sorted.length - 1].measured?.height ?? 100)
|
||||
const totalHeight = sorted.reduce((sum, n) => sum + (n.measured?.height ?? 100), 0)
|
||||
const gap = (maxY - minY - totalHeight) / (sorted.length - 1)
|
||||
let cursor = minY
|
||||
const positions: Record<string, number> = {}
|
||||
for (const n of sorted) {
|
||||
positions[n.id] = cursor
|
||||
cursor += (n.measured?.height ?? 100) + gap
|
||||
}
|
||||
setNodes(prev => prev.map(n =>
|
||||
n.selected && positions[n.id] !== undefined
|
||||
? { ...n, position: { ...n.position, y: positions[n.id] } }
|
||||
: n
|
||||
))
|
||||
}, [nodes, edges, selectedNodes, pushHistory, setNodes])
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────
|
||||
const canAlign = selectedNodes.length >= 2
|
||||
const canDistribute = selectedNodes.length >= 3
|
||||
|
||||
// ── Grouping ───────────────────────────────────────────────────────────
|
||||
const groupSelection = useCallback((groupType: string = 'custom') => {
|
||||
if (selectedNodes.length < 2) return
|
||||
pushHistory(nodes, edges)
|
||||
const PADDING = 24
|
||||
const minX = Math.min(...selectedNodes.map(n => n.position.x)) - PADDING
|
||||
const minY = Math.min(...selectedNodes.map(n => n.position.y)) - PADDING
|
||||
const maxX = Math.max(...selectedNodes.map(n => n.position.x + (n.measured?.width ?? 100))) + PADDING
|
||||
const maxY = Math.max(...selectedNodes.map(n => n.position.y + (n.measured?.height ?? 100))) + PADDING
|
||||
const groupId = `group-${Date.now()}`
|
||||
const groupNode: Node = {
|
||||
id: groupId,
|
||||
type: 'group',
|
||||
position: { x: minX, y: minY },
|
||||
style: { width: maxX - minX, height: maxY - minY },
|
||||
data: { label: groupType.charAt(0).toUpperCase() + groupType.slice(1), groupType },
|
||||
selected: false,
|
||||
}
|
||||
setNodes(prev => [
|
||||
groupNode,
|
||||
...prev.map(n =>
|
||||
n.selected
|
||||
? {
|
||||
...n,
|
||||
parentId: groupId,
|
||||
extent: 'parent' as const,
|
||||
position: { x: n.position.x - minX, y: n.position.y - minY },
|
||||
selected: false,
|
||||
}
|
||||
: n
|
||||
),
|
||||
])
|
||||
}, [nodes, edges, selectedNodes, pushHistory, setNodes])
|
||||
|
||||
const ungroupSelection = useCallback(() => {
|
||||
const selectedGroups = selectedNodes.filter(n => n.type === 'group')
|
||||
if (selectedGroups.length === 0) return
|
||||
pushHistory(nodes, edges)
|
||||
const groupIds = new Set(selectedGroups.map(g => g.id))
|
||||
setNodes(prev => {
|
||||
const groupPositions: Record<string, { x: number; y: number }> = {}
|
||||
for (const n of prev) {
|
||||
if (groupIds.has(n.id)) groupPositions[n.id] = n.position
|
||||
}
|
||||
return prev
|
||||
.filter(n => !groupIds.has(n.id))
|
||||
.map(n => {
|
||||
if (n.parentId && groupIds.has(n.parentId)) {
|
||||
const gPos = groupPositions[n.parentId] ?? { x: 0, y: 0 }
|
||||
return {
|
||||
...n,
|
||||
parentId: undefined,
|
||||
extent: undefined,
|
||||
position: { x: gPos.x + n.position.x, y: gPos.y + n.position.y },
|
||||
}
|
||||
}
|
||||
return n
|
||||
})
|
||||
})
|
||||
}, [nodes, edges, selectedNodes, pushHistory, setNodes])
|
||||
|
||||
const canGroup = selectedNodes.length >= 2 && !selectedNodes.some(n => n.type === 'group')
|
||||
const canUngroup = selectedNodes.some(n => n.type === 'group')
|
||||
|
||||
return {
|
||||
alignLeft,
|
||||
alignRight,
|
||||
alignCenterH,
|
||||
alignTop,
|
||||
alignBottom,
|
||||
alignCenterV,
|
||||
distributeHorizontally,
|
||||
distributeVertically,
|
||||
canAlign,
|
||||
canDistribute,
|
||||
selectedNodes,
|
||||
groupSelection,
|
||||
ungroupSelection,
|
||||
canGroup,
|
||||
canUngroup,
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
import { memo } from 'react'
|
||||
import { Position, NodeResizer, type NodeProps } from '@xyflow/react'
|
||||
import { BaseNode, BaseNodeHeader, BaseNodeHeaderTitle, BaseNodeContent } from '../ui/base-node'
|
||||
import { memo, useState, useRef, useEffect } from 'react'
|
||||
import { Position, NodeResizer, useReactFlow, type NodeProps } from '@xyflow/react'
|
||||
import { BaseNode, BaseNodeHeader, BaseNodeContent } from '../ui/base-node'
|
||||
import { BaseHandle } from '../ui/base-handle'
|
||||
import { NodeStatusIndicator, type NodeStatus } from '../ui/node-status-indicator'
|
||||
import { NodeTooltip, NodeTooltipTrigger, NodeTooltipContent } from '../ui/node-tooltip'
|
||||
import { getDeviceRenderConfig } from './deviceRegistry'
|
||||
import { getDeviceRenderConfig, CATEGORY_LABELS } from './deviceRegistry'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { DeviceProperties } from '@/types'
|
||||
|
||||
export interface DeviceNodeData {
|
||||
@@ -29,9 +30,9 @@ const NODE_DEFAULT = 120 // default square side in px
|
||||
const NODE_MIN = 80 // minimum square side in px
|
||||
const NODE_MAX = 280 // maximum square side in px
|
||||
|
||||
function DeviceNodeComponent({ data, selected, width, height }: NodeProps) {
|
||||
function DeviceNodeComponent({ id, data, selected, width, height }: NodeProps) {
|
||||
const nodeData = data as unknown as DeviceNodeData
|
||||
const { icon: Icon, color } = getDeviceRenderConfig(nodeData.deviceType, nodeData.category)
|
||||
const { icon: Icon, color, accentClass, surfaceClass, category } = getDeviceRenderConfig(nodeData.deviceType, nodeData.category)
|
||||
const status = (nodeData.properties?.status || 'unknown') as NodeStatus
|
||||
const ip = nodeData.properties?.ip
|
||||
const props = nodeData.properties || {}
|
||||
@@ -46,6 +47,25 @@ function DeviceNodeComponent({ data, selected, width, height }: NodeProps) {
|
||||
const labelPx = Math.max(9, Math.min(20, Math.round(scale * 11)))
|
||||
// IP font: 9px at default, clamped to [8, 16]
|
||||
const ipPx = Math.max(8, Math.min(16, Math.round(scale * 9)))
|
||||
const metaPx = Math.max(8, Math.min(11, Math.round(scale * 8)))
|
||||
const iconPlateSize = Math.round(Math.max(34, Math.min(82, scale * 50)))
|
||||
|
||||
const [editing, setEditing] = useState(false)
|
||||
const [labelValue, setLabelValue] = useState(nodeData.label ?? '')
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const { updateNodeData } = useReactFlow()
|
||||
|
||||
useEffect(() => {
|
||||
if (editing) {
|
||||
inputRef.current?.focus()
|
||||
inputRef.current?.select()
|
||||
}
|
||||
}, [editing])
|
||||
|
||||
// Sync if data.label changes externally (e.g. undo/redo)
|
||||
useEffect(() => {
|
||||
if (!editing) setLabelValue(nodeData.label ?? '')
|
||||
}, [nodeData.label, editing])
|
||||
|
||||
const hasTooltipContent = props.hostname || props.ip || props.vendor || props.model || props.role || props.notes
|
||||
|
||||
@@ -64,16 +84,70 @@ function DeviceNodeComponent({ data, selected, width, height }: NodeProps) {
|
||||
<NodeStatusIndicator status={status}>
|
||||
<NodeTooltip>
|
||||
<NodeTooltipTrigger>
|
||||
<BaseNode className="w-full h-full group flex flex-col items-center justify-center">
|
||||
<BaseNodeHeader className="flex-col gap-1 items-center py-2 px-2">
|
||||
<Icon size={iconPx} style={{ color }} />
|
||||
<BaseNodeHeaderTitle className="text-center leading-tight" style={{ fontSize: labelPx }}>
|
||||
{nodeData.label}
|
||||
</BaseNodeHeaderTitle>
|
||||
<BaseNode className="group h-full w-full bg-card">
|
||||
<div className={cn('pointer-events-none absolute inset-x-0 top-0 h-10 bg-gradient-to-b opacity-80', surfaceClass)} />
|
||||
<BaseNodeHeader className="flex h-full flex-col items-center justify-center gap-2 px-2 py-3">
|
||||
<div
|
||||
className={cn(
|
||||
'relative flex items-center justify-center rounded-xl border transition-colors',
|
||||
accentClass,
|
||||
)}
|
||||
style={{ width: iconPlateSize, height: iconPlateSize }}
|
||||
>
|
||||
<div className="absolute inset-[4px] rounded-[10px] border border-white/[0.06] bg-sidebar/50" />
|
||||
<div className="relative z-10">
|
||||
<Icon size={iconPx} style={{ color }} />
|
||||
</div>
|
||||
</div>
|
||||
{editing ? (
|
||||
<input
|
||||
ref={inputRef}
|
||||
value={labelValue}
|
||||
onChange={e => setLabelValue(e.target.value)}
|
||||
onBlur={() => {
|
||||
setEditing(false)
|
||||
if (labelValue !== nodeData.label) {
|
||||
updateNodeData(id, { ...nodeData, label: labelValue })
|
||||
}
|
||||
}}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter') inputRef.current?.blur()
|
||||
if (e.key === 'Escape') {
|
||||
setLabelValue(nodeData.label ?? '')
|
||||
setEditing(false)
|
||||
}
|
||||
e.stopPropagation()
|
||||
}}
|
||||
style={{ fontSize: labelPx }}
|
||||
className="bg-transparent border-none outline-none text-center text-primary font-medium w-4/5"
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
style={{ fontSize: labelPx }}
|
||||
className="max-w-[88%] cursor-default text-center font-medium leading-tight text-primary line-clamp-2"
|
||||
onDoubleClick={e => {
|
||||
e.stopPropagation()
|
||||
setEditing(true)
|
||||
}}
|
||||
>
|
||||
{labelValue}
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
style={{ fontSize: metaPx }}
|
||||
className="text-[9px] uppercase tracking-[0.16em] text-muted"
|
||||
>
|
||||
{CATEGORY_LABELS[category] ?? nodeData.deviceType.replace(/-/g, ' ')}
|
||||
</span>
|
||||
</BaseNodeHeader>
|
||||
{ip && (
|
||||
<BaseNodeContent className="items-center pt-0 pb-1">
|
||||
<span className="font-mono text-muted-foreground" style={{ fontSize: ipPx }}>{ip}</span>
|
||||
<BaseNodeContent className="items-center pt-0 pb-2">
|
||||
<span
|
||||
className="rounded-full border border-default bg-page/70 px-2 py-0.5 font-mono text-muted-foreground"
|
||||
style={{ fontSize: ipPx }}
|
||||
>
|
||||
{ip}
|
||||
</span>
|
||||
</BaseNodeContent>
|
||||
)}
|
||||
<BaseHandle type="target" position={Position.Top} />
|
||||
|
||||
86
frontend/src/components/network/nodes/GroupNode.tsx
Normal file
86
frontend/src/components/network/nodes/GroupNode.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
import { memo, useState, useRef, useEffect } from 'react'
|
||||
import { NodeResizer, useReactFlow, type NodeProps } from '@xyflow/react'
|
||||
import type { GroupNodeData } from '@/types/network-diagram'
|
||||
|
||||
const GROUP_COLORS: Record<string, string> = {
|
||||
subnet: '#60a5fa',
|
||||
vlan: '#a78bfa',
|
||||
site: '#34d399',
|
||||
dmz: '#f87171',
|
||||
custom: '#94a3b8',
|
||||
}
|
||||
|
||||
const GroupNodeComponent = ({ data, selected, id }: NodeProps) => {
|
||||
const groupData = data as GroupNodeData
|
||||
const color = GROUP_COLORS[groupData.groupType] ?? GROUP_COLORS.custom
|
||||
const [editing, setEditing] = useState(false)
|
||||
const [labelValue, setLabelValue] = useState(groupData.label ?? '')
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const { updateNodeData } = useReactFlow()
|
||||
|
||||
useEffect(() => {
|
||||
if (editing) inputRef.current?.focus()
|
||||
}, [editing])
|
||||
|
||||
// Sync if external data.label changes
|
||||
useEffect(() => {
|
||||
if (!editing) setLabelValue(groupData.label ?? '')
|
||||
}, [groupData.label, editing])
|
||||
|
||||
const handleLabelCommit = () => {
|
||||
setEditing(false)
|
||||
if (labelValue !== groupData.label) {
|
||||
updateNodeData(id, { ...groupData, label: labelValue })
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<NodeResizer
|
||||
isVisible={selected}
|
||||
minWidth={120}
|
||||
minHeight={80}
|
||||
lineStyle={{ border: `1px solid ${color}` }}
|
||||
handleStyle={{ width: 8, height: 8, borderRadius: 2, background: color }}
|
||||
/>
|
||||
<div
|
||||
className="w-full h-full rounded-lg relative"
|
||||
style={{
|
||||
border: `1.5px dashed ${color}`,
|
||||
background: `${color}0d`,
|
||||
boxSizing: 'border-box',
|
||||
}}
|
||||
>
|
||||
<div className="absolute top-0 left-2 -translate-y-full pb-0.5">
|
||||
{editing ? (
|
||||
<input
|
||||
ref={inputRef}
|
||||
value={labelValue}
|
||||
onChange={e => setLabelValue(e.target.value)}
|
||||
onBlur={handleLabelCommit}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter' || e.key === 'Escape') handleLabelCommit()
|
||||
e.stopPropagation()
|
||||
}}
|
||||
className="rounded-sm px-1.5 py-0.5 text-[11px] font-semibold bg-card/90 border-none outline-none min-w-[40px] max-w-[200px]"
|
||||
style={{ color }}
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
className="inline-block rounded-sm bg-card/90 px-1.5 py-0.5 text-[11px] font-semibold cursor-text select-none tracking-wide"
|
||||
style={{ color }}
|
||||
onDoubleClick={() => setEditing(true)}
|
||||
>
|
||||
{labelValue || groupData.groupType}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
GroupNodeComponent.displayName = 'GroupNode'
|
||||
|
||||
export const GroupNode = memo(GroupNodeComponent)
|
||||
export default GroupNode
|
||||
@@ -9,6 +9,9 @@ import {
|
||||
export interface DeviceRenderConfig {
|
||||
icon: LucideIcon
|
||||
color: string
|
||||
accentClass: string
|
||||
surfaceClass: string
|
||||
category: string
|
||||
}
|
||||
|
||||
// Category-semantic color palette — each color carries meaning:
|
||||
@@ -27,62 +30,107 @@ export const STORAGE_COLOR = '#a78bfa'
|
||||
export const CLOUD_COLOR = '#67e8f9'
|
||||
export const INFRA_COLOR = '#94a3b8'
|
||||
|
||||
const CATEGORY_STYLES: Record<string, Pick<DeviceRenderConfig, 'accentClass' | 'surfaceClass'>> = {
|
||||
network: {
|
||||
accentClass: 'border-sky-400/40 bg-sky-400/12 text-sky-300',
|
||||
surfaceClass: 'from-sky-400/12 via-sky-400/4 to-transparent',
|
||||
},
|
||||
security: {
|
||||
accentClass: 'border-rose-400/40 bg-rose-400/12 text-rose-300',
|
||||
surfaceClass: 'from-rose-400/12 via-rose-400/4 to-transparent',
|
||||
},
|
||||
compute: {
|
||||
accentClass: 'border-emerald-400/40 bg-emerald-400/12 text-emerald-300',
|
||||
surfaceClass: 'from-emerald-400/12 via-emerald-400/4 to-transparent',
|
||||
},
|
||||
storage: {
|
||||
accentClass: 'border-violet-400/40 bg-violet-400/12 text-violet-300',
|
||||
surfaceClass: 'from-violet-400/12 via-violet-400/4 to-transparent',
|
||||
},
|
||||
cloud: {
|
||||
accentClass: 'border-cyan-400/40 bg-cyan-400/12 text-cyan-300',
|
||||
surfaceClass: 'from-cyan-400/12 via-cyan-400/4 to-transparent',
|
||||
},
|
||||
endpoint: {
|
||||
accentClass: 'border-amber-400/40 bg-amber-400/12 text-amber-300',
|
||||
surfaceClass: 'from-amber-400/12 via-amber-400/4 to-transparent',
|
||||
},
|
||||
infrastructure: {
|
||||
accentClass: 'border-slate-400/40 bg-slate-300/10 text-slate-300',
|
||||
surfaceClass: 'from-slate-300/10 via-slate-300/4 to-transparent',
|
||||
},
|
||||
}
|
||||
|
||||
function makeConfig(
|
||||
icon: LucideIcon,
|
||||
color: string,
|
||||
category: string,
|
||||
): DeviceRenderConfig {
|
||||
return {
|
||||
icon,
|
||||
color,
|
||||
category,
|
||||
accentClass: CATEGORY_STYLES[category]?.accentClass ?? CATEGORY_STYLES.infrastructure.accentClass,
|
||||
surfaceClass: CATEGORY_STYLES[category]?.surfaceClass ?? CATEGORY_STYLES.infrastructure.surfaceClass,
|
||||
}
|
||||
}
|
||||
|
||||
const SYSTEM_DEVICE_ICONS: Record<string, DeviceRenderConfig> = {
|
||||
// Network layer
|
||||
'router': { icon: Router, color: NETWORK_COLOR },
|
||||
'switch': { icon: Network, color: NETWORK_COLOR },
|
||||
'access-point': { icon: Wifi, color: NETWORK_COLOR },
|
||||
'load-balancer': { icon: Gauge, color: NETWORK_COLOR },
|
||||
'router': makeConfig(Router, NETWORK_COLOR, 'network'),
|
||||
'switch': makeConfig(Network, NETWORK_COLOR, 'network'),
|
||||
'access-point': makeConfig(Wifi, NETWORK_COLOR, 'network'),
|
||||
'load-balancer': makeConfig(Gauge, NETWORK_COLOR, 'network'),
|
||||
|
||||
// Security
|
||||
'firewall': { icon: BrickWallFire, color: SECURITY_COLOR },
|
||||
'badge-reader': { icon: KeyRound, color: SECURITY_COLOR },
|
||||
'firewall': makeConfig(BrickWallFire, SECURITY_COLOR, 'security'),
|
||||
'badge-reader': makeConfig(KeyRound, SECURITY_COLOR, 'security'),
|
||||
|
||||
// Compute
|
||||
'server': { icon: Server, color: COMPUTE_COLOR },
|
||||
'vm': { icon: Boxes, color: COMPUTE_COLOR },
|
||||
'container': { icon: Package, color: COMPUTE_COLOR },
|
||||
'server': makeConfig(Server, COMPUTE_COLOR, 'compute'),
|
||||
'vm': makeConfig(Boxes, COMPUTE_COLOR, 'compute'),
|
||||
'container': makeConfig(Package, COMPUTE_COLOR, 'compute'),
|
||||
|
||||
// Storage
|
||||
'nas': { icon: Database, color: STORAGE_COLOR },
|
||||
'san': { icon: HardDrive, color: STORAGE_COLOR },
|
||||
'cloud-storage': { icon: CloudCog, color: STORAGE_COLOR },
|
||||
'nas': makeConfig(Database, STORAGE_COLOR, 'storage'),
|
||||
'san': makeConfig(HardDrive, STORAGE_COLOR, 'storage'),
|
||||
'cloud-storage': makeConfig(CloudCog, STORAGE_COLOR, 'storage'),
|
||||
|
||||
// Cloud / Internet
|
||||
'cloud': { icon: Cloud, color: CLOUD_COLOR },
|
||||
'aws': { icon: Cloud, color: CLOUD_COLOR },
|
||||
'azure': { icon: Cloud, color: CLOUD_COLOR },
|
||||
'gcp': { icon: Cloud, color: CLOUD_COLOR },
|
||||
'isp': { icon: Globe, color: CLOUD_COLOR },
|
||||
'cloud': makeConfig(Cloud, CLOUD_COLOR, 'cloud'),
|
||||
'aws': makeConfig(Cloud, CLOUD_COLOR, 'cloud'),
|
||||
'azure': makeConfig(Cloud, CLOUD_COLOR, 'cloud'),
|
||||
'gcp': makeConfig(Cloud, CLOUD_COLOR, 'cloud'),
|
||||
'isp': makeConfig(Globe, CLOUD_COLOR, 'cloud'),
|
||||
|
||||
// Endpoints
|
||||
'workstation': { icon: Monitor, color: ENDPOINT_COLOR },
|
||||
'laptop': { icon: Laptop, color: ENDPOINT_COLOR },
|
||||
'tablet': { icon: Tablet, color: ENDPOINT_COLOR },
|
||||
'phone': { icon: Smartphone, color: ENDPOINT_COLOR },
|
||||
'printer': { icon: Printer, color: ENDPOINT_COLOR },
|
||||
'workstation': makeConfig(Monitor, ENDPOINT_COLOR, 'endpoint'),
|
||||
'laptop': makeConfig(Laptop, ENDPOINT_COLOR, 'endpoint'),
|
||||
'tablet': makeConfig(Tablet, ENDPOINT_COLOR, 'endpoint'),
|
||||
'phone': makeConfig(Smartphone, ENDPOINT_COLOR, 'endpoint'),
|
||||
'printer': makeConfig(Printer, ENDPOINT_COLOR, 'endpoint'),
|
||||
|
||||
// Infrastructure / physical
|
||||
'ups': { icon: BatteryCharging, color: INFRA_COLOR },
|
||||
'pdu': { icon: PlugZap, color: INFRA_COLOR },
|
||||
'rack': { icon: RectangleVertical, color: INFRA_COLOR },
|
||||
'patch-panel': { icon: Cable, color: INFRA_COLOR },
|
||||
'camera': { icon: Camera, color: INFRA_COLOR },
|
||||
'nvr': { icon: Video, color: INFRA_COLOR },
|
||||
'iot': { icon: Radio, color: INFRA_COLOR },
|
||||
'ups': makeConfig(BatteryCharging, INFRA_COLOR, 'infrastructure'),
|
||||
'pdu': makeConfig(PlugZap, INFRA_COLOR, 'infrastructure'),
|
||||
'rack': makeConfig(RectangleVertical, INFRA_COLOR, 'infrastructure'),
|
||||
'patch-panel': makeConfig(Cable, INFRA_COLOR, 'infrastructure'),
|
||||
'camera': makeConfig(Camera, INFRA_COLOR, 'infrastructure'),
|
||||
'nvr': makeConfig(Video, INFRA_COLOR, 'infrastructure'),
|
||||
'iot': makeConfig(Radio, INFRA_COLOR, 'infrastructure'),
|
||||
}
|
||||
|
||||
const CATEGORY_DEFAULTS: Record<string, DeviceRenderConfig> = {
|
||||
'network': { icon: Router, color: NETWORK_COLOR },
|
||||
'compute': { icon: Server, color: COMPUTE_COLOR },
|
||||
'storage': { icon: Database, color: STORAGE_COLOR },
|
||||
'cloud': { icon: Cloud, color: CLOUD_COLOR },
|
||||
'endpoint': { icon: Monitor, color: ENDPOINT_COLOR },
|
||||
'infrastructure': { icon: PlugZap, color: INFRA_COLOR },
|
||||
'security': { icon: BrickWallFire, color: SECURITY_COLOR },
|
||||
'network': makeConfig(Router, NETWORK_COLOR, 'network'),
|
||||
'compute': makeConfig(Server, COMPUTE_COLOR, 'compute'),
|
||||
'storage': makeConfig(Database, STORAGE_COLOR, 'storage'),
|
||||
'cloud': makeConfig(Cloud, CLOUD_COLOR, 'cloud'),
|
||||
'endpoint': makeConfig(Monitor, ENDPOINT_COLOR, 'endpoint'),
|
||||
'infrastructure': makeConfig(PlugZap, INFRA_COLOR, 'infrastructure'),
|
||||
'security': makeConfig(BrickWallFire, SECURITY_COLOR, 'security'),
|
||||
}
|
||||
|
||||
const FALLBACK: DeviceRenderConfig = { icon: Cpu, color: INFRA_COLOR }
|
||||
const FALLBACK: DeviceRenderConfig = makeConfig(Cpu, INFRA_COLOR, 'infrastructure')
|
||||
|
||||
export function getDeviceRenderConfig(slug: string, category?: string): DeviceRenderConfig {
|
||||
if (SYSTEM_DEVICE_ICONS[slug]) return SYSTEM_DEVICE_ICONS[slug]
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { DeviceNode } from './DeviceNode'
|
||||
import { GroupNode } from '../ui/labeled-group-node'
|
||||
import { GroupNode } from './GroupNode'
|
||||
|
||||
export const nodeTypes = {
|
||||
device: DeviceNode,
|
||||
|
||||
@@ -151,22 +151,22 @@ export function DeviceToolbar({ deviceTypes, onDeviceTypesChange }: DeviceToolba
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{[
|
||||
{ slug: 'subnet', label: 'Subnet' },
|
||||
{ slug: 'vlan', label: 'VLAN' },
|
||||
{ slug: 'site', label: 'Site' },
|
||||
{ slug: 'dmz', label: 'DMZ' },
|
||||
{ slug: 'subnet', label: 'Subnet', color: '#60a5fa' },
|
||||
{ slug: 'vlan', label: 'VLAN', color: '#a78bfa' },
|
||||
{ slug: 'site', label: 'Site', color: '#34d399' },
|
||||
{ slug: 'dmz', label: 'DMZ', color: '#f87171' },
|
||||
].map(item => (
|
||||
<div
|
||||
key={item.slug}
|
||||
draggable
|
||||
onDragStart={e => {
|
||||
e.dataTransfer.setData('application/reactflow-group', JSON.stringify(item))
|
||||
e.dataTransfer.setData('application/reactflow-group', JSON.stringify({ slug: item.slug, label: item.label }))
|
||||
e.dataTransfer.effectAllowed = 'move'
|
||||
}}
|
||||
className="flex cursor-grab items-center gap-2 rounded px-2 py-1.5 text-xs text-primary hover:bg-elevated active:cursor-grabbing active:scale-[0.98] transition-transform"
|
||||
>
|
||||
<GripVertical size={12} className="shrink-0 text-muted-foreground/50" />
|
||||
<LayoutGrid size={14} className="text-muted-foreground" />
|
||||
<LayoutGrid size={14} style={{ color: item.color }} />
|
||||
<span>{item.label}</span>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { useCallback, useState, useEffect } from 'react'
|
||||
import { Trash2, Minus, Spline, GitBranch, BringToFront, SendToBack } from 'lucide-react'
|
||||
import {
|
||||
Trash2, Minus, Spline, GitBranch, CornerUpRight, BringToFront, SendToBack,
|
||||
AlignStartVertical, AlignCenterHorizontal, AlignEndVertical,
|
||||
AlignStartHorizontal, AlignCenterVertical, AlignEndHorizontal,
|
||||
AlignHorizontalSpaceAround, AlignVerticalSpaceAround,
|
||||
BoxSelect, Ungroup, MousePointer,
|
||||
} from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { DeviceProperties, DiagramEdge } from '@/types'
|
||||
import type { Node, Edge } from '@xyflow/react'
|
||||
@@ -15,6 +21,21 @@ interface PropertiesPanelProps {
|
||||
onSendToBack: (nodeId: string) => void
|
||||
onDeleteNode: (nodeId: string) => void
|
||||
onDeleteEdge: (edgeId: string) => void
|
||||
selectedNodeCount: number
|
||||
onAlignLeft: () => void
|
||||
onAlignRight: () => void
|
||||
onAlignCenterH: () => void
|
||||
onAlignTop: () => void
|
||||
onAlignBottom: () => void
|
||||
onAlignCenterV: () => void
|
||||
onDistributeH: () => void
|
||||
onDistributeV: () => void
|
||||
canAlign: boolean
|
||||
canDistribute: boolean
|
||||
canGroup: boolean
|
||||
canUngroup: boolean
|
||||
onGroupSelection: (groupType: string) => void
|
||||
onUngroupSelection: () => void
|
||||
}
|
||||
|
||||
type NodeStatus = 'online' | 'offline' | 'degraded' | 'unknown'
|
||||
@@ -68,6 +89,14 @@ function SectionDivider({ label }: { label: string }) {
|
||||
)
|
||||
}
|
||||
|
||||
const GROUP_TYPES = [
|
||||
{ value: 'subnet', label: 'Subnet' },
|
||||
{ value: 'vlan', label: 'VLAN' },
|
||||
{ value: 'site', label: 'Site' },
|
||||
{ value: 'dmz', label: 'DMZ' },
|
||||
{ value: 'custom', label: 'Custom' },
|
||||
]
|
||||
|
||||
export function PropertiesPanel({
|
||||
selectedNode,
|
||||
selectedEdge,
|
||||
@@ -78,8 +107,24 @@ export function PropertiesPanel({
|
||||
onSendToBack,
|
||||
onDeleteNode,
|
||||
onDeleteEdge,
|
||||
selectedNodeCount,
|
||||
onAlignLeft,
|
||||
onAlignRight,
|
||||
onAlignCenterH,
|
||||
onAlignTop,
|
||||
onAlignBottom,
|
||||
onAlignCenterV,
|
||||
onDistributeH,
|
||||
onDistributeV,
|
||||
canAlign,
|
||||
canDistribute,
|
||||
canGroup,
|
||||
canUngroup,
|
||||
onGroupSelection,
|
||||
onUngroupSelection,
|
||||
}: PropertiesPanelProps) {
|
||||
const [deleteConfirm, setDeleteConfirm] = useState(false)
|
||||
const [pendingGroupType, setPendingGroupType] = useState('subnet')
|
||||
|
||||
// Reset confirm state whenever the selection changes
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
@@ -98,14 +143,107 @@ export function PropertiesPanel({
|
||||
onNodeUpdate(selectedNode.id, { label: value } as Partial<DeviceNodeData>)
|
||||
}, [selectedNode, onNodeUpdate])
|
||||
|
||||
if (!selectedNode && !selectedEdge && selectedNodeCount >= 2) {
|
||||
return (
|
||||
<div className="w-[260px] border-l border-default bg-sidebar flex flex-col">
|
||||
<div className="px-4 py-3 border-b border-default">
|
||||
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
{selectedNodeCount} nodes selected
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-3 flex flex-col gap-4">
|
||||
{canAlign && (
|
||||
<div>
|
||||
<div className="text-[10px] font-medium text-muted uppercase tracking-wider mb-2">Align</div>
|
||||
<div className="grid grid-cols-3 gap-1">
|
||||
{([
|
||||
{ label: 'Left', icon: AlignStartVertical, action: onAlignLeft },
|
||||
{ label: 'Center', icon: AlignCenterHorizontal, action: onAlignCenterH },
|
||||
{ label: 'Right', icon: AlignEndVertical, action: onAlignRight },
|
||||
{ label: 'Top', icon: AlignStartHorizontal, action: onAlignTop },
|
||||
{ label: 'Middle', icon: AlignCenterVertical, action: onAlignCenterV },
|
||||
{ label: 'Bottom', icon: AlignEndHorizontal, action: onAlignBottom },
|
||||
] as const).map(({ label, icon: Icon, action }) => (
|
||||
<button
|
||||
key={label}
|
||||
onClick={action}
|
||||
title={`Align ${label}`}
|
||||
className="flex flex-col items-center gap-1 p-2 rounded bg-elevated hover:bg-card border border-default hover:border-hover transition-colors text-muted-foreground hover:text-primary"
|
||||
>
|
||||
<Icon size={14} />
|
||||
<span className="text-[9px]">{label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{canDistribute && (
|
||||
<div>
|
||||
<div className="text-[10px] font-medium text-muted uppercase tracking-wider mb-2">Distribute</div>
|
||||
<div className="grid grid-cols-2 gap-1">
|
||||
<button
|
||||
onClick={onDistributeH}
|
||||
className="flex items-center gap-1.5 px-2 py-1.5 rounded bg-elevated hover:bg-card border border-default hover:border-hover transition-colors text-muted-foreground hover:text-primary text-xs"
|
||||
>
|
||||
<AlignHorizontalSpaceAround size={13} /> Horizontal
|
||||
</button>
|
||||
<button
|
||||
onClick={onDistributeV}
|
||||
className="flex items-center gap-1.5 px-2 py-1.5 rounded bg-elevated hover:bg-card border border-default hover:border-hover transition-colors text-muted-foreground hover:text-primary text-xs"
|
||||
>
|
||||
<AlignVerticalSpaceAround size={13} /> Vertical
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{(canGroup || canUngroup) && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="text-[10px] font-medium text-muted uppercase tracking-wider">Grouping</div>
|
||||
{canGroup && (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<select
|
||||
value={pendingGroupType}
|
||||
onChange={e => setPendingGroupType(e.target.value)}
|
||||
className="w-full rounded border border-default bg-input px-2 py-1.5 text-xs text-primary focus:border-accent focus:outline-none"
|
||||
>
|
||||
{GROUP_TYPES.map(gt => (
|
||||
<option key={gt.value} value={gt.value}>{gt.label}</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
onClick={() => onGroupSelection(pendingGroupType)}
|
||||
className="flex items-center justify-center gap-1.5 px-2 py-1.5 rounded bg-elevated hover:bg-card border border-default hover:border-hover transition-colors text-muted-foreground hover:text-primary text-xs"
|
||||
>
|
||||
<BoxSelect size={13} /> Group into {GROUP_TYPES.find(g => g.value === pendingGroupType)?.label}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{canUngroup && (
|
||||
<button
|
||||
onClick={onUngroupSelection}
|
||||
className="flex items-center justify-center gap-1.5 px-2 py-1.5 rounded bg-elevated hover:bg-card border border-default hover:border-hover transition-colors text-muted-foreground hover:text-primary text-xs"
|
||||
>
|
||||
<Ungroup size={13} /> Ungroup
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!selectedNode && !selectedEdge) {
|
||||
return (
|
||||
<div className="flex h-full w-[260px] flex-col items-center justify-center border-l border-default bg-sidebar px-4">
|
||||
<p className="text-center text-xs text-muted-foreground">
|
||||
Select a device or connection to edit its properties
|
||||
<div className="flex h-full w-[260px] flex-col items-center justify-center border-l border-default bg-sidebar px-6">
|
||||
<div className="mb-3 flex h-9 w-9 items-center justify-center rounded-lg border border-default bg-elevated text-muted-foreground">
|
||||
<MousePointer size={15} />
|
||||
</div>
|
||||
<p className="text-center text-xs font-medium text-muted-foreground">
|
||||
Select a device or connection
|
||||
</p>
|
||||
<p className="mt-1 text-center text-[10px] text-muted-foreground/60">
|
||||
Hover a device to preview its info
|
||||
<p className="mt-1 text-center text-[10px] text-muted-foreground/50 leading-relaxed">
|
||||
Properties appear here. Hover a device to see a quick summary.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
@@ -122,6 +260,9 @@ export function PropertiesPanel({
|
||||
<h3 className="text-xs font-semibold text-heading">Connection</h3>
|
||||
</div>
|
||||
<div className="flex flex-1 flex-col gap-3 overflow-y-auto p-3">
|
||||
<div className="rounded border border-default bg-elevated/40 px-2.5 py-2 text-[10px] text-muted-foreground">
|
||||
Drag either end of the line on the canvas to reconnect it to a different asset.
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<FieldLabel>Label</FieldLabel>
|
||||
<FieldInput
|
||||
@@ -179,6 +320,7 @@ export function PropertiesPanel({
|
||||
{ value: null, icon: Minus, label: 'Straight' },
|
||||
{ value: 'curved', icon: Spline, label: 'Curved' },
|
||||
{ value: 'step', icon: GitBranch, label: 'Step' },
|
||||
{ value: 'orthogonal', icon: CornerUpRight, label: 'Ortho' },
|
||||
] as const).map(({ value, icon: Icon, label }) => {
|
||||
const routing = (edgeData.routing as string | null | undefined) ?? null
|
||||
const active = routing === value
|
||||
|
||||
@@ -9,8 +9,9 @@ export function BaseHandle({ className, children, ...props }: ComponentProps<typ
|
||||
<Handle
|
||||
{...props}
|
||||
className={cn(
|
||||
'h-[10px] w-[10px] rounded-full border border-default bg-elevated transition-opacity',
|
||||
'opacity-0 group-hover:opacity-100',
|
||||
'h-3 w-3 rounded-full border border-accent/60 bg-card transition-opacity',
|
||||
'opacity-0 group-hover:opacity-100 group-focus-within:opacity-100',
|
||||
'[.rf-connect-mode_&]:opacity-100',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -5,8 +5,8 @@ export function BaseNode({ className, ...props }: ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'bg-card text-heading relative rounded-lg border border-default',
|
||||
'transition-colors hover:border-hover',
|
||||
'bg-card text-heading relative overflow-hidden rounded-xl border border-default',
|
||||
'transition-colors hover:border-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40',
|
||||
'in-[.selected]:border-accent',
|
||||
className,
|
||||
)}
|
||||
|
||||
@@ -11,9 +11,9 @@ const STATUS_BORDER_COLORS: Record<NodeStatus, string> = {
|
||||
}
|
||||
|
||||
const STATUS_GLOW: Record<NodeStatus, string> = {
|
||||
online: 'shadow-[0_0_8px_rgba(52,211,153,0.3)]',
|
||||
offline: 'shadow-[0_0_8px_rgba(248,113,113,0.3)]',
|
||||
degraded: 'shadow-[0_0_8px_rgba(250,204,21,0.3)]',
|
||||
online: 'shadow-[0_0_6px_rgba(52,211,153,0.15)]',
|
||||
offline: 'shadow-[0_0_6px_rgba(248,113,113,0.15)]',
|
||||
degraded: 'shadow-[0_0_6px_rgba(250,204,21,0.15)]',
|
||||
unknown: '',
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ export function NodeStatusIndicator({ status = 'unknown', children, className }:
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-lg border-2 transition-colors',
|
||||
'w-full h-full rounded-lg border-2 transition-colors',
|
||||
STATUS_BORDER_COLORS[status],
|
||||
STATUS_GLOW[status],
|
||||
className,
|
||||
|
||||
@@ -14,20 +14,21 @@ const NodeTooltipContext = createContext<NodeTooltipContextValue>({
|
||||
hide: () => {},
|
||||
})
|
||||
|
||||
export function NodeTooltip({ children, ...props }: ComponentProps<'div'>) {
|
||||
export function NodeTooltip({ children, className, ...props }: ComponentProps<'div'>) {
|
||||
const [visible, setVisible] = useState(false)
|
||||
const show = useCallback(() => setVisible(true), [])
|
||||
const hide = useCallback(() => setVisible(false), [])
|
||||
|
||||
return (
|
||||
<NodeTooltipContext.Provider value={{ visible, show, hide }}>
|
||||
<div {...props}>{children}</div>
|
||||
<div className={cn('w-full h-full', className)} {...props}>{children}</div>
|
||||
</NodeTooltipContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function NodeTooltipTrigger({
|
||||
children,
|
||||
className,
|
||||
onMouseEnter,
|
||||
onMouseLeave,
|
||||
...props
|
||||
@@ -36,6 +37,7 @@ export function NodeTooltipTrigger({
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn('w-full h-full', className)}
|
||||
onMouseEnter={(e) => {
|
||||
show()
|
||||
onMouseEnter?.(e)
|
||||
|
||||
@@ -99,9 +99,14 @@ export function useFlowPilotSession(): UseFlowPilotSession {
|
||||
setAllSteps([firstStep])
|
||||
setCurrentStep(firstStep)
|
||||
} catch (e: unknown) {
|
||||
const message = e instanceof Error ? e.message : 'Failed to start session'
|
||||
// Prefer the backend's detail message over the generic axios status string
|
||||
const detail = (e as any)?.response?.data?.detail
|
||||
const message = typeof detail === 'string' ? detail : (e instanceof Error ? e.message : 'Failed to start session')
|
||||
setError(message)
|
||||
toast.error(message)
|
||||
// Global axios interceptor already shows a toast for 5xx — skip duplicate
|
||||
if (!(e as any)?.response?.status || (e as any)?.response?.status < 500) {
|
||||
toast.error(message)
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
|
||||
@@ -445,3 +445,42 @@
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Print / PDF export ───────────────────────────────────────────────── */
|
||||
@media print {
|
||||
/* Hide everything that isn't the canvas */
|
||||
body > * { display: none !important; }
|
||||
|
||||
/* Show only the React Flow viewport inside the diagram editor page */
|
||||
#root { display: block !important; }
|
||||
#root > * { display: none !important; }
|
||||
|
||||
/* The diagram editor mounts as a child of the app shell — target the canvas wrapper */
|
||||
.react-flow__renderer,
|
||||
.react-flow__viewport,
|
||||
.react-flow {
|
||||
display: block !important;
|
||||
}
|
||||
|
||||
/* Make the canvas fill the printed page */
|
||||
.react-flow {
|
||||
position: fixed !important;
|
||||
inset: 0 !important;
|
||||
width: 100vw !important;
|
||||
height: 100vh !important;
|
||||
background: #ffffff !important;
|
||||
}
|
||||
|
||||
/* Force light backgrounds on nodes so they're readable on white paper */
|
||||
.react-flow__node {
|
||||
print-color-adjust: exact;
|
||||
-webkit-print-color-adjust: exact;
|
||||
}
|
||||
|
||||
/* Hide UI chrome */
|
||||
.react-flow__controls,
|
||||
.react-flow__minimap,
|
||||
.react-flow__panel {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
99
frontend/src/lib/drawio-export.ts
Normal file
99
frontend/src/lib/drawio-export.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import type { Node, Edge } from '@xyflow/react'
|
||||
import type { DeviceNodeData } from '@/components/network/nodes/DeviceNode'
|
||||
import type { GroupNodeData } from '@/types/network-diagram'
|
||||
|
||||
// Maps our device slugs to draw.io Cisco stencil shape styles
|
||||
const SLUG_TO_DRAWIO_STYLE: Record<string, string> = {
|
||||
'router': 'shape=mxgraph.cisco.routers.router;',
|
||||
'switch': 'shape=mxgraph.cisco.switches.layer_3_switch;',
|
||||
'access-point': 'shape=mxgraph.cisco.misc.access_point;',
|
||||
'load-balancer': 'shape=mxgraph.cisco.misc.generic_building;',
|
||||
'firewall': 'shape=mxgraph.cisco.firewalls.firewall;',
|
||||
'badge-reader': 'shape=mxgraph.cisco.misc.generic_building;',
|
||||
'server': 'shape=mxgraph.cisco.servers.standard_server;',
|
||||
'vm': 'shape=mxgraph.cisco.servers.standard_server;',
|
||||
'container': 'shape=mxgraph.cisco.servers.standard_server;',
|
||||
'nas': 'shape=mxgraph.cisco.storage.tape_storage_library;',
|
||||
'san': 'shape=mxgraph.cisco.storage.tape_storage_library;',
|
||||
'cloud-storage': 'shape=mxgraph.cisco.misc.cloud;',
|
||||
'cloud': 'shape=mxgraph.cisco.misc.cloud;',
|
||||
'aws': 'shape=mxgraph.cisco.misc.cloud;',
|
||||
'azure': 'shape=mxgraph.cisco.misc.cloud;',
|
||||
'gcp': 'shape=mxgraph.cisco.misc.cloud;',
|
||||
'isp': 'shape=mxgraph.cisco.misc.cloud;',
|
||||
'workstation': 'shape=mxgraph.cisco.computers_and_peripherals.pc;',
|
||||
'laptop': 'shape=mxgraph.cisco.computers_and_peripherals.laptop;',
|
||||
'tablet': 'shape=mxgraph.cisco.computers_and_peripherals.laptop;',
|
||||
'phone': 'shape=mxgraph.cisco.computers_and_peripherals.ip_phone;',
|
||||
'printer': 'shape=mxgraph.cisco.computers_and_peripherals.printer;',
|
||||
'ups': 'shape=mxgraph.cisco.misc.generic_building;',
|
||||
'pdu': 'shape=mxgraph.cisco.misc.generic_building;',
|
||||
'rack': 'shape=mxgraph.cisco.misc.generic_building;',
|
||||
'patch-panel': 'shape=mxgraph.cisco.misc.generic_building;',
|
||||
'camera': 'shape=mxgraph.cisco.misc.generic_building;',
|
||||
'nvr': 'shape=mxgraph.cisco.misc.generic_building;',
|
||||
'iot': 'shape=mxgraph.cisco.misc.generic_building;',
|
||||
}
|
||||
|
||||
const BASE_NODE_STYLE =
|
||||
'sketch=0;html=1;pointerEvents=1;dashed=0;fillColor=#036897;strokeColor=#ffffff;strokeWidth=2;verticalLabelPosition=bottom;verticalAlign=top;align=center;outlineConnect=0;'
|
||||
const GROUP_STYLE =
|
||||
'swimlane;fontStyle=0;childLayout=stackLayout;horizontal=1;startSize=30;horizontalStack=0;resizeParent=1;resizeParentMax=0;collapsible=0;marginBottom=0;swimlaneHead=0;fillColor=none;'
|
||||
|
||||
function esc(s: string): string {
|
||||
return s
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
}
|
||||
|
||||
export function exportToDrawio(nodes: Node[], edges: Edge[]): string {
|
||||
const cells: string[] = [
|
||||
'<mxCell id="0"/>',
|
||||
'<mxCell id="1" parent="0"/>',
|
||||
]
|
||||
|
||||
for (const node of nodes) {
|
||||
const w = typeof node.style?.width === 'number' ? node.style.width : (node.measured?.width ?? 120)
|
||||
const h = typeof node.style?.height === 'number' ? node.style.height : (node.measured?.height ?? 120)
|
||||
const x = node.position.x
|
||||
const y = node.position.y
|
||||
const parentId = node.parentId ?? '1'
|
||||
|
||||
if (node.type === 'group') {
|
||||
const gd = node.data as GroupNodeData
|
||||
cells.push(
|
||||
`<mxCell id="${esc(node.id)}" value="${esc(gd.label ?? '')}" style="${GROUP_STYLE}" vertex="1" parent="${esc(parentId)}">` +
|
||||
`<mxGeometry x="${x}" y="${y}" width="${w}" height="${h}" as="geometry"/>` +
|
||||
`</mxCell>`,
|
||||
)
|
||||
} else {
|
||||
const dd = node.data as DeviceNodeData
|
||||
const slug = dd.deviceType ?? 'server'
|
||||
const shapeStyle = SLUG_TO_DRAWIO_STYLE[slug] ?? 'rounded=1;whiteSpace=wrap;html=1;'
|
||||
cells.push(
|
||||
`<mxCell id="${esc(node.id)}" value="${esc(dd.label ?? '')}" style="${shapeStyle}${BASE_NODE_STYLE}" vertex="1" parent="${esc(parentId)}">` +
|
||||
`<mxGeometry x="${x}" y="${y}" width="${w}" height="${h}" as="geometry"/>` +
|
||||
`</mxCell>`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
for (const edge of edges) {
|
||||
const label = typeof edge.label === 'string' ? edge.label : ''
|
||||
cells.push(
|
||||
`<mxCell id="${esc(edge.id)}" value="${esc(label)}" style="edgeStyle=orthogonalEdgeStyle;html=1;" edge="1" source="${esc(edge.source)}" target="${esc(edge.target)}" parent="1">` +
|
||||
`<mxGeometry relative="1" as="geometry"/>` +
|
||||
`</mxCell>`,
|
||||
)
|
||||
}
|
||||
|
||||
const xml =
|
||||
`<?xml version="1.0" encoding="UTF-8"?>\n` +
|
||||
`<mxGraphModel><root>\n` +
|
||||
cells.join('\n') +
|
||||
`\n</root></mxGraphModel>`
|
||||
|
||||
return xml
|
||||
}
|
||||
142
frontend/src/lib/drawio-import.ts
Normal file
142
frontend/src/lib/drawio-import.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
import type { DiagramNode, DiagramEdge } from '@/types/network-diagram'
|
||||
|
||||
// Maps draw.io shape identifiers (substrings of style) → our device slugs
|
||||
const DRAWIO_SHAPE_TO_SLUG: Array<[string, string]> = [
|
||||
['cisco.routers.router', 'router'],
|
||||
['cisco.routers', 'router'],
|
||||
['cisco.switches.layer_3_switch', 'switch'],
|
||||
['cisco.switches.workgroup_switch', 'switch'],
|
||||
['cisco.switches', 'switch'],
|
||||
['cisco.firewalls', 'firewall'],
|
||||
['cisco.servers', 'server'],
|
||||
['cisco.computers_and_peripherals.laptop', 'laptop'],
|
||||
['cisco.computers_and_peripherals.ip_phone', 'phone'],
|
||||
['cisco.computers_and_peripherals.pc', 'workstation'],
|
||||
['cisco.computers_and_peripherals.printer', 'printer'],
|
||||
['cisco.misc.access_point', 'access-point'],
|
||||
['cisco.misc.cloud', 'cloud'],
|
||||
['cisco.storage', 'nas'],
|
||||
['shape=router', 'router'],
|
||||
['shape=server', 'server'],
|
||||
['shape=firewall', 'firewall'],
|
||||
['shape=cloud', 'cloud'],
|
||||
]
|
||||
|
||||
function styleToSlug(style: string): string {
|
||||
const lower = style.toLowerCase()
|
||||
for (const [pattern, slug] of DRAWIO_SHAPE_TO_SLUG) {
|
||||
if (lower.includes(pattern)) return slug
|
||||
}
|
||||
return 'server'
|
||||
}
|
||||
|
||||
function isGroup(style: string): boolean {
|
||||
return style.includes('swimlane') || style.includes('container') || style.includes('group')
|
||||
}
|
||||
|
||||
export interface DrawioImportResult {
|
||||
nodes: DiagramNode[]
|
||||
edges: DiagramEdge[]
|
||||
warnings: string[]
|
||||
}
|
||||
|
||||
export function parseDrawioXml(xmlString: string): DrawioImportResult {
|
||||
const parser = new DOMParser()
|
||||
const doc = parser.parseFromString(xmlString, 'application/xml')
|
||||
|
||||
const parseError = doc.querySelector('parsererror')
|
||||
if (parseError) {
|
||||
throw new Error('Invalid draw.io XML: ' + parseError.textContent?.slice(0, 200))
|
||||
}
|
||||
|
||||
const cells = Array.from(doc.querySelectorAll('mxCell'))
|
||||
const warnings: string[] = []
|
||||
const nodes: DiagramNode[] = []
|
||||
const edges: DiagramEdge[] = []
|
||||
|
||||
const geoMap = new Map<string, { x: number; y: number; width: number; height: number }>()
|
||||
for (const cell of cells) {
|
||||
const geo = cell.querySelector('mxGeometry')
|
||||
if (geo) {
|
||||
geoMap.set(cell.getAttribute('id') ?? '', {
|
||||
x: parseFloat(geo.getAttribute('x') ?? '0'),
|
||||
y: parseFloat(geo.getAttribute('y') ?? '0'),
|
||||
width: parseFloat(geo.getAttribute('width') ?? '120'),
|
||||
height: parseFloat(geo.getAttribute('height') ?? '120'),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const groupIds = new Set<string>()
|
||||
|
||||
for (const cell of cells) {
|
||||
const id = cell.getAttribute('id') ?? ''
|
||||
if (id === '0' || id === '1') continue
|
||||
|
||||
const isEdge = cell.getAttribute('edge') === '1'
|
||||
const isVertex = cell.getAttribute('vertex') === '1'
|
||||
const style = cell.getAttribute('style') ?? ''
|
||||
const value = cell.getAttribute('value') ?? ''
|
||||
const parent = cell.getAttribute('parent') ?? '1'
|
||||
const geo = geoMap.get(id)
|
||||
|
||||
if (isEdge) {
|
||||
const source = cell.getAttribute('source') ?? ''
|
||||
const target = cell.getAttribute('target') ?? ''
|
||||
if (!source || !target) {
|
||||
warnings.push(`Edge "${id}" skipped — missing source or target`)
|
||||
continue
|
||||
}
|
||||
edges.push({
|
||||
id,
|
||||
source,
|
||||
target,
|
||||
label: value || null,
|
||||
connectionType: 'ethernet',
|
||||
speed: null,
|
||||
notes: null,
|
||||
routing: null,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
if (isVertex && geo) {
|
||||
if (isGroup(style)) {
|
||||
groupIds.add(id)
|
||||
nodes.push({
|
||||
id,
|
||||
type: 'subnet',
|
||||
label: value || 'Group',
|
||||
position: { x: geo.x, y: geo.y },
|
||||
properties: {
|
||||
hostname: null, ip: null, subnet: null, vendor: null,
|
||||
model: null, role: null, vlan: null, notes: null, status: 'unknown',
|
||||
},
|
||||
nodeType: 'group',
|
||||
style: { width: geo.width, height: geo.height },
|
||||
})
|
||||
} else {
|
||||
const slug = styleToSlug(style)
|
||||
const parentId = parent !== '1' && groupIds.has(parent) ? parent : undefined
|
||||
nodes.push({
|
||||
id,
|
||||
type: slug,
|
||||
label: value || slug,
|
||||
position: { x: geo.x, y: geo.y },
|
||||
properties: {
|
||||
hostname: null, ip: null, subnet: null, vendor: null,
|
||||
model: null, role: null, vlan: null, notes: null, status: 'unknown',
|
||||
},
|
||||
...(parentId ? { parentId } : {}),
|
||||
style: { width: geo.width, height: geo.height },
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (nodes.length === 0) {
|
||||
warnings.push('No nodes were found in this draw.io file. Only basic shapes and Cisco stencil shapes are supported.')
|
||||
}
|
||||
|
||||
return { nodes, edges, warnings }
|
||||
}
|
||||
@@ -9,7 +9,7 @@ const PREFETCH_MAP: Record<string, () => Promise<unknown>> = {
|
||||
'/shares': () => import('@/pages/MySharesPage'),
|
||||
'/analytics': () => import('@/pages/TeamAnalyticsPage'),
|
||||
'/analytics/me': () => import('@/pages/MyAnalyticsPage'),
|
||||
'/assistant': () => import('@/pages/AssistantChatPage'),
|
||||
'/pilot': () => import('@/pages/AssistantChatPage'),
|
||||
'/step-library': () => import('@/pages/StepLibraryPage'),
|
||||
'/guides': () => import('@/pages/GuidesHubPage'),
|
||||
'/feedback': () => import('@/pages/FeedbackPage'),
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -9,6 +9,8 @@ import { StatusUpdateModal } from '@/components/flowpilot/StatusUpdateModal'
|
||||
import { HandoffModal } from '@/components/session/HandoffModal'
|
||||
import { handoffsApi } from '@/api/handoffs'
|
||||
import { aiSessionsApi } from '@/api'
|
||||
import { integrationsApi } from '@/api/integrations'
|
||||
import type { PSATicketInfo } from '@/types/integrations'
|
||||
import { toast } from '@/lib/toast'
|
||||
|
||||
export default function FlowPilotSessionPage() {
|
||||
@@ -17,10 +19,13 @@ export default function FlowPilotSessionPage() {
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const prefill = (location.state as { prefill?: string } | null)?.prefill || ''
|
||||
const psaTicketId = (location.state as any)?.psaTicketId as string | undefined
|
||||
const psaTicket = (location.state as any)?.psaTicket as PSATicketInfo | undefined
|
||||
const isPickup = searchParams.get('pickup') === 'true'
|
||||
const fp = useFlowPilotSession()
|
||||
const branching = useBranching()
|
||||
const prefillHandledRef = useRef(false)
|
||||
const psaTicketHandledRef = useRef(false)
|
||||
const [showOverflow, setShowOverflow] = useState(false)
|
||||
const [showResolve, setShowResolve] = useState(false)
|
||||
const [showEscalate, setShowEscalate] = useState(false)
|
||||
@@ -44,6 +49,30 @@ export default function FlowPilotSessionPage() {
|
||||
fp.startSession({ intake_type: 'free_text', intake_content: { text: prefill } })
|
||||
}
|
||||
}, [prefill, sessionId, fp.session, fp.isLoading]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Auto-start when navigating from TicketQueue with a PSA ticket
|
||||
useEffect(() => {
|
||||
if (psaTicketId && psaTicket && !psaTicketHandledRef.current && !sessionId && !fp.session && !fp.isLoading) {
|
||||
psaTicketHandledRef.current = true
|
||||
integrationsApi.getConnection().then((conn) => {
|
||||
if (conn?.id) {
|
||||
fp.startSession({
|
||||
intake_type: 'psa_ticket',
|
||||
intake_content: {
|
||||
ticket_data: {
|
||||
summary: psaTicket.summary,
|
||||
company: psaTicket.company_name,
|
||||
priority: psaTicket.priority_name,
|
||||
},
|
||||
},
|
||||
psa_ticket_id: psaTicketId,
|
||||
psa_connection_id: conn.id,
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
}, [psaTicketId, psaTicket, sessionId, fp.session, fp.isLoading]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const [pickingUp, setPickingUp] = useState(false)
|
||||
|
||||
// Load existing session if ID in URL
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { useState, useCallback, useEffect, useRef } from 'react'
|
||||
import { useState, useCallback, useEffect, useRef, useReducer } from 'react'
|
||||
import { useParams, useNavigate } from 'react-router-dom'
|
||||
import {
|
||||
ReactFlowProvider,
|
||||
useNodesState,
|
||||
useEdgesState,
|
||||
addEdge,
|
||||
reconnectEdge,
|
||||
useReactFlow,
|
||||
getNodesBounds,
|
||||
getViewportForBounds,
|
||||
@@ -17,16 +18,25 @@ import '@xyflow/react/dist/style.css'
|
||||
import { NetworkCanvas } from '@/components/network/NetworkCanvas'
|
||||
import { ContextMenu, getNodeMenuActions, getCanvasMenuActions } from '@/components/network/ContextMenu'
|
||||
import { useCanvasShortcuts } from '@/components/network/hooks/useCanvasShortcuts'
|
||||
import { DiagramHeader } from '@/components/network/DiagramHeader'
|
||||
import { useDiagramCommands } from '@/components/network/hooks/useDiagramCommands'
|
||||
import { DiagramHeader, type InteractionMode } from '@/components/network/DiagramHeader'
|
||||
import { DeviceToolbar } from '@/components/network/panels/DeviceToolbar'
|
||||
import { PropertiesPanel } from '@/components/network/panels/PropertiesPanel'
|
||||
import { AIAssistPanel } from '@/components/network/panels/AIAssistPanel'
|
||||
import { CanvasEmptyPrompt } from '@/components/network/CanvasEmptyPrompt'
|
||||
import { KeyboardShortcutsOverlay } from '@/components/network/KeyboardShortcutsOverlay'
|
||||
import { networkDiagramsApi, deviceTypesApi } from '@/api'
|
||||
import { toast } from '@/lib/toast'
|
||||
import { exportToDrawio } from '@/lib/drawio-export'
|
||||
import { parseDrawioXml } from '@/lib/drawio-import'
|
||||
import type { DeviceTypeResponse, DeviceProperties, AIGenerateResponse, DiagramEdge, DiagramNode } from '@/types'
|
||||
import type { DeviceNodeData } from '@/components/network/nodes/DeviceNode'
|
||||
|
||||
function normalizeZOrder(nodes: Node[]): Node[] {
|
||||
const sorted = [...nodes].sort((a, b) => ((a.zIndex ?? 0) - (b.zIndex ?? 0)))
|
||||
return sorted.map((n, i) => ({ ...n, zIndex: i + 1 }))
|
||||
}
|
||||
|
||||
type ContextMenuState = {
|
||||
type: 'node' | 'canvas'
|
||||
position: { x: number; y: number }
|
||||
@@ -63,13 +73,78 @@ function DiagramEditorInner() {
|
||||
const [loading, setLoading] = useState(!!id)
|
||||
const [isDragOver, setIsDragOver] = useState(false)
|
||||
|
||||
const [interactionMode, setInteractionMode] = useState<InteractionMode>('select')
|
||||
const [showShortcuts, setShowShortcuts] = useState(false)
|
||||
|
||||
const canvasRef = useRef<HTMLDivElement | null>(null)
|
||||
const drawioImportRef = useRef<HTMLInputElement>(null)
|
||||
const [contextMenu, setContextMenu] = useState<ContextMenuState>(null)
|
||||
const [pendingDeleteNodeId, setPendingDeleteNodeId] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => { isDirtyRef.current = isDirty }, [isDirty])
|
||||
useEffect(() => { diagramIdRef.current = diagramId }, [diagramId])
|
||||
|
||||
// History
|
||||
const historyStack = useRef<{ nodes: Node[]; edges: Edge[] }[]>([])
|
||||
const historyIndex = useRef<number>(-1)
|
||||
const MAX_HISTORY = 50
|
||||
const [, forceHistoryUpdate] = useReducer((x: number) => x + 1, 0)
|
||||
|
||||
const pushHistory = useCallback((currentNodes: Node[], currentEdges: Edge[]) => {
|
||||
historyStack.current = historyStack.current.slice(0, historyIndex.current + 1)
|
||||
historyStack.current.push({
|
||||
nodes: JSON.parse(JSON.stringify(currentNodes)),
|
||||
edges: JSON.parse(JSON.stringify(currentEdges)),
|
||||
})
|
||||
if (historyStack.current.length > MAX_HISTORY) {
|
||||
historyStack.current.shift()
|
||||
} else {
|
||||
historyIndex.current += 1
|
||||
}
|
||||
forceHistoryUpdate()
|
||||
}, [forceHistoryUpdate])
|
||||
|
||||
const undo = useCallback(() => {
|
||||
if (historyIndex.current <= 0) return
|
||||
historyIndex.current -= 1
|
||||
const snapshot = historyStack.current[historyIndex.current]
|
||||
setNodes(snapshot.nodes)
|
||||
setEdges(snapshot.edges)
|
||||
setIsDirty(true)
|
||||
forceHistoryUpdate()
|
||||
}, [setNodes, setEdges, forceHistoryUpdate])
|
||||
|
||||
const redo = useCallback(() => {
|
||||
if (historyIndex.current >= historyStack.current.length - 1) return
|
||||
historyIndex.current += 1
|
||||
const snapshot = historyStack.current[historyIndex.current]
|
||||
setNodes(snapshot.nodes)
|
||||
setEdges(snapshot.edges)
|
||||
setIsDirty(true)
|
||||
forceHistoryUpdate()
|
||||
}, [setNodes, setEdges, forceHistoryUpdate])
|
||||
|
||||
const canUndo = historyIndex.current > 0
|
||||
const canRedo = historyIndex.current < historyStack.current.length - 1
|
||||
|
||||
const diagramCommands = useDiagramCommands({
|
||||
nodes,
|
||||
edges,
|
||||
pushHistory,
|
||||
setNodes,
|
||||
})
|
||||
|
||||
const onNudge = useCallback((dx: number, dy: number) => {
|
||||
const selected = nodes.filter(n => n.selected)
|
||||
if (selected.length === 0) return
|
||||
pushHistory(nodes, edges)
|
||||
setNodes(prev => prev.map(n =>
|
||||
n.selected
|
||||
? { ...n, position: { x: n.position.x + dx, y: n.position.y + dy } }
|
||||
: n
|
||||
))
|
||||
}, [nodes, edges, pushHistory, setNodes])
|
||||
|
||||
const {
|
||||
copyNodes,
|
||||
pasteNodes,
|
||||
@@ -84,6 +159,11 @@ function DiagramEditorInner() {
|
||||
setEdges,
|
||||
setIsDirty: (v: boolean) => setIsDirty(v),
|
||||
canvasRef,
|
||||
onUndo: undo,
|
||||
onRedo: redo,
|
||||
onNudge,
|
||||
onSetMode: setInteractionMode,
|
||||
onToggleShortcuts: () => setShowShortcuts(v => !v),
|
||||
})
|
||||
|
||||
const handleNodesChange: typeof onNodesChange = useCallback((changes) => {
|
||||
@@ -137,6 +217,7 @@ function DiagramEditorInner() {
|
||||
type: 'device',
|
||||
position: n.position,
|
||||
style: n.style || { width: 120, height: 120 },
|
||||
...(n.parentId ? { parentId: n.parentId, extent: 'parent' as const } : {}),
|
||||
data: {
|
||||
label: n.label,
|
||||
deviceType: n.type,
|
||||
@@ -161,6 +242,37 @@ function DiagramEditorInner() {
|
||||
}))
|
||||
)
|
||||
setLastSavedAt(new Date(diagram.updated_at))
|
||||
// Initialize history after load
|
||||
const loadedNodes = diagram.nodes.map(n => {
|
||||
if (n.nodeType === 'group') {
|
||||
return {
|
||||
id: n.id,
|
||||
type: 'group' as const,
|
||||
position: n.position,
|
||||
style: n.style || { width: 300, height: 200 },
|
||||
data: { label: n.label, groupType: n.type },
|
||||
}
|
||||
}
|
||||
return {
|
||||
id: n.id,
|
||||
type: 'device' as const,
|
||||
position: n.position,
|
||||
style: n.style || { width: 120, height: 120 },
|
||||
...(n.parentId ? { parentId: n.parentId, extent: 'parent' as const } : {}),
|
||||
data: { label: n.label, deviceType: n.type, properties: n.properties } satisfies DeviceNodeData,
|
||||
}
|
||||
})
|
||||
const loadedEdges = diagram.edges.map(e => ({
|
||||
id: e.id,
|
||||
source: e.source,
|
||||
target: e.target,
|
||||
type: 'connection' as const,
|
||||
label: e.label || undefined,
|
||||
data: { connectionType: e.connectionType, speed: e.speed, notes: e.notes, routing: e.routing ?? null },
|
||||
}))
|
||||
historyStack.current = []
|
||||
historyIndex.current = -1
|
||||
pushHistory(loadedNodes, loadedEdges)
|
||||
} catch {
|
||||
toast.error('Failed to load diagram')
|
||||
navigate('/network-diagrams')
|
||||
@@ -169,7 +281,7 @@ function DiagramEditorInner() {
|
||||
}
|
||||
})()
|
||||
return () => { cancelled = true }
|
||||
}, [id, navigate, setNodes, setEdges])
|
||||
}, [id, navigate, setNodes, setEdges, pushHistory])
|
||||
|
||||
const serializeNodes = useCallback((): DiagramNode[] => {
|
||||
return getNodes().map(n => {
|
||||
@@ -197,6 +309,7 @@ function DiagramEditorInner() {
|
||||
position: n.position,
|
||||
properties: data.properties,
|
||||
style: { width: dw, height: dh },
|
||||
...(n.parentId ? { parentId: n.parentId } : {}),
|
||||
}
|
||||
})
|
||||
}, [getNodes])
|
||||
@@ -212,7 +325,7 @@ function DiagramEditorInner() {
|
||||
connectionType: d.connectionType as string || 'ethernet',
|
||||
speed: d.speed as string || null,
|
||||
notes: d.notes as string || null,
|
||||
routing: d.routing as string || null,
|
||||
routing: (d.routing as DiagramEdge['routing']) || null,
|
||||
}
|
||||
})
|
||||
}, [edges])
|
||||
@@ -228,21 +341,51 @@ function DiagramEditorInner() {
|
||||
nodes: serializeNodes(),
|
||||
edges: serializeEdges(),
|
||||
}
|
||||
let savedId: string | null = diagramIdRef.current
|
||||
if (diagramIdRef.current) {
|
||||
await networkDiagramsApi.update(diagramIdRef.current, payload)
|
||||
} else {
|
||||
const created = await networkDiagramsApi.create(payload)
|
||||
savedId = created.id
|
||||
setDiagramId(created.id)
|
||||
navigate(`/network-diagrams/${created.id}`, { replace: true })
|
||||
}
|
||||
setIsDirty(false)
|
||||
setLastSavedAt(new Date())
|
||||
|
||||
// Generate thumbnail in the background — don't block save UX on failure
|
||||
if (savedId && nodes.length > 0) {
|
||||
try {
|
||||
const { toPng } = await import('html-to-image')
|
||||
const THUMB_W = 480
|
||||
const THUMB_H = 300
|
||||
const bounds = getNodesBounds(nodes)
|
||||
const viewport = getViewportForBounds(bounds, THUMB_W, THUMB_H, 0.5, 2, 0.1)
|
||||
const flowEl = document.querySelector('.react-flow__viewport') as HTMLElement | null
|
||||
if (flowEl) {
|
||||
const dataUrl = await toPng(flowEl, {
|
||||
backgroundColor: '#16181f',
|
||||
width: THUMB_W,
|
||||
height: THUMB_H,
|
||||
style: {
|
||||
width: `${THUMB_W}px`,
|
||||
height: `${THUMB_H}px`,
|
||||
transform: `translate(${viewport.x}px, ${viewport.y}px) scale(${viewport.zoom})`,
|
||||
transformOrigin: 'top left',
|
||||
},
|
||||
})
|
||||
await networkDiagramsApi.uploadThumbnail(savedId, dataUrl)
|
||||
}
|
||||
} catch {
|
||||
// Thumbnail failure is silent — doesn't affect save success
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
toast.error('Failed to save diagram')
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
}
|
||||
}, [name, clientName, assetName, description, serializeNodes, serializeEdges, navigate])
|
||||
}, [name, clientName, assetName, description, serializeNodes, serializeEdges, navigate, nodes])
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
@@ -254,13 +397,21 @@ function DiagramEditorInner() {
|
||||
}, [handleSave])
|
||||
|
||||
const onConnect = useCallback((connection: Connection) => {
|
||||
pushHistory(nodes, edges)
|
||||
setEdges(eds => addEdge({
|
||||
...connection,
|
||||
type: 'connection',
|
||||
data: { connectionType: 'ethernet' },
|
||||
}, eds))
|
||||
setIsDirty(true)
|
||||
}, [setEdges])
|
||||
}, [nodes, edges, pushHistory, setEdges])
|
||||
|
||||
const onReconnect = useCallback((oldEdge: Edge, newConnection: Connection) => {
|
||||
pushHistory(nodes, edges)
|
||||
setEdges(eds => reconnectEdge(oldEdge, newConnection, eds))
|
||||
setSelectedEdgeId(oldEdge.id)
|
||||
setIsDirty(true)
|
||||
}, [nodes, edges, pushHistory, setEdges])
|
||||
|
||||
const onDragOver = useCallback((event: React.DragEvent) => {
|
||||
event.preventDefault()
|
||||
@@ -292,11 +443,22 @@ function DiagramEditorInner() {
|
||||
|
||||
const handlePaneContextMenu = useCallback((event: MouseEvent | React.MouseEvent) => {
|
||||
event.preventDefault()
|
||||
setContextMenu({
|
||||
type: 'canvas',
|
||||
position: { x: event.clientX, y: event.clientY },
|
||||
})
|
||||
}, [])
|
||||
// Group nodes pass pointer events through to children, so right-clicking a group
|
||||
// may fire onPaneContextMenu instead of onNodeContextMenu. If nodes are selected,
|
||||
// show the node context menu so group/align/ungroup options are accessible.
|
||||
const selected = getNodes().filter(n => n.selected)
|
||||
if (selected.length > 0) {
|
||||
setContextMenu({
|
||||
type: 'node',
|
||||
position: { x: event.clientX, y: event.clientY },
|
||||
})
|
||||
} else {
|
||||
setContextMenu({
|
||||
type: 'canvas',
|
||||
position: { x: event.clientX, y: event.clientY },
|
||||
})
|
||||
}
|
||||
}, [getNodes])
|
||||
|
||||
const closeContextMenu = useCallback(() => {
|
||||
setContextMenu(null)
|
||||
@@ -334,6 +496,7 @@ function DiagramEditorInner() {
|
||||
} satisfies DeviceProperties,
|
||||
} satisfies DeviceNodeData,
|
||||
}
|
||||
pushHistory(nodes, edges)
|
||||
setNodes(nds => [...nds, newNode])
|
||||
setIsDirty(true)
|
||||
return
|
||||
@@ -353,20 +516,23 @@ function DiagramEditorInner() {
|
||||
groupType: slug,
|
||||
},
|
||||
}
|
||||
pushHistory(nodes, edges)
|
||||
setNodes(nds => [...nds, newNode])
|
||||
setIsDirty(true)
|
||||
}
|
||||
}, [setNodes, screenToFlowPosition])
|
||||
}, [nodes, edges, pushHistory, setNodes, screenToFlowPosition])
|
||||
|
||||
const handleNodeUpdate = useCallback((nodeId: string, updates: Partial<DeviceNodeData>) => {
|
||||
pushHistory(nodes, edges)
|
||||
setNodes(nds => nds.map(n => {
|
||||
if (n.id !== nodeId) return n
|
||||
return { ...n, data: { ...n.data, ...updates } }
|
||||
}))
|
||||
setIsDirty(true)
|
||||
}, [setNodes])
|
||||
}, [nodes, edges, pushHistory, setNodes])
|
||||
|
||||
const handleEdgeUpdate = useCallback((edgeId: string, updates: Partial<DiagramEdge>) => {
|
||||
pushHistory(nodes, edges)
|
||||
setEdges(eds => eds.map(e => {
|
||||
if (e.id !== edgeId) return e
|
||||
return {
|
||||
@@ -382,44 +548,50 @@ function DiagramEditorInner() {
|
||||
}
|
||||
}))
|
||||
setIsDirty(true)
|
||||
}, [setEdges])
|
||||
}, [nodes, edges, pushHistory, setEdges])
|
||||
|
||||
const handleEdgeTypeChange = useCallback((edgeId: string, edgeType: string) => {
|
||||
pushHistory(nodes, edges)
|
||||
setEdges(eds => eds.map(e => {
|
||||
if (e.id !== edgeId) return e
|
||||
return { ...e, type: edgeType }
|
||||
}))
|
||||
setIsDirty(true)
|
||||
}, [setEdges])
|
||||
}, [nodes, edges, pushHistory, setEdges])
|
||||
|
||||
const handleDeleteNode = useCallback((nodeId: string) => {
|
||||
pushHistory(nodes, edges)
|
||||
setNodes(nds => nds.filter(n => n.id !== nodeId))
|
||||
setEdges(eds => eds.filter(e => e.source !== nodeId && e.target !== nodeId))
|
||||
setSelectedNodeId(null)
|
||||
setIsDirty(true)
|
||||
}, [setNodes, setEdges])
|
||||
}, [nodes, edges, pushHistory, setNodes, setEdges])
|
||||
|
||||
const handleDeleteEdge = useCallback((edgeId: string) => {
|
||||
pushHistory(nodes, edges)
|
||||
setEdges(eds => eds.filter(e => e.id !== edgeId))
|
||||
setSelectedEdgeId(null)
|
||||
setIsDirty(true)
|
||||
}, [setEdges])
|
||||
}, [nodes, edges, pushHistory, setEdges])
|
||||
|
||||
const handleBringToFront = useCallback((nodeId: string) => {
|
||||
setNodes(nds => {
|
||||
const maxZ = Math.max(0, ...nds.map(n => n.zIndex ?? 0))
|
||||
return nds.map(n => n.id === nodeId ? { ...n, zIndex: maxZ + 1 } : n)
|
||||
pushHistory(nodes, edges)
|
||||
setNodes(prev => {
|
||||
const maxZ = Math.max(0, ...prev.map(n => n.zIndex ?? 0))
|
||||
return normalizeZOrder(
|
||||
prev.map(n => n.id === nodeId ? { ...n, zIndex: maxZ + 1 } : n)
|
||||
)
|
||||
})
|
||||
setIsDirty(true)
|
||||
}, [setNodes])
|
||||
}, [nodes, edges, pushHistory, setNodes])
|
||||
|
||||
const handleSendToBack = useCallback((nodeId: string) => {
|
||||
setNodes(nds => {
|
||||
const minZ = Math.min(0, ...nds.map(n => n.zIndex ?? 0))
|
||||
return nds.map(n => n.id === nodeId ? { ...n, zIndex: minZ - 1 } : n)
|
||||
})
|
||||
pushHistory(nodes, edges)
|
||||
setNodes(prev => normalizeZOrder(
|
||||
prev.map(n => n.id === nodeId ? { ...n, zIndex: 0 } : n)
|
||||
))
|
||||
setIsDirty(true)
|
||||
}, [setNodes])
|
||||
}, [nodes, edges, pushHistory, setNodes])
|
||||
|
||||
const handleAIGenerate = useCallback((result: AIGenerateResponse, mode: 'replace' | 'merge') => {
|
||||
const newNodes: Node[] = result.nodes.map(n => ({
|
||||
@@ -442,6 +614,7 @@ function DiagramEditorInner() {
|
||||
data: { connectionType: e.connectionType, speed: e.speed, notes: e.notes },
|
||||
}))
|
||||
|
||||
pushHistory(nodes, edges)
|
||||
if (mode === 'replace') {
|
||||
setNodes(newNodes)
|
||||
setEdges(newEdges)
|
||||
@@ -463,7 +636,7 @@ function DiagramEditorInner() {
|
||||
|
||||
setIsDirty(true)
|
||||
setTimeout(() => fitView({ padding: 0.2 }), 100)
|
||||
}, [setNodes, setEdges, diagramId, fitView])
|
||||
}, [nodes, edges, pushHistory, setNodes, setEdges, diagramId, fitView])
|
||||
|
||||
const getExistingBounds = useCallback(() => {
|
||||
const currentNodes = getNodes()
|
||||
@@ -514,6 +687,42 @@ function DiagramEditorInner() {
|
||||
}
|
||||
}, [nodes, name])
|
||||
|
||||
const handleExportSvg = useCallback(async () => {
|
||||
if (nodes.length === 0) {
|
||||
toast.warning('Add some devices to the diagram before exporting')
|
||||
return
|
||||
}
|
||||
try {
|
||||
const { toSvg } = await import('html-to-image')
|
||||
const IMAGE_WIDTH = 1920
|
||||
const IMAGE_HEIGHT = 1080
|
||||
const bounds = getNodesBounds(nodes)
|
||||
const viewport = getViewportForBounds(bounds, IMAGE_WIDTH, IMAGE_HEIGHT, 0.5, 2, 0.15)
|
||||
const flowEl = document.querySelector('.react-flow__viewport') as HTMLElement | null
|
||||
if (!flowEl) {
|
||||
toast.error('Could not find canvas to export')
|
||||
return
|
||||
}
|
||||
const dataUrl = await toSvg(flowEl, {
|
||||
backgroundColor: '#16181f',
|
||||
width: IMAGE_WIDTH,
|
||||
height: IMAGE_HEIGHT,
|
||||
style: {
|
||||
width: `${IMAGE_WIDTH}px`,
|
||||
height: `${IMAGE_HEIGHT}px`,
|
||||
transform: `translate(${viewport.x}px, ${viewport.y}px) scale(${viewport.zoom})`,
|
||||
transformOrigin: 'top left',
|
||||
},
|
||||
})
|
||||
const a = document.createElement('a')
|
||||
a.download = `${name.replace(/[^a-zA-Z0-9-_ ]/g, '') || 'diagram'}.svg`
|
||||
a.href = dataUrl
|
||||
a.click()
|
||||
} catch {
|
||||
toast.error('SVG export failed')
|
||||
}
|
||||
}, [nodes, name])
|
||||
|
||||
const handleExportPdf = useCallback(() => {
|
||||
window.print()
|
||||
}, [])
|
||||
@@ -534,6 +743,54 @@ function DiagramEditorInner() {
|
||||
}
|
||||
}, [diagramId, name])
|
||||
|
||||
const handleExportDrawio = useCallback(() => {
|
||||
if (nodes.length === 0) {
|
||||
toast.warning('Add some devices to the diagram before exporting')
|
||||
return
|
||||
}
|
||||
const xml = exportToDrawio(getNodes(), edges)
|
||||
const blob = new Blob([xml], { type: 'application/xml' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `${name.replace(/[^a-zA-Z0-9-_ ]/g, '') || 'diagram'}.drawio`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}, [nodes, edges, getNodes, name])
|
||||
|
||||
const handleImportDrawio = useCallback(() => {
|
||||
drawioImportRef.current?.click()
|
||||
}, [])
|
||||
|
||||
const handleDrawioFileChange = useCallback(async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
e.target.value = ''
|
||||
try {
|
||||
const text = await file.text()
|
||||
const { nodes: importedNodes, edges: importedEdges, warnings } = parseDrawioXml(text)
|
||||
const importPayload = {
|
||||
schemaVersion: 1 as const,
|
||||
name: file.name.replace(/\.drawio$/i, '') || 'Imported Diagram',
|
||||
client_name: null,
|
||||
description: null,
|
||||
nodes: importedNodes,
|
||||
edges: importedEdges,
|
||||
}
|
||||
const result = await networkDiagramsApi.importJson(importPayload)
|
||||
const allWarnings = [...warnings, ...result.warnings]
|
||||
if (allWarnings.length > 0) {
|
||||
toast.warning(`Imported with ${allWarnings.length} warning(s): ${allWarnings[0]}`)
|
||||
} else {
|
||||
toast.success('draw.io file imported successfully')
|
||||
}
|
||||
navigate(`/network-diagrams/${result.diagram.id}`)
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'Unknown error'
|
||||
toast.error(`Import failed: ${msg}`)
|
||||
}
|
||||
}, [navigate])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
@@ -551,11 +808,20 @@ function DiagramEditorInner() {
|
||||
isSaving={isSaving}
|
||||
lastSavedAt={lastSavedAt}
|
||||
diagramId={diagramId}
|
||||
onNameChange={n => { setName(n); setIsDirty(true) }}
|
||||
onNameChange={(n: string) => { setName(n); setIsDirty(true) }}
|
||||
onSave={handleSave}
|
||||
onExportPng={handleExportPng}
|
||||
onExportSvg={handleExportSvg}
|
||||
onExportPdf={handleExportPdf}
|
||||
onExportJson={handleExportJson}
|
||||
onExportDrawio={handleExportDrawio}
|
||||
onImportDrawio={handleImportDrawio}
|
||||
onUndo={undo}
|
||||
onRedo={redo}
|
||||
canUndo={canUndo}
|
||||
canRedo={canRedo}
|
||||
interactionMode={interactionMode}
|
||||
onModeChange={setInteractionMode}
|
||||
/>
|
||||
<div className="flex flex-1 min-h-0">
|
||||
<DeviceToolbar deviceTypes={deviceTypes} onDeviceTypesChange={loadDeviceTypes} />
|
||||
@@ -567,6 +833,7 @@ function DiagramEditorInner() {
|
||||
onNodesChange={handleNodesChange}
|
||||
onEdgesChange={handleEdgesChange}
|
||||
onConnect={onConnect}
|
||||
onReconnect={onReconnect}
|
||||
onNodeSelect={setSelectedNodeId}
|
||||
onEdgeSelect={setSelectedEdgeId}
|
||||
onDrop={onDrop}
|
||||
@@ -576,10 +843,24 @@ function DiagramEditorInner() {
|
||||
onNodeContextMenu={handleNodeContextMenu}
|
||||
onPaneContextMenu={handlePaneContextMenu}
|
||||
onPaneClick={closeContextMenu}
|
||||
interactionMode={interactionMode}
|
||||
/>
|
||||
{interactionMode === 'connect' && (
|
||||
<div className="pointer-events-none absolute left-1/2 top-4 z-10 -translate-x-1/2 rounded-full border border-accent/30 bg-card/95 px-3 py-1.5 text-[11px] text-muted-foreground">
|
||||
Connect mode: drag between device handles. Middle-click and drag to pan.
|
||||
</div>
|
||||
)}
|
||||
{nodes.length === 0 && !loading && (
|
||||
<CanvasEmptyPrompt onGenerate={handleAIGenerate} />
|
||||
)}
|
||||
{/* Keyboard shortcut hint button — above the MiniMap */}
|
||||
<button
|
||||
onClick={() => setShowShortcuts(true)}
|
||||
title="Keyboard shortcuts (?)"
|
||||
className="absolute bottom-[175px] right-3 z-10 flex h-6 w-6 items-center justify-center rounded-full border border-default bg-card text-[11px] font-semibold text-muted-foreground hover:border-accent hover:text-accent transition-colors"
|
||||
>
|
||||
?
|
||||
</button>
|
||||
</div>
|
||||
{nodes.length > 0 && (
|
||||
<AIAssistPanel
|
||||
@@ -599,6 +880,21 @@ function DiagramEditorInner() {
|
||||
onSendToBack={handleSendToBack}
|
||||
onDeleteNode={handleDeleteNode}
|
||||
onDeleteEdge={handleDeleteEdge}
|
||||
selectedNodeCount={nodes.filter(n => n.selected).length}
|
||||
onAlignLeft={diagramCommands.alignLeft}
|
||||
onAlignRight={diagramCommands.alignRight}
|
||||
onAlignCenterH={diagramCommands.alignCenterH}
|
||||
onAlignTop={diagramCommands.alignTop}
|
||||
onAlignBottom={diagramCommands.alignBottom}
|
||||
onAlignCenterV={diagramCommands.alignCenterV}
|
||||
onDistributeH={diagramCommands.distributeHorizontally}
|
||||
onDistributeV={diagramCommands.distributeVertically}
|
||||
canAlign={diagramCommands.canAlign}
|
||||
canDistribute={diagramCommands.canDistribute}
|
||||
canGroup={diagramCommands.canGroup}
|
||||
canUngroup={diagramCommands.canUngroup}
|
||||
onGroupSelection={diagramCommands.groupSelection}
|
||||
onUngroupSelection={diagramCommands.ungroupSelection}
|
||||
/>
|
||||
</div>
|
||||
{contextMenu && (
|
||||
@@ -626,6 +922,20 @@ function DiagramEditorInner() {
|
||||
})
|
||||
}
|
||||
onClose={closeContextMenu}
|
||||
onAlignLeft={diagramCommands.alignLeft}
|
||||
onAlignRight={diagramCommands.alignRight}
|
||||
onAlignCenterH={diagramCommands.alignCenterH}
|
||||
onAlignTop={diagramCommands.alignTop}
|
||||
onAlignBottom={diagramCommands.alignBottom}
|
||||
onAlignCenterV={diagramCommands.alignCenterV}
|
||||
onDistributeH={diagramCommands.distributeHorizontally}
|
||||
onDistributeV={diagramCommands.distributeVertically}
|
||||
canAlign={contextMenu.type === 'node' ? diagramCommands.canAlign : false}
|
||||
canDistribute={contextMenu.type === 'node' ? diagramCommands.canDistribute : false}
|
||||
onGroupSelection={diagramCommands.groupSelection}
|
||||
onUngroupSelection={diagramCommands.ungroupSelection}
|
||||
canGroup={contextMenu?.type === 'node' ? diagramCommands.canGroup : false}
|
||||
canUngroup={contextMenu?.type === 'node' ? diagramCommands.canUngroup : false}
|
||||
/>
|
||||
)}
|
||||
{pendingDeleteNodeId && (
|
||||
@@ -647,6 +957,16 @@ function DiagramEditorInner() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<input
|
||||
ref={drawioImportRef}
|
||||
type="file"
|
||||
accept=".drawio,.xml"
|
||||
className="hidden"
|
||||
onChange={handleDrawioFileChange}
|
||||
/>
|
||||
{showShortcuts && (
|
||||
<KeyboardShortcutsOverlay onClose={() => setShowShortcuts(false)} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect, useCallback, useMemo, useRef } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Plus, Search, Network, MoreHorizontal, Upload, ChevronDown } from 'lucide-react'
|
||||
import { Plus, Search, Network, MoreHorizontal, Upload, ChevronDown, FileJson, FileOutput, ExternalLink, Copy, Archive } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { networkDiagramsApi } from '@/api'
|
||||
import { toast } from '@/lib/toast'
|
||||
@@ -39,7 +39,10 @@ export default function NetworkDiagramsPage() {
|
||||
const [clientSearch, setClientSearch] = useState('')
|
||||
const [menuOpenId, setMenuOpenId] = useState<string | null>(null)
|
||||
const [confirmArchiveId, setConfirmArchiveId] = useState<string | null>(null)
|
||||
const [importMenuOpen, setImportMenuOpen] = useState(false)
|
||||
const clientDropdownRef = useRef<HTMLDivElement>(null)
|
||||
const importMenuRef = useRef<HTMLDivElement>(null)
|
||||
const drawioListImportRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!clientDropdownOpen) return
|
||||
@@ -52,6 +55,17 @@ export default function NetworkDiagramsPage() {
|
||||
return () => document.removeEventListener('mousedown', handleClick)
|
||||
}, [clientDropdownOpen])
|
||||
|
||||
useEffect(() => {
|
||||
if (!importMenuOpen) return
|
||||
const handleClick = (e: MouseEvent) => {
|
||||
if (importMenuRef.current && !importMenuRef.current.contains(e.target as Node)) {
|
||||
setImportMenuOpen(false)
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handleClick)
|
||||
return () => document.removeEventListener('mousedown', handleClick)
|
||||
}, [importMenuOpen])
|
||||
|
||||
const loadDiagrams = useCallback(async () => {
|
||||
try {
|
||||
const params: Record<string, string> = {}
|
||||
@@ -129,6 +143,35 @@ export default function NetworkDiagramsPage() {
|
||||
input.click()
|
||||
}, [navigate])
|
||||
|
||||
const handleListDrawioImport = useCallback(async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
e.target.value = ''
|
||||
try {
|
||||
const { parseDrawioXml } = await import('@/lib/drawio-import')
|
||||
const text = await file.text()
|
||||
const { nodes: importedNodes, edges: importedEdges, warnings } = parseDrawioXml(text)
|
||||
const result = await networkDiagramsApi.importJson({
|
||||
schemaVersion: 1,
|
||||
name: file.name.replace(/\.drawio$/i, '') || 'Imported Diagram',
|
||||
client_name: null,
|
||||
description: null,
|
||||
nodes: importedNodes,
|
||||
edges: importedEdges,
|
||||
})
|
||||
const allWarnings = [...warnings, ...result.warnings]
|
||||
if (allWarnings.length > 0) {
|
||||
toast.warning(`Imported with ${allWarnings.length} warning(s)`)
|
||||
} else {
|
||||
toast.success('Imported successfully')
|
||||
}
|
||||
navigate(`/network-diagrams/${result.diagram.id}`)
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'Unknown error'
|
||||
toast.error(`Import failed: ${msg}`)
|
||||
}
|
||||
}, [navigate])
|
||||
|
||||
const formatDate = (dateStr: string) => {
|
||||
return new Date(dateStr).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
|
||||
}
|
||||
@@ -141,13 +184,48 @@ export default function NetworkDiagramsPage() {
|
||||
<p className="mt-1 text-sm text-muted-foreground">Visual network topology documentation for your clients</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={handleImport}
|
||||
className="flex items-center gap-1.5 rounded border border-default px-3 py-2 text-sm text-primary hover:border-hover"
|
||||
>
|
||||
<Upload size={14} />
|
||||
Import
|
||||
</button>
|
||||
{/* Single "Import" dropdown replacing two separate buttons */}
|
||||
<div className="relative" ref={importMenuRef}>
|
||||
<button
|
||||
onClick={() => setImportMenuOpen(prev => !prev)}
|
||||
className="flex items-center gap-1.5 rounded border border-default px-3 py-2 text-sm text-primary hover:border-hover"
|
||||
>
|
||||
<Upload size={14} />
|
||||
Import
|
||||
<ChevronDown size={12} className="text-muted-foreground" />
|
||||
</button>
|
||||
{importMenuOpen && (
|
||||
<div className="absolute right-0 top-full z-50 mt-1 w-44 rounded border border-default bg-card py-1 shadow-lg">
|
||||
<button
|
||||
onClick={() => { setImportMenuOpen(false); handleImport() }}
|
||||
className="flex w-full items-center gap-2 px-3 py-2 text-xs text-primary hover:bg-elevated"
|
||||
>
|
||||
<FileJson size={13} />
|
||||
<div className="text-left">
|
||||
<div>Import JSON</div>
|
||||
<div className="text-[10px] text-muted-foreground">ResolutionFlow format</div>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setImportMenuOpen(false); drawioListImportRef.current?.click() }}
|
||||
className="flex w-full items-center gap-2 px-3 py-2 text-xs text-primary hover:bg-elevated"
|
||||
>
|
||||
<FileOutput size={13} />
|
||||
<div className="text-left">
|
||||
<div>Import draw.io</div>
|
||||
<div className="text-[10px] text-muted-foreground">.drawio or .xml file</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<input
|
||||
ref={drawioListImportRef}
|
||||
type="file"
|
||||
accept=".drawio,.xml"
|
||||
className="hidden"
|
||||
onChange={handleListDrawioImport}
|
||||
/>
|
||||
<button
|
||||
onClick={() => navigate('/network-diagrams/new')}
|
||||
className="flex items-center gap-1.5 rounded bg-accent px-4 py-2 text-sm font-medium text-white hover:bg-accent/90"
|
||||
@@ -222,16 +300,111 @@ export default function NetworkDiagramsPage() {
|
||||
)}
|
||||
|
||||
{!loading && diagrams.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center py-20">
|
||||
<Network size={48} className="mb-4 text-muted-foreground" />
|
||||
<h2 className="font-heading text-lg font-semibold text-heading">No network maps yet</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">Create your first network diagram to get started</p>
|
||||
<button
|
||||
onClick={() => navigate('/network-diagrams/new')}
|
||||
className="mt-4 rounded bg-accent px-4 py-2 text-sm font-medium text-white hover:bg-accent/90"
|
||||
>
|
||||
Create First Diagram
|
||||
</button>
|
||||
<div className="rounded-lg border border-default bg-card overflow-hidden">
|
||||
<div className="grid md:grid-cols-[1fr_380px]">
|
||||
{/* Left: mini topology preview */}
|
||||
<div className="relative flex items-center justify-center bg-[#0e1016] p-8 md:p-12 min-h-[280px]">
|
||||
{/* Dot grid background */}
|
||||
<svg className="absolute inset-0 h-full w-full opacity-20" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<pattern id="dots" x="0" y="0" width="24" height="24" patternUnits="userSpaceOnUse">
|
||||
<circle cx="1" cy="1" r="1" fill="#4f5666" />
|
||||
</pattern>
|
||||
</defs>
|
||||
<rect width="100%" height="100%" fill="url(#dots)" />
|
||||
</svg>
|
||||
{/* Static topology SVG */}
|
||||
<svg viewBox="0 0 460 240" className="relative w-full max-w-md" xmlns="http://www.w3.org/2000/svg">
|
||||
{/* Edges */}
|
||||
<line x1="230" y1="48" x2="130" y2="130" stroke="#2a2e3a" strokeWidth="1.5" />
|
||||
<line x1="230" y1="48" x2="230" y2="130" stroke="#2a2e3a" strokeWidth="1.5" />
|
||||
<line x1="230" y1="48" x2="330" y2="130" stroke="#2a2e3a" strokeWidth="1.5" />
|
||||
<line x1="130" y1="130" x2="80" y2="210" stroke="#2a2e3a" strokeWidth="1.5" strokeDasharray="4 3" />
|
||||
<line x1="130" y1="130" x2="180" y2="210" stroke="#2a2e3a" strokeWidth="1.5" strokeDasharray="4 3" />
|
||||
<line x1="330" y1="130" x2="280" y2="210" stroke="#2a2e3a" strokeWidth="1.5" strokeDasharray="4 3" />
|
||||
<line x1="330" y1="130" x2="380" y2="210" stroke="#2a2e3a" strokeWidth="1.5" strokeDasharray="4 3" />
|
||||
{/* Firewall node */}
|
||||
<rect x="196" y="16" width="68" height="40" rx="6" fill="#1e2028" stroke="#3d4252" strokeWidth="1" />
|
||||
<rect x="207" y="22" width="14" height="10" rx="2" fill="#f87171" opacity="0.9" />
|
||||
<rect x="225" y="22" width="6" height="10" rx="1" fill="#3d4252" />
|
||||
<rect x="235" y="22" width="20" height="10" rx="2" fill="#3d4252" />
|
||||
<text x="230" y="46" textAnchor="middle" fill="#848b9b" fontSize="9" fontFamily="monospace">Firewall</text>
|
||||
{/* Switch node */}
|
||||
<rect x="96" y="108" width="68" height="40" rx="6" fill="#1e2028" stroke="#3d4252" strokeWidth="1" />
|
||||
<circle cx="112" cy="124" r="4" fill="#34d399" opacity="0.9" />
|
||||
<circle cx="122" cy="124" r="4" fill="#34d399" opacity="0.9" />
|
||||
<circle cx="132" cy="124" r="4" fill="#fbbf24" opacity="0.8" />
|
||||
<circle cx="142" cy="124" r="4" fill="#34d399" opacity="0.9" />
|
||||
<text x="130" y="140" textAnchor="middle" fill="#848b9b" fontSize="9" fontFamily="monospace">Core Switch</text>
|
||||
{/* Router node */}
|
||||
<rect x="196" y="108" width="68" height="40" rx="6" fill="#1e2028" stroke="#60a5fa" strokeWidth="1" />
|
||||
<circle cx="230" cy="124" r="10" fill="none" stroke="#60a5fa" strokeWidth="1.5" opacity="0.7" />
|
||||
<circle cx="230" cy="124" r="5" fill="none" stroke="#60a5fa" strokeWidth="1" opacity="0.5" />
|
||||
<line x1="220" y1="124" x2="240" y2="124" stroke="#60a5fa" strokeWidth="1" opacity="0.6" />
|
||||
<text x="230" y="140" textAnchor="middle" fill="#93c5fd" fontSize="9" fontFamily="monospace">Router</text>
|
||||
{/* Server farm */}
|
||||
<rect x="296" y="108" width="68" height="40" rx="6" fill="#1e2028" stroke="#3d4252" strokeWidth="1" />
|
||||
<rect x="308" y="116" width="44" height="7" rx="2" fill="#2a2e3a" />
|
||||
<rect x="308" y="127" width="44" height="7" rx="2" fill="#2a2e3a" />
|
||||
<circle cx="345" cy="119.5" r="2" fill="#34d399" opacity="0.9" />
|
||||
<circle cx="345" cy="130.5" r="2" fill="#34d399" opacity="0.9" />
|
||||
<text x="330" y="140" textAnchor="middle" fill="#848b9b" fontSize="9" fontFamily="monospace">Servers</text>
|
||||
{/* Leaf nodes */}
|
||||
<rect x="46" y="190" width="52" height="34" rx="5" fill="#1e2028" stroke="#2a2e3a" strokeWidth="1" />
|
||||
<text x="72" y="211" textAnchor="middle" fill="#4f5666" fontSize="8" fontFamily="monospace">PC × 12</text>
|
||||
<rect x="154" y="190" width="52" height="34" rx="5" fill="#1e2028" stroke="#2a2e3a" strokeWidth="1" />
|
||||
<text x="180" y="211" textAnchor="middle" fill="#4f5666" fontSize="8" fontFamily="monospace">AP × 4</text>
|
||||
<rect x="254" y="190" width="52" height="34" rx="5" fill="#1e2028" stroke="#2a2e3a" strokeWidth="1" />
|
||||
<text x="280" y="211" textAnchor="middle" fill="#4f5666" fontSize="8" fontFamily="monospace">NAS</text>
|
||||
<rect x="354" y="190" width="52" height="34" rx="5" fill="#1e2028" stroke="#2a2e3a" strokeWidth="1" />
|
||||
<text x="380" y="211" textAnchor="middle" fill="#4f5666" fontSize="8" fontFamily="monospace">VM × 6</text>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* Right: value prop + CTA */}
|
||||
<div className="flex flex-col justify-center border-l border-default p-8">
|
||||
<div className="mb-1 flex items-center gap-2">
|
||||
<Network size={14} className="text-accent" />
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wider text-accent">Network Maps</span>
|
||||
</div>
|
||||
<h2 className="font-heading text-xl font-bold text-heading leading-snug">
|
||||
Document every client's infrastructure — once
|
||||
</h2>
|
||||
<p className="mt-3 text-sm leading-relaxed text-muted-foreground">
|
||||
Drag-and-drop topology diagrams that live next to your tickets. Generate a first draft from a plain-text description, then keep it up to date as networks change.
|
||||
</p>
|
||||
<ul className="mt-4 space-y-2 text-xs text-muted-foreground">
|
||||
<li className="flex items-center gap-2">
|
||||
<div className="h-1.5 w-1.5 rounded-full bg-accent" />
|
||||
AI topology generation from natural language
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<div className="h-1.5 w-1.5 rounded-full bg-accent" />
|
||||
Export to PNG, SVG, PDF, or draw.io
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<div className="h-1.5 w-1.5 rounded-full bg-accent" />
|
||||
Shared across your whole team instantly
|
||||
</li>
|
||||
</ul>
|
||||
<div className="mt-6 flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||
<button
|
||||
onClick={() => navigate('/network-diagrams/new')}
|
||||
className="flex items-center justify-center gap-1.5 rounded bg-accent px-4 py-2 text-sm font-medium text-white hover:bg-accent/90"
|
||||
>
|
||||
<Plus size={14} />
|
||||
Create Network Map
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setImportMenuOpen(true) }}
|
||||
className="flex items-center justify-center gap-1.5 rounded border border-default px-4 py-2 text-sm text-primary hover:border-hover"
|
||||
>
|
||||
<Upload size={14} />
|
||||
Import existing
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -260,6 +433,29 @@ export default function NetworkDiagramsPage() {
|
||||
{d.description && (
|
||||
<p className="mb-3 line-clamp-2 text-xs text-muted-foreground">{d.description}</p>
|
||||
)}
|
||||
{/* Thumbnail preview */}
|
||||
{d.thumbnail_url ? (
|
||||
<div className="mb-2 overflow-hidden rounded border border-default">
|
||||
<img
|
||||
src={d.thumbnail_url}
|
||||
alt={d.name}
|
||||
className="h-[120px] w-full object-cover"
|
||||
onError={e => { (e.target as HTMLImageElement).style.display = 'none' }}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="relative mb-2 flex h-[120px] items-center justify-center overflow-hidden rounded border border-default bg-[#0e1016]">
|
||||
<svg className="absolute inset-0 h-full w-full opacity-30" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<pattern id={`dots-${d.id}`} x="0" y="0" width="20" height="20" patternUnits="userSpaceOnUse">
|
||||
<circle cx="1" cy="1" r="0.8" fill="#4f5666" />
|
||||
</pattern>
|
||||
</defs>
|
||||
<rect width="100%" height="100%" fill={`url(#dots-${d.id})`} />
|
||||
</svg>
|
||||
<Network size={24} className="relative text-muted-foreground/20" />
|
||||
</div>
|
||||
)}
|
||||
{d.node_count > 0 && (
|
||||
<div className="mb-2">
|
||||
<TopologyBar categoryCounts={d.category_counts} nodeCount={d.node_count} />
|
||||
@@ -294,20 +490,24 @@ export default function NetworkDiagramsPage() {
|
||||
<>
|
||||
<button
|
||||
onClick={e => { e.stopPropagation(); navigate(`/network-diagrams/${d.id}`) }}
|
||||
className="w-full px-3 py-1.5 text-left text-xs text-primary hover:bg-elevated"
|
||||
className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-xs text-primary hover:bg-elevated"
|
||||
>
|
||||
<ExternalLink size={12} className="text-muted-foreground" />
|
||||
Open
|
||||
</button>
|
||||
<button
|
||||
onClick={e => { e.stopPropagation(); handleDuplicate(d.id) }}
|
||||
className="w-full px-3 py-1.5 text-left text-xs text-primary hover:bg-elevated"
|
||||
className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-xs text-primary hover:bg-elevated"
|
||||
>
|
||||
<Copy size={12} className="text-muted-foreground" />
|
||||
Duplicate
|
||||
</button>
|
||||
<div className="my-1 border-t border-default" />
|
||||
<button
|
||||
onClick={e => { e.stopPropagation(); setConfirmArchiveId(d.id) }}
|
||||
className="w-full px-3 py-1.5 text-left text-xs text-red-400 hover:bg-elevated"
|
||||
className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-xs text-red-400 hover:bg-elevated"
|
||||
>
|
||||
<Archive size={12} />
|
||||
Archive…
|
||||
</button>
|
||||
</>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useAuthStore } from '@/store/authStore'
|
||||
import { StartSessionInput } from '@/components/dashboard/StartSessionInput'
|
||||
import { PendingEscalations } from '@/components/dashboard/PendingEscalations'
|
||||
import { ActiveFlowPilotSessions } from '@/components/dashboard/ActiveFlowPilotSessions'
|
||||
import { TicketQueue } from '@/components/dashboard/TicketQueue'
|
||||
import { PerformanceCards } from '@/components/dashboard/PerformanceCards'
|
||||
import { KnowledgeBaseCards } from '@/components/dashboard/KnowledgeBaseCards'
|
||||
import { TeamSummary } from '@/components/dashboard/TeamSummary'
|
||||
@@ -59,6 +60,11 @@ export function QuickStartPage() {
|
||||
<ActiveFlowPilotSessions />
|
||||
</div>
|
||||
|
||||
{/* Ticket Queue (auto-hides if no PSA connection) */}
|
||||
<div className="mt-8">
|
||||
<TicketQueue />
|
||||
</div>
|
||||
|
||||
{/* Dashboard — always visible */}
|
||||
<div className="mt-10">
|
||||
<SectionLabel>Dashboard</SectionLabel>
|
||||
|
||||
@@ -648,10 +648,12 @@ function MemberMappingTab({ connection }: { connection: PsaConnectionResponse |
|
||||
setCwMembers(members)
|
||||
setMappings(existingMappings)
|
||||
|
||||
// Build local mapping state from existing mappings
|
||||
// Build local mapping state from existing mappings (skip unmapped entries)
|
||||
const lookup: Record<string, { external_member_id: string; external_member_name: string }> = {}
|
||||
for (const m of existingMappings) {
|
||||
lookup[m.user_id] = { external_member_id: m.external_member_id, external_member_name: m.external_member_name }
|
||||
if (m.external_member_id) {
|
||||
lookup[m.user_id] = { external_member_id: m.external_member_id, external_member_name: m.external_member_name ?? '' }
|
||||
}
|
||||
}
|
||||
setLocalMappings(lookup)
|
||||
setIsDirty(false)
|
||||
@@ -716,14 +718,11 @@ function MemberMappingTab({ connection }: { connection: PsaConnectionResponse |
|
||||
}
|
||||
}
|
||||
|
||||
// Derive user list from mappings response (all account users are returned)
|
||||
const userRows = mappings.length > 0
|
||||
// All account users — includes both mapped and unmapped
|
||||
const uniqueUsers = hasLoaded
|
||||
? mappings.map(m => ({ user_id: m.user_id, user_email: m.user_email, user_name: m.user_name, matched_by: m.matched_by }))
|
||||
: []
|
||||
|
||||
// Deduplicate: mappings may only contain mapped users, so we show what we have
|
||||
const uniqueUsers = hasLoaded ? userRows : []
|
||||
|
||||
if (!connection) {
|
||||
return (
|
||||
<div className="max-w-3xl">
|
||||
|
||||
637
frontend/src/pages/admin/AccountDetailPage.tsx
Normal file
637
frontend/src/pages/admin/AccountDetailPage.tsx
Normal file
@@ -0,0 +1,637 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useNavigate, useParams } from 'react-router-dom'
|
||||
import {
|
||||
ArrowLeft,
|
||||
Building2,
|
||||
CalendarClock,
|
||||
Check,
|
||||
Copy,
|
||||
Crown,
|
||||
Loader2,
|
||||
Mail,
|
||||
Pencil,
|
||||
UserCheck,
|
||||
UserPlus,
|
||||
UserX,
|
||||
X,
|
||||
} from 'lucide-react'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { Input } from '@/components/ui/Input'
|
||||
import { Modal } from '@/components/common/Modal'
|
||||
import { EmptyState, StatusBadge } from '@/components/admin'
|
||||
import { ConfirmButton } from '@/components/common/ConfirmButton'
|
||||
import { adminApi } from '@/api/admin'
|
||||
import { toast } from '@/lib/toast'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { AdminAccountDetailResponse, AdminAccountMember } from '@/types/admin'
|
||||
|
||||
function formatDate(value: string | null) {
|
||||
if (!value) return 'Never'
|
||||
return new Date(value).toLocaleDateString()
|
||||
}
|
||||
|
||||
export function AccountDetailPage() {
|
||||
const { accountId } = useParams<{ accountId: string }>()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const [account, setAccount] = useState<AdminAccountDetailResponse | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [isEditingName, setIsEditingName] = useState(false)
|
||||
const [editedName, setEditedName] = useState('')
|
||||
const [savingName, setSavingName] = useState(false)
|
||||
|
||||
const [showCreateUserModal, setShowCreateUserModal] = useState(false)
|
||||
const [createForm, setCreateForm] = useState({
|
||||
email: '',
|
||||
name: '',
|
||||
account_role: 'engineer' as 'owner' | 'admin' | 'engineer' | 'viewer',
|
||||
send_email: true,
|
||||
})
|
||||
const [createLoading, setCreateLoading] = useState(false)
|
||||
const [tempPassword, setTempPassword] = useState<string | null>(null)
|
||||
const [copiedPassword, setCopiedPassword] = useState(false)
|
||||
|
||||
const [showInviteModal, setShowInviteModal] = useState(false)
|
||||
const [inviteForm, setInviteForm] = useState({
|
||||
email: '',
|
||||
role: 'engineer' as 'engineer' | 'viewer',
|
||||
})
|
||||
const [inviteLoading, setInviteLoading] = useState(false)
|
||||
|
||||
const [editingPlan, setEditingPlan] = useState(false)
|
||||
const [selectedPlan, setSelectedPlan] = useState('free')
|
||||
const [planSaving, setPlanSaving] = useState(false)
|
||||
|
||||
const [editingTrial, setEditingTrial] = useState(false)
|
||||
const [trialDays, setTrialDays] = useState('14')
|
||||
const [trialSaving, setTrialSaving] = useState(false)
|
||||
|
||||
const loadAccount = useCallback(async () => {
|
||||
if (!accountId) return
|
||||
setLoading(true)
|
||||
try {
|
||||
const data = await adminApi.getAccountDetail(accountId)
|
||||
setAccount(data)
|
||||
setEditedName(data.name)
|
||||
setSelectedPlan(data.subscription?.plan ?? 'free')
|
||||
} catch {
|
||||
toast.error('Failed to load account')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [accountId])
|
||||
|
||||
useEffect(() => {
|
||||
loadAccount()
|
||||
}, [loadAccount])
|
||||
|
||||
const handleSaveName = async () => {
|
||||
if (!account || !editedName.trim() || editedName.trim() === account.name) {
|
||||
setIsEditingName(false)
|
||||
return
|
||||
}
|
||||
setSavingName(true)
|
||||
try {
|
||||
const updated = await adminApi.updateAccount(account.id, { name: editedName.trim() })
|
||||
setAccount(updated)
|
||||
setEditedName(updated.name)
|
||||
setIsEditingName(false)
|
||||
toast.success('Account updated')
|
||||
} catch {
|
||||
toast.error('Failed to update account')
|
||||
} finally {
|
||||
setSavingName(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCreateUser = async () => {
|
||||
if (!account || !createForm.email || !createForm.name) return
|
||||
setCreateLoading(true)
|
||||
try {
|
||||
const result = await adminApi.createUser({
|
||||
email: createForm.email,
|
||||
name: createForm.name,
|
||||
account_mode: 'existing',
|
||||
account_display_code: account.display_code,
|
||||
account_role: createForm.account_role,
|
||||
send_email: createForm.send_email,
|
||||
})
|
||||
setShowCreateUserModal(false)
|
||||
setCreateForm({ email: '', name: '', account_role: 'engineer' as 'owner' | 'admin' | 'engineer' | 'viewer', send_email: true })
|
||||
setTempPassword(result.temporary_password)
|
||||
setCopiedPassword(false)
|
||||
toast.success(result.email_sent ? 'User created and welcome email sent' : 'User created')
|
||||
loadAccount()
|
||||
} catch (err: unknown) {
|
||||
if (err && typeof err === 'object' && 'response' in err) {
|
||||
const axiosErr = err as { response?: { data?: { detail?: string } } }
|
||||
toast.error(axiosErr.response?.data?.detail || 'Failed to create user')
|
||||
} else {
|
||||
toast.error('Failed to create user')
|
||||
}
|
||||
} finally {
|
||||
setCreateLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleInviteUser = async () => {
|
||||
if (!account || !inviteForm.email) return
|
||||
setInviteLoading(true)
|
||||
try {
|
||||
await adminApi.createInvite({
|
||||
email: inviteForm.email,
|
||||
account_display_code: account.display_code,
|
||||
role: inviteForm.role,
|
||||
})
|
||||
toast.success('Invite sent')
|
||||
setInviteForm({ email: '', role: 'engineer' })
|
||||
setShowInviteModal(false)
|
||||
loadAccount()
|
||||
} catch (err: unknown) {
|
||||
if (err && typeof err === 'object' && 'response' in err) {
|
||||
const axiosErr = err as { response?: { data?: { detail?: string } } }
|
||||
toast.error(axiosErr.response?.data?.detail || 'Failed to send invite')
|
||||
} else {
|
||||
toast.error('Failed to send invite')
|
||||
}
|
||||
} finally {
|
||||
setInviteLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleUpdateMemberRole = async (member: AdminAccountMember, nextRole: string) => {
|
||||
try {
|
||||
await adminApi.updateAccountRole(member.id, nextRole)
|
||||
toast.success(`Updated ${member.name}`)
|
||||
loadAccount()
|
||||
} catch {
|
||||
toast.error('Failed to update account role')
|
||||
}
|
||||
}
|
||||
|
||||
const handleToggleActive = async (member: AdminAccountMember) => {
|
||||
try {
|
||||
if (member.is_active) {
|
||||
await adminApi.deactivateUser(member.id)
|
||||
toast.success('User deactivated')
|
||||
} else {
|
||||
await adminApi.activateUser(member.id)
|
||||
toast.success('User activated')
|
||||
}
|
||||
loadAccount()
|
||||
} catch {
|
||||
toast.error('Failed to update user status')
|
||||
}
|
||||
}
|
||||
|
||||
const handleUpdatePlan = async () => {
|
||||
if (!account) return
|
||||
setPlanSaving(true)
|
||||
try {
|
||||
await adminApi.updateAccountSubscriptionPlan(account.id, selectedPlan)
|
||||
toast.success(`Plan updated to ${selectedPlan}`)
|
||||
setEditingPlan(false)
|
||||
loadAccount()
|
||||
} catch {
|
||||
toast.error('Failed to update plan')
|
||||
} finally {
|
||||
setPlanSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleExtendTrial = async () => {
|
||||
if (!account || !trialDays) return
|
||||
setTrialSaving(true)
|
||||
try {
|
||||
await adminApi.extendAccountTrial(account.id, parseInt(trialDays, 10))
|
||||
toast.success(`Trial updated by ${trialDays} days`)
|
||||
setEditingTrial(false)
|
||||
loadAccount()
|
||||
} catch {
|
||||
toast.error('Failed to update trial')
|
||||
} finally {
|
||||
setTrialSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const copyDisplayCode = async () => {
|
||||
if (!account) return
|
||||
await navigator.clipboard.writeText(account.display_code)
|
||||
toast.success('Display code copied')
|
||||
}
|
||||
|
||||
const copyTempPassword = async () => {
|
||||
if (!tempPassword) return
|
||||
await navigator.clipboard.writeText(tempPassword)
|
||||
setCopiedPassword(true)
|
||||
setTimeout(() => setCopiedPassword(false), 2000)
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!account) {
|
||||
return (
|
||||
<EmptyState
|
||||
title="Account not found"
|
||||
description="This account may have been removed or is unavailable."
|
||||
action={<Button variant="secondary" onClick={() => navigate('/admin/accounts')}>Back to Accounts</Button>}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
onClick={() => navigate('/admin/accounts')}
|
||||
className="rounded-md border border-border p-2 text-muted-foreground hover:bg-elevated hover:text-foreground"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</button>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-3">
|
||||
<Building2 className="h-6 w-6 text-muted-foreground" />
|
||||
<h1 className="truncate text-2xl font-bold text-foreground">{account.name}</h1>
|
||||
<StatusBadge variant="default" title="Unique code for joining this account">{account.display_code}</StatusBadge>
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Manage account settings, subscription, invites, and users from one place.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<Button variant="secondary" onClick={() => setShowInviteModal(true)}>
|
||||
<Mail className="h-4 w-4" />
|
||||
Invite User
|
||||
</Button>
|
||||
<Button onClick={() => setShowCreateUserModal(true)}>
|
||||
<UserPlus className="h-4 w-4" />
|
||||
Create User
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-[1.1fr_0.9fr]">
|
||||
<section className="space-y-6">
|
||||
<div className="rounded-2xl border border-border bg-card p-5">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<h2 className="text-lg font-semibold text-foreground">Account Settings</h2>
|
||||
<Button variant="secondary" size="sm" onClick={copyDisplayCode}>
|
||||
<Copy className="h-4 w-4" />
|
||||
Copy Code
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 space-y-5">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-foreground">Account Name</label>
|
||||
{isEditingName ? (
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<Input value={editedName} onChange={(e) => setEditedName(e.target.value)} />
|
||||
<Button onClick={handleSaveName} loading={savingName} size="icon-sm">
|
||||
<Check className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon-sm"
|
||||
onClick={() => {
|
||||
setEditedName(account.name)
|
||||
setIsEditingName(false)
|
||||
}}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<span className="text-sm text-foreground">{account.name}</span>
|
||||
<button
|
||||
onClick={() => setIsEditingName(true)}
|
||||
className="rounded px-1.5 py-0.5 text-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="rounded-xl border border-border bg-card/50 p-4">
|
||||
<p className="text-xs uppercase tracking-[0.14em] text-muted-foreground">Owner</p>
|
||||
<p className="mt-2 text-sm text-foreground">{account.owner?.name ?? 'Unassigned'}</p>
|
||||
<p className="text-xs text-muted-foreground">{account.owner?.email ?? 'No owner user yet'}</p>
|
||||
</div>
|
||||
<div className="rounded-xl border border-border bg-card/50 p-4">
|
||||
<p className="text-xs uppercase tracking-[0.14em] text-muted-foreground">Created</p>
|
||||
<p className="mt-2 text-sm text-foreground">{formatDate(account.created_at)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border border-border bg-card p-5">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<h2 className="text-lg font-semibold text-foreground">Users</h2>
|
||||
<StatusBadge variant="default">{account.member_count} members</StatusBadge>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 space-y-3">
|
||||
{account.members.length > 0 ? (
|
||||
account.members.map((member) => (
|
||||
<div key={member.id} className="rounded-xl border border-border bg-card/50 p-4">
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<p className="font-medium text-foreground">{member.name}</p>
|
||||
<p className="text-sm text-muted-foreground">{member.email}</p>
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
<StatusBadge variant="default">{member.role}</StatusBadge>
|
||||
{member.account_role && <StatusBadge variant="default">{member.account_role}</StatusBadge>}
|
||||
<StatusBadge variant={member.is_active ? 'success' : 'destructive'}>
|
||||
{member.is_active ? 'Active' : 'Inactive'}
|
||||
</StatusBadge>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<select
|
||||
value={member.account_role ?? 'engineer'}
|
||||
onChange={(e) => handleUpdateMemberRole(member, e.target.value)}
|
||||
className={cn(
|
||||
'rounded-md border border-border bg-card px-3 py-2 text-sm text-foreground',
|
||||
'focus:outline-hidden focus:border-primary focus:ring-2 focus:ring-primary/20'
|
||||
)}
|
||||
>
|
||||
<option value="owner">Owner</option>
|
||||
<option value="admin">Admin</option>
|
||||
<option value="engineer">Engineer</option>
|
||||
<option value="viewer">Viewer</option>
|
||||
</select>
|
||||
{member.is_active ? (
|
||||
<ConfirmButton
|
||||
onConfirm={() => handleToggleActive(member)}
|
||||
confirmLabel="Confirm deactivate?"
|
||||
className="inline-flex items-center gap-1.5 rounded-md border border-border bg-card px-3 py-1.5 text-sm font-medium text-foreground transition-colors hover:bg-elevated"
|
||||
confirmClassName="inline-flex items-center rounded-md border border-danger/30 bg-danger-dim px-3 py-1.5 text-sm font-medium text-danger transition-colors"
|
||||
>
|
||||
<UserX className="h-4 w-4" />
|
||||
Deactivate
|
||||
</ConfirmButton>
|
||||
) : (
|
||||
<Button variant="secondary" size="sm" onClick={() => handleToggleActive(member)}>
|
||||
<UserCheck className="h-4 w-4" />
|
||||
Activate
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="secondary" size="sm" onClick={() => navigate(`/admin/users/${member.id}`)}>
|
||||
View User
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="rounded-xl border border-dashed border-border px-4 py-6 text-center text-sm text-muted-foreground">
|
||||
<p>No users yet.</p>
|
||||
<p className="mt-1">Use <strong className="text-foreground">Create User</strong> or <strong className="text-foreground">Invite User</strong> above to add members.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<aside className="space-y-6">
|
||||
<div className="rounded-2xl border border-border bg-card p-5">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h2 className="text-lg font-semibold text-foreground">Subscription</h2>
|
||||
{account.subscription ? (
|
||||
<div className="flex gap-2">
|
||||
<StatusBadge variant="default">{account.subscription.plan}</StatusBadge>
|
||||
<StatusBadge variant={account.subscription.status === 'active' ? 'success' : account.subscription.status === 'canceled' ? 'destructive' : 'warning'}>
|
||||
{account.subscription.status}
|
||||
</StatusBadge>
|
||||
</div>
|
||||
) : (
|
||||
<StatusBadge variant="warning">No subscription</StatusBadge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid gap-3 sm:grid-cols-2">
|
||||
<div className="rounded-xl border border-border bg-card/50 p-4">
|
||||
<p className="text-xs uppercase tracking-[0.14em] text-muted-foreground">Renewal</p>
|
||||
<p className="mt-2 text-sm text-foreground">{formatDate(account.subscription?.current_period_end ?? null)}</p>
|
||||
</div>
|
||||
<div className="rounded-xl border border-border bg-card/50 p-4">
|
||||
<p className="text-xs uppercase tracking-[0.14em] text-muted-foreground">Usage</p>
|
||||
<p className="mt-2 text-sm text-foreground">{account.usage.tree_count} flows · {account.usage.session_count_this_month} sessions</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{editingPlan ? (
|
||||
<div className="mt-4 flex items-center gap-2">
|
||||
<select
|
||||
value={selectedPlan}
|
||||
onChange={(e) => setSelectedPlan(e.target.value)}
|
||||
className={cn(
|
||||
'h-9 rounded-md border border-border bg-card px-3 text-sm text-foreground',
|
||||
'focus:outline-hidden focus:border-primary focus:ring-2 focus:ring-primary/20'
|
||||
)}
|
||||
>
|
||||
<option value="free">Free</option>
|
||||
<option value="pro">Pro</option>
|
||||
<option value="team">Team</option>
|
||||
</select>
|
||||
<Button size="sm" onClick={handleUpdatePlan} loading={planSaving}>Save</Button>
|
||||
<Button variant="secondary" size="sm" onClick={() => setEditingPlan(false)}>Cancel</Button>
|
||||
</div>
|
||||
) : editingTrial ? (
|
||||
<div className="mt-4 flex items-center gap-2">
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={90}
|
||||
value={trialDays}
|
||||
onChange={(e) => setTrialDays(e.target.value)}
|
||||
className="w-24"
|
||||
placeholder="Days"
|
||||
/>
|
||||
<Button size="sm" onClick={handleExtendTrial} loading={trialSaving}>Save</Button>
|
||||
<Button variant="secondary" size="sm" onClick={() => setEditingTrial(false)}>Cancel</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setSelectedPlan(account.subscription?.plan ?? 'free')
|
||||
setEditingPlan(true)
|
||||
}}
|
||||
>
|
||||
<Crown className="h-4 w-4" />
|
||||
Change Plan
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setTrialDays('14')
|
||||
setEditingTrial(true)
|
||||
}}
|
||||
>
|
||||
<CalendarClock className="h-4 w-4" />
|
||||
Extend Trial
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border border-border bg-card p-5">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h2 className="text-lg font-semibold text-foreground">Pending Invites</h2>
|
||||
{account.pending_invite_count > 0 && (
|
||||
<StatusBadge variant="warning">{account.pending_invite_count} pending</StatusBadge>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-4 space-y-3">
|
||||
{account.invites.length > 0 ? (
|
||||
account.invites.map((invite) => (
|
||||
<div key={invite.id} className="rounded-xl border border-border bg-card/50 p-4">
|
||||
<p className="font-medium text-foreground">{invite.email}</p>
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
<StatusBadge variant="default">{invite.role}</StatusBadge>
|
||||
<StatusBadge variant="default">Expires {formatDate(invite.expires_at)}</StatusBadge>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="rounded-xl border border-dashed border-border px-4 py-6 text-center text-sm text-muted-foreground">
|
||||
<p>No pending invites.</p>
|
||||
<p className="mt-1">Use <strong className="text-foreground">Invite User</strong> above to send an invitation.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
isOpen={showCreateUserModal}
|
||||
onClose={() => setShowCreateUserModal(false)}
|
||||
title="Create User in Account"
|
||||
size="sm"
|
||||
footer={(
|
||||
<div className="flex justify-end gap-3">
|
||||
<Button variant="secondary" onClick={() => setShowCreateUserModal(false)}>Cancel</Button>
|
||||
<Button onClick={handleCreateUser} disabled={!createForm.email || !createForm.name} loading={createLoading}>
|
||||
{createLoading ? 'Creating...' : 'Create User'}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-foreground">Name</label>
|
||||
<Input value={createForm.name} onChange={(e) => setCreateForm((f) => ({ ...f, name: e.target.value }))} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-foreground">Email</label>
|
||||
<Input type="email" value={createForm.email} onChange={(e) => setCreateForm((f) => ({ ...f, email: e.target.value }))} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-foreground">Account Role</label>
|
||||
<select
|
||||
value={createForm.account_role}
|
||||
onChange={(e) => setCreateForm((f) => ({ ...f, account_role: e.target.value as 'owner' | 'admin' | 'engineer' | 'viewer' }))}
|
||||
className={cn(
|
||||
'w-full rounded-md border border-border bg-card px-3 py-2 text-sm text-foreground',
|
||||
'focus:outline-hidden focus:border-primary focus:ring-2 focus:ring-primary/20'
|
||||
)}
|
||||
>
|
||||
<option value="owner">Owner</option>
|
||||
<option value="admin">Admin</option>
|
||||
<option value="engineer">Engineer</option>
|
||||
<option value="viewer">Viewer</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={createForm.send_email}
|
||||
onChange={(e) => setCreateForm((f) => ({ ...f, send_email: e.target.checked }))}
|
||||
className="rounded border-border bg-card"
|
||||
/>
|
||||
<label className="text-sm text-muted-foreground">Send welcome email with temporary password</label>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
isOpen={showInviteModal}
|
||||
onClose={() => setShowInviteModal(false)}
|
||||
title="Invite User to Account"
|
||||
size="sm"
|
||||
footer={(
|
||||
<div className="flex justify-end gap-3">
|
||||
<Button variant="secondary" onClick={() => setShowInviteModal(false)}>Cancel</Button>
|
||||
<Button onClick={handleInviteUser} disabled={!inviteForm.email} loading={inviteLoading}>
|
||||
{inviteLoading ? 'Sending...' : 'Send Invite'}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-foreground">Email</label>
|
||||
<Input type="email" value={inviteForm.email} onChange={(e) => setInviteForm((f) => ({ ...f, email: e.target.value }))} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-foreground">Role</label>
|
||||
<select
|
||||
value={inviteForm.role}
|
||||
onChange={(e) => setInviteForm((f) => ({ ...f, role: e.target.value as 'engineer' | 'viewer' }))}
|
||||
className={cn(
|
||||
'w-full rounded-md border border-border bg-card px-3 py-2 text-sm text-foreground',
|
||||
'focus:outline-hidden focus:border-primary focus:ring-2 focus:ring-primary/20'
|
||||
)}
|
||||
>
|
||||
<option value="engineer">Engineer</option>
|
||||
<option value="viewer">Viewer</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
isOpen={!!tempPassword}
|
||||
onClose={() => setTempPassword(null)}
|
||||
title="User Created"
|
||||
size="sm"
|
||||
footer={<div className="flex justify-end"><Button onClick={() => setTempPassword(null)}>Done</Button></div>}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-xl border border-yellow-400/20 bg-yellow-400/10 p-3 text-sm text-yellow-400">
|
||||
This password will not be shown again. Copy it now.
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="flex-1 rounded-md border border-border bg-card px-3 py-2 font-mono text-sm text-foreground">
|
||||
{tempPassword}
|
||||
</code>
|
||||
<button
|
||||
onClick={copyTempPassword}
|
||||
className="rounded-md border border-border p-2 text-muted-foreground transition-colors hover:bg-elevated hover:text-foreground"
|
||||
>
|
||||
{copiedPassword ? <Check className="h-4 w-4 text-green-400" /> : <Copy className="h-4 w-4" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default AccountDetailPage
|
||||
841
frontend/src/pages/admin/AccountsPage.tsx
Normal file
841
frontend/src/pages/admin/AccountsPage.tsx
Normal file
@@ -0,0 +1,841 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import {
|
||||
Building2,
|
||||
Check,
|
||||
Copy,
|
||||
ExternalLink,
|
||||
Loader2,
|
||||
Mail,
|
||||
Plus,
|
||||
Search,
|
||||
Sparkles,
|
||||
UserPlus,
|
||||
} from 'lucide-react'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { Input } from '@/components/ui/Input'
|
||||
import {
|
||||
DataTable,
|
||||
EmptyState,
|
||||
PageHeader,
|
||||
Pagination,
|
||||
SearchInput,
|
||||
StatusBadge,
|
||||
ActionMenu,
|
||||
type Column,
|
||||
} from '@/components/admin'
|
||||
import { Modal } from '@/components/common/Modal'
|
||||
import { adminApi } from '@/api/admin'
|
||||
import { toast } from '@/lib/toast'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type {
|
||||
AdminAccountListItem,
|
||||
AdminUserListItem,
|
||||
} from '@/types/admin'
|
||||
|
||||
function formatDate(value: string | null) {
|
||||
if (!value) return 'Never'
|
||||
return new Date(value).toLocaleDateString()
|
||||
}
|
||||
|
||||
function planBadgeVariant(status: string | undefined): 'success' | 'warning' | 'destructive' | 'default' {
|
||||
switch (status) {
|
||||
case 'active': return 'success'
|
||||
case 'trialing': return 'warning'
|
||||
case 'past_due': return 'warning'
|
||||
case 'canceled': return 'destructive'
|
||||
default: return 'default'
|
||||
}
|
||||
}
|
||||
|
||||
export function UsersPage() {
|
||||
const navigate = useNavigate()
|
||||
|
||||
const [accounts, setAccounts] = useState<AdminAccountListItem[]>([])
|
||||
const [accountsLoading, setAccountsLoading] = useState(true)
|
||||
const [accountSearch, setAccountSearch] = useState('')
|
||||
const [planFilter, setPlanFilter] = useState('all')
|
||||
const [statusFilter, setStatusFilter] = useState('all')
|
||||
const [page, setPage] = useState(1)
|
||||
const [total, setTotal] = useState(0)
|
||||
const accountPageSize = 12
|
||||
const [showArchived, setShowArchived] = useState(false)
|
||||
|
||||
const [people, setPeople] = useState<AdminUserListItem[]>([])
|
||||
const [peopleLoading, setPeopleLoading] = useState(false)
|
||||
const [peopleSearch, setPeopleSearch] = useState('')
|
||||
const [peoplePage, setPeoplePage] = useState(1)
|
||||
const [peopleTotal, setPeopleTotal] = useState(0)
|
||||
const peoplePageSize = 12
|
||||
|
||||
const [showCreateModal, setShowCreateModal] = useState(false)
|
||||
const [createForm, setCreateForm] = useState({
|
||||
email: '',
|
||||
name: '',
|
||||
account_mode: 'personal' as 'existing' | 'personal',
|
||||
account_display_code: '',
|
||||
account_role: 'engineer' as 'owner' | 'admin' | 'engineer' | 'viewer',
|
||||
send_email: true,
|
||||
})
|
||||
const [createLoading, setCreateLoading] = useState(false)
|
||||
const [tempPassword, setTempPassword] = useState<string | null>(null)
|
||||
const [copied, setCopied] = useState(false)
|
||||
const [showInviteModal, setShowInviteModal] = useState(false)
|
||||
const [inviteForm, setInviteForm] = useState({
|
||||
email: '',
|
||||
account_display_code: '',
|
||||
role: 'engineer' as 'engineer' | 'viewer',
|
||||
})
|
||||
const [inviteLoading, setInviteLoading] = useState(false)
|
||||
const [showCreateAccountModal, setShowCreateAccountModal] = useState(false)
|
||||
const [createAccountForm, setCreateAccountForm] = useState({ name: '', plan: 'free' as 'free' | 'pro' | 'team', owner_email: '' })
|
||||
const [createAccountLoading, setCreateAccountLoading] = useState(false)
|
||||
|
||||
const fetchAccounts = useCallback(async () => {
|
||||
setAccountsLoading(true)
|
||||
try {
|
||||
const accountsData = await adminApi.listAccounts({
|
||||
page,
|
||||
size: accountPageSize,
|
||||
search: accountSearch || undefined,
|
||||
plan: planFilter !== 'all' ? planFilter : undefined,
|
||||
status: statusFilter !== 'all' ? statusFilter : undefined,
|
||||
include_archived: showArchived || undefined,
|
||||
})
|
||||
setAccounts(accountsData.items)
|
||||
setTotal(accountsData.total)
|
||||
} catch {
|
||||
toast.error('Failed to load accounts')
|
||||
} finally {
|
||||
setAccountsLoading(false)
|
||||
}
|
||||
}, [accountPageSize, accountSearch, page, planFilter, showArchived, statusFilter])
|
||||
|
||||
const fetchPeople = useCallback(async () => {
|
||||
if (!peopleSearch.trim()) {
|
||||
setPeopleLoading(false)
|
||||
setPeople([])
|
||||
setPeopleTotal(0)
|
||||
return
|
||||
}
|
||||
setPeopleLoading(true)
|
||||
try {
|
||||
const data = await adminApi.listUsers({
|
||||
page: peoplePage,
|
||||
size: peoplePageSize,
|
||||
search: peopleSearch || undefined,
|
||||
include_archived: showArchived || undefined,
|
||||
})
|
||||
setPeople(data.items)
|
||||
setPeopleTotal(data.total)
|
||||
} catch {
|
||||
toast.error('Failed to load people search')
|
||||
} finally {
|
||||
setPeopleLoading(false)
|
||||
}
|
||||
}, [peoplePage, peoplePageSize, peopleSearch, showArchived])
|
||||
|
||||
useEffect(() => {
|
||||
fetchAccounts()
|
||||
}, [fetchAccounts])
|
||||
|
||||
useEffect(() => {
|
||||
fetchPeople()
|
||||
}, [fetchPeople])
|
||||
|
||||
const handleCreateUser = async () => {
|
||||
if (!createForm.email || !createForm.name) return
|
||||
if (createForm.account_mode === 'existing' && !createForm.account_display_code) {
|
||||
toast.error('Account display code is required')
|
||||
return
|
||||
}
|
||||
setCreateLoading(true)
|
||||
try {
|
||||
const result = await adminApi.createUser({
|
||||
email: createForm.email,
|
||||
name: createForm.name,
|
||||
account_mode: createForm.account_mode,
|
||||
account_display_code: createForm.account_mode === 'existing' ? createForm.account_display_code : undefined,
|
||||
account_role: createForm.account_mode === 'existing' ? createForm.account_role : undefined,
|
||||
send_email: createForm.send_email,
|
||||
})
|
||||
setShowCreateModal(false)
|
||||
setTempPassword(result.temporary_password)
|
||||
setCopied(false)
|
||||
toast.success(result.email_sent ? 'User created and welcome email sent' : 'User created')
|
||||
setCreateForm({
|
||||
email: '',
|
||||
name: '',
|
||||
account_mode: 'personal',
|
||||
account_display_code: '',
|
||||
account_role: 'engineer',
|
||||
send_email: true,
|
||||
})
|
||||
fetchAccounts()
|
||||
} catch (err: unknown) {
|
||||
if (err && typeof err === 'object' && 'response' in err) {
|
||||
const axiosErr = err as { response?: { data?: { detail?: string } } }
|
||||
toast.error(axiosErr.response?.data?.detail || 'Failed to create user')
|
||||
} else {
|
||||
toast.error('Failed to create user')
|
||||
}
|
||||
} finally {
|
||||
setCreateLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCopyPassword = async () => {
|
||||
if (!tempPassword) return
|
||||
await navigator.clipboard.writeText(tempPassword)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
}
|
||||
|
||||
const handleInviteUser = async () => {
|
||||
if (!inviteForm.email || !inviteForm.account_display_code) return
|
||||
setInviteLoading(true)
|
||||
try {
|
||||
const result = await adminApi.createInvite({
|
||||
email: inviteForm.email,
|
||||
account_display_code: inviteForm.account_display_code,
|
||||
role: inviteForm.role,
|
||||
})
|
||||
setShowInviteModal(false)
|
||||
setInviteForm({ email: '', account_display_code: '', role: 'engineer' })
|
||||
toast.success(result.email_sent ? 'Invite sent' : 'Invite created (email not configured)')
|
||||
fetchAccounts()
|
||||
} catch (err: unknown) {
|
||||
if (err && typeof err === 'object' && 'response' in err) {
|
||||
const axiosErr = err as { response?: { data?: { detail?: string } } }
|
||||
toast.error(axiosErr.response?.data?.detail || 'Failed to send invite')
|
||||
} else {
|
||||
toast.error('Failed to send invite')
|
||||
}
|
||||
} finally {
|
||||
setInviteLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCreateAccount = async () => {
|
||||
if (!createAccountForm.name.trim()) return
|
||||
setCreateAccountLoading(true)
|
||||
try {
|
||||
const created = await adminApi.createAccount({
|
||||
name: createAccountForm.name.trim(),
|
||||
plan: createAccountForm.plan,
|
||||
owner_email: createAccountForm.owner_email.trim() || undefined,
|
||||
})
|
||||
toast.success('Account created')
|
||||
setShowCreateAccountModal(false)
|
||||
setCreateAccountForm({ name: '', plan: 'free', owner_email: '' })
|
||||
navigate(`/admin/accounts/${created.id}`)
|
||||
} catch (err: unknown) {
|
||||
if (err && typeof err === 'object' && 'response' in err) {
|
||||
const axiosErr = err as { response?: { data?: { detail?: string } } }
|
||||
toast.error(axiosErr.response?.data?.detail || 'Failed to create account')
|
||||
} else {
|
||||
toast.error('Failed to create account')
|
||||
}
|
||||
} finally {
|
||||
setCreateAccountLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const accountColumns: Column<AdminAccountListItem>[] = [
|
||||
{
|
||||
key: 'name',
|
||||
header: 'Account',
|
||||
render: (account) => (
|
||||
<div className="min-w-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate(`/admin/accounts/${account.id}`)}
|
||||
className="text-sm font-medium text-foreground hover:underline"
|
||||
>
|
||||
{account.name}
|
||||
</button>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||
{account.display_code}
|
||||
{account.owner ? ` · ${account.owner.name}` : ''}
|
||||
</p>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'plan',
|
||||
header: 'Plan',
|
||||
render: (account) => (
|
||||
<StatusBadge variant="default">
|
||||
{account.subscription?.plan ?? 'free'}
|
||||
</StatusBadge>
|
||||
),
|
||||
className: 'w-[100px]',
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
header: 'Status',
|
||||
render: (account) => {
|
||||
if (!account.subscription) {
|
||||
return <StatusBadge variant="warning">No subscription</StatusBadge>
|
||||
}
|
||||
return (
|
||||
<StatusBadge variant={planBadgeVariant(account.subscription.status)}>
|
||||
{account.subscription.status}
|
||||
</StatusBadge>
|
||||
)
|
||||
},
|
||||
className: 'w-[120px]',
|
||||
},
|
||||
{
|
||||
key: 'members',
|
||||
header: 'Members',
|
||||
render: (account) => (
|
||||
<span className="text-sm text-foreground">
|
||||
{account.active_member_count}
|
||||
<span className="text-muted-foreground"> / {account.member_count}</span>
|
||||
</span>
|
||||
),
|
||||
className: 'w-[100px]',
|
||||
},
|
||||
{
|
||||
key: 'usage',
|
||||
header: 'Usage',
|
||||
render: (account) => (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{account.usage.tree_count} flows · {account.usage.session_count_this_month} sessions
|
||||
</span>
|
||||
),
|
||||
className: 'w-[160px]',
|
||||
},
|
||||
{
|
||||
key: 'created',
|
||||
header: 'Created',
|
||||
render: (account) => (
|
||||
<span className="text-sm text-muted-foreground">{formatDate(account.created_at)}</span>
|
||||
),
|
||||
className: 'w-[100px]',
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
header: '',
|
||||
render: (account) => (
|
||||
<ActionMenu
|
||||
items={[
|
||||
{
|
||||
label: 'Manage Account',
|
||||
icon: <Building2 className="h-4 w-4" />,
|
||||
onClick: () => navigate(`/admin/accounts/${account.id}`),
|
||||
},
|
||||
...(account.owner ? [{
|
||||
label: 'View Owner',
|
||||
icon: <ExternalLink className="h-4 w-4" />,
|
||||
onClick: () => navigate(`/admin/users/${account.owner?.id}`),
|
||||
}] : []),
|
||||
]}
|
||||
/>
|
||||
),
|
||||
className: 'w-[48px]',
|
||||
},
|
||||
]
|
||||
|
||||
const peopleColumns: Column<AdminUserListItem>[] = [
|
||||
{
|
||||
key: 'name',
|
||||
header: 'Name',
|
||||
render: (user) => (
|
||||
<div className="min-w-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate(`/admin/users/${user.id}`)}
|
||||
className="text-sm font-medium text-foreground hover:underline"
|
||||
>
|
||||
{user.name}
|
||||
</button>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">{user.email}</p>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'role',
|
||||
header: 'Role',
|
||||
render: (user) => (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{user.is_super_admin && <StatusBadge variant="destructive">Super Admin</StatusBadge>}
|
||||
<StatusBadge variant="default">{user.role}</StatusBadge>
|
||||
</div>
|
||||
),
|
||||
className: 'w-[140px]',
|
||||
},
|
||||
{
|
||||
key: 'account',
|
||||
header: 'Account',
|
||||
render: (user) => (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{user.account_name || 'No account'}
|
||||
{user.account_display_code && (
|
||||
<span className="ml-1 text-xs opacity-60">{user.account_display_code}</span>
|
||||
)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
header: 'Status',
|
||||
render: (user) => (
|
||||
<div className="flex gap-1">
|
||||
<StatusBadge variant={user.is_active ? 'success' : 'destructive'}>
|
||||
{user.is_active ? 'Active' : 'Inactive'}
|
||||
</StatusBadge>
|
||||
{user.deleted_at && <StatusBadge variant="warning">Archived</StatusBadge>}
|
||||
</div>
|
||||
),
|
||||
className: 'w-[140px]',
|
||||
},
|
||||
{
|
||||
key: 'last_login',
|
||||
header: 'Last Login',
|
||||
render: (user) => (
|
||||
<span className="text-sm text-muted-foreground">{formatDate(user.last_login)}</span>
|
||||
),
|
||||
className: 'w-[100px]',
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
header: '',
|
||||
render: (user) => (
|
||||
<ActionMenu
|
||||
items={[
|
||||
{
|
||||
label: 'View Detail',
|
||||
icon: <ExternalLink className="h-4 w-4" />,
|
||||
onClick: () => navigate(`/admin/users/${user.id}`),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
),
|
||||
className: 'w-[48px]',
|
||||
},
|
||||
]
|
||||
|
||||
const accountTotalPages = Math.max(1, Math.ceil(total / accountPageSize))
|
||||
const peopleTotalPages = Math.max(1, Math.ceil(peopleTotal / peoplePageSize))
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="Accounts"
|
||||
description="Manage customer accounts, subscriptions, and users."
|
||||
action={
|
||||
<div className="flex items-center gap-3">
|
||||
<Button variant="secondary" onClick={() => setShowCreateAccountModal(true)}>
|
||||
<Plus className="h-4 w-4" />
|
||||
Create Account
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={() => setShowInviteModal(true)}>
|
||||
<Mail className="h-4 w-4" />
|
||||
Invite User
|
||||
</Button>
|
||||
<Button onClick={() => setShowCreateModal(true)}>
|
||||
<UserPlus className="h-4 w-4" />
|
||||
Create User
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<SearchInput
|
||||
value={accountSearch}
|
||||
onSearch={(value) => {
|
||||
setAccountSearch(value)
|
||||
setPage(1)
|
||||
}}
|
||||
placeholder="Search accounts, owners, or codes..."
|
||||
className="w-full sm:max-w-sm"
|
||||
/>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<select
|
||||
value={planFilter}
|
||||
onChange={(e) => {
|
||||
setPlanFilter(e.target.value)
|
||||
setPage(1)
|
||||
}}
|
||||
className={cn(
|
||||
'h-9 rounded-md border border-border bg-card px-3 text-sm text-foreground',
|
||||
'focus:outline-hidden focus:border-primary focus:ring-2 focus:ring-primary/20'
|
||||
)}
|
||||
>
|
||||
<option value="all">All plans</option>
|
||||
<option value="free">Free</option>
|
||||
<option value="pro">Pro</option>
|
||||
<option value="team">Team</option>
|
||||
</select>
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(e) => {
|
||||
setStatusFilter(e.target.value)
|
||||
setPage(1)
|
||||
}}
|
||||
className={cn(
|
||||
'h-9 rounded-md border border-border bg-card px-3 text-sm text-foreground',
|
||||
'focus:outline-hidden focus:border-primary focus:ring-2 focus:ring-primary/20'
|
||||
)}
|
||||
>
|
||||
<option value="all">All statuses</option>
|
||||
<option value="active">Active</option>
|
||||
<option value="trialing">Trialing</option>
|
||||
<option value="past_due">Past due</option>
|
||||
<option value="canceled">Canceled</option>
|
||||
<option value="orphaned">Orphaned</option>
|
||||
</select>
|
||||
<label className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showArchived}
|
||||
onChange={(e) => {
|
||||
setShowArchived(e.target.checked)
|
||||
setPage(1)
|
||||
setPeoplePage(1)
|
||||
}}
|
||||
className="rounded border-border bg-card"
|
||||
/>
|
||||
Archived
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Accounts table */}
|
||||
<section className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-sm font-medium text-muted-foreground">
|
||||
{accountsLoading ? 'Loading...' : `${total} accounts`}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
columns={accountColumns}
|
||||
data={accounts}
|
||||
keyExtractor={(a) => a.id}
|
||||
isLoading={accountsLoading}
|
||||
skeletonRows={6}
|
||||
emptyState={
|
||||
<EmptyState
|
||||
icon={<Building2 className="h-8 w-8" />}
|
||||
title="No accounts found"
|
||||
description="Adjust the filters or clear the search."
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
<Pagination
|
||||
page={page}
|
||||
totalPages={accountTotalPages}
|
||||
total={total}
|
||||
pageSize={accountPageSize}
|
||||
onPageChange={setPage}
|
||||
/>
|
||||
</section>
|
||||
|
||||
{/* Global people search */}
|
||||
<section className="space-y-4 rounded-xl border border-border bg-card p-5">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Search className="h-4 w-4 text-muted-foreground" />
|
||||
<h2 className="text-base font-semibold text-foreground">Global People Search</h2>
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Find a user across all accounts by name or email.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<SearchInput
|
||||
value={peopleSearch}
|
||||
onSearch={(value) => {
|
||||
setPeopleSearch(value)
|
||||
setPeoplePage(1)
|
||||
}}
|
||||
placeholder="Search by name, email, or account..."
|
||||
className="max-w-sm"
|
||||
/>
|
||||
|
||||
{peopleSearch.trim() ? (
|
||||
people.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
<DataTable
|
||||
columns={peopleColumns}
|
||||
data={people}
|
||||
keyExtractor={(p) => p.id}
|
||||
isLoading={peopleLoading}
|
||||
skeletonRows={4}
|
||||
emptyState={
|
||||
<EmptyState
|
||||
icon={<Sparkles className="h-8 w-8" />}
|
||||
title="No matching people"
|
||||
description="Try another name or email."
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Pagination
|
||||
page={peoplePage}
|
||||
totalPages={peopleTotalPages}
|
||||
total={peopleTotal}
|
||||
pageSize={peoplePageSize}
|
||||
onPageChange={setPeoplePage}
|
||||
/>
|
||||
</div>
|
||||
) : !peopleLoading ? (
|
||||
<EmptyState
|
||||
icon={<Sparkles className="h-8 w-8" />}
|
||||
title="No matching people"
|
||||
description="Try another name or email."
|
||||
/>
|
||||
) : (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Searching...
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">Type a name or email to search.</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Create Account modal */}
|
||||
<Modal
|
||||
isOpen={showCreateAccountModal}
|
||||
onClose={() => setShowCreateAccountModal(false)}
|
||||
title="Create Account"
|
||||
size="sm"
|
||||
footer={(
|
||||
<div className="flex justify-end gap-3">
|
||||
<Button variant="secondary" onClick={() => setShowCreateAccountModal(false)}>Cancel</Button>
|
||||
<Button onClick={handleCreateAccount} disabled={!createAccountForm.name.trim()} loading={createAccountLoading}>
|
||||
{createAccountLoading ? 'Creating...' : 'Create Account'}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-foreground">Account Name</label>
|
||||
<Input
|
||||
value={createAccountForm.name}
|
||||
onChange={(e) => setCreateAccountForm((form) => ({ ...form, name: e.target.value }))}
|
||||
placeholder="Acme MSP"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-foreground">Initial Plan</label>
|
||||
<select
|
||||
value={createAccountForm.plan}
|
||||
onChange={(e) => setCreateAccountForm((form) => ({ ...form, plan: e.target.value as 'free' | 'pro' | 'team' }))}
|
||||
className={cn(
|
||||
'w-full rounded-md border border-border bg-card px-3 py-2 text-sm text-foreground',
|
||||
'focus:outline-hidden focus:border-primary focus:ring-2 focus:ring-primary/20'
|
||||
)}
|
||||
>
|
||||
<option value="free">Free</option>
|
||||
<option value="pro">Pro</option>
|
||||
<option value="team">Team</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-foreground">
|
||||
Owner Email <span className="text-muted-foreground font-normal">(optional)</span>
|
||||
</label>
|
||||
<Input
|
||||
type="email"
|
||||
value={createAccountForm.owner_email}
|
||||
onChange={(e) => setCreateAccountForm((form) => ({ ...form, owner_email: e.target.value }))}
|
||||
placeholder="owner@example.com"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-muted-foreground">Must be an existing user.</p>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* Create User modal */}
|
||||
<Modal
|
||||
isOpen={showCreateModal}
|
||||
onClose={() => setShowCreateModal(false)}
|
||||
title="Create User"
|
||||
size="sm"
|
||||
footer={(
|
||||
<div className="flex justify-end gap-3">
|
||||
<Button variant="secondary" onClick={() => setShowCreateModal(false)}>Cancel</Button>
|
||||
<Button onClick={handleCreateUser} disabled={!createForm.email || !createForm.name} loading={createLoading}>
|
||||
{createLoading ? 'Creating...' : 'Create User'}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-foreground">Name</label>
|
||||
<Input
|
||||
type="text"
|
||||
value={createForm.name}
|
||||
onChange={(e) => setCreateForm((form) => ({ ...form, name: e.target.value }))}
|
||||
placeholder="Full name"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-foreground">Email</label>
|
||||
<Input
|
||||
type="email"
|
||||
value={createForm.email}
|
||||
onChange={(e) => setCreateForm((form) => ({ ...form, email: e.target.value }))}
|
||||
placeholder="user@example.com"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-foreground">Account Mode</label>
|
||||
<select
|
||||
value={createForm.account_mode}
|
||||
onChange={(e) => setCreateForm((form) => ({ ...form, account_mode: e.target.value as 'existing' | 'personal' }))}
|
||||
className={cn(
|
||||
'w-full rounded-md border border-border bg-card px-3 py-2 text-sm text-foreground',
|
||||
'focus:outline-hidden focus:border-primary focus:ring-2 focus:ring-primary/20'
|
||||
)}
|
||||
>
|
||||
<option value="personal">Personal (new account)</option>
|
||||
<option value="existing">Join existing account</option>
|
||||
</select>
|
||||
</div>
|
||||
{createForm.account_mode === 'existing' && (
|
||||
<>
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-foreground">Account Display Code</label>
|
||||
<Input
|
||||
type="text"
|
||||
value={createForm.account_display_code}
|
||||
onChange={(e) => setCreateForm((form) => ({ ...form, account_display_code: e.target.value }))}
|
||||
placeholder="e.g. ABC12345"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-foreground">Account Role</label>
|
||||
<select
|
||||
value={createForm.account_role}
|
||||
onChange={(e) => setCreateForm((form) => ({ ...form, account_role: e.target.value as 'owner' | 'admin' | 'engineer' | 'viewer' }))}
|
||||
className={cn(
|
||||
'w-full rounded-md border border-border bg-card px-3 py-2 text-sm text-foreground',
|
||||
'focus:outline-hidden focus:border-primary focus:ring-2 focus:ring-primary/20'
|
||||
)}
|
||||
>
|
||||
<option value="owner">Owner</option>
|
||||
<option value="admin">Admin</option>
|
||||
<option value="engineer">Engineer</option>
|
||||
<option value="viewer">Viewer</option>
|
||||
</select>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="send-email"
|
||||
checked={createForm.send_email}
|
||||
onChange={(e) => setCreateForm((form) => ({ ...form, send_email: e.target.checked }))}
|
||||
className="rounded border-border bg-card"
|
||||
/>
|
||||
<label htmlFor="send-email" className="text-sm text-muted-foreground">
|
||||
Send welcome email with temporary password
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* Temp password modal */}
|
||||
<Modal
|
||||
isOpen={!!tempPassword}
|
||||
onClose={() => setTempPassword(null)}
|
||||
title="User Created"
|
||||
size="sm"
|
||||
footer={(
|
||||
<div className="flex justify-end">
|
||||
<Button onClick={() => setTempPassword(null)}>Done</Button>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-xl border border-yellow-400/20 bg-yellow-400/10 p-3 text-sm text-yellow-400">
|
||||
This password will not be shown again. Copy it now.
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-foreground">Temporary Password</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="flex-1 rounded-md border border-border bg-card px-3 py-2 font-mono text-sm text-foreground">
|
||||
{tempPassword}
|
||||
</code>
|
||||
<button
|
||||
onClick={handleCopyPassword}
|
||||
className="rounded-md border border-border p-2 text-muted-foreground transition-colors hover:bg-elevated hover:text-foreground"
|
||||
title="Copy password"
|
||||
>
|
||||
{copied ? <Check className="h-4 w-4 text-green-400" /> : <Copy className="h-4 w-4" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
The user will be required to change this password on first login.
|
||||
</p>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* Invite User modal */}
|
||||
<Modal
|
||||
isOpen={showInviteModal}
|
||||
onClose={() => setShowInviteModal(false)}
|
||||
title="Invite User"
|
||||
size="sm"
|
||||
footer={(
|
||||
<div className="flex justify-end gap-3">
|
||||
<Button variant="secondary" onClick={() => setShowInviteModal(false)}>Cancel</Button>
|
||||
<Button onClick={handleInviteUser} disabled={!inviteForm.email || !inviteForm.account_display_code} loading={inviteLoading}>
|
||||
{inviteLoading ? 'Sending...' : 'Send Invite'}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-foreground">Email</label>
|
||||
<Input
|
||||
type="email"
|
||||
value={inviteForm.email}
|
||||
onChange={(e) => setInviteForm((form) => ({ ...form, email: e.target.value }))}
|
||||
placeholder="user@example.com"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-foreground">Account Display Code</label>
|
||||
<Input
|
||||
type="text"
|
||||
value={inviteForm.account_display_code}
|
||||
onChange={(e) => setInviteForm((form) => ({ ...form, account_display_code: e.target.value }))}
|
||||
placeholder="e.g. ABC12345"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-foreground">Role</label>
|
||||
<select
|
||||
value={inviteForm.role}
|
||||
onChange={(e) => setInviteForm((form) => ({ ...form, role: e.target.value as 'engineer' | 'viewer' }))}
|
||||
className={cn(
|
||||
'w-full rounded-md border border-border bg-card px-3 py-2 text-sm text-foreground',
|
||||
'focus:outline-hidden focus:border-primary focus:ring-2 focus:ring-primary/20'
|
||||
)}
|
||||
>
|
||||
<option value="engineer">Engineer</option>
|
||||
<option value="viewer">Viewer</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default UsersPage
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { Users, TreePine, CreditCard, Activity, TrendingUp } from 'lucide-react'
|
||||
import { Users, TreePine, CreditCard, Activity, TrendingUp, Building2 } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { PageHeader } from '@/components/admin'
|
||||
import { adminApi } from '@/api/admin'
|
||||
@@ -43,7 +43,7 @@ export function DashboardPage() {
|
||||
}, [])
|
||||
|
||||
const quickLinks = [
|
||||
{ to: '/admin/users', label: 'Manage Users', icon: Users },
|
||||
{ to: '/admin/accounts', label: 'Manage Accounts', icon: Building2 },
|
||||
{ to: '/admin/plan-limits', label: 'Plan Limits', icon: TrendingUp },
|
||||
{ to: '/admin/feature-flags', label: 'Feature Flags', icon: Activity },
|
||||
{ to: '/admin/audit-logs', label: 'Audit Logs', icon: Activity },
|
||||
|
||||
@@ -177,7 +177,7 @@ export function UserDetailPage() {
|
||||
try {
|
||||
await adminApi.hardDeleteUser(userId)
|
||||
toast.success('User permanently deleted')
|
||||
navigate('/admin/users')
|
||||
navigate('/admin/accounts')
|
||||
} catch (err: unknown) {
|
||||
if (err && typeof err === 'object' && 'response' in err) {
|
||||
const axiosErr = err as { response?: { data?: { detail?: string } } }
|
||||
@@ -207,8 +207,8 @@ export function UserDetailPage() {
|
||||
title="User not found"
|
||||
description="This user may have been removed or is unavailable."
|
||||
action={(
|
||||
<Button variant="secondary" onClick={() => navigate('/admin/users')}>
|
||||
Back to Users
|
||||
<Button variant="secondary" onClick={() => navigate('/admin/accounts')}>
|
||||
Back to Accounts
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
@@ -223,7 +223,7 @@ export function UserDetailPage() {
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
onClick={() => navigate('/admin/users')}
|
||||
onClick={() => navigate('/admin/accounts')}
|
||||
className="rounded-md border border-border p-2 text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
|
||||
@@ -1,556 +0,0 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { UserCheck, UserX, Shield, ArrowRightLeft, ExternalLink, UserPlus, Copy, Check, Mail } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { Input } from '@/components/ui/Input'
|
||||
import { DataTable, Pagination, SearchInput, PageHeader, StatusBadge, ActionMenu } from '@/components/admin'
|
||||
import type { Column } from '@/components/admin'
|
||||
import { Modal } from '@/components/common/Modal'
|
||||
import { adminApi } from '@/api/admin'
|
||||
import { toast } from '@/lib/toast'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface AdminUser {
|
||||
id: string
|
||||
email: string
|
||||
name: string
|
||||
role: string
|
||||
is_super_admin: boolean
|
||||
is_active: boolean
|
||||
account_id: string | null
|
||||
account_role: string | null
|
||||
created_at: string
|
||||
last_login: string | null
|
||||
deleted_at: string | null
|
||||
}
|
||||
|
||||
export function UsersPage() {
|
||||
const navigate = useNavigate()
|
||||
const [users, setUsers] = useState<AdminUser[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [search, setSearch] = useState('')
|
||||
const [page, setPage] = useState(1)
|
||||
const [total, setTotal] = useState(0)
|
||||
const pageSize = 20
|
||||
const [showArchived, setShowArchived] = useState(false)
|
||||
|
||||
// Role change modal
|
||||
const [roleModalUser, setRoleModalUser] = useState<AdminUser | null>(null)
|
||||
const [newRole, setNewRole] = useState('')
|
||||
|
||||
// Move account modal
|
||||
const [moveModalUser, setMoveModalUser] = useState<AdminUser | null>(null)
|
||||
const [displayCode, setDisplayCode] = useState('')
|
||||
|
||||
// Create user modal
|
||||
const [showCreateModal, setShowCreateModal] = useState(false)
|
||||
const [createForm, setCreateForm] = useState({
|
||||
email: '',
|
||||
name: '',
|
||||
account_mode: 'personal' as 'existing' | 'personal',
|
||||
account_display_code: '',
|
||||
account_role: 'engineer' as 'engineer' | 'viewer',
|
||||
send_email: true,
|
||||
})
|
||||
const [createLoading, setCreateLoading] = useState(false)
|
||||
|
||||
// Temp password display modal
|
||||
const [tempPassword, setTempPassword] = useState<string | null>(null)
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
// Invite user modal
|
||||
const [showInviteModal, setShowInviteModal] = useState(false)
|
||||
const [inviteForm, setInviteForm] = useState({ email: '', account_display_code: '', role: 'engineer' as 'engineer' | 'viewer' })
|
||||
const [inviteLoading, setInviteLoading] = useState(false)
|
||||
|
||||
const fetchUsers = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const data = await adminApi.listUsers({ page, size: pageSize, search: search || undefined, include_archived: showArchived || undefined })
|
||||
setUsers(data.items || data)
|
||||
setTotal(data.total || (data.items ? data.items.length : data.length))
|
||||
} catch {
|
||||
toast.error('Failed to load users')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [page, search, showArchived])
|
||||
|
||||
useEffect(() => { fetchUsers() }, [fetchUsers])
|
||||
|
||||
const handleRoleChange = async () => {
|
||||
if (!roleModalUser || !newRole) return
|
||||
try {
|
||||
await adminApi.updateUserRole(roleModalUser.id, newRole)
|
||||
toast.success('Role updated')
|
||||
setRoleModalUser(null)
|
||||
fetchUsers()
|
||||
} catch {
|
||||
toast.error('Failed to update role')
|
||||
}
|
||||
}
|
||||
|
||||
const handleToggleActive = async (user: AdminUser) => {
|
||||
try {
|
||||
if (user.is_active) {
|
||||
await adminApi.deactivateUser(user.id)
|
||||
toast.success('User deactivated')
|
||||
} else {
|
||||
await adminApi.activateUser(user.id)
|
||||
toast.success('User activated')
|
||||
}
|
||||
fetchUsers()
|
||||
} catch {
|
||||
toast.error('Failed to update user status')
|
||||
}
|
||||
}
|
||||
|
||||
const handleMoveAccount = async () => {
|
||||
if (!moveModalUser || !displayCode) return
|
||||
try {
|
||||
await adminApi.moveUserAccount(moveModalUser.id, displayCode)
|
||||
toast.success('User moved to account')
|
||||
setMoveModalUser(null)
|
||||
setDisplayCode('')
|
||||
fetchUsers()
|
||||
} catch {
|
||||
toast.error('Failed to move user')
|
||||
}
|
||||
}
|
||||
|
||||
const handleCreateUser = async () => {
|
||||
if (!createForm.email || !createForm.name) return
|
||||
if (createForm.account_mode === 'existing' && !createForm.account_display_code) {
|
||||
toast.error('Account display code is required')
|
||||
return
|
||||
}
|
||||
setCreateLoading(true)
|
||||
try {
|
||||
const result = await adminApi.createUser({
|
||||
email: createForm.email,
|
||||
name: createForm.name,
|
||||
account_mode: createForm.account_mode,
|
||||
account_display_code: createForm.account_mode === 'existing' ? createForm.account_display_code : undefined,
|
||||
account_role: createForm.account_mode === 'existing' ? createForm.account_role : undefined,
|
||||
send_email: createForm.send_email,
|
||||
})
|
||||
setShowCreateModal(false)
|
||||
setTempPassword(result.temporary_password)
|
||||
setCopied(false)
|
||||
toast.success(result.email_sent ? 'User created and welcome email sent' : 'User created')
|
||||
setCreateForm({ email: '', name: '', account_mode: 'personal', account_display_code: '', account_role: 'engineer', send_email: true })
|
||||
fetchUsers()
|
||||
} catch (err: unknown) {
|
||||
if (err && typeof err === 'object' && 'response' in err) {
|
||||
const axiosErr = err as { response?: { data?: { detail?: string } } }
|
||||
toast.error(axiosErr.response?.data?.detail || 'Failed to create user')
|
||||
} else {
|
||||
toast.error('Failed to create user')
|
||||
}
|
||||
} finally {
|
||||
setCreateLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCopyPassword = async () => {
|
||||
if (!tempPassword) return
|
||||
await navigator.clipboard.writeText(tempPassword)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
}
|
||||
|
||||
const handleInviteUser = async () => {
|
||||
if (!inviteForm.email || !inviteForm.account_display_code) return
|
||||
setInviteLoading(true)
|
||||
try {
|
||||
const result = await adminApi.createInvite({
|
||||
email: inviteForm.email,
|
||||
account_display_code: inviteForm.account_display_code,
|
||||
role: inviteForm.role,
|
||||
})
|
||||
setShowInviteModal(false)
|
||||
setInviteForm({ email: '', account_display_code: '', role: 'engineer' })
|
||||
toast.success(result.email_sent ? 'Invite sent' : 'Invite created (email not configured)')
|
||||
} catch (err: unknown) {
|
||||
if (err && typeof err === 'object' && 'response' in err) {
|
||||
const axiosErr = err as { response?: { data?: { detail?: string } } }
|
||||
toast.error(axiosErr.response?.data?.detail || 'Failed to send invite')
|
||||
} else {
|
||||
toast.error('Failed to send invite')
|
||||
}
|
||||
} finally {
|
||||
setInviteLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const columns: Column<AdminUser>[] = [
|
||||
{
|
||||
key: 'name',
|
||||
header: 'Name',
|
||||
sortable: true,
|
||||
render: (u) => (
|
||||
<div>
|
||||
<div className="font-medium text-foreground">{u.name}</div>
|
||||
<div className="text-xs text-muted-foreground">{u.email}</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'role',
|
||||
header: 'Role',
|
||||
render: (u) => (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm">{u.role}</span>
|
||||
{u.is_super_admin && (
|
||||
<StatusBadge variant="destructive">Super Admin</StatusBadge>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
header: 'Status',
|
||||
render: (u) => (
|
||||
<div className="flex items-center gap-1">
|
||||
<StatusBadge variant={u.is_active ? 'success' : 'destructive'}>
|
||||
{u.is_active ? 'Active' : 'Inactive'}
|
||||
</StatusBadge>
|
||||
{u.deleted_at && (
|
||||
<StatusBadge variant="warning">Archived</StatusBadge>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'created_at',
|
||||
header: 'Joined',
|
||||
sortable: true,
|
||||
render: (u) => (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{new Date(u.created_at).toLocaleDateString()}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
header: '',
|
||||
className: 'w-12',
|
||||
render: (u) => (
|
||||
<ActionMenu items={[
|
||||
{
|
||||
label: 'View Detail',
|
||||
icon: <ExternalLink className="h-4 w-4" />,
|
||||
onClick: () => navigate(`/admin/users/${u.id}`),
|
||||
},
|
||||
{
|
||||
label: 'Change Role',
|
||||
icon: <Shield className="h-4 w-4" />,
|
||||
onClick: () => { setRoleModalUser(u); setNewRole(u.role) },
|
||||
},
|
||||
{
|
||||
label: u.is_active ? 'Deactivate' : 'Activate',
|
||||
icon: u.is_active ? <UserX className="h-4 w-4" /> : <UserCheck className="h-4 w-4" />,
|
||||
onClick: () => handleToggleActive(u),
|
||||
destructive: u.is_active,
|
||||
},
|
||||
{
|
||||
label: 'Move Account',
|
||||
icon: <ArrowRightLeft className="h-4 w-4" />,
|
||||
onClick: () => { setMoveModalUser(u); setDisplayCode('') },
|
||||
},
|
||||
]} />
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<PageHeader title="Users" description="Manage platform users and roles" />
|
||||
<div className="flex items-center gap-3">
|
||||
<Button variant="secondary" onClick={() => setShowInviteModal(true)}>
|
||||
<Mail className="h-4 w-4" />
|
||||
Invite User
|
||||
</Button>
|
||||
<Button onClick={() => setShowCreateModal(true)}>
|
||||
<UserPlus className="h-4 w-4" />
|
||||
Create User
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<SearchInput
|
||||
value={search}
|
||||
onSearch={(v) => { setSearch(v); setPage(1) }}
|
||||
placeholder="Search by name or email..."
|
||||
className="max-w-sm"
|
||||
/>
|
||||
<label className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showArchived}
|
||||
onChange={(e) => { setShowArchived(e.target.checked); setPage(1) }}
|
||||
className="rounded border-border bg-card"
|
||||
/>
|
||||
Show archived
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={users}
|
||||
keyExtractor={(u) => u.id}
|
||||
isLoading={loading}
|
||||
/>
|
||||
|
||||
<Pagination
|
||||
page={page}
|
||||
totalPages={Math.ceil(total / pageSize)}
|
||||
total={total}
|
||||
pageSize={pageSize}
|
||||
onPageChange={setPage}
|
||||
/>
|
||||
|
||||
{/* Role Change Modal */}
|
||||
<Modal
|
||||
isOpen={!!roleModalUser}
|
||||
onClose={() => setRoleModalUser(null)}
|
||||
title="Change Role"
|
||||
size="sm"
|
||||
footer={
|
||||
<div className="flex justify-end gap-3">
|
||||
<Button variant="secondary" onClick={() => setRoleModalUser(null)}>Cancel</Button>
|
||||
<Button onClick={handleRoleChange}>Save</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Changing role for <span className="font-medium text-foreground">{roleModalUser?.name}</span>
|
||||
</p>
|
||||
<select
|
||||
value={newRole}
|
||||
onChange={(e) => setNewRole(e.target.value)}
|
||||
className={cn(
|
||||
'w-full rounded-md border border-border bg-card px-3 py-2 text-sm text-foreground',
|
||||
'focus:outline-hidden focus:border-primary focus:ring-2 focus:ring-primary/20'
|
||||
)}
|
||||
>
|
||||
<option value="engineer">Engineer</option>
|
||||
<option value="viewer">Viewer</option>
|
||||
</select>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* Move Account Modal */}
|
||||
<Modal
|
||||
isOpen={!!moveModalUser}
|
||||
onClose={() => setMoveModalUser(null)}
|
||||
title="Move User to Account"
|
||||
size="sm"
|
||||
footer={
|
||||
<div className="flex justify-end gap-3">
|
||||
<Button variant="secondary" onClick={() => setMoveModalUser(null)}>Cancel</Button>
|
||||
<Button onClick={handleMoveAccount} disabled={!displayCode}>Move</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Moving <span className="font-medium text-foreground">{moveModalUser?.name}</span> to a new account.
|
||||
</p>
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-foreground">Account Display Code</label>
|
||||
<Input
|
||||
type="text"
|
||||
value={displayCode}
|
||||
onChange={(e) => setDisplayCode(e.target.value)}
|
||||
placeholder="e.g. ABC-1234"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* Create User Modal */}
|
||||
<Modal
|
||||
isOpen={showCreateModal}
|
||||
onClose={() => setShowCreateModal(false)}
|
||||
title="Create User"
|
||||
size="sm"
|
||||
footer={
|
||||
<div className="flex justify-end gap-3">
|
||||
<Button variant="secondary" onClick={() => setShowCreateModal(false)}>Cancel</Button>
|
||||
<Button onClick={handleCreateUser} disabled={!createForm.email || !createForm.name} loading={createLoading}>
|
||||
{createLoading ? 'Creating...' : 'Create User'}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-foreground">Name</label>
|
||||
<Input
|
||||
type="text"
|
||||
value={createForm.name}
|
||||
onChange={(e) => setCreateForm(f => ({ ...f, name: e.target.value }))}
|
||||
placeholder="Full name"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-foreground">Email</label>
|
||||
<Input
|
||||
type="email"
|
||||
value={createForm.email}
|
||||
onChange={(e) => setCreateForm(f => ({ ...f, email: e.target.value }))}
|
||||
placeholder="user@example.com"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-foreground">Account Mode</label>
|
||||
<select
|
||||
value={createForm.account_mode}
|
||||
onChange={(e) => setCreateForm(f => ({ ...f, account_mode: e.target.value as 'existing' | 'personal' }))}
|
||||
className={cn(
|
||||
'w-full rounded-md border border-border bg-card px-3 py-2 text-sm text-foreground',
|
||||
'focus:outline-hidden focus:border-primary focus:ring-2 focus:ring-primary/20'
|
||||
)}
|
||||
>
|
||||
<option value="personal">Personal (new account)</option>
|
||||
<option value="existing">Join existing account</option>
|
||||
</select>
|
||||
</div>
|
||||
{createForm.account_mode === 'existing' && (
|
||||
<>
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-foreground">Account Display Code</label>
|
||||
<Input
|
||||
type="text"
|
||||
value={createForm.account_display_code}
|
||||
onChange={(e) => setCreateForm(f => ({ ...f, account_display_code: e.target.value }))}
|
||||
placeholder="e.g. ABC12345"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-foreground">Account Role</label>
|
||||
<select
|
||||
value={createForm.account_role}
|
||||
onChange={(e) => setCreateForm(f => ({ ...f, account_role: e.target.value as 'engineer' | 'viewer' }))}
|
||||
className={cn(
|
||||
'w-full rounded-md border border-border bg-card px-3 py-2 text-sm text-foreground',
|
||||
'focus:outline-hidden focus:border-primary focus:ring-2 focus:ring-primary/20'
|
||||
)}
|
||||
>
|
||||
<option value="engineer">Engineer</option>
|
||||
<option value="viewer">Viewer</option>
|
||||
</select>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="send-email"
|
||||
checked={createForm.send_email}
|
||||
onChange={(e) => setCreateForm(f => ({ ...f, send_email: e.target.checked }))}
|
||||
className="rounded border-border bg-card"
|
||||
/>
|
||||
<label htmlFor="send-email" className="text-sm text-muted-foreground">
|
||||
Send welcome email with temporary password
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* Temporary Password Modal */}
|
||||
<Modal
|
||||
isOpen={!!tempPassword}
|
||||
onClose={() => setTempPassword(null)}
|
||||
title="User Created"
|
||||
size="sm"
|
||||
footer={
|
||||
<div className="flex justify-end">
|
||||
<Button onClick={() => setTempPassword(null)}>Done</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-xl border border-yellow-400/20 bg-yellow-400/10 p-3 text-sm text-yellow-400">
|
||||
This password will not be shown again. Copy it now.
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-foreground">Temporary Password</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="flex-1 rounded-md border border-border bg-card px-3 py-2 text-sm text-foreground font-mono">
|
||||
{tempPassword}
|
||||
</code>
|
||||
<button
|
||||
onClick={handleCopyPassword}
|
||||
className="rounded-md border border-border p-2 text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
|
||||
title="Copy password"
|
||||
>
|
||||
{copied ? <Check className="h-4 w-4 text-green-400" /> : <Copy className="h-4 w-4" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
The user will be required to change this password on first login.
|
||||
</p>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* Invite User Modal */}
|
||||
<Modal
|
||||
isOpen={showInviteModal}
|
||||
onClose={() => setShowInviteModal(false)}
|
||||
title="Invite User"
|
||||
size="sm"
|
||||
footer={
|
||||
<div className="flex justify-end gap-3">
|
||||
<Button variant="secondary" onClick={() => setShowInviteModal(false)}>Cancel</Button>
|
||||
<Button onClick={handleInviteUser} disabled={!inviteForm.email || !inviteForm.account_display_code} loading={inviteLoading}>
|
||||
{inviteLoading ? 'Sending...' : 'Send Invite'}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-foreground">Email</label>
|
||||
<Input
|
||||
type="email"
|
||||
value={inviteForm.email}
|
||||
onChange={(e) => setInviteForm(f => ({ ...f, email: e.target.value }))}
|
||||
placeholder="user@example.com"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-foreground">Account Display Code</label>
|
||||
<Input
|
||||
type="text"
|
||||
value={inviteForm.account_display_code}
|
||||
onChange={(e) => setInviteForm(f => ({ ...f, account_display_code: e.target.value }))}
|
||||
placeholder="e.g. ABC12345"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-foreground">Role</label>
|
||||
<select
|
||||
value={inviteForm.role}
|
||||
onChange={(e) => setInviteForm(f => ({ ...f, role: e.target.value as 'engineer' | 'viewer' }))}
|
||||
className={cn(
|
||||
'w-full rounded-md border border-border bg-card px-3 py-2 text-sm text-foreground',
|
||||
'focus:outline-hidden focus:border-primary focus:ring-2 focus:ring-primary/20'
|
||||
)}
|
||||
>
|
||||
<option value="engineer">Engineer</option>
|
||||
<option value="viewer">Viewer</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default UsersPage
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createBrowserRouter } from 'react-router-dom'
|
||||
import { createBrowserRouter, Navigate, useParams } from 'react-router-dom'
|
||||
import * as Sentry from '@sentry/react'
|
||||
import { Suspense } from 'react'
|
||||
import { AppLayout, ProtectedRoute } from '@/components/layout'
|
||||
@@ -49,7 +49,10 @@ const ScriptLibraryPage = lazyWithRetry(() => import('@/pages/ScriptLibraryPage'
|
||||
const ScriptManagePage = lazyWithRetry(() => import('@/pages/ScriptManagePage'))
|
||||
const AssistantChatPage = lazyWithRetry(() => import('@/pages/AssistantChatPage'))
|
||||
const FlowAssistPage = lazyWithRetry(() => import('@/pages/FlowAssistPage'))
|
||||
const FlowPilotSessionPage = lazyWithRetry(() => import('@/pages/FlowPilotSessionPage'))
|
||||
// FlowPilotSessionPage (the old guided-mode surface) was removed from active
|
||||
// routing in Phase 1 of the FlowPilot migration. The file is retained on disk
|
||||
// for reference but not mounted — the unified chat-primary surface now serves
|
||||
// /pilot. Delete the file when nothing in the tree references it anymore.
|
||||
const EscalationQueuePage = lazyWithRetry(() => import('@/pages/EscalationQueuePage'))
|
||||
const ReviewQueuePage = lazyWithRetry(() => import('@/pages/ReviewQueuePage'))
|
||||
const FlowPilotAnalyticsPage = lazyWithRetry(() => import('@/pages/FlowPilotAnalyticsPage'))
|
||||
@@ -65,7 +68,8 @@ const DiagramEditorPage = lazyWithRetry(() => import('@/pages/NetworkDiagrams/Di
|
||||
// Admin pages
|
||||
const AdminLayout = lazyWithRetry(() => import('@/components/admin/AdminLayout'))
|
||||
const AdminDashboardPage = lazyWithRetry(() => import('@/pages/admin/DashboardPage'))
|
||||
const AdminUsersPage = lazyWithRetry(() => import('@/pages/admin/UsersPage'))
|
||||
const AdminAccountsPage = lazyWithRetry(() => import('@/pages/admin/AccountsPage'))
|
||||
const AdminAccountDetailPage = lazyWithRetry(() => import('@/pages/admin/AccountDetailPage'))
|
||||
const AdminUserDetailPage = lazyWithRetry(() => import('@/pages/admin/UserDetailPage'))
|
||||
const AdminInviteCodesPage = lazyWithRetry(() => import('@/pages/admin/InviteCodesPage'))
|
||||
const AdminAuditLogsPage = lazyWithRetry(() => import('@/pages/admin/AuditLogsPage'))
|
||||
@@ -97,6 +101,16 @@ function page(Component: React.LazyExoticComponent<React.ComponentType>) {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Permanent 301-style redirect from /assistant/:sessionId to /pilot/:sessionId.
|
||||
* Used by the Phase 1 route-rename; paired with a bare-path redirect to /pilot.
|
||||
* SPA redirects replace history so the legacy URL does not linger in back-nav.
|
||||
*/
|
||||
function AssistantSessionRedirect() {
|
||||
const { sessionId } = useParams<{ sessionId: string }>()
|
||||
return <Navigate to={sessionId ? `/pilot/${sessionId}` : '/pilot'} replace />
|
||||
}
|
||||
|
||||
export const router = sentryCreateBrowserRouter([
|
||||
{
|
||||
path: '/landing',
|
||||
@@ -201,11 +215,14 @@ export const router = sentryCreateBrowserRouter([
|
||||
{ path: 'network-diagrams/new', element: page(DiagramEditorPage) },
|
||||
{ path: 'network-diagrams/:id', element: page(DiagramEditorPage) },
|
||||
{ path: 'kb-accelerator', element: page(KBAcceleratorPage) },
|
||||
{ path: 'assistant', element: page(AssistantChatPage) },
|
||||
{ path: 'assistant/:sessionId', element: page(AssistantChatPage) },
|
||||
// Phase 1 — FlowPilot migration. The unified chat-primary surface lives at
|
||||
// /pilot; /assistant permanently redirects. FlowPilotSessionPage (old
|
||||
// guided surface) is no longer mounted.
|
||||
{ path: 'pilot', element: page(AssistantChatPage) },
|
||||
{ path: 'pilot/:sessionId', element: page(AssistantChatPage) },
|
||||
{ path: 'assistant', element: <Navigate to="/pilot" replace /> },
|
||||
{ path: 'assistant/:sessionId', element: <AssistantSessionRedirect /> },
|
||||
{ path: 'flow-assist', element: page(FlowAssistPage) },
|
||||
{ path: 'pilot', element: page(FlowPilotSessionPage) },
|
||||
{ path: 'pilot/:sessionId', element: page(FlowPilotSessionPage) },
|
||||
{ path: 'escalations', element: page(EscalationQueuePage) },
|
||||
{ path: 'queue', element: page(SessionQueuePage) },
|
||||
{ path: 'review-queue', element: page(ReviewQueuePage) },
|
||||
@@ -227,7 +244,9 @@ export const router = sentryCreateBrowserRouter([
|
||||
),
|
||||
children: [
|
||||
{ index: true, element: page(AdminDashboardPage) },
|
||||
{ path: 'users', element: page(AdminUsersPage) },
|
||||
{ path: 'accounts', element: page(AdminAccountsPage) },
|
||||
{ path: 'accounts/:accountId', element: page(AdminAccountDetailPage) },
|
||||
{ path: 'users', element: page(AdminAccountsPage) },
|
||||
{ path: 'users/:userId', element: page(AdminUserDetailPage) },
|
||||
{ path: 'invite-codes', element: page(AdminInviteCodesPage) },
|
||||
{ path: 'audit-logs', element: page(AdminAuditLogsPage) },
|
||||
|
||||
@@ -18,6 +18,109 @@ export interface ActivityEntry {
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface AdminUserListItem {
|
||||
id: string
|
||||
email: string
|
||||
name: string
|
||||
role: string
|
||||
is_super_admin: boolean
|
||||
is_active: boolean
|
||||
account_id: string | null
|
||||
account_role: string | null
|
||||
account_name: string | null
|
||||
account_display_code: string | null
|
||||
created_at: string
|
||||
last_login: string | null
|
||||
deleted_at: string | null
|
||||
}
|
||||
|
||||
export interface AdminUserListResponse {
|
||||
items: AdminUserListItem[]
|
||||
total: number
|
||||
page: number
|
||||
per_page: number
|
||||
}
|
||||
|
||||
export interface AdminAccountMember {
|
||||
id: string
|
||||
email: string
|
||||
name: string
|
||||
role: string
|
||||
is_super_admin: boolean
|
||||
is_active: boolean
|
||||
account_role: string | null
|
||||
created_at: string
|
||||
last_login: string | null
|
||||
deleted_at: string | null
|
||||
}
|
||||
|
||||
export interface AdminAccountOwnerSummary {
|
||||
id: string
|
||||
name: string
|
||||
email: string
|
||||
}
|
||||
|
||||
export interface AdminAccountSubscriptionSummary {
|
||||
id: string
|
||||
plan: string
|
||||
status: string
|
||||
billing_interval: string | null
|
||||
current_period_end: string | null
|
||||
cancel_at_period_end: boolean
|
||||
}
|
||||
|
||||
export interface AdminAccountUsageSummary {
|
||||
tree_count: number
|
||||
session_count_this_month: number
|
||||
}
|
||||
|
||||
export interface AdminAccountListItem {
|
||||
id: string
|
||||
name: string
|
||||
display_code: string
|
||||
created_at: string
|
||||
owner_id: string | null
|
||||
owner: AdminAccountOwnerSummary | null
|
||||
subscription: AdminAccountSubscriptionSummary | null
|
||||
usage: AdminAccountUsageSummary
|
||||
member_count: number
|
||||
active_member_count: number
|
||||
pending_invite_count: number
|
||||
sso_enabled: boolean
|
||||
branding_company_name: string | null
|
||||
members: AdminAccountMember[]
|
||||
}
|
||||
|
||||
export interface AdminAccountListResponse {
|
||||
items: AdminAccountListItem[]
|
||||
total: number
|
||||
page: number
|
||||
per_page: number
|
||||
}
|
||||
|
||||
export interface AdminAccountInviteSummary {
|
||||
id: string
|
||||
email: string
|
||||
role: string
|
||||
expires_at: string | null
|
||||
created_at: string
|
||||
used_at: string | null
|
||||
}
|
||||
|
||||
export interface AdminAccountDetailResponse extends AdminAccountListItem {
|
||||
invites: AdminAccountInviteSummary[]
|
||||
}
|
||||
|
||||
export interface AdminAccountCreate {
|
||||
name: string
|
||||
plan: 'free' | 'pro' | 'team'
|
||||
owner_email?: string
|
||||
}
|
||||
|
||||
export interface AdminAccountUpdate {
|
||||
name: string
|
||||
}
|
||||
|
||||
export interface AuditLogEntry {
|
||||
id: string
|
||||
user_id: string
|
||||
@@ -164,7 +267,7 @@ export interface AdminUserCreate {
|
||||
name: string
|
||||
account_mode: 'existing' | 'personal'
|
||||
account_display_code?: string
|
||||
account_role?: 'engineer' | 'viewer'
|
||||
account_role?: 'owner' | 'admin' | 'engineer' | 'viewer'
|
||||
send_email: boolean
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user