Compare commits

..
40 Commits
Author SHA1 Message Date
Misha Bragin 9e1c29b769 fix(version): ignore build metadata when detecting available updates (#759) 2026-08-14 14:29:42 +02:00
Eduard GertandClaude Opus 5 fb05f2c172 chore(docker): upgrade base image to Alpine 3.24 (#756)
Alpine 3.14 reached end of life in May 2023 and has received no security
fixes for roughly three years. nginx is installed from the Alpine repos
rather than pinned, so the base bump also moves nginx 1.20.2 -> 1.30.4,
certbot 1.16.0 -> 5.6.0 and supervisor 4.2.2 -> 4.3.0.

Alpine 3.24 carries security support until June 2028.

Verified by building and running this Dockerfile on 3.14, 3.23 and 3.24 and
comparing: the served index.html is byte-identical across all three, the CSP
response header hashes identically, `nginx -t` passes on the existing config,
and supervisord reaches the same state. /etc/crontabs/root and
/etc/nginx/http.d/ still exist on 3.24, so the `rm /etc/crontabs/root` step and
the config COPY paths are unaffected. Every certbot flag used by init_cert.sh
is still accepted by certbot 5.x.

Certificate issuance itself could not be exercised without a real domain, so
the certbot 1.x -> 5.x jump should get one staging issuance before rollout.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 11:12:03 +02:00
Eduard GertandClaude Opus 5 6cf828ce8a Upgrade Next.js to 16.3 (#755)
* chore: upgrade to Next.js 16.3

Turbopack is now the default for both `next dev` and `next build`, so the
separate `turbo` script is redundant and has been dropped.

`next build` rewrites tsconfig `moduleResolution` to "bundler", which Next 16
expects.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore: migrate ESLint to flat config

`next lint` was removed in Next 16, so `npm run lint` had been failing with
"Invalid project directory provided, no such directory: ./lint" since that
upgrade. eslint-config-next 16 also ships flat config only, which the old
.eslintrc.json could not consume.

Ignores .claude/ so stray worktree copies of the repo are not linted.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: replace ip-cidr with ip-address to clear CVE advisories

ip-cidr pulled a vulnerable ip-address (GHSA-mwp4-54f8-5fhr, high: leading-zero
octets decoded as decimal while resolvers read them as octal, enabling SSRF;
GHSA-v2v4-37r5-5v8g, moderate: XSS in Address6 HTML methods). npm reported "no
fix available" because even the latest ip-cidr pins ip-address ^9.0.5, still
inside the vulnerable range.

ip-address 10.x was already a direct dependency and covers every use: its
isValid rejects leading-zero octets and validates mask ranges natively, and
isInSubnet replaces the one `new cidr().contains()` call site.

The new validators were differential-tested against the real ip-cidr over a
67-input corpus (134 checks) before removal, with no divergences; containment
was checked across 14 v4/v6/boundary/cross-family cases. `npm audit` now
reports 0 vulnerabilities.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* refactor: replace next-themes with an in-repo ThemeProvider

next-themes renders an inline <script> to set the theme before paint. It was
loaded through dynamic(ssr: false), so that script rendered on the client, where
React 19 never executes script tags and warns:

  Encountered a script tag while rendering React component.

Upgrading would not have helped; 0.4.6 still renders the script
unconditionally. The script was also useless here: injected after hydration, it
could not prevent a flash, and postbuild.js hoists inline scripts out of the
export anyway.

The replacement keeps the same `netbird-theme` storage key and three-mode
support, and sets the theme class on <html> in the layout instead, so the
static export ships the correct theme in its first byte with no script at all.

Dropping the dynamic(ssr: false) wrapper removed an implicit prerender bailout
for the whole provider tree, which exposed isNetBirdCloud() reading
window.location during the build-time render; it is now guarded. The exported
HTML is byte-identical either way, since the page already bails to client
rendering.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: key the router children slot in the dashboard layout

React logged "Each child in a list should have a unique key prop. Check the
render method of DashboardPageContent." The App Router's segment element for
`children` arrives with a null key, which React flags once it is reconciled
alongside the Navigation sibling. Wrapping it in a keyed Fragment resolves it.

Verified in the browser both ways: with the Fragment the console is clean,
without it the warning returns.

Also applies the pending simple-import-sort fix for this file.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: match IPv4-mapped IPv6 hosts against IPv4 CIDRs

Regression from replacing ip-cidr. Its contains() normalised an IPv4-mapped
IPv6 address to the embedded v4 before the subnet check, so
contains("::ffff:10.0.0.5") on 10.0.0.0/24 returned true; isHostInCIDR returned
false. Verified against ip-cidr@3.1.0 directly.

This is reachable: isValidIP accepts "::ffff:10.0.0.5", so a mapped host entered
in ReverseProxyAddressInput against an IPv4 resource CIDR was wrongly rejected.

An IPv6 network still rejects a mapped host, so families do not leak. Checked
across 20 cases (6 mapped-host, 14 pre-existing), with the mapped expectations
taken from measured ip-cidr output.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: derive skeleton colors from the resolved theme

SkeletonTheme had the dark palette hardcoded, so a light theme would render
dark skeletons. The light values are the ones already defined on the
feature-light-theme branch rather than new invented colors.

resolvedTheme is tracked in state and updated from the existing apply(), so the
palette also follows the OS preference under "system".

Latent today, since DarkModeToggle disables both Light and System.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 11:11:48 +02:00
Eduard GertandClaude Opus 5 83cb07a44c Fix icon alignment in collapsed navigation and network tab Add buttons (#748)
* fix: center icons in collapsed navigation rail

Nav items have 24px of left padding (px-3 on the li plus px-3 on the
button), so with 16px icons the icon center lands at 32px. The 70px rail
centered at 35px, leaving icons 3px off. Narrow the rail to 64px so the
two line up, and match the content offset in PageContainer.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: use one plus icon across network tab Add buttons

The Resources and Routing Peers Add buttons used tabler's IconCirclePlus
while the Services tab used lucide's PlusCircle. Both were size 16, but
the two icon sets draw at different inner scales, so the glyphs read as
different sizes. Standardize on lucide.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 11:11:33 +02:00
Viktor Liu bbfa2d3a79 Bump default wasm client to v0.76.3 (#754) 2026-08-11 17:17:00 +02:00
Brad Ison a92816b9b8 Bootstrap Agent Network settings via the explicit POST (#746)
Adopts the new Agent Network settings contract:

- The management API replaced the implicit settings bootstrap — a
  `bootstrap_cluster` hint on the first provider create — with an
  explicit POST /agent-network/settings carrying the proxy cluster
  address. The settings response now carries endpoint +
  proxy_address + dedicated instead of cluster + subdomain.

- The provider wizard keeps its UX (auto-picking a validated cluster,
  gating the first create on having one) but now bootstraps the
  settings row with the explicit POST right before the first provider
  create. A bootstrap failure keeps the wizard open and surfaces the
  error — previously a failed bootstrap was swallowed server-side and
  the account silently ended up with a provider but no endpoint. A 409
  (concurrent bootstrap won) is treated as success, since the row
  exists either way.

- Types updated accordingly; bootstrapAgentNetworkSettings is exposed
  on the providers context. The onboarding provider step reuses the
  same wizard, so it is covered by the same change.

Without this, the dashboard against a new management silently stops
bootstrapping accounts: provider create succeeds (the old hint is an
ignored unknown field), but no endpoint is ever assigned.
2026-08-11 11:32:27 +02:00
Bethuel Mmbaga 0caf754897 Update Entra ID SCIM setup wizard for the new Azure portal UI (#750) 2026-08-10 11:12:40 +02:00
Misha Bragin 49582fff74 Trim numeric build suffix from sidebar version display. (#749)
Self-hosted management versions can carry a numeric build suffix
  (e.g. 0.76.3-31256681241) that overflows the sidebar box. Show the
  semver only and move the full string into the hover tooltip alongside
  the latest version. Pre-release labels like -rc.1 are preserved.
2026-08-09 19:20:49 +02:00
Jack Carter af7745a03b chore: remove solutions engineer booking modal from onboarding (#745)
The onboarding survey opened a HubSpot booking modal for accounts with
50+ planned users. Removes the modal, its onboarding-call experiment
variants, the trigger in Onboarding.tsx, and the now-unused avatar.

Keeps src/cloud/cloud-hooks/useExperiment.ts, which this was the only
consumer of, for a future experiment.
2026-08-06 10:48:31 +02:00
Maycon Santos 19d82cc2ab Handle agent-network settings defaults response (#739)
* Handle agent-network settings defaults response

The management API now answers the settings GET with a defaults object
before the account is bootstrapped (empty cluster/subdomain/endpoint)
instead of a JSON null body. Normalize that shape to the existing null
settings signal so every consumer keeps a single not-bootstrapped check,
while still tolerating the null body and 404 from older backends.

* Pin the settings wire-shape normalization in a Playwright spec

The providers page must render the connect-first empty state for every
unbootstrapped settings shape the management API has answered with over
time (defaults object with an empty endpoint, JSON null body, 404) and
the endpoint badge once bootstrapped. The settings route is mocked per
test so the spec is independent of the backend build and of account
state left behind by other suites.

* Address review: no stale settings on 404, close context on setup failure

useFetchApi keeps the previous SWR data alongside the error, so a later
404 could expose stale settings through the memo; it now yields null
when the error is a 404 while still riding out transient failures on
the kept data. The wire-shapes spec closes its browser context when a
setup step throws before the close callback is handed to the test.
2026-08-04 01:59:34 +02:00
Misha Bragin b66cc4ccb7 Cache pricing + custom model entry (#736) 2026-08-01 15:56:24 +02:00
Zoltan Papp 1d408f2e7e Add openSUSE zypper instructions with GPG key import to the Linux tab (#735)
Zypper ignores the gpgkey= entry of a .repo file, so the yum snippet would
leave the repository key uninstalled on openSUSE. The new distro entry adds
the repository with GPG checking enabled and imports the key on refresh.
2026-07-28 17:50:09 +02:00
Maycon Santos 9fab9d7a83 Cover RPM-based distros and desktop app dependencies in the Linux tab (#733)
* Cover RPM-based distros and desktop app dependencies in the Linux tab

The manual-install section of the add-peer modal only covered Ubuntu via
APT. Replace the single Ubuntu accordion with a distro selector covering
Debian/Ubuntu (APT), Fedora (DNF), RHEL/AlmaLinux/Rocky (DNF), and Amazon
Linux (YUM).

The desktop app links GTK 4.10+ and WebKitGTK 6.0 but the released
netbird-ui packages do not declare them, so each desktop-app install line
names the packages explicitly and a callout states the minimum release
(Ubuntu 24.04, Debian 13, Fedora 43, RHEL 10 with EPEL). Distros without
the required stack get CLI-only instructions.

* Add e2e coverage for the Linux distro selector
2026-07-28 03:03:46 +09:00
Maycon Santosandbraginini 6ca91287fc Show agent-network prompt-cache tokens and cost on hover (#731)
Co-authored-by: braginini <bangvalo@gmail.com>
2026-07-26 21:43:33 +02:00
Misha Bragin 5a5e04ad35 Add 0.75 announcement (#727) 2026-07-23 21:05:52 +02:00
Maycon Santos 598ecb196d remove rc releases logic for agent networks (#725) 2026-07-23 20:29:09 +02:00
Maycon Santosandbraginini 7c8352822d Add Kimi (Moonshot AI) provider support to Agent Network UI (#724)
* Add Kimi (Moonshot AI) provider support to Agent Network UI

Pairs with the management catalog's new kimi_api entry:

- kimi_api in the AIProviderId union and the access-log provider-id
  normalization list.
- Provider modal: api.moonshot.ai host placeholder, upstream-URL help
  explaining the OpenAI-shape (/v1) vs Anthropic-shape (/anthropic)
  path choice, sk-... key placeholder.
- Configure Your Agent: Kimi backend option in the Claude Code tab
  (JSON + shell modes), a Kimi variant in the Codex tab using
  wire_api="chat", and a new Kimi CLI tab with the ~/.kimi/config.toml
  provider setup. All Kimi surfaces render only when a kimi_api
  provider is connected, and pre-select themselves when Kimi is the
  only provider of its shape.
- Onboarding defaults to the Claude Code tab for Kimi providers.

* Add Kimi provider e2e spec

Walks the Kimi lifecycle against the real backend: select kimi_api from
the catalog (prefilled host, catalog model pricing), connect it, then
assert the Kimi-gated Configure Your Agent surfaces (Kimi CLI tab,
Claude Code kimi-k3 config, Codex wire_api=chat variant) and clean up
via new agent-network API helpers. Skips when the management build
under test has no kimi_api catalog entry, so it can merge ahead of the
backend and activate once the management-cloud image carries it.

* Remove Codex option for Kimi and align Claude Code config with Moonshot guide

Codex has no clear support against Moonshot's upstream, so the Kimi
variant is dropped from the Codex tab. The Claude Code Kimi snippet now
pins all default model tiers and the subagent model to kimi-k3 and
disables tool search, per Moonshot's Claude Code guide.

* Carry the /anthropic shape prefix in the agent base URL for Kimi

The Kimi provider keeps its default https://api.moonshot.ai upstream;
Claude Code and Kimi CLI select the Anthropic shape by appending
/anthropic to the endpoint base URL, which rides through to Moonshot.
One provider now serves both API shapes.

* Kimi CLI uses the bare endpoint base URL

Only Claude Code carries the /anthropic prefix; Kimi CLI's anthropic
provider type points at the bare endpoint.

* Kimi tooltip

* Make the Claude Code provider dropdown width flexible and tidy Kimi notes

* Make the Claude Code provider dropdown width flexible and tidy Kimi notes

* Restored the note on the Kimi CLI tab

---------

Co-authored-by: braginini <bangvalo@gmail.com>
2026-07-23 19:24:41 +02:00
Maycon Santos e4f5046ff2 [agent-network] per-provider disable-metadata toggle (#715) 2026-07-20 14:46:17 +02:00
Eduard GertandClaude Opus 4.8 d5f61efc1a Fix network resource policy creation with single peer source (#721)
When creating a network resource and adding a policy with a single peer
(sourceResource) as the source, the payload included both an empty
`sources: []` array and `sourceResource`, causing the backend to reject
it with "specify either sources or source resources not both".

`createPolicyForResource` always computed `sources` as an array while the
`...rule` spread carried `sourceResource` through. This mirrors the
existing destinations handling: gate `sources` on `rule.sourceResource`
so it becomes undefined (and is dropped during serialization) when a
source resource is set.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 11:16:36 +02:00
Maycon Santos 3503141c56 Adjust license cache ttl to 10 minutes (#718) 2026-07-20 11:03:14 +02:00
Misha Bragin a27e4654cf Fix IdP cards spacing (#717) 2026-07-16 14:41:59 +02:00
Misha Bragin b9569f48d5 Add clusters view to agent network (#714) 2026-07-15 23:22:05 +02:00
Viktor Liu 9694bb753d Show RDP for Linux and FreeBSD peers and harden the browser RDP client (#710) 2026-07-15 12:29:54 +02:00
Maycon Santos 377d239a01 Make Agent Network menu available via dashboard_features (#709)
* Make Agent Network menu available via dashboard_features.agent_network

Wire the new dashboard_features account setting into the dashboard at the API
level: add it to the Account settings interface (so settings updates round-trip
it) and have useAgentNetworkMode treat dashboard_features.agent_network === true
as enabling the Agent Network menu alongside the full dashboard. Unlike
agent_network_only it does not focus the dashboard, and it does not trigger the
onboarding flow. No settings-menu UI yet; the field is set via the API.

* Add e2e coverage for dashboard_features.agent_network menu availability

* Set dashboard_features for existing netbird.ai accounts

The signup-source flow only handled new accounts (agent_network_only) and
discarded the source for existing accounts, so nothing set dashboard_features.
Branch on account existence: new accounts get the focused view, existing
accounts get the Agent Network menu made available via
dashboard_features.agent_network. Adds e2e coverage for the existing-account
write.

* Also enable Agent Network menu for new netbird.ai accounts

New accounts got agent_network_only (focused) but not
dashboard_features.agent_network, so turning the focused view off later would
remove the Agent Network menu entirely. Set both on new accounts so disabling
focus degrades to menu-available rather than losing access.

* Match badged nav labels by substring in dashboard_features e2e

* Treat agent network deployment config as a floor over account setting

* Add agent network env x signup-source e2e matrix with a config override
2026-07-13 17:17:36 +02:00
Maycon Santos a266fff89d Focus dashboard on Agent Network for netbird.ai signups (#708)
Accounts that sign up through netbird.ai are focused on the Agent Network
  surface and taken through the Agent Network onboarding. Reversible by the user
  and a no-op for every other account.
  
  - Capture ?signup_source=netbird.ai pre-auth (survives the OIDC redirect); once
    authenticated, mark new accounts with settings.agent_network_only, awaiting
    /accounts revalidation before clearing the source so the view never flips.
  - useAgentNetworkMode() resolves the mode with nullish semantics: an explicit
    account setting (true or false) wins over deployment config; when absent, a
    pending netbird.ai signup or NETBIRD_AGENT_NETWORK_ONLY/_ENABLED decides. 
    Purely additive — unchanged behavior when the setting is absent. 
  - Navigation, the agent-network route guard, control-center, and fetch gates
    read the resolver instead of the config flags.
  - Onboarding: the form is chosen from signup_source synchronously, so netbird.ai
    arrivals see the Agent Network signup form (now shown on cloud) and never the
    regular form, even under a slow backend.
  - Client Settings gains an "Agent Network focused view" opt-out toggle.
  - E2E coverage for focused-view menu/routes and onboarding form selection.
2026-07-12 21:16:05 +02:00
Maycon Santos 8ed69f21c5 [reverse proxy] Cluster one click deploy (#707) 2026-07-12 15:05:02 +02:00
Eduard Gert 58d1035b16 Remove experimental flag from lazy connections (#706) 2026-07-10 14:29:18 +02:00
Pascal Fischer 991234785c new traffic filters (#704) 2026-07-09 14:26:40 +02:00
Eduard Gert f2ad889fdc Revert using tenants own currency and instead use MSP currency (#703) 2026-07-08 10:47:17 +02:00
Jack CarterandEduard Gert 1f219c4ffc Default billing to EUR, preserve USD for active subscribers (#702)
* feat: default billing to EUR only, preserve USD for active subscribers

Removes the USD/EUR currency selector from Plans & Billing. New
subscriptions, MSP tenants, and distributor customers now default to
EUR; any existing active USD subscription keeps its real currency so
its price stays accurate.

Also fixes a latent bug where MSP/distributor per-tenant pricing was
computed off the admin's own account currency instead of each
tenant's, pins AWS Marketplace checkout to USD since it's a separate
billing rail, and removes an unused dead TotalCost stub from the MSP
Tenants table.

* move currency helper into provider

* force usd for aws

* fix flaky setup key test

---------

Co-authored-by: Eduard Gert <kontakt@eduardgert.de>
2026-07-07 16:45:31 +02:00
Viktor Liu 87b3a08adc Surface readable RDP errors, keep client connected on retry, bump wasm to v0.74.2 (#701) 2026-07-07 14:11:59 +02:00
Maycon SantosandClaude Opus 4.8 cfd0037cef Show management API request id in error notifications (#698)
* Show management API request id in error notifications

Newer management servers return an X-Request-Id header on every response,
including errors. Surface it in the error toast so it can be quoted in
support tickets.

- api.tsx: read X-Request-Id from the response and attach it to the
  rejected ErrorResponse (both the JSON-body and status-text paths).
- Notification.tsx: render a copyable "Request ID" line whenever an error
  carries one, covering the promise-based mutation notifications.
- ErrorBoundary.tsx: thread the request id through for GET/fetch errors.

The line is only shown when the header is present, so older servers that
do not send it are unaffected. Adds an e2e spec covering both cases.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016B2yLBeDK3kvX2FvhqcWk8

* docs: add request-id error toast screenshots for PR

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016B2yLBeDK3kvX2FvhqcWk8

* test: expose X-Request-Id via CORS in e2e mock

The dashboard reads the request id from a cross-origin response, so the
browser only surfaces the header to JS when the response lists it in
Access-Control-Expose-Headers. Mirror a CORS-enabled management server in
the mock so the test exercises the dashboard logic rather than the header
being filtered out cross-origin.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016B2yLBeDK3kvX2FvhqcWk8

* test: assert error toast via data-testid instead of text

Add a notification-title testid to the toast and target it with
getByTestId in the request-id spec, following the repo convention of
selecting by data-testid rather than brittle visible text.

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 14:02:16 +02:00
Pascal Fischer ac7d578bd7 aggregated traffic events (#693) 2026-07-07 11:36:25 +02:00
Misha Bragin 8d41de995c Fix Skip TLS doc link and model pricing format (#699) 2026-07-03 07:03:42 +02:00
Misha Bragin 30dd75d95d Feature/add skip tls verification (#697) 2026-07-01 22:25:05 +02:00
Zoltan Papp bfa064a19d Add Metrics settings tab with metrics push toggle (#613)
* Add Metrics settings tab with metrics push toggle

Add a dedicated Metrics tab to Settings with a toggle for enabling
anonymous performance metrics collection. Moves metrics out of the
Clients tab into its own tab with description and docs link.

* Update docs link
2026-07-01 19:00:00 +02:00
Misha Bragin db73d18370 Feature/add provider session (#695) 2026-07-01 16:32:44 +02:00
Misha Bragin ba32178425 Fix peer table page reset when switching kind (#696) 2026-07-01 16:09:15 +02:00
4ea4fd4a4c Reload nginx after patching CSP header (#687)
* fix(docker): reload nginx after patching CSP header

nginx is started by supervisord (priority 100) before init_react_envs.sh
(priority 201) and is never reloaded afterwards, so it keeps serving the
static default.conf CSP that has no connect-src. Over plain HTTP this blocks
OIDC discovery and the management API, breaking browser SSH/RDP auth.

Reload nginx at the end of the init script so the dynamically built CSP
header takes effect.

* Update docker/init_react_envs.sh

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* fix(docker): log nginx -t output on failed CSP reload

When the reload never succeeds, run nginx -t before exiting so the logs show
whether the patched config is invalid or nginx never came up.

* conditionally emit `upgrade-insecure-requests` in CSP

---------

Co-authored-by: Viktor Liu <17948409+lixmal@users.noreply.github.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Co-authored-by: mlsmaycon <mlsmaycon@gmail.com>
2026-07-01 15:32:22 +02:00
Bethuel Mmbaga deaae9159c Show reason for peer login expiration events (#694) 2026-07-01 12:32:11 +03:00
123 changed files with 8068 additions and 2317 deletions

No files matched your search

-13
View File
@@ -1,13 +0,0 @@
{
"extends": ["next/core-web-vitals","prettier"],
"plugins": ["simple-import-sort"],
"rules": {
"simple-import-sort/imports": [
"warn",
{
"groups": [["^\\u0000", "^@?\\w", "^[^.]", "^\\."]]
}
],
"simple-import-sort/exports": "warn"
}
}
+3 -3
View File
@@ -39,7 +39,7 @@ jobs:
with:
token: ${{ secrets.GITHUB_TOKEN }}
repository: netbirdio/IronRDP
latest: true
tag: v0.0.2
fileName: "*.ts"
out-file-path: 'public/ironrdp-pkg'
@@ -48,7 +48,7 @@ jobs:
with:
token: ${{ secrets.GITHUB_TOKEN }}
repository: netbirdio/IronRDP
latest: true
tag: v0.0.2
fileName: "*.js"
out-file-path: 'public/ironrdp-pkg'
@@ -57,7 +57,7 @@ jobs:
with:
token: ${{ secrets.GITHUB_TOKEN }}
repository: netbirdio/IronRDP
latest: true
tag: v0.0.2
fileName: "ironrdp_web_bg.wasm"
out-file-path: 'public/ironrdp-pkg'
@@ -0,0 +1,34 @@
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
@@ -0,0 +1,78 @@
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": "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",
"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",
"linkText": "Learn more",
"variant": "important",
"isExternal": true,
+1 -1
View File
@@ -1,4 +1,4 @@
FROM alpine:3.14
FROM alpine:3.24
RUN apk add --no-cache bash curl less ca-certificates git tzdata zip gettext \
nginx curl supervisor certbot-nginx && \
+34 -3
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"
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_SCRIPT_SRC="'sha256-7knV6EIjKUvCpYWE2rCYx8dYV2WCNb2bpTuitFXzBcA=' *.hs-scripts.com"
CSP_DOMAINS=""
@@ -141,8 +141,18 @@ CSP_CONNECT_SRC=$(echo $CSP_CONNECT_SRC | tr ' ' '\n' | sort -u | tr '\n' ' ' |
CSP_FRAME_SRC=$(echo $CSP_FRAME_SRC | tr ' ' '\n' | sort -u | tr '\n' ' ' | sed 's/ $//')
CSP_SCRIPT_SRC=$(echo $CSP_SCRIPT_SRC | tr ' ' '\n' | sort -u | tr '\n' ' ' | sed 's/ $//')
# upgrade-insecure-requests tells the browser to rewrite every http subresource,
# fetch and WebSocket to https/wss. That is correct for https deployments but breaks
# plain-http ones (e.g. local or self-hosted over http), where it would rewrite the
# working http/ws endpoints to unreachable https/wss. Emit it only when the backend
# is served over https, matching the ws:// vs wss:// scheme logic above.
CSP_UPGRADE_INSECURE=" upgrade-insecure-requests;"
if [[ "$NETBIRD_MGMT_API_ENDPOINT" == http://* || "$AUTH_AUTHORITY" == http://* ]]; then
CSP_UPGRADE_INSECURE=""
fi
# Update CSP in nginx config
CSP_POLICY="default-src 'none'; connect-src 'self' $CSP_CONNECT_SRC; frame-src 'self' $CSP_FRAME_SRC; script-src 'self' 'wasm-unsafe-eval' $CSP_SCRIPT_SRC; font-src 'self'; img-src * data:; manifest-src 'self'; style-src 'self' 'unsafe-inline'; frame-ancestors 'self'; base-uri 'self'; form-action 'self'; upgrade-insecure-requests;"
CSP_POLICY="default-src 'none'; connect-src 'self' $CSP_CONNECT_SRC; frame-src 'self' $CSP_FRAME_SRC; script-src 'self' 'wasm-unsafe-eval' $CSP_SCRIPT_SRC; font-src 'self'; img-src * data:; manifest-src 'self'; style-src 'self' 'unsafe-inline'; frame-ancestors 'self'; base-uri 'self'; form-action 'self';$CSP_UPGRADE_INSECURE"
CSP_HEADER="add_header Content-Security-Policy \"$CSP_POLICY\" always;"
echo "CSP header: $CSP_HEADER"
@@ -161,4 +171,25 @@ for f in $(grep -R -l AUTH_SUPPORTED_SCOPES /usr/share/nginx/html); do
cp "$f" "$f".copy
envsubst "$ENV_STR" < "$f".copy > "$f"
rm "$f".copy
done
done
# 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
# CSP that has no connect-src, breaking OIDC discovery over plain HTTP.
reloaded=false
for i in $(seq 1 10); do
if nginx -s reload 2>/dev/null; then
echo "Reloaded nginx to apply updated CSP configuration"
reloaded=true
break
fi
echo "Waiting for nginx to be ready before reload (attempt $i)..."
sleep 1
done
if [[ "$reloaded" != true ]]; then
echo "Failed to reload nginx after patching CSP header" >&2
nginx -t || true
exit 1
fi
Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

+75
View File
@@ -433,3 +433,78 @@ 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);
}
}
}
/**
* Whether the management build under test bootstraps the Agent Network
* settings row through the explicit POST /agent-network/settings.
*
* Builds that predate it seed the row as a side effect of the first provider
* create and describe the endpoint identity as cluster + subdomain; builds
* that have it expose proxy_address instead. The settings GET carries the
* field either way — including before the account is bootstrapped, where it
* answers with the defaults and an empty proxy_address — so the shape of that
* response is a side-effect-free probe for the endpoint's existence.
*/
export async function supportsAgentNetworkSettingsBootstrap(
page: Page,
): Promise<boolean> {
const { token, origin } = await getApiContext(page);
const resp = await page.request.get(`${origin}/api/agent-network/settings`, {
headers: { Authorization: `Bearer ${token}` },
});
if (!resp.ok()) return false;
const settings = await resp.json().catch(() => null);
return (
!!settings && typeof settings === "object" && "proxy_address" in settings
);
}
+17 -4
View File
@@ -20,7 +20,11 @@ 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") {
export async function loginToApp(
page: Page,
user: TestUser = "owner",
opts: { expectOnboarding?: boolean } = {},
) {
const { username, password } = credentials[user];
await page.goto("/");
@@ -39,7 +43,9 @@ export async function loginToApp(page: Page, user: TestUser = "owner") {
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),
@@ -51,6 +57,9 @@ export async function loginToApp(page: Page, user: TestUser = "owner") {
}
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;
@@ -89,8 +98,12 @@ export async function loginToApp(page: Page, user: TestUser = "owner") {
onboarding.waitFor({ timeout: 15_000 }),
]);
// Dismiss setup modal if present
if (await setupModal.isVisible().catch(() => false)) {
// 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))
) {
await setupModal.getByTestId("modal-close").click();
await expect(setupModal).not.toBeVisible();
}
@@ -0,0 +1,213 @@
/**
* 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();
}
});
});
@@ -0,0 +1,363 @@
/**
* 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();
}
});
});
@@ -0,0 +1,203 @@
/**
* 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 same applies
* to the explicit settings bootstrap: connecting the first provider of an
* account POSTs /agent-network/settings first, so on a build without that
* endpoint the wizard cannot get as far as creating a provider.
*
* 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,
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-";
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`,
);
test.skip(
!(await supportsAgentNetworkSettingsBootstrap(page)),
"management build has no POST /agent-network/settings, so the " +
"wizard cannot bootstrap the account before the first create",
);
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 },
);
// On the account's first provider the wizard bootstraps the settings row
// first and only creates the provider once that succeeds. A rejected
// bootstrap therefore means no provider POST ever happens, which on its
// own surfaces only as the whole test timing out — race it so the real
// status is reported instead. 409 is not a rejection: it means a
// concurrent bootstrap won and the row exists, which the wizard treats as
// success and follows with the provider create, so matching it here would
// fail a run that is about to succeed.
const bootstrapRejected: Promise<never> = page
.waitForResponse(
(resp) =>
resp.url().includes("/agent-network/settings") &&
resp.request().method() === "POST" &&
resp.status() !== 409 &&
!resp.ok(),
{ timeout: 30_000 },
)
.then(
(resp) => {
throw new Error(
`Agent Network settings bootstrap POST returned ${resp.status()}; ` +
"the provider was never created",
);
},
// Nothing matched: the bootstrap succeeded or wasn't needed. Never
// settle, leaving the race to the provider create.
() => new Promise<never>(() => {}),
);
await page
.getByRole("button", { name: "Connect Provider" })
.last()
.click({ force: true });
const created = await Promise.race([createResponse, bootstrapRejected]);
expect([200, 201]).toContain(created.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,145 @@
/**
* Agent Network settings wire-shape spec.
*
* The management API signals "account not bootstrapped" differently by
* generation: current servers answer GET /agent-network/settings with the
* defaults object carrying an empty endpoint/proxy_address, older ones with
* 200 + a JSON null body, and the oldest with a 404.
* useAgentNetworkSettings normalizes all three to the same null-settings
* signal, and this spec pins that: the providers page must render the
* connect-first endpoint placeholder for every unbootstrapped shape, and the
* endpoint badge once the account is bootstrapped. The settings route is
* mocked per test, so the assertions do not depend on the backend build or
* on account state left behind by other suites.
*/
import { test, expect, type Browser, type Page } from "@playwright/test";
import { loginToApp } from "../helpers/auth";
const AGENT_NETWORK_CONFIG_KEY = "netbird-test-agent-network";
const EMPTY_STATE_TEXT =
"Connect your first provider to set up your agent network endpoint.";
const BOOTSTRAPPED_ENDPOINT = "violet.eu.proxy.netbird.io";
// The defaults object current servers return for an account that has not been
// bootstrapped yet: the mutable settings carry their default values, the
// endpoint/proxy_address identity is empty, and there are no timestamps because
// no row has been persisted.
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 = {
endpoint: BOOTSTRAPPED_ENDPOINT,
proxy_address: "eu.proxy.netbird.io",
dedicated: false,
enable_log_collection: true,
enable_prompt_collection: false,
redact_pii: false,
access_log_retention_days: 30,
created_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-01T00:00:00Z",
};
async function mockSettingsResponse(
page: Page,
response: { status: number; body: string },
) {
await page.route("**/api/agent-network/settings", (route) => {
if (route.request().method() !== "GET") return route.continue();
return route.fulfill({
status: response.status,
contentType: "application/json",
body: response.body,
});
});
}
async function openProvidersPage(
browser: Browser,
settingsResponse: { status: number; body: string },
): Promise<{ page: Page; close: () => Promise<void> }> {
const context = await browser.newContext({
storageState: "e2e/fixtures/auth/owner.json",
});
// Close the context on any setup failure — the caller only receives the
// close callback once setup succeeds.
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 mockSettingsResponse(page, settingsResponse);
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;
}
}
const UNBOOTSTRAPPED_SHAPES: {
name: string;
response: { status: number; body: string };
}[] = [
{
name: "defaults object with empty endpoint (current servers)",
response: { status: 200, body: JSON.stringify(UNBOOTSTRAPPED_DEFAULTS) },
},
{
name: "200 with JSON null body (older servers)",
response: { status: 200, body: "null" },
},
{
name: "404 (oldest servers)",
response: {
status: 404,
body: JSON.stringify({ message: "settings not found", code: 404 }),
},
},
];
test.describe("Agent Network settings wire shapes @agent-network", () => {
for (const shape of UNBOOTSTRAPPED_SHAPES) {
test(`unbootstrapped account renders the empty state: ${shape.name}`, async ({
browser,
}) => {
const { page, close } = await openProvidersPage(browser, shape.response);
try {
await expect(page.getByText(EMPTY_STATE_TEXT)).toBeVisible();
await expect(page.getByText(BOOTSTRAPPED_ENDPOINT)).toHaveCount(0);
} finally {
await close();
}
});
}
test("bootstrapped account renders the endpoint instead of the empty state", async ({
browser,
}) => {
const { page, close } = await openProvidersPage(browser, {
status: 200,
body: JSON.stringify(BOOTSTRAPPED_SETTINGS),
});
try {
await expect(page.getByText(BOOTSTRAPPED_ENDPOINT)).toBeVisible();
await expect(page.getByText(EMPTY_STATE_TEXT)).toHaveCount(0);
} finally {
await close();
}
});
});
+109
View File
@@ -0,0 +1,109 @@
/**
* 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();
}
});
});
+238
View File
@@ -0,0 +1,238 @@
/**
* 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;
edition?: string;
expectOnboarding?: boolean;
},
): Promise<{ page: Page; close: () => Promise<void> }> {
const context = await browser.newContext({
storageState: "e2e/fixtures/auth/owner.json",
});
await context.addInitScript(
([sourceKey, sourceValue, withSource, edition]) => {
try {
window.localStorage.setItem("netbird-test-onboarding", "true");
if (withSource) {
window.localStorage.setItem(
sourceKey as string,
sourceValue as string,
);
}
if (edition) {
window.localStorage.setItem("netbird-test-edition", edition as string);
}
} catch (e) {}
},
[
SIGNUP_SOURCE_KEY,
AGENT_NETWORK_SOURCE,
!!opts.source,
opts.edition ?? "",
] as const,
);
const page = await context.newPage();
mockAccounts(page, opts.account, opts.delayMs ?? 0);
// The mocked GET /accounts is what drives the onboarding decision, and the
// dashboard can resolve before it lands — a "no form opened" assertion would
// then pass vacuously. Wait for the rewritten response before returning.
const accountsLoaded = page.waitForResponse(
(resp) =>
/\/api\/accounts(\?|$)/.test(resp.url()) &&
resp.request().method() === "GET",
);
await loginToApp(page, "owner", {
expectOnboarding: opts.expectOnboarding ?? true,
});
await accountsLoaded;
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 self-hosted account with onboarding pending shows the regular flow at the intent step", async ({
browser,
}) => {
const { page, close } = await openOnboarding(browser, {
source: false,
edition: "oss",
account: { onboardingFlowPending: true },
});
try {
await expect(page.getByTestId(REGULAR_FORM)).toBeVisible();
await expect(page.getByTestId(AGENT_FORM)).toHaveCount(0);
// The signup survey relies on a JWT domain claim self-hosted IdPs don't
// emit, so the flow skips it and opens on the intent step. Scoped to the
// form: the dashboard behind the modal has its own "Get Started with
// NetBird" heading, and getByText matches case-insensitively.
await expect(
page
.getByTestId(REGULAR_FORM)
.getByRole("heading", { name: "Get started with NetBird" }),
).toBeVisible();
} finally {
await close();
}
});
test("a self-hosted account with only the signup form pending shows no onboarding", async ({
browser,
}) => {
const { page, close } = await openOnboarding(browser, {
source: false,
edition: "oss",
account: { signupFormPending: true },
expectOnboarding: false,
});
try {
// loginToApp resolved the dashboard, so the account state has been
// applied — neither flow should have opened.
await expect(page.getByTestId(REGULAR_FORM)).toHaveCount(0);
await expect(page.getByTestId(AGENT_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();
}
});
});
+47 -12
View File
@@ -1,6 +1,6 @@
import { test, expect } from "../helpers/fixtures";
import { navigateTo } from "../helpers/auth";
import { generateRandomName } from "../helpers/utils";
import { clearScrollLock, generateRandomName } from "../helpers/utils";
import { deleteGroupsByPrefix, deleteSetupKeysByPrefix } from "../helpers/api";
let setupKeys: string[] = [];
@@ -118,25 +118,49 @@ async function createSetupKey(
await expect(page.getByText(opts.name)).toBeVisible();
}
async function revokeSetupKey(
// openRowActions opens a row's action dropdown. A confirmation dialog closed
// moments earlier can still leave Radix's scroll-lock artifacts on <body>
// (`pointer-events: none`, a stale overlay), and those swallow the click even
// with force — the menu then silently never opens and the item lookup times
// out. Clear them before clicking.
async function openRowActions(
page: import("@playwright/test").Page,
name: string,
) {
// Row actions are now behind a dropdown menu.
await clearScrollLock(page);
// A force-click can open a new action menu without the previous one having
// closed (scroll-lock artifacts suppress Radix's outside-click dismissal),
// leaving two menus open — the item lookup then hits a strict-mode violation.
// Dismiss any open menu and wait for it to be gone before opening the next.
if (await page.locator('[role="menu"]').count()) {
await page.keyboard.press("Escape");
await expect(page.locator('[role="menu"]')).toHaveCount(0);
}
await page
.locator("tr")
.filter({ hasText: name })
.getByTestId("setup-key-actions")
.click({ force: true });
await page
.locator('[data-testid="revoke-setup-key"]:not([data-disabled])')
.click({ force: true });
}
async function revokeSetupKey(
page: import("@playwright/test").Page,
name: string,
) {
// Row actions are now behind a dropdown menu.
await openRowActions(page, name);
const revokeItem = page.locator(
'[data-testid="revoke-setup-key"]:not([data-disabled])',
);
await expect(revokeItem).toBeVisible();
await revokeItem.click({ force: true });
const responsePromise = page.waitForResponse(
(resp) => resp.url().includes("/api/setup-keys/") && resp.request().method() === "PUT",
{ timeout: 10_000 },
);
await page.getByTestId("confirmation.confirm").click();
await responsePromise;
await expect(page.getByRole("dialog")).not.toBeVisible();
await expect(
page
.locator("tr")
@@ -150,12 +174,23 @@ async function deleteSetupKey(
name: string,
) {
// Row actions are now behind a dropdown menu.
await page
.locator("tr")
.filter({ hasText: name })
.getByTestId("setup-key-actions")
.click({ force: true });
await page.getByTestId("delete-setup-key").click({ force: true });
await openRowActions(page, name);
const deleteItem = page.locator(
'[data-testid="delete-setup-key"]:not([data-disabled])',
);
await expect(deleteItem).toBeVisible();
await deleteItem.click({ force: true });
// Await the DELETE like revoke awaits its PUT: the handler refetches
// /setup-keys afterwards, which re-renders every row — returning before that
// settles means the next key's trigger click can land on a replaced node.
const responsePromise = page.waitForResponse(
(resp) =>
resp.url().includes("/api/setup-keys/") &&
resp.request().method() === "DELETE",
{ timeout: 10_000 },
);
await page.getByTestId("confirmation.confirm").click();
await responsePromise;
await expect(page.locator("tr").filter({ hasText: name })).not.toBeVisible();
await expect(page.getByRole("dialog")).not.toBeVisible();
}
+122
View File
@@ -0,0 +1,122 @@
/**
* Linux tab of the setup modal — distro selector spec.
*
* The manual-install accordion offers per-distro instructions via a
* SelectDropdown (data-testid="linux-distro-select"). This validates that
* switching distros swaps the repository and install commands, that
* CLI-only distros render no desktop-app line, and that the copy button
* emits commands without comment lines. Runs against /install, which
* renders the setup modal without authentication.
*/
import { expect, test } from "@playwright/test";
async function openLinuxManualInstall(page: import("@playwright/test").Page) {
await page.goto("/install");
await page.getByTestId("setup-netbird-modal").waitFor();
await page.getByRole("tab", { name: /linux/i }).click();
await page.getByText("Install manually with a package manager").click();
await page.getByTestId("linux-distro-select").waitFor();
}
async function selectDistro(
page: import("@playwright/test").Page,
name: string,
) {
await page.getByTestId("linux-distro-select").click();
await page.getByRole("option", { name }).click();
}
test.describe("Setup modal Linux distro instructions", () => {
test("defaults to APT with repository, install and run steps", async ({
page,
}) => {
await openLinuxManualInstall(page);
const modal = page.getByTestId("setup-netbird-modal");
await expect(modal.getByTestId("linux-distro-select")).toContainText(
"Debian / Ubuntu (APT)",
);
await expect(modal).toContainText(
"https://pkgs.netbird.io/debian stable main",
);
await expect(modal).toContainText("sudo apt-get install netbird");
await expect(modal).toContainText(
"sudo apt-get install netbird-ui libgtk-4-1 libwebkitgtk-6.0-4 xdg-utils",
);
// The run step stays present alongside the distro-specific steps
await expect(modal.getByText("netbird up")).toHaveCount(2);
});
test("switches repository and install commands per distro", async ({
page,
}) => {
await openLinuxManualInstall(page);
const modal = page.getByTestId("setup-netbird-modal");
await selectDistro(page, "Fedora (DNF)");
await expect(modal).toContainText("/etc/yum.repos.d/netbird.repo");
await expect(modal).toContainText("sudo dnf install netbird");
await expect(modal).toContainText(
"sudo dnf install netbird-ui gtk4 webkitgtk6.0 xdg-utils",
);
await expect(modal).not.toContainText("apt-get");
await expect(modal).not.toContainText("epel-release");
await selectDistro(page, "RHEL / AlmaLinux / Rocky (DNF)");
await expect(modal).toContainText("sudo dnf install epel-release -y");
await expect(modal).toContainText(
"sudo dnf install netbird-ui gtk4 webkitgtk6.0 xdg-utils",
);
});
test("imports the repository GPG key on openSUSE", async ({ page }) => {
await openLinuxManualInstall(page);
const modal = page.getByTestId("setup-netbird-modal");
await selectDistro(page, "openSUSE (Zypper)");
await expect(modal).toContainText(
"sudo zypper --non-interactive addrepo -f -g https://pkgs.netbird.io/yum/ netbird",
);
await expect(modal).toContainText(
"sudo zypper --gpg-auto-import-keys refresh netbird",
);
await expect(modal).toContainText("sudo zypper install netbird");
await expect(modal).toContainText(
"sudo zypper install netbird-ui libgtk-4-1 libwebkitgtk-6_0-4 xdg-utils",
);
// Zypper ignores gpgkey= in a .repo file, so that snippet must not appear
await expect(modal).not.toContainText("/etc/yum.repos.d/netbird.repo");
});
test("offers CLI only on Amazon Linux", async ({ page }) => {
await openLinuxManualInstall(page);
const modal = page.getByTestId("setup-netbird-modal");
await selectDistro(page, "Amazon Linux (YUM)");
await expect(modal).toContainText("sudo yum install netbird");
await expect(modal).not.toContainText("netbird-ui");
await expect(modal).not.toContainText("for CLI only");
await expect(modal).toContainText("only the CLI is available");
});
test("copies install commands without comment lines", async ({
page,
context,
}) => {
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
await openLinuxManualInstall(page);
const modal = page.getByTestId("setup-netbird-modal");
// Copy buttons in DOM order: install script, run command, repository,
// install step, run command (accordion).
await modal.getByTestId("copy-to-clipboard").nth(3).click();
const copied = await page.evaluate(() => navigator.clipboard.readText());
expect(copied, "clipboard should hold runnable commands only").toBe(
[
"sudo apt-get update",
"sudo apt-get install netbird",
"sudo apt-get install netbird-ui libgtk-4-1 libwebkitgtk-6.0-4 xdg-utils",
].join("\n"),
);
});
});
Loaded 20 of 123 files, more files were not shown because too many files have changed in this diff. Show more