Compare commits
84 Commits
main
...
feat/cockp
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1a45f66358 | ||
|
|
6f12e42ebe | ||
|
|
c0d8163e98 | ||
|
|
af4f07cec6 | ||
|
|
5eac5fecff | ||
|
|
e042cf6186 | ||
|
|
c53ced8725 | ||
|
|
e3b2f73f38 | ||
|
|
866b02833c | ||
|
|
80c82f0b48 | ||
|
|
f989c1e487 | ||
|
|
375e739348 | ||
|
|
f4b05a2ff5 | ||
|
|
73ea126f4c | ||
|
|
93b1398b5c | ||
|
|
654b9cb4ac | ||
|
|
ee32fdbf78 | ||
|
|
db9d5a8393 | ||
|
|
289b3cacfc | ||
|
|
6b7bc3fd42 | ||
|
|
6aef6e2602 | ||
|
|
12f6a29dd5 | ||
|
|
600c3959af | ||
|
|
d8d8de91d1 | ||
|
|
ebfd4e5651 | ||
|
|
7c19521fc2 | ||
|
|
17e1bc84e2 | ||
|
|
ebc6b46823 | ||
|
|
38be5b0370 | ||
|
|
b6ea63d81a | ||
|
|
03f6100d4c | ||
|
|
a3c49873f5 | ||
|
|
5a91c4d672 | ||
|
|
068baec179 | ||
|
|
9c042c750e | ||
|
|
8c90da1960 | ||
|
|
be34a20441 | ||
|
|
e7ac87bf7d | ||
|
|
165e402284 | ||
|
|
aca976a49a | ||
|
|
47e71c4753 | ||
|
|
91e3f80707 | ||
|
|
b03f84aecf | ||
|
|
813b598101 | ||
|
|
ed6e6cd1ed | ||
|
|
b07dfb7603 | ||
|
|
4ba32a08ac | ||
|
|
3ea669a1e5 | ||
|
|
81ad52f5bc | ||
|
|
cd7774b733 | ||
|
|
3ce4201d62 | ||
|
|
b994e82c56 | ||
|
|
7d97412d1f | ||
|
|
fc51ceb610 | ||
|
|
4cc6ee4797 | ||
|
|
2ed02607a8 | ||
|
|
1a858237ba | ||
|
|
df9e069452 | ||
|
|
79c8632dbb | ||
|
|
a001aa11e5 | ||
|
|
2ee3c1afda | ||
|
|
cc929c8932 | ||
|
|
b7e5979d0c | ||
|
|
6117a83b0b | ||
|
|
a60c19b305 | ||
|
|
a2749104f4 | ||
|
|
8c28f48ce0 | ||
|
|
f9de76b28c | ||
|
|
296153850b | ||
|
|
1d4d7ef35d | ||
|
|
bfcb8c52d3 | ||
|
|
b8189a1999 | ||
|
|
9462da8b80 | ||
|
|
63023d486d | ||
|
|
1705ecbb9f | ||
|
|
6ee6faa712 | ||
|
|
23a7cee1f5 | ||
|
|
036198b224 | ||
|
|
92cc62bcbd | ||
|
|
15781baeb7 | ||
|
|
cb750d52f6 | ||
|
|
56ca5d2669 | ||
|
|
81f8aa0074 | ||
|
|
5dd43b2226 |
14
CHANGELOG.md
14
CHANGELOG.md
@@ -4,6 +4,20 @@ All notable changes to ResolutionFlow are documented here.
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [2026-04-04] Network Diagram Editor UX Improvements
|
||||
|
||||
### Added
|
||||
- Snap-to-grid (20px) on Network Diagram canvas — nodes align consistently when dragged
|
||||
- NodeResizer on group nodes (subnet/VLAN/site/DMZ) — select a group and drag its handles to resize
|
||||
- Group node dimensions now saved to and restored from the backend on reload
|
||||
|
||||
### Fixed
|
||||
- Connection edges now render as straight lines instead of orthogonal bent paths
|
||||
- ISP device now appears inside the Cloud category in the sidebar instead of a standalone "Internet" section; respects search and item count
|
||||
- Group nodes now restore correctly as `type: 'group'` on diagram load (previously loaded as `type: 'device'`, breaking group display after save)
|
||||
|
||||
---
|
||||
|
||||
### Added
|
||||
- Tree Templates + Import/Export marketplace (#66)
|
||||
- Recurring Issue Detection — client-specific pattern alerts (#60)
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
> **Purpose:** Quick-reference file showing exactly where the project stands.
|
||||
> **For Claude Code:** Read this first to understand what's done and what's next.
|
||||
> **Last Updated:** March 23, 2026
|
||||
> **Last Updated:** April 4, 2026 (evening)
|
||||
|
||||
---
|
||||
|
||||
@@ -13,8 +13,8 @@
|
||||
## What's Complete
|
||||
|
||||
### Core Platform
|
||||
- FastAPI project structure with 35+ API endpoints
|
||||
- PostgreSQL database with Docker, 75+ Alembic migrations
|
||||
- FastAPI project structure with 55+ API endpoints
|
||||
- PostgreSQL database with Docker, 100+ Alembic migrations
|
||||
- User authentication (JWT, register, login, refresh, logout, invite codes)
|
||||
- Refresh token rotation with JTI-based revocation
|
||||
- Trees CRUD with full-text search (FTS index)
|
||||
@@ -29,7 +29,7 @@
|
||||
|
||||
### Frontend Core
|
||||
- React 19 + Vite + TypeScript + Tailwind CSS v4 (`@tailwindcss/vite`)
|
||||
- **Charcoal Design System** — Flat, high-contrast dark theme (Sentry/PostHog-inspired), charcoal palette with sidebar-darkest approach
|
||||
- **Charcoal Design System v6** — Flat, high-contrast dark theme (Sentry/PostHog-inspired), charcoal palette; accent color is electric blue (#60a5fa), replacing ember orange
|
||||
- **Brand fonts:** Bricolage Grotesque (headings), IBM Plex Sans (body), JetBrains Mono (code)
|
||||
- Authentication UI (login, register, email verification)
|
||||
- Tree library/browsing page with grid/list/table views
|
||||
@@ -130,6 +130,36 @@
|
||||
- Enhanced PSA metrics: time entries, hours logged, push success funnel, daily trend chart
|
||||
- 13 new backend tests for coverage and flow quality endpoints
|
||||
|
||||
### Conversational Branching (Complete)
|
||||
|
||||
- SessionBranch, ForkPoint, SessionHandoff, SessionResolutionOutput models + migration (4 tables, 13 columns)
|
||||
- BranchManager service, BranchAwarePromptBuilder, HandoffManager service with integration tests
|
||||
- Branch API endpoints: `session_branches.py`, `session_handoffs.py`, `session_resolutions.py`
|
||||
- Integrated into `unified_chat_service.py` and AI session step creation
|
||||
- Frontend: BranchNode, ForkCard, BranchMap, BranchRevivalCard, BranchTransitionBar, HandoffModal, ResolutionOutputPanel components
|
||||
- Wired into FlowPilotSession and `useFlowPilotSession` hook
|
||||
|
||||
### Script Library Enhancements (Complete)
|
||||
|
||||
- ParameterizeAndSavePanel replaces SaveToLibraryDialog — accepts `script_body` and `parameters_schema` in save flow
|
||||
- "New from Script" button on ScriptLibraryPage for one-click script creation from template
|
||||
- Default tab is "All Scripts" (previously filtered to owned scripts)
|
||||
- Ownership filter state preserved across category and search changes
|
||||
- Backend: `save-to-library` endpoint accepts `script_body` + `parameters_schema`
|
||||
|
||||
### AI Vision Support (Complete)
|
||||
|
||||
- Image uploads (paste/drag-drop) wired into AI assistant chat via `upload_ids`
|
||||
- Server-side image resize before sending to Claude (Pillow, 1568px max, PNG→JPEG)
|
||||
- `storage_service.resize_image_for_vision()` handles vision pipeline
|
||||
- Images are NOT stored in conversation history (text-only history)
|
||||
|
||||
### Mid-Session Status Updates (Complete)
|
||||
|
||||
- AI assistant can generate `status_update` steps (step_type added to CHECK constraint)
|
||||
- Status update generation wired into `unified_chat_service.py`
|
||||
- Frontend renders status update cards in session view
|
||||
|
||||
### Search & Recall + Evidence-Rich Sessions (Complete)
|
||||
|
||||
**Evidence:**
|
||||
@@ -163,7 +193,7 @@
|
||||
- SQL wildcard escaping in tag search
|
||||
- PSA credentials encrypted at rest (Fernet)
|
||||
|
||||
### Copilot-First Dashboard (March 2026)
|
||||
### Copilot-First Dashboard (March–April 2026)
|
||||
|
||||
- Redesigned dashboard as FlowPilot copilot launchpad (ChatGPT-style input)
|
||||
- Chat-style input with paste images, drag-drop files, attach button, paste logs
|
||||
@@ -173,9 +203,33 @@
|
||||
- Unified Command Palette (Cmd+K) — merged QuickLaunch into omnibar
|
||||
- "Solutions Library" rename (from "Step Library") site-wide
|
||||
- Maintenance flows hidden from UI for pilot (backend still supports them)
|
||||
- Landing page copy rewrite: "Resolve tickets faster. Notes write themselves."
|
||||
- Spring bounce hover animation on dashboard cards
|
||||
- Charcoal color palette: sidebar `#10121a`, page `#1a1c23`, cards `#22252e`
|
||||
- Charcoal color palette: sidebar `#0e1016`, page `#16181f`, cards `#1e2028`
|
||||
- **Landing page redesign** — scroll-driven reveal animations, live chat animation, FAQ section, improved trust signals; copy: "Resolve tickets faster. Notes write themselves."
|
||||
- **Session History redesign** — tabbed layout with Load More pagination
|
||||
- **Edit Procedure page** — layout and color system overhaul
|
||||
- **TaskLane UX** improvements in assistant chat; persistence across page reload
|
||||
- TaskLane answers persist in sessionStorage; correct behavior on all three chat paths (send, prefill, resume)
|
||||
- **Action bar consolidation** — Deduplicated actions across FlowPilot/Cockpit headers and chat toolbars; chat toolbar now only has input tools (Attach, Paste Logs, Tasks)
|
||||
- **ViewToggle redesigned** as persistent tab bar with bottom-border active indicator and ARIA attributes (FlowPilot/Cockpit switcher)
|
||||
- **Standardized action naming** across all session pages: Resolve (emerald), Update (blue), Close (rose), Pause (muted)
|
||||
- **ConcludeSessionModal copy refresh** — Forward-facing action verbs, "Close & Generate" CTA, consistent outcome labels
|
||||
- Deleted unused FlowPilotActionBar component (227 lines dead code)
|
||||
|
||||
### Network Diagrams (In Progress)
|
||||
|
||||
- Network diagram editor with React Flow (@xyflow/react v12) canvas
|
||||
- Device node system: 27 device types across 7 categories (network, compute, storage, cloud, endpoint, infrastructure, security)
|
||||
- Custom device type creation via DeviceToolbar
|
||||
- Connection edges with 6 types (ethernet, fiber, wifi, vpn, vlan, wan) — color-coded, dashed for wireless/VPN
|
||||
- Properties panel for editing device and connection details
|
||||
- AI-assisted diagram generation (describe network → auto-layout)
|
||||
- Auto-save every 30 seconds, manual save, JSON export
|
||||
- **React Flow UI Components** — Cherry-picked and Charcoal-restyled: BaseNode (structured header/content/footer slots), BaseHandle (styled connection handles), LabeledHandle (named port labels), NodeStatusIndicator (status border effect: emerald/red/yellow), NodeTooltip (hover details via NodeToolbar), LabeledGroupNode (subnet/VLAN/site/DMZ containers), AnimatedSvgEdge (traffic flow visualization)
|
||||
- Grouping category in toolbar: Subnet, VLAN, Site, DMZ drag-drop to canvas
|
||||
- Traffic flow toggle on edges (switches between static and animated)
|
||||
- Context menu with copy/paste/duplicate/select all shortcuts
|
||||
- Drop position uses `screenToFlowPosition()` for correct placement at any zoom/pan level
|
||||
- **Bug fix:** PropertiesPanel inputs now work — selection uses IDs instead of stale object snapshots
|
||||
|
||||
### Maintenance Flows (Hidden from UI)
|
||||
|
||||
@@ -235,21 +289,22 @@
|
||||
|
||||
### Start Development
|
||||
```bash
|
||||
# Start PostgreSQL (Docker Compose)
|
||||
docker compose up -d
|
||||
# Start PostgreSQL (Docker — container name resolutionflow_postgres, port 5433, DB resolutionflow)
|
||||
docker start resolutionflow_postgres
|
||||
|
||||
# Backend (from backend/)
|
||||
source venv/bin/activate
|
||||
uvicorn app.main:app --reload
|
||||
|
||||
# Frontend (from frontend/)
|
||||
# Frontend (from frontend/, requires Node 20)
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### URLs
|
||||
- Frontend: http://192.168.0.9:5173
|
||||
- Backend API: http://192.168.0.9:8000
|
||||
- API Docs: http://192.168.0.9:8000/api/docs
|
||||
- Frontend: http://46.202.92.250:5173 (or https via Traefik reverse proxy)
|
||||
- Backend API: http://46.202.92.250:8000
|
||||
- API Docs: http://46.202.92.250:8000/api/docs
|
||||
- Dev env runs on Hostinger VPS (46.202.92.250) with Traefik + HTTPS; see [DEV-ENV.md](DEV-ENV.md)
|
||||
|
||||
### Run Tests
|
||||
```bash
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
"""add triage fields to ai_sessions for cockpit harness
|
||||
|
||||
Revision ID: 071
|
||||
Revises: 070
|
||||
Create Date: 2026-04-01
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
|
||||
|
||||
revision = "071"
|
||||
down_revision = "070"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("ai_sessions", sa.Column("client_name", sa.String(255), nullable=True))
|
||||
op.add_column("ai_sessions", sa.Column("asset_name", sa.String(255), nullable=True))
|
||||
op.add_column("ai_sessions", sa.Column("issue_category", sa.String(100), nullable=True))
|
||||
op.add_column("ai_sessions", sa.Column("triage_hypothesis", sa.Text(), nullable=True))
|
||||
op.add_column("ai_sessions", sa.Column("evidence_items", JSONB(), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("ai_sessions", "evidence_items")
|
||||
op.drop_column("ai_sessions", "triage_hypothesis")
|
||||
op.drop_column("ai_sessions", "issue_category")
|
||||
op.drop_column("ai_sessions", "asset_name")
|
||||
op.drop_column("ai_sessions", "client_name")
|
||||
61
backend/alembic/versions/072_seed_flowpilot_cockpit_flag.py
Normal file
61
backend/alembic/versions/072_seed_flowpilot_cockpit_flag.py
Normal file
@@ -0,0 +1,61 @@
|
||||
"""Seed flowpilot_cockpit feature flag with plan defaults.
|
||||
|
||||
Revision ID: 072
|
||||
Revises: 071
|
||||
Create Date: 2026-04-02
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "072"
|
||||
down_revision = "071"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Insert the feature flag
|
||||
op.execute(
|
||||
sa.text(
|
||||
"INSERT INTO feature_flags (id, flag_key, display_name, description) "
|
||||
"VALUES (gen_random_uuid(), 'flowpilot_cockpit', 'FlowPilot Cockpit', "
|
||||
"'Access to the FlowPilot Cockpit triage view') "
|
||||
"ON CONFLICT (flag_key) DO NOTHING"
|
||||
)
|
||||
)
|
||||
|
||||
# Set plan defaults: disabled for free, enabled for pro and team
|
||||
op.execute(
|
||||
sa.text(
|
||||
"INSERT INTO plan_feature_defaults (id, plan, flag_id, enabled) "
|
||||
"SELECT gen_random_uuid(), 'free', id, false FROM feature_flags WHERE flag_key = 'flowpilot_cockpit' "
|
||||
"ON CONFLICT (plan, flag_id) DO NOTHING"
|
||||
)
|
||||
)
|
||||
op.execute(
|
||||
sa.text(
|
||||
"INSERT INTO plan_feature_defaults (id, plan, flag_id, enabled) "
|
||||
"SELECT gen_random_uuid(), 'pro', id, true FROM feature_flags WHERE flag_key = 'flowpilot_cockpit' "
|
||||
"ON CONFLICT (plan, flag_id) DO NOTHING"
|
||||
)
|
||||
)
|
||||
op.execute(
|
||||
sa.text(
|
||||
"INSERT INTO plan_feature_defaults (id, plan, flag_id, enabled) "
|
||||
"SELECT gen_random_uuid(), 'team', id, true FROM feature_flags WHERE flag_key = 'flowpilot_cockpit' "
|
||||
"ON CONFLICT (plan, flag_id) DO NOTHING"
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute(
|
||||
sa.text(
|
||||
"DELETE FROM plan_feature_defaults WHERE flag_id IN "
|
||||
"(SELECT id FROM feature_flags WHERE flag_key = 'flowpilot_cockpit')"
|
||||
)
|
||||
)
|
||||
op.execute(
|
||||
sa.text("DELETE FROM feature_flags WHERE flag_key = 'flowpilot_cockpit'")
|
||||
)
|
||||
95
backend/alembic/versions/073_add_device_types_table.py
Normal file
95
backend/alembic/versions/073_add_device_types_table.py
Normal file
@@ -0,0 +1,95 @@
|
||||
"""Add device_types table with system seed data.
|
||||
|
||||
Revision ID: 073
|
||||
Revises: 072
|
||||
Create Date: 2026-04-04
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
import uuid
|
||||
|
||||
|
||||
revision = "073"
|
||||
down_revision = "072"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
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("team_id", UUID(as_uuid=True), sa.ForeignKey("teams.id", ondelete="CASCADE"), nullable=True),
|
||||
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.execute(
|
||||
"ALTER TABLE device_types ADD CONSTRAINT uq_device_types_slug_team "
|
||||
"UNIQUE NULLS NOT DISTINCT (slug, team_id)"
|
||||
)
|
||||
op.create_index("idx_device_types_team", "device_types", ["team_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("team_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,
|
||||
"team_id": None,
|
||||
"sort_order": sort_order,
|
||||
}
|
||||
for slug, label, category, sort_order in SYSTEM_DEVICE_TYPES
|
||||
])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("device_types")
|
||||
41
backend/alembic/versions/074_add_network_diagrams_table.py
Normal file
41
backend/alembic/versions/074_add_network_diagrams_table.py
Normal file
@@ -0,0 +1,41 @@
|
||||
"""Add network_diagrams table.
|
||||
|
||||
Revision ID: 074
|
||||
Revises: 073
|
||||
Create Date: 2026-04-04
|
||||
"""
|
||||
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
|
||||
|
||||
|
||||
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("team_id", UUID(as_uuid=True), sa.ForeignKey("teams.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("idx_network_diagrams_team", "network_diagrams", ["team_id"])
|
||||
op.create_index("idx_network_diagrams_client", "network_diagrams", ["team_id", "client_name"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("network_diagrams")
|
||||
@@ -5,8 +5,8 @@ from typing import Annotated, Optional
|
||||
from uuid import UUID
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func
|
||||
from sqlalchemy.orm import selectinload
|
||||
from sqlalchemy import select, func, or_
|
||||
from sqlalchemy.orm import selectinload, aliased
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.audit import log_audit
|
||||
@@ -24,21 +24,44 @@ from app.models.invite_code import InviteCode
|
||||
from app.models.account_invite import AccountInvite
|
||||
from app.models.tree import Tree
|
||||
from app.schemas.user import UserResponse, RoleUpdate, AccountRoleUpdate
|
||||
from app.schemas.admin import MoveUserAccount, AdminUserCreate, AdminUserCreateResponse, AdminPasswordReset, AdminPasswordResetResponse, HardDeleteCheckResponse
|
||||
from app.schemas.admin import (
|
||||
MoveUserAccount,
|
||||
AdminUserCreate,
|
||||
AdminUserCreateResponse,
|
||||
AdminPasswordReset,
|
||||
AdminPasswordResetResponse,
|
||||
HardDeleteCheckResponse,
|
||||
AdminUserListItem,
|
||||
AdminUserListResponse,
|
||||
AdminAccountMember,
|
||||
AdminAccountListItem,
|
||||
AdminAccountListResponse,
|
||||
AdminAccountOwnerSummary,
|
||||
AdminAccountSubscriptionSummary,
|
||||
AdminAccountUsageSummary,
|
||||
AdminAccountDetailResponse,
|
||||
AdminAccountInviteSummary,
|
||||
AdminAccountCreate,
|
||||
AdminAccountUpdate,
|
||||
)
|
||||
from app.schemas.subscription import SubscriptionPlanUpdate, ExtendTrialRequest
|
||||
from app.schemas.user_detail import (
|
||||
UserDetailResponse, AccountSummary, SubscriptionSummary,
|
||||
SessionSummary, AuditLogSummary, InviteCodeUsedSummary,
|
||||
)
|
||||
from app.api.deps import require_admin
|
||||
from app.core.subscriptions import get_account_usage
|
||||
|
||||
router = APIRouter(prefix="/admin", tags=["admin"])
|
||||
|
||||
|
||||
@router.get("/users", response_model=list[UserResponse])
|
||||
@router.get("/users", response_model=AdminUserListResponse)
|
||||
async def list_users(
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
current_user: Annotated[User, Depends(require_admin)],
|
||||
page: Optional[int] = Query(None, ge=1),
|
||||
size: Optional[int] = Query(None, ge=1, le=100),
|
||||
search: Optional[str] = Query(None, description="Search by user or account fields"),
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(100, ge=1, le=100),
|
||||
is_active: Optional[bool] = Query(None, description="Filter by active status"),
|
||||
@@ -46,23 +69,240 @@ async def list_users(
|
||||
account_id: Optional[UUID] = Query(None, description="Filter by account"),
|
||||
include_archived: bool = Query(False, description="Include archived (soft-deleted) users"),
|
||||
):
|
||||
"""List all users (super admin only)."""
|
||||
query = select(User)
|
||||
"""List users for super admin global people search."""
|
||||
resolved_limit = size or limit
|
||||
resolved_skip = skip
|
||||
current_page = 1
|
||||
|
||||
if page is not None:
|
||||
resolved_skip = (page - 1) * resolved_limit
|
||||
current_page = page
|
||||
elif resolved_limit > 0:
|
||||
current_page = (resolved_skip // resolved_limit) + 1
|
||||
|
||||
count_query = (
|
||||
select(func.count())
|
||||
.select_from(User)
|
||||
.outerjoin(Account, User.account_id == Account.id)
|
||||
)
|
||||
query = (
|
||||
select(
|
||||
User,
|
||||
Account.name.label("account_name"),
|
||||
Account.display_code.label("account_display_code"),
|
||||
)
|
||||
.outerjoin(Account, User.account_id == Account.id)
|
||||
)
|
||||
|
||||
if not include_archived:
|
||||
query = query.where(User.deleted_at.is_(None))
|
||||
count_query = count_query.where(User.deleted_at.is_(None))
|
||||
if is_active is not None:
|
||||
query = query.where(User.is_active == is_active)
|
||||
count_query = count_query.where(User.is_active == is_active)
|
||||
if role:
|
||||
query = query.where(User.role == role)
|
||||
count_query = count_query.where(User.role == role)
|
||||
if account_id:
|
||||
query = query.where(User.account_id == account_id)
|
||||
count_query = count_query.where(User.account_id == account_id)
|
||||
if search:
|
||||
search_term = f"%{search.strip()}%"
|
||||
search_filter = or_(
|
||||
User.name.ilike(search_term),
|
||||
User.email.ilike(search_term),
|
||||
Account.name.ilike(search_term),
|
||||
Account.display_code.ilike(search_term),
|
||||
)
|
||||
query = query.where(search_filter)
|
||||
count_query = count_query.where(search_filter)
|
||||
|
||||
query = query.order_by(User.created_at.desc()).offset(skip).limit(limit)
|
||||
total_result = await db.execute(count_query)
|
||||
total = total_result.scalar() or 0
|
||||
|
||||
query = query.order_by(User.created_at.desc()).offset(resolved_skip).limit(resolved_limit)
|
||||
result = await db.execute(query)
|
||||
users = result.scalars().all()
|
||||
return users
|
||||
rows = result.all()
|
||||
|
||||
items = [
|
||||
AdminUserListItem(
|
||||
id=user.id,
|
||||
email=user.email,
|
||||
name=user.name,
|
||||
role=user.role,
|
||||
is_super_admin=user.is_super_admin,
|
||||
is_active=user.is_active,
|
||||
account_id=user.account_id,
|
||||
account_role=user.account_role,
|
||||
account_name=account_name,
|
||||
account_display_code=account_display_code,
|
||||
created_at=user.created_at,
|
||||
last_login=user.last_login,
|
||||
deleted_at=user.deleted_at,
|
||||
)
|
||||
for user, account_name, account_display_code in rows
|
||||
]
|
||||
|
||||
return AdminUserListResponse(
|
||||
items=items,
|
||||
total=total,
|
||||
page=current_page,
|
||||
per_page=resolved_limit,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/accounts", response_model=AdminAccountListResponse)
|
||||
async def list_accounts(
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
current_user: Annotated[User, Depends(require_admin)],
|
||||
page: int = Query(1, ge=1),
|
||||
size: int = Query(12, ge=1, le=100),
|
||||
search: Optional[str] = Query(None, description="Search by account, display code, or owner"),
|
||||
plan: Optional[str] = Query(None, description="Filter by subscription plan"),
|
||||
status: Optional[str] = Query(None, description="Filter by subscription status"),
|
||||
include_archived: bool = Query(False, description="Include archived users in account member lists"),
|
||||
):
|
||||
"""List accounts with embedded members for the admin panel."""
|
||||
owner_user = aliased(User)
|
||||
|
||||
count_query = (
|
||||
select(func.count(func.distinct(Account.id)))
|
||||
.select_from(Account)
|
||||
.outerjoin(owner_user, Account.owner_id == owner_user.id)
|
||||
.outerjoin(Subscription, Subscription.account_id == Account.id)
|
||||
)
|
||||
accounts_query = (
|
||||
select(
|
||||
Account,
|
||||
owner_user.id.label("owner_user_id"),
|
||||
owner_user.name.label("owner_name"),
|
||||
owner_user.email.label("owner_email"),
|
||||
Subscription.id.label("subscription_id"),
|
||||
Subscription.plan.label("subscription_plan"),
|
||||
Subscription.status.label("subscription_status"),
|
||||
Subscription.billing_interval.label("subscription_billing_interval"),
|
||||
Subscription.current_period_end.label("subscription_current_period_end"),
|
||||
Subscription.cancel_at_period_end.label("subscription_cancel_at_period_end"),
|
||||
)
|
||||
.outerjoin(owner_user, Account.owner_id == owner_user.id)
|
||||
.outerjoin(Subscription, Subscription.account_id == Account.id)
|
||||
)
|
||||
|
||||
if search:
|
||||
search_term = f"%{search.strip()}%"
|
||||
search_filter = or_(
|
||||
Account.name.ilike(search_term),
|
||||
Account.display_code.ilike(search_term),
|
||||
owner_user.name.ilike(search_term),
|
||||
owner_user.email.ilike(search_term),
|
||||
)
|
||||
count_query = count_query.where(search_filter)
|
||||
accounts_query = accounts_query.where(search_filter)
|
||||
if plan:
|
||||
count_query = count_query.where(Subscription.plan == plan)
|
||||
accounts_query = accounts_query.where(Subscription.plan == plan)
|
||||
if status:
|
||||
count_query = count_query.where(Subscription.status == status)
|
||||
accounts_query = accounts_query.where(Subscription.status == status)
|
||||
|
||||
total_result = await db.execute(count_query)
|
||||
total = total_result.scalar() or 0
|
||||
|
||||
accounts_result = await db.execute(
|
||||
accounts_query
|
||||
.order_by(Account.created_at.desc())
|
||||
.offset((page - 1) * size)
|
||||
.limit(size)
|
||||
)
|
||||
rows = accounts_result.all()
|
||||
accounts = [row.Account for row in rows]
|
||||
|
||||
account_ids = [account.id for account in accounts]
|
||||
members_by_account: dict[UUID, list[AdminAccountMember]] = {account_id: [] for account_id in account_ids}
|
||||
pending_invites_by_account: dict[UUID, int] = {account_id: 0 for account_id in account_ids}
|
||||
usage_by_account: dict[UUID, AdminAccountUsageSummary] = {}
|
||||
|
||||
if account_ids:
|
||||
members_query = select(User).where(User.account_id.in_(account_ids))
|
||||
if not include_archived:
|
||||
members_query = members_query.where(User.deleted_at.is_(None))
|
||||
members_query = members_query.order_by(User.created_at.asc())
|
||||
|
||||
members_result = await db.execute(members_query)
|
||||
for member in members_result.scalars().all():
|
||||
members_by_account.setdefault(member.account_id, []).append(
|
||||
AdminAccountMember(
|
||||
id=member.id,
|
||||
email=member.email,
|
||||
name=member.name,
|
||||
role=member.role,
|
||||
is_super_admin=member.is_super_admin,
|
||||
is_active=member.is_active,
|
||||
account_role=member.account_role,
|
||||
created_at=member.created_at,
|
||||
last_login=member.last_login,
|
||||
deleted_at=member.deleted_at,
|
||||
)
|
||||
)
|
||||
|
||||
pending_invites_result = await db.execute(
|
||||
select(AccountInvite.account_id, func.count(AccountInvite.id))
|
||||
.where(
|
||||
AccountInvite.account_id.in_(account_ids),
|
||||
AccountInvite.used_at.is_(None),
|
||||
)
|
||||
.group_by(AccountInvite.account_id)
|
||||
)
|
||||
pending_invites_by_account.update({row[0]: row[1] for row in pending_invites_result.all()})
|
||||
|
||||
for account_id in account_ids:
|
||||
usage = await get_account_usage(account_id, db)
|
||||
usage_by_account[account_id] = AdminAccountUsageSummary(
|
||||
tree_count=usage.get("tree_count", 0),
|
||||
session_count_this_month=usage.get("session_count_this_month", 0),
|
||||
)
|
||||
|
||||
items = [
|
||||
AdminAccountListItem(
|
||||
id=row.Account.id,
|
||||
name=row.Account.name,
|
||||
display_code=row.Account.display_code,
|
||||
created_at=row.Account.created_at,
|
||||
owner_id=row.Account.owner_id,
|
||||
owner=(
|
||||
AdminAccountOwnerSummary(
|
||||
id=row.owner_user_id,
|
||||
name=row.owner_name,
|
||||
email=row.owner_email,
|
||||
) if row.owner_user_id and row.owner_name and row.owner_email else None
|
||||
),
|
||||
subscription=(
|
||||
AdminAccountSubscriptionSummary(
|
||||
id=row.subscription_id,
|
||||
plan=row.subscription_plan,
|
||||
status=row.subscription_status,
|
||||
billing_interval=row.subscription_billing_interval,
|
||||
current_period_end=row.subscription_current_period_end,
|
||||
cancel_at_period_end=row.subscription_cancel_at_period_end or False,
|
||||
) if row.subscription_id and row.subscription_plan and row.subscription_status else None
|
||||
),
|
||||
usage=usage_by_account.get(row.Account.id, AdminAccountUsageSummary()),
|
||||
member_count=len(members_by_account.get(row.Account.id, [])),
|
||||
active_member_count=sum(1 for member in members_by_account.get(row.Account.id, []) if member.is_active),
|
||||
pending_invite_count=pending_invites_by_account.get(row.Account.id, 0),
|
||||
sso_enabled=row.Account.sso_enabled,
|
||||
branding_company_name=row.Account.branding_company_name,
|
||||
members=members_by_account.get(row.Account.id, []),
|
||||
)
|
||||
for row in rows
|
||||
]
|
||||
|
||||
return AdminAccountListResponse(
|
||||
items=items,
|
||||
total=total,
|
||||
page=page,
|
||||
per_page=size,
|
||||
)
|
||||
|
||||
|
||||
def _generate_display_code() -> str:
|
||||
@@ -71,6 +311,183 @@ def _generate_display_code() -> str:
|
||||
return ''.join(secrets.choice(chars) for _ in range(8))
|
||||
|
||||
|
||||
async def _generate_unique_display_code(db: AsyncSession) -> str:
|
||||
"""Generate a unique display code for a new account."""
|
||||
while True:
|
||||
display_code = _generate_display_code()
|
||||
existing = await db.execute(select(Account.id).where(Account.display_code == display_code))
|
||||
if existing.scalar_one_or_none() is None:
|
||||
return display_code
|
||||
|
||||
|
||||
async def _get_account_detail_payload(
|
||||
account_id: UUID,
|
||||
db: AsyncSession,
|
||||
include_archived: bool = False,
|
||||
) -> AdminAccountDetailResponse:
|
||||
owner_user = aliased(User)
|
||||
result = await db.execute(
|
||||
select(
|
||||
Account,
|
||||
owner_user.id.label("owner_user_id"),
|
||||
owner_user.name.label("owner_name"),
|
||||
owner_user.email.label("owner_email"),
|
||||
Subscription.id.label("subscription_id"),
|
||||
Subscription.plan.label("subscription_plan"),
|
||||
Subscription.status.label("subscription_status"),
|
||||
Subscription.billing_interval.label("subscription_billing_interval"),
|
||||
Subscription.current_period_end.label("subscription_current_period_end"),
|
||||
Subscription.cancel_at_period_end.label("subscription_cancel_at_period_end"),
|
||||
)
|
||||
.outerjoin(owner_user, Account.owner_id == owner_user.id)
|
||||
.outerjoin(Subscription, Subscription.account_id == Account.id)
|
||||
.where(Account.id == account_id)
|
||||
)
|
||||
row = result.one_or_none()
|
||||
if not row:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Account not found")
|
||||
|
||||
members_query = select(User).where(User.account_id == account_id).order_by(User.created_at.asc())
|
||||
if not include_archived:
|
||||
members_query = members_query.where(User.deleted_at.is_(None))
|
||||
members_result = await db.execute(members_query)
|
||||
members = [
|
||||
AdminAccountMember(
|
||||
id=member.id,
|
||||
email=member.email,
|
||||
name=member.name,
|
||||
role=member.role,
|
||||
is_super_admin=member.is_super_admin,
|
||||
is_active=member.is_active,
|
||||
account_role=member.account_role,
|
||||
created_at=member.created_at,
|
||||
last_login=member.last_login,
|
||||
deleted_at=member.deleted_at,
|
||||
)
|
||||
for member in members_result.scalars().all()
|
||||
]
|
||||
|
||||
invites_result = await db.execute(
|
||||
select(AccountInvite)
|
||||
.where(AccountInvite.account_id == account_id)
|
||||
.order_by(AccountInvite.created_at.desc())
|
||||
)
|
||||
invites = [
|
||||
AdminAccountInviteSummary(
|
||||
id=invite.id,
|
||||
email=invite.email,
|
||||
role=invite.role,
|
||||
expires_at=invite.expires_at,
|
||||
created_at=invite.created_at,
|
||||
used_at=invite.used_at,
|
||||
)
|
||||
for invite in invites_result.scalars().all()
|
||||
if invite.used_at is None
|
||||
]
|
||||
|
||||
usage = await get_account_usage(account_id, db)
|
||||
|
||||
return AdminAccountDetailResponse(
|
||||
id=row.Account.id,
|
||||
name=row.Account.name,
|
||||
display_code=row.Account.display_code,
|
||||
created_at=row.Account.created_at,
|
||||
owner_id=row.Account.owner_id,
|
||||
owner=(
|
||||
AdminAccountOwnerSummary(
|
||||
id=row.owner_user_id,
|
||||
name=row.owner_name,
|
||||
email=row.owner_email,
|
||||
) if row.owner_user_id and row.owner_name and row.owner_email else None
|
||||
),
|
||||
subscription=(
|
||||
AdminAccountSubscriptionSummary(
|
||||
id=row.subscription_id,
|
||||
plan=row.subscription_plan,
|
||||
status=row.subscription_status,
|
||||
billing_interval=row.subscription_billing_interval,
|
||||
current_period_end=row.subscription_current_period_end,
|
||||
cancel_at_period_end=row.subscription_cancel_at_period_end or False,
|
||||
) if row.subscription_id and row.subscription_plan and row.subscription_status else None
|
||||
),
|
||||
usage=AdminAccountUsageSummary(
|
||||
tree_count=usage.get("tree_count", 0),
|
||||
session_count_this_month=usage.get("session_count_this_month", 0),
|
||||
),
|
||||
member_count=len(members),
|
||||
active_member_count=sum(1 for member in members if member.is_active),
|
||||
pending_invite_count=len(invites),
|
||||
sso_enabled=row.Account.sso_enabled,
|
||||
branding_company_name=row.Account.branding_company_name,
|
||||
members=members,
|
||||
invites=invites,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/accounts", response_model=AdminAccountDetailResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_account(
|
||||
data: AdminAccountCreate,
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
current_user: Annotated[User, Depends(require_admin)],
|
||||
):
|
||||
"""Create a new account without requiring an initial user."""
|
||||
display_code = await _generate_unique_display_code(db)
|
||||
new_account = Account(
|
||||
name=data.name.strip(),
|
||||
display_code=display_code,
|
||||
)
|
||||
db.add(new_account)
|
||||
await db.flush()
|
||||
|
||||
new_subscription = Subscription(
|
||||
account_id=new_account.id,
|
||||
plan=data.plan,
|
||||
status="active",
|
||||
)
|
||||
db.add(new_subscription)
|
||||
|
||||
await log_audit(
|
||||
db, current_user.id, "account.create_admin", "account", new_account.id,
|
||||
{"name": new_account.name, "plan": data.plan},
|
||||
)
|
||||
await db.commit()
|
||||
return await _get_account_detail_payload(new_account.id, db)
|
||||
|
||||
|
||||
@router.get("/accounts/{account_id}", response_model=AdminAccountDetailResponse)
|
||||
async def get_account_detail(
|
||||
account_id: UUID,
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
current_user: Annotated[User, Depends(require_admin)],
|
||||
include_archived: bool = Query(False),
|
||||
):
|
||||
"""Get detailed account information for admin management."""
|
||||
return await _get_account_detail_payload(account_id, db, include_archived=include_archived)
|
||||
|
||||
|
||||
@router.put("/accounts/{account_id}", response_model=AdminAccountDetailResponse)
|
||||
async def update_account(
|
||||
account_id: UUID,
|
||||
data: AdminAccountUpdate,
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
current_user: Annotated[User, Depends(require_admin)],
|
||||
):
|
||||
"""Update account settings from the admin panel."""
|
||||
result = await db.execute(select(Account).where(Account.id == account_id))
|
||||
account = result.scalar_one_or_none()
|
||||
if not account:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Account not found")
|
||||
|
||||
old_name = account.name
|
||||
account.name = data.name.strip()
|
||||
await log_audit(
|
||||
db, current_user.id, "account.update_admin", "account", account.id,
|
||||
{"old_name": old_name, "new_name": account.name},
|
||||
)
|
||||
await db.commit()
|
||||
return await _get_account_detail_payload(account.id, db)
|
||||
|
||||
|
||||
@router.post("/users", response_model=AdminUserCreateResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_user(
|
||||
data: AdminUserCreate,
|
||||
@@ -516,6 +933,28 @@ async def _get_user_subscription(user_id: UUID, db: AsyncSession) -> tuple[User,
|
||||
return user, subscription
|
||||
|
||||
|
||||
async def _get_account_subscription(account_id: UUID, db: AsyncSession) -> tuple[Account, Subscription]:
|
||||
"""Helper to load account and its subscription."""
|
||||
account_result = await db.execute(select(Account).where(Account.id == account_id))
|
||||
account = account_result.scalar_one_or_none()
|
||||
if not account:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Account not found")
|
||||
|
||||
sub_result = await db.execute(
|
||||
select(Subscription).where(Subscription.account_id == account.id)
|
||||
)
|
||||
subscription = sub_result.scalar_one_or_none()
|
||||
if not subscription:
|
||||
subscription = Subscription(
|
||||
account_id=account.id,
|
||||
plan="free",
|
||||
status="active",
|
||||
)
|
||||
db.add(subscription)
|
||||
await db.flush()
|
||||
return account, subscription
|
||||
|
||||
|
||||
@router.put("/users/{user_id}/subscription/plan")
|
||||
async def update_user_plan(
|
||||
user_id: UUID,
|
||||
@@ -535,6 +974,31 @@ async def update_user_plan(
|
||||
return {"plan": subscription.plan, "status": subscription.status}
|
||||
|
||||
|
||||
@router.put("/accounts/{account_id}/subscription/plan")
|
||||
async def update_account_plan(
|
||||
account_id: UUID,
|
||||
data: SubscriptionPlanUpdate,
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
current_user: Annotated[User, Depends(require_admin)],
|
||||
):
|
||||
"""Change an account subscription plan (super admin only)."""
|
||||
if data.plan not in ("free", "pro", "team"):
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid plan")
|
||||
account, subscription = await _get_account_subscription(account_id, db)
|
||||
old_plan = subscription.plan
|
||||
subscription.plan = data.plan
|
||||
await log_audit(
|
||||
db,
|
||||
current_user.id,
|
||||
"subscription.plan_change",
|
||||
"subscription",
|
||||
subscription.id,
|
||||
{"old_plan": old_plan, "new_plan": data.plan, "account_id": str(account_id)},
|
||||
)
|
||||
await db.commit()
|
||||
return {"plan": subscription.plan, "status": subscription.status}
|
||||
|
||||
|
||||
@router.put("/users/{user_id}/subscription/extend-trial")
|
||||
async def extend_user_trial(
|
||||
user_id: UUID,
|
||||
@@ -565,6 +1029,43 @@ async def extend_user_trial(
|
||||
"current_period_end": subscription.current_period_end}
|
||||
|
||||
|
||||
@router.put("/accounts/{account_id}/subscription/extend-trial")
|
||||
async def extend_account_trial(
|
||||
account_id: UUID,
|
||||
data: ExtendTrialRequest,
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
current_user: Annotated[User, Depends(require_admin)],
|
||||
):
|
||||
"""Extend or start a trial for an account subscription (super admin only)."""
|
||||
if data.days < 1 or data.days > 90:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Days must be 1-90")
|
||||
account, subscription = await _get_account_subscription(account_id, db)
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
if subscription.status == "trialing" and subscription.current_period_end:
|
||||
new_end = subscription.current_period_end + timedelta(days=data.days)
|
||||
else:
|
||||
subscription.status = "trialing"
|
||||
subscription.current_period_start = now
|
||||
new_end = now + timedelta(days=data.days)
|
||||
|
||||
subscription.current_period_end = new_end
|
||||
await log_audit(
|
||||
db,
|
||||
current_user.id,
|
||||
"subscription.extend_trial",
|
||||
"subscription",
|
||||
subscription.id,
|
||||
{"days": data.days, "new_end": new_end.isoformat(), "account_id": str(account.id)},
|
||||
)
|
||||
await db.commit()
|
||||
return {
|
||||
"plan": subscription.plan,
|
||||
"status": subscription.status,
|
||||
"current_period_end": subscription.current_period_end,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/users/{user_id}/password-reset", response_model=AdminPasswordResetResponse)
|
||||
async def admin_reset_password(
|
||||
user_id: UUID,
|
||||
|
||||
@@ -49,6 +49,8 @@ from app.schemas.ai_session import (
|
||||
ChatMessageRequest,
|
||||
ChatMessageResponse,
|
||||
SaveTaskLaneRequest,
|
||||
TriagePatchRequest,
|
||||
TriagePatchResponse,
|
||||
)
|
||||
from app.services import flowpilot_engine
|
||||
from app.services import unified_chat_service
|
||||
@@ -120,6 +122,11 @@ def _build_session_detail(session: AISession) -> AISessionDetail:
|
||||
pending_task_lane=session.pending_task_lane,
|
||||
is_branching=getattr(session, 'is_branching', False),
|
||||
active_branch_id=str(session.active_branch_id) if getattr(session, 'active_branch_id', None) else None,
|
||||
client_name=getattr(session, 'client_name', None),
|
||||
asset_name=getattr(session, 'asset_name', None),
|
||||
issue_category=getattr(session, 'issue_category', None),
|
||||
triage_hypothesis=getattr(session, 'triage_hypothesis', None),
|
||||
evidence_items=getattr(session, 'evidence_items', None),
|
||||
)
|
||||
|
||||
|
||||
@@ -301,7 +308,7 @@ async def send_chat_message(
|
||||
message = f"{message}\n\n[Attached document content]\n{doc_context}"
|
||||
|
||||
try:
|
||||
ai_content, suggested_flows, session, fork_metadata, actions_data, questions_data = await unified_chat_service.send_chat_message(
|
||||
ai_content, suggested_flows, session, fork_metadata, actions_data, questions_data, triage_update_data = await unified_chat_service.send_chat_message(
|
||||
session_id=session_id,
|
||||
user_id=user_id,
|
||||
account_id=account_id,
|
||||
@@ -346,6 +353,7 @@ async def send_chat_message(
|
||||
fork=fork_metadata,
|
||||
actions=actions_data,
|
||||
questions=questions_data,
|
||||
triage_update=triage_update_data,
|
||||
)
|
||||
|
||||
|
||||
@@ -442,7 +450,12 @@ async def resolve_session(
|
||||
try:
|
||||
from app.services.resolution_output_generator import ResolutionOutputGenerator
|
||||
gen = ResolutionOutputGenerator(db)
|
||||
await gen.generate_all(session_id)
|
||||
await gen.generate_all(
|
||||
session_id,
|
||||
root_cause=data.root_cause,
|
||||
steps_taken=data.steps_taken,
|
||||
recommendations=data.recommendations,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception(f"Failed to generate resolution outputs for session {session_id}")
|
||||
|
||||
@@ -540,6 +553,122 @@ async def save_task_lane(
|
||||
await db.commit()
|
||||
|
||||
|
||||
# ── Triage Metadata ──
|
||||
|
||||
@router.patch("/{session_id}/triage", response_model=TriagePatchResponse)
|
||||
@limiter.limit("30/minute")
|
||||
async def update_triage(
|
||||
request: Request,
|
||||
session_id: UUID,
|
||||
body: TriagePatchRequest,
|
||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
_: None = Depends(require_engineer_or_admin),
|
||||
):
|
||||
"""Update triage metadata on a session (incident header fields)."""
|
||||
session = await db.get(AISession, session_id)
|
||||
if not session:
|
||||
raise HTTPException(status_code=404, detail="Session not found")
|
||||
if session.user_id != current_user.id:
|
||||
raise HTTPException(status_code=403, detail="Not your session")
|
||||
|
||||
patch_data = body.model_dump(exclude_unset=True)
|
||||
for field, value in patch_data.items():
|
||||
setattr(session, field, value)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(session)
|
||||
|
||||
return TriagePatchResponse(
|
||||
id=session.id,
|
||||
client_name=session.client_name,
|
||||
asset_name=session.asset_name,
|
||||
issue_category=session.issue_category,
|
||||
triage_hypothesis=session.triage_hypothesis,
|
||||
evidence_items=session.evidence_items,
|
||||
)
|
||||
|
||||
|
||||
# ── Handoff Draft ──
|
||||
|
||||
@router.post("/{session_id}/handoff-draft")
|
||||
@limiter.limit("10/minute")
|
||||
async def handoff_draft(
|
||||
request: Request,
|
||||
session_id: UUID,
|
||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
_: None = Depends(require_engineer_or_admin),
|
||||
):
|
||||
"""Stream a structured handoff draft for the conclude modal."""
|
||||
from fastapi.responses import StreamingResponse
|
||||
from app.services.assistant_chat_service import _call_ai
|
||||
|
||||
session = await db.get(AISession, session_id)
|
||||
if not session:
|
||||
raise HTTPException(status_code=404, detail="Session not found")
|
||||
if session.user_id != current_user.id:
|
||||
raise HTTPException(status_code=403, detail="Not your session")
|
||||
|
||||
# Build context from session data
|
||||
context_parts = [
|
||||
f"Problem: {session.problem_summary or 'Unknown'}",
|
||||
f"Domain: {session.problem_domain or 'Unknown'}",
|
||||
f"Client: {session.client_name or 'Unknown'}",
|
||||
f"Asset: {session.asset_name or 'Unknown'}",
|
||||
f"Hypothesis: {session.triage_hypothesis or 'None'}",
|
||||
]
|
||||
|
||||
if session.evidence_items:
|
||||
context_parts.append("\nEvidence collected:")
|
||||
for item in session.evidence_items:
|
||||
status_icon = {"confirmed": "✓", "ruled_out": "✗", "pending": "?"}.get(item.get("status", ""), "?")
|
||||
context_parts.append(f" {status_icon} {item.get('text', '')}")
|
||||
|
||||
# Include task lane steps if available
|
||||
if session.pending_task_lane:
|
||||
actions = session.pending_task_lane.get("actions", [])
|
||||
if actions:
|
||||
context_parts.append("\nSteps taken:")
|
||||
for a in actions:
|
||||
context_parts.append(f" - {a.get('label', '')}")
|
||||
|
||||
# Include last 20 conversation messages
|
||||
msgs = session.conversation_messages or []
|
||||
if msgs:
|
||||
context_parts.append("\nRecent conversation:")
|
||||
for msg in msgs[-20:]:
|
||||
role = msg.get("role", "unknown")
|
||||
content = msg.get("content", "")[:300]
|
||||
context_parts.append(f" [{role}]: {content}")
|
||||
|
||||
context = "\n".join(context_parts)
|
||||
|
||||
prompt = (
|
||||
"Generate a structured handoff summary for this troubleshooting session.\n"
|
||||
"Return ONLY valid JSON with exactly these four fields:\n"
|
||||
'{"root_cause": "...", "resolution": "...", "steps_taken": ["step1", "step2"], "recommendations": "..."}\n\n'
|
||||
f"Session context:\n{context}"
|
||||
)
|
||||
|
||||
async def generate():
|
||||
try:
|
||||
content, _, _ = await _call_ai(
|
||||
system_base="You are a concise technical documentation assistant for MSP teams. Return only JSON.",
|
||||
rag_context="",
|
||||
history=[],
|
||||
new_message=prompt,
|
||||
max_tokens=1024,
|
||||
)
|
||||
yield f"data: {content}\n\n"
|
||||
except Exception as e:
|
||||
logger.exception(f"Handoff draft generation failed for session {session_id}")
|
||||
import json
|
||||
yield f"data: {json.dumps({'error': str(e)})}\n\n"
|
||||
|
||||
return StreamingResponse(generate(), media_type="text/event-stream")
|
||||
|
||||
|
||||
# ── Resume ──
|
||||
|
||||
@router.post("/{session_id}/resume", status_code=204)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import secrets
|
||||
import string
|
||||
import uuid
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from typing import Annotated
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Request
|
||||
@@ -26,6 +27,7 @@ from app.models.refresh_token import RefreshToken
|
||||
from app.models.account import Account
|
||||
from app.models.subscription import Subscription
|
||||
from app.models.account_invite import AccountInvite
|
||||
from app.models.feature_flag import FeatureFlag, PlanFeatureDefault, AccountFeatureOverride
|
||||
from app.schemas.user import UserCreate, UserResponse, UserLogin, UserUpdate
|
||||
from app.schemas.token import Token
|
||||
from app.schemas.auth_password import (
|
||||
@@ -718,3 +720,59 @@ async def verify_email(
|
||||
await db.commit()
|
||||
|
||||
return {"message": "Email verified successfully"}
|
||||
|
||||
|
||||
@router.get("/me/feature-flags", response_model=dict[str, bool])
|
||||
async def get_my_feature_flags(
|
||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
) -> dict[str, bool]:
|
||||
"""Resolve feature flags for the current user's account and plan."""
|
||||
plan = "free"
|
||||
if current_user.account_id:
|
||||
sub_result = await db.execute(
|
||||
select(Subscription).where(
|
||||
Subscription.account_id == current_user.account_id,
|
||||
Subscription.status.in_(["active", "trialing"]),
|
||||
)
|
||||
)
|
||||
sub = sub_result.scalar_one_or_none()
|
||||
if sub:
|
||||
plan = sub.plan
|
||||
|
||||
flags_result = await db.execute(select(FeatureFlag))
|
||||
flags = flags_result.scalars().all()
|
||||
|
||||
if not flags:
|
||||
return {}
|
||||
|
||||
flag_ids = [f.id for f in flags]
|
||||
|
||||
defaults_result = await db.execute(
|
||||
select(PlanFeatureDefault).where(
|
||||
PlanFeatureDefault.flag_id.in_(flag_ids),
|
||||
PlanFeatureDefault.plan == plan,
|
||||
)
|
||||
)
|
||||
plan_defaults = {d.flag_id: d.enabled for d in defaults_result.scalars().all()}
|
||||
|
||||
overrides: dict[uuid.UUID, bool] = {}
|
||||
if current_user.account_id:
|
||||
overrides_result = await db.execute(
|
||||
select(AccountFeatureOverride).where(
|
||||
AccountFeatureOverride.flag_id.in_(flag_ids),
|
||||
AccountFeatureOverride.account_id == current_user.account_id,
|
||||
)
|
||||
)
|
||||
overrides = {o.flag_id: o.enabled for o in overrides_result.scalars().all()}
|
||||
|
||||
resolved = {}
|
||||
for flag in flags:
|
||||
if flag.id in overrides:
|
||||
resolved[flag.flag_key] = overrides[flag.id]
|
||||
elif flag.id in plan_defaults:
|
||||
resolved[flag.flag_key] = plan_defaults[flag.id]
|
||||
else:
|
||||
resolved[flag.flag_key] = False
|
||||
|
||||
return resolved
|
||||
|
||||
119
backend/app/api/endpoints/device_types.py
Normal file
119
backend/app/api/endpoints/device_types.py
Normal file
@@ -0,0 +1,119 @@
|
||||
"""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,
|
||||
)
|
||||
|
||||
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.is_system.is_(True),
|
||||
DeviceType.team_id == current_user.team_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.team_id == current_user.team_id,
|
||||
)
|
||||
)
|
||||
if existing.scalar_one_or_none():
|
||||
raise HTTPException(status_code=409, detail=f"Device type '{data.slug}' already exists for your team")
|
||||
|
||||
system_existing = await db.execute(
|
||||
select(DeviceType).where(
|
||||
DeviceType.slug == data.slug,
|
||||
DeviceType.is_system.is_(True),
|
||||
)
|
||||
)
|
||||
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,
|
||||
team_id=current_user.team_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.team_id != current_user.team_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.team_id != current_user.team_id:
|
||||
raise HTTPException(status_code=404, detail="Device type not found")
|
||||
|
||||
await db.delete(device_type)
|
||||
await db.commit()
|
||||
332
backend/app/api/endpoints/network_diagrams.py
Normal file
332
backend/app/api/endpoints/network_diagrams.py
Normal file
@@ -0,0 +1,332 @@
|
||||
"""Network diagrams API endpoints."""
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from typing import Annotated
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
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.schemas.network_diagram import (
|
||||
NetworkDiagramCreate,
|
||||
NetworkDiagramUpdate,
|
||||
NetworkDiagramResponse,
|
||||
NetworkDiagramListItem,
|
||||
AIGenerateRequest,
|
||||
AIGenerateResponse,
|
||||
DiagramImportRequest,
|
||||
DiagramImportResponse,
|
||||
DiagramExportResponse,
|
||||
DiagramNode,
|
||||
DiagramEdge,
|
||||
)
|
||||
from app.services import network_diagram_ai_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,
|
||||
team_id: UUID,
|
||||
db: AsyncSession,
|
||||
) -> NetworkDiagram:
|
||||
diagram = await db.get(NetworkDiagram, diagram_id)
|
||||
if not diagram or diagram.team_id != team_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,
|
||||
created_by=diagram.created_by,
|
||||
created_at=diagram.created_at,
|
||||
updated_at=diagram.updated_at,
|
||||
)
|
||||
|
||||
|
||||
async def _get_available_slugs(team_id: UUID, db: AsyncSession) -> set[str]:
|
||||
stmt = select(DeviceType.slug).where(
|
||||
or_(DeviceType.is_system.is_(True), DeviceType.team_id == team_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.team_id == current_user.team_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.team_id == current_user.team_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.team_id == current_user.team_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:
|
||||
if current_user.team_id is None:
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail="Network Diagrams require a team account. Assign your account to a team first.",
|
||||
)
|
||||
diagram = NetworkDiagram(
|
||||
team_id=current_user.team_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.team_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.team_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.team_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.team_id, db)
|
||||
copy = NetworkDiagram(
|
||||
team_id=current_user.team_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.team_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.team_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(
|
||||
team_id=current_user.team_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,
|
||||
)
|
||||
|
||||
|
||||
@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.team_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")
|
||||
@@ -33,6 +33,8 @@ from app.api.endpoints import beta_feedback
|
||||
from app.api.endpoints import session_branches
|
||||
from app.api.endpoints import session_handoffs
|
||||
from app.api.endpoints import session_resolutions
|
||||
from app.api.endpoints import device_types
|
||||
from app.api.endpoints import network_diagrams
|
||||
|
||||
api_router = APIRouter()
|
||||
|
||||
@@ -79,6 +81,7 @@ api_router.include_router(integrations.router)
|
||||
api_router.include_router(onboarding.router)
|
||||
api_router.include_router(branding.router)
|
||||
api_router.include_router(supporting_data.router)
|
||||
api_router.include_router(network_diagrams.router) # Must be before ai_sessions to avoid /{diagram_id} conflict
|
||||
api_router.include_router(session_handoffs.queue_router) # Must be before ai_sessions to avoid /{session_id} conflict
|
||||
api_router.include_router(session_resolutions.router) # Must be before ai_sessions to avoid /{session_id} conflict
|
||||
api_router.include_router(ai_sessions.router)
|
||||
@@ -92,3 +95,4 @@ api_router.include_router(script_builder.router)
|
||||
api_router.include_router(beta_feedback.router)
|
||||
api_router.include_router(session_branches.router)
|
||||
api_router.include_router(session_handoffs.router)
|
||||
api_router.include_router(device_types.router)
|
||||
|
||||
@@ -105,6 +105,7 @@ 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:
|
||||
|
||||
@@ -54,6 +54,8 @@ from .session_branch import SessionBranch
|
||||
from .fork_point import ForkPoint
|
||||
from .session_handoff import SessionHandoff
|
||||
from .session_resolution_output import SessionResolutionOutput
|
||||
from .device_type import DeviceType
|
||||
from .network_diagram import NetworkDiagram
|
||||
|
||||
__all__ = [
|
||||
"User",
|
||||
@@ -122,4 +124,6 @@ __all__ = [
|
||||
"ForkPoint",
|
||||
"SessionHandoff",
|
||||
"SessionResolutionOutput",
|
||||
"DeviceType",
|
||||
"NetworkDiagram",
|
||||
]
|
||||
|
||||
@@ -137,6 +137,28 @@ class AISession(Base):
|
||||
comment="Snapshot of PSA ticket data at session start",
|
||||
)
|
||||
|
||||
# ── Triage / Cockpit Header ──
|
||||
client_name: Mapped[Optional[str]] = mapped_column(
|
||||
String(255), nullable=True,
|
||||
comment="MSP client name for incident header (AI-inferred or manual)",
|
||||
)
|
||||
asset_name: Mapped[Optional[str]] = mapped_column(
|
||||
String(255), nullable=True,
|
||||
comment="Device, asset, or user being worked on",
|
||||
)
|
||||
issue_category: Mapped[Optional[str]] = mapped_column(
|
||||
String(100), nullable=True,
|
||||
comment="Human-readable category (e.g. DNS / Networking)",
|
||||
)
|
||||
triage_hypothesis: Mapped[Optional[str]] = mapped_column(
|
||||
Text, nullable=True,
|
||||
comment="Current working hypothesis — AI-updated + engineer-editable",
|
||||
)
|
||||
evidence_items: Mapped[Optional[list[dict[str, Any]]]] = mapped_column(
|
||||
JSONB, nullable=True,
|
||||
comment='What We Know list: [{"text": str, "status": "confirmed"|"ruled_out"|"pending"}]',
|
||||
)
|
||||
|
||||
# ── Resolution / Escalation ──
|
||||
resolution_summary: Mapped[Optional[str]] = mapped_column(
|
||||
Text, nullable=True,
|
||||
|
||||
47
backend/app/models/device_type.py
Normal file
47
backend/app/models/device_type.py
Normal file
@@ -0,0 +1,47 @@
|
||||
"""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 (system or team-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",
|
||||
)
|
||||
team_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("teams.id", ondelete="CASCADE"),
|
||||
nullable=True,
|
||||
comment="NULL for system types, set for team-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)
|
||||
)
|
||||
53
backend/app/models/network_diagram.py
Normal file
53
backend/app/models/network_diagram.py
Normal file
@@ -0,0 +1,53 @@
|
||||
"""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, team-scoped."""
|
||||
__tablename__ = "network_diagrams"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||
)
|
||||
team_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("teams.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
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])
|
||||
@@ -28,6 +28,110 @@ class ActivityEntry(BaseModel):
|
||||
from_attributes = True
|
||||
|
||||
|
||||
# --- Admin Accounts & People Search ---
|
||||
|
||||
class AdminUserListItem(BaseModel):
|
||||
id: UUID
|
||||
email: EmailStr
|
||||
name: str
|
||||
role: str
|
||||
is_super_admin: bool = False
|
||||
is_active: bool = True
|
||||
account_id: Optional[UUID] = None
|
||||
account_role: Optional[str] = None
|
||||
account_name: Optional[str] = None
|
||||
account_display_code: Optional[str] = None
|
||||
created_at: datetime
|
||||
last_login: Optional[datetime] = None
|
||||
deleted_at: Optional[datetime] = None
|
||||
|
||||
|
||||
class AdminUserListResponse(BaseModel):
|
||||
items: list[AdminUserListItem]
|
||||
total: int
|
||||
page: int
|
||||
per_page: int
|
||||
|
||||
|
||||
class AdminAccountMember(BaseModel):
|
||||
id: UUID
|
||||
email: EmailStr
|
||||
name: str
|
||||
role: str
|
||||
is_super_admin: bool = False
|
||||
is_active: bool = True
|
||||
account_role: Optional[str] = None
|
||||
created_at: datetime
|
||||
last_login: Optional[datetime] = None
|
||||
deleted_at: Optional[datetime] = None
|
||||
|
||||
|
||||
class AdminAccountOwnerSummary(BaseModel):
|
||||
id: UUID
|
||||
name: str
|
||||
email: EmailStr
|
||||
|
||||
|
||||
class AdminAccountSubscriptionSummary(BaseModel):
|
||||
id: UUID
|
||||
plan: str
|
||||
status: str
|
||||
billing_interval: Optional[str] = None
|
||||
current_period_end: Optional[datetime] = None
|
||||
cancel_at_period_end: bool = False
|
||||
|
||||
|
||||
class AdminAccountUsageSummary(BaseModel):
|
||||
tree_count: int = 0
|
||||
session_count_this_month: int = 0
|
||||
|
||||
|
||||
class AdminAccountInviteSummary(BaseModel):
|
||||
id: UUID
|
||||
email: EmailStr
|
||||
role: str
|
||||
expires_at: Optional[datetime] = None
|
||||
created_at: datetime
|
||||
used_at: Optional[datetime] = None
|
||||
|
||||
|
||||
class AdminAccountListItem(BaseModel):
|
||||
id: UUID
|
||||
name: str
|
||||
display_code: str
|
||||
created_at: datetime
|
||||
owner_id: Optional[UUID] = None
|
||||
owner: Optional[AdminAccountOwnerSummary] = None
|
||||
subscription: Optional[AdminAccountSubscriptionSummary] = None
|
||||
usage: AdminAccountUsageSummary = Field(default_factory=AdminAccountUsageSummary)
|
||||
member_count: int = 0
|
||||
active_member_count: int = 0
|
||||
pending_invite_count: int = 0
|
||||
sso_enabled: bool = False
|
||||
branding_company_name: Optional[str] = None
|
||||
members: list[AdminAccountMember] = Field(default_factory=list)
|
||||
|
||||
|
||||
class AdminAccountListResponse(BaseModel):
|
||||
items: list[AdminAccountListItem]
|
||||
total: int
|
||||
page: int
|
||||
per_page: int
|
||||
|
||||
|
||||
class AdminAccountDetailResponse(AdminAccountListItem):
|
||||
invites: list[AdminAccountInviteSummary] = Field(default_factory=list)
|
||||
|
||||
|
||||
class AdminAccountCreate(BaseModel):
|
||||
name: str = Field(..., min_length=1, max_length=255)
|
||||
plan: Literal["free", "pro", "team"] = "free"
|
||||
|
||||
|
||||
class AdminAccountUpdate(BaseModel):
|
||||
name: str = Field(..., min_length=1, max_length=255)
|
||||
|
||||
|
||||
# --- Audit Logs ---
|
||||
|
||||
class AuditLogEntry(BaseModel):
|
||||
|
||||
@@ -102,12 +102,20 @@ class ResolveSessionRequest(BaseModel):
|
||||
resolution_action: str | None = None
|
||||
session_rating: int | None = Field(None, ge=1, le=5)
|
||||
session_feedback: str | None = None
|
||||
# Structured handoff fields (from cockpit conclude modal)
|
||||
root_cause: str | None = None
|
||||
steps_taken: list[str] | None = None
|
||||
recommendations: str | None = None
|
||||
|
||||
|
||||
class EscalateSessionRequest(BaseModel):
|
||||
"""Escalate a session to another engineer."""
|
||||
escalation_reason: str = Field(..., min_length=5, max_length=2000)
|
||||
escalated_to_id: UUID | None = None
|
||||
# Structured handoff fields (from cockpit conclude modal)
|
||||
root_cause: str | None = None
|
||||
steps_taken: list[str] | None = None
|
||||
recommendations: str | None = None
|
||||
|
||||
|
||||
class DocumentationStep(BaseModel):
|
||||
@@ -127,6 +135,7 @@ class SessionDocumentation(BaseModel):
|
||||
diagnostic_steps: list[DocumentationStep]
|
||||
resolution_summary: str | None = None
|
||||
escalation_reason: str | None = None
|
||||
follow_up_recommendations: list[str] = []
|
||||
total_steps: int
|
||||
duration_display: str | None = None
|
||||
generated_at: datetime
|
||||
@@ -146,7 +155,7 @@ class StatusUpdateRequest(BaseModel):
|
||||
"""Generate a mid-session or post-session status update."""
|
||||
audience: str = Field(
|
||||
...,
|
||||
pattern="^(ticket_notes|client_update|email_draft)$",
|
||||
pattern="^(ticket_notes|client_update|email_draft|request_info)$",
|
||||
description="Who is this update for?",
|
||||
)
|
||||
length: str = Field(
|
||||
@@ -231,6 +240,12 @@ class AISessionDetail(AISessionSummary):
|
||||
pending_task_lane: dict[str, Any] | None = None
|
||||
is_branching: bool = False
|
||||
active_branch_id: str | None = None
|
||||
# Triage / cockpit header fields
|
||||
client_name: str | None = None
|
||||
asset_name: str | None = None
|
||||
issue_category: str | None = None
|
||||
triage_hypothesis: str | None = None
|
||||
evidence_items: list[dict[str, Any]] | None = None
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
@@ -276,6 +291,16 @@ class QuestionItem(BaseModel):
|
||||
"""A question the AI needs answered by the engineer."""
|
||||
text: str
|
||||
context: str = ""
|
||||
options: list[str] | None = None # quick-reply button labels; null = free-text input
|
||||
|
||||
|
||||
class TriageUpdate(BaseModel):
|
||||
"""AI-inferred triage metadata returned with chat responses."""
|
||||
client_name: str | None = None
|
||||
asset_name: str | None = None
|
||||
issue_category: str | None = None
|
||||
triage_hypothesis: str | None = None
|
||||
evidence_items: list[dict[str, Any]] | None = None # appends to existing list
|
||||
|
||||
|
||||
class ChatMessageResponse(BaseModel):
|
||||
@@ -285,6 +310,7 @@ class ChatMessageResponse(BaseModel):
|
||||
fork: ForkMetadata | None = None
|
||||
actions: list[ActionItem] | None = None
|
||||
questions: list[QuestionItem] | None = None
|
||||
triage_update: TriageUpdate | None = None
|
||||
|
||||
|
||||
class SaveTaskLaneRequest(BaseModel):
|
||||
@@ -307,3 +333,24 @@ class AISessionSearchResult(BaseModel):
|
||||
created_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
# ── Triage / Cockpit ──
|
||||
|
||||
class TriagePatchRequest(BaseModel):
|
||||
"""Update triage metadata on a session (incident header fields)."""
|
||||
client_name: str | None = None
|
||||
asset_name: str | None = None
|
||||
issue_category: str | None = None
|
||||
triage_hypothesis: str | None = None
|
||||
evidence_items: list[dict[str, Any]] | None = None
|
||||
|
||||
|
||||
class TriagePatchResponse(BaseModel):
|
||||
"""Updated triage metadata after a PATCH."""
|
||||
id: UUID
|
||||
client_name: str | None = None
|
||||
asset_name: str | None = None
|
||||
issue_category: str | None = None
|
||||
triage_hypothesis: str | None = None
|
||||
evidence_items: list[dict[str, Any]] | None = None
|
||||
|
||||
37
backend/app/schemas/device_type.py
Normal file
37
backend/app/schemas/device_type.py
Normal file
@@ -0,0 +1,37 @@
|
||||
"""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
|
||||
team_id: UUID | None = None
|
||||
sort_order: int
|
||||
created_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
136
backend/app/schemas/network_diagram.py
Normal file
136
backend/app/schemas/network_diagram.py
Normal file
@@ -0,0 +1,136 @@
|
||||
"""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 DiagramNode(BaseModel):
|
||||
id: str
|
||||
type: str
|
||||
label: str
|
||||
position: Position
|
||||
properties: DeviceProperties = Field(default_factory=DeviceProperties)
|
||||
|
||||
|
||||
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
|
||||
team_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)
|
||||
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
|
||||
@@ -84,9 +84,38 @@ scope narrows it to this endpoint.
|
||||
- Commands should be PowerShell unless context indicates Linux/Mac
|
||||
- For GUI-only steps, omit `command`
|
||||
|
||||
**[QUESTIONS] `options` field:**
|
||||
When a question has a small, constrained set of answers (yes/no, 2-4 choices), include \
|
||||
an `options` array with the answer labels. The engineer will see these as quick-reply buttons. \
|
||||
Example: `{"text": "Did nslookup time out or return a wrong IP?", "options": ["Timed out", "Wrong IP", "Both"]}`
|
||||
Omit `options` when the answer is open-ended.
|
||||
|
||||
**Both markers are stripped from display** — the engineer sees them as interactive UI cards, \
|
||||
not raw JSON. Put analysis BEFORE markers. Markers go at the END of your response.
|
||||
|
||||
## Triage Context Extraction
|
||||
|
||||
When you learn NEW facts about the case from the engineer's messages, emit a \
|
||||
[TRIAGE_UPDATE] marker with a JSON object containing ONLY the fields that changed. \
|
||||
Do NOT repeat unchanged fields. Only emit this marker when you have grounded evidence — \
|
||||
never guess or fabricate. If you are not confident, do not emit the marker.
|
||||
|
||||
Fields:
|
||||
- `client_name` — the MSP client/company being helped (only from explicit mention or ticket data)
|
||||
- `asset_name` — the device, user, or asset being troubleshot
|
||||
- `issue_category` — human-readable category like "DNS / Networking", "Microsoft 365", "Active Directory"
|
||||
- `triage_hypothesis` — your current working hypothesis about the root cause (update as evidence changes)
|
||||
- `evidence_items` — NEW evidence to append: `[{"text": "description", "status": "confirmed|ruled_out|pending"}]`
|
||||
|
||||
Example (only include fields that have new information):
|
||||
|
||||
[TRIAGE_UPDATE]
|
||||
{"issue_category": "DNS / Networking", "triage_hypothesis": "Corrupted DNS cache on NIC", "evidence_items": [{"text": "Gateway 192.168.1.1 reachable", "status": "confirmed"}, {"text": "DNS 1.1.1.1 timeout", "status": "ruled_out"}]}
|
||||
[/TRIAGE_UPDATE]
|
||||
|
||||
Place [TRIAGE_UPDATE] AFTER [QUESTIONS]/[ACTIONS] markers, before [FORK] if present. \
|
||||
This marker is optional — only emit it when you learn something new.
|
||||
|
||||
## Using the Team's Flow Library
|
||||
Your team has built troubleshooting flows in ResolutionFlow. When relevant flows \
|
||||
appear in the context below, reference them by name so the engineer can launch them \
|
||||
|
||||
@@ -911,16 +911,36 @@ async def generate_status_update(
|
||||
steps_summary = []
|
||||
for step in sorted(session.steps, key=lambda s: s.step_order):
|
||||
content = step.content or {}
|
||||
text = content.get("text", "")
|
||||
response = step.free_text_input or step.selected_option or ("Skipped" if step.was_skipped else None)
|
||||
if content.get("type") in ("resolution_suggestion", "briefing", "status_update"):
|
||||
continue
|
||||
text = content.get("text", "").strip()
|
||||
if not text:
|
||||
continue
|
||||
# Resolve option label instead of raw machine value
|
||||
response = None
|
||||
if step.was_skipped:
|
||||
response = "Skipped"
|
||||
elif step.selected_option and step.options_presented:
|
||||
for opt in step.options_presented:
|
||||
if opt.get("value") == step.selected_option:
|
||||
response = opt.get("label", step.selected_option)
|
||||
break
|
||||
else:
|
||||
response = step.selected_option
|
||||
elif step.selected_option:
|
||||
response = step.selected_option
|
||||
elif step.free_text_input:
|
||||
response = step.free_text_input
|
||||
outcome = None
|
||||
if step.action_result:
|
||||
outcome = "Succeeded" if step.action_result.get("success") else "Did not resolve"
|
||||
entry = f"Step {step.step_order + 1}: {text}"
|
||||
if response:
|
||||
entry += f"\n Engineer response: {response}"
|
||||
entry = f"{step.step_order + 1}. {text}"
|
||||
if response and response != "Skipped":
|
||||
entry += f" — {response}"
|
||||
elif response == "Skipped":
|
||||
entry += " (skipped)"
|
||||
if outcome:
|
||||
entry += f"\n Outcome: {outcome}"
|
||||
entry += f" [{outcome}]"
|
||||
steps_summary.append(entry)
|
||||
|
||||
steps_text = "\n".join(steps_summary) if steps_summary else "No diagnostic steps yet."
|
||||
@@ -929,13 +949,8 @@ async def generate_status_update(
|
||||
now = datetime.now(timezone.utc)
|
||||
ref_time = session.resolved_at or now
|
||||
delta = ref_time - session.created_at
|
||||
total_minutes = int(delta.total_seconds() / 60)
|
||||
if total_minutes < 60:
|
||||
time_display = f"{total_minutes} minutes"
|
||||
else:
|
||||
hours = total_minutes // 60
|
||||
remaining = total_minutes % 60
|
||||
time_display = f"{hours}h {remaining}m"
|
||||
total_hrs = round(delta.total_seconds() / 3600, 2)
|
||||
time_display = f"{total_hrs} hrs"
|
||||
|
||||
# Extract client name from intake or ticket data
|
||||
client_name = None
|
||||
@@ -1135,8 +1150,9 @@ def _build_status_update_prompt(
|
||||
|
||||
Rules:
|
||||
- Be technical, concise, and factual
|
||||
- Use markdown formatting (bold headers, bullet lists)
|
||||
- Include: current status, steps completed, findings, what's been ruled out, next steps
|
||||
- Use plain text with simple section headers (no markdown bold/bullets — PSA renders raw text)
|
||||
- Structure as: current status paragraph, then "What We Know" section, then next steps
|
||||
- "What We Know" should list confirmed findings, ruled-out causes, and open questions — keep each item to one line
|
||||
- Do NOT soften language or add pleasantries
|
||||
- Do NOT include greetings or sign-offs
|
||||
- {length_instruction}
|
||||
@@ -1147,28 +1163,54 @@ Output ONLY the update text. No JSON, no markdown code fences, no preamble."""
|
||||
|
||||
elif audience == "client_update":
|
||||
client_greeting = f"Address the client as '{client_name}'" if client_name else "Use a generic greeting like 'Hi'"
|
||||
return f"""You are generating a client-facing {context_label}.
|
||||
context_guidance = {
|
||||
"status": "We're actively working on it. Describe progress made so far and what comes next without giving a timeline.",
|
||||
"resolution": "This is good news — the issue is resolved. Summarize what was wrong and what was done in plain language.",
|
||||
"escalation": "Be reassuring — explain that a specialist is being brought in to assist, not that something failed.",
|
||||
}.get(context, "")
|
||||
return f"""You are generating a brief client-facing {context_label}.
|
||||
|
||||
Rules:
|
||||
- Be professional, reassuring, and non-technical
|
||||
- NEVER use technical jargon (no "transport rules", "MX records", "DNS", "registry", "GPO", etc.)
|
||||
- NEVER include server names, IP addresses, internal tool names, or technical identifiers
|
||||
- NEVER use technical jargon (no "transport rules", "MX records", "DNS", "registry", "GPO", "connector", etc.)
|
||||
- NEVER include server names, IP addresses, internal tool names, or ticket IDs
|
||||
- Explain findings in plain language a non-technical business owner would understand
|
||||
- {client_greeting}
|
||||
- Sign off with: {engineer_name}
|
||||
- {length_instruction}
|
||||
{"- This is good news — the issue is resolved. Summarize what was wrong and what was done in plain language." if context == "resolution" else ""}
|
||||
{"- Be reassuring — explain that a specialist is being brought in, not that something failed." if context == "escalation" else ""}
|
||||
- {context_guidance}
|
||||
|
||||
Output ONLY the update text. No JSON, no markdown code fences, no preamble."""
|
||||
|
||||
elif audience == "request_info":
|
||||
client_greeting = f"Address the client as '{client_name}'" if client_name else "Use a generic greeting like 'Hi'"
|
||||
return f"""You are generating a brief, professional message requesting information from the client.
|
||||
|
||||
Rules:
|
||||
- Be friendly, concise, and non-technical
|
||||
- Start with one sentence explaining what you're currently working on (plain language, no jargon)
|
||||
- Then list the specific questions you need answered, as a numbered list
|
||||
- Each question should be clear and answerable by a non-technical user
|
||||
- NEVER use technical jargon, server names, IP addresses, or internal tool names
|
||||
- {client_greeting}
|
||||
- Sign off with: {engineer_name}
|
||||
- Keep it short — this is a targeted ask, not a status update
|
||||
|
||||
Output ONLY the message text. No JSON, no markdown code fences, no preamble."""
|
||||
|
||||
else: # email_draft
|
||||
client_greeting = f"Address the client as '{client_name}'" if client_name else "Use a generic greeting like 'Hi'"
|
||||
subject_hints = {
|
||||
"status": "Update: [brief issue description]",
|
||||
"resolution": "Resolved: [brief issue description]",
|
||||
"escalation": "Update: [brief issue description] — Specialist Review",
|
||||
"escalation": "Update: [brief issue description] — Specialist Assistance",
|
||||
"need_info": "Quick Question: [brief issue description]",
|
||||
}
|
||||
context_guidance = {
|
||||
"status": "We're actively working on it. Describe progress and next steps without giving a timeline.",
|
||||
"resolution": "This is good news — the issue is resolved. Summarize what was wrong and what was done in plain language.",
|
||||
"escalation": "Be reassuring — explain that a specialist is being brought in to assist, not that something failed.",
|
||||
}.get(context, "")
|
||||
return f"""You are generating a complete email draft for client communication.
|
||||
|
||||
Rules:
|
||||
@@ -1177,15 +1219,63 @@ Rules:
|
||||
- {client_greeting}
|
||||
- Be professional, reassuring, and non-technical
|
||||
- NEVER use technical jargon, server names, IP addresses, or internal tool names
|
||||
- Include a professional sign-off with:
|
||||
{engineer_name}
|
||||
- Include a professional sign-off with: {engineer_name}
|
||||
- {length_instruction}
|
||||
{"- This is good news — the issue is resolved." if context == "resolution" else ""}
|
||||
{"- Be reassuring — explain that a specialist is being brought in." if context == "escalation" else ""}
|
||||
- {context_guidance}
|
||||
|
||||
Output ONLY the email text (Subject + body). No JSON, no markdown code fences, no preamble."""
|
||||
|
||||
|
||||
def _build_what_we_know(session: AISession) -> str:
|
||||
"""Build a 'What We Know' summary from evidence_items (cockpit) or derived from steps.
|
||||
|
||||
When the cockpit branch merges, session.evidence_items will be populated by the AI
|
||||
with confirmed/ruled_out/pending classifications. Until then, we derive findings
|
||||
from completed diagnostic steps.
|
||||
"""
|
||||
evidence_items = getattr(session, 'evidence_items', None)
|
||||
if evidence_items:
|
||||
confirmed = [e['text'] for e in evidence_items if e.get('status') == 'confirmed']
|
||||
ruled_out = [e['text'] for e in evidence_items if e.get('status') == 'ruled_out']
|
||||
pending = [e['text'] for e in evidence_items if e.get('status') == 'pending']
|
||||
parts = []
|
||||
if confirmed:
|
||||
parts.append("Confirmed:\n" + "\n".join(f" - {t}" for t in confirmed))
|
||||
if ruled_out:
|
||||
parts.append("Ruled out:\n" + "\n".join(f" - {t}" for t in ruled_out))
|
||||
if pending:
|
||||
parts.append("Still investigating:\n" + "\n".join(f" - {t}" for t in pending))
|
||||
return "\n".join(parts)
|
||||
|
||||
# Derive from completed steps
|
||||
findings = []
|
||||
for step in sorted(session.steps or [], key=lambda s: s.step_order):
|
||||
content = step.content or {}
|
||||
if content.get("type") in ("resolution_suggestion", "briefing", "status_update"):
|
||||
continue
|
||||
description = content.get("text", "").strip()
|
||||
if not description or step.was_skipped:
|
||||
continue
|
||||
response = None
|
||||
if step.selected_option and step.options_presented:
|
||||
for opt in step.options_presented:
|
||||
if opt.get("value") == step.selected_option:
|
||||
response = opt.get("label", step.selected_option)
|
||||
break
|
||||
else:
|
||||
response = step.selected_option
|
||||
elif step.selected_option:
|
||||
response = step.selected_option
|
||||
elif step.free_text_input:
|
||||
response = step.free_text_input
|
||||
if response:
|
||||
findings.append(f"{description} — {response}")
|
||||
|
||||
if not findings:
|
||||
return ""
|
||||
return "Findings so far:\n" + "\n".join(f" - {f}" for f in findings)
|
||||
|
||||
|
||||
def _build_status_update_context(
|
||||
session: AISession,
|
||||
steps_text: str,
|
||||
@@ -1206,24 +1296,17 @@ def _build_status_update_context(
|
||||
if session.psa_ticket_id:
|
||||
parts.append(f"Ticket ID: {session.psa_ticket_id}")
|
||||
|
||||
parts.append(f"\nDiagnostic steps:\n{steps_text}")
|
||||
what_we_know = _build_what_we_know(session)
|
||||
if what_we_know:
|
||||
parts.append(f"\nWhat we know:\n{what_we_know}")
|
||||
|
||||
parts.append(f"\nDiagnostic steps taken:\n{steps_text}")
|
||||
|
||||
if context == "resolution" and session.resolution_summary:
|
||||
parts.append(f"\nResolution: {session.resolution_summary}")
|
||||
if context == "escalation" and session.escalation_reason:
|
||||
parts.append(f"\nEscalation reason: {session.escalation_reason}")
|
||||
|
||||
# Include recent conversation messages for richer context
|
||||
messages = session.conversation_messages or []
|
||||
if messages:
|
||||
recent = messages[-10:] # Last 10 messages
|
||||
convo_text = "\n".join(
|
||||
f"{'Engineer' if m['role'] == 'user' else 'FlowPilot'}: {m['content'][:300]}"
|
||||
for m in recent
|
||||
if isinstance(m, dict) and "role" in m and "content" in m
|
||||
)
|
||||
parts.append(f"\nRecent conversation:\n{convo_text}")
|
||||
|
||||
return "\n".join(parts)
|
||||
|
||||
|
||||
@@ -1420,6 +1503,7 @@ def _create_step_from_parsed(
|
||||
def _generate_documentation(session: AISession) -> SessionDocumentation:
|
||||
"""Generate structured documentation from a session's steps."""
|
||||
diagnostic_steps = []
|
||||
follow_up_recommendations: list[str] = []
|
||||
|
||||
for step in session.steps:
|
||||
content = step.content or {}
|
||||
@@ -1459,6 +1543,12 @@ def _generate_documentation(session: AISession) -> SessionDocumentation:
|
||||
outcome=outcome,
|
||||
))
|
||||
|
||||
# Collect follow-up recommendations from resolution suggestion steps
|
||||
if content.get("type") == "resolution_suggestion":
|
||||
recs = content.get("follow_up_recommendations", [])
|
||||
if isinstance(recs, list):
|
||||
follow_up_recommendations.extend(recs)
|
||||
|
||||
# Calculate duration
|
||||
duration_display = None
|
||||
if session.resolved_at and session.created_at:
|
||||
@@ -1484,6 +1574,7 @@ def _generate_documentation(session: AISession) -> SessionDocumentation:
|
||||
diagnostic_steps=diagnostic_steps,
|
||||
resolution_summary=session.resolution_summary,
|
||||
escalation_reason=session.escalation_reason,
|
||||
follow_up_recommendations=follow_up_recommendations,
|
||||
total_steps=session.step_count,
|
||||
duration_display=duration_display,
|
||||
generated_at=datetime.now(timezone.utc),
|
||||
|
||||
151
backend/app/services/network_diagram_ai_service.py
Normal file
151
backend/app/services/network_diagram_ai_service.py
Normal file
@@ -0,0 +1,151 @@
|
||||
"""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"),
|
||||
)
|
||||
@@ -57,181 +57,199 @@ def _format_datetime(dt: datetime | None) -> str:
|
||||
return dt.strftime("%Y-%m-%d %I:%M %p UTC")
|
||||
|
||||
|
||||
def _get_engineer_response(step) -> str | None:
|
||||
"""Extract the engineer's response label from a step."""
|
||||
if step.was_skipped:
|
||||
return "Skipped"
|
||||
if step.selected_option and step.options_presented:
|
||||
for opt in step.options_presented:
|
||||
if opt.get("value") == step.selected_option:
|
||||
return opt.get("label", step.selected_option)
|
||||
return step.selected_option
|
||||
if step.selected_option:
|
||||
return step.selected_option
|
||||
if step.free_text_input:
|
||||
return step.free_text_input
|
||||
return None
|
||||
|
||||
|
||||
def format_resolution_note(session: AISession, include_steps: bool = True) -> str:
|
||||
"""Format a resolved session as a plain-text note for CW."""
|
||||
lines = [
|
||||
"═══ FlowPilot Session Documentation ═══",
|
||||
f"Session: {session.id}",
|
||||
]
|
||||
|
||||
# Engineer name from relationship if loaded, otherwise user_id
|
||||
engineer_name = getattr(session, 'user', None)
|
||||
if engineer_name and hasattr(engineer_name, 'name'):
|
||||
lines.append(f"Engineer: {engineer_name.name}")
|
||||
engineer_display = engineer_name.name if engineer_name and hasattr(engineer_name, 'name') else "Unknown"
|
||||
|
||||
lines.extend([
|
||||
f"Date: {_format_datetime(session.resolved_at)}",
|
||||
f"Started: {_format_datetime(session.created_at)}",
|
||||
f"Ended: {_format_datetime(session.resolved_at)}",
|
||||
])
|
||||
|
||||
# Duration
|
||||
duration_str = ""
|
||||
if session.resolved_at and session.created_at:
|
||||
delta = session.resolved_at - session.created_at
|
||||
minutes = int(delta.total_seconds() / 60)
|
||||
if minutes < 60:
|
||||
lines.append(f"Duration: {minutes}m")
|
||||
else:
|
||||
lines.append(f"Duration: {minutes // 60}h {minutes % 60}m")
|
||||
total_hrs = round(delta.total_seconds() / 3600, 2)
|
||||
duration_str = f" — {total_hrs} hrs"
|
||||
|
||||
lines.append("")
|
||||
lines.append("── Problem ──")
|
||||
lines.append(session.problem_summary or "No summary available")
|
||||
if session.problem_domain:
|
||||
lines.append(f"Domain: {session.problem_domain}")
|
||||
lines = [
|
||||
f"FlowPilot Session — {engineer_display}{duration_str}",
|
||||
f"Problem: {session.problem_summary or 'No summary available'}",
|
||||
]
|
||||
|
||||
# Diagnostic steps
|
||||
if include_steps and session.steps:
|
||||
lines.append("")
|
||||
lines.append("── Diagnosis Path ──")
|
||||
lines.append("Steps:")
|
||||
for step in session.steps:
|
||||
content = step.content or {}
|
||||
step_type = content.get("type", step.step_type).capitalize()
|
||||
description = content.get("text", "")
|
||||
|
||||
response_text = ""
|
||||
if step.was_skipped:
|
||||
response_text = "Skipped"
|
||||
elif step.selected_option:
|
||||
# Try to find the label
|
||||
if step.options_presented:
|
||||
for opt in step.options_presented:
|
||||
if opt.get("value") == step.selected_option:
|
||||
response_text = opt.get("label", step.selected_option)
|
||||
break
|
||||
else:
|
||||
response_text = step.selected_option
|
||||
else:
|
||||
response_text = step.selected_option
|
||||
elif step.free_text_input:
|
||||
response_text = step.free_text_input
|
||||
|
||||
lines.append(f"{step.step_order + 1}. [{step_type}] {description}")
|
||||
if response_text:
|
||||
lines.append(f" → Response: {response_text}")
|
||||
if step.action_result:
|
||||
result = step.action_result
|
||||
outcome = "Succeeded" if result.get("success") else "Did not resolve"
|
||||
if details := result.get("details"):
|
||||
outcome += f" — {details}"
|
||||
lines.append(f" → Result: {outcome}")
|
||||
step_type = content.get("type", "")
|
||||
if step_type == "resolution_suggestion":
|
||||
continue # Not a diagnostic step
|
||||
description = content.get("text", "").strip()
|
||||
if not description:
|
||||
continue
|
||||
response = _get_engineer_response(step)
|
||||
line = f"{step.step_order + 1}. {description}"
|
||||
if response and response != "Skipped":
|
||||
line += f" — {response}"
|
||||
elif response == "Skipped":
|
||||
line += " (skipped)"
|
||||
lines.append(line)
|
||||
|
||||
# Resolution
|
||||
lines.append("")
|
||||
lines.append("── Resolution ──")
|
||||
lines.append(session.resolution_summary or "No resolution summary")
|
||||
lines.append(f"Resolution: {session.resolution_summary or 'No resolution summary'}")
|
||||
if session.resolution_action:
|
||||
lines.append(session.resolution_action)
|
||||
|
||||
# Confidence
|
||||
lines.append("")
|
||||
lines.append("── AI Confidence ──")
|
||||
lines.append(f"Final confidence: {session.confidence_tier} ({session.confidence_score:.0%})")
|
||||
# Follow-up recommendations from resolution suggestion step
|
||||
follow_ups: list[str] = []
|
||||
for step in session.steps:
|
||||
content = step.content or {}
|
||||
if content.get("type") == "resolution_suggestion":
|
||||
recs = content.get("follow_up_recommendations", [])
|
||||
if isinstance(recs, list):
|
||||
follow_ups.extend(recs)
|
||||
if follow_ups:
|
||||
lines.append("")
|
||||
lines.append("Follow-up:")
|
||||
for rec in follow_ups:
|
||||
lines.append(f"- {rec}")
|
||||
|
||||
# Timing section (always present)
|
||||
# Timing
|
||||
lines.append("")
|
||||
lines.append("── Session Timing ──")
|
||||
lines.append(f"Start: {_format_datetime(session.created_at)}")
|
||||
lines.append(f"End: {_format_datetime(session.resolved_at)}")
|
||||
if session.resolved_at and session.created_at:
|
||||
delta = session.resolved_at - session.created_at
|
||||
minutes = int(delta.total_seconds() / 60)
|
||||
lines.append(f"Total: {minutes // 60}h {minutes % 60}m" if minutes >= 60 else f"Total: {minutes}m")
|
||||
total_hrs = round(delta.total_seconds() / 3600, 2)
|
||||
lines.append(f"Total: {total_hrs} hrs")
|
||||
|
||||
lines.append("")
|
||||
lines.append("Generated by ResolutionFlow FlowPilot")
|
||||
lines.append("Generated by ResolutionFlow")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _derive_what_we_know(session: AISession) -> tuple[list[str], list[str], list[str]]:
|
||||
"""Return (confirmed, ruled_out, pending) findings.
|
||||
|
||||
Uses session.evidence_items when the cockpit branch is merged; falls back
|
||||
to deriving from completed diagnostic steps.
|
||||
"""
|
||||
evidence_items = getattr(session, 'evidence_items', None)
|
||||
if evidence_items:
|
||||
confirmed = [e['text'] for e in evidence_items if e.get('status') == 'confirmed']
|
||||
ruled_out = [e['text'] for e in evidence_items if e.get('status') == 'ruled_out']
|
||||
pending = [e['text'] for e in evidence_items if e.get('status') == 'pending']
|
||||
return confirmed, ruled_out, pending
|
||||
|
||||
# Derive from completed steps — all answered steps become findings
|
||||
findings = []
|
||||
for step in sorted(session.steps or [], key=lambda s: s.step_order):
|
||||
content = step.content or {}
|
||||
if content.get("type") in ("resolution_suggestion", "briefing", "status_update"):
|
||||
continue
|
||||
description = content.get("text", "").strip()
|
||||
if not description or step.was_skipped:
|
||||
continue
|
||||
response = _get_engineer_response(step)
|
||||
if response:
|
||||
findings.append(f"{description} — {response}")
|
||||
return findings, [], []
|
||||
|
||||
|
||||
def format_escalation_note(session: AISession, include_steps: bool = True) -> str:
|
||||
"""Format an escalated session as a plain-text note for CW."""
|
||||
engineer_obj = getattr(session, 'user', None)
|
||||
engineer_display = engineer_obj.name if engineer_obj and hasattr(engineer_obj, 'name') else "Unknown"
|
||||
|
||||
escalated_to_obj = getattr(session, 'escalated_to', None)
|
||||
escalated_to_display = escalated_to_obj.name if escalated_to_obj and hasattr(escalated_to_obj, 'name') else None
|
||||
|
||||
escalated_at = session.resolved_at or datetime.now(timezone.utc)
|
||||
duration_str = ""
|
||||
if session.created_at:
|
||||
delta = escalated_at - session.created_at
|
||||
total_hrs = round(delta.total_seconds() / 3600, 2)
|
||||
duration_str = f" — {total_hrs} hrs"
|
||||
|
||||
header = f"FlowPilot Escalation — {engineer_display}{duration_str}"
|
||||
if escalated_to_display:
|
||||
header += f" → {escalated_to_display}"
|
||||
lines = [
|
||||
"═══ FlowPilot Escalation Documentation ═══",
|
||||
f"Session: {session.id}",
|
||||
header,
|
||||
f"Problem: {session.problem_summary or 'No summary available'}",
|
||||
]
|
||||
|
||||
engineer_name = getattr(session, 'user', None)
|
||||
if engineer_name and hasattr(engineer_name, 'name'):
|
||||
lines.append(f"Escalated by: {engineer_name.name}")
|
||||
|
||||
escalated_to = getattr(session, 'escalated_to', None)
|
||||
if escalated_to and hasattr(escalated_to, 'name'):
|
||||
lines.append(f"Escalated to: {escalated_to.name}")
|
||||
else:
|
||||
lines.append("Escalated to: Unassigned")
|
||||
|
||||
lines.extend([
|
||||
f"Date: {_format_datetime(session.resolved_at or datetime.now(timezone.utc))}",
|
||||
f"Started: {_format_datetime(session.created_at)}",
|
||||
])
|
||||
|
||||
if session.resolved_at and session.created_at:
|
||||
delta = session.resolved_at - session.created_at
|
||||
minutes = int(delta.total_seconds() / 60)
|
||||
lines.append(f"Duration: {minutes // 60}h {minutes % 60}m" if minutes >= 60 else f"Duration: {minutes}m")
|
||||
|
||||
lines.append("")
|
||||
lines.append("── Problem ──")
|
||||
lines.append(session.problem_summary or "No summary available")
|
||||
|
||||
# Work completed
|
||||
# Work completed with responses
|
||||
if include_steps and session.steps:
|
||||
lines.append("")
|
||||
lines.append("── Work Completed ──")
|
||||
for step in session.steps:
|
||||
lines.append("Work completed:")
|
||||
for step in sorted(session.steps, key=lambda s: s.step_order):
|
||||
content = step.content or {}
|
||||
description = content.get("text", "")
|
||||
lines.append(f"{step.step_order + 1}. {description}")
|
||||
if content.get("type") in ("resolution_suggestion", "briefing", "status_update"):
|
||||
continue
|
||||
description = content.get("text", "").strip()
|
||||
if not description:
|
||||
continue
|
||||
response = _get_engineer_response(step)
|
||||
line = f"{step.step_order + 1}. {description}"
|
||||
if response and response != "Skipped":
|
||||
line += f" — {response}"
|
||||
elif response == "Skipped":
|
||||
line += " (skipped)"
|
||||
lines.append(line)
|
||||
|
||||
# What We Know
|
||||
confirmed, ruled_out, pending = _derive_what_we_know(session)
|
||||
if confirmed or ruled_out or pending:
|
||||
lines.append("")
|
||||
lines.append("What we know:")
|
||||
for f in confirmed:
|
||||
lines.append(f" ✓ {f}")
|
||||
for f in ruled_out:
|
||||
lines.append(f" ✗ {f}")
|
||||
for f in pending:
|
||||
lines.append(f" ? {f}")
|
||||
|
||||
# Escalation reason
|
||||
lines.append("")
|
||||
lines.append("── Escalation Reason ──")
|
||||
lines.append(session.escalation_reason or "No reason provided")
|
||||
lines.append(f"Escalation reason: {session.escalation_reason or 'No reason provided'}")
|
||||
|
||||
# Escalation package details
|
||||
# Suggested next steps from escalation package
|
||||
pkg = session.escalation_package or {}
|
||||
if hypotheses := pkg.get("remaining_hypotheses"):
|
||||
lines.append("")
|
||||
lines.append("── Remaining Hypotheses ──")
|
||||
if isinstance(hypotheses, list):
|
||||
for h in hypotheses:
|
||||
lines.append(f"- {h}")
|
||||
else:
|
||||
lines.append(str(hypotheses))
|
||||
|
||||
if suggestions := pkg.get("suggested_next_steps"):
|
||||
lines.append("")
|
||||
lines.append("── Suggested Next Steps ──")
|
||||
if isinstance(suggestions, list):
|
||||
for s in suggestions:
|
||||
lines.append(f"- {s}")
|
||||
else:
|
||||
lines.append(str(suggestions))
|
||||
lines.append("Suggested next steps:")
|
||||
items = suggestions if isinstance(suggestions, list) else [str(suggestions)]
|
||||
for s in items:
|
||||
lines.append(f"- {s}")
|
||||
|
||||
# Timing
|
||||
lines.append("")
|
||||
lines.append("── Session Timing ──")
|
||||
lines.append(f"Start: {_format_datetime(session.created_at)}")
|
||||
escalated_at = session.resolved_at or datetime.now(timezone.utc)
|
||||
lines.append(f"Escalated: {_format_datetime(escalated_at)}")
|
||||
if session.created_at:
|
||||
delta = escalated_at - session.created_at
|
||||
minutes = int(delta.total_seconds() / 60)
|
||||
lines.append(f"Total: {minutes // 60}h {minutes % 60}m" if minutes >= 60 else f"Total: {minutes}m")
|
||||
total_hrs = round(delta.total_seconds() / 3600, 2)
|
||||
lines.append(f"Total: {total_hrs} hrs")
|
||||
|
||||
lines.append("")
|
||||
lines.append("Generated by ResolutionFlow FlowPilot")
|
||||
lines.append("Generated by ResolutionFlow")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
@@ -19,7 +19,13 @@ class ResolutionOutputGenerator:
|
||||
def __init__(self, db: AsyncSession):
|
||||
self.db = db
|
||||
|
||||
async def generate_all(self, session_id: UUID) -> list[SessionResolutionOutput]:
|
||||
async def generate_all(
|
||||
self,
|
||||
session_id: UUID,
|
||||
root_cause: str | None = None,
|
||||
steps_taken: list[str] | None = None,
|
||||
recommendations: str | None = None,
|
||||
) -> list[SessionResolutionOutput]:
|
||||
result = await self.db.execute(
|
||||
select(AISession).where(AISession.id == session_id)
|
||||
)
|
||||
@@ -27,7 +33,12 @@ class ResolutionOutputGenerator:
|
||||
if not session:
|
||||
raise ValueError(f"Session {session_id} not found")
|
||||
|
||||
context = self._build_session_context(session)
|
||||
context = self._build_session_context(
|
||||
session,
|
||||
root_cause=root_cause,
|
||||
steps_taken=steps_taken,
|
||||
recommendations=recommendations,
|
||||
)
|
||||
|
||||
outputs = []
|
||||
for output_type, prompt in [
|
||||
@@ -82,20 +93,83 @@ class ResolutionOutputGenerator:
|
||||
await self.db.flush()
|
||||
return output
|
||||
|
||||
def _build_session_context(self, session: AISession) -> str:
|
||||
def _build_session_context(
|
||||
self,
|
||||
session: AISession,
|
||||
root_cause: str | None = None,
|
||||
steps_taken: list[str] | None = None,
|
||||
recommendations: str | None = None,
|
||||
) -> str:
|
||||
intake = session.intake_content or {}
|
||||
intake_text = intake.get("text", "") or str(intake)
|
||||
parts = [
|
||||
f"Problem: {session.problem_summary or 'Unknown'}",
|
||||
f"Domain: {session.problem_domain or 'Unknown'}",
|
||||
f"Original intake: {intake_text[:300]}",
|
||||
f"Resolution: {session.resolution_summary or 'Not specified'}",
|
||||
f"Steps taken: {session.step_count}",
|
||||
]
|
||||
msgs = session.conversation_messages or []
|
||||
if msgs:
|
||||
parts.append("\nConversation highlights:")
|
||||
for msg in msgs[-10:]:
|
||||
role = msg.get("role", "unknown")
|
||||
content = msg.get("content", "")[:200]
|
||||
parts.append(f" [{role}]: {content}")
|
||||
|
||||
# Structured handoff fields from cockpit conclude modal
|
||||
if root_cause:
|
||||
parts.append(f"Root cause: {root_cause}")
|
||||
if steps_taken:
|
||||
parts.append("Steps performed:")
|
||||
for step in steps_taken:
|
||||
parts.append(f" - {step}")
|
||||
if recommendations:
|
||||
parts.append(f"Recommendations: {recommendations}")
|
||||
|
||||
# Triage metadata (cockpit branch)
|
||||
if getattr(session, 'client_name', None):
|
||||
parts.append(f"Client: {session.client_name}")
|
||||
if getattr(session, 'triage_hypothesis', None):
|
||||
parts.append(f"Hypothesis: {session.triage_hypothesis}")
|
||||
if getattr(session, 'evidence_items', None):
|
||||
parts.append("Evidence collected:")
|
||||
for item in session.evidence_items:
|
||||
icon = {"confirmed": "✓", "ruled_out": "✗", "pending": "?"}.get(item.get("status", ""), "?")
|
||||
parts.append(f" {icon} {item.get('text', '')}")
|
||||
|
||||
# Diagnostic steps from FlowPilot session steps
|
||||
diagnostic = []
|
||||
follow_ups: list[str] = []
|
||||
for step in sorted(session.steps or [], key=lambda s: s.step_order):
|
||||
content = step.content or {}
|
||||
step_type = content.get("type", "")
|
||||
if step_type == "resolution_suggestion":
|
||||
recs = content.get("follow_up_recommendations", [])
|
||||
if isinstance(recs, list):
|
||||
follow_ups.extend(recs)
|
||||
continue
|
||||
description = content.get("text", "").strip()
|
||||
if not description:
|
||||
continue
|
||||
response = None
|
||||
if step.was_skipped:
|
||||
response = "skipped"
|
||||
elif step.selected_option and step.options_presented:
|
||||
for opt in step.options_presented:
|
||||
if opt.get("value") == step.selected_option:
|
||||
response = opt.get("label", step.selected_option)
|
||||
break
|
||||
else:
|
||||
response = step.selected_option
|
||||
elif step.selected_option:
|
||||
response = step.selected_option
|
||||
elif step.free_text_input:
|
||||
response = step.free_text_input
|
||||
entry = f" {step.step_order + 1}. {description}"
|
||||
if response and response != "skipped":
|
||||
entry += f" — {response}"
|
||||
diagnostic.append(entry)
|
||||
|
||||
if diagnostic:
|
||||
parts.append("\nDiagnostic steps:")
|
||||
parts.extend(diagnostic)
|
||||
if follow_ups:
|
||||
parts.append("\nRecommended follow-up:")
|
||||
parts.extend(f" - {r}" for r in follow_ups)
|
||||
|
||||
return "\n".join(parts)
|
||||
|
||||
def _psa_notes_prompt(self, context: str) -> str:
|
||||
|
||||
@@ -133,10 +133,13 @@ def _parse_questions_marker(ai_content: str) -> tuple[str, list[dict[str, Any]]
|
||||
valid_questions = []
|
||||
for q in questions:
|
||||
if isinstance(q, dict) and q.get("text"):
|
||||
valid_questions.append({
|
||||
item = {
|
||||
"text": q["text"],
|
||||
"context": q.get("context", ""),
|
||||
})
|
||||
}
|
||||
if q.get("options") and isinstance(q["options"], list):
|
||||
item["options"] = q["options"]
|
||||
valid_questions.append(item)
|
||||
|
||||
if not valid_questions:
|
||||
return ai_content, None
|
||||
@@ -147,6 +150,43 @@ def _parse_questions_marker(ai_content: str) -> tuple[str, list[dict[str, Any]]
|
||||
return cleaned, valid_questions
|
||||
|
||||
|
||||
def _parse_triage_update_marker(ai_content: str) -> tuple[str, dict[str, Any] | None]:
|
||||
"""Extract [TRIAGE_UPDATE]...[/TRIAGE_UPDATE] JSON from AI response.
|
||||
|
||||
Returns (cleaned_content, triage_update_dict_or_None).
|
||||
The marker is stripped from display text.
|
||||
"""
|
||||
match = re.search(r'\[TRIAGE_UPDATE\]\s*([\s\S]*?)\s*\[/TRIAGE_UPDATE\]', ai_content)
|
||||
if not match:
|
||||
return ai_content, None
|
||||
|
||||
try:
|
||||
raw = match.group(1).strip()
|
||||
if raw.startswith("```"):
|
||||
raw = re.sub(r'^```(?:json)?\s*', '', raw)
|
||||
raw = re.sub(r'\s*```$', '', raw)
|
||||
triage = json.loads(raw)
|
||||
except (json.JSONDecodeError, ValueError) as e:
|
||||
logger.warning("Failed to parse [TRIAGE_UPDATE] marker: %s", e)
|
||||
return ai_content, None
|
||||
|
||||
if not isinstance(triage, dict):
|
||||
logger.warning("Invalid [TRIAGE_UPDATE] data — expected object")
|
||||
return ai_content, None
|
||||
|
||||
# Only keep recognized fields
|
||||
valid_fields = {"client_name", "asset_name", "issue_category", "triage_hypothesis", "evidence_items"}
|
||||
filtered = {k: v for k, v in triage.items() if k in valid_fields and v is not None}
|
||||
|
||||
if not filtered:
|
||||
return ai_content, None
|
||||
|
||||
cleaned = ai_content[:match.start()] + ai_content[match.end():]
|
||||
cleaned = cleaned.strip()
|
||||
|
||||
return cleaned, filtered
|
||||
|
||||
|
||||
async def create_chat_session(
|
||||
user_id: UUID,
|
||||
account_id: UUID,
|
||||
@@ -183,14 +223,14 @@ async def send_chat_message(
|
||||
message: str,
|
||||
db: AsyncSession,
|
||||
images: list[dict[str, Any]] | None = None,
|
||||
) -> tuple[str, list[dict[str, Any]], AISession, dict[str, Any] | None, list[dict[str, Any]] | None, list[dict[str, Any]] | None]:
|
||||
) -> tuple[str, list[dict[str, Any]], AISession, dict[str, Any] | None, list[dict[str, Any]] | None, list[dict[str, Any]] | None, dict[str, Any] | None]:
|
||||
"""Send a message in a chat session and get AI response.
|
||||
|
||||
Args:
|
||||
images: Optional list of {"media_type": str, "data": str (base64)}
|
||||
for vision content attached to this message.
|
||||
|
||||
Returns (ai_content, suggested_flows, session, fork_metadata, actions_data, questions_data).
|
||||
Returns (ai_content, suggested_flows, session, fork_metadata, actions_data, questions_data, triage_update_data).
|
||||
"""
|
||||
result = await db.execute(
|
||||
select(AISession).where(
|
||||
@@ -253,6 +293,19 @@ async def send_chat_message(
|
||||
branch_display, branch_fork_data = _parse_fork_marker(ai_content)
|
||||
branch_display, branch_actions_data = _parse_actions_marker(branch_display)
|
||||
branch_display, branch_questions_data = _parse_questions_marker(branch_display)
|
||||
branch_display, branch_triage_data = _parse_triage_update_marker(branch_display)
|
||||
|
||||
# Auto-PATCH triage from branch response
|
||||
if branch_triage_data:
|
||||
for field in ("client_name", "asset_name", "issue_category", "triage_hypothesis"):
|
||||
if field in branch_triage_data and getattr(session, field) is None:
|
||||
setattr(session, field, branch_triage_data[field])
|
||||
new_evidence = branch_triage_data.get("evidence_items")
|
||||
if new_evidence and isinstance(new_evidence, list):
|
||||
existing = list(session.evidence_items or [])
|
||||
existing.extend(new_evidence)
|
||||
session.evidence_items = existing
|
||||
|
||||
if branch_display != ai_content:
|
||||
# Store stripped content in branch history
|
||||
msgs[-1] = {"role": "assistant", "content": branch_display}
|
||||
@@ -286,19 +339,17 @@ async def send_chat_message(
|
||||
except Exception:
|
||||
logger.exception("Failed to create fork within branch for session %s", session.id)
|
||||
|
||||
# Persist task lane state on session
|
||||
# Persist task lane state on session — only overwrite when new markers present
|
||||
if branch_questions_data or branch_actions_data:
|
||||
session.pending_task_lane = {
|
||||
"questions": branch_questions_data or [],
|
||||
"actions": branch_actions_data or [],
|
||||
}
|
||||
else:
|
||||
session.pending_task_lane = None
|
||||
|
||||
suggested_flows = extract_suggested_flows(
|
||||
await rag_search(query=message, account_id=account_id, db=db, limit=8)
|
||||
)
|
||||
return branch_display, suggested_flows, session, branch_fork_metadata, branch_actions_data, branch_questions_data
|
||||
return branch_display, suggested_flows, session, branch_fork_metadata, branch_actions_data, branch_questions_data, branch_triage_data
|
||||
|
||||
# Auto-title from first message if still default
|
||||
if session.step_count == 0 and message.strip():
|
||||
@@ -341,12 +392,29 @@ async def send_chat_message(
|
||||
# Check for questions marker in AI response
|
||||
display_content, questions_data = _parse_questions_marker(display_content)
|
||||
|
||||
# Check for triage update marker in AI response
|
||||
display_content, triage_update_data = _parse_triage_update_marker(display_content)
|
||||
|
||||
logger.info(
|
||||
"Marker parsing results — actions: %s, questions: %s, fork: %s, raw_length: %d, display_length: %d",
|
||||
bool(actions_data), bool(questions_data), bool(fork_data),
|
||||
"Marker parsing results — actions: %s, questions: %s, fork: %s, triage: %s, raw_length: %d, display_length: %d",
|
||||
bool(actions_data), bool(questions_data), bool(fork_data), bool(triage_update_data),
|
||||
len(ai_content), len(display_content),
|
||||
)
|
||||
|
||||
# Auto-PATCH session with triage metadata if AI inferred new fields
|
||||
if triage_update_data:
|
||||
# Apply non-evidence fields directly (AI only fills null fields — manual edits win)
|
||||
for field in ("client_name", "asset_name", "issue_category", "triage_hypothesis"):
|
||||
if field in triage_update_data and getattr(session, field) is None:
|
||||
setattr(session, field, triage_update_data[field])
|
||||
|
||||
# Append new evidence items (never modify existing)
|
||||
new_evidence = triage_update_data.get("evidence_items")
|
||||
if new_evidence and isinstance(new_evidence, list):
|
||||
existing = list(session.evidence_items or [])
|
||||
existing.extend(new_evidence)
|
||||
session.evidence_items = existing
|
||||
|
||||
# Store DISPLAY content (markers stripped) in conversation_messages.
|
||||
# The format reminder in the user message + system prompt final reminder
|
||||
# are sufficient to keep the AI emitting markers on subsequent turns.
|
||||
@@ -402,15 +470,13 @@ async def send_chat_message(
|
||||
logger.exception("Failed to create fork for session %s", session_id)
|
||||
# Fork failed but chat message still sent — don't break the response
|
||||
|
||||
# Persist task lane state on session
|
||||
# Persist task lane state on session — only overwrite when new markers present
|
||||
if questions_data or actions_data:
|
||||
session.pending_task_lane = {
|
||||
"questions": questions_data or [],
|
||||
"actions": actions_data or [],
|
||||
}
|
||||
else:
|
||||
session.pending_task_lane = None
|
||||
|
||||
suggested_flows = extract_suggested_flows(rag_results)
|
||||
|
||||
return display_content, suggested_flows, session, fork_metadata, actions_data, questions_data
|
||||
return display_content, suggested_flows, session, fork_metadata, actions_data, questions_data, triage_update_data
|
||||
|
||||
@@ -19,8 +19,116 @@ class TestAdminEndpoints:
|
||||
"/api/v1/admin/users", headers=admin_auth_headers
|
||||
)
|
||||
assert response.status_code == 200
|
||||
users = response.json()
|
||||
assert len(users) >= 2 # admin + test_user
|
||||
payload = response.json()
|
||||
assert payload["total"] >= 2 # admin + test_user
|
||||
assert len(payload["items"]) >= 2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_users_supports_search(
|
||||
self, client: AsyncClient, admin_auth_headers: dict, test_user: dict
|
||||
):
|
||||
"""Test admin people search by user email."""
|
||||
response = await client.get(
|
||||
"/api/v1/admin/users",
|
||||
params={"search": test_user["email"]},
|
||||
headers=admin_auth_headers,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
assert payload["total"] >= 1
|
||||
assert any(item["email"] == test_user["email"] for item in payload["items"])
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_accounts_as_admin(
|
||||
self, client: AsyncClient, admin_auth_headers: dict
|
||||
):
|
||||
"""Test listing accounts with member data."""
|
||||
response = await client.get(
|
||||
"/api/v1/admin/accounts", headers=admin_auth_headers
|
||||
)
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
assert payload["total"] >= 1
|
||||
assert len(payload["items"]) >= 1
|
||||
assert "members" in payload["items"][0]
|
||||
assert "subscription" in payload["items"][0]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_account_as_admin(
|
||||
self, client: AsyncClient, admin_auth_headers: dict
|
||||
):
|
||||
"""Test creating an empty account from admin."""
|
||||
response = await client.post(
|
||||
"/api/v1/admin/accounts",
|
||||
json={"name": "Acme Customer", "plan": "pro"},
|
||||
headers=admin_auth_headers,
|
||||
)
|
||||
assert response.status_code == 201
|
||||
payload = response.json()
|
||||
assert payload["name"] == "Acme Customer"
|
||||
assert payload["subscription"]["plan"] == "pro"
|
||||
assert payload["display_code"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_account_detail_as_admin(
|
||||
self, client: AsyncClient, admin_auth_headers: dict, test_user: dict
|
||||
):
|
||||
"""Test fetching account detail for management view."""
|
||||
account_id = test_user["user_data"]["account_id"]
|
||||
response = await client.get(
|
||||
f"/api/v1/admin/accounts/{account_id}",
|
||||
headers=admin_auth_headers,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
assert payload["id"] == account_id
|
||||
assert "members" in payload
|
||||
assert "invites" in payload
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_account_name_as_admin(
|
||||
self, client: AsyncClient, admin_auth_headers: dict, test_user: dict
|
||||
):
|
||||
"""Test renaming an account from admin detail view."""
|
||||
account_id = test_user["user_data"]["account_id"]
|
||||
response = await client.put(
|
||||
f"/api/v1/admin/accounts/{account_id}",
|
||||
json={"name": "Renamed Customer Account"},
|
||||
headers=admin_auth_headers,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
assert payload["id"] == account_id
|
||||
assert payload["name"] == "Renamed Customer Account"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_account_plan(
|
||||
self, client: AsyncClient, admin_auth_headers: dict, test_user: dict
|
||||
):
|
||||
"""Test changing an account's subscription plan."""
|
||||
account_id = test_user["user_data"]["account_id"]
|
||||
response = await client.put(
|
||||
f"/api/v1/admin/accounts/{account_id}/subscription/plan",
|
||||
json={"plan": "pro"},
|
||||
headers=admin_auth_headers,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["plan"] == "pro"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_extend_account_trial(
|
||||
self, client: AsyncClient, admin_auth_headers: dict, test_user: dict
|
||||
):
|
||||
"""Test starting or extending an account trial."""
|
||||
account_id = test_user["user_data"]["account_id"]
|
||||
response = await client.put(
|
||||
f"/api/v1/admin/accounts/{account_id}/subscription/extend-trial",
|
||||
json={"days": 14},
|
||||
headers=admin_auth_headers,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["status"] == "trialing"
|
||||
assert response.json()["current_period_end"] is not None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_users_as_non_admin(
|
||||
|
||||
107
backend/tests/test_feature_flags_resolution.py
Normal file
107
backend/tests/test_feature_flags_resolution.py
Normal file
@@ -0,0 +1,107 @@
|
||||
"""Integration tests for feature flag resolution endpoint."""
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import text
|
||||
|
||||
|
||||
async def _seed_feature_flag(db: AsyncSession, flag_key: str, display_name: str):
|
||||
"""Insert a feature flag and return its id."""
|
||||
result = await db.execute(
|
||||
text(
|
||||
"INSERT INTO feature_flags (id, flag_key, display_name) "
|
||||
"VALUES (gen_random_uuid(), :key, :name) RETURNING id"
|
||||
),
|
||||
{"key": flag_key, "name": display_name},
|
||||
)
|
||||
await db.commit()
|
||||
return result.scalar_one()
|
||||
|
||||
|
||||
async def _seed_plan_default(db: AsyncSession, flag_id, plan: str, enabled: bool):
|
||||
"""Insert a plan default for a flag."""
|
||||
await db.execute(
|
||||
text(
|
||||
"INSERT INTO plan_feature_defaults (id, plan, flag_id, enabled) "
|
||||
"VALUES (gen_random_uuid(), :plan, :flag_id, :enabled)"
|
||||
),
|
||||
{"plan": plan, "flag_id": flag_id, "enabled": enabled},
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def _seed_account_override(db: AsyncSession, flag_id, account_id, enabled: bool):
|
||||
"""Insert an account override for a flag."""
|
||||
await db.execute(
|
||||
text(
|
||||
"INSERT INTO account_feature_overrides (id, account_id, flag_id, enabled) "
|
||||
"VALUES (gen_random_uuid(), :account_id, :flag_id, :enabled)"
|
||||
),
|
||||
{"account_id": account_id, "flag_id": flag_id, "enabled": enabled},
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def _get_account_id(db: AsyncSession, user_id: str):
|
||||
"""Get account_id for a user."""
|
||||
result = await db.execute(
|
||||
text("SELECT account_id FROM users WHERE id = :uid"),
|
||||
{"uid": user_id},
|
||||
)
|
||||
return result.scalar_one()
|
||||
|
||||
|
||||
class TestFeatureFlagResolution:
|
||||
"""Tests for GET /auth/me/feature-flags."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_flags_returns_empty(self, client: AsyncClient, auth_headers: dict):
|
||||
"""When no flags exist, returns empty dict."""
|
||||
response = await client.get("/api/v1/auth/me/feature-flags", headers=auth_headers)
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_plan_default_resolves(
|
||||
self, client: AsyncClient, auth_headers: dict, test_user: dict, test_db: AsyncSession
|
||||
):
|
||||
"""Flag with plan default for 'free' plan resolves correctly."""
|
||||
flag_id = await _seed_feature_flag(test_db, "test_feature", "Test Feature")
|
||||
await _seed_plan_default(test_db, flag_id, "free", True)
|
||||
|
||||
response = await client.get("/api/v1/auth/me/feature-flags", headers=auth_headers)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["test_feature"] is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_plan_default_resolves_false(
|
||||
self, client: AsyncClient, auth_headers: dict, test_user: dict, test_db: AsyncSession
|
||||
):
|
||||
"""Flag with no plan default for user's plan resolves to false."""
|
||||
flag_id = await _seed_feature_flag(test_db, "pro_only", "Pro Only")
|
||||
await _seed_plan_default(test_db, flag_id, "pro", True)
|
||||
|
||||
response = await client.get("/api/v1/auth/me/feature-flags", headers=auth_headers)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["pro_only"] is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_account_override_beats_plan_default(
|
||||
self, client: AsyncClient, auth_headers: dict, test_user: dict, test_db: AsyncSession
|
||||
):
|
||||
"""Account override takes precedence over plan default."""
|
||||
flag_id = await _seed_feature_flag(test_db, "overridden", "Overridden Flag")
|
||||
await _seed_plan_default(test_db, flag_id, "free", False)
|
||||
account_id = await _get_account_id(test_db, test_user["user_data"]["id"])
|
||||
await _seed_account_override(test_db, flag_id, account_id, True)
|
||||
|
||||
response = await client.get("/api/v1/auth/me/feature-flags", headers=auth_headers)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["overridden"] is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unauthenticated_returns_401(self, client: AsyncClient):
|
||||
"""Unauthenticated request returns 401."""
|
||||
response = await client.get("/api/v1/auth/me/feature-flags")
|
||||
assert response.status_code == 401
|
||||
363
docs/cockpit/2026-04-01-msp-assistant-harness-design.md
Normal file
363
docs/cockpit/2026-04-01-msp-assistant-harness-design.md
Normal file
@@ -0,0 +1,363 @@
|
||||
# MSP Assistant Harness — Design Spec
|
||||
**Date:** 2026-04-01
|
||||
**Status:** Draft — pending user review
|
||||
**Source:** MSP_Assistant_Harness_Implementation_Plan.docx (v2.0, March 2026) + brainstorming session
|
||||
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
The `/assistant` page currently works as a generic AI chat surface with a task lane side panel and a chat sidebar for session history. It functions well but reads as "AI chat with extras" rather than an MSP engineer's operational tool.
|
||||
|
||||
The goal is to reframe the page as a **live triage cockpit** — the place where an engineer opens a ticket, works through it from intake to resolution, and closes with a structured handoff artifact. The underlying session, branching, and chat architecture is preserved. What changes is layout hierarchy, information density, field labelling, and the conclude output.
|
||||
|
||||
Scope is broader than the original docx: includes all required backend changes to support the frontend properly.
|
||||
|
||||
---
|
||||
|
||||
## Design Decisions
|
||||
|
||||
### 1. Overall Layout — Stacked Zones
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ Incident Header (labelled fields, 1 row) │
|
||||
├────────────────────────┬────────────────────┤
|
||||
│ │ FlowPilot Asks │
|
||||
│ Steps Checklist │ (quick replies) │
|
||||
│ (left, ~55%) ├────────────────────┤
|
||||
│ │ What We Know │
|
||||
│ │ (evidence list) │
|
||||
├────────────────────────┴────────────────────┤ ← drag handle
|
||||
│ Conversation Log (muted, darker bg) │
|
||||
├─────────────────────────────────────────────┤
|
||||
│ Compose area │
|
||||
└─────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
- Work zone (top) and conversation log (bottom) are **drag-resizable** via a handle
|
||||
- Default split: ~55% work zone, ~45% chat
|
||||
- Existing left sidebar (session history) unchanged
|
||||
- Compose area is always pinned to bottom, spans full width
|
||||
- `workZoneHeight` persisted to `localStorage` so split survives refresh
|
||||
|
||||
### 2. Incident Header
|
||||
|
||||
Single row with explicit micro-labels above each field:
|
||||
|
||||
```
|
||||
CLIENT DEVICE CATEGORY HYPOTHESIS
|
||||
Contoso Ltd ✏ jsmith-desktop ✏ DNS / Network ✏ Corrupted DNS cache on NIC ✏
|
||||
[CW #48291] [Resolve ▾] [⋯]
|
||||
```
|
||||
|
||||
- Each field has its own `✏` icon (visible on hover) that opens an inline edit popover
|
||||
- Fields populate from: (a) intake form on session create, (b) AI-inferred updates mid-session via `triage_update`, (c) manual engineer edits via `PATCH /ai-sessions/{id}/triage`
|
||||
- PSA ticket number shown if linked; action buttons (Resolve, overflow menu) on the right
|
||||
- Empty fields show muted placeholder text — never blank
|
||||
|
||||
### 3. Work Zone — Steps + FlowPilot Asks + What We Know
|
||||
|
||||
**Left panel (~55%): ordered step checklist**
|
||||
- Steps displayed as a vertical list: `✓` completed, `→` active (blue border, white text), `○` pending
|
||||
- Active step is visually distinct
|
||||
- "Generate Script" CTA appears at the bottom when a script-generation step is active
|
||||
|
||||
**Right panel (~45%): two stacked mini-panels**
|
||||
- **FlowPilot Asks** (top, amber label): current question from AI. When `options` are provided, renders as quick-reply buttons — clicking a button submits that answer as a chat message. When no `options`, renders a compact free-text input. Panel is empty/hidden when no pending question.
|
||||
- **What We Know** (bottom, muted label): running evidence list. Each entry: `✓ confirmed` / `✗ ruled out` / `? pending`. AI appends via `triage_update.evidence_items`; engineer can manually add or edit entries.
|
||||
|
||||
### 4. Conversation Log Zone
|
||||
|
||||
- Lives below the work zone, separated by a **drag handle**
|
||||
- Background: `#13151c` (one step darker than page) — visually recedes
|
||||
- Label: "CONVERSATION LOG" in muted colour (`text-muted`)
|
||||
- Messages are compact: `you:` / `fp:` prefixes instead of full name/avatar bubbles
|
||||
- Scrolls independently
|
||||
- Not collapsible — drag handle gives control
|
||||
|
||||
### 5. Conclude / Handoff Modal (redesigned)
|
||||
|
||||
On opening "Close Case":
|
||||
|
||||
1. **Header**: "Close Case — [Client Name]" + outcome selector (Resolved / Escalated / Parked)
|
||||
2. **Structured fields** — pre-filled by streaming `/handoff-draft`, all editable:
|
||||
- **Root Cause** (short text input)
|
||||
- **Resolution** (what fixed it)
|
||||
- **Steps Taken** (list, auto-populated from step checklist)
|
||||
- **Recommendations** (next steps / preventive actions)
|
||||
3. **Output destinations** (checkboxes): Post to CW ticket note / Save to Knowledge Base / Send client summary
|
||||
4. **Confirm** button — triggers resolve/escalate/pause and passes structured fields into `ResolutionOutputGenerator`
|
||||
|
||||
The existing `SessionResolutionOutput` model and `ResolutionOutputGenerator` service are reused. The `/handoff-draft` stream starts immediately on modal open — the engineer can begin editing while fields fill in.
|
||||
|
||||
---
|
||||
|
||||
## Backend Changes Required
|
||||
|
||||
### 1. New AISession columns (Alembic migration)
|
||||
|
||||
Add to `ai_sessions` table:
|
||||
|
||||
| Column | Type | Purpose |
|
||||
|--------|------|---------|
|
||||
| `client_name` | `VARCHAR(255)` | MSP client name for incident header |
|
||||
| `asset_name` | `VARCHAR(255)` | Device / asset / user being worked on |
|
||||
| `issue_category` | `VARCHAR(100)` | Human-readable category (e.g. "DNS / Networking") |
|
||||
| `triage_hypothesis` | `TEXT` | Current working hypothesis — AI-updated + engineer-editable |
|
||||
| `evidence_items` | `JSONB` | "What We Know" list — persisted for session resume |
|
||||
|
||||
`evidence_items` format: `[{ "text": str, "status": "confirmed" | "ruled_out" | "pending" }]`
|
||||
|
||||
Note: `problem_domain` (existing) is an internal classifier slug. `issue_category` is the human-readable display label for the header. Both coexist.
|
||||
|
||||
### 2. New PATCH endpoint — triage metadata
|
||||
|
||||
```
|
||||
PATCH /ai-sessions/{session_id}/triage
|
||||
Auth: require_engineer_or_admin
|
||||
Body: { client_name?, asset_name?, issue_category?, triage_hypothesis?, evidence_items? }
|
||||
Response: { id, client_name, asset_name, issue_category, triage_hypothesis, evidence_items }
|
||||
```
|
||||
|
||||
Used when the engineer edits any header field or evidence list manually.
|
||||
|
||||
### 3. Updated schemas — TriageUpdate and QuestionItem.options
|
||||
|
||||
**New `TriageUpdate` model** (returned in chat response when AI infers session context):
|
||||
|
||||
```python
|
||||
class TriageUpdate(BaseModel):
|
||||
client_name: str | None = None
|
||||
asset_name: str | None = None
|
||||
issue_category: str | None = None
|
||||
triage_hypothesis: str | None = None
|
||||
evidence_items: list[dict] | None = None # appends to existing list
|
||||
```
|
||||
|
||||
**Updated `ChatMessageResponse`:**
|
||||
```python
|
||||
class ChatMessageResponse(BaseModel):
|
||||
# existing fields unchanged...
|
||||
triage_update: TriageUpdate | None = None
|
||||
```
|
||||
|
||||
**Updated `QuestionItem`** — add `options` for quick-reply buttons:
|
||||
```python
|
||||
class QuestionItem(BaseModel):
|
||||
text: str
|
||||
context: str = ""
|
||||
options: list[str] | None = None # quick-reply labels; null = free-text fallback
|
||||
```
|
||||
|
||||
### 4. unified_chat_service.py — triage extraction
|
||||
|
||||
After generating each AI response, run a lightweight extraction to populate `triage_update`. Implementation options (pick one during implementation):
|
||||
|
||||
- **Option A (recommended):** Embed structured extraction in the system prompt using an `[TRIAGE_UPDATE]` marker, similar to existing `[QUESTIONS]` / `[ACTIONS]` markers. AI emits the block if it has new triage signals; service parses it.
|
||||
- **Option B:** Post-response extraction pass using a fast model (`claude-haiku-4-5`) with the last 3 messages as context.
|
||||
|
||||
When `triage_update` contains non-null fields, the service auto-PATCHes the session record (so fields are persisted) AND returns `triage_update` in the response for the frontend to update the header immediately.
|
||||
|
||||
### 5. New streaming endpoint — handoff draft
|
||||
|
||||
```
|
||||
POST /ai-sessions/{session_id}/handoff-draft
|
||||
Auth: require_engineer_or_admin
|
||||
Response: StreamingResponse (text/event-stream)
|
||||
```
|
||||
|
||||
Streams a structured handoff JSON object:
|
||||
```json
|
||||
{ "root_cause": "...", "resolution": "...", "steps_taken": ["..."], "recommendations": "..." }
|
||||
```
|
||||
|
||||
Built from session context: `problem_summary`, `triage_hypothesis`, `evidence_items`, `conversation_messages` (last 20), step checklist from saved task lane state.
|
||||
|
||||
### 6. Updated conclude schemas
|
||||
|
||||
Add optional structured fields to `ResolveSessionRequest` and `EscalateSessionRequest`:
|
||||
|
||||
```python
|
||||
root_cause: str | None = None
|
||||
steps_taken: list[str] | None = None
|
||||
recommendations: str | None = None
|
||||
```
|
||||
|
||||
Pass these into `ResolutionOutputGenerator._build_session_context()` to enrich `psa_ticket_notes` and `client_summary` outputs.
|
||||
|
||||
### 7. Session read endpoint — include new triage fields
|
||||
|
||||
Ensure the session detail response (`GET /ai-sessions/{id}`) returns the new fields so the frontend can restore header state on session resume.
|
||||
|
||||
---
|
||||
|
||||
## Frontend Changes Required
|
||||
|
||||
### 1. AssistantChatPage layout refactor
|
||||
|
||||
Replace current layout (sidebar + chat column + TaskLane side panel) with the stacked cockpit layout described above.
|
||||
|
||||
**New state:**
|
||||
- `triageMeta: TriageMeta` — `{ client_name, asset_name, issue_category, triage_hypothesis, evidence_items }`
|
||||
- `workZoneHeight: number` — persisted to `localStorage('rf-assistant-work-zone-height')`
|
||||
|
||||
**On session load / resume:** populate `triageMeta` from the session response (new fields).
|
||||
|
||||
**On AI response:** if `response.triage_update` is non-null, merge into `triageMeta` (partial update, preserve existing non-null values unless AI overwrites).
|
||||
|
||||
### 2. New component: `IncidentHeader`
|
||||
|
||||
```
|
||||
frontend/src/components/assistant/IncidentHeader.tsx
|
||||
```
|
||||
|
||||
Props: `triageMeta`, `psaTicketId`, `sessionId`, `onFieldSave(field, value)`, `onResolve`, `onOverflow`
|
||||
|
||||
- Renders labelled single-row header
|
||||
- Each field: micro-label + value + `✏` icon (visible on hover)
|
||||
- `✏` opens an `EditPopover` (small popover with text input + Save/Cancel)
|
||||
- On Save: calls `aiSessionsApi.updateTriage(sessionId, { [field]: value })`
|
||||
- Empty field shows muted placeholder (e.g. "Unknown client")
|
||||
|
||||
### 3. Refactored component: `StepsPanel` (from TaskLane)
|
||||
|
||||
```
|
||||
frontend/src/components/assistant/StepsPanel.tsx
|
||||
```
|
||||
|
||||
Same `activeActions` data source. Renders as ordered checklist:
|
||||
- Completed: `✓` + strikethrough label, muted
|
||||
- Active: `→` + blue left border, white text, full opacity
|
||||
- Pending: `○` + muted text
|
||||
|
||||
Script generation CTA: shown at bottom when the active step has `command` containing "script" or when AI has flagged it.
|
||||
|
||||
### 4. New component: `FlowPilotAsks`
|
||||
|
||||
```
|
||||
frontend/src/components/assistant/FlowPilotAsks.tsx
|
||||
```
|
||||
|
||||
Props: `questions: QuestionItem[]`, `onAnswer(answer: string)`
|
||||
|
||||
- Shows first unanswered question (or empty/hidden state if none)
|
||||
- When `question.options` is non-null: renders as button row, clicking calls `onAnswer(option)`
|
||||
- When `question.options` is null: renders compact text input with Send button
|
||||
- `onAnswer` calls `handleSend` in the parent page with the answer text
|
||||
|
||||
### 5. New component: `WhatWeKnow`
|
||||
|
||||
```
|
||||
frontend/src/components/assistant/WhatWeKnow.tsx
|
||||
```
|
||||
|
||||
Props: `items: EvidenceItem[]`, `onAdd(text, status)`, `onEdit(index, text, status)`
|
||||
|
||||
- Renders evidence list with status icons: `✓` (confirmed, green), `✗` (ruled out, red), `?` (pending, muted)
|
||||
- "+ Add finding" link at bottom opens an inline input row
|
||||
- Items are editable inline (click to edit)
|
||||
- State lives in `AssistantChatPage` as part of `triageMeta.evidence_items`, synced to backend via `PATCH /triage`
|
||||
|
||||
### 6. Drag handle — resizable split
|
||||
|
||||
Implement as a thin handle bar between work zone and conversation log. On drag:
|
||||
- Update `workZoneHeight` in state
|
||||
- Persist to `localStorage`
|
||||
|
||||
On mount: restore from `localStorage`, default to `55%` of available height.
|
||||
|
||||
### 7. Compact conversation log
|
||||
|
||||
Replace current `<ChatMessage>` bubble rendering in the log zone with a compact list:
|
||||
|
||||
```
|
||||
you: Can't resolve external DNS, internal fine
|
||||
fp: Ping passed — layer 3 OK. Run nslookup google.com.
|
||||
you: Timed out on 1.1.1.1 too.
|
||||
```
|
||||
|
||||
`ChatMessage` component still used for rich rendering (suggested flows, forks) but in a more compact variant. Full bubble rendering available on hover/expand if needed.
|
||||
|
||||
### 8. Redesigned `ConcludeSessionModal`
|
||||
|
||||
Replaces current simple textarea with the structured handoff form. On open:
|
||||
1. Call `aiSessionsApi.getHandoffDraft(sessionId)` — streaming — populate fields as stream arrives
|
||||
2. Render outcome selector + 4 structured fields (all `<textarea>` with labels)
|
||||
3. Render output destination checkboxes
|
||||
4. On Confirm: call resolve/escalate/pause with enriched request body
|
||||
|
||||
### 9. MSP-native language pass
|
||||
|
||||
| Old | New |
|
||||
|-----|-----|
|
||||
| "AI Assistant" | "FlowPilot" |
|
||||
| "New Chat" | "New Case" |
|
||||
| "Messages" | "Conversation Log" |
|
||||
| "Task Lane" (panel header) | "Steps" |
|
||||
| "Conclude" | "Close Case" |
|
||||
| "Chat history" (sidebar label) | "Case History" |
|
||||
|
||||
---
|
||||
|
||||
## What This Is NOT
|
||||
|
||||
- Not a redesign of the FlowPilot session page (`/pilot`) — separate page, untouched
|
||||
- Not a rebuild of session, branching, or PSA architecture
|
||||
- Not a new data model for conversations — `conversation_messages` JSONB is unchanged
|
||||
- Not a mobile-first redesign — mobile degrades cleanly but desktop is primary
|
||||
|
||||
---
|
||||
|
||||
## Verification
|
||||
|
||||
### Harness Feel Test (primary — subjective)
|
||||
- Open `/assistant`, start a new case: does the page read as an MSP triage cockpit within 3 seconds without reading labels?
|
||||
- Is the current active step obvious without scrolling through chat?
|
||||
- Do FlowPilot Asks quick-reply buttons submit answers and update the steps list?
|
||||
- Does the incident header update mid-session as the AI infers context?
|
||||
- Drag the handle, refresh: does the split restore correctly?
|
||||
|
||||
### Functional Regression
|
||||
- Free-text chat, image paste, suggested flows, forks, branching: all work
|
||||
- Session pause, resume, and handoff end-to-end: works
|
||||
- ConcludeSessionModal resolves / escalates / parks correctly
|
||||
- Handoff draft streams and pre-fills the modal fields
|
||||
- Manual header edit saves and persists across reload
|
||||
|
||||
### MSP Scenario Coverage (from docx)
|
||||
Run end-to-end: single-user endpoint issue · M365/tenant-wide issue · network/VPN outage · escalation and resume after handoff.
|
||||
|
||||
### Backend Checks
|
||||
```bash
|
||||
# Migration
|
||||
alembic upgrade head
|
||||
|
||||
# Verify new columns
|
||||
psql -U postgres -d resolutionflow -c "\d ai_sessions" | grep -E "client_name|asset_name|issue_category|triage_hypothesis|evidence_items"
|
||||
|
||||
# Smoke test endpoints (with valid token)
|
||||
curl -X PATCH .../ai-sessions/{id}/triage -d '{"client_name":"Test"}'
|
||||
curl -X POST .../ai-sessions/{id}/handoff-draft # should stream JSON
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Critical Files
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `backend/app/models/ai_session.py` | Add 5 new columns |
|
||||
| `backend/app/schemas/ai_session.py` | Add `TriageUpdate`, extend `QuestionItem`, update request/response schemas |
|
||||
| `backend/app/api/endpoints/ai_sessions.py` | Add `PATCH /{id}/triage`, `POST /{id}/handoff-draft` |
|
||||
| `backend/app/services/unified_chat_service.py` | Extract and return `triage_update` per AI response |
|
||||
| `backend/app/services/resolution_output_generator.py` | Accept structured handoff fields in context builder |
|
||||
| `backend/alembic/versions/NNN_add_triage_fields_to_ai_sessions.py` | Sequential migration (check `ls backend/alembic/versions/ \| sort \| tail -1` for NNN) |
|
||||
| `frontend/src/pages/AssistantChatPage.tsx` | Full layout refactor — cockpit structure |
|
||||
| `frontend/src/components/assistant/IncidentHeader.tsx` | New component |
|
||||
| `frontend/src/components/assistant/StepsPanel.tsx` | Refactored from `TaskLane` |
|
||||
| `frontend/src/components/assistant/FlowPilotAsks.tsx` | New component |
|
||||
| `frontend/src/components/assistant/WhatWeKnow.tsx` | New component |
|
||||
| `frontend/src/components/assistant/ConcludeSessionModal.tsx` | Redesigned |
|
||||
| `frontend/src/api/aiSessions.ts` | Add `updateTriage()`, `getHandoffDraft()` |
|
||||
| `frontend/src/types/ai-session.ts` | Add `TriageUpdate`, `TriageMeta`, `EvidenceItem`; extend `QuestionItem` |
|
||||
609
docs/cockpit/2026-04-01-msp-assistant-harness-plan-claude.md
Normal file
609
docs/cockpit/2026-04-01-msp-assistant-harness-plan-claude.md
Normal file
@@ -0,0 +1,609 @@
|
||||
# MSP Assistant Harness — Super Plan
|
||||
**Date:** 2026-04-01
|
||||
**Status:** Approved — ready to execute
|
||||
**Sources:** `MSP_Assistant_Harness_Implementation_Plan.docx` (v2.0) + `2026-04-01-msp-assistant-harness-design.md` (brainstorming session)
|
||||
|
||||
---
|
||||
|
||||
## Goal
|
||||
|
||||
Reframe `/assistant` from a generic AI chat surface into a **live MSP triage cockpit**. An engineer arrives with an open ticket; the page immediately reads as their operational tool — not an AI chatbot that's been adapted for IT work.
|
||||
|
||||
The change is a UI and data layer reframe. The existing session, branching, PSA, and conclude architecture is preserved and extended, not rebuilt.
|
||||
|
||||
### Key Architectural Choices
|
||||
|
||||
This plan explicitly chooses:
|
||||
|
||||
- **`FlowPilot`** as the primary page/product label (not "Assistant Harness")
|
||||
- **Backend triage + handoff contracts required in v1** — not deferred to a later phase
|
||||
- **Desktop-first cockpit layout** with clean mobile degradation
|
||||
- **Explicit persisted triage fields** on the session model, not purely derived/computed header state
|
||||
- **Prompt-embedded structured extraction** (`[TRIAGE_UPDATE]` marker) as the primary AI triage path, with post-response model pass only as fallback
|
||||
- **Sidebar visual demotion** — existing sidebar stays but is visually de-emphasized so the cockpit reads as an operations surface, not a chat app
|
||||
|
||||
---
|
||||
|
||||
## What Phase 0 Resolved
|
||||
|
||||
The brainstorming session (2026-04-01) locked these decisions. They are not open questions.
|
||||
|
||||
| Question | Decision |
|
||||
|----------|----------|
|
||||
| Layout structure | Stacked zones: incident header → work zone → (drag handle) → conversation log → compose |
|
||||
| Incident header style | Single row, explicit micro-labels above each field, per-field `✏` edit |
|
||||
| Work zone left panel | Ordered step checklist (✓ / → / ○) |
|
||||
| Work zone right panel | Two stacked mini-panels: FlowPilot Asks (top) + What We Know (bottom) |
|
||||
| Chat zone treatment | Drag-resizable split, compact `you:` / `fp:` prefix style, darker background |
|
||||
| Chat collapsibility | Not collapsible — drag handle gives control |
|
||||
| Scope | Includes all required backend changes, not UI-layer only |
|
||||
| Conclude modal | Fully redesigned as structured handoff artifact |
|
||||
| Page label | "FlowPilot" (not "AI Assistant") |
|
||||
| "New Chat" label | "New Case" |
|
||||
| "Conclude" label | "Close Case" |
|
||||
| Hypothesis language | "Hypothesis" (direct, not softened to "working theory") |
|
||||
| What We Know editability | Engineer-editable + AI-appended |
|
||||
| Header field population | Intake form + AI-inferred mid-session + manual engineer override |
|
||||
|
||||
---
|
||||
|
||||
## Cockpit Layout
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ [Left sidebar — Case History, unchanged] │
|
||||
│ ┌───────────────────────────────────────────────────────┐ │
|
||||
│ │ INCIDENT HEADER (single row, labelled fields) │ │
|
||||
│ │ CLIENT DEVICE CATEGORY HYPOTHESIS │ │
|
||||
│ │ Contoso ✏ jsmith-04 ✏ DNS/Net ✏ Cache fail ✏ │ │
|
||||
│ │ [CW #48291][Resolve⋯]│ │
|
||||
│ ├───────────────────────┬───────────────────────────────┤ │
|
||||
│ │ │ ▸ FLOWPILOT ASKS (amber) │ │
|
||||
│ │ STEPS (~55%) │ Did nslookup time out? │ │
|
||||
│ │ ✓ Ping 8.8.8.8 │ [Time out] [Wrong IP] [Both] │ │
|
||||
│ │ → nslookup ←active ├───────────────────────────────┤ │
|
||||
│ │ ○ Flush DNS │ WHAT WE KNOW │ │
|
||||
│ │ ○ Check NIC │ ✓ Gateway reachable │ │
|
||||
│ │ │ ✗ DNS 1.1.1.1 — timeout │ │
|
||||
│ │ [⚡ Generate Script] │ ? DNS 8.8.8.8 — pending │ │
|
||||
│ ├───────────────────────┴───── ≡ drag handle ───────────┤ │
|
||||
│ │ CONVERSATION LOG (compact, darker bg) │ │
|
||||
│ │ you: Can't resolve external DNS, internal fine │ │
|
||||
│ │ fp: Ping test passed. Run nslookup google.com. │ │
|
||||
│ │ you: Timed out on 1.1.1.1 too. │ │
|
||||
│ ├───────────────────────────────────────────────────────┤ │
|
||||
│ │ Describe next finding or ask FlowPilot... [Send] │ │
|
||||
│ └───────────────────────────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Contract Decisions (Codex Readiness Review)
|
||||
|
||||
The following decisions were flagged as ambiguous by the Codex readiness review. Each is now resolved.
|
||||
|
||||
### RED — Canonical handoff artifact
|
||||
|
||||
**Decision:** `ResolutionOutputGenerator` is the single canonical generator. Everything else is transport or UI.
|
||||
|
||||
- `POST /handoff-draft` is a **preview** endpoint — streams a draft for the conclude modal UI. Does not persist. Does not generate final artifacts.
|
||||
- On confirm (resolve/escalate), the page calls the existing resolve/escalate endpoints, which trigger `ResolutionOutputGenerator.generate_all()` as today. The structured fields from the modal (`root_cause`, `steps_taken`, `recommendations`) are passed into `_build_session_context()` to enrich the final outputs.
|
||||
- `/documentation/stream` and `/status-update` remain untouched — they are separate transport channels for the same canonical outputs.
|
||||
- `/handoff-draft` is **assistant-only** in v1 (not shared with guided FlowPilot sessions on `/pilot`).
|
||||
|
||||
### RED — AI vs manual field authority
|
||||
|
||||
**Decision:** Manual edits win. AI does not overwrite manual edits.
|
||||
|
||||
| Rule | Behavior |
|
||||
|------|----------|
|
||||
| AI auto-fill | Only fills fields that are currently `null` or empty. Never overwrites a non-null value. |
|
||||
| Manual edit | Persists immediately via `PATCH /triage`. Sets the field as "manually set." |
|
||||
| AI after manual edit | AI may **suggest** an update (shown as a subtle inline prompt: "FlowPilot suggests: Contoso Corp → Contoso Ltd"), but does not auto-write. |
|
||||
| Evidence items — AI | Appends new items only. Does not modify or remove existing items. |
|
||||
| Evidence items — engineer | Full authority: add, edit status, edit text, remove. |
|
||||
|
||||
Implementation: add a `triage_manual_fields` set (stored in frontend `localStorage` per session) tracking which fields the engineer has manually edited. AI `triage_update` skips those fields unless the engineer explicitly accepts the suggestion.
|
||||
|
||||
### RED — `evidence_items` write model
|
||||
|
||||
**Decision:** Full-list replacement for all writes. Keep it simple.
|
||||
|
||||
- `PATCH /triage` sends the complete `evidence_items` array. Backend replaces the stored array.
|
||||
- AI appends: frontend receives `triage_update.evidence_items`, appends to the current local list, then PATCHes the full merged list.
|
||||
- Engineer edits: frontend modifies the local list, PATCHes the full list.
|
||||
- No partial-update or append-only semantics on the backend. The frontend is the merge authority.
|
||||
|
||||
### YELLOW — TaskLane persistence in StepsPanel
|
||||
|
||||
**Decision:** `StepsPanel` is presentation only. All persistence behavior stays in `AssistantChatPage`.
|
||||
|
||||
`TaskLane` currently owns sessionStorage drafts, debounced backend saves, and restoration. In the cockpit refactor:
|
||||
- `AssistantChatPage` lifts all persistence logic out of `TaskLane` into the page (or a custom hook like `useTaskPersistence`)
|
||||
- `StepsPanel` receives `activeActions` as a prop and renders them — no persistence responsibility
|
||||
- `TaskLane.tsx` remains in the codebase untouched (other pages may still use it)
|
||||
|
||||
### YELLOW — Quick-reply submission semantics
|
||||
|
||||
**Decision:** Quick replies are **immediate-send** controls.
|
||||
|
||||
- Clicking a quick-reply button calls `handleSend(option)` — the answer goes directly to the AI as a chat message
|
||||
- No local-only "select then send" workflow
|
||||
- The answer appears in the conversation log as a regular `you:` message
|
||||
- This is a full-stack change: prompt instructions must tell the AI to include `options` on constrained questions, parser must extract them, schema must carry them, frontend must render and submit them
|
||||
|
||||
### YELLOW — `issue_category` format
|
||||
|
||||
**Decision:** Free text in v1. No controlled taxonomy.
|
||||
|
||||
- AI infers a human-readable category string (e.g., "DNS / Networking", "Microsoft 365", "Active Directory")
|
||||
- Engineer can edit to any value via the header `✏` popover
|
||||
- Future: may introduce a taxonomy dropdown populated from session history — but not in v1
|
||||
|
||||
### YELLOW — `asset_name` when user and device differ
|
||||
|
||||
**Decision:** Free text. The engineer enters whatever is most operationally relevant.
|
||||
|
||||
- Could be a device name ("jsmith-desktop-04"), a user ("John Smith"), or both ("jsmith-desktop-04 / John Smith")
|
||||
- AI infers from conversation context — typically the entity being troubleshot
|
||||
- No enforced format in v1
|
||||
|
||||
### YELLOW — Structured conclude fields persistence
|
||||
|
||||
**Decision:** Structured conclude fields (`root_cause`, `steps_taken`, `recommendations`) are **passed through to `ResolutionOutputGenerator`** but are NOT stored as separate session columns.
|
||||
|
||||
- They arrive in the resolve/escalate request body
|
||||
- `_build_session_context()` uses them to generate richer PSA notes and client summaries
|
||||
- The generated outputs (stored in `session_resolution_outputs`) are the persisted artifacts
|
||||
- If we later need the raw structured fields, add columns then — not speculatively now
|
||||
|
||||
### Fallback — `[TRIAGE_UPDATE]` unreliability
|
||||
|
||||
**Decision:** If prompt-embedded extraction proves unreliable after testing against 5 real sessions:
|
||||
|
||||
1. **First fallback:** Post-response extraction using `claude-haiku-4-5` with last 3 messages as context. Cheap, fast, decoupled from the main prompt.
|
||||
2. **Second fallback:** Fully manual header — engineer fills in fields, AI never auto-updates. Cockpit still works; it just requires more manual input.
|
||||
|
||||
Gate: Phase 2 step 15 ("verify extraction in a live session") must pass before wiring `triage_update` into the visible header.
|
||||
|
||||
---
|
||||
|
||||
## Implementation Guardrails
|
||||
|
||||
These are hard rules during implementation, not suggestions.
|
||||
|
||||
1. **Do not let AI write speculative values into the header.** Every AI-inferred field must trace to ticket data or explicit conversation evidence. If the AI can't ground it, the field stays empty.
|
||||
2. **Do not redesign conclude UX until the canonical handoff source-of-truth is wired.** Phase 6 (conclude modal) depends on Phase 1 (backend) being stable.
|
||||
3. **Do not treat `TaskLane` as presentation-only until its persistence behavior has been lifted.** Extract persistence into a hook or the page before building `StepsPanel`.
|
||||
4. **Do not wire header auto-updates from `[TRIAGE_UPDATE]` until real-session reliability is tested.** Phase 2 step 15 is a gate.
|
||||
5. **Run `npx tsc -b` after every phase.** Do not batch TypeScript error fixes (lesson #92).
|
||||
|
||||
---
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- No redesign of `/pilot` (FlowPilot session page) — separate page, untouched
|
||||
- No rebuild of session, branching, or PSA architecture
|
||||
- No new data model for conversations — `conversation_messages` JSONB unchanged
|
||||
- No mobile-first redesign — mobile degrades cleanly, desktop is primary
|
||||
- No generic "assistant polish" that does not tighten the harness
|
||||
|
||||
---
|
||||
|
||||
## Backend Changes
|
||||
|
||||
### B1 — Alembic migration `071`
|
||||
|
||||
File: `backend/alembic/versions/071_add_triage_fields_to_ai_sessions.py`
|
||||
|
||||
Add to `ai_sessions`:
|
||||
|
||||
| Column | Type | Notes |
|
||||
|--------|------|-------|
|
||||
| `client_name` | `VARCHAR(255)` | MSP client for incident header |
|
||||
| `asset_name` | `VARCHAR(255)` | Device / user being worked on |
|
||||
| `issue_category` | `VARCHAR(100)` | Human-readable category ("DNS / Networking") |
|
||||
| `triage_hypothesis` | `TEXT` | Working hypothesis — AI-updated + editable |
|
||||
| `evidence_items` | `JSONB` | What We Know list — persisted for resume |
|
||||
|
||||
`evidence_items` schema: `[{ "text": str, "status": "confirmed" | "ruled_out" | "pending" }]`
|
||||
|
||||
Note: existing `problem_domain` is an internal classifier slug and is unchanged. `issue_category` is the human-readable display label. Both coexist.
|
||||
|
||||
### B2 — Updated schemas (`backend/app/schemas/ai_session.py`)
|
||||
|
||||
**New `TriageUpdate`:**
|
||||
```python
|
||||
class TriageUpdate(BaseModel):
|
||||
client_name: str | None = None
|
||||
asset_name: str | None = None
|
||||
issue_category: str | None = None
|
||||
triage_hypothesis: str | None = None
|
||||
evidence_items: list[dict] | None = None # appends to existing list
|
||||
```
|
||||
|
||||
**Updated `ChatMessageResponse`:**
|
||||
```python
|
||||
class ChatMessageResponse(BaseModel):
|
||||
# ... existing fields unchanged ...
|
||||
triage_update: TriageUpdate | None = None
|
||||
```
|
||||
|
||||
**Updated `QuestionItem`** — add quick-reply options:
|
||||
```python
|
||||
class QuestionItem(BaseModel):
|
||||
text: str
|
||||
context: str = ""
|
||||
options: list[str] | None = None # quick-reply labels; null → free-text input
|
||||
```
|
||||
|
||||
**Updated `ResolveSessionRequest` / `EscalateSessionRequest`:**
|
||||
```python
|
||||
root_cause: str | None = None
|
||||
steps_taken: list[str] | None = None
|
||||
recommendations: str | None = None
|
||||
```
|
||||
|
||||
### B3 — New `PATCH /ai-sessions/{id}/triage` endpoint
|
||||
|
||||
```
|
||||
PATCH /ai-sessions/{session_id}/triage
|
||||
Auth: require_engineer_or_admin
|
||||
Body: { client_name?, asset_name?, issue_category?, triage_hypothesis?, evidence_items? }
|
||||
Response: { id, client_name, asset_name, issue_category, triage_hypothesis, evidence_items }
|
||||
```
|
||||
|
||||
Called on every manual header field edit. Partial update — only supplied fields are written.
|
||||
|
||||
### B4 — New `POST /ai-sessions/{id}/handoff-draft` endpoint
|
||||
|
||||
```
|
||||
POST /ai-sessions/{session_id}/handoff-draft
|
||||
Auth: require_engineer_or_admin
|
||||
Response: StreamingResponse (text/event-stream)
|
||||
```
|
||||
|
||||
Streams structured handoff JSON built from session context:
|
||||
```json
|
||||
{ "root_cause": "...", "resolution": "...", "steps_taken": ["..."], "recommendations": "..." }
|
||||
```
|
||||
|
||||
Uses: `problem_summary`, `triage_hypothesis`, `evidence_items`, last 20 `conversation_messages`, saved task lane state.
|
||||
|
||||
Called immediately on conclude modal open — engineer can edit while stream fills in.
|
||||
|
||||
### B5 — `unified_chat_service.py` — triage extraction
|
||||
|
||||
After each AI response, extract triage signals and return as `triage_update`.
|
||||
|
||||
**Recommended approach:** Add a `[TRIAGE_UPDATE]` structured marker to the system prompt, following the existing `[QUESTIONS]` / `[ACTIONS]` / `[FORK]` marker pattern. The AI emits the block only when it has new signal:
|
||||
|
||||
```
|
||||
[TRIAGE_UPDATE]
|
||||
client_name: Contoso Ltd
|
||||
issue_category: DNS / Networking
|
||||
triage_hypothesis: Corrupted DNS cache on NIC
|
||||
evidence_items:
|
||||
- confirmed: Gateway 192.168.1.1 reachable
|
||||
- ruled_out: DNS 1.1.1.1 — timeout
|
||||
[/TRIAGE_UPDATE]
|
||||
```
|
||||
|
||||
Service parses this, strips it from `display_content`, auto-PATCHes the session record, and returns `triage_update` in the response.
|
||||
|
||||
### B6 — `resolution_output_generator.py` — accept structured fields
|
||||
|
||||
Update `_build_session_context()` to incorporate `root_cause`, `steps_taken`, and `recommendations` when supplied, producing richer `psa_ticket_notes` and `client_summary` outputs.
|
||||
|
||||
### B7 — Session detail response — expose new triage fields
|
||||
|
||||
`GET /ai-sessions/{id}` (and the session list item) must return the 5 new fields so the frontend can restore header state on session load and resume.
|
||||
|
||||
---
|
||||
|
||||
## Frontend Changes
|
||||
|
||||
### F1 — `AssistantChatPage.tsx` — cockpit layout refactor
|
||||
|
||||
Replace current layout (sidebar + chat column + TaskLane right rail) with the stacked cockpit structure.
|
||||
|
||||
**New state:**
|
||||
- `triageMeta: TriageMeta` — `{ client_name, asset_name, issue_category, triage_hypothesis, evidence_items }`
|
||||
- `workZoneHeight: number` — persisted to `localStorage('rf-assistant-work-zone-height')`
|
||||
|
||||
**On session load / resume:** populate `triageMeta` from session response new fields.
|
||||
|
||||
**On AI response:** if `response.triage_update` is non-null, merge into `triageMeta` (partial — preserve existing non-null values unless AI explicitly overwrites).
|
||||
|
||||
**Work zone layout:** left `StepsPanel` + right column with `FlowPilotAsks` stacked above `WhatWeKnow`.
|
||||
|
||||
**Chat zone layout:** compact `ConversationLog` below drag handle, independent scroll.
|
||||
|
||||
### F2 — New `IncidentHeader.tsx`
|
||||
|
||||
```
|
||||
frontend/src/components/assistant/IncidentHeader.tsx
|
||||
```
|
||||
|
||||
Props: `triageMeta: TriageMeta`, `psaTicketId: string | null`, `sessionId: string`, `onFieldSave(field, value)`, `onResolve()`, `onOverflow()`
|
||||
|
||||
- Single-row bar with micro-labels (CLIENT / DEVICE / CATEGORY / HYPOTHESIS)
|
||||
- Each field: `✏` icon visible on hover → opens inline `EditPopover` (text input + Save/Cancel)
|
||||
- On Save: calls `aiSessionsApi.updateTriage(sessionId, { [field]: value })`
|
||||
- Empty fields: muted placeholder ("Unknown client", "No device specified", etc.)
|
||||
- Right side: PSA ticket badge (if linked) + Resolve button + `⋯` overflow menu
|
||||
|
||||
### F3 — Refactored `StepsPanel.tsx` (from `TaskLane`)
|
||||
|
||||
```
|
||||
frontend/src/components/assistant/StepsPanel.tsx
|
||||
```
|
||||
|
||||
Preserves all `TaskLane` data logic and persistence. Changes rendering only:
|
||||
|
||||
| State | Icon | Style |
|
||||
|-------|------|-------|
|
||||
| Completed | `✓` | Strikethrough, muted, green icon |
|
||||
| Active | `→` | Blue left border, white text, full opacity |
|
||||
| Pending | `○` | Muted text |
|
||||
|
||||
Script generation CTA: shown at bottom when active step `command` references "script" or AI has flagged it.
|
||||
|
||||
`TaskLane.tsx` can remain for now (no renames required in this phase) — `StepsPanel` is a new component that consumes the same `activeActions` prop.
|
||||
|
||||
### F4 — New `FlowPilotAsks.tsx`
|
||||
|
||||
```
|
||||
frontend/src/components/assistant/FlowPilotAsks.tsx
|
||||
```
|
||||
|
||||
Props: `questions: QuestionItem[]`, `onAnswer(answer: string)`
|
||||
|
||||
- Renders first unanswered question
|
||||
- `question.options` non-null → button row; clicking calls `onAnswer(option)`
|
||||
- `question.options` null → compact text input + Send
|
||||
- `onAnswer` calls parent's `handleSend` with the answer string
|
||||
- Hidden entirely when `questions` is empty
|
||||
|
||||
### F5 — New `WhatWeKnow.tsx`
|
||||
|
||||
```
|
||||
frontend/src/components/assistant/WhatWeKnow.tsx
|
||||
```
|
||||
|
||||
Props: `items: EvidenceItem[]`, `onAdd(text, status)`, `onEdit(index, text, status)`
|
||||
|
||||
- Evidence list: `✓` confirmed (green) / `✗` ruled out (red) / `?` pending (muted)
|
||||
- "+ Add finding" inline entry at bottom
|
||||
- Click any item to edit inline
|
||||
- State lives in `AssistantChatPage` (`triageMeta.evidence_items`), synced to backend via `PATCH /triage`
|
||||
|
||||
### F6 — Drag-resizable split
|
||||
|
||||
Thin handle bar between work zone and conversation log. On drag: update `workZoneHeight` in state, persist to `localStorage`. On mount: restore, default `55%`.
|
||||
|
||||
### F7 — Compact `ConversationLog` rendering
|
||||
|
||||
Replace current full `<ChatMessage>` bubbles in the log zone with a compact list: `you: ...` / `fp: ...` prefix style, tighter line height, no avatars. `ChatMessage` can still be used for rich content (forks, suggested flows) in a compact variant. Individual messages should support click-to-expand for full rendering when the engineer needs to re-read a longer response or review a suggested flow.
|
||||
|
||||
### F8 — Redesigned `ConcludeSessionModal.tsx`
|
||||
|
||||
On open:
|
||||
1. Call `aiSessionsApi.getHandoffDraft(sessionId)` (streaming) — fields fill in as stream arrives
|
||||
2. Render: outcome selector (Resolved / Escalated / Parked)
|
||||
3. Render 4 structured editable fields: Root Cause, Resolution, Steps Taken, Recommendations
|
||||
4. Render output destination checkboxes: Post to CW note / Save to KB / Send client summary
|
||||
5. Confirm → call resolve/escalate/pause with enriched request body including structured fields
|
||||
|
||||
### F9 — Sidebar visual demotion
|
||||
|
||||
The existing `ChatSidebar` stays functionally unchanged but should be visually softened so the cockpit — not the session list — reads as the primary surface. Specific changes:
|
||||
|
||||
- Reduce sidebar background contrast (use `bg-sidebar` or one step darker)
|
||||
- Reduce sidebar header prominence (smaller label, no bold "Chat History" heading)
|
||||
- Rename "Chat History" → "Case History" (part of language pass)
|
||||
- Default sidebar to collapsed state on first cockpit load (existing collapse toggle + `localStorage`)
|
||||
|
||||
### F10 — MSP-native language pass
|
||||
|
||||
| Old | New |
|
||||
|-----|-----|
|
||||
| "AI Assistant" (page title, meta) | "FlowPilot" |
|
||||
| "New Chat" | "New Case" |
|
||||
| "Messages" | "Conversation Log" |
|
||||
| "Task Lane" (panel label) | "Steps" |
|
||||
| "Conclude" | "Close Case" |
|
||||
| "Chat history" (sidebar label) | "Case History" |
|
||||
| Compose placeholder | "Describe finding, paste log output, or ask FlowPilot..." |
|
||||
|
||||
### F11 — New API methods (`aiSessions.ts`)
|
||||
|
||||
```typescript
|
||||
updateTriage(sessionId: string, fields: Partial<TriageMeta>): Promise<TriageMeta>
|
||||
getHandoffDraft(sessionId: string): AsyncGenerator<HandoffDraftChunk>
|
||||
```
|
||||
|
||||
### F12 — New types (`types/ai-session.ts`)
|
||||
|
||||
```typescript
|
||||
interface TriageMeta {
|
||||
client_name: string | null
|
||||
asset_name: string | null
|
||||
issue_category: string | null
|
||||
triage_hypothesis: string | null
|
||||
evidence_items: EvidenceItem[]
|
||||
}
|
||||
|
||||
interface EvidenceItem {
|
||||
text: string
|
||||
status: 'confirmed' | 'ruled_out' | 'pending'
|
||||
}
|
||||
|
||||
interface TriageUpdate extends Partial<TriageMeta> {}
|
||||
|
||||
// Extend existing:
|
||||
interface QuestionItem {
|
||||
text: string
|
||||
context: string
|
||||
options?: string[] // new
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phased Execution Order
|
||||
|
||||
### Phase 1 — Backend Foundation
|
||||
|
||||
> Lock backend schema and API changes first so the cockpit can be built against stable session contracts.
|
||||
|
||||
1. Write migration `071` — add 5 columns to `ai_sessions`
|
||||
2. Run `alembic upgrade head`, verify columns
|
||||
3. Update `AISession` model with new mapped columns
|
||||
4. Add `TriageUpdate` schema, extend `QuestionItem`, extend `ChatMessageResponse`
|
||||
5. Extend `ResolveSessionRequest` / `EscalateSessionRequest` with structured fields
|
||||
6. Add `PATCH /{id}/triage` endpoint
|
||||
7. Add `POST /{id}/handoff-draft` streaming endpoint
|
||||
8. Update `GET /ai-sessions/{id}` response to include new triage fields
|
||||
9. Update `resolution_output_generator._build_session_context()` to use structured fields
|
||||
10. Run backend tests — `pytest --override-ini="addopts="`
|
||||
|
||||
### Phase 2 — Triage Extraction (AI layer)
|
||||
11. Add `[TRIAGE_UPDATE]` marker to `unified_chat_service.py` system prompt
|
||||
12. Implement `_parse_triage_update_marker()` in the service (follow existing `_parse_questions_marker` / `_parse_actions_marker` pattern)
|
||||
13. Auto-PATCH session on non-null `triage_update` (respect manual-edit authority: skip fields in `triage_manual_fields`)
|
||||
14. Add `options` generation instructions to `[QUESTIONS]` system prompt section
|
||||
15. **GATE:** Verify extraction in 5 real sessions. If `[TRIAGE_UPDATE]` is emitted reliably (≥4/5), proceed. Otherwise switch to Haiku post-response fallback before wiring into the header.
|
||||
|
||||
### Phase 3 — New Frontend Types + API
|
||||
16. Add `TriageMeta`, `EvidenceItem`, `TriageUpdate` to `types/ai-session.ts`
|
||||
17. Extend `QuestionItem` type
|
||||
18. Add `updateTriage()` and `getHandoffDraft()` to `aiSessions.ts`
|
||||
|
||||
### Phase 4 — New Work Zone Components
|
||||
19. Extract `TaskLane` persistence logic into `useTaskPersistence` hook (sessionStorage drafts, debounced saves, restoration) — prerequisite for StepsPanel
|
||||
20. Build `IncidentHeader.tsx` with `EditPopover`
|
||||
21. Build `StepsPanel.tsx` (presentation only — receives props from hook)
|
||||
22. Build `FlowPilotAsks.tsx`
|
||||
23. Build `WhatWeKnow.tsx`
|
||||
|
||||
### Phase 5 — Page Layout Refactor
|
||||
24. Refactor `AssistantChatPage.tsx` — implement stacked cockpit layout
|
||||
25. Wire `triageMeta` state, session load population, `triage_update` merge (with `triage_manual_fields` guard)
|
||||
26. Implement drag-resizable split with `localStorage` persistence
|
||||
27. Compact `ConversationLog` rendering (with click-to-expand for long messages)
|
||||
|
||||
### Phase 6 — Handoff Modal + Language Pass + Sidebar
|
||||
28. Redesign `ConcludeSessionModal.tsx` — structured handoff form (calls `/handoff-draft` for preview, confirms via existing resolve/escalate endpoints which trigger `ResolutionOutputGenerator`)
|
||||
29. Sidebar visual demotion — background, label prominence, default-collapsed
|
||||
30. MSP-native language pass across all assistant components
|
||||
31. Update `<PageMeta>` title
|
||||
|
||||
### Phase 7 — QA + Hardening
|
||||
32. `npx tsc -b` — fix any TypeScript errors
|
||||
33. `npm run build` — production build clean
|
||||
34. Functional regression: all chat flows, session switching, conclude/resume
|
||||
35. Harness feel test: cockpit within 3 seconds?
|
||||
36. Mobile viewport check
|
||||
37. Stress test: 50+ messages, 10+ steps, long outputs
|
||||
|
||||
---
|
||||
|
||||
## Risks and Mitigations
|
||||
|
||||
| Risk | Mitigation |
|
||||
|------|-----------|
|
||||
| `[TRIAGE_UPDATE]` marker extraction is unreliable — AI doesn't emit it consistently | Gate Phase 2 on a pass/fail test with 5 real sessions before wiring it to the header. Fall back to Option B (post-response Haiku pass) if needed. |
|
||||
| Header fields feel fabricated — AI guesses wrong client or hypothesis | Show confidence-aware placeholder copy ("FlowPilot is building context…") until a field has real data. Never invent. |
|
||||
| Task lane visual promotion breaks established chat patterns | Keep all send/respond behavior intact. Change hierarchy only. Verify every task-lane state transition manually. |
|
||||
| Handoff modal exposes weak underlying summaries | Reuse existing `ResolutionOutputGenerator` output where possible. Add guardrail copy for empty fields. |
|
||||
| Mobile loses compose or step access | Test responsive layout as a first-class deliverable in Phase 7, not a final sweep. Enforce scroll isolation between all zones. |
|
||||
| `tsc -b` errors after component refactor | Run `npx tsc -b` after every phase. Trace unused imports/props immediately — don't batch (lesson #92). |
|
||||
|
||||
---
|
||||
|
||||
## Test Plan
|
||||
|
||||
### Harness Feel (primary, subjective)
|
||||
- Does the page read as an MSP triage cockpit within 3 seconds on first load?
|
||||
- Is the active step obvious without reading chat?
|
||||
- Do FlowPilot Asks quick-reply buttons work and update the step list?
|
||||
- Does the incident header update mid-session as AI learns context?
|
||||
- Drag handle, refresh — does split restore?
|
||||
- Does the conclude modal look like a case handoff or a chat closure?
|
||||
|
||||
### Functional Regression
|
||||
- New session (no PSA) — header degrades gracefully
|
||||
- New session (with CW ticket) — header populates from ticket data
|
||||
- Send message → `triage_update` updates header
|
||||
- Click quick-reply button → answer submitted, step advances
|
||||
- Add finding to What We Know → persisted via PATCH
|
||||
- Edit header field via `✏` → saved and survives refresh
|
||||
- Conclude as Resolved → handoff draft fills modal → post to CW note
|
||||
- Conclude as Escalated → same
|
||||
- Pause and resume → triage header restores from saved session fields
|
||||
- Session switching (currentChatRef guard) — no stale state
|
||||
- Image paste, forks, suggested flows — all still work
|
||||
|
||||
### MSP Scenarios (from docx)
|
||||
1. Single-user endpoint issue (basic triage flow, script generation)
|
||||
2. M365 / tenant-wide issue (multi-user context, issue category)
|
||||
3. Network / VPN outage (asset targeting, hypothesis tracking)
|
||||
4. Escalation and resume (session persistence, structured handoff)
|
||||
|
||||
### Edge Cases
|
||||
- 50+ messages — layout hierarchy stays intact
|
||||
- 10+ steps — step panel scrolls, compose remains accessible
|
||||
- Long issue titles / hypothesis text — header truncates gracefully
|
||||
- Missing PSA context — placeholder copy, not blank fields
|
||||
- Narrow mobile viewport — all zones reachable
|
||||
|
||||
### Backend Checks
|
||||
```bash
|
||||
# Migration
|
||||
alembic upgrade head
|
||||
psql -U postgres -d resolutionflow -c "\d ai_sessions" | grep -E "client_name|asset_name|issue_category|triage_hypothesis|evidence_items"
|
||||
|
||||
# Triage PATCH
|
||||
curl -X PATCH http://localhost:8000/ai-sessions/{id}/triage \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-d '{"client_name":"Test Client","triage_hypothesis":"Cache corruption"}'
|
||||
|
||||
# Handoff draft stream
|
||||
curl -X POST http://localhost:8000/ai-sessions/{id}/handoff-draft \
|
||||
-H "Authorization: Bearer $TOKEN"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Assumptions
|
||||
|
||||
- Desktop is the primary target; mobile must remain usable but does not drive the layout.
|
||||
- `/assistant` remains the chat-session cockpit; `/pilot` is out of scope.
|
||||
- New triage fields are **additive** — they do not replace `problem_summary`, `problem_domain`, `ticket_data`, or `conversation_messages`.
|
||||
- `issue_category` is the operator-facing display field; `problem_domain` remains the internal classifier. Both coexist.
|
||||
- `evidence_items` is editable by both AI and engineer; engineer edits persist through the triage PATCH endpoint.
|
||||
- PSA context is optional — every triage header field must degrade gracefully when PSA is absent or session is free-text-only.
|
||||
- The existing `TaskLane.tsx` component remains in the codebase — `StepsPanel` is a new component that consumes the same props with different rendering. No risky renames during this work.
|
||||
|
||||
---
|
||||
|
||||
## Critical Files
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `backend/alembic/versions/071_add_triage_fields_to_ai_sessions.py` | New migration |
|
||||
| `backend/app/models/ai_session.py` | Add 5 new mapped columns |
|
||||
| `backend/app/schemas/ai_session.py` | `TriageUpdate`, `QuestionItem.options`, extended request/response schemas |
|
||||
| `backend/app/api/endpoints/ai_sessions.py` | `PATCH /triage`, `POST /handoff-draft` |
|
||||
| `backend/app/services/unified_chat_service.py` | `[TRIAGE_UPDATE]` marker extraction, auto-PATCH |
|
||||
| `backend/app/services/resolution_output_generator.py` | Structured fields in context builder |
|
||||
| `frontend/src/types/ai-session.ts` | `TriageMeta`, `EvidenceItem`, `TriageUpdate`; extend `QuestionItem` |
|
||||
| `frontend/src/api/aiSessions.ts` | `updateTriage()`, `getHandoffDraft()` |
|
||||
| `frontend/src/pages/AssistantChatPage.tsx` | Full cockpit layout refactor |
|
||||
| `frontend/src/components/assistant/IncidentHeader.tsx` | New |
|
||||
| `frontend/src/components/assistant/StepsPanel.tsx` | New (from TaskLane logic) |
|
||||
| `frontend/src/components/assistant/FlowPilotAsks.tsx` | New |
|
||||
| `frontend/src/components/assistant/WhatWeKnow.tsx` | New |
|
||||
| `frontend/src/components/assistant/ConcludeSessionModal.tsx` | Redesigned |
|
||||
1545
docs/superpowers/plans/2026-04-02-flowpilot-cockpit-side-by-side.md
Normal file
1545
docs/superpowers/plans/2026-04-02-flowpilot-cockpit-side-by-side.md
Normal file
File diff suppressed because it is too large
Load Diff
3709
docs/superpowers/plans/2026-04-04-network-diagrams.md
Normal file
3709
docs/superpowers/plans/2026-04-04-network-diagrams.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,147 @@
|
||||
# FlowPilot / FlowPilot Cockpit Side-by-Side — Design Spec
|
||||
|
||||
**Date:** 2026-04-02
|
||||
**Status:** Approved
|
||||
**Prerequisite:** Sub-project 1 (Feature Flag Frontend Infrastructure) — completed
|
||||
**Branch:** `feat/cockpit-harness` (PR #124)
|
||||
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
ResolutionFlow's AI chat assistant lives at `/assistant`. On the `feat/cockpit-harness` branch (PR #124), this page was reframed as a triage cockpit with IncidentHeader, StepsPanel, FlowPilotAsks, and WhatWeKnow panels. The production version on `origin/main` retains the classic chat layout with ChatMessage and TaskLane.
|
||||
|
||||
Rather than replacing one with the other, both views will coexist on separate routes so beta users can try both and provide feedback. Long-term, the cockpit becomes a premium feature gated by the `flowpilot_cockpit` feature flag.
|
||||
|
||||
---
|
||||
|
||||
## Design
|
||||
|
||||
### 1. Pages & Routing
|
||||
|
||||
Two page components, two route groups, one shared session backend.
|
||||
|
||||
| Route | Page Component | Source | Description |
|
||||
|-------|---------------|--------|-------------|
|
||||
| `/assistant`, `/assistant/:sessionId` | `FlowPilotPage.tsx` | Production `AssistantChatPage` from `origin/main` + backported improvements | Classic chat layout: sidebar, chat messages, TaskLane side panel |
|
||||
| `/cockpit`, `/cockpit/:sessionId` | `CockpitPage.tsx` | Current `AssistantChatPage` from `feat/cockpit-harness` | Cockpit layout: IncidentHeader, StepsPanel, FlowPilotAsks, WhatWeKnow, conversation log |
|
||||
|
||||
**Session portability:** Both pages use the same `ai_sessions` backend and `aiSessionsApi` calls. Navigating from `/assistant/abc123` to `/cockpit/abc123` loads the same session — the cockpit renders triage panels on top of the same data.
|
||||
|
||||
**File changes:**
|
||||
- Rename current `AssistantChatPage.tsx` → `CockpitPage.tsx`
|
||||
- Create new `FlowPilotPage.tsx` from `origin/main` version of `AssistantChatPage` with improvements cherry-picked (session-switch race condition guard, etc.)
|
||||
- Update `router.tsx`: add `/cockpit` and `/cockpit/:sessionId` routes pointing to `CockpitPage`, update `/assistant` routes to point to `FlowPilotPage`
|
||||
- Remove the now-unused `AssistantChatPage.tsx` import from router
|
||||
|
||||
### 2. View Toggle
|
||||
|
||||
A toggle in the page header lets users switch between FlowPilot and FlowPilot Cockpit mid-session.
|
||||
|
||||
- **Location:** Top bar area of both pages, next to existing action buttons (Resolve/Escalate)
|
||||
- **Visibility:** Only appears when viewing an active session (not on empty/new session state) AND only if the user has the `flowpilot_cockpit` feature flag enabled
|
||||
- **Visual:** Segmented control / pill toggle — `FlowPilot | Cockpit` — with the active view highlighted
|
||||
- **Behavior:** Clicking the other segment navigates via `react-router` `navigate()`: e.g., `/assistant/abc123` → `/cockpit/abc123`
|
||||
- **Performance:** Navigation is instant — no data refetch needed since both pages load the same session by ID from the API on mount
|
||||
- **Component:** `ViewToggle.tsx` — shared component imported by both pages
|
||||
|
||||
**Edge case:** If the user is on `/cockpit/:sessionId` but loses the `flowpilot_cockpit` feature flag (admin disables it), the cockpit page redirects to `/assistant/:sessionId` via a `useEffect` check.
|
||||
|
||||
### 3. Sidebar Navigation
|
||||
|
||||
New top-level rail icon for FlowPilot, with flyout showing both views.
|
||||
|
||||
- **Rail icon:** `Sparkles` with label "FlowPilot"
|
||||
- **Position:** Second item in the rail, right after Home — this is the primary workflow entry point
|
||||
- **Flyout children:**
|
||||
- "FlowPilot" → `/assistant`
|
||||
- "FlowPilot Cockpit" → `/cockpit` — only shown if `useFeatureFlag('flowpilot_cockpit')` returns `true`
|
||||
- **matchPaths:** `['/assistant', '/cockpit']` — both routes highlight this rail icon
|
||||
- **Pinned sidebar sections:** Add under the existing "RESOLVE" section with the same gating logic
|
||||
|
||||
### 4. Dashboard Integration
|
||||
|
||||
`QuickStartPage` lets the user choose which view new sessions launch into.
|
||||
|
||||
- **Default behavior:** `StartSessionInput` continues to launch to `/assistant` (FlowPilot)
|
||||
- **Cockpit launch option:** If the user has the `flowpilot_cockpit` flag enabled, show a small toggle or dropdown near the submit button — "Open in: FlowPilot | Cockpit" — that controls whether the new session navigates to `/assistant/:sessionId` or `/cockpit/:sessionId`
|
||||
- **Preference persistence:** Store the user's last choice in `userPreferencesStore` (persisted to `localStorage`) so it remembers their preferred launch target across sessions
|
||||
- **Dashboard session cards:** `ActiveFlowPilotSessions` and `RecentFlowPilotSessions` — clicking a session navigates to the user's stored view preference
|
||||
|
||||
### 5. Shared Logic Extraction
|
||||
|
||||
Both pages share significant session management code. Extract into a shared hook to avoid duplication.
|
||||
|
||||
**`useAssistantSession` hook** (new file: `frontend/src/hooks/useAssistantSession.ts`):
|
||||
- Session CRUD (create, load, select, delete)
|
||||
- Chat message sending + response handling
|
||||
- File upload handling (`pendingUploads`, drag-drop)
|
||||
- Active questions/actions state management
|
||||
- Branching integration (`useBranching`)
|
||||
- Session-switch race condition guard (`currentChatRef` pattern)
|
||||
- Conclude/resolve/escalate flows
|
||||
- Prefill auto-submit logic
|
||||
|
||||
**`FlowPilotPage.tsx`** — imports `useAssistantSession`, renders the classic chat layout (ChatMessage, TaskLane sidebar panel)
|
||||
|
||||
**`CockpitPage.tsx`** — imports `useAssistantSession`, renders the cockpit layout (IncidentHeader, StepsPanel, FlowPilotAsks, WhatWeKnow, conversation log) plus manages cockpit-specific state (triage metadata, evidence items, drag-resizable split)
|
||||
|
||||
This keeps each page focused on layout/rendering (~200-400 lines each) while the ~600+ lines of shared session logic live in one place.
|
||||
|
||||
### 6. Backend Changes
|
||||
|
||||
Minimal backend work — most infrastructure already exists.
|
||||
|
||||
The triage columns (`client_name`, `asset_name`, `issue_category`, `triage_hypothesis`, `evidence_items`) are already on the `ai_sessions` table. What's needed:
|
||||
|
||||
- **Verify existing endpoints:** Confirm `PATCH /ai-sessions/{id}/triage` and the `triage_update` field in `ChatMessageResponse` are working. If not, implement per the cockpit design spec (`docs/cockpit/2026-04-01-msp-assistant-harness-design.md`)
|
||||
- **Handoff draft endpoint:** `POST /ai-sessions/{id}/handoff-draft` (streaming) — needed for the cockpit's Conclude modal. Streams a structured JSON object with `root_cause`, `resolution`, `steps_taken`, `recommendations`
|
||||
- **No new migrations** — triage columns already exist on this branch, `flowpilot_cockpit` flag seeded in migration 072
|
||||
- **No changes to the FlowPilot (chat) page's backend** — it uses the same endpoints it always has
|
||||
|
||||
### 7. UI Naming
|
||||
|
||||
- Rename "AI Assistant" → "FlowPilot" throughout the UI (sidebar, page titles, breadcrumbs, dashboard)
|
||||
- The cockpit view is labelled "FlowPilot Cockpit"
|
||||
- Internal code: `FlowPilotPage` and `CockpitPage`
|
||||
|
||||
---
|
||||
|
||||
## Scope Boundary
|
||||
|
||||
**In scope:**
|
||||
- New `FlowPilotPage.tsx` (from production `AssistantChatPage` + backported improvements)
|
||||
- Rename current `AssistantChatPage.tsx` to `CockpitPage.tsx`
|
||||
- Extract shared session logic into `useAssistantSession` hook
|
||||
- Routes: `/assistant`, `/assistant/:sessionId`, `/cockpit`, `/cockpit/:sessionId`
|
||||
- `ViewToggle` component in both page headers (gated by feature flag)
|
||||
- Sidebar rail entry with flyout (cockpit gated by feature flag)
|
||||
- Dashboard `StartSessionInput` launch preference toggle
|
||||
- Verify/complete triage backend endpoints (PATCH triage, handoff-draft streaming)
|
||||
- Rename "AI Assistant" to "FlowPilot" in UI labels
|
||||
|
||||
**Not in scope:**
|
||||
- Changes to the guided flow session page (`/pilot`) — stays as-is, hidden from sidebar
|
||||
- New AI model routing or prompt changes
|
||||
- PSA integration changes
|
||||
- Real-time flag updates (reload is fine)
|
||||
- Mobile-specific cockpit layout (responsive basics only)
|
||||
|
||||
---
|
||||
|
||||
## Files Changed
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `frontend/src/pages/AssistantChatPage.tsx` | Rename to `CockpitPage.tsx` |
|
||||
| `frontend/src/pages/FlowPilotPage.tsx` | New — classic chat layout from `origin/main` + improvements |
|
||||
| `frontend/src/pages/CockpitPage.tsx` | Renamed from AssistantChatPage, refactored to use `useAssistantSession` |
|
||||
| `frontend/src/hooks/useAssistantSession.ts` | New — shared session logic extracted from both pages |
|
||||
| `frontend/src/components/assistant/ViewToggle.tsx` | New — segmented control for switching views |
|
||||
| `frontend/src/router.tsx` | Add `/cockpit` routes, update `/assistant` routes to FlowPilotPage |
|
||||
| `frontend/src/components/layout/Sidebar.tsx` | Add FlowPilot rail entry with cockpit flyout child |
|
||||
| `frontend/src/pages/QuickStartPage.tsx` | Add launch preference toggle for cockpit |
|
||||
| `frontend/src/store/userPreferencesStore.ts` | Add `preferredFlowPilotView` preference |
|
||||
| `backend/app/api/endpoints/ai_sessions.py` | Verify/add PATCH triage endpoint + handoff-draft streaming endpoint |
|
||||
| `backend/app/schemas/ai_session.py` | Verify/add TriageUpdate, options on QuestionItem |
|
||||
| `backend/app/services/unified_chat_service.py` | Verify/add triage extraction in chat responses |
|
||||
499
docs/superpowers/specs/2026-04-04-network-diagrams-design.md
Normal file
499
docs/superpowers/specs/2026-04-04-network-diagrams-design.md
Normal file
@@ -0,0 +1,499 @@
|
||||
# Network Diagrams — Design Spec
|
||||
|
||||
> **Date:** 2026-04-04
|
||||
> **Status:** Approved
|
||||
> **Scope:** Standalone network diagram builder (Phase 1 — no FlowPilot integration)
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
A fully functional Network Diagram builder for MSP engineers. Engineers can manually build network topology diagrams with drag-and-drop device nodes and connections, or use AI to generate diagrams from plain English descriptions. Diagrams are team-scoped and filterable by client.
|
||||
|
||||
**User-facing label:** "Network Maps" in the sidebar.
|
||||
|
||||
---
|
||||
|
||||
## Key Design Decisions
|
||||
|
||||
| Decision | Choice | Rationale |
|
||||
|----------|--------|-----------|
|
||||
| Device types | Database-driven with system defaults | MSPs serve different verticals with different gear. Extensibility from day one avoids a refactor. |
|
||||
| Device type rendering | DB stores identity + metadata; frontend maps icons/colors by slug | Clean separation. Custom types get category-based default icons. Icon picker deferred to future. |
|
||||
| Connection types | Fixed defaults + free-text "Custom..." option | Connection types are more standardized than devices. 6 defaults cover 95% of cases. |
|
||||
| AI generation | Replace + Merge (Add to Diagram) modes | Iterative building is the natural workflow: "generate base" then "add VPN to branch office." |
|
||||
| Export formats | PNG + PDF + JSON | PNG for sharing, PDF for formal client docs, JSON for backup/import/portability. |
|
||||
| Client filter | Combobox/autocomplete | Type to filter, click to select from distinct client_name values. |
|
||||
| State management | Local React state (not Zustand) | Matches AssistantChatPage pattern. Editor page owns nodes/edges/meta. |
|
||||
| Scope | Standalone tool only | Future integration with FlowPilot sessions documented but not built. |
|
||||
|
||||
---
|
||||
|
||||
## Data Model
|
||||
|
||||
### `device_types` table
|
||||
|
||||
Stores system-default and team-custom device types.
|
||||
|
||||
```sql
|
||||
CREATE TABLE device_types (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
slug VARCHAR(50) NOT NULL,
|
||||
label VARCHAR(100) NOT NULL,
|
||||
category VARCHAR(50) NOT NULL, -- network, compute, storage, cloud, endpoint, infrastructure, security
|
||||
is_system BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
team_id UUID REFERENCES teams(id) ON DELETE CASCADE,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
CONSTRAINT uq_device_types_slug_team UNIQUE NULLS NOT DISTINCT (slug, team_id)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_device_types_team ON device_types(team_id);
|
||||
```
|
||||
|
||||
- `is_system = true, team_id = NULL` for built-in types
|
||||
- `is_system = false, team_id = <uuid>` for team-custom types
|
||||
- `slug` is the key used in diagram node data and the frontend rendering registry
|
||||
|
||||
### System device type seed data
|
||||
|
||||
| Slug | Label | Category |
|
||||
|------|-------|----------|
|
||||
| `router` | Router | network |
|
||||
| `switch` | Switch | network |
|
||||
| `firewall` | Firewall | network |
|
||||
| `access-point` | Access Point | network |
|
||||
| `load-balancer` | Load Balancer | network |
|
||||
| `server` | Server | compute |
|
||||
| `workstation` | Workstation | compute |
|
||||
| `vm` | Virtual Machine | compute |
|
||||
| `container` | Container | compute |
|
||||
| `nas` | NAS | storage |
|
||||
| `san` | SAN | storage |
|
||||
| `cloud-storage` | Cloud Storage | storage |
|
||||
| `cloud` | Cloud | cloud |
|
||||
| `aws` | AWS | cloud |
|
||||
| `azure` | Azure | cloud |
|
||||
| `gcp` | Google Cloud | cloud |
|
||||
| `printer` | Printer | endpoint |
|
||||
| `phone` | Phone | endpoint |
|
||||
| `iot` | IoT Device | endpoint |
|
||||
| `camera` | Camera | endpoint |
|
||||
| `tablet` | Tablet | endpoint |
|
||||
| `laptop` | Laptop | endpoint |
|
||||
| `ups` | UPS | infrastructure |
|
||||
| `pdu` | PDU | infrastructure |
|
||||
| `rack` | Rack | infrastructure |
|
||||
| `patch-panel` | Patch Panel | infrastructure |
|
||||
| `nvr` | NVR | security |
|
||||
| `badge-reader` | Badge Reader | security |
|
||||
|
||||
### `network_diagrams` table
|
||||
|
||||
```sql
|
||||
CREATE TABLE network_diagrams (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
team_id UUID NOT NULL REFERENCES teams(id) ON DELETE CASCADE,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
client_name VARCHAR(255),
|
||||
asset_name VARCHAR(255),
|
||||
description TEXT,
|
||||
nodes JSONB NOT NULL DEFAULT '[]',
|
||||
edges JSONB NOT NULL DEFAULT '[]',
|
||||
thumbnail_url TEXT,
|
||||
is_archived BOOLEAN DEFAULT FALSE,
|
||||
created_by UUID REFERENCES users(id),
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_network_diagrams_team ON network_diagrams(team_id);
|
||||
CREATE INDEX idx_network_diagrams_client ON network_diagrams(team_id, client_name);
|
||||
```
|
||||
|
||||
### JSONB Node Structure
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "unique-string",
|
||||
"type": "router",
|
||||
"label": "Core Router",
|
||||
"position": { "x": 400, "y": 200 },
|
||||
"properties": {
|
||||
"hostname": "core-rtr-01",
|
||||
"ip": "10.0.0.1",
|
||||
"subnet": "10.0.0.0/24",
|
||||
"vendor": "Cisco",
|
||||
"model": "ISR 4331",
|
||||
"role": "Core gateway",
|
||||
"vlan": "1",
|
||||
"notes": "",
|
||||
"status": "online"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- `type` field stores the device type `slug` (string, not enum)
|
||||
- `status` is one of: `unknown`, `online`, `offline`, `degraded`
|
||||
|
||||
### JSONB Edge Structure
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "unique-string",
|
||||
"source": "node-id",
|
||||
"target": "node-id",
|
||||
"label": "Uplink to Core",
|
||||
"connectionType": "ethernet",
|
||||
"speed": "1 Gbps",
|
||||
"notes": "Port Gi0/1 -> Gi0/24"
|
||||
}
|
||||
```
|
||||
|
||||
- `connectionType` is a free string. Standard defaults: `ethernet`, `fiber`, `wifi`, `vpn`, `vlan`, `wan`
|
||||
- `speed` and `notes` are additional edge metadata fields (not in original spec, added for MSP usefulness)
|
||||
|
||||
### JSON Export Schema
|
||||
|
||||
```json
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"name": "Office Network",
|
||||
"client_name": "Acme Corp",
|
||||
"description": "Main office topology",
|
||||
"nodes": [...],
|
||||
"edges": [...],
|
||||
"exportedAt": "2026-04-04T12:00:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### Device Types
|
||||
|
||||
All endpoints require authentication via `get_current_active_user`.
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| `GET` | `/api/v1/device-types/` | List all (system + team custom), ordered by category then sort_order |
|
||||
| `POST` | `/api/v1/device-types/` | Create custom type for team |
|
||||
| `PUT` | `/api/v1/device-types/{id}` | Update custom type (team-owned only) |
|
||||
| `DELETE` | `/api/v1/device-types/{id}` | Delete custom type (team-owned only, fails if `is_system`) |
|
||||
|
||||
### Network Diagrams
|
||||
|
||||
All endpoints team-scoped via `get_current_active_user`. Team ownership verified on every single-resource operation.
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| `POST` | `/api/v1/network-diagrams/` | Create diagram |
|
||||
| `GET` | `/api/v1/network-diagrams/` | List for team (exclude archived). Query params: `client_name`, `search` |
|
||||
| `GET` | `/api/v1/network-diagrams/{id}` | Get single diagram |
|
||||
| `PUT` | `/api/v1/network-diagrams/{id}` | Update diagram |
|
||||
| `DELETE` | `/api/v1/network-diagrams/{id}` | Soft delete (set `is_archived=True`) |
|
||||
| `POST` | `/api/v1/network-diagrams/{id}/duplicate` | Duplicate diagram (new name: "Copy of {name}") |
|
||||
| `POST` | `/api/v1/network-diagrams/import` | Import from JSON file |
|
||||
| `GET` | `/api/v1/network-diagrams/{id}/export` | Export as JSON |
|
||||
| `POST` | `/api/v1/network-diagrams/ai-generate` | AI diagram generation |
|
||||
| `GET` | `/api/v1/network-diagrams/clients` | List distinct client_name values for team (powers combobox) |
|
||||
|
||||
### AI Generate Endpoint
|
||||
|
||||
**Request:**
|
||||
```json
|
||||
{
|
||||
"description": "Small office with a firewall, core switch, 3 access points, and a file server",
|
||||
"client_name": "Acme Corp",
|
||||
"mode": "replace",
|
||||
"existingBounds": null
|
||||
}
|
||||
```
|
||||
|
||||
For merge mode:
|
||||
```json
|
||||
{
|
||||
"description": "Add a VPN tunnel to a branch office with 2 workstations",
|
||||
"client_name": "Acme Corp",
|
||||
"mode": "merge",
|
||||
"existingBounds": { "minX": 0, "maxX": 800, "minY": 0, "maxY": 600 }
|
||||
}
|
||||
```
|
||||
|
||||
**System prompt** includes:
|
||||
- The generation rules from the original spec (JSON-only response, topology layout guidelines)
|
||||
- The list of available device type slugs (system + team custom)
|
||||
- For merge mode: existing node positions and instruction to place new nodes in available space
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"nodes": [...],
|
||||
"edges": [...],
|
||||
"suggestedName": "Acme Corp - Main Office",
|
||||
"notes": "Assumed site-to-site VPN for branch office connection"
|
||||
}
|
||||
```
|
||||
|
||||
**Error handling:**
|
||||
- JSON parse failure: 422 "AI generated an invalid response, please try again"
|
||||
- Unknown device type slug: silently fall back to closest category match
|
||||
- Timeout: 504 "Generation took too long"
|
||||
|
||||
---
|
||||
|
||||
## Frontend Architecture
|
||||
|
||||
### New Files
|
||||
|
||||
```
|
||||
frontend/src/
|
||||
├── api/
|
||||
│ ├── deviceTypes.ts # Device types API client
|
||||
│ └── networkDiagrams.ts # Network diagrams API client
|
||||
├── components/network/
|
||||
│ ├── nodes/
|
||||
│ │ ├── deviceRegistry.ts # Slug → { icon, color } mapping + category defaults
|
||||
│ │ ├── DeviceNode.tsx # React Flow custom node
|
||||
│ │ └── nodeTypes.ts # React Flow node type registry
|
||||
│ ├── edges/
|
||||
│ │ └── ConnectionEdge.tsx # Custom edge with connection-type styling
|
||||
│ ├── panels/
|
||||
│ │ ├── DeviceToolbar.tsx # Left panel — categorized, searchable, draggable
|
||||
│ │ ├── PropertiesPanel.tsx # Right panel — node/edge property editor
|
||||
│ │ └── AIAssistPanel.tsx # Bottom collapsible — AI generation
|
||||
│ ├── NetworkCanvas.tsx # React Flow wrapper
|
||||
│ └── DiagramHeader.tsx # Top bar — name, save, export
|
||||
├── pages/NetworkDiagrams/
|
||||
│ ├── index.tsx # List/dashboard page
|
||||
│ └── DiagramEditor.tsx # Full editor page (assembles all panels)
|
||||
└── types/
|
||||
└── networkDiagram.ts # TypeScript interfaces
|
||||
```
|
||||
|
||||
### Modified Files
|
||||
|
||||
- `frontend/src/components/layout/Sidebar.tsx` — add "Network Maps" nav item
|
||||
- `frontend/src/router.tsx` — add routes
|
||||
- `frontend/src/api/index.ts` — export new API modules
|
||||
- `frontend/src/types/index.ts` — export new types
|
||||
- `backend/app/api/router.py` — register new routers
|
||||
- `backend/app/models/__init__.py` — export new models (if pattern requires)
|
||||
|
||||
### Page Layouts
|
||||
|
||||
**List Page** (`/network-diagrams`)
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────┐
|
||||
│ Network Maps [Import] [New Diagram]│
|
||||
│ Visual network topology documentation for your clients │
|
||||
├──────────────────────────────────────────────────────────────┤
|
||||
│ [🔍 Search diagrams...] [Client ▾ combobox filter] │
|
||||
├──────────────────────────────────────────────────────────────┤
|
||||
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
|
||||
│ │ Diagram │ │ Diagram │ │ Diagram │ │
|
||||
│ │ Card │ │ Card │ │ Card │ │
|
||||
│ │ │ │ │ │ │ │
|
||||
│ │ name │ │ name │ │ name │ │
|
||||
│ │ client │ │ client │ │ client │ │
|
||||
│ │ 12 devs │ │ 8 devs │ │ 5 devs │ │
|
||||
│ │ ⋯ menu │ │ ⋯ menu │ │ ⋯ menu │ │
|
||||
│ └──────────┘ └──────────┘ └──────────┘ │
|
||||
└──────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
Card three-dot menu: Open, Duplicate, Export JSON, Export PNG, Export PDF, Archive
|
||||
|
||||
**Editor Page** (`/network-diagrams/new`, `/network-diagrams/:id`)
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ ← Network Maps │ Diagram Name [Acme Corp] │ Save │ Export│
|
||||
├──────────┬──────────────────────────────┬───────────────────┤
|
||||
│ [🔍] │ │ Properties Panel │
|
||||
│ Network │ │ (when selected) │
|
||||
│ ├ Router│ React Flow Canvas │ │
|
||||
│ ├ Switch│ (dot background) │ Hostname [____] │
|
||||
│ └ ... │ (minimap bottom-right) │ IP [____] │
|
||||
│ Compute │ (controls top-left) │ Subnet [____] │
|
||||
│ ├ Server│ │ Vendor [____] │
|
||||
│ └ ... │ │ ... │
|
||||
│ ... │ │ │
|
||||
│ [+Custom│ │ [Delete Device] │
|
||||
│ Type] │ │ │
|
||||
├──────────┴──────────────────────────────┴───────────────────┤
|
||||
│ [✨ AI Generate] (collapsed) ──or── expanded textarea │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Component Details
|
||||
|
||||
**DeviceToolbar (left, 200px)**
|
||||
- Search box at top — filters across all categories
|
||||
- Collapsible category sections (Network, Compute, Storage, Cloud, Endpoint, Infrastructure, Security)
|
||||
- Each device: draggable card with icon + label
|
||||
- HTML5 drag-and-drop — `onDragStart` sets device type slug in dataTransfer
|
||||
- "+ Custom Type" button at bottom — opens inline form (slug, label, category dropdown)
|
||||
- Fetches device types from API on mount
|
||||
- `bg-sidebar` background, `border-default` right border
|
||||
|
||||
**DeviceNode (React Flow custom node)**
|
||||
- Renders device icon from registry (resolved by slug)
|
||||
- Label below icon
|
||||
- IP address below label (JetBrains Mono, muted color) if set
|
||||
- Status dot: green=online, red=offline, yellow=degraded, gray=unknown
|
||||
- Connection handles (top, bottom, left, right) — visible on hover
|
||||
- Selected: border changes to accent blue
|
||||
- `bg-card`, `border-default`, 8px radius, min-width 120px
|
||||
|
||||
**ConnectionEdge (React Flow custom edge)**
|
||||
- Renders as smoothstep edge
|
||||
- Visual style varies by connectionType:
|
||||
- `ethernet`: solid blue line
|
||||
- `fiber`: solid green, slightly thicker
|
||||
- `wifi`: dotted purple
|
||||
- `vpn`: dashed yellow
|
||||
- `vlan`: solid gray
|
||||
- `wan`: dashed red
|
||||
- custom: solid default with label
|
||||
- Shows connection label on the edge
|
||||
|
||||
**PropertiesPanel (right, 260px)**
|
||||
- **Node selected**: hostname, IP, subnet, vendor, model, role, VLAN, notes (text inputs), status (styled dropdown)
|
||||
- **Edge selected**: label, connectionType (dropdown with "Custom..." option), speed, notes
|
||||
- **Nothing selected**: "Select a device to edit its properties"
|
||||
- Changes update node/edge data immediately (controlled inputs)
|
||||
- "Delete" button at bottom (red accent)
|
||||
- `bg-sidebar`, `border-default` left border
|
||||
|
||||
**AIAssistPanel (bottom, collapsible)**
|
||||
- Collapsed: "AI Generate" button in a slim bar
|
||||
- Expanded: textarea, mode toggle ("Generate New" / "Add to Diagram"), "Generate" button
|
||||
- Replace mode warning: "This will replace your current diagram. Save first if needed."
|
||||
- Loading: animated pulse + "Generating your network diagram..."
|
||||
- Success: replaces/merges canvas, shows toast with suggested name
|
||||
- Error: inline error message
|
||||
|
||||
**DiagramHeader (top, 56px)**
|
||||
- Back button: `← Network Maps` navigates to list page
|
||||
- Inline-editable diagram name (click to edit, Enter to confirm)
|
||||
- Client name badge (pill) if set
|
||||
- Save button (primary, "Saving..." state)
|
||||
- Export dropdown: PNG, PDF, JSON
|
||||
- Last saved timestamp (muted, right side)
|
||||
- `bg-card`, `border-default` bottom border
|
||||
|
||||
### State Management
|
||||
|
||||
`DiagramEditor` page owns all state via `useState`:
|
||||
|
||||
- `nodes` / `edges` — managed by React Flow's `useNodesState` / `useEdgesState`
|
||||
- `diagramMeta` — name, client_name, asset_name, description
|
||||
- `isDirty` — tracks unsaved changes
|
||||
- `lastSavedAt` — timestamp for header display
|
||||
- `selectedNode` / `selectedEdge` — drives PropertiesPanel
|
||||
|
||||
**Auto-save**: `setInterval` at 30 seconds, only fires if `isDirty` is true. Clears dirty flag on save. Does not fire for new unsaved diagrams (must explicitly save first to create the record).
|
||||
|
||||
### Export Implementation
|
||||
|
||||
- **PNG**: Client-side using the Canvas API. Render the React Flow viewport to an offscreen canvas via `ReactFlow.toObject()` + manual SVG-to-canvas rendering, then `canvas.toBlob()` for download. No external library needed.
|
||||
- **PDF**: Client-side using `window.print()` with a print-specific stylesheet. Opens a print-optimized view of the diagram with metadata header (name, client, description, date, device count) and device legend. The user selects "Save as PDF" in the browser print dialog. No external library needed.
|
||||
- **JSON**: API call to `GET /network-diagrams/{id}/export`, triggers file download
|
||||
|
||||
### Import Implementation
|
||||
|
||||
- "Import" button on list page opens file picker (`.json` only)
|
||||
- Uploads to `POST /network-diagrams/import`
|
||||
- Backend validates schema version and structure
|
||||
- Creates new diagram for the team
|
||||
- Response includes warnings for unknown device type slugs
|
||||
- On success, navigates to the new diagram's editor
|
||||
|
||||
---
|
||||
|
||||
## Navigation Integration
|
||||
|
||||
### Sidebar
|
||||
|
||||
Add "Network Maps" to both sidebar modes:
|
||||
|
||||
**Rail groups (icon-only mode)**: Add as a child under the Flows group:
|
||||
```typescript
|
||||
{
|
||||
href: '/trees', icon: GitBranch, label: 'Flows', shortLabel: 'Flows',
|
||||
children: [
|
||||
{ href: '/trees', label: 'Flow Library' },
|
||||
{ href: '/trees?type=procedural', label: 'Projects' },
|
||||
{ href: '/network-diagrams', label: 'Network Maps' }, // NEW
|
||||
{ href: '/step-library', label: 'Solutions Library' },
|
||||
{ href: '/review-queue', label: 'Review Queue' },
|
||||
],
|
||||
}
|
||||
```
|
||||
|
||||
**Pinned sections**: Add under KNOWLEDGE:
|
||||
```typescript
|
||||
{
|
||||
title: 'KNOWLEDGE',
|
||||
items: [
|
||||
{ href: '/trees', icon: GitBranch, label: 'Flow Library', ... },
|
||||
{ href: '/network-diagrams', icon: Network, label: 'Network Maps', shortLabel: 'NetMap' }, // NEW
|
||||
{ href: '/scripts', icon: Code2, label: 'Scripts', ... },
|
||||
...
|
||||
],
|
||||
}
|
||||
```
|
||||
|
||||
### Routes
|
||||
|
||||
```typescript
|
||||
const NetworkDiagramsPage = lazyWithRetry(() => import('@/pages/NetworkDiagrams'))
|
||||
const DiagramEditorPage = lazyWithRetry(() => import('@/pages/NetworkDiagrams/DiagramEditor'))
|
||||
|
||||
// Inside ProtectedRoute/AppLayout children:
|
||||
{ path: 'network-diagrams', element: page(NetworkDiagramsPage) },
|
||||
{ path: 'network-diagrams/new', element: page(DiagramEditorPage) },
|
||||
{ path: 'network-diagrams/:id', element: page(DiagramEditorPage) },
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Future Integration Roadmap
|
||||
|
||||
These features are documented for future implementation. They are NOT in scope for Phase 1.
|
||||
|
||||
### Phase 2: FlowPilot Context Integration
|
||||
When a FlowPilot session has a `client_name` matching a saved network diagram, the diagram can be surfaced as read-only reference context. The AI copilot gains topology awareness — it can reference specific devices, connections, and network segments when guiding troubleshooting.
|
||||
|
||||
### Phase 3: Live Status Overlay
|
||||
Device status indicators updated from PSA/monitoring integrations (ConnectWise, Datto RMM, etc.). Green/red/yellow dots reflect real-time device state. Engineers see at-a-glance which devices are down before starting troubleshooting.
|
||||
|
||||
### Phase 4: Session-Linked Diagrams
|
||||
Attach a diagram to a FlowPilot session. During troubleshooting, highlight the device being investigated. Mark devices as "checked" or "problem found." Session resolution includes a diagram snapshot showing the problem path.
|
||||
|
||||
### Phase 5: Custom Device Type Icons
|
||||
Icon picker UI for team-custom device types. Optional SVG upload for vendor logos or specialized equipment icons (with SVG sanitization for security).
|
||||
|
||||
### Phase 6: Diagram Templates
|
||||
Pre-built topology templates as starting points: Small Office, Branch + HQ, Data Center, Remote Worker, MSP NOC. Templates include placeholder devices with typical connections.
|
||||
|
||||
### Phase 7: Collaborative Editing
|
||||
Multiple engineers editing the same diagram simultaneously. Requires WebSocket infrastructure (Yjs or similar CRDT). Cursor presence, conflict resolution, real-time sync.
|
||||
|
||||
---
|
||||
|
||||
## Quality Requirements
|
||||
|
||||
- Every file fully typed — no `any`, no untyped props
|
||||
- No inline styles — Tailwind classes only, using design system CSS variable tokens
|
||||
- No placeholder TODOs — build the real implementation
|
||||
- All API calls handle loading, error, and success states
|
||||
- Team-scoping enforced on every backend endpoint
|
||||
- Ownership verification on every GET/PUT/DELETE by ID
|
||||
- Editor handles both "new diagram" and "edit existing" modes
|
||||
- Auto-save only fires when dirty
|
||||
- No new npm packages — use only what's installed
|
||||
- Follow existing code style exactly
|
||||
7
frontend/package-lock.json
generated
7
frontend/package-lock.json
generated
@@ -23,6 +23,7 @@
|
||||
"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",
|
||||
@@ -5331,6 +5332,12 @@
|
||||
"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,6 +36,7 @@
|
||||
"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",
|
||||
|
||||
@@ -2,6 +2,11 @@ import api from './client'
|
||||
import type {
|
||||
DashboardMetrics,
|
||||
ActivityEntry,
|
||||
AdminUserListResponse,
|
||||
AdminAccountListResponse,
|
||||
AdminAccountDetailResponse,
|
||||
AdminAccountCreate,
|
||||
AdminAccountUpdate,
|
||||
AuditLogListResponse,
|
||||
PlanLimitConfig,
|
||||
AccountOverrideResponse,
|
||||
@@ -78,7 +83,15 @@ export const adminApi = {
|
||||
createUser: (data: AdminUserCreate) =>
|
||||
api.post<AdminUserCreateResponse>('/admin/users', data).then(r => r.data),
|
||||
listUsers: (params?: Record<string, unknown>) =>
|
||||
api.get('/admin/users', { params }).then(r => r.data),
|
||||
api.get<AdminUserListResponse>('/admin/users', { params }).then(r => r.data),
|
||||
listAccounts: (params?: Record<string, unknown>) =>
|
||||
api.get<AdminAccountListResponse>('/admin/accounts', { params }).then(r => r.data),
|
||||
createAccount: (data: AdminAccountCreate) =>
|
||||
api.post<AdminAccountDetailResponse>('/admin/accounts', data).then(r => r.data),
|
||||
getAccountDetail: (id: string, params?: Record<string, unknown>) =>
|
||||
api.get<AdminAccountDetailResponse>(`/admin/accounts/${id}`, { params }).then(r => r.data),
|
||||
updateAccount: (id: string, data: AdminAccountUpdate) =>
|
||||
api.put<AdminAccountDetailResponse>(`/admin/accounts/${id}`, data).then(r => r.data),
|
||||
getUser: (id: string) =>
|
||||
api.get(`/admin/users/${id}`).then(r => r.data),
|
||||
updateUserRole: (id: string, role: string) =>
|
||||
@@ -119,6 +132,10 @@ export const adminApi = {
|
||||
api.put(`/admin/users/${id}/subscription/plan`, { plan }).then(r => r.data),
|
||||
extendUserTrial: (id: string, days: number) =>
|
||||
api.put(`/admin/users/${id}/subscription/extend-trial`, { days }).then(r => r.data),
|
||||
updateAccountSubscriptionPlan: (id: string, plan: string) =>
|
||||
api.put(`/admin/accounts/${id}/subscription/plan`, { plan }).then(r => r.data),
|
||||
extendAccountTrial: (id: string, days: number) =>
|
||||
api.put(`/admin/accounts/${id}/subscription/extend-trial`, { days }).then(r => r.data),
|
||||
|
||||
// Invite Codes
|
||||
listInviteCodes: (params?: Record<string, unknown>) =>
|
||||
|
||||
@@ -18,6 +18,7 @@ import type {
|
||||
ChatSessionCreateResponse,
|
||||
ChatMessageRequest,
|
||||
ChatMessageResponse,
|
||||
TriageMeta,
|
||||
} from '@/types/ai-session'
|
||||
|
||||
export const aiSessionsApi = {
|
||||
@@ -241,6 +242,26 @@ export const aiSessionsApi = {
|
||||
)
|
||||
return response.data
|
||||
},
|
||||
|
||||
async updateTriage(sessionId: string, fields: Partial<TriageMeta>): Promise<TriageMeta> {
|
||||
const response = await apiClient.patch<TriageMeta>(
|
||||
`/ai-sessions/${sessionId}/triage`,
|
||||
fields
|
||||
)
|
||||
return response.data
|
||||
},
|
||||
|
||||
async getHandoffDraft(sessionId: string): Promise<string> {
|
||||
const response = await apiClient.post<string>(
|
||||
`/ai-sessions/${sessionId}/handoff-draft`,
|
||||
{},
|
||||
{ headers: { Accept: 'text/event-stream' }, responseType: 'text' }
|
||||
)
|
||||
// Response is SSE format: "data: {...}\n\n"
|
||||
const raw = typeof response.data === 'string' ? response.data : ''
|
||||
const dataLine = raw.split('\n').find(l => l.startsWith('data: '))
|
||||
return dataLine ? dataLine.slice(6) : raw
|
||||
},
|
||||
}
|
||||
|
||||
export default aiSessionsApi
|
||||
|
||||
23
frontend/src/api/deviceTypes.ts
Normal file
23
frontend/src/api/deviceTypes.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
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,3 +35,5 @@ export { betaFeedbackApi } from './betaFeedback'
|
||||
export { branchesApi } from './branches'
|
||||
export { handoffsApi } from './handoffs'
|
||||
export { resolutionsApi } from './resolutions'
|
||||
export { deviceTypesApi } from './deviceTypes'
|
||||
export { networkDiagramsApi } from './networkDiagrams'
|
||||
|
||||
63
frontend/src/api/networkDiagrams.ts
Normal file
63
frontend/src/api/networkDiagrams.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
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 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
|
||||
},
|
||||
}
|
||||
@@ -54,7 +54,7 @@ export function ActionMenu({ items }: ActionMenuProps) {
|
||||
onClick={() => setOpen(!open)}
|
||||
className={cn(
|
||||
'rounded-md p-1.5 text-muted-foreground transition-colors',
|
||||
'hover:bg-accent hover:text-foreground'
|
||||
'hover:bg-elevated hover:text-foreground'
|
||||
)}
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
@@ -81,7 +81,7 @@ export function ActionMenu({ items }: ActionMenuProps) {
|
||||
'disabled:opacity-50 disabled:pointer-events-none',
|
||||
item.destructive
|
||||
? 'text-red-400 hover:bg-red-400/10'
|
||||
: 'text-muted-foreground hover:bg-accent'
|
||||
: 'text-muted-foreground hover:bg-elevated'
|
||||
)}
|
||||
>
|
||||
{item.icon}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Link, useLocation } from 'react-router-dom'
|
||||
import {
|
||||
LayoutDashboard,
|
||||
Users,
|
||||
Building2,
|
||||
Ticket,
|
||||
FileText,
|
||||
Gauge,
|
||||
@@ -15,18 +15,54 @@ import {
|
||||
} from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const navItems = [
|
||||
{ path: '/admin', label: 'Dashboard', icon: LayoutDashboard, end: true },
|
||||
{ path: '/admin/users', label: 'Users', icon: Users },
|
||||
{ path: '/admin/invite-codes', label: 'Invite Codes', icon: Ticket },
|
||||
{ path: '/admin/audit-logs', label: 'Audit Logs', icon: FileText },
|
||||
{ path: '/admin/plan-limits', label: 'Plan Limits', icon: Gauge },
|
||||
{ path: '/admin/feature-flags', label: 'Feature Flags', icon: ToggleLeft },
|
||||
{ path: '/admin/settings', label: 'Settings', icon: Settings },
|
||||
{ path: '/admin/categories', label: 'Categories', icon: FolderTree },
|
||||
{ path: '/admin/survey-invites', label: 'Survey Invites', icon: ClipboardList },
|
||||
{ path: '/admin/survey-responses', label: 'Survey Responses', icon: MessageSquareText },
|
||||
{ path: '/admin/gallery', label: 'Gallery', icon: LayoutGrid },
|
||||
interface NavItem {
|
||||
path: string
|
||||
label: string
|
||||
icon: typeof LayoutDashboard
|
||||
end?: boolean
|
||||
}
|
||||
|
||||
interface NavSection {
|
||||
label?: string
|
||||
items: NavItem[]
|
||||
}
|
||||
|
||||
const navSections: NavSection[] = [
|
||||
{
|
||||
items: [
|
||||
{ path: '/admin', label: 'Dashboard', icon: LayoutDashboard, end: true },
|
||||
{ path: '/admin/accounts', label: 'Accounts', icon: Building2 },
|
||||
{ path: '/admin/invite-codes', label: 'Invite Codes', icon: Ticket },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Platform',
|
||||
items: [
|
||||
{ path: '/admin/plan-limits', label: 'Plan Limits', icon: Gauge },
|
||||
{ path: '/admin/feature-flags', label: 'Feature Flags', icon: ToggleLeft },
|
||||
{ path: '/admin/settings', label: 'Settings', icon: Settings },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Content',
|
||||
items: [
|
||||
{ path: '/admin/categories', label: 'Categories', icon: FolderTree },
|
||||
{ path: '/admin/gallery', label: 'Gallery', icon: LayoutGrid },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Feedback',
|
||||
items: [
|
||||
{ path: '/admin/survey-invites', label: 'Survey Invites', icon: ClipboardList },
|
||||
{ path: '/admin/survey-responses', label: 'Survey Responses', icon: MessageSquareText },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Audit',
|
||||
items: [
|
||||
{ path: '/admin/audit-logs', label: 'Audit Logs', icon: FileText },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
interface AdminSidebarProps {
|
||||
@@ -47,22 +83,33 @@ export function AdminSidebar({ className, onNavigate }: AdminSidebarProps) {
|
||||
<div className="p-4">
|
||||
<h2 className="text-lg font-bold text-foreground">Admin Panel</h2>
|
||||
</div>
|
||||
<nav className="flex-1 space-y-1 px-3">
|
||||
{navItems.map((item) => (
|
||||
<Link
|
||||
key={item.path}
|
||||
to={item.path}
|
||||
onClick={onNavigate}
|
||||
className={cn(
|
||||
'flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors',
|
||||
isActive(item.path, item.end)
|
||||
? 'bg-accent text-foreground'
|
||||
: 'text-muted-foreground hover:bg-accent hover:text-foreground'
|
||||
<nav className="flex-1 space-y-4 overflow-y-auto px-3">
|
||||
{navSections.map((section, i) => (
|
||||
<div key={i}>
|
||||
{section.label && (
|
||||
<p className="mb-1 px-3 text-[11px] font-medium uppercase tracking-[0.14em] text-muted-foreground">
|
||||
{section.label}
|
||||
</p>
|
||||
)}
|
||||
>
|
||||
<item.icon className="h-4 w-4" />
|
||||
{item.label}
|
||||
</Link>
|
||||
<div className="space-y-0.5">
|
||||
{section.items.map((item) => (
|
||||
<Link
|
||||
key={item.path}
|
||||
to={item.path}
|
||||
onClick={onNavigate}
|
||||
className={cn(
|
||||
'flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors',
|
||||
isActive(item.path, item.end)
|
||||
? 'bg-elevated text-foreground'
|
||||
: 'text-muted-foreground hover:bg-elevated hover:text-foreground'
|
||||
)}
|
||||
>
|
||||
<item.icon className="h-4 w-4" />
|
||||
{item.label}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
<div className="border-t border-border p-3">
|
||||
@@ -71,7 +118,7 @@ export function AdminSidebar({ className, onNavigate }: AdminSidebarProps) {
|
||||
onClick={onNavigate}
|
||||
className={cn(
|
||||
'flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium',
|
||||
'text-muted-foreground hover:bg-accent hover:text-foreground'
|
||||
'text-muted-foreground hover:bg-elevated hover:text-foreground'
|
||||
)}
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
|
||||
@@ -53,7 +53,7 @@ export function DataTable<T>({
|
||||
<div className="overflow-x-auto rounded-lg border border-border">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-accent">
|
||||
<tr className="border-b border-border bg-elevated">
|
||||
{columns.map((col) => (
|
||||
<th
|
||||
key={col.key}
|
||||
@@ -90,7 +90,7 @@ export function DataTable<T>({
|
||||
<tr key={i} className="border-b border-border last:border-0">
|
||||
{columns.map((col) => (
|
||||
<td key={col.key} className="px-4 py-3">
|
||||
<div className="h-4 w-3/4 animate-pulse rounded bg-accent" />
|
||||
<div className="h-4 w-3/4 animate-pulse rounded bg-muted" />
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
@@ -107,7 +107,7 @@ export function DataTable<T>({
|
||||
data.map((item) => (
|
||||
<tr
|
||||
key={keyExtractor(item)}
|
||||
className="border-b border-border last:border-0 hover:bg-accent transition-colors"
|
||||
className="border-b border-border last:border-0 hover:bg-elevated transition-colors"
|
||||
>
|
||||
{columns.map((col) => (
|
||||
<td key={col.key} className={cn('px-4 py-3', col.className)}>
|
||||
|
||||
@@ -43,7 +43,7 @@ export function Pagination({ page, totalPages, total, pageSize, onPageChange }:
|
||||
<button
|
||||
onClick={() => onPageChange(page - 1)}
|
||||
disabled={page <= 1}
|
||||
className={cn(btnBase, 'px-2 text-muted-foreground hover:bg-accent hover:text-foreground')}
|
||||
className={cn(btnBase, 'px-2 text-muted-foreground hover:bg-elevated hover:text-foreground')}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</button>
|
||||
@@ -59,7 +59,7 @@ export function Pagination({ page, totalPages, total, pageSize, onPageChange }:
|
||||
'px-2',
|
||||
p === page
|
||||
? 'bg-primary text-white'
|
||||
: 'text-muted-foreground hover:bg-accent hover:text-foreground'
|
||||
: 'text-muted-foreground hover:bg-elevated hover:text-foreground'
|
||||
)}
|
||||
>
|
||||
{p}
|
||||
@@ -69,7 +69,7 @@ export function Pagination({ page, totalPages, total, pageSize, onPageChange }:
|
||||
<button
|
||||
onClick={() => onPageChange(page + 1)}
|
||||
disabled={page >= totalPages}
|
||||
className={cn(btnBase, 'px-2 text-muted-foreground hover:bg-accent hover:text-foreground')}
|
||||
className={cn(btnBase, 'px-2 text-muted-foreground hover:bg-elevated hover:text-foreground')}
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
@@ -6,22 +6,26 @@ interface StatusBadgeProps {
|
||||
variant?: BadgeVariant
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
title?: string
|
||||
}
|
||||
|
||||
const variantClasses: Record<BadgeVariant, string> = {
|
||||
success: 'bg-emerald-400/10 text-emerald-400',
|
||||
destructive: 'bg-red-400/10 text-red-400',
|
||||
warning: 'bg-yellow-400/10 text-yellow-400',
|
||||
default: 'bg-accent text-muted-foreground',
|
||||
default: 'bg-muted text-muted-foreground',
|
||||
}
|
||||
|
||||
export function StatusBadge({ variant = 'default', children, className }: StatusBadgeProps) {
|
||||
export function StatusBadge({ variant = 'default', children, className, title }: StatusBadgeProps) {
|
||||
return (
|
||||
<span className={cn(
|
||||
'inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium',
|
||||
variantClasses[variant],
|
||||
className
|
||||
)}>
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium',
|
||||
variantClasses[variant],
|
||||
className
|
||||
)}
|
||||
title={title}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
|
||||
@@ -68,7 +68,7 @@ export function ChatSidebar({
|
||||
className="flex-1 flex items-center justify-center gap-2 bg-primary text-white font-semibold text-sm rounded-lg px-4 py-2.5 hover:brightness-110 active:scale-[0.98] transition-all"
|
||||
>
|
||||
<Plus size={16} />
|
||||
New Chat
|
||||
New Case
|
||||
</button>
|
||||
{onToggleCollapse && (
|
||||
<button
|
||||
@@ -154,7 +154,7 @@ export function ChatSidebarCollapsedBar({
|
||||
<button
|
||||
onClick={onExpand}
|
||||
className="flex items-center gap-1.5 rounded-md px-2.5 py-1.5 text-xs text-muted-foreground hover:text-heading hover:bg-elevated transition-colors"
|
||||
title="Show chat history"
|
||||
title="Show case history"
|
||||
>
|
||||
<History size={14} />
|
||||
<span>History</span>
|
||||
|
||||
@@ -33,8 +33,8 @@ interface ConcludeSessionModalProps {
|
||||
const OUTCOMES: { value: ConclusionOutcome; label: string; description: string; icon: typeof CheckCircle2; color: string; bg: string; border: string }[] = [
|
||||
{
|
||||
value: 'resolved',
|
||||
label: 'Resolved',
|
||||
description: 'Issue has been fixed or answered',
|
||||
label: 'Resolve',
|
||||
description: 'Mark the issue as fixed',
|
||||
icon: CheckCircle2,
|
||||
color: 'text-emerald-400',
|
||||
bg: 'bg-emerald-400/10',
|
||||
@@ -43,7 +43,7 @@ const OUTCOMES: { value: ConclusionOutcome; label: string; description: string;
|
||||
{
|
||||
value: 'escalated',
|
||||
label: 'Escalate',
|
||||
description: 'Needs to be handed off or escalated',
|
||||
description: 'Hand off to another engineer or team',
|
||||
icon: ArrowUpRight,
|
||||
color: 'text-amber-400',
|
||||
bg: 'bg-amber-400/10',
|
||||
@@ -51,8 +51,8 @@ const OUTCOMES: { value: ConclusionOutcome; label: string; description: string;
|
||||
},
|
||||
{
|
||||
value: 'paused',
|
||||
label: 'Paused',
|
||||
description: 'Continuing later — saving progress',
|
||||
label: 'Pause',
|
||||
description: 'Save progress and come back later',
|
||||
icon: Pause,
|
||||
color: 'text-blue-400',
|
||||
bg: 'bg-blue-400/10',
|
||||
@@ -155,7 +155,7 @@ export function ConcludeSessionModal({
|
||||
setSummary('')
|
||||
}
|
||||
} catch {
|
||||
setError('Failed to conclude session. Please try again.')
|
||||
setError('Failed to close case. Please try again.')
|
||||
setGenerating(false)
|
||||
}
|
||||
}
|
||||
@@ -234,7 +234,7 @@ export function ConcludeSessionModal({
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-base font-heading font-semibold text-foreground">
|
||||
Conclude Session
|
||||
Close Case
|
||||
</h2>
|
||||
<p className="text-xs text-muted-foreground truncate max-w-[300px]">
|
||||
{chatTitle}
|
||||
@@ -296,7 +296,7 @@ export function ConcludeSessionModal({
|
||||
{step === 'select-outcome' && (
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
How did this session end?
|
||||
What would you like to do?
|
||||
</p>
|
||||
{OUTCOMES.map(o => {
|
||||
const Icon = o.icon
|
||||
@@ -526,12 +526,12 @@ export function ConcludeSessionModal({
|
||||
{generating ? (
|
||||
<>
|
||||
<Loader2 size={15} className="animate-spin" />
|
||||
Generating...
|
||||
Closing...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Sparkles size={15} />
|
||||
Generate Summary
|
||||
Close & Generate
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
103
frontend/src/components/assistant/FlowPilotAsks.tsx
Normal file
103
frontend/src/components/assistant/FlowPilotAsks.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Send, HelpCircle, ChevronLeft, ChevronRight } from 'lucide-react'
|
||||
import type { QuestionItem } from '@/types/ai-session'
|
||||
|
||||
interface FlowPilotAsksProps {
|
||||
questions: QuestionItem[]
|
||||
onAnswer: (answer: string) => void
|
||||
loading?: boolean
|
||||
}
|
||||
|
||||
export function FlowPilotAsks({ questions, onAnswer, loading }: FlowPilotAsksProps) {
|
||||
const [freeText, setFreeText] = useState('')
|
||||
const [currentIdx, setCurrentIdx] = useState(0)
|
||||
|
||||
// Reset index when questions change
|
||||
useEffect(() => {
|
||||
setCurrentIdx(0)
|
||||
setFreeText('')
|
||||
}, [questions])
|
||||
|
||||
if (questions.length === 0) return null
|
||||
|
||||
const question = questions[Math.min(currentIdx, questions.length - 1)]
|
||||
|
||||
const handleFreeTextSubmit = () => {
|
||||
if (!freeText.trim()) return
|
||||
onAnswer(freeText.trim())
|
||||
setFreeText('')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-card border border-default rounded-lg p-3">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="text-[10px] uppercase tracking-wider text-warning font-semibold flex items-center gap-1.5">
|
||||
<HelpCircle size={11} />
|
||||
FlowPilot Asks
|
||||
</div>
|
||||
{questions.length > 1 && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<button
|
||||
onClick={() => setCurrentIdx(i => Math.max(0, i - 1))}
|
||||
disabled={currentIdx === 0}
|
||||
className="p-0.5 text-muted-foreground hover:text-foreground disabled:opacity-30 transition-colors"
|
||||
>
|
||||
<ChevronLeft size={14} />
|
||||
</button>
|
||||
<span className="text-[10px] text-muted-foreground tabular-nums">
|
||||
{currentIdx + 1}/{questions.length}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setCurrentIdx(i => Math.min(questions.length - 1, i + 1))}
|
||||
disabled={currentIdx >= questions.length - 1}
|
||||
className="p-0.5 text-muted-foreground hover:text-foreground disabled:opacity-30 transition-colors"
|
||||
>
|
||||
<ChevronRight size={14} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-primary leading-relaxed mb-2.5">
|
||||
{question.text}
|
||||
</p>
|
||||
{question.context && (
|
||||
<p className="text-xs text-muted-foreground mb-2.5 leading-relaxed">
|
||||
{question.context}
|
||||
</p>
|
||||
)}
|
||||
{question.options ? (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{question.options.map((option, idx) => (
|
||||
<button
|
||||
key={idx}
|
||||
onClick={() => onAnswer(option)}
|
||||
disabled={loading}
|
||||
className="bg-elevated border border-hover rounded px-3 py-1.5 text-xs text-muted-foreground hover:text-foreground hover:border-foreground/20 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{option}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex gap-1.5">
|
||||
<input
|
||||
type="text"
|
||||
value={freeText}
|
||||
onChange={e => setFreeText(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && handleFreeTextSubmit()}
|
||||
placeholder="Type your answer..."
|
||||
disabled={loading}
|
||||
className="flex-1 bg-input border border-default rounded px-2.5 py-1.5 text-sm text-primary outline-none focus:border-accent placeholder:text-muted-foreground/50"
|
||||
/>
|
||||
<button
|
||||
onClick={handleFreeTextSubmit}
|
||||
disabled={!freeText.trim() || loading}
|
||||
className="bg-accent/15 border border-accent rounded px-2.5 py-1.5 text-accent hover:bg-accent/25 transition-colors disabled:opacity-50"
|
||||
>
|
||||
<Send size={12} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
230
frontend/src/components/assistant/IncidentHeader.tsx
Normal file
230
frontend/src/components/assistant/IncidentHeader.tsx
Normal file
@@ -0,0 +1,230 @@
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import { Pencil, X, Check, CheckCircle2, ExternalLink, Pause, XCircle, Link2, MoreHorizontal, FileText } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { toast } from '@/lib/toast'
|
||||
import type { TriageMeta } from '@/types/ai-session'
|
||||
|
||||
interface IncidentHeaderProps {
|
||||
triageMeta: TriageMeta
|
||||
psaTicketId: string | null
|
||||
onFieldSave: (field: keyof TriageMeta, value: string) => void
|
||||
onResolve: () => void
|
||||
onStatusUpdate?: () => void
|
||||
onPause?: () => void
|
||||
onClose?: () => void
|
||||
}
|
||||
|
||||
interface EditPopoverProps {
|
||||
value: string
|
||||
onSave: (value: string) => void
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
function EditPopover({ value, onSave, onCancel }: EditPopoverProps) {
|
||||
const [editValue, setEditValue] = useState(value)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
inputRef.current?.focus()
|
||||
inputRef.current?.select()
|
||||
}, [])
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter') onSave(editValue)
|
||||
if (e.key === 'Escape') onCancel()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="absolute top-full left-0 mt-1 z-50 bg-elevated border border-hover rounded-md p-2 shadow-lg flex gap-1.5 items-center min-w-[200px]">
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={editValue}
|
||||
onChange={e => setEditValue(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
className="flex-1 bg-input border border-default rounded px-2 py-1 text-sm text-primary outline-none focus:border-accent"
|
||||
/>
|
||||
<button onClick={() => onSave(editValue)} className="p-1 text-success hover:text-success/80">
|
||||
<Check size={14} />
|
||||
</button>
|
||||
<button onClick={onCancel} className="p-1 text-muted-foreground hover:text-foreground">
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface HeaderFieldProps {
|
||||
label: string
|
||||
value: string | null
|
||||
placeholder: string
|
||||
onSave: (value: string) => void
|
||||
isHypothesis?: boolean
|
||||
}
|
||||
|
||||
function HeaderField({ label, value, placeholder, onSave, isHypothesis }: HeaderFieldProps) {
|
||||
const [editing, setEditing] = useState(false)
|
||||
|
||||
return (
|
||||
<div className="relative group flex flex-col gap-0.5 min-w-0">
|
||||
<span className="text-[10px] uppercase tracking-wider text-muted font-semibold leading-none">
|
||||
{label}
|
||||
</span>
|
||||
<div className="flex items-center gap-1 min-w-0">
|
||||
<span
|
||||
className={cn(
|
||||
'text-sm truncate',
|
||||
value ? (isHypothesis ? 'text-warning' : 'text-primary') : 'text-muted-foreground italic',
|
||||
)}
|
||||
>
|
||||
{value || placeholder}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setEditing(true)}
|
||||
className="opacity-0 group-hover:opacity-100 transition-opacity p-0.5 text-muted-foreground hover:text-foreground flex-shrink-0"
|
||||
>
|
||||
<Pencil size={11} />
|
||||
</button>
|
||||
</div>
|
||||
{editing && (
|
||||
<EditPopover
|
||||
value={value || ''}
|
||||
onSave={(v) => { onSave(v); setEditing(false) }}
|
||||
onCancel={() => setEditing(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function OverflowMenu({ onPause, onClose }: { onPause?: () => void; onClose?: () => void }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const handleEsc = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') setOpen(false)
|
||||
}
|
||||
document.addEventListener('keydown', handleEsc)
|
||||
return () => { document.removeEventListener('keydown', handleEsc) }
|
||||
}, [open])
|
||||
|
||||
const handleCopyLink = () => {
|
||||
navigator.clipboard.writeText(`${window.location.origin}${window.location.pathname}`)
|
||||
toast.success('Session link copied')
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setOpen(!open)}
|
||||
aria-label="More actions"
|
||||
className="flex items-center justify-center rounded-lg px-2 py-1.5 text-muted-foreground hover:text-foreground hover:bg-[rgba(255,255,255,0.06)] transition-colors"
|
||||
>
|
||||
<MoreHorizontal size={16} />
|
||||
</button>
|
||||
{open && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-40" onClick={() => setOpen(false)} />
|
||||
<div className="absolute right-0 top-full mt-1 z-50 w-40 rounded-lg border border-border bg-card py-1 shadow-xl">
|
||||
{onPause && (
|
||||
<button
|
||||
onClick={() => { onPause(); setOpen(false) }}
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-xs text-muted-foreground hover:text-foreground hover:bg-[rgba(255,255,255,0.06)] transition-colors text-left"
|
||||
>
|
||||
<Pause size={13} />
|
||||
Pause
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={handleCopyLink}
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-xs text-muted-foreground hover:text-foreground hover:bg-[rgba(255,255,255,0.06)] transition-colors text-left"
|
||||
>
|
||||
<Link2 size={13} />
|
||||
Copy Link
|
||||
</button>
|
||||
{onClose && (
|
||||
<button
|
||||
onClick={() => { onClose(); setOpen(false) }}
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-xs text-muted-foreground hover:text-rose-400 hover:bg-rose-500/10 transition-colors text-left"
|
||||
>
|
||||
<XCircle size={13} />
|
||||
Close
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function IncidentHeader({
|
||||
triageMeta,
|
||||
psaTicketId,
|
||||
onFieldSave,
|
||||
onResolve,
|
||||
onStatusUpdate,
|
||||
onPause,
|
||||
onClose,
|
||||
}: IncidentHeaderProps) {
|
||||
return (
|
||||
<div className="bg-card border-b border-default px-4 py-2 flex items-center gap-4 flex-wrap">
|
||||
<HeaderField
|
||||
label="Client"
|
||||
value={triageMeta.client_name}
|
||||
placeholder="Unknown client"
|
||||
onSave={v => onFieldSave('client_name', v)}
|
||||
/>
|
||||
<div className="w-px h-7 bg-elevated flex-shrink-0 hidden sm:block" />
|
||||
<HeaderField
|
||||
label="Device"
|
||||
value={triageMeta.asset_name}
|
||||
placeholder="No device"
|
||||
onSave={v => onFieldSave('asset_name', v)}
|
||||
/>
|
||||
<div className="w-px h-7 bg-elevated flex-shrink-0 hidden sm:block" />
|
||||
<HeaderField
|
||||
label="Category"
|
||||
value={triageMeta.issue_category}
|
||||
placeholder="Uncategorized"
|
||||
onSave={v => onFieldSave('issue_category', v)}
|
||||
/>
|
||||
<div className="w-px h-7 bg-elevated flex-shrink-0 hidden sm:block" />
|
||||
<HeaderField
|
||||
label="Hypothesis"
|
||||
value={triageMeta.triage_hypothesis}
|
||||
placeholder="No hypothesis yet"
|
||||
onSave={v => onFieldSave('triage_hypothesis', v)}
|
||||
isHypothesis
|
||||
/>
|
||||
|
||||
<div className="flex items-center gap-2 ml-auto flex-shrink-0">
|
||||
{psaTicketId && (
|
||||
<span className="bg-elevated border border-default rounded px-2 py-0.5 text-xs text-muted-foreground flex items-center gap-1">
|
||||
<ExternalLink size={10} />
|
||||
CW #{psaTicketId}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
onClick={onResolve}
|
||||
className="flex items-center gap-1.5 bg-emerald-500/10 border border-emerald-500/20 rounded-lg px-3 py-1.5 text-xs font-medium text-emerald-400 hover:bg-emerald-500/20 transition-colors"
|
||||
>
|
||||
<CheckCircle2 size={13} />
|
||||
Resolve
|
||||
</button>
|
||||
{onStatusUpdate && (
|
||||
<button
|
||||
onClick={onStatusUpdate}
|
||||
className="flex items-center gap-1.5 bg-blue-500/10 border border-blue-500/20 rounded-lg px-3 py-1.5 text-xs font-medium text-blue-400 hover:bg-blue-500/20 transition-colors"
|
||||
>
|
||||
<FileText size={13} />
|
||||
Update
|
||||
</button>
|
||||
)}
|
||||
<OverflowMenu onPause={onPause} onClose={onClose} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
142
frontend/src/components/assistant/StepsPanel.tsx
Normal file
142
frontend/src/components/assistant/StepsPanel.tsx
Normal file
@@ -0,0 +1,142 @@
|
||||
import { Check, ArrowRight, Circle, Terminal, ChevronRight } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { ActionItem } from '@/types/ai-session'
|
||||
|
||||
interface StepsPanelProps {
|
||||
actions: ActionItem[]
|
||||
activeIndex: number
|
||||
completedSteps?: Set<number>
|
||||
onStepComplete?: (index: number) => void
|
||||
onStepSelect?: (index: number) => void
|
||||
onGenerateScript?: () => void
|
||||
}
|
||||
|
||||
export function StepsPanel({
|
||||
actions,
|
||||
activeIndex,
|
||||
completedSteps = new Set(),
|
||||
onStepComplete,
|
||||
onStepSelect,
|
||||
onGenerateScript,
|
||||
}: StepsPanelProps) {
|
||||
if (actions.length === 0) {
|
||||
return (
|
||||
<div className="flex-1 flex items-center justify-center text-muted-foreground text-sm">
|
||||
No steps yet — start troubleshooting
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const completedCount = completedSteps.size
|
||||
const totalCount = actions.length
|
||||
const progressPct = totalCount > 0 ? Math.round((completedCount / totalCount) * 100) : 0
|
||||
|
||||
const showScriptCta = actions[activeIndex]?.command?.toLowerCase().includes('script') ||
|
||||
actions[activeIndex]?.description?.toLowerCase().includes('script')
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Header with progress */}
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="text-[10px] uppercase tracking-wider text-accent font-semibold">
|
||||
Steps
|
||||
</div>
|
||||
<span className="text-[10px] text-muted-foreground tabular-nums">
|
||||
{completedCount}/{totalCount} complete
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Progress bar */}
|
||||
<div className="h-1 bg-elevated rounded-full mb-3 overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-accent rounded-full transition-all duration-500 ease-out"
|
||||
style={{ width: `${progressPct}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Steps list */}
|
||||
<div className="flex-1 overflow-y-auto space-y-1.5 min-h-0">
|
||||
{actions.map((action, idx) => {
|
||||
const isCompleted = completedSteps.has(idx)
|
||||
const isActive = idx === activeIndex
|
||||
const isPending = !isCompleted && !isActive
|
||||
|
||||
return (
|
||||
<div
|
||||
key={idx}
|
||||
onClick={() => onStepSelect?.(idx)}
|
||||
className={cn(
|
||||
'rounded-md px-3 py-2 text-sm flex items-start gap-2.5 transition-all duration-200 cursor-pointer group',
|
||||
isCompleted && 'bg-success/[0.06] text-muted-foreground hover:bg-success/[0.1]',
|
||||
isActive && 'bg-elevated border border-accent/30 text-primary',
|
||||
isPending && 'bg-card text-muted-foreground/60 hover:bg-elevated/50',
|
||||
)}
|
||||
>
|
||||
{/* Status icon / complete button */}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
if (!isCompleted) onStepComplete?.(idx)
|
||||
}}
|
||||
className={cn(
|
||||
'mt-0.5 flex-shrink-0 rounded-full transition-all duration-200',
|
||||
isCompleted && 'text-success',
|
||||
isActive && 'text-accent hover:text-success hover:scale-110',
|
||||
isPending && 'text-muted hover:text-muted-foreground',
|
||||
)}
|
||||
title={isCompleted ? 'Completed' : 'Mark as done'}
|
||||
>
|
||||
{isCompleted && <Check size={14} />}
|
||||
{isActive && (
|
||||
<div className="relative">
|
||||
<ArrowRight size={14} className="group-hover:hidden" />
|
||||
<Check size={14} className="hidden group-hover:block" />
|
||||
</div>
|
||||
)}
|
||||
{isPending && <Circle size={14} />}
|
||||
</button>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<span className={cn(
|
||||
'transition-colors duration-200',
|
||||
isCompleted && 'line-through opacity-70',
|
||||
)}>{action.label}</span>
|
||||
{action.description && (
|
||||
<p className={cn(
|
||||
'text-xs text-muted-foreground mt-0.5 leading-relaxed',
|
||||
isCompleted && 'opacity-60',
|
||||
)}>
|
||||
{action.description}
|
||||
</p>
|
||||
)}
|
||||
{action.command && (
|
||||
<code className={cn(
|
||||
'block text-[11px] font-mono mt-1 px-2 py-1 rounded bg-white/[0.04] border border-white/[0.06] text-muted-foreground break-all',
|
||||
isCompleted && 'opacity-60',
|
||||
)}>
|
||||
{action.command}
|
||||
</code>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Active indicator */}
|
||||
{isActive && !isCompleted && (
|
||||
<ChevronRight size={12} className="text-accent/50 flex-shrink-0 mt-1" />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{showScriptCta && onGenerateScript && (
|
||||
<button
|
||||
onClick={onGenerateScript}
|
||||
className="mt-2 flex items-center justify-center gap-1.5 bg-accent/15 border border-accent rounded px-3 py-1.5 text-xs font-medium text-accent hover:bg-accent/25 transition-colors"
|
||||
>
|
||||
<Terminal size={12} />
|
||||
Generate Script
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
70
frontend/src/components/assistant/ViewToggle.tsx
Normal file
70
frontend/src/components/assistant/ViewToggle.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { MessageSquare, LayoutDashboard } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useFeatureFlag } from '@/hooks/useFeatureFlag'
|
||||
import { useUserPreferencesStore } from '@/store/userPreferencesStore'
|
||||
|
||||
type FlowPilotView = 'flowpilot' | 'cockpit'
|
||||
|
||||
const VIEW_OPTIONS: { key: FlowPilotView; label: string; icon: typeof MessageSquare }[] = [
|
||||
{ key: 'flowpilot', label: 'FlowPilot', icon: MessageSquare },
|
||||
{ key: 'cockpit', label: 'Cockpit', icon: LayoutDashboard },
|
||||
]
|
||||
|
||||
interface ViewToggleProps {
|
||||
/** Which view is currently active — drives highlight state */
|
||||
currentView: FlowPilotView
|
||||
/** Session ID for navigation (session pages only). Omit for preference-only mode (dashboard). */
|
||||
sessionId?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Persistent tab bar for switching between FlowPilot (chat) and Cockpit (triage) views.
|
||||
* Renders as a horizontal tab strip with an active bottom-border indicator.
|
||||
*
|
||||
* NOTE: If the tab bar proves too tall or prominent in certain layouts,
|
||||
* consider pivoting to a compact segmented control (Option A from the critique).
|
||||
*/
|
||||
export function ViewToggle({ currentView, sessionId }: ViewToggleProps) {
|
||||
const navigate = useNavigate()
|
||||
const hasCockpit = useFeatureFlag('flowpilot_cockpit')
|
||||
const setPreferredView = useUserPreferencesStore(s => s.setPreferredFlowPilotView)
|
||||
|
||||
if (!hasCockpit) return null
|
||||
|
||||
const handleSwitch = (view: FlowPilotView) => {
|
||||
if (view === currentView) return
|
||||
setPreferredView(view)
|
||||
if (sessionId) {
|
||||
const path = view === 'cockpit'
|
||||
? `/cockpit/${sessionId}`
|
||||
: `/assistant/${sessionId}`
|
||||
navigate(path)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center border-b border-border px-3 shrink-0" role="tablist">
|
||||
{VIEW_OPTIONS.map(({ key, label, icon: Icon }) => (
|
||||
<button
|
||||
key={key}
|
||||
role="tab"
|
||||
aria-selected={currentView === key}
|
||||
aria-current={currentView === key ? 'page' : undefined}
|
||||
onClick={() => handleSwitch(key)}
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 px-3 py-2 text-xs font-medium border-b-2 -mb-px',
|
||||
'transition-[color,border-color] duration-150',
|
||||
'active:opacity-80',
|
||||
currentView === key
|
||||
? 'text-foreground border-primary'
|
||||
: 'text-muted-foreground hover:text-foreground border-transparent'
|
||||
)}
|
||||
>
|
||||
<Icon size={14} />
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
144
frontend/src/components/assistant/WhatWeKnow.tsx
Normal file
144
frontend/src/components/assistant/WhatWeKnow.tsx
Normal file
@@ -0,0 +1,144 @@
|
||||
import { useState } from 'react'
|
||||
import { Check, X, HelpCircle, Plus } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { EvidenceItem } from '@/types/ai-session'
|
||||
|
||||
interface WhatWeKnowProps {
|
||||
items: EvidenceItem[]
|
||||
onAdd: (text: string, status: EvidenceItem['status']) => void
|
||||
onEdit: (index: number, text: string, status: EvidenceItem['status']) => void
|
||||
}
|
||||
|
||||
const STATUS_CONFIG = {
|
||||
confirmed: { icon: Check, color: 'text-success', label: 'Confirmed', tooltip: 'Confirmed — click to cycle, right-click to reverse' },
|
||||
ruled_out: { icon: X, color: 'text-danger', label: 'Ruled out', tooltip: 'Ruled out — click to cycle, right-click to reverse' },
|
||||
pending: { icon: HelpCircle, color: 'text-muted-foreground', label: 'Pending', tooltip: 'Pending — click to cycle, right-click to reverse' },
|
||||
} as const
|
||||
|
||||
const STATUS_CYCLE: EvidenceItem['status'][] = ['confirmed', 'ruled_out', 'pending']
|
||||
|
||||
export function WhatWeKnow({ items, onAdd, onEdit }: WhatWeKnowProps) {
|
||||
const [addingText, setAddingText] = useState('')
|
||||
const [showAddInput, setShowAddInput] = useState(false)
|
||||
const [editingIdx, setEditingIdx] = useState<number | null>(null)
|
||||
const [editText, setEditText] = useState('')
|
||||
|
||||
const handleAdd = () => {
|
||||
if (!addingText.trim()) return
|
||||
onAdd(addingText.trim(), 'pending')
|
||||
setAddingText('')
|
||||
setShowAddInput(false)
|
||||
}
|
||||
|
||||
const handleStatusCycle = (idx: number, reverse = false) => {
|
||||
const item = items[idx]
|
||||
const currentIdx = STATUS_CYCLE.indexOf(item.status)
|
||||
const offset = reverse ? STATUS_CYCLE.length - 1 : 1
|
||||
const nextStatus = STATUS_CYCLE[(currentIdx + offset) % STATUS_CYCLE.length]
|
||||
onEdit(idx, item.text, nextStatus)
|
||||
}
|
||||
|
||||
const handleEditStart = (idx: number) => {
|
||||
setEditingIdx(idx)
|
||||
setEditText(items[idx].text)
|
||||
}
|
||||
|
||||
const handleEditSave = (idx: number) => {
|
||||
if (editText.trim()) {
|
||||
onEdit(idx, editText.trim(), items[idx].status)
|
||||
}
|
||||
setEditingIdx(null)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-card border border-default rounded-lg p-3">
|
||||
<div className="text-[10px] uppercase tracking-wider text-muted font-semibold mb-2">
|
||||
What We Know
|
||||
</div>
|
||||
{items.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground italic">No evidence collected yet</p>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{items.map((item, idx) => {
|
||||
const config = STATUS_CONFIG[item.status]
|
||||
const Icon = config.icon
|
||||
|
||||
return (
|
||||
<div key={idx} className="flex items-start gap-2 text-sm group">
|
||||
<button
|
||||
onClick={() => handleStatusCycle(idx)}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault()
|
||||
handleStatusCycle(idx, true)
|
||||
}}
|
||||
className={cn('mt-0.5 flex-shrink-0 transition-colors hover:opacity-70', config.color)}
|
||||
title={config.tooltip}
|
||||
>
|
||||
<Icon size={13} />
|
||||
</button>
|
||||
{editingIdx === idx ? (
|
||||
<input
|
||||
type="text"
|
||||
value={editText}
|
||||
onChange={e => setEditText(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') handleEditSave(idx); if (e.key === 'Escape') setEditingIdx(null) }}
|
||||
onBlur={() => handleEditSave(idx)}
|
||||
autoFocus
|
||||
className="flex-1 bg-input border border-default rounded px-1.5 py-0.5 text-xs text-primary outline-none focus:border-accent"
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
onClick={() => handleEditStart(idx)}
|
||||
className={cn(
|
||||
'text-xs leading-relaxed cursor-pointer hover:text-foreground transition-colors',
|
||||
item.status === 'confirmed' && 'text-success/80',
|
||||
item.status === 'ruled_out' && 'text-danger/80',
|
||||
item.status === 'pending' && 'text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
{item.text}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showAddInput ? (
|
||||
<div className="flex gap-1.5 mt-2">
|
||||
<input
|
||||
type="text"
|
||||
value={addingText}
|
||||
onChange={e => setAddingText(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') handleAdd(); if (e.key === 'Escape') setShowAddInput(false) }}
|
||||
placeholder="New finding..."
|
||||
autoFocus
|
||||
className="flex-1 bg-input border border-default rounded px-2 py-1 text-xs text-primary outline-none focus:border-accent placeholder:text-muted-foreground/50"
|
||||
/>
|
||||
<button
|
||||
onClick={handleAdd}
|
||||
disabled={!addingText.trim()}
|
||||
className="text-accent hover:text-accent/80 disabled:opacity-50 p-1"
|
||||
>
|
||||
<Check size={14} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setShowAddInput(false); setAddingText('') }}
|
||||
className="text-muted-foreground hover:text-foreground p-1"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setShowAddInput(true)}
|
||||
className="mt-2 flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<Plus size={12} />
|
||||
Add finding
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
69
frontend/src/components/common/ConfirmButton.tsx
Normal file
69
frontend/src/components/common/ConfirmButton.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface ConfirmButtonProps {
|
||||
onConfirm: () => void
|
||||
children: React.ReactNode
|
||||
confirmLabel?: string
|
||||
className?: string
|
||||
confirmClassName?: string
|
||||
timeoutMs?: number
|
||||
'aria-label'?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Two-click inline confirm button.
|
||||
* First click arms the button (shows confirm state).
|
||||
* Second click executes the action.
|
||||
* Auto-resets after timeoutMs (default 3000ms).
|
||||
*/
|
||||
export function ConfirmButton({
|
||||
onConfirm,
|
||||
children,
|
||||
confirmLabel = 'Confirm?',
|
||||
className,
|
||||
confirmClassName,
|
||||
timeoutMs = 3000,
|
||||
'aria-label': ariaLabel,
|
||||
}: ConfirmButtonProps) {
|
||||
const [armed, setArmed] = useState(false)
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
const reset = useCallback(() => {
|
||||
setArmed(false)
|
||||
if (timerRef.current) {
|
||||
clearTimeout(timerRef.current)
|
||||
timerRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (timerRef.current) clearTimeout(timerRef.current)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleClick = () => {
|
||||
if (armed) {
|
||||
reset()
|
||||
onConfirm()
|
||||
} else {
|
||||
setArmed(true)
|
||||
timerRef.current = setTimeout(reset, timeoutMs)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClick}
|
||||
onBlur={reset}
|
||||
aria-label={ariaLabel}
|
||||
className={cn(armed ? confirmClassName : className)}
|
||||
>
|
||||
{armed ? confirmLabel : children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export default ConfirmButton
|
||||
@@ -6,6 +6,8 @@ import { cn } from '@/lib/utils'
|
||||
import { uploadsApi } from '@/api/uploads'
|
||||
import { toast } from '@/lib/toast'
|
||||
import type { PendingUpload } from '@/types/upload'
|
||||
import { useFeatureFlag } from '@/hooks/useFeatureFlag'
|
||||
import { useUserPreferencesStore } from '@/store/userPreferencesStore'
|
||||
|
||||
const SUGGESTIONS: { icon: LucideIcon; label: string }[] = [
|
||||
{ icon: Globe, label: 'VPN not connecting' },
|
||||
@@ -25,6 +27,8 @@ export function StartSessionInput() {
|
||||
const [pendingUploads, setPendingUploads] = useState<PendingUpload[]>([])
|
||||
const [isDragOver, setIsDragOver] = useState(false)
|
||||
const navigate = useNavigate()
|
||||
const hasCockpit = useFeatureFlag('flowpilot_cockpit')
|
||||
const preferredView = useUserPreferencesStore(s => s.preferredFlowPilotView)
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const dragCounterRef = useRef(0)
|
||||
@@ -52,7 +56,8 @@ export function StartSessionInput() {
|
||||
if (completedUploadIds.length > 0) {
|
||||
state.uploadIds = completedUploadIds
|
||||
}
|
||||
navigate('/assistant', { state })
|
||||
const target = hasCockpit && preferredView === 'cockpit' ? '/cockpit' : '/assistant'
|
||||
navigate(target, { state })
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
@@ -63,7 +68,8 @@ export function StartSessionInput() {
|
||||
}
|
||||
|
||||
const handleSuggestionClick = (suggestion: string) => {
|
||||
navigate('/assistant', { state: { prefill: suggestion } })
|
||||
const target = hasCockpit && preferredView === 'cockpit' ? '/cockpit' : '/assistant'
|
||||
navigate(target, { state: { prefill: suggestion } })
|
||||
}
|
||||
|
||||
// ── File handling ──────────────────────────────
|
||||
|
||||
@@ -1,227 +0,0 @@
|
||||
import { useState } from 'react'
|
||||
import { CheckCircle2, ArrowUpRight, Pause, X, FileText } from 'lucide-react'
|
||||
import { EscalateModal } from './EscalateModal'
|
||||
import { StatusUpdateModal } from './StatusUpdateModal'
|
||||
import type {
|
||||
ResolveSessionRequest,
|
||||
EscalateSessionRequest,
|
||||
SessionDocumentation,
|
||||
StatusUpdateAudience,
|
||||
StatusUpdateLength,
|
||||
StatusUpdateContext,
|
||||
StatusUpdateResponse,
|
||||
} from '@/types/ai-session'
|
||||
|
||||
interface FlowPilotActionBarProps {
|
||||
canResolve: boolean
|
||||
canEscalate: boolean
|
||||
isProcessing: boolean
|
||||
hasPsaTicket?: boolean
|
||||
sessionId?: string
|
||||
canShareUpdate?: boolean
|
||||
onResolve: (data: ResolveSessionRequest) => Promise<SessionDocumentation>
|
||||
onEscalate: (data: EscalateSessionRequest) => Promise<SessionDocumentation>
|
||||
onPause?: () => Promise<void>
|
||||
onAbandon?: () => Promise<void>
|
||||
onGenerateStatusUpdate?: (audience: StatusUpdateAudience, length: StatusUpdateLength, context: StatusUpdateContext) => Promise<StatusUpdateResponse>
|
||||
}
|
||||
|
||||
export function FlowPilotActionBar({
|
||||
canResolve,
|
||||
canEscalate,
|
||||
isProcessing,
|
||||
hasPsaTicket = false,
|
||||
sessionId,
|
||||
canShareUpdate = false,
|
||||
onResolve,
|
||||
onEscalate,
|
||||
onPause,
|
||||
onAbandon,
|
||||
onGenerateStatusUpdate,
|
||||
}: FlowPilotActionBarProps) {
|
||||
const [showResolve, setShowResolve] = useState(false)
|
||||
const [showEscalate, setShowEscalate] = useState(false)
|
||||
const [showAbandon, setShowAbandon] = useState(false)
|
||||
const [showStatusUpdate, setShowStatusUpdate] = useState(false)
|
||||
const [resolutionSummary, setResolutionSummary] = useState('')
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
const handleResolve = async () => {
|
||||
if (!resolutionSummary.trim() || resolutionSummary.length < 5) return
|
||||
setSubmitting(true)
|
||||
try {
|
||||
await onResolve({ resolution_summary: resolutionSummary })
|
||||
setShowResolve(false)
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handlePause = async () => {
|
||||
if (onPause) {
|
||||
setSubmitting(true)
|
||||
try {
|
||||
await onPause()
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleAbandon = async () => {
|
||||
if (onAbandon) {
|
||||
setSubmitting(true)
|
||||
try {
|
||||
await onAbandon()
|
||||
setShowAbandon(false)
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Bottom bar — fixed to viewport bottom, single row on all screen sizes */}
|
||||
<div
|
||||
className="fixed bottom-0 right-0 z-40 flex items-center gap-1.5 sm:gap-3 border-t border-border bg-card px-2 sm:px-5 py-2 sm:py-3"
|
||||
style={{ left: 'var(--sidebar-w, 0px)' }}
|
||||
>
|
||||
{/* Primary actions */}
|
||||
<button
|
||||
onClick={() => { setShowResolve(true); setShowEscalate(false) }}
|
||||
disabled={!canResolve || isProcessing}
|
||||
className="flex items-center justify-center gap-1.5 rounded-lg bg-emerald-500/10 border border-emerald-500/20 px-2.5 sm:px-4 py-2 min-h-[40px] sm:min-h-[44px] text-xs sm:text-sm font-medium text-emerald-400 hover:bg-emerald-500/20 disabled:opacity-40 disabled:pointer-events-none transition-colors"
|
||||
>
|
||||
<CheckCircle2 size={15} />
|
||||
Resolve
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowEscalate(true)}
|
||||
disabled={!canEscalate || isProcessing}
|
||||
className="flex items-center justify-center gap-1.5 rounded-lg bg-amber-500/10 border border-amber-500/20 px-2.5 sm:px-4 py-2 min-h-[40px] sm:min-h-[44px] text-xs sm:text-sm font-medium text-amber-400 hover:bg-amber-500/20 disabled:opacity-40 disabled:pointer-events-none transition-colors"
|
||||
>
|
||||
<ArrowUpRight size={15} />
|
||||
Escalate
|
||||
</button>
|
||||
{canShareUpdate && onGenerateStatusUpdate && (
|
||||
<button
|
||||
onClick={() => setShowStatusUpdate(true)}
|
||||
disabled={isProcessing}
|
||||
className="flex items-center justify-center gap-1.5 rounded-lg bg-blue-500/10 border border-blue-500/20 px-2.5 sm:px-4 py-2 min-h-[40px] sm:min-h-[44px] text-xs sm:text-sm font-medium text-blue-400 hover:bg-blue-500/20 disabled:opacity-40 disabled:pointer-events-none transition-colors"
|
||||
title="Share Update"
|
||||
>
|
||||
<FileText size={15} />
|
||||
<span className="hidden sm:inline">Share Update</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Spacer */}
|
||||
<div className="flex-1" />
|
||||
|
||||
{/* Secondary actions — right side */}
|
||||
{onPause && (
|
||||
<button
|
||||
onClick={handlePause}
|
||||
disabled={isProcessing || submitting}
|
||||
className="flex items-center justify-center gap-1.5 rounded-lg bg-[rgba(255,255,255,0.04)] border border-[rgba(255,255,255,0.06)] px-2.5 sm:px-4 py-2 min-h-[40px] sm:min-h-[44px] text-xs sm:text-sm font-medium text-muted-foreground hover:text-foreground hover:border-[rgba(255,255,255,0.12)] disabled:opacity-40 disabled:pointer-events-none transition-colors"
|
||||
>
|
||||
<Pause size={15} />
|
||||
<span className="hidden sm:inline">Pause</span>
|
||||
</button>
|
||||
)}
|
||||
{onAbandon && (
|
||||
<button
|
||||
onClick={() => setShowAbandon(true)}
|
||||
disabled={isProcessing || submitting}
|
||||
className="flex items-center justify-center gap-1.5 rounded-lg bg-[rgba(255,255,255,0.04)] border border-[rgba(255,255,255,0.06)] px-2.5 sm:px-4 py-2 min-h-[40px] sm:min-h-[44px] text-xs sm:text-sm font-medium text-muted-foreground hover:text-foreground hover:border-[rgba(255,255,255,0.12)] disabled:opacity-40 disabled:pointer-events-none transition-colors"
|
||||
>
|
||||
<X size={15} />
|
||||
<span className="hidden sm:inline">Close</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Resolve modal */}
|
||||
{showResolve && (
|
||||
<div className="fixed inset-0 z-50 flex items-end sm:items-center justify-center bg-black/60 backdrop-blur-sm">
|
||||
<div className="card-flat w-full max-w-full sm:max-w-lg mx-0 sm:mx-4 p-4 sm:p-6 rounded-t-2xl sm:rounded-2xl">
|
||||
<h3 className="font-heading text-lg font-semibold text-foreground mb-1">Resolve Session</h3>
|
||||
<p className="text-sm text-muted-foreground mb-4">Summarize what fixed the issue. This will be included in the auto-generated documentation.</p>
|
||||
<textarea
|
||||
value={resolutionSummary}
|
||||
onChange={(e) => setResolutionSummary(e.target.value)}
|
||||
placeholder="What resolved the issue?"
|
||||
className="w-full rounded-lg border border-border bg-card px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:border-[rgba(96,165,250,0.3)] focus:outline-none resize-none"
|
||||
rows={4}
|
||||
autoFocus
|
||||
/>
|
||||
<div className="mt-4 flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
|
||||
<button
|
||||
onClick={() => setShowResolve(false)}
|
||||
className="rounded-lg px-4 py-2 min-h-[44px] text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleResolve}
|
||||
disabled={resolutionSummary.length < 5 || submitting}
|
||||
className="rounded-lg bg-emerald-500/20 border border-emerald-500/30 px-4 py-2 min-h-[44px] text-sm font-medium text-emerald-400 hover:bg-emerald-500/30 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{submitting ? 'Resolving...' : 'Resolve Session'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Close/Abandon confirmation */}
|
||||
{showAbandon && (
|
||||
<div className="fixed inset-0 z-50 flex items-end sm:items-center justify-center bg-black/60 backdrop-blur-sm">
|
||||
<div className="card-flat w-full max-w-full sm:max-w-lg mx-0 sm:mx-4 p-4 sm:p-6 rounded-t-2xl sm:rounded-2xl">
|
||||
<h3 className="font-heading text-lg font-semibold text-foreground mb-1">Close Session</h3>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Are you sure you want to close this session? The session history will be kept but it won't count as resolved.
|
||||
</p>
|
||||
<div className="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
|
||||
<button
|
||||
onClick={() => setShowAbandon(false)}
|
||||
className="rounded-lg px-4 py-2 min-h-[44px] text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleAbandon}
|
||||
disabled={submitting}
|
||||
className="rounded-lg bg-rose-500/20 border border-rose-500/30 px-4 py-2 min-h-[44px] text-sm font-medium text-rose-400 hover:bg-rose-500/30 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{submitting ? 'Closing...' : 'Close Session'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Escalate modal */}
|
||||
<EscalateModal
|
||||
open={showEscalate}
|
||||
onClose={() => setShowEscalate(false)}
|
||||
onEscalate={onEscalate}
|
||||
isProcessing={isProcessing || submitting}
|
||||
hasPsaTicket={hasPsaTicket}
|
||||
sessionId={sessionId}
|
||||
/>
|
||||
|
||||
{/* Status Update modal */}
|
||||
{onGenerateStatusUpdate && (
|
||||
<StatusUpdateModal
|
||||
open={showStatusUpdate}
|
||||
onClose={() => setShowStatusUpdate(false)}
|
||||
onGenerate={onGenerateStatusUpdate}
|
||||
context="status"
|
||||
hasPsaTicket={hasPsaTicket}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -187,6 +187,23 @@ export function SessionDocView({
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Follow-up recommendations */}
|
||||
{documentation.follow_up_recommendations.length > 0 && (
|
||||
<div className="card-flat p-3 sm:p-4">
|
||||
<h4 className="font-sans text-xs text-[0.625rem] uppercase tracking-wider text-muted-foreground mb-2">
|
||||
Follow-up
|
||||
</h4>
|
||||
<ul className="space-y-1">
|
||||
{documentation.follow_up_recommendations.map((rec, i) => (
|
||||
<li key={i} className="flex items-start gap-2 text-sm text-foreground">
|
||||
<span className="mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full bg-primary" />
|
||||
{rec}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Rating */}
|
||||
{onRate && (
|
||||
<div className="card-flat p-3 sm:p-4 text-center">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState } from 'react'
|
||||
import { FileText, User, Mail, Zap, AlignLeft, Copy, Check, RotateCcw, ArrowLeftRight, Loader2 } from 'lucide-react'
|
||||
import { FileText, User, Mail, HelpCircle, Zap, AlignLeft, Copy, Check, RotateCcw, ArrowLeftRight, Loader2 } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { toast } from '@/lib/toast'
|
||||
import type { StatusUpdateAudience, StatusUpdateLength, StatusUpdateContext, StatusUpdateResponse } from '@/types/ai-session'
|
||||
@@ -12,10 +12,11 @@ interface StatusUpdateModalProps {
|
||||
hasPsaTicket?: boolean
|
||||
}
|
||||
|
||||
const AUDIENCES: { value: StatusUpdateAudience; icon: typeof FileText; label: string; description: string }[] = [
|
||||
const AUDIENCES: { value: StatusUpdateAudience; icon: typeof FileText; label: string; description: string; skipLength?: boolean }[] = [
|
||||
{ value: 'ticket_notes', icon: FileText, label: 'Ticket Notes', description: 'Technical, for your PSA' },
|
||||
{ value: 'client_update', icon: User, label: 'Client Update', description: 'Professional, non-technical' },
|
||||
{ value: 'email_draft', icon: Mail, label: 'Email Draft', description: 'Full email with subject line' },
|
||||
{ value: 'request_info', icon: HelpCircle, label: 'Request Information', description: 'Ask the client specific questions', skipLength: true },
|
||||
]
|
||||
|
||||
const LENGTHS: { value: StatusUpdateLength; icon: typeof Zap; label: string; description: string }[] = [
|
||||
@@ -38,9 +39,24 @@ export function StatusUpdateModal({ open, onClose, onGenerate, context = 'status
|
||||
escalation: 'Share Escalation',
|
||||
}
|
||||
|
||||
const handleAudienceSelect = (value: StatusUpdateAudience) => {
|
||||
const handleAudienceSelect = async (value: StatusUpdateAudience) => {
|
||||
setAudience(value)
|
||||
setStep('length')
|
||||
const opt = AUDIENCES.find(a => a.value === value)
|
||||
if (opt?.skipLength) {
|
||||
// Skip length selection — always concise for request_info
|
||||
setLength('quick')
|
||||
setStep('generating')
|
||||
try {
|
||||
const res = await onGenerate(value, 'quick', context)
|
||||
setResult(res)
|
||||
setStep('result')
|
||||
} catch {
|
||||
setStep('audience')
|
||||
setAudience(null)
|
||||
}
|
||||
} else {
|
||||
setStep('length')
|
||||
}
|
||||
}
|
||||
|
||||
const handleLengthSelect = async (value: StatusUpdateLength) => {
|
||||
@@ -170,7 +186,7 @@ export function StatusUpdateModal({ open, onClose, onGenerate, context = 'status
|
||||
<div className="flex flex-col items-center justify-center py-8 gap-3">
|
||||
<Loader2 size={24} className="animate-spin text-blue-400" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Generating {audience === 'email_draft' ? 'email draft' : audience === 'client_update' ? 'client update' : 'ticket notes'}...
|
||||
{audience === 'request_info' ? 'Drafting information request...' : audience === 'email_draft' ? 'Generating email draft...' : audience === 'client_update' ? 'Generating client update...' : 'Generating ticket notes...'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -2,7 +2,6 @@ export { FlowPilotIntake } from './FlowPilotIntake'
|
||||
export { FlowPilotSession } from './FlowPilotSession'
|
||||
export { FlowPilotStepCard } from './FlowPilotStepCard'
|
||||
export { FlowPilotOptions } from './FlowPilotOptions'
|
||||
export { FlowPilotActionBar } from './FlowPilotActionBar'
|
||||
export { ConfidenceIndicator } from './ConfidenceIndicator'
|
||||
export { SessionDocView } from './SessionDocView'
|
||||
export { AISessionListItem } from './AISessionListItem'
|
||||
|
||||
@@ -42,7 +42,7 @@ const PAGES: PaletteItem[] = [
|
||||
{ id: 'page-dashboard', group: 'pages', title: 'Dashboard', path: '/', icon: 'page' },
|
||||
{ id: 'page-flows', group: 'pages', title: 'All Flows', subtitle: 'Browse your flow library', path: '/trees', icon: 'page' },
|
||||
{ id: 'page-sessions', group: 'pages', title: 'Sessions', subtitle: 'View session history', path: '/sessions', icon: 'page' },
|
||||
{ id: 'page-assistant', group: 'pages', title: 'AI Assistant', subtitle: 'FlowPilot chat', path: '/assistant', icon: 'page' },
|
||||
{ id: 'page-assistant', group: 'pages', title: 'FlowPilot', subtitle: 'AI-powered troubleshooting chat', path: '/assistant', icon: 'page' },
|
||||
{ id: 'page-scripts', group: 'pages', title: 'Script Generator', subtitle: 'Generate PowerShell scripts', path: '/scripts', icon: 'page' },
|
||||
{ id: 'page-analytics', group: 'pages', title: 'Analytics', subtitle: 'Team usage & metrics', path: '/analytics', icon: 'page' },
|
||||
{ id: 'page-settings', group: 'pages', title: 'Settings', subtitle: 'Account & preferences', path: '/account', icon: 'page' },
|
||||
@@ -247,7 +247,7 @@ export function CommandPalette({ open, onClose }: CommandPaletteProps) {
|
||||
|
||||
if (intent === 'question') {
|
||||
// FlowPilot prominent at top
|
||||
result.push({ type: 'flowpilot', label: 'AI Assistant', items: [flowPilotItem] })
|
||||
result.push({ type: 'flowpilot', label: 'FlowPilot', items: [flowPilotItem] })
|
||||
if (flowItems.length > 0) result.push({ type: 'flows', label: 'Flows', items: flowItems })
|
||||
if (sessionItems.length > 0) result.push({ type: 'sessions', label: 'Sessions', items: sessionItems })
|
||||
if (aiSessionItems.length > 0) result.push({ type: 'ai-sessions', label: 'FlowPilot Sessions', items: aiSessionItems })
|
||||
@@ -259,10 +259,10 @@ export function CommandPalette({ open, onClose }: CommandPaletteProps) {
|
||||
if (sessionItems.length > 0) result.push({ type: 'sessions', label: 'Sessions', items: sessionItems })
|
||||
if (aiSessionItems.length > 0) result.push({ type: 'ai-sessions', label: 'FlowPilot Sessions', items: aiSessionItems })
|
||||
if (tagItems.length > 0) result.push({ type: 'tags', label: 'Tags', items: tagItems })
|
||||
result.push({ type: 'flowpilot', label: 'AI Assistant', items: [flowPilotItem] })
|
||||
result.push({ type: 'flowpilot', label: 'FlowPilot', items: [flowPilotItem] })
|
||||
} else {
|
||||
// keyword: FlowPilot at top, flows/sessions/tags below
|
||||
result.push({ type: 'flowpilot', label: 'AI Assistant', items: [flowPilotItem] })
|
||||
result.push({ type: 'flowpilot', label: 'FlowPilot', items: [flowPilotItem] })
|
||||
if (flowItems.length > 0) result.push({ type: 'flows', label: 'Flows', items: flowItems })
|
||||
if (sessionItems.length > 0) result.push({ type: 'sessions', label: 'Sessions', items: sessionItems })
|
||||
if (aiSessionItems.length > 0) result.push({ type: 'ai-sessions', label: 'FlowPilot Sessions', items: aiSessionItems })
|
||||
|
||||
@@ -5,13 +5,14 @@ import {
|
||||
LayoutGrid, Clock, AlertTriangle, GitBranch, Code2, Wand2,
|
||||
ListChecks, Download, BarChart3,
|
||||
Settings, Pin, PinOff,
|
||||
History, FileText,
|
||||
History, FileText, Sparkles, Network,
|
||||
} from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useUserPreferencesStore } from '@/store/userPreferencesStore'
|
||||
import { sidebarApi } from '@/api'
|
||||
import type { SidebarStatsResponse } from '@/api/sidebar'
|
||||
import { prefetchForRoute } from '@/lib/routePrefetch'
|
||||
import { useFeatureFlag } from '@/hooks/useFeatureFlag'
|
||||
|
||||
/* ── Types ──────────────────────────────────────────── */
|
||||
|
||||
@@ -42,6 +43,7 @@ export function Sidebar() {
|
||||
const location = useLocation()
|
||||
const sidebarPinned = useUserPreferencesStore(s => s.sidebarPinned)
|
||||
const toggleSidebarPinned = useUserPreferencesStore(s => s.toggleSidebarPinned)
|
||||
const hasCockpit = useFeatureFlag('flowpilot_cockpit')
|
||||
|
||||
const [stats, setStats] = useState<SidebarStatsResponse | null>(null)
|
||||
const [flyoutIndex, setFlyoutIndex] = useState<string | null>(null)
|
||||
@@ -74,6 +76,14 @@ export function Sidebar() {
|
||||
href: '/', icon: LayoutGrid, label: 'Home', shortLabel: 'Home',
|
||||
matchPaths: ['/'],
|
||||
},
|
||||
{
|
||||
href: '/assistant', icon: Sparkles, label: 'FlowPilot', shortLabel: 'FP',
|
||||
matchPaths: ['/assistant', '/cockpit'],
|
||||
children: [
|
||||
{ href: '/assistant', label: 'FlowPilot' },
|
||||
...(hasCockpit ? [{ href: '/cockpit', label: 'FlowPilot Cockpit' }] : []),
|
||||
],
|
||||
},
|
||||
{
|
||||
href: '/sessions', icon: History, label: 'History', shortLabel: 'History',
|
||||
badge: stats?.active_count || undefined,
|
||||
@@ -86,10 +96,11 @@ 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'],
|
||||
matchPaths: ['/trees', '/flows', '/my-trees', '/step-library', '/review-queue', '/network-diagrams'],
|
||||
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' },
|
||||
],
|
||||
@@ -118,6 +129,8 @@ export function Sidebar() {
|
||||
title: 'RESOLVE',
|
||||
items: [
|
||||
{ href: '/', icon: LayoutGrid, label: 'Dashboard', shortLabel: 'Dash' },
|
||||
{ href: '/assistant', icon: Sparkles, label: 'FlowPilot', shortLabel: 'FP', matchPaths: ['/assistant', '/cockpit'] },
|
||||
...(hasCockpit ? [{ href: '/cockpit', icon: Sparkles, label: 'FlowPilot Cockpit', shortLabel: 'Cockpit', matchPaths: ['/cockpit'] } as NavEntry] : []),
|
||||
{ href: '/sessions', icon: Clock, label: 'Session History', shortLabel: 'History', badge: stats?.active_count || undefined, matchPaths: ['/sessions'] },
|
||||
{ href: '/escalations', icon: AlertTriangle, label: 'Escalations', shortLabel: 'Escal', badge: stats?.escalation_count || undefined },
|
||||
],
|
||||
@@ -134,6 +147,7 @@ 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' },
|
||||
|
||||
109
frontend/src/components/network/CanvasEmptyPrompt.tsx
Normal file
109
frontend/src/components/network/CanvasEmptyPrompt.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
import { useState, useCallback } from 'react'
|
||||
import { Sparkles, ArrowRight } 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 [description, setDescription] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
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])
|
||||
|
||||
return (
|
||||
<div className="pointer-events-none absolute inset-0 z-10 flex items-center justify-center">
|
||||
<div className="pointer-events-auto w-full max-w-lg rounded-xl border border-default bg-card p-8 shadow-2xl">
|
||||
<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 drag devices from the left panel to build manually.
|
||||
</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/50">
|
||||
⌘↵ 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>}
|
||||
|
||||
{loading ? (
|
||||
<div className="flex 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 w-full 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>
|
||||
)
|
||||
}
|
||||
106
frontend/src/components/network/ContextMenu.tsx
Normal file
106
frontend/src/components/network/ContextMenu.tsx
Normal file
@@ -0,0 +1,106 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { Copy, CopyPlus, Trash2, ClipboardPaste, BoxSelect, Maximize2 } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface MenuAction {
|
||||
label: string
|
||||
icon: React.ElementType
|
||||
shortcut: string
|
||||
onClick: () => void
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
interface ContextMenuProps {
|
||||
position: { x: number; y: number }
|
||||
actions: MenuAction[]
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function ContextMenu({ position, actions, onClose }: ContextMenuProps) {
|
||||
const menuRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const clampedPosition = { ...position }
|
||||
if (typeof window !== 'undefined') {
|
||||
const menuWidth = 192
|
||||
const menuHeight = actions.length * 36 + 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"
|
||||
style={{ left: clampedPosition.x, top: clampedPosition.y }}
|
||||
>
|
||||
{actions.map((action) => (
|
||||
<button
|
||||
key={action.label}
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
export function getNodeMenuActions(handlers: {
|
||||
onCopy: () => void
|
||||
onDuplicate: () => 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: 'Delete', icon: Trash2, shortcut: 'Del', onClick: handlers.onDelete },
|
||||
]
|
||||
}
|
||||
|
||||
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 },
|
||||
]
|
||||
}
|
||||
162
frontend/src/components/network/DiagramHeader.tsx
Normal file
162
frontend/src/components/network/DiagramHeader.tsx
Normal file
@@ -0,0 +1,162 @@
|
||||
import { useState, useCallback, useRef, useEffect } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { ChevronLeft, Save, Download, FileJson, Image, FileText } from 'lucide-react'
|
||||
|
||||
interface DiagramHeaderProps {
|
||||
name: string
|
||||
clientName: string | null
|
||||
isSaving: boolean
|
||||
lastSavedAt: Date | null
|
||||
diagramId: string | null
|
||||
onNameChange: (name: string) => void
|
||||
onSave: () => void
|
||||
onExportPng: () => void
|
||||
onExportPdf: () => void
|
||||
onExportJson: () => void
|
||||
}
|
||||
|
||||
export function DiagramHeader({
|
||||
name,
|
||||
clientName,
|
||||
isSaving,
|
||||
lastSavedAt,
|
||||
diagramId,
|
||||
onNameChange,
|
||||
onSave,
|
||||
onExportPng,
|
||||
onExportPdf,
|
||||
onExportJson,
|
||||
}: 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
|
||||
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" />
|
||||
|
||||
{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" />
|
||||
|
||||
{lastSavedAt && (
|
||||
<span className="text-[10px] text-muted-foreground">{formatLastSaved()}</span>
|
||||
)}
|
||||
|
||||
<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
|
||||
</button>
|
||||
{showExportMenu && (
|
||||
<div className="absolute right-0 top-full z-50 mt-1 w-40 rounded border border-default bg-card py-1 shadow-lg">
|
||||
<button
|
||||
onClick={() => { onExportPng(); setShowExportMenu(false) }}
|
||||
className="flex w-full items-center gap-2 px-3 py-1.5 text-xs text-primary hover:bg-elevated"
|
||||
>
|
||||
<Image size={12} /> Export PNG
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { onExportPdf(); setShowExportMenu(false) }}
|
||||
className="flex w-full items-center gap-2 px-3 py-1.5 text-xs text-primary hover:bg-elevated"
|
||||
>
|
||||
<FileText size={12} /> Export PDF
|
||||
</button>
|
||||
{diagramId && (
|
||||
<button
|
||||
onClick={() => { onExportJson(); setShowExportMenu(false) }}
|
||||
className="flex w-full items-center gap-2 px-3 py-1.5 text-xs text-primary hover:bg-elevated"
|
||||
>
|
||||
<FileJson size={12} /> Export JSON
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
119
frontend/src/components/network/NetworkCanvas.tsx
Normal file
119
frontend/src/components/network/NetworkCanvas.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
import { useCallback } from 'react'
|
||||
import {
|
||||
ReactFlow,
|
||||
Background,
|
||||
Controls,
|
||||
MiniMap,
|
||||
BackgroundVariant,
|
||||
type OnConnect,
|
||||
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'
|
||||
|
||||
interface NetworkCanvasProps {
|
||||
nodes: Node[]
|
||||
edges: Edge[]
|
||||
onNodesChange: OnNodesChange
|
||||
onEdgesChange: OnEdgesChange
|
||||
onConnect: OnConnect
|
||||
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
|
||||
}
|
||||
|
||||
export function NetworkCanvas({
|
||||
nodes,
|
||||
edges,
|
||||
onNodesChange,
|
||||
onEdgesChange,
|
||||
onConnect,
|
||||
onNodeSelect,
|
||||
onEdgeSelect,
|
||||
onDrop,
|
||||
onDragOver,
|
||||
onDragLeave,
|
||||
isDragOver,
|
||||
onNodeContextMenu,
|
||||
onPaneContextMenu,
|
||||
onPaneClick: onPaneClickProp,
|
||||
}: 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}>
|
||||
<ReactFlow
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
onNodesChange={onNodesChange}
|
||||
onEdgesChange={onEdgesChange}
|
||||
onConnect={onConnect}
|
||||
onSelectionChange={handleSelectionChange}
|
||||
onPaneClick={handlePaneClick}
|
||||
onDrop={onDrop}
|
||||
onDragOver={onDragOver}
|
||||
onNodeContextMenu={onNodeContextMenu}
|
||||
onPaneContextMenu={onPaneContextMenu}
|
||||
nodeTypes={nodeTypes}
|
||||
edgeTypes={edgeTypes}
|
||||
defaultEdgeOptions={{ type: 'connection' }}
|
||||
deleteKeyCode={['Backspace', 'Delete']}
|
||||
multiSelectionKeyCode="Shift"
|
||||
snapToGrid={true}
|
||||
snapGrid={[20, 20]}
|
||||
fitView
|
||||
className="bg-page"
|
||||
>
|
||||
<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>
|
||||
)
|
||||
}
|
||||
71
frontend/src/components/network/edges/ConnectionEdge.tsx
Normal file
71
frontend/src/components/network/edges/ConnectionEdge.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
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)
|
||||
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 bg-page px-1.5 py-0.5 text-[10px] text-muted-foreground"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
transform: `translate(-50%, -50%) translate(${labelX}px, ${labelY}px)`,
|
||||
pointerEvents: 'all',
|
||||
}}
|
||||
>
|
||||
{props.label}
|
||||
</div>
|
||||
</EdgeLabelRenderer>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export const ConnectionEdge = memo(ConnectionEdgeComponent)
|
||||
7
frontend/src/components/network/edges/edgeTypes.ts
Normal file
7
frontend/src/components/network/edges/edgeTypes.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { ConnectionEdge } from './ConnectionEdge'
|
||||
import { AnimatedSvgEdge } from '../ui/animated-svg-edge'
|
||||
|
||||
export const edgeTypes = {
|
||||
connection: ConnectionEdge,
|
||||
animated: AnimatedSvgEdge,
|
||||
}
|
||||
222
frontend/src/components/network/hooks/useCanvasShortcuts.ts
Normal file
222
frontend/src/components/network/hooks/useCanvasShortcuts.ts
Normal file
@@ -0,0 +1,222 @@
|
||||
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,
|
||||
edges,
|
||||
setNodes,
|
||||
setEdges,
|
||||
setIsDirty,
|
||||
canvasRef,
|
||||
}: {
|
||||
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>
|
||||
}) {
|
||||
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])
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (isInputFocused()) return
|
||||
|
||||
const ctrl = e.ctrlKey || e.metaKey
|
||||
|
||||
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 })
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
return () => document.removeEventListener('keydown', handleKeyDown)
|
||||
}, [copyNodes, pasteNodes, duplicateNodes, selectAll, fitView])
|
||||
|
||||
return {
|
||||
copyNodes,
|
||||
pasteNodes,
|
||||
duplicateNodes,
|
||||
selectAll,
|
||||
deleteSelected,
|
||||
hasClipboard: () => clipboardRef.current !== null && clipboardRef.current.nodes.length > 0,
|
||||
}
|
||||
}
|
||||
79
frontend/src/components/network/nodes/DeviceNode.tsx
Normal file
79
frontend/src/components/network/nodes/DeviceNode.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
import { memo } from 'react'
|
||||
import { Position, type NodeProps } from '@xyflow/react'
|
||||
import { BaseNode, BaseNodeHeader, BaseNodeHeaderTitle, BaseNodeContent } from '../ui/base-node'
|
||||
import { BaseHandle } from '../ui/base-handle'
|
||||
import { NodeStatusIndicator, type NodeStatus } from '../ui/node-status-indicator'
|
||||
import { NodeTooltip, NodeTooltipTrigger, NodeTooltipContent } from '../ui/node-tooltip'
|
||||
import { getDeviceRenderConfig } from './deviceRegistry'
|
||||
import 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>
|
||||
)
|
||||
}
|
||||
|
||||
function DeviceNodeComponent({ data }: NodeProps) {
|
||||
const nodeData = data as unknown as DeviceNodeData
|
||||
const { icon: Icon, color } = getDeviceRenderConfig(nodeData.deviceType, nodeData.category)
|
||||
const status = (nodeData.properties?.status || 'unknown') as NodeStatus
|
||||
const ip = nodeData.properties?.ip
|
||||
const props = nodeData.properties || {}
|
||||
|
||||
const hasTooltipContent = props.hostname || props.ip || props.vendor || props.model || props.role || props.notes
|
||||
|
||||
return (
|
||||
<NodeStatusIndicator status={status}>
|
||||
<NodeTooltip>
|
||||
<NodeTooltipTrigger>
|
||||
<BaseNode className="min-w-[120px] group">
|
||||
<BaseNodeHeader className="flex-col gap-1 items-center py-3 px-4">
|
||||
<Icon size={28} style={{ color }} />
|
||||
<BaseNodeHeaderTitle className="text-center text-xs">
|
||||
{nodeData.label}
|
||||
</BaseNodeHeaderTitle>
|
||||
</BaseNodeHeader>
|
||||
{ip && (
|
||||
<BaseNodeContent className="items-center pt-0">
|
||||
<span className="font-mono text-[10px] text-muted-foreground">{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.Bottom}>
|
||||
<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)
|
||||
113
frontend/src/components/network/nodes/deviceRegistry.ts
Normal file
113
frontend/src/components/network/nodes/deviceRegistry.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
import {
|
||||
Router, Layers, ShieldAlert, 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
|
||||
}
|
||||
|
||||
// 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 = '#f97316'
|
||||
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 SYSTEM_DEVICE_ICONS: Record<string, DeviceRenderConfig> = {
|
||||
// Network layer
|
||||
'router': { icon: Router, color: NETWORK_COLOR },
|
||||
'switch': { icon: Layers, color: NETWORK_COLOR },
|
||||
'access-point': { icon: Wifi, color: NETWORK_COLOR },
|
||||
'load-balancer': { icon: Gauge, color: NETWORK_COLOR },
|
||||
|
||||
// Security
|
||||
'firewall': { icon: ShieldAlert, color: SECURITY_COLOR },
|
||||
'badge-reader': { icon: KeyRound, color: SECURITY_COLOR },
|
||||
|
||||
// Compute
|
||||
'server': { icon: Server, color: COMPUTE_COLOR },
|
||||
'vm': { icon: Boxes, color: COMPUTE_COLOR },
|
||||
'container': { icon: Package, color: COMPUTE_COLOR },
|
||||
|
||||
// Storage
|
||||
'nas': { icon: Database, color: STORAGE_COLOR },
|
||||
'san': { icon: HardDrive, color: STORAGE_COLOR },
|
||||
'cloud-storage': { icon: CloudCog, color: STORAGE_COLOR },
|
||||
|
||||
// Cloud / Internet
|
||||
'cloud': { icon: Cloud, color: CLOUD_COLOR },
|
||||
'aws': { icon: Cloud, color: CLOUD_COLOR },
|
||||
'azure': { icon: Cloud, color: CLOUD_COLOR },
|
||||
'gcp': { icon: Cloud, color: CLOUD_COLOR },
|
||||
'isp': { icon: Globe, color: CLOUD_COLOR },
|
||||
|
||||
// Endpoints
|
||||
'workstation': { icon: Monitor, color: ENDPOINT_COLOR },
|
||||
'laptop': { icon: Laptop, color: ENDPOINT_COLOR },
|
||||
'tablet': { icon: Tablet, color: ENDPOINT_COLOR },
|
||||
'phone': { icon: Smartphone, color: ENDPOINT_COLOR },
|
||||
'printer': { icon: Printer, color: ENDPOINT_COLOR },
|
||||
|
||||
// Infrastructure / physical
|
||||
'ups': { icon: BatteryCharging, color: INFRA_COLOR },
|
||||
'pdu': { icon: PlugZap, color: INFRA_COLOR },
|
||||
'rack': { icon: RectangleVertical, color: INFRA_COLOR },
|
||||
'patch-panel': { icon: Cable, color: INFRA_COLOR },
|
||||
'camera': { icon: Camera, color: INFRA_COLOR },
|
||||
'nvr': { icon: Video, color: INFRA_COLOR },
|
||||
'iot': { icon: Radio, color: INFRA_COLOR },
|
||||
}
|
||||
|
||||
const CATEGORY_DEFAULTS: Record<string, DeviceRenderConfig> = {
|
||||
'network': { icon: Router, color: NETWORK_COLOR },
|
||||
'compute': { icon: Server, color: COMPUTE_COLOR },
|
||||
'storage': { icon: Database, color: STORAGE_COLOR },
|
||||
'cloud': { icon: Cloud, color: CLOUD_COLOR },
|
||||
'endpoint': { icon: Monitor, color: ENDPOINT_COLOR },
|
||||
'infrastructure': { icon: PlugZap, color: INFRA_COLOR },
|
||||
'security': { icon: ShieldAlert, color: SECURITY_COLOR },
|
||||
}
|
||||
|
||||
const FALLBACK: DeviceRenderConfig = { icon: Cpu, color: INFRA_COLOR }
|
||||
|
||||
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']
|
||||
7
frontend/src/components/network/nodes/nodeTypes.ts
Normal file
7
frontend/src/components/network/nodes/nodeTypes.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { DeviceNode } from './DeviceNode'
|
||||
import { GroupNode } from '../ui/labeled-group-node'
|
||||
|
||||
export const nodeTypes = {
|
||||
device: DeviceNode,
|
||||
group: GroupNode,
|
||||
}
|
||||
131
frontend/src/components/network/panels/AIAssistPanel.tsx
Normal file
131
frontend/src/components/network/panels/AIAssistPanel.tsx
Normal file
@@ -0,0 +1,131 @@
|
||||
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 handleGenerate = useCallback(async () => {
|
||||
if (!description.trim()) return
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
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])
|
||||
|
||||
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)} 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={() => setMode('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={() => setMode('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>
|
||||
|
||||
{mode === 'replace' && hasNodes && (
|
||||
<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-2 w-2 animate-pulse rounded-full bg-accent" />
|
||||
<span className="text-xs text-muted-foreground">Generating your network diagram...</span>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
227
frontend/src/components/network/panels/DeviceToolbar.tsx
Normal file
227
frontend/src/components/network/panels/DeviceToolbar.tsx
Normal file
@@ -0,0 +1,227 @@
|
||||
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' },
|
||||
{ slug: 'vlan', label: 'VLAN' },
|
||||
{ slug: 'site', label: 'Site' },
|
||||
{ slug: 'dmz', label: 'DMZ' },
|
||||
].map(item => (
|
||||
<div
|
||||
key={item.slug}
|
||||
draggable
|
||||
onDragStart={e => {
|
||||
e.dataTransfer.setData('application/reactflow-group', JSON.stringify(item))
|
||||
e.dataTransfer.effectAllowed = 'move'
|
||||
}}
|
||||
className="flex cursor-grab items-center gap-2 rounded px-2 py-1.5 text-xs text-primary hover:bg-elevated active:cursor-grabbing active:scale-[0.98] transition-transform"
|
||||
>
|
||||
<GripVertical size={12} className="shrink-0 text-muted-foreground/50" />
|
||||
<LayoutGrid size={14} className="text-muted-foreground" />
|
||||
<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>
|
||||
)
|
||||
}
|
||||
339
frontend/src/components/network/panels/PropertiesPanel.tsx
Normal file
339
frontend/src/components/network/panels/PropertiesPanel.tsx
Normal file
@@ -0,0 +1,339 @@
|
||||
import { useCallback } from 'react'
|
||||
import { Trash2, Minus, Spline, GitBranch } 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
|
||||
onDeleteNode: (nodeId: string) => void
|
||||
onDeleteEdge: (edgeId: string) => 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>
|
||||
)
|
||||
}
|
||||
|
||||
export function PropertiesPanel({
|
||||
selectedNode,
|
||||
selectedEdge,
|
||||
onNodeUpdate,
|
||||
onEdgeUpdate,
|
||||
onEdgeTypeChange,
|
||||
onDeleteNode,
|
||||
onDeleteEdge,
|
||||
}: PropertiesPanelProps) {
|
||||
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) {
|
||||
return (
|
||||
<div className="flex h-full w-[260px] flex-col items-center justify-center border-l border-default bg-sidebar px-4">
|
||||
<p className="text-center text-xs text-muted-foreground">
|
||||
Select a device or connection to edit its properties
|
||||
</p>
|
||||
<p className="mt-1 text-center text-[10px] text-muted-foreground/60">
|
||||
Hover a device to preview its info
|
||||
</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="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' },
|
||||
] 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">
|
||||
<button
|
||||
onClick={() => onDeleteEdge(selectedEdge.id)}
|
||||
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>
|
||||
|
||||
{/* 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">
|
||||
<button
|
||||
onClick={() => onDeleteNode(selectedNode!.id)}
|
||||
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>
|
||||
)
|
||||
}
|
||||
131
frontend/src/components/network/ui/animated-svg-edge.tsx
Normal file
131
frontend/src/components/network/ui/animated-svg-edge.tsx
Normal file
@@ -0,0 +1,131 @@
|
||||
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)
|
||||
20
frontend/src/components/network/ui/base-handle.tsx
Normal file
20
frontend/src/components/network/ui/base-handle.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
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-[10px] w-[10px] rounded-full border border-default bg-elevated transition-opacity',
|
||||
'opacity-0 group-hover:opacity-100',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</Handle>
|
||||
)
|
||||
}
|
||||
56
frontend/src/components/network/ui/base-node.tsx
Normal file
56
frontend/src/components/network/ui/base-node.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
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 rounded-lg border border-default',
|
||||
'transition-colors hover:border-hover',
|
||||
'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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
68
frontend/src/components/network/ui/labeled-group-node.tsx
Normal file
68
frontend/src/components/network/ui/labeled-group-node.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
39
frontend/src/components/network/ui/labeled-handle.tsx
Normal file
39
frontend/src/components/network/ui/labeled-handle.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
43
frontend/src/components/network/ui/node-status-indicator.tsx
Normal file
43
frontend/src/components/network/ui/node-status-indicator.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
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_8px_rgba(52,211,153,0.3)]',
|
||||
offline: 'shadow-[0_0_8px_rgba(248,113,113,0.3)]',
|
||||
degraded: 'shadow-[0_0_8px_rgba(250,204,21,0.3)]',
|
||||
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(
|
||||
'rounded-lg border-2 transition-colors',
|
||||
STATUS_BORDER_COLORS[status],
|
||||
STATUS_GLOW[status],
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
77
frontend/src/components/network/ui/node-tooltip.tsx
Normal file
77
frontend/src/components/network/ui/node-tooltip.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
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, ...props }: ComponentProps<'div'>) {
|
||||
const [visible, setVisible] = useState(false)
|
||||
const show = useCallback(() => setVisible(true), [])
|
||||
const hide = useCallback(() => setVisible(false), [])
|
||||
|
||||
return (
|
||||
<NodeTooltipContext.Provider value={{ visible, show, hide }}>
|
||||
<div {...props}>{children}</div>
|
||||
</NodeTooltipContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function NodeTooltipTrigger({
|
||||
children,
|
||||
onMouseEnter,
|
||||
onMouseLeave,
|
||||
...props
|
||||
}: ComponentProps<'div'>) {
|
||||
const { show, hide } = useContext(NodeTooltipContext)
|
||||
|
||||
return (
|
||||
<div
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -297,14 +297,14 @@ export const guides: Guide[] = [
|
||||
},
|
||||
{
|
||||
slug: 'ai-assistant',
|
||||
title: 'AI Assistant',
|
||||
title: 'FlowPilot',
|
||||
icon: BotMessageSquare,
|
||||
summary: 'Standalone AI chat for IT questions and flow recommendations.',
|
||||
sections: [
|
||||
{
|
||||
title: 'Starting a Conversation',
|
||||
steps: [
|
||||
{ instruction: 'Click **AI Assistant** in the sidebar to open the chat page.' },
|
||||
{ instruction: 'Click **FlowPilot** in the sidebar to open the chat page.' },
|
||||
{ instruction: 'Click **Start a Conversation** or the **+ New Chat** button in the left panel.' },
|
||||
{ instruction: 'Type your question in the message box and press Enter or click the send button.' },
|
||||
{ instruction: 'The AI responds as a Senior Systems & Network Engineer with MSP expertise.' },
|
||||
|
||||
606
frontend/src/hooks/useAssistantSession.ts
Normal file
606
frontend/src/hooks/useAssistantSession.ts
Normal file
@@ -0,0 +1,606 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||
import { useLocation, useNavigate, useParams } from 'react-router-dom'
|
||||
import { uploadsApi } from '@/api/uploads'
|
||||
import type { PendingUpload } from '@/types/upload'
|
||||
import type { ForkMetadata, ActionItem, QuestionItem, ChatMessageResponse, TriageUpdate } from '@/types/ai-session'
|
||||
import { aiSessionsApi } from '@/api/aiSessions'
|
||||
import { useBranching } from '@/hooks/useBranching'
|
||||
import { analytics } from '@/lib/analytics'
|
||||
import { toast } from '@/lib/toast'
|
||||
import type { ChatListItem, ConclusionOutcome } from '@/types/assistant-chat'
|
||||
import type { SuggestedFlow } from '@/types/copilot'
|
||||
|
||||
export interface MessageWithMeta {
|
||||
role: 'user' | 'assistant'
|
||||
content: string
|
||||
suggestedFlows?: SuggestedFlow[]
|
||||
fork?: ForkMetadata | null
|
||||
actions?: ActionItem[] | null
|
||||
questions?: QuestionItem[] | null
|
||||
}
|
||||
|
||||
const ACCEPTED_FILE_TYPES = 'image/png,image/jpeg,image/gif,image/webp,.txt,.log,.csv,.pdf,.docx'
|
||||
|
||||
export function useAssistantSession() {
|
||||
const location = useLocation()
|
||||
const navigate = useNavigate()
|
||||
const { sessionId: urlSessionId } = useParams<{ sessionId?: string }>()
|
||||
|
||||
const [chats, setChats] = useState<ChatListItem[]>([])
|
||||
const [activeChatId, setActiveChatId] = useState<string | null>(() => {
|
||||
if (urlSessionId) return urlSessionId
|
||||
try { return sessionStorage.getItem('rf-active-chat-id') } catch { return null }
|
||||
})
|
||||
const [messages, setMessages] = useState<MessageWithMeta[]>([])
|
||||
const [input, setInput] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [showConclude, setShowConclude] = useState(false)
|
||||
const [showStatusUpdate, setShowStatusUpdate] = useState(false)
|
||||
const branching = useBranching()
|
||||
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false)
|
||||
const [showLogs, setShowLogs] = useState(false)
|
||||
const [logContent, setLogContent] = useState('')
|
||||
const [pendingUploads, setPendingUploads] = useState<PendingUpload[]>([])
|
||||
const [isDragOver, setIsDragOver] = useState(false)
|
||||
const [activeQuestions, setActiveQuestions] = useState<QuestionItem[]>(() => {
|
||||
try {
|
||||
const saved = sessionStorage.getItem('rf-tasklane-meta')
|
||||
if (saved) { const d = JSON.parse(saved); if (d.chatId === urlSessionId || d.chatId === sessionStorage.getItem('rf-active-chat-id')) return d.questions || [] }
|
||||
} catch { /* ignore */ }
|
||||
return []
|
||||
})
|
||||
const [activeActions, setActiveActions] = useState<ActionItem[]>(() => {
|
||||
try {
|
||||
const saved = sessionStorage.getItem('rf-tasklane-meta')
|
||||
if (saved) { const d = JSON.parse(saved); if (d.chatId === urlSessionId || d.chatId === sessionStorage.getItem('rf-active-chat-id')) return d.actions || [] }
|
||||
} catch { /* ignore */ }
|
||||
return []
|
||||
})
|
||||
const [showTaskLane, setShowTaskLane] = useState(() => {
|
||||
try {
|
||||
const saved = sessionStorage.getItem('rf-tasklane-meta')
|
||||
if (saved) { const d = JSON.parse(saved); return d.show === true && (d.chatId === urlSessionId || d.chatId === sessionStorage.getItem('rf-active-chat-id')) }
|
||||
} catch { /* ignore */ }
|
||||
return false
|
||||
})
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(() =>
|
||||
localStorage.getItem('rf-chat-sidebar-collapsed') === 'true'
|
||||
)
|
||||
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null)
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const dragCounterRef = useRef(0)
|
||||
const prefillHandledRef = useRef(false)
|
||||
const currentChatRef = useRef<string | null>(activeChatId)
|
||||
const loadingRef = useRef(false)
|
||||
const initialLoadDoneRef = useRef(false)
|
||||
|
||||
const toggleSidebarCollapse = () => {
|
||||
const next = !sidebarCollapsed
|
||||
setSidebarCollapsed(next)
|
||||
localStorage.setItem('rf-chat-sidebar-collapsed', String(next))
|
||||
}
|
||||
|
||||
// Persist active chat ID to sessionStorage
|
||||
useEffect(() => {
|
||||
try {
|
||||
if (activeChatId) sessionStorage.setItem('rf-active-chat-id', activeChatId)
|
||||
else sessionStorage.removeItem('rf-active-chat-id')
|
||||
} catch { /* ignore */ }
|
||||
}, [activeChatId])
|
||||
|
||||
// Load chat list on mount
|
||||
useEffect(() => { loadChats() }, [])
|
||||
|
||||
// Load session data on mount or when URL session changes.
|
||||
// On initial mount, always load even if activeChatId matches urlSessionId
|
||||
// (state is empty after view toggle between /assistant and /cockpit).
|
||||
useEffect(() => {
|
||||
if (urlSessionId) {
|
||||
if (!initialLoadDoneRef.current || urlSessionId !== activeChatId) {
|
||||
selectChat(urlSessionId)
|
||||
}
|
||||
initialLoadDoneRef.current = true
|
||||
} else if (!initialLoadDoneRef.current && activeChatId) {
|
||||
// Restore session from sessionStorage on mount (when URL has no session ID)
|
||||
selectChat(activeChatId)
|
||||
initialLoadDoneRef.current = true
|
||||
}
|
||||
}, [urlSessionId]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Persist task lane metadata to sessionStorage
|
||||
useEffect(() => {
|
||||
try {
|
||||
sessionStorage.setItem('rf-tasklane-meta', JSON.stringify({
|
||||
show: showTaskLane,
|
||||
chatId: activeChatId,
|
||||
questions: activeQuestions,
|
||||
actions: activeActions,
|
||||
}))
|
||||
} catch { /* ignore */ }
|
||||
}, [showTaskLane, activeChatId, activeQuestions, activeActions])
|
||||
|
||||
// Auto-scroll
|
||||
useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
|
||||
}, [messages])
|
||||
|
||||
// Auto-grow textarea
|
||||
useEffect(() => {
|
||||
const el = inputRef.current
|
||||
if (!el) return
|
||||
el.style.height = 'auto'
|
||||
el.style.height = `${Math.min(el.scrollHeight, 150)}px`
|
||||
}, [input])
|
||||
|
||||
// Cleanup blob URLs on unmount
|
||||
useEffect(() => {
|
||||
return () => { pendingUploads.forEach((u) => { if (u.preview) URL.revokeObjectURL(u.preview) }) }
|
||||
}, []) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const loadChats = async () => {
|
||||
try {
|
||||
const sessions = await aiSessionsApi.listSessions({ session_type: 'chat', limit: 100 })
|
||||
setChats(sessions.map(s => ({
|
||||
id: s.id,
|
||||
title: s.title || s.problem_summary || 'New Chat',
|
||||
message_count: s.step_count,
|
||||
pinned: false,
|
||||
created_at: s.created_at,
|
||||
updated_at: s.created_at,
|
||||
})))
|
||||
} catch {
|
||||
// silently handle
|
||||
}
|
||||
}
|
||||
|
||||
// Callback for pages to handle triage data from selectChat.
|
||||
// Pages that care about triage (CockpitPage) set this; FlowPilotPage ignores it.
|
||||
const onSessionLoadedRef = useRef<((detail: {
|
||||
client_name: string | null
|
||||
asset_name: string | null
|
||||
issue_category: string | null
|
||||
triage_hypothesis: string | null
|
||||
evidence_items: Array<{ text: string; status: string }> | null
|
||||
psa_ticket_id: string | null
|
||||
}) => void) | null>(null)
|
||||
|
||||
const selectChat = useCallback(async (chatId: string) => {
|
||||
currentChatRef.current = chatId
|
||||
setActiveChatId(chatId)
|
||||
// Clear task lane immediately to prevent the persist effect from
|
||||
// tagging the old session's data with the new chatId (race condition).
|
||||
// Server-side pending_task_lane will restore it below if it exists.
|
||||
setActiveQuestions([])
|
||||
setActiveActions([])
|
||||
setShowTaskLane(false)
|
||||
try {
|
||||
const detail = await aiSessionsApi.getSession(chatId)
|
||||
if (currentChatRef.current !== chatId) return
|
||||
setMessages(
|
||||
(detail.conversation_messages || []).map(m => ({
|
||||
role: m.role as 'user' | 'assistant',
|
||||
content: m.content,
|
||||
}))
|
||||
)
|
||||
// Notify page of triage data
|
||||
onSessionLoadedRef.current?.({
|
||||
client_name: detail.client_name ?? null,
|
||||
asset_name: detail.asset_name ?? null,
|
||||
issue_category: detail.issue_category ?? null,
|
||||
triage_hypothesis: detail.triage_hypothesis ?? null,
|
||||
evidence_items: detail.evidence_items ?? null,
|
||||
psa_ticket_id: detail.psa_ticket_id ?? null,
|
||||
})
|
||||
// Restore task lane from server state
|
||||
if (detail.pending_task_lane) {
|
||||
const q = detail.pending_task_lane.questions || []
|
||||
const a = detail.pending_task_lane.actions || []
|
||||
if (q.length > 0 || a.length > 0) {
|
||||
const responses = (detail.pending_task_lane as Record<string, unknown>).responses as unknown[] | undefined
|
||||
if (responses && responses.length > 0) {
|
||||
try {
|
||||
sessionStorage.setItem(`rf-tasklane-state:${chatId}`, JSON.stringify(responses))
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
setActiveQuestions(q)
|
||||
setActiveActions(a)
|
||||
setShowTaskLane(true)
|
||||
return
|
||||
}
|
||||
}
|
||||
// Fallback: restore from sessionStorage (covers view toggles before backend fix)
|
||||
try {
|
||||
const saved = sessionStorage.getItem('rf-tasklane-meta')
|
||||
if (saved) {
|
||||
const d = JSON.parse(saved)
|
||||
if (d.chatId === chatId) {
|
||||
const q = d.questions || []
|
||||
const a = d.actions || []
|
||||
if (q.length > 0 || a.length > 0) {
|
||||
setActiveQuestions(q)
|
||||
setActiveActions(a)
|
||||
setShowTaskLane(d.show === true)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
// No task lane data from either source — clear
|
||||
setShowTaskLane(false)
|
||||
setActiveQuestions([])
|
||||
setActiveActions([])
|
||||
} catch {
|
||||
setMessages([])
|
||||
setShowTaskLane(false)
|
||||
setActiveQuestions([])
|
||||
setActiveActions([])
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleNewChat = async () => {
|
||||
if (loadingRef.current) return
|
||||
loadingRef.current = true
|
||||
try {
|
||||
const session = await aiSessionsApi.createChatSession({
|
||||
intake_type: 'free_text',
|
||||
intake_content: { text: '' },
|
||||
})
|
||||
const chatItem: ChatListItem = {
|
||||
id: session.session_id,
|
||||
title: session.title,
|
||||
message_count: 0,
|
||||
pinned: false,
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
}
|
||||
currentChatRef.current = session.session_id
|
||||
setChats(prev => [chatItem, ...prev])
|
||||
setActiveChatId(session.session_id)
|
||||
setMessages([])
|
||||
setShowTaskLane(false)
|
||||
setActiveQuestions([])
|
||||
setActiveActions([])
|
||||
} catch {
|
||||
toast.error('Failed to create chat')
|
||||
} finally {
|
||||
loadingRef.current = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteChat = async (chatId: string) => {
|
||||
try {
|
||||
await aiSessionsApi.deleteSession(chatId)
|
||||
setChats(prev => prev.filter(c => c.id !== chatId))
|
||||
if (activeChatId === chatId) {
|
||||
setActiveChatId(null)
|
||||
setMessages([])
|
||||
}
|
||||
} catch {
|
||||
toast.error('Failed to delete chat')
|
||||
}
|
||||
}
|
||||
|
||||
const handleTogglePin = async () => {
|
||||
toast.info('Pin feature coming soon')
|
||||
}
|
||||
|
||||
// Process an AI chat response — updates messages, task lane, branching.
|
||||
// Returns the response for page-specific handling (e.g. triage_update).
|
||||
const processResponse = useCallback((response: ChatMessageResponse, chatId: string) => {
|
||||
if (currentChatRef.current !== chatId) return null
|
||||
setMessages(prev => [
|
||||
...prev,
|
||||
{
|
||||
role: 'assistant',
|
||||
content: response.content,
|
||||
suggestedFlows: response.suggested_flows,
|
||||
fork: response.fork,
|
||||
actions: response.actions,
|
||||
questions: response.questions,
|
||||
},
|
||||
])
|
||||
if (response.fork && chatId) {
|
||||
branching.loadBranches(chatId)
|
||||
}
|
||||
const hasQuestions = response.questions && response.questions.length > 0
|
||||
const hasActions = response.actions && response.actions.length > 0
|
||||
if (hasQuestions || hasActions) {
|
||||
setActiveQuestions(response.questions || [])
|
||||
setActiveActions(response.actions || [])
|
||||
setShowTaskLane(true)
|
||||
}
|
||||
return response
|
||||
}, [branching])
|
||||
|
||||
// Callback for pages to handle triage updates from chat responses.
|
||||
const onTriageUpdateRef = useRef<((update: TriageUpdate) => void) | null>(null)
|
||||
|
||||
const sendMessage = useCallback(async (
|
||||
rawMessage: string,
|
||||
options?: {
|
||||
uploadIds?: string[]
|
||||
clearComposer?: boolean
|
||||
}
|
||||
) => {
|
||||
const message = rawMessage.trim()
|
||||
if (!message || !activeChatId || loadingRef.current) return
|
||||
loadingRef.current = true
|
||||
|
||||
const sendChatId = activeChatId
|
||||
const uploadIds = options?.uploadIds
|
||||
const completedUploadIds = uploadIds ?? pendingUploads
|
||||
.filter((u) => u.status === 'done' && u.result?.id)
|
||||
.map((u) => u.result!.id)
|
||||
if (options?.clearComposer !== false) {
|
||||
setInput('')
|
||||
if (!uploadIds) {
|
||||
setPendingUploads([])
|
||||
}
|
||||
}
|
||||
setMessages(prev => [...prev, { role: 'user', content: message }])
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
const response = await aiSessionsApi.sendChatMessage(sendChatId, {
|
||||
message,
|
||||
upload_ids: completedUploadIds.length > 0 ? completedUploadIds : undefined,
|
||||
})
|
||||
if (currentChatRef.current !== sendChatId) return
|
||||
analytics.aiFeatureUsed({ feature: 'assistant_chat' })
|
||||
processResponse(response, sendChatId)
|
||||
setChats(prev =>
|
||||
prev.map(c =>
|
||||
c.id === sendChatId
|
||||
? { ...c, message_count: c.message_count + 2, title: c.message_count === 0 ? message.slice(0, 100) : c.title, updated_at: new Date().toISOString() }
|
||||
: c
|
||||
)
|
||||
)
|
||||
if (response.triage_update) onTriageUpdateRef.current?.(response.triage_update)
|
||||
} catch {
|
||||
if (currentChatRef.current !== sendChatId) return
|
||||
setMessages(prev => [
|
||||
...prev,
|
||||
{ role: 'assistant', content: 'Sorry, something went wrong. Please try again.' },
|
||||
])
|
||||
} finally {
|
||||
loadingRef.current = false
|
||||
if (currentChatRef.current === sendChatId) {
|
||||
setLoading(false)
|
||||
requestAnimationFrame(() => inputRef.current?.focus())
|
||||
}
|
||||
}
|
||||
}, [activeChatId, pendingUploads, processResponse])
|
||||
|
||||
const handleSend = async () => {
|
||||
await sendMessage(input)
|
||||
}
|
||||
|
||||
// Handle prefill from command palette / dashboard handoff
|
||||
const handlePrefill = useCallback((_prefillRoute: string) => {
|
||||
const state = location.state as { prefill?: string; logs?: string; uploadIds?: string[] } | null
|
||||
const prefill = state?.prefill
|
||||
const logs = state?.logs?.trim()
|
||||
const uploadIds = state?.uploadIds
|
||||
if (!prefill || prefillHandledRef.current) return
|
||||
prefillHandledRef.current = true
|
||||
|
||||
navigate(location.pathname, { replace: true, state: {} })
|
||||
|
||||
const sendPrefill = async () => {
|
||||
if (loadingRef.current) return
|
||||
loadingRef.current = true
|
||||
setShowTaskLane(false)
|
||||
setActiveQuestions([])
|
||||
setActiveActions([])
|
||||
setLoading(true)
|
||||
if (logs) {
|
||||
setShowLogs(true)
|
||||
setLogContent(logs)
|
||||
}
|
||||
|
||||
try {
|
||||
const initialMessage = logs
|
||||
? `${prefill}\n\nAttached logs/output:\n\`\`\`\n${logs}\n\`\`\``
|
||||
: prefill
|
||||
const session = await aiSessionsApi.createChatSession({
|
||||
intake_type: 'free_text',
|
||||
intake_content: { text: initialMessage },
|
||||
})
|
||||
const prefillChatId = session.session_id
|
||||
currentChatRef.current = prefillChatId
|
||||
const chatItem: ChatListItem = {
|
||||
id: prefillChatId,
|
||||
title: session.title,
|
||||
message_count: 0,
|
||||
pinned: false,
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
}
|
||||
setChats(prev => [chatItem, ...prev])
|
||||
setActiveChatId(prefillChatId)
|
||||
setMessages([{ role: 'user', content: initialMessage }])
|
||||
|
||||
const response = await aiSessionsApi.sendChatMessage(prefillChatId, {
|
||||
message: initialMessage,
|
||||
upload_ids: uploadIds?.length ? uploadIds : undefined,
|
||||
})
|
||||
if (currentChatRef.current !== prefillChatId) return
|
||||
processResponse(response, prefillChatId)
|
||||
setChats(prev =>
|
||||
prev.map(c =>
|
||||
c.id === prefillChatId
|
||||
? { ...c, message_count: 2, title: prefill.slice(0, 100), updated_at: new Date().toISOString() }
|
||||
: c
|
||||
)
|
||||
)
|
||||
if (response.triage_update) onTriageUpdateRef.current?.(response.triage_update)
|
||||
} catch {
|
||||
toast.error('Failed to start AI conversation')
|
||||
} finally {
|
||||
loadingRef.current = false
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
sendPrefill()
|
||||
}, [location.state, navigate, processResponse])
|
||||
|
||||
const handleConclude = async (outcome: ConclusionOutcome, _notes: string): Promise<string> => {
|
||||
if (!activeChatId) throw new Error('No active chat')
|
||||
|
||||
if (outcome === 'resolved') {
|
||||
await aiSessionsApi.resolveSession(activeChatId, {
|
||||
resolution_summary: _notes || 'Resolved via assistant chat',
|
||||
})
|
||||
return activeChatId
|
||||
} else if (outcome === 'escalated') {
|
||||
await aiSessionsApi.escalateSession(activeChatId, {
|
||||
escalation_reason: _notes || 'Escalated from assistant chat',
|
||||
})
|
||||
return activeChatId
|
||||
} else {
|
||||
await aiSessionsApi.pauseSession(activeChatId)
|
||||
return activeChatId
|
||||
}
|
||||
}
|
||||
|
||||
const handleResumeNew = async (summary: string) => {
|
||||
if (loadingRef.current) return
|
||||
loadingRef.current = true
|
||||
setLoading(true)
|
||||
try {
|
||||
const resumePrompt = `I'm continuing a previous troubleshooting session. Here's where we left off:\n\n${summary}\n\nPlease review this context and help me continue from where we stopped.`
|
||||
const session = await aiSessionsApi.createChatSession({
|
||||
intake_type: 'free_text',
|
||||
intake_content: { text: resumePrompt },
|
||||
})
|
||||
const chatItem: ChatListItem = {
|
||||
id: session.session_id,
|
||||
title: session.title,
|
||||
message_count: 0,
|
||||
pinned: false,
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
}
|
||||
currentChatRef.current = session.session_id
|
||||
setChats(prev => [chatItem, ...prev])
|
||||
setActiveChatId(session.session_id)
|
||||
setMessages([{ role: 'user', content: resumePrompt }])
|
||||
|
||||
const resumeChatId = session.session_id
|
||||
const response = await aiSessionsApi.sendChatMessage(resumeChatId, { message: resumePrompt })
|
||||
if (currentChatRef.current !== resumeChatId) return
|
||||
processResponse(response, resumeChatId)
|
||||
setChats(prev =>
|
||||
prev.map(c =>
|
||||
c.id === resumeChatId
|
||||
? { ...c, message_count: 2, title: resumePrompt.slice(0, 100), updated_at: new Date().toISOString() }
|
||||
: c
|
||||
)
|
||||
)
|
||||
if (response.triage_update) onTriageUpdateRef.current?.(response.triage_update)
|
||||
} catch {
|
||||
toast.error('Failed to create resume chat')
|
||||
} finally {
|
||||
loadingRef.current = false
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
handleSend()
|
||||
}
|
||||
}
|
||||
|
||||
// ── File handling ──────────────────────────────
|
||||
|
||||
const processFiles = useCallback((files: File[]) => {
|
||||
if (files.length === 0) return
|
||||
const newUploads: PendingUpload[] = files.map((file) => ({
|
||||
id: crypto.randomUUID(),
|
||||
file,
|
||||
preview: file.type.startsWith('image/') ? URL.createObjectURL(file) : '',
|
||||
status: 'uploading' as const,
|
||||
}))
|
||||
setPendingUploads((prev) => [...prev, ...newUploads])
|
||||
newUploads.forEach((upload) => {
|
||||
uploadsApi.upload(upload.file)
|
||||
.then((result) => {
|
||||
setPendingUploads((prev) => prev.map((u) => u.id === upload.id ? { ...u, status: 'done' as const, result } : u))
|
||||
})
|
||||
.catch((err) => {
|
||||
const is503 = err?.response?.status === 503
|
||||
if (is503) {
|
||||
toast.warning('Image attachments are not available yet — describe the issue in text instead')
|
||||
} else {
|
||||
toast.error(`Upload failed: ${err?.message || 'Unknown error'}`)
|
||||
}
|
||||
setPendingUploads((prev) => prev.filter((u) => u.id !== upload.id))
|
||||
})
|
||||
})
|
||||
}, [])
|
||||
|
||||
const handlePaste = useCallback((e: React.ClipboardEvent<HTMLTextAreaElement>) => {
|
||||
const items = e.clipboardData?.items
|
||||
if (!items) return
|
||||
const imageFiles: File[] = []
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
if (items[i].type.startsWith('image/')) {
|
||||
const file = items[i].getAsFile()
|
||||
if (file) imageFiles.push(file)
|
||||
}
|
||||
}
|
||||
if (imageFiles.length > 0) {
|
||||
e.preventDefault()
|
||||
processFiles(imageFiles)
|
||||
}
|
||||
}, [processFiles])
|
||||
|
||||
const handleDragOver = useCallback((e: React.DragEvent) => { e.preventDefault(); e.dataTransfer.dropEffect = 'copy' }, [])
|
||||
const handleDragEnter = useCallback((e: React.DragEvent) => { e.preventDefault(); dragCounterRef.current++; if (dragCounterRef.current === 1) setIsDragOver(true) }, [])
|
||||
const handleDragLeave = useCallback((e: React.DragEvent) => { e.preventDefault(); dragCounterRef.current--; if (dragCounterRef.current === 0) setIsDragOver(false) }, [])
|
||||
const handleDrop = useCallback((e: React.DragEvent) => { e.preventDefault(); dragCounterRef.current = 0; setIsDragOver(false); processFiles(Array.from(e.dataTransfer.files)) }, [processFiles])
|
||||
const handleFileSelect = useCallback((e: React.ChangeEvent<HTMLInputElement>) => { if (e.target.files) { processFiles(Array.from(e.target.files)); e.target.value = '' } }, [processFiles])
|
||||
const handleRemoveUpload = useCallback((uploadId: string) => {
|
||||
setPendingUploads((prev) => { const toRemove = prev.find((u) => u.id === uploadId); if (toRemove?.preview) URL.revokeObjectURL(toRemove.preview); return prev.filter((u) => u.id !== uploadId) })
|
||||
}, [])
|
||||
const retryUpload = useCallback((uploadId: string) => {
|
||||
const upload = pendingUploads.find((u) => u.id === uploadId)
|
||||
if (!upload) return
|
||||
setPendingUploads((prev) => prev.map((u) => u.id === uploadId ? { ...u, status: 'uploading' as const, error: undefined } : u))
|
||||
uploadsApi.upload(upload.file)
|
||||
.then((result) => { setPendingUploads((prev) => prev.map((u) => u.id === uploadId ? { ...u, status: 'done' as const, result } : u)) })
|
||||
.catch((err) => { setPendingUploads((prev) => prev.map((u) => u.id === uploadId ? { ...u, status: 'error' as const, error: err?.message || 'Upload failed' } : u)) })
|
||||
}, [pendingUploads])
|
||||
|
||||
return {
|
||||
// State
|
||||
chats, activeChatId, messages, input, loading,
|
||||
showConclude, showStatusUpdate, branching,
|
||||
mobileSidebarOpen, showLogs, logContent,
|
||||
pendingUploads, isDragOver,
|
||||
activeQuestions, activeActions, showTaskLane,
|
||||
sidebarCollapsed,
|
||||
// Setters
|
||||
setInput, setShowConclude, setShowStatusUpdate,
|
||||
setMobileSidebarOpen, setShowLogs, setLogContent,
|
||||
setShowTaskLane, setActiveQuestions, setActiveActions,
|
||||
// Handlers
|
||||
loadChats, selectChat, handleNewChat, handleDeleteChat, handleTogglePin,
|
||||
handleSend, sendMessage, handleConclude, handleResumeNew,
|
||||
handleKeyDown, handlePaste,
|
||||
handleDragOver, handleDragEnter, handleDragLeave, handleDrop,
|
||||
handleFileSelect, handleRemoveUpload, retryUpload,
|
||||
toggleSidebarCollapse, handlePrefill, processResponse,
|
||||
// Refs
|
||||
messagesEndRef, inputRef, fileInputRef, currentChatRef, loadingRef,
|
||||
// Page-specific callbacks
|
||||
onSessionLoadedRef, onTriageUpdateRef,
|
||||
// Constants
|
||||
ACCEPTED_FILE_TYPES,
|
||||
}
|
||||
}
|
||||
9
frontend/src/hooks/useFeatureFlag.ts
Normal file
9
frontend/src/hooks/useFeatureFlag.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { useAuthStore } from '@/store/authStore'
|
||||
|
||||
/**
|
||||
* Check if a feature flag is enabled for the current user.
|
||||
* Returns false for unknown keys (fail closed).
|
||||
*/
|
||||
export function useFeatureFlag(key: string): boolean {
|
||||
return useAuthStore((s) => s.featureFlags[key] ?? false)
|
||||
}
|
||||
@@ -95,6 +95,11 @@ export function useFlowPilotSession(): UseFlowPilotSession {
|
||||
pending_task_lane: null,
|
||||
is_branching: false,
|
||||
active_branch_id: null,
|
||||
client_name: null,
|
||||
asset_name: null,
|
||||
issue_category: null,
|
||||
triage_hypothesis: null,
|
||||
evidence_items: null,
|
||||
})
|
||||
setAllSteps([firstStep])
|
||||
setCurrentStep(firstStep)
|
||||
|
||||
@@ -10,11 +10,6 @@ export function useSubscription() {
|
||||
|
||||
const isPaidPlan = plan === 'pro' || plan === 'team'
|
||||
|
||||
const canUseFeature = (feature: 'custom_branding' | 'priority_support'): boolean => {
|
||||
if (!limits) return false
|
||||
return limits[feature]
|
||||
}
|
||||
|
||||
const isAtTreeLimit = (): boolean => {
|
||||
if (!limits || !usage) return false
|
||||
if (limits.max_trees === null) return false // unlimited
|
||||
@@ -37,7 +32,6 @@ export function useSubscription() {
|
||||
usage,
|
||||
isActive,
|
||||
isPaidPlan,
|
||||
canUseFeature,
|
||||
isAtTreeLimit,
|
||||
isAtSessionLimit,
|
||||
formatLimit,
|
||||
|
||||
@@ -9,7 +9,7 @@ const PREFETCH_MAP: Record<string, () => Promise<unknown>> = {
|
||||
'/shares': () => import('@/pages/MySharesPage'),
|
||||
'/analytics': () => import('@/pages/TeamAnalyticsPage'),
|
||||
'/analytics/me': () => import('@/pages/MyAnalyticsPage'),
|
||||
'/assistant': () => import('@/pages/AssistantChatPage'),
|
||||
'/assistant': () => import('@/pages/CockpitPage'),
|
||||
'/step-library': () => import('@/pages/StepLibraryPage'),
|
||||
'/guides': () => import('@/pages/GuidesHubPage'),
|
||||
'/feedback': () => import('@/pages/FeedbackPage'),
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,867 +0,0 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||
import { useLocation, useNavigate, useParams } from 'react-router-dom'
|
||||
import { Sparkles, Send, Loader2, Flag, MessageSquare, Paperclip, Terminal, X, RotateCcw, ImagePlus, ListChecks, FileText } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { uploadsApi } from '@/api/uploads'
|
||||
import type { PendingUpload } from '@/types/upload'
|
||||
import type { ForkMetadata, ActionItem, QuestionItem } from '@/types/ai-session'
|
||||
import { PageMeta } from '@/components/common/PageMeta'
|
||||
import { aiSessionsApi } from '@/api/aiSessions'
|
||||
import { useBranching } from '@/hooks/useBranching'
|
||||
import { analytics } from '@/lib/analytics'
|
||||
import { toast } from '@/lib/toast'
|
||||
import { ChatSidebar, ChatSidebarCollapsedBar } from '@/components/assistant/ChatSidebar'
|
||||
import { ChatMessage } from '@/components/assistant/ChatMessage'
|
||||
import { TaskLane } from '@/components/assistant/TaskLane'
|
||||
import { ConcludeSessionModal } from '@/components/assistant/ConcludeSessionModal'
|
||||
import { StatusUpdateModal } from '@/components/flowpilot/StatusUpdateModal'
|
||||
import type { ChatListItem, ConclusionOutcome } from '@/types/assistant-chat'
|
||||
import type { SuggestedFlow } from '@/types/copilot'
|
||||
|
||||
interface MessageWithMeta {
|
||||
role: 'user' | 'assistant'
|
||||
content: string
|
||||
suggestedFlows?: SuggestedFlow[]
|
||||
fork?: ForkMetadata | null
|
||||
actions?: ActionItem[] | null
|
||||
questions?: QuestionItem[] | null
|
||||
}
|
||||
|
||||
export default function AssistantChatPage() {
|
||||
const location = useLocation()
|
||||
const navigate = useNavigate()
|
||||
const { sessionId: urlSessionId } = useParams<{ sessionId?: string }>()
|
||||
const [chats, setChats] = useState<ChatListItem[]>([])
|
||||
const [activeChatId, setActiveChatId] = useState<string | null>(() => {
|
||||
if (urlSessionId) return urlSessionId
|
||||
try { return sessionStorage.getItem('rf-active-chat-id') } catch { return null }
|
||||
})
|
||||
const [messages, setMessages] = useState<MessageWithMeta[]>([])
|
||||
const [input, setInput] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [showConclude, setShowConclude] = useState(false)
|
||||
const [showStatusUpdate, setShowStatusUpdate] = useState(false)
|
||||
const branching = useBranching()
|
||||
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false)
|
||||
const [showLogs, setShowLogs] = useState(false)
|
||||
const [logContent, setLogContent] = useState('')
|
||||
const [pendingUploads, setPendingUploads] = useState<PendingUpload[]>([])
|
||||
const [isDragOver, setIsDragOver] = useState(false)
|
||||
const [activeQuestions, setActiveQuestions] = useState<QuestionItem[]>(() => {
|
||||
try {
|
||||
const saved = sessionStorage.getItem('rf-tasklane-meta')
|
||||
if (saved) { const d = JSON.parse(saved); if (d.chatId === activeChatId) return d.questions || [] }
|
||||
} catch { /* ignore */ }
|
||||
return []
|
||||
})
|
||||
const [activeActions, setActiveActions] = useState<ActionItem[]>(() => {
|
||||
try {
|
||||
const saved = sessionStorage.getItem('rf-tasklane-meta')
|
||||
if (saved) { const d = JSON.parse(saved); if (d.chatId === activeChatId) return d.actions || [] }
|
||||
} catch { /* ignore */ }
|
||||
return []
|
||||
})
|
||||
const [showTaskLane, setShowTaskLane] = useState(() => {
|
||||
try {
|
||||
const saved = sessionStorage.getItem('rf-tasklane-meta')
|
||||
if (saved) { const d = JSON.parse(saved); return d.show === true && d.chatId === activeChatId }
|
||||
} catch { /* ignore */ }
|
||||
return false
|
||||
})
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(() =>
|
||||
localStorage.getItem('rf-chat-sidebar-collapsed') === 'true'
|
||||
)
|
||||
const toggleSidebarCollapse = () => {
|
||||
const next = !sidebarCollapsed
|
||||
setSidebarCollapsed(next)
|
||||
localStorage.setItem('rf-chat-sidebar-collapsed', String(next))
|
||||
}
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null)
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const dragCounterRef = useRef(0)
|
||||
const prefillHandledRef = useRef(false)
|
||||
// Tracks the most recently requested active chat ID so in-flight selectChat
|
||||
// calls that complete after the user switches chats don't clobber new state.
|
||||
const currentChatRef = useRef<string | null>(activeChatId)
|
||||
|
||||
// Persist active chat ID to sessionStorage
|
||||
useEffect(() => {
|
||||
try {
|
||||
if (activeChatId) sessionStorage.setItem('rf-active-chat-id', activeChatId)
|
||||
else sessionStorage.removeItem('rf-active-chat-id')
|
||||
} catch { /* ignore */ }
|
||||
}, [activeChatId])
|
||||
|
||||
// Load chat list from ai_sessions
|
||||
useEffect(() => {
|
||||
loadChats()
|
||||
}, [])
|
||||
|
||||
// If URL has a session ID, load it
|
||||
useEffect(() => {
|
||||
if (urlSessionId && urlSessionId !== activeChatId) {
|
||||
selectChat(urlSessionId)
|
||||
}
|
||||
}, [urlSessionId]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Restore session from sessionStorage on mount (when URL has no session ID)
|
||||
useEffect(() => {
|
||||
if (!urlSessionId && activeChatId) {
|
||||
selectChat(activeChatId)
|
||||
}
|
||||
}, []) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Handle prefill from command palette / dashboard handoff
|
||||
useEffect(() => {
|
||||
const state = location.state as { prefill?: string; uploadIds?: string[] } | null
|
||||
const prefill = state?.prefill
|
||||
const uploadIds = state?.uploadIds
|
||||
if (!prefill || prefillHandledRef.current) return
|
||||
prefillHandledRef.current = true
|
||||
|
||||
navigate(location.pathname, { replace: true, state: {} })
|
||||
|
||||
const sendPrefill = async () => {
|
||||
// Clear stale task lane from previous session
|
||||
setShowTaskLane(false)
|
||||
setActiveQuestions([])
|
||||
setActiveActions([])
|
||||
|
||||
try {
|
||||
const session = await aiSessionsApi.createChatSession({
|
||||
intake_type: 'free_text',
|
||||
intake_content: { text: prefill },
|
||||
})
|
||||
const chatItem: ChatListItem = {
|
||||
id: session.session_id,
|
||||
title: session.title,
|
||||
message_count: 0,
|
||||
pinned: false,
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
}
|
||||
setChats(prev => [chatItem, ...prev])
|
||||
setActiveChatId(session.session_id)
|
||||
setMessages([{ role: 'user', content: prefill }])
|
||||
setLoading(true)
|
||||
|
||||
const response = await aiSessionsApi.sendChatMessage(session.session_id, {
|
||||
message: prefill,
|
||||
upload_ids: uploadIds?.length ? uploadIds : undefined,
|
||||
})
|
||||
setMessages(prev => [
|
||||
...prev,
|
||||
{ role: 'assistant', content: response.content, suggestedFlows: response.suggested_flows, fork: response.fork, actions: response.actions, questions: response.questions },
|
||||
])
|
||||
setChats(prev =>
|
||||
prev.map(c =>
|
||||
c.id === session.session_id
|
||||
? { ...c, message_count: 2, title: prefill.slice(0, 100), updated_at: new Date().toISOString() }
|
||||
: c
|
||||
)
|
||||
)
|
||||
// Show task lane if AI sent questions or actions
|
||||
if (response.fork && session.session_id) {
|
||||
branching.loadBranches(session.session_id)
|
||||
}
|
||||
const hasQuestions = response.questions && response.questions.length > 0
|
||||
const hasActions = response.actions && response.actions.length > 0
|
||||
if (hasQuestions || hasActions) {
|
||||
setActiveQuestions(response.questions || [])
|
||||
setActiveActions(response.actions || [])
|
||||
setShowTaskLane(true)
|
||||
}
|
||||
} catch {
|
||||
toast.error('Failed to start AI conversation')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
sendPrefill()
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
// Persist task lane metadata to sessionStorage
|
||||
useEffect(() => {
|
||||
try {
|
||||
sessionStorage.setItem('rf-tasklane-meta', JSON.stringify({
|
||||
show: showTaskLane,
|
||||
chatId: activeChatId,
|
||||
questions: activeQuestions,
|
||||
actions: activeActions,
|
||||
}))
|
||||
} catch { /* ignore */ }
|
||||
}, [showTaskLane, activeChatId, activeQuestions, activeActions])
|
||||
|
||||
// Auto-scroll
|
||||
useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
|
||||
}, [messages])
|
||||
|
||||
const loadChats = async () => {
|
||||
try {
|
||||
const sessions = await aiSessionsApi.listSessions({ session_type: 'chat', limit: 100 })
|
||||
setChats(sessions.map(s => ({
|
||||
id: s.id,
|
||||
title: s.title || s.problem_summary || 'New Chat',
|
||||
message_count: s.step_count,
|
||||
pinned: false,
|
||||
created_at: s.created_at,
|
||||
updated_at: s.created_at,
|
||||
})))
|
||||
} catch {
|
||||
// silently handle
|
||||
}
|
||||
}
|
||||
|
||||
const selectChat = useCallback(async (chatId: string) => {
|
||||
currentChatRef.current = chatId
|
||||
setActiveChatId(chatId)
|
||||
// Clear TaskLane when switching chats — will restore from backend if available
|
||||
setShowTaskLane(false)
|
||||
setActiveQuestions([])
|
||||
setActiveActions([])
|
||||
try {
|
||||
const detail = await aiSessionsApi.getSession(chatId)
|
||||
// Guard: if the user switched to a different chat while this API call was
|
||||
// in flight (e.g. clicked "New Chat"), discard stale results so we don't
|
||||
// clobber the new session's task lane state.
|
||||
if (currentChatRef.current !== chatId) return
|
||||
setMessages(
|
||||
(detail.conversation_messages || []).map(m => ({
|
||||
role: m.role as 'user' | 'assistant',
|
||||
content: m.content,
|
||||
}))
|
||||
)
|
||||
// Restore task lane from persisted state
|
||||
if (detail.pending_task_lane) {
|
||||
const q = detail.pending_task_lane.questions || []
|
||||
const a = detail.pending_task_lane.actions || []
|
||||
if (q.length > 0 || a.length > 0) {
|
||||
// Pre-load user's saved responses into sessionStorage BEFORE setting props
|
||||
// so TaskLane can restore them on mount/prop-change
|
||||
const responses = (detail.pending_task_lane as Record<string, unknown>).responses as unknown[] | undefined
|
||||
if (responses && responses.length > 0) {
|
||||
try {
|
||||
sessionStorage.setItem(`rf-tasklane-state:${chatId}`, JSON.stringify(responses))
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
setActiveQuestions(q)
|
||||
setActiveActions(a)
|
||||
setShowTaskLane(true)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
setMessages([])
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleNewChat = async () => {
|
||||
try {
|
||||
const session = await aiSessionsApi.createChatSession({
|
||||
intake_type: 'free_text',
|
||||
intake_content: { text: '' },
|
||||
})
|
||||
const chatItem: ChatListItem = {
|
||||
id: session.session_id,
|
||||
title: session.title,
|
||||
message_count: 0,
|
||||
pinned: false,
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
}
|
||||
currentChatRef.current = session.session_id
|
||||
setChats(prev => [chatItem, ...prev])
|
||||
setActiveChatId(session.session_id)
|
||||
setMessages([])
|
||||
// Clear TaskLane from previous session
|
||||
setShowTaskLane(false)
|
||||
setActiveQuestions([])
|
||||
setActiveActions([])
|
||||
} catch {
|
||||
toast.error('Failed to create chat')
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteChat = async (chatId: string) => {
|
||||
try {
|
||||
await aiSessionsApi.deleteSession(chatId)
|
||||
setChats(prev => prev.filter(c => c.id !== chatId))
|
||||
if (activeChatId === chatId) {
|
||||
setActiveChatId(null)
|
||||
setMessages([])
|
||||
}
|
||||
} catch {
|
||||
toast.error('Failed to delete chat')
|
||||
}
|
||||
}
|
||||
|
||||
const handleTogglePin = async () => {
|
||||
// Pin/unpin not yet supported on unified sessions — no-op for now
|
||||
toast.info('Pin feature coming soon')
|
||||
}
|
||||
|
||||
const handleSend = async () => {
|
||||
if (!input.trim() || !activeChatId || loading) return
|
||||
|
||||
const userMessage = input.trim()
|
||||
const completedUploadIds = pendingUploads
|
||||
.filter((u) => u.status === 'done' && u.result?.id)
|
||||
.map((u) => u.result!.id)
|
||||
setInput('')
|
||||
setPendingUploads([])
|
||||
setMessages(prev => [...prev, { role: 'user', content: userMessage }])
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
const response = await aiSessionsApi.sendChatMessage(activeChatId, {
|
||||
message: userMessage,
|
||||
upload_ids: completedUploadIds.length > 0 ? completedUploadIds : undefined,
|
||||
})
|
||||
analytics.aiFeatureUsed({ feature: 'assistant_chat' })
|
||||
setMessages(prev => [
|
||||
...prev,
|
||||
{ role: 'assistant', content: response.content, suggestedFlows: response.suggested_flows, fork: response.fork, actions: response.actions, questions: response.questions },
|
||||
])
|
||||
setChats(prev =>
|
||||
prev.map(c =>
|
||||
c.id === activeChatId
|
||||
? { ...c, message_count: c.message_count + 2, title: c.message_count === 0 ? userMessage.slice(0, 100) : c.title, updated_at: new Date().toISOString() }
|
||||
: c
|
||||
)
|
||||
)
|
||||
// Load branches if fork was created
|
||||
if (response.fork && activeChatId) {
|
||||
branching.loadBranches(activeChatId)
|
||||
}
|
||||
// Show task lane if AI sent questions or actions
|
||||
const hasQuestions = response.questions && response.questions.length > 0
|
||||
const hasActions = response.actions && response.actions.length > 0
|
||||
if (hasQuestions || hasActions) {
|
||||
setActiveQuestions(response.questions || [])
|
||||
setActiveActions(response.actions || [])
|
||||
setShowTaskLane(true)
|
||||
}
|
||||
} catch {
|
||||
setMessages(prev => [
|
||||
...prev,
|
||||
{ role: 'assistant', content: 'Sorry, something went wrong. Please try again.' },
|
||||
])
|
||||
} finally {
|
||||
setLoading(false)
|
||||
requestAnimationFrame(() => inputRef.current?.focus())
|
||||
}
|
||||
}
|
||||
|
||||
const handleTaskSubmit = async (responses: Array<{ type: string; state: string; value: string; text?: string; label?: string }>) => {
|
||||
if (!activeChatId || loading) return
|
||||
|
||||
// Format task responses into a structured message for the AI
|
||||
const parts: string[] = []
|
||||
for (const r of responses) {
|
||||
const name = r.type === 'question' ? `Q: ${r.text}` : r.label || 'Check'
|
||||
if (r.state === 'done' && r.value.trim()) {
|
||||
parts.push(`**${name}:**\n\`\`\`\n${r.value.trim()}\n\`\`\``)
|
||||
} else if (r.state === 'skipped') {
|
||||
parts.push(`**${name}:** _(skipped)_`)
|
||||
}
|
||||
}
|
||||
const userMessage = parts.join('\n\n')
|
||||
|
||||
setMessages(prev => [...prev, { role: 'user', content: userMessage }])
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
const response = await aiSessionsApi.sendChatMessage(activeChatId, { message: userMessage })
|
||||
setMessages(prev => [
|
||||
...prev,
|
||||
{ role: 'assistant', content: response.content, suggestedFlows: response.suggested_flows, fork: response.fork, actions: response.actions, questions: response.questions },
|
||||
])
|
||||
if (response.fork && activeChatId) {
|
||||
branching.loadBranches(activeChatId)
|
||||
}
|
||||
// Update task lane based on AI response
|
||||
const hasQuestions = response.questions && response.questions.length > 0
|
||||
const hasActions = response.actions && response.actions.length > 0
|
||||
if (hasQuestions || hasActions) {
|
||||
setActiveQuestions(response.questions || [])
|
||||
setActiveActions(response.actions || [])
|
||||
setShowTaskLane(true)
|
||||
} else {
|
||||
// AI sent no new tasks — clear the lane
|
||||
setShowTaskLane(false)
|
||||
setActiveQuestions([])
|
||||
setActiveActions([])
|
||||
}
|
||||
} catch {
|
||||
setMessages(prev => [
|
||||
...prev,
|
||||
{ role: 'assistant', content: 'Sorry, something went wrong processing your responses. Please try again.' },
|
||||
])
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleConclude = async (outcome: ConclusionOutcome, _notes: string): Promise<string> => {
|
||||
if (!activeChatId) throw new Error('No active chat')
|
||||
|
||||
if (outcome === 'resolved') {
|
||||
await aiSessionsApi.resolveSession(activeChatId, {
|
||||
resolution_summary: _notes || 'Resolved via assistant chat',
|
||||
})
|
||||
return activeChatId
|
||||
} else if (outcome === 'escalated') {
|
||||
await aiSessionsApi.escalateSession(activeChatId, {
|
||||
escalation_reason: _notes || 'Escalated from assistant chat',
|
||||
})
|
||||
return activeChatId
|
||||
} else {
|
||||
await aiSessionsApi.pauseSession(activeChatId)
|
||||
return activeChatId
|
||||
}
|
||||
}
|
||||
|
||||
const handleResumeNew = async (summary: string) => {
|
||||
try {
|
||||
const resumePrompt = `I'm continuing a previous troubleshooting session. Here's where we left off:\n\n${summary}\n\nPlease review this context and help me continue from where we stopped.`
|
||||
const session = await aiSessionsApi.createChatSession({
|
||||
intake_type: 'free_text',
|
||||
intake_content: { text: resumePrompt },
|
||||
})
|
||||
const chatItem: ChatListItem = {
|
||||
id: session.session_id,
|
||||
title: session.title,
|
||||
message_count: 0,
|
||||
pinned: false,
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
}
|
||||
currentChatRef.current = session.session_id
|
||||
setChats(prev => [chatItem, ...prev])
|
||||
setActiveChatId(session.session_id)
|
||||
setMessages([{ role: 'user', content: resumePrompt }])
|
||||
setLoading(true)
|
||||
|
||||
const response = await aiSessionsApi.sendChatMessage(session.session_id, { message: resumePrompt })
|
||||
setMessages(prev => [
|
||||
...prev,
|
||||
{ role: 'assistant', content: response.content, suggestedFlows: response.suggested_flows, fork: response.fork, actions: response.actions, questions: response.questions },
|
||||
])
|
||||
setChats(prev =>
|
||||
prev.map(c =>
|
||||
c.id === session.session_id
|
||||
? { ...c, message_count: 2, title: resumePrompt.slice(0, 100), updated_at: new Date().toISOString() }
|
||||
: c
|
||||
)
|
||||
)
|
||||
// Show task lane if AI sent questions or actions
|
||||
if (response.fork && session.session_id) {
|
||||
branching.loadBranches(session.session_id)
|
||||
}
|
||||
const hasQuestions = response.questions && response.questions.length > 0
|
||||
const hasActions = response.actions && response.actions.length > 0
|
||||
if (hasQuestions || hasActions) {
|
||||
setActiveQuestions(response.questions || [])
|
||||
setActiveActions(response.actions || [])
|
||||
setShowTaskLane(true)
|
||||
}
|
||||
} catch {
|
||||
toast.error('Failed to create resume chat')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
handleSend()
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-grow textarea
|
||||
useEffect(() => {
|
||||
const el = inputRef.current
|
||||
if (!el) return
|
||||
el.style.height = 'auto'
|
||||
el.style.height = `${Math.min(el.scrollHeight, 150)}px`
|
||||
}, [input])
|
||||
|
||||
// ── File handling ──────────────────────────────
|
||||
const ACCEPTED_FILE_TYPES = 'image/png,image/jpeg,image/gif,image/webp,.txt,.log,.csv,.pdf,.docx'
|
||||
|
||||
const processFiles = useCallback((files: File[]) => {
|
||||
if (files.length === 0) return
|
||||
const newUploads: PendingUpload[] = files.map((file) => ({
|
||||
id: crypto.randomUUID(),
|
||||
file,
|
||||
preview: file.type.startsWith('image/') ? URL.createObjectURL(file) : '',
|
||||
status: 'uploading' as const,
|
||||
}))
|
||||
setPendingUploads((prev) => [...prev, ...newUploads])
|
||||
newUploads.forEach((upload) => {
|
||||
uploadsApi.upload(upload.file)
|
||||
.then((result) => {
|
||||
setPendingUploads((prev) => prev.map((u) => u.id === upload.id ? { ...u, status: 'done' as const, result } : u))
|
||||
})
|
||||
.catch((err) => {
|
||||
const is503 = err?.response?.status === 503
|
||||
if (is503) {
|
||||
toast.warning('Image attachments are not available yet — describe the issue in text instead')
|
||||
} else {
|
||||
toast.error(`Upload failed: ${err?.message || 'Unknown error'}`)
|
||||
}
|
||||
setPendingUploads((prev) => prev.filter((u) => u.id !== upload.id))
|
||||
})
|
||||
})
|
||||
}, [])
|
||||
|
||||
const handlePaste = useCallback((e: React.ClipboardEvent<HTMLTextAreaElement>) => {
|
||||
const items = e.clipboardData?.items
|
||||
if (!items) return
|
||||
const imageFiles: File[] = []
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
if (items[i].type.startsWith('image/')) {
|
||||
const file = items[i].getAsFile()
|
||||
if (file) imageFiles.push(file)
|
||||
}
|
||||
}
|
||||
if (imageFiles.length > 0) {
|
||||
e.preventDefault()
|
||||
processFiles(imageFiles)
|
||||
}
|
||||
}, [processFiles])
|
||||
|
||||
const handleDragOver = useCallback((e: React.DragEvent) => { e.preventDefault(); e.dataTransfer.dropEffect = 'copy' }, [])
|
||||
const handleDragEnter = useCallback((e: React.DragEvent) => { e.preventDefault(); dragCounterRef.current++; if (dragCounterRef.current === 1) setIsDragOver(true) }, [])
|
||||
const handleDragLeave = useCallback((e: React.DragEvent) => { e.preventDefault(); dragCounterRef.current--; if (dragCounterRef.current === 0) setIsDragOver(false) }, [])
|
||||
const handleDrop = useCallback((e: React.DragEvent) => { e.preventDefault(); dragCounterRef.current = 0; setIsDragOver(false); processFiles(Array.from(e.dataTransfer.files)) }, [processFiles])
|
||||
const handleFileSelect = useCallback((e: React.ChangeEvent<HTMLInputElement>) => { if (e.target.files) { processFiles(Array.from(e.target.files)); e.target.value = '' } }, [processFiles])
|
||||
const handleRemoveUpload = useCallback((uploadId: string) => {
|
||||
setPendingUploads((prev) => { const toRemove = prev.find((u) => u.id === uploadId); if (toRemove?.preview) URL.revokeObjectURL(toRemove.preview); return prev.filter((u) => u.id !== uploadId) })
|
||||
}, [])
|
||||
const retryUpload = useCallback((uploadId: string) => {
|
||||
const upload = pendingUploads.find((u) => u.id === uploadId)
|
||||
if (!upload) return
|
||||
setPendingUploads((prev) => prev.map((u) => u.id === uploadId ? { ...u, status: 'uploading' as const, error: undefined } : u))
|
||||
uploadsApi.upload(upload.file)
|
||||
.then((result) => { setPendingUploads((prev) => prev.map((u) => u.id === uploadId ? { ...u, status: 'done' as const, result } : u)) })
|
||||
.catch((err) => { setPendingUploads((prev) => prev.map((u) => u.id === uploadId ? { ...u, status: 'error' as const, error: err?.message || 'Upload failed' } : u)) })
|
||||
}, [pendingUploads])
|
||||
|
||||
// Cleanup blob URLs on unmount
|
||||
useEffect(() => { return () => { pendingUploads.forEach((u) => { if (u.preview) URL.revokeObjectURL(u.preview) }) } }, []) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageMeta title="AI Assistant" />
|
||||
<div className="flex h-[calc(100vh-3.5rem)]">
|
||||
{/* Sidebar — hidden on mobile, collapsed to top bar or full sidebar on desktop */}
|
||||
{!sidebarCollapsed && (
|
||||
<div className="hidden sm:block">
|
||||
<ChatSidebar
|
||||
chats={chats}
|
||||
activeChatId={activeChatId}
|
||||
onSelectChat={selectChat}
|
||||
onNewChat={handleNewChat}
|
||||
onDeleteChat={handleDeleteChat}
|
||||
onTogglePin={handleTogglePin}
|
||||
onToggleCollapse={toggleSidebarCollapse}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="sm:hidden">
|
||||
<ChatSidebar
|
||||
chats={chats}
|
||||
activeChatId={activeChatId}
|
||||
onSelectChat={selectChat}
|
||||
onNewChat={handleNewChat}
|
||||
onDeleteChat={handleDeleteChat}
|
||||
onTogglePin={handleTogglePin}
|
||||
mobileOpen={mobileSidebarOpen}
|
||||
onMobileClose={() => setMobileSidebarOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Main chat area + optional branch sidebar */}
|
||||
<div className="flex-1 flex flex-col min-w-0">
|
||||
|
||||
{/* Collapsed sidebar top bar — desktop only */}
|
||||
{sidebarCollapsed && (
|
||||
<div className="hidden sm:block">
|
||||
<ChatSidebarCollapsedBar
|
||||
chats={chats}
|
||||
activeChatId={activeChatId}
|
||||
onNewChat={handleNewChat}
|
||||
onExpand={toggleSidebarCollapse}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Chat content row: chat column + TaskLane side by side */}
|
||||
<div className="flex-1 flex min-w-0 min-h-0">
|
||||
<div className="flex-1 flex flex-col min-w-0">
|
||||
{/* Mobile header with chat history toggle */}
|
||||
<div className="sm:hidden flex items-center gap-2 px-3 py-2 border-b border-border shrink-0">
|
||||
<button
|
||||
onClick={() => setMobileSidebarOpen(true)}
|
||||
className="flex items-center gap-2 rounded-lg px-3 py-2 text-sm text-muted-foreground hover:text-foreground hover:bg-[var(--color-bg-elevated)] transition-colors"
|
||||
>
|
||||
<MessageSquare size={16} />
|
||||
Chats
|
||||
</button>
|
||||
<div className="flex-1" />
|
||||
<button
|
||||
onClick={handleNewChat}
|
||||
className="rounded-lg px-3 py-2 text-sm font-medium text-primary hover:bg-primary/10 transition-colors"
|
||||
>
|
||||
+ New
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{activeChatId ? (
|
||||
<>
|
||||
{/* Messages */}
|
||||
<div className="flex-1 overflow-y-auto px-4 sm:px-6 py-4 space-y-4">
|
||||
{messages.length === 0 && !loading && (
|
||||
<div className="flex flex-col items-center justify-center h-full text-center">
|
||||
<div className="w-16 h-16 rounded-full bg-accent-dim flex items-center justify-center mb-4">
|
||||
<Sparkles size={28} className="text-primary" />
|
||||
</div>
|
||||
<h2 className="text-lg font-heading font-semibold text-foreground mb-2">
|
||||
AI Assistant
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground max-w-md">
|
||||
Ask me anything about IT infrastructure, networking, Active Directory,
|
||||
cloud platforms, or troubleshooting. I'll also suggest relevant flows from your team's library.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{messages.map((msg, i) => (
|
||||
<ChatMessage
|
||||
key={i}
|
||||
role={msg.role}
|
||||
content={msg.content}
|
||||
suggestedFlows={msg.suggestedFlows}
|
||||
/>
|
||||
))}
|
||||
{loading && (
|
||||
<div className="flex gap-3">
|
||||
<div className="w-8 h-8 rounded-full bg-primary/15 flex items-center justify-center">
|
||||
<Sparkles size={14} className="text-primary" />
|
||||
</div>
|
||||
<div className="bg-input border border-border rounded-2xl px-4 py-3">
|
||||
<Loader2 size={16} className="animate-spin text-primary" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
|
||||
{/* Rich Input */}
|
||||
<div className="px-3 sm:px-6 py-3 shrink-0">
|
||||
<div
|
||||
className="max-w-3xl mx-auto"
|
||||
onDragOver={handleDragOver}
|
||||
onDragEnter={handleDragEnter}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
<div className={cn(
|
||||
'relative rounded-xl border transition-all',
|
||||
loading ? 'border-border/50 opacity-50' :
|
||||
isDragOver ? 'border-primary/50 bg-primary/5' :
|
||||
'border-border focus-within:border-[rgba(96,165,250,0.3)] focus-within:ring-1 focus-within:ring-primary/20'
|
||||
)} style={{ background: 'var(--color-bg-card)' }}>
|
||||
{/* Drag overlay */}
|
||||
{isDragOver && (
|
||||
<div className="absolute inset-0 z-10 flex items-center justify-center rounded-xl border-2 border-dashed border-primary/50 bg-primary/5 pointer-events-none">
|
||||
<div className="flex items-center gap-2 text-sm text-primary">
|
||||
<ImagePlus size={18} />
|
||||
Drop files to attach
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Textarea */}
|
||||
<textarea
|
||||
ref={inputRef}
|
||||
value={input}
|
||||
onChange={e => setInput(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onPaste={handlePaste}
|
||||
placeholder={loading ? 'AI is thinking...' : 'Type a message, paste a screenshot, or drag a file...'}
|
||||
disabled={loading}
|
||||
rows={1}
|
||||
className="w-full resize-none bg-transparent px-4 pt-3 pb-1 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none disabled:cursor-not-allowed"
|
||||
style={{ minHeight: '40px', maxHeight: '150px' }}
|
||||
/>
|
||||
|
||||
{/* Thumbnail strip */}
|
||||
{pendingUploads.length > 0 && (
|
||||
<div className="flex gap-2 flex-wrap px-4 pb-1">
|
||||
{pendingUploads.map((upload) => (
|
||||
<div key={upload.id} className="relative w-12 h-12 rounded-lg overflow-hidden border border-border bg-background">
|
||||
{upload.preview ? (
|
||||
<img src={upload.preview} alt="" className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center text-[0.5rem] text-muted-foreground px-1 text-center">
|
||||
{upload.file.name.split('.').pop()?.toUpperCase()}
|
||||
</div>
|
||||
)}
|
||||
{upload.status === 'uploading' && (
|
||||
<div className="absolute inset-0 bg-background/60 flex items-center justify-center">
|
||||
<Loader2 size={12} className="animate-spin text-primary" />
|
||||
</div>
|
||||
)}
|
||||
{upload.status === 'done' && (
|
||||
<button type="button" onClick={() => handleRemoveUpload(upload.id)} className="absolute -top-1 -right-1 w-4 h-4 rounded-full bg-background border border-border flex items-center justify-center hover:bg-rose-500/20 transition-colors">
|
||||
<X size={8} className="text-muted-foreground" />
|
||||
</button>
|
||||
)}
|
||||
{upload.status === 'error' && (
|
||||
<div className="absolute inset-0 bg-rose-500/20 border-2 border-rose-500 flex items-center justify-center cursor-pointer" onClick={() => retryUpload(upload.id)}>
|
||||
<RotateCcw size={10} className="text-rose-500" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Logs textarea */}
|
||||
{showLogs && (
|
||||
<div className="px-4 pb-1">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="text-[0.625rem] uppercase tracking-wide text-muted-foreground font-sans">Paste logs or error output</span>
|
||||
<button type="button" onClick={() => { setShowLogs(false); setLogContent('') }} className="text-muted-foreground hover:text-foreground"><X size={14} /></button>
|
||||
</div>
|
||||
<textarea
|
||||
value={logContent}
|
||||
onChange={(e) => setLogContent(e.target.value)}
|
||||
placeholder="Paste event viewer logs, error messages, PowerShell output..."
|
||||
rows={3}
|
||||
className="w-full resize-none rounded-lg border border-border bg-background p-2 font-mono text-xs text-foreground placeholder:text-muted-foreground focus:border-[rgba(96,165,250,0.3)] focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Bottom toolbar */}
|
||||
<div className="flex items-center justify-between px-3 py-1.5 border-t border-border/50">
|
||||
<div className="flex items-center gap-0.5">
|
||||
<input ref={fileInputRef} type="file" multiple accept={ACCEPTED_FILE_TYPES} onChange={handleFileSelect} className="hidden" />
|
||||
<button type="button" onClick={() => fileInputRef.current?.click()} disabled={loading} className="flex items-center gap-1.5 rounded-lg px-2 py-1.5 text-xs text-muted-foreground hover:text-foreground hover:bg-secondary transition-colors disabled:opacity-40" title="Attach files">
|
||||
<Paperclip size={14} />
|
||||
<span className="hidden sm:inline">Attach</span>
|
||||
</button>
|
||||
{!showLogs && (
|
||||
<button type="button" onClick={() => setShowLogs(true)} disabled={loading} className="flex items-center gap-1.5 rounded-lg px-2 py-1.5 text-xs text-muted-foreground hover:text-foreground hover:bg-secondary transition-colors disabled:opacity-40" title="Paste logs">
|
||||
<Terminal size={14} />
|
||||
<span className="hidden sm:inline">Paste Logs</span>
|
||||
</button>
|
||||
)}
|
||||
{messages.length >= 2 && (
|
||||
<>
|
||||
<button type="button" onClick={() => setShowStatusUpdate(true)} disabled={loading} className="flex items-center gap-1.5 rounded-lg px-2 py-1.5 text-xs text-muted-foreground hover:text-blue-400 hover:bg-blue-500/10 transition-colors disabled:opacity-40" title="Share status update">
|
||||
<FileText size={14} />
|
||||
<span className="hidden sm:inline">Update</span>
|
||||
</button>
|
||||
<button type="button" onClick={() => setShowConclude(true)} disabled={loading} className="flex items-center gap-1.5 rounded-lg px-2 py-1.5 text-xs text-muted-foreground hover:text-amber-400 hover:bg-amber-400/10 transition-colors disabled:opacity-40" title="Conclude session">
|
||||
<Flag size={14} />
|
||||
<span className="hidden sm:inline">Conclude</span>
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{!showTaskLane && (activeQuestions.length > 0 || activeActions.length > 0) && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowTaskLane(true)}
|
||||
className="flex items-center gap-1.5 rounded-lg px-2 py-1.5 text-xs text-accent-text hover:text-foreground hover:bg-accent-dim transition-colors"
|
||||
title="Show task panel"
|
||||
>
|
||||
<ListChecks size={14} />
|
||||
Tasks ({activeQuestions.length + activeActions.length})
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<button type="button" onClick={handleSend} disabled={!input.trim() || loading} className={cn(
|
||||
'flex h-8 w-8 items-center justify-center rounded-lg transition-all',
|
||||
input.trim() && !loading ? 'bg-primary text-white hover:brightness-110 active:scale-95' : 'bg-secondary text-muted-foreground cursor-not-allowed'
|
||||
)} title="Send message">
|
||||
<Send size={15} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center h-full text-center">
|
||||
<div className="w-20 h-20 rounded-full bg-accent-dim flex items-center justify-center mb-4">
|
||||
<Sparkles size={32} className="text-primary" />
|
||||
</div>
|
||||
<h2 className="text-xl font-heading font-semibold text-foreground mb-2">
|
||||
AI Assistant
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground max-w-md mb-6">
|
||||
Your Senior Systems & Network Engineer. Ask anything about IT infrastructure,
|
||||
or start a new chat to get personalized help with your team's flows.
|
||||
</p>
|
||||
<button
|
||||
onClick={handleNewChat}
|
||||
className="bg-primary text-white font-semibold text-sm rounded-lg px-6 py-2.5 hover:brightness-110 active:scale-[0.98] transition-all"
|
||||
>
|
||||
Start a Conversation
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Task lane — slides in when AI sends questions or actions */}
|
||||
{showTaskLane && (activeQuestions.length > 0 || activeActions.length > 0) && (
|
||||
<TaskLane
|
||||
questions={activeQuestions}
|
||||
actions={activeActions}
|
||||
sessionId={activeChatId}
|
||||
onSubmit={handleTaskSubmit}
|
||||
onClose={() => {
|
||||
setShowTaskLane(false)
|
||||
}}
|
||||
loading={loading}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Branch map hidden — branching is now silent/background only.
|
||||
Branches are tracked in the DB but not shown to the user.
|
||||
The AI manages branch context internally. */}
|
||||
</div>{/* close chat content row */}
|
||||
</div>{/* close outer flex-col */}
|
||||
|
||||
{/* Conclude Session Modal */}
|
||||
<ConcludeSessionModal
|
||||
isOpen={showConclude}
|
||||
onClose={() => setShowConclude(false)}
|
||||
onConclude={handleConclude}
|
||||
onResumeNew={handleResumeNew}
|
||||
chatTitle={chats.find(c => c.id === activeChatId)?.title ?? 'Chat'}
|
||||
sessionId={activeChatId}
|
||||
/>
|
||||
|
||||
{/* Status Update Modal */}
|
||||
{activeChatId && (
|
||||
<StatusUpdateModal
|
||||
open={showStatusUpdate}
|
||||
onClose={() => setShowStatusUpdate(false)}
|
||||
onGenerate={(audience, length, context) =>
|
||||
aiSessionsApi.generateStatusUpdate(activeChatId, { audience, length, context })
|
||||
}
|
||||
context="status"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
591
frontend/src/pages/CockpitPage.tsx
Normal file
591
frontend/src/pages/CockpitPage.tsx
Normal file
@@ -0,0 +1,591 @@
|
||||
import { useState, useRef, useCallback, useEffect } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Sparkles, Send, Loader2, MessageSquare, Paperclip, Terminal, X, RotateCcw, ImagePlus, ListChecks, GripHorizontal } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { PageMeta } from '@/components/common/PageMeta'
|
||||
import { aiSessionsApi } from '@/api/aiSessions'
|
||||
import { ChatSidebar, ChatSidebarCollapsedBar } from '@/components/assistant/ChatSidebar'
|
||||
import { ConcludeSessionModal } from '@/components/assistant/ConcludeSessionModal'
|
||||
import { StatusUpdateModal } from '@/components/flowpilot/StatusUpdateModal'
|
||||
import { IncidentHeader } from '@/components/assistant/IncidentHeader'
|
||||
import { StepsPanel } from '@/components/assistant/StepsPanel'
|
||||
import { FlowPilotAsks } from '@/components/assistant/FlowPilotAsks'
|
||||
import { WhatWeKnow } from '@/components/assistant/WhatWeKnow'
|
||||
import { ViewToggle } from '@/components/assistant/ViewToggle'
|
||||
import { useAssistantSession } from '@/hooks/useAssistantSession'
|
||||
import { useFeatureFlag } from '@/hooks/useFeatureFlag'
|
||||
import type { TriageMeta, EvidenceItem, TriageUpdate } from '@/types/ai-session'
|
||||
|
||||
export default function CockpitPage() {
|
||||
const navigate = useNavigate()
|
||||
const hasCockpit = useFeatureFlag('flowpilot_cockpit')
|
||||
const session = useAssistantSession()
|
||||
|
||||
// ── Cockpit-specific state ──
|
||||
const [triageMeta, setTriageMeta] = useState<TriageMeta>({
|
||||
client_name: null, asset_name: null, issue_category: null,
|
||||
triage_hypothesis: null, evidence_items: [],
|
||||
})
|
||||
const [psaTicketId, setPsaTicketId] = useState<string | null>(null)
|
||||
const [workZonePct, setWorkZonePct] = useState(() => {
|
||||
const saved = localStorage.getItem('rf-assistant-work-zone-height')
|
||||
return saved ? parseFloat(saved) : 55
|
||||
})
|
||||
const [activeStepIndex, setActiveStepIndex] = useState(0)
|
||||
const [completedSteps, setCompletedSteps] = useState<Set<number>>(new Set())
|
||||
const [showOnboarding, setShowOnboarding] = useState(() =>
|
||||
!localStorage.getItem('rf-cockpit-onboarded')
|
||||
)
|
||||
const splitContainerRef = useRef<HTMLDivElement>(null)
|
||||
const prevMessageCountRef = useRef(session.messages.length)
|
||||
|
||||
const dismissOnboarding = () => {
|
||||
setShowOnboarding(false)
|
||||
localStorage.setItem('rf-cockpit-onboarded', '1')
|
||||
}
|
||||
|
||||
// ── Feature flag redirect ──
|
||||
useEffect(() => {
|
||||
if (!hasCockpit && session.activeChatId) {
|
||||
navigate(`/assistant/${session.activeChatId}`, { replace: true })
|
||||
} else if (!hasCockpit) {
|
||||
navigate('/assistant', { replace: true })
|
||||
}
|
||||
}, [hasCockpit, session.activeChatId, navigate])
|
||||
|
||||
// ── Wire triage data from session loads ──
|
||||
useEffect(() => {
|
||||
session.onSessionLoadedRef.current = (detail) => {
|
||||
setTriageMeta({
|
||||
client_name: detail.client_name ?? null,
|
||||
asset_name: detail.asset_name ?? null,
|
||||
issue_category: detail.issue_category ?? null,
|
||||
triage_hypothesis: detail.triage_hypothesis ?? null,
|
||||
evidence_items: (detail.evidence_items as EvidenceItem[]) ?? [],
|
||||
})
|
||||
setPsaTicketId(detail.psa_ticket_id ?? null)
|
||||
}
|
||||
return () => { session.onSessionLoadedRef.current = null }
|
||||
}, [session.onSessionLoadedRef])
|
||||
|
||||
// ── Wire triage updates from AI responses ──
|
||||
useEffect(() => {
|
||||
session.onTriageUpdateRef.current = mergeTriageUpdate
|
||||
return () => { session.onTriageUpdateRef.current = null }
|
||||
}, [session.onTriageUpdateRef]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// ── Handle prefill from command palette / dashboard handoff ──
|
||||
useEffect(() => {
|
||||
session.handlePrefill('/cockpit')
|
||||
}, []) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// ── Dismiss onboarding when first message is sent ──
|
||||
useEffect(() => {
|
||||
if (showOnboarding && session.messages.length > prevMessageCountRef.current) {
|
||||
dismissOnboarding()
|
||||
}
|
||||
prevMessageCountRef.current = session.messages.length
|
||||
}, [session.messages.length, showOnboarding])
|
||||
|
||||
// Reset all cockpit-local state when switching cases.
|
||||
// Triage data gets repopulated via onSessionLoadedRef after the async fetch.
|
||||
useEffect(() => {
|
||||
setActiveStepIndex(0)
|
||||
setCompletedSteps(new Set())
|
||||
setTriageMeta({
|
||||
client_name: null, asset_name: null, issue_category: null,
|
||||
triage_hypothesis: null, evidence_items: [],
|
||||
})
|
||||
setPsaTicketId(null)
|
||||
}, [session.activeChatId])
|
||||
|
||||
// Reset step UI when a new action set arrives from AI.
|
||||
useEffect(() => {
|
||||
setActiveStepIndex(0)
|
||||
setCompletedSteps(new Set())
|
||||
}, [session.activeActions])
|
||||
|
||||
// ── Triage handlers ──
|
||||
|
||||
const handleTriageFieldSave = useCallback(async (field: keyof TriageMeta, value: string) => {
|
||||
if (!session.activeChatId) return
|
||||
try {
|
||||
await aiSessionsApi.updateTriage(session.activeChatId, { [field]: value })
|
||||
setTriageMeta(prev => ({ ...prev, [field]: value }))
|
||||
} catch {
|
||||
// best-effort — toast handled by caller if needed
|
||||
}
|
||||
}, [session.activeChatId])
|
||||
|
||||
const handleEvidenceAdd = useCallback(async (text: string, status: EvidenceItem['status']) => {
|
||||
const newItem: EvidenceItem = { text, status }
|
||||
let updated: EvidenceItem[] = []
|
||||
setTriageMeta(prev => {
|
||||
updated = [...prev.evidence_items, newItem]
|
||||
return { ...prev, evidence_items: updated }
|
||||
})
|
||||
if (session.activeChatId) {
|
||||
try { await aiSessionsApi.updateTriage(session.activeChatId, { evidence_items: updated }) } catch { /* best-effort */ }
|
||||
}
|
||||
}, [session.activeChatId])
|
||||
|
||||
const handleEvidenceEdit = useCallback(async (index: number, text: string, status: EvidenceItem['status']) => {
|
||||
let updated: EvidenceItem[] = []
|
||||
setTriageMeta(prev => {
|
||||
updated = prev.evidence_items.map((item, i) => i === index ? { text, status } : item)
|
||||
return { ...prev, evidence_items: updated }
|
||||
})
|
||||
if (session.activeChatId) {
|
||||
try { await aiSessionsApi.updateTriage(session.activeChatId, { evidence_items: updated }) } catch { /* best-effort */ }
|
||||
}
|
||||
}, [session.activeChatId])
|
||||
|
||||
const handleStepComplete = useCallback((index: number) => {
|
||||
setCompletedSteps(prev => {
|
||||
const next = new Set(prev)
|
||||
next.add(index)
|
||||
// Auto-advance using the updated set (avoids stale closure)
|
||||
const nextIncomplete = session.activeActions.findIndex((_, i) => i > index && !next.has(i))
|
||||
if (nextIncomplete !== -1) {
|
||||
setActiveStepIndex(nextIncomplete)
|
||||
} else if (index + 1 < session.activeActions.length) {
|
||||
setActiveStepIndex(index + 1)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}, [session.activeActions])
|
||||
|
||||
const handleStepSelect = useCallback((index: number) => {
|
||||
setActiveStepIndex(index)
|
||||
}, [])
|
||||
|
||||
// Merge triage_update from AI response into local state
|
||||
const mergeTriageUpdate = useCallback((update: TriageUpdate) => {
|
||||
setTriageMeta(prev => {
|
||||
const merged = { ...prev }
|
||||
// AI only fills null fields (manual edits win)
|
||||
if (update.client_name && !prev.client_name) merged.client_name = update.client_name
|
||||
if (update.asset_name && !prev.asset_name) merged.asset_name = update.asset_name
|
||||
if (update.issue_category && !prev.issue_category) merged.issue_category = update.issue_category
|
||||
if (update.triage_hypothesis && !prev.triage_hypothesis) merged.triage_hypothesis = update.triage_hypothesis
|
||||
// Append new evidence items
|
||||
if (update.evidence_items && update.evidence_items.length > 0) {
|
||||
merged.evidence_items = [...prev.evidence_items, ...update.evidence_items]
|
||||
}
|
||||
return merged
|
||||
})
|
||||
}, [])
|
||||
|
||||
// Drag handle for work zone / chat split
|
||||
const handleDragStart = useCallback((e: React.MouseEvent) => {
|
||||
e.preventDefault()
|
||||
const container = splitContainerRef.current
|
||||
if (!container) return
|
||||
const rect = container.getBoundingClientRect()
|
||||
const onMove = (ev: MouseEvent) => {
|
||||
const pct = ((ev.clientY - rect.top) / rect.height) * 100
|
||||
const clamped = Math.max(25, Math.min(75, pct))
|
||||
setWorkZonePct(clamped)
|
||||
}
|
||||
const onUp = () => {
|
||||
document.removeEventListener('mousemove', onMove)
|
||||
document.removeEventListener('mouseup', onUp)
|
||||
setWorkZonePct(prev => { localStorage.setItem('rf-assistant-work-zone-height', String(prev)); return prev })
|
||||
}
|
||||
document.addEventListener('mousemove', onMove)
|
||||
document.addEventListener('mouseup', onUp)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageMeta title="FlowPilot" />
|
||||
<div className="flex h-[calc(100vh-3.5rem)]">
|
||||
{/* Sidebar — hidden on mobile, collapsed to top bar or full sidebar on desktop */}
|
||||
{!session.sidebarCollapsed && (
|
||||
<div className="hidden sm:block">
|
||||
<ChatSidebar
|
||||
chats={session.chats}
|
||||
activeChatId={session.activeChatId}
|
||||
onSelectChat={session.selectChat}
|
||||
onNewChat={session.handleNewChat}
|
||||
onDeleteChat={session.handleDeleteChat}
|
||||
onTogglePin={session.handleTogglePin}
|
||||
onToggleCollapse={session.toggleSidebarCollapse}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="sm:hidden">
|
||||
<ChatSidebar
|
||||
chats={session.chats}
|
||||
activeChatId={session.activeChatId}
|
||||
onSelectChat={session.selectChat}
|
||||
onNewChat={session.handleNewChat}
|
||||
onDeleteChat={session.handleDeleteChat}
|
||||
onTogglePin={session.handleTogglePin}
|
||||
mobileOpen={session.mobileSidebarOpen}
|
||||
onMobileClose={() => session.setMobileSidebarOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Main chat area + optional branch sidebar */}
|
||||
<div className="flex-1 flex flex-col min-w-0">
|
||||
|
||||
{/* Collapsed sidebar top bar — desktop only */}
|
||||
{session.sidebarCollapsed && (
|
||||
<div className="hidden sm:block">
|
||||
<ChatSidebarCollapsedBar
|
||||
chats={session.chats}
|
||||
activeChatId={session.activeChatId}
|
||||
onNewChat={session.handleNewChat}
|
||||
onExpand={session.toggleSidebarCollapse}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Cockpit content area */}
|
||||
<div className="flex-1 flex flex-col min-w-0 min-h-0">
|
||||
{/* Mobile header with case history toggle */}
|
||||
<div className="sm:hidden flex items-center gap-2 px-3 py-2 border-b border-border shrink-0">
|
||||
<button
|
||||
onClick={() => session.setMobileSidebarOpen(true)}
|
||||
className="flex items-center gap-2 rounded-lg px-3 py-2 text-sm text-muted-foreground hover:text-foreground hover:bg-[var(--color-bg-elevated)] transition-colors"
|
||||
>
|
||||
<MessageSquare size={16} />
|
||||
Cases
|
||||
</button>
|
||||
<div className="flex-1" />
|
||||
<button
|
||||
onClick={session.handleNewChat}
|
||||
className="rounded-lg px-3 py-2 text-sm font-medium text-primary hover:bg-primary/10 transition-colors"
|
||||
>
|
||||
+ New Case
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* View tab bar — persistent when a session is active */}
|
||||
{session.activeChatId && (
|
||||
<ViewToggle currentView="cockpit" sessionId={session.activeChatId} />
|
||||
)}
|
||||
|
||||
{session.activeChatId ? (
|
||||
<>
|
||||
{/* Incident Header */}
|
||||
<IncidentHeader
|
||||
triageMeta={triageMeta}
|
||||
psaTicketId={psaTicketId}
|
||||
onFieldSave={handleTriageFieldSave}
|
||||
onResolve={() => session.setShowConclude(true)}
|
||||
onStatusUpdate={session.messages.length >= 2 ? () => session.setShowStatusUpdate(true) : undefined}
|
||||
onClose={() => session.setShowConclude(true)}
|
||||
/>
|
||||
|
||||
{/* Resizable work zone + conversation log split */}
|
||||
<div ref={splitContainerRef} className="flex-1 flex flex-col min-h-0 relative">
|
||||
{/* First-run onboarding overlay */}
|
||||
{showOnboarding && session.messages.length === 0 && (
|
||||
<div className="absolute inset-0 z-30 pointer-events-none">
|
||||
{/* Steps zone label */}
|
||||
<div className="absolute top-3 left-3 pointer-events-auto">
|
||||
<div className="bg-elevated border border-accent/30 rounded-lg px-3 py-2 shadow-lg max-w-[200px]">
|
||||
<p className="text-xs font-medium text-accent mb-0.5">Steps Panel</p>
|
||||
<p className="text-[11px] text-muted-foreground leading-snug">Troubleshooting steps appear here as FlowPilot identifies them. Click to mark done.</p>
|
||||
</div>
|
||||
</div>
|
||||
{/* FlowPilot Asks zone label */}
|
||||
<div className="absolute top-3 right-3 pointer-events-auto">
|
||||
<div className="bg-elevated border border-warning/30 rounded-lg px-3 py-2 shadow-lg max-w-[200px]">
|
||||
<p className="text-xs font-medium text-warning mb-0.5">AI Questions & Evidence</p>
|
||||
<p className="text-[11px] text-muted-foreground leading-snug">FlowPilot asks clarifying questions here. Evidence you confirm or rule out is tracked below.</p>
|
||||
</div>
|
||||
</div>
|
||||
{/* Conversation log label */}
|
||||
<div className="absolute bottom-3 left-1/2 -translate-x-1/2 pointer-events-auto">
|
||||
<div className="bg-elevated border border-default rounded-lg px-3 py-2 shadow-lg max-w-[280px] text-center">
|
||||
<p className="text-xs font-medium text-muted-foreground mb-0.5">Conversation Log</p>
|
||||
<p className="text-[11px] text-muted-foreground leading-snug">Full chat history lives here. Drag the handle above to resize.</p>
|
||||
<button
|
||||
onClick={dismissOnboarding}
|
||||
className="mt-2 text-[11px] text-accent hover:text-accent/80 transition-colors pointer-events-auto"
|
||||
>
|
||||
Got it, dismiss
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Work zone */}
|
||||
<div className="flex min-h-0 overflow-hidden" style={{ height: `${workZonePct}%` }}>
|
||||
{/* Left: Steps panel */}
|
||||
<div className="flex-[55] min-w-0 p-3 overflow-y-auto border-r border-default">
|
||||
{session.loading && session.activeActions.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-full text-center gap-2">
|
||||
<Loader2 size={20} className="animate-spin text-accent" />
|
||||
<p className="text-xs text-muted-foreground">FlowPilot is analyzing the issue...</p>
|
||||
</div>
|
||||
) : (
|
||||
<StepsPanel
|
||||
actions={session.activeActions}
|
||||
activeIndex={activeStepIndex}
|
||||
completedSteps={completedSteps}
|
||||
onStepComplete={handleStepComplete}
|
||||
onStepSelect={handleStepSelect}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{/* Right: FlowPilot Asks + What We Know */}
|
||||
<div className="flex-[45] min-w-0 p-3 overflow-y-auto flex flex-col gap-3">
|
||||
{session.loading && session.activeQuestions.length === 0 && triageMeta.evidence_items.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-full text-center gap-2">
|
||||
<Sparkles size={18} className="text-muted" />
|
||||
<p className="text-xs text-muted-foreground">Questions and evidence will appear here</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<FlowPilotAsks
|
||||
questions={session.activeQuestions}
|
||||
onAnswer={(answer) => {
|
||||
void session.sendMessage(answer, { clearComposer: false })
|
||||
}}
|
||||
loading={session.loading}
|
||||
/>
|
||||
<WhatWeKnow
|
||||
items={triageMeta.evidence_items}
|
||||
onAdd={handleEvidenceAdd}
|
||||
onEdit={handleEvidenceEdit}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Drag handle */}
|
||||
<div
|
||||
onMouseDown={handleDragStart}
|
||||
className="h-4 flex items-center justify-center cursor-row-resize hover:bg-elevated transition-colors flex-shrink-0 border-y border-default/50 group"
|
||||
title="Drag to resize"
|
||||
>
|
||||
<GripHorizontal size={16} className="text-muted group-hover:text-muted-foreground transition-colors" />
|
||||
</div>
|
||||
|
||||
{/* Conversation log */}
|
||||
<div className="flex-1 min-h-[180px] overflow-y-auto bg-sidebar">
|
||||
<div className="px-4 pt-2 pb-1">
|
||||
<span className="text-[10px] uppercase tracking-wider text-muted font-semibold">Conversation Log</span>
|
||||
</div>
|
||||
{session.messages.length === 0 && !session.loading && (
|
||||
<div className="flex flex-col items-center justify-center h-full text-center px-4 py-8">
|
||||
<Sparkles size={24} className="text-muted mb-2" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Start a new case to begin troubleshooting
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="px-4 pb-2 space-y-1.5">
|
||||
{session.messages.map((msg, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={cn(
|
||||
'flex gap-3 text-sm leading-relaxed rounded-md px-3 py-2 border-l-2',
|
||||
msg.role === 'user'
|
||||
? 'border-l-muted-foreground/30 bg-white/[0.02]'
|
||||
: 'border-l-accent/40 bg-accent/[0.03]'
|
||||
)}
|
||||
>
|
||||
<span className={cn(
|
||||
'text-xs font-medium flex-shrink-0 mt-0.5 min-w-[58px]',
|
||||
msg.role === 'user' ? 'text-muted-foreground' : 'text-accent/70'
|
||||
)}>
|
||||
{msg.role === 'user' ? 'You' : 'FlowPilot'}
|
||||
</span>
|
||||
<span className={cn(
|
||||
msg.role === 'user' ? 'text-muted-foreground/80' : 'text-primary/80'
|
||||
)}>
|
||||
{msg.content}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{session.loading && (
|
||||
<div className="flex gap-3 text-sm px-3 py-2 border-l-2 border-l-accent/40 bg-accent/[0.03] rounded-md">
|
||||
<span className="text-xs font-medium text-accent/70 min-w-[58px]">FlowPilot</span>
|
||||
<Loader2 size={14} className="animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
<div ref={session.messagesEndRef} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Rich Input */}
|
||||
<div className="px-3 sm:px-6 py-3 shrink-0">
|
||||
<div
|
||||
className="max-w-3xl mx-auto"
|
||||
onDragOver={session.handleDragOver}
|
||||
onDragEnter={session.handleDragEnter}
|
||||
onDragLeave={session.handleDragLeave}
|
||||
onDrop={session.handleDrop}
|
||||
>
|
||||
<div className={cn(
|
||||
'relative rounded-xl border transition-all',
|
||||
session.loading ? 'border-border/50 opacity-50' :
|
||||
session.isDragOver ? 'border-primary/50 bg-primary/5' :
|
||||
'border-border focus-within:border-[rgba(96,165,250,0.3)] focus-within:ring-1 focus-within:ring-primary/20'
|
||||
)} style={{ background: 'var(--color-bg-card)' }}>
|
||||
{/* Drag overlay */}
|
||||
{session.isDragOver && (
|
||||
<div className="absolute inset-0 z-10 flex items-center justify-center rounded-xl border-2 border-dashed border-primary/50 bg-primary/5 pointer-events-none">
|
||||
<div className="flex items-center gap-2 text-sm text-primary">
|
||||
<ImagePlus size={18} />
|
||||
Drop files to attach
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Textarea */}
|
||||
<textarea
|
||||
ref={session.inputRef}
|
||||
value={session.input}
|
||||
onChange={e => session.setInput(e.target.value)}
|
||||
onKeyDown={session.handleKeyDown}
|
||||
onPaste={session.handlePaste}
|
||||
placeholder={session.loading ? 'FlowPilot is working...' : 'Describe the issue or ask FlowPilot...'}
|
||||
disabled={session.loading}
|
||||
rows={1}
|
||||
className="w-full resize-none bg-transparent px-4 pt-3 pb-1 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none disabled:cursor-not-allowed"
|
||||
style={{ minHeight: '40px', maxHeight: '150px' }}
|
||||
/>
|
||||
|
||||
{/* Thumbnail strip */}
|
||||
{session.pendingUploads.length > 0 && (
|
||||
<div className="flex gap-2 flex-wrap px-4 pb-1">
|
||||
{session.pendingUploads.map((upload) => (
|
||||
<div key={upload.id} className="relative w-12 h-12 rounded-lg overflow-hidden border border-border bg-background">
|
||||
{upload.preview ? (
|
||||
<img src={upload.preview} alt="" className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center text-[0.5rem] text-muted-foreground px-1 text-center">
|
||||
{upload.file.name.split('.').pop()?.toUpperCase()}
|
||||
</div>
|
||||
)}
|
||||
{upload.status === 'uploading' && (
|
||||
<div className="absolute inset-0 bg-background/60 flex items-center justify-center">
|
||||
<Loader2 size={12} className="animate-spin text-primary" />
|
||||
</div>
|
||||
)}
|
||||
{upload.status === 'done' && (
|
||||
<button type="button" onClick={() => session.handleRemoveUpload(upload.id)} className="absolute -top-1 -right-1 w-4 h-4 rounded-full bg-background border border-border flex items-center justify-center hover:bg-rose-500/20 transition-colors">
|
||||
<X size={8} className="text-muted-foreground" />
|
||||
</button>
|
||||
)}
|
||||
{upload.status === 'error' && (
|
||||
<div className="absolute inset-0 bg-rose-500/20 border-2 border-rose-500 flex items-center justify-center cursor-pointer" onClick={() => session.retryUpload(upload.id)}>
|
||||
<RotateCcw size={10} className="text-rose-500" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Logs textarea */}
|
||||
{session.showLogs && (
|
||||
<div className="px-4 pb-1">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="text-[0.625rem] uppercase tracking-wide text-muted-foreground font-sans">Paste logs or error output</span>
|
||||
<button type="button" onClick={() => { session.setShowLogs(false); session.setLogContent('') }} className="text-muted-foreground hover:text-foreground"><X size={14} /></button>
|
||||
</div>
|
||||
<textarea
|
||||
value={session.logContent}
|
||||
onChange={(e) => session.setLogContent(e.target.value)}
|
||||
placeholder="Paste event viewer logs, error messages, PowerShell output..."
|
||||
rows={3}
|
||||
className="w-full resize-none rounded-lg border border-border bg-background p-2 font-mono text-xs text-foreground placeholder:text-muted-foreground focus:border-[rgba(96,165,250,0.3)] focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Bottom toolbar */}
|
||||
<div className="flex items-center justify-between px-3 py-1.5 border-t border-border/50">
|
||||
<div className="flex items-center gap-0.5">
|
||||
<input ref={session.fileInputRef} type="file" multiple accept={session.ACCEPTED_FILE_TYPES} onChange={session.handleFileSelect} className="hidden" />
|
||||
<button type="button" onClick={() => session.fileInputRef.current?.click()} disabled={session.loading} className="flex items-center gap-1.5 rounded-lg px-2 py-1.5 text-xs text-muted-foreground hover:text-foreground hover:bg-secondary transition-colors disabled:opacity-40" title="Attach files">
|
||||
<Paperclip size={14} />
|
||||
<span className="hidden sm:inline">Attach</span>
|
||||
</button>
|
||||
{!session.showLogs && (
|
||||
<button type="button" onClick={() => session.setShowLogs(true)} disabled={session.loading} className="flex items-center gap-1.5 rounded-lg px-2 py-1.5 text-xs text-muted-foreground hover:text-foreground hover:bg-secondary transition-colors disabled:opacity-40" title="Paste logs">
|
||||
<Terminal size={14} />
|
||||
<span className="hidden sm:inline">Paste Logs</span>
|
||||
</button>
|
||||
)}
|
||||
{!session.showTaskLane && (session.activeQuestions.length > 0 || session.activeActions.length > 0) && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => session.setShowTaskLane(true)}
|
||||
className="flex items-center gap-1.5 rounded-lg px-2 py-1.5 text-xs text-accent-text hover:text-foreground hover:bg-accent-dim transition-colors"
|
||||
title="Show task panel"
|
||||
>
|
||||
<ListChecks size={14} />
|
||||
Tasks ({session.activeQuestions.length + session.activeActions.length})
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<button type="button" onClick={session.handleSend} disabled={!session.input.trim() || session.loading} className={cn(
|
||||
'flex h-8 w-8 items-center justify-center rounded-lg transition-all',
|
||||
session.input.trim() && !session.loading ? 'bg-primary text-white hover:brightness-110 active:scale-95' : 'bg-secondary text-muted-foreground cursor-not-allowed'
|
||||
)} title="Send message">
|
||||
<Send size={15} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center h-full text-center">
|
||||
<div className="w-20 h-20 rounded-full bg-accent-dim flex items-center justify-center mb-4">
|
||||
<Sparkles size={32} className="text-primary" />
|
||||
</div>
|
||||
<h2 className="text-xl font-heading font-semibold text-foreground mb-2">
|
||||
FlowPilot
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground max-w-md mb-6">
|
||||
Your MSP troubleshooting copilot. Start a new case to begin
|
||||
diagnosing with structured steps, evidence tracking, and AI guidance.
|
||||
</p>
|
||||
<button
|
||||
onClick={session.handleNewChat}
|
||||
className="bg-primary text-white font-semibold text-sm rounded-lg px-6 py-2.5 hover:brightness-110 active:scale-[0.98] transition-all"
|
||||
>
|
||||
New Case
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
</div>{/* close cockpit content area */}
|
||||
|
||||
{/* Close Case Session Modal */}
|
||||
<ConcludeSessionModal
|
||||
isOpen={session.showConclude}
|
||||
onClose={() => session.setShowConclude(false)}
|
||||
onConclude={session.handleConclude}
|
||||
onResumeNew={session.handleResumeNew}
|
||||
chatTitle={session.chats.find(c => c.id === session.activeChatId)?.title ?? 'Chat'}
|
||||
sessionId={session.activeChatId}
|
||||
/>
|
||||
|
||||
{/* Status Update Modal */}
|
||||
{session.activeChatId && (
|
||||
<StatusUpdateModal
|
||||
open={session.showStatusUpdate}
|
||||
onClose={() => session.setShowStatusUpdate(false)}
|
||||
onGenerate={(audience, length, context) =>
|
||||
aiSessionsApi.generateStatusUpdate(session.activeChatId!, { audience, length, context })
|
||||
}
|
||||
context="status"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
410
frontend/src/pages/FlowPilotPage.tsx
Normal file
410
frontend/src/pages/FlowPilotPage.tsx
Normal file
@@ -0,0 +1,410 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Sparkles, Send, Loader2, MessageSquare, Paperclip, Terminal, X, RotateCcw, ImagePlus, ListChecks, CheckCircle2, FileText, MoreHorizontal, Pause } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { PageMeta } from '@/components/common/PageMeta'
|
||||
import { aiSessionsApi } from '@/api/aiSessions'
|
||||
import { ChatSidebar, ChatSidebarCollapsedBar } from '@/components/assistant/ChatSidebar'
|
||||
import { ChatMessage } from '@/components/assistant/ChatMessage'
|
||||
import { TaskLane } from '@/components/assistant/TaskLane'
|
||||
import { ConcludeSessionModal } from '@/components/assistant/ConcludeSessionModal'
|
||||
import { StatusUpdateModal } from '@/components/flowpilot/StatusUpdateModal'
|
||||
import { ViewToggle } from '@/components/assistant/ViewToggle'
|
||||
import { useAssistantSession } from '@/hooks/useAssistantSession'
|
||||
|
||||
export default function FlowPilotPage() {
|
||||
const session = useAssistantSession()
|
||||
const [showOverflow, setShowOverflow] = useState(false)
|
||||
|
||||
// Handle prefill from dashboard / command palette
|
||||
useEffect(() => {
|
||||
session.handlePrefill('/assistant')
|
||||
}, []) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const handleTaskSubmit = async (responses: Array<{ type: string; state: string; value: string; text?: string; label?: string }>) => {
|
||||
if (!session.activeChatId || session.loading || session.loadingRef.current) return
|
||||
|
||||
const parts: string[] = []
|
||||
for (const r of responses) {
|
||||
const name = r.type === 'question' ? `Q: ${r.text}` : r.label || 'Check'
|
||||
if (r.state === 'done' && r.value.trim()) {
|
||||
parts.push(`**${name}:**\n\`\`\`\n${r.value.trim()}\n\`\`\``)
|
||||
} else if (r.state === 'skipped') {
|
||||
parts.push(`**${name}:** _(skipped)_`)
|
||||
}
|
||||
}
|
||||
const userMessage = parts.join('\n\n')
|
||||
const sendChatId = session.activeChatId
|
||||
|
||||
session.setInput('')
|
||||
session.setShowTaskLane(false)
|
||||
session.setActiveQuestions([])
|
||||
session.setActiveActions([])
|
||||
|
||||
try {
|
||||
const response = await aiSessionsApi.sendChatMessage(sendChatId, { message: userMessage })
|
||||
if (session.currentChatRef.current !== sendChatId) return
|
||||
session.processResponse(response, sendChatId)
|
||||
|
||||
const hasQuestions = response.questions && response.questions.length > 0
|
||||
const hasActions = response.actions && response.actions.length > 0
|
||||
if (!hasQuestions && !hasActions) {
|
||||
session.setShowTaskLane(false)
|
||||
session.setActiveQuestions([])
|
||||
session.setActiveActions([])
|
||||
}
|
||||
} catch {
|
||||
// Error handled by processResponse guard
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageMeta title="FlowPilot" />
|
||||
<div className="flex h-[calc(100vh-3.5rem)]">
|
||||
{/* Chat Sidebar — desktop */}
|
||||
{!session.sidebarCollapsed && (
|
||||
<div className="hidden sm:block">
|
||||
<ChatSidebar
|
||||
chats={session.chats}
|
||||
activeChatId={session.activeChatId}
|
||||
onSelectChat={session.selectChat}
|
||||
onNewChat={session.handleNewChat}
|
||||
onDeleteChat={session.handleDeleteChat}
|
||||
onTogglePin={session.handleTogglePin}
|
||||
onToggleCollapse={session.toggleSidebarCollapse}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{/* Chat Sidebar — mobile */}
|
||||
<div className="sm:hidden">
|
||||
<ChatSidebar
|
||||
chats={session.chats}
|
||||
activeChatId={session.activeChatId}
|
||||
onSelectChat={session.selectChat}
|
||||
onNewChat={session.handleNewChat}
|
||||
onDeleteChat={session.handleDeleteChat}
|
||||
onTogglePin={session.handleTogglePin}
|
||||
mobileOpen={session.mobileSidebarOpen}
|
||||
onMobileClose={() => session.setMobileSidebarOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Main area */}
|
||||
<div className="flex-1 flex flex-col min-w-0">
|
||||
{/* Collapsed sidebar bar */}
|
||||
{session.sidebarCollapsed && (
|
||||
<div className="hidden sm:block">
|
||||
<ChatSidebarCollapsedBar
|
||||
chats={session.chats}
|
||||
activeChatId={session.activeChatId}
|
||||
onNewChat={session.handleNewChat}
|
||||
onExpand={session.toggleSidebarCollapse}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Chat content row */}
|
||||
<div className="flex-1 flex min-w-0 min-h-0">
|
||||
<div className="flex-1 flex flex-col min-w-0">
|
||||
{/* Mobile header */}
|
||||
<div className="sm:hidden flex items-center gap-2 px-3 py-2 border-b border-border shrink-0">
|
||||
<button
|
||||
onClick={() => session.setMobileSidebarOpen(true)}
|
||||
className="flex items-center gap-2 rounded-lg px-3 py-2 text-sm text-muted-foreground hover:text-foreground hover:bg-[var(--color-bg-elevated)] transition-colors"
|
||||
>
|
||||
<MessageSquare size={16} />
|
||||
Cases
|
||||
</button>
|
||||
<div className="flex-1" />
|
||||
<button
|
||||
onClick={session.handleNewChat}
|
||||
className="rounded-lg px-3 py-2 text-sm font-medium text-primary hover:bg-primary/10 transition-colors"
|
||||
>
|
||||
+ New
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* View tab bar — persistent when a session is active */}
|
||||
{session.activeChatId && (
|
||||
<ViewToggle currentView="flowpilot" sessionId={session.activeChatId} />
|
||||
)}
|
||||
|
||||
{session.activeChatId ? (
|
||||
<>
|
||||
{/* Action bar — Resolve, Update, overflow (Pause/Close) */}
|
||||
{session.messages.length >= 2 && (
|
||||
<div className="hidden sm:flex items-center gap-1.5 px-4 py-1.5 border-b border-border/50">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => session.setShowConclude(true)}
|
||||
disabled={session.loading}
|
||||
className="flex items-center gap-1.5 rounded-lg bg-emerald-500/10 border border-emerald-500/20 px-3 py-1.5 text-xs font-medium text-emerald-400 hover:bg-emerald-500/20 disabled:opacity-40 disabled:pointer-events-none transition-colors"
|
||||
>
|
||||
<CheckCircle2 size={13} />
|
||||
Resolve
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => session.setShowStatusUpdate(true)}
|
||||
disabled={session.loading}
|
||||
className="flex items-center gap-1.5 bg-blue-500/10 border border-blue-500/20 rounded-lg px-3 py-1.5 text-xs font-medium text-blue-400 hover:bg-blue-500/20 disabled:opacity-40 disabled:pointer-events-none transition-colors"
|
||||
>
|
||||
<FileText size={13} />
|
||||
Update
|
||||
</button>
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setShowOverflow(!showOverflow)}
|
||||
className="flex items-center justify-center rounded-lg px-2 py-1.5 text-muted-foreground hover:text-foreground hover:bg-[rgba(255,255,255,0.06)] transition-colors"
|
||||
>
|
||||
<MoreHorizontal size={16} />
|
||||
</button>
|
||||
{showOverflow && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-40" onClick={() => setShowOverflow(false)} />
|
||||
<div className="absolute right-0 top-full mt-1 z-50 w-40 rounded-lg border border-border bg-card py-1 shadow-lg">
|
||||
<button
|
||||
onClick={() => { setShowOverflow(false); /* pause not wired on chat sessions yet */ }}
|
||||
className="flex w-full items-center gap-2 px-3 py-2 text-xs text-muted-foreground hover:text-foreground hover:bg-[rgba(255,255,255,0.06)] transition-colors"
|
||||
>
|
||||
<Pause size={13} />
|
||||
Pause
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setShowOverflow(false); session.setShowConclude(true) }}
|
||||
className="flex w-full items-center gap-2 px-3 py-2 text-xs text-muted-foreground hover:text-rose-400 hover:bg-rose-500/10 transition-colors"
|
||||
>
|
||||
<X size={13} />
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Messages */}
|
||||
<div className="flex-1 overflow-y-auto px-4 sm:px-6 py-4 space-y-4">
|
||||
{session.messages.length === 0 && !session.loading && (
|
||||
<div className="flex flex-col items-center justify-center h-full text-center">
|
||||
<div className="w-16 h-16 rounded-full bg-accent-dim flex items-center justify-center mb-4">
|
||||
<Sparkles size={28} className="text-primary" />
|
||||
</div>
|
||||
<h2 className="text-lg font-heading font-semibold text-foreground mb-2">
|
||||
FlowPilot
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground max-w-md">
|
||||
Ask me anything about IT infrastructure, networking, Active Directory,
|
||||
cloud platforms, or troubleshooting. I'll also suggest relevant flows from your team's library.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{session.messages.length === 0 && session.loading && (
|
||||
<div className="flex flex-col items-center justify-center h-full text-center gap-3">
|
||||
<Loader2 size={24} className="animate-spin text-primary" />
|
||||
<p className="text-sm text-muted-foreground">Starting session...</p>
|
||||
</div>
|
||||
)}
|
||||
{session.messages.map((msg, i) => (
|
||||
<ChatMessage
|
||||
key={i}
|
||||
role={msg.role}
|
||||
content={msg.content}
|
||||
suggestedFlows={msg.suggestedFlows}
|
||||
/>
|
||||
))}
|
||||
{session.loading && (
|
||||
<div className="flex gap-3">
|
||||
<div className="w-8 h-8 rounded-full bg-primary/15 flex items-center justify-center">
|
||||
<Sparkles size={14} className="text-primary" />
|
||||
</div>
|
||||
<div className="bg-input border border-border rounded-2xl px-4 py-3">
|
||||
<Loader2 size={16} className="animate-spin text-primary" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div ref={session.messagesEndRef} />
|
||||
</div>
|
||||
|
||||
{/* Rich Input */}
|
||||
<div className="px-3 sm:px-6 py-3 shrink-0">
|
||||
<div
|
||||
className="max-w-3xl mx-auto"
|
||||
onDragOver={session.handleDragOver}
|
||||
onDragEnter={session.handleDragEnter}
|
||||
onDragLeave={session.handleDragLeave}
|
||||
onDrop={session.handleDrop}
|
||||
>
|
||||
<div className={cn(
|
||||
'relative rounded-xl border transition-all',
|
||||
session.loading ? 'border-border/50 opacity-50' :
|
||||
session.isDragOver ? 'border-primary/50 bg-primary/5' :
|
||||
'border-border focus-within:border-[rgba(96,165,250,0.3)] focus-within:ring-1 focus-within:ring-primary/20'
|
||||
)} style={{ background: 'var(--color-bg-card)' }}>
|
||||
{session.isDragOver && (
|
||||
<div className="absolute inset-0 z-10 flex items-center justify-center rounded-xl border-2 border-dashed border-primary/50 bg-primary/5 pointer-events-none">
|
||||
<div className="flex items-center gap-2 text-sm text-primary">
|
||||
<ImagePlus size={18} />
|
||||
Drop files to attach
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<textarea
|
||||
ref={session.inputRef}
|
||||
value={session.input}
|
||||
onChange={e => session.setInput(e.target.value)}
|
||||
onKeyDown={session.handleKeyDown}
|
||||
onPaste={session.handlePaste}
|
||||
placeholder={session.loading ? 'AI is thinking...' : 'Type a message, paste a screenshot, or drag a file...'}
|
||||
disabled={session.loading}
|
||||
rows={1}
|
||||
className="w-full resize-none bg-transparent px-4 pt-3 pb-1 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none disabled:cursor-not-allowed"
|
||||
style={{ minHeight: '40px', maxHeight: '150px' }}
|
||||
/>
|
||||
|
||||
{session.pendingUploads.length > 0 && (
|
||||
<div className="flex gap-2 flex-wrap px-4 pb-1">
|
||||
{session.pendingUploads.map((upload) => (
|
||||
<div key={upload.id} className="relative w-12 h-12 rounded-lg overflow-hidden border border-border bg-background">
|
||||
{upload.preview ? (
|
||||
<img src={upload.preview} alt="" className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center text-[0.5rem] text-muted-foreground px-1 text-center">
|
||||
{upload.file.name.split('.').pop()?.toUpperCase()}
|
||||
</div>
|
||||
)}
|
||||
{upload.status === 'uploading' && (
|
||||
<div className="absolute inset-0 bg-background/60 flex items-center justify-center">
|
||||
<Loader2 size={12} className="animate-spin text-primary" />
|
||||
</div>
|
||||
)}
|
||||
{upload.status === 'done' && (
|
||||
<button type="button" onClick={() => session.handleRemoveUpload(upload.id)} className="absolute -top-1 -right-1 w-4 h-4 rounded-full bg-background border border-border flex items-center justify-center hover:bg-rose-500/20 transition-colors">
|
||||
<X size={8} className="text-muted-foreground" />
|
||||
</button>
|
||||
)}
|
||||
{upload.status === 'error' && (
|
||||
<div className="absolute inset-0 bg-rose-500/20 border-2 border-rose-500 flex items-center justify-center cursor-pointer" onClick={() => session.retryUpload(upload.id)}>
|
||||
<RotateCcw size={10} className="text-rose-500" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{session.showLogs && (
|
||||
<div className="px-4 pb-1">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="text-[0.625rem] uppercase tracking-wide text-muted-foreground font-sans">Paste logs or error output</span>
|
||||
<button type="button" onClick={() => { session.setShowLogs(false); session.setLogContent('') }} className="text-muted-foreground hover:text-foreground"><X size={14} /></button>
|
||||
</div>
|
||||
<textarea
|
||||
value={session.logContent}
|
||||
onChange={(e) => session.setLogContent(e.target.value)}
|
||||
placeholder="Paste event viewer logs, error messages, PowerShell output..."
|
||||
rows={3}
|
||||
className="w-full resize-none rounded-lg border border-border bg-background p-2 font-mono text-xs text-foreground placeholder:text-muted-foreground focus:border-[rgba(96,165,250,0.3)] focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between px-3 py-1.5 border-t border-border/50">
|
||||
<div className="flex items-center gap-0.5">
|
||||
<input ref={session.fileInputRef} type="file" multiple accept={session.ACCEPTED_FILE_TYPES} onChange={session.handleFileSelect} className="hidden" />
|
||||
<button type="button" onClick={() => session.fileInputRef.current?.click()} disabled={session.loading} className="flex items-center gap-1.5 rounded-lg px-2 py-1.5 text-xs text-muted-foreground hover:text-foreground hover:bg-secondary transition-colors disabled:opacity-40" title="Attach files">
|
||||
<Paperclip size={14} />
|
||||
<span className="hidden sm:inline">Attach</span>
|
||||
</button>
|
||||
{!session.showLogs && (
|
||||
<button type="button" onClick={() => session.setShowLogs(true)} disabled={session.loading} className="flex items-center gap-1.5 rounded-lg px-2 py-1.5 text-xs text-muted-foreground hover:text-foreground hover:bg-secondary transition-colors disabled:opacity-40" title="Paste logs">
|
||||
<Terminal size={14} />
|
||||
<span className="hidden sm:inline">Paste Logs</span>
|
||||
</button>
|
||||
)}
|
||||
{!session.showTaskLane && (session.activeQuestions.length > 0 || session.activeActions.length > 0) && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => session.setShowTaskLane(true)}
|
||||
className="flex items-center gap-1.5 rounded-lg px-2 py-1.5 text-xs text-accent-text hover:text-foreground hover:bg-accent-dim transition-colors"
|
||||
title="Show task panel"
|
||||
>
|
||||
<ListChecks size={14} />
|
||||
Tasks ({session.activeQuestions.length + session.activeActions.length})
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<button type="button" onClick={session.handleSend} disabled={!session.input.trim() || session.loading} className={cn(
|
||||
'flex h-8 w-8 items-center justify-center rounded-lg transition-all',
|
||||
session.input.trim() && !session.loading ? 'bg-primary text-white hover:brightness-110 active:scale-95' : 'bg-secondary text-muted-foreground cursor-not-allowed'
|
||||
)} title="Send message">
|
||||
<Send size={15} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center h-full text-center">
|
||||
<div className="w-20 h-20 rounded-full bg-accent-dim flex items-center justify-center mb-4">
|
||||
<Sparkles size={32} className="text-primary" />
|
||||
</div>
|
||||
<h2 className="text-xl font-heading font-semibold text-foreground mb-2">
|
||||
FlowPilot
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground max-w-md mb-6">
|
||||
Your Senior Systems & Network Engineer. Ask anything about IT infrastructure,
|
||||
or start a new chat to get personalized help with your team's flows.
|
||||
</p>
|
||||
<button
|
||||
onClick={session.handleNewChat}
|
||||
className="bg-primary text-white font-semibold text-sm rounded-lg px-6 py-2.5 hover:brightness-110 active:scale-[0.98] transition-all"
|
||||
>
|
||||
Start a Conversation
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Task lane */}
|
||||
{session.showTaskLane && (session.activeQuestions.length > 0 || session.activeActions.length > 0) && (
|
||||
<TaskLane
|
||||
questions={session.activeQuestions}
|
||||
actions={session.activeActions}
|
||||
sessionId={session.activeChatId}
|
||||
onSubmit={handleTaskSubmit}
|
||||
onClose={() => session.setShowTaskLane(false)}
|
||||
loading={session.loading}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Close Case Modal */}
|
||||
<ConcludeSessionModal
|
||||
isOpen={session.showConclude}
|
||||
onClose={() => session.setShowConclude(false)}
|
||||
onConclude={session.handleConclude}
|
||||
onResumeNew={session.handleResumeNew}
|
||||
chatTitle={session.chats.find(c => c.id === session.activeChatId)?.title ?? 'Chat'}
|
||||
sessionId={session.activeChatId}
|
||||
/>
|
||||
|
||||
{/* Status Update Modal */}
|
||||
{session.activeChatId && (
|
||||
<StatusUpdateModal
|
||||
open={session.showStatusUpdate}
|
||||
onClose={() => session.setShowStatusUpdate(false)}
|
||||
onGenerate={(audience, length, context) =>
|
||||
aiSessionsApi.generateStatusUpdate(session.activeChatId!, { audience, length, context })
|
||||
}
|
||||
context="status"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -264,7 +264,7 @@ export default function FlowPilotSessionPage() {
|
||||
onClick={() => setShowStatusUpdate(true)}
|
||||
disabled={fp.isProcessing}
|
||||
className="flex items-center gap-1.5 rounded-lg bg-blue-500/10 border border-blue-500/20 px-3 py-1.5 text-xs font-medium text-blue-400 hover:bg-blue-500/20 disabled:opacity-40 disabled:pointer-events-none transition-colors"
|
||||
title="Share Update"
|
||||
title="Update"
|
||||
>
|
||||
<FileText size={13} />
|
||||
Update
|
||||
@@ -281,7 +281,7 @@ export default function FlowPilotSessionPage() {
|
||||
{showOverflow && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-40" onClick={() => setShowOverflow(false)} />
|
||||
<div className="absolute right-0 top-full mt-1 z-50 w-36 rounded-lg border border-border bg-card py-1 shadow-lg">
|
||||
<div className="absolute right-0 top-full mt-1 z-50 w-40 rounded-lg border border-border bg-card py-1 shadow-lg">
|
||||
<button
|
||||
onClick={() => { setShowOverflow(false); fp.pauseSession() }}
|
||||
className="flex w-full items-center gap-2 px-3 py-2 text-xs text-muted-foreground hover:text-foreground hover:bg-[rgba(255,255,255,0.06)] transition-colors"
|
||||
@@ -294,7 +294,7 @@ export default function FlowPilotSessionPage() {
|
||||
className="flex w-full items-center gap-2 px-3 py-2 text-xs text-muted-foreground hover:text-rose-400 hover:bg-rose-500/10 transition-colors"
|
||||
>
|
||||
<X size={13} />
|
||||
Close Session
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
@@ -313,7 +313,7 @@ export default function FlowPilotSessionPage() {
|
||||
{showOverflow && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-40" onClick={() => setShowOverflow(false)} />
|
||||
<div className="absolute right-0 top-full mt-1 z-50 w-44 rounded-lg border border-border bg-card py-1 shadow-lg">
|
||||
<div className="absolute right-0 top-full mt-1 z-50 w-40 rounded-lg border border-border bg-card py-1 shadow-lg">
|
||||
<button
|
||||
onClick={() => { setShowOverflow(false); setShowResolve(true) }}
|
||||
disabled={!fp.canResolve}
|
||||
@@ -336,7 +336,7 @@ export default function FlowPilotSessionPage() {
|
||||
className="flex w-full items-center gap-2 px-3 py-2.5 text-xs text-blue-400 hover:bg-blue-500/10 transition-colors"
|
||||
>
|
||||
<FileText size={14} />
|
||||
Share Update
|
||||
Update
|
||||
</button>
|
||||
)}
|
||||
<div className="my-1 border-t border-border" />
|
||||
@@ -352,7 +352,7 @@ export default function FlowPilotSessionPage() {
|
||||
className="flex w-full items-center gap-2 px-3 py-2.5 text-xs text-muted-foreground hover:text-rose-400 hover:bg-rose-500/10 transition-colors"
|
||||
>
|
||||
<X size={14} />
|
||||
Close Session
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
@@ -450,7 +450,7 @@ export default function FlowPilotSessionPage() {
|
||||
{showAbandon && (
|
||||
<div className="fixed inset-0 z-50 flex items-end sm:items-center justify-center bg-black/60 backdrop-blur-sm">
|
||||
<div className="card-flat w-full max-w-full sm:max-w-lg mx-0 sm:mx-4 p-4 sm:p-6 rounded-t-2xl sm:rounded-2xl">
|
||||
<h3 className="font-heading text-lg font-semibold text-foreground mb-1">Close Session</h3>
|
||||
<h3 className="font-heading text-lg font-semibold text-foreground mb-1">Close</h3>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Are you sure you want to close this session? The session history will be kept but it won't count as resolved.
|
||||
</p>
|
||||
@@ -475,7 +475,7 @@ export default function FlowPilotSessionPage() {
|
||||
disabled={submitting}
|
||||
className="rounded-lg bg-rose-500/20 border border-rose-500/30 px-4 py-2 min-h-[44px] text-sm font-medium text-rose-400 hover:bg-rose-500/30 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{submitting ? 'Closing...' : 'Close Session'}
|
||||
{submitting ? 'Closing...' : 'Close'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
609
frontend/src/pages/NetworkDiagrams/DiagramEditor.tsx
Normal file
609
frontend/src/pages/NetworkDiagrams/DiagramEditor.tsx
Normal file
@@ -0,0 +1,609 @@
|
||||
import { useState, useCallback, useEffect, useRef } from 'react'
|
||||
import { useParams, useNavigate } from 'react-router-dom'
|
||||
import {
|
||||
ReactFlowProvider,
|
||||
useNodesState,
|
||||
useEdgesState,
|
||||
addEdge,
|
||||
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 { DiagramHeader } 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 { networkDiagramsApi, deviceTypesApi } from '@/api'
|
||||
import { toast } from '@/lib/toast'
|
||||
import type { DeviceTypeResponse, DeviceProperties, AIGenerateResponse, DiagramEdge, DiagramNode } from '@/types'
|
||||
import type { DeviceNodeData } from '@/components/network/nodes/DeviceNode'
|
||||
|
||||
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 canvasRef = useRef<HTMLDivElement | null>(null)
|
||||
const [contextMenu, setContextMenu] = useState<ContextMenuState>(null)
|
||||
|
||||
useEffect(() => { isDirtyRef.current = isDirty }, [isDirty])
|
||||
useEffect(() => { diagramIdRef.current = diagramId }, [diagramId])
|
||||
|
||||
const {
|
||||
copyNodes,
|
||||
pasteNodes,
|
||||
duplicateNodes,
|
||||
selectAll,
|
||||
deleteSelected,
|
||||
hasClipboard,
|
||||
} = useCanvasShortcuts({
|
||||
nodes,
|
||||
edges,
|
||||
setNodes,
|
||||
setEdges,
|
||||
setIsDirty: (v: boolean) => setIsDirty(v),
|
||||
canvasRef,
|
||||
})
|
||||
|
||||
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,
|
||||
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))
|
||||
} catch {
|
||||
toast.error('Failed to load diagram')
|
||||
navigate('/network-diagrams')
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false)
|
||||
}
|
||||
})()
|
||||
return () => { cancelled = true }
|
||||
}, [id, navigate, setNodes, setEdges])
|
||||
|
||||
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
|
||||
return {
|
||||
id: n.id,
|
||||
type: data.deviceType,
|
||||
label: data.label,
|
||||
position: n.position,
|
||||
properties: data.properties,
|
||||
}
|
||||
})
|
||||
}, [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 string || null,
|
||||
}
|
||||
})
|
||||
}, [edges])
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
setIsSaving(true)
|
||||
try {
|
||||
const payload = {
|
||||
name,
|
||||
client_name: clientName,
|
||||
asset_name: assetName,
|
||||
description,
|
||||
nodes: serializeNodes(),
|
||||
edges: serializeEdges(),
|
||||
}
|
||||
if (diagramIdRef.current) {
|
||||
await networkDiagramsApi.update(diagramIdRef.current, payload)
|
||||
} else {
|
||||
const created = await networkDiagramsApi.create(payload)
|
||||
setDiagramId(created.id)
|
||||
navigate(`/network-diagrams/${created.id}`, { replace: true })
|
||||
}
|
||||
setIsDirty(false)
|
||||
setLastSavedAt(new Date())
|
||||
} catch {
|
||||
toast.error('Failed to save diagram')
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
}
|
||||
}, [name, clientName, assetName, description, serializeNodes, serializeEdges, navigate])
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
if (isDirtyRef.current && diagramIdRef.current) {
|
||||
handleSave()
|
||||
}
|
||||
}, 30_000)
|
||||
return () => clearInterval(interval)
|
||||
}, [handleSave])
|
||||
|
||||
const onConnect = useCallback((connection: Connection) => {
|
||||
setEdges(eds => addEdge({
|
||||
...connection,
|
||||
type: 'connection',
|
||||
data: { connectionType: 'ethernet' },
|
||||
}, eds))
|
||||
setIsDirty(true)
|
||||
}, [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()
|
||||
setContextMenu({
|
||||
type: 'canvas',
|
||||
position: { x: event.clientX, y: event.clientY },
|
||||
})
|
||||
}, [])
|
||||
|
||||
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,
|
||||
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,
|
||||
}
|
||||
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,
|
||||
},
|
||||
}
|
||||
setNodes(nds => [...nds, newNode])
|
||||
setIsDirty(true)
|
||||
}
|
||||
}, [setNodes, screenToFlowPosition])
|
||||
|
||||
const handleNodeUpdate = useCallback((nodeId: string, updates: Partial<DeviceNodeData>) => {
|
||||
setNodes(nds => nds.map(n => {
|
||||
if (n.id !== nodeId) return n
|
||||
return { ...n, data: { ...n.data, ...updates } }
|
||||
}))
|
||||
setIsDirty(true)
|
||||
}, [setNodes])
|
||||
|
||||
const handleEdgeUpdate = useCallback((edgeId: string, updates: Partial<DiagramEdge>) => {
|
||||
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)
|
||||
}, [setEdges])
|
||||
|
||||
const handleEdgeTypeChange = useCallback((edgeId: string, edgeType: string) => {
|
||||
setEdges(eds => eds.map(e => {
|
||||
if (e.id !== edgeId) return e
|
||||
return { ...e, type: edgeType }
|
||||
}))
|
||||
setIsDirty(true)
|
||||
}, [setEdges])
|
||||
|
||||
const handleDeleteNode = useCallback((nodeId: string) => {
|
||||
setNodes(nds => nds.filter(n => n.id !== nodeId))
|
||||
setEdges(eds => eds.filter(e => e.source !== nodeId && e.target !== nodeId))
|
||||
setSelectedNodeId(null)
|
||||
setIsDirty(true)
|
||||
}, [setNodes, setEdges])
|
||||
|
||||
const handleDeleteEdge = useCallback((edgeId: string) => {
|
||||
setEdges(eds => eds.filter(e => e.id !== edgeId))
|
||||
setSelectedEdgeId(null)
|
||||
setIsDirty(true)
|
||||
}, [setEdges])
|
||||
|
||||
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 },
|
||||
}))
|
||||
|
||||
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)
|
||||
}, [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 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])
|
||||
|
||||
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}
|
||||
isSaving={isSaving}
|
||||
lastSavedAt={lastSavedAt}
|
||||
diagramId={diagramId}
|
||||
onNameChange={n => { setName(n); setIsDirty(true) }}
|
||||
onSave={handleSave}
|
||||
onExportPng={handleExportPng}
|
||||
onExportPdf={handleExportPdf}
|
||||
onExportJson={handleExportJson}
|
||||
/>
|
||||
<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}
|
||||
onNodeSelect={setSelectedNodeId}
|
||||
onEdgeSelect={setSelectedEdgeId}
|
||||
onDrop={onDrop}
|
||||
onDragOver={onDragOver}
|
||||
onDragLeave={onDragLeave}
|
||||
isDragOver={isDragOver}
|
||||
onNodeContextMenu={handleNodeContextMenu}
|
||||
onPaneContextMenu={handlePaneContextMenu}
|
||||
onPaneClick={closeContextMenu}
|
||||
/>
|
||||
{nodes.length === 0 && !loading && (
|
||||
<CanvasEmptyPrompt onGenerate={handleAIGenerate} />
|
||||
)}
|
||||
</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}
|
||||
onDeleteNode={handleDeleteNode}
|
||||
onDeleteEdge={handleDeleteEdge}
|
||||
/>
|
||||
</div>
|
||||
{contextMenu && (
|
||||
<ContextMenu
|
||||
position={contextMenu.position}
|
||||
actions={
|
||||
contextMenu.type === 'node'
|
||||
? getNodeMenuActions({
|
||||
onCopy: copyNodes,
|
||||
onDuplicate: duplicateNodes,
|
||||
onDelete: deleteSelected,
|
||||
})
|
||||
: getCanvasMenuActions({
|
||||
onPaste: pasteNodes,
|
||||
onSelectAll: selectAll,
|
||||
onFitView: () => fitView({ padding: 0.2 }),
|
||||
hasClipboard: hasClipboard(),
|
||||
})
|
||||
}
|
||||
onClose={closeContextMenu}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function DiagramEditor() {
|
||||
return (
|
||||
<ReactFlowProvider>
|
||||
<DiagramEditorInner />
|
||||
</ReactFlowProvider>
|
||||
)
|
||||
}
|
||||
299
frontend/src/pages/NetworkDiagrams/index.tsx
Normal file
299
frontend/src/pages/NetworkDiagrams/index.tsx
Normal file
@@ -0,0 +1,299 @@
|
||||
import { useState, useEffect, useCallback, useMemo, useRef } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Plus, Search, Network, MoreHorizontal, Upload, ChevronDown } from 'lucide-react'
|
||||
import { 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)
|
||||
return (
|
||||
<div className="flex h-1 w-full overflow-hidden rounded-full">
|
||||
{sorted.map(([cat, count]) => (
|
||||
<div
|
||||
key={cat}
|
||||
title={`${cat}: ${count}`}
|
||||
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 clientDropdownRef = useRef<HTMLDivElement>(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])
|
||||
|
||||
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)
|
||||
}, [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 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">
|
||||
<button
|
||||
onClick={handleImport}
|
||||
className="flex items-center gap-1.5 rounded border border-default px-3 py-2 text-sm text-primary hover:border-hover"
|
||||
>
|
||||
<Upload size={14} />
|
||||
Import
|
||||
</button>
|
||||
<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="flex flex-col items-center justify-center py-20">
|
||||
<Network size={48} className="mb-4 text-muted-foreground" />
|
||||
<h2 className="font-heading text-lg font-semibold text-heading">No network maps yet</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">Create your first network diagram to get started</p>
|
||||
<button
|
||||
onClick={() => navigate('/network-diagrams/new')}
|
||||
className="mt-4 rounded bg-accent px-4 py-2 text-sm font-medium text-white hover:bg-accent/90"
|
||||
>
|
||||
Create First Diagram
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!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>
|
||||
)}
|
||||
{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-36 rounded border border-default bg-card py-1 shadow-lg">
|
||||
<button
|
||||
onClick={e => { e.stopPropagation(); navigate(`/network-diagrams/${d.id}`) }}
|
||||
className="w-full px-3 py-1.5 text-left text-xs text-primary hover:bg-elevated"
|
||||
>
|
||||
Open
|
||||
</button>
|
||||
<button
|
||||
onClick={e => { e.stopPropagation(); handleDuplicate(d.id) }}
|
||||
className="w-full px-3 py-1.5 text-left text-xs text-primary hover:bg-elevated"
|
||||
>
|
||||
Duplicate
|
||||
</button>
|
||||
<button
|
||||
onClick={e => { e.stopPropagation(); handleArchive(d.id) }}
|
||||
className="w-full px-3 py-1.5 text-left text-xs text-red-400 hover:bg-elevated"
|
||||
>
|
||||
Archive
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -6,6 +6,8 @@ import { ActiveFlowPilotSessions } from '@/components/dashboard/ActiveFlowPilotS
|
||||
import { PerformanceCards } from '@/components/dashboard/PerformanceCards'
|
||||
import { KnowledgeBaseCards } from '@/components/dashboard/KnowledgeBaseCards'
|
||||
import { TeamSummary } from '@/components/dashboard/TeamSummary'
|
||||
import { ViewToggle } from '@/components/assistant/ViewToggle'
|
||||
import { useUserPreferencesStore } from '@/store/userPreferencesStore'
|
||||
|
||||
function SectionLabel({ children, action }: { children: React.ReactNode; action?: React.ReactNode }) {
|
||||
return (
|
||||
@@ -21,6 +23,7 @@ function SectionLabel({ children, action }: { children: React.ReactNode; action?
|
||||
|
||||
export function QuickStartPage() {
|
||||
const user = useAuthStore((s) => s.user)
|
||||
const preferredView = useUserPreferencesStore(s => s.preferredFlowPilotView)
|
||||
|
||||
const now = new Date()
|
||||
const greeting = now.getHours() < 12
|
||||
@@ -46,6 +49,12 @@ export function QuickStartPage() {
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
{/* View preference — standalone row above input */}
|
||||
<div className="flex items-center gap-3 mb-5">
|
||||
<span className="text-xs text-muted-foreground">Launch in</span>
|
||||
<ViewToggle currentView={preferredView} />
|
||||
</div>
|
||||
|
||||
{/* Chat-style input */}
|
||||
<StartSessionInput />
|
||||
|
||||
|
||||
@@ -624,7 +624,7 @@ export default function SessionHistoryPage() {
|
||||
ref={closePopoverRef}
|
||||
className="absolute right-0 top-full z-20 mt-2 w-72 rounded-xl border border-border bg-card p-4 shadow-xl"
|
||||
>
|
||||
<p className="text-sm font-heading font-medium text-foreground mb-3">Close Session</p>
|
||||
<p className="text-sm font-heading font-medium text-foreground mb-3">Close</p>
|
||||
|
||||
<label className="block text-[0.625rem] font-sans uppercase tracking-[0.1em] text-muted-foreground mb-1">Outcome</label>
|
||||
<select
|
||||
|
||||
633
frontend/src/pages/admin/AccountDetailPage.tsx
Normal file
633
frontend/src/pages/admin/AccountDetailPage.tsx
Normal file
@@ -0,0 +1,633 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useNavigate, useParams } from 'react-router-dom'
|
||||
import {
|
||||
ArrowLeft,
|
||||
Building2,
|
||||
CalendarClock,
|
||||
Check,
|
||||
Copy,
|
||||
Crown,
|
||||
Loader2,
|
||||
Mail,
|
||||
Pencil,
|
||||
UserCheck,
|
||||
UserPlus,
|
||||
UserX,
|
||||
X,
|
||||
} from 'lucide-react'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { Input } from '@/components/ui/Input'
|
||||
import { Modal } from '@/components/common/Modal'
|
||||
import { EmptyState, StatusBadge } from '@/components/admin'
|
||||
import { ConfirmButton } from '@/components/common/ConfirmButton'
|
||||
import { adminApi } from '@/api/admin'
|
||||
import { toast } from '@/lib/toast'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { AdminAccountDetailResponse, AdminAccountMember } from '@/types/admin'
|
||||
|
||||
function formatDate(value: string | null) {
|
||||
if (!value) return 'Never'
|
||||
return new Date(value).toLocaleDateString()
|
||||
}
|
||||
|
||||
export function AccountDetailPage() {
|
||||
const { accountId } = useParams<{ accountId: string }>()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const [account, setAccount] = useState<AdminAccountDetailResponse | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [isEditingName, setIsEditingName] = useState(false)
|
||||
const [editedName, setEditedName] = useState('')
|
||||
const [savingName, setSavingName] = useState(false)
|
||||
|
||||
const [showCreateUserModal, setShowCreateUserModal] = useState(false)
|
||||
const [createForm, setCreateForm] = useState({
|
||||
email: '',
|
||||
name: '',
|
||||
account_role: 'engineer' as 'engineer' | 'viewer',
|
||||
send_email: true,
|
||||
})
|
||||
const [createLoading, setCreateLoading] = useState(false)
|
||||
const [tempPassword, setTempPassword] = useState<string | null>(null)
|
||||
const [copiedPassword, setCopiedPassword] = useState(false)
|
||||
|
||||
const [showInviteModal, setShowInviteModal] = useState(false)
|
||||
const [inviteForm, setInviteForm] = useState({
|
||||
email: '',
|
||||
role: 'engineer' as 'engineer' | 'viewer',
|
||||
})
|
||||
const [inviteLoading, setInviteLoading] = useState(false)
|
||||
|
||||
const [editingPlan, setEditingPlan] = useState(false)
|
||||
const [selectedPlan, setSelectedPlan] = useState('free')
|
||||
const [planSaving, setPlanSaving] = useState(false)
|
||||
|
||||
const [editingTrial, setEditingTrial] = useState(false)
|
||||
const [trialDays, setTrialDays] = useState('14')
|
||||
const [trialSaving, setTrialSaving] = useState(false)
|
||||
|
||||
const loadAccount = useCallback(async () => {
|
||||
if (!accountId) return
|
||||
setLoading(true)
|
||||
try {
|
||||
const data = await adminApi.getAccountDetail(accountId)
|
||||
setAccount(data)
|
||||
setEditedName(data.name)
|
||||
setSelectedPlan(data.subscription?.plan ?? 'free')
|
||||
} catch {
|
||||
toast.error('Failed to load account')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [accountId])
|
||||
|
||||
useEffect(() => {
|
||||
loadAccount()
|
||||
}, [loadAccount])
|
||||
|
||||
const handleSaveName = async () => {
|
||||
if (!account || !editedName.trim() || editedName.trim() === account.name) {
|
||||
setIsEditingName(false)
|
||||
return
|
||||
}
|
||||
setSavingName(true)
|
||||
try {
|
||||
const updated = await adminApi.updateAccount(account.id, { name: editedName.trim() })
|
||||
setAccount(updated)
|
||||
setEditedName(updated.name)
|
||||
setIsEditingName(false)
|
||||
toast.success('Account updated')
|
||||
} catch {
|
||||
toast.error('Failed to update account')
|
||||
} finally {
|
||||
setSavingName(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCreateUser = async () => {
|
||||
if (!account || !createForm.email || !createForm.name) return
|
||||
setCreateLoading(true)
|
||||
try {
|
||||
const result = await adminApi.createUser({
|
||||
email: createForm.email,
|
||||
name: createForm.name,
|
||||
account_mode: 'existing',
|
||||
account_display_code: account.display_code,
|
||||
account_role: createForm.account_role,
|
||||
send_email: createForm.send_email,
|
||||
})
|
||||
setShowCreateUserModal(false)
|
||||
setCreateForm({ email: '', name: '', account_role: 'engineer', send_email: true })
|
||||
setTempPassword(result.temporary_password)
|
||||
setCopiedPassword(false)
|
||||
toast.success(result.email_sent ? 'User created and welcome email sent' : 'User created')
|
||||
loadAccount()
|
||||
} catch (err: unknown) {
|
||||
if (err && typeof err === 'object' && 'response' in err) {
|
||||
const axiosErr = err as { response?: { data?: { detail?: string } } }
|
||||
toast.error(axiosErr.response?.data?.detail || 'Failed to create user')
|
||||
} else {
|
||||
toast.error('Failed to create user')
|
||||
}
|
||||
} finally {
|
||||
setCreateLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleInviteUser = async () => {
|
||||
if (!account || !inviteForm.email) return
|
||||
setInviteLoading(true)
|
||||
try {
|
||||
await adminApi.createInvite({
|
||||
email: inviteForm.email,
|
||||
account_display_code: account.display_code,
|
||||
role: inviteForm.role,
|
||||
})
|
||||
toast.success('Invite sent')
|
||||
setInviteForm({ email: '', role: 'engineer' })
|
||||
setShowInviteModal(false)
|
||||
loadAccount()
|
||||
} catch (err: unknown) {
|
||||
if (err && typeof err === 'object' && 'response' in err) {
|
||||
const axiosErr = err as { response?: { data?: { detail?: string } } }
|
||||
toast.error(axiosErr.response?.data?.detail || 'Failed to send invite')
|
||||
} else {
|
||||
toast.error('Failed to send invite')
|
||||
}
|
||||
} finally {
|
||||
setInviteLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleUpdateMemberRole = async (member: AdminAccountMember, nextRole: string) => {
|
||||
try {
|
||||
await adminApi.updateAccountRole(member.id, nextRole)
|
||||
toast.success(`Updated ${member.name}`)
|
||||
loadAccount()
|
||||
} catch {
|
||||
toast.error('Failed to update account role')
|
||||
}
|
||||
}
|
||||
|
||||
const handleToggleActive = async (member: AdminAccountMember) => {
|
||||
try {
|
||||
if (member.is_active) {
|
||||
await adminApi.deactivateUser(member.id)
|
||||
toast.success('User deactivated')
|
||||
} else {
|
||||
await adminApi.activateUser(member.id)
|
||||
toast.success('User activated')
|
||||
}
|
||||
loadAccount()
|
||||
} catch {
|
||||
toast.error('Failed to update user status')
|
||||
}
|
||||
}
|
||||
|
||||
const handleUpdatePlan = async () => {
|
||||
if (!account) return
|
||||
setPlanSaving(true)
|
||||
try {
|
||||
await adminApi.updateAccountSubscriptionPlan(account.id, selectedPlan)
|
||||
toast.success(`Plan updated to ${selectedPlan}`)
|
||||
setEditingPlan(false)
|
||||
loadAccount()
|
||||
} catch {
|
||||
toast.error('Failed to update plan')
|
||||
} finally {
|
||||
setPlanSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleExtendTrial = async () => {
|
||||
if (!account || !trialDays) return
|
||||
setTrialSaving(true)
|
||||
try {
|
||||
await adminApi.extendAccountTrial(account.id, parseInt(trialDays, 10))
|
||||
toast.success(`Trial updated by ${trialDays} days`)
|
||||
setEditingTrial(false)
|
||||
loadAccount()
|
||||
} catch {
|
||||
toast.error('Failed to update trial')
|
||||
} finally {
|
||||
setTrialSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const copyDisplayCode = async () => {
|
||||
if (!account) return
|
||||
await navigator.clipboard.writeText(account.display_code)
|
||||
toast.success('Display code copied')
|
||||
}
|
||||
|
||||
const copyTempPassword = async () => {
|
||||
if (!tempPassword) return
|
||||
await navigator.clipboard.writeText(tempPassword)
|
||||
setCopiedPassword(true)
|
||||
setTimeout(() => setCopiedPassword(false), 2000)
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!account) {
|
||||
return (
|
||||
<EmptyState
|
||||
title="Account not found"
|
||||
description="This account may have been removed or is unavailable."
|
||||
action={<Button variant="secondary" onClick={() => navigate('/admin/accounts')}>Back to Accounts</Button>}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
onClick={() => navigate('/admin/accounts')}
|
||||
className="rounded-md border border-border p-2 text-muted-foreground hover:bg-elevated hover:text-foreground"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</button>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-3">
|
||||
<Building2 className="h-6 w-6 text-muted-foreground" />
|
||||
<h1 className="truncate text-2xl font-bold text-foreground">{account.name}</h1>
|
||||
<StatusBadge variant="default" title="Unique code for joining this account">{account.display_code}</StatusBadge>
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Manage account settings, subscription, invites, and users from one place.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<Button variant="secondary" onClick={() => setShowInviteModal(true)}>
|
||||
<Mail className="h-4 w-4" />
|
||||
Invite User
|
||||
</Button>
|
||||
<Button onClick={() => setShowCreateUserModal(true)}>
|
||||
<UserPlus className="h-4 w-4" />
|
||||
Create User
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-[1.1fr_0.9fr]">
|
||||
<section className="space-y-6">
|
||||
<div className="rounded-2xl border border-border bg-card p-5">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<h2 className="text-lg font-semibold text-foreground">Account Settings</h2>
|
||||
<Button variant="secondary" size="sm" onClick={copyDisplayCode}>
|
||||
<Copy className="h-4 w-4" />
|
||||
Copy Code
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 space-y-5">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-foreground">Account Name</label>
|
||||
{isEditingName ? (
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<Input value={editedName} onChange={(e) => setEditedName(e.target.value)} />
|
||||
<Button onClick={handleSaveName} loading={savingName} size="icon-sm">
|
||||
<Check className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon-sm"
|
||||
onClick={() => {
|
||||
setEditedName(account.name)
|
||||
setIsEditingName(false)
|
||||
}}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<span className="text-sm text-foreground">{account.name}</span>
|
||||
<button
|
||||
onClick={() => setIsEditingName(true)}
|
||||
className="rounded px-1.5 py-0.5 text-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="rounded-xl border border-border bg-card/50 p-4">
|
||||
<p className="text-xs uppercase tracking-[0.14em] text-muted-foreground">Owner</p>
|
||||
<p className="mt-2 text-sm text-foreground">{account.owner?.name ?? 'Unassigned'}</p>
|
||||
<p className="text-xs text-muted-foreground">{account.owner?.email ?? 'No owner user yet'}</p>
|
||||
</div>
|
||||
<div className="rounded-xl border border-border bg-card/50 p-4">
|
||||
<p className="text-xs uppercase tracking-[0.14em] text-muted-foreground">Created</p>
|
||||
<p className="mt-2 text-sm text-foreground">{formatDate(account.created_at)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border border-border bg-card p-5">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<h2 className="text-lg font-semibold text-foreground">Users</h2>
|
||||
<StatusBadge variant="default">{account.member_count} members</StatusBadge>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 space-y-3">
|
||||
{account.members.length > 0 ? (
|
||||
account.members.map((member) => (
|
||||
<div key={member.id} className="rounded-xl border border-border bg-card/50 p-4">
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<p className="font-medium text-foreground">{member.name}</p>
|
||||
<p className="text-sm text-muted-foreground">{member.email}</p>
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
<StatusBadge variant="default">{member.role}</StatusBadge>
|
||||
{member.account_role && <StatusBadge variant="default">{member.account_role}</StatusBadge>}
|
||||
<StatusBadge variant={member.is_active ? 'success' : 'destructive'}>
|
||||
{member.is_active ? 'Active' : 'Inactive'}
|
||||
</StatusBadge>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<select
|
||||
value={member.account_role ?? 'engineer'}
|
||||
onChange={(e) => handleUpdateMemberRole(member, e.target.value)}
|
||||
className={cn(
|
||||
'rounded-md border border-border bg-card px-3 py-2 text-sm text-foreground',
|
||||
'focus:outline-hidden focus:border-primary focus:ring-2 focus:ring-primary/20'
|
||||
)}
|
||||
>
|
||||
<option value="engineer">Engineer</option>
|
||||
<option value="viewer">Viewer</option>
|
||||
</select>
|
||||
{member.is_active ? (
|
||||
<ConfirmButton
|
||||
onConfirm={() => handleToggleActive(member)}
|
||||
confirmLabel="Confirm deactivate?"
|
||||
className="inline-flex items-center gap-1.5 rounded-md border border-border bg-card px-3 py-1.5 text-sm font-medium text-foreground transition-colors hover:bg-elevated"
|
||||
confirmClassName="inline-flex items-center rounded-md border border-danger/30 bg-danger-dim px-3 py-1.5 text-sm font-medium text-danger transition-colors"
|
||||
>
|
||||
<UserX className="h-4 w-4" />
|
||||
Deactivate
|
||||
</ConfirmButton>
|
||||
) : (
|
||||
<Button variant="secondary" size="sm" onClick={() => handleToggleActive(member)}>
|
||||
<UserCheck className="h-4 w-4" />
|
||||
Activate
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="secondary" size="sm" onClick={() => navigate(`/admin/users/${member.id}`)}>
|
||||
View User
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="rounded-xl border border-dashed border-border px-4 py-6 text-center text-sm text-muted-foreground">
|
||||
<p>No users yet.</p>
|
||||
<p className="mt-1">Use <strong className="text-foreground">Create User</strong> or <strong className="text-foreground">Invite User</strong> above to add members.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<aside className="space-y-6">
|
||||
<div className="rounded-2xl border border-border bg-card p-5">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h2 className="text-lg font-semibold text-foreground">Subscription</h2>
|
||||
{account.subscription ? (
|
||||
<div className="flex gap-2">
|
||||
<StatusBadge variant="default">{account.subscription.plan}</StatusBadge>
|
||||
<StatusBadge variant={account.subscription.status === 'active' ? 'success' : account.subscription.status === 'canceled' ? 'destructive' : 'warning'}>
|
||||
{account.subscription.status}
|
||||
</StatusBadge>
|
||||
</div>
|
||||
) : (
|
||||
<StatusBadge variant="warning">No subscription</StatusBadge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid gap-3 sm:grid-cols-2">
|
||||
<div className="rounded-xl border border-border bg-card/50 p-4">
|
||||
<p className="text-xs uppercase tracking-[0.14em] text-muted-foreground">Renewal</p>
|
||||
<p className="mt-2 text-sm text-foreground">{formatDate(account.subscription?.current_period_end ?? null)}</p>
|
||||
</div>
|
||||
<div className="rounded-xl border border-border bg-card/50 p-4">
|
||||
<p className="text-xs uppercase tracking-[0.14em] text-muted-foreground">Usage</p>
|
||||
<p className="mt-2 text-sm text-foreground">{account.usage.tree_count} flows · {account.usage.session_count_this_month} sessions</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{editingPlan ? (
|
||||
<div className="mt-4 flex items-center gap-2">
|
||||
<select
|
||||
value={selectedPlan}
|
||||
onChange={(e) => setSelectedPlan(e.target.value)}
|
||||
className={cn(
|
||||
'h-9 rounded-md border border-border bg-card px-3 text-sm text-foreground',
|
||||
'focus:outline-hidden focus:border-primary focus:ring-2 focus:ring-primary/20'
|
||||
)}
|
||||
>
|
||||
<option value="free">Free</option>
|
||||
<option value="pro">Pro</option>
|
||||
<option value="team">Team</option>
|
||||
</select>
|
||||
<Button size="sm" onClick={handleUpdatePlan} loading={planSaving}>Save</Button>
|
||||
<Button variant="secondary" size="sm" onClick={() => setEditingPlan(false)}>Cancel</Button>
|
||||
</div>
|
||||
) : editingTrial ? (
|
||||
<div className="mt-4 flex items-center gap-2">
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={90}
|
||||
value={trialDays}
|
||||
onChange={(e) => setTrialDays(e.target.value)}
|
||||
className="w-24"
|
||||
placeholder="Days"
|
||||
/>
|
||||
<Button size="sm" onClick={handleExtendTrial} loading={trialSaving}>Save</Button>
|
||||
<Button variant="secondary" size="sm" onClick={() => setEditingTrial(false)}>Cancel</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setSelectedPlan(account.subscription?.plan ?? 'free')
|
||||
setEditingPlan(true)
|
||||
}}
|
||||
>
|
||||
<Crown className="h-4 w-4" />
|
||||
Change Plan
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setTrialDays('14')
|
||||
setEditingTrial(true)
|
||||
}}
|
||||
>
|
||||
<CalendarClock className="h-4 w-4" />
|
||||
Extend Trial
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border border-border bg-card p-5">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h2 className="text-lg font-semibold text-foreground">Pending Invites</h2>
|
||||
{account.pending_invite_count > 0 && (
|
||||
<StatusBadge variant="warning">{account.pending_invite_count} pending</StatusBadge>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-4 space-y-3">
|
||||
{account.invites.length > 0 ? (
|
||||
account.invites.map((invite) => (
|
||||
<div key={invite.id} className="rounded-xl border border-border bg-card/50 p-4">
|
||||
<p className="font-medium text-foreground">{invite.email}</p>
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
<StatusBadge variant="default">{invite.role}</StatusBadge>
|
||||
<StatusBadge variant="default">Expires {formatDate(invite.expires_at)}</StatusBadge>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="rounded-xl border border-dashed border-border px-4 py-6 text-center text-sm text-muted-foreground">
|
||||
<p>No pending invites.</p>
|
||||
<p className="mt-1">Use <strong className="text-foreground">Invite User</strong> above to send an invitation.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
isOpen={showCreateUserModal}
|
||||
onClose={() => setShowCreateUserModal(false)}
|
||||
title="Create User in Account"
|
||||
size="sm"
|
||||
footer={(
|
||||
<div className="flex justify-end gap-3">
|
||||
<Button variant="secondary" onClick={() => setShowCreateUserModal(false)}>Cancel</Button>
|
||||
<Button onClick={handleCreateUser} disabled={!createForm.email || !createForm.name} loading={createLoading}>
|
||||
{createLoading ? 'Creating...' : 'Create User'}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-foreground">Name</label>
|
||||
<Input value={createForm.name} onChange={(e) => setCreateForm((f) => ({ ...f, name: e.target.value }))} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-foreground">Email</label>
|
||||
<Input type="email" value={createForm.email} onChange={(e) => setCreateForm((f) => ({ ...f, email: e.target.value }))} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-foreground">Account Role</label>
|
||||
<select
|
||||
value={createForm.account_role}
|
||||
onChange={(e) => setCreateForm((f) => ({ ...f, account_role: e.target.value as 'engineer' | 'viewer' }))}
|
||||
className={cn(
|
||||
'w-full rounded-md border border-border bg-card px-3 py-2 text-sm text-foreground',
|
||||
'focus:outline-hidden focus:border-primary focus:ring-2 focus:ring-primary/20'
|
||||
)}
|
||||
>
|
||||
<option value="engineer">Engineer</option>
|
||||
<option value="viewer">Viewer</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={createForm.send_email}
|
||||
onChange={(e) => setCreateForm((f) => ({ ...f, send_email: e.target.checked }))}
|
||||
className="rounded border-border bg-card"
|
||||
/>
|
||||
<label className="text-sm text-muted-foreground">Send welcome email with temporary password</label>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
isOpen={showInviteModal}
|
||||
onClose={() => setShowInviteModal(false)}
|
||||
title="Invite User to Account"
|
||||
size="sm"
|
||||
footer={(
|
||||
<div className="flex justify-end gap-3">
|
||||
<Button variant="secondary" onClick={() => setShowInviteModal(false)}>Cancel</Button>
|
||||
<Button onClick={handleInviteUser} disabled={!inviteForm.email} loading={inviteLoading}>
|
||||
{inviteLoading ? 'Sending...' : 'Send Invite'}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-foreground">Email</label>
|
||||
<Input type="email" value={inviteForm.email} onChange={(e) => setInviteForm((f) => ({ ...f, email: e.target.value }))} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-foreground">Role</label>
|
||||
<select
|
||||
value={inviteForm.role}
|
||||
onChange={(e) => setInviteForm((f) => ({ ...f, role: e.target.value as 'engineer' | 'viewer' }))}
|
||||
className={cn(
|
||||
'w-full rounded-md border border-border bg-card px-3 py-2 text-sm text-foreground',
|
||||
'focus:outline-hidden focus:border-primary focus:ring-2 focus:ring-primary/20'
|
||||
)}
|
||||
>
|
||||
<option value="engineer">Engineer</option>
|
||||
<option value="viewer">Viewer</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
isOpen={!!tempPassword}
|
||||
onClose={() => setTempPassword(null)}
|
||||
title="User Created"
|
||||
size="sm"
|
||||
footer={<div className="flex justify-end"><Button onClick={() => setTempPassword(null)}>Done</Button></div>}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-xl border border-yellow-400/20 bg-yellow-400/10 p-3 text-sm text-yellow-400">
|
||||
This password will not be shown again. Copy it now.
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="flex-1 rounded-md border border-border bg-card px-3 py-2 font-mono text-sm text-foreground">
|
||||
{tempPassword}
|
||||
</code>
|
||||
<button
|
||||
onClick={copyTempPassword}
|
||||
className="rounded-md border border-border p-2 text-muted-foreground transition-colors hover:bg-elevated hover:text-foreground"
|
||||
>
|
||||
{copiedPassword ? <Check className="h-4 w-4 text-green-400" /> : <Copy className="h-4 w-4" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default AccountDetailPage
|
||||
821
frontend/src/pages/admin/AccountsPage.tsx
Normal file
821
frontend/src/pages/admin/AccountsPage.tsx
Normal file
@@ -0,0 +1,821 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import {
|
||||
Building2,
|
||||
Check,
|
||||
Copy,
|
||||
ExternalLink,
|
||||
Loader2,
|
||||
Mail,
|
||||
Plus,
|
||||
Search,
|
||||
Sparkles,
|
||||
UserPlus,
|
||||
} from 'lucide-react'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { Input } from '@/components/ui/Input'
|
||||
import {
|
||||
DataTable,
|
||||
EmptyState,
|
||||
PageHeader,
|
||||
Pagination,
|
||||
SearchInput,
|
||||
StatusBadge,
|
||||
ActionMenu,
|
||||
type Column,
|
||||
} from '@/components/admin'
|
||||
import { Modal } from '@/components/common/Modal'
|
||||
import { adminApi } from '@/api/admin'
|
||||
import { toast } from '@/lib/toast'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type {
|
||||
AdminAccountListItem,
|
||||
AdminUserListItem,
|
||||
} from '@/types/admin'
|
||||
|
||||
function formatDate(value: string | null) {
|
||||
if (!value) return 'Never'
|
||||
return new Date(value).toLocaleDateString()
|
||||
}
|
||||
|
||||
function planBadgeVariant(status: string | undefined): 'success' | 'warning' | 'destructive' | 'default' {
|
||||
switch (status) {
|
||||
case 'active': return 'success'
|
||||
case 'trialing': return 'warning'
|
||||
case 'past_due': return 'warning'
|
||||
case 'canceled': return 'destructive'
|
||||
default: return 'default'
|
||||
}
|
||||
}
|
||||
|
||||
export function UsersPage() {
|
||||
const navigate = useNavigate()
|
||||
|
||||
const [accounts, setAccounts] = useState<AdminAccountListItem[]>([])
|
||||
const [accountsLoading, setAccountsLoading] = useState(true)
|
||||
const [accountSearch, setAccountSearch] = useState('')
|
||||
const [planFilter, setPlanFilter] = useState('all')
|
||||
const [statusFilter, setStatusFilter] = useState('all')
|
||||
const [page, setPage] = useState(1)
|
||||
const [total, setTotal] = useState(0)
|
||||
const accountPageSize = 12
|
||||
const [showArchived, setShowArchived] = useState(false)
|
||||
|
||||
const [people, setPeople] = useState<AdminUserListItem[]>([])
|
||||
const [peopleLoading, setPeopleLoading] = useState(false)
|
||||
const [peopleSearch, setPeopleSearch] = useState('')
|
||||
const [peoplePage, setPeoplePage] = useState(1)
|
||||
const [peopleTotal, setPeopleTotal] = useState(0)
|
||||
const peoplePageSize = 12
|
||||
|
||||
const [showCreateModal, setShowCreateModal] = useState(false)
|
||||
const [createForm, setCreateForm] = useState({
|
||||
email: '',
|
||||
name: '',
|
||||
account_mode: 'personal' as 'existing' | 'personal',
|
||||
account_display_code: '',
|
||||
account_role: 'engineer' as 'engineer' | 'viewer',
|
||||
send_email: true,
|
||||
})
|
||||
const [createLoading, setCreateLoading] = useState(false)
|
||||
const [tempPassword, setTempPassword] = useState<string | null>(null)
|
||||
const [copied, setCopied] = useState(false)
|
||||
const [showInviteModal, setShowInviteModal] = useState(false)
|
||||
const [inviteForm, setInviteForm] = useState({
|
||||
email: '',
|
||||
account_display_code: '',
|
||||
role: 'engineer' as 'engineer' | 'viewer',
|
||||
})
|
||||
const [inviteLoading, setInviteLoading] = useState(false)
|
||||
const [showCreateAccountModal, setShowCreateAccountModal] = useState(false)
|
||||
const [createAccountForm, setCreateAccountForm] = useState({ name: '', plan: 'free' as 'free' | 'pro' | 'team' })
|
||||
const [createAccountLoading, setCreateAccountLoading] = useState(false)
|
||||
|
||||
const fetchAccounts = useCallback(async () => {
|
||||
setAccountsLoading(true)
|
||||
try {
|
||||
const accountsData = await adminApi.listAccounts({
|
||||
page,
|
||||
size: accountPageSize,
|
||||
search: accountSearch || undefined,
|
||||
plan: planFilter !== 'all' ? planFilter : undefined,
|
||||
status: statusFilter !== 'all' ? statusFilter : undefined,
|
||||
include_archived: showArchived || undefined,
|
||||
})
|
||||
setAccounts(accountsData.items)
|
||||
setTotal(accountsData.total)
|
||||
} catch {
|
||||
toast.error('Failed to load accounts')
|
||||
} finally {
|
||||
setAccountsLoading(false)
|
||||
}
|
||||
}, [accountPageSize, accountSearch, page, planFilter, showArchived, statusFilter])
|
||||
|
||||
const fetchPeople = useCallback(async () => {
|
||||
if (!peopleSearch.trim()) {
|
||||
setPeopleLoading(false)
|
||||
setPeople([])
|
||||
setPeopleTotal(0)
|
||||
return
|
||||
}
|
||||
setPeopleLoading(true)
|
||||
try {
|
||||
const data = await adminApi.listUsers({
|
||||
page: peoplePage,
|
||||
size: peoplePageSize,
|
||||
search: peopleSearch || undefined,
|
||||
include_archived: showArchived || undefined,
|
||||
})
|
||||
setPeople(data.items)
|
||||
setPeopleTotal(data.total)
|
||||
} catch {
|
||||
toast.error('Failed to load people search')
|
||||
} finally {
|
||||
setPeopleLoading(false)
|
||||
}
|
||||
}, [peoplePage, peoplePageSize, peopleSearch, showArchived])
|
||||
|
||||
useEffect(() => {
|
||||
fetchAccounts()
|
||||
}, [fetchAccounts])
|
||||
|
||||
useEffect(() => {
|
||||
fetchPeople()
|
||||
}, [fetchPeople])
|
||||
|
||||
const handleCreateUser = async () => {
|
||||
if (!createForm.email || !createForm.name) return
|
||||
if (createForm.account_mode === 'existing' && !createForm.account_display_code) {
|
||||
toast.error('Account display code is required')
|
||||
return
|
||||
}
|
||||
setCreateLoading(true)
|
||||
try {
|
||||
const result = await adminApi.createUser({
|
||||
email: createForm.email,
|
||||
name: createForm.name,
|
||||
account_mode: createForm.account_mode,
|
||||
account_display_code: createForm.account_mode === 'existing' ? createForm.account_display_code : undefined,
|
||||
account_role: createForm.account_mode === 'existing' ? createForm.account_role : undefined,
|
||||
send_email: createForm.send_email,
|
||||
})
|
||||
setShowCreateModal(false)
|
||||
setTempPassword(result.temporary_password)
|
||||
setCopied(false)
|
||||
toast.success(result.email_sent ? 'User created and welcome email sent' : 'User created')
|
||||
setCreateForm({
|
||||
email: '',
|
||||
name: '',
|
||||
account_mode: 'personal',
|
||||
account_display_code: '',
|
||||
account_role: 'engineer',
|
||||
send_email: true,
|
||||
})
|
||||
fetchAccounts()
|
||||
} catch (err: unknown) {
|
||||
if (err && typeof err === 'object' && 'response' in err) {
|
||||
const axiosErr = err as { response?: { data?: { detail?: string } } }
|
||||
toast.error(axiosErr.response?.data?.detail || 'Failed to create user')
|
||||
} else {
|
||||
toast.error('Failed to create user')
|
||||
}
|
||||
} finally {
|
||||
setCreateLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCopyPassword = async () => {
|
||||
if (!tempPassword) return
|
||||
await navigator.clipboard.writeText(tempPassword)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
}
|
||||
|
||||
const handleInviteUser = async () => {
|
||||
if (!inviteForm.email || !inviteForm.account_display_code) return
|
||||
setInviteLoading(true)
|
||||
try {
|
||||
const result = await adminApi.createInvite({
|
||||
email: inviteForm.email,
|
||||
account_display_code: inviteForm.account_display_code,
|
||||
role: inviteForm.role,
|
||||
})
|
||||
setShowInviteModal(false)
|
||||
setInviteForm({ email: '', account_display_code: '', role: 'engineer' })
|
||||
toast.success(result.email_sent ? 'Invite sent' : 'Invite created (email not configured)')
|
||||
fetchAccounts()
|
||||
} catch (err: unknown) {
|
||||
if (err && typeof err === 'object' && 'response' in err) {
|
||||
const axiosErr = err as { response?: { data?: { detail?: string } } }
|
||||
toast.error(axiosErr.response?.data?.detail || 'Failed to send invite')
|
||||
} else {
|
||||
toast.error('Failed to send invite')
|
||||
}
|
||||
} finally {
|
||||
setInviteLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCreateAccount = async () => {
|
||||
if (!createAccountForm.name.trim()) return
|
||||
setCreateAccountLoading(true)
|
||||
try {
|
||||
const created = await adminApi.createAccount({
|
||||
name: createAccountForm.name.trim(),
|
||||
plan: createAccountForm.plan,
|
||||
})
|
||||
toast.success('Account created')
|
||||
setShowCreateAccountModal(false)
|
||||
setCreateAccountForm({ name: '', plan: 'free' })
|
||||
navigate(`/admin/accounts/${created.id}`)
|
||||
} catch {
|
||||
toast.error('Failed to create account')
|
||||
} finally {
|
||||
setCreateAccountLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const accountColumns: Column<AdminAccountListItem>[] = [
|
||||
{
|
||||
key: 'name',
|
||||
header: 'Account',
|
||||
render: (account) => (
|
||||
<div className="min-w-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate(`/admin/accounts/${account.id}`)}
|
||||
className="text-sm font-medium text-foreground hover:underline"
|
||||
>
|
||||
{account.name}
|
||||
</button>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||
{account.display_code}
|
||||
{account.owner ? ` · ${account.owner.name}` : ''}
|
||||
</p>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'plan',
|
||||
header: 'Plan',
|
||||
render: (account) => (
|
||||
<StatusBadge variant="default">
|
||||
{account.subscription?.plan ?? 'free'}
|
||||
</StatusBadge>
|
||||
),
|
||||
className: 'w-[100px]',
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
header: 'Status',
|
||||
render: (account) => {
|
||||
if (!account.subscription) {
|
||||
return <StatusBadge variant="warning">No subscription</StatusBadge>
|
||||
}
|
||||
return (
|
||||
<StatusBadge variant={planBadgeVariant(account.subscription.status)}>
|
||||
{account.subscription.status}
|
||||
</StatusBadge>
|
||||
)
|
||||
},
|
||||
className: 'w-[120px]',
|
||||
},
|
||||
{
|
||||
key: 'members',
|
||||
header: 'Members',
|
||||
render: (account) => (
|
||||
<span className="text-sm text-foreground">
|
||||
{account.active_member_count}
|
||||
<span className="text-muted-foreground"> / {account.member_count}</span>
|
||||
</span>
|
||||
),
|
||||
className: 'w-[100px]',
|
||||
},
|
||||
{
|
||||
key: 'usage',
|
||||
header: 'Usage',
|
||||
render: (account) => (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{account.usage.tree_count} flows · {account.usage.session_count_this_month} sessions
|
||||
</span>
|
||||
),
|
||||
className: 'w-[160px]',
|
||||
},
|
||||
{
|
||||
key: 'created',
|
||||
header: 'Created',
|
||||
render: (account) => (
|
||||
<span className="text-sm text-muted-foreground">{formatDate(account.created_at)}</span>
|
||||
),
|
||||
className: 'w-[100px]',
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
header: '',
|
||||
render: (account) => (
|
||||
<ActionMenu
|
||||
items={[
|
||||
{
|
||||
label: 'Manage Account',
|
||||
icon: <Building2 className="h-4 w-4" />,
|
||||
onClick: () => navigate(`/admin/accounts/${account.id}`),
|
||||
},
|
||||
...(account.owner ? [{
|
||||
label: 'View Owner',
|
||||
icon: <ExternalLink className="h-4 w-4" />,
|
||||
onClick: () => navigate(`/admin/users/${account.owner?.id}`),
|
||||
}] : []),
|
||||
]}
|
||||
/>
|
||||
),
|
||||
className: 'w-[48px]',
|
||||
},
|
||||
]
|
||||
|
||||
const peopleColumns: Column<AdminUserListItem>[] = [
|
||||
{
|
||||
key: 'name',
|
||||
header: 'Name',
|
||||
render: (user) => (
|
||||
<div className="min-w-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate(`/admin/users/${user.id}`)}
|
||||
className="text-sm font-medium text-foreground hover:underline"
|
||||
>
|
||||
{user.name}
|
||||
</button>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">{user.email}</p>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'role',
|
||||
header: 'Role',
|
||||
render: (user) => (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{user.is_super_admin && <StatusBadge variant="destructive">Super Admin</StatusBadge>}
|
||||
<StatusBadge variant="default">{user.role}</StatusBadge>
|
||||
</div>
|
||||
),
|
||||
className: 'w-[140px]',
|
||||
},
|
||||
{
|
||||
key: 'account',
|
||||
header: 'Account',
|
||||
render: (user) => (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{user.account_name || 'No account'}
|
||||
{user.account_display_code && (
|
||||
<span className="ml-1 text-xs opacity-60">{user.account_display_code}</span>
|
||||
)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
header: 'Status',
|
||||
render: (user) => (
|
||||
<div className="flex gap-1">
|
||||
<StatusBadge variant={user.is_active ? 'success' : 'destructive'}>
|
||||
{user.is_active ? 'Active' : 'Inactive'}
|
||||
</StatusBadge>
|
||||
{user.deleted_at && <StatusBadge variant="warning">Archived</StatusBadge>}
|
||||
</div>
|
||||
),
|
||||
className: 'w-[140px]',
|
||||
},
|
||||
{
|
||||
key: 'last_login',
|
||||
header: 'Last Login',
|
||||
render: (user) => (
|
||||
<span className="text-sm text-muted-foreground">{formatDate(user.last_login)}</span>
|
||||
),
|
||||
className: 'w-[100px]',
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
header: '',
|
||||
render: (user) => (
|
||||
<ActionMenu
|
||||
items={[
|
||||
{
|
||||
label: 'View Detail',
|
||||
icon: <ExternalLink className="h-4 w-4" />,
|
||||
onClick: () => navigate(`/admin/users/${user.id}`),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
),
|
||||
className: 'w-[48px]',
|
||||
},
|
||||
]
|
||||
|
||||
const accountTotalPages = Math.max(1, Math.ceil(total / accountPageSize))
|
||||
const peopleTotalPages = Math.max(1, Math.ceil(peopleTotal / peoplePageSize))
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="Accounts"
|
||||
description="Manage customer accounts, subscriptions, and users."
|
||||
action={
|
||||
<div className="flex items-center gap-3">
|
||||
<Button variant="secondary" onClick={() => setShowCreateAccountModal(true)}>
|
||||
<Plus className="h-4 w-4" />
|
||||
Create Account
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={() => setShowInviteModal(true)}>
|
||||
<Mail className="h-4 w-4" />
|
||||
Invite User
|
||||
</Button>
|
||||
<Button onClick={() => setShowCreateModal(true)}>
|
||||
<UserPlus className="h-4 w-4" />
|
||||
Create User
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<SearchInput
|
||||
value={accountSearch}
|
||||
onSearch={(value) => {
|
||||
setAccountSearch(value)
|
||||
setPage(1)
|
||||
}}
|
||||
placeholder="Search accounts, owners, or codes..."
|
||||
className="w-full sm:max-w-sm"
|
||||
/>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<select
|
||||
value={planFilter}
|
||||
onChange={(e) => {
|
||||
setPlanFilter(e.target.value)
|
||||
setPage(1)
|
||||
}}
|
||||
className={cn(
|
||||
'h-9 rounded-md border border-border bg-card px-3 text-sm text-foreground',
|
||||
'focus:outline-hidden focus:border-primary focus:ring-2 focus:ring-primary/20'
|
||||
)}
|
||||
>
|
||||
<option value="all">All plans</option>
|
||||
<option value="free">Free</option>
|
||||
<option value="pro">Pro</option>
|
||||
<option value="team">Team</option>
|
||||
</select>
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(e) => {
|
||||
setStatusFilter(e.target.value)
|
||||
setPage(1)
|
||||
}}
|
||||
className={cn(
|
||||
'h-9 rounded-md border border-border bg-card px-3 text-sm text-foreground',
|
||||
'focus:outline-hidden focus:border-primary focus:ring-2 focus:ring-primary/20'
|
||||
)}
|
||||
>
|
||||
<option value="all">All statuses</option>
|
||||
<option value="active">Active</option>
|
||||
<option value="trialing">Trialing</option>
|
||||
<option value="past_due">Past due</option>
|
||||
<option value="canceled">Canceled</option>
|
||||
<option value="orphaned">Orphaned</option>
|
||||
</select>
|
||||
<label className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showArchived}
|
||||
onChange={(e) => {
|
||||
setShowArchived(e.target.checked)
|
||||
setPage(1)
|
||||
setPeoplePage(1)
|
||||
}}
|
||||
className="rounded border-border bg-card"
|
||||
/>
|
||||
Archived
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Accounts table */}
|
||||
<section className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-sm font-medium text-muted-foreground">
|
||||
{accountsLoading ? 'Loading...' : `${total} accounts`}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
columns={accountColumns}
|
||||
data={accounts}
|
||||
keyExtractor={(a) => a.id}
|
||||
isLoading={accountsLoading}
|
||||
skeletonRows={6}
|
||||
emptyState={
|
||||
<EmptyState
|
||||
icon={<Building2 className="h-8 w-8" />}
|
||||
title="No accounts found"
|
||||
description="Adjust the filters or clear the search."
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
<Pagination
|
||||
page={page}
|
||||
totalPages={accountTotalPages}
|
||||
total={total}
|
||||
pageSize={accountPageSize}
|
||||
onPageChange={setPage}
|
||||
/>
|
||||
</section>
|
||||
|
||||
{/* Global people search */}
|
||||
<section className="space-y-4 rounded-xl border border-border bg-card p-5">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Search className="h-4 w-4 text-muted-foreground" />
|
||||
<h2 className="text-base font-semibold text-foreground">Global People Search</h2>
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Find a user across all accounts by name or email.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<SearchInput
|
||||
value={peopleSearch}
|
||||
onSearch={(value) => {
|
||||
setPeopleSearch(value)
|
||||
setPeoplePage(1)
|
||||
}}
|
||||
placeholder="Search by name, email, or account..."
|
||||
className="max-w-sm"
|
||||
/>
|
||||
|
||||
{peopleSearch.trim() ? (
|
||||
people.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
<DataTable
|
||||
columns={peopleColumns}
|
||||
data={people}
|
||||
keyExtractor={(p) => p.id}
|
||||
isLoading={peopleLoading}
|
||||
skeletonRows={4}
|
||||
emptyState={
|
||||
<EmptyState
|
||||
icon={<Sparkles className="h-8 w-8" />}
|
||||
title="No matching people"
|
||||
description="Try another name or email."
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Pagination
|
||||
page={peoplePage}
|
||||
totalPages={peopleTotalPages}
|
||||
total={peopleTotal}
|
||||
pageSize={peoplePageSize}
|
||||
onPageChange={setPeoplePage}
|
||||
/>
|
||||
</div>
|
||||
) : !peopleLoading ? (
|
||||
<EmptyState
|
||||
icon={<Sparkles className="h-8 w-8" />}
|
||||
title="No matching people"
|
||||
description="Try another name or email."
|
||||
/>
|
||||
) : (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Searching...
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">Type a name or email to search.</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Create Account modal */}
|
||||
<Modal
|
||||
isOpen={showCreateAccountModal}
|
||||
onClose={() => setShowCreateAccountModal(false)}
|
||||
title="Create Account"
|
||||
size="sm"
|
||||
footer={(
|
||||
<div className="flex justify-end gap-3">
|
||||
<Button variant="secondary" onClick={() => setShowCreateAccountModal(false)}>Cancel</Button>
|
||||
<Button onClick={handleCreateAccount} disabled={!createAccountForm.name.trim()} loading={createAccountLoading}>
|
||||
{createAccountLoading ? 'Creating...' : 'Create Account'}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-foreground">Account Name</label>
|
||||
<Input
|
||||
value={createAccountForm.name}
|
||||
onChange={(e) => setCreateAccountForm((form) => ({ ...form, name: e.target.value }))}
|
||||
placeholder="Acme MSP"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-foreground">Initial Plan</label>
|
||||
<select
|
||||
value={createAccountForm.plan}
|
||||
onChange={(e) => setCreateAccountForm((form) => ({ ...form, plan: e.target.value as 'free' | 'pro' | 'team' }))}
|
||||
className={cn(
|
||||
'w-full rounded-md border border-border bg-card px-3 py-2 text-sm text-foreground',
|
||||
'focus:outline-hidden focus:border-primary focus:ring-2 focus:ring-primary/20'
|
||||
)}
|
||||
>
|
||||
<option value="free">Free</option>
|
||||
<option value="pro">Pro</option>
|
||||
<option value="team">Team</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* Create User modal */}
|
||||
<Modal
|
||||
isOpen={showCreateModal}
|
||||
onClose={() => setShowCreateModal(false)}
|
||||
title="Create User"
|
||||
size="sm"
|
||||
footer={(
|
||||
<div className="flex justify-end gap-3">
|
||||
<Button variant="secondary" onClick={() => setShowCreateModal(false)}>Cancel</Button>
|
||||
<Button onClick={handleCreateUser} disabled={!createForm.email || !createForm.name} loading={createLoading}>
|
||||
{createLoading ? 'Creating...' : 'Create User'}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-foreground">Name</label>
|
||||
<Input
|
||||
type="text"
|
||||
value={createForm.name}
|
||||
onChange={(e) => setCreateForm((form) => ({ ...form, name: e.target.value }))}
|
||||
placeholder="Full name"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-foreground">Email</label>
|
||||
<Input
|
||||
type="email"
|
||||
value={createForm.email}
|
||||
onChange={(e) => setCreateForm((form) => ({ ...form, email: e.target.value }))}
|
||||
placeholder="user@example.com"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-foreground">Account Mode</label>
|
||||
<select
|
||||
value={createForm.account_mode}
|
||||
onChange={(e) => setCreateForm((form) => ({ ...form, account_mode: e.target.value as 'existing' | 'personal' }))}
|
||||
className={cn(
|
||||
'w-full rounded-md border border-border bg-card px-3 py-2 text-sm text-foreground',
|
||||
'focus:outline-hidden focus:border-primary focus:ring-2 focus:ring-primary/20'
|
||||
)}
|
||||
>
|
||||
<option value="personal">Personal (new account)</option>
|
||||
<option value="existing">Join existing account</option>
|
||||
</select>
|
||||
</div>
|
||||
{createForm.account_mode === 'existing' && (
|
||||
<>
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-foreground">Account Display Code</label>
|
||||
<Input
|
||||
type="text"
|
||||
value={createForm.account_display_code}
|
||||
onChange={(e) => setCreateForm((form) => ({ ...form, account_display_code: e.target.value }))}
|
||||
placeholder="e.g. ABC12345"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-foreground">Account Role</label>
|
||||
<select
|
||||
value={createForm.account_role}
|
||||
onChange={(e) => setCreateForm((form) => ({ ...form, account_role: e.target.value as 'engineer' | 'viewer' }))}
|
||||
className={cn(
|
||||
'w-full rounded-md border border-border bg-card px-3 py-2 text-sm text-foreground',
|
||||
'focus:outline-hidden focus:border-primary focus:ring-2 focus:ring-primary/20'
|
||||
)}
|
||||
>
|
||||
<option value="engineer">Engineer</option>
|
||||
<option value="viewer">Viewer</option>
|
||||
</select>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="send-email"
|
||||
checked={createForm.send_email}
|
||||
onChange={(e) => setCreateForm((form) => ({ ...form, send_email: e.target.checked }))}
|
||||
className="rounded border-border bg-card"
|
||||
/>
|
||||
<label htmlFor="send-email" className="text-sm text-muted-foreground">
|
||||
Send welcome email with temporary password
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* Temp password modal */}
|
||||
<Modal
|
||||
isOpen={!!tempPassword}
|
||||
onClose={() => setTempPassword(null)}
|
||||
title="User Created"
|
||||
size="sm"
|
||||
footer={(
|
||||
<div className="flex justify-end">
|
||||
<Button onClick={() => setTempPassword(null)}>Done</Button>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-xl border border-yellow-400/20 bg-yellow-400/10 p-3 text-sm text-yellow-400">
|
||||
This password will not be shown again. Copy it now.
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-foreground">Temporary Password</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="flex-1 rounded-md border border-border bg-card px-3 py-2 font-mono text-sm text-foreground">
|
||||
{tempPassword}
|
||||
</code>
|
||||
<button
|
||||
onClick={handleCopyPassword}
|
||||
className="rounded-md border border-border p-2 text-muted-foreground transition-colors hover:bg-elevated hover:text-foreground"
|
||||
title="Copy password"
|
||||
>
|
||||
{copied ? <Check className="h-4 w-4 text-green-400" /> : <Copy className="h-4 w-4" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
The user will be required to change this password on first login.
|
||||
</p>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* Invite User modal */}
|
||||
<Modal
|
||||
isOpen={showInviteModal}
|
||||
onClose={() => setShowInviteModal(false)}
|
||||
title="Invite User"
|
||||
size="sm"
|
||||
footer={(
|
||||
<div className="flex justify-end gap-3">
|
||||
<Button variant="secondary" onClick={() => setShowInviteModal(false)}>Cancel</Button>
|
||||
<Button onClick={handleInviteUser} disabled={!inviteForm.email || !inviteForm.account_display_code} loading={inviteLoading}>
|
||||
{inviteLoading ? 'Sending...' : 'Send Invite'}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-foreground">Email</label>
|
||||
<Input
|
||||
type="email"
|
||||
value={inviteForm.email}
|
||||
onChange={(e) => setInviteForm((form) => ({ ...form, email: e.target.value }))}
|
||||
placeholder="user@example.com"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-foreground">Account Display Code</label>
|
||||
<Input
|
||||
type="text"
|
||||
value={inviteForm.account_display_code}
|
||||
onChange={(e) => setInviteForm((form) => ({ ...form, account_display_code: e.target.value }))}
|
||||
placeholder="e.g. ABC12345"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-foreground">Role</label>
|
||||
<select
|
||||
value={inviteForm.role}
|
||||
onChange={(e) => setInviteForm((form) => ({ ...form, role: e.target.value as 'engineer' | 'viewer' }))}
|
||||
className={cn(
|
||||
'w-full rounded-md border border-border bg-card px-3 py-2 text-sm text-foreground',
|
||||
'focus:outline-hidden focus:border-primary focus:ring-2 focus:ring-primary/20'
|
||||
)}
|
||||
>
|
||||
<option value="engineer">Engineer</option>
|
||||
<option value="viewer">Viewer</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default UsersPage
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { Users, TreePine, CreditCard, Activity, TrendingUp } from 'lucide-react'
|
||||
import { Users, TreePine, CreditCard, Activity, TrendingUp, Building2 } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { PageHeader } from '@/components/admin'
|
||||
import { adminApi } from '@/api/admin'
|
||||
@@ -43,7 +43,7 @@ export function DashboardPage() {
|
||||
}, [])
|
||||
|
||||
const quickLinks = [
|
||||
{ to: '/admin/users', label: 'Manage Users', icon: Users },
|
||||
{ to: '/admin/accounts', label: 'Manage Accounts', icon: Building2 },
|
||||
{ to: '/admin/plan-limits', label: 'Plan Limits', icon: TrendingUp },
|
||||
{ to: '/admin/feature-flags', label: 'Feature Flags', icon: Activity },
|
||||
{ to: '/admin/audit-logs', label: 'Audit Logs', icon: Activity },
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user