Compare commits
6 Commits
f0ccf313a4
...
feat/admin
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0bda590537 | ||
|
|
2dbb8b6abf | ||
|
|
9452e5d408 | ||
|
|
e002fe4969 | ||
|
|
7cbc9fe224 | ||
|
|
70242ad037 |
@@ -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
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -11,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 2** — PostgreSQL Row Level Security (RLS) on 11 session-related tables (ai_sessions, session_steps, session_tags, etc.), account_id NOT NULL enforcement on all write paths, Alembic migrations with dual-env support (Railway native vars + explicit DATABASE_URL_SYNC), RLS test coverage with cross-account isolation verification, migration CI/CD integration
|
||||
- **Tenant Isolation Phase 3** — RLS on audit_logs and tree_shares tables, cross-tenant session access for public shares (via get_admin_db), complete account_id propagation across PSA integration write paths, final RLS policy enforcement
|
||||
- **Tenant Isolation Phase 4** (#136) — RLS enforcement on all 31 remaining tables (users, trees, teams, integrations, scripts, categories, templates, surveys, etc.), BYPASSRLS session pattern for auth deps and background jobs, admin session factory for startup routines (service accounts, seed data), global table exclusions (platform_steps, template_trees, script_categories, accounts), RLS tests with complete cross-tenant isolation verification, proper tree_shares ownership checks using tree owner's account_id
|
||||
- **Script Library default view** — "All Scripts" tab now displays all accessible scripts (team + library)
|
||||
- **Session documentation overhaul** — reformatted PSA resolution/escalation notes with cleaner headers, inline engineer responses, decimal hour display (0.25 hrs), follow-up recommendations, and improved "What We Know" section from evidence items
|
||||
- **Client communication improvements** — new `request_info` audience type for client-facing information requests, improved status update and email draft prompts with per-context guidance
|
||||
@@ -34,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.
|
||||
- **PSA retry ownership check** — retry-psa-push had no ownership validation (CRITICAL). Now validates user ownership before allowing retry.
|
||||
- **Task Lane save operation** — invalid task_lane_item UUIDs returned 403 revealing existence. Now returns 404 and uses query-level filtering.
|
||||
- **Phase 4 RLS enforcement** — fixed auth deps, user-mutation endpoints, background jobs, and lifespan routines to use BYPASSRLS sessions for reading/writing tenant-isolated tables; fixed seed scripts to use ADMIN_DATABASE_URL; bootstrap service account now initializes correctly with proper BYPASSRLS context
|
||||
- Dark text rendering on blue accent step-number badges across all flow types
|
||||
- Script Library tab ownership filter now preserved across category and search changes
|
||||
- Race conditions in script builder session creation and slug generation
|
||||
|
||||
110
CLAUDE.md
110
CLAUDE.md
@@ -222,9 +222,10 @@ docker exec -it resolutionflow_postgres psql -U postgres -d resolutionflow
|
||||
cd backend && pip install httpx && python -m scripts.seed_trees
|
||||
|
||||
# CI/CD debugging
|
||||
# CI runs on Gitea (gitea.resolutionflow.com), NOT GitHub Actions — gh run list will return nothing useful
|
||||
# Check CI status at: https://gitea.resolutionflow.com/chihlasm/resolutionflow/actions
|
||||
# `gh` CLI is still used for GitHub Issues/PRs (mirrored repo), not for CI runs
|
||||
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
|
||||
@@ -380,10 +381,6 @@ cd backend && pip install httpx && python -m scripts.seed_trees
|
||||
|
||||
**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.
|
||||
|
||||
**110. Backfill migrations for `account_id` require a service-code audit:** When a migration adds `account_id` to an existing model via backfill (nullable → backfill → NOT NULL), grep for ALL `ModelClass(` instantiation sites in service code and verify `account_id=` is passed. SQLAlchemy accepts `None` silently with no warning; Phase 4 RLS WITH CHECK only surfaces the problem at runtime as `InsufficientPrivilegeError: new row violates row-level security policy`. Fixed example: `AISessionStep` — all 5 creation sites in `flowpilot_engine.py` were missing `account_id` until April 2026.
|
||||
|
||||
**111. Global Axios interceptor fires before component `.catch()` — fix optional-data endpoints at the source:** The global 5xx handler in `client.ts` fires for ALL non-401 5xx responses, even when a component does `.catch(() => {})`. If an endpoint returns optional UI data (e.g., board filters, PSA config), return `[]` / `{}` on provider failure rather than raising 502. Silencing the error in the component is not enough — the toast appears anyway. See `list_boards` in `integrations.py` for the fixed pattern.
|
||||
|
||||
## RBAC & Permissions
|
||||
|
||||
- **Role hierarchy:** super_admin > team_admin > engineer > viewer
|
||||
@@ -453,7 +450,6 @@ cd backend && pip install httpx && python -m scripts.seed_trees
|
||||
- Always include `Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>`
|
||||
- Always create feature branch BEFORE committing: `git checkout -b feat/feature-name`
|
||||
- Large features: commit per phase with `npm run build` validation
|
||||
- **Remote is Gitea, not GitHub directly:** Push to `gitea.resolutionflow.com/chihlasm/resolutionflow`. Gitea auto-mirrors to GitHub via `.gitea/workflows/mirror-to-github.yml` — never push directly to GitHub.
|
||||
|
||||
### After Completing Work
|
||||
|
||||
@@ -501,7 +497,7 @@ When a feature, fix, or significant piece of work is finished and merged/committ
|
||||
## Deployment (Railway)
|
||||
|
||||
- **Production:** `resolutionflow.com` (frontend), `api.resolutionflow.com` (backend)
|
||||
- Auto-deploys via: push to Gitea → Gitea mirrors to GitHub → Railway watches GitHub `main` and deploys
|
||||
- Auto-deploys on push to `main`
|
||||
- PR environments auto-created (need manual domain generation in Railway dashboard)
|
||||
- PR envs need `VITE_API_URL` set with `https://` prefix on frontend service
|
||||
- `ALLOW_RAILWAY_ORIGINS=true` enables CORS for `*.up.railway.app`
|
||||
@@ -529,42 +525,104 @@ When a feature, fix, or significant piece of work is finished and merged/committ
|
||||
| Design System | [DESIGN-SYSTEM.md](DESIGN-SYSTEM.md) |
|
||||
| Dev Environment | [DEV-ENV.md](DEV-ENV.md) — 46.202.92.250 setup, Docker, CORS, networking |
|
||||
|
||||
|
||||
<!-- gitnexus:start -->
|
||||
# GitNexus — Code Intelligence
|
||||
|
||||
This project is indexed by GitNexus as **resolutionflow**. 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.
|
||||
|
||||
## When to Use It
|
||||
## Always Do
|
||||
|
||||
**Use GitNexus when:**
|
||||
- Touching a core shared symbol with many callers — `flowpilot_engine`, `unified_chat_service`, auth middleware, `get_db`, shared hooks
|
||||
- Renaming anything used across multiple files
|
||||
- Tracing an unfamiliar bug through a call chain you haven't read
|
||||
- Assessing whether a refactor is safe before starting
|
||||
- **MUST run impact analysis before editing any symbol.** Before modifying a function, class, or method, run `gitnexus_impact({target: "symbolName", direction: "upstream"})` and report the blast radius (direct callers, affected processes, risk level) to the user.
|
||||
- **MUST run `gitnexus_detect_changes()` before committing** to verify your changes only affect expected symbols and execution flows.
|
||||
- **MUST warn the user** if impact analysis returns HIGH or CRITICAL risk before proceeding with edits.
|
||||
- When exploring unfamiliar code, use `gitnexus_query({query: "concept"})` to find execution flows instead of grepping. It returns process-grouped results ranked by relevance.
|
||||
- When you need full context on a specific symbol — callers, callees, which execution flows it participates in — use `gitnexus_context({name: "symbolName"})`.
|
||||
|
||||
**Skip GitNexus when:**
|
||||
- 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
|
||||
## When Debugging
|
||||
|
||||
## 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 |
|
||||
|------|-------------|---------|
|
||||
| `query` | Find code by concept when you don't know where to look | `gitnexus_query({query: "auth validation"})` |
|
||||
| `context` | See all callers/callees of a symbol before touching it | `gitnexus_context({name: "symbolName"})` |
|
||||
| `impact` | Blast radius check before editing a shared symbol | `gitnexus_impact({target: "X", direction: "upstream"})` |
|
||||
| `query` | Find code by concept | `gitnexus_query({query: "auth validation"})` |
|
||||
| `context` | 360-degree view of one symbol | `gitnexus_context({name: "validateUser"})` |
|
||||
| `impact` | Blast radius before editing | `gitnexus_impact({target: "X", direction: "upstream"})` |
|
||||
| `detect_changes` | Pre-commit scope check | `gitnexus_detect_changes({scope: "staged"})` |
|
||||
| `rename` | Safe multi-file rename | `gitnexus_rename({symbol_name: "old", new_name: "new", dry_run: true})` |
|
||||
| `cypher` | Custom graph queries | `gitnexus_cypher({query: "MATCH ..."})` |
|
||||
|
||||
## Impact Risk Levels
|
||||
|
||||
| Depth | Meaning | Action |
|
||||
|-------|---------|--------|
|
||||
| d=1 | WILL BREAK — direct callers/importers | MUST update these |
|
||||
| d=2 | LIKELY AFFECTED — indirect deps | Should test |
|
||||
| d=3 | MAY NEED TESTING — transitive | Test if critical path |
|
||||
|
||||
## Resources
|
||||
|
||||
| Resource | Use for |
|
||||
|----------|---------|
|
||||
| `gitnexus://repo/resolutionflow/context` | Codebase overview, check index freshness |
|
||||
| `gitnexus://repo/resolutionflow/clusters` | All functional areas |
|
||||
| `gitnexus://repo/resolutionflow/processes` | All execution flows |
|
||||
| `gitnexus://repo/resolutionflow/process/{name}` | Step-by-step execution trace |
|
||||
|
||||
## Self-Check Before Finishing
|
||||
|
||||
Before completing any code modification task, verify:
|
||||
1. `gitnexus_impact` was run for all modified symbols
|
||||
2. No HIGH/CRITICAL risk warnings were ignored
|
||||
3. `gitnexus_detect_changes()` confirms changes match expected scope
|
||||
4. All d=1 (WILL BREAK) dependents were updated
|
||||
|
||||
## Keeping the Index Fresh
|
||||
|
||||
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
|
||||
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 -->
|
||||
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -431,19 +431,10 @@ async def create_account(
|
||||
current_user: Annotated[User, Depends(require_admin)],
|
||||
):
|
||||
"""Create a new account without requiring an initial user."""
|
||||
owner_id = None
|
||||
if data.owner_email:
|
||||
result = await db.execute(select(User).where(User.email == data.owner_email.strip()))
|
||||
owner = result.scalar_one_or_none()
|
||||
if not owner:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"No user found with email '{data.owner_email}'")
|
||||
owner_id = owner.id
|
||||
|
||||
display_code = await _generate_unique_display_code(db)
|
||||
new_account = Account(
|
||||
name=data.name.strip(),
|
||||
display_code=display_code,
|
||||
owner_id=owner_id,
|
||||
)
|
||||
db.add(new_account)
|
||||
await db.flush()
|
||||
@@ -457,7 +448,7 @@ async def create_account(
|
||||
|
||||
await log_audit(
|
||||
db, current_user.id, "account.create_admin", "account", new_account.id,
|
||||
{"name": new_account.name, "plan": data.plan, "owner_email": data.owner_email},
|
||||
{"name": new_account.name, "plan": data.plan},
|
||||
)
|
||||
await db.commit()
|
||||
return await _get_account_detail_payload(new_account.id, db)
|
||||
|
||||
@@ -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()
|
||||
@@ -27,7 +27,6 @@ from app.schemas.psa_connection import (
|
||||
PsaMemberMappingSaveRequest,
|
||||
PsaMemberResponse,
|
||||
AutoMatchResult,
|
||||
PSABoardResponse,
|
||||
)
|
||||
from app.core.config import settings
|
||||
from app.services.psa.encryption import (
|
||||
@@ -346,27 +345,6 @@ async def update_flowpilot_settings(
|
||||
# ── ticket / status / company endpoints ──────────────────────────
|
||||
|
||||
|
||||
@router.get("/boards", response_model=list[PSABoardResponse])
|
||||
async def list_boards(
|
||||
current_user: Annotated[User, Depends(require_engineer_or_admin)],
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
):
|
||||
"""List PSA service boards."""
|
||||
if not current_user.account_id:
|
||||
raise HTTPException(status_code=400, detail="User has no account")
|
||||
|
||||
from app.services.psa.registry import get_provider_for_account
|
||||
from app.services.psa.exceptions import PSAError
|
||||
|
||||
try:
|
||||
provider = await get_provider_for_account(current_user.account_id, db)
|
||||
boards = await provider.list_boards()
|
||||
return [PSABoardResponse(id=b.id, name=b.name) for b in boards]
|
||||
except PSAError:
|
||||
# Boards are optional UI chrome — degrade gracefully rather than surfacing a toast
|
||||
return []
|
||||
|
||||
|
||||
@router.get("/tickets/search", response_model=list[PSATicketSearchResult])
|
||||
async def search_tickets(
|
||||
current_user: Annotated[User, Depends(require_engineer_or_admin)],
|
||||
@@ -375,11 +353,6 @@ async def search_tickets(
|
||||
board_id: int | None = None,
|
||||
status_id: int | None = None,
|
||||
include_closed: bool = False,
|
||||
assigned_to_me: bool = False,
|
||||
unassigned: bool = False,
|
||||
board_ids: str = "",
|
||||
page: int = 1,
|
||||
page_size: int = 10,
|
||||
):
|
||||
"""Search ConnectWise tickets."""
|
||||
if not current_user.account_id:
|
||||
@@ -388,61 +361,10 @@ async def search_tickets(
|
||||
from app.services.psa.registry import get_provider_for_account
|
||||
from app.services.psa.exceptions import PSAError
|
||||
|
||||
# Resolve assigned_to_me → member_identifier (CW login name for resources contains filter)
|
||||
member_identifier: str | None = None
|
||||
if assigned_to_me:
|
||||
conn_result = await db.execute(
|
||||
select(PsaConnection).where(
|
||||
PsaConnection.account_id == current_user.account_id,
|
||||
PsaConnection.is_active.is_(True),
|
||||
)
|
||||
)
|
||||
conn = conn_result.scalar_one_or_none()
|
||||
if conn:
|
||||
mapping_result = await db.execute(
|
||||
select(PsaMemberMapping).where(
|
||||
PsaMemberMapping.psa_connection_id == conn.id,
|
||||
PsaMemberMapping.user_id == current_user.id,
|
||||
)
|
||||
)
|
||||
mapping = mapping_result.scalar_one_or_none()
|
||||
if not mapping:
|
||||
# No mapping for this user — return empty list
|
||||
return []
|
||||
|
||||
from app.services.psa.registry import get_provider_for_account as _get_provider
|
||||
from app.services.psa.exceptions import PSAError as _PSAError
|
||||
try:
|
||||
_provider = await _get_provider(current_user.account_id, db)
|
||||
cw_members = await _provider.list_members()
|
||||
matched = next((m for m in cw_members if m.id == mapping.external_member_id), None)
|
||||
if matched:
|
||||
member_identifier = matched.identifier
|
||||
else:
|
||||
return []
|
||||
except _PSAError:
|
||||
return []
|
||||
|
||||
# Parse comma-separated board_ids
|
||||
parsed_board_ids: list[int] = []
|
||||
if board_ids:
|
||||
try:
|
||||
parsed_board_ids = [int(bid.strip()) for bid in board_ids.split(",") if bid.strip()]
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail="board_ids must be comma-separated integers")
|
||||
|
||||
try:
|
||||
provider = await get_provider_for_account(current_user.account_id, db)
|
||||
tickets = await provider.search_tickets(
|
||||
query,
|
||||
board_id=board_id,
|
||||
status_id=status_id,
|
||||
include_closed=include_closed,
|
||||
member_identifier=member_identifier,
|
||||
unassigned=unassigned,
|
||||
board_ids=parsed_board_ids,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
query, board_id=board_id, status_id=status_id, include_closed=include_closed
|
||||
)
|
||||
return [
|
||||
PSATicketSearchResult(
|
||||
@@ -595,37 +517,31 @@ async def get_member_mappings(
|
||||
current_user: Annotated[User, Depends(require_account_owner)],
|
||||
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)
|
||||
if not conn:
|
||||
return []
|
||||
|
||||
# Fetch all active account users
|
||||
users_result = await db.execute(
|
||||
select(User).where(User.account_id == current_user.account_id, User.is_active.is_(True))
|
||||
)
|
||||
users = users_result.scalars().all()
|
||||
|
||||
# Fetch all existing mappings keyed by user_id for O(1) lookup
|
||||
mappings_result = await db.execute(
|
||||
result = await db.execute(
|
||||
select(PsaMemberMapping).where(PsaMemberMapping.psa_connection_id == conn.id)
|
||||
)
|
||||
mapping_by_user: dict[str, PsaMemberMapping] = {
|
||||
str(m.user_id): m for m in mappings_result.scalars().all()
|
||||
}
|
||||
mappings = result.scalars().all()
|
||||
|
||||
return [
|
||||
PsaMemberMappingResponse(
|
||||
id=str(m.id) if (m := mapping_by_user.get(str(user.id))) else None,
|
||||
user_id=str(user.id),
|
||||
user_email=user.email,
|
||||
user_name=user.name,
|
||||
external_member_id=m.external_member_id if m else None,
|
||||
external_member_name=m.external_member_name if m else None,
|
||||
matched_by=m.matched_by if m else None,
|
||||
)
|
||||
for user in users
|
||||
]
|
||||
response = []
|
||||
for m in mappings:
|
||||
user_result = await db.execute(select(User).where(User.id == m.user_id))
|
||||
user = user_result.scalar_one_or_none()
|
||||
if user:
|
||||
response.append(PsaMemberMappingResponse(
|
||||
id=str(m.id),
|
||||
user_id=str(m.user_id),
|
||||
user_email=user.email,
|
||||
user_name=user.name,
|
||||
external_member_id=m.external_member_id,
|
||||
external_member_name=m.external_member_name,
|
||||
matched_by=m.matched_by,
|
||||
))
|
||||
return response
|
||||
|
||||
|
||||
@router.post("/member-mappings", response_model=list[PsaMemberMappingResponse])
|
||||
@@ -648,7 +564,6 @@ async def save_member_mappings(
|
||||
for m in mappings:
|
||||
mapping = PsaMemberMapping(
|
||||
psa_connection_id=conn.id,
|
||||
account_id=current_user.account_id,
|
||||
user_id=UUID(m.user_id),
|
||||
external_member_id=m.external_member_id,
|
||||
external_member_name=m.external_member_name,
|
||||
@@ -709,7 +624,6 @@ async def auto_match_members(
|
||||
if not existing.scalar_one_or_none():
|
||||
mapping = PsaMemberMapping(
|
||||
psa_connection_id=conn.id,
|
||||
account_id=current_user.account_id,
|
||||
user_id=user.id,
|
||||
external_member_id=cw_member.id,
|
||||
external_member_name=cw_member.name,
|
||||
|
||||
@@ -1,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")
|
||||
@@ -24,7 +24,6 @@ from app.api.endpoints import (
|
||||
branding,
|
||||
categories,
|
||||
copilot,
|
||||
device_types,
|
||||
feedback,
|
||||
flow_proposals,
|
||||
flowpilot_analytics,
|
||||
@@ -33,7 +32,6 @@ from app.api.endpoints import (
|
||||
invite,
|
||||
kb_accelerator,
|
||||
maintenance_schedules,
|
||||
network_diagrams,
|
||||
notifications,
|
||||
onboarding,
|
||||
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_survey.router)
|
||||
api_router.include_router(admin_gallery.router)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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(branding.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
|
||||
api_router.include_router(session_handoffs.queue_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(session_branches.router, dependencies=_tenant_deps)
|
||||
api_router.include_router(session_handoffs.router, dependencies=_tenant_deps)
|
||||
api_router.include_router(device_types.router, dependencies=_tenant_deps)
|
||||
|
||||
@@ -128,7 +128,6 @@ class Settings(BaseSettings):
|
||||
"variable_inference": "fast",
|
||||
"kb_convert": "standard",
|
||||
"script_build": "standard",
|
||||
"network_diagram_generate": "standard",
|
||||
}
|
||||
|
||||
def get_model_for_action(self, action_type: str) -> str:
|
||||
|
||||
@@ -56,8 +56,6 @@ from .session_handoff import SessionHandoff
|
||||
from .session_resolution_output import SessionResolutionOutput
|
||||
from .template_tree import TemplateTree
|
||||
from .platform_step import PlatformStep
|
||||
from .device_type import DeviceType
|
||||
from .network_diagram import NetworkDiagram
|
||||
|
||||
__all__ = [
|
||||
"User",
|
||||
@@ -128,6 +126,4 @@ __all__ = [
|
||||
"SessionResolutionOutput",
|
||||
"TemplateTree",
|
||||
"PlatformStep",
|
||||
"DeviceType",
|
||||
"NetworkDiagram",
|
||||
]
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
@@ -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])
|
||||
@@ -20,7 +20,6 @@ from .psa_connection import (
|
||||
PSATicketSearchResult, PSATicketStatusItem,
|
||||
PsaPostRequest, PsaPostResponse, PsaPreviewResponse, PsaPostLogResponse,
|
||||
PsaMemberMappingResponse, PsaMemberMappingSaveRequest, PsaMemberResponse, AutoMatchResult,
|
||||
PSABoardResponse,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
@@ -51,5 +50,4 @@ __all__ = [
|
||||
"PSATicketSearchResult", "PSATicketStatusItem",
|
||||
"PsaPostRequest", "PsaPostResponse", "PsaPreviewResponse", "PsaPostLogResponse",
|
||||
"PsaMemberMappingResponse", "PsaMemberMappingSaveRequest", "PsaMemberResponse", "AutoMatchResult",
|
||||
"PSABoardResponse",
|
||||
]
|
||||
|
||||
@@ -126,7 +126,6 @@ class AdminAccountDetailResponse(AdminAccountListItem):
|
||||
class AdminAccountCreate(BaseModel):
|
||||
name: str = Field(..., min_length=1, max_length=255)
|
||||
plan: Literal["free", "pro", "team"] = "free"
|
||||
owner_email: Optional[EmailStr] = Field(None, description="Email of an existing user to set as owner")
|
||||
|
||||
|
||||
class AdminAccountUpdate(BaseModel):
|
||||
@@ -320,7 +319,7 @@ class AdminUserCreate(BaseModel):
|
||||
name: str = Field(..., min_length=1, max_length=255)
|
||||
account_mode: Literal["existing", "personal"]
|
||||
account_display_code: Optional[str] = Field(None, description="Required when account_mode='existing'")
|
||||
account_role: Optional[Literal["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
|
||||
|
||||
|
||||
|
||||
@@ -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}
|
||||
@@ -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
|
||||
@@ -111,13 +111,13 @@ class PsaPostLogResponse(BaseModel):
|
||||
|
||||
|
||||
class PsaMemberMappingResponse(BaseModel):
|
||||
id: str | None = None # None for users without a mapping
|
||||
id: str
|
||||
user_id: str
|
||||
user_email: str
|
||||
user_name: str
|
||||
external_member_id: str | None = None
|
||||
external_member_name: str | None = None
|
||||
matched_by: str | None = None
|
||||
external_member_id: str
|
||||
external_member_name: str
|
||||
matched_by: str
|
||||
|
||||
|
||||
class PsaMemberMappingSaveRequest(BaseModel):
|
||||
@@ -136,8 +136,3 @@ class PsaMemberResponse(BaseModel):
|
||||
class AutoMatchResult(BaseModel):
|
||||
matched: list[PsaMemberMappingResponse]
|
||||
unmatched_users: int
|
||||
|
||||
|
||||
class PSABoardResponse(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
|
||||
@@ -68,4 +68,4 @@ class RoleUpdate(BaseModel):
|
||||
|
||||
|
||||
class AccountRoleUpdate(BaseModel):
|
||||
account_role: str = Field(..., pattern="^(owner|admin|engineer|viewer)$")
|
||||
account_role: str = Field(..., pattern="^(engineer|viewer)$")
|
||||
|
||||
@@ -330,7 +330,6 @@ async def start_session(
|
||||
# 7. Create first step
|
||||
step = _create_step_from_parsed(
|
||||
session_id=session.id,
|
||||
account_id=session.account_id,
|
||||
step_order=0,
|
||||
parsed=parsed,
|
||||
input_tokens=input_tokens,
|
||||
@@ -434,7 +433,6 @@ async def process_response(
|
||||
# Create new step
|
||||
step = _create_step_from_parsed(
|
||||
session_id=session.id,
|
||||
account_id=session.account_id,
|
||||
step_order=session.step_count - 1,
|
||||
parsed=parsed,
|
||||
input_tokens=input_tokens,
|
||||
@@ -696,7 +694,6 @@ async def pickup_session(
|
||||
briefing_step = AISessionStep(
|
||||
id=uuid.uuid4(),
|
||||
session_id=session.id,
|
||||
account_id=session.account_id,
|
||||
branch_id=session.active_branch_id if session.is_branching else None,
|
||||
step_order=session.step_count,
|
||||
step_type="action",
|
||||
@@ -768,7 +765,6 @@ async def pickup_session(
|
||||
|
||||
next_step = _create_step_from_parsed(
|
||||
session_id=session.id,
|
||||
account_id=session.account_id,
|
||||
step_order=session.step_count - 1,
|
||||
parsed=parsed,
|
||||
input_tokens=input_tokens,
|
||||
@@ -1001,7 +997,6 @@ async def generate_status_update(
|
||||
step = AISessionStep(
|
||||
id=uuid.uuid4(),
|
||||
session_id=session.id,
|
||||
account_id=session.account_id,
|
||||
branch_id=session.active_branch_id if session.is_branching else None,
|
||||
step_order=session.step_count,
|
||||
step_type="status_update",
|
||||
@@ -1445,7 +1440,6 @@ def _format_engineer_response(request: StepResponseRequest) -> str:
|
||||
|
||||
def _create_step_from_parsed(
|
||||
session_id: UUID,
|
||||
account_id: UUID,
|
||||
step_order: int,
|
||||
parsed: dict[str, Any],
|
||||
input_tokens: int,
|
||||
@@ -1493,7 +1487,6 @@ def _create_step_from_parsed(
|
||||
return AISessionStep(
|
||||
id=uuid.uuid4(),
|
||||
session_id=session_id,
|
||||
account_id=account_id,
|
||||
branch_id=branch_id,
|
||||
step_order=step_order,
|
||||
step_type=step_type if parsed["type"] != "resolution_suggestion" else "action",
|
||||
|
||||
@@ -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"),
|
||||
)
|
||||
@@ -11,7 +11,6 @@ from app.services.psa.types import (
|
||||
PSAMember,
|
||||
PSAConfiguration,
|
||||
PSATimeEntry,
|
||||
PSABoard,
|
||||
)
|
||||
|
||||
|
||||
@@ -59,9 +58,6 @@ class AutotaskProvider(PSAProvider):
|
||||
async def list_members(self) -> list[PSAMember]:
|
||||
raise NotImplementedError("Autotask integration coming soon")
|
||||
|
||||
async def list_boards(self) -> list[PSABoard]:
|
||||
raise NotImplementedError("list_boards not implemented for this provider")
|
||||
|
||||
async def get_ticket_configurations(self, ticket_id: str) -> list[PSAConfiguration]:
|
||||
raise NotImplementedError("Autotask integration coming soon")
|
||||
|
||||
|
||||
@@ -12,7 +12,6 @@ from .types import (
|
||||
PSAMember,
|
||||
PSAConfiguration,
|
||||
PSATimeEntry,
|
||||
PSABoard,
|
||||
)
|
||||
|
||||
|
||||
@@ -65,10 +64,6 @@ class PSAProvider(ABC):
|
||||
async def list_members(self) -> list[PSAMember]:
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
async def list_boards(self) -> list[PSABoard]:
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
async def get_ticket_configurations(self, ticket_id: str) -> list[PSAConfiguration]:
|
||||
...
|
||||
|
||||
@@ -16,7 +16,6 @@ from app.services.psa.types import (
|
||||
PSAMember,
|
||||
PSAConfiguration,
|
||||
PSATimeEntry,
|
||||
PSABoard,
|
||||
)
|
||||
from .client import ConnectWiseClient
|
||||
|
||||
@@ -56,16 +55,11 @@ class ConnectWiseProvider(PSAProvider):
|
||||
return self._map_ticket(data)
|
||||
|
||||
async def search_tickets(self, query: str, **filters) -> list[PSATicket]:
|
||||
"""Search CW tickets by summary. Supports board_id, status_id, member_id,
|
||||
unassigned, board_ids, page, and page_size filters."""
|
||||
page_size = filters.get("page_size", 10)
|
||||
page = filters.get("page", 1)
|
||||
|
||||
"""Search CW tickets by summary. Supports board_id and status_id filters."""
|
||||
params: dict = {
|
||||
"fields": "id,summary,company,board,status,priority,closedFlag",
|
||||
"orderBy": "id desc",
|
||||
"pageSize": page_size,
|
||||
"page": page,
|
||||
"pageSize": 25,
|
||||
}
|
||||
|
||||
# Build CW condition query
|
||||
@@ -78,14 +72,6 @@ class ConnectWiseProvider(PSAProvider):
|
||||
conditions.append(f"status/id = {filters['status_id']}")
|
||||
if not filters.get("include_closed", False):
|
||||
conditions.append("closedFlag = false")
|
||||
if filters.get("member_identifier") is not None:
|
||||
conditions.append(f"resources contains '{filters['member_identifier']}'")
|
||||
if filters.get("unassigned", False):
|
||||
conditions.append("resources = null")
|
||||
board_ids: list[int] = filters.get("board_ids") or []
|
||||
if board_ids:
|
||||
board_list = ", ".join(str(bid) for bid in board_ids)
|
||||
conditions.append(f"board/id in ({board_list})")
|
||||
|
||||
if conditions:
|
||||
params["conditions"] = " and ".join(conditions)
|
||||
@@ -284,32 +270,6 @@ class ConnectWiseProvider(PSAProvider):
|
||||
psa_cache.set(cache_key, result, ttl_seconds=900)
|
||||
return result
|
||||
|
||||
async def list_boards(self) -> list[PSABoard]:
|
||||
"""List active CW service boards (cached 1 hour)."""
|
||||
cache_key = "boards"
|
||||
cached = psa_cache.get(cache_key)
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
data = await self.client.get(
|
||||
"/service/boards",
|
||||
params={
|
||||
"fields": "id,name,inactiveFlag",
|
||||
"conditions": "inactiveFlag = false",
|
||||
"pageSize": 100,
|
||||
},
|
||||
)
|
||||
result = [
|
||||
PSABoard(
|
||||
id=b["id"],
|
||||
name=b["name"],
|
||||
inactive=b.get("inactiveFlag", False),
|
||||
)
|
||||
for b in (data if isinstance(data, list) else [])
|
||||
]
|
||||
psa_cache.set(cache_key, result, ttl_seconds=3600)
|
||||
return result
|
||||
|
||||
# ── Ticket Context ────────────────────────────────────────────────
|
||||
|
||||
async def get_ticket_context(
|
||||
@@ -576,7 +536,7 @@ class ConnectWiseProvider(PSAProvider):
|
||||
if work_type:
|
||||
payload["workType"] = {"name": work_type}
|
||||
|
||||
data = await self.client.post("/time/entries", payload)
|
||||
data = await self._client.post("/time/entries", payload)
|
||||
return PSATimeEntry(
|
||||
id=str(data["id"]),
|
||||
ticket_id=ticket_id,
|
||||
|
||||
@@ -11,7 +11,6 @@ from app.services.psa.types import (
|
||||
PSAMember,
|
||||
PSAConfiguration,
|
||||
PSATimeEntry,
|
||||
PSABoard,
|
||||
)
|
||||
|
||||
|
||||
@@ -59,9 +58,6 @@ class HaloPSAProvider(PSAProvider):
|
||||
async def list_members(self) -> list[PSAMember]:
|
||||
raise NotImplementedError("Halo PSA integration coming soon")
|
||||
|
||||
async def list_boards(self) -> list[PSABoard]:
|
||||
raise NotImplementedError("list_boards not implemented for this provider")
|
||||
|
||||
async def get_ticket_configurations(self, ticket_id: str) -> list[PSAConfiguration]:
|
||||
raise NotImplementedError("Halo PSA integration coming soon")
|
||||
|
||||
|
||||
@@ -67,12 +67,6 @@ class PSATimeEntry(BaseModel):
|
||||
created_at: str | None = None
|
||||
|
||||
|
||||
class PSABoard(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
inactive: bool = False
|
||||
|
||||
|
||||
class NoteType:
|
||||
INTERNAL_ANALYSIS = "internal_analysis"
|
||||
RESOLUTION = "resolution"
|
||||
|
||||
@@ -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
|
||||
@@ -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 |
|
||||
@@ -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
7
frontend/package-lock.json
generated
7
frontend/package-lock.json
generated
@@ -23,7 +23,6 @@
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"html-to-image": "^1.11.13",
|
||||
"immer": "^11.1.3",
|
||||
"lucide-react": "^0.563.0",
|
||||
"monaco-editor": "^0.55.1",
|
||||
@@ -5332,12 +5331,6 @@
|
||||
"dev": true,
|
||||
"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": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz",
|
||||
|
||||
@@ -36,7 +36,6 @@
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"html-to-image": "^1.11.13",
|
||||
"immer": "^11.1.3",
|
||||
"lucide-react": "^0.563.0",
|
||||
"monaco-editor": "^0.55.1",
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 686 KiB |
@@ -49,9 +49,9 @@ function handleGlobalError(error: AxiosError) {
|
||||
return
|
||||
}
|
||||
|
||||
// Server errors (5xx) — show backend detail when available, else generic message
|
||||
// Server errors (5xx)
|
||||
if (status >= 500) {
|
||||
toast.error(detail || 'Server error - please try again later')
|
||||
toast.error('Server error - please try again later')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}`)
|
||||
},
|
||||
}
|
||||
@@ -35,5 +35,3 @@ export { betaFeedbackApi } from './betaFeedback'
|
||||
export { branchesApi } from './branches'
|
||||
export { handoffsApi } from './handoffs'
|
||||
export { resolutionsApi } from './resolutions'
|
||||
export { deviceTypesApi } from './deviceTypes'
|
||||
export { networkDiagramsApi } from './networkDiagrams'
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { apiClient } from './client'
|
||||
import type { PsaConnectionResponse, PsaConnectionCreate, PsaConnectionUpdate, PsaConnectionTestResponse } from '@/types'
|
||||
import type { PSABoard, TicketLinkResponse, PSATicketSearchResult, 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'
|
||||
|
||||
export const integrationsApi = {
|
||||
getConnection: () =>
|
||||
@@ -13,18 +13,8 @@ export const integrationsApi = {
|
||||
apiClient.delete(`/integrations/psa/connections/${id}`),
|
||||
testConnection: (id: string) =>
|
||||
apiClient.post<PsaConnectionTestResponse>(`/integrations/psa/connections/${id}/test`).then(r => r.data),
|
||||
listBoards: () =>
|
||||
apiClient.get<PSABoard[]>('/integrations/psa/boards').then(r => r.data),
|
||||
searchTickets: (params: { query?: string; board_id?: number; include_closed?: boolean }) =>
|
||||
apiClient.get<PSATicketSearchResult[]>('/integrations/psa/tickets/search', { params }).then(r => r.data),
|
||||
searchTicketsQueue: (params: {
|
||||
assigned_to_me?: boolean
|
||||
unassigned?: boolean
|
||||
board_ids?: string
|
||||
page?: number
|
||||
page_size?: number
|
||||
}) =>
|
||||
apiClient.get<PSATicketSearchResult[]>('/integrations/psa/tickets/search', { params }).then(r => r.data),
|
||||
getTicket: (id: string) =>
|
||||
apiClient.get<PSATicketInfo>(`/integrations/psa/tickets/${id}`).then(r => r.data),
|
||||
getTicketStatuses: (ticketId: string) =>
|
||||
|
||||
@@ -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
|
||||
},
|
||||
}
|
||||
@@ -1,397 +0,0 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Ticket, ChevronDown, Check, Loader2, AlertCircle } from 'lucide-react'
|
||||
import { integrationsApi } from '@/api/integrations'
|
||||
import type { PSABoard, PSATicketSearchResult } from '@/types/integrations'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const PAGE_SIZE = 10
|
||||
|
||||
type Tab = 'mine' | 'unassigned'
|
||||
|
||||
function SkeletonRows() {
|
||||
return (
|
||||
<div className="space-y-0">
|
||||
{[0, 1, 2].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex items-center gap-4 px-5 py-3.5"
|
||||
style={{ borderBottom: '1px solid var(--color-border-default)' }}
|
||||
>
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="h-3 w-1/3 rounded bg-[rgba(255,255,255,0.06)] animate-pulse" />
|
||||
<div className="h-3 w-2/3 rounded bg-[rgba(255,255,255,0.04)] animate-pulse" />
|
||||
<div className="h-2.5 w-1/4 rounded bg-[rgba(255,255,255,0.03)] animate-pulse" />
|
||||
</div>
|
||||
<div className="h-6 w-16 rounded bg-[rgba(255,255,255,0.04)] animate-pulse shrink-0" />
|
||||
<div className="h-7 w-24 rounded bg-[rgba(255,255,255,0.04)] animate-pulse shrink-0" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface BoardSelectorProps {
|
||||
boards: PSABoard[]
|
||||
selectedIds: number[]
|
||||
onChange: (ids: number[]) => void
|
||||
}
|
||||
|
||||
function BoardSelector({ boards, selectedIds, onChange }: BoardSelectorProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
function handleClickOutside(e: MouseEvent) {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) {
|
||||
setOpen(false)
|
||||
}
|
||||
}
|
||||
if (open) {
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
}
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||
}, [open])
|
||||
|
||||
const allSelected = selectedIds.length === 0
|
||||
const label = allSelected
|
||||
? 'All Boards'
|
||||
: selectedIds.length === 1
|
||||
? (boards.find((b) => b.id === selectedIds[0])?.name ?? '1 board')
|
||||
: `${selectedIds.length} boards`
|
||||
|
||||
const handleAllBoards = () => {
|
||||
onChange([])
|
||||
}
|
||||
|
||||
const handleToggleBoard = (id: number) => {
|
||||
if (selectedIds.includes(id)) {
|
||||
const next = selectedIds.filter((x) => x !== id)
|
||||
onChange(next)
|
||||
} else {
|
||||
onChange([...selectedIds, id])
|
||||
}
|
||||
}
|
||||
|
||||
if (boards.length === 0) return null
|
||||
|
||||
return (
|
||||
<div ref={ref} className="relative">
|
||||
<button
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className="flex items-center gap-1.5 rounded-lg border border-[rgba(255,255,255,0.08)] bg-[rgba(255,255,255,0.04)] px-3 py-1.5 text-xs text-muted-foreground hover:text-foreground hover:border-[rgba(255,255,255,0.14)] transition-colors"
|
||||
>
|
||||
{label}
|
||||
<ChevronDown size={12} className={cn('transition-transform', open && 'rotate-180')} />
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="absolute right-0 top-full mt-1 z-50 w-52 rounded-lg border border-[rgba(255,255,255,0.1)] bg-card shadow-lg py-1">
|
||||
{/* All Boards */}
|
||||
<button
|
||||
onClick={handleAllBoards}
|
||||
className="flex w-full items-center gap-2.5 px-3 py-2 text-xs text-foreground hover:bg-[rgba(255,255,255,0.06)] transition-colors"
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
'flex h-3.5 w-3.5 shrink-0 items-center justify-center rounded border',
|
||||
allSelected
|
||||
? 'border-accent bg-accent'
|
||||
: 'border-[rgba(255,255,255,0.2)] bg-transparent',
|
||||
)}
|
||||
>
|
||||
{allSelected && <Check size={9} className="text-white" />}
|
||||
</span>
|
||||
All Boards
|
||||
</button>
|
||||
|
||||
{boards.length > 0 && (
|
||||
<div className="my-1" style={{ borderTop: '1px solid var(--color-border-default)' }} />
|
||||
)}
|
||||
|
||||
{boards.map((board) => {
|
||||
const checked = selectedIds.includes(board.id)
|
||||
return (
|
||||
<button
|
||||
key={board.id}
|
||||
onClick={() => handleToggleBoard(board.id)}
|
||||
className="flex w-full items-center gap-2.5 px-3 py-2 text-xs text-foreground hover:bg-[rgba(255,255,255,0.06)] transition-colors"
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
'flex h-3.5 w-3.5 shrink-0 items-center justify-center rounded border',
|
||||
checked
|
||||
? 'border-accent bg-accent'
|
||||
: 'border-[rgba(255,255,255,0.2)] bg-transparent',
|
||||
)}
|
||||
>
|
||||
{checked && <Check size={9} className="text-white" />}
|
||||
</span>
|
||||
<span className="truncate">{board.name}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface TicketRowProps {
|
||||
ticket: PSATicketSearchResult
|
||||
isLast: boolean
|
||||
onStartSession: (ticket: PSATicketSearchResult) => void
|
||||
}
|
||||
|
||||
function TicketRow({ ticket, isLast, onStartSession }: TicketRowProps) {
|
||||
return (
|
||||
<div
|
||||
className="flex items-center gap-3 px-5 py-3.5"
|
||||
style={{ borderBottom: isLast ? undefined : '1px solid var(--color-border-default)' }}
|
||||
>
|
||||
{/* Left: ticket info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-baseline gap-2 mb-0.5">
|
||||
<span className="font-mono text-xs font-semibold text-accent shrink-0">
|
||||
#{ticket.id}
|
||||
</span>
|
||||
<span className="text-sm text-foreground truncate">{ticket.summary}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 text-[0.6875rem] text-muted-foreground">
|
||||
{ticket.company_name && <span className="truncate">{ticket.company_name}</span>}
|
||||
{ticket.company_name && ticket.priority_name && (
|
||||
<span className="shrink-0">·</span>
|
||||
)}
|
||||
{ticket.priority_name && <span className="shrink-0">{ticket.priority_name}</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right: status badge + action */}
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{ticket.status_name && (
|
||||
<span className="hidden sm:inline-flex items-center rounded-md border border-[rgba(255,255,255,0.08)] bg-[rgba(255,255,255,0.04)] px-2 py-0.5 text-[0.625rem] text-muted-foreground">
|
||||
{ticket.status_name}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
onClick={() => onStartSession(ticket)}
|
||||
className="flex items-center gap-1.5 rounded-lg border border-accent/30 bg-accent-dim px-3 py-1.5 text-xs font-medium text-accent hover:bg-accent/20 hover:border-accent/50 transition-colors"
|
||||
>
|
||||
<Ticket size={11} />
|
||||
Start Session
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function TicketQueue() {
|
||||
const navigate = useNavigate()
|
||||
const [hasConnection, setHasConnection] = useState<boolean | null>(null)
|
||||
const [boards, setBoards] = useState<PSABoard[]>([])
|
||||
const [selectedBoardIds, setSelectedBoardIds] = useState<number[]>([])
|
||||
const [activeTab, setActiveTab] = useState<Tab>('mine')
|
||||
const [tickets, setTickets] = useState<PSATicketSearchResult[]>([])
|
||||
const [page, setPage] = useState(1)
|
||||
const [hasMore, setHasMore] = useState(false)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [loadingMore, setLoadingMore] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// Check connection on mount
|
||||
useEffect(() => {
|
||||
integrationsApi.getConnection()
|
||||
.then((conn) => {
|
||||
const active = !!(conn && conn.is_active)
|
||||
setHasConnection(active)
|
||||
})
|
||||
.catch(() => setHasConnection(false))
|
||||
}, [])
|
||||
|
||||
// Fetch boards once connection confirmed
|
||||
useEffect(() => {
|
||||
if (!hasConnection) return
|
||||
integrationsApi.listBoards()
|
||||
.then(setBoards)
|
||||
.catch(() => {}) // boards are optional — don't block UI
|
||||
}, [hasConnection])
|
||||
|
||||
const fetchTickets = useCallback(
|
||||
async (tab: Tab, boardIds: number[], pageNum: number, append: boolean) => {
|
||||
const params: Parameters<typeof integrationsApi.searchTicketsQueue>[0] = {
|
||||
page: pageNum,
|
||||
page_size: PAGE_SIZE,
|
||||
}
|
||||
if (tab === 'mine') {
|
||||
params.assigned_to_me = true
|
||||
} else {
|
||||
params.unassigned = true
|
||||
}
|
||||
if (boardIds.length > 0) {
|
||||
params.board_ids = boardIds.join(',')
|
||||
}
|
||||
|
||||
try {
|
||||
const results = await integrationsApi.searchTicketsQueue(params)
|
||||
if (append) {
|
||||
setTickets((prev) => [...prev, ...results])
|
||||
} else {
|
||||
setTickets(results)
|
||||
}
|
||||
setHasMore(results.length === PAGE_SIZE)
|
||||
setError(null)
|
||||
} catch {
|
||||
setError('Failed to load tickets. Check your PSA connection.')
|
||||
}
|
||||
},
|
||||
[],
|
||||
)
|
||||
|
||||
// Initial + reset fetch when tab or board selection changes
|
||||
useEffect(() => {
|
||||
if (!hasConnection) return
|
||||
setPage(1)
|
||||
setTickets([])
|
||||
setHasMore(false)
|
||||
setLoading(true)
|
||||
fetchTickets(activeTab, selectedBoardIds, 1, false).finally(() => setLoading(false))
|
||||
}, [activeTab, selectedBoardIds, hasConnection, fetchTickets])
|
||||
|
||||
const handleLoadMore = async () => {
|
||||
const nextPage = page + 1
|
||||
setPage(nextPage)
|
||||
setLoadingMore(true)
|
||||
await fetchTickets(activeTab, selectedBoardIds, nextPage, true)
|
||||
setLoadingMore(false)
|
||||
}
|
||||
|
||||
const handleStartSession = (ticket: PSATicketSearchResult) => {
|
||||
navigate('/pilot', {
|
||||
state: {
|
||||
psaTicketId: ticket.id,
|
||||
psaTicket: {
|
||||
id: ticket.id,
|
||||
summary: ticket.summary,
|
||||
company_name: ticket.company_name,
|
||||
board_name: ticket.board_name,
|
||||
status_name: ticket.status_name,
|
||||
priority_name: ticket.priority_name,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Don't render until we know connection status
|
||||
if (hasConnection === null) return null
|
||||
// No active connection → hide entirely
|
||||
if (!hasConnection) return null
|
||||
|
||||
return (
|
||||
<div className="card-flat overflow-hidden">
|
||||
{/* Header */}
|
||||
<div
|
||||
className="flex items-center justify-between px-5 py-3"
|
||||
style={{ borderBottom: '1px solid var(--color-border-default)' }}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Ticket size={14} className="text-accent" />
|
||||
<h3 className="font-heading text-sm font-bold text-foreground">Ticket Queue</h3>
|
||||
</div>
|
||||
<BoardSelector
|
||||
boards={boards}
|
||||
selectedIds={selectedBoardIds}
|
||||
onChange={(ids) => setSelectedBoardIds(ids)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div
|
||||
className="flex"
|
||||
style={{ borderBottom: '1px solid var(--color-border-default)' }}
|
||||
>
|
||||
{(['mine', 'unassigned'] as Tab[]).map((tab) => (
|
||||
<button
|
||||
key={tab}
|
||||
onClick={() => setActiveTab(tab)}
|
||||
className={cn(
|
||||
'px-5 py-2.5 text-xs font-medium transition-colors border-b-2 -mb-px',
|
||||
activeTab === tab
|
||||
? 'border-accent text-accent'
|
||||
: 'border-transparent text-muted-foreground hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
{tab === 'mine' ? 'My Tickets' : 'Unassigned'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div>
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div className="flex items-center gap-2 px-5 py-4 text-sm text-danger">
|
||||
<AlertCircle size={14} className="shrink-0" />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Loading skeleton */}
|
||||
{!error && loading && <SkeletonRows />}
|
||||
|
||||
{/* Ticket rows */}
|
||||
{!error && !loading && tickets.length > 0 && (
|
||||
<>
|
||||
{tickets.map((ticket, i) => (
|
||||
<TicketRow
|
||||
key={ticket.id}
|
||||
ticket={ticket}
|
||||
isLast={i === tickets.length - 1 && !hasMore}
|
||||
onStartSession={handleStartSession}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Empty states */}
|
||||
{!error && !loading && tickets.length === 0 && (
|
||||
<div className="px-5 py-8 text-center">
|
||||
<Ticket size={24} className="mx-auto mb-2 text-muted-foreground/40" />
|
||||
{activeTab === 'mine' ? (
|
||||
<>
|
||||
<p className="text-sm text-muted-foreground">No open tickets assigned to you</p>
|
||||
<p className="mt-1 text-[0.6875rem] text-muted-foreground/60">
|
||||
Make sure your member mapping is configured in Account → Integrations
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">No unassigned open tickets</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Load more */}
|
||||
{!error && !loading && hasMore && (
|
||||
<div
|
||||
className="px-5 py-3"
|
||||
style={{ borderTop: '1px solid var(--color-border-default)' }}
|
||||
>
|
||||
<button
|
||||
onClick={handleLoadMore}
|
||||
disabled={loadingMore}
|
||||
className="flex w-full items-center justify-center gap-2 rounded-lg border border-[rgba(255,255,255,0.08)] bg-transparent py-2 text-xs text-muted-foreground hover:text-foreground hover:border-[rgba(255,255,255,0.14)] disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{loadingMore ? (
|
||||
<>
|
||||
<Loader2 size={12} className="animate-spin" />
|
||||
Loading...
|
||||
</>
|
||||
) : (
|
||||
'Load more'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
LayoutGrid, Clock, AlertTriangle, GitBranch, Code2, Wand2,
|
||||
ListChecks, Download, BarChart3,
|
||||
Settings, Pin, PinOff,
|
||||
History, FileText, Network,
|
||||
History, FileText,
|
||||
} from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useUserPreferencesStore } from '@/store/userPreferencesStore'
|
||||
@@ -86,11 +86,10 @@ export function Sidebar() {
|
||||
{
|
||||
href: '/trees', icon: GitBranch, label: 'Flows', shortLabel: 'Flows',
|
||||
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: [
|
||||
{ href: '/trees', label: 'Flow Library', count: stats?.tree_counts.total || 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: '/review-queue', label: 'Review Queue' },
|
||||
],
|
||||
@@ -135,7 +134,6 @@ export function Sidebar() {
|
||||
{ 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: '/script-builder', icon: Wand2, label: 'Script Builder', shortLabel: 'Builder' },
|
||||
{ href: '/review-queue', icon: ListChecks, label: 'Review Queue', shortLabel: 'Review' },
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 },
|
||||
]
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
@@ -1,7 +0,0 @@
|
||||
import { ConnectionEdge } from './ConnectionEdge'
|
||||
import { AnimatedSvgEdge } from '../ui/animated-svg-edge'
|
||||
|
||||
export const edgeTypes = {
|
||||
connection: ConnectionEdge,
|
||||
animated: AnimatedSvgEdge,
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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']
|
||||
@@ -1,7 +0,0 @@
|
||||
import { DeviceNode } from './DeviceNode'
|
||||
import { GroupNode } from './GroupNode'
|
||||
|
||||
export const nodeTypes = {
|
||||
device: DeviceNode,
|
||||
group: GroupNode,
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -99,14 +99,9 @@ export function useFlowPilotSession(): UseFlowPilotSession {
|
||||
setAllSteps([firstStep])
|
||||
setCurrentStep(firstStep)
|
||||
} catch (e: unknown) {
|
||||
// Prefer the backend's detail message over the generic axios status string
|
||||
const detail = (e as any)?.response?.data?.detail
|
||||
const message = typeof detail === 'string' ? detail : (e instanceof Error ? e.message : 'Failed to start session')
|
||||
const message = e instanceof Error ? e.message : 'Failed to start session'
|
||||
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 {
|
||||
setIsLoading(false)
|
||||
}
|
||||
|
||||
@@ -445,42 +445,3 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
}
|
||||
|
||||
export function exportToDrawio(nodes: Node[], edges: Edge[]): string {
|
||||
const cells: string[] = [
|
||||
'<mxCell id="0"/>',
|
||||
'<mxCell id="1" parent="0"/>',
|
||||
]
|
||||
|
||||
for (const node of nodes) {
|
||||
const w = typeof node.style?.width === 'number' ? node.style.width : (node.measured?.width ?? 120)
|
||||
const h = typeof node.style?.height === 'number' ? node.style.height : (node.measured?.height ?? 120)
|
||||
const x = node.position.x
|
||||
const y = node.position.y
|
||||
const parentId = node.parentId ?? '1'
|
||||
|
||||
if (node.type === 'group') {
|
||||
const gd = node.data as GroupNodeData
|
||||
cells.push(
|
||||
`<mxCell id="${esc(node.id)}" value="${esc(gd.label ?? '')}" style="${GROUP_STYLE}" vertex="1" parent="${esc(parentId)}">` +
|
||||
`<mxGeometry x="${x}" y="${y}" width="${w}" height="${h}" as="geometry"/>` +
|
||||
`</mxCell>`,
|
||||
)
|
||||
} else {
|
||||
const dd = node.data as DeviceNodeData
|
||||
const slug = dd.deviceType ?? 'server'
|
||||
const shapeStyle = SLUG_TO_DRAWIO_STYLE[slug] ?? 'rounded=1;whiteSpace=wrap;html=1;'
|
||||
cells.push(
|
||||
`<mxCell id="${esc(node.id)}" value="${esc(dd.label ?? '')}" style="${shapeStyle}${BASE_NODE_STYLE}" vertex="1" parent="${esc(parentId)}">` +
|
||||
`<mxGeometry x="${x}" y="${y}" width="${w}" height="${h}" as="geometry"/>` +
|
||||
`</mxCell>`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
for (const edge of edges) {
|
||||
const label = typeof edge.label === 'string' ? edge.label : ''
|
||||
cells.push(
|
||||
`<mxCell id="${esc(edge.id)}" value="${esc(label)}" style="edgeStyle=orthogonalEdgeStyle;html=1;" edge="1" source="${esc(edge.source)}" target="${esc(edge.target)}" parent="1">` +
|
||||
`<mxGeometry relative="1" as="geometry"/>` +
|
||||
`</mxCell>`,
|
||||
)
|
||||
}
|
||||
|
||||
const xml =
|
||||
`<?xml version="1.0" encoding="UTF-8"?>\n` +
|
||||
`<mxGraphModel><root>\n` +
|
||||
cells.join('\n') +
|
||||
`\n</root></mxGraphModel>`
|
||||
|
||||
return xml
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -9,8 +9,6 @@ import { StatusUpdateModal } from '@/components/flowpilot/StatusUpdateModal'
|
||||
import { HandoffModal } from '@/components/session/HandoffModal'
|
||||
import { handoffsApi } from '@/api/handoffs'
|
||||
import { aiSessionsApi } from '@/api'
|
||||
import { integrationsApi } from '@/api/integrations'
|
||||
import type { PSATicketInfo } from '@/types/integrations'
|
||||
import { toast } from '@/lib/toast'
|
||||
|
||||
export default function FlowPilotSessionPage() {
|
||||
@@ -19,13 +17,10 @@ export default function FlowPilotSessionPage() {
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const prefill = (location.state as { prefill?: string } | null)?.prefill || ''
|
||||
const psaTicketId = (location.state as any)?.psaTicketId as string | undefined
|
||||
const psaTicket = (location.state as any)?.psaTicket as PSATicketInfo | undefined
|
||||
const isPickup = searchParams.get('pickup') === 'true'
|
||||
const fp = useFlowPilotSession()
|
||||
const branching = useBranching()
|
||||
const prefillHandledRef = useRef(false)
|
||||
const psaTicketHandledRef = useRef(false)
|
||||
const [showOverflow, setShowOverflow] = useState(false)
|
||||
const [showResolve, setShowResolve] = useState(false)
|
||||
const [showEscalate, setShowEscalate] = useState(false)
|
||||
@@ -49,30 +44,6 @@ export default function FlowPilotSessionPage() {
|
||||
fp.startSession({ intake_type: 'free_text', intake_content: { text: prefill } })
|
||||
}
|
||||
}, [prefill, sessionId, fp.session, fp.isLoading]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Auto-start when navigating from TicketQueue with a PSA ticket
|
||||
useEffect(() => {
|
||||
if (psaTicketId && psaTicket && !psaTicketHandledRef.current && !sessionId && !fp.session && !fp.isLoading) {
|
||||
psaTicketHandledRef.current = true
|
||||
integrationsApi.getConnection().then((conn) => {
|
||||
if (conn?.id) {
|
||||
fp.startSession({
|
||||
intake_type: 'psa_ticket',
|
||||
intake_content: {
|
||||
ticket_data: {
|
||||
summary: psaTicket.summary,
|
||||
company: psaTicket.company_name,
|
||||
priority: psaTicket.priority_name,
|
||||
},
|
||||
},
|
||||
psa_ticket_id: psaTicketId,
|
||||
psa_connection_id: conn.id,
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
}, [psaTicketId, psaTicket, sessionId, fp.session, fp.isLoading]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const [pickingUp, setPickingUp] = useState(false)
|
||||
|
||||
// Load existing session if ID in URL
|
||||
|
||||
@@ -1,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>
|
||||
)
|
||||
}
|
||||
@@ -1,523 +0,0 @@
|
||||
import { useState, useEffect, useCallback, useMemo, useRef } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Plus, Search, Network, MoreHorizontal, Upload, ChevronDown, FileJson, FileOutput, ExternalLink, Copy, Archive } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { networkDiagramsApi } from '@/api'
|
||||
import { toast } from '@/lib/toast'
|
||||
import { CATEGORY_COLORS } from '@/components/network/nodes/deviceRegistry'
|
||||
import type { NetworkDiagramListItem, DiagramImportData } from '@/types'
|
||||
|
||||
const OTHER_COLOR = '#4f5666'
|
||||
|
||||
function TopologyBar({ categoryCounts, nodeCount }: { categoryCounts: Record<string, number>; nodeCount: number }) {
|
||||
if (nodeCount === 0) return null
|
||||
const sorted = Object.entries(categoryCounts).sort(([, a], [, b]) => b - a)
|
||||
const tooltipLabel = sorted.map(([cat, count]) => `${count} ${cat}`).join(' · ')
|
||||
return (
|
||||
<div className="group/bar relative flex h-2 w-full overflow-hidden rounded-full" title={tooltipLabel}>
|
||||
{sorted.map(([cat, count]) => (
|
||||
<div
|
||||
key={cat}
|
||||
style={{
|
||||
width: `${(count / nodeCount) * 100}%`,
|
||||
backgroundColor: CATEGORY_COLORS[cat] ?? OTHER_COLOR,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function NetworkDiagramsPage() {
|
||||
const navigate = useNavigate()
|
||||
const [diagrams, setDiagrams] = useState<NetworkDiagramListItem[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [search, setSearch] = useState('')
|
||||
const [clientFilter, setClientFilter] = useState<string | null>(null)
|
||||
const [clientOptions, setClientOptions] = useState<string[]>([])
|
||||
const [clientDropdownOpen, setClientDropdownOpen] = useState(false)
|
||||
const [clientSearch, setClientSearch] = useState('')
|
||||
const [menuOpenId, setMenuOpenId] = useState<string | null>(null)
|
||||
const [confirmArchiveId, setConfirmArchiveId] = useState<string | null>(null)
|
||||
const [importMenuOpen, setImportMenuOpen] = useState(false)
|
||||
const clientDropdownRef = useRef<HTMLDivElement>(null)
|
||||
const importMenuRef = useRef<HTMLDivElement>(null)
|
||||
const drawioListImportRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!clientDropdownOpen) return
|
||||
const handleClick = (e: MouseEvent) => {
|
||||
if (clientDropdownRef.current && !clientDropdownRef.current.contains(e.target as Node)) {
|
||||
setClientDropdownOpen(false)
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handleClick)
|
||||
return () => document.removeEventListener('mousedown', handleClick)
|
||||
}, [clientDropdownOpen])
|
||||
|
||||
useEffect(() => {
|
||||
if (!importMenuOpen) return
|
||||
const handleClick = (e: MouseEvent) => {
|
||||
if (importMenuRef.current && !importMenuRef.current.contains(e.target as Node)) {
|
||||
setImportMenuOpen(false)
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handleClick)
|
||||
return () => document.removeEventListener('mousedown', handleClick)
|
||||
}, [importMenuOpen])
|
||||
|
||||
const loadDiagrams = useCallback(async () => {
|
||||
try {
|
||||
const params: Record<string, string> = {}
|
||||
if (clientFilter) params.client_name = clientFilter
|
||||
if (search) params.search = search
|
||||
const data = await networkDiagramsApi.list(params)
|
||||
setDiagrams(data)
|
||||
} catch {
|
||||
toast.error('Failed to load diagrams')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [clientFilter, search])
|
||||
|
||||
const loadClients = useCallback(async () => {
|
||||
try {
|
||||
const clients = await networkDiagramsApi.listClients()
|
||||
setClientOptions(clients)
|
||||
} catch { /* ignore */ }
|
||||
}, [])
|
||||
|
||||
useEffect(() => { loadDiagrams() }, [loadDiagrams])
|
||||
useEffect(() => { loadClients() }, [loadClients])
|
||||
|
||||
const filteredClients = useMemo(() => {
|
||||
if (!clientSearch) return clientOptions
|
||||
const lower = clientSearch.toLowerCase()
|
||||
return clientOptions.filter(c => c.toLowerCase().includes(lower))
|
||||
}, [clientOptions, clientSearch])
|
||||
|
||||
const handleDuplicate = useCallback(async (id: string) => {
|
||||
try {
|
||||
const dup = await networkDiagramsApi.duplicate(id)
|
||||
toast.success(`Created: ${dup.name}`)
|
||||
loadDiagrams()
|
||||
} catch {
|
||||
toast.error('Failed to duplicate')
|
||||
}
|
||||
setMenuOpenId(null)
|
||||
}, [loadDiagrams])
|
||||
|
||||
const handleArchive = useCallback(async (id: string) => {
|
||||
try {
|
||||
await networkDiagramsApi.archive(id)
|
||||
toast.success('Diagram archived')
|
||||
loadDiagrams()
|
||||
} catch {
|
||||
toast.error('Failed to archive')
|
||||
}
|
||||
setMenuOpenId(null)
|
||||
setConfirmArchiveId(null)
|
||||
}, [loadDiagrams])
|
||||
|
||||
const handleImport = useCallback(async () => {
|
||||
const input = document.createElement('input')
|
||||
input.type = 'file'
|
||||
input.accept = '.json'
|
||||
input.onchange = async (e) => {
|
||||
const file = (e.target as HTMLInputElement).files?.[0]
|
||||
if (!file) return
|
||||
try {
|
||||
const text = await file.text()
|
||||
const data = JSON.parse(text) as DiagramImportData
|
||||
const result = await networkDiagramsApi.importJson(data)
|
||||
if (result.warnings.length > 0) {
|
||||
toast.warning(`Imported with warnings: ${result.warnings.join(', ')}`)
|
||||
} else {
|
||||
toast.success('Diagram imported')
|
||||
}
|
||||
navigate(`/network-diagrams/${result.diagram.id}`)
|
||||
} catch {
|
||||
toast.error('Failed to import — check that the file is a valid diagram JSON')
|
||||
}
|
||||
}
|
||||
input.click()
|
||||
}, [navigate])
|
||||
|
||||
const handleListDrawioImport = useCallback(async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
e.target.value = ''
|
||||
try {
|
||||
const { parseDrawioXml } = await import('@/lib/drawio-import')
|
||||
const text = await file.text()
|
||||
const { nodes: importedNodes, edges: importedEdges, warnings } = parseDrawioXml(text)
|
||||
const result = await networkDiagramsApi.importJson({
|
||||
schemaVersion: 1,
|
||||
name: file.name.replace(/\.drawio$/i, '') || 'Imported Diagram',
|
||||
client_name: null,
|
||||
description: null,
|
||||
nodes: importedNodes,
|
||||
edges: importedEdges,
|
||||
})
|
||||
const allWarnings = [...warnings, ...result.warnings]
|
||||
if (allWarnings.length > 0) {
|
||||
toast.warning(`Imported with ${allWarnings.length} warning(s)`)
|
||||
} else {
|
||||
toast.success('Imported successfully')
|
||||
}
|
||||
navigate(`/network-diagrams/${result.diagram.id}`)
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'Unknown error'
|
||||
toast.error(`Import failed: ${msg}`)
|
||||
}
|
||||
}, [navigate])
|
||||
|
||||
const formatDate = (dateStr: string) => {
|
||||
return new Date(dateStr).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-6xl px-6 py-8">
|
||||
<div className="mb-6 flex items-start justify-between">
|
||||
<div>
|
||||
<h1 className="font-heading text-2xl font-bold text-heading">Network Maps</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">Visual network topology documentation for your clients</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{/* Single "Import" dropdown replacing two separate buttons */}
|
||||
<div className="relative" ref={importMenuRef}>
|
||||
<button
|
||||
onClick={() => setImportMenuOpen(prev => !prev)}
|
||||
className="flex items-center gap-1.5 rounded border border-default px-3 py-2 text-sm text-primary hover:border-hover"
|
||||
>
|
||||
<Upload size={14} />
|
||||
Import
|
||||
<ChevronDown size={12} className="text-muted-foreground" />
|
||||
</button>
|
||||
{importMenuOpen && (
|
||||
<div className="absolute right-0 top-full z-50 mt-1 w-44 rounded border border-default bg-card py-1 shadow-lg">
|
||||
<button
|
||||
onClick={() => { setImportMenuOpen(false); handleImport() }}
|
||||
className="flex w-full items-center gap-2 px-3 py-2 text-xs text-primary hover:bg-elevated"
|
||||
>
|
||||
<FileJson size={13} />
|
||||
<div className="text-left">
|
||||
<div>Import JSON</div>
|
||||
<div className="text-[10px] text-muted-foreground">ResolutionFlow format</div>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setImportMenuOpen(false); drawioListImportRef.current?.click() }}
|
||||
className="flex w-full items-center gap-2 px-3 py-2 text-xs text-primary hover:bg-elevated"
|
||||
>
|
||||
<FileOutput size={13} />
|
||||
<div className="text-left">
|
||||
<div>Import draw.io</div>
|
||||
<div className="text-[10px] text-muted-foreground">.drawio or .xml file</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<input
|
||||
ref={drawioListImportRef}
|
||||
type="file"
|
||||
accept=".drawio,.xml"
|
||||
className="hidden"
|
||||
onChange={handleListDrawioImport}
|
||||
/>
|
||||
<button
|
||||
onClick={() => navigate('/network-diagrams/new')}
|
||||
className="flex items-center gap-1.5 rounded bg-accent px-4 py-2 text-sm font-medium text-white hover:bg-accent/90"
|
||||
>
|
||||
<Plus size={14} />
|
||||
New Diagram
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-6 flex gap-3">
|
||||
<div className="relative flex-1">
|
||||
<Search size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search diagrams..."
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
className="w-full rounded border border-default bg-input pl-9 pr-3 py-2 text-sm text-primary placeholder:text-muted-foreground focus:border-accent focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div className="relative w-48" ref={clientDropdownRef}>
|
||||
<button
|
||||
onClick={() => setClientDropdownOpen(prev => !prev)}
|
||||
className="flex w-full items-center justify-between rounded border border-default bg-input px-3 py-2 text-sm text-primary"
|
||||
>
|
||||
<span className={clientFilter ? 'text-primary' : 'text-muted-foreground'}>
|
||||
{clientFilter || 'All clients'}
|
||||
</span>
|
||||
<ChevronDown size={14} className="shrink-0 text-muted-foreground" />
|
||||
</button>
|
||||
{clientDropdownOpen && (
|
||||
<div className="absolute left-0 top-full z-50 mt-1 w-full rounded border border-default bg-card shadow-lg">
|
||||
<div className="p-2">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search clients..."
|
||||
value={clientSearch}
|
||||
onChange={e => setClientSearch(e.target.value)}
|
||||
className="w-full rounded border border-default bg-input px-2 py-1 text-xs text-primary placeholder:text-muted-foreground focus:border-accent focus:outline-none"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div className="max-h-48 overflow-y-auto">
|
||||
<button
|
||||
onClick={() => { setClientFilter(null); setClientDropdownOpen(false); setClientSearch('') }}
|
||||
className={cn('w-full px-3 py-1.5 text-left text-xs hover:bg-elevated', !clientFilter && 'text-accent')}
|
||||
>
|
||||
All clients
|
||||
</button>
|
||||
{filteredClients.map(c => (
|
||||
<button
|
||||
key={c}
|
||||
onClick={() => { setClientFilter(c); setClientDropdownOpen(false); setClientSearch('') }}
|
||||
className={cn('w-full px-3 py-1.5 text-left text-xs text-primary hover:bg-elevated', clientFilter === c && 'text-accent')}
|
||||
>
|
||||
{c}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading && (
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{[1, 2, 3].map(i => (
|
||||
<div key={i} className="h-40 animate-pulse rounded-lg border border-default bg-card" />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && diagrams.length === 0 && (
|
||||
<div className="rounded-lg border border-default bg-card overflow-hidden">
|
||||
<div className="grid md:grid-cols-[1fr_380px]">
|
||||
{/* Left: mini topology preview */}
|
||||
<div className="relative flex items-center justify-center bg-[#0e1016] p-8 md:p-12 min-h-[280px]">
|
||||
{/* Dot grid background */}
|
||||
<svg className="absolute inset-0 h-full w-full opacity-20" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<pattern id="dots" x="0" y="0" width="24" height="24" patternUnits="userSpaceOnUse">
|
||||
<circle cx="1" cy="1" r="1" fill="#4f5666" />
|
||||
</pattern>
|
||||
</defs>
|
||||
<rect width="100%" height="100%" fill="url(#dots)" />
|
||||
</svg>
|
||||
{/* Static topology SVG */}
|
||||
<svg viewBox="0 0 460 240" className="relative w-full max-w-md" xmlns="http://www.w3.org/2000/svg">
|
||||
{/* Edges */}
|
||||
<line x1="230" y1="48" x2="130" y2="130" stroke="#2a2e3a" strokeWidth="1.5" />
|
||||
<line x1="230" y1="48" x2="230" y2="130" stroke="#2a2e3a" strokeWidth="1.5" />
|
||||
<line x1="230" y1="48" x2="330" y2="130" stroke="#2a2e3a" strokeWidth="1.5" />
|
||||
<line x1="130" y1="130" x2="80" y2="210" stroke="#2a2e3a" strokeWidth="1.5" strokeDasharray="4 3" />
|
||||
<line x1="130" y1="130" x2="180" y2="210" stroke="#2a2e3a" strokeWidth="1.5" strokeDasharray="4 3" />
|
||||
<line x1="330" y1="130" x2="280" y2="210" stroke="#2a2e3a" strokeWidth="1.5" strokeDasharray="4 3" />
|
||||
<line x1="330" y1="130" x2="380" y2="210" stroke="#2a2e3a" strokeWidth="1.5" strokeDasharray="4 3" />
|
||||
{/* Firewall node */}
|
||||
<rect x="196" y="16" width="68" height="40" rx="6" fill="#1e2028" stroke="#3d4252" strokeWidth="1" />
|
||||
<rect x="207" y="22" width="14" height="10" rx="2" fill="#f87171" opacity="0.9" />
|
||||
<rect x="225" y="22" width="6" height="10" rx="1" fill="#3d4252" />
|
||||
<rect x="235" y="22" width="20" height="10" rx="2" fill="#3d4252" />
|
||||
<text x="230" y="46" textAnchor="middle" fill="#848b9b" fontSize="9" fontFamily="monospace">Firewall</text>
|
||||
{/* Switch node */}
|
||||
<rect x="96" y="108" width="68" height="40" rx="6" fill="#1e2028" stroke="#3d4252" strokeWidth="1" />
|
||||
<circle cx="112" cy="124" r="4" fill="#34d399" opacity="0.9" />
|
||||
<circle cx="122" cy="124" r="4" fill="#34d399" opacity="0.9" />
|
||||
<circle cx="132" cy="124" r="4" fill="#fbbf24" opacity="0.8" />
|
||||
<circle cx="142" cy="124" r="4" fill="#34d399" opacity="0.9" />
|
||||
<text x="130" y="140" textAnchor="middle" fill="#848b9b" fontSize="9" fontFamily="monospace">Core Switch</text>
|
||||
{/* Router node */}
|
||||
<rect x="196" y="108" width="68" height="40" rx="6" fill="#1e2028" stroke="#60a5fa" strokeWidth="1" />
|
||||
<circle cx="230" cy="124" r="10" fill="none" stroke="#60a5fa" strokeWidth="1.5" opacity="0.7" />
|
||||
<circle cx="230" cy="124" r="5" fill="none" stroke="#60a5fa" strokeWidth="1" opacity="0.5" />
|
||||
<line x1="220" y1="124" x2="240" y2="124" stroke="#60a5fa" strokeWidth="1" opacity="0.6" />
|
||||
<text x="230" y="140" textAnchor="middle" fill="#93c5fd" fontSize="9" fontFamily="monospace">Router</text>
|
||||
{/* Server farm */}
|
||||
<rect x="296" y="108" width="68" height="40" rx="6" fill="#1e2028" stroke="#3d4252" strokeWidth="1" />
|
||||
<rect x="308" y="116" width="44" height="7" rx="2" fill="#2a2e3a" />
|
||||
<rect x="308" y="127" width="44" height="7" rx="2" fill="#2a2e3a" />
|
||||
<circle cx="345" cy="119.5" r="2" fill="#34d399" opacity="0.9" />
|
||||
<circle cx="345" cy="130.5" r="2" fill="#34d399" opacity="0.9" />
|
||||
<text x="330" y="140" textAnchor="middle" fill="#848b9b" fontSize="9" fontFamily="monospace">Servers</text>
|
||||
{/* Leaf nodes */}
|
||||
<rect x="46" y="190" width="52" height="34" rx="5" fill="#1e2028" stroke="#2a2e3a" strokeWidth="1" />
|
||||
<text x="72" y="211" textAnchor="middle" fill="#4f5666" fontSize="8" fontFamily="monospace">PC × 12</text>
|
||||
<rect x="154" y="190" width="52" height="34" rx="5" fill="#1e2028" stroke="#2a2e3a" strokeWidth="1" />
|
||||
<text x="180" y="211" textAnchor="middle" fill="#4f5666" fontSize="8" fontFamily="monospace">AP × 4</text>
|
||||
<rect x="254" y="190" width="52" height="34" rx="5" fill="#1e2028" stroke="#2a2e3a" strokeWidth="1" />
|
||||
<text x="280" y="211" textAnchor="middle" fill="#4f5666" fontSize="8" fontFamily="monospace">NAS</text>
|
||||
<rect x="354" y="190" width="52" height="34" rx="5" fill="#1e2028" stroke="#2a2e3a" strokeWidth="1" />
|
||||
<text x="380" y="211" textAnchor="middle" fill="#4f5666" fontSize="8" fontFamily="monospace">VM × 6</text>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* Right: value prop + CTA */}
|
||||
<div className="flex flex-col justify-center border-l border-default p-8">
|
||||
<div className="mb-1 flex items-center gap-2">
|
||||
<Network size={14} className="text-accent" />
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wider text-accent">Network Maps</span>
|
||||
</div>
|
||||
<h2 className="font-heading text-xl font-bold text-heading leading-snug">
|
||||
Document every client's infrastructure — once
|
||||
</h2>
|
||||
<p className="mt-3 text-sm leading-relaxed text-muted-foreground">
|
||||
Drag-and-drop topology diagrams that live next to your tickets. Generate a first draft from a plain-text description, then keep it up to date as networks change.
|
||||
</p>
|
||||
<ul className="mt-4 space-y-2 text-xs text-muted-foreground">
|
||||
<li className="flex items-center gap-2">
|
||||
<div className="h-1.5 w-1.5 rounded-full bg-accent" />
|
||||
AI topology generation from natural language
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<div className="h-1.5 w-1.5 rounded-full bg-accent" />
|
||||
Export to PNG, SVG, PDF, or draw.io
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<div className="h-1.5 w-1.5 rounded-full bg-accent" />
|
||||
Shared across your whole team instantly
|
||||
</li>
|
||||
</ul>
|
||||
<div className="mt-6 flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||
<button
|
||||
onClick={() => navigate('/network-diagrams/new')}
|
||||
className="flex items-center justify-center gap-1.5 rounded bg-accent px-4 py-2 text-sm font-medium text-white hover:bg-accent/90"
|
||||
>
|
||||
<Plus size={14} />
|
||||
Create Network Map
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setImportMenuOpen(true) }}
|
||||
className="flex items-center justify-center gap-1.5 rounded border border-default px-4 py-2 text-sm text-primary hover:border-hover"
|
||||
>
|
||||
<Upload size={14} />
|
||||
Import existing
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && diagrams.length > 0 && (
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{diagrams.map(d => (
|
||||
<div
|
||||
key={d.id}
|
||||
onClick={() => navigate(`/network-diagrams/${d.id}`)}
|
||||
className="group relative cursor-pointer rounded-lg border border-default bg-card p-4 hover:border-hover"
|
||||
>
|
||||
<div className="mb-2 flex items-start justify-between">
|
||||
<h3 className="font-heading text-sm font-semibold text-heading">{d.name}</h3>
|
||||
<button
|
||||
onClick={e => { e.stopPropagation(); setMenuOpenId(menuOpenId === d.id ? null : d.id) }}
|
||||
className="rounded p-1 text-muted-foreground opacity-0 hover:bg-elevated group-hover:opacity-100"
|
||||
>
|
||||
<MoreHorizontal size={14} />
|
||||
</button>
|
||||
</div>
|
||||
{d.client_name && (
|
||||
<span className="mb-2 inline-block rounded-full bg-elevated px-2 py-0.5 text-[10px] text-muted-foreground">
|
||||
{d.client_name}
|
||||
</span>
|
||||
)}
|
||||
{d.description && (
|
||||
<p className="mb-3 line-clamp-2 text-xs text-muted-foreground">{d.description}</p>
|
||||
)}
|
||||
{/* Thumbnail preview */}
|
||||
{d.thumbnail_url ? (
|
||||
<div className="mb-2 overflow-hidden rounded border border-default">
|
||||
<img
|
||||
src={d.thumbnail_url}
|
||||
alt={d.name}
|
||||
className="h-[120px] w-full object-cover"
|
||||
onError={e => { (e.target as HTMLImageElement).style.display = 'none' }}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="relative mb-2 flex h-[120px] items-center justify-center overflow-hidden rounded border border-default bg-[#0e1016]">
|
||||
<svg className="absolute inset-0 h-full w-full opacity-30" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<pattern id={`dots-${d.id}`} x="0" y="0" width="20" height="20" patternUnits="userSpaceOnUse">
|
||||
<circle cx="1" cy="1" r="0.8" fill="#4f5666" />
|
||||
</pattern>
|
||||
</defs>
|
||||
<rect width="100%" height="100%" fill={`url(#dots-${d.id})`} />
|
||||
</svg>
|
||||
<Network size={24} className="relative text-muted-foreground/20" />
|
||||
</div>
|
||||
)}
|
||||
{d.node_count > 0 && (
|
||||
<div className="mb-2">
|
||||
<TopologyBar categoryCounts={d.category_counts} nodeCount={d.node_count} />
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center justify-between text-[10px] text-muted-foreground">
|
||||
<span>{d.node_count} device{d.node_count !== 1 ? 's' : ''}</span>
|
||||
<span>{formatDate(d.created_at)}</span>
|
||||
</div>
|
||||
|
||||
{menuOpenId === d.id && (
|
||||
<div className="absolute right-2 top-10 z-50 w-44 rounded border border-default bg-card py-1 shadow-lg">
|
||||
{confirmArchiveId === d.id ? (
|
||||
<>
|
||||
<p className="px-3 py-1.5 text-[10px] text-muted-foreground">Archive this diagram?</p>
|
||||
<div className="flex gap-1 px-2 pb-1.5">
|
||||
<button
|
||||
onClick={e => { e.stopPropagation(); setConfirmArchiveId(null) }}
|
||||
className="flex-1 rounded border border-default px-2 py-1 text-[10px] text-primary hover:bg-elevated"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={e => { e.stopPropagation(); handleArchive(d.id) }}
|
||||
className="flex-1 rounded bg-red-500/20 px-2 py-1 text-[10px] font-medium text-red-400 hover:bg-red-500/30"
|
||||
>
|
||||
Archive
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
onClick={e => { e.stopPropagation(); navigate(`/network-diagrams/${d.id}`) }}
|
||||
className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-xs text-primary hover:bg-elevated"
|
||||
>
|
||||
<ExternalLink size={12} className="text-muted-foreground" />
|
||||
Open
|
||||
</button>
|
||||
<button
|
||||
onClick={e => { e.stopPropagation(); handleDuplicate(d.id) }}
|
||||
className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-xs text-primary hover:bg-elevated"
|
||||
>
|
||||
<Copy size={12} className="text-muted-foreground" />
|
||||
Duplicate
|
||||
</button>
|
||||
<div className="my-1 border-t border-default" />
|
||||
<button
|
||||
onClick={e => { e.stopPropagation(); setConfirmArchiveId(d.id) }}
|
||||
className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-xs text-red-400 hover:bg-elevated"
|
||||
>
|
||||
<Archive size={12} />
|
||||
Archive…
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -3,7 +3,6 @@ import { useAuthStore } from '@/store/authStore'
|
||||
import { StartSessionInput } from '@/components/dashboard/StartSessionInput'
|
||||
import { PendingEscalations } from '@/components/dashboard/PendingEscalations'
|
||||
import { ActiveFlowPilotSessions } from '@/components/dashboard/ActiveFlowPilotSessions'
|
||||
import { TicketQueue } from '@/components/dashboard/TicketQueue'
|
||||
import { PerformanceCards } from '@/components/dashboard/PerformanceCards'
|
||||
import { KnowledgeBaseCards } from '@/components/dashboard/KnowledgeBaseCards'
|
||||
import { TeamSummary } from '@/components/dashboard/TeamSummary'
|
||||
@@ -60,11 +59,6 @@ export function QuickStartPage() {
|
||||
<ActiveFlowPilotSessions />
|
||||
</div>
|
||||
|
||||
{/* Ticket Queue (auto-hides if no PSA connection) */}
|
||||
<div className="mt-8">
|
||||
<TicketQueue />
|
||||
</div>
|
||||
|
||||
{/* Dashboard — always visible */}
|
||||
<div className="mt-10">
|
||||
<SectionLabel>Dashboard</SectionLabel>
|
||||
|
||||
@@ -648,12 +648,10 @@ function MemberMappingTab({ connection }: { connection: PsaConnectionResponse |
|
||||
setCwMembers(members)
|
||||
setMappings(existingMappings)
|
||||
|
||||
// Build local mapping state from existing mappings (skip unmapped entries)
|
||||
// Build local mapping state from existing mappings
|
||||
const lookup: Record<string, { external_member_id: string; external_member_name: string }> = {}
|
||||
for (const m of existingMappings) {
|
||||
if (m.external_member_id) {
|
||||
lookup[m.user_id] = { external_member_id: m.external_member_id, external_member_name: m.external_member_name ?? '' }
|
||||
}
|
||||
lookup[m.user_id] = { external_member_id: m.external_member_id, external_member_name: m.external_member_name }
|
||||
}
|
||||
setLocalMappings(lookup)
|
||||
setIsDirty(false)
|
||||
@@ -718,11 +716,14 @@ function MemberMappingTab({ connection }: { connection: PsaConnectionResponse |
|
||||
}
|
||||
}
|
||||
|
||||
// All account users — includes both mapped and unmapped
|
||||
const uniqueUsers = hasLoaded
|
||||
// Derive user list from mappings response (all account users are returned)
|
||||
const userRows = mappings.length > 0
|
||||
? mappings.map(m => ({ user_id: m.user_id, user_email: m.user_email, user_name: m.user_name, matched_by: m.matched_by }))
|
||||
: []
|
||||
|
||||
// Deduplicate: mappings may only contain mapped users, so we show what we have
|
||||
const uniqueUsers = hasLoaded ? userRows : []
|
||||
|
||||
if (!connection) {
|
||||
return (
|
||||
<div className="max-w-3xl">
|
||||
|
||||
@@ -44,7 +44,7 @@ export function AccountDetailPage() {
|
||||
const [createForm, setCreateForm] = useState({
|
||||
email: '',
|
||||
name: '',
|
||||
account_role: 'engineer' as 'owner' | 'admin' | 'engineer' | 'viewer',
|
||||
account_role: 'engineer' as 'engineer' | 'viewer',
|
||||
send_email: true,
|
||||
})
|
||||
const [createLoading, setCreateLoading] = useState(false)
|
||||
@@ -117,7 +117,7 @@ export function AccountDetailPage() {
|
||||
send_email: createForm.send_email,
|
||||
})
|
||||
setShowCreateUserModal(false)
|
||||
setCreateForm({ email: '', name: '', account_role: 'engineer' as 'owner' | 'admin' | 'engineer' | 'viewer', send_email: true })
|
||||
setCreateForm({ email: '', name: '', account_role: 'engineer', send_email: true })
|
||||
setTempPassword(result.temporary_password)
|
||||
setCopiedPassword(false)
|
||||
toast.success(result.email_sent ? 'User created and welcome email sent' : 'User created')
|
||||
@@ -365,8 +365,6 @@ export function AccountDetailPage() {
|
||||
'focus:outline-hidden focus:border-primary focus:ring-2 focus:ring-primary/20'
|
||||
)}
|
||||
>
|
||||
<option value="owner">Owner</option>
|
||||
<option value="admin">Admin</option>
|
||||
<option value="engineer">Engineer</option>
|
||||
<option value="viewer">Viewer</option>
|
||||
</select>
|
||||
@@ -545,14 +543,12 @@ export function AccountDetailPage() {
|
||||
<label className="mb-1 block text-sm font-medium text-foreground">Account Role</label>
|
||||
<select
|
||||
value={createForm.account_role}
|
||||
onChange={(e) => setCreateForm((f) => ({ ...f, account_role: e.target.value as 'owner' | 'admin' | 'engineer' | 'viewer' }))}
|
||||
onChange={(e) => setCreateForm((f) => ({ ...f, account_role: e.target.value as 'engineer' | 'viewer' }))}
|
||||
className={cn(
|
||||
'w-full rounded-md border border-border bg-card px-3 py-2 text-sm text-foreground',
|
||||
'focus:outline-hidden focus:border-primary focus:ring-2 focus:ring-primary/20'
|
||||
)}
|
||||
>
|
||||
<option value="owner">Owner</option>
|
||||
<option value="admin">Admin</option>
|
||||
<option value="engineer">Engineer</option>
|
||||
<option value="viewer">Viewer</option>
|
||||
</select>
|
||||
|
||||
@@ -74,7 +74,7 @@ export function UsersPage() {
|
||||
name: '',
|
||||
account_mode: 'personal' as 'existing' | 'personal',
|
||||
account_display_code: '',
|
||||
account_role: 'engineer' as 'owner' | 'admin' | 'engineer' | 'viewer',
|
||||
account_role: 'engineer' as 'engineer' | 'viewer',
|
||||
send_email: true,
|
||||
})
|
||||
const [createLoading, setCreateLoading] = useState(false)
|
||||
@@ -88,7 +88,7 @@ export function UsersPage() {
|
||||
})
|
||||
const [inviteLoading, setInviteLoading] = useState(false)
|
||||
const [showCreateAccountModal, setShowCreateAccountModal] = useState(false)
|
||||
const [createAccountForm, setCreateAccountForm] = useState({ name: '', plan: 'free' as 'free' | 'pro' | 'team', owner_email: '' })
|
||||
const [createAccountForm, setCreateAccountForm] = useState({ name: '', plan: 'free' as 'free' | 'pro' | 'team' })
|
||||
const [createAccountLoading, setCreateAccountLoading] = useState(false)
|
||||
|
||||
const fetchAccounts = useCallback(async () => {
|
||||
@@ -223,19 +223,13 @@ export function UsersPage() {
|
||||
const created = await adminApi.createAccount({
|
||||
name: createAccountForm.name.trim(),
|
||||
plan: createAccountForm.plan,
|
||||
owner_email: createAccountForm.owner_email.trim() || undefined,
|
||||
})
|
||||
toast.success('Account created')
|
||||
setShowCreateAccountModal(false)
|
||||
setCreateAccountForm({ name: '', plan: 'free', owner_email: '' })
|
||||
setCreateAccountForm({ name: '', plan: 'free' })
|
||||
navigate(`/admin/accounts/${created.id}`)
|
||||
} catch (err: unknown) {
|
||||
if (err && typeof err === 'object' && 'response' in err) {
|
||||
const axiosErr = err as { response?: { data?: { detail?: string } } }
|
||||
toast.error(axiosErr.response?.data?.detail || 'Failed to create account')
|
||||
} else {
|
||||
toast.error('Failed to create account')
|
||||
}
|
||||
} catch {
|
||||
toast.error('Failed to create account')
|
||||
} finally {
|
||||
setCreateAccountLoading(false)
|
||||
}
|
||||
@@ -640,18 +634,6 @@ export function UsersPage() {
|
||||
<option value="team">Team</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-foreground">
|
||||
Owner Email <span className="text-muted-foreground font-normal">(optional)</span>
|
||||
</label>
|
||||
<Input
|
||||
type="email"
|
||||
value={createAccountForm.owner_email}
|
||||
onChange={(e) => setCreateAccountForm((form) => ({ ...form, owner_email: e.target.value }))}
|
||||
placeholder="owner@example.com"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-muted-foreground">Must be an existing user.</p>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
@@ -718,14 +700,12 @@ export function UsersPage() {
|
||||
<label className="mb-1 block text-sm font-medium text-foreground">Account Role</label>
|
||||
<select
|
||||
value={createForm.account_role}
|
||||
onChange={(e) => setCreateForm((form) => ({ ...form, account_role: e.target.value as 'owner' | 'admin' | 'engineer' | 'viewer' }))}
|
||||
onChange={(e) => setCreateForm((form) => ({ ...form, account_role: e.target.value as 'engineer' | 'viewer' }))}
|
||||
className={cn(
|
||||
'w-full rounded-md border border-border bg-card px-3 py-2 text-sm text-foreground',
|
||||
'focus:outline-hidden focus:border-primary focus:ring-2 focus:ring-primary/20'
|
||||
)}
|
||||
>
|
||||
<option value="owner">Owner</option>
|
||||
<option value="admin">Admin</option>
|
||||
<option value="engineer">Engineer</option>
|
||||
<option value="viewer">Viewer</option>
|
||||
</select>
|
||||
|
||||
@@ -60,8 +60,6 @@ const DevBranchingPage = lazyWithRetry(() => import('@/pages/DevBranchingPage'))
|
||||
const GuidesHubPage = lazyWithRetry(() => import('@/pages/GuidesHubPage'))
|
||||
const GuideDetailPage = lazyWithRetry(() => import('@/pages/GuideDetailPage'))
|
||||
const AccountSettingsPage = lazyWithRetry(() => import('@/pages/AccountSettingsPage'))
|
||||
const NetworkDiagramsPage = lazyWithRetry(() => import('@/pages/NetworkDiagrams'))
|
||||
const DiagramEditorPage = lazyWithRetry(() => import('@/pages/NetworkDiagrams/DiagramEditor'))
|
||||
// Admin pages
|
||||
const AdminLayout = lazyWithRetry(() => import('@/components/admin/AdminLayout'))
|
||||
const AdminDashboardPage = lazyWithRetry(() => import('@/pages/admin/DashboardPage'))
|
||||
@@ -198,9 +196,6 @@ export const router = sentryCreateBrowserRouter([
|
||||
{ path: 'scripts', element: page(ScriptLibraryPage) },
|
||||
{ path: 'scripts/manage', element: page(ScriptManagePage) },
|
||||
{ path: 'script-builder', element: page(ScriptBuilderPage) },
|
||||
{ path: 'network-diagrams', element: page(NetworkDiagramsPage) },
|
||||
{ path: 'network-diagrams/new', element: page(DiagramEditorPage) },
|
||||
{ path: 'network-diagrams/:id', element: page(DiagramEditorPage) },
|
||||
{ path: 'kb-accelerator', element: page(KBAcceleratorPage) },
|
||||
{ path: 'assistant', element: page(AssistantChatPage) },
|
||||
{ path: 'assistant/:sessionId', element: page(AssistantChatPage) },
|
||||
|
||||
@@ -114,7 +114,6 @@ export interface AdminAccountDetailResponse extends AdminAccountListItem {
|
||||
export interface AdminAccountCreate {
|
||||
name: string
|
||||
plan: 'free' | 'pro' | 'team'
|
||||
owner_email?: string
|
||||
}
|
||||
|
||||
export interface AdminAccountUpdate {
|
||||
@@ -267,7 +266,7 @@ export interface AdminUserCreate {
|
||||
name: string
|
||||
account_mode: 'existing' | 'personal'
|
||||
account_display_code?: string
|
||||
account_role?: 'owner' | 'admin' | 'engineer' | 'viewer'
|
||||
account_role?: 'engineer' | 'viewer'
|
||||
send_email: boolean
|
||||
}
|
||||
|
||||
|
||||
@@ -98,4 +98,3 @@ export * from './script-builder'
|
||||
export * from './integrations'
|
||||
export * from './notification'
|
||||
export type * from './public-templates'
|
||||
export * from './network-diagram'
|
||||
|
||||
@@ -1,8 +1,3 @@
|
||||
export interface PSABoard {
|
||||
id: number
|
||||
name: string
|
||||
}
|
||||
|
||||
export interface PsaConnectionResponse {
|
||||
id: string
|
||||
account_id: string
|
||||
@@ -113,13 +108,13 @@ export interface PsaMemberResponse {
|
||||
}
|
||||
|
||||
export interface PsaMemberMappingResponse {
|
||||
id: string | null
|
||||
id: string
|
||||
user_id: string
|
||||
user_email: string
|
||||
user_name: string
|
||||
external_member_id: string | null
|
||||
external_member_name: string | null
|
||||
matched_by: string | null
|
||||
external_member_id: string
|
||||
external_member_name: string
|
||||
matched_by: string
|
||||
}
|
||||
|
||||
export interface AutoMatchResult {
|
||||
|
||||
@@ -1,142 +0,0 @@
|
||||
export interface DeviceProperties {
|
||||
hostname: string | null
|
||||
ip: string | null
|
||||
subnet: string | null
|
||||
vendor: string | null
|
||||
model: string | null
|
||||
role: string | null
|
||||
vlan: string | null
|
||||
notes: string | null
|
||||
status: 'unknown' | 'online' | 'offline' | 'degraded'
|
||||
}
|
||||
|
||||
export interface DiagramNode {
|
||||
id: string
|
||||
type: string
|
||||
label: string
|
||||
position: { x: number; y: number }
|
||||
properties: DeviceProperties
|
||||
nodeType?: string
|
||||
style?: { width?: number; height?: number } | null
|
||||
parentId?: string | null
|
||||
}
|
||||
|
||||
export interface DiagramEdge {
|
||||
id: string
|
||||
source: string
|
||||
target: string
|
||||
label: string | null
|
||||
connectionType: string
|
||||
speed: string | null
|
||||
notes: string | null
|
||||
routing?: 'curved' | 'step' | 'orthogonal' | null
|
||||
}
|
||||
|
||||
export interface DeviceTypeResponse {
|
||||
id: string
|
||||
slug: string
|
||||
label: string
|
||||
category: string
|
||||
is_system: boolean
|
||||
account_id: string
|
||||
sort_order: number
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface DeviceTypeCreate {
|
||||
slug: string
|
||||
label: string
|
||||
category: string
|
||||
sort_order?: number
|
||||
}
|
||||
|
||||
export interface NetworkDiagramResponse {
|
||||
id: string
|
||||
account_id: string
|
||||
name: string
|
||||
client_name: string | null
|
||||
asset_name: string | null
|
||||
description: string | null
|
||||
nodes: DiagramNode[]
|
||||
edges: DiagramEdge[]
|
||||
thumbnail_url: string | null
|
||||
is_archived: boolean
|
||||
created_by: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface NetworkDiagramListItem {
|
||||
id: string
|
||||
name: string
|
||||
client_name: string | null
|
||||
description: string | null
|
||||
node_count: number
|
||||
category_counts: Record<string, number>
|
||||
thumbnail_url?: string | null
|
||||
created_by: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface NetworkDiagramCreate {
|
||||
name: string
|
||||
client_name?: string | null
|
||||
asset_name?: string | null
|
||||
description?: string | null
|
||||
nodes?: DiagramNode[]
|
||||
edges?: DiagramEdge[]
|
||||
}
|
||||
|
||||
export interface NetworkDiagramUpdate {
|
||||
name?: string
|
||||
client_name?: string | null
|
||||
asset_name?: string | null
|
||||
description?: string | null
|
||||
nodes?: DiagramNode[]
|
||||
edges?: DiagramEdge[]
|
||||
}
|
||||
|
||||
export interface AIGenerateRequest {
|
||||
description: string
|
||||
client_name?: string | null
|
||||
mode: 'replace' | 'merge'
|
||||
existingBounds?: { minX: number; maxX: number; minY: number; maxY: number } | null
|
||||
}
|
||||
|
||||
export interface AIGenerateResponse {
|
||||
nodes: DiagramNode[]
|
||||
edges: DiagramEdge[]
|
||||
suggestedName: string | null
|
||||
notes: string | null
|
||||
}
|
||||
|
||||
export interface DiagramImportData {
|
||||
schemaVersion: number
|
||||
name: string
|
||||
client_name?: string | null
|
||||
description?: string | null
|
||||
nodes: DiagramNode[]
|
||||
edges: DiagramEdge[]
|
||||
}
|
||||
|
||||
export interface DiagramImportResponse {
|
||||
diagram: NetworkDiagramResponse
|
||||
warnings: string[]
|
||||
}
|
||||
|
||||
export interface GroupNodeData {
|
||||
label: string
|
||||
groupType: 'subnet' | 'vlan' | 'site' | 'dmz' | 'custom'
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export interface DiagramExportResponse {
|
||||
schemaVersion: number
|
||||
name: string
|
||||
client_name: string | null
|
||||
description: string | null
|
||||
nodes: DiagramNode[]
|
||||
edges: DiagramEdge[]
|
||||
exportedAt: string
|
||||
}
|
||||
Reference in New Issue
Block a user