Skip to content

PMM-15216 Migrate the SEP UI into PMM - #5653

Open
fabio-silva wants to merge 8 commits into
PMM-15288from
PMM-15216
Open

PMM-15216 Migrate the SEP UI into PMM#5653
fabio-silva wants to merge 8 commits into
PMM-15288from
PMM-15216

Conversation

@fabio-silva

@fabio-silva fabio-silva commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

Ticket number: PMM-15216

Feature build: SUBMODULES-0

Stacked PR. Base is #5728 (PMM-15288, the pnpm + oxlint/oxfmt + library upgrade). Review that one first; the diff shown here is only the UI migration on top of it. This branch was rebuilt from main to split the two, so the commit history changed — the previous tip was 9a507d0c.

What

Brings SEP's frontend into ui/ and mounts the migrated plugins as native PMM routes, so SEP surfaces render inside the PMM shell instead of an iframe.

Packages ported from SEP's frontend workspace

PMM path From
ui/packages/sep/api typed API client, generated OpenAPI surfaces, hooks
ui/packages/sep/framework schema-driven form/list/task components
ui/packages/sep/shared shared primitives
ui/packages/plugins/atw Collect Diagnostic Data (ATW)

SEP's "app" vocabulary is renamed to "plugin" throughout the port, since "app" already means a workspace app in ui/: SchemaDrivenAppSchemaDrivenPlugin, useAppSchemausePluginSchema, useAppTasksusePluginTasks, app-schema.tsplugin-schema.ts. Ported files also carry PMM's AGPL header.

Wiring in apps/pmm

  • router.tsx mounts the plugins under their own routes; SepPage gives them the standard PMM Page chrome (padding, width, auth gate, footer).
  • Navigation gates the SEP entries behind admin + the inventory settings flag.
  • main.tsx calls initSepAuth, pointing SEP's axios client at PMM's session. Auth is still the interim Option D: the dev proxy injects SEP_INTERNAL_TOKEN server-side, so no token reaches the browser.
  • vite.config.ts proxies SEP's paths (/api, /sep_app, /stream-logs, /execution-events, /files) to SEP_BACKEND_URL, and lets PMM_SERVER_URL override the PMM target.
  • A SyntaxHighlighter component backs the schema renderer's script/JSON fields.
  • Page gains maxWidth so SEP pages can opt into the full-width container from @percona/percona-ui; Settings.tsx moves to it in place of the removed fullWidth flag.

SEP catch-up (second commit)

SEP moved on after the initial port. Ported commit by commit rather than by copying files, so the app→plugin rename and license headers survive:

  • SEP-1629 / SEP-1684 / SEP-1689 / SEP-1696 / SEP-1668 — refresh the generated OpenAPI surface from SEP head. Two schema components are now namespaced: ConnectivityWarningframework__ConnectivityWarning, TaskExecuteWriteframework__TaskExecuteWrite; their consumers move with them.
  • SEP-1663 — honor HostRef / HostField.allow_custom. HostField passes it to HostSelector, which renders FreeSoloSelect instead of the closed AutoCompleteInput and commits a scalar id/string (including from cascade auto-select). FreeSoloSelect now resolves a stored string against option ids, not just labels, so string host ids like "nomad-1" display as their option.
  • SEP-1653 — hide the task-history Download files button unless the files API returns a non-empty listing. has_logs was the wrong signal: logs exist even when the output dir holds only the hidden .sep-run-result.json marker, leaving a dead download action. Probes are cached for 30s so the history table's poll loop doesn't re-hit the files API every tick.
  • SEP-1692 — add postSession / postSessionExchange to @sep/api. The exchange endpoint trades PMM's session cookie for a short-lived SEP bearer, which is what replaces the interim SEP_INTERNAL_TOKEN wiring (that token's service principal hardcodes is_admin = False and so 403s every admin-gated surface).

Deliberately not ported

  • SEP-1663's multi-host half (FreeSoloMultiSelect, MultiHostField) — PMM's snapshot has no multi-host selector to extend. Porting it means first porting SEP's multi-host feature, which is its own change.
  • Flipping bootstrap.ts to the token exchange (Option B) — only the @sep/api client surface lands here. Switching over needs a SEP backend carrying POST /api/oauth/session/exchange and live verification, so it gets its own ticket.

Review comments addressed

Also gitignores *.tsbuildinfo; one had been committed by accident.

Verification

From ui/, on this branch (i.e. including the base PR):

Check Result
pnpm install clean
make format-check (oxfmt) 769 files, all formatted
make lint (oxlint) 0 errors across all 6 packages
make test (vitest) 116 files passed, 1084 tests passed, 1/13 skipped
make build pmm + pmm-compat build
turbo run check-types clean

Not yet verified against a live PMM Server with a SEP backend attached — that needs a feature build, which is tracked below.

Related

If this PR adds, removes or alters one or more API endpoints, please review and update the relevant API documentation as well:

  • API Docs updated

@fabio-silva
fabio-silva requested review from a team as code owners July 16, 2026 10:14
@fabio-silva
fabio-silva requested review from 4nte, matejkubinec, mattiasimonato and maxkondr and removed request for a team July 16, 2026 10:14
@it-percona-cla

it-percona-cla commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

CLA assistant check
All committers have signed the CLA.

@codecov

codecov Bot commented Jul 16, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 45.41%. Comparing base (7e531d4) to head (c49953f).

Additional details and impacted files
@@              Coverage Diff              @@
##           PMM-15288    #5653      +/-   ##
=============================================
+ Coverage      45.38%   45.41%   +0.02%     
=============================================
  Files            418      418              
  Lines          43334    43334              
=============================================
+ Hits           19669    19678       +9     
+ Misses         21725    21715      -10     
- Partials        1940     1941       +1     
Flag Coverage Δ
admin 34.96% <ø> (ø)
agent 51.02% <ø> (+0.10%) ⬆️
managed 44.99% <ø> (-0.01%) ⬇️
vmproxy 72.22% <ø> (ø)

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Comment thread build/docker/rpmbuild/Dockerfile.el8 Outdated
dnf clean all && rm -rf /var/cache/dnf

# UI build uses pnpm (packageManager in ui/package.json); nodesource nodejs ships npm/yarn only.
RUN npm install -g pnpm@11.1.3

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it possible to pass ppm version during docker build (read from some file in repo)? It will simplify the maintenance in future (less files are required to be changed during version bump).

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good call — done, and the hardcoded version is gone entirely rather than being passed in as a build arg.

All three rpmbuild Dockerfiles now just do:

RUN corepack enable

corepack ships with nodesource's nodejs 22 and resolves pnpm from the packageManager field in ui/package.json at build time, so a pnpm bump only touches that one field. ui/Makefile's setup target does corepack enable pnpm for the same reason, and .devcontainer/setup.sh no longer pins a version either.

This moved to the split-out toolchain PR: #5728.

Comment thread build/docker/rpmbuild/Dockerfile.el9 Outdated
dnf clean all && rm -rf /var/cache/dnf

# UI build uses pnpm (packageManager in ui/package.json); nodesource nodejs ships npm/yarn only.
RUN npm install -g pnpm@11.1.3

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the same as above

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same fix as on el8 — see the reply above. Applied to el8, el9 and hetzner-el9 in #5728.

@yyyyyyyan

Copy link
Copy Markdown
Collaborator

@copilot resolve the merge conflicts in this pull request

Copilot AI commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

@copilot resolve the merge conflicts in this pull request

Resolved by merging origin/main into this branch and fixing the conflicts in commit 9bf5e656e.

Copilot AI requested a review from yyyyyyyan July 30, 2026 01:29
Comment thread build/docker/rpmbuild/Dockerfile.el8 Outdated
dnf clean all && rm -rf /var/cache/dnf

# UI build uses pnpm (packageManager in ui/package.json); nodesource nodejs ships npm/yarn only.
RUN npm install -g pnpm@11.1.3

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we really need both pnpm and yarn?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes — they cover different projects, and both are built from this image:

  • ui/ (the PMM UI workspace) is pnpm.
  • dashboards/pmm-app (the QAN Grafana plugin) is still yarn 1 — packageManager: yarn@1.22.21, and dashboards/Makefile runs yarn install --frozen-lockfile.

Migrating pmm-app off yarn is out of scope here, so the image needs both until that happens.

I've made the Dockerfile say so, and dropped the hardcoded pnpm version at the same time (per @maxkondr's comment above):

# The UI workspace (ui/) builds with pnpm; the QAN Grafana plugin (dashboards/pmm-app)
# is still on yarn 1, so both are needed. corepack ships with nodesource's nodejs 22
# and resolves pnpm from the `packageManager` field in ui/package.json, so the version
# is pinned in exactly one place and needs no change here on a bump.
RUN corepack enable

This moved to the split-out toolchain PR: #5728.

Comment thread build/docker/server/entrypoint.sh Outdated
is_enabled() { [ "$1" = "1" ] || [ "$1" = "true" ]; }
declare POSTGRES_DATA_DIR="/srv/postgres14"
declare POSTGRES_PASSWORD_FILE="/srv/.postgres_password"
declare POSTGRES_BIN_DIR="/usr/pgsql-14/bin"

@ademidoff ademidoff Aug 2, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@fabio-silva I don't really understand what this PR has to do with the entrypoint and postgres-migration.

Please remove that code, it's a separate PR that needs to be tested stand-alone and merged separately, possibly even prior to this one.

#5700

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, and removed. Neither branch touches that code any more — build/docker/server/entrypoint.sh, build/ansible/roles/postgres/* (including the new postgres-sep script), docker-compose.yml, .env.example and managed/utils/envvars are all gone from the diff. It's yours to land stand-alone in #5700.

While removing it we also split the rest of the PR in two, since the toolchain change and the UI migration were reviewing as one 320-file diff:

That meant rebuilding this branch from main, so its history changed; the previous tip was 9a507d0c.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great, thanks!

@Nailya Let's give priority to testing #5700, we need it merged asap.

@coderabbitai

coderabbitai Bot commented Aug 3, 2026

Copy link
Copy Markdown

Review Change Stack

Walkthrough

Changes

SEP platform and API

Layer / File(s) Summary
API contracts and clients
ui/packages/sep/api/...
Added OpenAPI specifications, typed clients, authentication helpers, normalized errors, React Query defaults, API hooks, code generation, and tests.
Framework components and hooks
ui/packages/sep/framework/...
Added schema-driven forms, selectors, scheduling, task history, log streaming, plugin routing, downloads, shared utilities, and extensive tests.
ATW incident workflow
ui/packages/plugins/atw/...
Added incident management, category browsing, batch snippet collection, execution results, diagnostic sending, polling, retries, and test coverage.
PMM integration and workspace wiring
ui/apps/pmm/..., ui/pnpm-workspace.yaml, ui/turbo.json, ui/.gitignore
Added SEP package workspace entries, PMM routes and navigation, authentication bootstrap, development proxies, page-container support, syntax highlighting, and build-output ignores.

Sequence Diagram(s)

sequenceDiagram
  participant PMM
  participant SEPProxy
  participant ATW
  participant SEPAPI
  PMM->>SEPProxy: Route SEP requests
  SEPProxy->>SEPAPI: Forward authenticated API calls
  PMM->>ATW: Mount ATW routes
  ATW->>SEPAPI: Load incidents, schemas, executions, and send jobs
Loading

Possibly related PRs

  • percona/pmm#5728: Adds related workspace-level changes that precede the SEP package workspace expansion.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly identifies the migration of the SEP UI into PMM.
Description check ✅ Passed The description includes the required ticket, feature build, API documentation status, related work, scope, deferred work, and verification results.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

Comment @coderabbitai help to get the list of available commands.

@nachodd nachodd changed the title PMM-15216 fix ui build: bootstrap pnpm via corepack, drop yarn leftover PMM-15216 Migrate the SEP UI into PMM Aug 3, 2026
@nachodd
nachodd changed the base branch from main to PMM-15288 August 3, 2026 17:20

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note

Due to the large number of review comments, Critical severity comments were prioritized as inline comments.

🟠 Major comments (30)
ui/packages/sep/api/tests/typed-client.test.ts-18-23 (1)

18-23: 📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Co-locate this test with typed-client.ts.

Move this file to ui/packages/sep/api/src/typed-client.test.ts. Update ui/packages/sep/api/vitest.config.ts so Vitest discovers the co-located test files.

As per coding guidelines, “Co-locate test files next to the components they test, using *.test.tsx or *.test.ts naming.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@ui/packages/sep/api/tests/typed-client.test.ts` around lines 18 - 23, Move
the typed-client test from the tests directory to be co-located with
typed-client.ts as typed-client.test.ts, preserving its existing coverage and
imports. Update vitest.config.ts so Vitest includes co-located *.test.ts and
*.test.tsx files under the source tree.

Source: Coding guidelines

ui/packages/sep/api/src/client.ts-136-159 (1)

136-159: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

A synchronous throw from _onRefreshed still fails the refresh.

The comment on Lines 137-140 states that a synchronous throw from the injected _onRefreshed handler must not be reported as a failed refresh. The call on Line 157 sits inside the async executor, so a throw rejects refreshInFlight. Every awaiting caller then receives a rejection instead of the new token, and the 401 interceptor propagates a non-ApiError without retrying and without calling _onUnauthorized. Isolate the handler call to match the documented intent.

🛡️ Proposed fix to isolate the handler
-      _onRefreshed(data.access_token, data.expires_in);
+      try {
+        _onRefreshed(data.access_token, data.expires_in);
+      } catch {
+        // A failing auth-layer handler must not invalidate a successful
+        // cookie rotation.
+      }
       return data.access_token;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@ui/packages/sep/api/src/client.ts` around lines 136 - 159, Isolate the
synchronous _onRefreshed call from the refreshInFlight async executor so its
exception cannot reject the shared refresh promise. Update the refresh flow
around _onRefreshed to invoke the handler after the promise has resolved, while
preserving the successful token return and existing network-error handling.
ui/packages/sep/framework/src/components/SchemaDrivenPlugin/PluginListPage.tsx-166-170 (1)

166-170: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Guard schema.list_view before dereferencing it. PluginSchema.list_view is optional. An unknown entity route sets multi to false, then PluginListPage reads listView.columns and can crash. The detail route can also reach OverviewTab, where schema.list_view!.columns can crash.

  • Render Not found when the entity is unresolved and no top-level list_view exists.
  • Omit the Task information card when schema.list_view is absent.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@ui/packages/sep/framework/src/components/SchemaDrivenPlugin/PluginListPage.tsx`
around lines 166 - 170, Guard the unresolved-entity path in PluginListPage so it
renders Not found when multi is false and schema.list_view is absent before
accessing listView.columns; preserve normal rendering when a list view exists.
In PluginDetailPage’s OverviewTab, conditionally omit the Task information card
when schema.list_view is unavailable instead of dereferencing
schema.list_view!.columns.
ui/packages/sep/framework/src/components/ScheduledTasksPanel/ScheduledTasksPanel.tsx-89-106 (1)

89-106: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

A simple enable/disable toggle destroys kwargs on the server.

handleToggleEnabled sends kwargs: '{}' in a full PUT. Any scheduled task created with non-default kwargs loses that data when a user flips the switch. The inline comment records the backend gap, but the current code turns a read-only-looking UI action into silent data loss.

Consider one of these mitigations until the backend exposes kwargs in PeriodicTaskResponse:

  • Preserve the value when it is present on the response object, and only fall back to '{}' when it is absent.
  • Disable the toggle for tasks whose kwargs cannot be round-tripped.

I can open a tracking issue for the backend schema gap if that helps.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@ui/packages/sep/framework/src/components/ScheduledTasksPanel/ScheduledTasksPanel.tsx`
around lines 89 - 106, Update handleToggleEnabled to preserve task.kwargs when
the response object provides it, using '{}' only as the fallback when kwargs is
absent. Keep the existing PeriodicTaskUpdate payload and toggle behavior
unchanged otherwise, and retain the fallback until PeriodicTaskResponse exposes
kwargs consistently.
ui/apps/pmm/src/contexts/navigation/navigation.utils.tsx-300-315 (1)

300-315: 📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Define shared SEP route constants.

The navigation builder and router define the same SEP route values independently. Define the route values in src/lib/constants.ts and consume them from both sites.

  • ui/apps/pmm/src/contexts/navigation/navigation.utils.tsx#L300-L315: use shared constants for url and matches.
  • ui/apps/pmm/src/router.tsx#L88-L107: use the same constants for route paths and routeBase.

As per coding guidelines, “Do not hardcode URLs; use constants from src/lib/constants.ts.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@ui/apps/pmm/src/contexts/navigation/navigation.utils.tsx` around lines 300 -
315, Define shared SEP route constants in src/lib/constants.ts, then update
addSepApps in ui/apps/pmm/src/contexts/navigation/navigation.utils.tsx to use
them for url and matches, and update the SEP routes and routeBase in
ui/apps/pmm/src/router.tsx to use the same constants instead of hardcoded paths.

Source: Coding guidelines

ui/apps/pmm/src/sep/SepPage.tsx-14-19 (1)

14-19: 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Enforce the PMM-admin gate at the route wrapper.

NavigationProvider hides SEP entries for non-admin users, but direct navigation still matches the SEP routes. SepPage does not pass a role restriction to Page, so every authorized user can render these plugins.

Apply the same PMM-admin authorization policy at SepPage or at each SEP route. Do not rely on navigation visibility as an authorization control.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@ui/apps/pmm/src/sep/SepPage.tsx` around lines 14 - 19, Update the SepPage
component to pass the existing PMM-admin role restriction to the Page wrapper,
ensuring direct SEP route navigation is denied for non-admin users while
preserving the current children layout.
ui/apps/pmm/vite.config.ts-20-21 (1)

20-21: 📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Use the development environment-variable prefix.

Rename SEP_BACKEND_URL to PMM_DEV_SEP_BACKEND_URL. Rename SEP_INTERNAL_TOKEN to PMM_DEV_SEP_INTERNAL_TOKEN. These variables configure only the Vite development proxy.

As per coding guidelines, “Use environment-variable prefixes consistently: PMM_DEV_* for development/testing only.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@ui/apps/pmm/vite.config.ts` around lines 20 - 21, Update the
environment-variable references used by the Vite development proxy: rename
SEP_BACKEND_URL to PMM_DEV_SEP_BACKEND_URL and SEP_INTERNAL_TOKEN to
PMM_DEV_SEP_INTERNAL_TOKEN in the configuration initialization. Preserve the
existing proxy behavior and values.

Source: Coding guidelines

ui/apps/pmm/src/sep/bootstrap.ts-17-20 (1)

17-20: 🔒 Security & Privacy | 🟠 Major | 🏗️ Heavy lift

Add production SEP routing and authentication.

The deployed Nginx configuration has no upstream or location for /api, /sep_app, /stream-logs, /execution-events, or /files. Only the Vite development proxy injects SEP_INTERNAL_TOKEN. Because initSepAuth returns null, deployed SEP requests have no SEP bearer and cannot work. Add production routing with server-side credentials, or wire postSessionExchange() before merge.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@ui/apps/pmm/src/sep/bootstrap.ts` around lines 17 - 20, Update initSepAuth to
establish production SEP authentication instead of returning a null token, and
ensure deployed Nginx routing forwards /api, /sep_app, /stream-logs,
/execution-events, and /files with server-side SEP credentials. Reuse
postSessionExchange() if that is the intended authentication flow, and preserve
the existing unauthorized-handler registration.
ui/packages/plugins/atw/tests/CategoryBrowser.test.tsx-18-22 (1)

18-22: 📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Component tests are not co-located with their components. The ATW package keeps component tests in a separate tests/ directory and imports the component through ../src/.... The coding guidelines require co-location.

  • ui/packages/plugins/atw/tests/CategoryBrowser.test.tsx#L18-L22: move the file to ui/packages/plugins/atw/src/CategoryBrowser.test.tsx and change the import to ./CategoryBrowser.
  • ui/packages/plugins/atw/tests/ResultsPane.test.tsx#L18-L22: move the file to ui/packages/plugins/atw/src/ResultsPane.test.tsx and change the import to ./ResultsPane.

Keep ui/packages/plugins/atw/tests/setup.ts where it is, and confirm that ui/packages/plugins/atw/vitest.config.ts include globs still match the new locations.

As per coding guidelines: "Co-locate test files next to the components they test, using *.test.tsx or *.test.ts naming."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@ui/packages/plugins/atw/tests/CategoryBrowser.test.tsx` around lines 18 - 22,
Move CategoryBrowser.test.tsx to
ui/packages/plugins/atw/src/CategoryBrowser.test.tsx and update its
CategoryBrowser import to ./CategoryBrowser; likewise move ResultsPane.test.tsx
to ui/packages/plugins/atw/src/ResultsPane.test.tsx and update its import to
./ResultsPane. Keep ui/packages/plugins/atw/tests/setup.ts unchanged and verify
vitest.config.ts include globs cover the relocated tests.

Source: Coding guidelines

ui/packages/plugins/atw/src/CollectPane.tsx-62-81 (1)

62-81: 🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Flatten dotted ATW parameter names before building the payload.

ATW fields include source.path. The form stores this as nested objects, but buildBatchPayload passes those objects through toArgs unchanged. Flatten shared and per-snippet values back to their declared keys, and add regression coverage for both paths.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@ui/packages/plugins/atw/src/CollectPane.tsx` around lines 62 - 81, Update
buildBatchPayload and its toArgs flow to flatten nested form objects into the
declared dotted ATW parameter keys, including shared values and per-snippet
values such as source.path. Preserve existing argument conversion behavior for
non-dotted fields, and add regression coverage verifying both shared and
per-snippet payload paths produce flattened keys.
ui/packages/sep/framework/src/components/SchemaDrivenPlugin/PluginTaskEditPage.tsx-63-85 (1)

63-85: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

normalizeChoiceDefaults misses fields with dotted names.

flattenSectionFields also returns one_of branch fields, whose names are dotted paths such as source.mode (see the one_of cases in SchemaFormRenderer.test.tsx). This function reads and writes flat keys with out[field.name], so a nested choice value stored at { source: { mode: 'rsync' } } is never normalized. The case-mismatched value then reaches the form and renders as an empty selection, which is the failure this function exists to prevent.

Use the exported getAtPath and setAtPath helpers instead of flat key access. Note that setAtPath mutates nested objects, so deep-copy the input first.

🐛 Proposed fix using path-aware accessors
-import {
-  SchemaFormRenderer,
-  coerceFormValues,
-  flattenSectionFields,
-} from '../SchemaFormRenderer';
+import {
+  SchemaFormRenderer,
+  coerceFormValues,
+  flattenSectionFields,
+  getAtPath,
+  setAtPath,
+} from '../SchemaFormRenderer';
@@
-  const out = { ...form };
+  const out = structuredClone(form) as Record<string, unknown>;
   for (const field of flattenSectionFields(sections)) {
     if (field.type !== 'choice' && field.type !== 'multi_choice') {
       continue;
     }
     const choiceMap = new Map(
       field.choices.map((c) => [c.value.toLowerCase(), c.value])
     );
-    const raw = out[field.name];
+    const raw = getAtPath(out, field.name);
     if (field.type === 'multi_choice' && Array.isArray(raw)) {
-      out[field.name] = raw.map((v) => {
-        const canonical =
-          typeof v === 'string' ? choiceMap.get(v.toLowerCase()) : undefined;
-        return canonical ?? v;
-      });
+      setAtPath(
+        out,
+        field.name,
+        raw.map((v) => {
+          const canonical =
+            typeof v === 'string' ? choiceMap.get(v.toLowerCase()) : undefined;
+          return canonical ?? v;
+        })
+      );
     } else if (field.type === 'choice' && typeof raw === 'string') {
       const canonical = choiceMap.get(raw.toLowerCase());
       if (canonical !== undefined) {
-        out[field.name] = canonical;
+        setAtPath(out, field.name, canonical);
       }
     }
   }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@ui/packages/sep/framework/src/components/SchemaDrivenPlugin/PluginTaskEditPage.tsx`
around lines 63 - 85, Update normalizeChoiceDefaults to deep-copy the input
form, then use the exported getAtPath and setAtPath helpers for reading and
writing each choice field in flattenSectionFields, including dotted one_of paths
such as source.mode. Preserve the existing choice and multi_choice
canonicalization behavior while ensuring setAtPath receives the normalized
value.
ui/packages/sep/framework/src/components/SchemaFormRenderer/hooks/useCascadingField.ts-66-72 (1)

66-72: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

useCascadingField does not follow the form's empty-value contract. buildFormDefaults in SchemaFormRenderer.tsx (line 85) seeds service, schema, table, and host fields to ''. These are exactly the cascading selector types named in the doc comment on line 38. This hook instead treats undefined as the empty value, which breaks both the clear path and the readiness check. Pick '' as the single empty-value sentinel and apply it in both places.

  • ui/packages/sep/framework/src/components/SchemaFormRenderer/hooks/useCascadingField.ts#L66-L72: change setValue(fieldName, undefined, …) to setValue(fieldName, '', …). undefined also flips a bound MUI input from controlled to uncontrolled, so React logs a warning and the field can keep showing the stale selection.
  • ui/packages/sep/framework/src/components/SchemaFormRenderer/hooks/useCascadingField.ts#L75-L80: add upstreamValue !== '' to the ready expression. An upstream selector with nothing selected holds '' today, so ready returns true and a downstream selector fetches options for an empty parent.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@ui/packages/sep/framework/src/components/SchemaFormRenderer/hooks/useCascadingField.ts`
around lines 66 - 72, The useCascadingField clear path and readiness check must
use '' as the form’s empty-value sentinel. In
ui/packages/sep/framework/src/components/SchemaFormRenderer/hooks/useCascadingField.ts
lines 66-72, update setValue in the previousRef change block to clear fieldName
with '' instead of undefined; in lines 75-80, update the ready expression to
require upstreamValue !== '' so downstream options are not fetched without a
selected parent.
ui/packages/sep/framework/src/components/SchemaFormRenderer/SchemaFormRenderer.tsx-356-361 (1)

356-361: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

A section-violation block silently disarms the unsaved-changes guard.

handleFormSubmit returns without throwing when hasSectionViolations is true. react-hook-form treats that as a successful submit and sets formState.isSubmitSuccessful = true. useUnsavedChangesGuard computes isDirty && !isSubmitSuccessful (useUnsavedChangesGuard.ts line 33), so isGuarded becomes false. The re-arm effect in that hook only runs when submitError is truthy, and submitError stays null on this path. The guard therefore stays disarmed: the beforeunload prompt and UnsavedChangesBlocker no longer fire, and the user can navigate away and lose the form data.

Gate the submit before handleSubmit runs, or mark the form invalid so RHF does not flag the submit as successful.

🐛 Proposed fix — block in the submit event handler instead
   const handleSubmitEvent = (event: FormEvent<HTMLFormElement>) => {
+    if (hasSectionViolations) {
+      // Section-level rules already render their own inline Alerts. Stop here so
+      // react-hook-form never marks the submit successful, which would disarm
+      // useUnsavedChangesGuard.
+      event.preventDefault();
+      return;
+    }
     if (appliedServerErrorPaths.current.length > 0) {

Then drop the early return from handleFormSubmit.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@ui/packages/sep/framework/src/components/SchemaFormRenderer/SchemaFormRenderer.tsx`
around lines 356 - 361, Move the hasSectionViolations check out of
handleFormSubmit and gate the submit before react-hook-form handleSubmit runs,
so blocked submissions are not marked successful and the unsaved-changes guard
remains active. Remove the early return from handleFormSubmit while preserving
its existing onSubmit behavior for valid submissions.
ui/packages/sep/framework/src/components/SchemaListView/SchemaListView.tsx-386-398 (1)

386-398: 📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

SchemaListView.tsx bypasses PMM theme tokens in two places. Both sites inline a design value instead of reading it from the theme, so the schema-driven list does not follow pmmThemeOptions. The shared fix is to define the missing tokens in pmmThemeOptions from @percona/percona-ui and read them here.

  • ui/packages/sep/framework/src/components/SchemaListView/SchemaListView.tsx#L386-L398: replace bgcolor: 'common.white' on both muiTablePaperProps and muiTableContainerProps with bgcolor: 'background.paper', and fix the opacity of background.paper in pmmThemeOptions so every PMM surface benefits. Pinning common.white renders a white surface in dark mode, as the inline comment already acknowledges.
  • ui/packages/sep/framework/src/components/SchemaListView/SchemaListView.tsx#L146-L154: replace fontFamily: "'Roboto Mono', monospace" in the code branch with a monospace token read from the theme, or add a theme Typography variant for code cells and use that variant.

As per coding guidelines: "Do not use hard-coded colors, font families, or spacing that bypass the theme; prefer sx with theme tokens, breakpoints, and MUI Typography variants" and "Use ColorModeContext or existing hooks such as useColorMode for light/dark mode".

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@ui/packages/sep/framework/src/components/SchemaListView/SchemaListView.tsx`
around lines 386 - 398, Update SchemaListView.tsx at lines 386-398 so both
muiTablePaperProps and muiTableContainerProps use the theme token
background.paper, and update pmmThemeOptions to provide an opaque,
mode-appropriate background.paper value. At lines 146-154, replace the
hard-coded "'Roboto Mono', monospace" in the code branch with the theme’s
monospace token or a dedicated code Typography variant, and use that theme value
for code cells.

Source: Coding guidelines

ui/packages/sep/framework/src/components/SchemaListView/SchemaListView.tsx-108-116 (1)

108-116: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Guard undefined as well as null.

Line 112 checks only value === null. data is Record<string, unknown>[], and the columns come from a server-supplied schema, so a row can omit a declared column key. In that case value is undefined and line 115 produces the string 'undefined'.

The formats amplify this:

  • default and chip render the literal text undefined.
  • date renders Invalid Date.
  • relative renders NaNd ago, because every mins/hours comparison against NaN is false.

Use a loose null check so both cases fall to the em dash.

🐛 Proposed fix
 function formatCellValue(
   value: unknown,
   format: ListColumn['format']
 ): ReactNode {
-  if (value === null) {
+  if (value === null || value === undefined) {
     return '—';
   }
   const str = String(value);

Consider also guarding an unparsable date in the date and relative branches:

     case 'date':
-      return new Date(str).toLocaleDateString();
+    case 'date': {
+      const d = new Date(str);
+      return Number.isNaN(d.getTime()) ? '—' : d.toLocaleDateString();
+    }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@ui/packages/sep/framework/src/components/SchemaListView/SchemaListView.tsx`
around lines 108 - 116, Update formatCellValue to use a loose null check so both
null and undefined values return the em dash before String conversion or format
handling. Preserve the existing behavior for defined values and formats.
ui/packages/sep/framework/src/hooks/useResolvedServiceField.ts-80-96 (1)

80-96: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Expose the fetch error state.

The hook discards the error state of useServices. If the services fetch fails, isFetched becomes true and services stays EMPTY_SERVICES. service is then undefined and isResolving is false.

Callers cannot distinguish two different situations:

  • The scalar id resolved to no matching service.
  • The fetch failed, so resolution never completed.

The doc comment at Lines 44-48 instructs callers to wait rather than treat the parent as missing. On a fetch failure the caller receives the exact signal it was told means "not resolving", so it renders a permanently blank service name with no error surfaced.

Add the error state to ResolvedServiceField.

🔧 Proposed fix
   isResolving: boolean;
+  /** True when the bounded ``useServices`` fetch failed. ``service`` stays undefined. */
+  isError: boolean;
+  /** Error from the bounded ``useServices`` fetch, when it failed. */
+  error: Error | null;
 }
-  const { data: services = EMPTY_SERVICES, isFetched } = useServices({
+  const {
+    data: services = EMPTY_SERVICES,
+    isFetched,
+    isError,
+    error,
+  } = useServices({
     serviceTypes: types,
     enabled,
   });
   return {
     parent,
     service,
     resetKey: cascadeParentResetKey(parent),
     isResolving: enabled && !isFetched,
+    isError: enabled && isError,
+    error: enabled ? (error ?? null) : null,
   };
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@ui/packages/sep/framework/src/hooks/useResolvedServiceField.ts` around lines
80 - 96, Expose the fetch error from useServices through the
ResolvedServiceField return value. Destructure the error alongside data and
isFetched, then add it to the returned object so callers can distinguish a
failed service lookup from a successful lookup with no matching service;
preserve the existing service and isResolving behavior.
ui/packages/sep/framework/src/components/HostSelector/StandaloneHostSelector.tsx-73-79 (1)

73-79: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

A failed hosts query disables the selector permanently.

Line 79 disables the Autocomplete when isError is true. Line 73 puts the only retry trigger, refetch(), on onOpen. A disabled Autocomplete never opens, so onOpen never fires. After one hosts-query failure the user cannot recover in this component. The user must remount the page.

Add an explicit retry control, or keep the control enabled on error so onOpen can retry.

🔧 Proposed fix — add a retry action to the error state
+import IconButton from '`@mui/material/IconButton`';
+import InputAdornment from '`@mui/material/InputAdornment`';
+import RefreshIcon from '`@mui/icons-material/Refresh`';
       loading={isLoading}
       loadingText="Loading hosts…"
       noOptionsText="No hosts available"
-      disabled={disabled || isError}
+      disabled={disabled}
       renderInput={(params) => (
         <TextField
           {...params}
           label={label}
           error={isError}
           helperText={
             isError ? (error?.message ?? 'Failed to load hosts') : undefined
           }
+          slotProps={{
+            input: {
+              ...params.InputProps,
+              endAdornment: (
+                <>
+                  {isError && (
+                    <InputAdornment position="end">
+                      <IconButton
+                        size="small"
+                        aria-label="Retry loading hosts"
+                        onClick={() => void refetch()}
+                      >
+                        <RefreshIcon fontSize="small" />
+                      </IconButton>
+                    </InputAdornment>
+                  )}
+                  {params.InputProps.endAdornment}
+                </>
+              ),
+            },
+          }}
         />
       )}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@ui/packages/sep/framework/src/components/HostSelector/StandaloneHostSelector.tsx`
around lines 73 - 79, Update the HostSelector Autocomplete error handling so a
hosts-query failure does not permanently block recovery: either remove isError
from disabled or add an explicit retry control that invokes refetch() while the
error state is shown. Preserve disabled behavior for the existing disabled prop
and ensure users can trigger the retry without remounting.
ui/packages/sep/framework/src/components/FreeSoloSelect/freeSoloValue.ts-112-117 (1)

112-117: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Committing the trimmed string blocks trailing spaces during typing.

FreeSoloSelect calls normalizeChange on every keystroke through onInputChange. This function commits trimmed, not the raw input. toDisplayValue then returns that trimmed string as the controlled Autocomplete value, so MUI resets the visible input text to the trimmed form. A user who types a space cannot keep it, because each keystroke removes the trailing space. Custom values that contain spaces become hard to enter.

Use trimmed only for the blank check and for label matching. Commit the raw string.

🐛 Proposed fix
   const trimmed = next.trim();
   if (trimmed === '') {
     return null;
   }
   const match = options.find((o) => getOptionLabel(o) === trimmed);
-  return match ? match.id : trimmed;
+  return match ? match.id : next;
 }

Update the docstring at lines 98-99 to state that a non-empty string is kept verbatim, including surrounding whitespace, and update the test at line 94 to cover a value with an inner space.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@ui/packages/sep/framework/src/components/FreeSoloSelect/freeSoloValue.ts`
around lines 112 - 117, Update normalizeChange around the trimmed blank check so
trimmed is used only to detect empty input and match option labels; return the
raw next string for unmatched non-empty custom values, preserving surrounding
whitespace during typing. Revise the function docstring to state that non-empty
strings are retained verbatim and update the related test to cover a value
containing an inner space.
ui/packages/sep/framework/src/components/HostSelector/HostSelector.tsx-160-160 (1)

160-160: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Use a path-aware error lookup for nested field names.

When name is a dotted path such as params.host, errors[name] returns undefined. Use get(errors, name)?.message so the autocomplete field displays its validation error.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@ui/packages/sep/framework/src/components/HostSelector/HostSelector.tsx` at
line 160, Update the fieldError lookup in the HostSelector component to resolve
nested field names through the form error object, using the path-aware get
helper with name before reading message. Preserve the existing
string-or-undefined typing and validation display behavior.
ui/packages/sep/framework/src/utils/extractId.ts-29-41 (1)

29-41: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Reject whitespace-only and non-integer strings.

Number(value) coerces a whitespace-only string to 0, so extractId(' ') returns 0 instead of null. Two callers then treat that as a resolvable id:

  • useResolvedServiceField.ts line 78 sets enabled = unresolvedServiceId !== null && ..., so a lookup fires for service id 0 and never matches.
  • SchemaSelector.tsx line 72 computes serviceId from the untrimmed parent, so noService becomes false and useSchemas({ serviceId: 0 }) fires against a nonexistent service.

Number also accepts '1.5' and '0x10', which produce non-integer or unintended ids while ServiceOption.id is an integer.

🐛 Proposed fix
   if (typeof value === 'string' && value !== '') {
-    const n = Number(value);
-    return Number.isFinite(n) ? n : null;
+    const trimmed = value.trim();
+    if (!/^-?\d+$/.test(trimmed)) {
+      return null;
+    }
+    const n = Number(trimmed);
+    return Number.isSafeInteger(n) ? n : null;
   }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@ui/packages/sep/framework/src/utils/extractId.ts` around lines 29 - 41,
Update extractId to reject whitespace-only strings by trimming before
validation, and accept string IDs only when they represent decimal integers.
Preserve finite numeric handling and recursive object id extraction, while
rejecting values such as "1.5", "0x10", and blank strings with null.
ui/packages/sep/framework/src/hooks/useTaskHistoryFiles.ts-41-49 (1)

41-49: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Add a production route for /files/*. PMM’s production nginx configuration defines no /files location or SEP upstream. The Vite proxy applies only in development, so these requests do not reach SEP in production. Add the production proxy or use the deployed SEP route before enabling the download affordance.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@ui/packages/sep/framework/src/hooks/useTaskHistoryFiles.ts` around lines 41 -
49, Update the request path used by the task-history file download flow in the
hook’s queryFn so production requests target the deployed SEP route rather than
relying on the development-only Vite `/files` proxy. Ensure the resulting URL
matches the production routing configuration before exposing the download
affordance.
ui/packages/sep/framework/test/setup.ts-18-18 (1)

18-18: 📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Two competing Vitest setup files exist in the framework package. The package adds test/setup.ts and tests/setup.ts in sibling directories. Both register the jest-dom matchers, but only the plural one registers afterEach(cleanup). Whichever path vitest.config.ts lists in setupFiles decides whether rendered components are torn down between tests, so a future edit to that config silently changes DOM isolation for the whole package.

  • ui/packages/sep/framework/test/setup.ts#L18-L18: delete this file, because it omits afterEach(cleanup); without cleanup, queries such as getByRole('tab') can match nodes left over from an earlier test.
  • ui/packages/sep/framework/tests/setup.ts#L18-L24: keep this file as the single setup entry point, and confirm setupFiles in ui/packages/sep/framework/vitest.config.ts resolves to this path.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@ui/packages/sep/framework/test/setup.ts` at line 18, Remove
ui/packages/sep/framework/test/setup.ts because it lacks afterEach(cleanup).
Retain ui/packages/sep/framework/tests/setup.ts as the sole setup entry point,
and update or verify setupFiles in ui/packages/sep/framework/vitest.config.ts
resolves to that plural-path file.
ui/packages/sep/framework/src/components/SchemaDrivenPlugin/SchemaDrivenPlugin.tsx-245-251 (1)

245-251: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Pass submitError, fieldErrors, and capabilities to the renderEditForm slot.

PluginEditPage maps API failures into submitError and fieldErrors at line 199, then passes both to the default SchemaFormRenderer at lines 257-258. The custom slot invocation omits them. A consumer that supplies renderEditForm therefore cannot render the persistent 422 banner or the inline per-field errors, and the mapSubmitError result becomes dead state on that path.

The sibling page keeps the contract complete. PluginTaskEditPage passes capabilities: schema.capabilities, submitError, and fieldErrors to the same RenderFormSlot (see ui/packages/sep/framework/src/components/SchemaDrivenPlugin/PluginTaskEditPage.tsx). Align this call so both pages honor one slot contract.

🐛 Proposed fix
       {renderEditForm?.({
         sections,
         onSubmit: handleSubmit,
         loading: updateEntity.isPending,
         defaultValues,
+        capabilities: schema.capabilities,
         renderField,
+        submitError,
+        fieldErrors,
       }) ?? (
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@ui/packages/sep/framework/src/components/SchemaDrivenPlugin/SchemaDrivenPlugin.tsx`
around lines 245 - 251, Update the renderEditForm invocation in
SchemaDrivenPlugin to pass capabilities, submitError, and fieldErrors alongside
the existing form props, matching the RenderFormSlot contract and the
PluginTaskEditPage implementation. Reuse the values already derived in
PluginEditPage, including schema.capabilities and the mapped error state.
ui/packages/sep/framework/src/components/SchemaDrivenPlugin/SchemaDrivenPlugin.tsx-300-301 (1)

300-301: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Render related-app tabs and routes for multi-entity schemas. The schema contract allows both entities and related_apps. When both are present, the early return bypasses related-app handling, so related-app paths render no route. Add this handling to the multi-entity branch or enforce mutual exclusion in the schema.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@ui/packages/sep/framework/src/components/SchemaDrivenPlugin/SchemaDrivenPlugin.tsx`
around lines 300 - 301, Update the multi-entity handling in SchemaDrivenPlugin
so schemas containing both entities and related_apps still render the
related-app tabs and routes instead of being bypassed by the early return. Reuse
the existing relatedApps and hasRelatedApps flow, or explicitly enforce mutual
exclusion in schema validation if that is the established contract.
ui/packages/sep/framework/src/components/SchemaFormRenderer/fields/ScriptPreviewField.tsx-87-118 (1)

87-118: 📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Fetch the preview through TanStack Query instead of a manual effect.

This effect performs server-state fetching with a hand-rolled debounce, abort handling, and status machine. The repository guidelines require TanStack Query for all server state and forbid bypassing it for API calls. A useQuery keyed by field.endpoint_url plus the serialized dependency values also gives caching and deduplication across remounts of the same field.

Keep the debounce by deriving a debounced key with useState/useEffect, then pass it into the query key and queryFn.

As per coding guidelines: "Use TanStack Query (useQuery, useMutation) for all server state" and "Do not bypass React Query for API calls; use it for caching, deduplication, and background refetching."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@ui/packages/sep/framework/src/components/SchemaFormRenderer/fields/ScriptPreviewField.tsx`
around lines 87 - 118, Replace the manual fetch effect in ScriptPreviewField
with TanStack Query’s useQuery, using field.endpoint_url and the serialized
dependency values in the query key and queryFn. Preserve the existing debounce
by deriving a debounced dependency key with useState/useEffect, and map query
loading, success, and error data to the existing preview state or rendering
contract while retaining request cancellation through the query signal.

Source: Coding guidelines

ui/packages/sep/framework/src/components/SchemaFormRenderer/utils/validationMapper.ts-59-73 (1)

59-73: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Reject invalid numeric values before coercion.

parseInt('2.5', 10) submits 2. parseFloat('3.14invalid') submits 3.14. The current rules do not reject fractional integer values or non-finite numeric values. SchemaFormRenderer sends this coerced output to the backend.

Add numeric validation with Number.isFinite(). Require Number.isInteger() for integer fields. Use Number() during coercion so invalid input is not silently truncated.

Proposed fix
     case 'integer':
     case 'float': {
+      rules.validate = (value: unknown) => {
+        if (value === '' || value === null || value === undefined) {
+          return true;
+        }
+
+        const numericValue = Number(value);
+        if (!Number.isFinite(numericValue)) {
+          return 'Enter a valid number';
+        }
+        if (field.type === 'integer' && !Number.isInteger(numericValue)) {
+          return 'Enter a whole number';
+        }
+        return true;
+      };
       if (field.ge !== undefined) {
         rules.min = {
-      const num =
-        field.type === 'integer'
-          ? parseInt(String(raw), 10)
-          : parseFloat(String(raw));
-      setAtPath(out, field.name, Number.isNaN(num) ? raw : num);
+      const num = Number(raw);
+      const valid =
+        Number.isFinite(num) &&
+        (field.type !== 'integer' || Number.isInteger(num));
+      setAtPath(out, field.name, valid ? num : raw);

Also applies to: 120-129

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@ui/packages/sep/framework/src/components/SchemaFormRenderer/utils/validationMapper.ts`
around lines 59 - 73, Update the numeric validation and coercion logic in the
integer/float handling of validationMapper so values are converted with Number()
rather than parseInt/parseFloat, rejecting non-finite results with
Number.isFinite(). For integer fields, also require Number.isInteger() to reject
fractional input before it reaches the backend; preserve the existing ge/le
range rules.
ui/packages/sep/framework/src/components/SchemaFormRenderer/fields/YamlField.tsx-48-51 (1)

48-51: 📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Use theme typography through sx.

Lines 48-50 hard-code a font family and font size. These values bypass PMM theme settings. Apply the code-font and font-size tokens through sx or a theme-aware styled component.

As per coding guidelines, do not use hard-coded colors, font families, or spacing that bypass the theme; prefer sx with theme tokens, breakpoints, and MUI Typography variants.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@ui/packages/sep/framework/src/components/SchemaFormRenderer/fields/YamlField.tsx`
around lines 48 - 51, Update the input styling in YamlField’s inputProps to use
the theme-aware sx prop with the appropriate typography code-font and font-size
tokens instead of hard-coded fontFamily and fontSize values; preserve the
existing spellCheck setting.

Source: Coding guidelines

ui/packages/sep/framework/src/components/SchemaFormRenderer/fields/FileField.tsx-63-76 (1)

63-76: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Add an accessible name to the file picker button.

IconButton contains only an icon and has no accessible name. Screen-reader users cannot identify the file selection action.

Proposed fix
-                  <IconButton component="label" htmlFor={inputId} edge="end">
+                  <IconButton
+                    aria-label={`Select file for ${field.label}`}
+                    component="label"
+                    htmlFor={inputId}
+                    edge="end"
+                  >
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@ui/packages/sep/framework/src/components/SchemaFormRenderer/fields/FileField.tsx`
around lines 63 - 76, Add an accessible name to the IconButton in the FileField
component, such as an appropriate aria-label describing the file selection
action, while preserving its existing label behavior, icon, and file input
handling.
ui/packages/sep/framework/src/hooks/useTaskLogs.ts-247-260 (1)

247-260: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Log lines without a step are dropped, which conflicts with the execution-events contract.

Line 250 rejects the payload when step is falsy, so a log line with step: '' is discarded. useExecutionEvents treats '' as a valid stepless bucket through STEPLESS_KEY, and ExecutionEventsPanel and LogStepTabs render that bucket as "General". The two streams therefore disagree on the meaning of an empty step. If the backend can emit stepless log lines, this silently loses output. Validate typeof step === 'string' and map '' to the same stepless key that the events path uses.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@ui/packages/sep/framework/src/hooks/useTaskLogs.ts` around lines 247 - 260,
Update the payload validation and key construction in the task-log handler
around offsetsRef to accept empty step strings by validating typeof step ===
'string' instead of treating step as falsy. Normalize step === '' to the shared
STEPLESS_KEY used by useExecutionEvents so stepless logs join the existing
General bucket, while preserving the current offset filtering for other steps.
ui/packages/sep/framework/src/hooks/useExecutionEvents.ts-234-246 (1)

234-246: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Transient stream failures retry without a limit and without user feedback.

onerror returns undefined for every non-sentinel error. fetchEventSource then retries indefinitely. The handler does not set sseError and does not clear sseLoading. If the endpoint fails persistently for a non-401 reason, for example a 500 or a DNS failure, the panel keeps showing the loading state and reconnects forever. Count consecutive failures. After a threshold, set sseError and stop the retry loop by re-throwing.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@ui/packages/sep/framework/src/hooks/useExecutionEvents.ts` around lines 234 -
246, Update the onerror handler in useExecutionEvents to count consecutive
transient stream failures, reset that count after a successful connection, and
after the defined threshold set sseError, clear sseLoading, and re-throw the
error to stop retries. Preserve the existing StreamRetriableAfterRefresh and
StreamFatalError handling, while continuing silent retries below the threshold.

@nachodd

nachodd commented Aug 5, 2026

Copy link
Copy Markdown
Collaborator

@coderabbitai Thanks — went through all 30 major findings. 24 applied in bb8c522d6, 6 declined with reasons below.

Applied — correctness / stability

Finding What changed
client.ts _onRefreshed Handler call wrapped in try/catch so a throwing handler can't reject the shared refresh promise. Confirmed: the call sat inside the async executor, contradicting its own comment.
SchemaFormRenderer section violations Gate moved into handleSubmitEvent (preventDefault + return) and dropped from handleFormSubmit, so RHF never sets isSubmitSuccessful on a blocked submit. Added a regression test that fails on the old code.
PluginTaskEditPage.normalizeChoiceDefaults Now reads/writes via getAtPath/setAtPath, covering dotted one-of names. No structuredClone needed: setAtPath shallow-clones the intermediates it walks (see its docstring), so {...form} already leaves the input untouched — test asserts that.
PluginListPage / PluginDetailPage list_view PluginListPage renders Not found when the optional list_view is absent (guard placed after all hooks); OverviewTab falls back to an empty column set. PluginDetailPage:917 needed no change — that value is only dereferenced inside the multi branch, where PluginEntitySchema.list_view is required.
SchemaListView.formatCellValue Loose null check; date/relative/chip no longer render Invalid Date / NaNd ago / undefined. Test added.
HostSelector error lookup get(errors, name) from react-hook-form.
extractId Decimal-integer regex + Number.isSafeInteger, so ' ', '1.5', '0x10' return null. New test file.
validationMapper Numeric validate rule (Number.isFinite, Number.isInteger for integers) and Number() in coerceFormValues.
useTaskLogs stepless lines typeof step === 'string'; '' already routes to the STEPLESS_STEP_KEY "General" bucket in LogStepTabs.
useExecutionEvents retries Consecutive-failure counter (cap 5, reset on successful open) that sets sseError, clears sseLoading, and re-throws to stop the loop.
StandaloneHostSelector disabled no longer includes isError, so onOpen can retry; the existing test was inverted to cover the new behavior.
ScheduledTasksPanel kwargs Preserved when the response carries it, '{}' only as fallback.
useCascadingField '' sentinel in both the clear path and ready.

Applied — contract / conventions

  • SepPage now gates on the nav's own predicate (user.isPMMAdmin) via Page's roles, so direct navigation is denied too. Note isPMMAdmin is isGrafanaAdmin || orgRole === Admin, which roles alone can't express — hence the computed value rather than a bare [OrgRole.Admin].
  • renderEditForm slot in SchemaDrivenPlugin now receives capabilities, submitError, fieldErrors, matching PluginTaskEditPage.
  • Shared SEP_ATW_PATH / SEP_MYSQL_BACKUPS_PATH in lib/constants.ts, consumed by both router.tsx (paths + routeBase) and navigation.utils.tsx.
  • Renamed to PMM_DEV_SEP_BACKEND_URL / PMM_DEV_SEP_INTERNAL_TOKEN (vite.config.ts, turbo.json, comments).
  • SchemaListView table surface is now mode-aware instead of pinning common.white — PMM does ship dark mode (hooks/theme.ts), so this was a real bug. pmmThemeOptions lives in @percona/percona-ui and is out of scope here.
  • FileField picker button has an aria-label.
  • useResolvedServiceField exposes isError / error.
  • Deleted the orphaned framework/test/setup.ts (unreferenced; vitest.config.ts uses tests/setup.ts).

Declined

  1. Production SEP routing / auth (bootstrap.ts), and /files/* for useTaskHistoryFiles — real gap, but intentional and documented: this is interim Option D (dev proxy + internal token). Production routing plus the postSessionExchange() provider (Option B, SEP-1692) is tracked separately; inventing it here isn't a review fix.
  2. CollectPane dotted-name flattening — dotted names denote nested backend models in SEP: setAtPath/coerceFormValues produce nested objects for every other plugin's submit, so passing them through is the consistent behavior. Flattening to literal "source.path" keys would need a backend-contract confirmation first.
  3. ScriptPreviewField → TanStack Query — deferring. It's a live debounced preview with abort handling; converting it also means adding a query client to the field-level form tests. Worth doing, but not as part of this migration PR.
  4. freeSoloValue.normalizeChange committing the raw string — trimming the committed value is deliberate (it prevents whitespace-padded custom ids). Inner spaces already survive, and MUI does not reset the visible input while the committed value is unchanged, so a trailing space can still be typed.
  5. Test co-location for @sep/api and atw — valid per the guideline, but moving those suites means relocating their shared tests/ helpers (msw-server.ts, queryWrapper.tsx) and touching vitest config; a mechanical follow-up rather than added diff on this PR. @sep/framework is already co-located.
  6. related_apps + entities on one schema — no shipped schema declares both, so this would add an untested path. Happy to take it if the schema contract is meant to allow the combination.

pnpm format, pnpm check-types, pnpm lint (0 errors) and pnpm test (1092 passing) are all green.

@coderabbitai

coderabbitai Bot commented Aug 5, 2026

Copy link
Copy Markdown

Tip

For best results, initiate chat on the files or code changes.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '=== Commit ==='
git show --no-patch --oneline bb8c522d6

echo '=== Changed files relevant to the reported fixes ==='
git show --format='' --name-only bb8c522d6 | rg \
  'client\.ts|SchemaFormRenderer|PluginTaskEditPage|PluginListPage|PluginDetailPage|SchemaListView|HostSelector|extractId|validationMapper|useTaskLogs|useExecutionEvents|StandaloneHostSelector|ScheduledTasksPanel|useCascadingField|SepPage|SchemaDrivenPlugin|constants\.ts|router\.tsx|navigation\.utils\.tsx|vite\.config\.ts|FileField|useResolvedServiceField|freeSoloValue|CollectPane|ScriptPreviewField|useTaskHistoryFiles' || true

echo '=== Symbol map ==='
ast-grep outline ui/packages/sep/api/src/client.ts --items all --match 'refreshAccessToken|_onRefreshed' 2>/dev/null || true
ast-grep outline ui/packages/sep/framework/src/components/SchemaFormRenderer/SchemaFormRenderer.tsx --items all --match 'handleSubmitEvent|handleFormSubmit' 2>/dev/null || true
ast-grep outline ui/packages/sep/framework/src/components/SchemaFormRenderer/utils/fieldPath.ts --items all --match 'getAtPath|setAtPath' 2>/dev/null || true
ast-grep outline ui/packages/sep/framework/src/components/SchemaDrivenPlugin/PluginTaskEditPage.tsx --items all --match 'normalizeChoiceDefaults' 2>/dev/null || true
ast-grep outline ui/packages/sep/framework/src/components/SchemaDrivenPlugin/PluginListPage.tsx --items all --match 'PluginListPage' 2>/dev/null || true
ast-grep outline ui/packages/sep/framework/src/components/SchemaListView/SchemaListView.tsx --items all --match 'formatCellValue' 2>/dev/null || true
ast-grep outline ui/packages/sep/framework/src/components/SchemaFormRenderer/fields/HostField.tsx --items all 2>/dev/null || true
ast-grep outline ui/packages/sep/framework/src/utils/extractId.ts --items all --match 'extractId' 2>/dev/null || true

echo '=== Focused implementation excerpts ==='
rg -n -A18 -B8 '_onRefreshed|refreshAccessToken' ui/packages/sep/api/src/client.ts
rg -n -A24 -B8 'handleSubmitEvent|handleFormSubmit' ui/packages/sep/framework/src/components/SchemaFormRenderer/SchemaFormRenderer.tsx
rg -n -A28 -B8 'normalizeChoiceDefaults' ui/packages/sep/framework/src/components/SchemaDrivenPlugin/PluginTaskEditPage.tsx
rg -n -A18 -B8 'function formatCellValue|const formatCellValue' ui/packages/sep/framework/src/components/SchemaListView/SchemaListView.tsx
rg -n -A24 -B8 'function extractId|const extractId' ui/packages/sep/framework/src/utils/extractId.ts
rg -n -A18 -B8 'SEP_ATW_PATH|SEP_MYSQL_BACKUPS_PATH' ui/apps/pmm/src
rg -n -A16 -B8 'roles=|isPMMAdmin' ui/apps/pmm/src/sep/SepPage.tsx

Length of output: 3144


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '=== Local checkout ==='
git rev-parse HEAD
git status --short --branch
git log -1 --oneline

echo '=== Pull request head ==='
gh pr view 5653 --repo percona/pmm --json headRefName,headRefOid,updatedAt,url \
  --jq '.headRefName + " " + .headRefOid + " " + .updatedAt + " " + .url'

echo '=== Reported change indicators in the current checkout ==='
rg -n -A12 -B5 '_onRefreshed|refreshAccessToken' ui/packages/sep/api/src/client.ts || true
rg -n -A16 -B5 'handleSubmitEvent|handleFormSubmit' ui/packages/sep/framework/src/components/SchemaFormRenderer/SchemaFormRenderer.tsx || true
rg -n -A22 -B5 'normalizeChoiceDefaults' ui/packages/sep/framework/src/components/SchemaDrivenPlugin/PluginTaskEditPage.tsx || true
rg -n -A12 -B5 'function extractId|const extractId' ui/packages/sep/framework/src/utils/extractId.ts || true
rg -n -A12 -B5 'SEP_ATW_PATH|SEP_MYSQL_BACKUPS_PATH' ui/apps/pmm/src || true
rg -n -A12 -B5 'roles=|isPMMAdmin' ui/apps/pmm/src/sep/SepPage.tsx || true

Length of output: 11212


@nachodd Verification against the current head of #5653 is not consistent with the reported commit.

The checkout matches PR head 43e92093d40565ae811187ecd0146e3cf287dbb7. It does not contain bb8c522d6.

The inspected files still contain the prior implementations:

  • client.ts calls _onRefreshed(...) outside a try/catch.
  • SchemaFormRenderer.tsx still checks hasSectionViolations inside handleFormSubmit.
  • PluginTaskEditPage.tsx still uses out[field.name]. It does not use getAtPath or setAtPath.
  • extractId.ts still uses Number(value). It accepts values such as whitespace-only strings, decimal strings, and hexadecimal strings.

I cannot mark these findings as addressed on the current PR head. Please push the commit containing the fixes, or update the PR head, and request another verification.

You are interacting with an AI system.

@nachodd

nachodd commented Aug 5, 2026

Copy link
Copy Markdown
Collaborator

Sorry — the commit was local at the time. Pushed now as 1c5963e44 (rebased onto db970422c, the SEP catch-up sync that landed in the meantime; no overlap with these hunks — that commit only added the remote_choice case to fieldDefault).

Head is now 1c5963e44, and it contains all of it: _onRefreshed wrapped in try/catch, the hasSectionViolations gate moved from handleFormSubmit into handleSubmitEvent, normalizeChoiceDefaults on getAtPath/setAtPath, and extractId on a decimal-integer test plus Number.isSafeInteger. Gates after the rebase: check-types clean, 1092 tests passing, lint 0 errors.

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Aug 5, 2026

Copy link
Copy Markdown

@nachodd Acknowledged. I will review the current head at 1c5963e44, including the four reported fixes.

✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
ui/packages/sep/framework/src/utils/extractId.ts (1)

30-43: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Validate direct numeric IDs as safe integers.

The numeric branch accepts 1.5 and unsafe integers. The string branch rejects equivalent invalid IDs. useResolvedServiceField then enables a lookup for the returned value.

Require Number.isSafeInteger(value) in the numeric branch. Add tests for extractId(1.5) and an unsafe integer.

Proposed fix
 export function extractId(value: unknown): number | null {
-  if (typeof value === 'number' && Number.isFinite(value)) {
-    return value;
+  if (typeof value === 'number') {
+    return Number.isSafeInteger(value) ? value : null;
   }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@ui/packages/sep/framework/src/utils/extractId.ts` around lines 30 - 43,
Update extractId so the numeric branch only returns direct values that satisfy
Number.isSafeInteger, matching the existing string parsing rules. Keep the
current finite-number and trimmed-string handling in extractId intact, but
reject 1.5 and unsafe integers before useResolvedServiceField can treat them as
resolvable IDs. Add coverage for extractId(1.5) and an unsafe integer to confirm
null is returned.
ui/packages/sep/framework/src/components/ScheduledTasksPanel/ScheduledTasksPanel.tsx (1)

89-105: 🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

The kwargs preservation logic requires defensive wire-format validation.

The code already acknowledges a backend schema gap in its comment. The issue remains valid: if the Tasks API returns kwargs as a JSON object rather than a string, line 100's check typeof rawKwargs === 'string' fails silently, and the toggle sends '{}', overwriting the task's arguments without error.

The OpenAPI schema declares kwargs absent from PeriodicTaskResponse, yet the code prepares for its presence. The actual runtime shape of kwargs (if returned) must be verified directly from live API traffic or backend implementation, not schema alone, because the schema gap creates this exact mismatch.

The broader issue remains: handleToggleEnabled performs a full-replacement PeriodicTaskUpdate using fields from PeriodicTaskResponse. Every field the response omits or returns in an unexpected shape—not only kwargs—gets overwritten. Consider whether the backend can provide a partial-update route for the enabled toggle, or confirm that the response always carries all required fields in the expected types.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@ui/packages/sep/framework/src/components/ScheduledTasksPanel/ScheduledTasksPanel.tsx`
around lines 89 - 105, Update the `handleToggleEnabled` update assembly in
`ScheduledTasksPanel` so `kwargs` is validated against the actual runtime shape
returned by the Tasks API, not just string-typed assumptions. Preserve `kwargs`
when the response includes a usable value in its real wire format, and only fall
back to `'{}'` when the field is truly missing or unusable. Keep the
`PeriodicTaskUpdate` construction path intact, but verify other fields sourced
from `PeriodicTaskResponse` are not being blindly overwritten by unexpected
shapes from the response.
🧹 Nitpick comments (4)
ui/packages/plugins/atw/tests/ResultsPane.test.tsx (1)

719-727: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Bind the fixture page size to ATW_PAGE_SIZE.

pagesByOffset hardcodes limit: 20, and the callers key their pages at offsets 0 and 20. ResultsPane requests limit: ATW_PAGE_SIZE. If that constant ever changes, the requested offset stops matching the fixture keys, pages[offset] falls back to [], and the cross-page tests fail for a reason unrelated to the behavior they cover. Import the constant and derive the offsets from it.

♻️ Proposed refactor to derive the offsets
-/** Serve `pages` keyed by offset, so a case can select across a page flip. */
-function pagesByOffset(pages: Record<number, unknown[]>, total: number) {
-  return (offset: number) => ({
-    items: pages[offset] ?? [],
-    total,
-    offset,
-    limit: 20,
-  });
-}
+/** Serve `pages` keyed by page index, so a case can select across a page flip. */
+function pagesByOffset(pages: Record<number, unknown[]>, total: number) {
+  return (offset: number) => ({
+    items: pages[offset / ATW_PAGE_SIZE] ?? [],
+    total,
+    offset,
+    limit: ATW_PAGE_SIZE,
+  });
+}

Then key the callers by page index, for example { 0: [FINISHED_EXECUTION], 1: [SECOND_PAGE_EXECUTION] }.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@ui/packages/plugins/atw/tests/ResultsPane.test.tsx` around lines 719 - 727,
Update pagesByOffset to import and use ATW_PAGE_SIZE for its limit, and change
the cross-page fixture callers to key pages by page index rather than hardcoded
offsets. Convert the selected page index to an offset using ATW_PAGE_SIZE before
looking up the fixture, preserving the existing cross-page test behavior when
the page size changes.
ui/packages/sep/framework/src/components/SchemaFormRenderer/SchemaFormRenderer.test.tsx (1)

1404-1440: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Restore the spy even when the assertion fails.

removeEventListener.mockRestore() on Line 1439 runs only on the success path. If the assertion on Line 1435 fails, the spy on window.removeEventListener survives into the remaining tests in this file. Move the restore into afterEach, or use vi.restoreAllMocks() in a teardown hook, so a single failure does not cascade into unrelated failures.

The test itself targets the right invariant: a blocked submit must not set isSubmitSuccessful and must not let useUnsavedChangesGuard drop its beforeunload listener while the form is dirty.

♻️ Proposed refactor to restore in teardown
-    removeEventListener.mockClear();
+    removeEventListener.mockClear();
+    onTestFinished(() => removeEventListener.mockRestore());
 
     await user.click(screen.getByRole('button', { name: /Run/ }));
@@
     expect(removeEventListener).not.toHaveBeenCalledWith(
       'beforeunload',
       expect.any(Function)
     );
-    removeEventListener.mockRestore();
   });

onTestFinished is imported from vitest. An afterEach(() => vi.restoreAllMocks()) at the describe level works equally well.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@ui/packages/sep/framework/src/components/SchemaFormRenderer/SchemaFormRenderer.test.tsx`
around lines 1404 - 1440, Ensure the removeEventListener spy in the
blocked-submit test is always restored, including when an assertion fails. Move
cleanup from the test body into an afterEach teardown using
vi.restoreAllMocks(), or the equivalent test-finished hook, while preserving the
existing assertion and test behavior.
ui/packages/sep/api/src/client.ts (1)

157-163: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Leave a trace when the refresh handler throws.

The isolation is correct. A throwing _onRefreshed must not reject the shared promise and log out every awaiting caller whose cookie rotation already succeeded on the backend.

The silent catch has one cost. If _onRefreshed throws, the auth layer never records the new token or its expiry, yet refreshAccessToken returns the token as though it were fully applied. The application then runs with inconsistent auth state and no diagnostic signal. Log the handler failure at error level, and never include data.access_token or data.expires_in in that log.

♻️ Proposed refactor to record the handler failure
       try {
         _onRefreshed(data.access_token, data.expires_in);
-      } catch {
+      } catch (handlerError) {
         // A throwing auth-layer handler must not invalidate a cookie rotation
         // that already succeeded on the backend: it would reject the shared
         // promise and force-logout every awaiting caller.
+        // Recorded without the token so the inconsistent auth state is
+        // diagnosable.
+        console.error(
+          'SEP auth: onRefreshed handler threw; token was not recorded',
+          handlerError
+        );
       }

Use this file's existing development logger instead of console.error if it is available at this scope.

As per coding guidelines: "Do not log, hardcode, or commit secrets, credentials, tokens, S3 keys, TLS material, or debug output".

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@ui/packages/sep/api/src/client.ts` around lines 157 - 163, The try/catch
around _onRefreshed in the refreshAccessToken flow is swallowing handler
failures without any diagnostic signal. Update the catch path to log the thrown
error at error level using the existing development logger available in
client.ts, and keep the log free of data.access_token and data.expires_in or any
other secret values. Preserve the current behavior of not rejecting the shared
refresh promise or changing the returned token flow; only add the failure trace
in the _onRefreshed handling.

Source: Coding guidelines

ui/packages/sep/framework/src/hooks/useExecutionEvents.ts (1)

249-269: 🩺 Stability & Availability | 🔵 Trivial | 💤 Low value

The onerror return contract is correct, but remove the ineffective ctrl.abort() call on line 268.

I have confirmed the three return behaviors against the 2.0.1 library documentation:

  • Return undefined uses the default retry interval ✓
  • Return 0 triggers an immediate retry ✓
  • Throw an error stops the retry loop permanently ✓

The counter logic is correct: failures 1 through 4 retry silently, and the 5th failure sets sseError, clears sseLoading, sets terminatedCleanly before throwing. This order correctly prevents onclose from overwriting the error message.

However, ctrl.abort() on line 268 is ineffective. The library creates a new internal AbortController for each retry attempt, and calling abort within the onerror callback does not reliably stop the retry loop due to race conditions. The library's documented, reliable way to halt retries is the throw err statement on line 269. Remove line 268 to eliminate misleading code that does not accomplish its apparent intent.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@ui/packages/sep/framework/src/hooks/useExecutionEvents.ts` around lines 249 -
269, Remove the ineffective ctrl.abort() call from useExecutionEvents’s onerror
retry-failure path and rely on the existing throw err to stop retries. Keep the
current transientFailures/MAX_TRANSIENT_STREAM_FAILURES logic, the sseError and
sseLoading updates, and the terminatedCleanly assignment in the same
final-failure branch. Ensure the change is limited to the onerror handler in
useExecutionEvents so the documented return/throw contract remains unchanged.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In
`@ui/packages/sep/framework/src/components/ScheduledTasksPanel/ScheduledTasksPanel.tsx`:
- Around line 89-105: Update the `handleToggleEnabled` update assembly in
`ScheduledTasksPanel` so `kwargs` is validated against the actual runtime shape
returned by the Tasks API, not just string-typed assumptions. Preserve `kwargs`
when the response includes a usable value in its real wire format, and only fall
back to `'{}'` when the field is truly missing or unusable. Keep the
`PeriodicTaskUpdate` construction path intact, but verify other fields sourced
from `PeriodicTaskResponse` are not being blindly overwritten by unexpected
shapes from the response.

In `@ui/packages/sep/framework/src/utils/extractId.ts`:
- Around line 30-43: Update extractId so the numeric branch only returns direct
values that satisfy Number.isSafeInteger, matching the existing string parsing
rules. Keep the current finite-number and trimmed-string handling in extractId
intact, but reject 1.5 and unsafe integers before useResolvedServiceField can
treat them as resolvable IDs. Add coverage for extractId(1.5) and an unsafe
integer to confirm null is returned.

---

Nitpick comments:
In `@ui/packages/plugins/atw/tests/ResultsPane.test.tsx`:
- Around line 719-727: Update pagesByOffset to import and use ATW_PAGE_SIZE for
its limit, and change the cross-page fixture callers to key pages by page index
rather than hardcoded offsets. Convert the selected page index to an offset
using ATW_PAGE_SIZE before looking up the fixture, preserving the existing
cross-page test behavior when the page size changes.

In `@ui/packages/sep/api/src/client.ts`:
- Around line 157-163: The try/catch around _onRefreshed in the
refreshAccessToken flow is swallowing handler failures without any diagnostic
signal. Update the catch path to log the thrown error at error level using the
existing development logger available in client.ts, and keep the log free of
data.access_token and data.expires_in or any other secret values. Preserve the
current behavior of not rejecting the shared refresh promise or changing the
returned token flow; only add the failure trace in the _onRefreshed handling.

In
`@ui/packages/sep/framework/src/components/SchemaFormRenderer/SchemaFormRenderer.test.tsx`:
- Around line 1404-1440: Ensure the removeEventListener spy in the
blocked-submit test is always restored, including when an assertion fails. Move
cleanup from the test body into an afterEach teardown using
vi.restoreAllMocks(), or the equivalent test-finished hook, while preserving the
existing assertion and test behavior.

In `@ui/packages/sep/framework/src/hooks/useExecutionEvents.ts`:
- Around line 249-269: Remove the ineffective ctrl.abort() call from
useExecutionEvents’s onerror retry-failure path and rely on the existing throw
err to stop retries. Keep the current
transientFailures/MAX_TRANSIENT_STREAM_FAILURES logic, the sseError and
sseLoading updates, and the terminatedCleanly assignment in the same
final-failure branch. Ensure the change is limited to the onerror handler in
useExecutionEvents so the documented return/throw contract remains unchanged.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 8b4c0fd6-e168-499e-8aca-d24b2e337e13

📥 Commits

Reviewing files that changed from the base of the PR and between 3324c95 and 1c5963e.

📒 Files selected for processing (34)
  • ui/apps/pmm/src/contexts/navigation/navigation.utils.tsx
  • ui/apps/pmm/src/lib/constants.ts
  • ui/apps/pmm/src/router.tsx
  • ui/apps/pmm/src/sep/SepPage.tsx
  • ui/apps/pmm/src/sep/bootstrap.ts
  • ui/apps/pmm/vite.config.ts
  • ui/packages/plugins/atw/src/ResultsPane.tsx
  • ui/packages/plugins/atw/tests/ResultsPane.test.tsx
  • ui/packages/sep/api/src/auth.ts
  • ui/packages/sep/api/src/client.ts
  • ui/packages/sep/framework/src/components/HostSelector/HostSelector.tsx
  • ui/packages/sep/framework/src/components/HostSelector/StandaloneHostSelector.test.tsx
  • ui/packages/sep/framework/src/components/HostSelector/StandaloneHostSelector.tsx
  • ui/packages/sep/framework/src/components/RemoteChoiceSelector/RemoteChoiceSelector.test.tsx
  • ui/packages/sep/framework/src/components/RemoteChoiceSelector/RemoteChoiceSelector.tsx
  • ui/packages/sep/framework/src/components/ScheduledTasksPanel/ScheduledTasksPanel.tsx
  • ui/packages/sep/framework/src/components/SchemaDrivenPlugin/PluginDetailPage.tsx
  • ui/packages/sep/framework/src/components/SchemaDrivenPlugin/PluginListPage.tsx
  • ui/packages/sep/framework/src/components/SchemaDrivenPlugin/PluginTaskEditPage.test.tsx
  • ui/packages/sep/framework/src/components/SchemaDrivenPlugin/PluginTaskEditPage.tsx
  • ui/packages/sep/framework/src/components/SchemaDrivenPlugin/SchemaDrivenPlugin.tsx
  • ui/packages/sep/framework/src/components/SchemaFormRenderer/SchemaFormRenderer.test.tsx
  • ui/packages/sep/framework/src/components/SchemaFormRenderer/SchemaFormRenderer.tsx
  • ui/packages/sep/framework/src/components/SchemaFormRenderer/fields/FileField.tsx
  • ui/packages/sep/framework/src/components/SchemaFormRenderer/hooks/useCascadingField.ts
  • ui/packages/sep/framework/src/components/SchemaFormRenderer/utils/validationMapper.ts
  • ui/packages/sep/framework/src/components/SchemaListView/SchemaListView.test.tsx
  • ui/packages/sep/framework/src/components/SchemaListView/SchemaListView.tsx
  • ui/packages/sep/framework/src/hooks/useExecutionEvents.ts
  • ui/packages/sep/framework/src/hooks/useResolvedServiceField.ts
  • ui/packages/sep/framework/src/hooks/useTaskLogs.ts
  • ui/packages/sep/framework/src/utils/extractId.test.ts
  • ui/packages/sep/framework/src/utils/extractId.ts
  • ui/turbo.json
🔗 Linked repositories identified

CodeRabbit considers these linked repositories for cross-repo context during reviews:

  • percona/pmm-qa (manual)
  • percona/pmm (manual)
🚧 Files skipped from review as they are similar to previous changes (19)
  • ui/apps/pmm/vite.config.ts
  • ui/apps/pmm/src/contexts/navigation/navigation.utils.tsx
  • ui/apps/pmm/src/router.tsx
  • ui/packages/sep/framework/src/components/SchemaDrivenPlugin/PluginTaskEditPage.test.tsx
  • ui/packages/sep/framework/src/components/SchemaFormRenderer/hooks/useCascadingField.ts
  • ui/packages/sep/api/src/auth.ts
  • ui/packages/sep/framework/src/components/SchemaDrivenPlugin/SchemaDrivenPlugin.tsx
  • ui/packages/sep/framework/src/components/HostSelector/StandaloneHostSelector.test.tsx
  • ui/packages/sep/framework/src/components/SchemaFormRenderer/SchemaFormRenderer.tsx
  • ui/packages/sep/framework/src/components/RemoteChoiceSelector/RemoteChoiceSelector.tsx
  • ui/packages/sep/framework/src/components/SchemaFormRenderer/fields/FileField.tsx
  • ui/packages/sep/framework/src/components/SchemaDrivenPlugin/PluginListPage.tsx
  • ui/packages/sep/framework/src/components/SchemaListView/SchemaListView.tsx
  • ui/packages/sep/framework/src/components/SchemaFormRenderer/utils/validationMapper.ts
  • ui/packages/sep/framework/src/components/SchemaDrivenPlugin/PluginTaskEditPage.tsx
  • ui/packages/sep/framework/src/components/HostSelector/HostSelector.tsx
  • ui/packages/sep/framework/src/hooks/useTaskLogs.ts
  • ui/packages/plugins/atw/src/ResultsPane.tsx
  • ui/packages/sep/framework/src/components/SchemaDrivenPlugin/PluginDetailPage.tsx

nachodd added a commit to percona/SEP that referenced this pull request Aug 5, 2026
CodeRabbit reviewed the PMM side again (percona/pmm#5653) after the first
round landed there; these are the follow-ups, ported here for the same
reason as the original set.

- `extractId`: hold the numeric branch to the same bar as the string one.
`Number.isSafeInteger` rejects 1.5 and unsafe integers, which previously
passed straight through and enabled a service lookup nothing can satisfy —
the exact asymmetry the string branch was tightened to close.
- `ScheduledTasksPanel`: the preserved `kwargs` now accepts either wire
shape. `PeriodicTaskResponse` does not declare the field, so a decoded
object is as likely as a JSON string, and only the string case was kept —
the object case fell back to '{}' and wiped the arguments this guard
exists to protect. A partial-update route for the toggle would remove the
whole class of problem; that is a backend change.
- `client.ts`: log when the injected `_onRefreshed` handler throws. The
isolation is right, but swallowing it silently left the auth layer without
the rotated token while `refreshAccessToken` returned it as applied, with
no diagnostic. The trace carries neither the token nor its expiry.
- `SchemaFormRenderer` test: restore the `removeEventListener` spy through
`onTestFinished`, so a failing assertion cannot leak the spy into the rest
of the file.
- `HostSelector` had the same wedge as `StandaloneHostSelector`, in all
three of its branches: the control was disabled on a hosts-query failure
while `onOpen` held the only `refetch()` trigger, so one failure blocked
recovery until the page remounted. Not flagged by the review — the diff
hunk did not reach those lines — but the same defect, so it is fixed here
rather than left behind.

Declined, with the reasoning recorded on the PMM PR: removing
`ctrl.abort()` from `useExecutionEvents`' terminal path. The claim was
that the library creates a fresh `AbortController` per retry, so aborting
ours is ineffective. `fetch-event-source@2.0.1` registers an `abort`
listener on the signal we pass and calls its own `dispose()` from it,
which clears the retry timer and aborts the in-flight request;
`curRequestController` is a separate internal controller. Every other
terminal path in this file (`finish`, `sep-error`) stops the stream the
same way, so the call stays.

Gates: type-check clean across every package, oxlint 0 errors, 1178 tests
pass (743 in @sep/framework), oxfmt clean.
@nachodd

nachodd commented Aug 5, 2026

Copy link
Copy Markdown
Collaborator

Second pass addressed in 1e1a6ab3e. Four applied, two declined.

Applied

  • extractId numeric branch — correct, and the asymmetry was mine to begin with: I tightened the string branch and left 1.5 passing through the numeric one. Now Number.isSafeInteger, with cases for 1.5, MAX_SAFE_INTEGER + 1, and { id: 1.5 } (the recursive path).
  • kwargs wire format — right that a string-only check is a guess. Both shapes are now accepted: a JSON string passes through, a decoded object is re-serialised with JSON.stringify, and '{}' is left only for a genuinely missing or blank value. On the broader point: a partial-update route for the toggle would delete the whole class of problem, but that is a backend change — the client can only stop making it worse.
  • Log the throwing refresh handler — agreed, the silent catch traded one failure mode for an invisible one. Logged at error level via the file's existing console pattern, with neither the token nor the expiry in the message.
  • Spy restore in teardown — fixed with onTestFinished.

Declined

  • Removing ctrl.abort() from useExecutionEvents' terminal path. The premise doesn't hold for @microsoft/fetch-event-source@2.0.1. From lib/cjs/fetch.js:

    inputSignal?.addEventListener('abort', () => {
      dispose();      // clears retryTimer and aborts curRequestController
      resolve();
    });

    curRequestController is the library's own per-attempt controller; the signal we pass is inputSignal, and aborting it runs dispose() — clearing the retry timer and aborting the in-flight request. So the call is effective, not misleading. It is redundant alongside throw err, which is why I'm keeping it: finish and sep-error in this same hook both stop the stream by aborting that controller, and having the third terminal path do something different would be the confusing version. Thanks for checking the return contract against the library docs, though — that part matched what I'd concluded.

  • Binding the ATW pagesByOffset fixture to ATW_PAGE_SIZE. Sound in isolation, but that file is a mirror of SEP's packages/apps/atw test, kept aligned so the periodic sync between the two trees stays a clean no-op. A cosmetic edit on one side only makes the next sync diff noisier for a constant that has never changed. If it moves, it should move in SEP first.

Two extra fixes in the same commit, same defect class as the reviewed ones, found while porting this to SEP:

  • HostSelector had the identical wedge to StandaloneHostSelector — both its free-solo and standard branches passed disabled={disabled || isError} while onOpen held the only refetch(). The review's diff hunk didn't reach those lines. Its existing "disables the input on error" test was inverted to cover retry-on-open.
  • SchemaListView.formatCellValue now renders an absent date / relative value as an empty cell rather than an em dash, adopting SEP's variant of the guard I ported: an em dash for a task that never ran reads as a placeholder for a time that exists. SEP was ahead of the port here.

Gates: check-types clean, oxlint 0 errors, 1105 tests pass, oxfmt clean.

nachodd and others added 7 commits August 5, 2026 17:12
Brings SEP's frontend packages into ui/ and mounts the migrated plugins as
native PMM routes, so SEP surfaces render inside the PMM shell instead of an
iframe. Builds on PMM-15288, which moved the workspace to pnpm and the
library versions SEP's code targets.

Packages, ported from SEP's frontend workspace:
- packages/sep/api       — typed API client, generated OpenAPI surfaces, hooks
- packages/sep/framework  — schema-driven form/list/task components
- packages/sep/shared     — shared primitives
- packages/plugins/atw    — Collect Diagnostic Data (ATW)

SEP's "app" vocabulary is renamed to "plugin" throughout the port, since "app"
already means a workspace app in ui/: SchemaDrivenApp -> SchemaDrivenPlugin,
useAppSchema -> usePluginSchema, useAppTasks -> usePluginTasks, app-schema.ts
-> plugin-schema.ts. Ported files also carry PMM's AGPL header.

Wiring in apps/pmm:
- router.tsx mounts the plugins under their own routes; SepPage gives them the
  standard PMM Page chrome (padding, width, auth gate, footer).
- navigation gates the SEP entries behind admin + the inventory settings flag.
- main.tsx calls initSepAuth, which points SEP's axios client at PMM's session.
  Auth is still the interim Option D: the dev proxy injects SEP_INTERNAL_TOKEN
  server-side, so no token reaches the browser.
- vite.config.ts proxies SEP's paths (/api, /sep_app, /stream-logs,
  /execution-events, /files) to SEP_BACKEND_URL, and lets PMM_SERVER_URL
  override the PMM target.
- A SyntaxHighlighter component backs the schema renderer's script/JSON fields.

Page gains maxWidth so SEP pages can opt into the full-width container from
@percona/percona-ui; Settings.tsx moves to it in place of the removed
fullWidth flag.

*.tsbuildinfo is gitignored; one had been committed by accident.

Signed-off-by: Ignacio Durand <nachodurand@gmail.com>
Catches the migrated packages up with SEP's frontend, which moved on after
the initial port. Ported commit by commit rather than by copying files, so
PMM's app -> plugin rename and license headers survive.

- SEP-1629 / SEP-1684 / SEP-1689 / SEP-1696 / SEP-1668: refresh the generated
  OpenAPI surface (specs/*.json + src/generated/*.ts) from SEP head. Two
  schema components are now namespaced — ConnectivityWarning and
  TaskExecuteWrite became framework__ConnectivityWarning and
  framework__TaskExecuteWrite — so their consumers move with them.
- SEP-1663: honor HostRef/HostField `allow_custom`. HostField passes it to
  HostSelector, which renders FreeSoloSelect instead of the closed
  AutoCompleteInput and commits a scalar id/string (including from cascade
  auto-select). FreeSoloSelect resolves a stored string against option ids,
  not just labels, so string host ids like "nomad-1" display as their option.
  SEP's multi-host half (FreeSoloMultiSelect, MultiHostField) is not ported —
  PMM's snapshot has no multi-host selector to extend.
- SEP-1653: hide the task-history Download files button unless the files API
  returns a non-empty listing. `has_logs` was the wrong signal: logs exist
  even when the output dir holds only the hidden .sep-run-result.json marker,
  which left a dead download action. Probes are cached for 30s so the history
  table's poll loop does not re-hit the files API every tick.
- SEP-1692: add postSession / postSessionExchange to @sep/api. The exchange
  endpoint trades PMM's session cookie for a short-lived SEP bearer, which is
  what replaces the interim SEP_INTERNAL_TOKEN wiring — that token's service
  principal hardcodes is_admin = False and so 403s every admin-gated surface.
  Only the client surface lands here; flipping bootstrap.ts over to it needs
  a SEP backend carrying the endpoint and is left to its own change.

Signed-off-by: Ignacio Durand <nachodurand@gmail.com>
The interim SEP dev proxy could never see SEP_INTERNAL_TOKEN, so every
call to the SEP backend from the migrated pages returned 401.

Two independent faults:

turbo.json declared no passThroughEnv, and Turbo 2.x defaults to
envMode strict, which strips undeclared variables before spawning a
task. vite was therefore started without the variable no matter how it
was exported. Declare the three variables vite.config.ts reads.

Vite exposes .env files to client code as import.meta.env but never to
the config file's own process.env, so the only working setup was an
export in the exact shell launching the dev server, and anything else
fell back to the defaults silently. Load the files explicitly with
loadEnv, keeping real environment variables ahead of file values so CI
and the devcontainer are unaffected.

PMM_SERVER_URL was broken the same way and is fixed by the same change.

Signed-off-by: Ignacio Durand <nachodurand@gmail.com>
…ch-up

SEP moved on again after the previous sync. Of the eleven commits touching
the mirrored packages, nine were already carried; the two that were not both
ship frontend code. The rest are spec/generated-only or belong to apps PMM has
not migrated.

- SEP-1666: page-scoped select-all on the ATW Results pane. The header toggle
  reuses `isSelectable`, so it can never disagree with the row checkboxes about
  which rows are eligible, and deselecting removes only the current page's ids
  — the selection deliberately outlives a page flip.
- SEP-1631: make a cascading RemoteChoices field usable with `allow_custom`.
  Free-text entry no longer needs the parent (or a successful fetch) before it
  is typable, a typed value survives the parent being set afterwards, and
  `required` now rejects whitespace-only input. SchemaFormRenderer defaults a
  `remote_choice` field to `null` rather than `''`, which the backend's
  NonEmptyStr rejects.

One deviation from SEP: SEP-1666's test asserts `aria-checked="mixed"` on the
select-all toggle, which MUI only emits after 7.3.7 — the version this repo's
lockfile resolves, against SEP's 7.3.11. The helper here also accepts the
`data-indeterminate` attribute MUI documents for 7.3.7, so the case covers the
same state on both. PMM-15296 tracks bumping MUI and dropping the shim.

Gates (node 22): @sep/plugins-atw 55 and @sep/framework 610 tests pass,
check-types clean across framework/atw/ui, oxfmt clean tree-wide.
Correctness / stability:
- client.ts: isolate a throwing _onRefreshed so a successful cookie
  rotation is not reported as a failed refresh.
- SchemaFormRenderer: gate a section-violation submit before
  react-hook-form runs, so the unsaved-changes guard stays armed.
- PluginTaskEditPage: normalize choice defaults through getAtPath /
  setAtPath so dotted one-of field names are covered.
- PluginListPage / PluginDetailPage: guard the optional list_view before
  dereferencing it on an unresolved entity route.
- SchemaListView: treat undefined cell values like null (em dash).
- HostSelector: path-aware error lookup for dotted field names.
- extractId: accept only decimal integers.
- validationMapper: reject non-finite and fractional numeric input
  instead of silently truncating it.
- useTaskLogs: keep stepless log lines (step: '') instead of dropping them.
- useExecutionEvents: bound transient stream retries and surface the
  failure instead of reconnecting forever in the loading state.
- StandaloneHostSelector: stay enabled on a failed hosts query so onOpen
  can retry.
- ScheduledTasksPanel: preserve kwargs on an enable/disable toggle.
- useCascadingField: use '' as the form's empty-value sentinel.

Contract / conventions:
- SepPage: enforce the PMM-admin gate at the route wrapper, not only in
  the navigation.
- SchemaDrivenPlugin: pass capabilities / submitError / fieldErrors to
  the renderEditForm slot, matching PluginTaskEditPage.
- Share SEP route constants between the router and the nav builder.
- Rename the dev-only proxy variables to PMM_DEV_SEP_BACKEND_URL and
  PMM_DEV_SEP_INTERNAL_TOKEN.
- SchemaListView: mode-aware opaque table surface instead of common.white.
- FileField: give the file picker button an accessible name.
- useResolvedServiceField: expose the services fetch error.
- Delete the orphaned framework test/setup.ts (no afterEach(cleanup)).

Signed-off-by: Ignacio Durand <nachodurand@gmail.com>
- extractId: hold the numeric branch to the same bar as the string one.
  `Number.isSafeInteger` rejects 1.5 and unsafe integers, which previously
  passed straight through and enabled a service lookup nothing can satisfy.
- ScheduledTasksPanel: the preserved `kwargs` now accepts either wire
  shape. `PeriodicTaskResponse` does not declare the field, so a decoded
  object was as likely as a JSON string, and only the string case was
  kept — the object case fell back to '{}' and wiped the arguments it was
  added to protect.
- client.ts: log when the injected `_onRefreshed` handler throws. The
  isolation is right, but swallowing it silently left the auth layer
  without the rotated token while this function returned it as applied,
  with no diagnostic. The trace carries neither token nor expiry.
- SchemaFormRenderer test: restore the `removeEventListener` spy through
  `onTestFinished` so a failing assertion cannot leak it into the rest of
  the file.

Two fixes of the same class as the reviewed ones, found while porting
this to SEP:

- HostSelector had the same wedge as StandaloneHostSelector: both its
  free-solo and standard branches disabled the control on a hosts-query
  failure while `onOpen` held the only `refetch()` trigger, so one failure
  blocked recovery until the page remounted.
- SchemaListView.formatCellValue now renders an absent `date` / `relative`
  value as an empty cell instead of an em dash, adopting SEP's variant of
  this guard: an em dash for a task that has never run reads as a
  placeholder for a time that exists. Keeps the mirrored file aligned with
  its source.

Declined, with reasons on the PR: removing `ctrl.abort()` from
useExecutionEvents' terminal path (the library disposes on the signal we
pass — its own controller is separate — and every other terminal path here
aborts the same way), and binding the ATW ResultsPane fixture to
ATW_PAGE_SIZE (trivial, and that file mirrors SEP's).

Gates: check-types clean, oxlint 0 errors, 1105 tests pass, oxfmt clean.

Signed-off-by: Ignacio Durand <nachodurand@gmail.com>
`Number('   ')` is 0, so a numeric field containing only whitespace passed
the validate rule and `coerceFormValues` submitted 0 for a value the user
never typed. RHF's built-in `required` rule does not fire on it either,
since the string is non-empty.

Both the validate rule and the coercion now trim string input and treat a
trimmed-empty string as empty: required fields report the required error,
optional fields serialise as absent.

Ported from SEP-1760 (percona/SEP#1283, 4bf7adcc4), where a Copilot review
caught it on the same code.

Signed-off-by: Ignacio Durand <nachodurand@gmail.com>
yyyyyyyan pushed a commit to percona/SEP that referenced this pull request Aug 7, 2026
)

The CodeRabbit review of the SEP-UI-into-PMM migration PR
([percona/pmm#5653](percona/pmm#5653),
PMM-15216) raised these findings against the ported copy of `@sep/api` /
`@sep/framework`. Every one of them is present in the original, so they
are fixed here too — otherwise the next sync in either direction
re-introduces them. PMM-only wiring (route constants, the route-level
admin gate, the dev-proxy env variables) is out of scope.

## Token-minter seam (upstreamed, not a review fix)

The last commit is a different kind of change from the rest of this PR,
so it is called out separately: it upstreams a capability PMM needed
([percona/pmm#5739](percona/pmm#5739),
PMM-15293) rather than fixing a review finding. It lands here for the
same reason as everything else — otherwise the next sync clobbers it.

`refreshAccessToken()` hardcoded `POST /oauth/refresh` as the only way
to obtain a token. That is correct for this SPA. It is not workable for
PMM, which embeds SEP with no refresh cookie at all: it trades its own
session cookie for a bearer through `POST /oauth/session/exchange`
(SEP-1692), so the default 401s on every recovery attempt and the entire
retry path is dead weight there.

- **`setTokenMinter()`** replaces just that one call, defaulting to the
existing behaviour — **nothing changes for a standalone SEP
deployment**. Everything downstream was already minter-agnostic: the
single-flight coalescer, the axios 401 retry, and the `setOnRefreshed`
notification do not care which endpoint produced the token.
- **`isTokenMintRequest`** composes the existing `isRefreshRequest` and
`isSessionRequest` guards — which the axios retry condition already
listed separately — and is exported so the typed client shares one
definition rather than growing a second copy. Keeping them together is
load-bearing: minting is single-flighted, so letting a mint's own 401
into the retry path hands the interceptor the very promise it is running
inside, an await on itself that never settles. There is a regression
test that times out rather than fails if that guard is lost.
- **The `openapi-fetch` transport gained the 401 retry the axios one
already had.** This one is a fix for *both* deployments: it previously
only reported unauthorized, so every typed hook — `useCurrentUser` and
all the generated-path ones — surfaced an expired token as a failure
instead of recovering. `fetch` consumes a Request's body, so the
middleware stashes a clone taken before dispatch and replays that; only
replay-eligible requests are cloned, and the replay goes through raw
`fetch` so it cannot re-enter the middleware and loop.

`@sep/api` grows 10 tests for this: minting through a registered minter,
coalescing a burst of 401s into one exchange, the self-await regression
guard, a null-resolving minter, restoring the default, and on the typed
side mint-and-replay, replaying a request body, replaying at most once,
one mint across concurrent 401s, and no recovery attempt on a minting
endpoint's own 401.

**Correctness**

- **`refreshAccessToken`** called the injected `_onRefreshed` handler
inside the async executor, so a synchronous throw from it rejected the
shared `refreshInFlight` promise — every awaiting caller saw a failed
refresh, and a force-logout, for a cookie rotation that had already
succeeded on the backend. The function's own comment says this must not
happen.
- **`SchemaFormRenderer`** returned from `handleFormSubmit` on a section
violation, which react-hook-form reads as a *successful* submit
(`isSubmitSuccessful = true`). `useUnsavedChangesGuard` is `isDirty &&
!isSubmitSuccessful` and only re-arms when `submitError` is truthy —
never on this path — so the guard stayed disarmed: no `beforeunload`
prompt, no `UnsavedChangesBlocker`, and the user could navigate away
from a dirty form and lose it. The gate now runs in the submit event
handler, ahead of `handleSubmit`.
- **`normalizeChoiceDefaults`** read and wrote flat keys, but
`flattenSectionFields` also returns `one_of` branch fields, whose names
are dotted paths stored nested. A case-mismatched nested choice value
was never canonicalised and rendered as an empty selection — the exact
failure that function exists to prevent.
- **`AppListPage` / `AppDetailPage`** dereferenced the optional
`list_view` (`schema.list_view!.columns`), reachable through an
unresolved entity route. The list page now renders `Not found`; the
Overview tab falls back to an empty column set and still lists the
task's own fields.
- **`HostSelector`** looked up `errors[name]`, which never resolves for
a dotted branch-field name, so an affected field showed no validation
error.
- **`extractId`** used `Number`, which turns a whitespace-only string
into `0` and accepts `'1.5'` / `'0x10'`. Each result reads as a
resolvable inventory id downstream: `useResolvedServiceField` enables a
lookup for service `0`, and `SchemaSelector` fires `useSchemas({
serviceId: 0 })` for a service that cannot exist.
- **`validationMapper`** submitted `parseInt('2.5', 10)` as `2` and
`parseFloat('3.14invalid')` as `3.14`. Numeric fields now validate with
`Number.isFinite` (plus `Number.isInteger` for `integer`) and coerce
with `Number`.
- **`useTaskLogs`** guarded on `!step`, dropping a log line with `step:
''`. `useExecutionEvents` treats `''` as the stepless bucket and the
viewer labels it "General", so the two streams disagreed and stepless
output was silently lost.

**Stability**

- **`useExecutionEvents`** returned `undefined` from `onerror` for every
non-sentinel error, so `fetchEventSource` retried forever while
`sseError` stayed unset and `sseLoading` stayed true — a persistently
failing endpoint (500, DNS failure) left the panel spinning and
reconnecting indefinitely. Consecutive failures are now counted, reset
on a successful open, and the loop stops with the error surfaced.
- **`StandaloneHostSelector`** disabled its Autocomplete when the hosts
query failed, but `onOpen` holds the only `refetch()` trigger and a
disabled Autocomplete never opens — one failure wedged the control until
the page remounted.
- **A scheduled-task enable/disable toggle** sent `kwargs: '{}'` in a
full PUT, wiping the arguments of any task created with non-default
kwargs. `kwargs` is preserved when the response carries it; `'{}'` stays
the fallback until `PeriodicTaskResponse` declares the field.

**Contract and consistency**

- **`useCascadingField`** cleared with `undefined` while
`buildFormDefaults` seeds these selector types to `''`, and counted `''`
as ready — a downstream selector fetched options for an empty parent,
and a bound MUI input flipped from controlled to uncontrolled.
- **`SchemaDrivenApp`**'s `renderEditForm` invocation omitted
`capabilities`, `submitError` and `fieldErrors`, so a consumer supplying
the slot could render neither the 422 banner nor the inline field
errors. `AppTaskEditPage` already passes all three to the same slot
type.
- **`SchemaListView`** pinned `bgcolor: 'common.white'`, which renders a
white table in dark mode; it now reads the mode's own opaque surface.
- **`FileField`**'s file-picker `IconButton` had no accessible name.
- **`useResolvedServiceField`** discarded `useServices`' error, so
callers could not tell a failed lookup from an id that matched no
service.
- **`packages/framework/test/setup.ts`** was an unreferenced sibling of
`tests/setup.ts` registering the jest-dom matchers but no
`afterEach(cleanup)`. Deleted, so a future `setupFiles` edit cannot
silently drop DOM isolation for the package.

**Deliberately not ported**, matching the decisions recorded on the PMM
PR: production SEP routing / token exchange, flattening dotted app
argument names (they denote nested backend models, which
`coerceFormValues` already produces), converting `ScriptPreviewField` to
TanStack Query, committing untrimmed free-solo input, relocating the
`api` / `atw` test suites, `related_apps` alongside `entities`, and the
hard-coded `Roboto Mono` stacks. `formatCellValue`'s `undefined` guard
is already here — in a better form than the port has, which should go
back to PMM.
@nachodd
nachodd marked this pull request as ready for review August 7, 2026 23:00
Copilot AI lite review requested due to automatic review settings August 7, 2026 23:00

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot wasn't able to review this pull request because it exceeds the maximum number of lines (20,000). Try reducing the number of changed lines and requesting a review from Copilot again.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

8 participants