Skip to content

refactor(slm-frontend): route the getSlmApiBase() call sites through slmApiClient (#13140) - #13170

Merged
mrveiss merged 3 commits into
Dev_new_guifrom
issue-13140-slmapibase
Jul 31, 2026
Merged

mrveiss merged 3 commits into
Dev_new_guifrom
issue-13140-slmapibase

Conversation

@mrveiss

@mrveiss mrveiss commented Jul 31, 2026

Copy link
Copy Markdown
Owner

Second slice of #13140, after PR #13169 (824f4576b) converted the 23 sites that resolved their base URL through stores/auth.ts:getApiUrl(). This one takes the 30 sites that already call getSlmApiBase() — every remaining SLM-backend fetch in the app. #13140 stays open (definition-of-done item 4, the ESLint rule, is not landed here), so this PR deliberately carries no Closes keyword; the advisory PR-link gate is left red as the honest signal for partial delivery (#12995).

Thinking Path

Re-measured first. 50 raw fetch( sites in autobot-slm-frontend/src (tests excluded), down from the 73 the first slice measured:

target count disposition
SLM backend via getSlmApiBase() 30 this PR — all of them
autobot backend via getBackendUrl() 17 #13079, a different backend per ADR-008 — untouched
external (Prometheus /-/healthy, Grafana /api/health) 2 legitimately raw
utils/ApiClient.ts:221 1 the client itself

Not every site should become a get(). Two have a genuine transport reason to keep the Response object, and use rawRequest rather than the JSON helpers:

  • usePrometheusMetrics.fetchPrometheusExport reads /performance/metrics/prometheus, which returns the Prometheus text exposition format. get() calls response.json() and would reject on it.
  • useSlmWebSocket.checkBackendHealth is a liveness pre-flight that gates the reconnect backoff. It must stay single-shot and keep a short budget, not the client's 30s default.

A third class keeps rawRequest because the rendered error must not change: ToolsView (6), FleetToolsTab (2), RolesView.apiFetch, InfrastructureWizard.execute and usePerformanceMonitoring.apiRequest all surface the backend's detail body (or, for usePerformanceMonitoring, its own HTTP <n>: <raw body text> string) directly to the operator. post()/get() would flatten those into HTTP <n>: <msg>.

Behaviour changes were decided, not inherited. Four seams needed a call rather than a swap; each is described below and asserted in a test.

What Changed

The retry hazard get() introduces

slmApiClient.get() retries a non-4xx three times with exponential backoff (~3s). That is right for a page load and wrong inside a poll, so a new POLLED_READ_MAX_RETRIES is passed by:

  • usePrometheusMetrics — all 8 JSON reads, on a 30s tick;
  • InfrastructureWizard.pollExecutionStatus — a 1s interval, where the default would leave several ticks in flight at once and make the reported status lag the run it describes.

usePerformanceMonitoring.apiRequest routes through rawRequest for the same reason (plus its error-string contract) — startPolling() re-issues fetchOverview on a timer.

Page loads (InfrastructureView, DeploymentWizard, AgentsView, RolesView) keep the default retry on purpose; a transient 5xx recovers on its own.

The timeout hazard get() introduces

These sites had no timeout at all, and several deliberately run longer than 30s. Handing them the client default would abort a workflow that works today — a regression dressed as a refactor. New src/constants/api-timeouts.ts gives them explicit, named, env-sourced budgets instead of magic numbers at the call site:

  • REMOTE_EXEC_TIMEOUT_MS (5 min) — POST /nodes/{id}/exec ×4, node health, service start/stop/restart, service logs, connection test, POST /roles/{name}/migrate (all SSH/ansible-backed);
  • PLAYBOOK_EXECUTE_TIMEOUT_MS (2 min) — POST /infrastructure/execute;
  • HEALTH_PROBE_TIMEOUT_MS (3s) — the WS reconnect pre-flight, replacing a hard-coded 3000.

Two defects fixed on the way

Bearer null — 7 sites. InfrastructureWizard ×4, InfrastructureView, DeploymentWizard built Authorization: `Bearer ${sessionStorage.getItem('slm_access_token')}` unconditionally; AgentsView ×3 did the same with authStore.token. With no session the interpolation stringifies null and the literal header Bearer null went out — a malformed credential rather than an absent one, which also made the resulting 401 indistinguishable from a genuinely expired session. The client omits the header entirely, which lets handleUnauthorized tell "session rejected" (clear + redirect) apart from "never had one" (log only).

A fallback that never ran. DeploymentWizard.fetchRoles has a catch that rebuilds the role list from NODE_ROLE_METADATA "if API fails" — but the success path was if (response.ok) with no else, and fetch only rejects at the network level. So an HTTP failure fell through both branches: roles stayed empty, the wizard's role picker rendered blank, and the fallback written for exactly that case never executed. get() rejects on a non-OK, so it now does.

A destructive action that reported nothing

AlertsMonitor.clearAlerts was await fetch(url, { method: 'DELETE', headers }) with the Response never bound. A 401/403/500 was indistinguishable from success: refresh() re-rendered the same alerts and the operator, having just confirmed a "this cannot be undone" dialog, was left to infer from an unchanged list that nothing had happened. It now catches and renders the failure (one new en key, monitoring.alertsMonitor.clearFailed; no bare @ added).

A fourth base-URL spelling retired

useSlmWebSocket.ts:188 hand-built ${config.apiBaseUrl}/api/health. That is exactly getSlmApiBase(), so the two agree today — this is convergence, not a fix, but it removes the copy that would need finding again the next time the co-located prefix moves (#2829).

One claim corrected mid-PR

The first two commits asserted that authStore.getAuthHeaders() sent requests anonymous "whenever a session sat in storage". That overstates it — stores/auth.ts:66 seeds the ref from sessionStorage with a localStorage fallback, so a page that loads with a session is fine. The two bearer tests passing against pre-change sources is what exposed it. The real defect is narrower and still real: the seed happens once, at store construction, so a token reaching storage afterwards (login in another tab, refresh through a different store instance) is invisible to the ref. Commit 3 corrects every comment and reshapes both tests to construct the store before writing the token — the scenario that actually diverges.

Verification

Against a cp-swapped pristine baseline at 824f4576b with an identical failing set (zero).

before after
npm run test:unit 29 files / 260 tests, 0 failures 34 files / 284 tests, 0 failures
npm run type-check clean clean
npm run lint 0 errors / 16 warnings 0 errors / 16 warnings (same pre-existing set)

Non-vacuity. Pristine sources cp-swapped back with the 24 new tests left in place: 16 of 24 fail, each on the specific defect it names.

x sends a bearer that reached storage after the auth store was constructed
    expected undefined to be 'Bearer poll-token'
x attaches a bearer that reached storage after the auth store was constructed
    expected undefined to be 'Bearer tools-token'
x sends NO Authorization header when there is no session, not "Bearer null"   (x2)
    expected 'Bearer null' to be undefined
x gives every polled read an abort signal ...                                 (x4)
    expected undefined to be an instance of AbortSignal
x clears the session and redirects when ... unauthorised                      (x3)
    expected 'expired-token' to be null
x tells the operator when a confirmed clear was rejected
    Unable to get [data-testid="clear-alerts-error"] within: <div class="p-6">
x falls back to the built-in role metadata when the role fetch is rejected
    expected 0 to be greater than 0
x reports a failed dashboard read instead of leaving the panel silently stale
    expected 'HTTP 503: ' to contain 'dashboard down'
x retries a 5xx page load, then fails without touching the session
    expected false to be true

The 8 that pass on both sides are stated as regression guards, not defect proofs: endpoint paths, the JSON payload shape, POLL_OPTS staying single-shot, the Prometheus export still being read as text, and a declined confirmation still sending nothing.

Assertions are on what reaches the wire — the Authorization header value, the request URL, the method and parsed body, init.signal instanceof AbortSignal, and (with fake timers) that the abort fires after 31s and only once REMOTE_EXEC_TIMEOUT_MS is spent — not that a client method was called.

Deferred, and why

After this PR, zero SLM-backend raw fetch calls remain in autobot-slm-frontend/src.

Model Used

claude-opus-4-6-20260213

mrveiss added 3 commits August 1, 2026 02:16
…slmApiClient (#13140)

The 30 SLM-backend fetch sites that already resolved their base URL via
getSlmApiBase() now go through the canonical client, which owns the origin,
the bearer token, the request timeout, the 401 handler and the error shape.

Four seam behaviours were decided rather than inherited:

- Polled reads pass POLLED_READ_MAX_RETRIES. get() otherwise retries a 5xx
  three times with exponential backoff (~3s); InfrastructureWizard polls
  execution status on a 1s interval, so the default would leave several ticks
  in flight and make the reported status lag the run.
- Remote-exec endpoints (/nodes/{id}/exec, node health, service actions,
  role migrate, playbook execute) take explicit env-sourced budgets from the
  new constants/api-timeouts.ts. They had NO timeout before; handing them the
  client's 30s default would abort commands that complete fine today.
- Sites needing a structured error body (err.detail) or a non-JSON body
  (Prometheus text exposition) use rawRequest rather than the get/post
  helpers, so the rendered message and the parse are unchanged.
- useSlmWebSocket's liveness pre-flight keeps its own short budget and stays
  single-shot; it gates the reconnect backoff.

Two defects fixed on the way. Seven sites built Authorization headers
unconditionally from sessionStorage/authStore.token and so sent the literal
'Bearer null' with no session; the client omits the header instead, which
also lets its 401 handler distinguish a rejected session from an absent one.
DeploymentWizard's role fetch dropped a non-OK response and left the picker
empty, never reaching the NODE_ROLE_METADATA fallback written for that case.

AlertsMonitor's clear-all discarded its DELETE response entirely, so a
rejected destructive action reported nothing; it now surfaces the failure.
…13140)

Five suites at the seam the conversion moved, asserting what goes on the
wire rather than that a client method was called:

- usePrometheusMetrics: the stored bearer reaches a poll although the auth
  store ref was never hydrated; every polled read carries an abort signal; a
  500 stays single-shot inside the tick; the Prometheus export is still read
  as text; a 401 clears the session.
- ToolsView: the bearer reaches an SSH exec from storage alone; the endpoint,
  method and JSON payload are unchanged; the abort fires on the remote-exec
  budget and NOT at the client's 30s default; the backend's detail message is
  still what the operator is shown.
- InfrastructureView: no Authorization header at all without a session, where
  the literal 'Bearer null' used to go out; a 5xx page load retries then fails
  without touching the session.
- AlertsMonitor: a rejected clear-all now reports instead of looking like a
  success; a declined confirmation still sends nothing.
- DeploymentWizard: an HTTP failure finally reaches the NODE_ROLE_METADATA
  fallback that its catch was written for and never ran.
…rove it (#13140)

The comments and test docs claimed `authStore.getAuthHeaders()` / `authStore.token`
sent the request anonymous whenever a session sat in storage. That overstates
it: stores/auth.ts:66 seeds the ref from sessionStorage with a localStorage
fallback, so a page that LOADS with a session in place is fine.

The real defect is narrower and still real — the seed happens exactly once, at
store construction, so any token that reaches storage afterwards (a login in
another tab, a refresh performed through a different store instance) is
invisible to the ref and the request goes out with no credential. The client
re-reads storage on every request, so there is no window in which the two
disagree.

Both bearer tests originally passed against the pre-change sources, which is
what exposed the overstatement. They now construct the store BEFORE the token
is written, which is the scenario that actually diverges, and fail pre-change
with 'expected undefined to be Bearer <token>'.
@github-actions

Copy link
Copy Markdown
Contributor

✅ SSOT Configuration Compliance: Passing

🎉 No hardcoded values detected that have SSOT config equivalents!

@mrveiss
mrveiss merged commit d16ec8e into Dev_new_gui Jul 31, 2026
38 of 39 checks passed
mrveiss added a commit that referenced this pull request Sep 27, 2026
…13140) (#17616)

#13140's definition-of-done item 3 asks that each migrated module gain a test
asserting its endpoint path and payload survive. Measured against merged main,
two of the twenty-one modules in the issue's inventory had no test naming them
at all: `composables/usePerformanceMonitoring.ts` and `views/RolesView.vue`.

Both call `slmApiClient.rawRequest` rather than the client's typed `get`/`post`
helpers, and both say why in their own docstrings. That is the interesting part:
each helper preserves behaviour its callers depend on, and the obvious later
"simplification" to `get()` would destroy it silently.

  * Single-shot dispatch. `get()` retries a 5xx three times with backoff.
    `usePerformanceMonitoring.startPolling()` re-issues `fetchOverview` every
    tick, so a retried request would overlap the next one; a retried role write
    is a second attempt at a change the operator confirmed once.
  * The error text the UI shows. `usePerformanceMonitoring` surfaces
    `HTTP <n>: <raw body text>` verbatim and `RolesView` renders the backend's
    own `body.detail`; the typed helpers re-shape both into a status code.

A documented intention with no test is one refactor away from reading as an
oversight, so those are the assertions, alongside the endpoint path and the
payload. Also pinned: the bearer is read from storage per request rather than
from a ref seeded once at store construction -- the defect that sent requests
out anonymous when the token landed later -- with the localStorage fallback, the
no-session case (no header at all, never `Bearer null`), the client's timeout,
the 204 that must not read as an unparseable body, and a 500 leaving a stored
session alone.

Asserted against the stubbed global `fetch`, following
`views/InfrastructureView.test.ts` from #13170, so the endpoint is checked
through the real base-URL resolution rather than around a mocked client.

Not validated locally: `vue-tsc` in the symlinked install is not executable from
a worktree (`vue-tsc: Permission denied`), so the type-check and vitest runs
happen at the pre-push hook and in CI.

Refs #13140
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.

1 participant