Compare commits

..
17 Commits
Author SHA1 Message Date
Eduard Gert d4451274c7 Replace the pending-approval error page with a dedicated screen (#802)
* Replace the pending-approval error page with a dedicated screen

A user waiting for an administrator to approve their account was shown
the generic /error page: an error icon, a raw response_message dump and
"if you believe this is an error, please contact your administrator".
Waiting for approval is an expected part of signing up, so it now gets a
screen of its own, following the onboarding layout of logo, centered card
and a stepper placing the user between "Account Created" and
"Join Account".

The owner to contact is read out of the refusal management sends, which
names a masked address, and the copy asks for the owner without naming
one when the address is absent.

Steps.Step gains an optional status and size, so a step can render as
complete, current or upcoming at a larger scale. Steps that pass neither
keep the look used by the instruction-list steppers.

* Detect the pending-approval screen by test id in e2e

The three places that recognise a pending user matched on "User Approval
Pending", the heading the old error page rendered. The screen that
replaced it has no heading, so the approval branch of the login races
could never resolve and every leg timed out instead: both fixtures treat
approval as a legitimate outcome, not a failure.

They now key off the data-testid on the screen's root, which does not
move when the copy does.

* Point the pending-approval footer at the docs and support pages

A mailbox asks the reader to compose something; the approval docs explain
what they are waiting for, and the support page routes them the way every
other NetBird surface does. The support address constant went with it.

* Render the pending-approval screen from the caller-identity response

Every call a pending user makes is refused, but only /users/current
names the owner who can approve them. Redirecting to /error on whichever
403 landed first therefore threw that name away about as often as it
kept it, and which screen appeared came down to a race.

Pending users are no longer routed to /error at all. The provider that
already waits on /users/current renders the screen from that response,
which also ends the wait it could never finish: the caller identity it
blocks on is exactly what such a user is refused.

* Decide the refused-user screen from the responses, not the first 403

Routing on whichever refused call landed first meant the screen depended
on a race, and once only /users/current could name the owner it meant
the name was usually thrown away. Management older than that reports a
pending user as merely blocked there, so it also meant a pending user
landed on the blocked screen, which is what the e2e run caught.

UserProfileProvider now decides from the responses it already waits on:
/users/current first, since it alone names the owner, then the user list,
which reports pending on every management version. Blocked keeps the
error page, decided after pending so the older wording cannot win.

* Wait for both refusal responses before choosing a screen

Concluding blocked the moment /users/current said so meant concluding it
while the user list was still in flight — and on management that reports
a pending user as merely blocked there, that list is the one response
that would have said otherwise. The screen it was meant to prevent was
reachable again, just through a narrower window.

The decision moves into resolveRefusedUser, which holds off while a
better-informed response is still coming: the named one before the list,
the list before blocked. Navigating to the error page becomes an effect
rather than a call made while rendering.

* Move the refused-user decision next to the provider that makes it

It sat in utils only because two callers needed it, but the second one —
the guard keeping these errors off the error boundary — needs nothing
more than to recognise them, which is a line of its own. So the decision
moves beside the provider that acts on it, and reads an error into a
RefusalReason rather than assembling a discriminated union.

The cost is that both files now spell out management's wording. The one
here is the tested reading; the one in @utils/api only has to be no
narrower than it.

* Assert the refusal kind the resolver actually returns

The result property is kind, not reason. toEqual types its argument
loosely, so the stale expectations type-checked and only failed when run.
2026-09-21 10:33:43 +02:00
Brad Ison b2e658a2be Add a deployment announcement banner set through NETBIRD_ANNOUNCEMENT (#806)
An operator can now give a deployment a permanent banner: the text in
NETBIRD_ANNOUNCEMENT is shown to every user in the prominent
announcement style, cannot be closed, and appears in every edition.  It
sits ahead of the remote and MSP announcements, behind only the billing
warnings when those open, so it is always the first thing a user sees.
Empty or unset means no banner, which is the default.
2026-09-18 10:54:22 +02:00
Jack Carter 4b2c394181 fix: keep tables from scrolling horizontally when the Active toggle shows (#805)
* fix: keep tables from scrolling horizontally when the Active toggle shows

The Active toggle column restored in #803 adds about 100px to every table,
which pushed policies and legacy routes into a horizontal scrollbar next to
an expanded sidebar on 1280px and 1440px screens.

Hide the column below the xl breakpoint (2xl for legacy routes, the widest
table) instead of dropping it. Enable/Disable remains available in each
row's action menu on narrower viewports.

Also on the legacy routes page: tighten cell padding in the expanded route
rows, remove a 10px max-width on the network address that made it overflow
into the Type column, let the Name cell shrink from 270px to 200px when
space is tight, and drop the unused sort control from the Active header.

* refactor: hide the Active column with responsive classes instead of a media query hook

Columns can now carry a className through TanStack's column meta, which
DataTable applies to both the header and body cells. The Active column
uses `hidden xl:table-cell` (`2xl` for legacy routes) so the browser
handles the breakpoint and no JavaScript listener is needed.

* refactor: apply the Active column treatment consistently and cover it with a test

- Peer routes, group resources and reverse proxy targets carry the same
  Active toggle column and now hide it below xl like the other tables.
- DataTable gains tableHeadClassName so RouteTable can tighten header and
  body padding through props instead of descendant selectors.
- The four tables reference ENABLED_COLUMN_ID instead of a literal id so
  the fade-on-disable helper and the column definitions stay linked.
- The network address cell truncates past 16rem with a title tooltip so a
  long IPv6 CIDR cannot push the routes table back into a scrollbar.
- Add a DataTable render test for column meta classes on header and body.
2026-09-17 11:08:58 +02:00
Jack Carter 74eae99820 Fix group and resource order in resource group audit events (#804) 2026-09-17 09:54:40 +02:00
Maycon SantosandClaude Fable 5.1 da81cb7f50 Pick a private-capable proxy cluster when bootstrapping the agent network (#780)
* Pick a private-capable proxy cluster when bootstrapping the agent network

The connect-provider wizard bootstraps the account's agent network
endpoint from a proxy cluster it auto-picks off /reverse-proxies/domains,
taking the first validated free domain. That list includes clusters that
cannot host the endpoint: the endpoint is a private service, reachable
only from connected peers and authorised by their tunnel identity, which
needs a cluster with at least one connected embedded proxy — the same
supports_private flag the Reverse Proxy modal gates NetBird-Only Access
on. Management refuses a bootstrap onto a cluster reporting it false, so
the wizard could propose a pick the API rejects, and the endpoint it
assigns is immutable.

Filter the candidates on supports_private before auto-picking, and give
the "nothing to pick" callout a second message for the case where
clusters exist but none of them has an embedded proxy — a different
problem from having no proxy at all, and a different fix.

Only an explicit false disqualifies a cluster: a management build that
predates the flag reports nothing at all, and dropping every cluster
there would block setup on a backend that would have accepted it.

Callout takes an optional data-testid so a spec can assert on the state
it announces rather than on its copy.

* Derive the bootstrap cluster instead of holding it in state

Nothing picks the bootstrap cluster: there is no selector, so the state
could only ever mirror the first usable entry of the domains list, with
an effect writing it back on arrival and a render pass to show it. Derive
it from that list instead, which drops the state, the effect and the two
resets that kept them in sync.

* Declare the e2e proxy private so a bootstrap cluster exists

The connect-provider wizard now offers only clusters whose proxies report
the private capability, because the agent network endpoint is a private
service and management refuses to bootstrap it anywhere else. The e2e
environment's reverse-proxy ran with NB_PROXY_PRIVATE at its default of
false, so with that filter in place no cluster qualified: the wizard
showed the no-cluster callout, Continue stayed disabled, and every spec
that walks through the wizard failed on the Models tab it could no longer
reach — agentgateway, Kimi and the refused-save spec.

Declare the capability on the primary proxy. The no-ports proxy stays
centralised, so the pick also has to skip past a cluster that does not
qualify, which is the case the wizard filter exists for.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Sa3DsBDP3VciAi4PPG17L6

* Send the no-cluster callout to the agent network Clusters section

The callout told users to connect a proxy under Reverse Proxy, the generic
services page. Agent Network has its own Clusters tab under Configuration,
which is where a user setting up the agent network expects to manage the
clusters it runs on, so both messages link there instead. The tab and the
callout gate on the same permission (services read), so every role that
sees the callout can follow the link.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Sa3DsBDP3VciAi4PPG17L6

* Say private capabilities, not embedded proxy, in the no-cluster callout

The requirement is a cluster with private capabilities, which is what the
supports_private flag reports. Tying the message to an embedded proxy named
one way of getting there as if it were the requirement itself, and pointed
users at the wrong fix. The callout now states the requirement and where to
connect such a cluster.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Sa3DsBDP3VciAi4PPG17L6

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-14 22:19:37 +02:00
Jack Carter f4e80d37c4 feat: restore the Active toggle and keep it visible on disabled rows (#803)
Disabled routing peers, network resources, policies and legacy routes were
only distinguishable by a faded row, and the fade also dimmed the controls
needed to re-enable them. Bring back the Active toggle column that was
hidden in #654, and add a cellClassName hook to DataTable with a shared
fadeDisabledRowCells helper that fades every cell of a disabled row except
the toggle and actions cells.
2026-09-14 15:43:50 +02:00
Pascal Fischer de045582e5 mark is cloud false unless explicitly overridden (#799) 2026-09-12 09:07:45 +02:00
Eduard GertandClaude Opus 5 6a85748b91 chore: update Next.js to 16.3.4 and refresh dependencies (#797)
Bump next from 16.3.0 to 16.3.4 and refresh the transitive lockfile
entries that came with it (@next/* swc binaries, sharp 0.35.4,
rolldown, vitest, playwright).

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-11 14:47:14 +02:00
Nicolas Frati a9a91905fa Add rootless dashboard container image (#791) 2026-09-09 23:00:19 +02:00
Eduard Gert 3459a4db28 chore: remove dead code and unused dependencies (#787)
Remove the unreachable FirewallGPT module and the abandoned traffic-event
detail prototypes, along with the dependencies they were the sole consumers of.

FirewallGPT was disabled in 9d8d9ee4 by removing its trigger button, leaving
the modal mounted but impossible to open. TrafficEventDetails was never
imported anywhere, and it was the only consumer of TrafficEventChart.

Dependency changes:

- Drop @faker-js/faker, whose only usage was mock chart data in the removed
  TrafficEventChart. This also resolves GHSA-qxc2-j82w-r537 without needing
  the 9 -> 10 major upgrade.
- Drop react-confetti-explosion, only used by the removed
  FirewallGPTSuccessModal.
- Bump @types/node to 20.19.43 so it satisfies the peer range of the vite
  version pulled in by vitest, silencing an ERESOLVE warning on install.
  Stays on the 20.x line to match the Node 20 used in CI.
- Add an allowScripts deny policy for the four packages with install scripts.
  Both @axa-fr postinstalls are bare echo statements, fsevents ships a
  prebuilt binary, and the unrs-resolver postinstall only verifies a binding
  that optionalDependencies already installs.
2026-09-09 11:38:38 +02:00
Maycon Santos cba2613e68 Add anonymization level and upload URL to the debug bundle modal (#753)
Surfaces the two bundle parameters added to the management API: an
anonymization level (Default/Strict, shown when anonymization is enabled)
and an optional upload service URL that overrides the default upload
server. The upload URL is validated as an https URL before the job can be
submitted; both fields are omitted from the request when left at their
defaults so the peer applies its own.
2026-09-03 20:05:02 +02:00
Maycon Santos fe32179dc1 Pin the group view's group, and give agentgateway a reachable upstream (#788)
Stabilize the flaky Control Center and agentgateway e2e specs (#788)

  Three separate causes behind the recent e2e failures.

  The group view ranked every group in the account by peer count to decide what
  to display, so with two workers it could land on a group another spec had just
  created. openGroupView() selects the group by name instead.

  Management now checks a provider's upstream URL when the provider is saved and
  rejects it unless the URL returns a model list, so the agentgateway spec's
  placeholder host stopped working. The test environment runs a stub that answers
  the check.

  The Backspace test blurred the group panel's search input before that input had
  taken focus, so it reclaimed focus and swallowed the key.
2026-09-03 18:38:12 +02:00
Eduard Gert 1bad86b277 Show peer-sourced policies in the Control Center peer view (#786)
The peer view matched policies only by source group membership, so a
policy whose source is the peer itself never appeared in that peer's
view. There was no group fallback either: a rule carrying a
sourceResource has its sources nulled on save, so the group check could
only ever return false for exactly these policies.

Such policies are reachable from the canvas itself, since dragging a
connection off a peer node writes a sourceResource rather than a group.
A user could create a peer-sourced policy, deploy it, drill into that
same peer and not see it.

Match rule.sourceResource against the peer id alongside the group check.
The rendering side needs no change, as select-peer-node is the peer and
its edge to the policy is drawn unconditionally. Draft mode is fixed
transitively: useDraft harvests visible policies from the live view's
nodes, and already draws a sourceResource peer as a source card.
2026-09-03 14:29:06 +02:00
Maycon Santos 3c72444e83 Keep a refused provider save on screen, and its discovered prices (#772)
A refused save closed the modal anyway, throwing away the key just typed — and
the API never returns one. Both paths now stop before closing. The failure was
also reported twice and, because notify() only reddens through its promise path,
both times as a success.

Load and submit now spin, say what they are waiting on, and stop taking clicks;
dismissal is refused mid-save, since the write lands regardless and would
otherwise close a session reopened in the meantime.

Discovery carries the record id and the form's URL together, so changing an
endpoint no longer demands the key back. A 422 from it was reported as
unreachable rather than unsupported — useApiCall rejects with { code, message }
and the hook read status, leaving the catalog fallback unreachable.

Discovered models arrived at zero: 0/0 was hardcoded for anything the catalog
lacked by exact id, which stopped being true once discovery returned real rates.
Bedrock felt all of it — its geography-prefixed ids never match by string.

A Playwright spec covers the refusal end to end.
2026-09-02 21:47:47 +02:00
Nicolas Frati 386fb12013 fix(e2e): stabilize agentgateway provider test (#782) 2026-09-02 19:09:36 +02:00
Daneyon Hansen e3cc74522c feat(agent-network): add agentgateway provider support (#774)
* feat(agent-network): add agentgateway provider UI

Signed-off-by: Daneyon Hansen <daneyon.hansen@solo.io>

* fix(agent-network): address agentgateway review feedback

Signed-off-by: Daneyon Hansen <daneyon.hansen@solo.io>

* fix(agent-network): align with updated dashboard lint

Signed-off-by: Daneyon Hansen <daneyon.hansen@solo.io>

---------

Signed-off-by: Daneyon Hansen <daneyon.hansen@solo.io>
2026-09-02 01:01:13 +02:00
Maycon Santosandbraginini 1537d31f0d Add Agent Network Admin and Usage Viewer roles, gate Agent Network per submodule (#764)
* Add Agent Network Admin and Usage Viewer roles, gate Agent Network per submodule

The management server now ships two delegated roles (agent_network_admin,
usage_viewer) and reports agent_network submodule permissions
(agent_network.providers, .policies, .usage, .logs, .settings, ...) in
/users/current. The dashboard still gated the whole Agent Network section
on services.read, so the new roles saw either everything or nothing.

- register the agent_network module keys so PermissionsProvider fills
  denied defaults and typed lookups work
- gate the Agent Network sidebar and pages per submodule: providers,
  policies, usage (usage or logs read), configuration (settings)
- show the section when any child is permitted instead of services.read
- add both roles to the role selector, user/invite tables and badges

* Gate Usage and Clusters tabs by their own permissions

A caller holding only agent_network.logs.read landed on the Usage tab it
cannot read, and one holding only agent_network.usage.read could open
Access Logs. Show each tab only with its matching read permission and
fall back to the first permitted tab on deep links.

The Clusters tab is a reverse-proxy surface: its table and controls run
on the services permission, so agent_network.settings alone (e.g.
agent_network_admin) no longer surfaces it.

* Fall back to a known configuration tab for unknown query values

An unknown ?tab= value selected no content pane and left the
configuration page blank; unknown or unauthorized values now fall back
to Global Limits.

* Derive the configuration tab from the URL query

The ?tab= query was mirrored into local state through an effect, which
can lag a render behind the URL and duplicates the source of truth.
Derive the active tab from the query directly; trigger clicks already
push the query themselves, and onChange keeps pushing it for Radix's
keyboard navigation, which fires without a click.

* Derive the usage tab from the URL query

Same treatment as the configuration page: the ?tab= query was mirrored
into local state through an effect, duplicating the source of truth.
Derive the active tab from the query; onTabChange keeps pushing it so
clicks, deep links and back/forward all resolve the same way.

* Group user roles into General and Agent Network tabs

  Tab the role dropdown by product surface, gate the Agent Network
  roles and tab on the Agent Network surface being available, and
  disable the role field for the owner unless the caller is an owner.

* Point agent network learn-more links at their docs pages

* Skip the domains lookup when the role can't read it

* Flatten the role selector and add a caller-scoped My Setup page

The role dropdown split General and Agent Network roles into tabs,
which reads as two products inside one field. Return to the single
flat list from main, keeping the surface gate (Agent Network roles
hidden when the surface is unavailable) and the owner protections.

Regular users had no way to see which providers and models their
policies allow or where to point their tools. Add
/agent-network/my-setup backed by the caller-scoped me/setup and
me/consumption endpoints: the endpoint with copy, per-provider
available models, the Configure Your Agent instructions flattened
inline from the connect modal, and the caller's own usage counters.
The sidebar surfaces it for any role — limited view included —
whenever the caller's setup is configured, and stays hidden when
not, which is indistinguishable from no access by design.

* Fix delegated-role API errors and reuse the usage overview for My Usage

Field testing surfaced three problems. The policy and budget modals'
group selector fetched /networks/resources unconditionally, so an
agent_network_admin without the networks permission got a 403 and an
error toast on the policies page; gate the fetch on networks read.

Restricted users got no navigation at all, so the limited view could
never reach My Setup; render the sidebar for restricted users when
their setup is configured or their role reads an agent_network surface.
Roles below account admin also cannot read the account feature flag
(accounts read), so holding an explicit agent_network grant now counts
as proof the surface exists, while callers with accounts read keep the
flag as the source of truth.

My Usage rendered raw per-window counter rows, nothing like the admin
usage view; render it with the same chart-and-table component as the
admin overview, fed from the caller-scoped me/usage/overview endpoint
that returns the identical bucket shape filtered to the current user.

* Reuse the providers-page agent config on My Setup and self-scope Usage & Logs

My Setup now presents the endpoint exactly like the providers page — the
API Base URL badge with Copy and Agent Config opening the same modal
(provider ids come from the caller's me/setup answer instead of the
admin-permissioned provider context) — and drops the My Usage tab: the
regular Usage & Logs page now serves callers without the account-wide
grants too, showing their own data (the server pins the filters to them),
with the identity and provider filters trimmed since they'd be overridden.

usage_viewer gets working filters: the feature-flag fallback moves into
useAgentNetworkMode, AIProvidersProvider gates each fetch on its
submodule read grant so partially-granted roles don't fire 403s, and
GroupsProvider serves restricted roles that hold the groups read grant.
The limited view also gains the Peers link (the add-your-device page).

* Hide provider write actions from read-only viewers

usage_viewer now reads the provider list, but the page still offered every
write flow: Connect Provider, row-click edit, and the enable/disable/delete
menu. Clicking any of them opened the wizard, which - unable to read the
settings row - warned that Agent Network isn't set up even on configured
accounts. Each action is now gated on its own operation grant: the connect
button needs providers create, row-click edit needs update, and the menu
items need update/delete respectively (no menu at all without either).

* Restore the grouped role selector tabs

Bring back the General and Agent Network tabs in the role dropdown
from 3fbeb1e, undoing the flattening: the tabbed grouping keeps the
list readable as more surfaces ship their own delegated roles. The
surface gate (Agent Network roles hidden when the surface is
unavailable) and the owner protections are unchanged.

* Offer the provider and model filters to self-scoped callers

The providers endpoint now self-scopes on the server, so a configured
plain user fetches it too and the caller's own authorized providers back
the Provider and Model filter options on the self-scoped Usage & Logs
view. The identity filters stay hidden — the server pins user and group
to the caller regardless.

* Give the agent config a single home on Connect Agent

  The endpoint card carried an Agent Config action on both the providers
  page and the caller-scoped self-service page, so the same connect
  snippets had two entry points. EndpointBadge now renders it only when
  the caller passes providerIds, which is just the self-service page; the
  providers page keeps the URL and Copy. The context-backed
  AgentConnectModal wrapper is then unused, so it goes.

  Rename that page from My Setup to Connect Agent in the sidebar,
  breadcrumb and heading: My Setup was the only first-person label in the
  navigation, and Connect Agent stays clear of the Configuration sibling.

* Simplify Bedrock config

* Inline the agent config on the Connect page

  Drop the Agent Config modal: its snippets now render inline under the
  endpoint card, so AgentConnectModal is just AgentConnectTabs and
  EndpointBadge is back to the URL and Copy. Move the route to
  /agent-network/connect and swap the hand-rolled provider list for the
  standard DataTable. Follow the backend rename of me/setup to
  agent-config.

* Show a policy's allowed models in the policies table

  A policy's model restrictions live on the guardrails attached to it, so
  the new Models column unions the enabled model allowlists: one chip for
  a single model, a count with the names on hover past that, and a
  Restrict badge opening the guardrails tab when nothing narrows the
  policy. Clicking the column lands on that tab too.

  Chip the models on the Connect Agent table instead of comma-joining
  them, and capitalize the model counts across all three tables to match
  the "2 Groups" chips on Peers.

* Point the Kimi e2e config assertions at the Connect Agent page

The Kimi-gated config surfaces moved: the Agent Config modal is gone and
the snippets render inline on /agent-network/connect, so the spec stopped
finding the button it used to click on the providers page. Navigate to the
Connect page instead.

That page's answer is caller-scoped — it only offers providers the
caller's own policies authorize — so the spec now grants the owner the
freshly connected provider through a dedicated group + policy, torn down
again (group detached from the owner first) before and after the run. The
backing GET /agent-network/agent-config ships with newer management
builds; on builds that predate it the suite skips, same as the existing
catalog and settings-bootstrap probes.

* Hand the agent config to every account member

  The agent-config answer withheld the endpoint from callers no policy
  covers, so "account not set up" and "you have no access" were the same
  shape. The dashboard shows every user the same connection config, so
  Configured now tracks the account: once it has an endpoint every member
  gets it, with an empty providers list for those no policy covers. That
  list stays caller-scoped, and the endpoint authorizes nothing on its own
  since the gateway still refuses every request no policy permits.

  Rename the files and symbols the endpoint outgrew when it became
  /agent-network/agent-config: me_handler.go, setup.go and types/setup.go
  become agent_config.go, GetSetupForUser becomes GetAgentConfigForUser,
  and the AgentNetworkMeSetup schema becomes AgentNetworkAgentConfig.
  Regenerating types.gen.go from the untouched spec first reproduced it
  byte for byte, so the generated diff is only these renames.

* Let the surface switch decide the Agent Network section

  Connect Agent and Usage & Logs were visible on the caller's own setup
  alone, so a deployment with Agent Network off still showed them, and the
  route tree then 404d the links the nav had just rendered. The switch now
  decides first.

  The caller's agent config still stands in for the switch, but only for
  callers who cannot read the config themselves - otherwise an account
  holding an endpoint kept the surface on for its owner regardless.

* Collapse the two User types the merge left behind

  Both sides of the merge declared a User type, in different regions of the
  helper, so the merge took both without ever reporting a conflict and
  Playwright refused to parse the file. One type now carries the union of
  the fields the /users answer returns.

* Fix SNAPSHOT version display

* Fix e2e tests

* Fix e2e tests

* Fix version display

* Fix e2e

* Fix e2e

* Fix e2e

---------

Co-authored-by: braginini <bangvalo@gmail.com>
2026-09-01 14:35:18 +02:00
118 changed files with 4601 additions and 3817 deletions

No files matched your search

File renamed without changes.
+29
View File
@@ -98,6 +98,24 @@ jobs:
type=ref,event=pr
type=sha
- name: Docker meta (rootless)
id: meta_rootless
uses: docker/metadata-action@v5
with:
images: |
${{ env.DOCKERHUB_IMAGE }}
${{ env.GHCR_IMAGE }}
flavor: |
latest=false
suffix=-rootless,onlatest=true
tags: |
type=schedule
type=ref,event=branch
type=ref,event=tag
type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/') }}
type=ref,event=pr
type=sha
- name: Log in to Docker Hub
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
uses: docker/login-action@v3
@@ -123,7 +141,18 @@ jobs:
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
- name: Build and push rootless Docker image
uses: docker/build-push-action@v5
with:
context: .
file: docker/Dockerfile.rootless
push: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }}
platforms: linux/amd64,linux/arm64,linux/arm
tags: ${{ steps.meta_rootless.outputs.tags }}
labels: ${{ steps.meta_rootless.outputs.labels }}
- if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
run: |
echo '### Pushed tags' >> $GITHUB_STEP_SUMMARY
echo '${{ steps.meta.outputs.tags }}' >> $GITHUB_STEP_SUMMARY
echo '${{ steps.meta_rootless.outputs.tags }}' >> $GITHUB_STEP_SUMMARY
+24 -12
View File
@@ -11,6 +11,7 @@
# ./build.sh # build + export netbird-dashboard.tar.gz
# IMAGE_TAG=v1 ./build.sh # override the image tag
# SKIP_BUILD=1 ./build.sh # reuse an existing out/ (skip npm build)
# ROOTLESS=1 ./build.sh # build the amd64-rootless image variant
#
set -euo pipefail
@@ -19,8 +20,26 @@ cd "$(dirname "$0")"
IMAGE_NAME="${IMAGE_NAME:-netbird-dashboard}"
IMAGE_TAG="${IMAGE_TAG:-amd64}"
DOCKERFILE="docker/Dockerfile"
CONTAINER_PORT=80
OUTPUT_SUFFIX=""
case "${ROOTLESS:-0}" in
0) ;;
1)
DOCKERFILE="docker/Dockerfile.rootless"
CONTAINER_PORT=8080
OUTPUT_SUFFIX="-rootless"
[[ "${IMAGE_TAG}" == *-rootless ]] || IMAGE_TAG="${IMAGE_TAG}-rootless"
;;
*)
echo "ERROR: ROOTLESS must be 0 or 1." >&2
exit 1
;;
esac
IMAGE="${IMAGE_NAME}:${IMAGE_TAG}"
OUTPUT="${OUTPUT:-${IMAGE_NAME}.tar.gz}"
OUTPUT="${OUTPUT:-${IMAGE_NAME}${OUTPUT_SUFFIX}.tar.gz}"
# Build the static export (out/) unless told to reuse an existing one. Next 16
# no longer runs ESLint during `next build`; types are checked by the build via
@@ -39,17 +58,10 @@ if [[ ! -d out ]]; then
exit 1
fi
# The .dockerignore lives under docker/, but Docker reads it from the build
# context root. Stage it for the duration of the build so node_modules/.next
# don't get shipped into the build context.
cleanup() { rm -f .dockerignore; }
trap cleanup EXIT
cp docker/.dockerignore .dockerignore
echo "==> Building ${IMAGE} for linux/amd64"
docker build \
--platform linux/amd64 \
-f docker/Dockerfile \
-f "${DOCKERFILE}" \
-t "${IMAGE}" \
.
@@ -60,13 +72,13 @@ echo "==> Done: ${OUTPUT} ($(du -h "${OUTPUT}" | cut -f1))"
echo
echo "Copy and run on the server:"
echo " scp ${OUTPUT} user@your-server:/tmp/"
echo " docker load < /tmp/${OUTPUT}"
echo " docker load < /tmp/${OUTPUT##*/}"
echo
echo "Run (nginx serves on container port 80). Set the runtime env vars the"
echo "Run (nginx serves on container port ${CONTAINER_PORT}). Set the runtime env vars the"
echo "dashboard templates at startup — for the Agent Network onboarding test,"
echo "flip NETBIRD_AGENT_NETWORK_ONLY=true:"
echo " docker run -d --name ${IMAGE_NAME} --restart unless-stopped \\"
echo " -p 8080:80 \\"
echo " -p 8080:${CONTAINER_PORT} \\"
echo " -e NETBIRD_MGMT_API_ENDPOINT=https://your-mgmt:443 \\"
echo " -e AUTH_AUTHORITY=https://your-idp/realms/netbird \\"
echo " -e AUTH_CLIENT_ID=netbird-dashboard \\"
+2 -1
View File
@@ -24,5 +24,6 @@
"hubspotSignupFormId": "$NETBIRD_HUBSPOT_SIGNUP_FORM_ID",
"hubspotOnboardingFormId": "$NETBIRD_HUBSPOT_ONBOARDING_FORM_ID",
"hubspotSurveyFormId": "$NETBIRD_HUBSPOT_SURVEY_FORM_ID",
"analyticsExcludedEmails": "$NETBIRD_ANALYTICS_EXCLUDED_EMAILS"
"analyticsExcludedEmails": "$NETBIRD_ANALYTICS_EXCLUDED_EMAILS",
"announcement": "$NETBIRD_ANNOUNCEMENT"
}
+29
View File
@@ -0,0 +1,29 @@
FROM alpine:3.24
RUN apk add --no-cache bash ca-certificates gettext nginx tzdata && \
rm -rf /var/cache/apk/* && \
mkdir -p /tmp/nginx
STOPSIGNAL SIGINT
EXPOSE 8080
WORKDIR /usr/share/nginx/html
COPY docker/default.conf /etc/nginx/http.d/default.conf
COPY docker/nginx.conf /etc/nginx/nginx.conf
COPY docker/rootless.conf /etc/nginx/http.d/rootless.conf
COPY docker/init_react_envs.sh /usr/local/init_react_envs.sh
COPY out/ /usr/share/nginx/html/
RUN sed -i \
-e 's/listen 80 default_server;/listen 8080 default_server;/' \
-e 's/listen \[::\]:80 default_server;/listen [::]:8080 default_server;/' \
/etc/nginx/http.d/default.conf && \
sed -i '/^user nginx;$/d' /etc/nginx/nginx.conf && \
chmod +x /usr/local/init_react_envs.sh && \
chgrp -R 0 /etc/nginx/http.d /usr/share/nginx/html /tmp/nginx && \
chmod -R g=u /etc/nginx/http.d /usr/share/nginx/html /tmp/nginx
USER 10001:0
ENTRYPOINT ["/usr/local/init_react_envs.sh"]
CMD ["/usr/sbin/nginx", "-e", "/dev/stderr", "-g", "pid /tmp/nginx.pid;"]
+47 -11
View File
@@ -1,23 +1,59 @@
# NetBird Dashboard
NetBird Dashboard is NetBirds Management server UI. It allows users to signin, view setup keys and manage peers. This image is **not ready** for production use.
## Tags
```latest``` ```vX.X.X``` not available yet.
```main``` built on every PR being merged to the repository
NetBird Dashboard is NetBird's management server UI.
## Tags
Each published image tag also has a rootless variant with the `-rootless`
suffix. For example, `main` is also published as `main-rootless`.
## How to use this image
HTTP run:
HTTP:
```shell
docker run -d --rm -p 80:80 wiretrustee/dashboard:main
docker run -d --rm -p 80:80 netbirdio/dashboard:main
```
Using SSL certificate from Let's Encrypt®:
Using an SSL certificate from Let's Encrypt:
```shell
docker run -d --rm -p 80:80 -p 443:443 \
-e LETSENCRYPT_DOMAIN=app.mydomain.com \
-e LETSENCRYPT_EMAIL=hello@mydomain.com \
netbirdio/dashboard:main
```
> For SSL generation, you need to run this image in a server with proper public IP and a domain name pointing to it.
For certificate generation, the server needs a public IP and a domain name
pointing to it.
### Rootless and OpenShift
The rootless image listens on port `8080`, runs as UID `10001` by default, and
supports OpenShift's arbitrary UID when the container retains GID `0`:
```shell
docker run -d --rm \
--user 12345:0 \
-p 8080:8080 \
-e AUTH_AUTHORITY=https://idp.example.com \
-e AUTH_CLIENT_ID=netbird-dashboard \
-e AUTH_AUDIENCE=netbird-dashboard \
-e AUTH_SUPPORTED_SCOPES='openid profile email' \
-e USE_AUTH0=false \
-e NETBIRD_MGMT_API_ENDPOINT=https://api.example.com \
netbirdio/dashboard:main-rootless
```
The rootless image does not include Certbot or expose HTTPS. Terminate TLS at
the OpenShift Route, Ingress, or another reverse proxy.
## Environment variables
* ```NGINX_SSL_PORT``` Changes the port that Nginx listens to. Defaults to ```443```
* ```LETSENCRYPT_DOMAIN``` Enables Certbot`s client execution for the specified domain. Defaults to ```none```
* ```LETSENCRYPT_EMAIL``` Email used in Certbot`s client execution to register the certificate request. Defaults to ```example@local```
- `NGINX_SSL_PORT` changes the port that Nginx listens to. Defaults to `443`.
- `LETSENCRYPT_DOMAIN` enables Certbot for the specified domain. Defaults to
`none`.
- `LETSENCRYPT_EMAIL` is used to register the certificate request. Defaults to
`example@local`.
These certificate variables apply only to the standard image.
+9 -1
View File
@@ -73,6 +73,8 @@ export NETBIRD_HUBSPOT_SIGNUP_FORM_ID=${NETBIRD_HUBSPOT_SIGNUP_FORM_ID}
export NETBIRD_HUBSPOT_ONBOARDING_FORM_ID=${NETBIRD_HUBSPOT_ONBOARDING_FORM_ID}
export NETBIRD_HUBSPOT_SURVEY_FORM_ID=${NETBIRD_HUBSPOT_SURVEY_FORM_ID}
export NETBIRD_ANALYTICS_EXCLUDED_EMAILS=${NETBIRD_ANALYTICS_EXCLUDED_EMAILS}
# A permanent banner for this deployment, shown to every user; empty for none.
export NETBIRD_ANNOUNCEMENT=${NETBIRD_ANNOUNCEMENT}
echo "NetBird latest version: ${NETBIRD_LATEST_VERSION}"
@@ -163,7 +165,7 @@ sed -i "s|add_header Content-Security-Policy \"[^\"]*\" always;|$CSP_HEADER|g" /
}
# replace ENVs in the config
ENV_STR="\$\$USE_AUTH0 \$\$AUTH_AUDIENCE \$\$AUTH_AUTHORITY \$\$AUTH_CLIENT_ID \$\$AUTH_CLIENT_SECRET \$\$AUTH_SUPPORTED_SCOPES \$\$NETBIRD_MGMT_API_ENDPOINT \$\$NETBIRD_MGMT_GRPC_API_ENDPOINT \$\$NETBIRD_HOTJAR_TRACK_ID \$\$NETBIRD_GOOGLE_ANALYTICS_ID \$\$NETBIRD_GOOGLE_TAG_MANAGER_ID \$\$AUTH_REDIRECT_URI \$\$AUTH_SILENT_REDIRECT_URI \$\$NETBIRD_TOKEN_SOURCE \$\$NETBIRD_DRAG_QUERY_PARAMS \$\$NETBIRD_AUTH_SERVICE_URL \$\$NETBIRD_WASM_PATH \$\$NETBIRD_LICENSED \$\$NETBIRD_CLOUD \$\$NETBIRD_AGENT_NETWORK_ONLY \$\$NETBIRD_AGENT_NETWORK_ENABLED \$\$NETBIRD_HUBSPOT_PORTAL_ID \$\$NETBIRD_HUBSPOT_SIGNUP_FORM_ID \$\$NETBIRD_HUBSPOT_ONBOARDING_FORM_ID \$\$NETBIRD_HUBSPOT_SURVEY_FORM_ID \$\$NETBIRD_ANALYTICS_EXCLUDED_EMAILS"
ENV_STR="\$\$USE_AUTH0 \$\$AUTH_AUDIENCE \$\$AUTH_AUTHORITY \$\$AUTH_CLIENT_ID \$\$AUTH_CLIENT_SECRET \$\$AUTH_SUPPORTED_SCOPES \$\$NETBIRD_MGMT_API_ENDPOINT \$\$NETBIRD_MGMT_GRPC_API_ENDPOINT \$\$NETBIRD_HOTJAR_TRACK_ID \$\$NETBIRD_GOOGLE_ANALYTICS_ID \$\$NETBIRD_GOOGLE_TAG_MANAGER_ID \$\$AUTH_REDIRECT_URI \$\$AUTH_SILENT_REDIRECT_URI \$\$NETBIRD_TOKEN_SOURCE \$\$NETBIRD_DRAG_QUERY_PARAMS \$\$NETBIRD_AUTH_SERVICE_URL \$\$NETBIRD_WASM_PATH \$\$NETBIRD_LICENSED \$\$NETBIRD_CLOUD \$\$NETBIRD_AGENT_NETWORK_ONLY \$\$NETBIRD_AGENT_NETWORK_ENABLED \$\$NETBIRD_HUBSPOT_PORTAL_ID \$\$NETBIRD_HUBSPOT_SIGNUP_FORM_ID \$\$NETBIRD_HUBSPOT_ONBOARDING_FORM_ID \$\$NETBIRD_HUBSPOT_SURVEY_FORM_ID \$\$NETBIRD_ANALYTICS_EXCLUDED_EMAILS \$\$NETBIRD_ANNOUNCEMENT"
OIDC_TRUSTED_DOMAINS="/usr/share/nginx/html/OidcTrustedDomains.js"
envsubst "$ENV_STR" < "$OIDC_TRUSTED_DOMAINS".tmpl > "$OIDC_TRUSTED_DOMAINS"
@@ -173,6 +175,12 @@ for f in $(grep -R -l AUTH_SUPPORTED_SCOPES /usr/share/nginx/html); do
rm "$f".copy
done
# When a command is provided, initialization happens before the server starts.
# The rootless image uses this path so nginx never needs to be reloaded.
if (( $# > 0 )); then
exec "$@"
fi
# Reload nginx so the patched CSP header takes effect.
# supervisord starts nginx (priority 100) before this script (priority 201) and
# never reloads it, so without this nginx keeps serving the static default.conf
+7
View File
@@ -0,0 +1,7 @@
# nginx includes this file inside the http context.
# All runtime state stays in a directory writable by the arbitrary UID used by OpenShift.
client_body_temp_path /tmp/nginx/client_temp;
proxy_temp_path /tmp/nginx/proxy_temp;
fastcgi_temp_path /tmp/nginx/fastcgi_temp;
uwsgi_temp_path /tmp/nginx/uwsgi_temp;
scgi_temp_path /tmp/nginx/scgi_temp;
+1
View File
@@ -1,6 +1,7 @@
# Ignore zitadel environment
.env
Caddyfile
agentgateway-stub.Caddyfile
management.json
turnserver.conf
zitadel.env
+1 -1
View File
@@ -19,7 +19,7 @@ check_docker_compose() {
DOCKER_COMPOSE_COMMAND=$(check_docker_compose)
$DOCKER_COMPOSE_COMMAND down --volumes
rm -f docker-compose.yml Caddyfile zitadel.env .env dashboard.env machinekey/zitadel-admin-sa.token turnserver.conf management.json proxy.env proxy-no-ports.env
rm -f docker-compose.yml Caddyfile agentgateway-stub.Caddyfile zitadel.env .env dashboard.env machinekey/zitadel-admin-sa.token turnserver.conf management.json proxy.env proxy-no-ports.env
rm -rf proxy-certs proxy-certs-no-ports
rm -f ../../.test-config.json ../playwright.env.json
rm -f ../fixtures/auth/owner.json ../fixtures/auth/user.json
+45 -1
View File
@@ -532,7 +532,7 @@ initEnvironment() {
echo "Generated files already exist, if you want to reinitialize the environment, please remove them first."
echo "You can use the following commands:"
echo " $DOCKER_COMPOSE_COMMAND down --volumes # to remove all containers and volumes"
echo " rm -f docker-compose.yml Caddyfile zitadel.env dashboard.env machinekey/zitadel-admin-sa.token turnserver.conf management.json proxy.env proxy-no-ports.env && rm -rf proxy-certs proxy-certs-no-ports"
echo " rm -f docker-compose.yml Caddyfile agentgateway-stub.Caddyfile zitadel.env dashboard.env machinekey/zitadel-admin-sa.token turnserver.conf management.json proxy.env proxy-no-ports.env && rm -rf proxy-certs proxy-certs-no-ports"
echo "Be aware that this will remove all data from the database, and you will have to reconfigure the dashboard."
exit 1
fi
@@ -540,6 +540,7 @@ initEnvironment() {
echo Rendering initial files...
renderDockerCompose > docker-compose.yml
renderCaddyfile > Caddyfile
renderAgentGatewayStub > agentgateway-stub.Caddyfile
renderZitadelEnv > zitadel.env
echo "" > turnserver.conf
echo "" > management.json
@@ -612,6 +613,30 @@ initEnvironment() {
echo "Password: $ZITADEL_ADMIN_PASSWORD" | tee -a .env
}
# Saving an Agent Network provider makes management call the upstream and
# refuses the save unless it answers a model listing, so a provider spec needs
# a reachable OpenAI-compatible endpoint. agentgateway is self-hosted and has
# no public one; this stub stands in for it, inside the compose network where
# management can reach it and without depending on a vendor being up.
renderAgentGatewayStub() {
cat <<'EOF'
{
admin off
auto_https off
}
:8088 {
handle /v1/models {
header Content-Type application/json
respond `{"object":"list","data":[{"id":"agentgateway-e2e-model","object":"model","owned_by":"netbird-e2e"}]}` 200
}
handle {
respond 404
}
}
EOF
}
renderCaddyfile() {
cat <<EOF
{
@@ -818,6 +843,15 @@ services:
volumes:
- netbird_caddy_data:/data
- ./Caddyfile:/etc/caddy/Caddyfile
# Stand-in upstream for the Agent Network provider specs: answers the model
# listing management probes before it accepts a provider save. Reachable
# only from inside the compose network, at http://agentgateway-stub:8088.
agentgateway-stub:
image: caddy
restart: unless-stopped
networks: [ netbird ]
volumes:
- ./agentgateway-stub.Caddyfile:/etc/caddy/Caddyfile
# Management
management:
image: ghcr.io/netbirdio/management-cloud:${MANAGEMENT_IMAGE_TAG}
@@ -892,6 +926,16 @@ services:
# GeoLite2 download removes a startup stall of up to 2 minutes
# when pkgs.netbird.io is slow.
- NB_PROXY_DISABLE_GEOLOCATION=true
# Declare the private capability so this cluster can host the agent
# network endpoint. That endpoint is a private service — reachable only
# from connected peers — and management refuses to bootstrap it onto a
# cluster without a private-capable proxy; the connect-provider wizard
# reads the same flag (supports_private) to pick its cluster. Without
# it no cluster qualifies and the wizard stops at the provider tab,
# which strands every spec that walks through it. The no-ports proxy
# below stays centralised, so the pick has to skip past a cluster that
# does not qualify.
- NB_PROXY_PRIVATE=true
volumes:
- ./proxy-certs:/certs:ro
command: [
+145 -13
View File
@@ -46,8 +46,7 @@ async function getApiContext(
]);
const request = response.request();
const authHeader =
(await request.allHeaders())["authorization"] || "";
const authHeader = (await request.allHeaders())["authorization"] || "";
const token = authHeader.replace("Bearer ", "");
const url = new URL(request.url());
const origin = `${url.protocol}//${url.host}`;
@@ -76,15 +75,35 @@ async function apiDelete(page: Page, path: string): Promise<void> {
});
}
async function apiPost<T>(page: Page, path: string, data: unknown): Promise<T> {
async function apiPost<T>(page: Page, path: string, body: unknown): Promise<T> {
const { token, origin } = await getApiContext(page);
const resp = await page.request.post(`${origin}/api${path}`, {
headers: { Authorization: `Bearer ${token}` },
data,
data: body,
});
if (!resp.ok()) {
throw new Error(
`POST ${path} returned ${resp.status()}: ${await resp.text()}`,
);
}
return resp.json();
}
async function apiPut<T>(page: Page, path: string, body: unknown): Promise<T> {
const { token, origin } = await getApiContext(page);
const resp = await page.request.put(`${origin}/api${path}`, {
headers: { Authorization: `Bearer ${token}` },
data: body,
});
if (!resp.ok()) {
throw new Error(
`PUT ${path} returned ${resp.status()}: ${await resp.text()}`,
);
}
return resp.json();
}
/** List all groups. */
export async function listGroups(page: Page): Promise<Group[]> {
return apiGet<Group[]>(page, "/groups");
}
@@ -108,10 +127,12 @@ export async function deletePeersByPrefix(page: Page, prefix: string) {
}
}
/** Create a group by name. */
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}`);
}
@@ -259,7 +280,10 @@ export async function deleteRouteById(page: Page, routeId: string) {
await apiDelete(page, `/routes/${routeId}`);
}
export async function deleteRoutesByNetworkIdPrefix(page: Page, prefix: string) {
export async function deleteRoutesByNetworkIdPrefix(
page: Page,
prefix: string,
) {
const routes = await listRoutes(page);
const toDelete = routes.filter((r) => r.network_id.startsWith(prefix));
for (const r of toDelete) {
@@ -338,8 +362,13 @@ type NotificationChannel = {
enabled: boolean;
};
export async function listNotificationChannels(page: Page): Promise<NotificationChannel[]> {
return apiGet<NotificationChannel[]>(page, "/integrations/notifications/channels");
export async function listNotificationChannels(
page: Page,
): Promise<NotificationChannel[]> {
return apiGet<NotificationChannel[]>(
page,
"/integrations/notifications/channels",
);
}
export async function deleteNotificationChannel(page: Page, channelId: string) {
@@ -353,7 +382,10 @@ export async function deleteAllNotificationChannels(page: Page) {
}
}
export async function deleteNotificationChannelsByType(page: Page, type: string) {
export async function deleteNotificationChannelsByType(
page: Page,
type: string,
) {
const channels = await listNotificationChannels(page);
const toDelete = channels.filter((c) => c.type === type);
for (const c of toDelete) {
@@ -368,7 +400,9 @@ type NameserverGroup = {
name: string;
};
export async function listNameserverGroups(page: Page): Promise<NameserverGroup[]> {
export async function listNameserverGroups(
page: Page,
): Promise<NameserverGroup[]> {
return apiGet<NameserverGroup[]>(page, "/dns/nameservers");
}
@@ -376,7 +410,10 @@ export async function deleteNameserverGroupById(page: Page, id: string) {
await apiDelete(page, `/dns/nameservers/${id}`);
}
export async function deleteNameserverGroupsByPrefix(page: Page, prefix: string) {
export async function deleteNameserverGroupsByPrefix(
page: Page,
prefix: string,
) {
const groups = await listNameserverGroups(page);
const toDelete = groups.filter((g) => g.name.startsWith(prefix));
for (const g of toDelete) {
@@ -391,11 +428,16 @@ type ReverseProxyService = {
name: string;
};
export async function listReverseProxyServices(page: Page): Promise<ReverseProxyService[]> {
export async function listReverseProxyServices(
page: Page,
): Promise<ReverseProxyService[]> {
return apiGet<ReverseProxyService[]>(page, "/reverse-proxies/services");
}
export async function deleteReverseProxyServiceById(page: Page, serviceId: string) {
export async function deleteReverseProxyServiceById(
page: Page,
serviceId: string,
) {
await apiDelete(page, `/reverse-proxies/services/${serviceId}`);
}
@@ -449,7 +491,13 @@ export async function waitForProxyClustersOnline(
throw new Error(
`Proxy clusters not online after ${timeoutMs}ms. Expected ${addresses.join(
", ",
)}; got ${JSON.stringify(last.map((c) => ({ a: c.address, online: c.online, n: c.connected_proxies })))}`,
)}; got ${JSON.stringify(
last.map((c) => ({
a: c.address,
online: c.online,
n: c.connected_proxies,
})),
)}`,
);
}
@@ -461,6 +509,8 @@ type User = {
name: string;
role: string;
status: string;
auto_groups: string[];
is_blocked: boolean;
is_current: boolean;
};
@@ -538,3 +588,85 @@ export async function supportsAgentNetworkSettingsBootstrap(
!!settings && typeof settings === "object" && "proxy_address" in settings
);
}
/**
* Whether the management build under test serves the caller-scoped
* GET /agent-network/agent-config answer that backs the Connect Agent page.
* The endpoint answers 200 for every authenticated caller (an unconfigured
* caller gets configured=false, never an error), so any non-OK status means
* the build predates it.
*/
export async function supportsAgentNetworkAgentConfig(
page: Page,
): Promise<boolean> {
const { token, origin } = await getApiContext(page);
const resp = await page.request.get(
`${origin}/api/agent-network/agent-config`,
{ headers: { Authorization: `Bearer ${token}` } },
);
return resp.ok();
}
type AgentNetworkPolicy = {
id: string;
name: string;
};
/** List Agent Network policies. */
export async function listAgentNetworkPolicies(
page: Page,
): Promise<AgentNetworkPolicy[]> {
return apiGet<AgentNetworkPolicy[]>(page, "/agent-network/policies");
}
/** Create an Agent Network policy. */
export async function createAgentNetworkPolicy(
page: Page,
body: {
name: string;
source_groups: string[];
destination_provider_ids: string[];
enabled?: boolean;
},
): Promise<AgentNetworkPolicy> {
return apiPost<AgentNetworkPolicy>(page, "/agent-network/policies", {
enabled: true,
...body,
});
}
/** Delete all Agent Network policies whose name starts with the prefix. */
export async function deleteAgentNetworkPoliciesByPrefix(
page: Page,
prefix: string,
) {
const policies = await listAgentNetworkPolicies(page);
for (const p of policies) {
if (p.name.startsWith(prefix)) {
await apiDelete(page, `/agent-network/policies/${p.id}`);
}
}
}
/** The user the captured token belongs to. */
export async function getCurrentUser(page: Page): Promise<User> {
const users = await apiGet<User[]>(page, "/users");
const current = users.find((u) => u.is_current);
if (!current) {
throw new Error("no is_current user in the /users answer");
}
return current;
}
/** Replace a user's auto-groups, keeping role and blocked state. */
export async function updateUserAutoGroups(
page: Page,
user: User,
autoGroups: string[],
): Promise<void> {
await apiPut(page, `/users/${user.id}`, {
role: user.role,
auto_groups: autoGroups,
is_blocked: !!user.is_blocked,
});
}
+1 -1
View File
@@ -33,7 +33,7 @@ export async function loginToApp(
// Use locators that match either outcome — Playwright auto-waits.
const appReady = page.getByTestId("left-navigation-item").first();
const setupModal = page.getByTestId("setup-netbird-modal");
const approvalPending = page.getByText("User Approval Pending");
const approvalPending = page.getByTestId("pending-approval");
const onboarding = page.getByText("Add new device to your network");
const selectAccount = page.getByText("Select account");
const loginInput = page.locator("input[id=loginName]");
+25
View File
@@ -24,6 +24,31 @@ export async function openControlCenter(page: Page, tab?: FlowView) {
await dismissBlockingOverlays(page);
}
/**
* Opens the groups view already showing `groupName`.
*
* The view picks its own group on first render (getFirstGroup: the highest
* peers_count that is a policy source), and that ranking runs over every group
* in the account — including ones a spec on the other worker seeded moments
* ago. Deleting the spec's own fixtures first is not enough to pin it, so
* select the group by name and let the view rebuild around it.
*/
export async function openGroupView(page: Page, groupName: string) {
await openControlCenter(page, "groups");
const selector = canvasNode(page, "select-group-node");
await expect(selector).toBeVisible();
// Unforced on purpose: the opening fitView animates the canvas, and the
// actionability check is what waits for the node to stop moving.
await selector.click();
await page.getByTestId("select-dropdown-search").fill(groupName);
await page
.locator('[role="option"]')
.filter({ hasText: groupName })
.first()
.click({ force: true });
await expect(selector).toContainText(groupName);
}
export async function switchFlowView(page: Page, view: FlowView) {
await dismissBlockingOverlays(page);
await page.getByTestId(`cc-flow-${view}`).click({ force: true });
@@ -0,0 +1,156 @@
/**
* Agent Network agentgateway provider spec.
*
* Exercises the catalog-driven provider flow against a management build that
* includes the agentgateway catalog entry. Older builds skip the test.
*/
import {
deleteAgentNetworkProvidersByPrefix,
listAgentNetworkCatalog,
supportsAgentNetworkSettingsBootstrap,
} from "../helpers/api";
import { navigateTo } from "../helpers/auth";
import { expect, test } from "../helpers/fixtures";
import { generateRandomName } from "../helpers/utils";
const AGENT_NETWORK_CONFIG_KEY = "netbird-test-agent-network";
const AGENTGATEWAY_CATALOG_ID = "agentgateway";
const PROVIDER_PREFIX = "e2e-agentgateway-";
test.describe
.serial("Agent Network agentgateway provider @agent-network", () => {
test("connect agentgateway with its trusted identity mapping", async ({
dashboardAsOwner: page,
}) => {
await page.addInitScript(
([key, value]) => {
try {
window.localStorage.setItem(key as string, value as string);
} catch {}
},
[AGENT_NETWORK_CONFIG_KEY, "enabled"],
);
const catalog = await listAgentNetworkCatalog(page);
test.skip(
!catalog.some((entry) => entry.id === AGENTGATEWAY_CATALOG_ID),
`management catalog has no ${AGENTGATEWAY_CATALOG_ID} entry`,
);
test.skip(
!(await supportsAgentNetworkSettingsBootstrap(page)),
"management build does not support Agent Network settings bootstrap",
);
await deleteAgentNetworkProvidersByPrefix(page, PROVIDER_PREFIX);
try {
await navigateTo(page, "/agent-network/providers");
await page
.getByTestId("connect-agent-network-provider")
.first()
.click({ force: true });
await page
.getByTestId("agent-network-provider-type")
.click({ force: true });
await page
.getByTestId("select-dropdown-search")
.fill(AGENTGATEWAY_CATALOG_ID);
await page
.getByTestId("agent-network-provider-option-agentgateway")
.click({ force: true });
// Saving a provider makes management call the upstream, and it refuses
// the save with a 422 unless the upstream answers a model listing — so a
// made-up host cannot be used here. agentgateway is self-hosted and has
// no public endpoint to point at, so create-test-env.sh runs a stub that
// answers that probe inside the compose network.
const upstreamURL = "http://agentgateway-stub:8088";
await page
.getByTestId("agent-network-provider-upstream-url")
.fill(upstreamURL);
await page
.getByTestId("agent-network-provider-api-key")
.fill("e2e-agentgateway-virtual-key");
const providerName = generateRandomName(PROVIDER_PREFIX);
await page.getByTestId("agent-network-provider-name").fill(providerName);
await page
.getByTestId("agent-network-provider-models-tab")
.click({ force: true });
await expect(
page.getByTestId("agent-network-provider-models-help"),
).toContainText("Empty = all catalog models");
await page
.getByTestId("agent-network-provider-continue")
.click({ force: true });
await expect(
page.getByTestId("agent-network-provider-mappings-tab"),
).toHaveAttribute("data-state", "active");
await expect(
page.getByTestId("agent-network-provider-user-mapping"),
).toContainText("x-netbird-user-id");
await expect(
page.getByTestId("agent-network-provider-groups-mapping"),
).toContainText("x-netbird-groups");
await expect(
page.getByTestId("agent-network-provider-groups-guidance"),
).toContainText("must not be used as an agentgateway authorization claim");
const createResponse = page.waitForResponse(
(response) =>
response.url().includes("/agent-network/providers") &&
response.request().method() === "POST",
{ timeout: 30_000 },
);
await page.getByTestId("agent-network-provider-submit").click({
force: true,
});
const created = await createResponse;
expect([200, 201]).toContain(created.status());
const body = created.request().postDataJSON();
expect(body).toMatchObject({
provider_id: AGENTGATEWAY_CATALOG_ID,
name: providerName,
upstream_url: upstreamURL,
models: [],
metadata_disabled: false,
});
expect(body).not.toHaveProperty("identity_header_user_id");
expect(body).not.toHaveProperty("identity_header_groups");
await expect(
page.getByTestId("agent-network-provider-modal"),
).toBeHidden();
await expect(page.getByTestId(providerName)).toBeVisible();
await expect(
page.getByTestId(`provider-models-${providerName}`),
).toHaveText("All Models");
await page.getByTestId(providerName).click({ force: true });
await expect(
page.getByTestId("agent-network-provider-modal"),
).toBeVisible();
await expect(
page.getByTestId("agent-network-provider-upstream-url"),
).toHaveValue(upstreamURL);
await page
.getByTestId("agent-network-provider-mappings-tab")
.click({ force: true });
await expect(
page.getByTestId("agent-network-provider-user-mapping"),
).toContainText("x-netbird-user-id");
await expect(
page.getByTestId("agent-network-provider-groups-mapping"),
).toContainText("x-netbird-groups");
await page
.getByTestId("agent-network-provider-modal")
.getByTestId("modal-close")
.click({ force: true });
} finally {
await deleteAgentNetworkProvidersByPrefix(page, PROVIDER_PREFIX);
}
});
});
@@ -0,0 +1,206 @@
/**
* Agent Network bootstrap-cluster spec.
*
* The account's agent network endpoint is assigned once, from the proxy
* cluster the connect-provider wizard pins it to, and it is immutable after
* that. Not every live cluster can host it: the endpoint is a private service,
* reachable only from connected peers and authorised by their tunnel identity,
* which only a cluster with private capabilities can serve. Management
* reports that per cluster as supports_private and refuses a
* bootstrap onto a cluster without it.
*
* This spec pins the wizard's half of that contract: it picks a
* private-capable cluster even when a centralised one is listed first, and it
* blocks setup — with a message naming what is missing — when no listed
* cluster qualifies. Both the settings and domains routes are mocked, so the
* assertions do not depend on which clusters the test backend happens to have
* connected, and no provider is created against it.
*/
import { type Browser, expect, type Page, test } from "@playwright/test";
import { loginToApp } from "../helpers/auth";
const AGENT_NETWORK_CONFIG_KEY = "netbird-test-agent-network";
// Listed first, and unusable: a centralised proxy cluster. Picking it is the
// bug this spec guards against.
const CENTRAL_CLUSTER = "central.proxy.netbird.io";
// Listed second, and the only valid pick.
const PRIVATE_CLUSTER = "private.proxy.netbird.io";
const UNBOOTSTRAPPED_DEFAULTS = {
endpoint: "",
proxy_address: "",
dedicated: false,
enable_log_collection: true,
enable_prompt_collection: false,
redact_pii: false,
access_log_retention_days: 30,
};
const BOOTSTRAPPED_SETTINGS = {
...UNBOOTSTRAPPED_DEFAULTS,
endpoint: `violet.${PRIVATE_CLUSTER}`,
proxy_address: PRIVATE_CLUSTER,
created_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-01T00:00:00Z",
};
/** A free (cluster-backed) domain as /reverse-proxies/domains reports it. */
function freeDomain(name: string, supportsPrivate: boolean) {
return {
id: name,
domain: name,
validated: true,
type: "free",
supports_private: supportsPrivate,
supports_custom_ports: true,
require_subdomain: true,
};
}
async function json(route: import("@playwright/test").Route, body: unknown) {
return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(body),
});
}
/**
* Opens the providers page for an account that has not been bootstrapped,
* with the given clusters on the domains endpoint. Returns the page plus a
* promise-friendly getter for the bootstrap POST body, which is captured and
* answered locally so the real backend is never mutated.
*/
async function openWizard(
browser: Browser,
domains: ReturnType<typeof freeDomain>[],
): Promise<{ page: Page; close: () => Promise<void> }> {
const context = await browser.newContext({
storageState: "e2e/fixtures/auth/owner.json",
});
try {
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 page.route("**/api/agent-network/settings", (route) => {
const method = route.request().method();
if (method === "GET") return json(route, UNBOOTSTRAPPED_DEFAULTS);
if (method === "POST") return json(route, BOOTSTRAPPED_SETTINGS);
return route.continue();
});
await page.route("**/api/reverse-proxies/domains", (route) =>
route.request().method() === "GET"
? json(route, domains)
: route.continue(),
);
// Keep the create hermetic: the wizard only reaches this once the
// bootstrap it does first has succeeded.
await page.route("**/api/agent-network/providers", (route) =>
route.request().method() === "POST"
? json(route, { id: "prov-e2e", name: "e2e-provider" })
: route.continue(),
);
await loginToApp(page, "owner");
await page.goto("/agent-network/providers");
await page.keyboard.press("Escape");
return { page, close: () => context.close() };
} catch (e) {
await context.close();
throw e;
}
}
/** Fills the provider tab with the minimum a create needs. */
async function fillProviderTab(page: Page) {
await page
.getByPlaceholder("https://api.openai.com")
.fill("https://api.openai.com");
await page.getByPlaceholder("sk-...").first().fill("sk-e2e-bootstrap-key");
}
test.describe("Agent Network bootstrap cluster @agent-network", () => {
test("bootstraps onto a private-capable cluster, skipping a centralised one listed first", async ({
browser,
}) => {
const { page, close } = await openWizard(browser, [
freeDomain(CENTRAL_CLUSTER, false),
freeDomain(PRIVATE_CLUSTER, true),
]);
try {
await page
.getByRole("button", { name: "Connect Provider" })
.first()
.click({ force: true });
// The unusable cluster is listed, but it is not a blocker: a usable one
// exists, so the wizard proceeds without the warning.
await expect(
page.getByTestId("agent-network-no-cluster-callout"),
).toHaveCount(0);
await fillProviderTab(page);
const bootstrap = page.waitForResponse(
(resp) =>
resp.url().includes("/agent-network/settings") &&
resp.request().method() === "POST",
{ timeout: 30_000 },
);
await page
.getByRole("button", { name: "Continue" })
.click({ force: true });
await page
.getByRole("button", { name: "Connect Provider" })
.last()
.click({ force: true });
const payload = (await bootstrap).request().postDataJSON();
expect(payload.proxy_address).toBe(PRIVATE_CLUSTER);
} finally {
await close();
}
});
test("blocks setup when no listed cluster has private capabilities", async ({
browser,
}) => {
const { page, close } = await openWizard(browser, [
freeDomain(CENTRAL_CLUSTER, false),
]);
try {
await page
.getByRole("button", { name: "Connect Provider" })
.first()
.click({ force: true });
const callout = page.getByTestId("agent-network-no-cluster-callout");
await expect(callout).toBeVisible({ timeout: 10_000 });
await expect(callout).toContainText("private capabilities");
// The fix lives in the agent network's own Clusters section, not the
// generic Reverse Proxy page, so that is where the callout sends users.
await expect(callout.getByRole("link")).toHaveAttribute(
"href",
"/agent-network/configuration?tab=clusters",
);
// With nothing to pin to there is no bootstrap to make, so the wizard
// cannot advance — rather than sending a request management refuses.
await fillProviderTab(page);
await expect(
page.getByRole("button", { name: "Continue" }),
).toBeDisabled();
} finally {
await close();
}
});
});
+81 -18
View File
@@ -3,9 +3,9 @@
*
* 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.
* pricing), connect it, then verify the Kimi-gated config surfaces on the
* Connect Agent page (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 —
@@ -19,20 +19,57 @@
* 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 { type Browser, expect, type Page,test } from "@playwright/test";
import {
createAgentNetworkPolicy,
createGroup,
deleteAgentNetworkPoliciesByPrefix,
deleteAgentNetworkProvidersByPrefix,
deleteGroup,
getCurrentUser,
listAgentNetworkCatalog,
listGroups,
supportsAgentNetworkAgentConfig,
supportsAgentNetworkSettingsBootstrap,
updateUserAutoGroups,
} from "../helpers/api";
import { loginToApp } from "../helpers/auth";
import { generateRandomName } from "../helpers/utils";
import {
deleteAgentNetworkProvidersByPrefix,
listAgentNetworkCatalog,
supportsAgentNetworkSettingsBootstrap,
} 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-";
/**
* Remove every fixture this spec creates: the policy granting the caller the
* provider, the group carrying that grant (detached from the caller first —
* a group referenced by a user's auto-groups refuses deletion), and the
* provider itself. Runs before the test too, so leftovers from an interrupted
* run never poison the next one.
*/
async function cleanupKimiFixtures(page: Page) {
await deleteAgentNetworkPoliciesByPrefix(page, PROVIDER_PREFIX);
const fixtureGroups = (await listGroups(page)).filter((g) =>
g.name.startsWith(PROVIDER_PREFIX),
);
if (fixtureGroups.length > 0) {
const fixtureGroupIds = new Set(fixtureGroups.map((g) => g.id));
const caller = await getCurrentUser(page);
if ((caller.auto_groups ?? []).some((id) => fixtureGroupIds.has(id))) {
await updateUserAutoGroups(
page,
caller,
(caller.auto_groups ?? []).filter((id) => !fixtureGroupIds.has(id)),
);
}
for (const g of fixtureGroups) {
await deleteGroup(page, g.id);
}
}
await deleteAgentNetworkProvidersByPrefix(page, PROVIDER_PREFIX);
}
async function newAgentNetworkPage(browser: Browser): Promise<{
page: Page;
close: () => Promise<void>;
@@ -71,8 +108,17 @@ test.describe.serial("Agent Network Kimi provider @agent-network", () => {
"management build has no POST /agent-network/settings, so the " +
"wizard cannot bootstrap the account before the first create",
);
// The Kimi-gated config surfaces live on the Connect Agent page, whose
// caller-scoped GET /agent-network/agent-config answer ships with
// newer management builds. The suite starts running once the backend
// under test carries it.
test.skip(
!(await supportsAgentNetworkAgentConfig(page)),
"management build has no GET /agent-network/agent-config, so the " +
"Connect Agent page cannot render the agent config",
);
await deleteAgentNetworkProvidersByPrefix(page, PROVIDER_PREFIX);
await cleanupKimiFixtures(page);
await page.goto("/agent-network/providers");
await page.keyboard.press("Escape");
@@ -153,17 +199,36 @@ test.describe.serial("Agent Network Kimi provider @agent-network", () => {
.click({ force: true });
const created = await Promise.race([createResponse, bootstrapRejected]);
expect([200, 201]).toContain(created.status());
const createdProvider = (await created.json()) as { id: string };
// 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 });
// The agent config lives inline on the Connect Agent page — the
// providers page keeps only the endpoint URL and Copy. That page's
// answer is caller-scoped: it offers only providers the caller's own
// policies authorize, so grant the caller the new provider through a
// dedicated group + policy (removed again by cleanupKimiFixtures).
const caller = await getCurrentUser(page);
const grantGroup = await createGroup(page, `${PROVIDER_PREFIX}grant`);
await updateUserAutoGroups(page, caller, [
...(caller.auto_groups ?? []),
grantGroup.id,
]);
await createAgentNetworkPolicy(page, {
name: `${PROVIDER_PREFIX}policy`,
source_groups: [grantGroup.id],
destination_provider_ids: [createdProvider.id],
});
// Kimi CLI tab only renders when a kimi_api provider is connected.
await expect(page.getByRole("tab", { name: "Kimi CLI" })).toBeVisible();
await page.goto("/agent-network/connect");
// Kimi CLI tab only renders when a kimi_api provider is connected. The
// longer timeout covers the page's caller-scoped agent-config fetch.
await expect(page.getByRole("tab", { name: "Kimi CLI" })).toBeVisible({
timeout: 15_000,
});
// Claude Code tab's backend dropdown offers (and, with Kimi as the only
// Anthropic-shaped provider, pre-selects) Kimi — its settings.json
@@ -192,10 +257,8 @@ test.describe.serial("Agent Network Kimi provider @agent-network", () => {
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);
await cleanupKimiFixtures(page);
} finally {
await close();
}
@@ -0,0 +1,195 @@
/**
* Agent Network provider save: what the operator sees when the backend refuses.
*
* Saving a provider checks its upstream URL and credential against the vendor
* before storing anything, so a 422 naming the field to correct is an ordinary
* outcome of the form rather than a server fault. Four things have to hold,
* and each one has been wrong at some point:
*
* - while the check runs the submit says so and refuses a second click. The
* vendor call is the slow part and an unreachable upstream drags it out to
* a timeout, which looked exactly like a button that had not registered
* the first click.
* - exactly ONE toast, the shared "Request failed with status code N", which
* quotes the API's own sentence. A second toast from the save path said the
* same thing under a vaguer title.
* - the toast is styled as a failure. notify() renders green with a check
* mark unless told otherwise, so a refusal announced itself as a success.
* - the modal stays open with the typed values intact. The API never returns
* an API key, so closing the form loses it with nowhere to retype it.
*
* The refusal is mocked rather than provoked: the backend check ships with a
* management build these tests do not pin, and what is under test here is the
* dashboard's handling of the response, not the vendor call that produces it.
*
* Like the other agent-network specs, this one builds its own context rather
* than taking the shared dashboardAsOwner fixture: the Agent Network menu is
* deployment-gated behind a localStorage override that has to be in place
* before the first navigation, which addInitScript on an own context is the
* way to do. The route interception below is a second reason — the shared page
* is worker-scoped, and a 422 left on it would follow every later test.
*/
import { type Browser, expect, type Page, test } from "@playwright/test";
import { loginToApp, navigateTo } from "../helpers/auth";
import { generateRandomName } from "../helpers/utils";
const AGENT_NETWORK_CONFIG_KEY = "netbird-test-agent-network";
const PROVIDERS_ENDPOINT = /\/api\/agent-network\/providers(\?|$)/;
const PROVIDER_PREFIX = "e2e-refused-";
const TITLE_TESTID = "notification-title";
// The message a refused save carries: the backend names which of the two
// fields is at fault, without a status code and without echoing the URL.
const REFUSAL = "the upstream url could not be reached: no such host";
// Matched case-insensitively on purpose. The backend lowercases its messages
// (WriteError does, and the copy is written for it) while the toast uppercases
// the first character before rendering. Asserting either spelling would pin
// the test to that transform rather than to the sentence the operator reads.
const REFUSAL_TEXT = new RegExp(
REFUSAL.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"),
"i",
);
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() };
}
// How long the mocked create takes to answer. The real check is a vendor round
// trip, so a save is never instant; holding the response gives the in-flight
// assertions a window to run in without racing the toast.
const REFUSAL_DELAY_MS = 1500;
// refuseProviderCreate answers the create with the 422 the credential check
// produces. Only POST is intercepted: the page still lists providers, and the
// settings bootstrap that may precede the create is left alone.
async function refuseProviderCreate(page: Page) {
await page.route(PROVIDERS_ENDPOINT, async (route) => {
if (route.request().method() !== "POST") return route.continue();
const origin = route.request().headers()["origin"] || "*";
await new Promise((resolve) => setTimeout(resolve, REFUSAL_DELAY_MS));
await route.fulfill({
status: 422,
headers: {
"content-type": "application/json",
"access-control-allow-origin": origin,
},
body: JSON.stringify({ code: 422, message: REFUSAL }),
});
});
}
test.describe
.serial("Agent Network refused provider save @agent-network", () => {
test("reports the refusal once and keeps the form open", async ({
browser,
}) => {
const { page, close } = await newAgentNetworkPage(browser);
try {
await refuseProviderCreate(page);
// navigateTo rather than goto: it dismisses the setup modal that greets a
// fresh account and clears the scroll lock it leaves behind, either of
// which swallows the clicks below.
await navigateTo(page, "/agent-network/providers");
await page
.getByRole("button", { name: "Connect Provider" })
.first()
.click({ force: true });
const providerName = generateRandomName(PROVIDER_PREFIX);
await page
.getByTestId("agent-network-provider-name")
.fill(providerName);
await page
.getByTestId("agent-network-provider-api-key")
.fill("sk-e2e-refused-key");
// The submit lives on the Models tab — the Provider tab's primary button
// only advances to it.
await page.getByRole("tab", { name: "Models" }).click({ force: true });
const submit = page.getByTestId("agent-network-provider-submit");
await expect(submit).toBeEnabled();
const refusal = page.waitForResponse(
(resp) =>
PROVIDERS_ENDPOINT.test(resp.url()) &&
resp.request().method() === "POST",
{ timeout: 30_000 },
);
await submit.click({ force: true });
// ---- the wait is visible while it lasts ----
// A second create is not idempotent, so the button has to say it is
// working and stop taking clicks rather than sit there looking untouched.
// Asserted before the response is awaited: the mock holds it open for
// exactly this window.
await expect(submit).toContainText("Connecting provider");
await expect(submit).toBeDisabled();
expect((await refusal).status()).toBe(422);
// ---- the toast says what the API said ----
const title = page.getByTestId(TITLE_TESTID).first();
await expect(title).toContainText("Request failed with status code 422");
await expect(
page.locator("[data-toast-notification]").first(),
).toContainText(REFUSAL_TEXT);
// ---- and only that toast ----
// The save path used to add its own on top, so the count is the
// assertion rather than the presence of the right one.
await expect(page.locator("[data-toast-notification]")).toHaveCount(1);
await expect(page.getByText("Failed to connect provider")).toHaveCount(0);
// ---- styled as a failure, not a success ----
// notify() paints the icon tile green with a check unless the caller
// says otherwise, which is how a refusal once looked like a success.
//
// A failure reaches the tile two ways — notify()'s own error state, and
// a caller passing its own colour and icon, which is what the shared
// request-failed toast does — so what has to hold is the negative: this
// is not the default success tile.
const icon = page.getByTestId("notification-icon");
await expect(icon).toBeVisible();
await expect(icon).not.toHaveAttribute("data-variant", "success");
// ---- the form is still there, still holding what was typed ----
// The submit only exists while the modal is open, so its presence is the
// check that nothing closed underneath the toast. It also has to come
// back out of the in-flight state, or the retry the toast asks for is
// impossible.
await expect(submit).toBeVisible();
await expect(submit).toBeEnabled();
await expect(submit).toContainText("Connect Provider");
await page.getByRole("tab", { name: "Provider" }).click({ force: true });
await expect(
page.getByTestId("agent-network-provider-name"),
).toHaveValue(providerName);
// The key matters most: the API never returns one, so a form that lost
// it leaves the operator with nothing to correct.
await expect(
page.getByTestId("agent-network-provider-api-key"),
).toHaveValue("sk-e2e-refused-key");
} finally {
await close();
}
});
});
@@ -364,13 +364,17 @@ test.describe.serial("Control Center Draft Matrix @control-center", () => {
}) => {
const group = await place(page, "group", 0.6, 0.5);
await expectChangeCount(page, 1);
// React Flow ignores delete keys while an input is focused, and the panel's
// search takes focus on click, so blur it before pressing Backspace.
await group.click();
await expect(group).toHaveClass(/selected/);
await page.evaluate(
() => (document.activeElement as HTMLElement | null)?.blur(),
);
// React Flow ignores delete keys while an input is focused, and the panel
// the click opens takes focus into its search — from an effect, about
// 100ms later. Blurring before that lands leaves the input to claim focus
// afterwards and swallow the Backspace, so wait for the focus, then drop
// it.
const panelSearch = page.locator("#cc-group-panel input").first();
await expect(panelSearch).toBeFocused();
await panelSearch.blur();
await expect(panelSearch).not.toBeFocused();
await page.keyboard.press("Backspace");
await expect(canvasNode(page, "group-new-")).toHaveCount(0);
await expectChangeCount(page, 0);
@@ -19,6 +19,7 @@ import {
dismissBlockingOverlays,
enterDraft,
openControlCenter,
openGroupView,
resetDraftState,
reviewButton,
} from "../helpers/control-center";
@@ -37,7 +38,6 @@ test.describe
await deleteGroupsByPrefix(page, PREFIX);
});
// Clean-slated so the draft group view's auto-select is deterministic.
async function seedPolicyGroupView(page: Page) {
await deletePoliciesBySubstring(page, PREFIX);
await deleteGroupsByPrefix(page, PREFIX);
@@ -49,7 +49,9 @@ test.describe
src.id,
dst.id,
);
await openControlCenter(page, "groups");
// Selected by name: the view's own pick ranks every group in the account,
// so a peer-bearing group from another spec on the other worker wins it.
await openGroupView(page, src.name);
await enterDraft(page);
const policyNode = canvasNode(page, `policy-${policy.id}`);
await expect(policyNode).toBeVisible({ timeout: 15_000 });
Loaded 20 of 118 files, more files were not shown because too many files have changed in this diff. Show more