Compare commits

..
Author SHA1 Message Date
pascal af49dd6870 add timeline 2026-07-28 18:58:51 +02:00
pascal f6479c1f84 put on top of changeset branch 2026-07-28 17:13:13 +02:00
Eduard GertandClaude Fable 5 b523a7aa46 Live destination cards: match peer card height, show network inline
- Live destination peer cards (ResourceNode standalone branch) get the
  shared 64px card height instead of the old py-1 metrics
- Live standalone resource cards show "Name - Network" inline like
  draft (only drilled views keep hiding it - the header names it there)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 17:36:39 +02:00
Eduard GertandClaude Fable 5 8d66b88fea Clarify panel discard dialogs
"Discard changes to X?" read like dismissing the popup — the dialogs now
state the situation ("You have unsaved member/group changes") and spell
out that closing the panel loses the toggled assignments; the confirm
stays the short "Discard".

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 16:30:11 +02:00
Eduard GertandClaude Fable 5 c8ba72517c Components panel: no blink on tab switch
Tab/search changes no longer remount the Virtuoso list (which painted
empty for a frame while measuring) — the mounted list scrolls back to
the top imperatively instead; the open/close remount for fresh
measurements stays.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 16:19:46 +02:00
Eduard GertandClaude Fable 5 ccd5440a5f Panels swap in place and virtualize; edges pass drags to the pane
- Group/peer panels: no remount on target switch (motion key removed,
  placement kept) — the slide-in only plays when opening from closed;
  search/tab/selection still reset per target
- Peers, resources, and groups lists virtualize with react-virtuoso
  through the styled scroll area (shared PanelVirtuosoScroller); lists
  remount per target so scroll offsets don't carry over
- Edges (and their wide interaction path) get pointer-events: none —
  no edge has a click action, so dragging over connection lines now
  pans the canvas; replaces the dimmed-only rule

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 16:15:57 +02:00
Eduard GertandClaude Fable 5 fab63e2c08 Highlight Connections polish; fix draft-entry policy column gaps
- Rename Focus to "Highlight Connections" (Waypoints icon): context menu
  item, header pill text, exit aria-label. No ring on the highlighted
  node (the dim is the signal) and no menu entry on it while active
- Availability relaxed: any node with an edge, as long as the canvas has
  at least two policies
- Dimmed edges get pointer-events: none (incl. the wide interaction
  path) so drag-to-pan works over them in highlight mode
- Draft entry from peer view: adopt live twin sizes BEFORE the build
  layout — its overlap pass measured fresh nodes with an 80px fallback
  and shoved the 60px-pitch policy column apart (huge gaps until Auto
  Arrange re-ran on measured nodes)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 16:03:51 +02:00
Eduard GertandClaude Fable 5 700950b452 Peer rename in canvas context menu; user-device modal says Assign
- Extract the peer page's Edit Peer Name modal into shared
  EditPeerNameModal and reuse it from the control-center context menu
  (existing peers only, permission-gated); saving PUTs the peer,
  patches canvas nodes in place, and revalidates /peers
- User-device setup modal: "Select" -> "Assign" (header, step 2, button)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 15:42:37 +02:00
Eduard GertandClaude Fable 5 344004f05e Align live canvas card heights and text metrics
- Peer, placeholder, select-peer, and select-user nodes all match the
  group node's 64px height
- SelectGroupNode padding py-3 -> py-3.5 (same as GroupNode)
- DeviceCard default variant adopts the placeholder card's tighter
  title/subtitle metrics so installed and pending peers read the same
- User select avatar on the peer icon-box surface (nb-gray-850)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 15:31:54 +02:00
Eduard GertandClaude Fable 5 77fa76d9f1 Draft mode: install steps, group sync, placeholder flows, e2e suites
Placeholder peers (user device / server / agent):
- Track pending installs as install-peer changeset entries: own "Install"
  section in Review & Deploy, resolved by install/select or removal,
  renames follow, deploy skips them (user steps, not API calls)
- User-device setup stepper modal (install NetBird -> select peer);
  the canvas node drops its inline select, upgraded placeholders always
  become plain peer cards
- Placeholders open the peer groups panel (click or Details); assigned
  existing groups ride on the generated setup key as auto_groups
- Group panel lists absorbed draft members (held on the group node) with
  NEW badge, IP/address placeholders, and alert CTAs: "Install or assign"
  / "Install" / "No network" (network picker works node-less via
  assignHeldResourceToNetwork)
- Install hostname matching also scans group-held placeholders

Groups & policies:
- Group renames and membership changes propagate into the group copies
  held by policy nodes/edges (policy-group-sync) so the edit modal and
  PeerGroupSelector stay current
- New side groups added while editing a policy join their column
  (same x, stacked below) instead of landing at the origin
- Group names: "All" reserved, duplicates rejected against canvas drafts
- Dropping into "All" rejected; All still mirrors draft peers added to
  other groups (implicit membership, never deployed)
- No-network draft resources can join groups
- Delete/Backspace routes through the context menu's Remove semantics
  (useNodeRemoval) instead of React Flow's raw deletion

Review & Deploy:
- Canvas warnings for undeployable states (placeholder-peer policies,
  unassigned resources); modal stays open on partial failure so a retry
  resumes; update-group deploys against fresh API state
- Focus mode is explicit only (context menu / header tool) - left-click
  opens panels without dimming
- Drop-eligible groups show a white border mid-drag

Tests & docs: interaction matrix spec (specs/interaction-matrix.md),
three Playwright suites (lifecycle, connections, full matrix) on a shared
e2e helper, testids across draft controls, unit tests for canvas
warnings and policy-group-sync.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 15:19:46 +02:00
Eduard GertandClaude Fable 5 9dec55718b Live resource edit/disable, peer Details in context menu, dialog polish
Live resources open the networks page's resource modal on click (PUT on
save) and their context menu offers Focus / Edit / Disable — every
action behind the live-mode warning; the canvas patches from the
response. Peer context menus gain Details (opens the groups panel);
live group Delete is dropped (Focus / Details / Rename remain). Dialog
confirms gain an opt-in dismissOnOutsideClick, enabled for all
control-center warnings.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 12:09:57 +02:00
Eduard GertandClaude Fable 5 afad7ebbe3 Peer groups panel: click a peer to assign its groups
Clicking a peer opens the peer-side twin of the group panel — one Groups
tab listing every group ("All" excluded, draft-created ones included)
with assigned-first frozen order, search, and batch apply: draft stages
adds/removes into the changeset (canvas group nodes update in place),
live PUTs each changed group behind one warning confirmation. Same
chrome and close semantics (ESC keycap, discard guard, pane click), and
the clicked peer wears the panel halo — selectedPeerPanel lives in the
narrow DestinationGroup context so PeerNode can ring without
subscribing to CanvasState.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 11:53:06 +02:00
Eduard GertandClaude Fable 5 5f352614c8 Order drilled resources like frames; size network selector to its labels
The live single-network view now runs its resource column through
orderFrameResources, so the drilled view, the draft drill-down and the
overview frames all agree (policy-reached resources first). The network
selectors (live + draft drill-down) size to their longest option label,
clamped to the old 256px as max.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 11:33:41 +02:00
Eduard GertandClaude Fable 5 65b8f59031 Focus via context menu, live group actions, drilled group order parity
Focus Mode is entered from the node context menus ("Focus", shown only
where it declutters — 4+ edges and 2+ policies in the neighborhood, via
isFocusWorthy) or the "F" shortcut; the header button is gone and the
top-center pill owns the key handling. Live right-click now also covers
groups (Details / Rename / Delete behind live-mode warning confirmations,
PUT/DELETE + canvas patch) and peers/resources (Focus).

The shared drilled layout sorts both group columns by name so the live
single-network view and the draft drill-down agree on the order.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 11:25:51 +02:00
Eduard GertandClaude Fable 5 515ab88182 Rework focus mode into an explicit header tool; restore policy click-to-edit
Focus Mode is now armed from a header button next to the mode switcher
(SquareDashedMousePointer, sky accents, "F", switcher-style shell): the
next node click picks the focus target — hover previews the ring, node
buttons are inert, selectors/edge-less nodes are unfocusable — then the
mode goes sticky: the target keeps a glowing sky ring (cc-focus-root),
on-path nodes stay clickable/movable (dimmed ones lock), group panels
open without stealing focus, and only Esc / the top-center pill's X /
the button exit. The pill (switcher surface, fully rounded) names the
focused node. Fit-view moves to "1" to free F.

Policy left-click opens the editor again (live and draft); the context
menu keeps Edit/Disable. Network frames order policy-reached resources
first, via one shared helper so live and draft frames agree.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 18:19:19 +02:00
Eduard GertandClaude Fable 5 102891784e Add a Beta badge to the Draft tab in the mode switcher
Same SmallBadge treatment as the sidebar's Reverse Proxy entry.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 17:33:02 +02:00
Eduard GertandClaude Fable 5 523095d3bd Rebuild the group panel as a membership editor with checkbox assign
The destination group panel becomes a search + Peers/Resources tabs
editor (PeerGroupSelector-style rows and checkboxes): toggles edit a
local selection previewed on the node's count text, and Assign (draft)
stages ONE coalesced update-group change while Save (live) PUTs behind
the live-mode warning. Implicit closes (Esc, keycap, pane click) confirm
before discarding unassigned toggles via a shared close guard; Cancel
discards directly. The changeset/deploy pipeline learns member REMOVALS
(removedPeerIds/removedResourceIds + removedMembers node sets).

Panel chrome: components-panel surface, right-anchored full-height with
header-width tracking (in-place on width changes — no flash), toolbar
spring slide-in, singleton mount, frozen row order per open, and the
canvas pans the group node clear of the panel. Focus mode is skipped for
nodes without connections, and mode switches close the panel and drop
node selection.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 17:30:52 +02:00
Eduard GertandClaude Fable 5 5a113b6b64 Reset the components panel scroll when switching category tabs
Virtuoso keeps its scroll offset across data swaps, so changing the
category (or entering/leaving search) landed mid-list at the previous
tab's position. Remount the list on category/search-mode changes, like
the existing open/close remount.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 16:27:02 +02:00
Eduard GertandClaude Fable 5 25cc20efcc Pin the draft frame grid to the live overview's fixed midline
The draft build centered the frame grid on the measured midpoint of the
source/policy columns, which always landed a few px off the live
overview's fixed midline — and the live-frame anchor shift then pushed
that delta onto the columns, so peer/policy cards hopped a few pixels on
every live↔draft switch. Use the same fixed midline as live
(SOURCE_NODE_HALF_HEIGHT) so the geometry matches exactly and nothing
moves.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 15:35:30 +02:00
Eduard GertandClaude Fable 5 e23272d163 Align the networks overview's columns with the other views' anchors
Policies move from x 480 / -17 center offset to the shared x 500 / +14
anchor, sources drop their -30 half-height shift, and the frame grid
centers on the columns' midline instead — so a policy and its source
keep their world position when switching between the peer/user/group
and networks views. The draft build layout mirrors the same change to
keep entry/Auto Arrange parity.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 15:27:40 +02:00
Eduard GertandClaude Fable 5 80254c7d9a Keep the focused node draggable in focus mode
Focus mode turns global nodesDraggable off so dragging pans, which also
froze the focused node itself. The dim effect now stamps a per-node
draggable: true on the focus root (overrides the global flag) and strips
it when focus ends — works for policy/peer/resource focus and the group
panel alike.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 15:20:27 +02:00
Eduard GertandClaude Fable 5 dd08aea531 Focus policies/peers on click; live policy context menu with Edit + toggle
Clicking a policy or peer now enters focus mode (path highlight + blue
ring) in live and draft instead of opening the policy modal. Policy
editing moves to the right-click menu: in live it offers Edit and
Disable/Enable only — both behind a "you are in live mode"
confirmation — applying via updatePolicy and patching the canvas in
place through refreshLiveViewRef. No Delete in live.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 15:16:40 +02:00
Eduard GertandClaude Fable 5 63abf6bbfb Keep parents before children on the controlled canvas
On a controlled flow, instance.setNodes round-trips through
applyNodeChanges, which keeps replaced nodes at their ORIGINAL index —
so reparenting a resource that predates its frame (network drop
adoption, assign-to-network) left the child in front of the frame and
ReactFlow dropped the containment: correct parentId in state, card
rendered detached at frame-relative coordinates.

Reconcile the invariant centrally in onNodesChange (and heal persisted
snapshots saved with the bad order on draft restore). Also stamp the
owning network on policy-drop destination resources so they don't show
"No Network".

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 14:49:17 +02:00
Eduard GertandClaude Fable 5 5605ff4eed Adopt pre-assigned draft resources when their network lands on canvas
Assigning a resource to a network that isn't a frame yet stamps only the
ref; dropping that network later now adopts those resources into the
frame (and the create-network assign waits for the frame to reach the
ReactFlow store instead of racing it on a 0ms timeout).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 14:49:05 +02:00
Eduard GertandClaude Fable 5 1b29d44146 Make Auto Arrange object-aware: resolve node overlaps after layout
Stragglers outside every layout column (e.g. a standalone resource in a
frames draft, type-bucketed at x 1400 inside the frame grid) could land
on top of another node. Add resolveNodeOverlaps to the shared draft
build layout: larger nodes anchor, smaller intersecting nodes are nudged
the shortest way clear plus a 40px margin, re-checked until free.
Detection is exact intersection so tight column rhythms keep entry-layout
parity. Unit-tested.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 14:21:41 +02:00
Eduard GertandClaude Fable 5 3516fa6f42 Make Auto Arrange reproduce the initial layout in every view
Extract the draft build layout into utils/draft-build-layout.ts
(applyDraftBuildLayout) and use it for the toolbar's Auto Arrange, so
arranging an untouched draft matches the entry layout exactly for drafts
entered from any live view; drill-downs re-run the shared drilled layout.
Delete the divergent applyDraftArrangeLayout and its height-aware pitch
machinery.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 14:15:16 +02:00
Eduard GertandClaude Fable 5 d103f171ea Polish the Assign a network modal
- Footer buttons follow the shared modal pattern (right-aligned, gap-3)
  with the header's separator color; CTA renamed Save -> Assign Network.
- Network select: search inside the popover, popover matches trigger
  width, list capped at 190px, placeholder "Select or create a
  network...".
- New optional `footer` slot on SelectDropdown: content pinned below the
  scrollable options (always visible regardless of scroll/search) — used
  for "Create New Network", styled like a regular entry.
- Creating a network opens the Add Network form ON TOP of the picker
  (nested modal); Cancel returns to the picker instead of closing both.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 13:59:24 +02:00
Eduard GertandClaude Fable 5 8583fd9175 Gate canvas deletion: nothing in live, no standalone edges in draft
onBeforeDelete on the ReactFlow canvas: live mode blocks Backspace/
Delete entirely (the canvas mirrors the account); draft mode keeps node
deletion (edges cascade with their endpoints) but never deletes a
selected connection on its own.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 12:48:57 +02:00
Eduard GertandClaude Fable 5 d43c51495d Center-align source/policy columns with network frames
The networks view stacked source and policy node TOPS on the column
midline while the frame grid centers frames on it, leaving a lone peer
~30px and a policy pill ~17px below their frame. Both the live overview
and the draft rebuild now subtract shared half-height constants.

Draft fixes this exposed: packFrameGrid also runs for a single frame
(previously left at the far layout column, so the live-anchor shift
dragged the columns ~350px out of line), and the frame-grid midline
falls back to the live twin's measured height instead of an 80px guess.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 12:39:44 +02:00
Eduard GertandClaude Fable 5 47a01c68c1 Unify column spacing and kill the live<->draft mode-switch flicker
- Source columns (live peer/group/user views, user-view peers, draft
  rebuild) share the destination column's 100px pitch instead of 80/120.
- Live destination resources show "Name - Network" inline like draft
  (network ref stamped in addDestinationResourceNodes).
- Draft rebuild: dual-role groups (source AND destination) restack in
  the destination column like the live view shows them.
- Mode-switch flicker: React Flow hides unmeasured nodes for one frame,
  blanking the swapped canvas. Draft nodes now adopt their live twin's
  measured size (initialWidth/Height), including renamed ids (self-ref
  dest-group clones, destination resources/peers).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 18:04:01 +02:00
Eduard GertandClaude Fable 5 966d7bca36 Group view: show policies where the group is a destination
Policies were matched by rule.sources only, so a destination-only group
rendered an empty view. Those policies now render mirrored to the left
(sources -> policy -> selected group); source groups carry explicit
enabled state so they don't fall back to the incoming-edge check and
render dimmed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 17:38:25 +02:00
Eduard GertandClaude Fable 5 e3ef24e9bd Rewrite control-center CLAUDE.md: 326 → 67 lines
Keep architecture, layout/sort parity rules, changeset/deploy facts, and
the hard-won perf/camera gotchas; drop per-file trees and feature
narration discoverable from code. Fix stale claims (change types,
deploy order, provider names, focus-mode scope).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 17:27:25 +02:00
Eduard GertandClaude Fable 5 e79de5e317 Match draft rebuild layout/order to the live view it was entered from
Peer/group/user-entered drafts used the networks-overview rhythm (160
base, policies 480/90) and scattered destinations across layout buckets.
Now frameless drafts use the live views' spacing (120 + DEFAULT_LAYOUT_
CONFIG), restack destinations as one x1000/100 column ordered like live
(groups first, then resources/peers by first policy edge), sort policies
with the views' sortBy, and name-sort destinations on group-view entry.
Existing destination resources get their real network stamped (no bogus
"No Network" badge), and the live destination-peer card sizes to content
(w-auto) like the draft peer card.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 17:21:30 +02:00
Eduard GertandClaude Fable 5 a6f43c8ef0 Fix top-left node flash when returning to control center
On client-side nav back to the page, nodes commit before fitView's
measurement wait completes and painted at the default viewport. The
first fit after mount now hides the viewport (cc-prefit, instant hide),
snap-fits without animation, and fades the scene in already framed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 16:57:45 +02:00
Eduard GertandClaude Fable 5 cc0455d4a5 Unify live/draft network view: interactive live frames, matching layout
- Live frames gain the draft chrome for real: Add Resource opens the
  networks ResourceModalContent (real POST), routing bar Add + zero-state
  open the real RoutingPeerModalContent; the Add band is reserved in both
  modes
- Live frames contain destination resource-group rows like draft
  (per-network ids); peer-only destination groups are skipped in the
  networks view (live never drew them; networks-entered drafts drop them
  + their edges); draft group folding matches canDropGroupIntoNetwork
  (one member resource in exactly one network, peers don't block)
- Identical geometry and order across modes: shared FRAME_GRID_BASE_X,
  frame heights re-seeded after folding, group rows first, sources
  (groups + source peers) as ONE 160-pitch column, policies at x480/90,
  every column + the frame grid sorted by name
- Routing-peers dropdown polish: rows prefetch on trigger hover (no
  skeleton flash), skeleton geometry matches the final list, exact list
  height (no scrollbar for short lists), tighter search-to-rows gap

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 16:48:57 +02:00
Eduard GertandClaude Fable 5 f3d4ef75ae Focus mode works in draft mode too
useGroupFocusDim and the focus drag-lock no longer gate on live mode;
className joins the history's transient keys so focus toggles never
create undo entries.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 16:05:22 +02:00
Eduard GertandClaude Fable 5 b3dde3f6a2 Live focus mode + policy nodes in networks overview; drop user-detail view
- Networks overview builds source -> policy node -> network frame with
  smart edges (like draft) instead of labeled straight lines; sources
  left, policies middle, packFrameGrid right; frames whose policies are
  all disabled dim (frame + child rows)
- AnimatedLine is a SmartEdge-style bezier with a midpoint label chip
  (orthogonal channel router removed); drill-out reveal zooms out
- Focus mode (live): clicking a group opens its panel, clicking a
  peer (user view) or resource card sets focusedNodeId; everything off
  the node's path dims to grayscale (forward+backward closures) via
  cc-dimmed className; dimmed nodes are non-interactive and clicks fall
  through to the pane which exits focus; node dragging disabled while
  focused (dragging pans; per-node draggable:true removed - it
  overrides the global flag)
- User-detail view removed: previousSelectedUser breadcrumb,
  forceSinglePeerView/forceSingleUserView, user-peer history level,
  onForceSingleUserView
- Bigger frame grid gaps (280/200)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 16:03:27 +02:00
Eduard GertandClaude Fable 5 93010cd3d7 Drill-out reveal zooms out instead of growing in
The fly-out's reveal reused the drill-in's grow-in (0.7x -> fit), which
read inverted going back; direction "out" now starts at 1.45x and
settles outward, mirroring the dive.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 14:56:12 +02:00
Eduard GertandClaude Fable 5 34b2ce3493 Fix whole-canvas re-renders: stable ReactFlow props + memoized contexts
React profiler showed ~2700-fiber User-Blocking commits (120-170ms) on
every drag tick, hover flip and click. Root causes and fixes:

- DraftModeProvider context value memoized (hover state changes handed a
  fresh value to every useDraftMode consumer = every node)
- Every callback/object prop on <ReactFlow> is identity-stable via
  useStableHandler; inline callbacks defeated GraphView's memo so each
  nodes update re-rendered the entire canvas subtree
- selectedDestinationGroup split into its own context (group click
  re-rendered every node via useCanvasUI)
- Edges subscribe to endpoint rects via useEdgeNodeRect (value equality)
  instead of useInternalNode; ResourceNode's parent-hidden check likewise
- elevateEdgesOnSelect off (edge DOM reshuffle restarted dash animations)
- Draft persistence + history capture skip while dragging and pre-check
  structurally before stringifying; history ignores transient node state
- Lazy router-rows fetch for draft frames too; Add Resource button uses
  a ref instead of mounting useControlCenterData per frame
- Components panel, PeersToolbar, DestinationGroupPanel, CanvasToolbar,
  DraftEmptyCanvas subscribe structurally (useStructuralNodes), not to
  the churning canvas context
- Drag drop-target highlight throttled to ~8px steps
- Live networks overview arranged like draft (sources left, packFrameGrid
  staggered frame grid right; d3 force layout removed from this view)
- Draft build: resource-only destination groups fold INTO their network
  frame; frames seeded with the capped grid; useAnySourceGroupEnabled
  skips when enabled is explicit

Verified via Chrome trace on the live app: 4ms/s JS during a 125Hz drag
over 36 network frames, no long tasks.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 14:50:43 +02:00
Eduard GertandClaude Fable 5 be6744aa18 Live view policy destinations render as cards
destinationResourceNode gets standalone: true in the shared builder —
resource destinations route to the StandaloneResourceNode card, peer
destinations get the same card surface as PeerNode's card variant
(previously both were transparent rows).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 10:44:20 +02:00
Eduard GertandClaude Fable 5 6eec46e37c Draft overview network selector + drill history survives mode switches
Draft mode (not drilled) shows the top-left network SelectDropdown like
live: "All Networks" first, canvas frames sorted by name, picking one
drills in. Drill history entries are consumed deferred (150ms) so the
transient deactivation during draft/live switches reuses the entry
instead of racing history.back() — browser Back after switching modes
now exits to the overview instead of leaving the page.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 10:24:26 +02:00
Eduard GertandClaude Fable 5 a9836381d4 Hide peer connect handles in live networks view
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 10:13:19 +02:00
Eduard GertandClaude Fable 5 5882d95343 Browser Back exits canvas drill-downs; lighter simple connection lines
useDrillDownBrowserHistory tracks a stack of drill levels (network
drill-down, user-to-peer breadcrumb): entering pushes one same-URL
history entry, popstate exits through the same path as the UI back
arrow, UI exits consume the entry silently. SimpleConnection stroke
lightened from #595959 to #7d7d7d.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 10:08:57 +02:00
Eduard GertandClaude Fable 5 6526f72f4d Fix misaligned camera when returning to control center via client nav
Warm SWR cache makes the view initialize before ReactFlow measured the
freshly remounted nodes; fitView now retries (bounded rAF loop) until
every target node has a measured size before fitting.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 09:54:58 +02:00
Eduard GertandClaude Fable 5 8aebc55be2 Group view initial pick falls back to All when only All has policies
getFirstGroup excluded All from the has-policies check, so an empty
non-All group could win over a populated All view. New order: non-All
group with policies, then All with policies, then any non-All, then any.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 09:51:54 +02:00
Eduard GertandClaude Fable 5 aeea740bd2 User view peers render as draft-style cards without connect handles
PeerNode now always renders the invisible AllHandles edge anchors
(GroupNode's pattern) so showHandles:false only hides the visible
connect bubbles instead of detaching edges.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 09:39:39 +02:00
Eduard GertandClaude Fable 5 7c0f6b4d66 Live policy saves patch the canvas in place from the PUT response
No more waiting on the SWR /policies revalidation (or a layout reset):
refreshLiveView rebuilds the current view with the saved policy spliced
in, keeping positions and camera; all view appliers accept a
policiesOverride and the policy modal reads the canvas node's policy
first in both modes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 09:29:26 +02:00
Eduard GertandClaude Fable 5 9908a5ddcb Shared drill-down canvas transition (dive/fly-out) for draft and live
utils/canvas-transition.ts: one continuous zoom stitched across an invisible
swap — accelerating dive/zoom-out + fade-out (half ease-in), swap while
transparent, grow-in reveal + fade-in (half ease-out) into a viewport
computed from the new scene. One-liner facades drillInto/drillOutOf (Node or
Rect targets) for any future drill-down; isCanvasTransitionActive suppresses
view-init fitViews mid-transition.

- Draft drill-down enter/exit refactored onto it (position-aware close-up on
  exit, exitingRef guards the reconciling repair; frame layout + edge
  attachment + row/card rendering key off frame.hidden so nothing morphs
  while visible)
- Live network select dives into the clicked frame (dropdown picks zoom from
  center), back flies out to the overview

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 17:54:02 +02:00
Eduard GertandClaude Fable 5 acabf71011 Exiting draft mirrors the draft's drill state in live
Backed out of the drill inside draft → live lands on the all-networks
overview instead of the stale drilled snapshot.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 16:41:19 +02:00
Eduard GertandClaude Fable 5 9e5816c5a4 Unify the single-network (drilled) layout across live and draft
- utils/drilled-layout.ts: ONE layout (applyDrilledLayout, 95px resource
  pitch, getDrilledFrameAnchor incl. the column's +5 centerY offset) shared
  by the live single-network view, the draft drill-down, and live→draft
- Draft drill re-lays the kept world out with it (parent positions
  snapshotted, restored on exit with hidden flags + viewport); drilled frame
  grid is a fixed-pitch single column, cards auto-size (no forced width)
- Live networks render as read-only frames (RoutingPeersBar without Add,
  frame anchors/handles for edges, hover highlight, click/row-click drills,
  bottom padding instead of the draft Add band); drilled resources render as
  standalone cards in live too, with direct policy→resource edges
- Entering draft from live drilled carries the network as a frame and enters
  the draft drill-down in the same commit (pre-hidden, no flash); exiting
  draft while drilled lands in the live view of that network
- Live all-networks lines are blue with an All fallback label; peer sources
  drawn in both live network views; NEW badge only for id-less networks
- Drill header: name chip matches the live selector (icon size/color,
  typography, paddings), edit segment for all frames (existing networks open
  the real network modal), RoutingPeersBar height/chevron aligned with the
  fixed-height network selector; SelectDropdown defers onChange past its
  close animation (no blink) and uses a 16px chevron

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 16:14:07 +02:00
Eduard GertandClaude Fable 5 48e899ed65 Shared RoutingPeersBar for draft frames and the live network header
Extracts the frame's [status | Add] routing button group + searchable
routers popover into RoutingPeersBar (indicator, row type, peer-count and
sort helpers). NetworkNode consumes it unchanged; the live single-network
header's NetworkRoutingPeerCount now renders the same bar over the API
routers, with Add/edit navigating to the routing-peers tab. Status button
left padding 14px.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 12:37:13 +02:00
Eduard GertandClaude Fable 5 fc68538457 Frame's Add Resource button drops a blank row instead of opening the editor
Matches the context menu's Add Resource (addResourceToFrame); the editor
still opens on row click.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 12:14:40 +02:00
Eduard GertandClaude Fable 5 b994d703ff Network frame routing-peers dropdown
- Frame status button gains a ChevronsUpDown trigger and a PeerSelector-style
  popover (search by peer/group name, no enter badge): one row per router with
  muted OS icon + peer name or group badge + group name (peer count inline)
- Draft router rows open the routing-peer modal prefilled (editChangeId on
  routingPeerModal); save replaces the create-router change
- Status counts peers, not routers: groups contribute their peer count,
  disabled routers contribute nothing and render at 50% opacity, sorted last
- Floating bar nudged 2px left; NetworkRouter API routers of existing
  networks are listed too (read-only)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 12:08:18 +02:00
Eduard GertandClaude Fable 5 59af25d45c Groups can drop into network frames; selection, count-label and grouping fixes
- Drag a group onto a network frame → converts in place to a resource-group
  row inside it (same node id, policy edges re-attach to the frame). Eligible
  when the group is empty, has a resource already in that network, or carries
  unassigned draft resources (canDropGroupIntoNetwork, tested)
- Grouped standalone draft resources ride on the group node (data.draftResources);
  dropping the group into a frame records create-resource changes assigning
  them to that network with the group membership
- Frame children are selectable:false — full-width rows no longer join any
  grazing rubber-band selection (phantom Create Group members)
- Create Group resolves draft resources via getDraftResource so their new-…
  ids reach the group (count label, changeset membership, policy regrouping)
- Group count label leads with resources: "2 Resources, 1 Peer"

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 11:24:00 +02:00
Eduard GertandClaude Fable 5 ee7f1d1dfb Resource-sourced drags onto peers/groups open the policy modal flipped
Dragging from a resource onto a peer/group prefills the create-policy modal
with the resource as destination and the peer/group as source (framed
resources keep the network destination scope). Also: context-menu Add
Routing Peer uses Share2Icon, NetworkNode Add buttons use CirclePlusIcon.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 10:56:27 +02:00
Eduard GertandClaude Fable 5 d6bc80670f Dropped and dragged nodes settle on top of the canvas z-order
Shared getTopZIndex (max settled z + 2, beating frames and their children)
applied in placeNode, dropExistingNetworkFrame, addNewGroup, and
onNodeDragStop — e.g. a peer dropped over a network frame paints above it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 10:35:49 +02:00
Eduard GertandClaude Fable 5 e8ba1d7378 Policy Remove empties its sources/destinations; re-drop uses draft state
- Removing an existing policy records an update-policy change with emptied sources/destinations (draft policies still just cancel their pending create)
- Components panel re-drop (and network/resource auto-drawn policies) prefer the pending update-policy state over API data, so a disconnected policy doesn't come back with its deployed sides

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 10:29:01 +02:00
Eduard GertandClaude Fable 5 1f6028253e Draft policies: pull in resource policies on drop, canvas-only policy Remove, delete confirms
- Dropping an existing network/resource also draws the API policies targeting its resources (getPoliciesTargetingResources + drawResourcePolicies)
- Policy context menu Remove (CircleMinus, no confirm) removes only the policy node and edges, keeps sources/destinations on canvas, cancels pending create / drops pending updates
- Deletes (policy/resource/group) confirm: marked for deletion, deleted on Review & Deploy
- Group node removal strips the group from connected policies (like peers/resources); deferred policy sweep skips policies removed in the same cascade
- Remove icons switched to CircleMinusIcon; unit tests for the policy matcher

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 10:15:09 +02:00
Eduard GertandClaude Opus 4.8 b5e3d8ff88 Fix group node "Details" being immediately dismissed
The context menu's item-click and outside-click shared one onClose that
cleared the panel too, so "Details" opened the group panel then instantly
closed it. Split into onClose (menu only, for item clicks — stopPropagation so
it doesn't reach the document listener) and onDismiss (everything, for outside
click/scroll). Details now opens the panel and it sticks; outside click still
dismisses menu + panel in one click.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 17:09:15 +02:00
Eduard GertandClaude Opus 4.8 3a3b0785c3 Fix live hierarchical layout alignment regression
centerNodesVertically is shared by the live hierarchical layout and the draft
auto-arrange; the height-aware pitch added for tall network frames broke live
column alignment (unequal per-node pitches). Make height-aware pitch opt-in
(auto-arrange only); live keeps a fixed pitch so equal-height nodes stay in a
straight line.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 17:03:52 +02:00
Eduard GertandClaude Opus 4.8 0913a6aa2c Standalone resources, existing-network frames, resource changeset ops
- Dropped resources are standalone cards with an inline network label / "No
  Network" picker (DraftResourceNetworkModal); assign via drag-into-frame or
  the picker; extract StandaloneResourceNode
- Existing networks drop as full frames (dropExistingNetworkFrame) with their
  resources as children; reparent an already-dropped resource instead of
  duplicating its node id
- Refactor frame-ness to an explicit data.frame flag (isFrameNode /
  isDraftNetworkNode); network-new- now means only "draft network", existing
  frames keep their real id
- Resource context menu: Edit (all), Enable/Disable, Rename (draft), and
  Remove vs Delete (existing framed) — wired into the changeset via new
  update-resource / delete-resource change types + deploy PUT/DELETE; draft
  enabled folds into create-resource
- Components panel: existing resources show "name - network" and can't drop
  into a frame; resource drags highlight the target frame (white border)
- Frame z-layer so no node renders between a frame and its resources
- Auto-arrange stacks columns by measured node height
- Group node "Details" menu item opens the panel; policy left-click no longer
  leaves a halo; misc spacing/badge/icon tweaks

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 16:57:21 +02:00
Eduard GertandClaude Opus 4.8 aa8be0655d Network frame refinements: resource groups, spacing, auto-arrange heights
- Add "Add Resource Group" context-menu item (default group icon); blank
  group rows drop first in the frame and count toward layout/overflow
- Group naming now consistent ("Group", "Group (2)", …) everywhere
- Resource groups: Rename + Details context menu, sky halo on right-click,
  hover-only white ring during connects, "No Resources" count label
- Group node context menu gains "Details" (list icon) to open the panel;
  clicking a group no longer toggles the panel closed (only outside click)
- Newly added resources/groups always land first in the frame grid
- Auto-arrange stacks columns by measured node height so tall network
  frames (with floating routing bars) don't overlap neighbours
- Tune Add Resource button spacing (top gap + bottom padding)
- Remove routing-peer HelpCircle + HA tooltip; nudge "+N more" cell 1px
- Fix User Device select node NEW badge alignment

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 12:09:57 +02:00
Eduard GertandClaude Opus 4.8 1471767e27 Network frame: floating Add/routing controls + bottom Add Resource button
- PeerNode: server/agent placeholder Install button uses the shared Button
  (secondary, xs) to match the User Device select node.
- NetworkNode: rework the frame's floating controls — an "Add" resource
  control and a routing-peers button group ([â—Ź status | + Add]) sized to
  match the Install button (secondary, xs).
- Add a secondary "Add Resource" button in the frame body: centered when
  empty, full-width bottom row once resources exist; opens the resource
  editor. Reserve a bottom band for it in the frame layout (mutually
  exclusive with the "+N More" overflow footer).
- Sparse frames (0/1 resource) match the two-resource width; empty frames
  get a little extra height so the centered button breathes.
- Hovering the floating controls / add button no longer highlights the
  frame or reveals its ConnectHandle (new ConnectHandle `hidden` prop).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 17:28:13 +02:00
Eduard GertandClaude Opus 4.8 b26c6f0a12 Network frame: header Add dropdown + "+N More" overflow footer
Rework the draft network frame's resource affordances:

- Move adding into a header "Add" dropdown (chevron) on the right of the
  name/count, with Routing Peer (server icon) and Resource (resource
  icon) items. Removes the separate "Add" button from the floating
  routing-peer status pill and drops the always-present "Add Resource"
  child row (AddNetworkResourceNode deleted).
- Summarize resources past the visible cap with a "+N More" footer
  rendered by NetworkNode in a band useNetworkFrameLayout reserves at the
  frame bottom (pointer-events-none, so clicking still drills in),
  replacing the fade mask and the short-lived ResourceOverflowNode.

useNetworkFrameLayout is simplified to lay out only resource children and
sweeps legacy add-resource/overflow child nodes from persisted drafts.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 10:27:31 +02:00
Eduard GertandClaude Opus 4.8 b1824b50ba Add networks empty with an always-present Add Resource row
New draft networks are created without seeded resources: drop the
layout-testing seed (30 fake resources + 10 resource groups) from
addDraftNetwork so a network starts empty.

Every network frame — including a freshly-added empty one — now always
contains the "Add Resource" child row: useNetworkFrameLayout creates it
if missing and no longer removes it when the frame is empty (dead
obsoleteAddRows path removed). The centered "No resources yet" hint is
gone; the header subtitle reads "No Resources" when empty and the frame
keeps its header separator.

Also removes the bottom-fade gradient overlay from the existing-network
card's resource preview grid.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 09:41:49 +02:00
Eduard GertandClaude Fable 5 9bfb73231d Draft editors, context menus, components panel & selection toolbar
- Resource editor gains a create-mode (frame's Add Resource row opens the
  modal; the node is only created on save) and the provider nesting fix
  for the resource-modal refactor; new DraftNetworkEditModal (name +
  description) shared by the network context menu and drill-down header.
- Context menus: Edit items (SquarePen) for policies/networks/resources,
  policy Remove vs Delete split (draft vs API), PencilLine renames,
  Add Resource on frames, Policy above Group ordering.
- Components panel: draft peers/policies/networks listed with NEW badges
  (IP placeholder, kind icons), "Networks & Resources" category with
  existing networks droppable as live cards, virtualization measurement
  fixes, panel hides once a real drag starts.
- Selection toolbar: Remove option next to Create Group, padding fix.
- Auto-names drop the "New " prefix ("Group", "Resource").

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 17:54:06 +02:00
Eduard GertandClaude Fable 5 987054f765 Network frames v2: destination picker, drill-down, routing modal, layout
Spec §10 implementation plus the frame interaction model:

- Connections: peer/group drops onto a frame or its contained rows open
  the create-policy modal (source prefilled, destination scoped to the
  network, one-way); POLICY↔network drags open the minimal destination
  picker (DraftNetworkDestinationModal). Drag no longer creates routers.
- Policy edges to framed resources attach to the frame in the parent view
  and to the actual resource in the drill-down (useFrameEdgeAttachment,
  pure logic + tests in utils/frame-view).
- Drill-down: clicking a frame enters a live-mode-like single-network view
  (no frame box, square-ish grid, back/name/edit/routing-count header);
  hidden flags + viewport restore on exit.
- Routing peers: the frame button group and context menu open the networks
  page's routing-peer modal; routers are changeset-only (no canvas nodes),
  counts derive from create-router changes, deploy sends the modal's
  metric/masquerade/enabled.
- Frame layout: 4x2 row-major grid capped at 6 visible resources with a
  fade-out on the last row, always-visible Add Resource row, tunable
  padding/gap/width constants, drop z-order fix for stacked frames.
- Node visuals: NEW badges after draft names, frame-hover highlights
  header/border/icon boxes, placeholder select copy, min widths.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 17:53:51 +02:00
Eduard GertandClaude Fable 5 71cc5c038d Pure-data modal modes and draft-aware props for shared components
- NetworkModal, NetworkRoutingPeerModal and NetworkResourceModal gain a
  useSave={false} mode: no API calls, values return via onSaved — the
  draft canvas reuses the exact networks-page modals.
- AccessControlModal: destinationScope restricts the destination selector
  to a network's resources/groups (no peers) and locks the policy one-way.
- PeerGroupSelector: groupIds whitelist (disables inline group creation),
  initialTab (open tab without reordering triggers), scoped empty states,
  and a default icon for type-less draft resources.
- ResourceBadge/PeerOperatingSystemIcon: default resource icon and
  Server/Agent/User-Device icons for draft placeholder peers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 17:53:31 +02:00
Eduard GertandClaude Fable 5 991ee63b6d DRY up NetworkNode and the full-area target handle
NetworkNode: RoutingPeersIndicator and AddRoutingPeerButton extracted
(shared by the draft frame and the live card). The invisible
whole-node target handle becomes handles/FullAreaTargetHandle, reused
by AllHandles, PolicyNode, and NetworkNode.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 14:20:59 +02:00
Eduard GertandClaude Fable 5 869ae3af02 Dragging a contained resource moves its network frame
Frame children are draggable again (non-draggable nodes emit no drag
events — the gesture fell through to canvas panning). A drag-start
handler snapshots child + frame positions; each drag tick transfers
the child's displacement to the frame while snapping the child back
into its slot, so the whole frame (and every resource in it) follows
the cursor. Framed resources stay excluded from drag-into-group.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 14:14:22 +02:00
Eduard GertandClaude Fable 5 fe5eae8625 Dragging a contained resource drags its network frame
Restore ReactFlow's fall-through: frame children are non-draggable,
so a drag on a resource moves the parent frame (and everything in
it). Clicks still open the resource editor.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 13:53:49 +02:00
Eduard GertandClaude Fable 5 79096499c1 Stop contained resources from dragging their network frame
Dragging a non-draggable frame child fell through to dragging the
parent — contained resources now carry the "nodrag" class (clicks
still open the editor). Frame width reduced to 340px.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 13:51:33 +02:00
Eduard GertandClaude Fable 5 c75f4d93f1 Document accepted draft-mode v1 limitations
specs/limitations.md: framed resources can't drag into groups (use the
editor's Groups field), routing-edge-only deletion leaves its router
change, network descriptions not editable on canvas.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 13:48:49 +02:00
Eduard GertandClaude Fable 5 7d1281f941 Enlarge the draft network frame
420px wide with a taller header (64px), roomier rows and padding;
contained resources keep spanning the frame width.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 13:46:37 +02:00
Eduard GertandClaude Fable 5 a57f4a6276 Fixed full-width resources inside network frames; roomier nodes
- Contained resources are laid out by the frame: not draggable, and
  spanning (basically) the full frame width; popping out of a removed
  frame restores free dragging and auto width
- Network frame widened (360px) with slightly taller header/padding
  and peer-style hover treatment
- Group nodes get a little more padding

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 13:44:51 +02:00
Eduard GertandClaude Fable 5 b9e65b1ad6 Simplify draft resource/network node presentation
- No "Set up" affordance above incomplete resources — the dimmed
  x.x.x.x placeholder and click-to-edit carry that state
- The resource editor no longer opens on drop (click / context-menu
  Edit instead)
- Network frames: solid background with a dashed border
- Routing-peer indicator hidden entirely at 0 (the floating Add
  Routing Peer button covers it); NEW badges removed from draft
  resource and network nodes

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 13:37:59 +02:00
Eduard GertandClaude Fable 5 2f86fd0421 Draft resources: peer-style cards inside auto-created network frames
- Dropping a resource auto-creates a draft network FRAME around it —
  a bordered, slightly opaque container node; the resource lives
  inside as a ReactFlow child (frame grows per member, children pop
  out at absolute positions when the frame is removed)
- Draft networks (panel template + editor inline-create) use the
  frame rendering: header with name/NEW badge/routing traffic light,
  "No resources yet" hint, floating Add Routing Peer button
- The draft resource node mirrors the placeholder-peer card: derived
  type icon box, NEW badge, dimmed "x.x.x.x" address placeholder;
  left-click and context-menu Edit open the resource editor
- Resources carry a single LEFT connect handle — dragging into a
  policy sets it as the destination (source side rejected); network
  assignment reparents into frames instead of membership edges
- Auto-arrange is parent-aware: child connectivity resolves onto the
  frame, children keep their relative positions

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 13:26:08 +02:00
Eduard GertandClaude Fable 5 15475d8b14 Add unit tests for draft networks, resources & routing peers
- draft-connect: peer/group→network router connects, resource→network
  membership, network/resource source rejection, policy-side resource
  rules, draft-resource prefill with derived types
- changeset: create-network/resource/router recording, fold-into-create,
  network removal cascade, group-rename following into resource/router
  refs, placeholder router id renames, membership cleanup
- capabilities: routing-peer/assignable/configurable/renamable matrix
  rows; complete vs incomplete draft-resource groupability
- helpers: address→type derivation, isCompleteDraftResource, policy
  gating on tracked draft resources
- storage: round-trip of the three new change types

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 12:36:34 +02:00
Eduard GertandClaude Fable 5 e89d7251e2 Implement draft-mode networks, resources & routing peers
Implements specs/draft-networks-resources.md (v1):

- Networks drop with a create-network change (unique auto-name,
  deployable immediately); NetworkNode is draft-aware — NEW badge,
  draft resource count/previews, routing-peer traffic light from
  routing edges, floating "Add Routing Peer" button at 0 routers,
  Rename / Add Routing Peer / Remove context menu (removal cascades
  to dependent resource/router changes)
- Resources drop + open the new DraftResourceEditorModal (unique
  name, validated address, network selector with API + draft
  networks and inline create, optional groups/description);
  incomplete resources carry an amber "Set up" affordance and stay
  out of the changeset; completing one re-runs referencing policies
- Routing peers: peer/group → network connects (and the Add Routing
  Peer install flow) draw "routes" edges and record create-router;
  placeholder routers gate until install, then the upgrade sweep
  records them with the real peer id
- Resources are destinations-only (hard-rejected as connect
  sources); draft resources participate in policies with new-… ids
  via additionalResources, join groups (deploy fans membership out
  through the resource's own groups field)
- Deploy: groups → networks → resources → routers → policies with
  networkClientToId / resourceClientToId resolution and live-modal
  router defaults; Review & Deploy shows non-blocking warnings for
  router-less networks and policy-less resources

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 12:36:34 +02:00
Eduard GertandClaude Fable 5 9a7b313a19 Add Vitest unit tests for draft mode logic
npm run test:unit — Vitest + jsdom, no infra (e2e stays on Playwright).

- draft-connect.test.ts: full connect matrix (modal prefills incl.
  placeholders and XOR-side rules; direct policy-side edits with
  handle mapping, duplicate/occupied no-ops)
- node-capabilities.test.ts: per-node-kind capability spec (rename /
  install / peer-select / join-a-group for every node type)
- DraftChangesetContext.test.tsx: coalescing rules via renderHook
- helpers.test.ts: isDeployablePolicy, policy regrouping, IP/hostname
  placeholders
- draft-storage.test.ts: persistence round-trips
- Legacy hand-rolled ip/version test scripts excluded from vitest

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 12:07:36 +02:00
Eduard GertandClaude Fable 5 db261b184a User Device select node, upgrade follow-through, and draft consistency fixes
User Device placeholder:
- Drops a canvas node like Server/Agent, rendered as a live-style
  peer-select dropdown ("Select user device..."); choosing a peer
  upgrades the node in place and keeps the dropdown so the selection
  can be switched — policies and edges follow the new peer
- Install buttons float top-left above all placeholder nodes (inside
  the node so they zoom with the canvas), secondary styling
- Placeholder ids renamed new-<uuid> → draft-<uuid>

Group membership follow-through:
- Grouping/dragging placeholders records them as members with their
  draft ids; on install/select the id is renamed across the changeset
  (replacePeerIdInGroups) and group nodes' addedMembers; deploy
  filters never-installed draft ids
- Group counts/addedMembers update on every canvas instance of a
  group (source node + destination copies)
- Policies referencing a grouped or removed peer/resource are rewired
  (group takes the side) or cleared (side emptied) — an incomplete
  policy drops its pending create/update so broken state never deploys

Other:
- Peer view → draft: the picked peer is skipped when a canvas group
  already contains it (was overlapping the group node)
- Auto-arrange vertical spacing increased (160/80/160)
- Connect rules extracted to utils/draft-connect.ts and capability
  predicates to utils/node-capabilities.ts (pure, unit-testable),
  wired into NodeContextMenu + useDragToGroup

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 12:07:36 +02:00
Eduard GertandClaude Fable 5 ca6ac21d52 Add spec for draft-mode networks, resources & routing peers
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 12:01:15 +02:00
Eduard GertandClaude Fable 5 f27f7b189b Draft peers, blank policies, and placeholder install flow
Components panel & context menus:
- Panel not-found state matches the global search modal styling
- Context menus (canvas + node) flip away from viewport edges
- User Device template delays panel close so its modal opens first
- Template icon boxes match existing entities (solid bg)

Placeholder peers (Server / Agent):
- Node mirrors the real peer card (layout, text sizes, truncation);
  IP slot shows a dimmed placeholder from the account network range
- Unique per-drop names ("Agent", "Agent (1)", ...), renamable via
  context menu with uniqueness check across draft peers
- Selectable in policy modals (additionalPeers in PeerGroupSelector)
  and connectable like real peers via their draft-<uuid> ids
- Install modal suggests a unique hostname (netbird up --hostname),
  stamped on the node; useDraftPeerUpgrade watches the peers list and
  swaps installed placeholders for real peers in place, re-recording
  their policies into the changeset

Policies in draft:
- Policy template drops a blank policy node (no modal) named
  "Policy" / "Policy (1)"; blank/undeployable policies stay out of
  the changeset until they have both sides and no placeholder refs
- Peers can be dragged onto policy sides (single-peer XOR groups)
- Connect prefills the create modal (peers as peer resource, groups
  as lists) and resets stale prefill state on every connect
- Peer selector dropdown opens on the tab of the current selection

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 17:34:01 +02:00
Eduard Gert dee5c6e244 Use SmartEdge for policy edges in live views
Migrate peer/group/user/network view policy edges from the DirectionIn
edge type to SmartEdge, enabling bidirectional rendering and dynamic
side selection in live mode. DirectionIn and BidirectionalEdges remain
registered but unused.
2026-07-15 11:58:00 +02:00
Eduard Gert 6db980fcff Add draft persistence, undo/redo, and review & deploy flow
- Persist draft changes and canvas to localStorage, restored on mount
- Snapshot-based undo/redo for canvas and changeset (DraftHistoryContext)
- Review & deploy modal with grouped changes and deploy changeset hook
- Discard draft flow and leave guard for unsaved drafts
- Group rename modal and draft node creation hooks
- Move components panel into draft/ and adjust related UI
2026-07-15 11:45:32 +02:00
Eduard GertandClaude Fable 5 6a299aa594 Hide network selector in header when no networks exist
In live mode on the networks tab, the header network selector now only
renders when there is at least one network, matching the empty state
shown by ControlCenterEmptyStates.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 11:29:27 +02:00
Eduard Gert 050d28d7af adjust empty draft state 2026-07-14 11:59:34 +02:00
Eduard Gert 6abf5d21c1 adjust empty draft state 2026-07-14 11:59:30 +02:00
Eduard Gert 368cfac0fe adjust sidebar 2026-07-13 17:03:09 +02:00
Eduard Gert 5c220e5176 adjust sidebar 2026-07-13 13:45:03 +02:00
Eduard Gert 9f5e0edd45 adjust save and apply 2026-07-09 09:45:01 +02:00
Eduard Gert 9a99c5930f adjust draft mode ui 2026-07-08 17:26:24 +02:00
Eduard Gert 1c10d195e7 add blue halo when selecting node 2026-07-07 12:19:18 +02:00
Eduard GertandClaude Opus 4.7 a57da47f45 Merge control-center-edit: draft mode, canvas toolbar, connect handles
Overlays feature/control-center-edit's control-center paths onto main:
new draft/ mode with canvas toolbar, keyboard shortcuts, drag-to-group,
create-group modal, connect-handles for drawing policies, SmartEdge
routing, and refactored ControlCenterContext into CanvasState + UI +
Policy providers. Adds hooks/views split (peer/group/user/network).

AccessControlModal + useAccessControl gain initialSourceGroups /
initialSourceResource so draft-mode policy creation can seed the source
side (previously only destination could be pre-filled).

Unrelated cloud-repo changes (data-testid -> data-cy renames, DNS,
reverse-proxy, integrations, etc.) are excluded.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-07-06 17:10:19 +02:00
242 changed files with 35299 additions and 7744 deletions

No files matched your search

+3 -3
View File
@@ -39,7 +39,7 @@ jobs:
with:
token: ${{ secrets.GITHUB_TOKEN }}
repository: netbirdio/IronRDP
tag: v0.0.2
latest: true
fileName: "*.ts"
out-file-path: 'public/ironrdp-pkg'
@@ -48,7 +48,7 @@ jobs:
with:
token: ${{ secrets.GITHUB_TOKEN }}
repository: netbirdio/IronRDP
tag: v0.0.2
latest: true
fileName: "*.js"
out-file-path: 'public/ironrdp-pkg'
@@ -57,7 +57,7 @@ jobs:
with:
token: ${{ secrets.GITHUB_TOKEN }}
repository: netbirdio/IronRDP
tag: v0.0.2
latest: true
fileName: "ironrdp_web_bg.wasm"
out-file-path: 'public/ironrdp-pkg'
@@ -1,34 +0,0 @@
name: Sync deploy templates to S3
on:
push:
branches: [main]
paths:
- "templates/reverse-proxy/**"
permissions:
id-token: write
contents: read
jobs:
sync:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ secrets.DEPLOY_TEMPLATES_ROLE_ARN }}
aws-region: eu-central-1
- name: Validate CloudFormation template
run: |
aws cloudformation validate-template \
--template-body file://templates/reverse-proxy/netbird-proxy-cfn.yaml
- name: Upload CloudFormation template
run: |
aws s3 cp templates/reverse-proxy/netbird-proxy-cfn.yaml \
s3://netbird-deploy-templates/templates/netbird-proxy-cfn.yaml
-78
View File
@@ -1,78 +0,0 @@
name: Test cloud deploy config
on:
pull_request:
paths:
- "docker/init_react_envs.sh"
- "templates/reverse-proxy/**"
- "src/modules/reverse-proxy/clusters/ClusterCloudDeploy.tsx"
- ".github/workflows/test-cloud-deploy.yml"
permissions:
contents: read
jobs:
csp-connect-src:
name: CSP allows cloud provider APIs
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install nginx
run: |
sudo apt-get update
sudo apt-get install -y nginx gettext-base
- name: Prepare nginx layout expected by the init script
run: |
sudo mkdir -p /etc/nginx/http.d /usr/share/nginx/html
echo 'add_header Content-Security-Policy "placeholder" always;' | sudo tee /etc/nginx/http.d/default.conf
sudo touch /usr/share/nginx/html/OidcTrustedDomains.js.tmpl
sudo systemctl start nginx
- name: Run init script with production-like env
env:
AUTH_AUTHORITY: https://auth.example.com
AUTH_CLIENT_ID: test-client
AUTH_AUDIENCE: test-audience
AUTH_SUPPORTED_SCOPES: openid profile email
USE_AUTH0: "false"
NETBIRD_MGMT_API_ENDPOINT: https://api.example.com
run: sudo -E bash docker/init_react_envs.sh
- name: Assert provider APIs are in connect-src
run: |
CSP=$(grep -o 'Content-Security-Policy "[^"]*"' /etc/nginx/http.d/default.conf)
echo "Generated CSP: $CSP"
CONNECT_SRC=$(echo "$CSP" | sed 's/.*connect-src \([^;]*\);.*/\1/')
echo "connect-src: $CONNECT_SRC"
status=0
for origin in https://api.hetzner.cloud https://api.digitalocean.com; do
if echo "$CONNECT_SRC" | grep -qF "$origin"; then
echo "OK: $origin"
else
echo "MISSING in connect-src: $origin"
status=1
fi
done
exit $status
cfn-template-url:
name: CloudFormation template URL is live
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Extract template URL from component and check it
run: |
URL=$(grep -o 'https://[^"]*netbird-proxy-cfn\.yaml' src/modules/reverse-proxy/clusters/ClusterCloudDeploy.tsx)
echo "CFN_TEMPLATE_URL: $URL"
test -n "$URL"
# Non-blocking: the template is published by sync-deploy-templates on
# the default branch, so it may not exist yet for a PR that only edits
# the URL. Warn instead of failing the check.
if curl -sfI "$URL" >/dev/null; then
echo "OK: template URL is live"
else
echo "::warning::CloudFormation template URL is not reachable yet: $URL"
fi
+3 -3
View File
@@ -1,8 +1,8 @@
[
{
"tag": "New",
"text": "The new NetBird desktop app is here - now available in the v0.75 release.",
"link": "https://netbird.io/knowledge-hub/netbird-v0-75-new-desktop-app",
"tag": "Preview",
"text": "The new NetBird desktop app is here - now available as a 0.75 release candidate.",
"link": "https://github.com/netbirdio/netbird/discussions/6483",
"linkText": "Learn more",
"variant": "important",
"isExternal": true,
+1 -1
View File
@@ -80,7 +80,7 @@ echo "NetBird latest version: ${NETBIRD_LATEST_VERSION}"
FIRST_PARTY_CSP="pkgs.netbird.io"
FIRST_PARTY_CSP_CONNECT_SRC="wss://*.netbird.io"
THIRD_PARTY_CSP="*.licdn.com *.linkedin.com *.vector.co *.sibforms.com *.hotjar.com *.hotjar.io *.redditstatic.com pixel-config.reddit.com *.clarity.ms c.bing.com *.microsoft.com googleads.g.doubleclick.net pagead2.googlesyndication.com www.google.com www.googleadservices.com *.google-analytics.com *.googletagmanager.com analytics.google.com *.hubapi.com *.hs-banner.com *.hubspot.com *.hubspot.net js.hs-analytics.com *.hsforms.net *.hscollectedforms.net *.hs-analytics.net *.hsforms.com track.hubspot.com *.hsadspixel.net static.hsappstatic.net"
THIRD_PARTY_CSP_CONNECT_SRC="https://api.github.com/repos/netbirdio/netbird/releases/latest https://raw.githubusercontent.com/netbirdio/dashboard/ wss://ws.hotjar.com https://api.hetzner.cloud https://api.digitalocean.com"
THIRD_PARTY_CSP_CONNECT_SRC="https://api.github.com/repos/netbirdio/netbird/releases/latest https://raw.githubusercontent.com/netbirdio/dashboard/ wss://ws.hotjar.com"
THIRD_PARTY_CSP_SCRIPT_SRC="'sha256-7knV6EIjKUvCpYWE2rCYx8dYV2WCNb2bpTuitFXzBcA=' *.hs-scripts.com"
CSP_DOMAINS=""
Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

+1
View File
@@ -218,6 +218,7 @@ await visitByNavigation(page, "Policies"); // Click child
| Notifications | `settings-notifications-email.spec.ts`, `settings-notifications-slack.spec.ts`, `settings-notifications-webhook.spec.ts` | `@notifications` |
| Team | `team-users.spec.ts`, `team-service-users.spec.ts`, `team-users-approval-and-billing.spec.ts` | `@team` |
| Setup Keys | `setup-keys.spec.ts` | `@setup-keys` |
| Control Center | `control-center-draft.spec.ts`, `control-center-draft-connections.spec.ts`, `control-center-draft-matrix.spec.ts` | `@control-center` |
## Debugging
+14 -50
View File
@@ -89,11 +89,25 @@ async function apiDelete(page: Page, path: string): Promise<void> {
});
}
async function apiPost<T>(page: Page, path: string, data: unknown): Promise<T> {
const { token, origin } = await getApiContext(page);
const resp = await page.request.post(`${origin}/api${path}`, {
headers: { Authorization: `Bearer ${token}` },
data,
});
return resp.json();
}
/** List all groups. */
export async function listGroups(page: Page): Promise<Group[]> {
return apiGet<Group[]>(page, "/groups");
}
/** Create a group directly via the API (test setup). */
export async function createGroup(page: Page, name: string): Promise<Group> {
return apiPost<Group>(page, "/groups", { name, peers: [] });
}
/** Delete a group by ID. */
export async function deleteGroup(page: Page, groupId: string) {
await apiDelete(page, `/groups/${groupId}`);
@@ -433,53 +447,3 @@ export async function deleteUserByEmail(page: Page, email: string) {
await deleteUserById(page, user.id);
}
}
// ---------------------------------------------------------------------------
// Agent Network
// ---------------------------------------------------------------------------
type AgentNetworkCatalogProvider = {
id: string;
name: string;
};
type AgentNetworkProvider = {
id: string;
name: string;
provider_id: string;
};
/** List the Agent Network provider catalog (server-defined provider types). */
export async function listAgentNetworkCatalog(
page: Page,
): Promise<AgentNetworkCatalogProvider[]> {
return apiGet<AgentNetworkCatalogProvider[]>(
page,
"/agent-network/catalog/providers",
);
}
/** List connected Agent Network providers. */
export async function listAgentNetworkProviders(
page: Page,
): Promise<AgentNetworkProvider[]> {
return apiGet<AgentNetworkProvider[]>(page, "/agent-network/providers");
}
/** Delete an Agent Network provider by ID. */
export async function deleteAgentNetworkProviderById(page: Page, id: string) {
await apiDelete(page, `/agent-network/providers/${id}`);
}
/** Delete all Agent Network providers whose name starts with the prefix. */
export async function deleteAgentNetworkProvidersByPrefix(
page: Page,
prefix: string,
) {
const providers = await listAgentNetworkProviders(page);
for (const p of providers) {
if (p.name.startsWith(prefix)) {
await deleteAgentNetworkProviderById(page, p.id);
}
}
}
+4 -17
View File
@@ -20,11 +20,7 @@ const credentials: Record<TestUser, { username: string; password: string }> = {
/**
* Navigate to the app, authenticate via Zitadel, and wait for the app to load.
*/
export async function loginToApp(
page: Page,
user: TestUser = "owner",
opts: { expectOnboarding?: boolean } = {},
) {
export async function loginToApp(page: Page, user: TestUser = "owner") {
const { username, password } = credentials[user];
await page.goto("/");
@@ -43,9 +39,7 @@ export async function loginToApp(
const which = await Promise.race([
appReady.waitFor({ timeout: 20_000 }).then(() => "app" as const),
setupModal.waitFor({ timeout: 20_000 }).then(() => "modal" as const),
approvalPending
.waitFor({ timeout: 20_000 })
.then(() => "approval" as const),
approvalPending.waitFor({ timeout: 20_000 }).then(() => "approval" as const),
onboarding.waitFor({ timeout: 20_000 }).then(() => "onboarding" as const),
selectAccount.waitFor({ timeout: 20_000 }).then(() => "select" as const),
loginInput.waitFor({ timeout: 20_000 }).then(() => "login" as const),
@@ -57,9 +51,6 @@ export async function loginToApp(
}
if (which === "modal") {
// Onboarding tests render a full-screen overlay over the setup modal;
// clicking modal-close would be pointer-intercepted, so leave it be.
if (opts.expectOnboarding) return;
await setupModal.getByTestId("modal-close").click();
await expect(setupModal).not.toBeVisible();
return;
@@ -98,12 +89,8 @@ export async function loginToApp(
onboarding.waitFor({ timeout: 15_000 }),
]);
// Dismiss setup modal if present, unless an onboarding overlay is expected
// on top of it (clicking through it would be pointer-intercepted).
if (
!opts.expectOnboarding &&
(await setupModal.isVisible().catch(() => false))
) {
// Dismiss setup modal if present
if (await setupModal.isVisible().catch(() => false)) {
await setupModal.getByTestId("modal-close").click();
await expect(setupModal).not.toBeVisible();
}
+200
View File
@@ -0,0 +1,200 @@
import { Page, Locator, expect } from "@playwright/test";
import { navigateTo } from "./auth";
export const CHANGES_KEY = "netbird-control-center-draft-changes";
export const CANVAS_KEY = "netbird-control-center-draft-canvas";
export async function openControlCenter(page: Page) {
await navigateTo(page, "/control-center");
// The canvas hides behind cc-prefit until the first fitView; wait for the
// pane itself so later mouse coordinates are meaningful.
await expect(page.locator(".react-flow__pane")).toBeVisible();
}
export async function enterDraft(page: Page) {
await page.getByTestId("cc-mode-draft").click();
// The toolbar slides in with a spring animation — waiting for the Add
// button also guarantees draft mode is fully active.
await expect(page.getByTestId("cc-toolbar-add")).toBeVisible();
}
export async function exitDraftDiscarding(page: Page) {
const draftTab = page.getByTestId("cc-mode-draft");
if ((await draftTab.getAttribute("data-state")) !== "active") return;
await page.getByTestId("cc-mode-live").click();
const confirm = page.getByTestId("confirmation.confirm");
if (await confirm.isVisible({ timeout: 1000 }).catch(() => false)) {
await confirm.click();
}
await expect(page.getByTestId("cc-toolbar-add")).not.toBeVisible();
}
/** Clears draft localStorage and makes sure we're on a clean live canvas. */
export async function resetDraftState(page: Page) {
await openControlCenter(page);
await page.evaluate(
([changes, canvas]) => {
localStorage.removeItem(changes);
localStorage.removeItem(canvas);
},
[CHANGES_KEY, CANVAS_KEY],
);
await exitDraftDiscarding(page);
}
export async function readDraftChanges(page: Page): Promise<any[]> {
const raw = await page.evaluate(
(key) => localStorage.getItem(key),
CHANGES_KEY,
);
return raw ? JSON.parse(raw) : [];
}
async function centerOf(locator: Locator) {
const box = await locator.boundingBox();
if (!box) throw new Error("element has no layout");
return { x: box.x + box.width / 2, y: box.y + box.height / 2 };
}
async function mouseDrag(
page: Page,
from: { x: number; y: number },
to: { x: number; y: number },
) {
await page.mouse.move(from.x, from.y);
await page.mouse.down();
// Intermediate moves so drag thresholds and drop-target detection see a
// realistic pointer path.
const steps = 12;
for (let i = 1; i <= steps; i++) {
await page.mouse.move(
from.x + ((to.x - from.x) * i) / steps,
from.y + ((to.y - from.y) * i) / steps,
);
}
await page.mouse.up();
}
/**
* Drags a components-panel template onto the canvas. The panel uses a custom
* pointer-based drag (not HTML5 DnD), so raw mouse events are required.
*/
export async function dragTemplateToCanvas(
page: Page,
templateTestId: string,
target?: { x: number; y: number },
) {
const item = page.getByTestId(templateTestId);
if (!(await item.isVisible().catch(() => false))) {
await page.getByTestId("cc-toolbar-add").click();
await expect(item).toBeVisible();
}
const pane = page.locator(".react-flow__pane");
const paneBox = await pane.boundingBox();
if (!paneBox) throw new Error("canvas pane not laid out");
const to = target ?? {
// Land right of the panel, roughly canvas center.
x: paneBox.x + paneBox.width * 0.6,
y: paneBox.y + paneBox.height * 0.5,
};
await mouseDrag(page, await centerOf(item), to);
// Close the panel so it doesn't cover nodes for the next interaction.
await page.keyboard.press("Escape");
}
export function canvasNode(page: Page, dataIdPrefix: string) {
return page.locator(`.react-flow__node[data-id^="${dataIdPrefix}"]`);
}
/**
* Connects two canvas nodes by dragging from a connect handle onto the target
* node (FullAreaTargetHandle spans the whole node). Handle side matters:
* dragging from `sr` (right) puts the source node on a policy's SOURCE side,
* from `sl` (left) on the DESTINATION side. Resources only have `sl`.
*/
export async function connectNodes(
page: Page,
source: Locator,
target: Locator,
handleSide: "sr" | "sl" = "sr",
) {
// Connect handles fade in on node hover.
await source.hover();
const handle = source.locator(
`.react-flow__handle[data-handleid="${handleSide}-connect"]`,
);
await expect(handle).toBeAttached();
await mouseDrag(page, await centerOf(handle), await centerOf(target));
}
/** Positions relative to the canvas pane (fractions of width/height). */
export async function panePoint(page: Page, fx: number, fy: number) {
const pane = await page.locator(".react-flow__pane").boundingBox();
if (!pane) throw new Error("canvas pane not laid out");
return { x: pane.x + pane.width * fx, y: pane.y + pane.height * fy };
}
/** Right-clicks empty canvas and clicks a creation item ("New Group", …). */
export async function createViaCanvasMenu(
page: Page,
itemText: string,
at?: { fx: number; fy: number },
) {
const point = await panePoint(page, at?.fx ?? 0.6, at?.fy ?? 0.5);
await page.mouse.move(point.x, point.y);
await page.mouse.click(point.x, point.y, { button: "right" });
const menu = page.getByTestId("cc-canvas-context-menu");
await expect(menu).toBeVisible();
await menu.getByRole("button", { name: itemText }).click();
}
export async function readDraftCanvas(page: Page): Promise<any | null> {
const raw = await page.evaluate(
(key) => localStorage.getItem(key),
CANVAS_KEY,
);
return raw ? JSON.parse(raw) : null;
}
/** Drags one canvas node onto another (e.g. a peer into a group). */
export async function dragNodeOnto(page: Page, node: Locator, target: Locator) {
await mouseDrag(page, await centerOf(node), await centerOf(target));
}
/** Right-clicks a node and clicks the given context-menu item. */
export async function clickContextMenuItem(
page: Page,
node: Locator,
itemText: string,
) {
await node.click({ button: "right" });
const menu = page.getByTestId("cc-node-context-menu");
await expect(menu).toBeVisible();
await menu.getByRole("button", { name: itemText, exact: true }).click();
}
export function reviewButton(page: Page) {
return page.getByTestId("cc-draft-review");
}
export async function expectChangeCount(page: Page, count: number) {
if (count === 0) {
await expect(reviewButton(page)).toBeDisabled();
} else {
await expect(reviewButton(page)).toBeEnabled();
await expect(reviewButton(page)).toContainText(String(count));
}
}
/**
* Walks the create-policy modal wizard (policy → posture checks → general)
* and submits. Assumes both sides are already filled in.
*/
export async function submitCreatePolicyModal(page: Page, name?: string) {
await page.getByTestId("policy-continue").click();
await page.getByTestId("policy-continue").click();
if (name) {
await page.getByTestId("policy-name").fill(name);
}
await page.getByTestId("submit-policy").click();
}
@@ -1,213 +0,0 @@
/**
* Agent Network prompt-cache accounting spec.
*
* Drives the Usage & Logs page against mocked agent-network endpoints whose
* rows carry the cache fields (cached_input_tokens / cache_creation_tokens /
* cache_cost_usd) plus the per-bucket cost breakdown, and verifies the hover
* breakdowns: the access-log Tokens tooltip gains cache read/write rows and a
* cache-aware total, the Cost tooltip lists one line per billed bucket, and
* the usage overview's daily table
* surfaces the day's cache buckets. Route mocks keep the spec hermetic, so it
* passes against management builds that predate the cache fields.
*/
import { test, expect, type Browser, type Page } from "@playwright/test";
import { loginToApp, navigateTo } from "../helpers/auth";
const AGENT_NETWORK_CONFIG_KEY = "netbird-test-agent-network";
// The reported field scenario: a session-start request that wrote a ~32k-token
// prompt cache, and a follow-up that read it back.
const writeEntry = {
id: "anlog-cache-write",
service_id: "svc-e2e",
timestamp: new Date().toISOString(),
status_code: 200,
duration_ms: 29000,
input_tokens: 10,
output_tokens: 1472,
total_tokens: 33591,
cached_input_tokens: 0,
cache_creation_tokens: 32109,
cost_usd: 0.142519,
cache_cost_usd: 0.120409,
input_cost_usd: 0.00003,
cached_input_cost_usd: 0,
cache_creation_cost_usd: 0.120409,
output_cost_usd: 0.02208,
provider: "bedrock",
model: "anthropic.claude-sonnet-4-5",
session_id: "sess-cache-write",
decision: "allow",
stream: false,
};
const readEntry = {
...writeEntry,
id: "anlog-cache-read",
output_tokens: 800,
total_tokens: 32919,
cached_input_tokens: 32109,
cache_creation_tokens: 0,
cost_usd: 0.021663,
cache_cost_usd: 0.009633,
input_cost_usd: 0.00003,
cached_input_cost_usd: 0.009633,
cache_creation_cost_usd: 0,
output_cost_usd: 0.012,
session_id: "sess-cache-read",
};
const todayBucket = {
period_start: new Date().toISOString().slice(0, 10),
input_tokens: 20,
output_tokens: 2272,
total_tokens: 66510,
cached_input_tokens: 32109,
cache_creation_tokens: 32109,
cost_usd: 0.164182,
cache_cost_usd: 0.130042,
input_cost_usd: 0.00006,
cached_input_cost_usd: 0.009633,
cache_creation_cost_usd: 0.120409,
output_cost_usd: 0.03408,
};
async function mockCacheUsage(page: Page) {
await page.route("**/api/agent-network/access-logs*", (route) =>
route.fulfill({
json: {
data: [writeEntry, readEntry],
page: 1,
page_size: 25,
total_records: 2,
total_pages: 1,
},
}),
);
await page.route("**/api/agent-network/usage/overview*", (route) =>
route.fulfill({ json: [todayBucket] }),
);
}
async function newUsagePage(browser: Browser): Promise<{
page: Page;
close: () => Promise<void>;
}> {
const context = await browser.newContext({
storageState: "e2e/fixtures/auth/owner.json",
});
await context.addInitScript(
([key, value]) => {
try {
window.localStorage.setItem(key as string, value as string);
} catch (e) {}
},
[AGENT_NETWORK_CONFIG_KEY, "enabled"],
);
const page = await context.newPage();
await mockCacheUsage(page);
await loginToApp(page, "owner");
return { page, close: () => context.close() };
}
// FullTooltip force-mounts its content, so a tooltip keeps its role=tooltip
// element in the DOM after closing — a bare getByRole("tooltip") accumulates
// every tooltip the test has ever opened. Radix puts role=tooltip on a
// visually-hidden span and the open/closed flag on that span's parent
// ("delayed-open"/"instant-open" vs "closed"), so match the parent that is
// currently open. Its text covers both the visible copy and the hidden one.
function tooltip(page: Page) {
return page.locator(
'[data-state="delayed-open"]:has(> [role="tooltip"]), [data-state="instant-open"]:has(> [role="tooltip"])',
);
}
// Radix's hoverable content holds a tooltip open while the pointer travels
// toward it, so moving straight to the next cell can leave two open at once.
// Escape dismisses the open one deterministically, wherever the pointer sits.
async function closeTooltip(page: Page) {
await page.keyboard.press("Escape");
await expect(tooltip(page)).toHaveCount(0);
}
test.describe.serial("Agent Network cache accounting @agent-network", () => {
test("access-log hover breaks out prompt-cache tokens and cost", async ({
browser,
}) => {
const { page, close } = await newUsagePage(browser);
try {
await navigateTo(page, "/agent-network/usage?tab=access-logs");
const writeRow = page.getByRole("row").filter({ hasText: "$0.1425" });
await expect(writeRow).toBeVisible();
// Tokens tooltip: cache-write bucket plus a total that includes it.
await writeRow.getByText("1,472").hover();
await expect(tooltip(page)).toContainText("32,109");
await expect(tooltip(page)).toContainText("cache write");
await expect(tooltip(page)).toContainText("33,591");
// Cost tooltip: all four buckets are listed plus the total, including
// buckets that cost nothing — this request wrote the cache but never read
// it, so "cache read" is present with a $0.0000 amount.
await closeTooltip(page);
await writeRow.getByText("$0.1425").hover();
await expect(tooltip(page)).toContainText("input");
await expect(tooltip(page)).toContainText("cache read");
await expect(tooltip(page)).toContainText("cache write");
await expect(tooltip(page)).toContainText("output");
await expect(tooltip(page)).toContainText("$0.1204");
await expect(tooltip(page)).toContainText("$0.0221");
await expect(tooltip(page)).toContainText("$0.0000");
await expect(tooltip(page)).toContainText("total");
// The follow-up request reads the cache back: read bucket filled, write
// bucket still listed as a zero row.
// No exact match: the output count renders behind an sr-only "Output:" prefix.
const readRow = page.getByRole("row").filter({ hasText: "$0.0217" });
await closeTooltip(page);
await readRow.getByText("800").hover();
await expect(tooltip(page)).toContainText("cache read");
await expect(tooltip(page)).toContainText("32,919");
await expect(tooltip(page)).toContainText(/0\s*cache write/);
} finally {
await close();
}
});
test("usage overview daily table surfaces the day's cache buckets", async ({
browser,
}) => {
const { page, close } = await newUsagePage(browser);
try {
await navigateTo(page, "/agent-network/usage");
// Row-scoped on purpose: the hovers now repeat the cell's own figure on
// their total row, and FullTooltip force-mounts that content, so a
// page-wide getByText would match the cell and the tooltip both. Radix
// portals tooltip content to <body>, outside the row.
const dayRow = page.getByRole("row").filter({ hasText: "$0.16" });
// Total Tokens includes the additive cache buckets; hover splits them out
// in the same value-then-label rows the access-log table uses.
await expect(dayRow.getByText("66,510")).toBeVisible();
await dayRow.getByText("66,510").hover();
await expect(tooltip(page)).toContainText(/20\s*input/);
await expect(tooltip(page)).toContainText(/2,272\s*output/);
await expect(tooltip(page)).toContainText(/32,109\s*cache read/);
await expect(tooltip(page)).toContainText(/32,109\s*cache write/);
await expect(tooltip(page)).toContainText(/66,510\s*total/);
// Cost hover breaks the day into the same four billed buckets.
await closeTooltip(page);
await dayRow.getByText("$0.16", { exact: true }).hover();
await expect(tooltip(page)).toContainText(/\$0\.0001\s*input/);
await expect(tooltip(page)).toContainText(/\$0\.0341\s*output/);
await expect(tooltip(page)).toContainText(/\$0\.0096\s*cache read/);
await expect(tooltip(page)).toContainText(/\$0\.1204\s*cache write/);
await expect(tooltip(page)).toContainText(/\$0\.1642\s*total/);
} finally {
await close();
}
});
});
@@ -1,363 +0,0 @@
/**
* Agent Network focused-view spec.
*
* Exercises the account-driven focused view added for netbird.ai signups:
* the `agent_network_only` account setting hides the rest of the dashboard,
* an explicit `false` opts back out, and a pending netbird.ai signup applies
* the focused view optimistically and persists the setting via PUT /accounts.
*
* The test management backend does not know the setting, so GET /accounts is
* rewritten per-test to inject it (same interception approach as
* edition-gating.spec.ts). "only" mode is asserted through the Networks nav
* item, which toggles purely on the focused-view flag and does not depend on
* premium permission modules.
*/
import { test, expect, type Browser, type Page } from "@playwright/test";
import { loginToApp, navigateTo } from "../helpers/auth";
const SIGNUP_SOURCE_KEY = "netbird-signup-source";
const AGENT_NETWORK_SOURCE = "netbird.ai";
// Drives the NETBIRD_AGENT_NETWORK_ONLY / _ENABLED deployment flags against the
// test build (see testAgentNetworkOverride in utils/netbird.ts).
const AGENT_NETWORK_CONFIG_KEY = "netbird-test-agent-network";
type AgentNetworkConfig = "only" | "enabled" | "off";
type AccountMock = {
// undefined leaves the setting absent (as an un-onboarded account would be).
agentNetworkOnly?: boolean;
signupFormPending?: boolean;
// dashboardFeaturesAgentNetwork sets settings.dashboard_features.agent_network,
// which makes the Agent Network menu available without focusing the dashboard.
dashboardFeaturesAgentNetwork?: boolean;
};
// mockAccounts rewrites GET /accounts to inject the focused-view setting and
// onboarding state onto the real account. A PUT to /accounts/{id} is captured
// and flips the injected setting to true, mirroring the server persisting the
// value so the optimistic view does not revert once the write settles.
function mockAccounts(
page: Page,
initial: AccountMock,
captured: { putBody?: any },
) {
let applied = initial.agentNetworkOnly;
page.route("**/api/accounts", async (route) => {
if (route.request().method() !== "GET") return route.continue();
const response = await route.fetch();
let body: any;
try {
body = await response.json();
} catch (e) {
return route.fulfill({ response });
}
if (Array.isArray(body) && body[0]) {
body[0].settings = { ...(body[0].settings ?? {}) };
if (applied === undefined) {
delete body[0].settings.agent_network_only;
} else {
body[0].settings.agent_network_only = applied;
}
if (initial.dashboardFeaturesAgentNetwork !== undefined) {
body[0].settings.dashboard_features = {
agent_network: initial.dashboardFeaturesAgentNetwork,
};
}
body[0].onboarding = {
...(body[0].onboarding ?? {}),
signup_form_pending: !!initial.signupFormPending,
};
}
return route.fulfill({ response, json: body });
});
page.route("**/api/accounts/*", async (route) => {
if (route.request().method() !== "PUT") return route.continue();
try {
captured.putBody = route.request().postDataJSON();
} catch (e) {
captured.putBody = null;
}
applied = true;
return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ settings: { agent_network_only: true } }),
});
});
}
async function openWithAccount(
browser: Browser,
account: AccountMock,
opts: {
signupSource?: boolean;
agentNetworkConfig?: AgentNetworkConfig;
} = {},
): Promise<{
page: Page;
captured: { putBody?: any };
close: () => Promise<void>;
}> {
const context = await browser.newContext({
storageState: "e2e/fixtures/auth/owner.json",
});
if (opts.signupSource) {
await context.addInitScript(
([key, value]) => {
try {
window.localStorage.setItem(key as string, value as string);
} catch (e) {}
},
[SIGNUP_SOURCE_KEY, AGENT_NETWORK_SOURCE],
);
}
if (opts.agentNetworkConfig) {
await context.addInitScript(
([key, value]) => {
try {
window.localStorage.setItem(key as string, value as string);
} catch (e) {}
},
[AGENT_NETWORK_CONFIG_KEY, opts.agentNetworkConfig],
);
}
const page = await context.newPage();
const captured: { putBody?: any } = {};
mockAccounts(page, account, captured);
await loginToApp(page, "owner");
return { page, captured, close: () => context.close() };
}
function navItem(page: Page, text: string) {
return page
.getByTestId("left-navigation-item")
.getByText(text, { exact: true });
}
// navItemContaining matches a sidebar entry by a substring of its label. Some
// entries (Agent Network, Reverse Proxy) render a "Beta" badge inside the label
// when the dashboard is not focused, so exact text matching would miss them.
function navItemContaining(page: Page, text: string) {
return page.getByTestId("left-navigation-item").filter({ hasText: text });
}
// Regular dashboard sections that the focused view hides (gated on
// !agentNetworkOnly in Navigation.tsx).
const FOCUS_HIDDEN_NAV = ["Networks", "Reverse Proxy", "DNS", "Activity"];
// Agent Network view routes that make up the focused surface.
const AGENT_VIEW_PATHS = [
"/agent-network/providers",
"/agent-network/policies",
"/agent-network/usage",
"/agent-network/configuration",
];
test.describe.serial("Agent Network focused view @agent-network", () => {
test("focused menu shows only Agent Network views and hides the regular sections", async ({
browser,
}) => {
const { page, close } = await openWithAccount(browser, {
agentNetworkOnly: true,
});
try {
// Peers always renders for an owner and is not part of the focused
// split, so it confirms the sidebar rendered before asserting absences.
await expect(navItem(page, "Peers")).toBeVisible();
// The Agent Network section is present and the regular sections are gone.
await expect(navItem(page, "Agent Network")).toBeVisible();
for (const label of FOCUS_HIDDEN_NAV) {
await expect(navItem(page, label)).toHaveCount(0);
}
} finally {
await close();
}
});
test("focused view keeps every Agent Network route reachable", async ({
browser,
}) => {
const { page, close } = await openWithAccount(browser, {
agentNetworkOnly: true,
});
try {
// Each Agent Network view renders instead of the route guard redirecting
// away; the Agent Network section stays in the sidebar throughout.
for (const path of AGENT_VIEW_PATHS) {
await navigateTo(page, path);
await expect(page).toHaveURL(new RegExp(path.replace(/\//g, "\\/")));
await expect(navItem(page, "Agent Network")).toBeVisible();
}
} finally {
await close();
}
});
test("explicit opt-out restores the regular menu and removes Agent Network", async ({
browser,
}) => {
const { page, close } = await openWithAccount(browser, {
agentNetworkOnly: false,
});
try {
// The sidebar rendered (Peers is always present for an owner)...
await expect(navItem(page, "Peers")).toBeVisible();
// ...and the Agent Network section is gone: the explicit opt-out wins
// and, without a deployment config flag, the section is not enabled.
await expect(navItem(page, "Agent Network")).toHaveCount(0);
} finally {
await close();
}
});
test("dashboard_features.agent_network makes the menu available without focusing", async ({
browser,
}) => {
const { page, close } = await openWithAccount(browser, {
dashboardFeaturesAgentNetwork: true,
});
try {
// The Agent Network menu is available (the label carries a "Beta" badge
// outside focused mode, so match on the label substring)...
await expect(navItemContaining(page, "Agent Network")).toBeVisible();
// ...but the dashboard is not focused: a regular section that focused
// mode hides (Reverse Proxy) is still present.
await expect(navItemContaining(page, "Reverse Proxy")).toBeVisible();
} finally {
await close();
}
});
test("applies the focused view optimistically for a pending netbird.ai signup and persists it", async ({
browser,
}) => {
const { page, captured, close } = await openWithAccount(
browser,
{ agentNetworkOnly: undefined, signupFormPending: true },
{ signupSource: true },
);
try {
// Focused view is applied immediately, before the setting is persisted.
await expect(navItem(page, "Agent Network")).toBeVisible();
await expect(navItem(page, "Networks")).toHaveCount(0);
// The signup source is persisted: focused view plus the Agent Network
// menu flag, so turning focus off later keeps access to Agent Network.
await expect
.poll(() => captured.putBody?.settings?.agent_network_only)
.toBe(true);
expect(
captured.putBody?.settings?.dashboard_features?.agent_network,
).toBe(true);
// The focused view is retained after the write settles.
await expect(navItem(page, "Networks")).toHaveCount(0);
} finally {
await close();
}
});
test("existing netbird.ai account gets dashboard_features, not the focused view", async ({
browser,
}) => {
// An existing account (signup form already submitted) with the netbird.ai
// source should have the Agent Network menu made available via
// dashboard_features, not the focused agent_network_only view.
const { page, captured, close } = await openWithAccount(
browser,
{ signupFormPending: false },
{ signupSource: true },
);
try {
await expect
.poll(
() => captured.putBody?.settings?.dashboard_features?.agent_network,
)
.toBe(true);
expect(captured.putBody?.settings?.agent_network_only).not.toBe(true);
} finally {
await close();
}
});
// Matrix of the two deployment flags (NETBIRD_AGENT_NETWORK_ONLY / _ENABLED,
// "off" = neither) crossed with the netbird.ai signup source, with no explicit
// account settings. The API defaults agent_network_only to false, so the
// deployment flags must still take effect. A present source implies a new
// signup (signup_form_pending), which focuses the dashboard optimistically
// regardless of the flags.
type Expectation = "hidden" | "available" | "focused";
const ENV_SIGNUP_MATRIX: {
env: AgentNetworkConfig;
signupSource: boolean;
expected: Expectation;
}[] = [
{ env: "off", signupSource: false, expected: "hidden" },
{ env: "off", signupSource: true, expected: "focused" },
{ env: "enabled", signupSource: false, expected: "available" },
{ env: "enabled", signupSource: true, expected: "focused" },
{ env: "only", signupSource: false, expected: "focused" },
{ env: "only", signupSource: true, expected: "focused" },
];
async function expectAgentNetworkState(page: Page, expected: Expectation) {
// Peers always renders for an owner, confirming the sidebar loaded.
await expect(navItem(page, "Peers")).toBeVisible();
if (expected === "hidden") {
await expect(navItemContaining(page, "Agent Network")).toHaveCount(0);
// The regular dashboard stays intact.
await expect(navItemContaining(page, "Reverse Proxy")).toBeVisible();
return;
}
await expect(navItemContaining(page, "Agent Network")).toBeVisible();
if (expected === "focused") {
for (const label of FOCUS_HIDDEN_NAV) {
await expect(navItem(page, label)).toHaveCount(0);
}
return;
}
// available: the menu shows alongside the regular dashboard.
await expect(navItemContaining(page, "Reverse Proxy")).toBeVisible();
}
for (const { env, signupSource, expected } of ENV_SIGNUP_MATRIX) {
const sourceLabel = signupSource ? "netbird.ai" : "none";
test(`env=${env} signup_source=${sourceLabel} -> ${expected}`, async ({
browser,
}) => {
const { page, close } = await openWithAccount(
browser,
// A source present means a fresh netbird.ai signup (form still pending);
// otherwise a plain account with no agent-network settings.
{ signupFormPending: signupSource },
{ agentNetworkConfig: env, signupSource },
);
try {
await expectAgentNetworkState(page, expected);
} finally {
await close();
}
});
}
test("exposes the focused-view toggle in client settings", async ({
browser,
}) => {
const { page, close } = await openWithAccount(browser, {
agentNetworkOnly: true,
});
try {
await navigateTo(page, "/settings?tab=clients");
await expect(page.getByTestId("agent-network-only")).toBeVisible();
} finally {
await close();
}
});
});
@@ -1,165 +0,0 @@
/**
* Agent Network Kimi (Moonshot AI) provider spec.
*
* Walks the Kimi provider lifecycle end to end against the real backend:
* pick kimi_api from the catalog (prefilled host, catalog models with
* pricing), connect it, then verify the Kimi-gated config surfaces in the
* "Configure Your Agent" modal (Kimi CLI tab, Kimi backend option in the
* Claude Code tab) that only render when a Kimi provider is connected.
*
* The kimi_api catalog entry ships with newer management builds. When the
* backend under test predates it, the whole suite skips instead of failing —
* same trade-off as the provider matrix in netbird's agent-network e2e
* harness, which skips per-provider on missing credentials.
*
* The Agent Network menu is deployment-gated; the test build honors the
* localStorage override (see testAgentNetworkOverride in utils/netbird.ts),
* set via addInitScript on a dedicated context below.
*/
import { test, expect, type Browser, type Page } from "@playwright/test";
import { loginToApp } from "../helpers/auth";
import { generateRandomName } from "../helpers/utils";
import {
deleteAgentNetworkProvidersByPrefix,
listAgentNetworkCatalog,
} from "../helpers/api";
const AGENT_NETWORK_CONFIG_KEY = "netbird-test-agent-network";
const KIMI_CATALOG_ID = "kimi_api";
const KIMI_CATALOG_NAME = "Kimi (Moonshot AI) API";
const PROVIDER_PREFIX = "e2e-kimi-";
async function newAgentNetworkPage(browser: Browser): Promise<{
page: Page;
close: () => Promise<void>;
}> {
const context = await browser.newContext({
storageState: "e2e/fixtures/auth/owner.json",
});
await context.addInitScript(
([key, value]) => {
try {
window.localStorage.setItem(key as string, value as string);
} catch (e) {}
},
[AGENT_NETWORK_CONFIG_KEY, "enabled"],
);
const page = await context.newPage();
await loginToApp(page, "owner");
return { page, close: () => context.close() };
}
test.describe.serial("Agent Network Kimi provider @agent-network", () => {
test("connect a Kimi provider and see Kimi agent configs", async ({
browser,
}) => {
const { page, close } = await newAgentNetworkPage(browser);
try {
// Backend support probe: skip the suite on management builds whose
// catalog predates kimi_api.
const catalog = await listAgentNetworkCatalog(page);
test.skip(
!catalog.some((c) => c.id === KIMI_CATALOG_ID),
`management catalog has no ${KIMI_CATALOG_ID} entry`,
);
await deleteAgentNetworkProvidersByPrefix(page, PROVIDER_PREFIX);
await page.goto("/agent-network/providers");
await page.keyboard.press("Escape");
// ---- connect the provider ----
await page
.getByRole("button", { name: "Connect Provider" })
.first()
.click({ force: true });
// The provider select defaults to OpenAI API; search the catalog for
// the Kimi entry.
await page
.getByRole("button", { name: /OpenAI API/ })
.first()
.click({ force: true });
await page.getByPlaceholder("Search providers...").fill("kimi");
await page.getByText(KIMI_CATALOG_NAME).first().click({ force: true });
// Catalog default host lands in the upstream URL input.
await expect(
page.locator('input[value="https://api.moonshot.ai"]'),
).toBeVisible();
await page
.getByPlaceholder("sk-...")
.first()
.fill("sk-e2e-kimi-test-key");
const providerName = generateRandomName(PROVIDER_PREFIX);
const nameInput = page.locator(`input[value="${KIMI_CATALOG_NAME}"]`);
await nameInput.fill(providerName);
// ---- models tab: catalog models carry pricing ----
await page.getByRole("tab", { name: "Models" }).click({ force: true });
await page.getByRole("button", { name: "Add More" }).click({ force: true });
await page.getByText("Kimi K3", { exact: false }).first().click({ force: true });
// Catalog price for kimi-k3 input tokens pre-fills the price cell.
await expect(page.locator('input[value="0.003"]')).toBeVisible();
const createResponse = page.waitForResponse(
(resp) =>
resp.url().includes("/agent-network/providers") &&
resp.request().method() === "POST",
{ timeout: 30_000 },
);
await page
.getByRole("button", { name: "Connect Provider" })
.last()
.click({ force: true });
expect([200, 201]).toContain((await createResponse).status());
// Row lands in the providers table.
await expect(page.getByText(providerName).first()).toBeVisible();
// ---- Kimi-gated agent config surfaces ----
await page
.getByRole("button", { name: "Agent Config" })
.click({ force: true });
// Kimi CLI tab only renders when a kimi_api provider is connected.
await expect(page.getByRole("tab", { name: "Kimi CLI" })).toBeVisible();
// Claude Code tab's backend dropdown offers (and, with Kimi as the only
// Anthropic-shaped provider, pre-selects) Kimi — its settings.json
// snippet pins every model slot to kimi-k3 and disables tool search,
// per Moonshot's Claude Code guide.
await expect(page.getByText('"ANTHROPIC_MODEL": "kimi-k3"')).toBeVisible();
await expect(
page.getByText('"CLAUDE_CODE_SUBAGENT_MODEL": "kimi-k3"'),
).toBeVisible();
await expect(
page.getByText('"ENABLE_TOOL_SEARCH": "false"'),
).toBeVisible();
// The base URL carries the /anthropic shape prefix that rides through
// the endpoint to Moonshot's Anthropic surface.
await expect(
page.getByText(/"ANTHROPIC_BASE_URL": "https:\/\/[^"]+\/anthropic"/),
).toBeVisible();
// Kimi CLI tab carries the ~/.kimi/config.toml provider block.
await page.getByRole("tab", { name: "Kimi CLI" }).click({ force: true });
await expect(page.getByText('default_model = "kimi-k3"')).toBeVisible();
// Codex has no Kimi variant — Kimi's upstream doesn't support Codex, so
// the tab keeps the plain Responses-API config with no backend dropdown.
await page.getByRole("tab", { name: "Codex" }).click({ force: true });
await expect(page.getByText('wire_api = "responses"')).toBeVisible();
await expect(page.getByText('wire_api = "chat"')).not.toBeVisible();
await page.keyboard.press("Escape");
// ---- cleanup ----
await deleteAgentNetworkProvidersByPrefix(page, PROVIDER_PREFIX);
} finally {
await close();
}
});
});
@@ -0,0 +1,353 @@
import { expect } from "@playwright/test";
import { test } from "../helpers/fixtures";
import { createGroup, deleteGroup, listGroups } from "../helpers/api";
import { generateRandomName } from "../helpers/utils";
import {
canvasNode,
clickContextMenuItem,
connectNodes,
dragNodeOnto,
dragTemplateToCanvas,
enterDraft,
expectChangeCount,
readDraftChanges,
resetDraftState,
reviewButton,
submitCreatePolicyModal,
} from "../helpers/control-center";
test.describe.serial(
"Control Center Draft Connections @control-center",
() => {
test.beforeEach(async ({ dashboardAsOwner: page }) => {
await resetDraftState(page);
});
test("Should create a policy by connecting two groups", async ({
dashboardAsOwner: page,
}) => {
await enterDraft(page);
// Two groups, placed apart so the connect drag has a clear path.
const pane = await page.locator(".react-flow__pane").boundingBox();
if (!pane) throw new Error("no pane");
await dragTemplateToCanvas(page, "cc-template-group", {
x: pane.x + pane.width * 0.45,
y: pane.y + pane.height * 0.35,
});
await dragTemplateToCanvas(page, "cc-template-group", {
x: pane.x + pane.width * 0.75,
y: pane.y + pane.height * 0.65,
});
const groups = canvasNode(page, "group-new-");
await expect(groups).toHaveCount(2);
await expectChangeCount(page, 2);
// Drag from the first group's right handle onto the second group —
// the create-policy modal opens prefilled with both sides.
await connectNodes(page, groups.nth(0), groups.nth(1));
await expect(page.getByTestId("policy-name")).toBeVisible();
await submitCreatePolicyModal(page);
// A complete policy lands on canvas with two edges and a tracked
// create-policy change (2 groups + 1 policy = 3).
await expect(canvasNode(page, "policy-new-")).toHaveCount(1);
await expect(page.locator(".react-flow__edge")).toHaveCount(2);
await expectChangeCount(page, 3);
const changes = await readDraftChanges(page);
expect(changes.filter((c) => c.type === "create-policy")).toHaveLength(1);
});
test("Should keep a placeholder-peer policy out of the changeset until the peer installs", async ({
dashboardAsOwner: page,
}) => {
await enterDraft(page);
const pane = await page.locator(".react-flow__pane").boundingBox();
if (!pane) throw new Error("no pane");
await dragTemplateToCanvas(page, "cc-template-group", {
x: pane.x + pane.width * 0.45,
y: pane.y + pane.height * 0.35,
});
await dragTemplateToCanvas(page, "cc-template-peer-user-device", {
x: pane.x + pane.width * 0.75,
y: pane.y + pane.height * 0.65,
});
const group = canvasNode(page, "group-new-").first();
const peer = page.locator('.react-flow__node[data-id^="peer-"]');
await expect(peer).toHaveCount(1);
await connectNodes(page, peer, group);
await expect(page.getByTestId("policy-name")).toBeVisible();
await submitCreatePolicyModal(page);
// The policy node and edges exist on canvas…
await expect(canvasNode(page, "policy-new-")).toHaveCount(1);
// …but a policy referencing an uninstalled placeholder peer is
// incomplete: only the group create and the pending peer install are
// tracked.
const changes = await readDraftChanges(page);
expect(changes.filter((c) => c.type === "create-policy")).toHaveLength(0);
expect(changes.filter((c) => c.type === "install-peer")).toHaveLength(1);
await expectChangeCount(page, 2);
});
test("Should rename a group and propagate the name into the policy changeset", async ({
dashboardAsOwner: page,
}) => {
await enterDraft(page);
const pane = await page.locator(".react-flow__pane").boundingBox();
if (!pane) throw new Error("no pane");
await dragTemplateToCanvas(page, "cc-template-group", {
x: pane.x + pane.width * 0.45,
y: pane.y + pane.height * 0.35,
});
await dragTemplateToCanvas(page, "cc-template-group", {
x: pane.x + pane.width * 0.75,
y: pane.y + pane.height * 0.65,
});
const groups = canvasNode(page, "group-new-");
await connectNodes(page, groups.nth(0), groups.nth(1));
await submitCreatePolicyModal(page);
await expectChangeCount(page, 3);
// Rename the source group via its context menu.
const sourceGroup = groups.nth(0);
const newName = generateRandomName("cc-renamed-");
await clickContextMenuItem(page, sourceGroup, "Rename");
await page.getByTestId("cc-rename-input").fill(newName);
await page.getByTestId("cc-rename-submit").click();
// The canvas node shows the new name…
await expect(sourceGroup).toContainText(newName);
// …the rename folded into the create-group entry (still 3 changes)…
await expectChangeCount(page, 3);
const changes = await readDraftChanges(page);
expect(
changes.some((c) => c.type === "create-group" && c.name === newName),
).toBe(true);
// …and the policy's group reference followed the rename.
const policy = changes.find((c) => c.type === "create-policy");
const rule = policy.policy.rules[0];
const referenced = [...(rule.sources ?? []), ...(rule.destinations ?? [])]
.map((g) => (typeof g === "string" ? g : g.name));
expect(referenced).toContain(newName);
// Reopening the policy editor must show the renamed group SELECTED in
// the group selector (the modal seeds from the canvas policy node).
const policyNode = canvasNode(page, "policy-new-");
await clickContextMenuItem(page, policyNode, "Edit");
await expect(page.getByTestId("source-group-selector")).toContainText(
newName,
);
await page.keyboard.press("Escape");
});
test("Should create a group inline in the policy modal and show it on canvas", async ({
dashboardAsOwner: page,
}) => {
await enterDraft(page);
const pane = await page.locator(".react-flow__pane").boundingBox();
if (!pane) throw new Error("no pane");
await dragTemplateToCanvas(page, "cc-template-group", {
x: pane.x + pane.width * 0.45,
y: pane.y + pane.height * 0.35,
});
await dragTemplateToCanvas(page, "cc-template-group", {
x: pane.x + pane.width * 0.75,
y: pane.y + pane.height * 0.65,
});
const groups = canvasNode(page, "group-new-");
await connectNodes(page, groups.nth(0), groups.nth(1));
await expect(page.getByTestId("policy-name")).toBeVisible();
// Type a brand-new group name into the destination selector and add it.
const inlineName = generateRandomName("cc-inline-");
await page.getByTestId("destination-group-selector").click();
await page
.getByTestId("destination-group-selector-search")
.fill(inlineName);
await page.keyboard.press("Enter");
await page.keyboard.press("Escape"); // close the selector dropdown
await submitCreatePolicyModal(page);
// The inline group exists on canvas as its own draft node and as a
// create-group change.
await expect(
page.locator(".react-flow__node", { hasText: inlineName }),
).toHaveCount(1);
const changes = await readDraftChanges(page);
expect(
changes.some(
(c) => c.type === "create-group" && c.name === inlineName,
),
).toBe(true);
});
test("Should edit a draft policy and reflect the change on the canvas", async ({
dashboardAsOwner: page,
}) => {
await enterDraft(page);
const pane = await page.locator(".react-flow__pane").boundingBox();
if (!pane) throw new Error("no pane");
await dragTemplateToCanvas(page, "cc-template-group", {
x: pane.x + pane.width * 0.45,
y: pane.y + pane.height * 0.35,
});
await dragTemplateToCanvas(page, "cc-template-group", {
x: pane.x + pane.width * 0.75,
y: pane.y + pane.height * 0.65,
});
const groups = canvasNode(page, "group-new-");
await connectNodes(page, groups.nth(0), groups.nth(1));
await submitCreatePolicyModal(page);
const policyNode = canvasNode(page, "policy-new-");
await expect(policyNode).toHaveCount(1);
// Edit via context menu, rename the policy on the General tab.
const newName = generateRandomName("cc-policy-");
await clickContextMenuItem(page, policyNode, "Edit");
await expect(page.getByTestId("policy-name")).toBeVisible();
await page.getByTestId("policy-name").fill(newName);
await page.getByTestId("submit-policy").click();
// Canvas node text and the pending create-policy entry both updated.
await expect(policyNode).toContainText(newName);
const changes = await readDraftChanges(page);
const policy = changes.find((c) => c.type === "create-policy");
expect(policy.policy.name).toBe(newName);
});
test("Should remove a draft policy from the canvas without a confirmation", async ({
dashboardAsOwner: page,
}) => {
await enterDraft(page);
const pane = await page.locator(".react-flow__pane").boundingBox();
if (!pane) throw new Error("no pane");
await dragTemplateToCanvas(page, "cc-template-group", {
x: pane.x + pane.width * 0.45,
y: pane.y + pane.height * 0.35,
});
await dragTemplateToCanvas(page, "cc-template-group", {
x: pane.x + pane.width * 0.75,
y: pane.y + pane.height * 0.65,
});
const groups = canvasNode(page, "group-new-");
await connectNodes(page, groups.nth(0), groups.nth(1));
await submitCreatePolicyModal(page);
await expectChangeCount(page, 3);
// Remove is canvas-only: no confirm dialog, pending create cancelled,
// endpoint groups stay on canvas.
const policyNode = canvasNode(page, "policy-new-");
await clickContextMenuItem(page, policyNode, "Remove");
await expect(page.getByTestId("confirmation.confirm")).not.toBeVisible();
await expect(policyNode).toHaveCount(0);
await expect(page.locator(".react-flow__edge")).toHaveCount(0);
await expect(groups).toHaveCount(2);
await expectChangeCount(page, 2);
});
test("Should drop a peer into a group and track the membership", async ({
dashboardAsOwner: page,
}) => {
await enterDraft(page);
const pane = await page.locator(".react-flow__pane").boundingBox();
if (!pane) throw new Error("no pane");
await dragTemplateToCanvas(page, "cc-template-group", {
x: pane.x + pane.width * 0.45,
y: pane.y + pane.height * 0.35,
});
await dragTemplateToCanvas(page, "cc-template-peer-user-device", {
x: pane.x + pane.width * 0.75,
y: pane.y + pane.height * 0.65,
});
const group = canvasNode(page, "group-new-").first();
const peer = page.locator('.react-flow__node[data-id^="peer-"]');
await expect(peer).toHaveCount(1);
// Dragging the peer NODE onto the group absorbs it as a member: the
// peer node leaves the canvas and the group's counter increments.
await dragNodeOnto(page, peer, group);
await expect(peer).toHaveCount(0);
await expect(group).toContainText(/1\s*peer/i);
// The membership folded into the group's create entry.
const changes = await readDraftChanges(page);
const createGroupChange = changes.find(
(c) => c.type === "create-group",
);
expect(createGroupChange.peerIds).toHaveLength(1);
// group create + the peer's pending install step
await expectChangeCount(page, 2);
// The group's Details panel lists the dropped (placeholder) peer even
// though it isn't in the API peer list.
await clickContextMenuItem(page, group, "Details");
await expect(
page.getByText("User Device", { exact: false }).last(),
).toBeVisible();
await page.keyboard.press("Escape");
});
test("Should delete an existing group in draft and deploy the deletion", async ({
dashboardAsOwner: page,
}) => {
// Seed a real group via the API, then reload so the dashboard and the
// components panel know it.
const name = generateRandomName("cc-delete-");
const seeded = await createGroup(page, name);
await page.reload();
await expect(page.locator(".react-flow__pane")).toBeVisible();
await enterDraft(page);
// Pull the existing group onto the canvas from the components panel
// (search narrows the virtualized list so the row is rendered).
await page.getByTestId("cc-toolbar-add").click();
await page.getByPlaceholder(/Search components/).fill(name);
await dragTemplateToCanvas(page, `cc-panel-group-${seeded.id}`);
const groupNode = page.locator(
`.react-flow__node[data-id="group-${seeded.id}"]`,
);
await expect(groupNode).toHaveCount(1);
// Delete (not Remove) an existing group: confirmed, tracked, deployed.
await clickContextMenuItem(page, groupNode, "Delete");
await expect(
page.getByText(`Delete group "${name}"?`),
).toBeVisible();
await page.getByTestId("confirmation.confirm").click();
await expect(groupNode).toHaveCount(0);
await expectChangeCount(page, 1);
await reviewButton(page).click();
const deleteResponse = page.waitForResponse(
(resp) =>
resp.url().includes(`/api/groups/${seeded.id}`) &&
resp.request().method() === "DELETE",
{ timeout: 30_000 },
);
await page.getByTestId("cc-deploy").click({ force: true });
const response = await deleteResponse;
expect(response.status()).toBeLessThan(300);
const remaining = await listGroups(page);
expect(remaining.some((g) => g.id === seeded.id)).toBe(false);
});
test.afterAll(async ({ browser }) => {
// Safety net: clear any seeded groups left behind by failed runs.
const context = await browser.newContext({
storageState: "e2e/fixtures/auth/owner.json",
});
const page = await context.newPage();
await page.goto("/peers");
const groups = await listGroups(page).catch(() => []);
for (const g of groups) {
if (g.name.startsWith("cc-delete-") || g.name.startsWith("cc-inline-")) {
await deleteGroup(page, g.id).catch(() => {});
}
}
await context.close();
});
},
);
File diff suppressed because it is too large. Load diff
+156
View File
@@ -0,0 +1,156 @@
import { Page, expect } from "@playwright/test";
import { test } from "../helpers/fixtures";
import { deleteGroup, listGroups } from "../helpers/api";
import {
CHANGES_KEY,
CANVAS_KEY,
canvasNode,
dragTemplateToCanvas,
enterDraft,
expectChangeCount,
resetDraftState,
reviewButton,
} from "../helpers/control-center";
test.describe.serial("Control Center Draft Mode @control-center", () => {
test.beforeEach(async ({ dashboardAsOwner: page }) => {
// Every test starts from a clean live view: no leftover draft storage
// from a previous (possibly failed) test.
await resetDraftState(page);
});
test("Should enter and leave draft mode without changes", async ({
dashboardAsOwner: page,
}) => {
await enterDraft(page);
await expect(page.getByTestId("cc-draft-cancel")).toBeVisible();
await expectChangeCount(page, 0);
// No pending changes — switching back to live must not ask to confirm.
await page.getByTestId("cc-mode-live").click();
await expect(page.getByTestId("confirmation.confirm")).not.toBeVisible();
await expect(page.getByTestId("cc-toolbar-add")).not.toBeVisible();
});
test("Should add a draft group via drag and drop and track the change", async ({
dashboardAsOwner: page,
}) => {
await enterDraft(page);
await dragTemplateToCanvas(page, "cc-template-group");
await expect(canvasNode(page, "group-new-")).toHaveCount(1);
await expectChangeCount(page, 1);
// The changeset is persisted to localStorage on every update.
await expect
.poll(() =>
page.evaluate((key) => localStorage.getItem(key), CHANGES_KEY),
)
.toContain("create-group");
});
test("Should undo and redo the dropped group", async ({
dashboardAsOwner: page,
}) => {
await enterDraft(page);
await dragTemplateToCanvas(page, "cc-template-group");
const groupNode = canvasNode(page, "group-new-");
await expect(groupNode).toHaveCount(1);
// History snapshots are debounced (300ms) — wait for Undo to arm
// instead of clicking immediately.
const undo = page.getByTestId("cc-toolbar-undo");
await expect(undo).toBeEnabled();
await undo.click();
await expect(groupNode).toHaveCount(0);
await expectChangeCount(page, 0);
const redo = page.getByTestId("cc-toolbar-redo");
await expect(redo).toBeEnabled();
await redo.click();
await expect(groupNode).toHaveCount(1);
await expectChangeCount(page, 1);
});
test("Should restore the draft from storage after a reload", async ({
dashboardAsOwner: page,
}) => {
await enterDraft(page);
await dragTemplateToCanvas(page, "cc-template-group");
await expectChangeCount(page, 1);
// Wait for the canvas snapshot to land in storage before reloading.
await expect
.poll(() => page.evaluate((key) => localStorage.getItem(key), CANVAS_KEY))
.toContain("group-new-");
await page.reload();
await expect(page.locator(".react-flow__pane")).toBeVisible();
// A reload returns to live; re-entering draft restores the snapshot
// instead of rebuilding from the live view.
await enterDraft(page);
await expect(canvasNode(page, "group-new-")).toHaveCount(1);
await expectChangeCount(page, 1);
});
test("Should confirm before discarding draft changes", async ({
dashboardAsOwner: page,
}) => {
await enterDraft(page);
await dragTemplateToCanvas(page, "cc-template-group");
await expectChangeCount(page, 1);
// Cancelling the confirmation keeps the draft.
await page.getByTestId("cc-draft-cancel").click();
await expect(page.getByText("Discard draft changes?")).toBeVisible();
await page.getByTestId("confirmation.cancel").click();
await expect(page.getByTestId("cc-toolbar-add")).toBeVisible();
await expectChangeCount(page, 1);
// Confirming discards and returns to live with storage cleared.
await page.getByTestId("cc-draft-cancel").click();
await page.getByTestId("confirmation.confirm").click();
await expect(page.getByTestId("cc-toolbar-add")).not.toBeVisible();
await expect
.poll(() =>
page.evaluate((key) => localStorage.getItem(key), CHANGES_KEY),
)
.toBeNull();
});
test("Should deploy a created group to the live account", async ({
dashboardAsOwner: page,
}) => {
await enterDraft(page);
await dragTemplateToCanvas(page, "cc-template-group");
await expectChangeCount(page, 1);
await reviewButton(page).click();
await expect(page.getByText("Review & Deploy")).toBeVisible();
// The single pending change is a group create.
await expect(page.getByText("Create", { exact: true })).toBeVisible();
const createResponse = page.waitForResponse(
(resp) =>
resp.url().includes("/api/groups") &&
resp.request().method() === "POST",
{ timeout: 30_000 },
);
await page.getByTestId("cc-deploy").click({ force: true });
const response = await createResponse;
expect([200, 201]).toContain(response.status());
const created = await response.json();
// Deploy exits the draft back to the rebuilt live view.
await expect(page.getByTestId("cc-toolbar-add")).not.toBeVisible();
await expect
.poll(() =>
page.evaluate((key) => localStorage.getItem(key), CHANGES_KEY),
)
.toBeNull();
// The group really exists in the account — then clean it up.
const groups = await listGroups(page);
expect(groups.some((g) => g.id === created.id)).toBe(true);
await deleteGroup(page, created.id);
});
});
-109
View File
@@ -1,109 +0,0 @@
/**
* Validates that the dashboard surfaces the management API's request id in the
* error toast. Newer management servers set an `X-Request-Id` response header
* (see telemetry.RequestIDHeader). When present, the dashboard shows it in the
* error notification so it can be quoted in support tickets. Older servers omit
* the header, and the toast must render without a request id.
*
* Each scenario runs in its own browser context so the mocked `/networks`
* route and its resulting toast never bleed into another test through the
* worker-scoped page.
*/
import { test, expect, type Browser, type Page } from "@playwright/test";
import { loginToApp, navigateTo } from "../helpers/auth";
import * as path from "path";
const SCREENSHOTS_DIR = path.resolve(__dirname, "..", "screenshots");
const REQUEST_ID = "9m4e2mr0ui3e8a215n4g";
const NETWORKS_ENDPOINT = /\/api\/networks(\?|$)/;
const REQUEST_ID_TESTID = "notification-request-id";
const TITLE_TESTID = "notification-title";
// failNetworks mocks the networks list GET with a 500, optionally attaching the
// X-Request-Id header to emulate a newer management server. The dashboard talks
// to the management API cross-origin, so the mock must mirror a CORS-enabled
// server: the browser only lets JS read a non-safelisted response header when it
// is listed in Access-Control-Expose-Headers. This is the same requirement the
// real management server must satisfy for the feature to work in production.
async function failNetworks(page: Page, opts: { withRequestId: boolean }) {
await page.route(NETWORKS_ENDPOINT, async (route) => {
if (route.request().method() !== "GET") return route.continue();
const origin = route.request().headers()["origin"] || "*";
const headers: Record<string, string> = {
"content-type": "application/json",
"access-control-allow-origin": origin,
"access-control-expose-headers": "X-Request-Id",
};
if (opts.withRequestId) headers["X-Request-Id"] = REQUEST_ID;
await route.fulfill({
status: 500,
headers,
body: JSON.stringify({ code: 500, message: "internal server error" }),
});
});
}
async function openOwner(
browser: Browser,
): Promise<{ page: Page; close: () => Promise<void> }> {
const context = await browser.newContext({
storageState: "e2e/fixtures/auth/owner.json",
});
const page = await context.newPage();
return { page, close: () => context.close() };
}
test.describe.serial("API error request id @error-handling", () => {
test("shows the X-Request-Id from a failed request in the error toast", async ({
browser,
}) => {
const { page, close } = await openOwner(browser);
try {
await failNetworks(page, { withRequestId: true });
await loginToApp(page, "owner");
await navigateTo(page, "/networks");
const requestIdLine = page.getByTestId(REQUEST_ID_TESTID);
await expect(requestIdLine).toBeVisible();
await expect(requestIdLine).toContainText(REQUEST_ID);
await expect(page.getByTestId(TITLE_TESTID).first()).toContainText(
"Request failed with status code 500",
);
const toast = page.locator("[data-toast-notification]").first();
await toast.screenshot({
path: path.join(SCREENSHOTS_DIR, "error-toast-with-request-id.png"),
});
await page.screenshot({
path: path.join(SCREENSHOTS_DIR, "networks-page-error-request-id.png"),
});
} finally {
await close();
}
});
test("omits the request id when the server does not send the header", async ({
browser,
}) => {
const { page, close } = await openOwner(browser);
try {
await failNetworks(page, { withRequestId: false });
await loginToApp(page, "owner");
await navigateTo(page, "/networks");
// The generic error toast still appears for older servers...
await expect(page.getByTestId(TITLE_TESTID).first()).toContainText(
"Request failed with status code 500",
);
// ...but no request id line is rendered.
await expect(page.getByTestId(REQUEST_ID_TESTID)).toHaveCount(0);
const toast = page.locator("[data-toast-notification]").first();
await toast.screenshot({
path: path.join(SCREENSHOTS_DIR, "error-toast-without-request-id.png"),
});
} finally {
await close();
}
});
});
-169
View File
@@ -1,169 +0,0 @@
/**
* Onboarding form selection spec.
*
* Validates which onboarding form is shown first and which one persists,
* across signup-source and account states — the recurring "regular form
* flashes before the Agent Network form" bug. Onboarding is disabled in the
* test build by default; each test opts in via the `netbird-test-onboarding`
* localStorage flag (see testOnboardingEnabled in src/utils/netbird.ts), and
* GET /accounts is rewritten to drive the account's onboarding + settings
* state (same interception approach as edition-gating.spec.ts).
*/
import { test, expect, type Browser, type Page } from "@playwright/test";
import { loginToApp } from "../helpers/auth";
const SIGNUP_SOURCE_KEY = "netbird-signup-source";
const AGENT_NETWORK_SOURCE = "netbird.ai";
const AGENT_FORM = "agent-network-onboarding";
const REGULAR_FORM = "regular-onboarding";
type AccountState = {
signupFormPending?: boolean;
onboardingFlowPending?: boolean;
agentNetworkOnly?: boolean;
};
// mockAccounts rewrites GET /accounts to drive the onboarding + focused-view
// state, optionally delaying the response to emulate a slow backend. A PUT to
// /accounts/{id} flips agent_network_only to true afterwards, mirroring the
// server persisting the netbird.ai signup mark.
function mockAccounts(page: Page, state: AccountState, delayMs: number) {
let agentNetworkOnly = state.agentNetworkOnly;
page.route("**/api/accounts", async (route) => {
if (route.request().method() !== "GET") return route.continue();
if (delayMs > 0) {
await new Promise((resolve) => setTimeout(resolve, delayMs));
}
const response = await route.fetch();
let body: any;
try {
body = await response.json();
} catch (e) {
return route.fulfill({ response });
}
if (Array.isArray(body) && body[0]) {
body[0].onboarding = {
...(body[0].onboarding ?? {}),
signup_form_pending: !!state.signupFormPending,
onboarding_flow_pending: !!state.onboardingFlowPending,
};
body[0].settings = { ...(body[0].settings ?? {}) };
if (agentNetworkOnly === undefined) {
delete body[0].settings.agent_network_only;
} else {
body[0].settings.agent_network_only = agentNetworkOnly;
}
}
return route.fulfill({ response, json: body });
});
page.route("**/api/accounts/*", async (route) => {
if (route.request().method() !== "PUT") return route.continue();
agentNetworkOnly = true;
return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ settings: { agent_network_only: true } }),
});
});
}
async function openOnboarding(
browser: Browser,
opts: { source?: boolean; account: AccountState; delayMs?: number },
): Promise<{ page: Page; close: () => Promise<void> }> {
const context = await browser.newContext({
storageState: "e2e/fixtures/auth/owner.json",
});
await context.addInitScript(
([sourceKey, sourceValue, withSource]) => {
try {
window.localStorage.setItem("netbird-test-onboarding", "true");
if (withSource) {
window.localStorage.setItem(
sourceKey as string,
sourceValue as string,
);
}
} catch (e) {}
},
[SIGNUP_SOURCE_KEY, AGENT_NETWORK_SOURCE, !!opts.source] as const,
);
const page = await context.newPage();
mockAccounts(page, opts.account, opts.delayMs ?? 0);
await loginToApp(page, "owner", { expectOnboarding: true });
return { page, close: () => context.close() };
}
test.describe.serial("Onboarding form selection @onboarding", () => {
test("netbird.ai signup shows the Agent Network form and never the regular one", async ({
browser,
}) => {
const { page, close } = await openOnboarding(browser, {
source: true,
account: { signupFormPending: true, onboardingFlowPending: true },
});
try {
await expect(page.getByTestId(AGENT_FORM)).toBeVisible();
await expect(page.getByTestId(REGULAR_FORM)).toHaveCount(0);
} finally {
await close();
}
});
test("a plain new cloud account shows the regular onboarding form", async ({
browser,
}) => {
const { page, close } = await openOnboarding(browser, {
source: false,
account: { signupFormPending: true, onboardingFlowPending: true },
});
try {
await expect(page.getByTestId(REGULAR_FORM)).toBeVisible();
await expect(page.getByTestId(AGENT_FORM)).toHaveCount(0);
} finally {
await close();
}
});
test("an account already marked agent_network_only shows the Agent Network form", async ({
browser,
}) => {
const { page, close } = await openOnboarding(browser, {
source: false,
account: { onboardingFlowPending: true, agentNetworkOnly: true },
});
try {
await expect(page.getByTestId(AGENT_FORM)).toBeVisible();
await expect(page.getByTestId(REGULAR_FORM)).toHaveCount(0);
} finally {
await close();
}
});
test("a slow backend never flashes the regular form for a netbird.ai signup", async ({
browser,
}) => {
const { page, close } = await openOnboarding(browser, {
source: true,
account: { signupFormPending: true, onboardingFlowPending: true },
delayMs: 1500,
});
try {
// While /accounts is still loading, no onboarding form should have been
// committed to the wrong flow.
await expect(page.getByTestId(REGULAR_FORM)).toHaveCount(0);
// Once it resolves, the Agent Network form is the one that appears...
await expect(page.getByTestId(AGENT_FORM)).toBeVisible();
await expect(page.getByTestId(REGULAR_FORM)).toHaveCount(0);
// ...and it persists after the setting is applied (no flip/close).
await page.waitForTimeout(1500);
await expect(page.getByTestId(AGENT_FORM)).toBeVisible();
await expect(page.getByTestId(REGULAR_FORM)).toHaveCount(0);
} finally {
await close();
}
});
});
Loaded 20 of 242 files, more files were not shown because too many files have changed in this diff. Show more