6 Commits

Author SHA1 Message Date
chihlasm
0bda590537 fix: use get_admin_db for all new admin account endpoints
All admin endpoints query across tenants without a tenant context.
get_db (app-role, subject to RLS) was never imported and would crash
at runtime — replace all 6 occurrences with get_admin_db (BYPASSRLS).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 07:47:42 +00:00
chihlasm
2dbb8b6abf refactor: design critique fixes for account pages
- Admin accounts: replace dense card grid with compact DataTable
- Account settings: remove redundant hero card, stat grid, header pills
- Fix bg-accent (orange) misuse on decorative elements across 7 files
- Add ConfirmButton for destructive actions (deactivate, remove member)
- Replace single-field modals with inline editing (plan, trial)
- Add contextual help: display code tooltip, improved empty states
- Non-owner aside explanation for hidden owner-only sections
- Admin sidebar: group 11 items into 5 labeled sections
- Rename UsersPage.tsx → AccountsPage.tsx to match route
- Fix border radius consistency, hide zero-count badges

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 04:54:26 +00:00
chihlasm
9452e5d408 fix: remove unused admin account icon import 2026-04-12 04:54:26 +00:00
chihlasm
e002fe4969 feat: add admin account detail management 2026-04-12 04:54:26 +00:00
chihlasm
7cbc9fe224 feat: expand admin customer account controls 2026-04-12 04:54:26 +00:00
chihlasm
70242ad037 feat: reorganize admin panel around accounts 2026-04-12 04:54:26 +00:00
112 changed files with 463 additions and 19606 deletions

View File

@@ -1,154 +0,0 @@
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

View File

@@ -1,19 +0,0 @@
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

View File

@@ -1,43 +0,0 @@
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"

View File

@@ -2,30 +2,6 @@
All notable changes to ResolutionFlow are documented here. All notable changes to ResolutionFlow are documented here.
## [0.1.0.0] - 2026-04-16
### Added
- **PSA Ticket Management** — dedicated `/tickets` page with URL-param filter state (board, status, priority, company, assignment, closed), paginated ticket list, and slide-in detail panel
- **TicketDetailPanel** — full ticket view with notes feed, configurations, related tickets, and resource manager; optimistic status updates via dropdown
- **NewTicketModal** — two-tab ticket creation: "Quick Create (AI)" parses natural language into a pre-filled form via Claude, "Full Form" for manual entry; validates required fields before submitting to CW
- **AiTicketParseForm** — natural language → structured ticket data using Claude; resolves board and assignee automatically, flags fields needing manual selection
- **TicketResourceManager** — add/remove CW members as ticket resources with member search autocomplete
- **Spin-off ticket creation from ResolutionAssist** — AI can detect when a new ticket should be created mid-session and surface the NewTicketModal pre-filled with session context
- **TicketQueue improvements** — dashboard widget now detects member mapping, caps at 5 items, shows "View All" link to `/tickets`
- **Board statuses endpoint** — `GET /integrations/boards/{board_id}/statuses` for direct status lookup without a ticket context
- **Paginated ticket search** — `search_tickets` returns `{items, total, page, page_size}`; parallel CW count fetch for accurate totals
- **Ticket service layer** — `ticket_service.py` wraps all PSA mutations (create, update status, list/add/remove resources)
- **Priority lookup endpoint** — `GET /integrations/tickets/priorities` for form dropdowns
- **PSA error surfacing** — `/tickets` page shows inline error banner with specific guidance when CW returns a permissions error (replaces silent empty state)
### Fixed
- CW query injection: sanitize search `query` string to strip single quotes before interpolation into CW conditions
- `company_id` filter now correctly applied to CW ticket search conditions (was silently ignored)
- `linkedTicket` fetch in ResolutionAssist guarded with `currentChatRef` to prevent race condition on session switch
- Members endpoint auth gate no longer rejects engineers without a PSA mapping
- Board fallback: ticket list derives available boards from ticket data when the boards API returns empty (permissions)
- Assignment search and "Load More" removed from resource manager in favor of direct member list
## [Unreleased] ## [Unreleased]
### Added ### Added
@@ -35,7 +11,6 @@ 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 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 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 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) - **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 - **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 - **Client communication improvements** — new `request_info` audience type for client-facing information requests, improved status update and email draft prompts with per-context guidance
@@ -58,7 +33,6 @@ 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. - **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. - **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. - **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 - 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 - Script Library tab ownership filter now preserved across category and search changes
- Race conditions in script builder session creation and slug generation - Race conditions in script builder session creation and slug generation

513
CLAUDE.md
View File

@@ -1,6 +1,6 @@
# CLAUDE.md - Patherly / ResolutionFlow Project Context # CLAUDE.md - Patherly / ResolutionFlow Project Context
> **Last Updated:** April 16, 2026 > **Last Updated:** April 6, 2026
--- ---
@@ -20,12 +20,18 @@
| Docker containers | `resolutionflow_postgres`, `resolutionflow_frontend`, `resolutionflow_backend` | | Docker containers | `resolutionflow_postgres`, `resolutionflow_frontend`, `resolutionflow_backend` |
| Backend, frontend UI, production URLs | **ResolutionFlow** | | Backend, frontend UI, production URLs | **ResolutionFlow** |
- **Brand assets:** `brand-assets/` (source SVGs), `frontend/src/assets/brand/` (app assets), `frontend/public/icons/` (favicon) - **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 - **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. - **Layout:** Icon rail sidebar (72px default) with hover flyout panels. Pinnable to full 260px sidebar. See [DESIGN-SYSTEM.md](DESIGN-SYSTEM.md)
- **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. `tree_type` column values unchanged in DB. - **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) - **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 ## Implementation Principles
- Prefer correct architecture over minimal diff - Prefer correct architecture over minimal diff
@@ -53,10 +59,22 @@
## Tech Stack ## Tech Stack
### Backend ### Backend
Python FastAPI, PostgreSQL 16 (async SQLAlchemy 2.0 + asyncpg), Alembic, JWT (python-jose) + bcrypt, Pydantic v2, APScheduler 3.x
- **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 ### Frontend
React 19 + Vite + TypeScript, Tailwind CSS v4 (CSS-only config in `index.css`), Zustand (immer + zundo), React Router v7, Axios, Lucide React
- **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
--- ---
@@ -64,23 +82,37 @@ React 19 + Vite + TypeScript, Tailwind CSS v4 (CSS-only config in `index.css`),
``` ```
patherly/ patherly/
├── backend/app/ ├── backend/
│ ├── main.py # FastAPI entry point │ ├── app/
│ ├── api/endpoints/ # Route handlers │ ├── main.py # FastAPI entry point
│ ├── api/deps.py # Auth dependencies │ ├── api/endpoints/ # Route handlers (auth, trees, sessions, admin, steps, survey, copilot, assistant_chat, integrations)
├── core/ # config, database, permissions, security, audit, rate_limit ├── flow_proposals.py # Knowledge Flywheel review queue CRUD
├── models/ # SQLAlchemy models │ │ └── flowpilot_analytics.py # FlowPilot dashboard metrics
│ ├── schemas/ # Pydantic schemas │ ├── api/deps.py # Auth dependencies (includes require_team_admin)
└── services/psa/ # PSA provider abstraction (connectwise/, autotask/, halopsa/) │ ├── api/router.py # Route registration
├── backend/alembic/ # Migrations (001-070 sequential, then hash IDs) │ │ ├── core/ # config, database, permissions, security, audit, rate_limit
├── backend/tests/ # pytest integration tests │ │ ├── models/ # SQLAlchemy models (includes FlowProposal)
├── frontend/src/ │ │ ├── schemas/ # Pydantic schemas
│ ├── api/ # Axios client + endpoint modules │ ├── services/psa/ # PSA provider abstraction (base, connectwise/, autotask/, halopsa/, cache, encryption, registry, types)
│ ├── components/ # UI components │ ├── services/knowledge_flywheel.py # AI session analysis → flow proposals
├── hooks/ # usePermissions, useSessionTimer, etc. │ ├── services/knowledge_flywheel_scheduler.py # APScheduler job for batch analysis
├── pages/ # Page components │ └── services/knowledge_gap_service.py # Weak options & escalation signal detection
│ ├── store/ # Zustand stores │ ├── alembic/ # Database migrations (001-070 sequential, then hash IDs)
── types/ # TypeScript interfaces ── scripts/ # seed_data.py, seed_trees.py
│ └── tests/ # pytest integration tests
├── 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 └── docs/plans/ # Design docs & implementation plans
``` ```
@@ -111,163 +143,249 @@ VITE_API_URL=http://localhost:8000
## ConnectWise PSA Integration ## ConnectWise PSA Integration
All reference materials in `docs/connectwise/`. See [CONNECTWISE-API-REFERENCE.md](docs/connectwise/CONNECTWISE-API-REFERENCE.md) first. ResolutionFlow integrates with ConnectWise PSA (formerly Manage) as the primary PSA integration. All ConnectWise API reference materials live in `docs/connectwise/`.
### Best Practices Documentation ### Best Practices Documentation
Read `docs/connectwise/best-practices/` BEFORE implementing any CW API integration code: 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, condition syntax, PATCH format. READ FIRST. - `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 matrix, HMAC verification. - `PSA-Callbacks.md` — Callback type/level matrix, retry behavior, URL parameter gotcha, HMAC signature verification.
- `PSA-Pagination.md` — Forward-Only vs Navigable, Link headers. - `PSA-Pagination.md` Navigable vs Forward-Only pagination, Link headers, while-loop pattern.
- `PSA-Service-Tickets.md` — Ticket field mappings. - `PSA-Service-Tickets.md` — Ticket field philosophy, recommended field mappings.
- `PSA-Versioning.md` — Pin `application/vnd.connectwise.com+json; version=2025.16`. - `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 via `/login/companyinfo/{companyId}`. - `PSA-Cloud-URL-Formatting.md` — Dynamic base URL construction via `/login/companyinfo/{companyId}`.
- `Bundled-Requests.md` — Batch via `/system/bundles`. - `Bundled-Requests.md` — Batch multiple API calls into one request via `/system/bundles`.
- `PSA-Markdown.md`Notes support markdown. - `PSA-Markdown.md`Ticket notes support markdown. Format session documentation output accordingly.
- `PSA-Company-Synchronization.md` — Filter companies by Status/Type. - `PSA-Company-Synchronization.md` — Filter companies by Status/Type for mapping UI.
- `PSA-Data-Protection.md`Request minimal permissions (MY not ALL). - `PSA-Data-Protection.md`Security role model, request minimal permissions (MY not ALL).
### Reference Files (read in this order) ### Reference Files (read in this order)
1. `docs/connectwise/CONNECTWISE-API-REFERENCE.md`Auth patterns, endpoint map, field mappings. 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 (670 endpoints, 342 schemas). 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`Full spec (1838 endpoints). Only if you need something outside the subset. 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 ### Key Implementation Rules
- Auth: API Key auth (Base64 of `companyId+publicKey:privateKey`) + `clientId` header on every request - 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 ResolutionFlow app, NOT per-tenant. Per-connection: `company_id`, `public_key`, `private_key`, `server_url` - `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 code in `services/psa/``PSAProvider` abstract base, `ConnectWiseProvider` impl, `PsaProviderRegistry` for multi-PSA dispatch - 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 - PSA endpoints in `api/endpoints/integrations.py` — connection CRUD, ticket ops, member mapping
- Credentials encrypted via `services/psa/encryption.py` (Fernet); stored per-team, never per-user - 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 - In-memory TTL cache in `services/psa/cache.py` for board/status/priority lookups
- Integration flows: Session → Ticket Notes via `POST /service/tickets/{id}/notes`; Ticket Context → FlowPilot via ticket details/company/configs; Callbacks via `/system/callbacks` - Respect CW API: paginate with max 1000 per page, handle retries gracefully
--- ---
## Development Commands ## Development Commands
```bash ```powershell
# PostgreSQL (run from VPS SSH — docker not available in code-server, see Lesson 103) # Start PostgreSQL (run from VPS SSH — docker not available inside code-server, see Lesson 103)
docker start resolutionflow_postgres docker start resolutionflow_postgres
# Backend (from backend/) # Backend (from backend/)
source venv/bin/activate source venv/bin/activate # Linux/Mac
# .\venv\Scripts\Activate # Windows
uvicorn app.main:app --reload uvicorn app.main:app --reload
# Frontend (from frontend/) — requires Node 20 (use nvm: nvm use 20) # Frontend (from frontend/)
npm run dev npm run dev
# Tests (from backend/) # Run tests (from backend/)
pytest --override-ini="addopts=" pytest --override-ini="addopts="
# TypeScript check (use in code-server — avoids EACCES on dist/, see Lesson 105) # First time only: create test database
npx tsc -b docker exec -it resolutionflow_postgres psql -U postgres -c "CREATE DATABASE resolutionflow_test;"
# Frontend build stricter than tsc, always use as final check before push # Frontend build (IMPORTANT: stricter than tsc --noEmit — always use as final check)
cd frontend && npm run build cd frontend && npm run build
# Migrations # Database migrations
cd backend && alembic upgrade head cd backend && alembic upgrade head
alembic revision --autogenerate -m "Description" # do NOT pass --rev-id; Alembic generates hash IDs alembic revision --autogenerate -m "Description"
# Sequential 3-digit IDs (001070) 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 (VPS SSH) # Access PostgreSQL (run from VPS SSH — docker not available inside code-server, see Lesson 103)
docker exec -it resolutionflow_postgres psql -U postgres -d resolutionflow docker exec -it resolutionflow_postgres psql -U postgres -d resolutionflow
# CI runs on Gitea (NOT GitHub Actions): https://gitea.resolutionflow.com/chihlasm/resolutionflow/actions # 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
``` ```
### URLs & Test Users ### URLs
- Frontend: `http://localhost:5173` | Backend: `http://localhost:8000` | API Docs: `http://localhost:8000/api/docs` - Frontend: <http://localhost:5173>
- Test password: `TestPass123!` — users: `admin@`, `teamadmin@`, `engineer@`, `pro@` (all `@resolutionflow.example.com`) - Backend API: <http://localhost:8000>
- API Docs: <http://localhost:8000/api/docs>
### Test Users (seeded via `scripts/seed_test_users.py`)
- 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)
--- ---
## Critical Lessons Learned ## Critical Lessons Learned
> Lessons 1-70 archived to `docs/LESSONS-ARCHIVE.md` — fixes are baked into the codebase. > Lessons 1-40 archived to `docs/LESSONS-ARCHIVE.md` — fixes are baked into the codebase. Consult if you hit a regression.
**71. Enhancement/branch_addition proposals cannot be directly approved:** Backend returns 400 — requires `modified_flow_data` via "Edit & Publish". Only `new_flow` proposals support direct approve. ### Active Lessons (41+)
**72. `ai_sessions.status` column is `VARCHAR(30)`:** Must fit `requesting_escalation` (23 chars). Verify length when adding new status values. **41. Assistant chat uses local React state, not Zustand:** `AssistantChatPage.tsx` uses `useState` for `chats`, `messages`, `input`, `loading`. No store.
**73. `get_db` rolls back on exception:** Prevents `InFailedSQLTransaction` cascade. Never remove the `await session.rollback()` in the dependency. **42. Public pages use raw `fetch()`, not `apiClient`:** Survey, shared sessions, and no-auth pages use `fetch()` with full URL. `apiClient` requires auth tokens.
**74. FlowPilot action bar height chain:** `ViewTransitionOutlet` wrapper needs `flex flex-col`. If action bar disappears, walk `getBoundingClientRect()` from `app-shell` down. **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).
**75. Dashboard prefill auto-submits:** `StartSessionInput` passes `{ state: { prefill } }`. Both `FlowPilotSessionPage` and `AssistantChatPage` auto-submit via `useEffect` + `prefillHandledRef` guard. **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]`.
**76. Active session navigation guard:** `FlowPilotSessionPage` uses `useBlocker` to intercept navigation. "Pause & Leave" auto-pauses before proceeding. **45. Intake form field schema:** Uses `variable_name` and `field_type` (NOT `name` and `type`).
**77. Prefer manual Alembic migrations for targeted changes:** `--autogenerate` picks up all table drift. For single-column fixes, use `alembic revision -m "desc"` and write `op.alter_column()` manually. **46. `CreateFlowDropdown` uses `AIPromptDialog`:** Opens prompt modal, starts AI session, generates flow, navigates to editor with `{ state: { aiPanelOpen: true, sessionId } }`.
**78. Landing page subtitle is "AI-Powered Troubleshooting for MSPs":** Appears on login, register, and `<title>`. Not "Decision Tree Platform". **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.
**79. Custom modals must be mobile-responsive:** Use `items-end sm:items-center` + `max-w-full sm:max-w-lg`. See `Modal.tsx` and `PrepareSessionModal.tsx`. **48. Tree orphan validation uses dynamic root ID:** Orphan check compares against `state.treeStructure?.id` (NOT hardcoded `'root'`).
**80. TopBar search collapses to icon on mobile:** Full bar (`hidden sm:block`) + icon fallback (`sm:hidden`). Both open `CommandPalette`. **49. Full-stack features — verify both ends:** Check the full data flow: schema → endpoint → API client → hook → store → UI.
**81. Never use `transition: all` in landing.css:** Specify exact properties. `transition: all` animates layout and causes jank. **50. Anthropic SDK retry:** Set `max_retries=1` to fail fast. Default `max_retries=2` can take 3× timeout.
**82. `bun` requires PATH setup:** `export BUN_INSTALL="$HOME/.bun" && export PATH="$BUN_INSTALL/bin:$PATH"`. Chromium deps: `libatk1.0-0 libatk-bridge2.0-0 libcups2 libxkbcommon0 libatspi2.0-0 libxcomposite1 libxdamage1 libxfixes3 libxrandr2 libgbm1 libasound2`. **51. AI model tier routing:** Use `settings.get_model_for_action(action_type)`. Model IDs: use alias form (`claude-sonnet-4-6`).
**84. AI session `abandoned` status is fully wired:** `POST /ai-sessions/{id}/abandon` with optional `reason`. Frontend: `aiSessionsApi.abandonSession()``useFlowPilotSession().abandonSession()`. **52. Mobile scroll-to-top:** Use `ref.current.scrollIntoView()`, not `window.scrollTo()`. Trigger via `useEffect`.
**85. Date range filter end dates must use end-of-day:** Set `toDate.setHours(23, 59, 59, 999)`. For string inputs append `T23:59:59.999Z`. See `SessionHistoryPage.tsx`. **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.
**86. Script Builder:** `/script-builder``ScriptBuilderSession` model, `script_builder_service.py`, endpoints at `/scripts/builder/`. FlowPilot handoff via `action_type: "open_script_builder"` + sessionStorage context. **54. React Flow CSS in Tailwind v4:** Import in `index.css`, not component JS. Override dark theme using `--xy-*` CSS custom properties.
**87. FlowPilot must ask GUI vs script preference:** Ask BEFORE suggesting either approach. See `FLOWPILOT_SYSTEM_PROMPT` in `flowpilot_engine.py`. **55. App shell height chain:** Every wrapper between `.main-content` and canvas needs `flex` + `flex-1` + `min-h-0` or `h-full`.
**88. Charcoal palette:** Sidebar `#0e1016`, page `#16181f`, cards `#1e2028`, borders `#2a2e3a`. All via CSS variables in `index.css` `@theme`. Accent is electric blue (#60a5fa). **56. Railway backend service name is `patherly`:** Production DB name is `railway`. Public Postgres proxy: `interchange.proxy.rlwy.net:45797`.
**92. `tsc -b` in Dockerfile enforces `noUnusedLocals`/`noUnusedParameters` as hard errors.** After refactors, trace every import and destructured prop. Check IDE yellow squiggles before pushing. **57. Node field priority:** `title``question``description``content``label`. See `copilot_service.py`.
**93. FlowPilot actions live in the page header, not a bottom bar:** Resolve/Escalate/Share Update in header. Desktop: inline + `⋯` overflow (Pause/Close). Mobile: single `⋯`. Bottom = message input only. **58. `scriptGeneratorStore.generate()` optional param:** Always wrap: `onClick={() => generate()}`, never `onClick={generate}`.
**94. Frontend chat uses `unified_chat_service`, not `assistant_chat_service`:** `AssistantChatPage` `/ai-sessions/{id}/chat``unified_chat_service.py`. Never wire chat into `assistant_chat.py`. **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`.
**95. Image upload → AI vision:** `uploadsApi.upload()` `upload_ids` in message → backend fetches S3 → `storage_service.resize_image_for_vision()` (Pillow, 1568px, PNG→JPEG) → base64 → Claude multimodal. Max 3 images/message. Images NOT stored in history. **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.
**96. `bg-accent` is electric blue — never use for code/kbd.** Use `bg-code` for code blocks, `bg-white/[0.12]` for inline code/badges, `bg-white/[0.08]` for kbd. **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.
**97. Railway S3 provisioned:** Bucket `resolutionflow-uploads`. Variables: `STORAGE_ENDPOINT`, `STORAGE_ACCESS_KEY`, `STORAGE_SECRET_KEY`, `STORAGE_BUCKET_NAME`, `STORAGE_REGION`. boto3 in `storage_service.py`. **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.
**98. `lazyWithRetry` for lazy routes:** Use instead of `React.lazy` — auto-reloads on chunk failures with 10s sessionStorage debounce. **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"`.
**99. `text-secondary` renders invisible on dark backgrounds:** Maps to `--color-secondary` (dark surface). Use `text-muted-foreground` (`#848b9b`) for readable secondary text. Never use `text-muted` for body text. **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.
**100. Hover pop-out card pattern:** `pointer-events-none` on scrim (`z-40`), `z-50` expanded card with own `onClick`, dismiss via `onMouseLeave`. Never put handlers on scrim. **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`.
**101. AI marker format compliance:** `[QUESTIONS]`, `[ACTIONS]`, `[FORK]` parsed by `unified_chat_service.py`. History stores `display_content` (stripped). Each user message gets `[SYSTEM: ...]` reminder appended in `_call_anthropic_cached()`. **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.
**102. TaskLane activation must happen in ALL chat response paths:** Three paths in `AssistantChatPage.tsx``handleSend`, `sendPrefill`, `handleResumeNew`. All must check `response.actions`/`response.questions` and call `setShowTaskLane(true)`. **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.
**103. Docker not available in code-server:** Use VPS SSH: `docker exec resolutionflow_postgres psql -U postgres -d resolutionflow -t -c "SQL"`. Python also not available in container. **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`.
**104. `landing.css` uses `--lp-*` variables:** Never use `var(--color-*)` tokens in `landing.css`. Extend the `--lp-*` palette for new landing page colors. **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.
**105. `npm run build` fails with `EACCES` on `dist/` in code-server:** Use `npx tsc -b` to verify TypeScript without writing to `dist/`. **70. Toast library uses `toast.warning()` not `toast.warn()`:** Import from `@/lib/toast`. Methods: `success`, `error`, `warning`, `info`. See `frontend/src/lib/toast.ts`.
**106. Guard async "select item → load data → apply state" flows:** Use `currentSelectionRef = useRef(id)` — update on every switch, bail after each `await` if ref no longer matches. See `AssistantChatPage.tsx` `currentChatRef`. **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.
**107. Startup routines use `_admin_session_factory()`:** RLS is enabled; `get_db()` at startup has no `app.current_account_id`, so queries return 0 rows. Affects lifespan, `ensure_service_account`, seed scripts. **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.
**108. Tables with no `account_id` (never add to RLS migrations):** `script_categories`, `platform_steps`, `template_trees`, `plan_feature_defaults`, `accounts`. Scan at class level, not file level — one `.py` file can have multiple classes with different columns. **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.
**109. `tree_shares.account_id` must equal `tree.account_id`:** Use tree owner's tenant, not the actor's. Cross-tenant admin shares become invisible after RLS enforcement. **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.
**110. Backfill `account_id` migrations require service-code audit:** Grep all `ModelClass(` sites, verify `account_id=` is passed. SQLAlchemy accepts `None` silently; RLS WITH CHECK surfaces it at runtime as `InsufficientPrivilegeError`. **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.
**111. Global Axios interceptor fires before component `.catch()`:** Fix optional-data endpoints at the source — return `[]`/`{}` on provider failure instead of raising 502. See `list_boards` in `integrations.py`. **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 8991 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 ## RBAC & Permissions
- **Role hierarchy:** super_admin > team_admin > engineer > viewer - **Role hierarchy:** super_admin > team_admin > engineer > viewer
- **Team Admin:** `role='engineer'` + `is_team_admin=True` + valid `team_id` - **Team Admin:** `role='engineer'` + `is_team_admin=True` + valid `team_id`
- **Backend deps:** `get_current_active_user`, `require_engineer_or_admin` (blocks viewers), `require_admin` (super admin only) - **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 - **Never use** `role == "admin"` — use `is_super_admin` instead
- **Frontend:** `usePermissions()` hook for all permission checks - **Frontend:** `usePermissions()` hook for all permission checks
- **Centralized:** `backend/app/core/permissions.py`, `frontend/src/hooks/usePermissions.ts` - **Centralized:** `backend/app/core/permissions.py`, `frontend/src/hooks/usePermissions.ts`
@@ -276,16 +394,18 @@ docker exec -it resolutionflow_postgres psql -U postgres -d resolutionflow
## Design System ## Design System
**Source of truth:** [DESIGN-SYSTEM.md](DESIGN-SYSTEM.md) — always read before visual/UI decisions. **Source of truth:** [DESIGN-SYSTEM.md](DESIGN-SYSTEM.md) — always read this before making visual or UI decisions.
- **Theme:** Flat, high-contrast dark (Sentry/PostHog-inspired). No glass morphism, no backdrop blur, no gradients on surfaces. Fonts: IBM Plex Sans (body), Bricolage Grotesque (headings), JetBrains Mono (code). - **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`) - **Backgrounds:** `bg-page` (`#16181f`), `bg-sidebar` (`#0e1016`), `bg-card` (`#1e2028`), `bg-elevated` (`#2a2d38`)
- **Cards:** `bg-card` + 1px `border-default` (`#2a2e3a`), 8px radius. Hover: `border-hover` (`#3d4252`) - **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 / #2563eb), white text, 5px radius. Ghost: transparent + 1px border. - **Buttons:** Primary: solid `accent` (#60a5fa dark / #2563eb light), white text, 5px radius. Ghost: transparent + 1px border, hover `bg-elevated`
- **Inputs:** `bg-input` (`#252830`) + 1px `border-default`, 5px radius. Focus: `border-color: accent` + `box-shadow: 0 0 0 2px accent-dim` - **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``text-primary``text-muted-foreground` (`#848b9b`). **NEVER `text-secondary`** — maps to a dark surface color. - **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.
- **Functional colors:** `#34d399` success, `#fbbf24` warning, `#f87171` danger, `#67e8f9` info — each has `-dim` at 10% opacity - **Borders:** `border-default` (`#2a2e3a`), `border-hover` (`#3d4252`)
- **Deprecated:** No `glass-card`, `backdrop-filter: blur()`, ambient orbs, ember orange (`#f97316`), or cyan as accent - **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
--- ---
@@ -293,21 +413,23 @@ docker exec -it resolutionflow_postgres psql -U postgres -d resolutionflow
- **Component guidelines:** Use `cn()` from `@/lib/utils`, Lucide icons (wrap in `<span>` for title), modals with fixed header/footer - **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'` - **Type organization:** Create in `types/`, export from `types/index.ts`, import with `import type { T } from '@/types'`
- **Custom step flow:** `CustomStepModal``PostStepActionModal` `ContinuationModal`. Use `findCustomStep()` not `findNode()` for custom step UUIDs. - **Scratchpad overlay:** `position: fixed`, `onOpenChange` callback for parent padding adjustment, `right-2` positioning
- **Session sharing:** `ShareSessionModal` + `SharedSessionPage`. Utils in `lib/sessionShare.ts`. Share URLs: `/shared/sessions/:token`. - **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. - **Routing helper:** Use `getTreeNavigatePath()` and `getTreeEditorPath()` from `@/lib/routing` for all tree/session navigation.
- **Account section:** `AccountLayout` has NO sidebar nav. New account pages: route under `account` children in `router.tsx` + link card in `AccountSettingsPage`. - **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` `StartSessionInput` + `PendingEscalations`, `ActiveFlowPilotSessions`, `RecentFlowPilotSessions`. Collapsible section for `PerformanceCards`, `KnowledgeBaseCards`, `TeamSummary`. - **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:** Amber "New Session" → Home → RESOLVE → KNOWLEDGE (Flows, Scripts) → INSIGHTS. Footer: Account, Pin/Unpin. - **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.
--- ---
## Common Tasks ## Common Tasks
- **New endpoint:** Create in `endpoints/` → add to `router.py` → schema in `schemas/` → tests → frontend API client - **New endpoint:** Create in `endpoints/` → add to `router.py` → schema in `schemas/` → tests → frontend API client
- **New page:** Create in `pages/` → route in `router.tsx` → nav link in `AppLayout.tsx` - **New page:** Create in `pages/` add route in `router.tsx` → nav link in `AppLayout.tsx`
- **New public route:** Add at top level in `router.tsx` (alongside `/login`) — NOT inside `ProtectedRoute`/`AppLayout` - **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 -m "desc"` (no `--rev-id`) → review → `alembic upgrade head` - **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` - **New frontend API module:** Types in `types/` → export from `types/index.ts` → client in `api/` → export from `api/index.ts`
--- ---
@@ -315,41 +437,79 @@ docker exec -it resolutionflow_postgres psql -U postgres -d resolutionflow
## Coding Standards ## Coding Standards
### Python ### Python
Type hints everywhere, async/await for DB, Pydantic validation, `DateTime(timezone=True)` always.
- Type hints everywhere, async/await for DB, Pydantic for validation, `DateTime(timezone=True)` always
### TypeScript ### TypeScript
Interfaces for all data, `const` over `let`, functional components + hooks.
- Interfaces for all data, `const` over `let`, functional components + hooks, reusable logic in custom hooks
### Git ### Git
- Format: `type: description` (feat, fix, refactor, docs, test, chore) - Format: `type: description` (feat, fix, refactor, docs, test, chore)
- Always include `Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>` - Always include `Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>`
- Create feature branch BEFORE committing: `git checkout -b feat/feature-name` - Always create feature branch BEFORE committing: `git checkout -b feat/feature-name`
- **Remote is Gitea:** Push to `gitea.resolutionflow.com/chihlasm/resolutionflow`. Mirrors to GitHub via `.gitea/workflows/mirror-to-github.yml` — never push directly to GitHub. - Large features: commit per phase with `npm run build` validation
### After Completing Work ### After Completing Work
1. Update `CURRENT-STATE.md`
2. Update `03-DEVELOPMENT-ROADMAP.md` When a feature, fix, or significant piece of work is finished and merged/committed:
3. Close related GitHub Issues: `gh issue close #N`
4. Update `CLAUDE.md` if new patterns or lessons emerged 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
--- ---
## gstack (Browser & Workflow Skills) ## gstack (Browser & Workflow Skills)
**Web browsing:** Always use `/browse`. Never use `mcp__claude-in-chrome__*` tools. **Web browsing:** Always use the `/browse` skill from gstack for all web browsing needs. Never use `mcp__claude-in-chrome__*` tools.
**Skills:** `/office-hours` · `/plan-ceo-review` · `/plan-eng-review` · `/plan-design-review` · `/design-consultation` · `/review` (PR review) · `/ship` · `/browse` (headless QA) · `/qa` (QA + fix) · `/qa-only` · `/design-review` (visual QA) · `/setup-browser-cookies` · `/retro` · `/investigate` · `/document-release` · `/codex` · `/careful` · `/freeze` · `/unfreeze` · `/guard` · `/gstack-upgrade` **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 |
--- ---
## Deployment (Railway) ## Deployment (Railway)
- **Production:** `resolutionflow.com` (frontend), `api.resolutionflow.com` (backend) - **Production:** `resolutionflow.com` (frontend), `api.resolutionflow.com` (backend)
- Deploy pipeline: push to Gitea → mirrors to GitHub → Railway watches `main` - Auto-deploys on push to `main`
- PR envs: need manual domain generation + `VITE_API_URL` with `https://` prefix - 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` - `ALLOW_RAILWAY_ORIGINS=true` enables CORS for `*.up.railway.app`
- Shared Variables auto-propagate to all PR envs — use for `ANTHROPIC_API_KEY` etc. - Shared Variables (project-level in Railway dashboard) auto-propagate to all environments including PR envs — use for secrets like `ANTHROPIC_API_KEY`
- Super admin: `backend/make_superadmin_simple.py list|<email>` - 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
--- ---
@@ -357,49 +517,112 @@ Interfaces for all data, `const` over `let`, functional components + hooks.
| What | Where | | What | Where |
|------|-------| |------|-------|
| API Docs | http://localhost:8000/api/docs | | API Docs | <http://localhost:8000/api/docs> |
| Detailed Status | [CURRENT-STATE.md](CURRENT-STATE.md) | | Detailed Status | [CURRENT-STATE.md](CURRENT-STATE.md) |
| Development Roadmap | [03-DEVELOPMENT-ROADMAP.md](03-DEVELOPMENT-ROADMAP.md) | | Development Roadmap | [03-DEVELOPMENT-ROADMAP.md](03-DEVELOPMENT-ROADMAP.md) |
| GitHub Issues | `gh issue list --state open` | | GitHub Issues | `gh issue list --state open` |
| Bugs & Fixes | CLAUDE.md → Critical Lessons Learned section |
| Design System | [DESIGN-SYSTEM.md](DESIGN-SYSTEM.md) | | Design System | [DESIGN-SYSTEM.md](DESIGN-SYSTEM.md) |
| Dev Environment | [DEV-ENV.md](DEV-ENV.md) — VPS setup, Docker, CORS, networking | | Dev Environment | [DEV-ENV.md](DEV-ENV.md) — 46.202.92.250 setup, Docker, CORS, networking |
<!-- gitnexus:start --> <!-- gitnexus:start -->
# GitNexus — Code Intelligence # GitNexus — Code Intelligence
This project is indexed by GitNexus as **resolutionflow**. Use it selectively — for routine additive work (new endpoints, new components, isolated fixes) just read the files directly. GitNexus earns its cost when you're about to touch something genuinely central with many callers. 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. > If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first.
## When to Use It ## Always Do
**Use GitNexus when:** - **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.
- Touching a core shared symbol with many callers — `flowpilot_engine`, `unified_chat_service`, auth middleware, `get_db`, shared hooks - **MUST run `gitnexus_detect_changes()` before committing** to verify your changes only affect expected symbols and execution flows.
- Renaming anything used across multiple files - **MUST warn the user** if impact analysis returns HIGH or CRITICAL risk before proceeding with edits.
- Tracing an unfamiliar bug through a call chain you haven't read - When exploring unfamiliar code, use `gitnexus_query({query: "concept"})` to find execution flows instead of grepping. It returns process-grouped results ranked by relevance.
- Assessing whether a refactor is safe before starting - When you need full context on a specific symbol — callers, callees, which execution flows it participates in — use `gitnexus_context({name: "symbolName"})`.
**Skip GitNexus when:** ## When Debugging
- Adding a new endpoint, component, or isolated feature
- Fixing a bug in a self-contained file
- Making changes you can already see the full scope of by reading the file
## Useful Tools 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 | | Tool | When to use | Command |
|------|-------------|---------| |------|-------------|---------|
| `query` | Find code by concept when you don't know where to look | `gitnexus_query({query: "auth validation"})` | | `query` | Find code by concept | `gitnexus_query({query: "auth validation"})` |
| `context` | See all callers/callees of a symbol before touching it | `gitnexus_context({name: "symbolName"})` | | `context` | 360-degree view of one symbol | `gitnexus_context({name: "validateUser"})` |
| `impact` | Blast radius check before editing a shared symbol | `gitnexus_impact({target: "X", direction: "upstream"})` | | `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})` | | `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 ## Keeping the Index Fresh
A PostToolUse hook re-indexes automatically after `git commit`. To manually refresh: After committing code changes, the GitNexus index becomes stale. Re-run analyze to update it:
```bash ```bash
npx gitnexus analyze 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 --> <!-- gitnexus:end -->

View File

@@ -1 +0,0 @@
0.1.0.0

View File

@@ -1,132 +0,0 @@
"""Add account-scoped device_types table with platform seed data.
Revision ID: 073
Revises: b3c7e9f2a1d8
Create Date: 2026-04-12
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects.postgresql import UUID
import uuid
revision = "073"
down_revision = "b3c7e9f2a1d8"
branch_labels = None
depends_on = None
_PLATFORM_UUID = "00000000-0000-0000-0000-000000000001"
_CURRENT_ACCOUNT = (
"COALESCE("
"NULLIF(current_setting('app.current_account_id', TRUE), ''), "
"'00000000-0000-0000-0000-000000000000'"
")::uuid"
)
SYSTEM_DEVICE_TYPES = [
("router", "Router", "network", 0),
("switch", "Switch", "network", 1),
("firewall", "Firewall", "network", 2),
("access-point", "Access Point", "network", 3),
("load-balancer", "Load Balancer", "network", 4),
("server", "Server", "compute", 0),
("workstation", "Workstation", "compute", 1),
("vm", "Virtual Machine", "compute", 2),
("container", "Container", "compute", 3),
("nas", "NAS", "storage", 0),
("san", "SAN", "storage", 1),
("cloud-storage", "Cloud Storage", "storage", 2),
("cloud", "Cloud", "cloud", 0),
("aws", "AWS", "cloud", 1),
("azure", "Azure", "cloud", 2),
("gcp", "Google Cloud", "cloud", 3),
("printer", "Printer", "endpoint", 0),
("phone", "Phone", "endpoint", 1),
("iot", "IoT Device", "endpoint", 2),
("camera", "Camera", "endpoint", 3),
("tablet", "Tablet", "endpoint", 4),
("laptop", "Laptop", "endpoint", 5),
("ups", "UPS", "infrastructure", 0),
("pdu", "PDU", "infrastructure", 1),
("rack", "Rack", "infrastructure", 2),
("patch-panel", "Patch Panel", "infrastructure", 3),
("nvr", "NVR", "security", 0),
("badge-reader", "Badge Reader", "security", 1),
]
def upgrade() -> None:
op.create_table(
"device_types",
sa.Column("id", UUID(as_uuid=True), primary_key=True, server_default=sa.text("gen_random_uuid()")),
sa.Column("slug", sa.String(50), nullable=False),
sa.Column("label", sa.String(100), nullable=False),
sa.Column("category", sa.String(50), nullable=False),
sa.Column("is_system", sa.Boolean(), nullable=False, server_default=sa.text("false")),
sa.Column("account_id", UUID(as_uuid=True), sa.ForeignKey("accounts.id", ondelete="CASCADE"), nullable=False),
sa.Column("sort_order", sa.Integer(), nullable=False, server_default=sa.text("0")),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()")),
)
op.create_unique_constraint("uq_device_types_slug_account", "device_types", ["slug", "account_id"])
op.create_index("ix_device_types_account_id", "device_types", ["account_id"])
device_types_table = sa.table(
"device_types",
sa.column("id", UUID(as_uuid=True)),
sa.column("slug", sa.String),
sa.column("label", sa.String),
sa.column("category", sa.String),
sa.column("is_system", sa.Boolean),
sa.column("account_id", UUID(as_uuid=True)),
sa.column("sort_order", sa.Integer),
)
op.bulk_insert(device_types_table, [
{
"id": uuid.uuid4(),
"slug": slug,
"label": label,
"category": category,
"is_system": True,
"account_id": uuid.UUID(_PLATFORM_UUID),
"sort_order": sort_order,
}
for slug, label, category, sort_order in SYSTEM_DEVICE_TYPES
])
op.execute("ALTER TABLE device_types ENABLE ROW LEVEL SECURITY")
op.execute("ALTER TABLE device_types FORCE ROW LEVEL SECURITY")
op.execute(f"""
CREATE POLICY device_types_select ON device_types
FOR SELECT
USING (
account_id = {_CURRENT_ACCOUNT}
OR account_id = '{_PLATFORM_UUID}'::uuid
)
""")
op.execute(f"""
CREATE POLICY device_types_insert ON device_types
FOR INSERT
WITH CHECK (account_id = {_CURRENT_ACCOUNT})
""")
op.execute(f"""
CREATE POLICY device_types_update ON device_types
FOR UPDATE
USING (account_id = {_CURRENT_ACCOUNT})
WITH CHECK (account_id = {_CURRENT_ACCOUNT})
""")
op.execute(f"""
CREATE POLICY device_types_delete ON device_types
FOR DELETE
USING (account_id = {_CURRENT_ACCOUNT})
""")
def downgrade() -> None:
op.execute("DROP POLICY IF EXISTS device_types_delete ON device_types")
op.execute("DROP POLICY IF EXISTS device_types_update ON device_types")
op.execute("DROP POLICY IF EXISTS device_types_insert ON device_types")
op.execute("DROP POLICY IF EXISTS device_types_select ON device_types")
op.execute("ALTER TABLE device_types DISABLE ROW LEVEL SECURITY")
op.drop_table("device_types")

View File

@@ -1,57 +0,0 @@
"""Add network_diagrams table.
Revision ID: 074
Revises: 073
Create Date: 2026-04-12
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects.postgresql import UUID, JSONB
revision = "074"
down_revision = "073"
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:
op.create_table(
"network_diagrams",
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", ondelete="CASCADE"), nullable=False),
sa.Column("name", sa.String(255), nullable=False),
sa.Column("client_name", sa.String(255), nullable=True),
sa.Column("asset_name", sa.String(255), nullable=True),
sa.Column("description", sa.Text(), nullable=True),
sa.Column("nodes", JSONB(), nullable=False, server_default=sa.text("'[]'::jsonb")),
sa.Column("edges", JSONB(), nullable=False, server_default=sa.text("'[]'::jsonb")),
sa.Column("thumbnail_url", sa.Text(), nullable=True),
sa.Column("is_archived", sa.Boolean(), nullable=False, server_default=sa.text("false")),
sa.Column("created_by", UUID(as_uuid=True), sa.ForeignKey("users.id"), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()")),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()")),
)
op.create_index("ix_network_diagrams_account_id", "network_diagrams", ["account_id"])
op.create_index("idx_network_diagrams_account_client", "network_diagrams", ["account_id", "client_name"])
op.execute("ALTER TABLE network_diagrams ENABLE ROW LEVEL SECURITY")
op.execute("ALTER TABLE network_diagrams FORCE ROW LEVEL SECURITY")
op.execute(f"""
CREATE POLICY tenant_isolation ON network_diagrams
USING (account_id = {_CURRENT_ACCOUNT})
WITH CHECK (account_id = {_CURRENT_ACCOUNT})
""")
def downgrade() -> None:
op.execute("DROP POLICY IF EXISTS tenant_isolation ON network_diagrams")
op.execute("ALTER TABLE network_diagrams DISABLE ROW LEVEL SECURITY")
op.drop_table("network_diagrams")

View File

@@ -431,19 +431,10 @@ async def create_account(
current_user: Annotated[User, Depends(require_admin)], current_user: Annotated[User, Depends(require_admin)],
): ):
"""Create a new account without requiring an initial user.""" """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) display_code = await _generate_unique_display_code(db)
new_account = Account( new_account = Account(
name=data.name.strip(), name=data.name.strip(),
display_code=display_code, display_code=display_code,
owner_id=owner_id,
) )
db.add(new_account) db.add(new_account)
await db.flush() await db.flush()
@@ -457,7 +448,7 @@ async def create_account(
await log_audit( await log_audit(
db, current_user.id, "account.create_admin", "account", new_account.id, db, current_user.id, "account.create_admin", "account", new_account.id,
{"name": new_account.name, "plan": data.plan, "owner_email": data.owner_email}, {"name": new_account.name, "plan": data.plan},
) )
await db.commit() await db.commit()
return await _get_account_detail_payload(new_account.id, db) return await _get_account_detail_payload(new_account.id, db)

View File

@@ -1,120 +0,0 @@
"""Device types API endpoints."""
from typing import Annotated
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select, or_
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.api.deps import get_current_active_user
from app.models.user import User
from app.models.device_type import DeviceType
from app.schemas.device_type import (
DeviceTypeCreate,
DeviceTypeUpdate,
DeviceTypeResponse,
)
from app.core.service_account import PLATFORM_ACCOUNT_ID
router = APIRouter(prefix="/device-types", tags=["device-types"])
@router.get("/", response_model=list[DeviceTypeResponse])
async def list_device_types(
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_active_user)],
) -> list[DeviceTypeResponse]:
stmt = (
select(DeviceType)
.where(
or_(
DeviceType.account_id == PLATFORM_ACCOUNT_ID,
DeviceType.account_id == current_user.account_id,
)
)
.order_by(DeviceType.category, DeviceType.sort_order, DeviceType.label)
)
result = await db.execute(stmt)
rows = result.scalars().all()
return [DeviceTypeResponse.model_validate(r) for r in rows]
@router.post("/", response_model=DeviceTypeResponse, status_code=201)
async def create_device_type(
data: DeviceTypeCreate,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_active_user)],
) -> DeviceTypeResponse:
existing = await db.execute(
select(DeviceType).where(
DeviceType.slug == data.slug,
DeviceType.account_id == current_user.account_id,
)
)
if existing.scalar_one_or_none():
raise HTTPException(status_code=409, detail=f"Device type '{data.slug}' already exists for your account")
system_existing = await db.execute(
select(DeviceType).where(
DeviceType.slug == data.slug,
DeviceType.account_id == PLATFORM_ACCOUNT_ID,
)
)
if system_existing.scalar_one_or_none():
raise HTTPException(status_code=409, detail=f"Device type '{data.slug}' conflicts with a system type")
device_type = DeviceType(
slug=data.slug,
label=data.label,
category=data.category,
is_system=False,
account_id=current_user.account_id,
sort_order=data.sort_order,
)
db.add(device_type)
await db.commit()
await db.refresh(device_type)
return DeviceTypeResponse.model_validate(device_type)
@router.put("/{device_type_id}", response_model=DeviceTypeResponse)
async def update_device_type(
device_type_id: UUID,
data: DeviceTypeUpdate,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_active_user)],
) -> DeviceTypeResponse:
device_type = await db.get(DeviceType, device_type_id)
if not device_type:
raise HTTPException(status_code=404, detail="Device type not found")
if device_type.is_system:
raise HTTPException(status_code=403, detail="Cannot modify system device types")
if device_type.account_id != current_user.account_id:
raise HTTPException(status_code=404, detail="Device type not found")
update_data = data.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(device_type, field, value)
await db.commit()
await db.refresh(device_type)
return DeviceTypeResponse.model_validate(device_type)
@router.delete("/{device_type_id}", status_code=204)
async def delete_device_type(
device_type_id: UUID,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_active_user)],
) -> None:
device_type = await db.get(DeviceType, device_type_id)
if not device_type:
raise HTTPException(status_code=404, detail="Device type not found")
if device_type.is_system:
raise HTTPException(status_code=403, detail="Cannot delete system device types")
if device_type.account_id != current_user.account_id:
raise HTTPException(status_code=404, detail="Device type not found")
await db.delete(device_type)
await db.commit()

View File

@@ -1,7 +1,6 @@
"""PSA integration endpoints — connection CRUD and test.""" """PSA integration endpoints — connection CRUD and test."""
from __future__ import annotations from __future__ import annotations
import logging
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Annotated from typing import Annotated
from uuid import UUID from uuid import UUID
@@ -12,8 +11,6 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import delete from sqlalchemy import delete
logger = logging.getLogger(__name__)
from app.api.deps import get_current_active_user, require_account_owner, require_engineer_or_admin from app.api.deps import get_current_active_user, require_account_owner, require_engineer_or_admin
from app.core.database import get_db from app.core.database import get_db
from app.models.psa_connection import PsaConnection from app.models.psa_connection import PsaConnection
@@ -30,20 +27,8 @@ from app.schemas.psa_connection import (
PsaMemberMappingSaveRequest, PsaMemberMappingSaveRequest,
PsaMemberResponse, PsaMemberResponse,
AutoMatchResult, AutoMatchResult,
PSABoardResponse,
) )
from app.core.config import settings from app.core.config import settings
from app.schemas.psa_tickets import (
PSAResourceSchema,
PSATicketCreatedSchema,
PSATicketStatusUpdateSchema,
TicketCreatePayloadSchema,
PSAPrioritySchema,
TicketListResponseSchema,
AiParseRequestSchema,
AiParseResponseSchema,
)
import app.services.ticket_service as ticket_svc
from app.services.psa.encryption import ( from app.services.psa.encryption import (
decrypt_credentials, decrypt_credentials,
encrypt_credentials, encrypt_credentials,
@@ -360,29 +345,7 @@ async def update_flowpilot_settings(
# ── ticket / status / company endpoints ────────────────────────── # ── ticket / status / company endpoints ──────────────────────────
@router.get("/boards", response_model=list[PSABoardResponse]) @router.get("/tickets/search", response_model=list[PSATicketSearchResult])
async def list_boards(
current_user: Annotated[User, Depends(require_engineer_or_admin)],
db: Annotated[AsyncSession, Depends(get_db)],
):
"""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 as e:
# Boards are optional UI chrome — degrade gracefully rather than surfacing a toast
logger.warning("list_boards failed: %s", e)
return []
@router.get("/tickets/search", response_model=TicketListResponseSchema)
async def search_tickets( async def search_tickets(
current_user: Annotated[User, Depends(require_engineer_or_admin)], current_user: Annotated[User, Depends(require_engineer_or_admin)],
db: Annotated[AsyncSession, Depends(get_db)], db: Annotated[AsyncSession, Depends(get_db)],
@@ -390,303 +353,35 @@ async def search_tickets(
board_id: int | None = None, board_id: int | None = None,
status_id: int | None = None, status_id: int | None = None,
include_closed: bool = False, include_closed: bool = False,
assigned_to_me: bool = False,
unassigned: bool = False,
board_ids: str = "",
priority: str | None = None,
company_id: int | None = None,
page: int = 1,
page_size: int = 25,
): ):
"""Search ConnectWise tickets — returns paginated TicketListResponse.""" """Search ConnectWise tickets."""
if not current_user.account_id: if not current_user.account_id:
raise HTTPException(status_code=400, detail="User has no account") raise HTTPException(status_code=400, detail="User has no account")
from app.services.psa.registry import get_provider_for_account from app.services.psa.registry import get_provider_for_account
from app.services.psa.exceptions import PSAError from app.services.psa.exceptions import PSAError
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:
return {"items": [], "total": 0, "page": page, "page_size": page_size}
try:
_provider = await get_provider_for_account(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 {"items": [], "total": 0, "page": page, "page_size": page_size}
except PSAError:
return {"items": [], "total": 0, "page": page, "page_size": page_size}
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: try:
provider = await get_provider_for_account(current_user.account_id, db) provider = await get_provider_for_account(current_user.account_id, db)
result = await provider.search_tickets( tickets = await provider.search_tickets(
query, query, board_id=board_id, status_id=status_id, include_closed=include_closed
board_id=board_id,
status_id=status_id,
include_closed=include_closed,
member_identifier=member_identifier,
unassigned=unassigned,
board_ids=parsed_board_ids,
company_id=company_id,
page=page,
page_size=page_size,
) )
items = [ return [
PSATicketSearchResult( PSATicketSearchResult(
id=t.id, id=t.id,
summary=t.summary, summary=t.summary,
company_name=t.company_name, company_name=t.company_name,
company_id=t.company_id,
board_name=t.board_name, board_name=t.board_name,
board_id=t.board_id,
status_name=t.status_name, status_name=t.status_name,
status_id=t.status_id,
priority_name=t.priority_name, priority_name=t.priority_name,
priority_id=t.priority_id,
closed=t.closed, closed=t.closed,
) )
for t in result.items for t in tickets
] ]
return {"items": items, "total": result.total, "page": result.page, "page_size": result.page_size}
except PSAError as e: except PSAError as e:
raise HTTPException(status_code=502, detail=str(e)) raise HTTPException(status_code=502, detail=str(e))
@router.post("/tickets", response_model=PSATicketCreatedSchema, status_code=201)
async def create_ticket(
data: TicketCreatePayloadSchema,
current_user: Annotated[User, Depends(require_engineer_or_admin)],
db: Annotated[AsyncSession, Depends(get_db)],
):
"""Create a new PSA ticket."""
if not current_user.account_id:
raise HTTPException(status_code=400, detail="User has no account")
from app.services.psa.exceptions import PSAError
from app.services.psa.types import TicketCreatePayload
try:
return await ticket_svc.create_ticket(
current_user.account_id,
TicketCreatePayload(**data.model_dump()),
db,
)
except PSAError as e:
raise HTTPException(status_code=502, detail=str(e))
@router.post("/tickets/ai-parse", response_model=AiParseResponseSchema)
async def ai_parse_ticket(
data: AiParseRequestSchema,
current_user: Annotated[User, Depends(require_engineer_or_admin)],
db: Annotated[AsyncSession, Depends(get_db)],
):
"""Parse natural language into a ticket pre-fill payload using Claude."""
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
import anthropic
import json
# Fetch boards + members for context (both cached)
boards = []
members = []
try:
provider = await get_provider_for_account(current_user.account_id, db)
boards = await provider.list_boards()
members = await provider.list_members()
except PSAError:
pass
boards_list = [{"id": b.id, "name": b.name} for b in boards]
members_list = [{"id": m.id, "name": m.name, "identifier": m.identifier} for m in members]
system_prompt = """You are a ticket triage assistant for an MSP help desk.
Extract structured ticket information from the engineer's natural language description.
Return ONLY valid JSON matching this exact schema — no other text:
{
"summary": "short one-line ticket title or null",
"board_id": "integer matching one of the provided boards or null",
"priority_name": "one of: Critical, High, Medium, Low, or null",
"description": "expanded description or null",
"assignee_identifier": "member identifier string from the provided members list or null",
"warnings": ["list of strings explaining what could not be resolved"]
}"""
user_msg = f"""Available boards: {json.dumps(boards_list)}
Available members: {json.dumps(members_list[:50])}
Engineer's description: {data.prompt}"""
missing_fields: list[str] = []
warnings: list[str] = []
response_data = AiParseResponseSchema()
try:
client = anthropic.AsyncAnthropic(
api_key=settings.ANTHROPIC_API_KEY,
max_retries=1,
)
msg = await client.messages.create(
model=settings.get_model_for_action("default"),
max_tokens=512,
system=system_prompt,
messages=[{"role": "user", "content": user_msg}],
)
raw = msg.content[0].text.strip()
# Strip markdown fences if present
if raw.startswith("```"):
import re
raw = re.sub(r'^```(?:json)?\s*', '', raw)
raw = re.sub(r'\s*```$', '', raw.strip())
parsed = json.loads(raw)
response_data.summary = parsed.get("summary")
response_data.description = parsed.get("description")
warnings = parsed.get("warnings", [])
# Resolve board_id
if parsed.get("board_id"):
board_match = next((b for b in boards if b.id == int(parsed["board_id"])), None)
if board_match:
response_data.board_id = board_match.id
else:
missing_fields.append("board_id")
warnings.append(f"Board ID {parsed['board_id']} not found")
else:
missing_fields.append("board_id")
# Resolve assignee
if parsed.get("assignee_identifier"):
member = next((m for m in members if m.identifier == parsed["assignee_identifier"]), None)
if member:
response_data.assigned_member_id = int(member.id)
else:
warnings.append(f"Member '{parsed['assignee_identifier']}' not found")
# Priority/status/company always need manual selection
missing_fields.extend(["status_id", "priority_id", "company_id"])
except Exception as e:
logger.warning("AI parse failed: %s", e)
missing_fields = ["summary", "board_id", "status_id", "priority_id", "company_id"]
warnings = ["AI parsing failed — please fill in manually"]
response_data.missing_fields = missing_fields
response_data.warnings = warnings
return response_data
@router.patch("/tickets/{ticket_id}/status", response_model=PSATicketStatusUpdateSchema)
async def update_ticket_status_endpoint(
ticket_id: int,
status_id: int,
current_user: Annotated[User, Depends(require_engineer_or_admin)],
db: Annotated[AsyncSession, Depends(get_db)],
):
"""Update a ticket's status."""
if not current_user.account_id:
raise HTTPException(status_code=400, detail="User has no account")
from app.services.psa.exceptions import PSAError
try:
return await ticket_svc.update_status(current_user.account_id, ticket_id, status_id, db)
except PSAError as e:
raise HTTPException(status_code=502, detail=str(e))
@router.get("/tickets/{ticket_id}/resources", response_model=list[PSAResourceSchema])
async def list_ticket_resources(
ticket_id: int,
current_user: Annotated[User, Depends(require_engineer_or_admin)],
db: Annotated[AsyncSession, Depends(get_db)],
):
if not current_user.account_id:
raise HTTPException(status_code=400, detail="User has no account")
from app.services.psa.exceptions import PSAError
try:
return await ticket_svc.list_resources(current_user.account_id, ticket_id, db)
except PSAError as e:
# Resources are optional display data — degrade gracefully rather than surfacing a toast
logger.warning("list_resources(%s) failed: %s", ticket_id, e)
return []
@router.post("/tickets/{ticket_id}/resources", response_model=PSAResourceSchema, status_code=201)
async def add_ticket_resource(
ticket_id: int,
member_id: int,
current_user: Annotated[User, Depends(require_engineer_or_admin)],
db: Annotated[AsyncSession, Depends(get_db)],
):
if not current_user.account_id:
raise HTTPException(status_code=400, detail="User has no account")
from app.services.psa.exceptions import PSAError
try:
return await ticket_svc.add_resource(current_user.account_id, ticket_id, member_id, db)
except PSAError as e:
raise HTTPException(status_code=502, detail=str(e))
@router.delete("/tickets/{ticket_id}/resources/{member_id}", status_code=204)
async def remove_ticket_resource(
ticket_id: int,
member_id: int,
current_user: Annotated[User, Depends(require_engineer_or_admin)],
db: Annotated[AsyncSession, Depends(get_db)],
):
if not current_user.account_id:
raise HTTPException(status_code=400, detail="User has no account")
from app.services.psa.exceptions import PSAError
try:
await ticket_svc.remove_resource(current_user.account_id, ticket_id, member_id, db)
except PSAError as e:
raise HTTPException(status_code=502, detail=str(e))
@router.get("/priorities", response_model=list[PSAPrioritySchema])
async def list_priorities(
current_user: Annotated[User, Depends(require_engineer_or_admin)],
db: Annotated[AsyncSession, Depends(get_db)],
):
"""List PSA priority levels for ticket creation form."""
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)
raw = await provider.list_priorities()
return [PSAPrioritySchema(id=p["id"], name=p["name"]) for p in raw if p.get("id")]
except PSAError as e:
logger.warning("list_priorities failed: %s", e)
return []
@router.get("/tickets/{ticket_id}/context") @router.get("/tickets/{ticket_id}/context")
async def get_ticket_context( async def get_ticket_context(
ticket_id: int, ticket_id: int,
@@ -788,30 +483,7 @@ async def get_ticket_statuses(
except PSANotFoundError: except PSANotFoundError:
raise HTTPException(status_code=404, detail="Ticket not found") raise HTTPException(status_code=404, detail="Ticket not found")
except PSAError as e: except PSAError as e:
logger.warning("get_ticket_statuses(%s) failed: %s", ticket_id, e) raise HTTPException(status_code=502, detail=str(e))
return []
@router.get("/boards/{board_id}/statuses", response_model=list[PSATicketStatusItem])
async def get_board_statuses(
board_id: int,
current_user: Annotated[User, Depends(require_engineer_or_admin)],
db: Annotated[AsyncSession, Depends(get_db)],
):
"""Get available statuses for a service board directly (no ticket lookup required)."""
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)
statuses = await provider.get_ticket_statuses(board_id)
return [PSATicketStatusItem(id=s.id, name=s.name, is_closed=s.is_closed) for s in statuses]
except PSAError as e:
logger.warning("get_board_statuses(%s) failed: %s", board_id, e)
return []
# ── member mapping endpoints ───────────────────────────────────────── # ── member mapping endpoints ─────────────────────────────────────────
@@ -819,7 +491,7 @@ async def get_board_statuses(
@router.get("/members", response_model=list[PsaMemberResponse]) @router.get("/members", response_model=list[PsaMemberResponse])
async def list_members( async def list_members(
current_user: Annotated[User, Depends(require_engineer_or_admin)], current_user: Annotated[User, Depends(require_account_owner)],
db: Annotated[AsyncSession, Depends(get_db)], db: Annotated[AsyncSession, Depends(get_db)],
): ):
"""List CW members (from CW API).""" """List CW members (from CW API)."""
@@ -837,9 +509,7 @@ async def list_members(
for m in members for m in members
] ]
except PSAError as e: except PSAError as e:
# Members are optional display data — degrade gracefully raise HTTPException(status_code=502, detail=str(e))
logger.warning("list_members failed: %s", e)
return []
@router.get("/member-mappings", response_model=list[PsaMemberMappingResponse]) @router.get("/member-mappings", response_model=list[PsaMemberMappingResponse])
@@ -847,37 +517,31 @@ async def get_member_mappings(
current_user: Annotated[User, Depends(require_account_owner)], current_user: Annotated[User, Depends(require_account_owner)],
db: Annotated[AsyncSession, Depends(get_db)], db: Annotated[AsyncSession, Depends(get_db)],
): ):
"""Get all account users with their PSA member mappings (unmapped users included).""" """Get all member mappings for the account."""
conn = await _get_account_connection(current_user.account_id, db) conn = await _get_account_connection(current_user.account_id, db)
if not conn: if not conn:
return [] return []
# Fetch all active account users result = await db.execute(
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) select(PsaMemberMapping).where(PsaMemberMapping.psa_connection_id == conn.id)
) )
mapping_by_user: dict[str, PsaMemberMapping] = { mappings = result.scalars().all()
str(m.user_id): m for m in mappings_result.scalars().all()
}
return [ response = []
PsaMemberMappingResponse( for m in mappings:
id=str(m.id) if (m := mapping_by_user.get(str(user.id))) else None, user_result = await db.execute(select(User).where(User.id == m.user_id))
user_id=str(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_email=user.email,
user_name=user.name, user_name=user.name,
external_member_id=m.external_member_id if m else None, external_member_id=m.external_member_id,
external_member_name=m.external_member_name if m else None, external_member_name=m.external_member_name,
matched_by=m.matched_by if m else None, matched_by=m.matched_by,
) ))
for user in users return response
]
@router.post("/member-mappings", response_model=list[PsaMemberMappingResponse]) @router.post("/member-mappings", response_model=list[PsaMemberMappingResponse])
@@ -900,7 +564,6 @@ async def save_member_mappings(
for m in mappings: for m in mappings:
mapping = PsaMemberMapping( mapping = PsaMemberMapping(
psa_connection_id=conn.id, psa_connection_id=conn.id,
account_id=current_user.account_id,
user_id=UUID(m.user_id), user_id=UUID(m.user_id),
external_member_id=m.external_member_id, external_member_id=m.external_member_id,
external_member_name=m.external_member_name, external_member_name=m.external_member_name,
@@ -961,7 +624,6 @@ async def auto_match_members(
if not existing.scalar_one_or_none(): if not existing.scalar_one_or_none():
mapping = PsaMemberMapping( mapping = PsaMemberMapping(
psa_connection_id=conn.id, psa_connection_id=conn.id,
account_id=current_user.account_id,
user_id=user.id, user_id=user.id,
external_member_id=cw_member.id, external_member_id=cw_member.id,
external_member_name=cw_member.name, external_member_name=cw_member.name,

View File

@@ -1,362 +0,0 @@
"""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
from app.core.database import get_db
from app.api.deps import get_current_active_user
from app.models.user import User
from app.models.device_type import DeviceType
from app.models.network_diagram import NetworkDiagram
from app.core.service_account import PLATFORM_ACCOUNT_ID
from app.schemas.network_diagram import (
NetworkDiagramCreate,
NetworkDiagramUpdate,
NetworkDiagramResponse,
NetworkDiagramListItem,
AIGenerateRequest,
AIGenerateResponse,
DiagramImportRequest,
DiagramImportResponse,
DiagramExportResponse,
DiagramNode,
DiagramEdge,
)
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] = {
"router": "network", "switch": "network", "access-point": "network", "load-balancer": "network",
"firewall": "security", "badge-reader": "security",
"server": "compute", "vm": "compute", "container": "compute",
"nas": "storage", "san": "storage", "cloud-storage": "storage",
"cloud": "cloud", "aws": "cloud", "azure": "cloud", "gcp": "cloud", "isp": "cloud",
"workstation": "endpoint", "laptop": "endpoint", "tablet": "endpoint",
"phone": "endpoint", "printer": "endpoint",
"ups": "infrastructure", "pdu": "infrastructure", "rack": "infrastructure",
"patch-panel": "infrastructure", "camera": "infrastructure",
"nvr": "infrastructure", "iot": "infrastructure",
}
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/network-diagrams", tags=["network-diagrams"])
async def _get_diagram_or_404(
diagram_id: UUID,
account_id: UUID,
db: AsyncSession,
) -> NetworkDiagram:
diagram = await db.get(NetworkDiagram, diagram_id)
if not diagram or diagram.account_id != account_id or diagram.is_archived:
raise HTTPException(status_code=404, detail="Diagram not found")
return diagram
def _diagram_to_response(diagram: NetworkDiagram) -> NetworkDiagramResponse:
return NetworkDiagramResponse.model_validate(diagram)
def _diagram_to_list_item(
diagram: NetworkDiagram,
custom_slug_category: dict[str, str] | None = None,
) -> NetworkDiagramListItem:
nodes = diagram.nodes if isinstance(diagram.nodes, list) else []
slug_to_cat = {**_SLUG_CATEGORY, **(custom_slug_category or {})}
category_counts: dict[str, int] = {}
for node in nodes:
slug = node.get("type", "") if isinstance(node, dict) else ""
cat = slug_to_cat.get(slug, "other")
category_counts[cat] = category_counts.get(cat, 0) + 1
return NetworkDiagramListItem(
id=diagram.id,
name=diagram.name,
client_name=diagram.client_name,
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,
)
async def _get_available_slugs(account_id: UUID, db: AsyncSession) -> set[str]:
stmt = select(DeviceType.slug).where(
or_(
DeviceType.account_id == PLATFORM_ACCOUNT_ID,
DeviceType.account_id == account_id,
)
)
result = await db.execute(stmt)
return {row[0] for row in result.all()}
@router.get("/clients", response_model=list[str])
async def list_client_names(
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_active_user)],
) -> list[str]:
stmt = (
select(NetworkDiagram.client_name)
.where(
NetworkDiagram.account_id == current_user.account_id,
NetworkDiagram.is_archived.is_(False),
NetworkDiagram.client_name.isnot(None),
NetworkDiagram.client_name != "",
)
.distinct()
.order_by(NetworkDiagram.client_name)
)
result = await db.execute(stmt)
return [row[0] for row in result.all()]
@router.get("/", response_model=list[NetworkDiagramListItem])
async def list_diagrams(
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_active_user)],
client_name: str | None = Query(default=None),
search: str | None = Query(default=None),
) -> list[NetworkDiagramListItem]:
stmt = (
select(NetworkDiagram)
.where(
NetworkDiagram.account_id == current_user.account_id,
NetworkDiagram.is_archived.is_(False),
)
.order_by(NetworkDiagram.updated_at.desc())
)
if client_name:
stmt = stmt.where(NetworkDiagram.client_name == client_name)
if search:
escaped = search.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
search_filter = f"%{escaped}%"
stmt = stmt.where(
or_(
NetworkDiagram.name.ilike(search_filter),
NetworkDiagram.client_name.ilike(search_filter),
)
)
# Single query for custom device types so category_counts is accurate
dt_stmt = select(DeviceType.slug, DeviceType.category).where(
DeviceType.is_system.is_(False),
DeviceType.account_id == current_user.account_id,
)
dt_result = await db.execute(dt_stmt)
custom_slug_category = {row[0]: row[1] for row in dt_result.all()}
result = await db.execute(stmt)
rows = result.scalars().all()
return [_diagram_to_list_item(r, custom_slug_category) for r in rows]
@router.post("/", response_model=NetworkDiagramResponse, status_code=201)
async def create_diagram(
data: NetworkDiagramCreate,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_active_user)],
) -> NetworkDiagramResponse:
diagram = NetworkDiagram(
account_id=current_user.account_id,
name=data.name,
client_name=data.client_name,
asset_name=data.asset_name,
description=data.description,
nodes=[n.model_dump() for n in data.nodes],
edges=[e.model_dump() for e in data.edges],
created_by=current_user.id,
)
db.add(diagram)
await db.commit()
await db.refresh(diagram)
return _diagram_to_response(diagram)
@router.get("/{diagram_id}", response_model=NetworkDiagramResponse)
async def get_diagram(
diagram_id: UUID,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_active_user)],
) -> NetworkDiagramResponse:
diagram = await _get_diagram_or_404(diagram_id, current_user.account_id, db)
return _diagram_to_response(diagram)
@router.put("/{diagram_id}", response_model=NetworkDiagramResponse)
async def update_diagram(
diagram_id: UUID,
data: NetworkDiagramUpdate,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_active_user)],
) -> NetworkDiagramResponse:
diagram = await _get_diagram_or_404(diagram_id, current_user.account_id, db)
update_data = data.model_dump(exclude_unset=True)
if "nodes" in update_data and update_data["nodes"] is not None:
update_data["nodes"] = [n.model_dump() if hasattr(n, "model_dump") else n for n in update_data["nodes"]]
if "edges" in update_data and update_data["edges"] is not None:
update_data["edges"] = [e.model_dump() if hasattr(e, "model_dump") else e for e in update_data["edges"]]
for field, value in update_data.items():
setattr(diagram, field, value)
diagram.updated_at = datetime.now(timezone.utc)
await db.commit()
await db.refresh(diagram)
return _diagram_to_response(diagram)
@router.delete("/{diagram_id}", status_code=204)
async def archive_diagram(
diagram_id: UUID,
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)
diagram.is_archived = True
diagram.updated_at = datetime.now(timezone.utc)
await db.commit()
@router.post("/{diagram_id}/duplicate", response_model=NetworkDiagramResponse, status_code=201)
async def duplicate_diagram(
diagram_id: UUID,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_active_user)],
) -> NetworkDiagramResponse:
source = await _get_diagram_or_404(diagram_id, current_user.account_id, db)
copy = NetworkDiagram(
account_id=current_user.account_id,
name=f"Copy of {source.name}",
client_name=source.client_name,
asset_name=source.asset_name,
description=source.description,
nodes=source.nodes,
edges=source.edges,
created_by=current_user.id,
)
db.add(copy)
await db.commit()
await db.refresh(copy)
return _diagram_to_response(copy)
@router.get("/{diagram_id}/export", response_model=DiagramExportResponse)
async def export_diagram(
diagram_id: UUID,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_active_user)],
) -> DiagramExportResponse:
diagram = await _get_diagram_or_404(diagram_id, current_user.account_id, db)
nodes = [DiagramNode(**n) for n in (diagram.nodes or [])]
edges = [DiagramEdge(**e) for e in (diagram.edges or [])]
return DiagramExportResponse(
schemaVersion=1,
name=diagram.name,
client_name=diagram.client_name,
description=diagram.description,
nodes=nodes,
edges=edges,
exportedAt=datetime.now(timezone.utc).isoformat(),
)
@router.post("/import", response_model=DiagramImportResponse, status_code=201)
async def import_diagram(
data: DiagramImportRequest,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_active_user)],
) -> DiagramImportResponse:
available_slugs = await _get_available_slugs(current_user.account_id, db)
warnings: list[str] = []
for node in data.nodes:
if node.type not in available_slugs:
warnings.append(f"Unknown device type '{node.type}' — will render with default icon")
diagram = NetworkDiagram(
account_id=current_user.account_id,
name=data.name,
client_name=data.client_name,
description=data.description,
nodes=[n.model_dump() for n in data.nodes],
edges=[e.model_dump() for e in data.edges],
created_by=current_user.id,
)
db.add(diagram)
await db.commit()
await db.refresh(diagram)
return DiagramImportResponse(
diagram=_diagram_to_response(diagram),
warnings=warnings,
)
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,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_active_user)],
) -> AIGenerateResponse:
available_slugs_set = await _get_available_slugs(current_user.account_id, db)
available_slugs = list(available_slugs_set)
existing_node_ids: list[str] | None = None
if data.mode == "merge" and data.existingBounds:
existing_node_ids = []
try:
return await network_diagram_ai_service.generate_diagram(
request=data,
available_slugs=available_slugs,
existing_node_ids=existing_node_ids,
)
except ValueError as e:
raise HTTPException(status_code=422, detail=str(e))
except Exception:
logger.exception("AI diagram generation failed")
raise HTTPException(status_code=500, detail="Diagram generation failed")

View File

@@ -24,7 +24,6 @@ from app.api.endpoints import (
branding, branding,
categories, categories,
copilot, copilot,
device_types,
feedback, feedback,
flow_proposals, flow_proposals,
flowpilot_analytics, flowpilot_analytics,
@@ -33,7 +32,6 @@ from app.api.endpoints import (
invite, invite,
kb_accelerator, kb_accelerator,
maintenance_schedules, maintenance_schedules,
network_diagrams,
notifications, notifications,
onboarding, onboarding,
public_templates, public_templates,
@@ -95,6 +93,7 @@ api_router.include_router(admin_settings.router)
api_router.include_router(admin_categories.router) api_router.include_router(admin_categories.router)
api_router.include_router(admin_survey.router) api_router.include_router(admin_survey.router)
api_router.include_router(admin_gallery.router) api_router.include_router(admin_gallery.router)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# User-facing endpoints — tenant context required # User-facing endpoints — tenant context required
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -131,7 +130,6 @@ api_router.include_router(integrations.router, dependencies=_tenant_deps)
api_router.include_router(onboarding.router, dependencies=_tenant_deps) api_router.include_router(onboarding.router, dependencies=_tenant_deps)
api_router.include_router(branding.router, dependencies=_tenant_deps) api_router.include_router(branding.router, dependencies=_tenant_deps)
api_router.include_router(supporting_data.router, dependencies=_tenant_deps) api_router.include_router(supporting_data.router, dependencies=_tenant_deps)
api_router.include_router(network_diagrams.router, dependencies=_tenant_deps)
# session_handoffs queue router must come before ai_sessions to avoid conflict # session_handoffs queue router must come before ai_sessions to avoid conflict
api_router.include_router(session_handoffs.queue_router, dependencies=_tenant_deps) api_router.include_router(session_handoffs.queue_router, dependencies=_tenant_deps)
api_router.include_router(session_resolutions.router, dependencies=_tenant_deps) api_router.include_router(session_resolutions.router, dependencies=_tenant_deps)
@@ -144,4 +142,3 @@ api_router.include_router(script_builder.router, dependencies=_tenant_deps)
api_router.include_router(beta_feedback.router, dependencies=_tenant_deps) api_router.include_router(beta_feedback.router, dependencies=_tenant_deps)
api_router.include_router(session_branches.router, dependencies=_tenant_deps) api_router.include_router(session_branches.router, dependencies=_tenant_deps)
api_router.include_router(session_handoffs.router, dependencies=_tenant_deps) api_router.include_router(session_handoffs.router, dependencies=_tenant_deps)
api_router.include_router(device_types.router, dependencies=_tenant_deps)

View File

@@ -128,7 +128,6 @@ class Settings(BaseSettings):
"variable_inference": "fast", "variable_inference": "fast",
"kb_convert": "standard", "kb_convert": "standard",
"script_build": "standard", "script_build": "standard",
"network_diagram_generate": "standard",
} }
def get_model_for_action(self, action_type: str) -> str: def get_model_for_action(self, action_type: str) -> str:

View File

@@ -56,8 +56,6 @@ from .session_handoff import SessionHandoff
from .session_resolution_output import SessionResolutionOutput from .session_resolution_output import SessionResolutionOutput
from .template_tree import TemplateTree from .template_tree import TemplateTree
from .platform_step import PlatformStep from .platform_step import PlatformStep
from .device_type import DeviceType
from .network_diagram import NetworkDiagram
__all__ = [ __all__ = [
"User", "User",
@@ -128,6 +126,4 @@ __all__ = [
"SessionResolutionOutput", "SessionResolutionOutput",
"TemplateTree", "TemplateTree",
"PlatformStep", "PlatformStep",
"DeviceType",
"NetworkDiagram",
] ]

View File

@@ -1,47 +0,0 @@
"""Device type model for network diagrams."""
import uuid
from datetime import datetime, timezone
from sqlalchemy import String, Boolean, Integer, DateTime, ForeignKey
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.dialects.postgresql import UUID
from app.core.database import Base
class DeviceType(Base):
"""A device type for network diagram nodes (platform or account-custom)."""
__tablename__ = "device_types"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
slug: Mapped[str] = mapped_column(
String(50), nullable=False,
comment="Unique identifier used in diagram node data",
)
label: Mapped[str] = mapped_column(
String(100), nullable=False,
comment="Display name",
)
category: Mapped[str] = mapped_column(
String(50), nullable=False,
comment="network, compute, storage, cloud, endpoint, infrastructure, security",
)
is_system: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=False,
comment="True for built-in types that cannot be deleted",
)
account_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("accounts.id", ondelete="CASCADE"),
nullable=False,
comment="Platform account for system types, tenant account for custom types",
)
sort_order: Mapped[int] = mapped_column(
Integer, nullable=False, default=0,
comment="Display order within category",
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)

View File

@@ -1,53 +0,0 @@
"""Network diagram model."""
import uuid
from datetime import datetime, timezone
from typing import Any, TYPE_CHECKING
from sqlalchemy import String, Text, Boolean, DateTime, ForeignKey
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.dialects.postgresql import UUID, JSONB
from app.core.database import Base
if TYPE_CHECKING:
from app.models.user import User
class NetworkDiagram(Base):
"""A network topology diagram scoped to one account."""
__tablename__ = "network_diagrams"
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", ondelete="CASCADE"),
nullable=False,
index=True,
)
name: Mapped[str] = mapped_column(String(255), nullable=False)
client_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
asset_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
nodes: Mapped[list[dict[str, Any]]] = mapped_column(JSONB, nullable=False, server_default="'[]'")
edges: Mapped[list[dict[str, Any]]] = mapped_column(JSONB, nullable=False, server_default="'[]'")
thumbnail_url: Mapped[str | None] = mapped_column(Text, nullable=True)
is_archived: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=False,
)
created_by: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True),
ForeignKey("users.id"),
nullable=True,
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(timezone.utc),
onupdate=lambda: datetime.now(timezone.utc),
)
creator: Mapped["User | None"] = relationship("User", foreign_keys=[created_by])

View File

@@ -20,7 +20,6 @@ from .psa_connection import (
PSATicketSearchResult, PSATicketStatusItem, PSATicketSearchResult, PSATicketStatusItem,
PsaPostRequest, PsaPostResponse, PsaPreviewResponse, PsaPostLogResponse, PsaPostRequest, PsaPostResponse, PsaPreviewResponse, PsaPostLogResponse,
PsaMemberMappingResponse, PsaMemberMappingSaveRequest, PsaMemberResponse, AutoMatchResult, PsaMemberMappingResponse, PsaMemberMappingSaveRequest, PsaMemberResponse, AutoMatchResult,
PSABoardResponse,
) )
__all__ = [ __all__ = [
@@ -51,5 +50,4 @@ __all__ = [
"PSATicketSearchResult", "PSATicketStatusItem", "PSATicketSearchResult", "PSATicketStatusItem",
"PsaPostRequest", "PsaPostResponse", "PsaPreviewResponse", "PsaPostLogResponse", "PsaPostRequest", "PsaPostResponse", "PsaPreviewResponse", "PsaPostLogResponse",
"PsaMemberMappingResponse", "PsaMemberMappingSaveRequest", "PsaMemberResponse", "AutoMatchResult", "PsaMemberMappingResponse", "PsaMemberMappingSaveRequest", "PsaMemberResponse", "AutoMatchResult",
"PSABoardResponse",
] ]

View File

@@ -126,7 +126,6 @@ class AdminAccountDetailResponse(AdminAccountListItem):
class AdminAccountCreate(BaseModel): class AdminAccountCreate(BaseModel):
name: str = Field(..., min_length=1, max_length=255) name: str = Field(..., min_length=1, max_length=255)
plan: Literal["free", "pro", "team"] = "free" 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): class AdminAccountUpdate(BaseModel):
@@ -320,7 +319,7 @@ class AdminUserCreate(BaseModel):
name: str = Field(..., min_length=1, max_length=255) name: str = Field(..., min_length=1, max_length=255)
account_mode: Literal["existing", "personal"] account_mode: Literal["existing", "personal"]
account_display_code: Optional[str] = Field(None, description="Required when account_mode='existing'") account_display_code: Optional[str] = Field(None, description="Required when account_mode='existing'")
account_role: Optional[Literal["owner", "admin", "engineer", "viewer"]] = Field(None, description="Required when account_mode='existing'") account_role: Optional[Literal["engineer", "viewer"]] = Field(None, description="Required when account_mode='existing'")
send_email: bool = True send_email: bool = True

View File

@@ -1,37 +0,0 @@
"""Pydantic schemas for device types."""
from datetime import datetime
from uuid import UUID
from pydantic import BaseModel, Field
class DeviceTypeCreate(BaseModel):
slug: str = Field(min_length=1, max_length=50, pattern=r"^[a-z0-9\-]+$")
label: str = Field(min_length=1, max_length=100)
category: str = Field(
min_length=1, max_length=50,
pattern=r"^(network|compute|storage|cloud|endpoint|infrastructure|security)$",
)
sort_order: int = Field(default=0, ge=0)
class DeviceTypeUpdate(BaseModel):
label: str | None = Field(default=None, min_length=1, max_length=100)
category: str | None = Field(
default=None, min_length=1, max_length=50,
pattern=r"^(network|compute|storage|cloud|endpoint|infrastructure|security)$",
)
sort_order: int | None = Field(default=None, ge=0)
class DeviceTypeResponse(BaseModel):
id: UUID
slug: str
label: str
category: str
is_system: bool
account_id: UUID
sort_order: int
created_at: datetime
model_config = {"from_attributes": True}

View File

@@ -1,145 +0,0 @@
"""Pydantic schemas for network diagrams."""
from datetime import datetime
from uuid import UUID
from pydantic import BaseModel, Field
class Position(BaseModel):
x: float
y: float
class DeviceProperties(BaseModel):
hostname: str | None = None
ip: str | None = None
subnet: str | None = None
vendor: str | None = None
model: str | None = None
role: str | None = None
vlan: str | None = None
notes: str | None = None
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):
id: str
source: str
target: str
label: str | None = None
connectionType: str = "ethernet"
speed: str | None = None
notes: str | None = None
routing: str | None = None
class NetworkDiagramCreate(BaseModel):
name: str = Field(min_length=1, max_length=255)
client_name: str | None = None
asset_name: str | None = None
description: str | None = None
nodes: list[DiagramNode] = Field(default_factory=list)
edges: list[DiagramEdge] = Field(default_factory=list)
class NetworkDiagramUpdate(BaseModel):
name: str | None = Field(default=None, min_length=1, max_length=255)
client_name: str | None = None
asset_name: str | None = None
description: str | None = None
nodes: list[DiagramNode] | None = None
edges: list[DiagramEdge] | None = None
class NetworkDiagramResponse(BaseModel):
id: UUID
account_id: UUID
name: str
client_name: str | None = None
asset_name: str | None = None
description: str | None = None
nodes: list[DiagramNode] = Field(default_factory=list)
edges: list[DiagramEdge] = Field(default_factory=list)
thumbnail_url: str | None = None
is_archived: bool = False
created_by: UUID | None = None
created_at: datetime
updated_at: datetime
model_config = {"from_attributes": True}
class NetworkDiagramListItem(BaseModel):
id: UUID
name: str
client_name: str | None = None
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
model_config = {"from_attributes": True}
class ExistingBounds(BaseModel):
minX: float
maxX: float
minY: float
maxY: float
class AIGenerateRequest(BaseModel):
description: str = Field(min_length=1, max_length=5000)
client_name: str | None = None
mode: str = Field(default="replace", pattern=r"^(replace|merge)$")
existingBounds: ExistingBounds | None = None
class AIGenerateResponse(BaseModel):
nodes: list[DiagramNode]
edges: list[DiagramEdge]
suggestedName: str | None = None
notes: str | None = None
class DiagramImportRequest(BaseModel):
schemaVersion: int = Field(ge=1, le=1)
name: str = Field(min_length=1, max_length=255)
client_name: str | None = None
description: str | None = None
nodes: list[DiagramNode] = Field(default_factory=list)
edges: list[DiagramEdge] = Field(default_factory=list)
class DiagramImportResponse(BaseModel):
diagram: NetworkDiagramResponse
warnings: list[str] = Field(default_factory=list)
class DiagramExportResponse(BaseModel):
schemaVersion: int = 1
name: str
client_name: str | None = None
description: str | None = None
nodes: list[DiagramNode]
edges: list[DiagramEdge]
exportedAt: str

View File

@@ -53,13 +53,9 @@ class PSATicketSearchResult(BaseModel):
id: str id: str
summary: str summary: str
company_name: str | None = None company_name: str | None = None
company_id: str | None = None
board_name: str | None = None board_name: str | None = None
board_id: int | None = None
status_name: str | None = None status_name: str | None = None
status_id: int | None = None
priority_name: str | None = None priority_name: str | None = None
priority_id: int | None = None
closed: bool = False closed: bool = False
@@ -115,13 +111,13 @@ class PsaPostLogResponse(BaseModel):
class PsaMemberMappingResponse(BaseModel): class PsaMemberMappingResponse(BaseModel):
id: str | None = None # None for users without a mapping id: str
user_id: str user_id: str
user_email: str user_email: str
user_name: str user_name: str
external_member_id: str | None = None external_member_id: str
external_member_name: str | None = None external_member_name: str
matched_by: str | None = None matched_by: str
class PsaMemberMappingSaveRequest(BaseModel): class PsaMemberMappingSaveRequest(BaseModel):
@@ -140,8 +136,3 @@ class PsaMemberResponse(BaseModel):
class AutoMatchResult(BaseModel): class AutoMatchResult(BaseModel):
matched: list[PsaMemberMappingResponse] matched: list[PsaMemberMappingResponse]
unmatched_users: int unmatched_users: int
class PSABoardResponse(BaseModel):
id: int
name: str

View File

@@ -1,64 +0,0 @@
"""Normalized DTOs for ticket management endpoints."""
from __future__ import annotations
from pydantic import BaseModel
class PSAResourceSchema(BaseModel):
member_id: int
member_name: str
member_identifier: str
is_rf_user: bool = False
class PSATicketCreatedSchema(BaseModel):
id: int
summary: str
board_name: str
status_name: str
priority_name: str
company_name: str
resources: list[PSAResourceSchema] = []
class PSATicketStatusUpdateSchema(BaseModel):
ticket_id: int
previous_status: str
new_status: str
class TicketCreatePayloadSchema(BaseModel):
summary: str
company_id: int
board_id: int
status_id: int
priority_id: int
description: str | None = None
assigned_member_id: int | None = None
class TicketListResponseSchema(BaseModel):
items: list = []
total: int = 0
page: int = 1
page_size: int = 25
class AiParseRequestSchema(BaseModel):
prompt: str
class AiParseResponseSchema(BaseModel):
summary: str | None = None
company_id: int | None = None
board_id: int | None = None
priority_id: int | None = None
status_id: int | None = None
assigned_member_id: int | None = None
description: str | None = None
missing_fields: list[str] = []
warnings: list[str] = []
class PSAPrioritySchema(BaseModel):
id: int
name: str

View File

@@ -68,4 +68,4 @@ class RoleUpdate(BaseModel):
class AccountRoleUpdate(BaseModel): class AccountRoleUpdate(BaseModel):
account_role: str = Field(..., pattern="^(owner|admin|engineer|viewer)$") account_role: str = Field(..., pattern="^(engineer|viewer)$")

View File

@@ -154,23 +154,6 @@ To create a fork, append this marker AFTER your [QUESTIONS]/[ACTIONS] markers:
- If a question is clearly outside your domain, say so briefly and redirect. - If a question is clearly outside your domain, say so briefly and redirect.
- Never fabricate error codes, KB article numbers, or CLI flags. If unsure, say so. - Never fabricate error codes, KB article numbers, or CLI flags. If unsure, say so.
## SPIN-OFF TICKET CREATION
When you identify a second distinct issue that is clearly separate from the primary topic \
of this session, suggest creating a spin-off ticket using the [ACTIONS] marker below. \
Use this sparingly — only when the issue is genuinely independent, not for every tangential mention.
Format:
[ACTIONS]
[
{
"label": "Create ticket: <brief issue title>",
"command": "create_spin_off_ticket",
"description": "<one sentence description of the separate issue>"
}
]
[/ACTIONS]
## FINAL REMINDER — THIS OVERRIDES EVERYTHING ABOVE ## FINAL REMINDER — THIS OVERRIDES EVERYTHING ABOVE
Every single response MUST contain [QUESTIONS] and/or [ACTIONS] markers with valid JSON. \ Every single response MUST contain [QUESTIONS] and/or [ACTIONS] markers with valid JSON. \
No exceptions. Not even when forking. A response without at least one of these markers \ No exceptions. Not even when forking. A response without at least one of these markers \

View File

@@ -330,7 +330,6 @@ async def start_session(
# 7. Create first step # 7. Create first step
step = _create_step_from_parsed( step = _create_step_from_parsed(
session_id=session.id, session_id=session.id,
account_id=session.account_id,
step_order=0, step_order=0,
parsed=parsed, parsed=parsed,
input_tokens=input_tokens, input_tokens=input_tokens,
@@ -434,7 +433,6 @@ async def process_response(
# Create new step # Create new step
step = _create_step_from_parsed( step = _create_step_from_parsed(
session_id=session.id, session_id=session.id,
account_id=session.account_id,
step_order=session.step_count - 1, step_order=session.step_count - 1,
parsed=parsed, parsed=parsed,
input_tokens=input_tokens, input_tokens=input_tokens,
@@ -696,7 +694,6 @@ async def pickup_session(
briefing_step = AISessionStep( briefing_step = AISessionStep(
id=uuid.uuid4(), id=uuid.uuid4(),
session_id=session.id, session_id=session.id,
account_id=session.account_id,
branch_id=session.active_branch_id if session.is_branching else None, branch_id=session.active_branch_id if session.is_branching else None,
step_order=session.step_count, step_order=session.step_count,
step_type="action", step_type="action",
@@ -768,7 +765,6 @@ async def pickup_session(
next_step = _create_step_from_parsed( next_step = _create_step_from_parsed(
session_id=session.id, session_id=session.id,
account_id=session.account_id,
step_order=session.step_count - 1, step_order=session.step_count - 1,
parsed=parsed, parsed=parsed,
input_tokens=input_tokens, input_tokens=input_tokens,
@@ -1001,7 +997,6 @@ async def generate_status_update(
step = AISessionStep( step = AISessionStep(
id=uuid.uuid4(), id=uuid.uuid4(),
session_id=session.id, session_id=session.id,
account_id=session.account_id,
branch_id=session.active_branch_id if session.is_branching else None, branch_id=session.active_branch_id if session.is_branching else None,
step_order=session.step_count, step_order=session.step_count,
step_type="status_update", step_type="status_update",
@@ -1445,7 +1440,6 @@ def _format_engineer_response(request: StepResponseRequest) -> str:
def _create_step_from_parsed( def _create_step_from_parsed(
session_id: UUID, session_id: UUID,
account_id: UUID,
step_order: int, step_order: int,
parsed: dict[str, Any], parsed: dict[str, Any],
input_tokens: int, input_tokens: int,
@@ -1493,7 +1487,6 @@ def _create_step_from_parsed(
return AISessionStep( return AISessionStep(
id=uuid.uuid4(), id=uuid.uuid4(),
session_id=session_id, session_id=session_id,
account_id=account_id,
branch_id=branch_id, branch_id=branch_id,
step_order=step_order, step_order=step_order,
step_type=step_type if parsed["type"] != "resolution_suggestion" else "action", step_type=step_type if parsed["type"] != "resolution_suggestion" else "action",

View File

@@ -1,151 +0,0 @@
"""AI service for generating network diagrams from natural language."""
import json
import logging
from app.core.ai_provider import get_ai_provider
from app.core.config import settings
from app.schemas.network_diagram import (
AIGenerateRequest,
AIGenerateResponse,
DiagramNode,
DiagramEdge,
DeviceProperties,
Position,
)
logger = logging.getLogger(__name__)
SYSTEM_PROMPT_TEMPLATE = """You are a network diagram generator for MSP engineers.
Given a plain English description of a network, you must return ONLY valid JSON with no markdown, no explanation, no preamble.
Return this exact structure:
{{
"nodes": [
{{
"id": "unique-string",
"type": "device-type-slug",
"label": "device label",
"position": {{ "x": number, "y": number }},
"properties": {{
"hostname": "string or null",
"ip": "string or null",
"subnet": "string or null",
"vendor": "string or null",
"model": "string or null",
"role": "string or null",
"vlan": "string or null",
"notes": "string or null",
"status": "unknown"
}}
}}
],
"edges": [
{{
"id": "unique-string",
"source": "node-id",
"target": "node-id",
"label": "connection label or null",
"connectionType": "ethernet|fiber|wifi|vpn|vlan|wan",
"speed": "string or null",
"notes": "string or null"
}}
],
"suggestedName": "short descriptive diagram name",
"notes": "any important assumptions or missing info, or null"
}}
Available device type slugs: {available_slugs}
Position nodes thoughtfully in a logical network topology layout.
Use x/y coordinates between 0 and 1200 for x, 0 and 800 for y.
Place WAN/internet at top, core network in middle, endpoints at bottom.
{merge_instructions}"""
MERGE_INSTRUCTIONS = """
IMPORTANT: You are ADDING devices to an existing diagram. Do NOT replace existing devices.
The existing diagram occupies this bounding box: minX={minX}, maxX={maxX}, minY={minY}, maxY={maxY}.
Place all new nodes OUTSIDE this bounding box — below (y > {maxY} + 100) or to the right (x > {maxX} + 100).
You may create edges that connect new nodes to existing nodes if the description implies a connection.
Use these existing node IDs for connections: {existing_node_ids}"""
async def generate_diagram(
request: AIGenerateRequest,
available_slugs: list[str],
existing_node_ids: list[str] | None = None,
) -> AIGenerateResponse:
merge_instructions = ""
if request.mode == "merge" and request.existingBounds:
b = request.existingBounds
merge_instructions = MERGE_INSTRUCTIONS.format(
minX=b.minX, maxX=b.maxX, minY=b.minY, maxY=b.maxY,
existing_node_ids=", ".join(existing_node_ids or []),
)
system_prompt = SYSTEM_PROMPT_TEMPLATE.format(
available_slugs=", ".join(available_slugs),
merge_instructions=merge_instructions,
)
model = settings.get_model_for_action("network_diagram_generate")
provider = get_ai_provider(model)
messages = [{"role": "user", "content": request.description}]
response_text, input_tokens, output_tokens = await provider.generate_json(
system_prompt=system_prompt,
messages=messages,
max_tokens=4096,
)
logger.info(
"Network diagram AI generation: input_tokens=%d, output_tokens=%d",
input_tokens, output_tokens,
)
try:
data = json.loads(response_text)
except json.JSONDecodeError as e:
logger.error("Failed to parse AI response as JSON: %s", e)
raise ValueError("AI generated an invalid response, please try again")
try:
nodes = []
for raw_node in data.get("nodes", []):
node_type = raw_node.get("type", "server")
if node_type not in available_slugs:
logger.warning("Unknown device type '%s', falling back to 'server'", node_type)
node_type = "server"
nodes.append(DiagramNode(
id=raw_node["id"],
type=node_type,
label=raw_node.get("label", node_type),
position=Position(**raw_node.get("position", {"x": 0, "y": 0})),
properties=DeviceProperties(**{
k: v for k, v in raw_node.get("properties", {}).items()
if k in DeviceProperties.model_fields
}),
))
edges = []
for raw_edge in data.get("edges", []):
edges.append(DiagramEdge(
id=raw_edge["id"],
source=raw_edge["source"],
target=raw_edge["target"],
label=raw_edge.get("label"),
connectionType=raw_edge.get("connectionType", "ethernet"),
speed=raw_edge.get("speed"),
notes=raw_edge.get("notes"),
))
except KeyError as e:
logger.warning("AI response missing required field: %s", e)
raise ValueError(f"AI generated incomplete data (missing {e}), please try again")
return AIGenerateResponse(
nodes=nodes,
edges=edges,
suggestedName=data.get("suggestedName"),
notes=data.get("notes"),
)

View File

@@ -11,11 +11,6 @@ from app.services.psa.types import (
PSAMember, PSAMember,
PSAConfiguration, PSAConfiguration,
PSATimeEntry, PSATimeEntry,
PSABoard,
PaginatedTicketResult,
PSAResource,
PSACreatedTicket,
TicketCreatePayload,
) )
@@ -32,7 +27,7 @@ class AutotaskProvider(PSAProvider):
async def get_ticket(self, ticket_id: str) -> PSATicket: async def get_ticket(self, ticket_id: str) -> PSATicket:
raise NotImplementedError("Autotask integration coming soon") raise NotImplementedError("Autotask integration coming soon")
async def search_tickets(self, query: str, **filters) -> PaginatedTicketResult: async def search_tickets(self, query: str, **filters) -> list[PSATicket]:
raise NotImplementedError("Autotask integration coming soon") raise NotImplementedError("Autotask integration coming soon")
async def post_note( async def post_note(
@@ -63,9 +58,6 @@ class AutotaskProvider(PSAProvider):
async def list_members(self) -> list[PSAMember]: async def list_members(self) -> list[PSAMember]:
raise NotImplementedError("Autotask integration coming soon") 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]: async def get_ticket_configurations(self, ticket_id: str) -> list[PSAConfiguration]:
raise NotImplementedError("Autotask integration coming soon") raise NotImplementedError("Autotask integration coming soon")
@@ -78,18 +70,3 @@ class AutotaskProvider(PSAProvider):
work_type: str | None = None, work_type: str | None = None,
) -> PSATimeEntry: ) -> PSATimeEntry:
raise NotImplementedError("Autotask integration coming soon") raise NotImplementedError("Autotask integration coming soon")
async def list_resources(self, ticket_id: int) -> list[PSAResource]:
raise NotImplementedError("Autotask integration coming soon")
async def add_resource(self, ticket_id: int, member_id: int) -> PSAResource:
raise NotImplementedError("Autotask integration coming soon")
async def remove_resource(self, ticket_id: int, member_id: int) -> None:
raise NotImplementedError("Autotask integration coming soon")
async def create_ticket(self, payload: TicketCreatePayload) -> PSACreatedTicket:
raise NotImplementedError("Autotask integration coming soon")
async def list_priorities(self) -> list[dict]:
raise NotImplementedError("Autotask integration coming soon")

View File

@@ -12,11 +12,6 @@ from .types import (
PSAMember, PSAMember,
PSAConfiguration, PSAConfiguration,
PSATimeEntry, PSATimeEntry,
PSABoard,
PaginatedTicketResult,
PSAResource,
PSACreatedTicket,
TicketCreatePayload,
) )
@@ -32,7 +27,7 @@ class PSAProvider(ABC):
... ...
@abstractmethod @abstractmethod
async def search_tickets(self, query: str, **filters) -> PaginatedTicketResult: async def search_tickets(self, query: str, **filters) -> list[PSATicket]:
... ...
@abstractmethod @abstractmethod
@@ -69,10 +64,6 @@ class PSAProvider(ABC):
async def list_members(self) -> list[PSAMember]: async def list_members(self) -> list[PSAMember]:
... ...
@abstractmethod
async def list_boards(self) -> list[PSABoard]:
...
@abstractmethod @abstractmethod
async def get_ticket_configurations(self, ticket_id: str) -> list[PSAConfiguration]: async def get_ticket_configurations(self, ticket_id: str) -> list[PSAConfiguration]:
... ...
@@ -87,23 +78,3 @@ class PSAProvider(ABC):
work_type: str | None = None, work_type: str | None = None,
) -> PSATimeEntry: ) -> PSATimeEntry:
... ...
@abstractmethod
async def list_resources(self, ticket_id: int) -> list[PSAResource]:
...
@abstractmethod
async def add_resource(self, ticket_id: int, member_id: int) -> PSAResource:
...
@abstractmethod
async def remove_resource(self, ticket_id: int, member_id: int) -> None:
...
@abstractmethod
async def create_ticket(self, payload: TicketCreatePayload) -> PSACreatedTicket:
...
@abstractmethod
async def list_priorities(self) -> list[dict]:
...

View File

@@ -16,11 +16,6 @@ from app.services.psa.types import (
PSAMember, PSAMember,
PSAConfiguration, PSAConfiguration,
PSATimeEntry, PSATimeEntry,
PSABoard,
PaginatedTicketResult,
PSAResource,
PSACreatedTicket,
TicketCreatePayload,
) )
from .client import ConnectWiseClient from .client import ConnectWiseClient
@@ -59,59 +54,34 @@ class ConnectWiseProvider(PSAProvider):
) )
return self._map_ticket(data) return self._map_ticket(data)
async def search_tickets(self, query: str, **filters) -> PaginatedTicketResult: async def search_tickets(self, query: str, **filters) -> list[PSATicket]:
"""Search CW tickets by summary. Supports board_id, status_id, member_identifier, """Search CW tickets by summary. Supports board_id and status_id filters."""
unassigned, board_ids, page, and page_size filters. Returns paginated result."""
page_size = filters.get("page_size", 10)
page = filters.get("page", 1)
params: dict = { params: dict = {
"fields": "id,summary,company,board,status,priority,closedFlag", "fields": "id,summary,company,board,status,priority,closedFlag",
"orderBy": "priority/sort asc,dateEntered desc", "orderBy": "id desc",
"pageSize": page_size, "pageSize": 25,
"page": page,
} }
# Build CW condition query
conditions: list[str] = [] conditions: list[str] = []
if query: if query:
# Sanitize: strip single quotes to prevent CW condition injection conditions.append(f"summary contains '{query}'")
safe_query = query.replace("'", "")
conditions.append(f"summary contains '{safe_query}'")
if filters.get("board_id"): if filters.get("board_id"):
conditions.append(f"board/id = {filters['board_id']}") conditions.append(f"board/id = {filters['board_id']}")
if filters.get("status_id"): if filters.get("status_id"):
conditions.append(f"status/id = {filters['status_id']}") conditions.append(f"status/id = {filters['status_id']}")
if not filters.get("include_closed", False): if not filters.get("include_closed", False):
conditions.append("closedFlag = 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 filters.get("company_id"):
conditions.append(f"company/id = {int(filters['company_id'])}")
condition_str = " and ".join(conditions) if conditions else "" if conditions:
if condition_str: params["conditions"] = " and ".join(conditions)
params["conditions"] = condition_str
count_params: dict = {} data = await self.client.get("/service/tickets", params=params)
if condition_str:
count_params["conditions"] = condition_str
# Fire page fetch + count in parallel return [
data, count_data = await asyncio.gather( self._map_ticket(t)
self.client.get("/service/tickets", params=params), for t in (data if isinstance(data, list) else [])
self.client.get("/service/tickets/count", params=count_params), ]
)
items = [self._map_ticket(t) for t in (data if isinstance(data, list) else [])]
total = count_data.get("count", len(items)) if isinstance(count_data, dict) else len(items)
return PaginatedTicketResult(items=items, total=total, page=page, page_size=page_size)
async def get_ticket_configurations( async def get_ticket_configurations(
self, ticket_id: str self, ticket_id: str
@@ -300,32 +270,6 @@ class ConnectWiseProvider(PSAProvider):
psa_cache.set(cache_key, result, ttl_seconds=900) psa_cache.set(cache_key, result, ttl_seconds=900)
return result 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 ──────────────────────────────────────────────── # ── Ticket Context ────────────────────────────────────────────────
async def get_ticket_context( async def get_ticket_context(
@@ -592,7 +536,7 @@ class ConnectWiseProvider(PSAProvider):
if work_type: if work_type:
payload["workType"] = {"name": 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( return PSATimeEntry(
id=str(data["id"]), id=str(data["id"]),
ticket_id=ticket_id, ticket_id=ticket_id,
@@ -607,112 +551,16 @@ class ConnectWiseProvider(PSAProvider):
@staticmethod @staticmethod
def _map_ticket(data: dict) -> PSATicket: def _map_ticket(data: dict) -> PSATicket:
"""Map a CW ticket JSON dict to a PSATicket.""" """Map a CW ticket JSON dict to a PSATicket."""
company = data.get("company") or {}
board = data.get("board") or {}
status = data.get("status") or {}
priority = data.get("priority") or {}
return PSATicket( return PSATicket(
id=str(data.get("id", "")), id=str(data["id"]),
summary=data.get("summary", ""), summary=data.get("summary", ""),
company_name=company.get("name"), company_name=data.get("company", {}).get("name"),
company_id=str(company.get("id")) if company.get("id") else None, company_id=str(data["company"]["id"]) if data.get("company") else None,
board_name=board.get("name"), board_name=data.get("board", {}).get("name"),
board_id=board.get("id"), board_id=data.get("board", {}).get("id"),
status_name=status.get("name"), status_name=data.get("status", {}).get("name"),
status_id=status.get("id"), status_id=data.get("status", {}).get("id"),
priority_name=priority.get("name"), priority_name=data.get("priority", {}).get("name"),
priority_id=priority.get("id"), priority_id=data.get("priority", {}).get("id"),
closed=data.get("closedFlag", False), closed=data.get("closedFlag", False),
) )
# ── Resource management ───────────────────────────────────────────
async def list_resources(self, ticket_id: int) -> list[PSAResource]:
"""List members assigned to a CW ticket."""
data = await self.client.get(f"/service/tickets/{ticket_id}/members")
results = []
for m in (data if isinstance(data, list) else []):
member = m.get("member") or {}
results.append(PSAResource(
member_id=member.get("id", 0),
member_name=member.get("name", ""),
member_identifier=member.get("identifier", ""),
))
return results
async def add_resource(self, ticket_id: int, member_id: int) -> PSAResource:
"""Assign a member to a CW ticket."""
data = await self.client.post(
f"/service/tickets/{ticket_id}/members",
json_body={"member": {"id": member_id}},
)
member = (data.get("member") or {}) if isinstance(data, dict) else {}
return PSAResource(
member_id=member.get("id", member_id),
member_name=member.get("name", ""),
member_identifier=member.get("identifier", ""),
)
async def remove_resource(self, ticket_id: int, member_id: int) -> None:
"""Remove a member from a CW ticket (idempotent)."""
# CW DELETE requires the member record id (junction record), not the member's id
members_data = await self.client.get(f"/service/tickets/{ticket_id}/members")
record_id = None
for m in (members_data if isinstance(members_data, list) else []):
if (m.get("member") or {}).get("id") == member_id:
record_id = m.get("id")
break
if record_id is None:
return # Already not assigned — idempotent
await self.client.delete(f"/service/tickets/{ticket_id}/members/{record_id}")
# ── Ticket creation ───────────────────────────────────────────────
async def create_ticket(self, payload: TicketCreatePayload) -> PSACreatedTicket:
"""Create a new CW service ticket."""
body: dict = {
"summary": payload.summary,
"board": {"id": payload.board_id},
"company": {"id": payload.company_id},
"status": {"id": payload.status_id},
"priority": {"id": payload.priority_id},
}
if payload.description:
body["initialDescription"] = payload.description
if payload.assigned_member_id:
body["owner"] = {"id": payload.assigned_member_id}
data = await self.client.post("/service/tickets", json_body=body)
ticket_id = data.get("id") if isinstance(data, dict) else None
resources: list[PSAResource] = []
if ticket_id and payload.assigned_member_id:
try:
resources = await self.list_resources(ticket_id)
except Exception:
pass
company = (data.get("company") or {}) if isinstance(data, dict) else {}
board = (data.get("board") or {}) if isinstance(data, dict) else {}
status = (data.get("status") or {}) if isinstance(data, dict) else {}
priority = (data.get("priority") or {}) if isinstance(data, dict) else {}
return PSACreatedTicket(
id=ticket_id or 0,
summary=data.get("summary", payload.summary) if isinstance(data, dict) else payload.summary,
board_name=board.get("name", ""),
status_name=status.get("name", ""),
priority_name=priority.get("name", ""),
company_name=company.get("name", ""),
resources=resources,
)
# ── Priorities ────────────────────────────────────────────────────
async def list_priorities(self) -> list[dict]:
"""List CW service priorities."""
data = await self.client.get("/service/priorities", params={"pageSize": 50})
return [
{"id": p.get("id"), "name": p.get("name")}
for p in (data if isinstance(data, list) else [])
]

View File

@@ -11,11 +11,6 @@ from app.services.psa.types import (
PSAMember, PSAMember,
PSAConfiguration, PSAConfiguration,
PSATimeEntry, PSATimeEntry,
PSABoard,
PaginatedTicketResult,
PSAResource,
PSACreatedTicket,
TicketCreatePayload,
) )
@@ -32,7 +27,7 @@ class HaloPSAProvider(PSAProvider):
async def get_ticket(self, ticket_id: str) -> PSATicket: async def get_ticket(self, ticket_id: str) -> PSATicket:
raise NotImplementedError("Halo PSA integration coming soon") raise NotImplementedError("Halo PSA integration coming soon")
async def search_tickets(self, query: str, **filters) -> PaginatedTicketResult: async def search_tickets(self, query: str, **filters) -> list[PSATicket]:
raise NotImplementedError("Halo PSA integration coming soon") raise NotImplementedError("Halo PSA integration coming soon")
async def post_note( async def post_note(
@@ -63,9 +58,6 @@ class HaloPSAProvider(PSAProvider):
async def list_members(self) -> list[PSAMember]: async def list_members(self) -> list[PSAMember]:
raise NotImplementedError("Halo PSA integration coming soon") 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]: async def get_ticket_configurations(self, ticket_id: str) -> list[PSAConfiguration]:
raise NotImplementedError("Halo PSA integration coming soon") raise NotImplementedError("Halo PSA integration coming soon")
@@ -78,18 +70,3 @@ class HaloPSAProvider(PSAProvider):
work_type: str | None = None, work_type: str | None = None,
) -> PSATimeEntry: ) -> PSATimeEntry:
raise NotImplementedError("Halo PSA integration coming soon") raise NotImplementedError("Halo PSA integration coming soon")
async def list_resources(self, ticket_id: int) -> list[PSAResource]:
raise NotImplementedError("Halo PSA integration coming soon")
async def add_resource(self, ticket_id: int, member_id: int) -> PSAResource:
raise NotImplementedError("Halo PSA integration coming soon")
async def remove_resource(self, ticket_id: int, member_id: int) -> None:
raise NotImplementedError("Halo PSA integration coming soon")
async def create_ticket(self, payload: TicketCreatePayload) -> PSACreatedTicket:
raise NotImplementedError("Halo PSA integration coming soon")
async def list_priorities(self) -> list[dict]:
raise NotImplementedError("Halo PSA integration coming soon")

View File

@@ -67,46 +67,6 @@ class PSATimeEntry(BaseModel):
created_at: str | None = None created_at: str | None = None
class PSABoard(BaseModel):
id: int
name: str
inactive: bool = False
class PaginatedTicketResult(BaseModel):
items: list[PSATicket]
total: int
page: int
page_size: int
class PSAResource(BaseModel):
member_id: int
member_name: str
member_identifier: str
is_rf_user: bool = False
class PSACreatedTicket(BaseModel):
id: int
summary: str
board_name: str
status_name: str
priority_name: str
company_name: str
resources: list[PSAResource] = []
class TicketCreatePayload(BaseModel):
summary: str
company_id: int
board_id: int
status_id: int
priority_id: int
description: str | None = None
assigned_member_id: int | None = None
class NoteType: class NoteType:
INTERNAL_ANALYSIS = "internal_analysis" INTERNAL_ANALYSIS = "internal_analysis"
RESOLUTION = "resolution" RESOLUTION = "resolution"

View File

@@ -1,115 +0,0 @@
"""Ticket mutation service — wraps PSA provider, resolves is_rf_user flag."""
from __future__ import annotations
import logging
from uuid import UUID
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.psa_connection import PsaConnection
from app.models.psa_member_mapping import PsaMemberMapping
from app.schemas.psa_tickets import (
PSAResourceSchema,
PSATicketCreatedSchema,
PSATicketStatusUpdateSchema,
)
from app.services.psa.registry import get_provider_for_account
from app.services.psa.types import TicketCreatePayload
logger = logging.getLogger(__name__)
async def _get_mapped_member_ids(account_id: UUID, db: AsyncSession) -> set[int]:
"""Return set of external_member_id ints that are mapped to RF users."""
conn_result = await db.execute(
select(PsaConnection).where(PsaConnection.account_id == account_id)
)
conn = conn_result.scalar_one_or_none()
if not conn:
return set()
mappings = await db.execute(
select(PsaMemberMapping).where(PsaMemberMapping.psa_connection_id == conn.id)
)
return {int(m.external_member_id) for m in mappings.scalars().all() if m.external_member_id}
async def list_resources(
account_id: UUID, ticket_id: int, db: AsyncSession
) -> list[PSAResourceSchema]:
provider = await get_provider_for_account(account_id, db)
mapped_ids = await _get_mapped_member_ids(account_id, db)
resources = await provider.list_resources(ticket_id)
return [
PSAResourceSchema(
member_id=r.member_id,
member_name=r.member_name,
member_identifier=r.member_identifier,
is_rf_user=r.member_id in mapped_ids,
)
for r in resources
]
async def add_resource(
account_id: UUID, ticket_id: int, member_id: int, db: AsyncSession
) -> PSAResourceSchema:
provider = await get_provider_for_account(account_id, db)
mapped_ids = await _get_mapped_member_ids(account_id, db)
resource = await provider.add_resource(ticket_id, member_id)
return PSAResourceSchema(
member_id=resource.member_id,
member_name=resource.member_name,
member_identifier=resource.member_identifier,
is_rf_user=resource.member_id in mapped_ids,
)
async def remove_resource(
account_id: UUID, ticket_id: int, member_id: int, db: AsyncSession
) -> None:
provider = await get_provider_for_account(account_id, db)
await provider.remove_resource(ticket_id, member_id)
async def update_status(
account_id: UUID, ticket_id: int, status_id: int, db: AsyncSession
) -> PSATicketStatusUpdateSchema:
provider = await get_provider_for_account(account_id, db)
# get current status before updating
ticket = await provider.get_ticket(str(ticket_id))
previous_status = ticket.status_name or ""
await provider.update_ticket_status(str(ticket_id), status_id)
# get new status name from statuses list
statuses = await provider.get_ticket_statuses(ticket.board_id or 0)
new_status = next((s.name for s in statuses if s.id == status_id), str(status_id))
return PSATicketStatusUpdateSchema(
ticket_id=ticket_id,
previous_status=previous_status,
new_status=new_status,
)
async def create_ticket(
account_id: UUID, payload: TicketCreatePayload, db: AsyncSession
) -> PSATicketCreatedSchema:
provider = await get_provider_for_account(account_id, db)
mapped_ids = await _get_mapped_member_ids(account_id, db)
result = await provider.create_ticket(payload)
return PSATicketCreatedSchema(
id=result.id,
summary=result.summary,
board_name=result.board_name,
status_name=result.status_name,
priority_name=result.priority_name,
company_name=result.company_name,
resources=[
PSAResourceSchema(
member_id=r.member_id,
member_name=r.member_name,
member_identifier=r.member_identifier,
is_rf_user=r.member_id in mapped_ids,
)
for r in result.resources
],
)

View File

@@ -1,96 +0,0 @@
from __future__ import annotations
import uuid
import pytest
from sqlalchemy import select
from app.models.device_type import DeviceType
from app.models.user import User
from app.core.service_account import PLATFORM_ACCOUNT_ID
async def _login_headers(client, email: str, password: str) -> dict[str, str]:
response = await client.post(
"/api/v1/auth/login/json",
json={"email": email, "password": password},
)
assert response.status_code == 200
token = response.json()["access_token"]
return {"Authorization": f"Bearer {token}"}
@pytest.mark.asyncio
async def test_device_types_include_platform_and_account_custom(client, test_db, auth_headers, test_user):
result = await test_db.execute(select(User).where(User.email == test_user["email"]))
user = result.scalar_one()
test_db.add(
DeviceType(
id=uuid.uuid4(),
slug="platform-router",
label="Platform Router",
category="network",
is_system=True,
account_id=PLATFORM_ACCOUNT_ID,
sort_order=0,
)
)
await test_db.commit()
create_response = await client.post(
"/api/v1/device-types/",
json={
"slug": "tenant-appliance",
"label": "Tenant Appliance",
"category": "network",
"sort_order": 3,
},
headers=auth_headers,
)
assert create_response.status_code == 201
assert create_response.json()["account_id"] == str(user.account_id)
list_response = await client.get("/api/v1/device-types/", headers=auth_headers)
assert list_response.status_code == 200
payload = list_response.json()
slugs = {item["slug"] for item in payload}
assert "platform-router" in slugs
assert "tenant-appliance" in slugs
@pytest.mark.asyncio
async def test_network_diagrams_are_account_scoped(client, test_db, auth_headers, test_user):
other_user = {
"email": "other-network@example.com",
"password": "TestPassword123!",
"name": "Other Network User",
}
register_response = await client.post("/api/v1/auth/register", json=other_user)
assert register_response.status_code in (200, 201)
other_headers = await _login_headers(client, other_user["email"], other_user["password"])
owner_result = await test_db.execute(select(User).where(User.email == test_user["email"]))
owner = owner_result.scalar_one()
create_response = await client.post(
"/api/v1/network-diagrams/",
json={
"name": "HQ Core",
"client_name": "Acme",
"description": "Primary topology",
"nodes": [],
"edges": [],
},
headers=auth_headers,
)
assert create_response.status_code == 201
diagram = create_response.json()
assert diagram["account_id"] == str(owner.account_id)
own_get = await client.get(f"/api/v1/network-diagrams/{diagram['id']}", headers=auth_headers)
assert own_get.status_code == 200
other_get = await client.get(f"/api/v1/network-diagrams/{diagram['id']}", headers=other_headers)
assert other_get.status_code == 404

View File

@@ -1,55 +0,0 @@
# backend/tests/test_psa_tickets.py
"""Routing and auth tests for new ticket management endpoints."""
import pytest
@pytest.mark.asyncio
async def test_create_ticket_requires_auth(client):
"""POST /tickets returns 401 without auth."""
response = await client.post(
"/api/v1/integrations/psa/tickets",
json={
"summary": "Test", "company_id": 1, "board_id": 1,
"status_id": 1, "priority_id": 1
},
)
assert response.status_code == 401
@pytest.mark.asyncio
async def test_list_resources_requires_auth(client):
response = await client.get("/api/v1/integrations/psa/tickets/1/resources")
assert response.status_code == 401
@pytest.mark.asyncio
async def test_search_tickets_returns_paginated_shape(client, auth_headers):
"""search endpoint returns TicketListResponse shape when no PSA connected."""
response = await client.get(
"/api/v1/integrations/psa/tickets/search",
headers=auth_headers,
)
# No PSA connection → 400 or 502; with PSA → 200
assert response.status_code in (200, 400, 502)
if response.status_code == 200:
data = response.json()
assert "items" in data
assert "total" in data
assert "page" in data
@pytest.mark.asyncio
async def test_update_status_requires_auth(client):
response = await client.patch(
"/api/v1/integrations/psa/tickets/1/status?status_id=5"
)
assert response.status_code == 401
@pytest.mark.asyncio
async def test_ai_parse_requires_auth(client):
response = await client.post(
"/api/v1/integrations/psa/tickets/ai-parse",
json={"prompt": "New ticket for Acme"},
)
assert response.status_code == 401

View File

@@ -1,4 +1,4 @@
# Lessons Archive (1-70) # Lessons Archive (1-40)
> These lessons were originally in CLAUDE.md. They've been archived because the fixes are now baked into the codebase. Consult this file if you encounter a regression in any of these areas. > These lessons were originally in CLAUDE.md. They've been archived because the fixes are now baked into the codebase. Consult this file if you encounter a regression in any of these areas.
@@ -81,67 +81,3 @@
**39. Platform settings for feature toggles:** Use `SettingsManager.get("key", db, default=True)`. **39. Platform settings for feature toggles:** Use `SettingsManager.get("key", db, default=True)`.
**40. Survey public routes:** Add at top level in `router.tsx` alongside `/login`. **40. Survey public routes:** Add at top level in `router.tsx` alongside `/login`.
---
## Archived Lessons (41-70)
**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. 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:** 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: 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_*` var must be added as `ARG` + `ENV` in `frontend/Dockerfile`. Railway env vars are runtime-only 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()` — no intake form screen or "Start" button. Variables filled inline. Troubleshooting flows DO have a start screen.
**62. Playwright strict mode — scope selectors:** Step titles appear in both sidebar and main heading. Use `getByRole('heading', { name })` for main content.
**63. Node 20 required for frontend builds:** `export NVM_DIR="$HOME/.nvm" && source "$NVM_DIR/nvm.sh" && nvm use 20`. Or: `PATH="$HOME/.nvm/versions/node/v20.19.0/bin:$PATH"`.
**64. PostHog product analytics:** `PostHogProvider` in `main.tsx`. Event helpers in `lib/analytics.ts`. `identifyUser()` in `authStore.fetchUser()`, `resetAnalytics()` on logout. Env vars: `VITE_PUBLIC_POSTHOG_KEY`, `VITE_PUBLIC_POSTHOG_HOST`.
**65. Local Docker Compose uses `resolutionflow` database on port 5433:** Container `resolutionflow_postgres`, DB `resolutionflow` (not `patherly`), port `5433`. Playwright config defaults must match.
**66. Dev environment runs on Hostinger VPS (46.202.92.250):** CORS must include VPS IP in `CORS_ORIGINS` and `FRONTEND_URL`. See DEV-ENV.md.
**67. Tree editor route is `/trees/new`:** NOT `/editor/new`. Use `getTreeEditorPath()` from `@/lib/routing`.
**68. APScheduler jobs need `max_instances=1`:** Without it, overlapping runs can process the same records twice (TOCTOU race).
**69. PostgreSQL `func.sum(case(...))` returns `Decimal` via asyncpg:** Cast to `int()` before storing in Pydantic `dict[str, Any]` fields.
**70. Toast library uses `toast.warning()` not `toast.warn()`:** Import from `@/lib/toast`. Methods: `success`, `error`, `warning`, `info`.

View File

@@ -1,158 +0,0 @@
# 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 |

View File

@@ -1,63 +0,0 @@
# ConnectWise integration docs
Reference material for ResolutionFlow's ConnectWise Manage integration.
This folder pairs a **human-editable source** (the XLSX) with two
**generated artifacts** (YAML + Markdown). Code reads the YAML; humans
read the Markdown; edits happen in the XLSX.
## Files
| File | Role | Edit? |
|------|------|-------|
| `api-member-security-roles.md` | Human-readable reference — browse on GitHub, link in PRs, onboard new contributors. | Generated — do not edit |
| `api-member-security-roles.yaml` | Machine-readable source of truth — imported by integration code, queried by Claude Code when writing permission checks. | Generated — do not edit |
| `source/Security_Roles_Matrix_11132017.xlsx` | Canonical source. The matrix as published by ConnectWise (with any corrections we've applied). | Yes — this is the editing surface |
| `source/generate_role_docs.py` | Regenerates the YAML and Markdown from the XLSX. Deterministic. | Only if the matrix schema itself changes |
| `source/requirements.txt` | Python deps for the generator (`openpyxl`, `PyYAML`). | Only when bumping deps |
## Regeneration workflow
After editing the XLSX:
```bash
cd docs/integrations/connectwise/source
pip install -r requirements.txt
python generate_role_docs.py \
--source Security_Roles_Matrix_11132017.xlsx \
--out-yaml ../api-member-security-roles.yaml \
--out-md ../api-member-security-roles.md
```
Commit all three files together (XLSX, YAML, MD). The diff on the YAML
is what reviewers should scrutinize — it is the source of truth for code.
## Querying the YAML from integration code
The YAML groups permissions by module and action. Example — checking
what `Inquire: ALL` means for Service Desk → Service Tickets:
```python
import yaml
from pathlib import Path
doc = yaml.safe_load(
Path("docs/integrations/connectwise/api-member-security-roles.yaml").read_text()
)
levels = doc["modules"]["Service Desk"]["actions"]["Service Tickets"]["inquire"]["levels"]
print(levels["ALL"])
```
This is the pattern `ConnectWiseAuthManager` and the proxy authorization
layer should use when the required permission level for a given API
endpoint needs to be documented or validated against an assigned role.
## Conventions
- **Levels are ordered most-to-least privileged:** `ALL`, `MY`, `MINE`, `NONE`.
- **Verbs are always in this order:** `add`, `edit`, `delete`, `inquire`.
- **`Not applicable` notes** in a verb's cell mean the meaningful level
is documented under another verb (almost always `inquire`) — the
generator preserves these as `note:` fields rather than inventing
placeholder levels.
- **The XLSX is the single source of input.** Never hand-edit the YAML
or Markdown; your changes will be overwritten on the next regeneration.

File diff suppressed because it is too large Load Diff

View File

@@ -1,361 +0,0 @@
"""
Generate ConnectWise security-role documentation from the source XLSX.
Produces:
- api-member-security-roles.yaml : machine-readable source of truth
- api-member-security-roles.md : human-readable reference
Re-run this script after editing the source XLSX. Both outputs are
deterministic — they will produce identical content from identical input,
so diffs in version control reflect only real permission-model changes.
Usage:
python generate_role_docs.py \
--source source/Security_Roles_Matrix_11132017.xlsx \
--out-yaml ../api-member-security-roles.yaml \
--out-md ../api-member-security-roles.md
"""
from __future__ import annotations
import argparse
import re
from dataclasses import dataclass, field
from datetime import date
from pathlib import Path
from typing import Dict, List, Optional
import yaml
from openpyxl import load_workbook
# ---------------------------------------------------------------------------
# Parsing
# ---------------------------------------------------------------------------
# A level description line looks like "ALL: text..." or "NONE: text..."
# We capture the prefix (ALL | NONE | MINE | MY) and the trailing description.
LEVEL_LINE = re.compile(r"^(ALL|NONE|MINE|MY)\s*:\s*(.*)$", re.DOTALL)
# Recognized ConnectWise permission levels, most-to-least privileged.
LEVEL_ORDER = ["ALL", "MY", "MINE", "NONE"]
VERBS = ["add", "edit", "delete", "inquire"]
VERB_COLS = {"add": 3, "edit": 4, "delete": 5, "inquire": 6}
@dataclass
class CellPermission:
"""Parsed contents of a single (action, verb) cell."""
levels: Dict[str, str] = field(default_factory=dict) # level -> description
note: Optional[str] = None # for "Not applicable. See Inquire level." etc.
raw: str = "" # original cell text, preserved for audit
@dataclass
class ActionRow:
module: str
action: str
permissions: Dict[str, CellPermission] # verb -> CellPermission
def parse_cell(raw: Optional[str]) -> CellPermission:
"""Parse a single cell's multi-line content into levels + note."""
if raw is None:
return CellPermission(raw="")
text = str(raw).strip()
cp = CellPermission(raw=text)
if not text:
return cp
# Split into candidate entries. Each entry is typically one line that
# starts with a level prefix, but description text can itself contain
# newlines. We therefore split on newlines and accumulate continuation
# lines into the preceding entry.
current_level: Optional[str] = None
current_buf: List[str] = []
note_buf: List[str] = []
def flush_level() -> None:
nonlocal current_level, current_buf
if current_level is not None:
cp.levels[current_level] = " ".join(current_buf).strip()
current_level = None
current_buf = []
for line in text.splitlines():
line = line.strip()
if not line:
continue
m = LEVEL_LINE.match(line)
if m:
flush_level()
current_level = m.group(1).upper()
current_buf = [m.group(2).strip()]
elif current_level is not None:
current_buf.append(line)
else:
# No level prefix yet — belongs to the note.
note_buf.append(line)
flush_level()
if note_buf:
cp.note = " ".join(note_buf).strip()
return cp
def read_matrix(xlsx_path: Path) -> List[ActionRow]:
wb = load_workbook(xlsx_path, data_only=True)
ws = wb.active # Single sheet in this workbook.
# Header row is row 2 per the source file; data begins row 3.
actions: List[ActionRow] = []
for r in range(3, ws.max_row + 1):
module = ws.cell(row=r, column=1).value
action = ws.cell(row=r, column=2).value
if not (module or action):
continue # skip fully empty rows
if not module or not action:
# Partial row — keep but flag. This shouldn't happen in the
# current source; if it does, the generator should fail loudly
# rather than silently produce wrong output.
raise ValueError(
f"Row {r} has a missing Module or Action: "
f"module={module!r}, action={action!r}"
)
perms: Dict[str, CellPermission] = {}
for verb, col in VERB_COLS.items():
perms[verb] = parse_cell(ws.cell(row=r, column=col).value)
actions.append(
ActionRow(module=module.strip(), action=action.strip(), permissions=perms)
)
return actions
# ---------------------------------------------------------------------------
# Output: YAML
# ---------------------------------------------------------------------------
def build_yaml_document(actions: List[ActionRow], source_file: str) -> dict:
"""Build a plain-dict representation that YAML dumps cleanly."""
# Group by module, preserving action order within each module.
modules: Dict[str, List[ActionRow]] = {}
for a in actions:
modules.setdefault(a.module, []).append(a)
doc = {
"metadata": {
"source_file": source_file,
"generated_on": date.today().isoformat(),
"generator": "docs/integrations/connectwise/source/generate_role_docs.py",
"description": (
"ConnectWise security-role matrix. Each (module, action) entry "
"describes what each access level (ALL, MY, MINE, NONE) means "
"for the Add, Edit, Delete, and Inquire verbs. This is a "
"reference catalog, not a per-role assignment — role "
"assignments live in ConnectWise and are mirrored in the "
"ResolutionFlow integration config."
),
"level_order_most_to_least_privileged": LEVEL_ORDER,
},
"modules": {},
}
for module_name, rows in modules.items():
module_block = {"actions": {}}
for a in rows:
action_block: Dict[str, object] = {}
for verb in VERBS:
cell = a.permissions[verb]
entry: Dict[str, object] = {}
if cell.levels:
# Emit levels in canonical order, only those present.
entry["levels"] = {
lvl: cell.levels[lvl]
for lvl in LEVEL_ORDER
if lvl in cell.levels
}
if cell.note:
entry["note"] = cell.note
if not entry:
# Truly empty cell — represent explicitly so downstream
# consumers can distinguish "empty" from "missing".
entry["note"] = "(no description provided)"
action_block[verb] = entry
module_block["actions"][a.action] = action_block
doc["modules"][module_name] = module_block
return doc
class _LiteralStr(str):
"""Marker type so PyYAML renders long strings as block literals."""
def _literal_presenter(dumper, data):
return dumper.represent_scalar("tag:yaml.org,2002:str", data, style="|")
yaml.add_representer(_LiteralStr, _literal_presenter)
def _use_block_style_for_long_strings(obj):
"""Recursively wrap long strings so the YAML is readable, not one-line."""
if isinstance(obj, dict):
return {k: _use_block_style_for_long_strings(v) for k, v in obj.items()}
if isinstance(obj, list):
return [_use_block_style_for_long_strings(v) for v in obj]
if isinstance(obj, str) and (len(obj) > 80 or "\n" in obj):
return _LiteralStr(obj)
return obj
def dump_yaml(doc: dict, out_path: Path) -> None:
prepared = _use_block_style_for_long_strings(doc)
out_path.parent.mkdir(parents=True, exist_ok=True)
with out_path.open("w", encoding="utf-8") as f:
f.write("# ConnectWise API Member Security Roles — reference matrix.\n")
f.write("# Generated from the source XLSX; do not edit by hand.\n")
f.write("# Re-run generate_role_docs.py after updating the XLSX.\n\n")
yaml.dump(
prepared,
f,
sort_keys=False,
allow_unicode=True,
width=100,
default_flow_style=False,
)
# ---------------------------------------------------------------------------
# Output: Markdown
# ---------------------------------------------------------------------------
def _md_escape(text: str) -> str:
"""Escape pipes and collapse whitespace for Markdown table cells."""
return text.replace("|", "\\|").replace("\n", " ").strip()
def build_markdown(actions: List[ActionRow], source_file: str) -> str:
modules: Dict[str, List[ActionRow]] = {}
for a in actions:
modules.setdefault(a.module, []).append(a)
lines: List[str] = []
lines.append("# ConnectWise API Member — Security Roles Reference")
lines.append("")
lines.append(
f"_Generated {date.today().isoformat()} from "
f"`{source_file}`. Do not edit by hand — update the XLSX and "
f"re-run `generate_role_docs.py`._"
)
lines.append("")
lines.append("## How to read this document")
lines.append("")
lines.append(
"Each ConnectWise module lists the actions it governs. For every "
"action, four permission verbs — **Add**, **Edit**, **Delete**, "
"**Inquire** — can be granted at one of these levels, most to "
"least privileged:"
)
lines.append("")
lines.append("| Level | Meaning |")
lines.append("|-------|---------|")
lines.append("| `ALL` | Access to all records in the system. |")
lines.append("| `MY` | Access to records owned by the user's team. |")
lines.append("| `MINE` | Access only to records owned by the user. |")
lines.append("| `NONE` | No access. |")
lines.append("")
lines.append(
"Not every level applies to every action — the source matrix "
"only documents the levels that are meaningful for each cell. "
"Cells marked _Not applicable_ reference another verb (usually "
"Inquire) where the meaningful level is defined."
)
lines.append("")
lines.append(
"The machine-readable form of this document is "
"[`api-member-security-roles.yaml`](./api-member-security-roles.yaml). "
"Use the YAML when writing integration code; use this Markdown "
"when reviewing, discussing, or onboarding."
)
lines.append("")
lines.append("## Table of contents")
lines.append("")
for module_name in modules:
anchor = module_name.lower().replace(" ", "-").replace("/", "")
lines.append(f"- [{module_name}](#{anchor}) — {len(modules[module_name])} actions")
lines.append("")
for module_name, rows in modules.items():
lines.append(f"## {module_name}")
lines.append("")
for a in rows:
lines.append(f"### {a.action}")
lines.append("")
lines.append("| Verb | Level | Description |")
lines.append("|------|-------|-------------|")
wrote_any = False
for verb in VERBS:
cell = a.permissions[verb]
if cell.levels:
for lvl in LEVEL_ORDER:
if lvl in cell.levels:
lines.append(
f"| {verb.capitalize()} | `{lvl}` | "
f"{_md_escape(cell.levels[lvl])} |"
)
wrote_any = True
elif cell.note:
lines.append(
f"| {verb.capitalize()} | — | "
f"_{_md_escape(cell.note)}_ |"
)
wrote_any = True
if not wrote_any:
lines.append("| — | — | _(no description provided)_ |")
lines.append("")
return "\n".join(lines) + "\n"
def write_markdown(md_text: str, out_path: Path) -> None:
out_path.parent.mkdir(parents=True, exist_ok=True)
out_path.write_text(md_text, encoding="utf-8")
# ---------------------------------------------------------------------------
# Entry point
# ---------------------------------------------------------------------------
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--source", type=Path, required=True,
help="Path to the source .xlsx")
parser.add_argument("--out-yaml", type=Path, required=True,
help="Path to write the YAML output")
parser.add_argument("--out-md", type=Path, required=True,
help="Path to write the Markdown output")
args = parser.parse_args()
actions = read_matrix(args.source)
doc = build_yaml_document(actions, source_file=args.source.name)
dump_yaml(doc, args.out_yaml)
md = build_markdown(actions, source_file=args.source.name)
write_markdown(md, args.out_md)
# Quick data-quality summary to stdout — helpful when re-running after edits.
from collections import Counter
modules_seen = Counter(a.module for a in actions)
print(f"Parsed {len(actions)} actions across {len(modules_seen)} modules:")
for m, n in modules_seen.most_common():
print(f" {m}: {n}")
print(f"\nWrote {args.out_yaml}")
print(f"Wrote {args.out_md}")
if __name__ == "__main__":
main()

View File

@@ -1,5 +0,0 @@
# Dependencies for generate_role_docs.py.
# These are only needed when regenerating the role docs from the XLSX —
# they are not runtime dependencies of ResolutionFlow itself.
openpyxl>=3.1,<4.0
PyYAML>=6.0,<7.0

View File

@@ -1,757 +0,0 @@
# 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.

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,485 +0,0 @@
# PSA Ticket Management — Design Spec
**Date:** 2026-04-16
**Status:** Approved
**Author:** Michael Chihlas + Claude
---
## Overview
Add PSA ticket management to ResolutionFlow so MSP engineers can view, manage, and create ConnectWise tickets without leaving the app. The feature surfaces in three places: a dedicated Tickets page, a dashboard widget on QuickStartPage, and a spin-off ticket flow inside ResolutionAssist sessions.
---
## Decisions Made
| Question | Decision |
|----------|----------|
| Where does ticket management live? | Both: dedicated `/tickets` page + dashboard widget on QuickStartPage |
| List layout | Flat list with rich filters + pagination |
| Row density | Compact single-line rows |
| Ticket detail | Right-side slide-out panel (~50% width) |
| Ticket creation | Two-tab modal: Quick Create (AI) + Full Form |
| Resource member list | All CW members, RF-mapped users visually highlighted |
| Architecture | Dedicated `ticket_service.py` + normalized DTOs |
---
## Section 1 — Backend
### New Endpoints
All added to `backend/app/api/endpoints/integrations.py`, backed by `backend/app/services/ticket_service.py`.
| Method | Path | Purpose |
|--------|------|---------|
| `POST` | `/integrations/psa/tickets` | Create a ticket |
| `PATCH` | `/integrations/psa/tickets/{id}/status` | Update ticket status |
| `GET` | `/integrations/psa/tickets/{id}/resources` | List current assignees |
| `POST` | `/integrations/psa/tickets/{id}/resources` | Add a resource (member) |
| `DELETE` | `/integrations/psa/tickets/{id}/resources/{member_id}` | Remove a resource |
| `POST` | `/integrations/psa/tickets/ai-parse` | Natural language → structured pre-fill payload |
**Breaking change — `search_tickets` response shape updated to `TicketListResponse`.**
The existing `/integrations/psa/tickets/search` endpoint currently returns `list[PSATicketSearchResult]`. This spec changes it to return `TicketListResponse` (adds `total`, `page`, `page_size` wrapper).
Current callers that must be migrated:
- `integrationsApi.searchTickets()` in `frontend/src/api/integrations.ts` (line 18) — update return type
- `integrationsApi.searchTicketsQueue()` in `frontend/src/api/integrations.ts` (line 20) — update return type
- `frontend/src/components/dashboard/TicketQueue.tsx` — update to read `.items` from response
- `frontend/src/components/session/TicketPickerModal.tsx` — update to read `.items` from response
All other existing endpoints (`get_ticket`, `get_ticket_statuses`, `list_members`, `list_boards`) are unchanged.
### ticket_service.py
New service wrapping the PSA provider for ticket mutations. Keeps `integrations.py` clean and PSA-agnostic for future Autotask support.
Methods:
- `create_ticket(account_id, payload) → PSATicketCreated`
- `add_resource(account_id, ticket_id, member_id) → PSAResource`
- `remove_resource(account_id, ticket_id, member_id) → None`
- `update_status(account_id, ticket_id, status_id) → PSATicketStatusUpdate`
- `list_resources(account_id, ticket_id) → list[PSAResource]`
### PSA Provider — New Abstract Methods and Paginated Result Type
**New type in `backend/app/services/psa/types.py`:**
```python
@dataclass
class PaginatedTicketResult:
items: list[PSATicket]
total: int
page: int
page_size: int
```
**`search_tickets` signature change** — updated on both the abstract base and `ConnectWiseProvider` to return `PaginatedTicketResult` instead of `list[PSATicket]`:
```python
# base.py
@abstractmethod
async def search_tickets(self, query: str, **filters) -> PaginatedTicketResult: ...
```
**How `total` is fetched** — ConnectWise provides `GET /service/tickets/count?conditions=...` which accepts the same conditions string as the page fetch. The `ConnectWiseProvider.search_tickets()` implementation fires two parallel requests:
1. `GET /service/tickets?conditions=...&pageSize=N&page=N` — the current page
2. `GET /service/tickets/count?conditions=...` — returns `{ "count": 142 }`
Both use the same built conditions string. `asyncio.gather()` runs them in parallel. The count result is used to populate `PaginatedTicketResult.total`.
**New abstract methods** added to `PSAProvider` base and `ConnectWiseProvider`:
```python
async def list_resources(self, ticket_id: int) -> list[PSAResource]: ...
async def add_resource(self, ticket_id: int, member_id: int) -> PSAResource: ...
async def remove_resource(self, ticket_id: int, member_id: int) -> None: ...
async def create_ticket(self, payload: TicketCreatePayload) -> PSATicketCreated: ...
```
`update_status` already exists on the provider — no change needed there.
ConnectWise implementation:
- `list_resources``GET /service/tickets/{id}/members`
- `add_resource``POST /service/tickets/{id}/members`
- `remove_resource``DELETE /service/tickets/{id}/members/{member_id}`
- `create_ticket``POST /service/tickets`
### Normalized DTOs (Pydantic Schemas)
New schemas in `backend/app/schemas/psa_tickets.py`:
```python
class PSAResource(BaseModel):
member_id: int
member_name: str
member_identifier: str # CW username
is_rf_user: bool # True if mapped in RF member mappings
class PSATicketCreated(BaseModel):
id: int
summary: str
board_name: str
status_name: str
priority_name: str
company_name: str
resources: list[PSAResource]
class PSATicketStatusUpdate(BaseModel):
ticket_id: int
previous_status: str
new_status: str
class TicketCreatePayload(BaseModel):
summary: str
company_id: int
board_id: int
status_id: int
priority_id: int
description: str | None = None
assigned_member_id: int | None = None
class TicketListResponse(BaseModel):
items: list[PSATicketSearchResult] # existing schema
total: int
page: int
page_size: int
```
`search_tickets` endpoint updated to return `TicketListResponse` (was a plain list). Backend sorts results by `priority desc, dateEntered desc` via CW `orderBy` param.
### AI Parse Endpoint
`POST /integrations/psa/tickets/ai-parse`
Request:
```json
{ "prompt": "New ticket for Acme Corp, Outlook not syncing, high priority, assign to me" }
```
Response — all pre-fill fields nullable, explicit `missing_fields` and `warnings`:
```json
{
"summary": "Outlook not syncing",
"company_id": 42,
"board_id": null,
"priority_id": null,
"status_id": null,
"assigned_member_id": 17,
"description": "User reports Outlook calendar not syncing since yesterday morning.",
"missing_fields": ["board_id", "priority_id", "status_id"],
"warnings": ["Could not determine board from context"]
}
```
Frontend uses `missing_fields` to highlight required fields still needing engineer input. No ticket is created at this step — it is a parse-only endpoint.
---
## Section 2 — Frontend Architecture
### New Files
| File | Purpose |
|------|---------|
| `pages/TicketsPage.tsx` | Main tickets page — filter bar + paginated list |
| `components/tickets/TicketListRow.tsx` | Compact single-line row |
| `components/tickets/TicketFilterBar.tsx` | Config-driven filter bar (7 filters) |
| `components/tickets/TicketDetailPanel.tsx` | Slide-out panel orchestrator |
| `components/tickets/detail/TicketDetailHeader.tsx` | ID, summary, company, board, SLA |
| `components/tickets/detail/TicketResourceManager.tsx` | Assignee list + add/remove |
| `components/tickets/detail/TicketNotesFeed.tsx` | Chronological notes history |
| `components/tickets/detail/TicketAddNote.tsx` | Inline note composer |
| `components/tickets/detail/TicketConfigs.tsx` | Attached devices/configs |
| `components/tickets/detail/TicketRelated.tsx` | Related tickets list |
| `components/tickets/NewTicketModal.tsx` | Two-tab modal (owns draft state) |
| `components/tickets/AiTicketParseForm.tsx` | Prompt input → emits parsed values upward |
| `api/tickets.ts` | All ticket API calls (typed, `.then(r => r.data)` pattern) |
| `types/tickets.ts` | TypeScript interfaces mirroring normalized DTOs |
### Existing Files Touched
- `router.tsx` — add `/tickets` route (lazy, via `lazyWithRetry`)
- `AppLayout.tsx` — add "Tickets" nav item in sidebar under RESOLVE section
- `AssistantChatPage.tsx` — handle `create_spin_off_ticket` action type in TaskLane + add "New Ticket" button to session header
- `QuickStartPage.tsx` — no structural change needed; `TicketQueue` already renders at line 64. The existing component is updated in place (see Section 4).
### Shared Types (`types/tickets.ts`)
```typescript
export interface TicketFilters {
search: string;
board_id: number | null;
status_id: number | null;
priority: string | null;
company_id: number | null;
assigned: 'me' | 'unassigned' | 'all' | number; // number = specific member_id
include_closed: boolean;
}
export interface TicketCreationPayload {
summary: string;
company_id: number | null;
board_id: number | null;
status_id: number | null;
priority_id: number | null;
description: string;
assigned_member_id: number | null;
}
export interface AiParseResponse {
summary: string | null;
company_id: number | null;
board_id: number | null;
priority_id: number | null;
status_id: number | null;
assigned_member_id: number | null;
description: string | null;
missing_fields: string[];
warnings: string[];
}
export interface PSAResource {
member_id: number;
member_name: string;
member_identifier: string;
is_rf_user: boolean;
}
// TicketSearchResult is the existing PSATicketSearchResult type from types/integrations.ts
// Re-export or import from there — do not redefine
export interface TicketListResponse {
items: PSATicketSearchResult[];
total: number;
page: number;
page_size: number;
}
```
### TicketsPage — Filter & Pagination State
All filter and pagination state lives in URL query params via `useSearchParams`:
| Param | Type | Default |
|-------|------|---------|
| `search` | string | `""` |
| `board` | number | — |
| `status` | number | — |
| `priority` | string | — |
| `company` | number | — |
| `assigned` | `me \| unassigned \| all \| {id}` | `all` |
| `closed` | boolean | `false` |
| `page` | number | `1` |
Filter changes reset `page` to 1. Pagination: page size of 25. Controls show "Showing XY of Z tickets". Next disabled when `page * 25 >= total`.
### TicketFilterBar — Config-Driven
Filters defined as a `FILTER_CONFIG` array. Each entry:
```typescript
{ key: keyof TicketFilters, label: string, type: 'text' | 'select' | 'toggle', loadOptions?: () => Promise<Option[]> }
```
Adding or removing a filter is a one-line config change, not a component edit.
### TicketDetailPanel — Optimistic Hydration
The panel uses the **existing** `/integrations/psa/tickets/{id}/context` endpoint (client: `psaContextApi.getTicketContext()` in `frontend/src/api/psaContext.ts`) which already returns company, contact, configurations, notes, and related tickets in one call. This avoids creating redundant endpoints.
1. Panel opens immediately with list row data (id, summary, company, board, status, priority) — no loading state for these fields
2. Two parallel fetches fire on open:
- `psaContextApi.getTicketContext(ticketId)` — hydrates contact, notes, configs, related tickets
- `ticketsApi.listResources(ticketId)` — hydrates assignees (new endpoint)
3. All detail sections (contact, notes, configs, related) render skeletons until `getTicketContext` resolves
4. Resources section renders skeleton until `listResources` resolves
`get_ticket` (the simpler single-ticket endpoint) is **not** used by the panel — `getTicketContext` is a strict superset of the data needed.
### NewTicketModal — State Ownership
- `NewTicketModal` owns the `TicketCreationPayload` draft state
- `AiTicketParseForm` is a pure emitter: accepts a prompt string, calls `ai-parse`, fires `onParsed(Partial<TicketCreationPayload>)` upward
- Modal merges parsed values into draft, highlights `missing_fields` with visual indicators
- Two tabs: **Quick Create** (AI prompt → review) | **Full Form** (manual entry)
- Default tab: Quick Create if AI-triggered, Full Form if engineer-initiated
- Initial props: `initialValues?: Partial<TicketCreationPayload>` — used for spin-off pre-population
---
## Section 3 — ResolutionAssist Integration
### Two Trigger Paths
**1. AI-suggested (via `[ACTIONS]` marker)**
When the AI identifies a second distinct issue during a session, it emits a JSON array inside the `[ACTIONS]` marker — matching the exact format `_parse_actions_marker()` in `unified_chat_service.py` expects (a list of objects with `label`, `command`, `description`):
```
[ACTIONS]
[
{
"label": "Create ticket: Printer offline on 2nd floor",
"command": "create_spin_off_ticket",
"description": "Printer offline on 2nd floor"
}
]
[/ACTIONS]
```
The existing `_parse_actions_marker()` parser in `unified_chat_service.py` already handles this format — no parser changes needed. The frontend reads `action.command === "create_spin_off_ticket"` to render the "Create Ticket" button in TaskLane, and uses `action.description` as the `summary_hint` pre-populated into the Quick Create prompt input.
`summary_hint` (from `action.description`) populates the AI prompt input only, not the summary field directly. The engineer still runs the AI parse step and reviews all output. This prevents bypassing review with potentially hallucinated values.
**2. Engineer-initiated**
A "New Ticket" button in the ResolutionAssist session header. Always visible regardless of AI suggestion. Opens `NewTicketModal` with Full Form tab as default.
### Both Paths — NewTicketModal Pre-population
**The linked ticket IDs problem:** The current `PSATicketInfo` type in `frontend/src/types/integrations.ts` only exposes `company_name` and `board_name` — not `company_id` or `board_id`. The modal needs the numeric IDs to pre-populate the form selects.
**Fix:** Expand `PSATicketInfo` in `types/integrations.ts` to add the optional ID fields:
```typescript
export interface PSATicketInfo {
id: string
summary: string
company_name: string | null
board_name: string | null
status_name: string | null
priority_name: string | null
company_id: number | null // add
board_id: number | null // add
}
```
These fields are already returned by the CW API in `get_ticket()` — update `_map_ticket()` in `ConnectWiseProvider` and the `PSATicketInfo` Pydantic schema to pass them through.
**`AssistantChatPage` state change required:** The current page only tracks `activePsaTicketId: string | null` (line 76) — it does not hold a `PSATicketInfo` object. Add a new state field:
```typescript
const [linkedTicket, setLinkedTicket] = useState<PSATicketInfo | null>(null)
```
When the modal is opened (either via AI suggestion or the "New Ticket" button), if `activePsaTicketId` is set and `linkedTicket` is null, fire `integrationsApi.getTicket(activePsaTicketId)` to fetch the full ticket (which now includes `company_id` and `board_id`) and store it in `linkedTicket`. The modal opens immediately — `initialValues` is populated once the fetch resolves and the form fields update. If the fetch is still in flight when the modal opens, `company_id` and `board_id` start empty and fill in when ready.
Once `linkedTicket` is populated, the modal receives:
```typescript
initialValues: {
company_id: linkedTicket.company_id,
board_id: linkedTicket.board_id,
}
```
When no linked ticket exists (`activePsaTicketId === null`): `initialValues` is omitted. `company_id` and `board_id` render empty, requiring manual selection. No silent defaults, no errors.
### TaskLane Action Lifecycle
- Opening the modal does **not** remove the action from TaskLane
- Dismissing the modal without submitting leaves the action visible
- Successful ticket creation removes the action and shows a success toast: `"Ticket #1042 created in ConnectWise"`
### System Prompt Addition
New rule added to `ASSISTANT_SYSTEM_PROMPT` in `backend/app/services/assistant_chat_service.py`:
> When you identify a second distinct issue that is clearly separate from the primary topic of this session, suggest creating a spin-off ticket using the `[ACTIONS]` marker. Use `"command": "create_spin_off_ticket"` and put the issue description in `"description"`. Only suggest this when the issue is genuinely separate — do not suggest for every tangential mention.
### Backend
- **`assistant_chat_service.py`** — system prompt updated with spin-off ticket instruction (above)
- **`unified_chat_service.py`** — no parser changes needed; the existing `_parse_actions_marker()` already handles the JSON array format. The frontend reads `command === "create_spin_off_ticket"` to route the action
- **`flowpilot_engine.py`** — no changes needed for this feature; guided FlowPilot sessions do not use this action type in the current scope
No new backend endpoints — the modal reuses `POST /integrations/psa/tickets` and `POST /integrations/psa/tickets/ai-parse`.
---
## Section 4 — Dashboard Widget (QuickStartPage)
### Placement
`TicketQueue` **already exists** in `QuickStartPage` (line 64, below `ActiveFlowPilotSessions`, above the Dashboard section). It currently auto-hides if no PSA connection exists. This spec updates the existing `TicketQueue` component — it is **not** a new widget and does not need to be added to `QuickStartPage`. The Dashboard section below it is not collapsible.
### Data Fetching
On mount: `GET /integrations/psa/member-mappings` first to detect mapping state, then `integrationsApi.searchTicketsQueue({ assigned_to_me: true, include_closed: false, page_size: 5 })` if a mapping exists for the current user.
`searchTicketsQueue` is used (not `searchTickets`) because it already accepts `assigned_to_me` and `page_size` params. Its return type will be updated to `TicketListResponse` as part of the search endpoint migration, so the widget reads `.items` after that change.
Member mapping detection is explicit — the widget checks the mappings response, not the ticket result. "No mapping" and "no tickets" are distinct states.
### Widget States
| State | Condition | Display |
|-------|-----------|---------|
| Hidden | No PSA connection | Widget not rendered |
| Prompt | PSA connected, no member mapping | "Map your PSA member to see your queue" → `/account/integrations` |
| Loading | Fetching | 3 skeleton rows |
| Populated | Tickets returned | Up to 5 compact rows + "View All Tickets →" |
| Empty | No assigned open tickets | "No open tickets assigned to you" — muted, no CTA |
| Error | PSA fetch fails | Silent — returns `[]`, no toast (per Lesson 111) |
### Row Display
Compact row matching Tickets page style: `#ID · Summary · Status badge · Priority dot`
Clicking a row opens `TicketDetailPanel` as a right-side sheet rendered at the `QuickStartPage` level. Does **not** navigate away.
### "View All Tickets" Link
Links to `/tickets?assigned=me`. `TicketsPage` reads `assigned` from `useSearchParams` on mount and applies it as the initial filter state — consistent with Section 2 URL param contract.
### Sorting
Backend `search_tickets()` adds `orderBy=priority desc,dateEntered desc` to the CW API query. Widget does not sort client-side.
---
## Files Changed Summary
### New Backend Files
- `backend/app/services/ticket_service.py`
- `backend/app/schemas/psa_tickets.py`
### Modified Backend Files
- `backend/app/api/endpoints/integrations.py` — 6 new endpoints, update search to return `TicketListResponse`
- `backend/app/services/psa/types.py` — add `PaginatedTicketResult` dataclass
- `backend/app/services/psa/base.py` — 4 new abstract methods; update `search_tickets` return type to `PaginatedTicketResult`
- `backend/app/services/psa/connectwise/provider.py` — implement 4 new methods; update `search_tickets` to fire parallel count request and return `PaginatedTicketResult`; update `_map_ticket()` to pass through `company_id` and `board_id`
- `backend/app/schemas/psa_connection.py` — add `company_id` and `board_id` to `PSATicketInfo` Pydantic schema
- `backend/app/services/assistant_chat_service.py` — add spin-off ticket rule to `ASSISTANT_SYSTEM_PROMPT`
- ~~`backend/app/services/flowpilot_engine.py`~~ — no changes (FlowPilot out of scope for this feature)
- ~~`backend/app/services/unified_chat_service.py`~~ — no changes (existing `[ACTIONS]` parser handles the format)
### New Frontend Files
- `frontend/src/pages/TicketsPage.tsx`
- `frontend/src/api/tickets.ts`
- `frontend/src/types/tickets.ts`
- `frontend/src/components/tickets/TicketListRow.tsx`
- `frontend/src/components/tickets/TicketFilterBar.tsx`
- `frontend/src/components/tickets/TicketDetailPanel.tsx`
- `frontend/src/components/tickets/NewTicketModal.tsx`
- `frontend/src/components/tickets/AiTicketParseForm.tsx`
- `frontend/src/components/tickets/detail/TicketDetailHeader.tsx`
- `frontend/src/components/tickets/detail/TicketResourceManager.tsx`
- `frontend/src/components/tickets/detail/TicketNotesFeed.tsx`
- `frontend/src/components/tickets/detail/TicketAddNote.tsx`
- `frontend/src/components/tickets/detail/TicketConfigs.tsx`
- `frontend/src/components/tickets/detail/TicketRelated.tsx`
### Modified Frontend Files
- `frontend/src/router.tsx``/tickets` route
- `frontend/src/components/layout/AppLayout.tsx` — Tickets nav item
- `frontend/src/pages/AssistantChatPage.tsx` — handle `create_spin_off_ticket` command in action renderer + add "New Ticket" button to session header
- `frontend/src/components/dashboard/TicketQueue.tsx` — update existing component (see Section 4 — not a new file)
- `frontend/src/api/integrations.ts` — update `searchTickets()` and `searchTicketsQueue()` return types to `TicketListResponse`
- `frontend/src/types/integrations.ts` — add `company_id: number | null` and `board_id: number | null` to `PSATicketInfo`
- `frontend/src/components/dashboard/TicketQueue.tsx` — update existing component: read `.items`, add mapping-state detection, member-mapping check, and 5-item cap
- `frontend/src/components/session/TicketPickerModal.tsx` — read `.items` from paginated response
---
## Out of Scope
- Autotask provider implementation (schema-ready, not implemented)
- Time entry creation from ticket detail (provider method exists, no UI)
- Ticket editing beyond status (summary, description, priority changes)
- Bulk ticket operations
- Real-time ticket updates / polling

View File

@@ -23,7 +23,6 @@
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"html-to-image": "^1.11.13",
"immer": "^11.1.3", "immer": "^11.1.3",
"lucide-react": "^0.563.0", "lucide-react": "^0.563.0",
"monaco-editor": "^0.55.1", "monaco-editor": "^0.55.1",
@@ -5332,12 +5331,6 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/html-to-image": {
"version": "1.11.13",
"resolved": "https://registry.npmjs.org/html-to-image/-/html-to-image-1.11.13.tgz",
"integrity": "sha512-cuOPoI7WApyhBElTTb9oqsawRvZ0rHhaHwghRLlTuffoD1B2aDemlCruLeZrUIIdvG7gs9xeELEPm6PhuASqrg==",
"license": "MIT"
},
"node_modules/html-url-attributes": { "node_modules/html-url-attributes": {
"version": "3.0.1", "version": "3.0.1",
"resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz", "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz",

View File

@@ -36,7 +36,6 @@
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"html-to-image": "^1.11.13",
"immer": "^11.1.3", "immer": "^11.1.3",
"lucide-react": "^0.563.0", "lucide-react": "^0.563.0",
"monaco-editor": "^0.55.1", "monaco-editor": "^0.55.1",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 686 KiB

View File

@@ -49,9 +49,9 @@ function handleGlobalError(error: AxiosError) {
return return
} }
// Server errors (5xx) — show backend detail when available, else generic message // Server errors (5xx)
if (status >= 500) { if (status >= 500) {
toast.error(detail || 'Server error - please try again later') toast.error('Server error - please try again later')
return return
} }
} }

View File

@@ -1,23 +0,0 @@
import apiClient from './client'
import type { DeviceTypeResponse, DeviceTypeCreate } from '@/types'
export const deviceTypesApi = {
async list(): Promise<DeviceTypeResponse[]> {
const response = await apiClient.get<DeviceTypeResponse[]>('/device-types/')
return response.data
},
async create(data: DeviceTypeCreate): Promise<DeviceTypeResponse> {
const response = await apiClient.post<DeviceTypeResponse>('/device-types/', data)
return response.data
},
async update(id: string, data: Partial<DeviceTypeCreate>): Promise<DeviceTypeResponse> {
const response = await apiClient.put<DeviceTypeResponse>(`/device-types/${id}`, data)
return response.data
},
async remove(id: string): Promise<void> {
await apiClient.delete(`/device-types/${id}`)
},
}

View File

@@ -35,6 +35,3 @@ export { betaFeedbackApi } from './betaFeedback'
export { branchesApi } from './branches' export { branchesApi } from './branches'
export { handoffsApi } from './handoffs' export { handoffsApi } from './handoffs'
export { resolutionsApi } from './resolutions' export { resolutionsApi } from './resolutions'
export { deviceTypesApi } from './deviceTypes'
export { networkDiagramsApi } from './networkDiagrams'
export { ticketsApi } from './tickets'

View File

@@ -1,7 +1,6 @@
import { apiClient } from './client' import { apiClient } from './client'
import type { PsaConnectionResponse, PsaConnectionCreate, PsaConnectionUpdate, PsaConnectionTestResponse } from '@/types' import type { PsaConnectionResponse, PsaConnectionCreate, PsaConnectionUpdate, PsaConnectionTestResponse } from '@/types'
import type { PSABoard, TicketLinkResponse, PSATicketInfo, PSATicketStatusItem, PsaPreviewResponse, PsaPostResponse, PsaPostLogEntry, PsaMemberResponse, PsaMemberMappingResponse, AutoMatchResult, FlowpilotSettings } from '@/types/integrations' import type { TicketLinkResponse, PSATicketSearchResult, PSATicketInfo, PSATicketStatusItem, PsaPreviewResponse, PsaPostResponse, PsaPostLogEntry, PsaMemberResponse, PsaMemberMappingResponse, AutoMatchResult, FlowpilotSettings } from '@/types/integrations'
import type { TicketListResponse } from '@/types/tickets'
export const integrationsApi = { export const integrationsApi = {
getConnection: () => getConnection: () =>
@@ -14,24 +13,12 @@ export const integrationsApi = {
apiClient.delete(`/integrations/psa/connections/${id}`), apiClient.delete(`/integrations/psa/connections/${id}`),
testConnection: (id: string) => testConnection: (id: string) =>
apiClient.post<PsaConnectionTestResponse>(`/integrations/psa/connections/${id}/test`).then(r => r.data), apiClient.post<PsaConnectionTestResponse>(`/integrations/psa/connections/${id}/test`).then(r => r.data),
listBoards: () => searchTickets: (params: { query?: string; board_id?: number; include_closed?: boolean }) =>
apiClient.get<PSABoard[]>('/integrations/psa/boards').then(r => r.data), apiClient.get<PSATicketSearchResult[]>('/integrations/psa/tickets/search', { params }).then(r => r.data),
searchTickets: (params: { query?: string; board_id?: number; include_closed?: boolean }): Promise<TicketListResponse> =>
apiClient.get<TicketListResponse>('/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
}): Promise<TicketListResponse> =>
apiClient.get<TicketListResponse>('/integrations/psa/tickets/search', { params }).then(r => r.data),
getTicket: (id: string) => getTicket: (id: string) =>
apiClient.get<PSATicketInfo>(`/integrations/psa/tickets/${id}`).then(r => r.data), apiClient.get<PSATicketInfo>(`/integrations/psa/tickets/${id}`).then(r => r.data),
getTicketStatuses: (ticketId: string) => getTicketStatuses: (ticketId: string) =>
apiClient.get<PSATicketStatusItem[]>(`/integrations/psa/tickets/${ticketId}/statuses`).then(r => r.data), apiClient.get<PSATicketStatusItem[]>(`/integrations/psa/tickets/${ticketId}/statuses`).then(r => r.data),
getBoardStatuses: (boardId: number | string) =>
apiClient.get<PSATicketStatusItem[]>(`/integrations/psa/boards/${boardId}/statuses`).then(r => r.data),
listMembers: () => listMembers: () =>
apiClient.get<PsaMemberResponse[]>('/integrations/psa/members').then(r => r.data), apiClient.get<PsaMemberResponse[]>('/integrations/psa/members').then(r => r.data),
getMemberMappings: () => getMemberMappings: () =>

View File

@@ -1,67 +0,0 @@
import apiClient from './client'
import type {
NetworkDiagramResponse,
NetworkDiagramListItem,
NetworkDiagramCreate,
NetworkDiagramUpdate,
AIGenerateRequest,
AIGenerateResponse,
DiagramImportData,
DiagramImportResponse,
DiagramExportResponse,
} from '@/types'
export const networkDiagramsApi = {
async list(params?: { client_name?: string; search?: string }): Promise<NetworkDiagramListItem[]> {
const response = await apiClient.get<NetworkDiagramListItem[]>('/network-diagrams/', { params })
return response.data
},
async get(id: string): Promise<NetworkDiagramResponse> {
const response = await apiClient.get<NetworkDiagramResponse>(`/network-diagrams/${id}`)
return response.data
},
async create(data: NetworkDiagramCreate): Promise<NetworkDiagramResponse> {
const response = await apiClient.post<NetworkDiagramResponse>('/network-diagrams/', data)
return response.data
},
async update(id: string, data: NetworkDiagramUpdate): Promise<NetworkDiagramResponse> {
const response = await apiClient.put<NetworkDiagramResponse>(`/network-diagrams/${id}`, data)
return response.data
},
async archive(id: string): Promise<void> {
await apiClient.delete(`/network-diagrams/${id}`)
},
async duplicate(id: string): Promise<NetworkDiagramResponse> {
const response = await apiClient.post<NetworkDiagramResponse>(`/network-diagrams/${id}/duplicate`)
return response.data
},
async exportJson(id: string): Promise<DiagramExportResponse> {
const response = await apiClient.get<DiagramExportResponse>(`/network-diagrams/${id}/export`)
return response.data
},
async importJson(data: DiagramImportData): Promise<DiagramImportResponse> {
const response = await apiClient.post<DiagramImportResponse>('/network-diagrams/import', data)
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
},
async listClients(): Promise<string[]> {
const response = await apiClient.get<string[]>('/network-diagrams/clients')
return response.data
},
}

View File

@@ -1,48 +0,0 @@
import { apiClient } from './client'
import type {
PSAResource,
PSATicketCreated,
PSATicketStatusUpdate,
TicketCreationPayload,
AiParseResponse,
TicketListResponse,
PSAPriority,
} from '@/types/tickets'
export const ticketsApi = {
listResources: (ticketId: number): Promise<PSAResource[]> =>
apiClient.get<PSAResource[]>(`/integrations/psa/tickets/${ticketId}/resources`).then(r => r.data),
addResource: (ticketId: number, memberId: number): Promise<PSAResource> =>
apiClient.post<PSAResource>(`/integrations/psa/tickets/${ticketId}/resources?member_id=${memberId}`).then(r => r.data),
removeResource: (ticketId: number, memberId: number): Promise<void> =>
apiClient.delete(`/integrations/psa/tickets/${ticketId}/resources/${memberId}`).then(() => undefined),
updateStatus: (ticketId: number, statusId: number): Promise<PSATicketStatusUpdate> =>
apiClient.patch<PSATicketStatusUpdate>(`/integrations/psa/tickets/${ticketId}/status?status_id=${statusId}`).then(r => r.data),
createTicket: (payload: TicketCreationPayload): Promise<PSATicketCreated> =>
apiClient.post<PSATicketCreated>('/integrations/psa/tickets', payload).then(r => r.data),
aiParse: (prompt: string): Promise<AiParseResponse> =>
apiClient.post<AiParseResponse>('/integrations/psa/tickets/ai-parse', { prompt }).then(r => r.data),
listPriorities: (): Promise<PSAPriority[]> =>
apiClient.get<PSAPriority[]>('/integrations/psa/priorities').then(r => r.data),
searchTickets: (params: {
query?: string
board_id?: number | null
status_id?: number | null
include_closed?: boolean
assigned_to_me?: boolean
unassigned?: boolean
board_ids?: string
priority?: string | null
company_id?: number | null
page?: number
page_size?: number
}): Promise<TicketListResponse> =>
apiClient.get<TicketListResponse>('/integrations/psa/tickets/search', { params }).then(r => r.data),
}

View File

@@ -1,392 +0,0 @@
import { useState, useEffect, useRef, useCallback } from 'react'
import { useNavigate, Link } from 'react-router-dom'
import { Ticket, ChevronDown, Check, AlertCircle } from 'lucide-react'
import { integrationsApi } from '@/api/integrations'
import type { PSABoard, PSATicketSearchResult } from '@/types/integrations'
import { cn } from '@/lib/utils'
const PAGE_SIZE = 5
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">&middot;</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 [hasMemberMapping, setHasMemberMapping] = useState<boolean | null>(null) // null = loading
const [boards, setBoards] = useState<PSABoard[]>([])
const [selectedBoardIds, setSelectedBoardIds] = useState<number[]>([])
const [activeTab, setActiveTab] = useState<Tab>('mine')
const [tickets, setTickets] = useState<PSATicketSearchResult[]>([])
const [loading, setLoading] = 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))
}, [])
// Detect member mapping on mount
useEffect(() => {
integrationsApi.getMemberMappings()
.then(mappings => {
setHasMemberMapping(mappings.length > 0)
})
.catch(() => setHasMemberMapping(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[]) => {
const params: Parameters<typeof integrationsApi.searchTicketsQueue>[0] = {
page: 1,
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)
setTickets(results.items)
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
if (activeTab === 'mine' && hasMemberMapping !== true) return
setTickets([])
setLoading(true)
fetchTickets(activeTab, selectedBoardIds).finally(() => setLoading(false))
}, [activeTab, selectedBoardIds, hasConnection, hasMemberMapping, fetchTickets])
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>
{/* Mapping prompt for "mine" tab when no member mapping configured */}
{activeTab === 'mine' && hasMemberMapping === false && (
<div className="px-5 py-6 text-center">
<p className="text-sm text-muted-foreground">
<Link to="/account/integrations" className="text-accent hover:underline">
Map your PSA member
</Link>{' '}
to see your ticket queue.
</p>
</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}
onStartSession={handleStartSession}
/>
))}
</>
)}
{/* View all tickets link */}
{tickets.length > 0 && (
<div className="px-5 py-3 border-t border-default">
<Link
to="/tickets?assigned=me"
className="text-xs text-accent hover:text-accent/80 transition-colors"
>
View all tickets
</Link>
</div>
)}
{/* 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 &rarr; Integrations
</p>
</>
) : (
<p className="text-sm text-muted-foreground">No unassigned open tickets</p>
)}
</div>
)}
</div>
</div>
)
}

View File

@@ -5,7 +5,7 @@ import {
LayoutGrid, Clock, AlertTriangle, GitBranch, Code2, Wand2, LayoutGrid, Clock, AlertTriangle, GitBranch, Code2, Wand2,
ListChecks, Download, BarChart3, ListChecks, Download, BarChart3,
Settings, Pin, PinOff, Settings, Pin, PinOff,
History, FileText, Network, Ticket, History, FileText,
} from 'lucide-react' } from 'lucide-react'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { useUserPreferencesStore } from '@/store/userPreferencesStore' import { useUserPreferencesStore } from '@/store/userPreferencesStore'
@@ -83,18 +83,13 @@ export function Sidebar() {
{ href: '/escalations', label: 'Escalations', count: stats?.escalation_count || undefined }, { href: '/escalations', label: 'Escalations', count: stats?.escalation_count || undefined },
], ],
}, },
{
href: '/tickets', icon: Ticket, label: 'Tickets', shortLabel: 'Tickets',
matchPaths: ['/tickets'],
},
{ {
href: '/trees', icon: GitBranch, label: 'Flows', shortLabel: 'Flows', href: '/trees', icon: GitBranch, label: 'Flows', shortLabel: 'Flows',
badge: stats?.tree_counts.total || undefined, badge: stats?.tree_counts.total || undefined,
matchPaths: ['/trees', '/flows', '/my-trees', '/step-library', '/review-queue', '/network-diagrams'], matchPaths: ['/trees', '/flows', '/my-trees', '/step-library', '/review-queue'],
children: [ children: [
{ href: '/trees', label: 'Flow Library', count: stats?.tree_counts.total || undefined }, { href: '/trees', label: 'Flow Library', count: stats?.tree_counts.total || undefined },
{ href: '/trees?type=procedural', label: 'Projects', count: stats?.tree_counts.procedural || undefined }, { href: '/trees?type=procedural', label: 'Projects', count: stats?.tree_counts.procedural || undefined },
{ href: '/network-diagrams', label: 'Network Maps' },
{ href: '/step-library', label: 'Solutions Library' }, { href: '/step-library', label: 'Solutions Library' },
{ href: '/review-queue', label: 'Review Queue' }, { href: '/review-queue', label: 'Review Queue' },
], ],
@@ -124,7 +119,6 @@ export function Sidebar() {
items: [ items: [
{ href: '/', icon: LayoutGrid, label: 'Dashboard', shortLabel: 'Dash' }, { href: '/', icon: LayoutGrid, label: 'Dashboard', shortLabel: 'Dash' },
{ href: '/sessions', icon: Clock, label: 'Session History', shortLabel: 'History', badge: stats?.active_count || undefined, matchPaths: ['/sessions'] }, { href: '/sessions', icon: Clock, label: 'Session History', shortLabel: 'History', badge: stats?.active_count || undefined, matchPaths: ['/sessions'] },
{ href: '/tickets', icon: Ticket, label: 'Tickets', shortLabel: 'Tickets', matchPaths: ['/tickets'] },
{ href: '/escalations', icon: AlertTriangle, label: 'Escalations', shortLabel: 'Escal', badge: stats?.escalation_count || undefined }, { href: '/escalations', icon: AlertTriangle, label: 'Escalations', shortLabel: 'Escal', badge: stats?.escalation_count || undefined },
], ],
}, },
@@ -140,7 +134,6 @@ export function Sidebar() {
{ href: '/trees?type=procedural', label: 'Projects', count: stats?.tree_counts.procedural || undefined }, { href: '/trees?type=procedural', label: 'Projects', count: stats?.tree_counts.procedural || undefined },
], ],
}, },
{ href: '/network-diagrams', icon: Network, label: 'Network Maps', shortLabel: 'NetMap', matchPaths: ['/network-diagrams'] },
{ href: '/scripts', icon: Code2, label: 'Scripts', shortLabel: 'Scripts' }, { href: '/scripts', icon: Code2, label: 'Scripts', shortLabel: 'Scripts' },
{ href: '/script-builder', icon: Wand2, label: 'Script Builder', shortLabel: 'Builder' }, { href: '/script-builder', icon: Wand2, label: 'Script Builder', shortLabel: 'Builder' },
{ href: '/review-queue', icon: ListChecks, label: 'Review Queue', shortLabel: 'Review' }, { href: '/review-queue', icon: ListChecks, label: 'Review Queue', shortLabel: 'Review' },

View File

@@ -1,232 +0,0 @@
import { useState, useCallback, useEffect } from 'react'
import { Sparkles, ArrowRight, PencilRuler, Wand2, X } from 'lucide-react'
import { networkDiagramsApi } from '@/api'
import type { AIGenerateResponse } from '@/types'
const EXAMPLE_PROMPTS = [
'Small office with firewall and core switch',
'Azure hybrid cloud with VPN gateway',
'Branch office connected to HQ via MPLS',
'Data center with redundant core switches',
'Remote workforce with Meraki and cloud apps',
]
interface CanvasEmptyPromptProps {
onGenerate: (result: AIGenerateResponse, mode: 'replace' | 'merge') => void
}
export function CanvasEmptyPrompt({ onGenerate }: CanvasEmptyPromptProps) {
const [mode, setMode] = useState<'choice' | 'ai' | 'manual'>('choice')
const [description, setDescription] = useState('')
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const switchToManual = useCallback(() => {
if (loading) return
setMode('manual')
setError(null)
}, [loading])
const handleGenerate = useCallback(async (text?: string) => {
const desc = (text ?? description).trim()
if (!desc) return
setLoading(true)
setError(null)
try {
const result = await networkDiagramsApi.aiGenerate({
description: desc,
mode: 'replace',
existingBounds: null,
})
onGenerate(result, 'replace')
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'Generation failed. Please try again.')
} finally {
setLoading(false)
}
}, [description, onGenerate])
useEffect(() => {
if (mode === 'manual') return
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
event.preventDefault()
switchToManual()
}
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [mode, switchToManual])
if (mode === 'manual') {
return (
<div className="pointer-events-none absolute inset-x-0 bottom-6 z-10 flex justify-center px-6">
<div className="pointer-events-auto flex max-w-xl items-center gap-3 rounded-lg border border-default bg-card px-4 py-3 shadow-xl">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-accent/10 text-accent">
<PencilRuler size={14} />
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-heading">Manual mode is on</p>
<p className="text-xs text-muted-foreground">
Drag devices from the left panel onto the canvas, or reopen AI whenever you want.
</p>
</div>
<button
onClick={() => setMode('ai')}
className="inline-flex shrink-0 items-center gap-1 rounded-full border border-default px-3 py-1 text-xs font-medium text-primary hover:border-accent hover:text-accent"
>
<Sparkles size={12} />
Open AI Generator
</button>
</div>
</div>
)
}
return (
<div
className="pointer-events-none absolute inset-0 z-10 flex items-center justify-center bg-[rgba(10,14,20,0.42)] px-6"
onClick={event => {
if (event.target === event.currentTarget) {
switchToManual()
}
}}
>
<div className="pointer-events-auto relative w-full max-w-lg rounded-lg border border-default bg-card p-8 shadow-2xl">
<button
onClick={switchToManual}
disabled={loading}
aria-label="Close AI prompt and build manually"
className="absolute right-4 top-4 inline-flex h-8 w-8 items-center justify-center rounded-full border border-default text-muted-foreground hover:border-hover hover:text-primary disabled:opacity-40"
>
<X size={14} />
</button>
{mode === 'choice' ? (
<>
<div className="mb-6 text-center">
<div className="mb-2 flex items-center justify-center gap-2">
<Wand2 size={16} className="text-accent" />
<h2 className="font-heading text-base font-semibold text-heading">
Start a network map
</h2>
</div>
<p className="text-xs text-muted-foreground">
Generate a topology with AI or start with a blank canvas and build it manually.
</p>
<p className="mt-2 text-[11px] text-muted-foreground/80">
Press <span className="font-medium text-primary">Esc</span> or click outside to skip AI and start dragging devices.
</p>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<button
onClick={() => setMode('ai')}
className="rounded-lg border border-accent/40 bg-accent/10 p-4 text-left transition-colors hover:border-accent hover:bg-accent/15"
>
<div className="mb-3 inline-flex rounded-lg bg-accent/15 p-2 text-accent">
<Sparkles size={16} />
</div>
<div className="mb-1 text-sm font-semibold text-heading">Generate with AI</div>
<p className="text-xs text-muted-foreground">
Describe the environment and let AI lay out the first version for you.
</p>
</button>
<button
onClick={switchToManual}
className="rounded-lg border border-default bg-elevated/40 p-4 text-left transition-colors hover:border-accent hover:bg-elevated/60"
>
<div className="mb-3 inline-flex rounded-lg bg-primary/10 p-2 text-primary">
<PencilRuler size={16} />
</div>
<div className="mb-1 text-sm font-semibold text-heading">Build manually</div>
<p className="text-xs text-muted-foreground">
Close this prompt and use click-and-drag from the left toolbar to place devices on the canvas.
</p>
</button>
</div>
</>
) : (
<>
<div className="mb-5 text-center">
<div className="mb-2 flex items-center justify-center gap-2">
<Sparkles size={16} className="text-accent" />
<h2 className="font-heading text-base font-semibold text-heading">
Describe your network
</h2>
</div>
<p className="text-xs text-muted-foreground">
AI will generate the topology in seconds, or you can go back and switch to manual creation.
</p>
<p className="mt-2 text-[11px] text-muted-foreground/80">
Press <span className="font-medium text-primary">Esc</span>, click outside, or use the close button to build manually instead.
</p>
</div>
<div className="relative mb-3">
<textarea
value={description}
onChange={e => setDescription(e.target.value)}
onKeyDown={e => {
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) handleGenerate()
}}
placeholder="e.g. Small office with a firewall, core switch, 3 access points, a file server, and 20 workstations"
rows={3}
disabled={loading}
autoFocus
className="w-full resize-none rounded-lg border border-default bg-input px-4 py-3 pb-7 text-sm text-primary placeholder:text-muted-foreground focus:border-accent focus:outline-none disabled:opacity-50"
/>
<span className="pointer-events-none absolute bottom-2 right-3 text-[10px] text-muted-foreground">
to generate
</span>
</div>
<div className="mb-4 flex flex-wrap gap-1.5">
{EXAMPLE_PROMPTS.map(p => (
<button
key={p}
onClick={() => handleGenerate(p)}
disabled={loading}
className="rounded-full border border-default px-3 py-1 text-xs text-muted-foreground transition-colors hover:border-accent hover:text-accent disabled:opacity-40"
>
{p}
</button>
))}
</div>
{error && <p className="mb-3 text-xs text-red-400">{error}</p>}
<div className="flex gap-2">
<button
onClick={switchToManual}
disabled={loading}
className="flex-1 rounded-lg border border-default px-4 py-2.5 text-sm font-medium text-primary hover:border-accent hover:text-accent disabled:opacity-40"
>
Build Manually
</button>
{loading ? (
<div className="flex flex-1 items-center justify-center gap-2 py-2.5">
<div className="h-4 w-4 animate-spin rounded-full border-2 border-accent border-t-transparent" />
<span className="text-sm text-muted-foreground">Mapping your network</span>
</div>
) : (
<button
onClick={() => handleGenerate()}
disabled={!description.trim()}
className="flex flex-1 items-center justify-center gap-2 rounded-lg bg-accent px-4 py-2.5 text-sm font-medium text-white transition-opacity hover:bg-accent/90 disabled:opacity-40"
>
<Sparkles size={14} />
Generate Diagram
<ArrowRight size={14} />
</button>
)}
</div>
</>
)}
</div>
</div>
)
}

View File

@@ -1,209 +0,0 @@
import { useEffect, useRef } from '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 {
label: string
icon: React.ElementType
shortcut: string
onClick: () => void
disabled?: boolean
dividerBefore?: boolean
}
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,
onAlignLeft,
onAlignRight,
onAlignCenterH,
onAlignTop,
onAlignBottom,
onAlignCenterV,
onDistributeH,
onDistributeV,
canAlign,
canDistribute,
onGroupSelection,
onUngroupSelection,
canGroup,
canUngroup,
}: ContextMenuProps) {
const menuRef = useRef<HTMLDivElement>(null)
const clampedPosition = { ...position }
if (typeof window !== 'undefined') {
const itemCount = actions.length
const dividerCount = actions.filter(a => a.dividerBefore).length
const menuWidth = 192
const menuHeight = itemCount * 36 + dividerCount * 9 + 8
if (clampedPosition.x + menuWidth > window.innerWidth) {
clampedPosition.x = window.innerWidth - menuWidth - 8
}
if (clampedPosition.y + menuHeight > window.innerHeight) {
clampedPosition.y = window.innerHeight - menuHeight - 8
}
}
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(e.target as HTMLElement)) {
onClose()
}
}
const handleEscape = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose()
}
const handleScroll = () => onClose()
document.addEventListener('mousedown', handleClickOutside)
document.addEventListener('keydown', handleEscape)
document.addEventListener('scroll', handleScroll, true)
return () => {
document.removeEventListener('mousedown', handleClickOutside)
document.removeEventListener('keydown', handleEscape)
document.removeEventListener('scroll', handleScroll, true)
}
}, [onClose])
return (
<div
ref={menuRef}
className="fixed z-50 w-48 rounded-lg border border-default bg-card py-1 shadow-lg"
style={{ left: clampedPosition.x, top: clampedPosition.y }}
>
{actions.map((action) => (
<div key={action.label}>
{action.dividerBefore && (
<div className="my-1 border-t border-default" />
)}
<button
onClick={() => {
action.onClick()
onClose()
}}
disabled={action.disabled}
className={cn(
'flex w-full items-center gap-2 px-3 py-2 text-xs text-primary hover:bg-elevated',
action.disabled && 'opacity-40 pointer-events-none',
)}
>
<action.icon size={14} />
<span>{action.label}</span>
<span className="ml-auto text-[10px] text-muted-foreground">{action.shortcut}</span>
</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>
)
}
// eslint-disable-next-line react-refresh/only-export-components
export function getNodeMenuActions(handlers: {
onCopy: () => void
onDuplicate: () => void
onBringToFront: () => void
onSendToBack: () => void
onDelete: () => void
}): MenuAction[] {
return [
{ label: 'Copy', icon: Copy, shortcut: 'Ctrl+C', onClick: handlers.onCopy },
{ label: 'Duplicate', icon: CopyPlus, shortcut: 'Ctrl+D', onClick: handlers.onDuplicate },
{ label: 'Bring to Front', icon: BringToFront, shortcut: ']', onClick: handlers.onBringToFront, dividerBefore: true },
{ label: 'Send to Back', icon: SendToBack, shortcut: '[', onClick: handlers.onSendToBack },
{ label: 'Delete', icon: Trash2, shortcut: 'Del', onClick: handlers.onDelete, dividerBefore: true },
]
}
// eslint-disable-next-line react-refresh/only-export-components
export function getCanvasMenuActions(handlers: {
onPaste: () => void
onSelectAll: () => void
onFitView: () => void
hasClipboard: boolean
}): MenuAction[] {
return [
{ label: 'Paste', icon: ClipboardPaste, shortcut: 'Ctrl+V', onClick: handlers.onPaste, disabled: !handlers.hasClipboard },
{ label: 'Select All', icon: BoxSelect, shortcut: 'Ctrl+A', onClick: handlers.onSelectAll },
{ label: 'Fit View', icon: Maximize2, shortcut: '⌘⇧F', onClick: handlers.onFitView },
]
}

View File

@@ -1,275 +0,0 @@
import { useState, useCallback, useRef, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
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
clientName: string | null
isDirty: boolean
isSaving: boolean
lastSavedAt: Date | null
diagramId: string | null
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({
name,
clientName,
isDirty,
isSaving,
lastSavedAt,
diagramId,
onNameChange,
onSave,
onExportPng,
onExportSvg,
onExportPdf,
onExportJson,
onExportDrawio,
onImportDrawio,
onUndo,
onRedo,
canUndo,
canRedo,
interactionMode,
onModeChange,
}: DiagramHeaderProps) {
const navigate = useNavigate()
const [editing, setEditing] = useState(false)
const [editValue, setEditValue] = useState(name)
const [showExportMenu, setShowExportMenu] = useState(false)
const inputRef = useRef<HTMLInputElement>(null)
const exportMenuRef = useRef<HTMLDivElement>(null)
useEffect(() => {
if (editing && inputRef.current) {
inputRef.current.focus()
inputRef.current.select()
}
}, [editing])
useEffect(() => {
setEditValue(name)
}, [name])
useEffect(() => {
if (!showExportMenu) return
const handleClick = (e: MouseEvent) => {
if (exportMenuRef.current && !exportMenuRef.current.contains(e.target as HTMLElement)) {
setShowExportMenu(false)
}
}
document.addEventListener('mousedown', handleClick)
return () => document.removeEventListener('mousedown', handleClick)
}, [showExportMenu])
const handleConfirmName = useCallback(() => {
setEditing(false)
if (editValue.trim() && editValue !== name) {
onNameChange(editValue.trim())
} else {
setEditValue(name)
}
}, [editValue, name, onNameChange])
const formatLastSaved = () => {
if (!lastSavedAt) return null
// eslint-disable-next-line react-hooks/purity
const diff = Date.now() - lastSavedAt.getTime()
if (diff < 60_000) return 'Saved just now'
const mins = Math.floor(diff / 60_000)
return `Saved ${mins}m ago`
}
return (
<div className="flex h-14 items-center gap-3 border-b border-default bg-card px-4">
<button
onClick={() => navigate('/network-diagrams')}
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-primary"
>
<ChevronLeft size={16} />
Network Maps
</button>
<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}
value={editValue}
onChange={e => setEditValue(e.target.value)}
onBlur={handleConfirmName}
onKeyDown={e => { if (e.key === 'Enter') handleConfirmName(); if (e.key === 'Escape') { setEditing(false); setEditValue(name) } }}
className="rounded border border-accent bg-input px-2 py-1 text-sm font-heading font-semibold text-heading focus:outline-none"
/>
) : (
<button
onClick={() => setEditing(true)}
className="text-sm font-heading font-semibold text-heading hover:text-accent"
>
{name || 'Untitled Diagram'}
</button>
)}
{clientName && (
<span className="rounded-full bg-elevated px-2 py-0.5 text-[10px] text-muted-foreground">
{clientName}
</span>
)}
<div className="flex-1" />
{isDirty && !isSaving ? (
<span className="text-[10px] text-amber-400">Unsaved changes</span>
) : lastSavedAt ? (
<span className="text-[10px] text-muted-foreground">{formatLastSaved()}</span>
) : null}
<button
onClick={onSave}
disabled={isSaving}
className="flex items-center gap-1.5 rounded bg-accent px-3 py-1.5 text-xs font-medium text-white hover:bg-accent/90 disabled:opacity-50"
>
<Save size={14} />
{isSaving ? 'Saving...' : 'Save'}
</button>
<div className="relative" ref={exportMenuRef}>
<button
onClick={() => setShowExportMenu(prev => !prev)}
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 / Import
</button>
{showExportMenu && (
<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} /> 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} /> 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} /> 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>
</div>
)
}

View File

@@ -1,129 +0,0 @@
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>
)
}

View File

@@ -1,146 +0,0 @@
import { useCallback } from 'react'
import {
ReactFlow,
Background,
Controls,
MiniMap,
BackgroundVariant,
type OnConnect,
type OnReconnect,
type OnNodesChange,
type OnEdgesChange,
type Node,
type Edge,
} from '@xyflow/react'
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[]
edges: Edge[]
onNodesChange: OnNodesChange
onEdgesChange: OnEdgesChange
onConnect: OnConnect
onReconnect: OnReconnect<Edge>
onNodeSelect: (nodeId: string | null) => void
onEdgeSelect: (edgeId: string | null) => void
onDrop: (event: React.DragEvent) => void
onDragOver: (event: React.DragEvent) => void
onDragLeave?: (event: React.DragEvent) => void
isDragOver?: boolean
onNodeContextMenu?: (event: React.MouseEvent, node: Node) => void
onPaneContextMenu?: (event: MouseEvent | React.MouseEvent) => void
onPaneClick?: () => void
interactionMode?: InteractionMode
}
export function NetworkCanvas({
nodes,
edges,
onNodesChange,
onEdgesChange,
onConnect,
onReconnect,
onNodeSelect,
onEdgeSelect,
onDrop,
onDragOver,
onDragLeave,
isDragOver,
onNodeContextMenu,
onPaneContextMenu,
onPaneClick: onPaneClickProp,
interactionMode = 'select',
}: NetworkCanvasProps) {
const handleSelectionChange = useCallback(({ nodes: selectedNodes, edges: selectedEdges }: { nodes: Node[]; edges: Edge[] }) => {
if (selectedNodes.length === 1) {
onNodeSelect(selectedNodes[0].id)
onEdgeSelect(null)
} else if (selectedEdges.length === 1) {
onEdgeSelect(selectedEdges[0].id)
onNodeSelect(null)
} else {
onNodeSelect(null)
onEdgeSelect(null)
}
}, [onNodeSelect, onEdgeSelect])
const handlePaneClick = useCallback(() => {
onNodeSelect(null)
onEdgeSelect(null)
onPaneClickProp?.()
}, [onNodeSelect, onEdgeSelect, onPaneClickProp])
const getNodeColor = useCallback((node: Node) => {
if (node.type === 'group') return 'var(--color-bg-elevated)'
const data = node.data as unknown as DeviceNodeData
return getDeviceRenderConfig(data?.deviceType || '', data?.category).color
}, [])
return (
<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}
onDragOver={onDragOver}
onNodeContextMenu={onNodeContextMenu}
onPaneContextMenu={onPaneContextMenu}
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={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" />
<MiniMap
nodeColor={getNodeColor}
maskColor="rgba(0,0,0,0.5)"
className="!border-default !bg-card"
position="bottom-right"
/>
</ReactFlow>
{isDragOver && (
<div className="pointer-events-none absolute inset-2 z-10 flex items-center justify-center rounded-lg border-2 border-dashed border-accent/30">
<span className="rounded-md bg-card/80 px-3 py-1.5 text-sm text-muted-foreground">
Drop to add
</span>
</div>
)}
</div>
)
}

View File

@@ -1,72 +0,0 @@
import { memo } from 'react'
import { BaseEdge, EdgeLabelRenderer, getStraightPath, getBezierPath, getSmoothStepPath, type EdgeProps } from '@xyflow/react'
interface ConnectionEdgeData {
connectionType?: string
routing?: string | null
speed?: string | null
notes?: string | null
[key: string]: unknown
}
const CONNECTION_STYLES: Record<string, { stroke: string; strokeDasharray?: string; strokeWidth: number }> = {
ethernet: { stroke: '#60a5fa', strokeWidth: 2 },
fiber: { stroke: '#34d399', strokeWidth: 3 },
wifi: { stroke: '#a78bfa', strokeDasharray: '3,3', strokeWidth: 2 },
vpn: { stroke: '#eab308', strokeDasharray: '8,4', strokeWidth: 2 },
vlan: { stroke: '#848b9b', strokeWidth: 2 },
wan: { stroke: '#f87171', strokeDasharray: '12,4', strokeWidth: 2 },
}
const DEFAULT_STYLE = { stroke: '#848b9b', strokeWidth: 2 }
function getEdgePath(routing: string | null | undefined, props: EdgeProps) {
const base = {
sourceX: props.sourceX,
sourceY: props.sourceY,
sourcePosition: props.sourcePosition,
targetX: props.targetX,
targetY: props.targetY,
targetPosition: props.targetPosition,
}
if (routing === 'curved') return getBezierPath(base)
if (routing === 'step') return getSmoothStepPath(base)
if (routing === 'orthogonal') return getSmoothStepPath({ ...base, borderRadius: 0 })
return getStraightPath(base)
}
function ConnectionEdgeComponent(props: EdgeProps) {
const edgeData = props.data as ConnectionEdgeData | undefined
const connectionType = edgeData?.connectionType || 'ethernet'
const style = CONNECTION_STYLES[connectionType] || DEFAULT_STYLE
const [edgePath, labelX, labelY] = getEdgePath(edgeData?.routing, props)
return (
<>
<BaseEdge
path={edgePath}
style={{
...style,
...(props.selected ? { stroke: '#60a5fa', strokeWidth: style.strokeWidth + 1 } : {}),
}}
/>
{props.label && (
<EdgeLabelRenderer>
<div
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)`,
pointerEvents: 'all',
}}
>
{props.label}
</div>
</EdgeLabelRenderer>
)}
</>
)
}
export const ConnectionEdge = memo(ConnectionEdgeComponent)

View File

@@ -1,7 +0,0 @@
import { ConnectionEdge } from './ConnectionEdge'
import { AnimatedSvgEdge } from '../ui/animated-svg-edge'
export const edgeTypes = {
connection: ConnectionEdge,
animated: AnimatedSvgEdge,
}

View File

@@ -1,304 +0,0 @@
import { useCallback, useEffect, useRef } from 'react'
import { useReactFlow, type Node, type Edge } from '@xyflow/react'
interface ClipboardData {
nodes: Array<{
type: string
data: Record<string, unknown>
style?: React.CSSProperties
relativePosition: { x: number; y: number }
}>
edges: Array<{
sourceIndex: number
targetIndex: number
type?: string
data?: Record<string, unknown>
label?: string
}>
}
function generateId(prefix: string): string {
return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`
}
function isInputFocused(): boolean {
const tag = document.activeElement?.tagName
return tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT'
}
export function useCanvasShortcuts({
nodes: _nodes, // eslint-disable-line @typescript-eslint/no-unused-vars
edges,
setNodes,
setEdges,
setIsDirty,
canvasRef,
onUndo,
onRedo,
onNudge,
onSetMode,
onToggleShortcuts,
}: {
nodes: Node[]
edges: Edge[]
setNodes: React.Dispatch<React.SetStateAction<Node[]>>
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)
const getSelectedNodes = useCallback((): Node[] => {
return getNodes().filter(n => n.selected)
}, [getNodes])
const copyNodes = useCallback(() => {
const selected = getSelectedNodes()
if (selected.length === 0) return
const centroid = {
x: selected.reduce((sum, n) => sum + n.position.x, 0) / selected.length,
y: selected.reduce((sum, n) => sum + n.position.y, 0) / selected.length,
}
const selectedIds = new Set(selected.map(n => n.id))
const clipNodes = selected.map(n => ({
type: n.type || 'device',
data: structuredClone(n.data),
style: n.style ? { ...n.style } : undefined,
relativePosition: {
x: n.position.x - centroid.x,
y: n.position.y - centroid.y,
},
}))
const selectedList = selected.map(n => n.id)
const clipEdges = edges
.filter(e => selectedIds.has(e.source) && selectedIds.has(e.target))
.map(e => ({
sourceIndex: selectedList.indexOf(e.source),
targetIndex: selectedList.indexOf(e.target),
type: e.type,
data: e.data ? structuredClone(e.data) as Record<string, unknown> : undefined,
label: typeof e.label === 'string' ? e.label : undefined,
}))
clipboardRef.current = { nodes: clipNodes, edges: clipEdges }
}, [getSelectedNodes, edges])
const pasteNodes = useCallback(() => {
const clipboard = clipboardRef.current
if (!clipboard || clipboard.nodes.length === 0) return
const canvasEl = canvasRef.current
if (!canvasEl) return
const rect = canvasEl.getBoundingClientRect()
const center = screenToFlowPosition({
x: rect.left + rect.width / 2,
y: rect.top + rect.height / 2,
})
const newNodeIds: string[] = []
const newNodes: Node[] = clipboard.nodes.map(cn => {
const prefix = cn.type === 'group' ? 'group' : 'device'
const id = generateId(prefix)
newNodeIds.push(id)
return {
id,
type: cn.type,
position: {
x: center.x + cn.relativePosition.x,
y: center.y + cn.relativePosition.y,
},
data: structuredClone(cn.data) as Record<string, unknown>,
style: cn.style ? { ...cn.style } : undefined,
selected: true,
}
})
const newEdges: Edge[] = clipboard.edges.map(ce => ({
id: generateId('edge'),
source: newNodeIds[ce.sourceIndex],
target: newNodeIds[ce.targetIndex],
type: ce.type,
data: ce.data ? structuredClone(ce.data) as Record<string, unknown> : undefined,
label: ce.label,
}))
setNodes(nds => [
...nds.map(n => ({ ...n, selected: false })),
...newNodes,
])
setEdges(eds => [...eds, ...newEdges])
setIsDirty(true)
}, [canvasRef, screenToFlowPosition, setNodes, setEdges, setIsDirty])
const duplicateNodes = useCallback(() => {
const selected = getSelectedNodes()
if (selected.length === 0) return
const selectedIds = new Set(selected.map(n => n.id))
const idMap = new Map<string, string>()
const newNodes: Node[] = selected.map(n => {
const prefix = n.type === 'group' ? 'group' : 'device'
const newId = generateId(prefix)
idMap.set(n.id, newId)
return {
id: newId,
type: n.type,
position: { x: n.position.x + 30, y: n.position.y + 30 },
data: structuredClone(n.data) as Record<string, unknown>,
style: n.style ? { ...n.style } : undefined,
selected: true,
}
})
const newEdges: Edge[] = edges
.filter(e => selectedIds.has(e.source) && selectedIds.has(e.target))
.map(e => ({
id: generateId('edge'),
source: idMap.get(e.source)!,
target: idMap.get(e.target)!,
type: e.type,
data: e.data ? structuredClone(e.data) as Record<string, unknown> : undefined,
label: e.label,
}))
setNodes(nds => [
...nds.map(n => ({ ...n, selected: false })),
...newNodes,
])
setEdges(eds => [...eds, ...newEdges])
setIsDirty(true)
}, [getSelectedNodes, edges, setNodes, setEdges, setIsDirty])
const selectAll = useCallback(() => {
rfSetNodes(nds => nds.map(n => ({ ...n, selected: true })))
}, [rfSetNodes])
const deleteSelected = useCallback(() => {
const selected = getSelectedNodes()
if (selected.length === 0) return
const selectedIds = new Set(selected.map(n => n.id))
setNodes(nds => nds.filter(n => !selectedIds.has(n.id)))
setEdges(eds => eds.filter(e => !selectedIds.has(e.source) && !selectedIds.has(e.target)))
setIsDirty(true)
}, [getSelectedNodes, setNodes, setEdges, setIsDirty])
const bringSelectedToFront = useCallback(() => {
const selected = getSelectedNodes()
if (!selected.length) return
const selectedIds = new Set(selected.map(n => n.id))
setNodes(nds => {
const maxZ = Math.max(0, ...nds.map(n => n.zIndex ?? 0))
return nds.map(n => selectedIds.has(n.id) ? { ...n, zIndex: maxZ + 1 } : n)
})
setIsDirty(true)
}, [getSelectedNodes, setNodes, setIsDirty])
const sendSelectedToBack = useCallback(() => {
const selected = getSelectedNodes()
if (!selected.length) return
const selectedIds = new Set(selected.map(n => n.id))
setNodes(nds => {
const minZ = Math.min(0, ...nds.map(n => n.zIndex ?? 0))
return nds.map(n => selectedIds.has(n.id) ? { ...n, zIndex: minZ - 1 } : n)
})
setIsDirty(true)
}, [getSelectedNodes, setNodes, setIsDirty])
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (isInputFocused()) return
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()
} else if (ctrl && e.key === 'v') {
e.preventDefault()
pasteNodes()
} else if (ctrl && e.key === 'd') {
e.preventDefault()
duplicateNodes()
} else if (ctrl && e.key === 'a') {
e.preventDefault()
selectAll()
} else if (ctrl && e.shiftKey && (e.key === 'f' || e.key === 'F')) {
e.preventDefault()
fitView({ padding: 0.2 })
} else if (e.key === ']' && !ctrl) {
e.preventDefault()
bringSelectedToFront()
} 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, onUndo, onRedo, onNudge, onSetMode, onToggleShortcuts])
return {
copyNodes,
pasteNodes,
duplicateNodes,
selectAll,
deleteSelected,
bringSelectedToFront,
sendSelectedToBack,
hasClipboard: () => clipboardRef.current !== null && clipboardRef.current.nodes.length > 0,
}
}

View File

@@ -1,206 +0,0 @@
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,
}
}

View File

@@ -1,180 +0,0 @@
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, CATEGORY_LABELS } from './deviceRegistry'
import { cn } from '@/lib/utils'
import type { DeviceProperties } from '@/types'
export interface DeviceNodeData {
label: string
deviceType: string
category?: string
properties: DeviceProperties
[key: string]: unknown
}
function TooltipRow({ label, value }: { label: string; value: string | null | undefined }) {
if (!value) return null
return (
<div className="flex gap-2">
<span className="text-[10px] uppercase tracking-wider text-muted-foreground">{label}</span>
<span className="text-xs font-mono text-primary">{value}</span>
</div>
)
}
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({ id, data, selected, width, height }: NodeProps) {
const nodeData = data as unknown as DeviceNodeData
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 || {}
// Use the shorter dimension so content never overflows a non-square node
const size = Math.min(width ?? NODE_DEFAULT, height ?? NODE_DEFAULT)
const scale = size / NODE_DEFAULT
// Icon: 28px at default, clamped to [14, 72]
const iconPx = Math.round(Math.max(14, Math.min(72, scale * 28)))
// Label font: 11px at default, clamped to [9, 20]
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
return (
<>
<NodeResizer
isVisible={selected}
minWidth={NODE_MIN}
minHeight={NODE_MIN}
maxWidth={NODE_MAX}
maxHeight={NODE_MAX}
keepAspectRatio
lineStyle={{ borderColor: 'var(--color-accent)', borderWidth: 1 }}
handleStyle={{ width: 8, height: 8, borderColor: 'var(--color-accent)', background: 'var(--color-card)' }}
/>
<NodeStatusIndicator status={status}>
<NodeTooltip>
<NodeTooltipTrigger>
<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-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} />
<BaseHandle type="source" position={Position.Bottom} />
<BaseHandle type="target" position={Position.Left} id="left" />
<BaseHandle type="source" position={Position.Right} id="right" />
</BaseNode>
</NodeTooltipTrigger>
{hasTooltipContent && (
<NodeTooltipContent position={Position.Top}>
<div className="flex flex-col gap-1 min-w-[140px]">
<TooltipRow label="Host" value={props.hostname} />
<TooltipRow label="IP" value={props.ip} />
{(props.vendor || props.model) && (
<TooltipRow label="HW" value={[props.vendor, props.model].filter(Boolean).join(' ')} />
)}
<TooltipRow label="Role" value={props.role} />
{props.notes && (
<TooltipRow label="Notes" value={props.notes.length > 100 ? props.notes.slice(0, 100) + '...' : props.notes} />
)}
</div>
</NodeTooltipContent>
)}
</NodeTooltip>
</NodeStatusIndicator>
</>
)
}
export const DeviceNode = memo(DeviceNodeComponent)

View File

@@ -1,86 +0,0 @@
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

View File

@@ -1,161 +0,0 @@
import type { LucideIcon } from 'lucide-react'
import {
Router, Network, BrickWallFire, Wifi, Server, Monitor, Boxes, Package, Cloud,
Printer, Smartphone, HardDrive, Gauge, Database, CloudCog,
Cpu, Tablet, Laptop, BatteryCharging, RectangleVertical,
Cable, Camera, KeyRound, Globe, Video, PlugZap, Radio,
} from 'lucide-react'
export interface DeviceRenderConfig {
icon: LucideIcon
color: string
accentClass: string
surfaceClass: string
category: string
}
// Category-semantic color palette — each color carries meaning:
// Network (blue) — backbone connectivity layer
// Security (orange) — critical/protective elements
// Compute (emerald)— running workloads and VMs
// Endpoint (amber) — user-facing devices
// Storage (violet) — data at rest
// Cloud (cyan) — external/internet-connected
// Infra (steel) — physical/passive hardware
export const NETWORK_COLOR = '#60a5fa'
export const SECURITY_COLOR = '#f87171'
export const COMPUTE_COLOR = '#34d399'
export const ENDPOINT_COLOR = '#fbbf24'
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': 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': makeConfig(BrickWallFire, SECURITY_COLOR, 'security'),
'badge-reader': makeConfig(KeyRound, SECURITY_COLOR, 'security'),
// Compute
'server': makeConfig(Server, COMPUTE_COLOR, 'compute'),
'vm': makeConfig(Boxes, COMPUTE_COLOR, 'compute'),
'container': makeConfig(Package, COMPUTE_COLOR, 'compute'),
// Storage
'nas': makeConfig(Database, STORAGE_COLOR, 'storage'),
'san': makeConfig(HardDrive, STORAGE_COLOR, 'storage'),
'cloud-storage': makeConfig(CloudCog, STORAGE_COLOR, 'storage'),
// Cloud / Internet
'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': 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': 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': 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 = makeConfig(Cpu, INFRA_COLOR, 'infrastructure')
export function getDeviceRenderConfig(slug: string, category?: string): DeviceRenderConfig {
if (SYSTEM_DEVICE_ICONS[slug]) return SYSTEM_DEVICE_ICONS[slug]
if (category && CATEGORY_DEFAULTS[category]) return CATEGORY_DEFAULTS[category]
return FALLBACK
}
export const CATEGORY_LABELS: Record<string, string> = {
'network': 'Network',
'compute': 'Compute',
'storage': 'Storage',
'cloud': 'Cloud',
'endpoint': 'Endpoints',
'infrastructure': 'Infrastructure',
'security': 'Security',
}
export const CATEGORY_COLORS: Record<string, string> = {
'network': NETWORK_COLOR,
'compute': COMPUTE_COLOR,
'storage': STORAGE_COLOR,
'cloud': CLOUD_COLOR,
'endpoint': ENDPOINT_COLOR,
'infrastructure': INFRA_COLOR,
'security': SECURITY_COLOR,
}
export const CATEGORY_ORDER = ['network', 'compute', 'storage', 'cloud', 'endpoint', 'infrastructure', 'security']

View File

@@ -1,7 +0,0 @@
import { DeviceNode } from './DeviceNode'
import { GroupNode } from './GroupNode'
export const nodeTypes = {
device: DeviceNode,
group: GroupNode,
}

View File

@@ -1,168 +0,0 @@
import { useState, useCallback } from 'react'
import { Sparkles, ChevronUp, ChevronDown, AlertTriangle } from 'lucide-react'
import { cn } from '@/lib/utils'
import { networkDiagramsApi } from '@/api'
import type { AIGenerateResponse } from '@/types'
interface AIAssistPanelProps {
onGenerate: (result: AIGenerateResponse, mode: 'replace' | 'merge') => void
getExistingBounds: () => { minX: number; maxX: number; minY: number; maxY: number } | null
hasNodes: boolean
}
export function AIAssistPanel({ onGenerate, getExistingBounds, hasNodes }: AIAssistPanelProps) {
const [expanded, setExpanded] = useState(false)
const [description, setDescription] = useState('')
const [mode, setMode] = useState<'replace' | 'merge'>('replace')
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [replaceConfirm, setReplaceConfirm] = useState(false)
const handleGenerate = useCallback(async () => {
if (!description.trim()) return
setLoading(true)
setError(null)
setReplaceConfirm(false)
try {
const result = await networkDiagramsApi.aiGenerate({
description: description.trim(),
mode,
existingBounds: mode === 'merge' ? getExistingBounds() : null,
})
onGenerate(result, mode)
setDescription('')
setExpanded(false)
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : 'Generation failed. Please try again.'
setError(msg)
} finally {
setLoading(false)
}
}, [description, mode, onGenerate, getExistingBounds])
// Reset confirm state when mode changes or panel collapses
const handleModeChange = (newMode: 'replace' | 'merge') => {
setMode(newMode)
setReplaceConfirm(false)
}
const needsReplaceConfirm = mode === 'replace' && hasNodes
if (!expanded) {
return (
<div className="border-t border-default bg-card">
<button
onClick={() => setExpanded(true)}
className="flex w-full items-center justify-center gap-2 px-4 py-2 text-xs text-muted-foreground hover:text-primary"
>
<Sparkles size={14} />
AI Generate
<ChevronUp size={14} />
</button>
</div>
)
}
return (
<div className="border-t border-default bg-card">
<div className="flex items-center justify-between border-b border-default px-4 py-2">
<div className="flex items-center gap-2 text-xs font-medium text-heading">
<Sparkles size={14} />
AI Generate
</div>
<button
onClick={() => { setExpanded(false); setReplaceConfirm(false) }}
className="text-muted-foreground hover:text-primary"
>
<ChevronDown size={14} />
</button>
</div>
<div className="flex flex-col gap-3 p-4">
<div className="flex gap-2">
<button
onClick={() => handleModeChange('replace')}
className={cn(
'rounded px-3 py-1 text-xs font-medium transition-colors',
mode === 'replace'
? 'bg-accent text-white'
: 'border border-default text-muted-foreground hover:text-primary',
)}
>
Generate New
</button>
<button
onClick={() => handleModeChange('merge')}
className={cn(
'rounded px-3 py-1 text-xs font-medium transition-colors',
mode === 'merge'
? 'bg-accent text-white'
: 'border border-default text-muted-foreground hover:text-primary',
)}
>
Add to Diagram
</button>
</div>
{needsReplaceConfirm && (
<div className="flex items-start gap-2 rounded border border-yellow-500/30 bg-yellow-500/5 px-3 py-2">
<AlertTriangle size={14} className="mt-0.5 shrink-0 text-yellow-400" />
<p className="text-[11px] text-yellow-400">
This will replace your current diagram. Save first if needed.
</p>
</div>
)}
<textarea
value={description}
onChange={e => setDescription(e.target.value)}
placeholder="Describe the network you want to create... e.g. 'Small office with a firewall, core switch, 3 access points, and a file server'"
rows={3}
disabled={loading}
className="w-full resize-none rounded border border-default bg-input px-3 py-2 text-xs text-primary placeholder:text-muted-foreground focus:border-accent focus:outline-none disabled:opacity-50"
/>
{error && <p className="text-[11px] text-red-400">{error}</p>}
{loading ? (
<div className="flex items-center justify-center gap-2 py-2">
<div className="h-4 w-4 animate-spin rounded-full border-2 border-accent border-t-transparent" />
<span className="text-xs text-muted-foreground">Generating your network diagram</span>
</div>
) : needsReplaceConfirm && !replaceConfirm ? (
<button
onClick={() => setReplaceConfirm(true)}
disabled={!description.trim()}
className="rounded border border-yellow-500/40 bg-yellow-500/10 px-4 py-2 text-xs font-medium text-yellow-400 hover:bg-yellow-500/20 disabled:opacity-50"
>
Replace Diagram
</button>
) : needsReplaceConfirm && replaceConfirm ? (
<div className="flex gap-2">
<button
onClick={() => setReplaceConfirm(false)}
className="flex-1 rounded border border-default px-3 py-2 text-xs text-primary hover:bg-elevated"
>
Cancel
</button>
<button
onClick={handleGenerate}
disabled={!description.trim()}
className="flex-1 rounded bg-red-500/20 px-3 py-2 text-xs font-medium text-red-400 hover:bg-red-500/30 disabled:opacity-50"
>
Yes, Replace
</button>
</div>
) : (
<button
onClick={handleGenerate}
disabled={!description.trim()}
className="rounded bg-accent px-4 py-2 text-xs font-medium text-white hover:bg-accent/90 disabled:opacity-50"
>
Generate
</button>
)}
</div>
</div>
)
}

View File

@@ -1,227 +0,0 @@
import { useState, useMemo, useCallback } from 'react'
import { Search, Plus, ChevronDown, ChevronRight, X, LayoutGrid, GripVertical, Globe } from 'lucide-react'
import { getDeviceRenderConfig, CATEGORY_LABELS, CATEGORY_ORDER } from '../nodes/deviceRegistry'
import type { DeviceTypeResponse, DeviceTypeCreate } from '@/types'
import { deviceTypesApi } from '@/api'
interface DeviceToolbarProps {
deviceTypes: DeviceTypeResponse[]
onDeviceTypesChange: () => void
}
export function DeviceToolbar({ deviceTypes, onDeviceTypesChange }: DeviceToolbarProps) {
const [search, setSearch] = useState('')
const [collapsedCategories, setCollapsedCategories] = useState<Set<string>>(new Set())
const [showAddForm, setShowAddForm] = useState(false)
const [newType, setNewType] = useState<DeviceTypeCreate>({ slug: '', label: '', category: 'network' })
const [addError, setAddError] = useState<string | null>(null)
const [addLoading, setAddLoading] = useState(false)
const filteredByCategory = useMemo(() => {
const lower = search.toLowerCase()
const filtered = search
? deviceTypes.filter(dt => dt.label.toLowerCase().includes(lower) || dt.slug.toLowerCase().includes(lower))
: deviceTypes
const grouped: Record<string, DeviceTypeResponse[]> = {}
for (const dt of filtered) {
if (!grouped[dt.category]) grouped[dt.category] = []
grouped[dt.category].push(dt)
}
return grouped
}, [deviceTypes, search])
const toggleCategory = useCallback((cat: string) => {
setCollapsedCategories(prev => {
const next = new Set(prev)
if (next.has(cat)) next.delete(cat)
else next.add(cat)
return next
})
}, [])
const handleDragStart = useCallback((e: React.DragEvent, deviceType: DeviceTypeResponse) => {
e.dataTransfer.setData('application/reactflow-device', JSON.stringify({
slug: deviceType.slug,
label: deviceType.label,
category: deviceType.category,
}))
e.dataTransfer.effectAllowed = 'move'
}, [])
const handleAddType = useCallback(async () => {
if (!newType.slug || !newType.label) {
setAddError('Slug and label are required')
return
}
setAddLoading(true)
setAddError(null)
try {
await deviceTypesApi.create(newType)
setNewType({ slug: '', label: '', category: 'network' })
setShowAddForm(false)
onDeviceTypesChange()
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : 'Failed to create device type'
setAddError(msg)
} finally {
setAddLoading(false)
}
}, [newType, onDeviceTypesChange])
return (
<div className="flex h-full w-[200px] flex-col border-r border-default bg-sidebar">
<div className="relative p-2">
<Search size={14} className="absolute left-4 top-1/2 -translate-y-1/2 text-muted-foreground" />
<input
type="text"
placeholder="Search devices..."
value={search}
onChange={e => setSearch(e.target.value)}
className="w-full rounded-md border border-default bg-input pl-8 pr-2 py-1.5 text-xs text-primary placeholder:text-muted-foreground focus:border-accent focus:outline-none"
/>
</div>
<div className="flex-1 overflow-y-auto px-2 pb-2">
{CATEGORY_ORDER.map(cat => {
const items = filteredByCategory[cat] || []
const isCloud = cat === 'cloud'
const ispMatchesSearch = !search || 'isp'.includes(search.toLowerCase()) || 'internet service provider'.includes(search.toLowerCase())
const showIsp = isCloud && ispMatchesSearch
if (!items.length && !showIsp) return null
const collapsed = collapsedCategories.has(cat)
const totalCount = items.length + (showIsp ? 1 : 0)
return (
<div key={cat} className="mb-1">
<button
onClick={() => toggleCategory(cat)}
className="flex w-full items-center gap-1 rounded px-1 py-1 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground hover:text-primary"
>
{collapsed ? <ChevronRight size={12} /> : <ChevronDown size={12} />}
{CATEGORY_LABELS[cat] || cat}
<span className="ml-auto text-[10px] font-normal">{totalCount}</span>
</button>
{!collapsed && (
<div className="flex flex-col gap-0.5">
{items.map(dt => {
const { icon: Icon, color } = getDeviceRenderConfig(dt.slug, dt.category)
return (
<div
key={dt.id}
draggable
onDragStart={e => handleDragStart(e, dt)}
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" />
<Icon size={14} style={{ color }} />
<span>{dt.label}</span>
</div>
)
})}
{showIsp && (
<div
draggable
onDragStart={e => {
e.dataTransfer.setData('application/reactflow-device', JSON.stringify({
slug: 'isp',
label: 'ISP',
category: 'cloud',
}))
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" />
<Globe size={14} style={{ color: 'var(--color-accent)' }} />
<span>ISP</span>
</div>
)}
</div>
)}
</div>
)
})}
</div>
{/* Grouping section */}
<div className="mb-1 mt-2 border-t border-default pt-2">
<div className="flex items-center gap-1 px-1 py-1 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
Grouping
</div>
<div className="flex flex-col gap-0.5">
{[
{ 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({ 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} style={{ color: item.color }} />
<span>{item.label}</span>
</div>
))}
</div>
</div>
<div className="border-t border-default p-2">
{!showAddForm ? (
<button
onClick={() => setShowAddForm(true)}
className="flex w-full items-center justify-center gap-1 rounded border border-default px-2 py-1.5 text-xs text-muted-foreground hover:border-hover hover:text-primary"
>
<Plus size={12} />
Custom Type
</button>
) : (
<div className="flex flex-col gap-1.5">
<div className="flex items-center justify-between">
<span className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">New Type</span>
<button onClick={() => { setShowAddForm(false); setAddError(null) }} className="text-muted-foreground hover:text-primary">
<X size={12} />
</button>
</div>
<input
placeholder="slug (e.g. pacs-server)"
value={newType.slug}
onChange={e => setNewType(prev => ({ ...prev, slug: e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, '') }))}
className="rounded border border-default bg-input px-2 py-1 text-xs text-primary placeholder:text-muted-foreground focus:border-accent focus:outline-none"
/>
<input
placeholder="Label (e.g. PACS Server)"
value={newType.label}
onChange={e => setNewType(prev => ({ ...prev, label: e.target.value }))}
className="rounded border border-default bg-input px-2 py-1 text-xs text-primary placeholder:text-muted-foreground focus:border-accent focus:outline-none"
/>
<select
value={newType.category}
onChange={e => setNewType(prev => ({ ...prev, category: e.target.value }))}
className="rounded border border-default bg-input px-2 py-1 text-xs text-primary focus:border-accent focus:outline-none"
>
{CATEGORY_ORDER.map(c => (
<option key={c} value={c}>{CATEGORY_LABELS[c]}</option>
))}
</select>
{addError && <p className="text-[10px] text-red-400">{addError}</p>}
<button
onClick={handleAddType}
disabled={addLoading}
className="rounded bg-accent px-2 py-1 text-xs font-medium text-white hover:bg-accent/90 disabled:opacity-50"
>
{addLoading ? 'Adding...' : 'Add Type'}
</button>
</div>
)}
</div>
</div>
)
}

View File

@@ -1,554 +0,0 @@
import { useCallback, useState, useEffect } from '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'
import type { DeviceNodeData } from '../nodes/DeviceNode'
interface PropertiesPanelProps {
selectedNode: Node | null
selectedEdge: Edge | null
onNodeUpdate: (nodeId: string, data: Partial<DeviceNodeData>) => void
onEdgeUpdate: (edgeId: string, data: Partial<DiagramEdge>) => void
onEdgeTypeChange: (edgeId: string, edgeType: string) => void
onBringToFront: (nodeId: string) => void
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'
const STATUS_CONFIG: Record<NodeStatus, { color: string; label: string }> = {
online: { color: '#34d399', label: 'Online' },
offline: { color: '#f87171', label: 'Offline' },
degraded: { color: '#fbbf24', label: 'Degraded' },
unknown: { color: '#94a3b8', label: 'Unknown' },
}
const STATUS_OPTIONS = Object.keys(STATUS_CONFIG) as NodeStatus[]
const CONNECTION_TYPE_OPTIONS = ['ethernet', 'fiber', 'wifi', 'vpn', 'vlan', 'wan'] as const
function FieldLabel({ children }: { children: React.ReactNode }) {
return (
<label className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
{children}
</label>
)
}
function FieldInput({ value, onChange, placeholder, mono }: {
value: string
onChange: (val: string) => void
placeholder?: string
mono?: boolean
}) {
return (
<input
type="text"
value={value}
onChange={e => onChange(e.target.value)}
placeholder={placeholder}
className={cn(
'w-full rounded border border-default bg-input px-2 py-1.5 text-xs text-primary placeholder:text-muted-foreground focus:border-accent focus:outline-none',
mono && 'font-mono',
)}
/>
)
}
function SectionDivider({ label }: { label: string }) {
return (
<div className="flex items-center gap-2 pt-1">
<span className="whitespace-nowrap text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
{label}
</span>
<div className="flex-1 border-t border-default" />
</div>
)
}
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,
onNodeUpdate,
onEdgeUpdate,
onEdgeTypeChange,
onBringToFront,
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
useEffect(() => { setDeleteConfirm(false) }, [selectedNode?.id, selectedEdge?.id])
const handlePropertyChange = useCallback((field: keyof DeviceProperties, value: string) => {
if (!selectedNode) return
const nodeData = selectedNode.data as unknown as DeviceNodeData
onNodeUpdate(selectedNode.id, {
properties: { ...nodeData.properties, [field]: value },
} as Partial<DeviceNodeData>)
}, [selectedNode, onNodeUpdate])
const handleLabelChange = useCallback((value: string) => {
if (!selectedNode) return
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-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/50 leading-relaxed">
Properties appear here. Hover a device to see a quick summary.
</p>
</div>
)
}
if (selectedEdge) {
const edgeData = (selectedEdge.data || {}) as Record<string, unknown>
const connectionType = (edgeData.connectionType as string) || 'ethernet'
const isCustomType = !CONNECTION_TYPE_OPTIONS.includes(connectionType as typeof CONNECTION_TYPE_OPTIONS[number])
return (
<div className="flex h-full w-[260px] flex-col border-l border-default bg-sidebar">
<div className="border-b border-default px-3 py-2">
<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
value={(selectedEdge.label as string) || ''}
onChange={val => onEdgeUpdate(selectedEdge.id, { label: val || null })}
placeholder="Connection label"
/>
</div>
<div className="flex flex-col gap-1">
<FieldLabel>Type</FieldLabel>
<select
value={isCustomType ? '__custom__' : connectionType}
onChange={e => {
const val = e.target.value
if (val !== '__custom__') {
onEdgeUpdate(selectedEdge.id, { connectionType: val })
}
}}
className="w-full rounded border border-default bg-input px-2 py-1.5 text-xs text-primary focus:border-accent focus:outline-none"
>
{CONNECTION_TYPE_OPTIONS.map(opt => (
<option key={opt} value={opt}>{opt.charAt(0).toUpperCase() + opt.slice(1)}</option>
))}
<option value="__custom__">Custom</option>
</select>
{isCustomType && (
<FieldInput
value={connectionType}
onChange={val => onEdgeUpdate(selectedEdge.id, { connectionType: val })}
placeholder="Custom type name"
/>
)}
</div>
<div className="flex flex-col gap-1">
<FieldLabel>Speed</FieldLabel>
<FieldInput
value={(edgeData.speed as string) || ''}
onChange={val => onEdgeUpdate(selectedEdge.id, { speed: val || null })}
placeholder="e.g. 1 Gbps"
/>
</div>
<div className="flex flex-col gap-1">
<FieldLabel>Notes</FieldLabel>
<FieldInput
value={(edgeData.notes as string) || ''}
onChange={val => onEdgeUpdate(selectedEdge.id, { notes: val || null })}
placeholder="Port info, cable type…"
mono
/>
</div>
<div className="flex flex-col gap-1">
<FieldLabel>Line Style</FieldLabel>
<div className="flex gap-1">
{([
{ 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
return (
<button
key={label}
title={label}
onClick={() => onEdgeUpdate(selectedEdge.id, { routing: value })}
className={cn(
'flex flex-1 items-center justify-center gap-1 rounded border py-1.5 text-[10px] transition-colors',
active
? 'border-accent bg-accent/10 text-accent'
: 'border-default text-muted-foreground hover:border-hover hover:text-primary',
)}
>
<Icon size={12} />
{label}
</button>
)
})}
</div>
</div>
<div className="flex items-center justify-between">
<FieldLabel>Show Traffic</FieldLabel>
<button
onClick={() => {
const newType = selectedEdge.type === 'animated' ? 'connection' : 'animated'
onEdgeTypeChange(selectedEdge.id, newType)
}}
className={cn(
'relative h-5 w-9 rounded-full transition-colors',
selectedEdge.type === 'animated' ? 'bg-accent' : 'bg-elevated',
)}
>
<span
className={cn(
'absolute top-0.5 left-0.5 h-4 w-4 rounded-full bg-white transition-transform',
selectedEdge.type === 'animated' && 'translate-x-4',
)}
/>
</button>
</div>
</div>
<div className="border-t border-default p-3">
{deleteConfirm ? (
<div className="flex flex-col gap-1.5">
<p className="text-center text-[10px] text-muted-foreground">Delete this connection?</p>
<div className="flex gap-1.5">
<button
onClick={() => setDeleteConfirm(false)}
className="flex-1 rounded border border-default px-2 py-1.5 text-xs text-primary hover:bg-elevated"
>
Cancel
</button>
<button
onClick={() => onDeleteEdge(selectedEdge.id)}
className="flex-1 rounded bg-red-500/20 px-2 py-1.5 text-xs font-medium text-red-400 hover:bg-red-500/30"
>
Delete
</button>
</div>
</div>
) : (
<button
onClick={() => setDeleteConfirm(true)}
className="flex w-full items-center justify-center gap-1.5 rounded border border-red-500/30 px-2 py-1.5 text-xs text-red-400 hover:bg-red-500/10"
>
<Trash2 size={12} />
Delete Connection
</button>
)}
</div>
</div>
)
}
const nodeData = selectedNode!.data as unknown as DeviceNodeData
const props = nodeData.properties || {} as DeviceProperties
const currentStatus = (props.status || 'unknown') as NodeStatus
return (
<div className="flex h-full w-[260px] flex-col border-l border-default bg-sidebar">
<div className="border-b border-default px-3 py-2">
<h3 className="text-xs font-semibold text-heading">Device Properties</h3>
</div>
<div className="flex flex-1 flex-col gap-3 overflow-y-auto p-3">
{/* Identity */}
<div className="flex flex-col gap-1">
<FieldLabel>Name</FieldLabel>
<FieldInput value={nodeData.label} onChange={handleLabelChange} placeholder="Device name" />
</div>
{/* Layering */}
<div className="flex flex-col gap-1">
<FieldLabel>Layer</FieldLabel>
<div className="flex gap-1.5">
<button
onClick={() => onBringToFront(selectedNode!.id)}
title="Bring to Front ]"
className="flex flex-1 items-center justify-center gap-1.5 rounded border border-default px-2 py-1.5 text-[10px] text-muted-foreground hover:border-hover hover:text-primary"
>
<BringToFront size={12} />
Bring Front
</button>
<button
onClick={() => onSendToBack(selectedNode!.id)}
title="Send to Back ["
className="flex flex-1 items-center justify-center gap-1.5 rounded border border-default px-2 py-1.5 text-[10px] text-muted-foreground hover:border-hover hover:text-primary"
>
<SendToBack size={12} />
Send Back
</button>
</div>
</div>
{/* Status badge grid */}
<div className="flex flex-col gap-1.5">
<FieldLabel>Status</FieldLabel>
<div className="grid grid-cols-2 gap-1">
{STATUS_OPTIONS.map(opt => {
const { color, label } = STATUS_CONFIG[opt]
const active = currentStatus === opt
return (
<button
key={opt}
onClick={() => handlePropertyChange('status', opt)}
className={cn(
'flex items-center justify-center gap-1.5 rounded border py-1.5 text-[10px] font-medium transition-colors',
active
? 'border-transparent text-white'
: 'border-default text-muted-foreground hover:border-hover hover:text-primary',
)}
style={active ? { backgroundColor: color } : undefined}
>
<span
className="h-1.5 w-1.5 shrink-0 rounded-full"
style={{ backgroundColor: active ? 'rgba(255,255,255,0.8)' : color }}
/>
{label}
</button>
)
})}
</div>
</div>
{/* Network section */}
<SectionDivider label="Network" />
<div className="flex flex-col gap-2">
<div className="flex flex-col gap-1">
<FieldLabel>IP Address</FieldLabel>
<FieldInput value={props.ip || ''} onChange={v => handlePropertyChange('ip', v)} placeholder="e.g. 10.0.0.1" mono />
</div>
<div className="flex flex-col gap-1">
<FieldLabel>Subnet</FieldLabel>
<FieldInput value={props.subnet || ''} onChange={v => handlePropertyChange('subnet', v)} placeholder="e.g. 10.0.0.0/24" mono />
</div>
<div className="flex flex-col gap-1">
<FieldLabel>VLAN</FieldLabel>
<FieldInput value={props.vlan || ''} onChange={v => handlePropertyChange('vlan', v)} placeholder="e.g. 10" mono />
</div>
</div>
{/* Hardware section */}
<SectionDivider label="Hardware" />
<div className="flex flex-col gap-2">
<div className="flex flex-col gap-1">
<FieldLabel>Hostname</FieldLabel>
<FieldInput value={props.hostname || ''} onChange={v => handlePropertyChange('hostname', v)} placeholder="e.g. core-rtr-01" mono />
</div>
<div className="flex flex-col gap-1">
<FieldLabel>Vendor</FieldLabel>
<FieldInput value={props.vendor || ''} onChange={v => handlePropertyChange('vendor', v)} placeholder="e.g. Cisco" />
</div>
<div className="flex flex-col gap-1">
<FieldLabel>Model</FieldLabel>
<FieldInput value={props.model || ''} onChange={v => handlePropertyChange('model', v)} placeholder="e.g. ISR 4331" />
</div>
<div className="flex flex-col gap-1">
<FieldLabel>Role</FieldLabel>
<FieldInput value={props.role || ''} onChange={v => handlePropertyChange('role', v)} placeholder="e.g. Core gateway" />
</div>
</div>
{/* Notes */}
<SectionDivider label="Notes" />
<div className="flex flex-col gap-1">
<textarea
value={props.notes || ''}
onChange={e => handlePropertyChange('notes', e.target.value)}
placeholder="Additional notes…"
rows={3}
className="w-full resize-none rounded border border-default bg-input px-2 py-1.5 text-xs text-primary placeholder:text-muted-foreground focus:border-accent focus:outline-none"
/>
</div>
</div>
<div className="border-t border-default p-3">
{deleteConfirm ? (
<div className="flex flex-col gap-1.5">
<p className="text-center text-[10px] text-muted-foreground">Delete this device?</p>
<div className="flex gap-1.5">
<button
onClick={() => setDeleteConfirm(false)}
className="flex-1 rounded border border-default px-2 py-1.5 text-xs text-primary hover:bg-elevated"
>
Cancel
</button>
<button
onClick={() => onDeleteNode(selectedNode!.id)}
className="flex-1 rounded bg-red-500/20 px-2 py-1.5 text-xs font-medium text-red-400 hover:bg-red-500/30"
>
Delete
</button>
</div>
</div>
) : (
<button
onClick={() => setDeleteConfirm(true)}
className="flex w-full items-center justify-center gap-1.5 rounded border border-red-500/30 px-2 py-1.5 text-xs text-red-400 hover:bg-red-500/10"
>
<Trash2 size={12} />
Delete Device
</button>
)}
</div>
</div>
)
}

View File

@@ -1,131 +0,0 @@
import { memo } from 'react'
import {
BaseEdge,
getSmoothStepPath,
getStraightPath,
getBezierPath,
type EdgeProps,
} from '@xyflow/react'
interface AnimatedEdgeData {
connectionType?: string
duration?: number
direction?: 'forward' | 'reverse' | 'alternate' | 'alternate-reverse'
path?: 'bezier' | 'smoothstep' | 'step' | 'straight'
repeat?: number | 'indefinite'
shape?: 'circle' | 'package'
speed?: string | null
notes?: string | null
[key: string]: unknown
}
const CONNECTION_COLORS: Record<string, string> = {
ethernet: '#60a5fa',
fiber: '#34d399',
wifi: '#a78bfa',
vpn: '#eab308',
vlan: '#848b9b',
wan: '#f87171',
}
const DEFAULT_COLOR = '#848b9b'
function getPath(
props: EdgeProps,
pathType: string,
): [string, number, number] {
const params = {
sourceX: props.sourceX,
sourceY: props.sourceY,
sourcePosition: props.sourcePosition,
targetX: props.targetX,
targetY: props.targetY,
targetPosition: props.targetPosition,
}
switch (pathType) {
case 'bezier': {
const [path, labelX, labelY] = getBezierPath(params)
return [path, labelX, labelY]
}
case 'straight': {
const [path, labelX, labelY] = getStraightPath(params)
return [path, labelX, labelY]
}
default: {
const [path, labelX, labelY] = getSmoothStepPath(params)
return [path, labelX, labelY]
}
}
}
function getAnimateMotionProps(data: AnimatedEdgeData) {
const duration = data.duration ?? 2
const direction = data.direction ?? 'forward'
const repeat = data.repeat ?? 'indefinite'
const keyPoints: Record<string, string> = {
forward: '0;1',
reverse: '1;0',
alternate: '0;1',
'alternate-reverse': '1;0',
}
return {
dur: `${duration}s`,
repeatCount: String(repeat),
keyPoints: keyPoints[direction] || '0;1',
keyTimes: '0;1',
}
}
function AnimatedSvgEdgeComponent(props: EdgeProps) {
const data = (props.data || {}) as AnimatedEdgeData
const connectionType = data.connectionType || 'ethernet'
const color = CONNECTION_COLORS[connectionType] || DEFAULT_COLOR
const pathType = data.path ?? 'smoothstep'
const shape = data.shape ?? 'circle'
const [edgePath] = getPath(props, pathType)
const motionProps = getAnimateMotionProps(data)
return (
<>
<BaseEdge
path={edgePath}
style={{
stroke: color,
strokeWidth: props.selected ? 3 : 2,
...(connectionType === 'wifi' || connectionType === 'wan' || connectionType === 'vpn'
? { strokeDasharray: connectionType === 'wifi' ? '3,3' : '8,4' }
: {}),
}}
/>
<circle r={0} fill={color}>
<animateMotion
path={edgePath}
calcMode="linear"
{...motionProps}
/>
<animate
attributeName="r"
values="0;3;3;3;0"
keyTimes="0;0.05;0.5;0.95;1"
dur={motionProps.dur}
repeatCount={motionProps.repeatCount}
/>
</circle>
{shape === 'package' && (
<rect x={-4} y={-4} width={8} height={8} rx={2} fill={color} opacity={0.8}>
<animateMotion
path={edgePath}
calcMode="linear"
{...motionProps}
/>
</rect>
)}
</>
)
}
export const AnimatedSvgEdge = memo(AnimatedSvgEdgeComponent)

View File

@@ -1,21 +0,0 @@
import type { ComponentProps } from 'react'
import { Handle, type HandleProps } from '@xyflow/react'
import { cn } from '@/lib/utils'
export type BaseHandleProps = HandleProps
export function BaseHandle({ className, children, ...props }: ComponentProps<typeof Handle>) {
return (
<Handle
{...props}
className={cn(
'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,
)}
>
{children}
</Handle>
)
}

View File

@@ -1,56 +0,0 @@
import type { ComponentProps } from 'react'
import { cn } from '@/lib/utils'
export function BaseNode({ className, ...props }: ComponentProps<'div'>) {
return (
<div
className={cn(
'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,
)}
tabIndex={0}
{...props}
/>
)
}
export function BaseNodeHeader({ className, ...props }: ComponentProps<'header'>) {
return (
<header
{...props}
className={cn('flex flex-row items-center gap-2 px-3 py-2', className)}
/>
)
}
export function BaseNodeHeaderTitle({ className, ...props }: ComponentProps<'h3'>) {
return (
<h3
data-slot="base-node-title"
className={cn('select-none flex-1 text-xs font-semibold text-heading', className)}
{...props}
/>
)
}
export function BaseNodeContent({ className, ...props }: ComponentProps<'div'>) {
return (
<div
data-slot="base-node-content"
className={cn('flex flex-col gap-y-1 px-3 pb-2', className)}
{...props}
/>
)
}
export function BaseNodeFooter({ className, ...props }: ComponentProps<'div'>) {
return (
<div
data-slot="base-node-footer"
className={cn('flex flex-col items-center gap-y-1 border-t border-default px-3 pt-1.5 pb-2', className)}
{...props}
/>
)
}

View File

@@ -1,68 +0,0 @@
import type { ReactNode, ComponentProps } from 'react'
import { Panel, NodeResizer, type NodeProps, type PanelPosition } from '@xyflow/react'
import { BaseNode } from './base-node'
import { cn } from '@/lib/utils'
export type GroupNodeLabelProps = ComponentProps<'div'>
export function GroupNodeLabel({ children, className, ...props }: GroupNodeLabelProps) {
return (
<div className="h-full w-full" {...props}>
<div className={cn('bg-card text-muted-foreground w-fit p-2 text-[10px] font-semibold uppercase tracking-wider', className)}>
{children}
</div>
</div>
)
}
export interface GroupNodeData {
label?: string
groupType?: string
[key: string]: unknown
}
export type GroupNodeProps = Partial<NodeProps> & {
label?: ReactNode
position?: PanelPosition
}
function getLabelClassName(position?: PanelPosition): string {
switch (position) {
case 'top-left': return 'rounded-br-sm'
case 'top-center': return 'rounded-b-sm'
case 'top-right': return 'rounded-bl-sm'
case 'bottom-left': return 'rounded-tr-sm'
case 'bottom-right': return 'rounded-tl-sm'
case 'bottom-center': return 'rounded-t-sm'
default: return 'rounded-br-sm'
}
}
export function GroupNode({ data, selected }: NodeProps) {
const nodeData = data as unknown as GroupNodeData
const label = nodeData.label || 'Group'
return (
<>
<NodeResizer
isVisible={selected}
minWidth={150}
minHeight={100}
lineStyle={{ borderColor: 'var(--color-accent)', borderWidth: 1 }}
handleStyle={{ width: 8, height: 8, borderColor: 'var(--color-accent)', background: 'var(--color-card)' }}
/>
<BaseNode
className={cn(
'h-full w-full min-h-[100px] min-w-[150px] overflow-hidden rounded-lg bg-elevated/30 border-default/50',
selected && 'border-accent',
)}
>
<Panel className="m-0 p-0" position="top-left">
<GroupNodeLabel className={getLabelClassName('top-left')}>
{label}
</GroupNodeLabel>
</Panel>
</BaseNode>
</>
)
}

View File

@@ -1,39 +0,0 @@
import type { ComponentProps } from 'react'
import { type HandleProps, Position } from '@xyflow/react'
import { cn } from '@/lib/utils'
import { BaseHandle } from './base-handle'
const flexDirections: Record<string, string> = {
[Position.Top]: 'flex-col',
[Position.Right]: 'flex-row-reverse justify-end',
[Position.Bottom]: 'flex-col-reverse justify-end',
[Position.Left]: 'flex-row',
}
export function LabeledHandle({
className,
labelClassName,
handleClassName,
title,
position,
...props
}: HandleProps &
ComponentProps<'div'> & {
title: string
handleClassName?: string
labelClassName?: string
}) {
const { ref, ...handleProps } = props
return (
<div
title={title}
className={cn('relative flex items-center', flexDirections[position], className)}
ref={ref}
>
<BaseHandle position={position} className={handleClassName} {...handleProps} />
<label className={cn('text-muted-foreground text-[10px] font-mono px-1.5', labelClassName)}>
{title}
</label>
</div>
)
}

View File

@@ -1,43 +0,0 @@
import type { ReactNode } from 'react'
import { cn } from '@/lib/utils'
export type NodeStatus = 'online' | 'offline' | 'degraded' | 'unknown'
const STATUS_BORDER_COLORS: Record<NodeStatus, string> = {
online: 'border-emerald-400',
offline: 'border-red-400',
degraded: 'border-yellow-400',
unknown: '',
}
const STATUS_GLOW: Record<NodeStatus, string> = {
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: '',
}
interface NodeStatusIndicatorProps {
status?: NodeStatus
children: ReactNode
className?: string
}
export function NodeStatusIndicator({ status = 'unknown', children, className }: NodeStatusIndicatorProps) {
if (status === 'unknown') {
return <>{children}</>
}
return (
<div
className={cn(
'w-full h-full rounded-lg border-2 transition-colors',
STATUS_BORDER_COLORS[status],
STATUS_GLOW[status],
className,
)}
>
{children}
</div>
)
}

View File

@@ -1,79 +0,0 @@
import { createContext, useContext, useState, useCallback, type ReactNode, type ComponentProps } from 'react'
import { NodeToolbar, type NodeToolbarProps } from '@xyflow/react'
import { cn } from '@/lib/utils'
interface NodeTooltipContextValue {
visible: boolean
show: () => void
hide: () => void
}
const NodeTooltipContext = createContext<NodeTooltipContextValue>({
visible: false,
show: () => {},
hide: () => {},
})
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 className={cn('w-full h-full', className)} {...props}>{children}</div>
</NodeTooltipContext.Provider>
)
}
export function NodeTooltipTrigger({
children,
className,
onMouseEnter,
onMouseLeave,
...props
}: ComponentProps<'div'>) {
const { show, hide } = useContext(NodeTooltipContext)
return (
<div
className={cn('w-full h-full', className)}
onMouseEnter={(e) => {
show()
onMouseEnter?.(e)
}}
onMouseLeave={(e) => {
hide()
onMouseLeave?.(e)
}}
{...props}
>
{children}
</div>
)
}
export function NodeTooltipContent({
className,
position,
children,
...props
}: Omit<NodeToolbarProps, 'children'> & { children: ReactNode }) {
const { visible } = useContext(NodeTooltipContext)
if (!visible) return null
return (
<NodeToolbar
position={position}
className={cn(
'rounded-lg border border-default bg-elevated px-3 py-2',
'pointer-events-none',
className,
)}
{...props}
>
{children}
</NodeToolbar>
)
}

View File

@@ -56,7 +56,7 @@ export function TicketPickerModal({ open, onClose, sessionId, onLinked, onSelect
query: query.trim(), query: query.trim(),
include_closed: closed, include_closed: closed,
}) })
setSearchResults(results.items) setSearchResults(results)
setHasSearched(true) setHasSearched(true)
} catch (err: unknown) { } catch (err: unknown) {
const message = const message =

View File

@@ -1,63 +0,0 @@
import { useState } from 'react'
import { Sparkles, Loader2 } from 'lucide-react'
import { ticketsApi } from '@/api/tickets'
import type { AiParseResponse, TicketCreationPayload } from '@/types/tickets'
interface Props {
initialHint?: string
onParsed: (values: Partial<TicketCreationPayload>, parseResponse: AiParseResponse) => void
}
export function AiTicketParseForm({ initialHint = '', onParsed }: Props) {
const [prompt, setPrompt] = useState(initialHint)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
async function handleParse() {
if (!prompt.trim()) return
setLoading(true)
setError(null)
try {
const result = await ticketsApi.aiParse(prompt)
const values: Partial<TicketCreationPayload> = {
summary: result.summary ?? undefined,
company_id: result.company_id,
board_id: result.board_id,
status_id: result.status_id,
priority_id: result.priority_id,
assigned_member_id: result.assigned_member_id,
description: result.description ?? undefined,
}
onParsed(values, result)
} catch {
setError('AI parsing failed. Please try again or use the full form.')
} finally {
setLoading(false)
}
}
return (
<div className="space-y-3">
<p className="text-sm text-muted-foreground">
Describe the ticket in plain language who, what, which client, and priority.
</p>
<textarea
aria-label="Ticket description"
className="w-full bg-input border border-default rounded-[5px] px-3 py-2 text-sm text-primary placeholder:text-muted-foreground focus:border-accent focus:outline-none resize-none"
rows={4}
placeholder="e.g. Create a high priority ticket for Acme Corp — Outlook not syncing for jsmith, assign to me"
value={prompt}
onChange={e => setPrompt(e.target.value)}
/>
{error && <p className="text-xs text-danger">{error}</p>}
<button
onClick={handleParse}
disabled={!prompt.trim() || loading}
className="flex items-center gap-1.5 px-4 py-2 bg-accent text-white text-sm font-medium rounded-[5px] hover:bg-accent/90 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
{loading ? <Loader2 className="w-4 h-4 animate-spin" /> : <Sparkles className="w-4 h-4" />}
{loading ? 'Parsing…' : 'Parse with AI'}
</button>
</div>
)
}

View File

@@ -1,261 +0,0 @@
import { useState, useEffect } from 'react'
import { X, AlertCircle } from 'lucide-react'
import { ticketsApi } from '@/api/tickets'
import { integrationsApi } from '@/api/integrations'
import { AiTicketParseForm } from './AiTicketParseForm'
import { toast } from '@/lib/toast'
import { cn } from '@/lib/utils'
import type { TicketCreationPayload, AiParseResponse, PSAPriority } from '@/types/tickets'
import type { PSABoard, PSATicketStatusItem } from '@/types/integrations'
interface Props {
defaultTab?: 'quick' | 'manual'
initialValues?: Partial<TicketCreationPayload>
summaryHint?: string
onClose: () => void
onCreated: (ticketId: number, summary: string) => void
}
const EMPTY_DRAFT: TicketCreationPayload = {
summary: '',
company_id: null,
board_id: null,
status_id: null,
priority_id: null,
description: '',
assigned_member_id: null,
}
export function NewTicketModal({ defaultTab = 'quick', initialValues, summaryHint, onClose, onCreated }: Props) {
const [tab, setTab] = useState<'quick' | 'manual'>(defaultTab)
const [draft, setDraft] = useState<TicketCreationPayload>({ ...EMPTY_DRAFT, ...initialValues })
const [missingFields, setMissingFields] = useState<string[]>([])
const [warnings, setWarnings] = useState<string[]>([])
const [boards, setBoards] = useState<PSABoard[]>([])
const [statuses, setStatuses] = useState<PSATicketStatusItem[]>([])
const [priorities, setPriorities] = useState<PSAPriority[]>([])
const [submitting, setSubmitting] = useState(false)
const [parsed, setParsed] = useState(false)
useEffect(() => {
integrationsApi.listBoards().then(setBoards).catch(() => {})
ticketsApi.listPriorities().then(setPriorities).catch(err => console.error('Failed to load priorities', err))
}, [])
useEffect(() => {
if (draft.board_id) {
integrationsApi.getBoardStatuses(draft.board_id)
.then(setStatuses).catch(() => {})
} else {
setStatuses([])
}
}, [draft.board_id])
function handleParsed(values: Partial<TicketCreationPayload>, result: AiParseResponse) {
setDraft(prev => ({ ...prev, ...values }))
setMissingFields(result.missing_fields)
setWarnings(result.warnings)
setParsed(true)
}
function updateDraft(field: keyof TicketCreationPayload, value: unknown) {
setDraft(prev => ({ ...prev, [field]: value }))
setMissingFields(prev => prev.filter(f => f !== field))
}
async function handleSubmit() {
if (!draft.summary.trim() || !draft.company_id || !draft.board_id || !draft.status_id || !draft.priority_id) {
toast.warning('Please fill in all required fields')
return
}
setSubmitting(true)
try {
const result = await ticketsApi.createTicket(draft)
toast.success(`Ticket #${result.id} created in ConnectWise`)
onCreated(result.id, result.summary)
} catch {
toast.error('Failed to create ticket')
} finally {
setSubmitting(false)
}
}
const requiredMissing = (f: string) => missingFields.includes(f)
return (
<div className="fixed inset-0 z-50 flex items-end sm:items-center justify-center">
<div className="fixed inset-0 bg-black/50" onClick={onClose} />
<div className="relative z-10 bg-card border border-default rounded-lg w-full max-w-lg max-h-[90vh] flex flex-col shadow-xl">
{/* Header */}
<div className="flex items-center justify-between px-5 py-4 border-b border-default shrink-0">
<h2 className="font-heading font-semibold text-heading">New Ticket</h2>
<button onClick={onClose} aria-label="Close" className="text-muted-foreground hover:text-primary transition-colors">
<X className="w-4 h-4" />
</button>
</div>
{/* Tabs */}
<div className="flex border-b border-default shrink-0">
{(['quick', 'manual'] as const).map(t => (
<button
key={t}
onClick={() => setTab(t)}
className={cn(
'flex-1 py-2.5 text-sm font-medium transition-colors',
tab === t
? 'text-accent border-b-2 border-accent'
: 'text-muted-foreground hover:text-primary'
)}
>
{t === 'quick' ? 'Quick Create (AI)' : 'Full Form'}
</button>
))}
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-5 space-y-4">
{/* Warnings */}
{warnings.length > 0 && (
<div className="flex gap-2 bg-warning/10 border border-warning/30 rounded p-3">
<AlertCircle className="w-4 h-4 text-warning shrink-0 mt-0.5" />
<ul className="text-xs text-warning space-y-0.5">
{warnings.map((w, i) => <li key={i}>{w}</li>)}
</ul>
</div>
)}
{/* Quick Create tab — before parse */}
{tab === 'quick' && !parsed && (
<AiTicketParseForm initialHint={summaryHint} onParsed={handleParsed} />
)}
{/* Form — shown after parse OR in manual tab */}
{(tab === 'manual' || parsed) && (
<div className="space-y-3">
{/* Summary */}
<div>
<label className="text-xs text-muted-foreground uppercase tracking-wider font-semibold block mb-1">
Summary *
</label>
<input
className={cn(
'w-full bg-input border rounded-[5px] px-3 py-2 text-sm text-primary placeholder:text-muted-foreground focus:outline-none focus:border-accent',
requiredMissing('summary') ? 'border-warning' : 'border-default'
)}
placeholder="Short ticket title"
value={draft.summary}
onChange={e => updateDraft('summary', e.target.value)}
/>
</div>
{/* Board */}
<div>
<label className="text-xs text-muted-foreground uppercase tracking-wider font-semibold block mb-1">
Board *
</label>
<select
className={cn(
'w-full bg-input border rounded-[5px] px-3 py-2 text-sm text-primary focus:outline-none focus:border-accent',
requiredMissing('board_id') ? 'border-warning' : 'border-default'
)}
value={draft.board_id ?? ''}
onChange={e => updateDraft('board_id', e.target.value ? Number(e.target.value) : null)}
>
<option value="">Select board</option>
{boards.map(b => <option key={b.id} value={b.id}>{b.name}</option>)}
</select>
</div>
{/* Status */}
<div>
<label className="text-xs text-muted-foreground uppercase tracking-wider font-semibold block mb-1">
Status *
</label>
<select
disabled={statuses.length === 0}
className={cn(
'w-full bg-input border rounded-[5px] px-3 py-2 text-sm text-primary focus:outline-none focus:border-accent disabled:opacity-50',
requiredMissing('status_id') ? 'border-warning' : 'border-default'
)}
value={draft.status_id ?? ''}
onChange={e => updateDraft('status_id', e.target.value ? Number(e.target.value) : null)}
>
<option value="">{draft.board_id ? 'Select status…' : 'Select board first'}</option>
{statuses.map(s => <option key={s.id} value={s.id}>{s.name}</option>)}
</select>
</div>
{/* Priority */}
<div>
<label className="text-xs text-muted-foreground uppercase tracking-wider font-semibold block mb-1">
Priority *
</label>
<select
className={cn(
'w-full bg-input border rounded-[5px] px-3 py-2 text-sm text-primary focus:outline-none focus:border-accent',
requiredMissing('priority_id') ? 'border-warning' : 'border-default'
)}
value={draft.priority_id ?? ''}
onChange={e => updateDraft('priority_id', e.target.value ? Number(e.target.value) : null)}
>
<option value="">Select priority</option>
{priorities.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
</select>
</div>
{/* Company ID */}
<div>
<label className="text-xs text-muted-foreground uppercase tracking-wider font-semibold block mb-1">
Company ID *
</label>
<input
type="number"
className={cn(
'w-full bg-input border rounded-[5px] px-3 py-2 text-sm text-primary placeholder:text-muted-foreground focus:outline-none focus:border-accent',
requiredMissing('company_id') ? 'border-warning' : 'border-default'
)}
placeholder="ConnectWise company ID"
value={draft.company_id ?? ''}
onChange={e => updateDraft('company_id', e.target.value ? Number(e.target.value) : null)}
/>
</div>
{/* Description */}
<div>
<label className="text-xs text-muted-foreground uppercase tracking-wider font-semibold block mb-1">
Description
</label>
<textarea
className="w-full bg-input border border-default rounded-[5px] px-3 py-2 text-sm text-primary placeholder:text-muted-foreground focus:outline-none focus:border-accent resize-none"
rows={3}
placeholder="Detailed description…"
value={draft.description}
onChange={e => updateDraft('description', e.target.value)}
/>
</div>
</div>
)}
</div>
{/* Footer */}
{(tab === 'manual' || parsed) && (
<div className="flex items-center justify-end gap-2 px-5 py-4 border-t border-default shrink-0">
<button
onClick={onClose}
className="px-4 py-2 text-sm text-muted-foreground hover:text-primary transition-colors"
>
Cancel
</button>
<button
onClick={handleSubmit}
disabled={submitting}
className="px-4 py-2 bg-accent text-white text-sm font-medium rounded-[5px] hover:bg-accent/90 disabled:opacity-40 transition-colors"
>
{submitting ? 'Creating…' : 'Create Ticket'}
</button>
</div>
)}
</div>
</div>
)
}

View File

@@ -1,173 +0,0 @@
import { useCallback, useEffect, useState } from 'react'
import { X } from 'lucide-react'
import { psaContextApi } from '@/api/psaContext'
import type { TicketContext } from '@/api/psaContext'
import { ticketsApi } from '@/api/tickets'
import { integrationsApi } from '@/api/integrations'
import { TicketDetailHeader } from './detail/TicketDetailHeader'
import { TicketResourceManager } from './detail/TicketResourceManager'
import { TicketNotesFeed } from './detail/TicketNotesFeed'
import { TicketAddNote } from './detail/TicketAddNote'
import { TicketConfigs } from './detail/TicketConfigs'
import { TicketRelated } from './detail/TicketRelated'
import type { PSATicketSearchResult, PSATicketStatusItem, PsaMemberResponse } from '@/types/integrations'
import type { PSAResource } from '@/types/tickets'
interface Props {
ticket: PSATicketSearchResult
onClose: () => void
onStatusUpdated?: (ticketId: number, newStatus: string) => void
onSelectRelated?: (ticketId: number) => void
}
function Skeleton() {
return (
<div className="px-4 py-3 space-y-2 animate-pulse">
<div className="h-3 w-3/4 bg-elevated rounded" />
<div className="h-3 w-1/2 bg-elevated rounded" />
</div>
)
}
export function TicketDetailPanel({ ticket, onClose, onStatusUpdated, onSelectRelated }: Props) {
const [context, setContext] = useState<TicketContext | null>(null)
const [resources, setResources] = useState<PSAResource[]>([])
const [allMembers, setAllMembers] = useState<PsaMemberResponse[]>([])
const [statuses, setStatuses] = useState<PSATicketStatusItem[]>([])
const [contextLoading, setContextLoading] = useState(true)
const [resourcesLoading, setResourcesLoading] = useState(true)
const ticketIdNum = Number(ticket.id)
const loadResources = useCallback(() => {
ticketsApi.listResources(ticketIdNum)
.then(setResources)
.catch(() => {})
}, [ticketIdNum])
useEffect(() => {
setContextLoading(true)
setResourcesLoading(true)
setContext(null)
setResources([])
setStatuses([])
Promise.all([
psaContextApi.getTicketContext(ticketIdNum),
ticketsApi.listResources(ticketIdNum),
integrationsApi.listMembers(),
integrationsApi.getTicketStatuses(String(ticket.id)),
])
.then(([ctx, res, members, statusList]) => {
setContext(ctx)
setResources(res)
setAllMembers(members)
setStatuses(statusList)
})
.catch(() => {})
.finally(() => {
setContextLoading(false)
setResourcesLoading(false)
})
}, [ticket.id, ticketIdNum])
function handleStatusUpdated(ticketId: number, newStatus: string) {
onStatusUpdated?.(ticketId, newStatus)
}
return (
<div className="flex flex-col h-full bg-card border-l border-default overflow-hidden">
{/* Panel header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-default flex-shrink-0">
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
Ticket Detail
</span>
<button
onClick={onClose}
className="text-muted-foreground hover:text-primary transition-colors"
aria-label="Close ticket detail"
>
<X className="w-4 h-4" />
</button>
</div>
{/* Scrollable body */}
<div className="flex-1 overflow-y-auto divide-y divide-default">
{/* Header with status selector — optimistic, no loading gate */}
<TicketDetailHeader
ticket={ticket}
statuses={statuses}
onStatusUpdated={handleStatusUpdated}
/>
{/* Resources */}
{resourcesLoading ? (
<Skeleton />
) : (
<TicketResourceManager
ticketId={ticketIdNum}
resources={resources}
allMembers={allMembers}
onChanged={loadResources}
/>
)}
{/* Notes */}
<div>
<div className="px-4 pt-3 pb-1">
<h4 className="text-xs uppercase tracking-wider text-muted-foreground font-semibold">
Notes
</h4>
</div>
{contextLoading ? (
<Skeleton />
) : (
<TicketNotesFeed notes={context?.notes ?? []} />
)}
</div>
{/* Add note */}
<TicketAddNote
ticketId={String(ticket.id)}
onPosted={() => {
// Re-fetch context to refresh notes
psaContextApi.getTicketContext(ticketIdNum)
.then(setContext)
.catch(() => {})
}}
/>
{/* Configurations */}
<div>
<div className="px-4 pt-3 pb-1">
<h4 className="text-xs uppercase tracking-wider text-muted-foreground font-semibold">
Configurations
</h4>
</div>
{contextLoading ? (
<Skeleton />
) : (
<TicketConfigs configs={context?.configurations ?? []} />
)}
</div>
{/* Related tickets */}
<div>
<div className="px-4 pt-3 pb-1">
<h4 className="text-xs uppercase tracking-wider text-muted-foreground font-semibold">
Related Tickets
</h4>
</div>
{contextLoading ? (
<Skeleton />
) : (
<TicketRelated
tickets={context?.related_tickets ?? []}
onSelectTicket={ticketId => onSelectRelated?.(ticketId)}
/>
)}
</div>
</div>
</div>
)
}

View File

@@ -1,207 +0,0 @@
// frontend/src/components/tickets/TicketFilterBar.tsx
import { useState } from 'react'
import { Search, X, User } from 'lucide-react'
import { cn } from '@/lib/utils'
import type { TicketFilters, PSAPriority } from '@/types/tickets'
import type { PSABoard, PSATicketStatusItem } from '@/types/integrations'
interface TicketFilterBarProps {
filters: TicketFilters
onChange: (updated: Partial<TicketFilters>) => void
boards: PSABoard[]
statuses: PSATicketStatusItem[]
priorities: PSAPriority[]
members: { id: number; name: string }[]
total: number
page: number
pageSize: number
onPageChange: (page: number) => void
loading: boolean
}
export function TicketFilterBar({
filters, onChange, boards, statuses, priorities, members,
total, page, pageSize, onPageChange, loading,
}: TicketFilterBarProps) {
const start = (page - 1) * pageSize + 1
const end = Math.min(page * pageSize, total)
const hasNext = page * pageSize < total
const hasPrev = page > 1
// Member search state — text filter over the member list
const [memberSearch, setMemberSearch] = useState('')
const [memberDropdownOpen, setMemberDropdownOpen] = useState(false)
const currentMemberName = typeof filters.assigned === 'number'
? (members.find(m => m.id === filters.assigned)?.name ?? `Member ${filters.assigned}`)
: null
const filteredMembers = members.filter(m =>
m.name.toLowerCase().includes(memberSearch.toLowerCase())
)
function handleMemberSelect(memberId: number | 'all' | 'me' | 'unassigned') {
onChange({ assigned: memberId })
setMemberDropdownOpen(false)
setMemberSearch('')
}
return (
<div className="space-y-3">
{/* Filter row */}
<div className="flex flex-wrap gap-2 items-center">
{/* Search */}
<div className="relative">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-muted-foreground pointer-events-none" />
<input
className="bg-input border border-default rounded-[5px] pl-8 pr-3 py-1.5 text-sm text-primary placeholder:text-muted-foreground focus:border-accent focus:outline-none w-48"
placeholder="Search tickets..."
value={filters.search}
onChange={e => onChange({ search: e.target.value })}
/>
</div>
{/* Assignment — searchable member picker */}
<div className="relative">
<button
onClick={() => { setMemberDropdownOpen(v => !v); setMemberSearch('') }}
className={cn(
'flex items-center gap-1.5 bg-input border rounded-[5px] px-3 py-1.5 text-sm focus:border-accent focus:outline-none',
filters.assigned === 'all' ? 'text-muted-foreground border-default' : 'text-primary border-accent'
)}
>
<User className="w-3.5 h-3.5" />
{filters.assigned === 'all' && 'All Tickets'}
{filters.assigned === 'me' && 'My Tickets'}
{filters.assigned === 'unassigned' && 'Unassigned'}
{currentMemberName}
</button>
{memberDropdownOpen && (
<>
<div className="fixed inset-0 z-10" onClick={() => setMemberDropdownOpen(false)} />
<div className="absolute left-0 top-full mt-1 z-20 w-52 bg-card border border-default rounded-[5px] shadow-lg overflow-hidden">
<div className="p-2 border-b border-default">
<input
autoFocus
className="w-full bg-input border border-default rounded-[5px] px-2 py-1 text-xs text-primary placeholder:text-muted-foreground focus:border-accent focus:outline-none"
placeholder="Search member..."
value={memberSearch}
onChange={e => setMemberSearch(e.target.value)}
/>
</div>
<div className="max-h-48 overflow-y-auto py-1">
{!memberSearch && (
<>
<button onClick={() => handleMemberSelect('all')} className={cn('w-full text-left px-3 py-1.5 text-xs hover:bg-elevated transition-colors', filters.assigned === 'all' && 'text-accent')}>All Tickets</button>
<button onClick={() => handleMemberSelect('me')} className={cn('w-full text-left px-3 py-1.5 text-xs hover:bg-elevated transition-colors', filters.assigned === 'me' && 'text-accent')}>My Tickets</button>
<button onClick={() => handleMemberSelect('unassigned')} className={cn('w-full text-left px-3 py-1.5 text-xs hover:bg-elevated transition-colors', filters.assigned === 'unassigned' && 'text-accent')}>Unassigned</button>
{members.length > 0 && <div className="border-t border-default mx-2 my-1" />}
</>
)}
{filteredMembers.map(m => (
<button
key={m.id}
onClick={() => handleMemberSelect(m.id)}
className={cn('w-full text-left px-3 py-1.5 text-xs hover:bg-elevated transition-colors truncate', filters.assigned === m.id && 'text-accent')}
>
{m.name}
</button>
))}
{memberSearch && filteredMembers.length === 0 && (
<p className="px-3 py-2 text-xs text-muted-foreground">No members found</p>
)}
</div>
</div>
</>
)}
</div>
{/* Board */}
<select
className="bg-input border border-default rounded-[5px] px-3 py-1.5 text-sm text-primary focus:border-accent focus:outline-none"
value={filters.board_id ?? ''}
onChange={e => onChange({ board_id: e.target.value ? Number(e.target.value) : null })}
>
<option value="">All Boards</option>
{boards.map(b => <option key={b.id} value={b.id}>{b.name}</option>)}
</select>
{/* Status */}
<select
className="bg-input border border-default rounded-[5px] px-3 py-1.5 text-sm text-primary focus:border-accent focus:outline-none"
value={filters.status_id ?? ''}
onChange={e => onChange({ status_id: e.target.value ? Number(e.target.value) : null })}
>
<option value="">All Statuses</option>
{statuses.map(s => <option key={s.id} value={s.id}>{s.name}</option>)}
</select>
{/* Priority */}
<select
className="bg-input border border-default rounded-[5px] px-3 py-1.5 text-sm text-primary focus:border-accent focus:outline-none"
value={filters.priority ?? ''}
onChange={e => onChange({ priority: e.target.value || null })}
>
<option value="">All Priorities</option>
{priorities.map(p => <option key={p.id} value={p.name}>{p.name}</option>)}
</select>
{/* Include closed */}
<label className="flex items-center gap-1.5 text-sm text-muted-foreground cursor-pointer select-none">
<input
type="checkbox"
className="accent-accent"
checked={filters.include_closed}
onChange={e => onChange({ include_closed: e.target.checked })}
/>
Include closed
</label>
{/* Clear filters */}
{(filters.search || filters.board_id || filters.status_id || filters.priority || filters.assigned !== 'all' || filters.include_closed) && (
<button
onClick={() => onChange({ search: '', board_id: null, status_id: null, priority: null, assigned: 'all', include_closed: false, company_id: null })}
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-primary transition-colors"
>
<X className="w-3 h-3" /> Clear
</button>
)}
</div>
{/* Pagination row */}
{total > 0 && (
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span>
{loading ? 'Loading…' : `Showing ${start}${end} of ${total} tickets`}
</span>
<div className="flex gap-1">
<button
disabled={!hasPrev}
onClick={() => onPageChange(page - 1)}
className={cn(
'px-2 py-1 rounded border text-xs transition-colors',
hasPrev
? 'border-default text-primary hover:border-hover'
: 'border-default text-muted-foreground opacity-40 cursor-not-allowed'
)}
>
Prev
</button>
<button
disabled={!hasNext}
onClick={() => onPageChange(page + 1)}
className={cn(
'px-2 py-1 rounded border text-xs transition-colors',
hasNext
? 'border-default text-primary hover:border-hover'
: 'border-default text-muted-foreground opacity-40 cursor-not-allowed'
)}
>
Next
</button>
</div>
</div>
)}
</div>
)
}

View File

@@ -1,72 +0,0 @@
// frontend/src/components/tickets/TicketListRow.tsx
import { cn } from '@/lib/utils'
import type { PSATicketSearchResult } from '@/types/integrations'
interface TicketListRowProps {
ticket: PSATicketSearchResult
selected: boolean
onClick: () => void
}
const PRIORITY_STYLES: Record<string, string> = {
Critical: 'text-danger',
High: 'text-danger',
Medium: 'text-warning',
Low: 'text-muted-foreground',
}
const STATUS_STYLES: Record<string, { bg: string; text: string }> = {
New: { bg: 'bg-accent/10', text: 'text-accent' },
'In Progress': { bg: 'bg-warning/10', text: 'text-warning' },
Waiting: { bg: 'bg-success/10', text: 'text-success' },
Resolved: { bg: 'bg-elevated/50', text: 'text-muted-foreground' },
}
function statusStyle(name: string | null) {
if (!name) return { bg: 'bg-elevated', text: 'text-muted-foreground' }
return STATUS_STYLES[name] ?? { bg: 'bg-elevated', text: 'text-muted-foreground' }
}
export function TicketListRow({ ticket, selected, onClick }: TicketListRowProps) {
const { bg, text } = statusStyle(ticket.status_name)
const priorityClass = PRIORITY_STYLES[ticket.priority_name ?? ''] ?? 'text-muted-foreground'
return (
<div
role="button"
tabIndex={0}
onClick={onClick}
onKeyDown={e => e.key === 'Enter' && onClick()}
className={cn(
'flex items-center gap-3 px-4 py-2.5 cursor-pointer transition-colors border-b border-default text-sm',
selected ? 'bg-accent/5' : 'hover:bg-elevated'
)}
>
{/* ID */}
<span className="w-12 shrink-0 text-accent text-xs font-mono">#{ticket.id}</span>
{/* Summary */}
<span className="flex-1 truncate text-primary font-medium">{ticket.summary}</span>
{/* Company */}
<span className="w-32 shrink-0 truncate text-muted-foreground text-xs hidden md:block">
{ticket.company_name ?? '—'}
</span>
{/* Board */}
<span className="w-28 shrink-0 truncate text-muted-foreground text-xs hidden lg:block">
{ticket.board_name ?? '—'}
</span>
{/* Status badge */}
<span className={cn('shrink-0 px-1.5 py-0.5 rounded text-[11px] font-medium', bg, text)}>
{ticket.status_name ?? '—'}
</span>
{/* Priority */}
<span className={cn('w-14 shrink-0 text-xs text-right', priorityClass)}>
{ticket.priority_name ?? '—'}
</span>
</div>
)
}

View File

@@ -1,58 +0,0 @@
import { useState } from 'react'
import { toast } from '@/lib/toast'
interface Props {
ticketId: string
sessionId?: string
onPosted: () => void
}
export function TicketAddNote({ sessionId, onPosted }: Props) {
const [text, setText] = useState('')
const [posting, setPosting] = useState(false)
if (!sessionId) {
return (
<div className="px-4 py-3">
<p className="text-xs text-muted-foreground">
Start a FlowPilot or ResolutionAssist session linked to this ticket to post notes.
</p>
</div>
)
}
async function handlePost() {
if (!text.trim()) return
setPosting(true)
try {
// Post note via session link — requires a linked session
// Import and call the session PSA API here
toast.success('Note posted to ticket')
setText('')
onPosted()
} catch {
toast.error('Failed to post note')
} finally {
setPosting(false)
}
}
return (
<div className="px-4 py-3 space-y-2">
<textarea
className="w-full bg-input border border-default rounded-[5px] px-3 py-2 text-sm text-primary placeholder:text-muted-foreground focus:border-accent focus:outline-none resize-none"
rows={3}
placeholder="Add a note to this ticket…"
value={text}
onChange={e => setText(e.target.value)}
/>
<button
disabled={!text.trim() || posting}
onClick={handlePost}
className="px-3 py-1.5 bg-accent text-white text-xs font-medium rounded-[5px] hover:bg-accent/90 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
{posting ? 'Posting…' : 'Post Note'}
</button>
</div>
)
}

View File

@@ -1,28 +0,0 @@
import type { ConfigItemInfo } from '@/api/psaContext'
interface Props {
configs: ConfigItemInfo[]
}
export function TicketConfigs({ configs }: Props) {
if (configs.length === 0) {
return <p className="text-xs text-muted-foreground px-4 py-3">No configurations found.</p>
}
return (
<div className="divide-y divide-default">
{configs.map((config, i) => (
<div key={i} className="px-4 py-3 space-y-1.5">
<p className="text-sm font-medium text-primary">{config.device_identifier}</p>
<div className="grid grid-cols-2 gap-2 text-xs text-muted-foreground">
{config.type && <span>Type: {config.type}</span>}
{config.os_type && <span>OS: {config.os_type}</span>}
{config.ip_address && <span>IP: {config.ip_address}</span>}
{config.serial_number && <span>Serial: {config.serial_number}</span>}
{config.model_number && <span>Model: {config.model_number}</span>}
</div>
</div>
))}
</div>
)
}

View File

@@ -1,74 +0,0 @@
import { useState } from 'react'
import { ticketsApi } from '@/api/tickets'
import { toast } from '@/lib/toast'
import type { PSATicketSearchResult, PSATicketStatusItem } from '@/types/integrations'
import type { PSATicketStatusUpdate } from '@/types/tickets'
interface Props {
ticket: PSATicketSearchResult
statuses: PSATicketStatusItem[]
onStatusUpdated: (ticketId: number, newStatus: string) => void
}
export function TicketDetailHeader({ ticket, statuses, onStatusUpdated }: Props) {
const [updating, setUpdating] = useState(false)
async function handleStatusChange(statusId: number) {
if (!ticket.id) return
setUpdating(true)
try {
const result: PSATicketStatusUpdate = await ticketsApi.updateStatus(Number(ticket.id), statusId)
onStatusUpdated(result.ticket_id, result.new_status)
toast.success(`Status updated to ${result.new_status}`)
} catch {
toast.error('Failed to update status')
} finally {
setUpdating(false)
}
}
return (
<div className="p-4 border-b border-default space-y-3">
<div>
<div className="flex items-center gap-2 mb-1">
<span className="text-accent text-xs font-mono">#{ticket.id}</span>
{ticket.board_name && (
<span className="text-xs text-muted-foreground">{ticket.board_name}</span>
)}
</div>
<h2 className="font-heading font-semibold text-heading text-base leading-snug">
{ticket.summary}
</h2>
{ticket.company_name && (
<p className="text-sm text-muted-foreground mt-0.5">{ticket.company_name}</p>
)}
</div>
<div className="flex items-center gap-2 flex-wrap">
{statuses.length > 0 ? (
<select
disabled={updating}
value={ticket.status_id ?? ''}
onChange={e => handleStatusChange(Number(e.target.value))}
className="bg-input border border-default rounded-[5px] px-2 py-1 text-xs text-primary focus:border-accent focus:outline-none"
>
{statuses.map(s => (
<option key={s.id} value={s.id}>{s.name}</option>
))}
</select>
) : (
ticket.status_name && (
<span className="px-2 py-0.5 bg-elevated rounded text-xs text-muted-foreground">
{ticket.status_name}
</span>
)
)}
{ticket.priority_name && (
<span className="px-2 py-0.5 bg-elevated rounded text-xs text-muted-foreground">
{ticket.priority_name}
</span>
)}
</div>
</div>
)
}

View File

@@ -1,28 +0,0 @@
import type { TicketNote } from '@/api/psaContext'
interface Props {
notes: TicketNote[]
}
export function TicketNotesFeed({ notes }: Props) {
if (notes.length === 0) {
return <p className="text-xs text-muted-foreground px-4 py-3">No notes yet.</p>
}
return (
<div className="divide-y divide-default">
{notes.map((note, i) => (
<div key={i} className="px-4 py-3 space-y-1">
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span>{note.member ?? 'Unknown'}</span>
<span>{new Date(note.date_created).toLocaleDateString()}</span>
</div>
{note.internal_analysis_flag && (
<span className="text-[10px] uppercase tracking-wider text-warning">Internal</span>
)}
<p className="text-sm text-primary whitespace-pre-wrap">{note.text}</p>
</div>
))}
</div>
)
}

View File

@@ -1,42 +0,0 @@
import type { RelatedTicket } from '@/api/psaContext'
interface Props {
tickets: RelatedTicket[]
onSelectTicket: (ticketId: number) => void
}
export function TicketRelated({ tickets, onSelectTicket }: Props) {
if (tickets.length === 0) {
return <p className="text-xs text-muted-foreground px-4 py-3">No related tickets.</p>
}
return (
<div className="space-y-2 px-4 py-3">
{tickets.map(ticket => (
<button
key={ticket.id}
onClick={() => onSelectTicket(ticket.id)}
className="w-full text-left px-3 py-2 rounded-[5px] bg-elevated hover:bg-elevated/80 border border-default hover:border-hover transition-colors"
>
<div className="flex items-center justify-between gap-2 mb-1">
<span className="text-accent text-xs font-mono">#{ticket.id}</span>
{ticket.board && <span className="text-xs text-muted-foreground">{ticket.board}</span>}
</div>
<p className="text-sm text-primary line-clamp-2 mb-1.5">{ticket.summary}</p>
<div className="flex items-center gap-2">
{ticket.status && (
<span className="px-1.5 py-0.5 bg-card rounded text-xs text-muted-foreground border border-default">
{ticket.status}
</span>
)}
{ticket.priority && (
<span className="px-1.5 py-0.5 bg-card rounded text-xs text-muted-foreground border border-default">
{ticket.priority}
</span>
)}
</div>
</button>
))}
</div>
)
}

View File

@@ -1,121 +0,0 @@
import { useState } from 'react'
import { UserPlus, X, User } from 'lucide-react'
import { ticketsApi } from '@/api/tickets'
import { toast } from '@/lib/toast'
import type { PSAResource } from '@/types/tickets'
import type { PsaMemberResponse } from '@/types/integrations'
import { cn } from '@/lib/utils'
interface Props {
ticketId: number
resources: PSAResource[]
allMembers: PsaMemberResponse[]
onChanged: () => void
}
export function TicketResourceManager({ ticketId, resources, allMembers, onChanged }: Props) {
const [adding, setAdding] = useState(false)
const [selectedMemberId, setSelectedMemberId] = useState<string>('')
const [busy, setBusy] = useState<number | null>(null)
async function handleAdd() {
if (!selectedMemberId) return
setBusy(Number(selectedMemberId))
try {
await ticketsApi.addResource(ticketId, Number(selectedMemberId))
toast.success('Resource added')
setAdding(false)
setSelectedMemberId('')
onChanged()
} catch {
toast.error('Failed to add resource')
} finally {
setBusy(null)
}
}
async function handleRemove(memberId: number) {
setBusy(memberId)
try {
await ticketsApi.removeResource(ticketId, memberId)
toast.success('Resource removed')
onChanged()
} catch {
toast.error('Failed to remove resource')
} finally {
setBusy(null)
}
}
const assignedIds = new Set(resources.map(r => r.member_id))
return (
<div className="px-4 py-3 space-y-2">
<div className="flex items-center justify-between">
<h4 className="text-xs uppercase tracking-wider text-muted-foreground font-semibold">
Resources
</h4>
<button
onClick={() => setAdding(!adding)}
className="flex items-center gap-1 text-xs text-accent hover:text-accent/80 transition-colors"
>
<UserPlus className="w-3.5 h-3.5" /> Assign
</button>
</div>
{adding && (
<div className="flex gap-2">
<select
className="flex-1 bg-input border border-default rounded-[5px] px-2 py-1 text-xs text-primary focus:border-accent focus:outline-none"
value={selectedMemberId}
onChange={e => setSelectedMemberId(e.target.value)}
>
<option value="">Select member</option>
{allMembers
.filter(m => !assignedIds.has(Number(m.id)))
.map(m => (
<option key={m.id} value={m.id}>{m.name}</option>
))}
</select>
<button
onClick={handleAdd}
disabled={!selectedMemberId || busy !== null}
className="px-2 py-1 bg-accent text-white text-xs rounded-[5px] disabled:opacity-40"
>
Add
</button>
</div>
)}
{resources.length === 0 ? (
<p className="text-xs text-muted-foreground">No resources assigned.</p>
) : (
<div className="space-y-1">
{resources.map(r => (
<div key={r.member_id} className="flex items-center justify-between">
<div className="flex items-center gap-1.5 text-xs text-primary">
<User className="w-3 h-3 text-muted-foreground" />
{r.member_name}
{r.is_rf_user && (
<span className="px-1 py-0.5 bg-accent/10 text-accent rounded text-[10px] font-medium">
RF
</span>
)}
</div>
<button
onClick={() => handleRemove(r.member_id)}
disabled={busy === r.member_id}
className={cn(
'text-muted-foreground hover:text-danger transition-colors',
busy === r.member_id && 'opacity-40'
)}
>
<X className="w-3.5 h-3.5" />
</button>
</div>
))}
</div>
)}
</div>
)
}

View File

@@ -99,14 +99,9 @@ export function useFlowPilotSession(): UseFlowPilotSession {
setAllSteps([firstStep]) setAllSteps([firstStep])
setCurrentStep(firstStep) setCurrentStep(firstStep)
} catch (e: unknown) { } catch (e: unknown) {
// Prefer the backend's detail message over the generic axios status string const message = e instanceof Error ? e.message : 'Failed to start session'
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) setError(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) toast.error(message)
}
} finally { } finally {
setIsLoading(false) setIsLoading(false)
} }

View File

@@ -445,42 +445,3 @@
scroll-behavior: auto !important; 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;
}
}

View File

@@ -1,99 +0,0 @@
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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
}
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
}

View File

@@ -1,142 +0,0 @@
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 }
}

View File

@@ -1,13 +1,12 @@
import { useState, useEffect, useRef, useCallback } from 'react' import { useState, useEffect, useRef, useCallback } from 'react'
import { useLocation, useNavigate, useParams } from 'react-router-dom' import { useLocation, useNavigate, useParams } from 'react-router-dom'
import { Sparkles, Send, Loader2, MessageSquare, Paperclip, Terminal, X, RotateCcw, ImagePlus, ListChecks, FileText, CheckCircle2, ArrowUpRight, MoreHorizontal, Pause, Plus } from 'lucide-react' import { Sparkles, Send, Loader2, MessageSquare, Paperclip, Terminal, X, RotateCcw, ImagePlus, ListChecks, FileText, CheckCircle2, ArrowUpRight, MoreHorizontal, Pause } from 'lucide-react'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { uploadsApi } from '@/api/uploads' import { uploadsApi } from '@/api/uploads'
import type { PendingUpload } from '@/types/upload' import type { PendingUpload } from '@/types/upload'
import type { ForkMetadata, ActionItem, QuestionItem } from '@/types/ai-session' import type { ForkMetadata, ActionItem, QuestionItem } from '@/types/ai-session'
import { PageMeta } from '@/components/common/PageMeta' import { PageMeta } from '@/components/common/PageMeta'
import { aiSessionsApi } from '@/api/aiSessions' import { aiSessionsApi } from '@/api/aiSessions'
import { integrationsApi } from '@/api/integrations'
import { useBranching } from '@/hooks/useBranching' import { useBranching } from '@/hooks/useBranching'
import { analytics } from '@/lib/analytics' import { analytics } from '@/lib/analytics'
import { toast } from '@/lib/toast' import { toast } from '@/lib/toast'
@@ -16,10 +15,8 @@ import { ChatMessage } from '@/components/assistant/ChatMessage'
import { TaskLane, clearTaskState } from '@/components/assistant/TaskLane' import { TaskLane, clearTaskState } from '@/components/assistant/TaskLane'
import { ConcludeSessionModal } from '@/components/assistant/ConcludeSessionModal' import { ConcludeSessionModal } from '@/components/assistant/ConcludeSessionModal'
import { StatusUpdateModal } from '@/components/flowpilot/StatusUpdateModal' import { StatusUpdateModal } from '@/components/flowpilot/StatusUpdateModal'
import { NewTicketModal } from '@/components/tickets/NewTicketModal'
import type { ChatListItem, ConclusionOutcome } from '@/types/assistant-chat' import type { ChatListItem, ConclusionOutcome } from '@/types/assistant-chat'
import type { SuggestedFlow } from '@/types/copilot' import type { SuggestedFlow } from '@/types/copilot'
import type { PSATicketInfo } from '@/types/integrations'
interface MessageWithMeta { interface MessageWithMeta {
role: 'user' | 'assistant' role: 'user' | 'assistant'
@@ -77,9 +74,6 @@ export default function AssistantChatPage() {
) )
const [activeSessionStatus, setActiveSessionStatus] = useState<string | null>(null) const [activeSessionStatus, setActiveSessionStatus] = useState<string | null>(null)
const [activePsaTicketId, setActivePsaTicketId] = useState<string | null>(null) const [activePsaTicketId, setActivePsaTicketId] = useState<string | null>(null)
const [linkedTicket, setLinkedTicket] = useState<PSATicketInfo | null>(null)
const [showNewTicket, setShowNewTicket] = useState(false)
const [spinOffHint, setSpinOffHint] = useState<string | undefined>(undefined)
const [showOverflow, setShowOverflow] = useState(false) const [showOverflow, setShowOverflow] = useState(false)
const toggleSidebarCollapse = () => { const toggleSidebarCollapse = () => {
const next = !sidebarCollapsed const next = !sidebarCollapsed
@@ -245,16 +239,6 @@ export default function AssistantChatPage() {
if (currentChatRef.current !== chatId) return if (currentChatRef.current !== chatId) return
setActiveSessionStatus(detail.status) setActiveSessionStatus(detail.status)
setActivePsaTicketId(detail.psa_ticket_id) setActivePsaTicketId(detail.psa_ticket_id)
if (detail.psa_ticket_id) {
integrationsApi.getTicket(detail.psa_ticket_id)
.then(ticket => {
if (currentChatRef.current !== chatId) return
setLinkedTicket(ticket)
})
.catch(() => {})
} else {
setLinkedTicket(null)
}
setMessages( setMessages(
(detail.conversation_messages || []).map(m => ({ (detail.conversation_messages || []).map(m => ({
role: m.role as 'user' | 'assistant', role: m.role as 'user' | 'assistant',
@@ -403,17 +387,9 @@ export default function AssistantChatPage() {
} }
} }
const handleTaskSubmit = async (responses: Array<{ type: string; state: string; value: string; text?: string; label?: string; command?: string | null }>) => { const handleTaskSubmit = async (responses: Array<{ type: string; state: string; value: string; text?: string; label?: string }>) => {
if (!activeChatId || loading) return if (!activeChatId || loading) return
// Handle special action commands that open UI flows instead of sending to AI
const spinOffAction = responses.find(r => r.type === 'action' && r.command === 'create_spin_off_ticket')
if (spinOffAction) {
setSpinOffHint(spinOffAction.label || spinOffAction.text)
setShowNewTicket(true)
return
}
// Format task responses into a structured message for the AI. // Format task responses into a structured message for the AI.
// Pending tasks are included so the AI knows they weren't completed yet. // Pending tasks are included so the AI knows they weren't completed yet.
const parts: string[] = [] const parts: string[] = []
@@ -732,14 +708,6 @@ export default function AssistantChatPage() {
{/* Desktop actions — shown when session is active and has messages */} {/* Desktop actions — shown when session is active and has messages */}
<div className="hidden sm:flex items-center gap-1.5"> <div className="hidden sm:flex items-center gap-1.5">
{activePsaTicketId && (
<button
onClick={() => { setSpinOffHint(undefined); setShowNewTicket(true) }}
className="flex items-center gap-1 px-2 py-1 text-xs text-muted-foreground border border-default rounded-[5px] hover:border-hover hover:text-primary transition-colors"
>
<Plus className="w-3 h-3" /> New Ticket
</button>
)}
{isActive && ( {isActive && (
<> <>
<button <button
@@ -1084,24 +1052,6 @@ export default function AssistantChatPage() {
context="status" context="status"
/> />
)} )}
{/* Spin-off Ticket Modal */}
{showNewTicket && (
<NewTicketModal
defaultTab={spinOffHint ? 'quick' : 'manual'}
summaryHint={spinOffHint}
initialValues={linkedTicket ? {
company_id: linkedTicket.company_id,
board_id: linkedTicket.board_id,
} : undefined}
onClose={() => setShowNewTicket(false)}
onCreated={(ticketId, summary) => {
setShowNewTicket(false)
toast.success(`Ticket #${ticketId} created: ${summary}`)
setActiveActions(prev => prev.filter(a => a.command !== 'create_spin_off_ticket'))
}}
/>
)}
</div> </div>
</> </>
) )

View File

@@ -9,8 +9,6 @@ import { StatusUpdateModal } from '@/components/flowpilot/StatusUpdateModal'
import { HandoffModal } from '@/components/session/HandoffModal' import { HandoffModal } from '@/components/session/HandoffModal'
import { handoffsApi } from '@/api/handoffs' import { handoffsApi } from '@/api/handoffs'
import { aiSessionsApi } from '@/api' import { aiSessionsApi } from '@/api'
import { integrationsApi } from '@/api/integrations'
import type { PSATicketInfo } from '@/types/integrations'
import { toast } from '@/lib/toast' import { toast } from '@/lib/toast'
export default function FlowPilotSessionPage() { export default function FlowPilotSessionPage() {
@@ -19,13 +17,10 @@ export default function FlowPilotSessionPage() {
const navigate = useNavigate() const navigate = useNavigate()
const location = useLocation() const location = useLocation()
const prefill = (location.state as { prefill?: string } | null)?.prefill || '' 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 isPickup = searchParams.get('pickup') === 'true'
const fp = useFlowPilotSession() const fp = useFlowPilotSession()
const branching = useBranching() const branching = useBranching()
const prefillHandledRef = useRef(false) const prefillHandledRef = useRef(false)
const psaTicketHandledRef = useRef(false)
const [showOverflow, setShowOverflow] = useState(false) const [showOverflow, setShowOverflow] = useState(false)
const [showResolve, setShowResolve] = useState(false) const [showResolve, setShowResolve] = useState(false)
const [showEscalate, setShowEscalate] = useState(false) const [showEscalate, setShowEscalate] = useState(false)
@@ -49,30 +44,6 @@ export default function FlowPilotSessionPage() {
fp.startSession({ intake_type: 'free_text', intake_content: { text: prefill } }) fp.startSession({ intake_type: 'free_text', intake_content: { text: prefill } })
} }
}, [prefill, sessionId, fp.session, fp.isLoading]) // eslint-disable-line react-hooks/exhaustive-deps }, [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) const [pickingUp, setPickingUp] = useState(false)
// Load existing session if ID in URL // Load existing session if ID in URL

View File

@@ -1,980 +0,0 @@
import { useState, useCallback, useEffect, useRef, useReducer } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import {
ReactFlowProvider,
useNodesState,
useEdgesState,
addEdge,
reconnectEdge,
useReactFlow,
getNodesBounds,
getViewportForBounds,
type Node,
type Edge,
type Connection,
} from '@xyflow/react'
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 { 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 }
nodeId?: string
} | null
function DiagramEditorInner() {
const { id } = useParams<{ id: string }>()
const navigate = useNavigate()
const { getNodes, fitView, screenToFlowPosition } = useReactFlow()
const [diagramId, setDiagramId] = useState<string | null>(id || null)
const [name, setName] = useState('Untitled Diagram')
const [clientName, setClientName] = useState<string | null>(null)
const [assetName, setAssetName] = useState<string | null>(null)
const [description, setDescription] = useState<string | null>(null)
const [nodes, setNodes, onNodesChange] = useNodesState<Node>([])
const [edges, setEdges, onEdgesChange] = useEdgesState<Edge>([])
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null)
const [selectedEdgeId, setSelectedEdgeId] = useState<string | null>(null)
const selectedNode = selectedNodeId ? nodes.find(n => n.id === selectedNodeId) ?? null : null
const selectedEdge = selectedEdgeId ? edges.find(e => e.id === selectedEdgeId) ?? null : null
const [isDirty, setIsDirty] = useState(false)
const [isSaving, setIsSaving] = useState(false)
const [lastSavedAt, setLastSavedAt] = useState<Date | null>(null)
const isDirtyRef = useRef(false)
const diagramIdRef = useRef<string | null>(id || null)
const [deviceTypes, setDeviceTypes] = useState<DeviceTypeResponse[]>([])
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,
duplicateNodes,
selectAll,
deleteSelected,
hasClipboard,
} = useCanvasShortcuts({
nodes,
edges,
setNodes,
setEdges,
setIsDirty: (v: boolean) => setIsDirty(v),
canvasRef,
onUndo: undo,
onRedo: redo,
onNudge,
onSetMode: setInteractionMode,
onToggleShortcuts: () => setShowShortcuts(v => !v),
})
const handleNodesChange: typeof onNodesChange = useCallback((changes) => {
onNodesChange(changes)
const hasRealChange = changes.some(c => c.type !== 'select')
if (hasRealChange) setIsDirty(true)
}, [onNodesChange])
const handleEdgesChange: typeof onEdgesChange = useCallback((changes) => {
onEdgesChange(changes)
const hasRealChange = changes.some(c => c.type !== 'select')
if (hasRealChange) setIsDirty(true)
}, [onEdgesChange])
const loadDeviceTypes = useCallback(async () => {
try {
const types = await deviceTypesApi.list()
setDeviceTypes(types)
} catch { /* ignore */ }
}, [])
useEffect(() => { loadDeviceTypes() }, [loadDeviceTypes])
useEffect(() => {
if (!id) return
let cancelled = false
;(async () => {
try {
const diagram = await networkDiagramsApi.get(id)
if (cancelled) return
setName(diagram.name)
setClientName(diagram.client_name)
setAssetName(diagram.asset_name)
setDescription(diagram.description)
setNodes(
diagram.nodes.map(n => {
if (n.nodeType === 'group') {
return {
id: n.id,
type: 'group',
position: n.position,
style: n.style || { width: 300, height: 200 },
data: {
label: n.label,
groupType: n.type,
},
}
}
return {
id: n.id,
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,
properties: n.properties,
} satisfies DeviceNodeData,
}
})
)
setEdges(
diagram.edges.map(e => ({
id: e.id,
source: e.source,
target: e.target,
type: 'connection',
label: e.label || undefined,
data: {
connectionType: e.connectionType,
speed: e.speed,
notes: e.notes,
routing: e.routing ?? null,
},
}))
)
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')
} finally {
if (!cancelled) setLoading(false)
}
})()
return () => { cancelled = true }
}, [id, navigate, setNodes, setEdges, pushHistory])
const serializeNodes = useCallback((): DiagramNode[] => {
return getNodes().map(n => {
if (n.type === 'group') {
const data = n.data as Record<string, unknown>
const width = typeof n.style?.width === 'number' ? n.style.width : (n.measured?.width ?? 300)
const height = typeof n.style?.height === 'number' ? n.style.height : (n.measured?.height ?? 200)
return {
id: n.id,
type: (data.groupType as string) || 'subnet',
label: (data.label as string) || 'Group',
position: n.position,
properties: {} as DeviceProperties,
nodeType: 'group',
style: { width, height },
}
}
const data = n.data as unknown as DeviceNodeData
const dw = typeof n.style?.width === 'number' ? n.style.width : (n.measured?.width ?? 120)
const dh = typeof n.style?.height === 'number' ? n.style.height : (n.measured?.height ?? 120)
return {
id: n.id,
type: data.deviceType,
label: data.label,
position: n.position,
properties: data.properties,
style: { width: dw, height: dh },
...(n.parentId ? { parentId: n.parentId } : {}),
}
})
}, [getNodes])
const serializeEdges = useCallback((): DiagramEdge[] => {
return edges.map(e => {
const d = (e.data as Record<string, unknown>) || {}
return {
id: e.id,
source: e.source,
target: e.target,
label: (e.label as string) || null,
connectionType: d.connectionType as string || 'ethernet',
speed: d.speed as string || null,
notes: d.notes as string || null,
routing: (d.routing as DiagramEdge['routing']) || null,
}
})
}, [edges])
const handleSave = useCallback(async () => {
setIsSaving(true)
try {
const payload = {
name,
client_name: clientName,
asset_name: assetName,
description,
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, nodes])
useEffect(() => {
const interval = setInterval(() => {
if (isDirtyRef.current && diagramIdRef.current) {
handleSave()
}
}, 30_000)
return () => clearInterval(interval)
}, [handleSave])
const onConnect = useCallback((connection: Connection) => {
pushHistory(nodes, edges)
setEdges(eds => addEdge({
...connection,
type: 'connection',
data: { connectionType: 'ethernet' },
}, eds))
setIsDirty(true)
}, [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()
event.dataTransfer.dropEffect = 'move'
setIsDragOver(true)
}, [])
const onDragLeave = useCallback((event: React.DragEvent) => {
const relatedTarget = event.relatedTarget as HTMLElement | null
if (relatedTarget && (event.currentTarget as HTMLElement).contains(relatedTarget)) return
setIsDragOver(false)
}, [])
const handleNodeContextMenu = useCallback((event: React.MouseEvent, node: Node) => {
event.preventDefault()
const currentNodes = getNodes()
const isInSelection = currentNodes.find(n => n.id === node.id)?.selected
if (!isInSelection) {
setNodes(nds => nds.map(n => ({ ...n, selected: n.id === node.id })))
setSelectedNodeId(node.id)
setSelectedEdgeId(null)
}
setContextMenu({
type: 'node',
position: { x: event.clientX, y: event.clientY },
nodeId: node.id,
})
}, [getNodes, setNodes, setSelectedNodeId, setSelectedEdgeId])
const handlePaneContextMenu = useCallback((event: MouseEvent | React.MouseEvent) => {
event.preventDefault()
// 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)
}, [])
const onDrop = useCallback((event: React.DragEvent) => {
event.preventDefault()
setIsDragOver(false)
const position = screenToFlowPosition({ x: event.clientX, y: event.clientY })
// Handle device drops
const deviceRaw = event.dataTransfer.getData('application/reactflow-device')
if (deviceRaw) {
const { slug, label, category } = JSON.parse(deviceRaw) as { slug: string; label: string; category: string }
const newNode: Node = {
id: `device-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
type: 'device',
position,
style: { width: 120, height: 120 },
data: {
label,
deviceType: slug,
category,
properties: {
hostname: null,
ip: null,
subnet: null,
vendor: null,
model: null,
role: null,
vlan: null,
notes: null,
status: 'unknown',
} satisfies DeviceProperties,
} satisfies DeviceNodeData,
}
pushHistory(nodes, edges)
setNodes(nds => [...nds, newNode])
setIsDirty(true)
return
}
// Handle group drops
const groupRaw = event.dataTransfer.getData('application/reactflow-group')
if (groupRaw) {
const { slug, label } = JSON.parse(groupRaw) as { slug: string; label: string }
const newNode: Node = {
id: `group-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
type: 'group',
position,
style: { width: 300, height: 200 },
data: {
label,
groupType: slug,
},
}
pushHistory(nodes, edges)
setNodes(nds => [...nds, newNode])
setIsDirty(true)
}
}, [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)
}, [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 {
...e,
label: updates.label !== undefined ? (updates.label || undefined) : e.label,
data: {
...(e.data || {}),
...(updates.connectionType !== undefined ? { connectionType: updates.connectionType } : {}),
...(updates.speed !== undefined ? { speed: updates.speed } : {}),
...(updates.notes !== undefined ? { notes: updates.notes } : {}),
...(updates.routing !== undefined ? { routing: updates.routing } : {}),
},
}
}))
setIsDirty(true)
}, [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)
}, [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)
}, [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)
}, [nodes, edges, pushHistory, setEdges])
const handleBringToFront = useCallback((nodeId: string) => {
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)
}, [nodes, edges, pushHistory, setNodes])
const handleSendToBack = useCallback((nodeId: string) => {
pushHistory(nodes, edges)
setNodes(prev => normalizeZOrder(
prev.map(n => n.id === nodeId ? { ...n, zIndex: 0 } : n)
))
setIsDirty(true)
}, [nodes, edges, pushHistory, setNodes])
const handleAIGenerate = useCallback((result: AIGenerateResponse, mode: 'replace' | 'merge') => {
const newNodes: Node[] = result.nodes.map(n => ({
id: n.id,
type: 'device',
position: n.position,
data: {
label: n.label,
deviceType: n.type,
properties: n.properties,
} satisfies DeviceNodeData,
}))
const newEdges: Edge[] = result.edges.map(e => ({
id: e.id,
source: e.source,
target: e.target,
type: 'connection',
label: e.label || undefined,
data: { connectionType: e.connectionType, speed: e.speed, notes: e.notes },
}))
pushHistory(nodes, edges)
if (mode === 'replace') {
setNodes(newNodes)
setEdges(newEdges)
} else {
setNodes(nds => [...nds, ...newNodes])
setEdges(eds => [...eds, ...newEdges])
}
if (result.suggestedName && !diagramId) {
setName(result.suggestedName)
toast.success(`Generated: ${result.suggestedName}`)
} else {
toast.success('Diagram generated')
}
if (result.notes) {
toast.info(result.notes)
}
setIsDirty(true)
setTimeout(() => fitView({ padding: 0.2 }), 100)
}, [nodes, edges, pushHistory, setNodes, setEdges, diagramId, fitView])
const getExistingBounds = useCallback(() => {
const currentNodes = getNodes()
if (currentNodes.length === 0) return null
let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity
for (const n of currentNodes) {
minX = Math.min(minX, n.position.x)
maxX = Math.max(maxX, n.position.x + 120)
minY = Math.min(minY, n.position.y)
maxY = Math.max(maxY, n.position.y + 80)
}
return { minX, maxX, minY, maxY }
}, [getNodes])
const handleExportPng = useCallback(async () => {
if (nodes.length === 0) {
toast.warning('Add some devices to the diagram before exporting')
return
}
try {
const { toPng } = 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 toPng(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'}.png`
a.href = dataUrl
a.click()
} catch {
toast.error('PNG export failed — try Print > Save as PDF instead')
}
}, [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()
}, [])
const handleExportJson = useCallback(async () => {
if (!diagramId) return
try {
const data = await networkDiagramsApi.exportJson(diagramId)
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `${name.replace(/[^a-zA-Z0-9-_ ]/g, '')}.json`
a.click()
URL.revokeObjectURL(url)
} catch {
toast.error('Failed to export diagram')
}
}, [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">
<div className="h-6 w-6 animate-spin rounded-full border-2 border-accent border-t-transparent" />
</div>
)
}
return (
<div className="flex h-full flex-col">
<DiagramHeader
name={name}
clientName={clientName}
isDirty={isDirty}
isSaving={isSaving}
lastSavedAt={lastSavedAt}
diagramId={diagramId}
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} />
<div className="flex flex-1 flex-col min-h-0">
<div className="relative flex-1 min-h-0" ref={canvasRef}>
<NetworkCanvas
nodes={nodes}
edges={edges}
onNodesChange={handleNodesChange}
onEdgesChange={handleEdgesChange}
onConnect={onConnect}
onReconnect={onReconnect}
onNodeSelect={setSelectedNodeId}
onEdgeSelect={setSelectedEdgeId}
onDrop={onDrop}
onDragOver={onDragOver}
onDragLeave={onDragLeave}
isDragOver={isDragOver}
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
onGenerate={handleAIGenerate}
getExistingBounds={getExistingBounds}
hasNodes={nodes.length > 0}
/>
)}
</div>
<PropertiesPanel
selectedNode={selectedNode}
selectedEdge={selectedEdge}
onNodeUpdate={handleNodeUpdate}
onEdgeUpdate={handleEdgeUpdate}
onEdgeTypeChange={handleEdgeTypeChange}
onBringToFront={handleBringToFront}
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 && (
<ContextMenu
position={contextMenu.position}
actions={
contextMenu.type === 'node'
? getNodeMenuActions({
onCopy: copyNodes,
onDuplicate: duplicateNodes,
onBringToFront: () => { if (contextMenu.nodeId) handleBringToFront(contextMenu.nodeId) },
onSendToBack: () => { if (contextMenu.nodeId) handleSendToBack(contextMenu.nodeId) },
onDelete: () => {
const nodeId = contextMenu.nodeId
setContextMenu(null)
if (nodeId) setPendingDeleteNodeId(nodeId)
else deleteSelected()
},
})
: getCanvasMenuActions({
onPaste: pasteNodes,
onSelectAll: selectAll,
onFitView: () => fitView({ padding: 0.2 }),
hasClipboard: hasClipboard(),
})
}
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 && (
<div className="pointer-events-none absolute inset-x-0 bottom-4 z-50 flex justify-center">
<div className="pointer-events-auto flex items-center gap-3 rounded-lg border border-default bg-card px-4 py-2.5 shadow-lg">
<span className="text-xs text-muted-foreground">Delete this device?</span>
<button
onClick={() => setPendingDeleteNodeId(null)}
className="rounded border border-default px-3 py-1 text-xs text-primary hover:bg-elevated"
>
Cancel
</button>
<button
onClick={() => { handleDeleteNode(pendingDeleteNodeId); setPendingDeleteNodeId(null) }}
className="rounded bg-red-500/20 px-3 py-1 text-xs font-medium text-red-400 hover:bg-red-500/30"
>
Delete
</button>
</div>
</div>
)}
<input
ref={drawioImportRef}
type="file"
accept=".drawio,.xml"
className="hidden"
onChange={handleDrawioFileChange}
/>
{showShortcuts && (
<KeyboardShortcutsOverlay onClose={() => setShowShortcuts(false)} />
)}
</div>
)
}
export default function DiagramEditor() {
return (
<ReactFlowProvider>
<DiagramEditorInner />
</ReactFlowProvider>
)
}

Some files were not shown because too many files have changed in this diff Show More