84 Commits

Author SHA1 Message Date
chihlasm
1a45f66358 feat: overhaul session documentation, PSA notes, and client communications
- Reformat PSA resolution/escalation notes: clean single-line header,
  steps with engineer responses inline, remove duplicate timing blocks,
  remove AI confidence section, add follow-up recommendations
- Standardize time display to decimal hours (e.g. 0.25 hrs) across all
  note formatters and status update context
- Add follow_up_recommendations to SessionDocumentation schema and
  surface in SessionDocView; extracted from resolution suggestion steps
- Add _build_what_we_know() helper: uses session.evidence_items when
  cockpit branch merges, falls back to deriving findings from steps
- Fix option label lookup in generate_status_update (was passing raw
  machine values to AI instead of human-readable labels)
- Add 'What We Know' section to status update ticket notes prompt
- Improve _build_session_context in resolution_output_generator to
  include intake text and full step details instead of truncated chat
- Add request_info audience type: client-facing information request
  that skips the length step and generates a numbered question list
- Improve client_update and email_draft prompts with per-context
  guidance (status/resolution/escalation) and fix escalation subject
  line from 'Specialist Review' to 'Specialist Assistance'

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 15:19:19 +00:00
chihlasm
6f12e42ebe feat: network diagrams UX overhaul — icons, empty canvas, properties panel
- Colorize: semantic category colors for all device types (network=blue,
  security=orange, compute=emerald, endpoint=amber, storage=violet,
  cloud=cyan, infra=steel); better icons (Router, ShieldAlert, Boxes,
  Package, Gauge, PlugZap, Video, Radio); MiniMap uses category colors
- Onboard: centered AI generate prompt on empty canvas with 5 MSP-specific
  example chips, ⌘↵ shortcut, spinner; AIAssistPanel only shown with nodes
- Arrange: properties panel — status badge grid at top, fields grouped into
  Network (IP/Subnet/VLAN) and Hardware (Hostname/Vendor/Model/Role) sections
- Delight: segmented topology color bar on listing cards; backend returns
  category_counts via single extra query on list endpoint
- Harden: real PNG export via html-to-image + getNodesBounds/getViewportForBounds
- Polish: ChevronDown replaces unicode ▾, click-outside for client filter,
  consistent spinner in empty prompt

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 00:55:03 +00:00
chihlasm
c0d8163e98 fix: network diagram team_id guard + multi-style edge routing
Backend:
- Guard create_diagram with 422 if current_user.team_id is None (prevents
  NOT NULL constraint crash for accounts not yet assigned to a team)
- Add routing field to DiagramEdge schema (straight/curved/step)

Frontend:
- ConnectionEdge now supports straight (default), curved (bezier), and
  step (smooth-step) routing per-edge via routing field in edge data
- PropertiesPanel Connection section gets a Line Style toggle:
  Straight | Curved | Step buttons, active state highlights in accent
- handleEdgeUpdate and serializeEdges now propagate the routing field
- DiagramEdge type gets optional routing field

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 00:05:12 +00:00
chihlasm
af4f07cec6 fix: network diagram editor UX — straight edges, snap-to-grid, ISP in Cloud, group resize
- Straight edges: replace SmoothStepEdge with BaseEdge + getStraightPath so
  connections draw direct diagonal lines instead of orthogonal bent paths
- Snap-to-grid: add snapToGrid/snapGrid=[20,20] to NetworkCanvas so nodes
  align consistently when dragged
- ISP in Cloud: remove standalone "Internet" sidebar section, inject ISP into
  the Cloud category loop with search support and correct item count
- Group node resize: add NodeResizer to GroupNode (subnet/VLAN/site/DMZ),
  handles visible when selected; dimensions saved/restored correctly on
  reload (also fixes group node load bug where type was always 'device')
- DiagramNode type: add nodeType and style optional fields

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 23:09:49 +00:00
chihlasm
5eac5fecff Merge remote-tracking branch 'origin/main' into feat/cockpit-harness 2026-04-04 23:06:41 +00:00
chihlasm
e042cf6186 fix: backend code review fixes for network diagrams
- Replace legacy Optional imports with modern str | None syntax
- Type JSONB columns as Mapped[list[dict[str, Any]]]
- Escape SQL LIKE wildcards (%, _) in diagram search
- Type DiagramNode.position as Position(x, y) Pydantic model
- Wrap AI response parsing in KeyError handler for clean 422 errors
- Remove unused Optional/TYPE_CHECKING imports from schemas/models
- Extract _get_available_slugs helper to DRY duplicate queries

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 19:11:44 +00:00
chihlasm
c53ced8725 fix: context menu dismisses on pane click, ISP in toolbar
Context menu now closes when clicking anywhere on the canvas via
onPaneClick prop. ISP device added as built-in toolbar item under
Internet section so it's always available without a database entry.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 17:44:48 +00:00
chihlasm
e3b2f73f38 feat: wire context menu and keyboard shortcuts into diagram editor
Right-click context menus for nodes (copy/duplicate/delete) and
canvas (paste/select-all/fit-view). Right-click selects the node
per spec. serializeNodes now handles group nodes correctly.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 17:33:27 +00:00
chihlasm
866b02833c feat: add useCanvasShortcuts hook for copy/paste/duplicate
Keyboard shortcuts with preventDefault and input guard.
Clipboard stores nodes with relative positions and edge indices.
Paste computes canvas center via screenToFlowPosition.
Duplicate offsets +30px. Supports both device and group nodes.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 17:28:05 +00:00
chihlasm
80c82f0b48 feat: add ContextMenu component for network diagram editor
Charcoal-styled context menu with action factories for node
and canvas variants. Viewport-clamped positioning, auto-dismiss
on click outside, escape, or scroll.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 17:26:59 +00:00
chihlasm
f989c1e487 feat: improve drag-and-drop feel in network diagram editor
Grip icons on draggable toolbar items, press effect on drag start,
dashed border overlay with 'Drop to add' text when dragging over canvas.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 17:26:38 +00:00
chihlasm
375e739348 feat: add ISP icon to network diagram device registry
Globe icon with accent color, under cloud category.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 17:24:36 +00:00
chihlasm
f4b05a2ff5 fix: address code review findings for React Flow UI integration
- Use screenToFlowPosition() for drop coordinates (fixes zoom/pan bug)
- Remove duplicate selection border from DeviceNode (BaseNode handles it)
- Add w-full to GroupNode for proper container sizing
- Remove unused 'selected' destructuring from DeviceNode

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 14:33:00 +00:00
chihlasm
73ea126f4c feat: add grouping toolbar items and traffic flow toggle
DeviceToolbar gets Subnet/VLAN/Site/DMZ grouping section with
drag-drop. PropertiesPanel gets Show Traffic toggle that switches
edges between connection and animated types. DiagramEditor handles
both device and group node drops.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 14:27:23 +00:00
chihlasm
93b1398b5c refactor: DeviceNode uses BaseNode, BaseHandle, StatusIndicator, Tooltip
Replaces hand-rolled node layout with composable React Flow UI
components. Status is now a border effect instead of a dot.
Hover tooltip shows hostname, IP, vendor, role, notes.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 14:24:25 +00:00
chihlasm
654b9cb4ac feat: add LabeledGroupNode and AnimatedSvgEdge components
GroupNode for subnet/VLAN/site grouping with positioned label badge.
AnimatedSvgEdge for traffic flow visualization with animated SVG
shape along edge path. Both registered in type maps.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 14:22:45 +00:00
chihlasm
ee32fdbf78 feat: add React Flow UI foundation components for network diagrams
BaseNode (structured node shell with header/content/footer slots),
BaseHandle (styled connection handle), LabeledHandle (handle with
port label), NodeStatusIndicator (status border effect),
NodeTooltip (hover details via NodeToolbar).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 14:20:42 +00:00
chihlasm
db9d5a8393 fix: resolve stale selection bug in network diagram PropertiesPanel
Selection state now stores IDs and derives objects from live arrays,
so edits in PropertiesPanel inputs reflect immediately.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 14:18:02 +00:00
chihlasm
289b3cacfc fix: resolve TypeScript errors in DeviceToolbar and DiagramEditor
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 08:00:42 +00:00
chihlasm
6b7bc3fd42 feat: add Network Maps to sidebar navigation and router
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 07:59:06 +00:00
chihlasm
6aef6e2602 feat: add Network Diagrams list page with search, client filter, import
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 07:57:43 +00:00
chihlasm
12f6a29dd5 feat: add DiagramEditor page assembling all panels with auto-save and AI generation
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 07:56:03 +00:00
chihlasm
600c3959af feat: add NetworkCanvas wrapper and DiagramHeader components
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 07:53:52 +00:00
chihlasm
d8d8de91d1 feat: add AIAssistPanel with replace and merge modes
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 07:52:28 +00:00
chihlasm
ebfd4e5651 feat: add PropertiesPanel for node and edge property editing
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 07:52:15 +00:00
chihlasm
7c19521fc2 feat: add DeviceToolbar panel with search, categories, drag-drop, custom type creation
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 07:51:44 +00:00
chihlasm
17e1bc84e2 feat: add device registry, DeviceNode, ConnectionEdge for React Flow
Creates the React Flow building blocks for the network diagram editor:
device type registry with icon/color mappings, DeviceNode component with
status indicators and connection handles, ConnectionEdge with per-type
styling, and nodeTypes/edgeTypes registration maps.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 07:50:10 +00:00
chihlasm
ebc6b46823 feat: add frontend API clients for device types and network diagrams
Adds deviceTypesApi (list, create, update, remove) and networkDiagramsApi
(list, get, create, update, archive, duplicate, exportJson, importJson,
aiGenerate, listClients) following the existing apiClient module pattern.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 07:48:03 +00:00
chihlasm
38be5b0370 feat: add TypeScript types for network diagrams
Adds all interfaces for network diagrams and device types including
DiagramNode, DiagramEdge, DeviceProperties, NetworkDiagramResponse,
AI generate request/response, import/export shapes, and list item types.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 07:48:00 +00:00
chihlasm
b6ea63d81a feat: add network diagrams CRUD + AI generate + export/import router
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 07:45:42 +00:00
chihlasm
03f6100d4c feat: add AI generation service for network diagrams
Adds network_diagram_ai_service.py with generate_diagram() function that
calls the AI provider to convert plain-English network descriptions into
structured DiagramNode/DiagramEdge data. Registers the action in
ACTION_MODEL_MAP as a standard-tier route.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 07:44:00 +00:00
chihlasm
a3c49873f5 feat: add device types CRUD router
Adds GET/POST/PUT/DELETE endpoints at /device-types with team-scoped access. System types are read-only; custom types are scoped to the creating team.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 07:43:23 +00:00
chihlasm
5a91c4d672 feat: add Pydantic schemas for device types and network diagrams
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 07:42:18 +00:00
chihlasm
068baec179 feat: add network_diagrams table
Create NetworkDiagram SQLAlchemy model with JSONB nodes/edges, team-scoped with client/asset metadata, and Alembic migration 074.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 07:41:13 +00:00
chihlasm
9c042c750e feat: add device_types table with system seed data
Creates DeviceType SQLAlchemy model and migration 073 that provisions the
device_types table with 28 system-seeded device types across 7 categories
(network, compute, storage, cloud, endpoint, infrastructure, security).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 07:40:12 +00:00
chihlasm
8c90da1960 docs: add network diagrams implementation plan
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 07:36:52 +00:00
chihlasm
be34a20441 docs: add network diagrams design spec
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 07:25:51 +00:00
chihlasm
e7ac87bf7d docs: update CURRENT-STATE.md with action bar consolidation work
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 05:05:23 +00:00
chihlasm
165e402284 fix: deduplicate actions, promote ViewToggle tab bar, standardize naming
Remove duplicate Update/Close actions from chat toolbars (FlowPilotPage,
CockpitPage) — session lifecycle actions now live only in headers. Redesign
ViewToggle as a persistent tab bar with bottom-border active indicator and
ARIA attributes. Standardize all action naming: Resolve (emerald), Update
(blue), Close (rose), Pause (muted). Fix IncidentHeader Resolve from orange
to emerald. Delete unused FlowPilotActionBar component (227 lines). Update
ConcludeSessionModal copy to use forward-facing action verbs.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 04:40:06 +00:00
chihlasm
aca976a49a fix: move ViewToggle into header bars and remove subtitle
Eliminates the dedicated ViewToggle row on CockpitPage by merging
it into IncidentHeader's action group via extraActions prop. Removes
subtitle from ViewToggle component entirely — the icon + label is
self-explanatory. Cleans up showSubtitle prop from all call sites.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 01:48:09 +00:00
chihlasm
47e71c4753 fix: opaque overflow menu, add Update button to cockpit and assistant headers
The IncidentHeader overflow menu was see-through due to bg-elevated on
bg-card. Switched to bg-card with shadow-xl and fixed-scrim dismiss
pattern matching production FlowPilotSessionPage. Replaced unicode
ellipsis with MoreHorizontal icon + aria-label. Added Update button
(StatusUpdateModal) to IncidentHeader and FlowPilotPage header bar
for feature parity with production.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 00:43:48 +00:00
chihlasm
91e3f80707 fix: clear cockpit state on session switch and add loading placeholders
- Reset triageMeta, psaTicketId, steps, and completedSteps when
  activeChatId changes (prevents stale triage data from previous session
  showing while AI processes the new one)
- Split the activeChatId and activeActions reset effects so triage
  only resets on session switch, not on every new action set
- Add loading placeholders to cockpit work zone: spinner + "analyzing"
  text in steps panel, "questions will appear here" in right panel
- Add centered "Starting session..." loader to FlowPilot page when
  loading with no messages yet (prefill creation period)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 05:56:29 +00:00
chihlasm
b03f84aecf fix: prevent task lane from showing previous session's data on switch
Root cause: race condition between setActiveChatId and the persist
effect. When switching from session A to B, setActiveChatId(B) triggers
the persist effect which writes {chatId: B, questions: [A's data]} to
sessionStorage BEFORE the async selectChat clears the task lane. The
sessionStorage fallback then finds chatId === B and restores A's stale
task lane data.

Fix: clear task lane state synchronously in selectChat before the await.
Server-side pending_task_lane restores it if the new session has data.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 05:23:55 +00:00
chihlasm
813b598101 fix: cockpit/flowpilot bugs and redesign view toggle placement
- Fix return type annotation on unified_chat_service.send_chat_message (6→7 tuple)
- Fix stale closure in CockpitPage handleStepComplete auto-advance logic
- Fix IncidentHeader copy link hardcoding /assistant/ path (now uses current URL)
- Wire psaTicketId from session data through to CockpitPage incident header
- Fix FlowPilotAsks showing only first question — add chevron navigation for all
- Redesign ViewToggle: add icons (MessageSquare/LayoutDashboard) and subtitles
- Move view toggle from chatbar toolbar to standalone row above input on dashboard
- Standardize toggle placement across dashboard, FlowPilot, and Cockpit pages

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 05:02:42 +00:00
chihlasm
ed6e6cd1ed fix: stabilize cockpit and assistant session handoff 2026-04-03 04:00:47 +00:00
chihlasm
b07dfb7603 fix: preserve cockpit steps across view toggles and show full step info
Backend: stop wiping pending_task_lane when AI response has no new
[ACTIONS]/[QUESTIONS] markers — previous task lane state is still
relevant until replaced by new markers.

Frontend (selectChat): don't eagerly clear task lane before server
response arrives; restore from sessionStorage as fallback when
pending_task_lane is null (covers sessions before backend fix).

StepsPanel: show description and command for all steps instead of
hiding behind hover/active-only visibility. Commands render as
inline code blocks.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 03:09:19 +00:00
chihlasm
4ba32a08ac fix: resolve race conditions in assistant/cockpit session loading
- Always load session data on mount even when urlSessionId matches
  activeChatId, fixing empty state after view toggle between /assistant
  and /cockpit (tasks/messages not showing until sidebar click)
- Add loadingRef for synchronous guards preventing duplicate sends,
  duplicate session creation, and prefill races
- Fix stale evidence_items closure in CockpitPage handlers
- Move setLoading(true) before first await in handlePrefill and
  handleResumeNew

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 02:49:29 +00:00
chihlasm
3ea669a1e5 chore: rename 'AI Assistant' to 'FlowPilot' in user-facing text
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 17:34:36 +00:00
chihlasm
81ad52f5bc feat: add launch view preference toggle to StartSessionInput
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 17:33:57 +00:00
chihlasm
cd7774b733 feat: add preferredFlowPilotView to user preferences store
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 17:33:08 +00:00
chihlasm
3ce4201d62 feat: add FlowPilot and FlowPilot Cockpit to sidebar navigation
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 17:32:40 +00:00
chihlasm
b994e82c56 feat: add /cockpit routes, point /assistant to FlowPilotPage
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 17:31:47 +00:00
chihlasm
7d97412d1f feat: add ViewToggle component, uncomment in both pages
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 17:31:20 +00:00
chihlasm
fc51ceb610 refactor: rename AssistantChatPage to CockpitPage, consume useAssistantSession hook
Replace all inline session management with the shared useAssistantSession
hook. Keep cockpit-specific state (triageMeta, workZonePct, steps, onboarding)
and handlers. Wire onSessionLoadedRef/onTriageUpdateRef callbacks. Add feature
flag redirect for flowpilot_cockpit. Update router and prefetch references.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 17:29:42 +00:00
chihlasm
4cc6ee4797 feat: create FlowPilotPage with classic chat layout
Recreates the production AssistantChatPage as FlowPilotPage using the
shared useAssistantSession hook. Classic chat interface with ChatMessage
bubbles, TaskLane side panel, rich input with file uploads, and
conclude/status update modals. ViewToggle commented out pending Task 4.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 17:20:34 +00:00
chihlasm
2ed02607a8 fix: add loadChats to useAssistantSession return value
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 17:18:20 +00:00
chihlasm
1a858237ba feat: extract useAssistantSession hook from AssistantChatPage
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 17:16:46 +00:00
chihlasm
df9e069452 docs: add FlowPilot / Cockpit side-by-side implementation plan
10-task plan covering hook extraction, page split, view toggle,
routing, sidebar nav, dashboard preference, and UI renaming.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 16:58:49 +00:00
chihlasm
79c8632dbb docs: add FlowPilot / Cockpit side-by-side design spec
Sub-project 2 design spec covering dual-page routing, view toggle,
sidebar navigation, dashboard integration, and shared logic extraction.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 16:44:07 +00:00
chihlasm
a001aa11e5 chore: seed flowpilot_cockpit feature flag with plan defaults
Disabled for free plan, enabled for pro and team.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-02 15:23:17 +00:00
chihlasm
2ee3c1afda refactor: remove canUseFeature from useSubscription
No external call sites existed. Feature gating now handled by
useFeatureFlag hook backed by the feature_flags system.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-02 15:20:58 +00:00
chihlasm
cc929c8932 feat: add useFeatureFlag hook for feature gating
Selector-based hook reads from authStore.featureFlags.
Returns false for unknown keys (fail closed).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-02 15:20:53 +00:00
chihlasm
b7e5979d0c feat: add featureFlags to authStore, fetched in fetchUser
Loads resolved feature flags from /auth/me/feature-flags alongside
user, account, and subscription data.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-02 15:20:38 +00:00
chihlasm
6117a83b0b fix: add response_model, tighten overrides type annotation, add uuid import
Code review fixes for feature flags endpoint.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-02 15:19:11 +00:00
chihlasm
a60c19b305 feat: add GET /auth/me/feature-flags resolution endpoint
Resolves feature flags for the current user using:
account override > plan default > false

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

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 06:24:26 +00:00
chihlasm
8c28f48ce0 fix: remove unused admin account icon import 2026-04-02 04:48:38 +00:00
chihlasm
f9de76b28c feat: add admin account detail management 2026-04-02 04:37:23 +00:00
chihlasm
296153850b feat: expand admin customer account controls 2026-04-02 04:17:29 +00:00
chihlasm
1d4d7ef35d feat: expand account management hub 2026-04-02 03:57:27 +00:00
chihlasm
bfcb8c52d3 feat: reorganize admin panel around accounts 2026-04-02 03:46:11 +00:00
chihlasm
b8189a1999 fix: guard all chat response paths against session-switch race condition
handleSend, sendPrefill, and handleResumeNew all make async API calls
that can return after the user has switched to a different session. Without
a guard, the stale response overwrites the new session's questions/actions
state — causing the previous session's FlowPilot Asks to persist.

Fix: capture the session ID before each await and check currentChatRef
after — discarding the response if the user has since switched. This
matches the existing guard pattern in selectChat (lesson #106).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 01:09:28 +00:00
chihlasm
9462da8b80 feat: cockpit UX polish — conversation log, interactive steps, onboarding
- Redesign conversation log: proper role labels, left-border accents,
  larger text, CSS variable background, min-height guarantee
- Interactive steps panel: click-to-complete, click-to-select, progress
  bar with counter, hover-reveal descriptions, smooth transitions
- Replace noop overflow button with real dropdown menu (Pause, Copy Link,
  Close Case) with keyboard/click-outside dismiss
- Evidence cycling: right-click to reverse-cycle status, tooltips on icons
- First-run onboarding overlay labeling the three cockpit zones, auto-
  dismisses on first message or manual dismiss, persisted via localStorage
- Drag handle: taller, visible hover state, title tooltip
- Simplify input placeholder (remove redundant paste-log hint)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 00:13:46 +00:00
chihlasm
63023d486d fix: resolve TypeScript errors from cockpit refactor (Phase 7)
- Remove unused imports (ChatMessage, TaskLane)
- Remove unused handleTaskSubmit and handleFlowPilotAnswer
- Remove unused setActiveStepIndex setter
- Add triage fields to AISessionDetail construction in useFlowPilotSession
- npx tsc -b passes clean

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 23:04:23 +00:00
chihlasm
1705ecbb9f feat: MSP-native language pass + conclude modal update (Phase 6)
- ConcludeSessionModal: "Conclude Session" → "Close Case"
- ChatSidebar: "New Chat" → "New Case", "chat history" → "case history"
- Language pass in AssistantChatPage already done in Phase 5

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 23:02:14 +00:00
chihlasm
6ee6faa712 feat: refactor AssistantChatPage into cockpit layout (Phase 5)
- Stacked zone layout: incident header → work zone → drag handle →
  conversation log → compose
- IncidentHeader wired with triageMeta state and field save handlers
- Work zone: StepsPanel (left) + FlowPilotAsks + WhatWeKnow (right)
- Drag-resizable split with localStorage persistence
- Compact conversation log with you:/fp: prefixes
- Triage state populated on session load/resume
- AI triage_update merged into header via mergeTriageUpdate()
- MSP-native language: FlowPilot, New Case, Close Case

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 22:51:29 +00:00
chihlasm
23a7cee1f5 feat: add cockpit work zone components (Phase 4)
- IncidentHeader: labelled fields with per-field edit popovers
- StepsPanel: ordered step checklist (✓/→/○) with script CTA
- FlowPilotAsks: quick-reply buttons or free-text input
- WhatWeKnow: evidence list with status toggle and inline editing

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 22:43:15 +00:00
chihlasm
036198b224 feat: add cockpit frontend types and API methods (Phase 3)
- TriageMeta, EvidenceItem, TriageUpdate interfaces
- QuestionItem.options field for quick-reply buttons
- triage_update on ChatMessageResponse
- Triage fields on AISessionDetail for session resume
- updateTriage() and getHandoffDraft() API methods

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 22:41:49 +00:00
chihlasm
92cc62bcbd feat: add [TRIAGE_UPDATE] marker extraction and auto-PATCH (Phase 2)
- Add _parse_triage_update_marker() parser following existing marker pattern
- Add [TRIAGE_UPDATE] instructions to system prompt with grounding rules
- Add QuestionItem.options support in question parser
- Wire triage extraction into both main and branch-aware chat paths
- Auto-PATCH session: AI only fills null fields (manual edits win)
- Evidence items: AI appends only, never modifies existing
- Return triage_update in ChatMessageResponse for frontend header sync

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 22:40:49 +00:00
chihlasm
15781baeb7 feat: add cockpit triage backend foundation (Phase 1)
- Migration 071: add client_name, asset_name, issue_category,
  triage_hypothesis, evidence_items columns to ai_sessions
- TriageUpdate schema for AI-inferred header updates in chat responses
- QuestionItem.options field for quick-reply buttons
- PATCH /ai-sessions/{id}/triage endpoint for manual header edits
- POST /ai-sessions/{id}/handoff-draft streaming endpoint for conclude modal
- Structured handoff fields (root_cause, steps_taken, recommendations)
  on resolve/escalate requests, passed through to ResolutionOutputGenerator
- Triage fields exposed in AISessionDetail response for session resume

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 22:30:48 +00:00
chihlasm
cb750d52f6 docs: resolve all contract decisions from codex readiness review
Addresses every Red and Yellow item from the codex review:
- Canonical handoff: ResolutionOutputGenerator is the source of truth
- AI vs manual authority: manual edits win, AI never overwrites
- evidence_items: full-list replacement, frontend is merge authority
- TaskLane persistence: lifted into hook, StepsPanel is presentation-only
- Quick replies: immediate-send, full-stack contract change
- issue_category + asset_name: free text in v1
- Adds 5 implementation guardrails and Phase 2 gate for triage extraction
- Execution order updated to 37 steps with persistence extraction step

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 21:33:47 +00:00
chihlasm
56ca5d2669 docs: merge codex insights into claude super plan
Adds key architectural choices summary, assumptions section,
sidebar visual demotion (F9), message click-to-expand in compact
log, and backend-first rationale from the codex plan.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 21:17:08 +00:00
chihlasm
81f8aa0074 docs: add MSP assistant harness super plan (claude synthesis)
Merges MSP_Assistant_Harness_Implementation_Plan.docx with the
brainstorming design spec into a single executable plan. Resolves
all open questions from the original docx, expands scope to include
backend changes, and adds a 35-step phased execution order.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 21:11:26 +00:00
chihlasm
5dd43b2226 docs: add MSP assistant harness cockpit design spec
Design spec for evolving /assistant into a live triage cockpit.
Covers layout decisions (stacked zones, drag-resizable split),
incident header (labelled fields, AI-inferred + editable),
work zone (steps checklist + FlowPilot Asks + What We Know),
conclude modal redesign, and all required backend changes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 20:59:15 +00:00
109 changed files with 17783 additions and 2557 deletions

View File

@@ -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)

View File

@@ -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 (MarchApril 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

View File

@@ -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")

View 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'")
)

View 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")

View 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")

View File

@@ -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,

View File

@@ -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)

View File

@@ -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

View 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()

View 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")

View File

@@ -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)

View File

@@ -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:

View File

@@ -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",
]

View File

@@ -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,

View 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)
)

View 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])

View File

@@ -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):

View File

@@ -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

View 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}

View 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

View File

@@ -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 \

View File

@@ -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),

View 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"),
)

View File

@@ -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)

View File

@@ -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:

View File

@@ -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

View File

@@ -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(

View 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

View 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` |

View 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 |

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -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 |

View 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

View File

@@ -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",

View File

@@ -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",

View File

@@ -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>) =>

View File

@@ -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

View 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}`)
},
}

View File

@@ -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'

View 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
},
}

View File

@@ -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}

View File

@@ -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" />

View File

@@ -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)}>

View File

@@ -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>

View File

@@ -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>
)

View File

@@ -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>

View File

@@ -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 &amp; Generate
</>
)}
</button>

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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

View File

@@ -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 ──────────────────────────────

View File

@@ -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}
/>
)}
</>
)
}

View File

@@ -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">

View File

@@ -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>
)}

View File

@@ -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'

View File

@@ -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 })

View File

@@ -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' },

View 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>
)
}

View 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 },
]
}

View 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>
)
}

View 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>
)
}

View 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)

View File

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

View 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,
}
}

View 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)

View 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']

View File

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

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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)

View 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>
)
}

View 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}
/>
)
}

View 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>
</>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View File

@@ -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.' },

View 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,
}
}

View 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)
}

View File

@@ -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)

View File

@@ -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,

View File

@@ -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

View File

@@ -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>
</>
)
}

View 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>
</>
)
}

View 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&apos;ll also suggest relevant flows from your team&apos;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 &amp; Network Engineer. Ask anything about IT infrastructure,
or start a new chat to get personalized help with your team&apos;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>
</>
)
}

View File

@@ -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>

View 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>
)
}

View 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>
)
}

View File

@@ -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 />

View File

@@ -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

View 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

View 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

View File

@@ -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